diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index a2f4e5f..03e4213 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -227,9 +227,36 @@ exists to dodge a role/ramp name collision). `style-dictionary` is an OPTIONAL `peerDependency`: the token exports (`.`, `./tokens.css`, `./tailwind-preset.css`) never touch it; only the build-time brand pipeline does, so a consumer pays for it only if they use it. The pipeline source files (`brand.mjs`, `cli.mjs`, `contrast.mjs`, -`style-dictionary.config.mjs`) and the `examples/sunset/` brand ship in the package `files`. A quick -**runtime** path (an app redefining `--color-*` in its own `:root`/`.dark`) is documented for cases -that don't need the guard. +`style-dictionary.config.mjs`) and the `examples/sunset/` + `examples/thoughtstream/` brands ship in +the package `files`. A quick **runtime** path (an app redefining `--color-*` in its own +`:root`/`.dark`) is documented for cases that don't need the guard. + +### iOS / Swift export (spec 0032) + +The native target this pipeline always anticipated ("just another Style Dictionary platform - no +token rewrite"). `buildSwift()` (`packages/roots/swift.mjs`, exported at `@rogueoak/roots/swift`, +with a `roots-swift` bin in `cli-swift.mjs`) compiles a brand into one `Tokens.swift`: + +- **Colours** - two throwaway SD instances over `[brand primitives + brand semantic]` and + `[brand primitives + brand semantic.dark]`, run through the SAME `cssTransforms` with + `outputReferences: false`, so every semantic role resolves to a concrete hex per theme. The two hex + maps become one `Color(light:dark:)` per role. A brand primitive ramp is filtered out (only roles + emit); a role the dark file omits keeps its light hex (the brand pipeline's light-is-dark-fallback + rule). +- **Non-colours** - a third SD instance over Canopy's OWN `space.json` / `radius.json` / + `typography.json` / `typography-roles.json` (NOT the brand), so every brand shares one spacing / + radius / type scale; only colours are brand-specific. Dimensions convert **rem -> points at + 16pt/rem** (1rem = 16px = 16pt); a raw `px` value (`radius.full`'s 9999pt pill) is kept as-is; a + unitless `leading` passes through as a multiplier. + +The output is three **caseless enums** (`CanopyColor` / `CanopySpacing` / `CanopyRadius` / +`CanopyFont` - the idiomatic Swift namespace with no instances, cleaner than a `Color`/`Font` +extension that would pollute completion) plus a generated `Color(light:dark:)` initializer backed by +a dynamic `UIColor { traits in ... }` provider (guarded by `#if canImport(UIKit)`), carrying a +"Do not edit - generated by Style Dictionary" header like the web outputs. It is generated **on +demand** (NOT part of `build.mjs` / the web `pnpm build`) and lands at `dist//Tokens.swift`, +so the web build is unchanged and the Swift file is a deliberate export. `swift.mjs` reads Canopy's +`tokens/*.json` at runtime, so `tokens/` joins `dist`/the pipeline files in the package `files`. ### Naming convention diff --git a/docs/overview/features.md b/docs/overview/features.md index 996c624..e0587c2 100644 --- a/docs/overview/features.md +++ b/docs/overview/features.md @@ -667,6 +667,30 @@ token concern") is now a real, tested pipeline. - **Runtime path** documented too - an app can redefine `--color-*` in its own `:root`/`.dark` for quick cases that do not need the build-time guard. +## iOS / Swift token export + thoughtstream brand (0032) + +A native SwiftUI app consumes Canopy tokens with no re-authoring - the Swift target the architecture +always anticipated ("just another Style Dictionary platform - no token rewrite"). + +- **`roots-swift` / `buildSwift()`** (`packages/roots/swift.mjs`, `@rogueoak/roots/swift`, bin + `roots-swift`) generates one `Tokens.swift` for a brand from the SAME config `roots-brand` reads. + It reuses the core transforms: two throwaway Style Dictionary instances resolve the brand's light + and dark semantics to concrete hex (`outputReferences: false`), and one more resolves Canopy's own + spacing / radius / type scale. +- **`Tokens.swift` shape** - three caseless enums (a Swift namespace that can't be instantiated): + `CanopyColor` (every semantic role as a `Color(light:dark:)` that adapts to the color scheme via a + generated dynamic-`UIColor` initializer), `CanopySpacing` / `CanopyRadius` (`CGFloat` points), and + `CanopyFont` (type sizes as points + `Font` helpers, line-height multipliers). rem converts to + points at 16pt/rem; a raw px pill (`radius.full`) is kept as-is. Spacing/radius/type come from + Canopy core (shared across brands); only colours are brand-specific. Generated on demand (not part + of the web `pnpm build`), landing at `dist//Tokens.swift`. +- **thoughtstream brand (River Mist)** (`packages/roots/examples/thoughtstream/`) - the first REAL + brand (vs the sunset demo): a calm slate-teal water palette (`slate` primary, `current` secondary, + `tide` accent, `mist` neutral, plus cool-tuned status ramps), mapping every Canopy role in light + + dark. Builds AA-clean through `buildBrand()` and feeds the Swift export. Tests assert the brand + builds AA-clean and the emitter's structure (header, a role's light+dark hex, spacing/radius/font + constants, valid Swift). + Not yet built: more **Branches** (the layer is open - Dialog · TopNav · SideNav · Combobox are live; -DataTable to come) and more Twigs as needed, then **Boughs** (templates), and the native Swift token -target. +DataTable to come) and more Twigs as needed, then **Boughs** (templates), and an Xcode/Swift Package +wrapper around the generated `Tokens.swift`. diff --git a/docs/specs/0032-roots-ios-swift-tokens.md b/docs/specs/0032-roots-ios-swift-tokens.md new file mode 100644 index 0000000..bd1cca9 --- /dev/null +++ b/docs/specs/0032-roots-ios-swift-tokens.md @@ -0,0 +1,88 @@ +# 0032 - Roots iOS/Swift token export + thoughtstream brand + +## Problem + +Canopy tokens today ship three web outputs (CSS vars, typed TS, Tailwind preset). A native +SwiftUI app (Thought Stream) needs the SAME tokens without re-authoring them by hand. The +architecture already anticipates this: the Style Dictionary pipeline is "the seam that lets a +native (Swift) target be added later as just another platform - no token rewrite." This spec +delivers that Swift target, plus a real brand to drive it (thoughtstream / River Mist), proving +the whole path end to end. + +Who it is for: a native iOS/SwiftUI app that wants Canopy's colours (light + dark), spacing, +radius, and type scale as first-class Swift values. + +## Outcome + +- A new brand `examples/thoughtstream/` (River Mist palette) authored as DTCG token files, + mirroring `examples/sunset/` exactly (primitive.json, semantic.json, semantic.dark.json, + brand.config.json, README.md). It builds AA-clean through `buildBrand()`. +- A `swift.mjs` module (plus a `roots-swift` bin) that generates one `Tokens.swift` for a brand: + - Semantic COLORS resolved to literal hex for BOTH light and dark, emitted as SwiftUI `Color`s + that adapt to the color scheme. + - Core non-color tokens from Canopy's OWN sources (spacing, radius, type sizes / line-heights) + as `CGFloat` / `Font` helpers. + - A "Do not edit - generated by Style Dictionary" header, matching the other generated files. +- Vitest tests proving the brand builds AA-clean and the Swift emitter produces the expected + structure. `pnpm test` + `pnpm lint` stay green. + +## Scope + +In: + +- The thoughtstream brand token files. +- `swift.mjs` (`generateSwift()` / `buildSwift()`), a `cli-swift.mjs` `roots-swift` bin, tests, + README + overview doc updates. + +Out: + +- Adding Swift as a platform to the CORE `build.mjs` web pipeline (the Swift output is brand- + scoped and generated on demand, not part of the default `pnpm build`). +- A Swift Package / Xcode project. This ships a single generated `.swift` file; wiring it into an + app is the app's job (Thought Stream lives in a separate repo). +- Shadows, motion, and font FAMILY resolution beyond names. + +## Approach + +**Brand.** Copy sunset's structure and role set verbatim; only the ramp hexes and mappings +change. River Mist is a cool slate-teal water palette: a `mist` neutral (blue-grey with a hint of +teal), a `slate` primary teal, a `current` secondary teal, a `tide` accent, and cool-tuned +functional ramps (`positive`/`caution`/`critical`/`informative`). Iterate ramp steps against the +AA guard until `buildBrand()` succeeds. + +**Swift emitter.** Reuse the existing Style Dictionary machinery rather than re-resolving tokens +by hand: + +- Build TWO throwaway SD instances over `[brand primitives + brand semantic]` and + `[brand primitives + brand semantic.dark]` with `outputReferences: false`, so every semantic + role resolves to a concrete hex. Read the two hex maps, emit one `Color(light:dark:)` per role. +- Build a THIRD SD instance over Canopy's own `tokens/space.json`, `radius.json`, + `typography.json`, `typography-roles.json` to resolve spacing / radius / type sizes to literal + numbers. + +Key decisions / trade-offs: + +- **rem -> pt at 16pt/rem.** iOS points are the natural analogue of CSS px, and Canopy authors + dimensions in rem (1rem = 16px). Emit `rem * 16` as a `CGFloat` point value. Documented in the + generated header and the README. +- **Namespace: caseless enums.** `enum CanopyColor`, `enum CanopySpacing`, `enum CanopyRadius`, + `enum CanopyFont` - static members on caseless enums, the idiomatic Swift "namespace with no + instances" (cleaner than a `Color` extension that pollutes the global `Color.` completion, and + can't be instantiated). A `Color(light:dark:)` initializer helper (backed by a dynamic + `UIColor { traits in ... }` provider) is generated so colours adapt to light/dark at runtime. +- **Output lands at `dist//Tokens.swift`** (e.g. `dist/thoughtstream/Tokens.swift`), + generated on demand by `roots-swift`, NOT part of the default web `pnpm build` (keeps the web + build unchanged and the Swift file a deliberate export). + +## Acceptance + +- [ ] `examples/thoughtstream/` mirrors sunset's files; `roots-swift`/`buildBrand` on it succeeds + and reports inherited roles. +- [ ] `node cli.mjs examples/thoughtstream/brand.config.json` builds AA-clean (no throw). +- [ ] `roots-swift examples/thoughtstream/brand.config.json` writes `dist/thoughtstream/Tokens.swift`. +- [ ] `Tokens.swift` has the generated-file header, a `Color(light:dark:)` helper, `primary` + (and every role) with both a light and a dark hex, and spacing + radius + font constants. +- [ ] Vitest: thoughtstream builds AA-clean; the Swift emitter's structure is asserted (header, + a known role's light+dark hex, spacing/radius constants, valid-looking Swift). +- [ ] `pnpm test` + `pnpm lint` green in `packages/roots`. +- [ ] README (Swift export section) + `docs/overview/` updated to match. diff --git a/packages/roots/README.md b/packages/roots/README.md index c60fc5b..6b9255d 100644 --- a/packages/roots/README.md +++ b/packages/roots/README.md @@ -109,8 +109,9 @@ inherits its Canopy default until you choose to map it. (Its default pairs are a by Canopy's own build.) A runnable example brand lives in [`examples/sunset/`](./examples/sunset) - copy it as a starting -point. `style-dictionary` is an OPTIONAL peer dependency, needed only if you run the brand pipeline; -the token exports do not require it. +point. A real brand, [`examples/thoughtstream/`](./examples/thoughtstream) (River Mist, a +slate-teal water palette), also drives the iOS/Swift export below. `style-dictionary` is an OPTIONAL +peer dependency, needed only if you run the brand pipeline; the token exports do not require it. ### Runtime path (quick cases) @@ -133,6 +134,57 @@ vars in its own CSS after importing `tokens.css` - Canopy reads them at runtime: Prefer the build-time pipeline when you want the AA guarantee - including for a partial brand, whose overrides are still checked against the Canopy defaults they inherit. +## iOS / Swift export + +A native SwiftUI app can consume Canopy tokens with no re-authoring: `roots-swift` generates one +`Tokens.swift` for a brand. It takes the SAME brand config `roots-brand` reads: + +```bash +npx roots-swift examples/thoughtstream/brand.config.json +# -> writes dist/thoughtstream/Tokens.swift (39 semantic colors, light + dark) +``` + +Or from code: + +```js +import { buildSwift } from '@rogueoak/roots/swift'; + +await buildSwift({ + name: 'thoughtstream', + primitives: 'examples/thoughtstream/primitive.json', + semantic: 'examples/thoughtstream/semantic.json', + semanticDark: 'examples/thoughtstream/semantic.dark.json', + outFile: 'dist/thoughtstream/Tokens.swift', +}); +``` + +`Tokens.swift` holds three things, each in a caseless enum (a Swift namespace that cannot be +instantiated, so it does not pollute `Color.` / `Font.` completion): + +- `CanopyColor` - every semantic role resolved to a light AND dark hex, emitted as a + `Color(light:dark:)` that adapts to the color scheme. A generated `Color(light:dark:)` + initializer (backed by a dynamic `UIColor { traits in ... }` provider on platforms with UIKit) + makes the switch happen at runtime. +- `CanopySpacing` / `CanopyRadius` - Canopy's OWN spacing and radius scales as `CGFloat` points. +- `CanopyFont` - Canopy's type sizes as `CGFloat` points + `Font` helpers, and line-height + multipliers. + +```swift +Text("Thought Stream") + .font(CanopyFont.sizeX2xlFont()) + .foregroundStyle(CanopyColor.text) + .padding(CanopySpacing.x4) // 16pt + .background(CanopyColor.surface) + .clipShape(RoundedRectangle(cornerRadius: CanopyRadius.md)) // 8pt +``` + +Notes: dimensions convert **rem -> points at 16pt/rem** (1rem = 16px = 16pt); a raw `px` value +(`radius.full`'s 9999pt pill) is emitted as-is. Spacing / radius / type come from Canopy's core +tokens, not the brand, so every brand shares one scale; only colours are brand-specific. The Swift +file is generated **on demand** (it is not part of the web `pnpm build`) and lands at +`dist//Tokens.swift` unless you pass `--out`. `style-dictionary` (the optional peer dep) is +required to run this, like the brand pipeline. + ## Fonts Roots ships the family _names_ only (no CDN). Install the open-licensed diff --git a/packages/roots/cli-swift.mjs b/packages/roots/cli-swift.mjs new file mode 100644 index 0000000..c54cadf --- /dev/null +++ b/packages/roots/cli-swift.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * `roots-swift` CLI (spec 0032) - a thin wrapper over `buildSwift()`. + * + * Usage: + * roots-swift [config.json] [--out ] + * + * Reuses the SAME brand config `roots-brand` reads: + * { + * "name": "thoughtstream", + * "primitives": "primitive.json", // string or string[] + * "semantic": "semantic.json", // light role mapping + * "semanticDark": "semantic.dark.json" // dark role mapping + * } + * + * The Swift file lands at `dist//Tokens.swift` in the roots package by default (a deliberate, + * on-demand export - it is NOT part of the web `pnpm build`). Pass `--out` to redirect it. Relative + * paths in the config resolve RELATIVE TO THE CONFIG FILE, so a brand folder is portable. + */ +import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildSwift, slugify } from './swift.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +let configArg; +let outOverride; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--out' || a === '-o') outOverride = argv[++i]; + else if (a === '--help' || a === '-h') { + console.log('Usage: roots-swift [config.json] [--out ]'); + process.exit(0); + } else if (!a.startsWith('-')) configArg = a; +} + +const configPath = resolve(process.cwd(), configArg ?? 'brand.config.json'); +const configDir = dirname(configPath); +const rel = (p) => (isAbsolute(p) ? p : resolve(configDir, p)); + +const run = async () => { + let config; + try { + config = JSON.parse(readFileSync(configPath, 'utf8')); + } catch (err) { + throw new Error(`Cannot read brand config at ${configPath}: ${err.message}`); + } + const primitives = ( + Array.isArray(config.primitives) ? config.primitives : [config.primitives] + ).map(rel); + const slug = slugify(config.name) || 'brand'; + const defaultOut = resolve(here, 'dist', slug, 'Tokens.swift'); + const { outFile, roles } = await buildSwift({ + name: config.name, + primitives, + semantic: rel(config.semantic), + semanticDark: rel(config.semanticDark), + outFile: outOverride ? rel(outOverride) : defaultOut, + }); + console.log(`roots-swift: wrote ${outFile} (${roles.length} semantic colors, light + dark).`); +}; + +run().catch((err) => { + console.error(`roots-swift: ${err.message}`); + process.exit(1); +}); diff --git a/packages/roots/examples/thoughtstream/README.md b/packages/roots/examples/thoughtstream/README.md new file mode 100644 index 0000000..835ab54 --- /dev/null +++ b/packages/roots/examples/thoughtstream/README.md @@ -0,0 +1,65 @@ +# thoughtstream - River Mist brand + +A real Canopy brand for the `@rogueoak/roots` brand pipeline (spec 0028) and the iOS/Swift export +(spec 0032). River Mist is a calm, muted slate-teal water palette - cool blue-grey neutrals with a +hint of teal, a slate-teal primary, and a brighter aqua accent. It powers the Thought Stream native +app. + +It defines its OWN primitive ramps (`slate` primary, `current` secondary, `tide` accent, `mist` +neutral, plus status ramps `positive`/`caution`/`critical`/`informative` tuned cool) and maps +Canopy's semantic roles onto them for light (`semantic.json`) and dark (`semantic.dark.json`). + +## Build the CSS brand + +```bash +# from this folder, with @rogueoak/roots installed +npx roots-brand brand.config.json +# -> writes thoughtstream.css: a :root { } block (brand primitives + light roles) + a .dark { } block +``` + +Or from code: + +```js +import { buildBrand } from '@rogueoak/roots/brand'; + +await buildBrand({ + name: 'thoughtstream', + primitives: 'primitive.json', + semantic: 'semantic.json', + semanticDark: 'semantic.dark.json', + outFile: 'thoughtstream.css', +}); +``` + +Import `thoughtstream.css` AFTER Canopy's tokens - it overrides the semantic vars by cascade: + +```css +@import '@rogueoak/roots/tokens.css'; +@import './thoughtstream.css'; +``` + +## Build the Swift tokens (iOS/SwiftUI) + +The same brand feeds a native SwiftUI target (spec 0032): + +```bash +npx roots-swift brand.config.json +# -> writes ../../dist/thoughtstream/Tokens.swift +``` + +`Tokens.swift` holds every semantic colour resolved to a light + dark hex (adapting to the color +scheme), plus Canopy's spacing / radius / type sizes as `CGFloat` and `Font` helpers. See the roots +README "iOS / Swift export" section for the API shape. + +## River Mist ramps (anchors) + +- **slate** (primary slate-teal): `slate-600 #42666f` (light primary), `slate-300 #8fb6bb` (dark + primary). +- **mist** (neutral): `mist-50 #f3f6f6` (bg light), `mist-900 #1e3238` (text light), `mist-950 +#0e1618` (bg dark), `mist-100 #e6eded` (text dark). +- **current** (secondary teal): `current-600 #356c73` .. `current-300 #72b6b8`. +- **tide** (accent aqua): `tide-400 #33a89f` (accent fill). + +The build FAILS if any role/state pair breaks WCAG AA in either theme, if a dark override equals its +light value, or if a dark override is a flat hex instead of a primitive reference - so every step +above is AA-verified. diff --git a/packages/roots/examples/thoughtstream/brand.config.json b/packages/roots/examples/thoughtstream/brand.config.json new file mode 100644 index 0000000..9f333e8 --- /dev/null +++ b/packages/roots/examples/thoughtstream/brand.config.json @@ -0,0 +1,8 @@ +{ + "name": "thoughtstream", + "primitives": "primitive.json", + "semantic": "semantic.json", + "semanticDark": "semantic.dark.json", + "outFile": "thoughtstream.css", + "scope": null +} diff --git a/packages/roots/examples/thoughtstream/primitive.json b/packages/roots/examples/thoughtstream/primitive.json new file mode 100644 index 0000000..135af2c --- /dev/null +++ b/packages/roots/examples/thoughtstream/primitive.json @@ -0,0 +1,118 @@ +{ + "color": { + "base": { + "white": { + "$value": "#ffffff", + "$type": "color" + } + }, + "slate": { + "50": { "$value": "#eef4f5", "$type": "color" }, + "100": { "$value": "#dbe8ea", "$type": "color" }, + "200": { "$value": "#b8d2d5", "$type": "color" }, + "300": { "$value": "#8fb6bb", "$type": "color" }, + "400": { "$value": "#63959c", "$type": "color" }, + "500": { "$value": "#4a7981", "$type": "color" }, + "600": { "$value": "#42666f", "$type": "color" }, + "700": { "$value": "#375258", "$type": "color" }, + "800": { "$value": "#2f4448", "$type": "color" }, + "900": { "$value": "#28393c", "$type": "color" }, + "950": { "$value": "#141f22", "$type": "color" } + }, + "current": { + "50": { "$value": "#e9f4f4", "$type": "color" }, + "100": { "$value": "#d0e8e8", "$type": "color" }, + "200": { "$value": "#a5d2d3", "$type": "color" }, + "300": { "$value": "#72b6b8", "$type": "color" }, + "400": { "$value": "#4a999c", "$type": "color" }, + "500": { "$value": "#3c838b", "$type": "color" }, + "600": { "$value": "#356c73", "$type": "color" }, + "700": { "$value": "#2e585d", "$type": "color" }, + "800": { "$value": "#294749", "$type": "color" }, + "900": { "$value": "#243b3d", "$type": "color" }, + "950": { "$value": "#122123", "$type": "color" } + }, + "tide": { + "50": { "$value": "#e6f6f4", "$type": "color" }, + "100": { "$value": "#c6ece8", "$type": "color" }, + "200": { "$value": "#93d9d2", "$type": "color" }, + "300": { "$value": "#5cc2b9", "$type": "color" }, + "400": { "$value": "#33a89f", "$type": "color" }, + "500": { "$value": "#1f8f87", "$type": "color" }, + "600": { "$value": "#1a7770", "$type": "color" }, + "700": { "$value": "#195f5a", "$type": "color" }, + "800": { "$value": "#194c48", "$type": "color" }, + "900": { "$value": "#173f3c", "$type": "color" }, + "950": { "$value": "#082322", "$type": "color" } + }, + "mist": { + "50": { "$value": "#f3f6f6", "$type": "color" }, + "100": { "$value": "#e6eded", "$type": "color" }, + "200": { "$value": "#cbd9da", "$type": "color" }, + "300": { "$value": "#a7bdbf", "$type": "color" }, + "400": { "$value": "#7c989b", "$type": "color" }, + "500": { "$value": "#5c797c", "$type": "color" }, + "600": { "$value": "#476063", "$type": "color" }, + "700": { "$value": "#3a4e51", "$type": "color" }, + "800": { "$value": "#2c3d40", "$type": "color" }, + "900": { "$value": "#1e3238", "$type": "color" }, + "950": { "$value": "#0e1618", "$type": "color" } + }, + "canvas": { + "light": { "$value": "#fbfdfd", "$type": "color" }, + "dark": { "$value": "#18262a", "$type": "color" } + }, + "positive": { + "50": { "$value": "#e8f2ec", "$type": "color" }, + "100": { "$value": "#d1e5d9", "$type": "color" }, + "200": { "$value": "#a6cdb5", "$type": "color" }, + "300": { "$value": "#79b491", "$type": "color" }, + "400": { "$value": "#549b73", "$type": "color" }, + "500": { "$value": "#41855d", "$type": "color" }, + "600": { "$value": "#367049", "$type": "color" }, + "700": { "$value": "#2c5a3b", "$type": "color" }, + "800": { "$value": "#26492f", "$type": "color" }, + "900": { "$value": "#1f3b28", "$type": "color" }, + "950": { "$value": "#102015", "$type": "color" } + }, + "caution": { + "50": { "$value": "#f5efd9", "$type": "color" }, + "100": { "$value": "#ebe0b7", "$type": "color" }, + "200": { "$value": "#d9c57f", "$type": "color" }, + "300": { "$value": "#c5a94c", "$type": "color" }, + "400": { "$value": "#b09030", "$type": "color" }, + "500": { "$value": "#957820", "$type": "color" }, + "600": { "$value": "#78621b", "$type": "color" }, + "700": { "$value": "#5f4e17", "$type": "color" }, + "800": { "$value": "#4b3d13", "$type": "color" }, + "900": { "$value": "#3a2f0f", "$type": "color" }, + "950": { "$value": "#211b08", "$type": "color" } + }, + "critical": { + "50": { "$value": "#f6e8e5", "$type": "color" }, + "100": { "$value": "#eccdc7", "$type": "color" }, + "200": { "$value": "#dba49a", "$type": "color" }, + "300": { "$value": "#c87c6d", "$type": "color" }, + "400": { "$value": "#b85f4d", "$type": "color" }, + "500": { "$value": "#a24a39", "$type": "color" }, + "600": { "$value": "#87392c", "$type": "color" }, + "700": { "$value": "#6c2e24", "$type": "color" }, + "800": { "$value": "#55251d", "$type": "color" }, + "900": { "$value": "#421d17", "$type": "color" }, + "950": { "$value": "#26100c", "$type": "color" } + }, + "informative": { + "50": { "$value": "#e4edef", "$type": "color" }, + "100": { "$value": "#c9dde0", "$type": "color" }, + "200": { "$value": "#9cc0c6", "$type": "color" }, + "300": { "$value": "#6ba1aa", "$type": "color" }, + "400": { "$value": "#4a838d", "$type": "color" }, + "500": { "$value": "#3b6b74", "$type": "color" }, + "600": { "$value": "#32575f", "$type": "color" }, + "700": { "$value": "#29464c", "$type": "color" }, + "800": { "$value": "#22383d", "$type": "color" }, + "900": { "$value": "#1b2e32", "$type": "color" }, + "950": { "$value": "#0f1b1e", "$type": "color" } + } + } +} diff --git a/packages/roots/examples/thoughtstream/semantic.dark.json b/packages/roots/examples/thoughtstream/semantic.dark.json new file mode 100644 index 0000000..23f6d37 --- /dev/null +++ b/packages/roots/examples/thoughtstream/semantic.dark.json @@ -0,0 +1,43 @@ +{ + "color": { + "bg": { "$value": "{color.mist.950}", "$type": "color" }, + "surface": { "$value": "{color.canvas.dark}", "$type": "color" }, + "surface-raised": { "$value": "{color.mist.800}", "$type": "color" }, + "overlay": { "$value": "{color.mist.950}", "$type": "color" }, + "text": { "$value": "{color.mist.100}", "$type": "color" }, + "text-muted": { "$value": "{color.mist.300}", "$type": "color" }, + "text-subtle": { "$value": "{color.mist.400}", "$type": "color" }, + "text-inverted": { "$value": "{color.mist.900}", "$type": "color" }, + "border": { "$value": "{color.mist.700}", "$type": "color" }, + "border-strong": { "$value": "{color.mist.600}", "$type": "color" }, + "ring": { "$value": "{color.slate.300}", "$type": "color" }, + "ring-offset": { "$value": "{color.mist.950}", "$type": "color" }, + "primary": { "$value": "{color.slate.300}", "$type": "color" }, + "primary-foreground": { "$value": "{color.slate.950}", "$type": "color" }, + "primary-hover": { "$value": "{color.slate.200}", "$type": "color" }, + "primary-active": { "$value": "{color.slate.400}", "$type": "color" }, + "secondary": { "$value": "{color.current.300}", "$type": "color" }, + "secondary-foreground": { "$value": "{color.current.950}", "$type": "color" }, + "secondary-hover": { "$value": "{color.current.200}", "$type": "color" }, + "secondary-active": { "$value": "{color.current.100}", "$type": "color" }, + "accent": { "$value": "{color.tide.300}", "$type": "color" }, + "accent-strong": { "$value": "{color.tide.300}", "$type": "color" }, + "accent-foreground": { "$value": "{color.tide.950}", "$type": "color" }, + "accent-hover": { "$value": "{color.tide.200}", "$type": "color" }, + "muted": { "$value": "{color.mist.900}", "$type": "color" }, + "muted-foreground": { "$value": "{color.mist.300}", "$type": "color" }, + "muted-raised": { "$value": "{color.mist.700}", "$type": "color" }, + "success": { "$value": "{color.positive.400}", "$type": "color" }, + "success-foreground": { "$value": "{color.positive.950}", "$type": "color" }, + "warning": { "$value": "{color.caution.400}", "$type": "color" }, + "warning-foreground": { "$value": "{color.caution.950}", "$type": "color" }, + "danger": { "$value": "{color.critical.300}", "$type": "color" }, + "danger-foreground": { "$value": "{color.critical.950}", "$type": "color" }, + "danger-hover": { "$value": "{color.critical.200}", "$type": "color" }, + "danger-active": { "$value": "{color.critical.100}", "$type": "color" }, + "info": { "$value": "{color.informative.300}", "$type": "color" }, + "info-foreground": { "$value": "{color.informative.950}", "$type": "color" }, + "disabled": { "$value": "{color.mist.800}", "$type": "color" }, + "disabled-foreground": { "$value": "{color.mist.600}", "$type": "color" } + } +} diff --git a/packages/roots/examples/thoughtstream/semantic.json b/packages/roots/examples/thoughtstream/semantic.json new file mode 100644 index 0000000..b785406 --- /dev/null +++ b/packages/roots/examples/thoughtstream/semantic.json @@ -0,0 +1,43 @@ +{ + "color": { + "bg": { "$value": "{color.mist.50}", "$type": "color" }, + "surface": { "$value": "{color.canvas.light}", "$type": "color" }, + "surface-raised": { "$value": "{color.base.white}", "$type": "color" }, + "overlay": { "$value": "{color.mist.900}", "$type": "color" }, + "text": { "$value": "{color.mist.900}", "$type": "color" }, + "text-muted": { "$value": "{color.mist.600}", "$type": "color" }, + "text-subtle": { "$value": "{color.mist.500}", "$type": "color" }, + "text-inverted": { "$value": "{color.mist.50}", "$type": "color" }, + "border": { "$value": "{color.mist.200}", "$type": "color" }, + "border-strong": { "$value": "{color.mist.300}", "$type": "color" }, + "ring": { "$value": "{color.slate.600}", "$type": "color" }, + "ring-offset": { "$value": "{color.base.white}", "$type": "color" }, + "primary": { "$value": "{color.slate.600}", "$type": "color" }, + "primary-foreground": { "$value": "{color.slate.50}", "$type": "color" }, + "primary-hover": { "$value": "{color.slate.700}", "$type": "color" }, + "primary-active": { "$value": "{color.slate.800}", "$type": "color" }, + "secondary": { "$value": "{color.current.600}", "$type": "color" }, + "secondary-foreground": { "$value": "{color.current.50}", "$type": "color" }, + "secondary-hover": { "$value": "{color.current.700}", "$type": "color" }, + "secondary-active": { "$value": "{color.current.800}", "$type": "color" }, + "accent": { "$value": "{color.tide.400}", "$type": "color" }, + "accent-strong": { "$value": "{color.tide.700}", "$type": "color" }, + "accent-foreground": { "$value": "{color.tide.950}", "$type": "color" }, + "accent-hover": { "$value": "{color.tide.300}", "$type": "color" }, + "muted": { "$value": "{color.mist.100}", "$type": "color" }, + "muted-foreground": { "$value": "{color.mist.600}", "$type": "color" }, + "muted-raised": { "$value": "{color.mist.100}", "$type": "color" }, + "success": { "$value": "{color.positive.600}", "$type": "color" }, + "success-foreground": { "$value": "{color.positive.50}", "$type": "color" }, + "warning": { "$value": "{color.caution.600}", "$type": "color" }, + "warning-foreground": { "$value": "{color.caution.50}", "$type": "color" }, + "danger": { "$value": "{color.critical.600}", "$type": "color" }, + "danger-foreground": { "$value": "{color.critical.50}", "$type": "color" }, + "danger-hover": { "$value": "{color.critical.700}", "$type": "color" }, + "danger-active": { "$value": "{color.critical.800}", "$type": "color" }, + "info": { "$value": "{color.informative.600}", "$type": "color" }, + "info-foreground": { "$value": "{color.informative.50}", "$type": "color" }, + "disabled": { "$value": "{color.mist.100}", "$type": "color" }, + "disabled-foreground": { "$value": "{color.mist.400}", "$type": "color" } + } +} diff --git a/packages/roots/package.json b/packages/roots/package.json index b2fcbcc..66f1624 100644 --- a/packages/roots/package.json +++ b/packages/roots/package.json @@ -23,12 +23,16 @@ "dist", "brand.mjs", "cli.mjs", + "cli-swift.mjs", + "swift.mjs", "contrast.mjs", "style-dictionary.config.mjs", + "tokens", "examples" ], "bin": { - "roots-brand": "./cli.mjs" + "roots-brand": "./cli.mjs", + "roots-swift": "./cli-swift.mjs" }, "exports": { ".": { @@ -37,7 +41,8 @@ }, "./tokens.css": "./dist/tokens.css", "./tailwind-preset.css": "./dist/tailwind-preset.css", - "./brand": "./brand.mjs" + "./brand": "./brand.mjs", + "./swift": "./swift.mjs" }, "scripts": { "build": "pnpm clean && node build.mjs && tsup", diff --git a/packages/roots/swift.mjs b/packages/roots/swift.mjs new file mode 100644 index 0000000..77bca34 --- /dev/null +++ b/packages/roots/swift.mjs @@ -0,0 +1,355 @@ +/** + * iOS / Swift token export (spec 0032). + * + * `buildSwift()` turns a brand (the SAME DTCG files `buildBrand()` consumes) plus Canopy's OWN core + * non-colour tokens into a single SwiftUI-friendly `Tokens.swift`: + * + * enum CanopyColor - every semantic role as a `Color(light:dark:)` that adapts to the color scheme + * enum CanopySpacing - `space.*` as `CGFloat` points + * enum CanopyRadius - `radius.*` as `CGFloat` points + * enum CanopyFont - `text.*` sizes as `CGFloat` + `Font` helpers, `leading.*` as `CGFloat` + * + * The architecture already names this the seam that lets a native (Swift) target be added "as just + * another Style Dictionary platform - no token rewrite." So this reuses the core build's transforms + * (`cssTransforms`) rather than re-resolving tokens by hand: + * + * - Colours: build TWO throwaway SD instances over [brand primitives + brand semantic] and + * [brand primitives + brand semantic.dark] with `outputReferences: false`, so every role resolves + * to a concrete hex. The two hex maps become one `Color(light:dark:)` per role. + * - Non-colours: one SD instance over Canopy's `space.json` / `radius.json` / `typography.json` / + * `typography-roles.json`, resolving each dimension to a literal. + * + * Decisions (also stamped into the generated header + the README): + * - rem -> pt at 16pt/rem. iOS points are the analogue of CSS px, and Canopy authors rem + * (1rem = 16px), so a rem value emits `rem * 16` as a CGFloat. A raw `px` value (radius.full's + * 9999px pill) emits its number directly. + * - Namespace = caseless enums (`enum CanopyColor { ... }`): the idiomatic Swift "namespace with no + * instances" - it can't be constructed and does not pollute `Color.`/`Font.` completion the way a + * type extension would. + * - Colours adapt at runtime via a generated `Color(light:dark:)` initializer backed by a dynamic + * `UIColor { traits in ... }` provider (UIKit is imported on iOS/tvOS/Catalyst). + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import StyleDictionary from 'style-dictionary'; +import { cssTransforms } from './style-dictionary.config.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const toAbs = (p) => resolve(process.cwd(), p); +const asArray = (v) => (Array.isArray(v) ? v : [v]); +const slugify = (s) => + String(s) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + +// The brand primitive ramps are named per brand, so the semantic ROLE set is "every color-* token +// that is not a ramp step". A ramp step ends in a numeric scale segment (`-600`) or is a bare +// primitive leaf (`color-base-white`, `color-canvas-light`); a role never does. +const isRampStep = (name) => /-(?:\d+|white|black|light|dark)$/.test(name); +const isColorRole = (name) => name.startsWith('color-') && !isRampStep(name); + +/** + * A flattened kebab token name -> a valid Swift member identifier, dropping a leading category + * prefix (`space-`, `color-`, ...). + * + * - A numeric scale (`4`, `2xl`, `3xl`) starts with a digit, invalid as a Swift identifier, so it is + * prefixed with `x` -> `x4`, `x2xl`. + * - A fractional step (`space.0.5` flattened to `space-0-5`) keeps its fraction readable by joining + * the all-numeric tail with an underscore -> `x0_5` (not the ambiguous `x05`). + * - Otherwise the segments camelCase (`text-muted` -> `textMuted`). + */ +const camel = (name, prefix) => { + const body = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name; + const parts = body.split('-').filter(Boolean); + // A wholly-numeric scale step (every segment is digits): `0-5` -> `x0_5`, `4` -> `x4`. + if (parts.length > 0 && parts.every((p) => /^\d+$/.test(p))) { + return `x${parts.join('_')}`; + } + return parts + .map((p, i) => { + if (i === 0 && /^\d/.test(p)) return `x${p}`; // `2xl` -> `x2xl` + return i === 0 ? p : p[0].toUpperCase() + p.slice(1); + }) + .join(''); +}; + +// A `space.*` / `text.*` scale sorts naturally by numeric magnitude (so `x2` precedes `x10`), with +// the size-word scale (`xs`,`sm`,...) sorted by a fixed ramp order. Falls back to lexical. +const SIZE_ORDER = ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']; +const scaleKey = (name, prefix) => { + const body = name.startsWith(prefix) ? name.slice(prefix.length) : name; + const wordIdx = SIZE_ORDER.indexOf(body); + if (wordIdx !== -1) return wordIdx; + const n = parseFloat(body.replace(/-/g, '.')); + return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY; +}; +const byScale = (prefix) => (a, b) => scaleKey(a, prefix) - scaleKey(b, prefix) || (a < b ? -1 : 1); + +/** Capitalize the first character (for composing camelCase members: `size` + `Xl`). */ +const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); + +/** `#rrggbb` -> `0xRRGGBB` (Swift hex literal). Throws on a non-6-digit hex so a bad value is loud. */ +const hexLiteral = (hex) => { + const m = /^#([0-9a-f]{6})$/i.exec(String(hex).trim()); + if (!m) throw new Error(`swift: expected a 6-digit hex, got ${JSON.stringify(hex)}`); + return `0x${m[1].toUpperCase()}`; +}; + +/** + * A DTCG dimension -> CGFloat points. `rem` converts at 16pt/rem (1rem = 16px = 16pt); a raw `px` + * emits its number (radius.full's 9999px pill); a unitless `number` (leading) passes through. + * Returns a trimmed numeric string (no trailing `.0`) so the emitted Swift is clean. + */ +const REM_TO_PT = 16; +const toPoints = (value) => { + const raw = String(value).trim(); + let n; + if (raw.endsWith('rem')) n = parseFloat(raw) * REM_TO_PT; + else if (raw.endsWith('px')) n = parseFloat(raw); + else n = parseFloat(raw); // unitless number (leading.*) + if (!Number.isFinite(n)) + throw new Error(`swift: cannot convert dimension ${JSON.stringify(value)}`); + // Trim to a tidy literal: 16, 1.5, 0.125 - never 16.0 or 0.12500001. + return String(Number(n.toFixed(4))); +}; + +/** Build one SD instance over `sources` and return `{ name -> {type, value} }` for every token. */ +const resolveTokens = async (sources, outDir) => { + const captured = {}; + const formatName = `swift-capture-${Math.random().toString(36).slice(2)}`; + StyleDictionary.registerFormat({ + name: formatName, + format: ({ dictionary }) => { + for (const t of dictionary.allTokens) { + captured[t.name] = { type: t.$type ?? t.type, value: t.$value ?? t.value }; + } + return ''; + }, + }); + const sd = new StyleDictionary({ + log: { verbosity: 'silent' }, + source: sources, + platforms: { + capture: { + transforms: cssTransforms, + buildPath: `${outDir}/`, + // outputReferences:false so every reference is flattened to a concrete literal. + files: [ + { + destination: `.swift-capture-${formatName}`, + format: formatName, + options: { outputReferences: false }, + }, + ], + }, + }, + }); + await sd.buildAllPlatforms(); + return captured; +}; + +const HEADER = (name) => + [ + '// Do not edit - generated by Style Dictionary (Canopy Roots, @rogueoak/roots).', + `// Brand: ${name}. Regenerate with: npx roots-swift `, + '//', + '// Colors: each semantic role resolves to a light + dark hex and adapts to the color scheme.', + '// Dimensions: rem is converted to points at 16pt/rem (1rem = 16px = 16pt); a raw px value', + '// (radius.full) is emitted as-is. Spacing/radius/type sizes come from Canopy core tokens,', + '// not the brand.', + ].join('\n'); + +const COLOR_HELPER = [ + '#if canImport(UIKit)', + 'import UIKit', + '#endif', + 'import SwiftUI', + '', + 'public extension Color {', + ' /// A color that resolves to `light` in light mode and `dark` in dark mode.', + ' /// Hexes are 0xRRGGBB. On platforms with UIKit this uses a dynamic UIColor provider so it', + ' /// updates live when the color scheme changes; elsewhere it falls back to the light value.', + ' init(light: UInt, dark: UInt) {', + ' #if canImport(UIKit)', + ' self = Color(UIColor { traits in', + ' traits.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light)', + ' })', + ' #else', + ' self = Color(rgb: light)', + ' #endif', + ' }', + '', + ' init(rgb: UInt) {', + ' self.init(', + ' .sRGB,', + ' red: Double((rgb >> 16) & 0xFF) / 255.0,', + ' green: Double((rgb >> 8) & 0xFF) / 255.0,', + ' blue: Double(rgb & 0xFF) / 255.0,', + ' opacity: 1.0', + ' )', + ' }', + '}', + '', + '#if canImport(UIKit)', + 'private extension UIColor {', + ' convenience init(rgb: UInt) {', + ' self.init(', + ' red: CGFloat((rgb >> 16) & 0xFF) / 255.0,', + ' green: CGFloat((rgb >> 8) & 0xFF) / 255.0,', + ' blue: CGFloat(rgb & 0xFF) / 255.0,', + ' alpha: 1.0', + ' )', + ' }', + '}', + '#endif', +].join('\n'); + +/** + * Assemble the whole `Tokens.swift` string from the resolved token maps. Pure (no I/O) so it is + * directly unit-testable. + * + * @param {string} name - brand name (header + doc). + * @param {Record} lightColors - role name -> light hex. + * @param {Record} darkColors - role name -> dark hex. + * @param {Record} core - non-colour tokens (space/radius/typography). + */ +export const renderSwift = (name, lightColors, darkColors, core) => { + const roles = Object.keys(lightColors).sort(); + const colorLines = roles.map((role) => { + const member = camel(role, 'color-'); + const light = hexLiteral(lightColors[role]); + const dark = hexLiteral(darkColors[role] ?? lightColors[role]); + return ` public static let ${member} = Color(light: ${light}, dark: ${dark})`; + }); + + const spacing = Object.keys(core) + .filter((n) => n.startsWith('space-')) + .sort(byScale('space-')) + .map( + (n) => ` public static let ${camel(n, 'space-')}: CGFloat = ${toPoints(core[n].value)}`, + ); + + const radius = Object.keys(core) + .filter((n) => n.startsWith('radius-')) + .sort() + .map( + (n) => ` public static let ${camel(n, 'radius-')}: CGFloat = ${toPoints(core[n].value)}`, + ); + + // Type sizes (`text-*` dimensions) as point CGFloats + a `Font` helper, and `leading-*` as + // unitless CGFloat multipliers. Only `text-*` DIMENSION tokens (the scale) - not the composite + // `text-role-*` typography tokens, which are not plain dimensions. + const textSizes = Object.keys(core) + .filter((n) => n.startsWith('text-') && core[n].type === 'dimension') + .sort(byScale('text-')); + const fontSizeLines = textSizes.map( + (n) => + ` public static let size${cap(camel(n, 'text-'))}: CGFloat = ${toPoints(core[n].value)}`, + ); + const fontHelperLines = textSizes.map((n) => { + const m = camel(n, 'text-'); + return ` public static func size${cap(m)}Font() -> Font { .system(size: size${cap(m)}) }`; + }); + // Line-height multipliers, member-prefixed `leading` so a size and a leading never collide and the + // call site reads clearly (`CanopyFont.leadingTight`). + const leadingLines = Object.keys(core) + .filter((n) => n.startsWith('leading-')) + .sort() + .map( + (n) => + ` public static let leading${cap(camel(n, 'leading-'))}: CGFloat = ${toPoints(core[n].value)}`, + ); + + return [ + HEADER(name), + '', + COLOR_HELPER, + '', + '/// Canopy semantic colors. Each adapts to the current color scheme.', + 'public enum CanopyColor {', + colorLines.join('\n'), + '}', + '', + '/// Canopy spacing scale, in points (rem * 16).', + 'public enum CanopySpacing {', + spacing.join('\n'), + '}', + '', + '/// Canopy corner radii, in points.', + 'public enum CanopyRadius {', + radius.join('\n'), + '}', + '', + '/// Canopy type scale: sizes in points, matching line-height multipliers, and Font helpers.', + 'public enum CanopyFont {', + fontSizeLines.join('\n'), + '', + leadingLines.join('\n'), + '', + fontHelperLines.join('\n'), + '}', + '', + ].join('\n'); +}; + +/** + * Generate a brand's `Tokens.swift` and write it to `outFile`. + * + * @param {object} options + * @param {string} options.name - brand name. + * @param {string|string[]} options.primitives - brand primitive-ramp DTCG file(s). + * @param {string} options.semantic - brand LIGHT semantic mapping. + * @param {string} options.semanticDark - brand DARK semantic mapping. + * @param {string} options.outFile - where to write `Tokens.swift`. + * @returns {Promise<{outFile:string, swift:string, roles:string[]}>} + */ +export async function buildSwift({ name, primitives, semantic, semanticDark, outFile }) { + if (!name) throw new Error('buildSwift: `name` is required'); + if (!primitives) throw new Error('buildSwift: `primitives` is required'); + if (!semantic) throw new Error('buildSwift: `semantic` is required'); + if (!semanticDark) throw new Error('buildSwift: `semanticDark` is required'); + if (!outFile) throw new Error('buildSwift: `outFile` is required'); + + const out = toAbs(outFile); + const outDir = dirname(out); + mkdirSync(outDir, { recursive: true }); + const primitivePaths = asArray(primitives).map(toAbs); + + const lightAll = await resolveTokens([...primitivePaths, toAbs(semantic)], outDir); + const darkAll = await resolveTokens([...primitivePaths, toAbs(semanticDark)], outDir); + + const pick = (all) => + Object.fromEntries( + Object.keys(all) + .filter(isColorRole) + .map((n) => [n, all[n].value]), + ); + const lightColors = pick(lightAll); + // The dark semantic file may map only a SUBSET; a role it omits keeps its light hex (mirrors the + // brand pipeline's light-is-the-dark-fallback rule), so the color adapts only where a dark value + // was authored. + const darkPicked = pick(darkAll); + const darkColors = Object.fromEntries( + Object.keys(lightColors).map((n) => [n, darkPicked[n] ?? lightColors[n]]), + ); + + // Core non-colour tokens come from Canopy's OWN sources (not the brand), so every brand shares one + // spacing/radius/type scale. + const core = await resolveTokens( + [ + resolve(here, 'tokens/space.json'), + resolve(here, 'tokens/radius.json'), + resolve(here, 'tokens/typography.json'), + resolve(here, 'tokens/typography-roles.json'), + ], + outDir, + ); + + const swift = renderSwift(name, lightColors, darkColors, core); + writeFileSync(out, swift); + return { outFile: out, swift, roles: Object.keys(lightColors).sort() }; +} + +export { slugify }; diff --git a/packages/roots/swift.test.ts b/packages/roots/swift.test.ts new file mode 100644 index 0000000..8b1e423 --- /dev/null +++ b/packages/roots/swift.test.ts @@ -0,0 +1,149 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildSwift, renderSwift } from './swift.mjs'; + +/** + * The iOS/Swift export (spec 0032): a brand's DTCG files -> one `Tokens.swift` with every semantic + * colour as a light+dark `Color`, plus Canopy's core spacing / radius / type scale as CGFloat/Font. + * Uses the thoughtstream brand as the fixture (the real brand this feature ships for). + */ +const here = dirname(fileURLToPath(import.meta.url)); +const brand = (f: string) => resolve(here, 'examples/thoughtstream', f); + +let outDir: string; +let swift: string; + +beforeAll(async () => { + outDir = mkdtempSync(join(tmpdir(), 'canopy-swift-')); + const res = await buildSwift({ + name: 'thoughtstream', + primitives: brand('primitive.json'), + semantic: brand('semantic.json'), + semanticDark: brand('semantic.dark.json'), + outFile: join(outDir, 'Tokens.swift'), + }); + swift = res.swift; +}); + +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +describe('Swift emitter - generated Tokens.swift', () => { + it('carries the do-not-edit generated header and the imports', () => { + expect(swift).toMatch(/^\/\/ Do not edit - generated by Style Dictionary/); + expect(swift).toContain('import SwiftUI'); + expect(swift).toContain('#if canImport(UIKit)'); + }); + + it('generates the Color(light:dark:) adaptive helper', () => { + expect(swift).toContain('init(light: UInt, dark: UInt)'); + // The dynamic UIColor provider is what makes it adapt at runtime. + expect(swift).toContain('UIColor { traits in'); + expect(swift).toContain('traits.userInterfaceStyle == .dark'); + }); + + it('emits every semantic colour under CanopyColor as a light + dark hex', () => { + expect(swift).toContain('public enum CanopyColor {'); + // primary is the documented anchor: slate.600 light / slate.300 dark. + expect(swift).toContain('public static let primary = Color(light: 0x42666F, dark: 0x8FB6BB)'); + // A role must never emit a bare single hex - every CanopyColor member is light+dark. + const colorBlock = swift.slice( + swift.indexOf('enum CanopyColor'), + swift.indexOf('}', swift.indexOf('enum CanopyColor')), + ); + const memberLines = colorBlock.match(/public static let \w+ = Color\(.*\)/g) ?? []; + expect(memberLines.length).toBeGreaterThan(30); + for (const line of memberLines) { + expect(line, line).toMatch(/Color\(light: 0x[0-9A-F]{6}, dark: 0x[0-9A-F]{6}\)/); + } + }); + + it('emits the spacing scale as CGFloat points (rem * 16)', () => { + expect(swift).toContain('public enum CanopySpacing {'); + expect(swift).toContain('public static let x4: CGFloat = 16'); // 1rem -> 16pt + expect(swift).toContain('public static let x0_5: CGFloat = 2'); // 0.125rem -> 2pt (fractional step) + expect(swift).toContain('public static let x0: CGFloat = 0'); + }); + + it('emits the radius scale, keeping a raw px pill as-is', () => { + expect(swift).toContain('public enum CanopyRadius {'); + expect(swift).toContain('public static let md: CGFloat = 8'); // 0.5rem -> 8pt + expect(swift).toContain('public static let full: CGFloat = 9999'); // 9999px stays a number + }); + + it('emits type sizes + Font helpers + line-height multipliers', () => { + expect(swift).toContain('public enum CanopyFont {'); + expect(swift).toContain('public static let sizeBase: CGFloat = 16'); // text.base 1rem -> 16pt + expect(swift).toContain( + 'public static func sizeBaseFont() -> Font { .system(size: sizeBase) }', + ); + expect(swift).toContain('public static let leadingTight: CGFloat = 1.2'); // unitless leading + }); + + it('writes Tokens.swift to disk and reports the roles', async () => { + const dir = mkdtempSync(join(tmpdir(), 'canopy-swift-out-')); + try { + const res = await buildSwift({ + name: 'thoughtstream', + primitives: brand('primitive.json'), + semantic: brand('semantic.json'), + semanticDark: brand('semantic.dark.json'), + outFile: join(dir, 'Tokens.swift'), + }); + expect(existsSync(res.outFile)).toBe(true); + expect(res.roles).toContain('color-primary'); + expect(readFileSync(res.outFile, 'utf8')).toBe(res.swift); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('renderSwift - pure assembly', () => { + it('falls back to the light hex for a role with no dark override', () => { + const out = renderSwift( + 'demo', + { 'color-primary': '#112233' }, + {}, + { 'space-4': { type: 'dimension', value: '1rem' } }, + ); + // No dark value supplied -> light is reused as the dark value (adapts to nothing, stays legible). + expect(out).toContain('public static let primary = Color(light: 0x112233, dark: 0x112233)'); + expect(out).toContain('public static let x4: CGFloat = 16'); + }); + + it('throws loudly on a non-hex colour value', () => { + expect(() => + renderSwift('demo', { 'color-primary': 'rgb(1,2,3)' }, { 'color-primary': '#000000' }, {}), + ).toThrow(/6-digit hex/); + }); +}); + +describe('Swift emitter - roots-swift CLI', () => { + const cli = resolve(here, 'cli-swift.mjs'); + + it('generates Tokens.swift from a brand config, honoring --out', () => { + const dir = mkdtempSync(join(tmpdir(), 'canopy-swift-cli-')); + try { + const out = join(dir, 'Tokens.swift'); + const stdout = execFileSync('node', [cli, brand('brand.config.json'), '--out', out], { + encoding: 'utf8', + }); + expect(stdout).toMatch(/wrote/); + expect(stdout).toMatch(/semantic colors/); + expect(existsSync(out)).toBe(true); + expect(readFileSync(out, 'utf8')).toMatch(/enum CanopyColor/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('exits non-zero on a missing config', () => { + expect(() => + execFileSync('node', [cli, join(here, 'does-not-exist.json')], { stdio: 'pipe' }), + ).toThrow(); + }); +}); diff --git a/packages/roots/thoughtstream.test.ts b/packages/roots/thoughtstream.test.ts new file mode 100644 index 0000000..d2dec5d --- /dev/null +++ b/packages/roots/thoughtstream.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildBrand } from './brand.mjs'; +import { checkBrandCss, contrast, extractBlock, extractThemedRoles } from './contrast.mjs'; + +/** + * The thoughtstream (River Mist) brand (spec 0032) must build AA-clean through the SAME pipeline the + * sunset example proves - a real brand, not a demo, so a break here fails a shipping app. Reads the + * BUILT `dist/tokens.css` (the package builds before it is tested, turbo `test.dependsOn`) for the + * required-role contract, so the brand is held to whatever roles Canopy actually ships. + */ +const here = dirname(fileURLToPath(import.meta.url)); +const brand = (f: string) => resolve(here, 'examples/thoughtstream', f); +const requiredRoles = extractThemedRoles(readFileSync(resolve(here, 'dist/tokens.css'), 'utf8')); + +let outDir: string; +let css: string; + +beforeAll(async () => { + outDir = mkdtempSync(join(tmpdir(), 'canopy-thoughtstream-')); + const res = await buildBrand({ + name: 'thoughtstream', + primitives: brand('primitive.json'), + semantic: brand('semantic.json'), + semanticDark: brand('semantic.dark.json'), + outFile: join(outDir, 'thoughtstream.css'), + }); + css = res.css; +}); + +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +describe('thoughtstream brand (River Mist)', () => { + it('maps every Canopy semantic role in BOTH themes, reference-aware', () => { + const root = extractBlock(css, ':root')!; + const dark = extractBlock(css, '.dark')!; + expect(requiredRoles.length).toBeGreaterThan(30); + for (const role of requiredRoles) { + expect(root[role], `light ${role}`).toMatch(/^var\(--color-[a-z0-9-]+\)$/i); + expect(dark[role], `dark ${role}`).toMatch(/^var\(--color-[a-z0-9-]+\)$/i); + } + }); + + it('points primary at the River Mist slate ramp (light + dark anchors)', () => { + const root = extractBlock(css, ':root')!; + const dark = extractBlock(css, '.dark')!; + // The documented anchors: slate.600 light, slate.300 dark. + expect(root['color-primary']).toBe('var(--color-slate-600)'); + expect(dark['color-primary']).toBe('var(--color-slate-300)'); + expect(root['color-slate-600']).toBe('#42666f'); + expect(root['color-slate-300']).toBe('#8fb6bb'); + // The palette fully replaces Canopy's - no moss/stone ramps leak through. + expect(css).not.toContain('--color-moss-'); + expect(css).not.toContain('--color-stone-'); + }); + + it('meets WCAG AA for every role pair in light AND dark', () => { + const { failures, missingLight, missingDark, identicalDark } = checkBrandCss(css, { + requiredRoles, + }); + expect(missingLight, missingLight.join(', ')).toEqual([]); + expect(missingDark, missingDark.join(', ')).toEqual([]); + expect(identicalDark, identicalDark.join(', ')).toEqual([]); + expect(failures, failures.join('\n')).toEqual([]); + }); + + it('resolves primary-foreground on primary to an AA-passing ratio (light)', () => { + const root = extractBlock(css, ':root')!; + const chase = (name: string): string => { + const ref = /^var\(--([a-z0-9-]+)\)$/i.exec(root[name]); + return ref ? chase(ref[1]) : root[name]; + }; + expect( + contrast(chase('color-primary-foreground'), chase('color-primary')), + ).toBeGreaterThanOrEqual(4.5); + }); +});