From 559f458a4ad1c90113785e979071de0dfbeb0d02 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 12:05:03 +0100 Subject: [PATCH 1/6] feat: emit native token outputs for iOS (Swift) and Android (Kotlin) Adds Swift and Kotlin formatters that emit colours, type scale, spacing, radii and line heights as ThemeTokens constants alongside the existing CSS/JS/Tailwind outputs, per theme. Co-Authored-By: Claude Fable 5 --- README.md | 13 ++ src/build.ts | 10 ++ src/formatters/kotlin-theme.ts | 77 ++++++++++++ src/formatters/native-tokens.ts | 209 ++++++++++++++++++++++++++++++++ src/formatters/swift-theme.ts | 62 ++++++++++ src/style.config.ts | 10 ++ test/native.test.ts | 163 +++++++++++++++++++++++++ 7 files changed, 544 insertions(+) create mode 100644 src/formatters/kotlin-theme.ts create mode 100644 src/formatters/native-tokens.ts create mode 100644 src/formatters/swift-theme.ts create mode 100644 test/native.test.ts diff --git a/README.md b/README.md index f3f85e6..9c2d391 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,16 @@ 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 +- `lib/theme-*.kt` — the equivalent Compose `Color(0xAARRGGBB)`, `sp`/`dp` and `Float` constants + +Values are converted at build time: colours from hsl()/hex to sRGB components, `size.font`/`size.radii`/`size.space` from rem to pt (× 16), and `size.leading` emitted as unitless multipliers. Constant names are 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. diff --git a/src/build.ts b/src/build.ts index 57faae5..4bfbcc9 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,6 +9,8 @@ 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 swiftTheme from './formatters/swift-theme.ts' +import kotlinTheme from './formatters/kotlin-theme.ts' import { setBuildConfig } from './formatters/shared.ts' import { readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs' import { join } from 'node:path' @@ -65,6 +67,14 @@ const buildTheme = async ( name: 'custom/format/all-themes-css', format: allThemesCss }) + sd.registerFormat({ + name: 'custom/format/swift-theme', + format: swiftTheme + }) + sd.registerFormat({ + name: 'custom/format/kotlin-theme', + format: kotlinTheme + }) await sd.buildAllPlatforms() } diff --git a/src/formatters/kotlin-theme.ts b/src/formatters/kotlin-theme.ts new file mode 100644 index 0000000..de8cbe1 --- /dev/null +++ b/src/formatters/kotlin-theme.ts @@ -0,0 +1,77 @@ +import { + collectNativeTokens, + formatNumber, + type Dictionary, + type Rgba, + type ColorToken, + type NumberToken +} from './native-tokens.ts' + +const toHexByte = (channel: number): string => + Math.round(channel * 255) + .toString(16) + .toUpperCase() + .padStart(2, '0') + +const kotlinColor = ({ red, green, blue, alpha }: Rgba): string => + `Color(0x${toHexByte(alpha)}${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)})` + +const section = (title: string, lines: string[]): string[] => { + if (lines.length === 0) return [] + return [` // ${title}`, ...lines] +} + +const formatter = (dictionary: Dictionary): string => { + const tokens = collectNativeTokens(dictionary) + + const sections = [ + section( + 'Colors', + tokens.colors.map( + ({ name, color }) => ` val ${name} = ${kotlinColor(color)}` + ) + ), + section( + 'Font sizes (sp)', + tokens.fontSizes.map( + ({ name, value }) => ` val ${name} = ${formatNumber(value)}.sp` + ) + ), + section( + 'Line heights (multipliers)', + tokens.lineHeights.map( + ({ name, value }) => ` const val ${name} = ${formatNumber(value)}f` + ) + ), + section( + 'Radii (dp)', + tokens.radii.map( + ({ name, value }) => ` val ${name} = ${formatNumber(value)}.dp` + ) + ), + section( + 'Spacing (dp)', + tokens.space.map( + ({ name, value }) => ` val ${name} = ${formatNumber(value)}.dp` + ) + ) + ].filter((lines) => lines.length > 0) + + const body = sections.map((lines) => lines.join('\n')).join('\n\n') + + return `// Do not edit directly — generated by @atom-learning/theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object ThemeTokens { +${body} +} +` +} + +formatter.nested = true + +export default formatter +export { setBuildConfig } from './shared.ts' diff --git a/src/formatters/native-tokens.ts b/src/formatters/native-tokens.ts new file mode 100644 index 0000000..84a73fc --- /dev/null +++ b/src/formatters/native-tokens.ts @@ -0,0 +1,209 @@ +import { pascalCase } from 'pascal-case' +import { getBuildConfig, shouldIncludeProperty } from './shared.ts' + +interface Property { + attributes: { + type: string + category: string + item?: string + subitem?: string + } + value: string | number + name: string + filePath?: string +} + +export interface Dictionary { + allTokens?: Property[] + allProperties?: Property[] +} + +export interface Rgba { + red: number + green: number + blue: number + alpha: number +} + +export interface ColorToken { + name: string + color: Rgba +} + +export interface NumberToken { + name: string + value: number +} + +export interface NativeTokens { + colors: ColorToken[] + fontSizes: NumberToken[] + lineHeights: NumberToken[] + radii: NumberToken[] + space: NumberToken[] +} + +// Native platforms treat 1rem as 16pt/16dp +const REM_TO_PT = 16 + +const isPlainNumber = (value: unknown): boolean => { + if (typeof value !== 'string' && typeof value !== 'number') return false + const str = String(value).trim() + return /^-?\d*\.?\d+$/.test(str) && !/[a-zA-Z%]/.test(str) +} + +const round = (value: number, decimals = 5): number => { + const factor = 10 ** decimals + return Math.round(value * factor) / factor +} + +const hslToRgb = (h: number, s: number, l: number): [number, number, number] => { + const hue = ((h % 360) + 360) % 360 + const chroma = (1 - Math.abs(2 * l - 1)) * s + const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)) + const m = l - chroma / 2 + + let rgb: [number, number, number] + if (hue < 60) rgb = [chroma, x, 0] + else if (hue < 120) rgb = [x, chroma, 0] + else if (hue < 180) rgb = [0, chroma, x] + else if (hue < 240) rgb = [0, x, chroma] + else if (hue < 300) rgb = [x, 0, chroma] + else rgb = [chroma, 0, x] + + return [rgb[0] + m, rgb[1] + m, rgb[2] + m] +} + +export const parseColor = (value: string): Rgba | null => { + const str = value.trim() + + const hexMatch = str.match(/^#([0-9a-fA-F]{3,8})$/) + if (hexMatch) { + let hex = hexMatch[1] + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map((char) => char + char) + .join('') + } + if (hex.length !== 6 && hex.length !== 8) return null + const red = parseInt(hex.slice(0, 2), 16) / 255 + const green = parseInt(hex.slice(2, 4), 16) / 255 + const blue = parseInt(hex.slice(4, 6), 16) / 255 + const alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 + return { red, green, blue, alpha } + } + + const hslMatch = str.match( + /^hsla?\(\s*(-?[\d.]+)(?:deg)?\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+)\s*)?\)$/ + ) + if (hslMatch) { + const [red, green, blue] = hslToRgb( + parseFloat(hslMatch[1]), + parseFloat(hslMatch[2]) / 100, + parseFloat(hslMatch[3]) / 100 + ) + const alpha = hslMatch[4] === undefined ? 1 : parseFloat(hslMatch[4]) + return { + red: round(red), + green: round(green), + blue: round(blue), + alpha + } + } + + return null +} + +// Flat camelCase name from the token path, matching the JS theme naming: +// color.blue.800 -> blue800, color.subject.gcse-maths -> subjectGcseMaths, +// size.font.sm -> fontSm. `base` segments collapse (color.info.base -> info). +const camelName = (type: string, item?: string, subitem = ''): string => { + if (!item || item === 'base') return type + if (subitem === 'base') subitem = '' + return parseInt(item) + ? `${type}${item}${subitem}` + : `${type}${pascalCase(item)}${pascalCase(subitem)}` +} + +export const collectNativeTokens = (dictionary: Dictionary): NativeTokens => { + const config = getBuildConfig() + const properties = dictionary.allTokens || dictionary.allProperties || [] + + const tokens: NativeTokens = { + colors: [], + fontSizes: [], + lineHeights: [], + radii: [], + space: [] + } + const seenNames = new Set() + + const registerName = (name: string, property: Property): string => { + if (seenNames.has(name)) { + throw new Error( + `Duplicate native token name "${name}" (from ${property.attributes.category}.${property.attributes.type}.${property.attributes.item || ''}) — native constant names must be unique` + ) + } + seenNames.add(name) + return name + } + + properties.forEach((property) => { + if ( + !shouldIncludeProperty( + property as unknown as Parameters[0], + config + ) + ) + return + + const { category, type, item, subitem } = property.attributes + + if (category === 'color') { + const color = parseColor(String(property.value)) + if (!color) { + throw new Error( + `Unable to convert colour token "${property.name}" with value "${property.value}" for native output` + ) + } + tokens.colors.push({ + name: registerName(camelName(type, item, subitem || ''), property), + color + }) + return + } + + if (category !== 'size') return + + // font.families (web font stacks), size.breakpoint (windowed-web concern), + // size.size and effects don't translate to native — deliberately excluded + if (type !== 'font' && type !== 'leading' && type !== 'radii' && type !== 'space') + return + + if (!isPlainNumber(property.value)) { + throw new Error( + `Unable to convert size token "${property.name}" with value "${property.value}" for native output — expected a unitless number` + ) + } + const numValue = + typeof property.value === 'number' + ? property.value + : parseFloat(String(property.value)) + + const name = registerName(camelName(type, item), property) + + if (type === 'leading') { + // unitless line-height multipliers, emitted as-is + tokens.lineHeights.push({ name, value: numValue }) + } else { + const target = + type === 'font' ? tokens.fontSizes : type === 'radii' ? tokens.radii : tokens.space + target.push({ name, value: round(numValue * REM_TO_PT) }) + } + }) + + return tokens +} + +export const formatNumber = (value: number): string => String(round(value)) diff --git a/src/formatters/swift-theme.ts b/src/formatters/swift-theme.ts new file mode 100644 index 0000000..5c18984 --- /dev/null +++ b/src/formatters/swift-theme.ts @@ -0,0 +1,62 @@ +import { + collectNativeTokens, + formatNumber, + type Dictionary, + type Rgba, + type ColorToken, + type NumberToken +} from './native-tokens.ts' + +const swiftColor = ({ red, green, blue, alpha }: Rgba): string => + `Color(red: ${formatNumber(red)}, green: ${formatNumber(green)}, blue: ${formatNumber(blue)}, opacity: ${formatNumber(alpha)})` + +const colorSection = (title: string, tokens: ColorToken[]): string[] => { + if (tokens.length === 0) return [] + return [ + ` // MARK: - ${title}`, + '', + ...tokens.map( + ({ name, color }) => ` public static let ${name} = ${swiftColor(color)}` + ) + ] +} + +const numberSection = (title: string, tokens: NumberToken[]): string[] => { + if (tokens.length === 0) return [] + return [ + ` // MARK: - ${title}`, + '', + ...tokens.map( + ({ name, value }) => + ` public static let ${name}: CGFloat = ${formatNumber(value)}` + ) + ] +} + +const formatter = (dictionary: Dictionary): string => { + const tokens = collectNativeTokens(dictionary) + + const sections = [ + colorSection('Colors', tokens.colors), + numberSection('Font sizes (pt)', tokens.fontSizes), + numberSection('Line heights (multipliers)', tokens.lineHeights), + numberSection('Radii (pt)', tokens.radii), + numberSection('Spacing (pt)', tokens.space) + ].filter((section) => section.length > 0) + + const body = sections.map((section) => section.join('\n')).join('\n\n') + + return `// Do not edit directly — generated by @atom-learning/theme + +import SwiftUI + +public enum ThemeTokens { +${body} +} +` +} + +formatter.nested = true + +export default formatter +export { setBuildConfig } from './shared.ts' diff --git a/src/style.config.ts b/src/style.config.ts index 2ee6ec1..9827b4d 100644 --- a/src/style.config.ts +++ b/src/style.config.ts @@ -64,6 +64,16 @@ export default (themes: string[], includeBase = true): Config => { name ? `theme-${name}.d.ts` : 'theme-base.d.ts', 'custom/format/system-ui-theme-types' ), + swift: createPlatform( + COMMON_TRANSFORMS, + name ? `theme-${name}.swift` : 'theme-base.swift', + 'custom/format/swift-theme' + ), + kotlin: createPlatform( + COMMON_TRANSFORMS, + name ? `theme-${name}.kt` : 'theme-base.kt', + 'custom/format/kotlin-theme' + ), 'assets/copy': { actions: ['copy_assets'], buildPath: 'lib/', diff --git a/test/native.test.ts b/test/native.test.ts new file mode 100644 index 0000000..0de7bf4 --- /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(/(?:const )?val (\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, green: 0, blue: 0, opacity: 1)' + ) + expect(swift).toContain( + 'public static let white = Color(red: 1, green: 1, blue: 1, opacity: 1)' + ) + // hsl(0, 0%, 96%) + expect(swift).toContain( + 'public static let grey100 = Color(red: 0.96, green: 0.96, blue: 0.96, opacity: 1)' + ) + // hsla(0, 0%, 20%, 0.1) carries its opacity component + expect(swift).toContain( + 'public static let alpha100 = Color(red: 0.2, green: 0.2, blue: 0.2, 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') + expect(swift).toContain('public static let radiiMd: CGFloat = 8') + expect(swift).toContain('public static let space: CGFloat = 4') + // leading multipliers are emitted as-is + expect(swift).toContain('public static let leadingMd: CGFloat = 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('import androidx.compose.ui.graphics.Color') + expect(kotlin).toContain('import androidx.compose.ui.unit.dp') + expect(kotlin).toContain('import androidx.compose.ui.unit.sp') + 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 leading to Float', () => { + const kotlin = readOutput('theme-base.kt') + + expect(kotlin).toContain('val fontSm = 14.sp') + expect(kotlin).toContain('val radiiMd = 8.dp') + expect(kotlin).toContain('val space = 4.dp') + expect(kotlin).toContain('const val leadingMd = 1.5f') + }) + + 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.sp') + }) + }) +}) From 66da5322e9d2a2e21a1e5122e15c94afc4b54713 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 12:25:29 +0100 Subject: [PATCH 2/6] refactor: use style-dictionary built-in transforms/formats for native outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates token sources to the DTCG format ($value + group-level $type) so style-dictionary's built-in transforms can drive the conversions: - Swift/Kotlin files are now emitted by the built-in ios-swift/enum.swift and compose/object formats, with colour and rem->pt conversion handled by color/ColorSwiftUI, color/composeColor, size/swift/remToCGFloat and size/compose/remToSp|Dp — the hand-rolled formatters are deleted - Custom code shrinks to src/native.ts: the flat camelCase naming contract and the native token filter (coverage + theme scoping) - Web formatters read $value; the dormant color/hsl and size/rem transforms are removed from the web platforms so their outputs stay byte-identical (verified against a pre-migration snapshot) Co-Authored-By: Claude Fable 5 --- README.md | 18 +- src/build.ts | 14 +- src/formatters/all-themes-css.ts | 7 +- src/formatters/kotlin-theme.ts | 77 --- src/formatters/media-queries-types.ts | 7 +- src/formatters/media-queries.ts | 7 +- src/formatters/native-tokens.ts | 209 ------ src/formatters/shared.ts | 6 + src/formatters/swift-theme.ts | 62 -- src/formatters/system-ui-theme.ts | 11 +- src/formatters/tailwind-theme.ts | 12 +- src/native.ts | 44 ++ src/properties/aliases.json | 25 +- src/properties/colors.json | 936 +++++++++++++++++++------- src/properties/containers.json | 17 +- src/properties/effects.json | 9 +- src/properties/fonts.json | 9 +- src/properties/sizes.json | 88 ++- src/style.config.ts | 70 +- src/themes/atom/color.json | 49 +- src/themes/atom/fonts.json | 5 +- src/themes/quest/color.json | 49 +- src/themes/quest/fonts.json | 5 +- src/themes/quest/reports/sizes.json | 33 +- test/native.test.ts | 44 +- 25 files changed, 1078 insertions(+), 735 deletions(-) delete mode 100644 src/formatters/kotlin-theme.ts delete mode 100644 src/formatters/native-tokens.ts delete mode 100644 src/formatters/swift-theme.ts create mode 100644 src/native.ts diff --git a/README.md b/README.md index 9c2d391..167f13c 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" } } } @@ -72,10 +72,10 @@ Some CSS properties are not included in the [defaultThemeMap](https://stitches.d 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 -- `lib/theme-*.kt` — the equivalent Compose `Color(0xAARRGGBB)`, `sp`/`dp` and `Float` constants +- `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: colours from hsl()/hex to sRGB components, `size.font`/`size.radii`/`size.space` from rem to pt (× 16), and `size.leading` emitted as unitless multipliers. Constant names are flat camelCase from the token path (`color.blue.800` → `blue800`, `size.font.sm` → `fontSm`) — **renames are breaking** for the native apps. +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). diff --git a/src/build.ts b/src/build.ts index 4bfbcc9..43c6501 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,8 +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 swiftTheme from './formatters/swift-theme.ts' -import kotlinTheme from './formatters/kotlin-theme.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' @@ -67,13 +66,10 @@ const buildTheme = async ( name: 'custom/format/all-themes-css', format: allThemesCss }) - sd.registerFormat({ - name: 'custom/format/swift-theme', - format: swiftTheme - }) - sd.registerFormat({ - name: 'custom/format/kotlin-theme', - format: kotlinTheme + 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/kotlin-theme.ts b/src/formatters/kotlin-theme.ts deleted file mode 100644 index de8cbe1..0000000 --- a/src/formatters/kotlin-theme.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - collectNativeTokens, - formatNumber, - type Dictionary, - type Rgba, - type ColorToken, - type NumberToken -} from './native-tokens.ts' - -const toHexByte = (channel: number): string => - Math.round(channel * 255) - .toString(16) - .toUpperCase() - .padStart(2, '0') - -const kotlinColor = ({ red, green, blue, alpha }: Rgba): string => - `Color(0x${toHexByte(alpha)}${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)})` - -const section = (title: string, lines: string[]): string[] => { - if (lines.length === 0) return [] - return [` // ${title}`, ...lines] -} - -const formatter = (dictionary: Dictionary): string => { - const tokens = collectNativeTokens(dictionary) - - const sections = [ - section( - 'Colors', - tokens.colors.map( - ({ name, color }) => ` val ${name} = ${kotlinColor(color)}` - ) - ), - section( - 'Font sizes (sp)', - tokens.fontSizes.map( - ({ name, value }) => ` val ${name} = ${formatNumber(value)}.sp` - ) - ), - section( - 'Line heights (multipliers)', - tokens.lineHeights.map( - ({ name, value }) => ` const val ${name} = ${formatNumber(value)}f` - ) - ), - section( - 'Radii (dp)', - tokens.radii.map( - ({ name, value }) => ` val ${name} = ${formatNumber(value)}.dp` - ) - ), - section( - 'Spacing (dp)', - tokens.space.map( - ({ name, value }) => ` val ${name} = ${formatNumber(value)}.dp` - ) - ) - ].filter((lines) => lines.length > 0) - - const body = sections.map((lines) => lines.join('\n')).join('\n\n') - - return `// Do not edit directly — generated by @atom-learning/theme - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -object ThemeTokens { -${body} -} -` -} - -formatter.nested = true - -export default formatter -export { setBuildConfig } from './shared.ts' 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/native-tokens.ts b/src/formatters/native-tokens.ts deleted file mode 100644 index 84a73fc..0000000 --- a/src/formatters/native-tokens.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { pascalCase } from 'pascal-case' -import { getBuildConfig, shouldIncludeProperty } from './shared.ts' - -interface Property { - attributes: { - type: string - category: string - item?: string - subitem?: string - } - value: string | number - name: string - filePath?: string -} - -export interface Dictionary { - allTokens?: Property[] - allProperties?: Property[] -} - -export interface Rgba { - red: number - green: number - blue: number - alpha: number -} - -export interface ColorToken { - name: string - color: Rgba -} - -export interface NumberToken { - name: string - value: number -} - -export interface NativeTokens { - colors: ColorToken[] - fontSizes: NumberToken[] - lineHeights: NumberToken[] - radii: NumberToken[] - space: NumberToken[] -} - -// Native platforms treat 1rem as 16pt/16dp -const REM_TO_PT = 16 - -const isPlainNumber = (value: unknown): boolean => { - if (typeof value !== 'string' && typeof value !== 'number') return false - const str = String(value).trim() - return /^-?\d*\.?\d+$/.test(str) && !/[a-zA-Z%]/.test(str) -} - -const round = (value: number, decimals = 5): number => { - const factor = 10 ** decimals - return Math.round(value * factor) / factor -} - -const hslToRgb = (h: number, s: number, l: number): [number, number, number] => { - const hue = ((h % 360) + 360) % 360 - const chroma = (1 - Math.abs(2 * l - 1)) * s - const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)) - const m = l - chroma / 2 - - let rgb: [number, number, number] - if (hue < 60) rgb = [chroma, x, 0] - else if (hue < 120) rgb = [x, chroma, 0] - else if (hue < 180) rgb = [0, chroma, x] - else if (hue < 240) rgb = [0, x, chroma] - else if (hue < 300) rgb = [x, 0, chroma] - else rgb = [chroma, 0, x] - - return [rgb[0] + m, rgb[1] + m, rgb[2] + m] -} - -export const parseColor = (value: string): Rgba | null => { - const str = value.trim() - - const hexMatch = str.match(/^#([0-9a-fA-F]{3,8})$/) - if (hexMatch) { - let hex = hexMatch[1] - if (hex.length === 3 || hex.length === 4) { - hex = hex - .split('') - .map((char) => char + char) - .join('') - } - if (hex.length !== 6 && hex.length !== 8) return null - const red = parseInt(hex.slice(0, 2), 16) / 255 - const green = parseInt(hex.slice(2, 4), 16) / 255 - const blue = parseInt(hex.slice(4, 6), 16) / 255 - const alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 - return { red, green, blue, alpha } - } - - const hslMatch = str.match( - /^hsla?\(\s*(-?[\d.]+)(?:deg)?\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+)\s*)?\)$/ - ) - if (hslMatch) { - const [red, green, blue] = hslToRgb( - parseFloat(hslMatch[1]), - parseFloat(hslMatch[2]) / 100, - parseFloat(hslMatch[3]) / 100 - ) - const alpha = hslMatch[4] === undefined ? 1 : parseFloat(hslMatch[4]) - return { - red: round(red), - green: round(green), - blue: round(blue), - alpha - } - } - - return null -} - -// Flat camelCase name from the token path, matching the JS theme naming: -// color.blue.800 -> blue800, color.subject.gcse-maths -> subjectGcseMaths, -// size.font.sm -> fontSm. `base` segments collapse (color.info.base -> info). -const camelName = (type: string, item?: string, subitem = ''): string => { - if (!item || item === 'base') return type - if (subitem === 'base') subitem = '' - return parseInt(item) - ? `${type}${item}${subitem}` - : `${type}${pascalCase(item)}${pascalCase(subitem)}` -} - -export const collectNativeTokens = (dictionary: Dictionary): NativeTokens => { - const config = getBuildConfig() - const properties = dictionary.allTokens || dictionary.allProperties || [] - - const tokens: NativeTokens = { - colors: [], - fontSizes: [], - lineHeights: [], - radii: [], - space: [] - } - const seenNames = new Set() - - const registerName = (name: string, property: Property): string => { - if (seenNames.has(name)) { - throw new Error( - `Duplicate native token name "${name}" (from ${property.attributes.category}.${property.attributes.type}.${property.attributes.item || ''}) — native constant names must be unique` - ) - } - seenNames.add(name) - return name - } - - properties.forEach((property) => { - if ( - !shouldIncludeProperty( - property as unknown as Parameters[0], - config - ) - ) - return - - const { category, type, item, subitem } = property.attributes - - if (category === 'color') { - const color = parseColor(String(property.value)) - if (!color) { - throw new Error( - `Unable to convert colour token "${property.name}" with value "${property.value}" for native output` - ) - } - tokens.colors.push({ - name: registerName(camelName(type, item, subitem || ''), property), - color - }) - return - } - - if (category !== 'size') return - - // font.families (web font stacks), size.breakpoint (windowed-web concern), - // size.size and effects don't translate to native — deliberately excluded - if (type !== 'font' && type !== 'leading' && type !== 'radii' && type !== 'space') - return - - if (!isPlainNumber(property.value)) { - throw new Error( - `Unable to convert size token "${property.name}" with value "${property.value}" for native output — expected a unitless number` - ) - } - const numValue = - typeof property.value === 'number' - ? property.value - : parseFloat(String(property.value)) - - const name = registerName(camelName(type, item), property) - - if (type === 'leading') { - // unitless line-height multipliers, emitted as-is - tokens.lineHeights.push({ name, value: numValue }) - } else { - const target = - type === 'font' ? tokens.fontSizes : type === 'radii' ? tokens.radii : tokens.space - target.push({ name, value: round(numValue * REM_TO_PT) }) - } - }) - - return tokens -} - -export const formatNumber = (value: number): string => String(round(value)) 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/swift-theme.ts b/src/formatters/swift-theme.ts deleted file mode 100644 index 5c18984..0000000 --- a/src/formatters/swift-theme.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - collectNativeTokens, - formatNumber, - type Dictionary, - type Rgba, - type ColorToken, - type NumberToken -} from './native-tokens.ts' - -const swiftColor = ({ red, green, blue, alpha }: Rgba): string => - `Color(red: ${formatNumber(red)}, green: ${formatNumber(green)}, blue: ${formatNumber(blue)}, opacity: ${formatNumber(alpha)})` - -const colorSection = (title: string, tokens: ColorToken[]): string[] => { - if (tokens.length === 0) return [] - return [ - ` // MARK: - ${title}`, - '', - ...tokens.map( - ({ name, color }) => ` public static let ${name} = ${swiftColor(color)}` - ) - ] -} - -const numberSection = (title: string, tokens: NumberToken[]): string[] => { - if (tokens.length === 0) return [] - return [ - ` // MARK: - ${title}`, - '', - ...tokens.map( - ({ name, value }) => - ` public static let ${name}: CGFloat = ${formatNumber(value)}` - ) - ] -} - -const formatter = (dictionary: Dictionary): string => { - const tokens = collectNativeTokens(dictionary) - - const sections = [ - colorSection('Colors', tokens.colors), - numberSection('Font sizes (pt)', tokens.fontSizes), - numberSection('Line heights (multipliers)', tokens.lineHeights), - numberSection('Radii (pt)', tokens.radii), - numberSection('Spacing (pt)', tokens.space) - ].filter((section) => section.length > 0) - - const body = sections.map((section) => section.join('\n')).join('\n\n') - - return `// Do not edit directly — generated by @atom-learning/theme - -import SwiftUI - -public enum ThemeTokens { -${body} -} -` -} - -formatter.nested = true - -export default formatter -export { setBuildConfig } from './shared.ts' 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 9827b4d..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/native/camel', + 'color/ColorSwiftUI', + 'size/swift/remToCGFloat' +] +const COMPOSE_TRANSFORMS = [ 'attribute/cti', - 'name/pascal', - 'size/rem', - 'color/hsl' + 'name/native/camel', + 'color/composeColor', + 'size/compose/remToSp', + 'size/compose/remToDp' ] -const CSS_TRANSFORMS = ['attribute/cti', 'color/hsl'] const createPlatform = ( transforms: string[], @@ -64,16 +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: createPlatform( - COMMON_TRANSFORMS, - name ? `theme-${name}.swift` : 'theme-base.swift', - 'custom/format/swift-theme' - ), - kotlin: createPlatform( - COMMON_TRANSFORMS, - name ? `theme-${name}.kt` : 'theme-base.kt', - 'custom/format/kotlin-theme' - ), + 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/native.test.ts b/test/native.test.ts index 0de7bf4..ce4eb8b 100644 --- a/test/native.test.ts +++ b/test/native.test.ts @@ -11,7 +11,7 @@ const swiftConstantNames = (source: string): string[] => [...source.matchAll(/public static let (\w+)/g)].map((match) => match[1]) const kotlinConstantNames = (source: string): string[] => - [...source.matchAll(/(?:const )?val (\w+)/g)].map((match) => match[1]) + [...source.matchAll(/\bval (\w+)/g)].map((match) => match[1]) describe('Native Token Outputs', () => { describe('Swift Output', () => { @@ -45,29 +45,29 @@ describe('Native Token Outputs', () => { const swift = readOutput('theme-base.swift') expect(swift).toContain( - 'public static let black = Color(red: 0, green: 0, blue: 0, opacity: 1)' + '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, green: 1, blue: 1, opacity: 1)' + '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.96, green: 0.96, blue: 0.96, opacity: 1)' + '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.2, green: 0.2, blue: 0.2, opacity: 0.1)' + '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') - expect(swift).toContain('public static let radiiMd: CGFloat = 8') - expect(swift).toContain('public static let space: CGFloat = 4') - // leading multipliers are emitted as-is - expect(swift).toContain('public static let leadingMd: CGFloat = 1.5') + 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', () => { @@ -103,9 +103,9 @@ describe('Native Token Outputs', () => { 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.dp') - expect(kotlin).toContain('import androidx.compose.ui.unit.sp') + expect(kotlin).toContain('import androidx.compose.ui.unit.*') expect(kotlin).toContain('object ThemeTokens {') expect(kotlinConstantNames(kotlin).length).toBeGreaterThan(0) }) @@ -128,21 +128,21 @@ describe('Native Token Outputs', () => { 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)') + 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)') + 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)') + expect(kotlin).toContain('val alpha100 = Color(0x1a333333)') }) - it('base should convert sizes to sp/dp and leading to Float', () => { + it('base should convert sizes to sp/dp and leave leading unitless', () => { const kotlin = readOutput('theme-base.kt') - expect(kotlin).toContain('val fontSm = 14.sp') - expect(kotlin).toContain('val radiiMd = 8.dp') - expect(kotlin).toContain('val space = 4.dp') - expect(kotlin).toContain('const val leadingMd = 1.5f') + 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', () => { @@ -157,7 +157,7 @@ describe('Native Token Outputs', () => { const kotlin = readOutput('theme-quest-reports.kt') // 0.625rem × 16 - expect(kotlin).toContain('val fontXs = 10.sp') + expect(kotlin).toContain('val fontXs = 10.00.sp') }) }) }) From 1acc8909188547940754d18bc87c99d1b6103638 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 12:36:22 +0100 Subject: [PATCH 3/6] test: add CI, completeness, value fidelity, assets and native compile coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI workflow runs build + tests + tsc on every PR; a macOS job installs swiftc/kotlinc so the native compile checks actually execute - pretest hooks build before testing, so the suite can no longer pass against stale lib/ output - completeness tests reconcile the token sources against every output, catching filter/naming regressions that silently drop tokens - value fidelity tests assert exact shadow, breakpoint and font-stack values and check colours agree across the JS, Swift and Kotlin outputs by re-deriving sRGB from the source hsl - native-compile tests compile the generated files with swiftc and kotlinc (against minimal Compose stubs), skipping when absent - assets tests cover every export target, typesVersions path and copied asset — previously untested Documents one pre-existing bug as an expected failure: the CSS formatters emit --color-coolGrey-100 while the JS properties map declares --color-cool-grey-100, so that var() resolves to nothing. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 41 +++++++ README.md | 15 +++ package.json | 4 +- test/assets.test.ts | 120 ++++++++++++++++++++ test/completeness.test.ts | 220 ++++++++++++++++++++++++++++++++++++ test/native-compile.test.ts | 75 ++++++++++++ test/values.test.ts | 217 +++++++++++++++++++++++++++++++++++ 7 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/assets.test.ts create mode 100644 test/completeness.test.ts create mode 100644 test/native-compile.test.ts create mode 100644 test/values.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d46492 --- /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: 20 + 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: 20 + 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 167f13c..5c5971d 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,18 @@ Values are converted at build time by style-dictionary's built-in transforms (`c 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; the macOS CI job installs both so they always execute there +- `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..6b658c7 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" }, 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..c42edb7 --- /dev/null +++ b/test/native-compile.test.ts @@ -0,0 +1,75 @@ +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) + +// 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() + }) + }) +}) + +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 }) + } + }) + }) +}) diff --git a/test/values.test.ts b/test/values.test.ts new file mode 100644 index 0000000..fe023fc --- /dev/null +++ b/test/values.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const readOutput = (file: string): string => + fs.readFileSync(path.join(process.cwd(), 'lib', file), 'utf-8') + +const themes = ['base', 'atom', 'quest', 'quest-reports'] as const + +// hsl -> sRGB, mirroring what the native transforms do, so the assertions below +// are derived from the source tokens rather than copied from the output +const hslToRgb = (h: number, s: number, l: number): [number, number, number] => { + const chroma = (1 - Math.abs(2 * l - 1)) * s + const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - chroma / 2 + const [r, g, b] = + h < 60 + ? [chroma, x, 0] + : h < 120 + ? [x, chroma, 0] + : h < 180 + ? [0, chroma, x] + : h < 240 + ? [0, x, chroma] + : h < 300 + ? [x, 0, chroma] + : [chroma, 0, x] + return [r + m, g + m, b + m] +} + +const parseHsl = (value: string): { rgb: [number, number, number]; alpha: number } => { + const match = value.match( + /hsla?\(\s*(-?[\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\)/ + ) + if (!match) throw new Error(`not an hsl value: ${value}`) + return { + rgb: hslToRgb( + parseFloat(match[1]), + parseFloat(match[2]) / 100, + parseFloat(match[3]) / 100 + ), + alpha: match[4] === undefined ? 1 : parseFloat(match[4]) + } +} + +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 + + // hex tokens (#000/#fff) are exact; hsl tokens are compared after conversion + if (value.startsWith('#')) { + const hex = value.slice(1) + const expand = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex + const channels = [0, 2, 4].map( + (offset) => parseInt(expand.slice(offset, offset + 2), 16) / 255 + ) + channels.forEach((channel, index) => { + expect(parseFloat(match[index + 1])).toBeCloseTo(channel, 2) + }) + } else { + const { rgb, alpha } = parseHsl(value) + rgb.forEach((channel, index) => { + expect( + parseFloat(match[index + 1]), + `${name} channel ${index} should match ${value}` + ).toBeCloseTo(channel, 2) + }) + expect(parseFloat(match[4])).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 + + const alpha = parseInt(argb.slice(0, 2), 16) / 255 + const channels = [2, 4, 6].map( + (offset) => parseInt(argb.slice(offset, offset + 2), 16) / 255 + ) + + 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)})` + ) + }) + }) + }) + }) +}) From e93d9029bd8bce29df011e4afe3463f9b46f3079 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 12:37:47 +0100 Subject: [PATCH 4/6] ci: pin Node 22 to match style-dictionary's engine requirement style-dictionary 5.1.1 requires node >=22, and the build relies on native TypeScript support, so Node 20 failed at install. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d46492..cb54fe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: yarn - run: yarn install --frozen-lockfile - run: yarn validate:types @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: yarn - uses: actions/setup-java@v4 with: From 6bc9021c9a105f0fccba02ec6d730dacdd48db81 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 12:40:46 +0100 Subject: [PATCH 5/6] test: give the native compile checks an explicit timeout GitHub's ubuntu-latest image ships both swiftc and kotlinc, so these tests run there rather than skipping, and invoking a real compiler exceeds vitest's 5s default. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- test/native-compile.test.ts | 76 +++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5c5971d..3982f2e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The suite is output-focused — it builds the package and inspects the real arti - `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; the macOS CI job installs both so they always execute there +- `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/test/native-compile.test.ts b/test/native-compile.test.ts index c42edb7..81c42fc 100644 --- a/test/native-compile.test.ts +++ b/test/native-compile.test.ts @@ -11,6 +11,10 @@ const hasTool = (tool: string): boolean => 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 @@ -33,43 +37,51 @@ 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() - }) + 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) + 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 }) - } - }) + 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 + ) }) }) From ecb7af5389cfbdfce630998713342312290e5f72 Mon Sep 17 00:00:00 2001 From: Thomas Digby Date: Thu, 30 Jul 2026 13:52:33 +0100 Subject: [PATCH 6/6] test: parse colours with color2k instead of hand-rolled conversion Replaces the bespoke hsl->rgb maths and the separate hex branch with color2k's parseToRgba, which handles hex, hsl and hsla uniformly. Also routes the Compose AARRGGBB comparison through the same parser. color2k is deliberately not tinycolor2, which style-dictionary converts with, so the assertions stay an independent check rather than a restatement of the build's own maths. Co-Authored-By: Claude Fable 5 --- package.json | 1 + test/values.test.ts | 82 +++++++++++++-------------------------------- yarn.lock | 5 +++ 3 files changed, 30 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 6b658c7..c8f8106 100644 --- a/package.json +++ b/package.json @@ -22,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/test/values.test.ts b/test/values.test.ts index fe023fc..b6ce60d 100644 --- a/test/values.test.ts +++ b/test/values.test.ts @@ -1,46 +1,22 @@ 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 -// hsl -> sRGB, mirroring what the native transforms do, so the assertions below -// are derived from the source tokens rather than copied from the output -const hslToRgb = (h: number, s: number, l: number): [number, number, number] => { - const chroma = (1 - Math.abs(2 * l - 1)) * s - const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1)) - const m = l - chroma / 2 - const [r, g, b] = - h < 60 - ? [chroma, x, 0] - : h < 120 - ? [x, chroma, 0] - : h < 180 - ? [0, chroma, x] - : h < 240 - ? [0, x, chroma] - : h < 300 - ? [x, 0, chroma] - : [chroma, 0, x] - return [r + m, g + m, b + m] -} - -const parseHsl = (value: string): { rgb: [number, number, number]; alpha: number } => { - const match = value.match( - /hsla?\(\s*(-?[\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\)/ - ) - if (!match) throw new Error(`not an hsl value: ${value}`) - return { - rgb: hslToRgb( - parseFloat(match[1]), - parseFloat(match[2]) / 100, - parseFloat(match[3]) / 100 - ), - alpha: match[4] === undefined ? 1 : parseFloat(match[4]) - } +// 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', () => { @@ -144,26 +120,17 @@ describe('Value Fidelity', () => { expect(match, `${name} missing from theme-${themeName}.swift`).not.toBeNull() if (!match) return - // hex tokens (#000/#fff) are exact; hsl tokens are compared after conversion - if (value.startsWith('#')) { - const hex = value.slice(1) - const expand = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex - const channels = [0, 2, 4].map( - (offset) => parseInt(expand.slice(offset, offset + 2), 16) / 255 - ) - channels.forEach((channel, index) => { - expect(parseFloat(match[index + 1])).toBeCloseTo(channel, 2) - }) - } else { - const { rgb, alpha } = parseHsl(value) - rgb.forEach((channel, index) => { - expect( - parseFloat(match[index + 1]), - `${name} channel ${index} should match ${value}` - ).toBeCloseTo(channel, 2) - }) - expect(parseFloat(match[4])).toBeCloseTo(alpha, 3) - } + 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 + ) }) }) @@ -183,10 +150,9 @@ describe('Value Fidelity', () => { expect(match, `${name} missing from Swift output`).not.toBeNull() if (!match) return - const alpha = parseInt(argb.slice(0, 2), 16) / 255 - const channels = [2, 4, 6].map( - (offset) => parseInt(argb.slice(offset, offset + 2), 16) / 255 - ) + // 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( 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"