diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb54fe4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index f3f85e6..3982f2e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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 @@ -20,19 +20,19 @@ If you need to add tokens that are not part of the [theme specification](https:/ "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" } } } @@ -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. diff --git a/package.json b/package.json index d6bc891..c8f8106 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" }, @@ -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", diff --git a/src/build.ts b/src/build.ts index 57faae5..43c6501 100644 --- a/src/build.ts +++ b/src/build.ts @@ -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' @@ -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() } diff --git a/src/formatters/all-themes-css.ts b/src/formatters/all-themes-css.ts index 5f1211a..5cbec78 100644 --- a/src/formatters/all-themes-css.ts +++ b/src/formatters/all-themes-css.ts @@ -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' @@ -8,7 +8,8 @@ interface Property { category: string item: string } - value: string | number + value?: string | number + $value?: string | number name: string filePath?: string } @@ -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};`) }) diff --git a/src/formatters/media-queries-types.ts b/src/formatters/media-queries-types.ts index a8e6fe8..056a71b 100644 --- a/src/formatters/media-queries-types.ts +++ b/src/formatters/media-queries-types.ts @@ -1,4 +1,4 @@ -import { getBuildConfig, isBaseTheme } from './shared.ts' +import { getBuildConfig, isBaseTheme, tokenValue } from './shared.ts' interface Property { attributes: { @@ -6,7 +6,8 @@ interface Property { type: string item: string } - value: string | number + value?: string | number + $value?: string | number } interface Dictionary { @@ -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)})` } }) diff --git a/src/formatters/media-queries.ts b/src/formatters/media-queries.ts index eb2211b..c1ec64e 100644 --- a/src/formatters/media-queries.ts +++ b/src/formatters/media-queries.ts @@ -1,4 +1,4 @@ -import { getBuildConfig, isBaseTheme } from './shared.ts' +import { getBuildConfig, isBaseTheme, tokenValue } from './shared.ts' interface Property { attributes: { @@ -6,7 +6,8 @@ interface Property { type: string item: string } - value: string | number + value?: string | number + $value?: string | number } interface Dictionary { @@ -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 diff --git a/src/formatters/shared.ts b/src/formatters/shared.ts index fe7df56..3bb7ccc 100644 --- a/src/formatters/shared.ts +++ b/src/formatters/shared.ts @@ -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 + diff --git a/src/formatters/system-ui-theme.ts b/src/formatters/system-ui-theme.ts index be3a9e0..dcba38b 100644 --- a/src/formatters/system-ui-theme.ts +++ b/src/formatters/system-ui-theme.ts @@ -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: { @@ -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 @@ -98,7 +99,7 @@ export const transformPropertiesToTheme = ( if (category === 'color') { theme.colors = { ...(theme.colors as Record), - [prefix(type, item, subitem || '')]: String(property.value) + [prefix(type, item, subitem || '')]: String(tokenValue(property)) } return } @@ -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') && @@ -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') && diff --git a/src/formatters/tailwind-theme.ts b/src/formatters/tailwind-theme.ts index bbd0b10..9d6b58f 100644 --- a/src/formatters/tailwind-theme.ts +++ b/src/formatters/tailwind-theme.ts @@ -1,4 +1,9 @@ -import { getBuildConfig, shouldIncludeProperty, isBaseTheme } from './shared.ts' +import { + getBuildConfig, + shouldIncludeProperty, + isBaseTheme, + tokenValue +} from './shared.ts' interface Property { attributes: { @@ -6,7 +11,8 @@ interface Property { category: string item: string } - value: string | number + value?: string | number + $value?: string | number name: string filePath?: string } @@ -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)) } diff --git a/src/native.ts b/src/native.ts new file mode 100644 index 0000000..ba3b3d3 --- /dev/null +++ b/src/native.ts @@ -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[0], + getBuildConfig() + ) diff --git a/src/properties/aliases.json b/src/properties/aliases.json index be69de1..c924d5e 100644 --- a/src/properties/aliases.json +++ b/src/properties/aliases.json @@ -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}" + } } } } diff --git a/src/properties/colors.json b/src/properties/colors.json index 80ced41..4be9c4b 100644 --- a/src/properties/colors.json +++ b/src/properties/colors.json @@ -1,290 +1,748 @@ { "color": { - "black": { "value": "#000" }, - "white": { "value": "#fff" }, - + "$type": "color", + "black": { + "$value": "#000" + }, + "white": { + "$value": "#fff" + }, "grey": { - "100": { "value": "hsl(0, 0%, 96%)" }, - "200": { "value": "hsl(0, 0%, 92%)" }, - "300": { "value": "hsl(0, 0%, 88%)" }, - "400": { "value": "hsl(0, 0%, 81%)" }, - "500": { "value": "hsl(0, 0%, 73%)" }, - "600": { "value": "hsl(0, 0%, 62%)" }, - "700": { "value": "hsl(0, 0%, 46%)" }, - "800": { "value": "hsl(0, 0%, 33%)" }, - "900": { "value": "hsl(0, 0%, 20%)" }, - "1000": { "value": "hsl(0, 0%, 12%)" }, - "1100": { "value": "hsl(0, 0%, 10%)" }, - "1200": { "value": "hsl(0, 0%, 6%)" } + "100": { + "$value": "hsl(0, 0%, 96%)" + }, + "200": { + "$value": "hsl(0, 0%, 92%)" + }, + "300": { + "$value": "hsl(0, 0%, 88%)" + }, + "400": { + "$value": "hsl(0, 0%, 81%)" + }, + "500": { + "$value": "hsl(0, 0%, 73%)" + }, + "600": { + "$value": "hsl(0, 0%, 62%)" + }, + "700": { + "$value": "hsl(0, 0%, 46%)" + }, + "800": { + "$value": "hsl(0, 0%, 33%)" + }, + "900": { + "$value": "hsl(0, 0%, 20%)" + }, + "1000": { + "$value": "hsl(0, 0%, 12%)" + }, + "1100": { + "$value": "hsl(0, 0%, 10%)" + }, + "1200": { + "$value": "hsl(0, 0%, 6%)" + } }, - "blue": { - "100": { "value": "hsl(215, 100%, 98%)" }, - "200": { "value": "hsl(212, 100%, 95%)" }, - "300": { "value": "hsl(211, 100%, 92%)" }, - "400": { "value": "hsl(211, 100%, 88%)" }, - "500": { "value": "hsl(212, 100%, 80%)" }, - "600": { "value": "hsl(213, 100%, 71%)" }, - "700": { "value": "hsl(214, 100%, 58%)" }, - "800": { "value": "hsl(217, 92%, 51%)" }, - "900": { "value": "hsl(223, 79%, 44%)" }, - "1000": { "value": "hsl(228, 82%, 35%)" }, - "1100": { "value": "hsl(228, 63%, 23%)" }, - "1200": { "value": "hsl(227, 57%, 11%)" } + "100": { + "$value": "hsl(215, 100%, 98%)" + }, + "200": { + "$value": "hsl(212, 100%, 95%)" + }, + "300": { + "$value": "hsl(211, 100%, 92%)" + }, + "400": { + "$value": "hsl(211, 100%, 88%)" + }, + "500": { + "$value": "hsl(212, 100%, 80%)" + }, + "600": { + "$value": "hsl(213, 100%, 71%)" + }, + "700": { + "$value": "hsl(214, 100%, 58%)" + }, + "800": { + "$value": "hsl(217, 92%, 51%)" + }, + "900": { + "$value": "hsl(223, 79%, 44%)" + }, + "1000": { + "$value": "hsl(228, 82%, 35%)" + }, + "1100": { + "$value": "hsl(228, 63%, 23%)" + }, + "1200": { + "$value": "hsl(227, 57%, 11%)" + } }, "pink": { - "100": { "value": "hsl(311, 100%, 98%)" }, - "200": { "value": "hsl(310, 100%, 95%)" }, - "300": { "value": "hsl(311, 100%, 90%)" }, - "400": { "value": "hsl(313, 100%, 80%)" }, - "500": { "value": "hsl(313, 83%, 72%)" }, - "600": { "value": "hsl(315, 82%, 66%)" }, - "700": { "value": "hsl(316, 63%, 56%)" }, - "800": { "value": "hsl(317, 63%, 44%)" }, - "900": { "value": "hsl(318, 63%, 37%)" }, - "1000": { "value": "hsl(319, 55%, 33%)" }, - "1100": { "value": "hsl(318, 98%, 16%)" }, - "1200": { "value": "hsl(318, 97%, 12%)" } + "100": { + "$value": "hsl(311, 100%, 98%)" + }, + "200": { + "$value": "hsl(310, 100%, 95%)" + }, + "300": { + "$value": "hsl(311, 100%, 90%)" + }, + "400": { + "$value": "hsl(313, 100%, 80%)" + }, + "500": { + "$value": "hsl(313, 83%, 72%)" + }, + "600": { + "$value": "hsl(315, 82%, 66%)" + }, + "700": { + "$value": "hsl(316, 63%, 56%)" + }, + "800": { + "$value": "hsl(317, 63%, 44%)" + }, + "900": { + "$value": "hsl(318, 63%, 37%)" + }, + "1000": { + "$value": "hsl(319, 55%, 33%)" + }, + "1100": { + "$value": "hsl(318, 98%, 16%)" + }, + "1200": { + "$value": "hsl(318, 97%, 12%)" + } }, "purple": { - "100": { "value": "hsl(246, 83%, 98%)" }, - "200": { "value": "hsl(244, 74%, 95%)" }, - "300": { "value": "hsl(246, 76%, 92%)" }, - "400": { "value": "hsl(246, 74%, 85%)" }, - "500": { "value": "hsl(249, 72%, 76%)" }, - "600": { "value": "hsl(252, 70%, 66%)" }, - "700": { "value": "hsl(256, 65%, 62%)" }, - "800": { "value": "hsl(252, 51%, 51%)" }, - "900": { "value": "hsl(257, 54%, 42%)" }, - "1000": { "value": "hsl(257, 53%, 35%)" }, - "1100": { "value": "hsl(255, 57%, 23%)" }, - "1200": { "value": "hsl(255, 76%, 13%)" } + "100": { + "$value": "hsl(246, 83%, 98%)" + }, + "200": { + "$value": "hsl(244, 74%, 95%)" + }, + "300": { + "$value": "hsl(246, 76%, 92%)" + }, + "400": { + "$value": "hsl(246, 74%, 85%)" + }, + "500": { + "$value": "hsl(249, 72%, 76%)" + }, + "600": { + "$value": "hsl(252, 70%, 66%)" + }, + "700": { + "$value": "hsl(256, 65%, 62%)" + }, + "800": { + "$value": "hsl(252, 51%, 51%)" + }, + "900": { + "$value": "hsl(257, 54%, 42%)" + }, + "1000": { + "$value": "hsl(257, 53%, 35%)" + }, + "1100": { + "$value": "hsl(255, 57%, 23%)" + }, + "1200": { + "$value": "hsl(255, 76%, 13%)" + } }, "cyan": { - "100": { "value": "hsl(198, 100%, 97%)" }, - "200": { "value": "hsl(199, 100%, 94%)" }, - "300": { "value": "hsl(201, 100%, 89%)" }, - "400": { "value": "hsl(200, 100%, 84%)" }, - "500": { "value": "hsl(201, 96%, 73%)" }, - "600": { "value": "hsl(202, 85%, 60%)" }, - "700": { "value": "hsl(204, 81%, 46%)" }, - "800": { "value": "hsl(205, 100%, 38%)" }, - "900": { "value": "hsl(206, 100%, 30%)" }, - "1000": { "value": "hsl(205, 100%, 21%)" }, - "1100": { "value": "hsl(206, 97%, 15%)" }, - "1200": { "value": "hsl(207, 73%, 9%)" } + "100": { + "$value": "hsl(198, 100%, 97%)" + }, + "200": { + "$value": "hsl(199, 100%, 94%)" + }, + "300": { + "$value": "hsl(201, 100%, 89%)" + }, + "400": { + "$value": "hsl(200, 100%, 84%)" + }, + "500": { + "$value": "hsl(201, 96%, 73%)" + }, + "600": { + "$value": "hsl(202, 85%, 60%)" + }, + "700": { + "$value": "hsl(204, 81%, 46%)" + }, + "800": { + "$value": "hsl(205, 100%, 38%)" + }, + "900": { + "$value": "hsl(206, 100%, 30%)" + }, + "1000": { + "$value": "hsl(205, 100%, 21%)" + }, + "1100": { + "$value": "hsl(206, 97%, 15%)" + }, + "1200": { + "$value": "hsl(207, 73%, 9%)" + } }, "green": { - "100": { "value": "hsl(148, 93%, 94%)" }, - "200": { "value": "hsl(149, 95%, 91%)" }, - "300": { "value": "hsl(147, 87%, 85%)" }, - "400": { "value": "hsl(148, 84%, 70%)" }, - "500": { "value": "hsl(148, 75%, 54%)" }, - "600": { "value": "hsl(148, 77%, 45%)" }, - "700": { "value": "hsl(148, 84%, 36%)" }, - "800": { "value": "hsl(158, 79%, 29%)" }, - "900": { "value": "hsl(166, 71%, 24%)" }, - "1000": { "value": "hsl(166, 67%, 20%)" }, - "1100": { "value": "hsl(169, 88%, 10%)" }, - "1200": { "value": "hsl(155, 93%, 5%)" } + "100": { + "$value": "hsl(148, 93%, 94%)" + }, + "200": { + "$value": "hsl(149, 95%, 91%)" + }, + "300": { + "$value": "hsl(147, 87%, 85%)" + }, + "400": { + "$value": "hsl(148, 84%, 70%)" + }, + "500": { + "$value": "hsl(148, 75%, 54%)" + }, + "600": { + "$value": "hsl(148, 77%, 45%)" + }, + "700": { + "$value": "hsl(148, 84%, 36%)" + }, + "800": { + "$value": "hsl(158, 79%, 29%)" + }, + "900": { + "$value": "hsl(166, 71%, 24%)" + }, + "1000": { + "$value": "hsl(166, 67%, 20%)" + }, + "1100": { + "$value": "hsl(169, 88%, 10%)" + }, + "1200": { + "$value": "hsl(155, 93%, 5%)" + } }, "magenta": { - "100": { "value": "hsl(330, 100%, 99%)" }, - "200": { "value": "hsl(329, 100%, 96%)" }, - "300": { "value": "hsl(332, 100%, 92%)" }, - "400": { "value": "hsl(333, 100%, 90%)" }, - "500": { "value": "hsl(333, 90%, 80%)" }, - "600": { "value": "hsl(333, 87%, 72%)" }, - "700": { "value": "hsl(333, 75%, 59%)" }, - "800": { "value": "hsl(333, 69%, 49%)" }, - "900": { "value": "hsl(333, 74%, 36%)" }, - "1000": { "value": "hsl(333, 86%, 25%)" }, - "1100": { "value": "hsl(333, 95%, 16%)" }, - "1200": { "value": "hsl(334, 62%, 10%)" } + "100": { + "$value": "hsl(330, 100%, 99%)" + }, + "200": { + "$value": "hsl(329, 100%, 96%)" + }, + "300": { + "$value": "hsl(332, 100%, 92%)" + }, + "400": { + "$value": "hsl(333, 100%, 90%)" + }, + "500": { + "$value": "hsl(333, 90%, 80%)" + }, + "600": { + "$value": "hsl(333, 87%, 72%)" + }, + "700": { + "$value": "hsl(333, 75%, 59%)" + }, + "800": { + "$value": "hsl(333, 69%, 49%)" + }, + "900": { + "$value": "hsl(333, 74%, 36%)" + }, + "1000": { + "$value": "hsl(333, 86%, 25%)" + }, + "1100": { + "$value": "hsl(333, 95%, 16%)" + }, + "1200": { + "$value": "hsl(334, 62%, 10%)" + } }, "red": { - "100": { "value": "hsl(0, 100%, 99%)" }, - "200": { "value": "hsl(0, 100%, 96%)" }, - "300": { "value": "hsl(357, 100%, 93%)" }, - "400": { "value": "hsl(356, 100%, 90%)" }, - "500": { "value": "hsl(356, 96%, 83%)" }, - "600": { "value": "hsl(357, 90%, 73%)" }, - "700": { "value": "hsl(357, 80%, 59%)" }, - "800": { "value": "hsl(357, 76%, 49%)" }, - "900": { "value": "hsl(357, 73%, 37%)" }, - "1000": { "value": "hsl(357, 79%, 26%)" }, - "1100": { "value": "hsl(357, 91%, 17%)" }, - "1200": { "value": "hsl(357, 73%, 10%)" } + "100": { + "$value": "hsl(0, 100%, 99%)" + }, + "200": { + "$value": "hsl(0, 100%, 96%)" + }, + "300": { + "$value": "hsl(357, 100%, 93%)" + }, + "400": { + "$value": "hsl(356, 100%, 90%)" + }, + "500": { + "$value": "hsl(356, 96%, 83%)" + }, + "600": { + "$value": "hsl(357, 90%, 73%)" + }, + "700": { + "$value": "hsl(357, 80%, 59%)" + }, + "800": { + "$value": "hsl(357, 76%, 49%)" + }, + "900": { + "$value": "hsl(357, 73%, 37%)" + }, + "1000": { + "$value": "hsl(357, 79%, 26%)" + }, + "1100": { + "$value": "hsl(357, 91%, 17%)" + }, + "1200": { + "$value": "hsl(357, 73%, 10%)" + } }, "teal": { - "100": { "value": "hsl(180, 83%, 95%)" }, - "200": { "value": "hsl(180, 75%, 88%)" }, - "300": { "value": "hsl(180, 71%, 78%)" }, - "400": { "value": "hsl(179, 70%, 71%)" }, - "500": { "value": "hsl(179, 65%, 52%)" }, - "600": { "value": "hsl(179, 76%, 41%)" }, - "700": { "value": "hsl(179, 91%, 31%)" }, - "800": { "value": "hsl(178, 100%, 25%)" }, - "900": { "value": "hsl(180, 100%, 18%)" }, - "1000": { "value": "hsl(183, 100%, 13%)" }, - "1100": { "value": "hsl(187, 92%, 10%)" }, - "1200": { "value": "hsl(186, 56%, 7%)" } + "100": { + "$value": "hsl(180, 83%, 95%)" + }, + "200": { + "$value": "hsl(180, 75%, 88%)" + }, + "300": { + "$value": "hsl(180, 71%, 78%)" + }, + "400": { + "$value": "hsl(179, 70%, 71%)" + }, + "500": { + "$value": "hsl(179, 65%, 52%)" + }, + "600": { + "$value": "hsl(179, 76%, 41%)" + }, + "700": { + "$value": "hsl(179, 91%, 31%)" + }, + "800": { + "$value": "hsl(178, 100%, 25%)" + }, + "900": { + "$value": "hsl(180, 100%, 18%)" + }, + "1000": { + "$value": "hsl(183, 100%, 13%)" + }, + "1100": { + "$value": "hsl(187, 92%, 10%)" + }, + "1200": { + "$value": "hsl(186, 56%, 7%)" + } }, "orange": { - "100": { "value": "hsl(45, 100%, 96%)" }, - "200": { "value": "hsl(46, 100%, 89%)" }, - "300": { "value": "hsl(46, 100%, 77%)" }, - "400": { "value": "hsl(44, 100%, 65%)" }, - "500": { "value": "hsl(41, 100%, 55%)" }, - "600": { "value": "hsl(35, 95%, 50%)" }, - "700": { "value": "hsl(29, 100%, 55%)" }, - "800": { "value": "hsl(22, 94%, 54%)" }, - "900": { "value": "hsl(22, 100%, 46%)" }, - "1000": { "value": "hsl(20, 100%, 39%)" }, - "1100": { "value": "hsl(18, 100%, 27%)" }, - "1200": { "value": "hsl(18, 100%, 21%)" } + "100": { + "$value": "hsl(45, 100%, 96%)" + }, + "200": { + "$value": "hsl(46, 100%, 89%)" + }, + "300": { + "$value": "hsl(46, 100%, 77%)" + }, + "400": { + "$value": "hsl(44, 100%, 65%)" + }, + "500": { + "$value": "hsl(41, 100%, 55%)" + }, + "600": { + "$value": "hsl(35, 95%, 50%)" + }, + "700": { + "$value": "hsl(29, 100%, 55%)" + }, + "800": { + "$value": "hsl(22, 94%, 54%)" + }, + "900": { + "$value": "hsl(22, 100%, 46%)" + }, + "1000": { + "$value": "hsl(20, 100%, 39%)" + }, + "1100": { + "$value": "hsl(18, 100%, 27%)" + }, + "1200": { + "$value": "hsl(18, 100%, 21%)" + } }, "yellow": { - "100": { "value": "hsl(53, 94%, 93%)" }, - "200": { "value": "hsl(54, 92%, 85%)" }, - "300": { "value": "hsl(54, 92%, 75%)" }, - "400": { "value": "hsl(52, 97%, 63%)" }, - "500": { "value": "hsl(51, 100%, 46%)" }, - "600": { "value": "hsl(49, 100%, 39%)" }, - "700": { "value": "hsl(48, 100%, 35%)" }, - "800": { "value": "hsl(46, 100%, 30%)" }, - "900": { "value": "hsl(44, 100%, 22%)" }, - "1000": { "value": "hsl(44, 100%, 18%)" }, - "1100": { "value": "hsl(41, 100%, 11%)" }, - "1200": { "value": "hsl(39, 100%, 8%)" } + "100": { + "$value": "hsl(53, 94%, 93%)" + }, + "200": { + "$value": "hsl(54, 92%, 85%)" + }, + "300": { + "$value": "hsl(54, 92%, 75%)" + }, + "400": { + "$value": "hsl(52, 97%, 63%)" + }, + "500": { + "$value": "hsl(51, 100%, 46%)" + }, + "600": { + "$value": "hsl(49, 100%, 39%)" + }, + "700": { + "$value": "hsl(48, 100%, 35%)" + }, + "800": { + "$value": "hsl(46, 100%, 30%)" + }, + "900": { + "$value": "hsl(44, 100%, 22%)" + }, + "1000": { + "$value": "hsl(44, 100%, 18%)" + }, + "1100": { + "$value": "hsl(41, 100%, 11%)" + }, + "1200": { + "$value": "hsl(39, 100%, 8%)" + } }, "lime": { - "100": { "value": "hsl(73, 94%, 93%)" }, - "200": { "value": "hsl(73, 94%, 87%)" }, - "300": { "value": "hsl(73, 90%, 77%)" }, - "400": { "value": "hsl(74, 82%, 69%)" }, - "500": { "value": "hsl(74, 68%, 58%)" }, - "600": { "value": "hsl(74, 77%, 41%)" }, - "700": { "value": "hsl(75, 100%, 31%)" }, - "800": { "value": "hsl(75, 100%, 27%)" }, - "900": { "value": "hsl(75, 100%, 19%)" }, - "1000": { "value": "hsl(75, 100%, 15%)" }, - "1100": { "value": "hsl(75, 100%, 9%)" }, - "1200": { "value": "hsl(74, 100%, 6%)" } + "100": { + "$value": "hsl(73, 94%, 93%)" + }, + "200": { + "$value": "hsl(73, 94%, 87%)" + }, + "300": { + "$value": "hsl(73, 90%, 77%)" + }, + "400": { + "$value": "hsl(74, 82%, 69%)" + }, + "500": { + "$value": "hsl(74, 68%, 58%)" + }, + "600": { + "$value": "hsl(74, 77%, 41%)" + }, + "700": { + "$value": "hsl(75, 100%, 31%)" + }, + "800": { + "$value": "hsl(75, 100%, 27%)" + }, + "900": { + "$value": "hsl(75, 100%, 19%)" + }, + "1000": { + "$value": "hsl(75, 100%, 15%)" + }, + "1100": { + "$value": "hsl(75, 100%, 9%)" + }, + "1200": { + "$value": "hsl(74, 100%, 6%)" + } }, "lapis": { - "100": { "value": "hsl(214, 100%, 97%)" }, - "200": { "value": "hsl(215, 100%, 95%)" }, - "300": { "value": "hsl(202, 100%, 87%)" }, - "400": { "value": "hsl(212, 100%, 83%)" }, - "500": { "value": "hsl(220, 95%, 76%)" }, - "600": { "value": "hsl(230, 84%, 70%)" }, - "700": { "value": "hsl(240, 79%, 66%)" }, - "800": { "value": "hsl(240, 59%, 52%)" }, - "900": { "value": "hsl(240, 58%, 38%)" }, - "1000": { "value": "hsl(240, 63%, 29%)" }, - "1100": { "value": "hsl(240, 87%, 18%)" }, - "1200": { "value": "hsl(240, 97%, 12%)" } + "100": { + "$value": "hsl(214, 100%, 97%)" + }, + "200": { + "$value": "hsl(215, 100%, 95%)" + }, + "300": { + "$value": "hsl(202, 100%, 87%)" + }, + "400": { + "$value": "hsl(212, 100%, 83%)" + }, + "500": { + "$value": "hsl(220, 95%, 76%)" + }, + "600": { + "$value": "hsl(230, 84%, 70%)" + }, + "700": { + "$value": "hsl(240, 79%, 66%)" + }, + "800": { + "$value": "hsl(240, 59%, 52%)" + }, + "900": { + "$value": "hsl(240, 58%, 38%)" + }, + "1000": { + "$value": "hsl(240, 63%, 29%)" + }, + "1100": { + "$value": "hsl(240, 87%, 18%)" + }, + "1200": { + "$value": "hsl(240, 97%, 12%)" + } }, "maroon": { - "100": { "value": "hsl(15, 100%, 98%)" }, - "200": { "value": "hsl(16, 100%, 93%)" }, - "300": { "value": "hsl(16, 100%, 87%)" }, - "400": { "value": "hsl(16, 100%, 80%)" }, - "500": { "value": "hsl(7, 89%, 70%)" }, - "600": { "value": "hsl(7, 78%, 60%)" }, - "700": { "value": "hsl(7, 67%, 44%)" }, - "800": { "value": "hsl(7, 95%, 32%)" }, - "900": { "value": "hsl(349, 89%, 28%)" }, - "1000": { "value": "hsl(346, 77%, 26%)" }, - "1100": { "value": "hsl(335, 73%, 20%)" }, - "1200": { "value": "hsl(335, 81%, 12%)" } + "100": { + "$value": "hsl(15, 100%, 98%)" + }, + "200": { + "$value": "hsl(16, 100%, 93%)" + }, + "300": { + "$value": "hsl(16, 100%, 87%)" + }, + "400": { + "$value": "hsl(16, 100%, 80%)" + }, + "500": { + "$value": "hsl(7, 89%, 70%)" + }, + "600": { + "$value": "hsl(7, 78%, 60%)" + }, + "700": { + "$value": "hsl(7, 67%, 44%)" + }, + "800": { + "$value": "hsl(7, 95%, 32%)" + }, + "900": { + "$value": "hsl(349, 89%, 28%)" + }, + "1000": { + "$value": "hsl(346, 77%, 26%)" + }, + "1100": { + "$value": "hsl(335, 73%, 20%)" + }, + "1200": { + "$value": "hsl(335, 81%, 12%)" + } }, "marsh": { - "100": { "value": "hsl(147, 50%, 96%)" }, - "200": { "value": "hsl(147, 27%, 88%)" }, - "300": { "value": "hsl(147, 26%, 82%)" }, - "400": { "value": "hsl(147, 25%, 73%)" }, - "500": { "value": "hsl(147, 22%, 60%)" }, - "600": { "value": "hsl(147, 15%, 48%)" }, - "700": { "value": "hsl(147, 15%, 37%)" }, - "800": { "value": "hsl(147, 23%, 29%)" }, - "900": { "value": "hsl(147, 25%, 21%)" }, - "1000": { "value": "hsl(147, 17%, 18%)" }, - "1100": { "value": "hsl(147, 24%, 13%)" }, - "1200": { "value": "hsl(147, 14%, 7%)" } + "100": { + "$value": "hsl(147, 50%, 96%)" + }, + "200": { + "$value": "hsl(147, 27%, 88%)" + }, + "300": { + "$value": "hsl(147, 26%, 82%)" + }, + "400": { + "$value": "hsl(147, 25%, 73%)" + }, + "500": { + "$value": "hsl(147, 22%, 60%)" + }, + "600": { + "$value": "hsl(147, 15%, 48%)" + }, + "700": { + "$value": "hsl(147, 15%, 37%)" + }, + "800": { + "$value": "hsl(147, 23%, 29%)" + }, + "900": { + "$value": "hsl(147, 25%, 21%)" + }, + "1000": { + "$value": "hsl(147, 17%, 18%)" + }, + "1100": { + "$value": "hsl(147, 24%, 13%)" + }, + "1200": { + "$value": "hsl(147, 14%, 7%)" + } }, - "coolGrey": { - "100": { "value": "hsl(206, 47%, 97%)" }, - "200": { "value": "hsl(205, 35%, 93%)" }, - "300": { "value": "hsl(206, 27%, 90%)" }, - "400": { "value": "hsl(205, 20%, 83%)" }, - "500": { "value": "hsl(207, 16%, 76%)" }, - "600": { "value": "hsl(207, 14%, 65%)" }, - "700": { "value": "hsl(206, 9%, 49%)" }, - "800": { "value": "hsl(207, 11%, 35%)" }, - "900": { "value": "hsl(208, 15%, 22%)" }, - "1000": { "value": "hsl(208, 19%, 14%)" }, - "1100": { "value": "hsl(207, 20%, 11%)" }, - "1200": { "value": "hsl(210, 18%, 7%)" } + "100": { + "$value": "hsl(206, 47%, 97%)" + }, + "200": { + "$value": "hsl(205, 35%, 93%)" + }, + "300": { + "$value": "hsl(206, 27%, 90%)" + }, + "400": { + "$value": "hsl(205, 20%, 83%)" + }, + "500": { + "$value": "hsl(207, 16%, 76%)" + }, + "600": { + "$value": "hsl(207, 14%, 65%)" + }, + "700": { + "$value": "hsl(206, 9%, 49%)" + }, + "800": { + "$value": "hsl(207, 11%, 35%)" + }, + "900": { + "$value": "hsl(208, 15%, 22%)" + }, + "1000": { + "$value": "hsl(208, 19%, 14%)" + }, + "1100": { + "$value": "hsl(207, 20%, 11%)" + }, + "1200": { + "$value": "hsl(210, 18%, 7%)" + } }, - "alpha": { - "100": { "value": "hsla(0, 0%, 20%, 0.1)" }, - "150": { "value": "hsla(0, 0%, 20%, 0.15)" }, - "200": { "value": "hsla(0, 0%, 20%, 0.2)" }, - "250": { "value": "hsla(0, 0%, 20%, 0.25)" }, - "600": { "value": "hsla(0, 0%, 20%, 0.6)" } + "100": { + "$value": "hsla(0, 0%, 20%, 0.1)" + }, + "150": { + "$value": "hsla(0, 0%, 20%, 0.15)" + }, + "200": { + "$value": "hsla(0, 0%, 20%, 0.2)" + }, + "250": { + "$value": "hsla(0, 0%, 20%, 0.25)" + }, + "600": { + "$value": "hsla(0, 0%, 20%, 0.6)" + } }, - "info": { - "light": { "value": "{color.blue.100}" }, - "base": { "value": "{color.blue.800}" }, - "mid": { "value": "{color.blue.900}" }, - "dark": { "value": "{color.blue.1000}" } + "light": { + "$value": "{color.blue.100}" + }, + "base": { + "$value": "{color.blue.800}" + }, + "mid": { + "$value": "{color.blue.900}" + }, + "dark": { + "$value": "{color.blue.1000}" + } }, "success": { - "light": { "value": "hsl(119, 44%, 94%)" }, - "base": { "value": "hsl(119, 100%, 27%)" }, - "mid": { "value": "hsl(124, 100%, 22%)" }, - "dark": { "value": "hsl(126, 100%, 17%)" } + "light": { + "$value": "hsl(119, 44%, 94%)" + }, + "base": { + "$value": "hsl(119, 100%, 27%)" + }, + "mid": { + "$value": "hsl(124, 100%, 22%)" + }, + "dark": { + "$value": "hsl(126, 100%, 17%)" + } }, "danger": { - "light": { "value": "hsl(0, 77%, 95%)" }, - "base": { "value": "hsl(0, 96%, 48%)" }, - "mid": { "value": "hsl(0, 96%, 41%)" }, - "dark": { "value": "hsl(0, 97%, 34%)" } + "light": { + "$value": "hsl(0, 77%, 95%)" + }, + "base": { + "$value": "hsl(0, 96%, 48%)" + }, + "mid": { + "$value": "hsl(0, 96%, 41%)" + }, + "dark": { + "$value": "hsl(0, 97%, 34%)" + } }, "warning": { - "light": { "value": "hsl(39, 100%, 94%)" }, - "base": { "value": "hsl(41, 100%, 55%)" }, - "mid": { "value": "hsl(41, 89%, 48%)" }, - "dark": { "value": "hsl(41, 100%, 41%)" }, - "text": { "value": "hsl(24, 100%, 37%)" } + "light": { + "$value": "hsl(39, 100%, 94%)" + }, + "base": { + "$value": "hsl(41, 100%, 55%)" + }, + "mid": { + "$value": "hsl(41, 89%, 48%)" + }, + "dark": { + "$value": "hsl(41, 100%, 41%)" + }, + "text": { + "$value": "hsl(24, 100%, 37%)" + } }, - "subject": { - "english": { "value": "{color.magenta.700}" }, - "maths": { "value": "{color.blue.700}" }, - "science": { "value": "{color.purple.700}" }, - "verbal-reasoning": { "value": "{color.green.700}" }, - "non-verbal-reasoning": { "value": "{color.orange.500}" }, - "creative-writing": { "value": "{color.orange.700}" }, - "exam-skills": { "value": "{color.purple.1000}" }, - "gcse-english-literature": { "value": "{color.magenta.700}" }, - "gcse-english-language": { "value": "{color.purple.700}" }, - "gcse-maths": { "value": "{color.blue.700}" }, - "gcse-chemistry": { "value": "{color.orange.700}" }, - "gcse-physics": { "value": "{color.coolGrey.700}" }, - "gcse-biology": { "value": "{color.green.700}" } + "english": { + "$value": "{color.magenta.700}" + }, + "maths": { + "$value": "{color.blue.700}" + }, + "science": { + "$value": "{color.purple.700}" + }, + "verbal-reasoning": { + "$value": "{color.green.700}" + }, + "non-verbal-reasoning": { + "$value": "{color.orange.500}" + }, + "creative-writing": { + "$value": "{color.orange.700}" + }, + "exam-skills": { + "$value": "{color.purple.1000}" + }, + "gcse-english-literature": { + "$value": "{color.magenta.700}" + }, + "gcse-english-language": { + "$value": "{color.purple.700}" + }, + "gcse-maths": { + "$value": "{color.blue.700}" + }, + "gcse-chemistry": { + "$value": "{color.orange.700}" + }, + "gcse-physics": { + "$value": "{color.coolGrey.700}" + }, + "gcse-biology": { + "$value": "{color.green.700}" + } }, - "gl": { "blue": { - "light": { "value": "hsl(222, 68%, 78%)" }, - "primary": { "value": "hsl(222, 56%, 55%)" }, - "dark": { "value": "hsl(222, 35%, 43%)" } + "light": { + "$value": "hsl(222, 68%, 78%)" + }, + "primary": { + "$value": "hsl(222, 56%, 55%)" + }, + "dark": { + "$value": "hsl(222, 35%, 43%)" + } } } } diff --git a/src/properties/containers.json b/src/properties/containers.json index c40a868..b9b2a19 100644 --- a/src/properties/containers.json +++ b/src/properties/containers.json @@ -1,10 +1,19 @@ { "size": { "breakpoint": { - "sm": { "value": "34.375rem" }, - "md": { "value": "50rem" }, - "lg": { "value": "68.75rem" }, - "xl": { "value": "84.375rem" } + "$type": "dimension", + "sm": { + "$value": "34.375rem" + }, + "md": { + "$value": "50rem" + }, + "lg": { + "$value": "68.75rem" + }, + "xl": { + "$value": "84.375rem" + } } } } diff --git a/src/properties/effects.json b/src/properties/effects.json index 814eb94..d446be1 100644 --- a/src/properties/effects.json +++ b/src/properties/effects.json @@ -1,17 +1,18 @@ { "effects": { "shadows": { + "$type": "shadow", "sm": { - "value": "0 1px 3px {color.alpha.100}, 0 1px 2px {color.alpha.150}" + "$value": "0 1px 3px {color.alpha.100}, 0 1px 2px {color.alpha.150}" }, "md": { - "value": "0 3px 6px {color.alpha.100}, 0 3px 6px {color.alpha.100}" + "$value": "0 3px 6px {color.alpha.100}, 0 3px 6px {color.alpha.100}" }, "lg": { - "value": "0 10px 20px {color.alpha.100}, 0 6px 6px {color.alpha.100}" + "$value": "0 10px 20px {color.alpha.100}, 0 6px 6px {color.alpha.100}" }, "xl": { - "value": "0 14px 28px {color.alpha.150}, 0 10px 10px {color.alpha.100}" + "$value": "0 14px 28px {color.alpha.150}, 0 10px 10px {color.alpha.100}" } } } diff --git a/src/properties/fonts.json b/src/properties/fonts.json index 72e9d5c..d8a27f8 100644 --- a/src/properties/fonts.json +++ b/src/properties/fonts.json @@ -1,17 +1,18 @@ { "font": { "families": { + "$type": "fontFamily", "sans": { - "value": "system-ui, -apple-system, 'Helvetica Neue', sans-serif" + "$value": "system-ui, -apple-system, 'Helvetica Neue', sans-serif" }, "mono": { - "value": "'SFMono-Regular', Consolas, Menlo, monospace" + "$value": "'SFMono-Regular', Consolas, Menlo, monospace" }, "display": { - "value": "{font.families.sans}" + "$value": "{font.families.sans}" }, "body": { - "value": "{font.families.sans}" + "$value": "{font.families.sans}" } } } diff --git a/src/properties/sizes.json b/src/properties/sizes.json index d19c9e6..44baf61 100644 --- a/src/properties/sizes.json +++ b/src/properties/sizes.json @@ -1,33 +1,79 @@ { "size": { "font": { - "xs": { "value": 0.75 }, - "sm": { "value": 0.875 }, - "md": { "value": 1 }, - "lg": { "value": 1.3125 }, - "xl": { "value": 1.75 }, - "2xl": { "value": 2.3125 }, - "3xl": { "value": 3.125 }, - "4xl": { "value": 5.625 } + "$type": "fontSize", + "xs": { + "$value": 0.75 + }, + "sm": { + "$value": 0.875 + }, + "md": { + "$value": 1 + }, + "lg": { + "$value": 1.3125 + }, + "xl": { + "$value": 1.75 + }, + "2xl": { + "$value": 2.3125 + }, + "3xl": { + "$value": 3.125 + }, + "4xl": { + "$value": 5.625 + } }, "leading": { - "xs": { "value": 1.6 }, - "sm": { "value": 1.53 }, - "md": { "value": 1.5 }, - "lg": { "value": 1.52 }, - "xl": { "value": 1.42 }, - "2xl": { "value": 1.08 }, - "3xl": { "value": 1.12 }, - "4xl": { "value": 1 } + "$type": "number", + "xs": { + "$value": 1.6 + }, + "sm": { + "$value": 1.53 + }, + "md": { + "$value": 1.5 + }, + "lg": { + "$value": 1.52 + }, + "xl": { + "$value": 1.42 + }, + "2xl": { + "$value": 1.08 + }, + "3xl": { + "$value": 1.12 + }, + "4xl": { + "$value": 1 + } }, "space": { - "base": { "value": 0.25 } + "$type": "dimension", + "base": { + "$value": 0.25 + } }, "radii": { - "sm": { "value": 0.25 }, - "md": { "value": 0.5 }, - "lg": { "value": 0.75 }, - "xl": { "value": 1 } + "$type": "dimension", + "sm": { + "$value": 0.25 + }, + "md": { + "$value": 0.5 + }, + "lg": { + "$value": 0.75 + }, + "xl": { + "$value": 1 + } } } } diff --git a/src/style.config.ts b/src/style.config.ts index 2ee6ec1..782262c 100644 --- a/src/style.config.ts +++ b/src/style.config.ts @@ -1,7 +1,16 @@ +import { nativeTokenFilter } from './native.ts' + +interface File { + destination: string + format: string + filter?: (token: Record) => boolean + options?: Record +} + interface Platform { transforms: string[] buildPath: string - files: Array<{ destination: string; format: string }> + files: File[] } interface Config { @@ -15,13 +24,25 @@ interface Config { > } -const COMMON_TRANSFORMS = [ +const COMMON_TRANSFORMS = ['attribute/cti', 'name/pascal'] +const CSS_TRANSFORMS = ['attribute/cti'] + +// Built-in style-dictionary transforms convert values for native platforms: +// colours to sRGB Color initialisers, rem sizes to pt (× 16). size.leading +// has no transform on purpose — the multipliers pass through unitless. +const SWIFT_TRANSFORMS = [ 'attribute/cti', - 'name/pascal', - 'size/rem', - 'color/hsl' + 'name/native/camel', + 'color/ColorSwiftUI', + 'size/swift/remToCGFloat' +] +const COMPOSE_TRANSFORMS = [ + 'attribute/cti', + 'name/native/camel', + 'color/composeColor', + 'size/compose/remToSp', + 'size/compose/remToDp' ] -const CSS_TRANSFORMS = ['attribute/cti', 'color/hsl'] const createPlatform = ( transforms: string[], @@ -64,6 +85,33 @@ export default (themes: string[], includeBase = true): Config => { name ? `theme-${name}.d.ts` : 'theme-base.d.ts', 'custom/format/system-ui-theme-types' ), + swift: { + transforms: SWIFT_TRANSFORMS, + buildPath: 'lib/', + files: [ + { + destination: name ? `theme-${name}.swift` : 'theme-base.swift', + format: 'ios-swift/enum.swift', + filter: nativeTokenFilter, + options: { className: 'ThemeTokens', import: ['SwiftUI'] } + } + ] + }, + compose: { + transforms: COMPOSE_TRANSFORMS, + buildPath: 'lib/', + files: [ + { + destination: name ? `theme-${name}.kt` : 'theme-base.kt', + format: 'compose/object', + filter: nativeTokenFilter, + options: { + className: 'ThemeTokens', + packageName: 'uk.co.atomlearning.theme' + } + } + ] + }, 'assets/copy': { actions: ['copy_assets'], buildPath: 'lib/', diff --git a/src/themes/atom/color.json b/src/themes/atom/color.json index 0c9d823..3c762dd 100644 --- a/src/themes/atom/color.json +++ b/src/themes/atom/color.json @@ -1,18 +1,43 @@ { "color": { + "$type": "color", "primary": { - "100": { "value": "{color.blue.100}" }, - "200": { "value": "{color.blue.200}" }, - "300": { "value": "{color.blue.300}" }, - "400": { "value": "{color.blue.400}" }, - "500": { "value": "{color.blue.500}" }, - "600": { "value": "{color.blue.600}" }, - "700": { "value": "{color.blue.700}" }, - "800": { "value": "{color.blue.800}" }, - "900": { "value": "{color.blue.900}" }, - "1000": { "value": "{color.blue.1000}" }, - "1100": { "value": "{color.blue.1100}" }, - "1200": { "value": "{color.blue.1200}" } + "100": { + "$value": "{color.blue.100}" + }, + "200": { + "$value": "{color.blue.200}" + }, + "300": { + "$value": "{color.blue.300}" + }, + "400": { + "$value": "{color.blue.400}" + }, + "500": { + "$value": "{color.blue.500}" + }, + "600": { + "$value": "{color.blue.600}" + }, + "700": { + "$value": "{color.blue.700}" + }, + "800": { + "$value": "{color.blue.800}" + }, + "900": { + "$value": "{color.blue.900}" + }, + "1000": { + "$value": "{color.blue.1000}" + }, + "1100": { + "$value": "{color.blue.1100}" + }, + "1200": { + "$value": "{color.blue.1200}" + } } } } diff --git a/src/themes/atom/fonts.json b/src/themes/atom/fonts.json index 6446789..9a8a6f2 100644 --- a/src/themes/atom/fonts.json +++ b/src/themes/atom/fonts.json @@ -1,11 +1,12 @@ { "font": { "families": { + "$type": "fontFamily", "display": { - "value": "'National 2 Condensed', {font.families.sans}" + "$value": "'National 2 Condensed', {font.families.sans}" }, "body": { - "value": "'Inter', {font.families.sans}" + "$value": "'Inter', {font.families.sans}" } } } diff --git a/src/themes/quest/color.json b/src/themes/quest/color.json index 783f517..698c49e 100644 --- a/src/themes/quest/color.json +++ b/src/themes/quest/color.json @@ -1,18 +1,43 @@ { "color": { + "$type": "color", "primary": { - "100": { "value": "hsl(151, 70%, 96%)" }, - "200": { "value": "hsl(151, 62%, 92%)" }, - "300": { "value": "hsl(151, 53%, 83%)" }, - "400": { "value": "hsl(151, 50%, 75%)" }, - "500": { "value": "hsl(151, 46%, 64%)" }, - "600": { "value": "hsl(158, 42%, 49%)" }, - "700": { "value": "hsl(162, 51%, 35%)" }, - "800": { "value": "hsl(162, 69%, 28%)" }, - "900": { "value": "hsl(164, 100%, 15%)" }, - "1000": { "value": "hsl(164, 100%, 13%)" }, - "1100": { "value": "hsl(150, 34%, 15%)" }, - "1200": { "value": "hsl(150, 30%, 9%)" } + "100": { + "$value": "hsl(151, 70%, 96%)" + }, + "200": { + "$value": "hsl(151, 62%, 92%)" + }, + "300": { + "$value": "hsl(151, 53%, 83%)" + }, + "400": { + "$value": "hsl(151, 50%, 75%)" + }, + "500": { + "$value": "hsl(151, 46%, 64%)" + }, + "600": { + "$value": "hsl(158, 42%, 49%)" + }, + "700": { + "$value": "hsl(162, 51%, 35%)" + }, + "800": { + "$value": "hsl(162, 69%, 28%)" + }, + "900": { + "$value": "hsl(164, 100%, 15%)" + }, + "1000": { + "$value": "hsl(164, 100%, 13%)" + }, + "1100": { + "$value": "hsl(150, 34%, 15%)" + }, + "1200": { + "$value": "hsl(150, 30%, 9%)" + } } } } diff --git a/src/themes/quest/fonts.json b/src/themes/quest/fonts.json index e74519f..a77afee 100644 --- a/src/themes/quest/fonts.json +++ b/src/themes/quest/fonts.json @@ -1,11 +1,12 @@ { "font": { "families": { + "$type": "fontFamily", "display": { - "value": "'DM Sans', {font.families.sans}" + "$value": "'DM Sans', {font.families.sans}" }, "body": { - "value": "'Inter', {font.families.sans}" + "$value": "'Inter', {font.families.sans}" } } } diff --git a/src/themes/quest/reports/sizes.json b/src/themes/quest/reports/sizes.json index 1095ee8..6f5ce5f 100644 --- a/src/themes/quest/reports/sizes.json +++ b/src/themes/quest/reports/sizes.json @@ -1,14 +1,31 @@ { "size": { "font": { - "xs": { "value": 0.625 }, - "sm": { "value": 0.75 }, - "md": { "value": 0.875 }, - "lg": { "value": 1 }, - "xl": { "value": 1.3125 }, - "2xl": { "value": 1.75 }, - "3xl": { "value": 2.3125 }, - "4xl": { "value": 3.125 } + "$type": "fontSize", + "xs": { + "$value": 0.625 + }, + "sm": { + "$value": 0.75 + }, + "md": { + "$value": 0.875 + }, + "lg": { + "$value": 1 + }, + "xl": { + "$value": 1.3125 + }, + "2xl": { + "$value": 1.75 + }, + "3xl": { + "$value": 2.3125 + }, + "4xl": { + "$value": 3.125 + } } } } diff --git a/test/assets.test.ts b/test/assets.test.ts new file mode 100644 index 0000000..5399e9f --- /dev/null +++ b/test/assets.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +interface PackageJson { + files: string[] + exports: Record> +} + +const pkg = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8') +) as PackageJson + +const resolve = (relative: string): string => + path.join(process.cwd(), relative.replace(/^\.\//, '')) + +const assetExports = Object.entries(pkg.exports).filter(([key]) => + key.startsWith('./assets/') +) + +const stringExports = Object.entries(pkg.exports).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value] as const] : [] +) + +const conditionExports = Object.entries(pkg.exports).flatMap(([key, value]) => + typeof value === 'string' + ? [] + : Object.entries(value).map(([condition, target]) => [key, condition, target] as const) +) + +describe('Package Outputs', () => { + describe('Assets', () => { + it('should declare asset export paths', () => { + expect(assetExports.length).toBeGreaterThan(0) + }) + + assetExports.forEach(([key, target]) => { + it(`${key} should be copied into lib`, () => { + const file = resolve(target as string) + + expect(fs.existsSync(file), `${target} was not built`).toBe(true) + expect(fs.statSync(file).size, `${target} is empty`).toBeGreaterThan(0) + }) + }) + + it('should copy the source asset tree without dropping files', () => { + const collect = (dir: string): string[] => + fs.existsSync(dir) + ? fs + .readdirSync(dir, { recursive: true, encoding: 'utf-8' }) + .filter((entry) => fs.statSync(path.join(dir, entry)).isFile()) + .sort() + : [] + + const source = collect(path.join(process.cwd(), 'assets')) + const built = collect(path.join(process.cwd(), 'lib', 'assets')) + + expect(source.length).toBeGreaterThan(0) + expect(built).toEqual(source) + }) + + it('svg logos should be valid svg and pngs should have a png signature', () => { + assetExports.forEach(([, target]) => { + const file = resolve(target as string) + if (file.endsWith('.svg')) { + expect(fs.readFileSync(file, 'utf-8')).toContain(' { + it('every export target should exist in lib', () => { + const targets = [ + ...stringExports.map(([, target]) => target), + ...conditionExports.map(([, , target]) => target) + ] + + expect(targets.length).toBeGreaterThan(0) + targets.forEach((target) => { + expect(fs.existsSync(resolve(target)), `${target} missing from lib`).toBe(true) + }) + }) + + it('main and types entry points should exist', () => { + const { main, types } = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8') + ) as { main: string; types: string } + + expect(fs.existsSync(resolve(main))).toBe(true) + expect(fs.existsSync(resolve(types))).toBe(true) + }) + + it('typesVersions targets should exist', () => { + const { typesVersions } = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8') + ) as { typesVersions: Record> } + + Object.values(typesVersions['*']).forEach((paths) => { + paths.forEach((target) => { + expect(fs.existsSync(resolve(target)), `${target} missing`).toBe(true) + }) + }) + }) + + it('native outputs should ship inside the published files list', () => { + // no explicit exports entry needed — the native repos vendor these + // straight out of the tarball (e.g. via unpkg) + expect(pkg.files).toContain('lib') + ;['base', 'atom', 'quest', 'quest-reports'].forEach((themeName) => { + expect(fs.existsSync(resolve(`./lib/theme-${themeName}.swift`))).toBe(true) + expect(fs.existsSync(resolve(`./lib/theme-${themeName}.kt`))).toBe(true) + }) + }) + }) +}) diff --git a/test/completeness.test.ts b/test/completeness.test.ts new file mode 100644 index 0000000..7793da0 --- /dev/null +++ b/test/completeness.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { pascalCase } from 'pascal-case' + +interface TokenGroup { + [key: string]: TokenGroup | string | number | undefined + $value?: string | number + $type?: string +} + +interface SourceToken { + path: string[] + value: string | number +} + +const readJson = (file: string): TokenGroup => + JSON.parse(fs.readFileSync(path.join(process.cwd(), file), 'utf-8')) + +const readOutput = (file: string): string => + fs.readFileSync(path.join(process.cwd(), 'lib', file), 'utf-8') + +// Walk a DTCG token file into a flat list of {path, value} +const flatten = (group: TokenGroup, trail: string[] = []): SourceToken[] => { + if (group.$value !== undefined) return [{ path: trail, value: group.$value }] + return Object.entries(group) + .filter(([key]) => !key.startsWith('$')) + .flatMap(([key, child]) => + child && typeof child === 'object' + ? flatten(child as TokenGroup, [...trail, key]) + : [] + ) +} + +const BASE_FILES = [ + 'src/properties/colors.json', + 'src/properties/aliases.json', + 'src/properties/sizes.json', + 'src/properties/containers.json', + 'src/properties/fonts.json', + 'src/properties/effects.json' +] + +const baseTokens = BASE_FILES.flatMap((file) => flatten(readJson(file))) + +// Mirrors the naming contract in src/native.ts +const nativeName = ([, type, item, subitem]: string[]): string => { + 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'] + +const isNativeToken = ({ path: tokenPath }: SourceToken): boolean => { + const [category, type] = tokenPath + if (category === 'color') return true + return category === 'size' && NATIVE_SIZE_TYPES.includes(type) +} + +const swiftConstantNames = (source: string): string[] => + [...source.matchAll(/public static let (\w+)/g)].map((match) => match[1]) + +const kotlinConstantNames = (source: string): string[] => + [...source.matchAll(/\bval (\w+)/g)].map((match) => match[1]) + +describe('Output Completeness', () => { + it('source token set is non-trivial (guards the reconciliation below)', () => { + expect(baseTokens.length).toBeGreaterThan(200) + }) + + describe('Native outputs', () => { + const expectedNames = baseTokens.filter(isNativeToken).map((token) => nativeName(token.path)) + + it('swift should contain exactly the native-eligible base tokens', () => { + const actual = swiftConstantNames(readOutput('theme-base.swift')) + + expect([...actual].sort()).toEqual([...expectedNames].sort()) + }) + + it('kotlin should contain exactly the native-eligible base tokens', () => { + const actual = kotlinConstantNames(readOutput('theme-base.kt')) + + expect([...actual].sort()).toEqual([...expectedNames].sort()) + }) + + it('swift and kotlin should expose an identical constant surface', () => { + const themes = ['base', 'atom', 'quest', 'quest-reports'] + + themes.forEach((themeName) => { + const swift = swiftConstantNames(readOutput(`theme-${themeName}.swift`)).sort() + const kotlin = kotlinConstantNames(readOutput(`theme-${themeName}.kt`)).sort() + + expect(kotlin, `${themeName} native outputs should agree`).toEqual(swift) + }) + }) + + it('every excluded group should be absent from native output', () => { + const swift = readOutput('theme-base.swift') + const kotlin = readOutput('theme-base.kt') + const excluded = baseTokens.filter((token) => !isNativeToken(token)) + + // font.families, size.breakpoint, size.size and effects.shadows + expect(excluded.length).toBeGreaterThan(0) + excluded.forEach((token) => { + const name = nativeName(token.path) + expect(swiftConstantNames(swift)).not.toContain(name) + expect(kotlinConstantNames(kotlin)).not.toContain(name) + }) + }) + }) + + describe('Web outputs', () => { + it('js properties should cover every base token except size.size and ratios', async () => { + const { properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { properties: Record } + + const expected = baseTokens.filter( + ({ path: p }) => + !(p[0] === 'size' && p[1] === 'size') && p[0] !== 'ratios' + ) + + expect(Object.keys(properties)).toHaveLength(expected.length) + }) + + it('css should declare a custom property for every js property', async () => { + const { properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { properties: Record } + const css = readOutput('theme-base.css') + + Object.keys(properties) + // coolGrey is excluded here only because of a known naming mismatch, + // covered by the dedicated test below + .filter((name) => !name.includes('cool-grey')) + .forEach((name) => { + expect(css, `${name} missing from theme-base.css`).toContain(`${name}:`) + }) + }) + + // Pre-existing bug (predates the native outputs work): the CSS formatters + // build names from `property.name`, which keeps camelCase for the only + // camelCase scale, emitting `--color-coolGrey-100`, while the JS/d.ts + // `properties` map kebab-cases it to `--color-cool-grey-100`. So + // `var(--color-cool-grey-100)` resolves to nothing in CSS. + // Delete the `.fails` once the naming is reconciled. + it.fails('css and js should agree on coolGrey custom property names', async () => { + const { properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { properties: Record } + const css = readOutput('theme-base.css') + + Object.keys(properties) + .filter((name) => name.includes('cool-grey')) + .forEach((name) => { + expect(css, `${name} missing from theme-base.css`).toContain(`${name}:`) + }) + }) + + it('d.ts should declare every js theme key and property', async () => { + const { theme, properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { + theme: Record> + properties: Record + } + const dts = readOutput('theme-base.d.ts') + + Object.keys(theme).forEach((group) => { + expect(dts, `theme.${group} missing from types`).toContain(`${group}: {`) + }) + Object.keys(properties).forEach((name) => { + expect(dts, `${name} missing from types`).toContain(`'${name}'`) + }) + }) + + it('every colour token should reach both the js theme and native output', () => { + const colourTokens = baseTokens.filter(({ path: p }) => p[0] === 'color') + const swiftNames = swiftConstantNames(readOutput('theme-base.swift')) + + expect(colourTokens.length).toBeGreaterThan(150) + colourTokens.forEach((token) => { + expect(swiftNames, `${token.path.join('.')} missing from Swift`).toContain( + nativeName(token.path) + ) + }) + }) + }) + + describe('Per-theme outputs', () => { + const themeSources: Record = { + atom: ['src/themes/atom/color.json', 'src/themes/atom/fonts.json'], + quest: ['src/themes/quest/color.json', 'src/themes/quest/fonts.json'] + } + + Object.entries(themeSources).forEach(([themeName, files]) => { + it(`${themeName} native output should contain exactly its colour overrides`, () => { + const tokens = files.flatMap((file) => flatten(readJson(file))) + const expected = tokens.filter(isNativeToken).map((token) => nativeName(token.path)) + const actual = swiftConstantNames(readOutput(`theme-${themeName}.swift`)) + + expect([...actual].sort()).toEqual([...expected].sort()) + }) + }) + + it('quest-reports should contain quest colours plus its own font scale', () => { + const questColours = flatten(readJson('src/themes/quest/color.json')) + const reportFonts = flatten(readJson('src/themes/quest/reports/sizes.json')) + const expected = [...questColours, ...reportFonts] + .filter(isNativeToken) + .map((token) => nativeName(token.path)) + const actual = swiftConstantNames(readOutput('theme-quest-reports.swift')) + + expect([...actual].sort()).toEqual([...expected].sort()) + }) + }) +}) diff --git a/test/native-compile.test.ts b/test/native-compile.test.ts new file mode 100644 index 0000000..81c42fc --- /dev/null +++ b/test/native-compile.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { execFileSync, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const themes = ['base', 'atom', 'quest', 'quest-reports'] as const + +const hasTool = (tool: string): boolean => + spawnSync('which', [tool], { stdio: 'ignore' }).status === 0 + +const libPath = (file: string): string => path.join(process.cwd(), 'lib', file) + +// Invoking a real compiler is far slower than vitest's 5s default, and slower +// again on CI runners than locally +const COMPILE_TIMEOUT = 180_000 + +// Minimal stand-ins for the Compose APIs the generated file references, so the +// output can be type-checked without pulling in the Android toolchain. +const COMPOSE_COLOR_STUB = `package androidx.compose.ui.graphics + +class Color(val value: Long) { + constructor(value: Int) : this(value.toLong()) +} +` + +const COMPOSE_UNIT_STUBS = `package androidx.compose.ui.unit + +class Dp(val value: Float) +class TextUnit(val value: Float) + +val Double.dp: Dp get() = Dp(this.toFloat()) +val Int.dp: Dp get() = Dp(this.toFloat()) +val Double.sp: TextUnit get() = TextUnit(this.toFloat()) +val Int.sp: TextUnit get() = TextUnit(this.toFloat()) +` + +describe.skipIf(!hasTool('swiftc'))('Swift output compiles', () => { + themes.forEach((themeName) => { + it( + `theme-${themeName}.swift should parse with swiftc`, + () => { + expect(() => + execFileSync('swiftc', ['-parse', libPath(`theme-${themeName}.swift`)], { + stdio: 'pipe' + }) + ).not.toThrow() + }, + COMPILE_TIMEOUT + ) + }) +}) + +describe.skipIf(!hasTool('kotlinc'))('Kotlin output compiles', () => { + themes.forEach((themeName) => { + it( + `theme-${themeName}.kt should compile with kotlinc`, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `theme-kt-${themeName}-`)) + try { + const graphics = path.join(dir, 'ComposeColorStub.kt') + const units = path.join(dir, 'ComposeUnitStub.kt') + fs.writeFileSync(graphics, COMPOSE_COLOR_STUB) + fs.writeFileSync(units, COMPOSE_UNIT_STUBS) + + expect(() => + execFileSync( + 'kotlinc', + [ + graphics, + units, + libPath(`theme-${themeName}.kt`), + '-nowarn', + '-d', + path.join(dir, 'out') + ], + { stdio: 'pipe' } + ) + ).not.toThrow() + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }, + COMPILE_TIMEOUT + ) + }) +}) diff --git a/test/native.test.ts b/test/native.test.ts new file mode 100644 index 0000000..ce4eb8b --- /dev/null +++ b/test/native.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const themes = ['base', 'atom', 'quest', 'quest-reports'] as const + +const readOutput = (file: string): string => + fs.readFileSync(path.join(process.cwd(), 'lib', file), 'utf-8') + +const swiftConstantNames = (source: string): string[] => + [...source.matchAll(/public static let (\w+)/g)].map((match) => match[1]) + +const kotlinConstantNames = (source: string): string[] => + [...source.matchAll(/\bval (\w+)/g)].map((match) => match[1]) + +describe('Native Token Outputs', () => { + describe('Swift Output', () => { + themes.forEach((themeName) => { + const file = `theme-${themeName}.swift` + + it(`${themeName} should declare a ThemeTokens enum importing SwiftUI`, () => { + const swift = readOutput(file) + + expect(swift).toContain('import SwiftUI') + expect(swift).toContain('public enum ThemeTokens {') + expect(swiftConstantNames(swift).length).toBeGreaterThan(0) + }) + + it(`${themeName} should contain no unconverted hsl/hex/rem values`, () => { + const swift = readOutput(file) + + expect(swift).not.toMatch(/hsla?\(/) + expect(swift).not.toMatch(/#[0-9a-fA-F]/) + expect(swift).not.toMatch(/\d\s*rem/) + }) + + it(`${themeName} should have no duplicate constant names`, () => { + const names = swiftConstantNames(readOutput(file)) + + expect(new Set(names).size).toBe(names.length) + }) + }) + + it('base should convert colours to sRGB Color initialisers', () => { + const swift = readOutput('theme-base.swift') + + expect(swift).toContain( + 'public static let black = Color(red: 0.000, green: 0.000, blue: 0.000, opacity: 1)' + ) + expect(swift).toContain( + 'public static let white = Color(red: 1.000, green: 1.000, blue: 1.000, opacity: 1)' + ) + // hsl(0, 0%, 96%) + expect(swift).toContain( + 'public static let grey100 = Color(red: 0.961, green: 0.961, blue: 0.961, opacity: 1)' + ) + // hsla(0, 0%, 20%, 0.1) carries its opacity component + expect(swift).toContain( + 'public static let alpha100 = Color(red: 0.200, green: 0.200, blue: 0.200, opacity: 0.1)' + ) + }) + + it('base should convert sizes from rem to pt as CGFloat', () => { + const swift = readOutput('theme-base.swift') + + expect(swift).toContain('public static let fontSm = CGFloat(14.00)') + expect(swift).toContain('public static let radiiMd = CGFloat(8.00)') + expect(swift).toContain('public static let space = CGFloat(4.00)') + // leading multipliers are emitted as-is, unitless + expect(swift).toContain('public static let leadingMd = 1.5') + }) + + it('base should use flat camelCase names from the token path', () => { + const names = swiftConstantNames(readOutput('theme-base.swift')) + + expect(names).toContain('blue800') + expect(names).toContain('subjectGcseMaths') + expect(names).toContain('glBlueLight') + expect(names).toContain('fontSm') + }) + + it('base should exclude font families, breakpoints and effects', () => { + const swift = readOutput('theme-base.swift') + + expect(swift).not.toContain('system-ui') + expect(swift).not.toMatch(/breakpoint/i) + expect(swift).not.toMatch(/shadow/i) + }) + + it('atom should only contain theme-specific tokens', () => { + const names = swiftConstantNames(readOutput('theme-atom.swift')) + + expect(names).toContain('primary100') + expect(names).not.toContain('textBold') + expect(names).not.toContain('grey100') + }) + }) + + describe('Kotlin Output', () => { + themes.forEach((themeName) => { + const file = `theme-${themeName}.kt` + + it(`${themeName} should declare a ThemeTokens object importing Compose types`, () => { + const kotlin = readOutput(file) + + expect(kotlin).toContain('package uk.co.atomlearning.theme') + expect(kotlin).toContain('import androidx.compose.ui.graphics.Color') + expect(kotlin).toContain('import androidx.compose.ui.unit.*') + expect(kotlin).toContain('object ThemeTokens {') + expect(kotlinConstantNames(kotlin).length).toBeGreaterThan(0) + }) + + it(`${themeName} should contain no unconverted hsl/hex/rem values`, () => { + const kotlin = readOutput(file) + + expect(kotlin).not.toMatch(/hsla?\(/) + expect(kotlin).not.toMatch(/#[0-9a-fA-F]/) + expect(kotlin).not.toMatch(/\d\s*rem/) + }) + + it(`${themeName} should have no duplicate constant names`, () => { + const names = kotlinConstantNames(readOutput(file)) + + expect(new Set(names).size).toBe(names.length) + }) + }) + + it('base should convert colours to ARGB Color constants', () => { + const kotlin = readOutput('theme-base.kt') + + expect(kotlin).toContain('val black = Color(0xff000000)') + expect(kotlin).toContain('val white = Color(0xffffffff)') + // hsl(0, 0%, 96%) + expect(kotlin).toContain('val grey100 = Color(0xfff5f5f5)') + // hsla(0, 0%, 20%, 0.1) carries its opacity in the alpha byte + expect(kotlin).toContain('val alpha100 = Color(0x1a333333)') + }) + + it('base should convert sizes to sp/dp and leave leading unitless', () => { + const kotlin = readOutput('theme-base.kt') + + expect(kotlin).toContain('val fontSm = 14.00.sp') + expect(kotlin).toContain('val radiiMd = 8.00.dp') + expect(kotlin).toContain('val space = 4.00.dp') + expect(kotlin).toContain('val leadingMd = 1.5') + }) + + it('atom should only contain theme-specific tokens', () => { + const names = kotlinConstantNames(readOutput('theme-atom.kt')) + + expect(names).toContain('primary100') + expect(names).not.toContain('textBold') + expect(names).not.toContain('grey100') + }) + + it('quest-reports should convert its font size overrides', () => { + const kotlin = readOutput('theme-quest-reports.kt') + + // 0.625rem × 16 + expect(kotlin).toContain('val fontXs = 10.00.sp') + }) + }) +}) diff --git a/test/values.test.ts b/test/values.test.ts new file mode 100644 index 0000000..b6ce60d --- /dev/null +++ b/test/values.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { parseToRgba } from 'color2k' + +const readOutput = (file: string): string => + fs.readFileSync(path.join(process.cwd(), 'lib', file), 'utf-8') + +const themes = ['base', 'atom', 'quest', 'quest-reports'] as const + +// Any CSS colour (hex, hsl, hsla) -> normalised sRGB channels in 0-1. +// color2k is a deliberately different implementation from the tinycolor2 that +// style-dictionary converts with, so these assertions are an independent +// check rather than a restatement of the build's own maths. +const toSrgb = ( + value: string +): { channels: [number, number, number]; alpha: number } => { + const [red, green, blue, alpha] = parseToRgba(value) + return { channels: [red / 255, green / 255, blue / 255], alpha } +} + +describe('Value Fidelity', () => { + describe('Shadows', () => { + it('base css should emit full box-shadow values, not just the reset', () => { + const css = readOutput('theme-base.css') + + expect(css).toContain( + '--shadow-sm: 0 1px 3px hsla(0, 0%, 20%, 0.1), 0 1px 2px hsla(0, 0%, 20%, 0.15);' + ) + expect(css).toContain( + '--shadow-md: 0 3px 6px hsla(0, 0%, 20%, 0.1), 0 3px 6px hsla(0, 0%, 20%, 0.1);' + ) + expect(css).toContain( + '--shadow-lg: 0 10px 20px hsla(0, 0%, 20%, 0.1), 0 6px 6px hsla(0, 0%, 20%, 0.1);' + ) + expect(css).toContain( + '--shadow-xl: 0 14px 28px hsla(0, 0%, 20%, 0.15), 0 10px 10px hsla(0, 0%, 20%, 0.1);' + ) + }) + + it('shadow aliases should be fully resolved, with no unresolved references', async () => { + const { theme, properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { + theme: { shadows: Record } + properties: Record + } + + expect(Object.keys(theme.shadows)).toEqual(['sm', 'md', 'lg', 'xl']) + Object.values(theme.shadows).forEach((value) => { + expect(value).not.toMatch(/[{}]/) + expect(value).toContain('hsla(') + }) + expect(properties['--shadow-sm']).toBe(theme.shadows.sm) + }) + }) + + describe('Breakpoints', () => { + it('css breakpoint values should match the media query output exactly', async () => { + const css = readOutput('theme-base.css') + const { media } = (await import(path.join(process.cwd(), 'lib', 'media.js'))) as { + media: Record + } + + expect(css).toContain('--breakpoint-sm: 34.375rem;') + expect(css).toContain('--breakpoint-md: 50rem;') + expect(css).toContain('--breakpoint-lg: 68.75rem;') + expect(css).toContain('--breakpoint-xl: 84.375rem;') + + Object.entries(media).forEach(([key, query]) => { + const size = query.replace('(min-width: ', '').replace(')', '') + expect(css).toContain(`--breakpoint-${key}: ${size};`) + }) + }) + }) + + describe('Font families', () => { + it('base should emit the full web font stacks', async () => { + const { properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-base.js') + )) as { properties: Record } + + expect(properties['--font-sans']).toBe( + "system-ui, -apple-system, 'Helvetica Neue', sans-serif" + ) + expect(properties['--font-mono']).toBe("'SFMono-Regular', Consolas, Menlo, monospace") + // display/body alias sans in base and must be resolved, not a reference + expect(properties['--font-display']).toBe(properties['--font-sans']) + expect(properties['--font-body']).toBe(properties['--font-sans']) + }) + + it('atom should override display and body with its bundled faces', async () => { + const { properties } = (await import( + path.join(process.cwd(), 'lib', 'theme-atom.js') + )) as { properties: Record } + + expect(properties['--font-display']).toContain("'National 2 Condensed'") + expect(properties['--font-body']).toContain("'Inter'") + expect(properties['--font-display']).not.toMatch(/[{}]/) + }) + }) + + describe('Cross-output consistency', () => { + themes.forEach((themeName) => { + it(`${themeName} swift colours should match the js theme colours`, async () => { + const { theme } = (await import( + path.join(process.cwd(), 'lib', `theme-${themeName}.js`) + )) as { theme: { colors?: Record } } + const swift = readOutput(`theme-${themeName}.swift`) + + const colors = theme.colors || {} + expect(Object.keys(colors).length).toBeGreaterThan(0) + + Object.entries(colors).forEach(([name, value]) => { + const match = swift.match( + new RegExp( + `public static let ${name} = Color\\(red: ([\\d.]+), green: ([\\d.]+), blue: ([\\d.]+), opacity: ([\\d.]+)\\)` + ) + ) + expect(match, `${name} missing from theme-${themeName}.swift`).not.toBeNull() + if (!match) return + + const { channels, alpha } = toSrgb(value) + channels.forEach((channel, index) => { + expect( + parseFloat(match[index + 1]), + `${name} channel ${index} should match ${value}` + ).toBeCloseTo(channel, 2) + }) + expect(parseFloat(match[4]), `${name} opacity should match ${value}`).toBeCloseTo( + alpha, + 3 + ) + }) + }) + + it(`${themeName} kotlin colours should match the swift colours`, () => { + const swift = readOutput(`theme-${themeName}.swift`) + const kotlin = readOutput(`theme-${themeName}.kt`) + + const kotlinColors = [...kotlin.matchAll(/val (\w+) = Color\(0x([0-9a-f]{8})\)/g)] + expect(kotlinColors.length).toBeGreaterThan(0) + + kotlinColors.forEach(([, name, argb]) => { + const match = swift.match( + new RegExp( + `public static let ${name} = Color\\(red: ([\\d.]+), green: ([\\d.]+), blue: ([\\d.]+), opacity: ([\\d.]+)\\)` + ) + ) + expect(match, `${name} missing from Swift output`).not.toBeNull() + if (!match) return + + // Compose packs colours as AARRGGBB; reorder to the RRGGBBAA that + // CSS (and so color2k) understands + const { channels, alpha } = toSrgb(`#${argb.slice(2)}${argb.slice(0, 2)}`) + + channels.forEach((channel, index) => { + expect( + parseFloat(match[index + 1]), + `${name} channel ${index} should agree across native outputs` + ).toBeCloseTo(channel, 2) + }) + expect(parseFloat(match[4])).toBeCloseTo(alpha, 2) + }) + }) + + it(`${themeName} native font sizes should be the js rem values × 16`, async () => { + const { theme } = (await import( + path.join(process.cwd(), 'lib', `theme-${themeName}.js`) + )) as { theme: { fontSizes?: Record } } + const swift = readOutput(`theme-${themeName}.swift`) + + Object.entries(theme.fontSizes || {}).forEach(([key, value]) => { + const pt = parseFloat(value) * 16 + const name = `font${key.charAt(0).toUpperCase()}${key.slice(1)}` + expect(swift, `${name} should be ${pt}pt`).toContain( + `public static let ${name} = CGFloat(${pt.toFixed(2)})` + ) + }) + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 1f3718f..4551fab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -862,6 +862,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color2k@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.4.tgz#c874da1e6f089fcc32ef5ab0427e7ba7fe069d64" + integrity sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg== + commander@^12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3"