@radicool/throughline 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sync-layer.md +64 -4
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +64 -4
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +64 -4
- package/package.json +1 -1
- package/references/manifest-schema.md +2 -2
- package/references/native-adapter-config.md +930 -0
- package/references/sync-adapters.md +94 -12
- package/scripts/README.md +14 -3
- package/scripts/build-native-adapter-config.mjs +280 -0
- package/scripts/install.mjs +3 -2
- package/scripts/lib/dtcg.mjs +87 -0
- package/scripts/lib/native-literal.mjs +205 -0
- package/scripts/lib/sd-native.mjs +770 -0
- package/scripts/validate-crosswalk.mjs +3 -29
- package/scripts/validate-token-output.mjs +338 -0
|
@@ -35,16 +35,54 @@ user names one of these, use the vetted preset — high confidence, no guessing.
|
|
|
35
35
|
| `tailwind` | tailwind theme config | `dark:` variant / class strategy | preserved (via CSS vars) | `colors.background` |
|
|
36
36
|
| `mui` | JS theme object | `createTheme` palettes | preserved (object refs) | `palette.primary.main` |
|
|
37
37
|
| `vanilla-css` | one CSS file | `:root` + `[data-theme]` | preserved | `--color-bg-default` |
|
|
38
|
-
| `ios-swift` | Swift enum
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
| `ios-swift` | Swift enum constants (`Tokens.swift`) | one build per mode, one output directory per mode | flattened | `Tokens.textSm` |
|
|
39
|
+
|
|
40
|
+
Four of the five cover this plugin's web-first, design-led audience: three React
|
|
41
|
+
framework adapters (shadcn — the dominant new-project choice; standalone
|
|
42
|
+
Tailwind — for the large Tailwind-without-shadcn population; MUI — the
|
|
43
|
+
enterprise/Material standard), plus the universal `vanilla-css` escape hatch
|
|
44
|
+
(plain CSS custom properties, no framework). `ios-swift` is the one native
|
|
45
|
+
member, and it is curated because its configuration ships as tested code rather
|
|
46
|
+
than as advice — see `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`.
|
|
47
|
+
Everything else — Ant Design, Chakra, HeroUI, Android/Kotlin, Flutter, React
|
|
48
|
+
Native, etc. — is fully supported via Tier 2.
|
|
49
|
+
|
|
50
|
+
**`ios-swift` was demoted, and has been restored.** Run against a real DTCG
|
|
51
|
+
source under the *stock* transform group it emitted px-authored dimensions at
|
|
52
|
+
×16 their authored value (valid, compiling Swift), leaked `color-mix()`
|
|
53
|
+
expressions, and left dual-node aliases as bare `px` literals. The tier badge
|
|
54
|
+
claimed a confidence the stock configuration had not earned, so it came off.
|
|
55
|
+
|
|
56
|
+
The cause was the transform group, not the adapter concept. That configuration
|
|
57
|
+
now ships as tested code at
|
|
58
|
+
`${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs`, is installed into the
|
|
59
|
+
consumer's repo, and is verified end to end against a real source (195 emitted
|
|
60
|
+
symbols matched, zero rule failures under the shipping ruleset, on both light
|
|
61
|
+
and dark builds). The badge is back on that basis.
|
|
62
|
+
|
|
63
|
+
**What the badge does not cover: nothing is compiled.** Every emitted value is
|
|
64
|
+
checked to be a well-formed Swift or Kotlin *literal* — `tokens:validate-output`'s
|
|
65
|
+
`invalid-literal` rule parses each one and fails on anything that is not
|
|
66
|
+
(#53) — but no `swiftc` or `kotlinc` runs, so a type mismatch or a call to an
|
|
67
|
+
undefined symbol would still pass. String-valued tokens (`fontFamily`, `string`,
|
|
68
|
+
keyword `fontWeight`) are quoted by the module's own transforms, and a value with
|
|
69
|
+
no native form at all — a CSS `linear-gradient(...)` — is filtered out of native
|
|
70
|
+
output rather than emitted broken. That drop is reported as an unemitted token,
|
|
71
|
+
not hidden.
|
|
72
|
+
|
|
73
|
+
`android-kotlin` uses the same module and stays Tier 2: its remaining unknowns
|
|
74
|
+
are on the consumption side — Compose `dp`/`sp` behaviour against a real Compose
|
|
75
|
+
app, resource-qualifier conventions, package layout — which building tokens does
|
|
76
|
+
not exercise. The `dp`/`sp` split itself is no longer among them: font sizes and
|
|
77
|
+
line heights whose role a DTCG source states — the `fontSize`, `letterSpacing`
|
|
78
|
+
and `lineHeight` member names of §9.8's typography composite — now emit as
|
|
79
|
+
`sp`. What remains is narrower and documented in
|
|
80
|
+
`${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`: a bare scale
|
|
81
|
+
primitive carries no role and stays `dp`, and an `em` letterSpacing is
|
|
82
|
+
filtered out rather than emitted as `.em`.
|
|
83
|
+
`tokens:validate-output` remains what decides whether any adapter can be
|
|
84
|
+
trusted, and re-promotion is available to any adapter that passes it against a
|
|
85
|
+
real source.
|
|
48
86
|
|
|
49
87
|
`shadcn` vs `tailwind`: shadcn emits CSS vars *plus* the specific var names
|
|
50
88
|
shadcn components expect; `tailwind` targets Tailwind used on its own, mapping
|
|
@@ -99,8 +137,52 @@ emit as references to primitive vars rather than flattened literals.
|
|
|
99
137
|
- **Native adapters** (`ios-swift`, and generated native targets like
|
|
100
138
|
Android/Kotlin): `outputReferences: false` — references resolve to literal
|
|
101
139
|
values at build time, because the target language has no runtime var
|
|
102
|
-
indirection. Modes map to
|
|
103
|
-
|
|
140
|
+
indirection. Modes map to **one build per mode, one output directory per
|
|
141
|
+
mode** — each build passing only that mode's sources, through `nativeSources`.
|
|
142
|
+
Asset-catalog variants and resource qualifiers are *not* implemented: nothing
|
|
143
|
+
here emits an `.xcassets` catalog or a `values-night/` resource tree.
|
|
144
|
+
|
|
145
|
+
## What the stock configuration gets wrong
|
|
146
|
+
|
|
147
|
+
These are all legal in a real DTCG source, and a **stock** Style Dictionary
|
|
148
|
+
transform group mishandles every one of them silently — emitting output that
|
|
149
|
+
compiles and is wrong, which is why `tokens:validate-output` exists.
|
|
150
|
+
|
|
151
|
+
**None of these are Style Dictionary limitations.** All four are fixed by
|
|
152
|
+
roughly 80 lines of preprocessor and transform code, which ships as a tested
|
|
153
|
+
module at `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs` and is documented in
|
|
154
|
+
`${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`. Against a real
|
|
155
|
+
322-token source it emitted 195 symbols that all map to a source token, 107 of
|
|
156
|
+
them with their numeric magnitude additionally verified, with zero rule failures
|
|
157
|
+
— colour and string values are matched by name only and are checked by no rule.
|
|
158
|
+
**Import that module** rather than configuring a native adapter from a stock
|
|
159
|
+
`transformGroup` or transcribing the reference doc.
|
|
160
|
+
|
|
161
|
+
- **CSS expressions in a value** — `color-mix(in srgb, {color.brand.500} 12%,
|
|
162
|
+
transparent)` is a runtime CSS construct. Style Dictionary does no colour
|
|
163
|
+
math, so it resolves only the inner reference and leaves the function wrapper
|
|
164
|
+
in the output. *Fix: a transform that computes the blend to a literal.*
|
|
165
|
+
- **Dual-node tokens** — a node carrying both a `$value` and children (`text.sm`
|
|
166
|
+
with `$value: "14px"` plus a `text.sm.lineHeight` child). Style Dictionary's
|
|
167
|
+
resolver will not traverse into one, so every alias to the child fails to
|
|
168
|
+
resolve and emits as a bare literal; its collector also stops there, so the
|
|
169
|
+
child is never emitted at all. *Fix: a preprocessor that resolves aliases and
|
|
170
|
+
hoists the children.*
|
|
171
|
+
- **`%` and `em` dimensions** — parent-relative or container-relative, so there
|
|
172
|
+
genuinely is no build-time native magnitude. This one is a real limit rather
|
|
173
|
+
than a configuration gap. *Handling: filter them out of native builds — on the
|
|
174
|
+
authored value, since a `100%` token may be typed `string`, not `dimension`.*
|
|
175
|
+
- **A third mode axis** — this reference models theme (`.dark` /
|
|
176
|
+
`[data-theme]`) and brand (`[data-brand]`). A viewport axis carrying its own
|
|
177
|
+
spacing and type scales is common and has no mapping here; on native it is
|
|
178
|
+
size classes and resource qualifiers, resolved by a different mechanism
|
|
179
|
+
entirely.
|
|
180
|
+
|
|
181
|
+
**Native dimension transforms must read the authored unit.** The stock
|
|
182
|
+
`ios-swift` and `compose` transform groups assume `rem` input and multiply by
|
|
183
|
+
16. Against a `px`-authored source that silently produces output at sixteen
|
|
184
|
+
times scale which compiles and ships. Emit 1:1 for `px` and unitless ratios;
|
|
185
|
+
×16 only for `rem`.
|
|
104
186
|
|
|
105
187
|
## Brownfield value transforms
|
|
106
188
|
|
package/scripts/README.md
CHANGED
|
@@ -10,7 +10,11 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
10
10
|
| `validate-crosswalk.mjs` | Resolve every `newToken` against the DTCG token source; assert resolved value == `newValue`, N/N. The CI gate. | `tokens:validate` |
|
|
11
11
|
| `build-reverse-index.mjs` | Emit a `codeToken -> newToken` map from the crosswalk to semi-automate SCSS/Tailwind swaps. | `tokens:reverse-index` |
|
|
12
12
|
| `guard-token-removal.mjs` | Grep `.ts/.tsx` (minus generated + tests) for about-to-be-deleted symbols; blocks cleanup until zero references remain. | run during the cleanup phase |
|
|
13
|
+
| `validate-token-output.mjs` | Assert generated native token output matches its DTCG source: authored-unit fidelity, no leaked CSS syntax, no bare unit literals, no mode collisions. Fails when no emitted symbol matches a source token, and reports match rate, unparsed lines, and unemitted tokens on every run. | `tokens:validate-output` |
|
|
13
14
|
| `lib/crosswalk.mjs` | Shared loader + structural validation for `crosswalk.json` (used by the validator and reverse-index). | copied alongside |
|
|
15
|
+
| `lib/dtcg.mjs` | Shared DTCG flatten + `{alias}` resolution. Dual-node aware: a node carrying both a `$value` and children yields its own value **and** is descended into. Used by `validate-crosswalk.mjs` and `validate-token-output.mjs`. | copied alongside both |
|
|
16
|
+
| `lib/sd-native.mjs` | The Style Dictionary native configuration as code: unit-aware dimension transforms, `color-mix` computation, dual-node preprocessing, platform assembly, and a per-mode source guard. Style Dictionary is a parameter, never an import. | copied alongside `validate-token-output.mjs` |
|
|
17
|
+
| `lib/native-literal.mjs` | Shared grammar for "is this a well-formed Swift or Kotlin literal": parses rather than pattern-matches, so an unquoted string, a raw CSS function, or any other unanticipated case fails the same way. Used by `sd-native.mjs`'s output filter and by `validate-token-output.mjs`'s invalid-literal rule. | copied alongside `validate-token-output.mjs` and `lib/sd-native.mjs` |
|
|
14
18
|
| `crosswalk.schema.json` | The finalized JSON Schema for `crosswalk.json` (contract + editor support). | copied beside `crosswalk.json` |
|
|
15
19
|
| `build-docs-digest.mjs` | Aggregate every `design-system/docs/components/*.doc.json` into `design-system/docs/index.json` + `llms.txt` for AI/human consumers. | `docs:digest` |
|
|
16
20
|
| `docs-check.mjs` | Drift gate — verifies each component's doc surfaces still match its canonical record (via `lib/doc-record.mjs` fingerprints). Exits 1 on drift. | `docs:check` |
|
|
@@ -19,6 +23,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
19
23
|
| `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
|
|
20
24
|
| `lib/doc-card-plan.mjs` | Pure layout planner for the doc card's `Usage` band + `DOC_CARD_RENDERER_VERSION` (single source of the layout version). Inlined into `references/doc-card-builder.md`; imported by `docs-check.mjs`. | copied alongside docs-check.mjs; also inlined into the generated builder |
|
|
21
25
|
| `build-doc-card-builder.mjs` | Generate `references/doc-card-builder.md` from the planner + the Figma renderer template (`lib/doc-card-render.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
26
|
+
| `build-native-adapter-config.mjs` | Generate `references/native-adapter-config.md` by slicing `lib/sd-native.mjs` on its `@doc-section` markers and interleaving each fragment under its prose. Fails when module code falls outside every section, so the doc cannot silently ship incomplete. `--check` gates CI. | plugin-internal (not installed) |
|
|
22
27
|
|
|
23
28
|
**Documentation scripts — install as a set.** Copying these files without
|
|
24
29
|
registering them leaves a repo with a script on disk and no entry point, which
|
|
@@ -54,9 +59,9 @@ conflict, or remaining reference), `2` bad CLI arguments.
|
|
|
54
59
|
|
|
55
60
|
## How the skill installs these
|
|
56
61
|
|
|
57
|
-
`token-crosswalk-builder` copies `lib/crosswalk.mjs`, `
|
|
58
|
-
`build-reverse-index.mjs`, `guard-token-removal.mjs`, and
|
|
59
|
-
into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
|
|
62
|
+
`token-crosswalk-builder` copies `lib/crosswalk.mjs`, `lib/dtcg.mjs`,
|
|
63
|
+
`validate-crosswalk.mjs`, `build-reverse-index.mjs`, `guard-token-removal.mjs`, and
|
|
64
|
+
`crosswalk.schema.json` into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
|
|
60
65
|
wires `packages/tokens/package.json`:
|
|
61
66
|
|
|
62
67
|
```jsonc
|
|
@@ -66,6 +71,12 @@ wires `packages/tokens/package.json`:
|
|
|
66
71
|
}
|
|
67
72
|
```
|
|
68
73
|
|
|
74
|
+
`token-sync-layer` copies `validate-token-output.mjs`, `lib/dtcg.mjs`,
|
|
75
|
+
`lib/native-literal.mjs`, **and** `lib/sd-native.mjs`, and wires
|
|
76
|
+
`"tokens:validate-output"`. All four travel together: `sd-native.mjs` and the
|
|
77
|
+
validator both import `lib/dtcg.mjs` and `lib/native-literal.mjs`, so
|
|
78
|
+
installing any one of them alone breaks at import time.
|
|
79
|
+
|
|
69
80
|
The scripts version with the user's repo so their CI runs them locally — a path
|
|
70
81
|
inside the plugin install would not be reachable from the user's CI.
|
|
71
82
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Generates references/native-adapter-config.md from scripts/lib/sd-native.mjs.
|
|
2
|
+
// The prose lives here, keyed by section id; the code is sliced out of the
|
|
3
|
+
// module's real source between @doc-section markers and interleaved beneath its
|
|
4
|
+
// prose. Mirrors build-doc-card-builder.mjs: run bare to write, --check to gate
|
|
5
|
+
// CI. Zero dependencies.
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
export const SOURCE = join(REPO_ROOT, 'scripts', 'lib', 'sd-native.mjs');
|
|
12
|
+
export const OUT = join(REPO_ROOT, 'references', 'native-adapter-config.md');
|
|
13
|
+
|
|
14
|
+
const OPEN = /^\s*\/\/\s*@doc-section\s+(\S+)\s*$/;
|
|
15
|
+
const CLOSE = /^\s*\/\/\s*@doc-section-end\s+(\S+)\s*$/;
|
|
16
|
+
|
|
17
|
+
export function sliceSections(source) {
|
|
18
|
+
const sections = new Map();
|
|
19
|
+
let open = null;
|
|
20
|
+
let buffer = [];
|
|
21
|
+
for (const line of source.split('\n')) {
|
|
22
|
+
const closeMatch = line.match(CLOSE);
|
|
23
|
+
if (closeMatch) {
|
|
24
|
+
if (!open) throw new Error(`@doc-section-end ${closeMatch[1]} with no open section`);
|
|
25
|
+
if (closeMatch[1] !== open) {
|
|
26
|
+
throw new Error(`@doc-section-end ${closeMatch[1]} does not close ${open}`);
|
|
27
|
+
}
|
|
28
|
+
sections.set(open, buffer.join('\n').trim());
|
|
29
|
+
open = null;
|
|
30
|
+
buffer = [];
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const openMatch = line.match(OPEN);
|
|
34
|
+
if (openMatch) {
|
|
35
|
+
if (open) throw new Error(`unclosed @doc-section ${open}`);
|
|
36
|
+
if (sections.has(openMatch[1])) throw new Error(`duplicate @doc-section ${openMatch[1]}`);
|
|
37
|
+
open = openMatch[1];
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (open) buffer.push(line);
|
|
41
|
+
}
|
|
42
|
+
if (open) throw new Error(`unclosed @doc-section ${open}`);
|
|
43
|
+
return sections;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Every line of module source must live inside some @doc-section pair, so that
|
|
47
|
+
// adding a piece to the module without documenting it breaks CI rather than
|
|
48
|
+
// silently shipping a doc whose code does not run. Legitimately outside: blank
|
|
49
|
+
// lines, `//` comment lines (the file's leading banner), and the markers
|
|
50
|
+
// themselves. Anything else — any line with executable code on it — is a gap.
|
|
51
|
+
export function assertCovered(source) {
|
|
52
|
+
const uncovered = [];
|
|
53
|
+
let open = false;
|
|
54
|
+
source.split('\n').forEach((line, i) => {
|
|
55
|
+
if (CLOSE.test(line)) {
|
|
56
|
+
open = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (OPEN.test(line)) {
|
|
60
|
+
open = true;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (open) return;
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (trimmed === '' || trimmed.startsWith('//')) return;
|
|
66
|
+
uncovered.push(` ${i + 1}: ${line}`);
|
|
67
|
+
});
|
|
68
|
+
if (uncovered.length === 0) return;
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${uncovered.length} line(s) of ${SOURCE} fall outside every @doc-section pair.\n` +
|
|
71
|
+
'Only blank lines and // comments may sit outside a section; code must be\n' +
|
|
72
|
+
'inside one, or the generated doc shows code that cannot run.\n' +
|
|
73
|
+
uncovered.join('\n'),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Section id -> the prose that introduces it. Order here is the doc's order.
|
|
78
|
+
const PROSE = [
|
|
79
|
+
['imports', `## Imports
|
|
80
|
+
|
|
81
|
+
\`node:fs\` plus the siblings \`lib/dtcg.mjs\` and \`lib/native-literal.mjs\` this
|
|
82
|
+
plugin already installs — nothing else. Style Dictionary is passed in as a
|
|
83
|
+
parameter, never imported, which is what keeps this module installable into a
|
|
84
|
+
consumer's repo.`],
|
|
85
|
+
['unit-aware', `## 1. Read the authored unit
|
|
86
|
+
|
|
87
|
+
**This replaces \`size/swift/remToCGFloat\` and the \`size/compose/*\` transforms,
|
|
88
|
+
and it is the single most important piece.** Those assume every dimension is
|
|
89
|
+
authored in \`rem\` and multiply by 16. Against a \`px\`-authored source that
|
|
90
|
+
silently produces output at sixteen times scale which compiles and ships.`],
|
|
91
|
+
['color-mix', `## 2. Compute \`color-mix()\` to a literal
|
|
92
|
+
|
|
93
|
+
A CSS expression has no native equivalent, and Style Dictionary does no colour
|
|
94
|
+
math. Native adapters resolve to literals; for a \`color-mix\` that means
|
|
95
|
+
actually computing the blend. Register this **before** the platform's colour
|
|
96
|
+
transform, so the colour transform receives a valid hex8 rather than a CSS
|
|
97
|
+
function.`],
|
|
98
|
+
['preprocess', `## 3. Resolve aliases and hoist dual-node children
|
|
99
|
+
|
|
100
|
+
Style Dictionary's resolver will not traverse into a node that carries both a
|
|
101
|
+
\`$value\` and children, and its collector stops there too. The dual-node pattern
|
|
102
|
+
is invalid DTCG — the Design Tokens Format Module's 30 July 2026 draft, §6.1,
|
|
103
|
+
requires tools to report it as an error, and §6.2's \`$root\` is the sanctioned
|
|
104
|
+
way to pair a value with children. Figma-derived sources emit it anyway:
|
|
105
|
+
\`text.sm\` holds \`$value: "14px"\` *and* a \`text.sm.lineHeight\` child. So every
|
|
106
|
+
alias to such a child fails to resolve, and the child is never emitted at all.
|
|
107
|
+
|
|
108
|
+
Both are fixed before Style Dictionary sees the tree.`],
|
|
109
|
+
['platform', `## 4. Assemble the platform from the stock list
|
|
110
|
+
|
|
111
|
+
Build the transform list from Style Dictionary's **stock group**, replacing only
|
|
112
|
+
the rem-assuming size transforms. A hand-picked list silently drops whatever it
|
|
113
|
+
forgets — three real defects arose exactly that way.
|
|
114
|
+
|
|
115
|
+
**The \`dp\`/\`sp\` split is fixed here; two narrower Android-only limits remain.**
|
|
116
|
+
Style Dictionary's Compose transforms select on \`$type\`, and DTCG's type set
|
|
117
|
+
does not line up with what they expect — there is no \`fontSize\` type, because
|
|
118
|
+
DTCG types font sizes as \`dimension\`. So the role is taken instead from the
|
|
119
|
+
member names DTCG §9.8 fixes for the typography composite, stamped onto
|
|
120
|
+
\`$extensions\` during preprocessing, and the two Compose transforms partition on
|
|
121
|
+
that stamp. Measured against a real source: 39 declarations that emitted \`dp\`
|
|
122
|
+
now emit \`sp\`, with the Swift output byte-identical.
|
|
123
|
+
|
|
124
|
+
What remains:
|
|
125
|
+
|
|
126
|
+
- **A bare scale primitive emits as \`dp\`.** \`text.base: "16px"\` is a font size
|
|
127
|
+
only to a human — no nominal or structural signal marks it — so it is not
|
|
128
|
+
stamped. The semantic tokens that reference it are, and those are what a
|
|
129
|
+
consumer should reach for.
|
|
130
|
+
- **An \`em\` letter spacing reaches Compose but not Swift.** \`size/unit-aware/compose-em\`
|
|
131
|
+
emits it as a real \`.em\` TextUnit, parenthesised — \`(-0.03).em\` — because
|
|
132
|
+
\`-0.03.em\` parses as \`-(0.03.em)\` and needs an \`unaryMinus\` operator, while
|
|
133
|
+
the parenthesised form compiles regardless. iOS is excluded deliberately, not
|
|
134
|
+
pending: letter spacing there is an \`NSAttributedString\` kern in points,
|
|
135
|
+
which needs the font size the token does not carry, so no constant Swift
|
|
136
|
+
could emit would be right at every font size.
|
|
137
|
+
|
|
138
|
+
The first is Android-only. \`size/unit-aware/swift\` filters
|
|
139
|
+
\`dimension || fontSize\` and emits \`CGFloat\`, which carries no unit to be wrong
|
|
140
|
+
about; iOS handles Dynamic Type at the use site via \`UIFontMetrics\`.
|
|
141
|
+
\`tokens:validate-output\` passes in both cases: it checks magnitude, not unit.
|
|
142
|
+
|
|
143
|
+
**A unitless value is no longer one of them.** DTCG §8.2.1 requires a dimension
|
|
144
|
+
to carry a unit, §8.7's \`number\` is the type for a ratio, and §9.8 types
|
|
145
|
+
\`lineHeight\` as one — so \`leading.normal: "1.5"\` typed \`dimension\` is malformed
|
|
146
|
+
input. No size transform claims it: it emits bare on both platforms, which is
|
|
147
|
+
byte-for-byte what a correctly typed \`number\` already produced, so correcting
|
|
148
|
+
the source's \`$type\` changes no output. \`tokens:validate-output\` reports it as
|
|
149
|
+
a \`unitless-dimension\` advisory, which does not gate — the emitted value is
|
|
150
|
+
right under the ratio reading, and only the author can say whether a ratio is
|
|
151
|
+
what was meant.
|
|
152
|
+
|
|
153
|
+
**The stock list is accounted for, not transcribed.** \`PLATFORMS\` records the
|
|
154
|
+
stock group each platform mirrors, and \`auditStockGroups\` checks at
|
|
155
|
+
registration that every transform in that live group is either run here or
|
|
156
|
+
declined in writing, with a reason. A stock transform this config has never
|
|
157
|
+
decided about warns; it is never silently dropped. The check warns and never
|
|
158
|
+
throws — a new stock transform is usually harmless, and the fatal direction, a
|
|
159
|
+
transform we run being removed, already makes Style Dictionary throw on an
|
|
160
|
+
unknown name. It runs in your build because that is the only place the
|
|
161
|
+
installed Style Dictionary version is knowable: ThroughLine declares no
|
|
162
|
+
dependency on it.`],
|
|
163
|
+
['sources', `## 5. Guard the per-mode source list
|
|
164
|
+
|
|
165
|
+
Style Dictionary deduplicates by dot-path, so one build over both a light and a
|
|
166
|
+
dark definition of the same token keeps whichever file sorts last and drops the
|
|
167
|
+
other mode with no diagnostic. Pass every build's sources through
|
|
168
|
+
\`nativeSources\`, which returns them, so the check cannot be skipped by
|
|
169
|
+
forgetting it.`],
|
|
170
|
+
['register', `## 6. Register with Style Dictionary
|
|
171
|
+
|
|
172
|
+
One call. Style Dictionary is a parameter, never an import, which is what lets
|
|
173
|
+
this module install into a consumer's \`packages/tokens/scripts/lib/\`.`],
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const HEADER = `# Native adapter configuration (GENERATED)
|
|
177
|
+
|
|
178
|
+
> **GENERATED FILE — do not edit by hand.** Source: \`scripts/lib/sd-native.mjs\`,
|
|
179
|
+
> which is unit-tested in Node and installed into the consumer's repo.
|
|
180
|
+
> Regenerate with \`node scripts/build-native-adapter-config.mjs\`; CI gates
|
|
181
|
+
> freshness with \`--check\`.
|
|
182
|
+
|
|
183
|
+
The Style Dictionary configuration a native adapter (\`ios-swift\`,
|
|
184
|
+
\`android-kotlin\`, or any generated native target) needs in order to emit
|
|
185
|
+
**correct** output from a real DTCG token source.
|
|
186
|
+
|
|
187
|
+
**Why this exists.** The stock \`ios-swift\` and \`compose\` transform groups
|
|
188
|
+
produce output that compiles and is wrong. Run against a real source, the stock
|
|
189
|
+
configuration emitted every \`px\`-authored dimension at ×16 its authored value,
|
|
190
|
+
leaked \`color-mix()\` expressions into Swift, and left dual-node aliases as bare
|
|
191
|
+
\`px\` literals — all at exit \`0\`. None of that is a Style Dictionary limitation.
|
|
192
|
+
All of it is configuration.
|
|
193
|
+
|
|
194
|
+
**You do not need to copy any of this.** It ships as
|
|
195
|
+
\`\${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs\`. Install it beside
|
|
196
|
+
\`lib/dtcg.mjs\` and \`lib/native-literal.mjs\` and call it:
|
|
197
|
+
|
|
198
|
+
\`\`\`js
|
|
199
|
+
import StyleDictionary from 'style-dictionary';
|
|
200
|
+
import { registerNativeTransforms, nativePlatform, nativeSources }
|
|
201
|
+
from './scripts/lib/sd-native.mjs';
|
|
202
|
+
|
|
203
|
+
registerNativeTransforms(StyleDictionary);
|
|
204
|
+
|
|
205
|
+
for (const mode of ['light', 'dark']) {
|
|
206
|
+
const sd = new StyleDictionary({
|
|
207
|
+
source: nativeSources(sourcesFor(mode)),
|
|
208
|
+
preprocessors: ['dtcg/resolve-dual-node'],
|
|
209
|
+
platforms: {
|
|
210
|
+
ios: nativePlatform({ platform: 'ios-swift', buildPath: \`out/\${mode}/\` }),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
await sd.buildAllPlatforms();
|
|
214
|
+
}
|
|
215
|
+
\`\`\`
|
|
216
|
+
|
|
217
|
+
The sections below are the module's own source, inlined so the configuration
|
|
218
|
+
stays reviewable. Pair this with \`\${CLAUDE_PLUGIN_ROOT}/references/sync-adapters.md\`,
|
|
219
|
+
which covers the adapter contract itself.
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
const FOOTER = `## Verify, always
|
|
223
|
+
|
|
224
|
+
Configuration this specific is exactly what regresses unnoticed, because every
|
|
225
|
+
failure mode above produces output that compiles. Run \`tokens:validate-output\`
|
|
226
|
+
against each generated file with the same source list that file's build used,
|
|
227
|
+
and treat it as a gate rather than a spot check:
|
|
228
|
+
|
|
229
|
+
\`\`\`
|
|
230
|
+
node scripts/validate-token-output.mjs \\
|
|
231
|
+
--source tokens/color-primitives.json --source tokens/text-primitives.json \\
|
|
232
|
+
--output out/light/Tokens.swift --platform ios-swift --min-match 1
|
|
233
|
+
\`\`\`
|
|
234
|
+
|
|
235
|
+
A clean run reports 100% of emitted symbols matched with zero rule failures.
|
|
236
|
+
Anything less means the configuration drifted — so **pass \`--min-match 1\`**.
|
|
237
|
+
The flag's default is \`0.5\`, which is a floor against wholly unparseable output
|
|
238
|
+
rather than the gate this doc describes; without it a 60% match rate exits \`0\`.
|
|
239
|
+
See \`\${CLAUDE_PLUGIN_ROOT}/scripts/README.md\`.
|
|
240
|
+
|
|
241
|
+
"Matched" means an emitted symbol's name resolved to a source token. Numeric
|
|
242
|
+
magnitudes are additionally compared; colour and string values are matched by
|
|
243
|
+
name only, and no rule checks that the output compiles.
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
export function render(sections) {
|
|
247
|
+
const declared = PROSE.map(([id]) => id);
|
|
248
|
+
for (const id of declared) {
|
|
249
|
+
if (!sections.has(id)) throw new Error(`@doc-section ${id} is declared in PROSE but missing from ${SOURCE}`);
|
|
250
|
+
}
|
|
251
|
+
for (const id of sections.keys()) {
|
|
252
|
+
if (!declared.includes(id)) throw new Error(`@doc-section ${id} exists in ${SOURCE} but has no prose entry`);
|
|
253
|
+
}
|
|
254
|
+
const body = PROSE.map(([id, prose]) => `${prose}\n\n\`\`\`js\n${sections.get(id)}\n\`\`\`\n`).join('\n');
|
|
255
|
+
return `${HEADER}\n${body}\n${FOOTER}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function main() {
|
|
259
|
+
const check = process.argv.includes('--check');
|
|
260
|
+
const source = readFileSync(SOURCE, 'utf8');
|
|
261
|
+
assertCovered(source);
|
|
262
|
+
const rendered = render(sliceSections(source));
|
|
263
|
+
if (!check) {
|
|
264
|
+
writeFileSync(OUT, rendered);
|
|
265
|
+
console.log(`wrote ${OUT}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const current = readFileSync(OUT, 'utf8');
|
|
269
|
+
if (current === rendered) {
|
|
270
|
+
console.log('references/native-adapter-config.md is up to date');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
console.error(
|
|
274
|
+
'references/native-adapter-config.md is stale.\n' +
|
|
275
|
+
'Run: node scripts/build-native-adapter-config.mjs',
|
|
276
|
+
);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
package/scripts/install.mjs
CHANGED
|
@@ -99,12 +99,13 @@ function stagePayload(srcRoot, destRoot, skip) {
|
|
|
99
99
|
return written;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
|
-
// doc
|
|
102
|
+
// Scripts that never run from a consuming repo: the installer itself, the two
|
|
103
|
+
// reference-doc generators, and the renderer template one of them inlines (the
|
|
104
104
|
// renderer reaches Figma pre-inlined inside references/doc-card-builder.md).
|
|
105
105
|
const PLUGIN_INTERNAL = new Set([
|
|
106
106
|
'install.mjs',
|
|
107
107
|
'build-doc-card-builder.mjs',
|
|
108
|
+
'build-native-adapter-config.mjs',
|
|
108
109
|
'lib/doc-card-render.figma.js',
|
|
109
110
|
]);
|
|
110
111
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared DTCG reading: flatten a token tree to dot-paths, resolve {alias} chains.
|
|
2
|
+
// Zero dependencies. Consumed by validate-crosswalk.mjs and validate-token-output.mjs,
|
|
3
|
+
// and copied alongside both when a skill installs either gate.
|
|
4
|
+
|
|
5
|
+
const REF = /^\{([^}]+)\}$/;
|
|
6
|
+
|
|
7
|
+
// Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
|
|
8
|
+
//
|
|
9
|
+
// A node carrying BOTH a $value and children yields its own value AND is descended
|
|
10
|
+
// into — the dual-node pattern, where `text.sm` has `$value: "14px"` plus a
|
|
11
|
+
// `text.sm.lineHeight` child. Stopping at the first $value drops those children,
|
|
12
|
+
// which makes every alias to one unresolvable: the crosswalk gate reported them as
|
|
13
|
+
// "missing from the DTCG source" though they exist, and the output validator could
|
|
14
|
+
// not check them at all.
|
|
15
|
+
export function flattenDtcg(obj, prefix = [], out = {}) {
|
|
16
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
17
|
+
if (key.startsWith('$')) continue;
|
|
18
|
+
if (!val || typeof val !== 'object') continue;
|
|
19
|
+
const path = [...prefix, key];
|
|
20
|
+
if ('$value' in val) out[path.join('.')] = val.$value;
|
|
21
|
+
flattenDtcg(val, path, out);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Flatten nested DTCG groups into { "dot.path": effectiveType }, applying the
|
|
27
|
+
// $type resolution of DTCG 5.2.2: a token's own $type wins, otherwise the
|
|
28
|
+
// nearest ancestor GROUP's. A node carrying a $value is a token, not a group
|
|
29
|
+
// (DTCG 6.1), so it is not an inheritance source for its children — the same
|
|
30
|
+
// rule hoistDualNodes computes as `inherited`, and the two must agree or two
|
|
31
|
+
// functions in this codebase disagree about the type of the same tree.
|
|
32
|
+
//
|
|
33
|
+
// Separate from flattenDtcg rather than folded into it: that function has four
|
|
34
|
+
// consumers and both validators re-export it, so its return shape is fixed.
|
|
35
|
+
//
|
|
36
|
+
// LIMIT, stated rather than hidden: this reads the RAW source, so it cannot see
|
|
37
|
+
// the $type carry hoistDualNodes applies during preprocessing. An untyped child
|
|
38
|
+
// of a dimension-typed dual node with no enclosing group type is a dimension to
|
|
39
|
+
// the pipeline and undefined here. Reference-derived typing (5.2.2 rule 1) is
|
|
40
|
+
// likewise not resolved — an alias is undefined, but its referent is typed, and
|
|
41
|
+
// the referent is the token an author edits.
|
|
42
|
+
export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefined) {
|
|
43
|
+
const inherited = '$value' in obj ? groupType : (obj.$type ?? groupType);
|
|
44
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
45
|
+
if (key.startsWith('$')) continue;
|
|
46
|
+
if (!val || typeof val !== 'object') continue;
|
|
47
|
+
const path = [...prefix, key];
|
|
48
|
+
if ('$value' in val) out[path.join('.')] = val.$type ?? inherited;
|
|
49
|
+
flattenDtcgTypes(val, path, out, inherited);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
|
|
55
|
+
export function resolveValue(name, flat, seen = new Set()) {
|
|
56
|
+
if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
|
|
57
|
+
const val = flat[name];
|
|
58
|
+
if (typeof val === 'string') {
|
|
59
|
+
const m = val.match(REF);
|
|
60
|
+
if (m) {
|
|
61
|
+
if (seen.has(name)) throw new Error(`circular reference at "${name}"`);
|
|
62
|
+
seen.add(name);
|
|
63
|
+
return resolveValue(m[1], flat, seen);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return val;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A token path defined in more than one source file with differing values means
|
|
70
|
+
// the build's source list spans modes. Style Dictionary dedupes these silently,
|
|
71
|
+
// dropping one whole mode — 864 such collisions produced a light-only build from
|
|
72
|
+
// a dark-default system.
|
|
73
|
+
export function findModeCollisions(sources) {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
for (const { file, dtcg } of sources) {
|
|
76
|
+
for (const [path, value] of Object.entries(flattenDtcg(dtcg))) {
|
|
77
|
+
if (!seen.has(path)) seen.set(path, []);
|
|
78
|
+
seen.get(path).push({ file, value });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const collisions = [];
|
|
82
|
+
for (const [path, defs] of seen) {
|
|
83
|
+
const distinct = new Set(defs.map((d) => JSON.stringify(d.value)));
|
|
84
|
+
if (defs.length > 1 && distinct.size > 1) collisions.push({ path, defs });
|
|
85
|
+
}
|
|
86
|
+
return collisions;
|
|
87
|
+
}
|