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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions docs/overview/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<brand>/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

Expand Down
28 changes: 26 additions & 2 deletions docs/overview/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<brand>/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`.
88 changes: 88 additions & 0 deletions docs/specs/0032-roots-ios-swift-tokens.md
Original file line number Diff line number Diff line change
@@ -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/<brand>/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.
56 changes: 54 additions & 2 deletions packages/roots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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/<brand>/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
Expand Down
67 changes: 67 additions & 0 deletions packages/roots/cli-swift.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* `roots-swift` CLI (spec 0032) - a thin wrapper over `buildSwift()`.
*
* Usage:
* roots-swift [config.json] [--out <path>]
*
* 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/<brand>/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 <path>]');
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);
});
65 changes: 65 additions & 0 deletions packages/roots/examples/thoughtstream/README.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/roots/examples/thoughtstream/brand.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "thoughtstream",
"primitives": "primitive.json",
"semantic": "semantic.json",
"semanticDark": "semantic.dark.json",
"outFile": "thoughtstream.css",
"scope": null
}
Loading
Loading