Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
name: Build & test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn validate:types
# test:run builds first via its pretest hook, so outputs are never stale
- run: yarn clean && yarn test:run

native:
name: Native output compiles
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install Kotlin compiler
run: brew install kotlin
- run: yarn install --frozen-lockfile
# Runs the same suite, but with swiftc and kotlinc present the guarded
# syntax tests in test/native-compile.test.ts actually execute
- run: yarn clean && yarn test:run
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,27 @@ If you need to add tokens that are not part of the [theme specification](https:/
- first level: the `category` mentioned in the step above
- second level: the `type` mentioned in the step above
- third level: the token name, as you would use it with `$`, e.g.: `$16-9`
- fourth level: `value`, the value the token will be replaced by.
- fourth level: `$value`, the value the token will be replaced by (token sources use the [DTCG format](https://tr.designtokens.org/format/); groups may also declare a `$type`).

e.g.:
```json
{
"ratios": {
"ratio": {
"16-9": {
"value": "16/9"
"$value": "16/9"
},
"3-2": {
"value": "3/2"
"$value": "3/2"
},
"4-3": {
"value": "4/3"
"$value": "4/3"
},
"1-1": {
"value": "1/1"
"$value": "1/1"
},
"3-4": {
"value": "3/4"
"$value": "3/4"
}
}
}
Expand Down Expand Up @@ -67,3 +67,31 @@ const stitchesConfig = createStitches({

### Why/When do we need `themeMap`?
Some CSS properties are not included in the [defaultThemeMap](https://stitches.dev/docs/api#defaultthememap). If they are missing (e.g.: aspectRatio) you need to add them to our custom `themeMap` which we pass to stitches [themeMap](https://stitches.dev/docs/api#thememap) config

## Native outputs (Swift & Kotlin)

Alongside the web outputs, the build emits the tokens in native-consumable form for the iOS and Android apps:

- `lib/theme-*.swift` — a `ThemeTokens` enum of SwiftUI `Color(red:green:blue:opacity:)` and `CGFloat` constants (style-dictionary's `ios-swift/enum.swift` format)
- `lib/theme-*.kt` — the equivalent Compose `Color(0xAARRGGBB)`, `.sp`/`.dp` constants in `package uk.co.atomlearning.theme` (style-dictionary's `compose/object` format)

Values are converted at build time by style-dictionary's built-in transforms (`color/ColorSwiftUI`, `color/composeColor`, `size/swift/remToCGFloat`, `size/compose/remToSp`, `size/compose/remToDp`), driven by the `$type` declared on each token group: colours from hsl()/hex to sRGB components, `size.font`/`size.radii`/`size.space` from rem to pt (× 16). `size.leading` has no transform on purpose — the multipliers pass through unitless. Constant names come from the custom `name/native/camel` transform in `src/native.ts`: flat camelCase from the token path (`color.blue.800` → `blue800`, `size.font.sm` → `fontSm`) — **renames are breaking** for the native apps.

Deliberately excluded: `font.families.*` (web font stacks — the apps bundle their own fonts), `size.breakpoint.*` (windowed-web concern) and `effects.*` (CSS box-shadow strings don't translate to native shadow parameters).

The files ship inside the npm tarball; the native repos vendor the file for a pinned version (e.g. fetched from unpkg in their build). There is no Swift Package or Maven artifact.

## Testing

`yarn test` (watch) and `yarn test:run` (single run) both build first via a `pretest` hook, so the suite never asserts against stale `lib/` output. CI runs the same suite on every PR (`.github/workflows/ci.yml`), plus `yarn validate:types`.

The suite is output-focused — it builds the package and inspects the real artifacts in `lib/`:

- `test/theme.test.ts` — JS / CSS / `.d.ts` / media query structure and formatting
- `test/completeness.test.ts` — reconciles the token sources against every output, so a filter or naming regression that silently drops tokens fails the build
- `test/values.test.ts` — exact values for shadows, breakpoints and font stacks, and cross-output consistency (Swift colours are re-derived from the source hsl and checked against the JS theme and the Kotlin output)
- `test/native.test.ts` — Swift/Kotlin structure, conversions and per-theme filtering
- `test/native-compile.test.ts` — compiles the generated files with `swiftc` and `kotlinc`. These tests **skip when the toolchain is absent**, so a local run without Xcode or Kotlin still passes. Both CI runners have them (GitHub's Ubuntu image ships Swift and Kotlin; the macOS job additionally validates against the real Xcode toolchain), so they always execute in CI. Invoking a real compiler far exceeds vitest's default 5s timeout, hence the explicit `COMPILE_TIMEOUT`
- `test/assets.test.ts` — every `package.json` export target, `typesVersions` path and copied asset exists and is non-empty

One known failure is encoded as an expected failure (`it.fails`) in `test/completeness.test.ts`: the CSS formatters emit `--color-coolGrey-100` while the JS/`.d.ts` `properties` map declares `--color-cool-grey-100`, so `var(--color-cool-grey-100)` resolves to nothing. This predates the native outputs work; remove the `.fails` marker when the naming is reconciled.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@atom-learning/theme",
"version": "6.0.0",
"version": "6.0.1",
"description": "Design tokens and assets for Atom Learning and Quest",
"type": "module",
"main": "lib/theme-base.js",
Expand All @@ -9,7 +9,9 @@
"build": "node ./src/build.ts --path=./src/themes",
"prepublishOnly": "run-s clean build test:run",
"clean": "del ./lib",
"pretest": "run-s build",
"test": "vitest",
"pretest:run": "run-s build",
"test:run": "vitest run",
"validate:types": "tsc --noEmit"
},
Expand All @@ -20,6 +22,7 @@
"license": "ISC",
"devDependencies": {
"@types/node": "^20.0.0",
"color2k": "^2.0.4",
"del-cli": "^3.0.1",
"dree": "^5.1.5",
"npm-run-all": "^4.1.5",
Expand Down
6 changes: 6 additions & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import mediaQueriesTypes from './formatters/media-queries-types.ts'
import systemUi from './formatters/system-ui-theme.ts'
import tailwindTheme from './formatters/tailwind-theme.ts'
import allThemesCss from './formatters/all-themes-css.ts'
import { nativeName } from './native.ts'
import { setBuildConfig } from './formatters/shared.ts'
import { readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
Expand Down Expand Up @@ -65,6 +66,11 @@ const buildTheme = async (
name: 'custom/format/all-themes-css',
format: allThemesCss
})
sd.registerTransform({
name: 'name/native/camel',
type: 'name',
transform: nativeName
})

await sd.buildAllPlatforms()
}
Expand Down
7 changes: 4 additions & 3 deletions src/formatters/all-themes-css.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getBuildConfig, shouldIncludeProperty } from './shared.ts'
import { getBuildConfig, shouldIncludeProperty, tokenValue } from './shared.ts'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'

Expand All @@ -8,7 +8,8 @@ interface Property {
category: string
item: string
}
value: string | number
value?: string | number
$value?: string | number
name: string
filePath?: string
}
Expand Down Expand Up @@ -102,7 +103,7 @@ const generateThemeCSS = (
const varName = generateCustomPropertyName(property)
if (!varName) return

const value = formatValue(property.value, category, type)
const value = formatValue(tokenValue(property), category, type)
cssVars.push(` ${varName}: ${value};`)
})

Expand Down
7 changes: 4 additions & 3 deletions src/formatters/media-queries-types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { getBuildConfig, isBaseTheme } from './shared.ts'
import { getBuildConfig, isBaseTheme, tokenValue } from './shared.ts'

interface Property {
attributes: {
category: string
type: string
item: string
}
value: string | number
value?: string | number
$value?: string | number
}

interface Dictionary {
Expand All @@ -25,7 +26,7 @@ const formatter = (dictionary: Dictionary): string => {
properties.forEach((property) => {
const { category, type, item } = property.attributes
if (category === 'size' && type === 'breakpoint') {
media[item] = `(min-width: ${property.value})`
media[item] = `(min-width: ${tokenValue(property)})`
}
})

Expand Down
7 changes: 4 additions & 3 deletions src/formatters/media-queries.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { getBuildConfig, isBaseTheme } from './shared.ts'
import { getBuildConfig, isBaseTheme, tokenValue } from './shared.ts'

interface Property {
attributes: {
category: string
type: string
item: string
}
value: string | number
value?: string | number
$value?: string | number
}

interface Dictionary {
Expand All @@ -22,7 +23,7 @@ const generateMediaQueries = (
properties.forEach((property) => {
const { category, type, item } = property.attributes
if (category === 'size' && type === 'breakpoint') {
media[item] = `(min-width: ${property.value})`
media[item] = `(min-width: ${tokenValue(property)})`
}
})
return media
Expand Down
6 changes: 6 additions & 0 deletions src/formatters/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ export const shouldIncludeProperty = (property: Property, config?: BuildConfig |
export const isBaseTheme = (config?: BuildConfig | null): boolean =>
config?.includeBase === true && !config?.themePath

// Token sources use the DTCG format ($value); fall back to legacy `value`
export const tokenValue = (property: {
$value?: unknown
value?: unknown
}): string | number => (property.$value ?? property.value) as string | number

11 changes: 6 additions & 5 deletions src/formatters/system-ui-theme.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { pascalCase } from 'pascal-case'
import { getBuildConfig, shouldIncludeProperty } from './shared.ts'
import { getBuildConfig, shouldIncludeProperty, tokenValue } from './shared.ts'

interface Property {
attributes: {
Expand All @@ -8,7 +8,8 @@ interface Property {
item: string
subitem?: string
}
value: string | number
value?: string | number
$value?: string | number
path?: string[]
name: string
filePath?: string
Expand Down Expand Up @@ -98,7 +99,7 @@ export const transformPropertiesToTheme = (
if (category === 'color') {
theme.colors = {
...(theme.colors as Record<string, string>),
[prefix(type, item, subitem || '')]: String(property.value)
[prefix(type, item, subitem || '')]: String(tokenValue(property))
}
return
}
Expand All @@ -107,7 +108,7 @@ export const transformPropertiesToTheme = (

// Format font sizes, radii, and space with rem
// Ensure units are always added for numeric values
let value = property.value
let value = tokenValue(property)
if (
category === 'size' &&
(type === 'font' || type === 'radii' || type === 'space') &&
Expand Down Expand Up @@ -196,7 +197,7 @@ export const generateCustomProperties = (
// Format font sizes, radii, and space with rem
// leading (line heights) are unitless numbers
// Ensure units are always added for numeric values
let value = property.value
let value = tokenValue(property)
if (
category === 'size' &&
(type === 'font' || type === 'radii' || type === 'space') &&
Expand Down
12 changes: 9 additions & 3 deletions src/formatters/tailwind-theme.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { getBuildConfig, shouldIncludeProperty, isBaseTheme } from './shared.ts'
import {
getBuildConfig,
shouldIncludeProperty,
isBaseTheme,
tokenValue
} from './shared.ts'

interface Property {
attributes: {
type: string
category: string
item: string
}
value: string | number
value?: string | number
$value?: string | number
name: string
filePath?: string
}
Expand Down Expand Up @@ -85,7 +91,7 @@ const transformPropertiesToTheme = (
name = `shadow-${item}`
}

return `--${name}: ${formatValue(property.value, category, type)};`
return `--${name}: ${formatValue(tokenValue(property), category, type)};`
})
.filter((property): property is string => Boolean(property))
}
Expand Down
44 changes: 44 additions & 0 deletions src/native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { pascalCase } from 'pascal-case'
import { getBuildConfig, shouldIncludeProperty } from './formatters/shared.ts'

interface TransformedToken {
attributes?: {
category?: string
type?: string
item?: string
subitem?: string
}
filePath?: string
[key: string]: unknown
}

// Flat camelCase names from the token path — these become the native apps'
// API, so renames are breaking: color.blue.800 -> blue800,
// color.subject.gcse-maths -> subjectGcseMaths, size.font.sm -> fontSm.
// `base` segments collapse (color.info.base -> info, size.space.base -> space).
export const nativeName = (token: TransformedToken): string => {
const { type = '', item, subitem = '' } = token.attributes || {}
if (!item || item === 'base') return type
const sub = subitem === 'base' ? '' : subitem
return parseInt(item)
? `${type}${item}${sub}`
: `${type}${pascalCase(item)}${pascalCase(sub)}`
}

const NATIVE_SIZE_TYPES = ['font', 'leading', 'radii', 'space']

// Colours, type scale, line heights, radii and spacing only. font.families
// (web font stacks), size.breakpoint (windowed-web concern), size.size and
// effects (CSS shadow strings) deliberately don't ship to native.
const isNativeToken = (token: TransformedToken): boolean => {
const { category, type } = token.attributes || {}
if (category === 'color') return true
return category === 'size' && NATIVE_SIZE_TYPES.includes(type || '')
}

export const nativeTokenFilter = (token: TransformedToken): boolean =>
isNativeToken(token) &&
shouldIncludeProperty(
token as Parameters<typeof shouldIncludeProperty>[0],
getBuildConfig()
)
25 changes: 19 additions & 6 deletions src/properties/aliases.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
{
"color": {
"$type": "color",
"text": {
"bold": { "value": "{color.grey.1000}" },
"regular": { "value": "{color.grey.900}" },
"subtle": { "value": "{color.grey.800}" },
"minimal": { "value": "{color.grey.700}" }
"bold": {
"$value": "{color.grey.1000}"
},
"regular": {
"$value": "{color.grey.900}"
},
"subtle": {
"$value": "{color.grey.800}"
},
"minimal": {
"$value": "{color.grey.700}"
}
},
"background": {
"base": { "value": "{color.grey.100}" },
"accent": { "value": "{color.blue.100}" }
"base": {
"$value": "{color.grey.100}"
},
"accent": {
"$value": "{color.blue.100}"
}
}
}
}
Loading
Loading