@hegemonart/get-design-done 1.33.6 → 1.34.1

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.
@@ -0,0 +1,273 @@
1
+ # Native Platforms — Token-Bridge Spec
2
+
3
+ This reference is the **token→native-code bridge**: it specifies how the canonical
4
+ CSS-token form produced by the Phase-23 token engine
5
+ (`scripts/lib/design-tokens/`) maps onto the three native theme systems —
6
+ SwiftUI, Jetpack Compose, and Flutter — and pins down the **precision contract**
7
+ that defines what "token identity preserved" means for the deterministic
8
+ round-trip (`reference/native-platforms.md` is the authority that
9
+ `test/suite/native-token-bridge.test.cjs` asserts against).
10
+
11
+ It is the sibling of [`reference/platforms.md`](./platforms.md). Those two files
12
+ have distinct jobs and must not be confused:
13
+
14
+ | File | Job |
15
+ | --- | --- |
16
+ | `reference/platforms.md` (Phase 19) | Interaction **conventions** — navigation, safe areas, gestures, native typography, haptics. *Behavioral* knowledge the executors reference when laying out a screen. |
17
+ | `reference/native-platforms.md` (Phase 34.1, this file) | The token→theme **bridge** — how a design token (`#3B82F6`, `16px`, `Inter`) becomes a SwiftUI `Color` / Compose `Color(0x…)` / Flutter `Color(0x…)`, plus the precision contract for the round-trip. *Structural* knowledge the emitters implement. |
18
+
19
+ Per **D-02** the bridge **extends** the Phase-23 engine with three new emitters
20
+ (`scripts/lib/design-tokens/{swift,compose,flutter}.cjs`) rather than forking a
21
+ separate native IR. There is one canonical token form (below) and one set of
22
+ readers; the native emitters are additional *sinks* on the same facade. Per
23
+ **D-10** the round-trip operates at the **token level** — deterministic emit +
24
+ re-extract with documented precision — never full-view parsing and never a live
25
+ simulator, so the default `npm test` stays green on any machine.
26
+
27
+ ---
28
+
29
+ ## 1. Purpose
30
+
31
+ Phase 19 shipped platform *references* but zero generators; Phase 23 shipped a
32
+ multi-source token reader (`css-vars` / `js-const` / `tailwind` / `figma`) that
33
+ all normalise to a single flat `{ tokens: Record<string, string> }` map. Phase
34
+ 34.1 crosses from platform-knowledge into platform-execution: instead of each
35
+ native executor re-deriving "how does `#3B82F6` become a SwiftUI `Color`", the
36
+ mapping lives once here (the spec) and once in the emitters (the
37
+ implementation), and the executors consume it. This amortizes the Phase-23 token
38
+ investment across SwiftUI / Compose / Flutter.
39
+
40
+ The canonical CSS-token form is the **single input** to all three native
41
+ emitters. This spec maps that one input to three native theme systems and states
42
+ the precision each mapping preserves.
43
+
44
+ ---
45
+
46
+ ## 2. Canonical input shape
47
+
48
+ The emitters consume the exact map shape the Phase-23 readers return: a **flat
49
+ `{ tokens: Record<string, string> }` object** whose keys are the design-token
50
+ names with the leading `--` stripped (exactly as `css-vars.cjs` returns) and
51
+ whose values are the raw token values as strings.
52
+
53
+ ```js
54
+ { tokens: { "color-primary": "#3B82F6", "space-4": "16px", "font-family-body": "Inter, system-ui" } }
55
+ ```
56
+
57
+ Each emitter accepts either the full Phase-23 `TokenSet`
58
+ (`{ tokens, source?, format? }`) **or** a bare `{ tokens }` object — it reads
59
+ `tokenSet.tokens` and throws a `TypeError` only when no `.tokens` object is
60
+ present.
61
+
62
+ ### Prefix → category inference
63
+
64
+ Token **category** is inferred from the key prefix. The emitters use this table
65
+ to decide whether a value is a color, a dimension, or a string:
66
+
67
+ | Key prefix | Category | Native treatment |
68
+ | --- | --- | --- |
69
+ | `color-` | color | hex → native channel form (§3–§5, §6 COLOR) |
70
+ | `space-`, `spacing-` | dimension | px → pt / dp / logical px (§6 DIMENSION) |
71
+ | `radius-` | dimension | px → pt / dp / logical px |
72
+ | `size-` | dimension | px → pt / dp / logical px |
73
+ | `font-`, `text-` | typography | string pass-through (family / weight / named size) |
74
+ | `shadow-` | other | string pass-through (composite values are non-mappable) |
75
+ | *(anything else)* | other | value-sniffed: a `#…` value is treated as color, an `Npx` value as dimension, otherwise string pass-through |
76
+
77
+ A value is **always** re-sniffed regardless of prefix, so a `#…` value under a
78
+ non-color prefix is still emitted as a color and a `Npx` value under a non-space
79
+ prefix is still emitted as a dimension. The prefix is the hint; the value is the
80
+ authority. This keeps the bridge robust against arbitrary token-naming schemes.
81
+
82
+ ---
83
+
84
+ ## 3. SwiftUI mapping
85
+
86
+ Target: a Swift source string exposing an `enum` of static theme constants
87
+ (`enum GDDTheme { … }`) — colors as `Color`, dimensions as `CGFloat` points,
88
+ typography families as `String` (and an optional `Font` helper).
89
+
90
+ | Token | SwiftUI form |
91
+ | --- | --- |
92
+ | color `#RRGGBB` | `Color(red: R/255.0, green: G/255.0, blue: B/255.0, opacity: A/255.0)` from the 8-bit channels |
93
+ | dimension `Npx` | `CGFloat` point literal — integer `N` (pt) |
94
+ | font-family | `String` literal (`"Inter, system-ui"`) |
95
+
96
+ Illustrative (2-line) snippet:
97
+
98
+ ```swift
99
+ static let colorPrimary = Color(red: 59.0/255.0, green: 130.0/255.0, blue: 246.0/255.0, opacity: 255.0/255.0)
100
+ static let space4: CGFloat = 16
101
+ ```
102
+
103
+ SwiftUI uses normalized `0.0…1.0` channel fractions; to keep the round-trip
104
+ **exact** the emitter writes each channel as the 8-bit numerator over `255.0`
105
+ (e.g. `59.0/255.0`) rather than a pre-divided decimal — the re-extractor reads
106
+ the numerator back as the integer channel, avoiding float drift. The `Color` /
107
+ `Font` / `ViewModifier` consumption pattern (applying the constants to views) is
108
+ the executor's job; this emitter produces the *constants*.
109
+
110
+ ---
111
+
112
+ ## 4. Jetpack Compose mapping
113
+
114
+ Target: a Kotlin source string with `Color` vals, a `Shapes` block (from
115
+ `radius-` tokens), a `Typography` block (from `font-`/`text-` tokens), and a
116
+ `MaterialTheme` wiring (`object GDDTheme { val Colors… ; val Shapes… ; val Typography… }`).
117
+
118
+ | Token | Compose form |
119
+ | --- | --- |
120
+ | color `#RRGGBB` | `Color(0xAARRGGBB)` long literal (alpha-first, 8 hex digits) |
121
+ | dimension `Npx` | `N.dp` (integer dp) |
122
+ | radius `Npx` | `RoundedCornerShape(N.dp)` inside `Shapes` |
123
+ | typography family | `String` (fed into a `TextStyle.fontFamily` slot / `Typography`) |
124
+
125
+ Illustrative (2-line) snippet:
126
+
127
+ ```kotlin
128
+ val colorPrimary = Color(0xFF3B82F6)
129
+ val space4 = 16.dp
130
+ ```
131
+
132
+ Compose packs ARGB into a single `0xAARRGGBB` long; alpha is the high byte. The
133
+ re-extractor reads the 8 hex digits straight back to the channels.
134
+
135
+ ---
136
+
137
+ ## 5. Flutter mapping
138
+
139
+ Target: a Dart source string building a `ThemeData` whose `colorScheme`
140
+ (`ColorScheme`) carries the color tokens and whose `textTheme` (`TextTheme`)
141
+ carries the typography tokens, plus a constants class
142
+ (`class GDDTheme { … }`).
143
+
144
+ | Token | Flutter form |
145
+ | --- | --- |
146
+ | color `#RRGGBB` | `Color(0xAARRGGBB)` (alpha-first, 8 hex digits) |
147
+ | dimension `Npx` | logical-px **double** — `N.0` |
148
+ | typography family | `String` (`fontFamily: 'Inter'`) |
149
+
150
+ Illustrative (2-line) snippet:
151
+
152
+ ```dart
153
+ static const colorPrimary = Color(0xFF3B82F6);
154
+ static const space4 = 16.0;
155
+ ```
156
+
157
+ Flutter measures in logical pixels and keeps the value as a `double` (`16.0`),
158
+ so — unlike pt/dp — Flutter dimensions are **not** rounded to integers; the
159
+ fractional part survives.
160
+
161
+ ---
162
+
163
+ ## 6. PRECISION CONTRACT
164
+
165
+ This section is the crux. It defines, per value category, exactly what
166
+ information the emit → re-extract round-trip preserves. The test asserts token
167
+ identity **within this precision** — not bit-exact floats, not lossy
168
+ approximation. An emitter is correct **iff** `reextract(emit({tokens}))`
169
+ reproduces every token in the identity set under these rules.
170
+
171
+ ### COLOR — 8-bit-per-channel, EXACT
172
+
173
+ - Accepted input forms: `#RGB`, `#RRGGBB`, `#RGBA`, `#RRGGBBAA` (case-insensitive).
174
+ - `#RGB` / `#RGBA` shorthand **expands** to `#RRGGBB` / `#RRGGBBAA` by
175
+ duplicating each nibble (`#3af` → `#33aaff`). This expansion is part of the
176
+ contract: the re-extractor recovers the **expanded** `#RRGGBB(AA)` form, so
177
+ `#3af` round-trips to `#33aaff` (canonically equal, the documented identity).
178
+ - Each channel is an 8-bit integer (0–255) and is preserved **exactly** — no
179
+ channel may be off by one. SwiftUI stores channels as `N.0/255.0` numerators;
180
+ Compose/Flutter store them in a `0xAARRGGBB` literal. Both forms recover the
181
+ identical 8-bit channels.
182
+ - **Alpha:** when the input has no alpha (`#RGB`/`#RRGGBB`) the emitted color is
183
+ **opaque** — alpha byte `0xFF` (Compose/Flutter) / `opacity: 255.0/255.0`
184
+ (SwiftUI). The re-extractor emits an alpha channel **only when the original
185
+ had one**: a 6-digit input round-trips to a 6-digit `#RRGGBB` (the implied
186
+ opaque alpha is dropped on the way back); an 8-digit input round-trips to the
187
+ 8-digit `#RRGGBBAA`. This keeps `#3B82F6 → #3B82F6` an exact identity.
188
+
189
+ ### DIMENSION — integer pt/dp, logical-px double
190
+
191
+ - Accepted input: `Npx` or a bare unit-less number (`16px`, `16`). The unit is
192
+ normalised to `px` on the canonical side.
193
+ - **iOS (pt) / Android (dp):** rounded to the nearest integer, **round-half-up**
194
+ (`15.5px` → `16`). Because rounding is lossy for non-integers, the round-trip
195
+ **identity set** is restricted to integer-px dimensions (e.g. `16px`), which
196
+ recover exactly: `16px → 16 (pt/dp) → 16px`.
197
+ - **Flutter (logical px):** kept as a `double` (`16px` → `16.0`), so Flutter
198
+ does **not** round and a fractional dimension survives. The re-extractor
199
+ recovers `Npx` by stripping the trailing `.0` for whole numbers.
200
+ - The re-extractor always recovers the canonical `Npx` string form, so the
201
+ emit→re-extract identity for an integer dimension is `"16px" === "16px"`.
202
+
203
+ ### `rem` / `em` — passed through verbatim (non-mappable)
204
+
205
+ `rem`/`em` values depend on a root/element font-size that the token map does not
206
+ carry, so they are **not** converted. They are treated as **non-mappable**
207
+ (below): emitted verbatim into a raw slot and **excluded** from the round-trip
208
+ identity set. (A future plan may add an explicit base-size option; until then,
209
+ verbatim pass-through is the stated, deterministic behavior.)
210
+
211
+ ### TYPOGRAPHY / NAMED VALUES — string pass-through
212
+
213
+ `font-family`, `font-weight`, named sizes, and any other string token are
214
+ emitted **verbatim** as a string literal and recovered **string-equal**
215
+ (`"Inter, system-ui" → "Inter, system-ui"`). No normalisation, no quoting
216
+ changes that alter the recovered string.
217
+
218
+ ### NON-MAPPABLE — verbatim, EXCLUDED from the identity set
219
+
220
+ Values the emitter cannot represent as a native primitive — CSS `var(--x)`
221
+ references, `calc(…)` expressions, gradients (`linear-gradient(…)`), and `rem`/
222
+ `em` dimensions — are **passed through verbatim** into a raw-string slot
223
+ (a trailing comment such as `// non-mappable: <name> = <value>` or the language
224
+ equivalent) so no information is lost, and are **explicitly excluded** from the
225
+ round-trip identity assertion. The contract documents them as
226
+ "verbatim / not round-tripped": the test asserts the verbatim value appears in
227
+ the emitted source, and does **not** assert it survives re-extraction as a typed
228
+ token.
229
+
230
+ ---
231
+
232
+ ## 7. The round-trip (what the test locks)
233
+
234
+ For each emitter the bridge guarantees:
235
+
236
+ 1. **Determinism.** `emit(x) === emit(x)` byte-for-byte. Keys are iterated in a
237
+ stable sorted order; no `Date`, no `process.env`, no filesystem, no network in
238
+ the emit path (D-10).
239
+ 2. **Identity within precision.** For the identity set (color + integer
240
+ dimension + typography), `reextract(emit({tokens}))` deep-equals `{tokens}`
241
+ under the precision rules above (8-bit color channels exact with `#RGB`
242
+ expansion; integer pt/dp; logical-px double; family/weight string-equal).
243
+ 3. **Verbatim exclusion.** Non-mappable values appear verbatim in the source and
244
+ are not part of the identity assertion.
245
+
246
+ Each emitter module exports a symmetric re-extractor
247
+ (`reextractSwift` / `reextractCompose` / `reextractFlutter`) that parses the
248
+ emitted native source back into a `{ tokens }` map, so the round-trip is
249
+ deterministic and bijective on the identity set and reusable by the Phase-34.1
250
+ regression baseline.
251
+
252
+ ---
253
+
254
+ ## 8. Registration
255
+
256
+ This reference is registered in
257
+ [`reference/registry.json`](./registry.json) as the `native-platforms` entry
258
+ (type `heuristic`, phase `34.1`) so the registry round-trip test
259
+ (`test/suite/reference-registry.test.cjs`) stays green — every `reference/*.md`
260
+ must be registered and resolve (D-05, the 33.5-01 lesson).
261
+
262
+ ---
263
+
264
+ ## 9. Cross-references
265
+
266
+ - [`reference/platforms.md`](./platforms.md) — the interaction-conventions
267
+ sibling (navigation, safe areas, gestures, native typography). Executors read
268
+ **both**: this file for the token→theme bridge, that file for layout/behavior.
269
+ - `scripts/lib/design-tokens/` — the Phase-23 token engine this bridge extends
270
+ (`index.cjs` facade + `css-vars` / `js-const` / `tailwind` / `figma` readers +
271
+ the new `swift` / `compose` / `flutter` emitters).
272
+ - `test/fixtures/mapper-outputs/tokens.json` — the canonical token fixture the
273
+ round-trip test derives its map from.
@@ -888,6 +888,13 @@
888
888
  "type": "data",
889
889
  "phase": 33.6,
890
890
  "description": "Phase 33.6 catalog-derived OpenRouter price sub-table — per-model prompt/completion $/tok snapshot of .design/cache/openrouter-models.json; derived view, the dynamic catalog is the source of truth (D-11 registry round-trip)."
891
+ },
892
+ {
893
+ "name": "native-platforms",
894
+ "path": "reference/native-platforms.md",
895
+ "type": "heuristic",
896
+ "phase": 34.1,
897
+ "description": "Phase 34.1 token-bridge spec — maps the canonical CSS-token form (Phase 23) to SwiftUI Color/Font/ViewModifier, Jetpack Compose Color/Shapes/Typography/MaterialTheme, and Flutter ThemeData/ColorScheme/TextTheme, with the precision contract (color hex→8-bit channels exact, dimension px→pt/dp/logical px) defining token-identity for the round-trip."
891
898
  }
892
899
  ]
893
900
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * design-tokens/_native-shared.cjs — shared precision-contract helpers for the
3
+ * native emitters (swift.cjs / compose.cjs / flutter.cjs), Phase 34.1 Plan 01.
4
+ *
5
+ * The single implementation of the PRECISION CONTRACT documented in
6
+ * reference/native-platforms.md, so all three emitters convert color /
7
+ * dimension / typography / non-mappable values identically:
8
+ * COLOR hex #RGB/#RGBA/#RRGGBB/#RRGGBBAA -> 8-bit channels EXACT;
9
+ * #RGB/#RGBA expand by nibble duplication; alpha tracked.
10
+ * DIMENSION Npx | unit-less -> integer (round-half-up) + logical-px double.
11
+ * TYPOGRAPHY strings -> pass-through.
12
+ * NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim, excluded.
13
+ *
14
+ * Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ // Markers embedded in every emitted line so the symmetric re-extractors can
20
+ // recover the EXACT canonical token key (native identifiers are mangled, e.g.
21
+ // `color-primary` -> `colorPrimary`, so the key must travel alongside).
22
+ const TOKEN_MARKER = '// token: ';
23
+ const NONMAPPABLE_MARKER = '// non-mappable: ';
24
+
25
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
26
+ const PX_RE = /^-?\d+(?:\.\d+)?px$/;
27
+ const UNITLESS_RE = /^-?\d+(?:\.\d+)?$/;
28
+ const NONMAPPABLE_RE = /(^var\(|^calc\(|gradient\(|\b(rem|em)$|\d(rem|em)$)/i;
29
+
30
+ /**
31
+ * Read `.tokens` from a Phase-23 TokenSet or a bare {tokens} map.
32
+ * @param {*} tokenSet
33
+ * @returns {Record<string,string>}
34
+ */
35
+ function readTokens(tokenSet) {
36
+ if (
37
+ !tokenSet ||
38
+ typeof tokenSet !== 'object' ||
39
+ !tokenSet.tokens ||
40
+ typeof tokenSet.tokens !== 'object'
41
+ ) {
42
+ throw new TypeError(
43
+ 'native emitter: expected { tokens: Record<string,string> } (or a Phase-23 TokenSet)',
44
+ );
45
+ }
46
+ return tokenSet.tokens;
47
+ }
48
+
49
+ /**
50
+ * Stable, sorted [key, value] entries so emit(x) === emit(x) byte-for-byte.
51
+ * @param {Record<string,string>} tokens
52
+ * @returns {[string,string][]}
53
+ */
54
+ function sortedEntries(tokens) {
55
+ return Object.keys(tokens)
56
+ .sort()
57
+ .map((k) => [k, String(tokens[k])]);
58
+ }
59
+
60
+ /**
61
+ * Classify a token value per the precision contract. The value is the
62
+ * authority; the key prefix is only a hint (see native-platforms.md §2).
63
+ * @param {string} _key
64
+ * @param {string} value
65
+ * @returns {'non-mappable'|'color'|'dimension'|'typography'}
66
+ */
67
+ function classify(_key, value) {
68
+ const v = String(value).trim();
69
+ if (NONMAPPABLE_RE.test(v)) return 'non-mappable';
70
+ if (HEX_RE.test(v)) return 'color';
71
+ if (PX_RE.test(v) || UNITLESS_RE.test(v)) return 'dimension';
72
+ return 'typography';
73
+ }
74
+
75
+ /**
76
+ * Expand #RGB/#RGBA shorthand to #RRGGBB/#RRGGBBAA and split into 8-bit
77
+ * channels. Alpha defaults to opaque (255) when absent; `hadAlpha` records
78
+ * whether the source carried an alpha channel so the re-extractor can emit a
79
+ * 6- or 8-digit hex faithfully.
80
+ * @param {string} hex
81
+ * @returns {{r:number,g:number,b:number,a:number,hadAlpha:boolean}}
82
+ */
83
+ function parseHexChannels(hex) {
84
+ let h = String(hex).trim().replace(/^#/, '');
85
+ let hadAlpha = false;
86
+ if (h.length === 3) {
87
+ h = h.split('').map((c) => c + c).join('');
88
+ } else if (h.length === 4) {
89
+ h = h.split('').map((c) => c + c).join('');
90
+ hadAlpha = true;
91
+ } else if (h.length === 8) {
92
+ hadAlpha = true;
93
+ }
94
+ const r = parseInt(h.slice(0, 2), 16);
95
+ const g = parseInt(h.slice(2, 4), 16);
96
+ const b = parseInt(h.slice(4, 6), 16);
97
+ const a = hadAlpha ? parseInt(h.slice(6, 8), 16) : 255;
98
+ return { r, g, b, a, hadAlpha };
99
+ }
100
+
101
+ /**
102
+ * Recover the canonical hex from 8-bit channels. Emits 6-digit #RRGGBB when
103
+ * the original had no alpha, 8-digit #RRGGBBAA when it did (contract: implied
104
+ * opaque alpha is dropped on the way back so #3B82F6 round-trips exactly).
105
+ * @param {number} r
106
+ * @param {number} g
107
+ * @param {number} b
108
+ * @param {number} a
109
+ * @param {boolean} hadAlpha
110
+ * @returns {string}
111
+ */
112
+ function channelsToHex(r, g, b, a, hadAlpha) {
113
+ const h2 = (n) => (n & 0xff).toString(16).padStart(2, '0');
114
+ const base = `#${h2(r)}${h2(g)}${h2(b)}`;
115
+ return hadAlpha ? `${base}${h2(a)}` : base;
116
+ }
117
+
118
+ /**
119
+ * Pack channels into a Compose/Flutter 0xAARRGGBB literal (uppercase digits).
120
+ * @param {{r:number,g:number,b:number,a:number}} ch
121
+ * @returns {string} e.g. "0xFF3B82F6"
122
+ */
123
+ function channelsToArgbLiteral({ r, g, b, a }) {
124
+ const h2 = (n) => (n & 0xff).toString(16).toUpperCase().padStart(2, '0');
125
+ return `0x${h2(a)}${h2(r)}${h2(g)}${h2(b)}`;
126
+ }
127
+
128
+ /**
129
+ * Strip a 0xAARRGGBB literal back into channels.
130
+ * @param {string} literal e.g. "0xFF3B82F6"
131
+ * @returns {{r:number,g:number,b:number,a:number}}
132
+ */
133
+ function argbLiteralToChannels(literal) {
134
+ const h = String(literal).replace(/^0x/i, '').padStart(8, '0');
135
+ return {
136
+ a: parseInt(h.slice(0, 2), 16),
137
+ r: parseInt(h.slice(2, 4), 16),
138
+ g: parseInt(h.slice(4, 6), 16),
139
+ b: parseInt(h.slice(6, 8), 16),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * px (or unit-less) -> nearest integer, round-half-up (for pt/dp).
145
+ * @param {string} value
146
+ * @returns {number}
147
+ */
148
+ function pxToInt(value) {
149
+ const n = parseFloat(String(value));
150
+ return Math.floor(n + 0.5);
151
+ }
152
+
153
+ /**
154
+ * px (or unit-less) -> logical-px double form string for Dart (`16` -> `16.0`).
155
+ * @param {string} value
156
+ * @returns {string}
157
+ */
158
+ function pxToDouble(value) {
159
+ const n = parseFloat(String(value));
160
+ return Number.isInteger(n) ? `${n}.0` : String(n);
161
+ }
162
+
163
+ /**
164
+ * Recover the canonical `Npx` string from an emitted numeric form.
165
+ * @param {string|number} num
166
+ * @returns {string}
167
+ */
168
+ function numToPx(num) {
169
+ const n = parseFloat(String(num));
170
+ return Number.isInteger(n) ? `${n}px` : `${n}px`;
171
+ }
172
+
173
+ /**
174
+ * dash-case token -> camelCase Swift/Kotlin/Dart identifier.
175
+ * `color-primary` -> `colorPrimary`; leading digit gets a `t` prefix.
176
+ * @param {string} key
177
+ * @returns {string}
178
+ */
179
+ function swiftIdent(key) {
180
+ let id = String(key).replace(/[^A-Za-z0-9]+(.)?/g, (_, c) =>
181
+ c ? c.toUpperCase() : '',
182
+ );
183
+ if (!id) id = 'token';
184
+ if (/^[0-9]/.test(id)) id = 't' + id;
185
+ return id;
186
+ }
187
+
188
+ module.exports = {
189
+ TOKEN_MARKER,
190
+ NONMAPPABLE_MARKER,
191
+ HEX_RE,
192
+ PX_RE,
193
+ UNITLESS_RE,
194
+ NONMAPPABLE_RE,
195
+ readTokens,
196
+ sortedEntries,
197
+ classify,
198
+ parseHexChannels,
199
+ channelsToHex,
200
+ channelsToArgbLiteral,
201
+ argbLiteralToChannels,
202
+ pxToInt,
203
+ pxToDouble,
204
+ numToPx,
205
+ swiftIdent,
206
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * design-tokens/compose.cjs — Jetpack Compose (Kotlin) theme emitter +
3
+ * re-extractor (Phase 34.1 Plan 01).
4
+ *
5
+ * Consumes the canonical Phase-23 token map ({ tokens: Record<string,string> })
6
+ * and emits a deterministic Kotlin theme source string: `Color` vals, a
7
+ * `Shapes` block (radius tokens), a `Typography` block (font/text tokens), and
8
+ * a `MaterialTheme` wiring object — per the PRECISION CONTRACT in
9
+ * reference/native-platforms.md.
10
+ *
11
+ * Precision contract:
12
+ * COLOR hex -> Color(0xAARRGGBB) long. 8-bit channels EXACT; #RGB
13
+ * expands; alpha 0xFF when absent.
14
+ * DIMENSION Npx -> N.dp (integer dp, round-half-up).
15
+ * TYPOGRAPHY strings -> Kotlin string literal, pass-through.
16
+ * NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim comment, EXCLUDED.
17
+ *
18
+ * Each emitted token line carries a `// token: <original-key>` marker so the
19
+ * symmetric `reextractCompose(source)` recovers the exact canonical key+value.
20
+ * Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const {
26
+ TOKEN_MARKER,
27
+ NONMAPPABLE_MARKER,
28
+ readTokens,
29
+ sortedEntries,
30
+ classify,
31
+ parseHexChannels,
32
+ channelsToArgbLiteral,
33
+ argbLiteralToChannels,
34
+ channelsToHex,
35
+ pxToInt,
36
+ swiftIdent,
37
+ } = require('./_native-shared.cjs');
38
+
39
+ const RADIUS_RE = /(^|[^a-z])radius/i;
40
+
41
+ /**
42
+ * Emit a Jetpack Compose / Kotlin theme source string from a token set.
43
+ *
44
+ * @param {{tokens: Record<string,string>}|Record<string,string>} tokenSet
45
+ * @returns {string}
46
+ */
47
+ function emitCompose(tokenSet) {
48
+ const tokens = readTokens(tokenSet);
49
+ const entries = sortedEntries(tokens);
50
+ const lines = [];
51
+ lines.push('// Generated by get-design-done — Jetpack Compose theme tokens.');
52
+ lines.push('// See reference/native-platforms.md (token-bridge precision contract).');
53
+ lines.push('import androidx.compose.ui.graphics.Color');
54
+ lines.push('import androidx.compose.ui.unit.dp');
55
+ lines.push('import androidx.compose.material3.Shapes');
56
+ lines.push('import androidx.compose.material3.Typography');
57
+ lines.push('import androidx.compose.foundation.shape.RoundedCornerShape');
58
+ lines.push('');
59
+ lines.push('object GDDTheme {');
60
+
61
+ // Colors
62
+ for (const [key, value] of entries) {
63
+ if (classify(key, value) !== 'color') continue;
64
+ const { r, g, b, a, hadAlpha } = parseHexChannels(value);
65
+ lines.push(
66
+ ` val ${swiftIdent(key)} = Color(${channelsToArgbLiteral({ r, g, b, a })}) ${TOKEN_MARKER}${key} hadAlpha=${hadAlpha ? 1 : 0}`,
67
+ );
68
+ }
69
+
70
+ // Dimensions (dp)
71
+ for (const [key, value] of entries) {
72
+ if (classify(key, value) !== 'dimension') continue;
73
+ lines.push(
74
+ ` val ${swiftIdent(key)} = ${pxToInt(value)}.dp ${TOKEN_MARKER}${key}`,
75
+ );
76
+ }
77
+
78
+ // Typography (strings)
79
+ for (const [key, value] of entries) {
80
+ if (classify(key, value) !== 'typography') continue;
81
+ lines.push(
82
+ ` val ${swiftIdent(key)} = ${JSON.stringify(value)} ${TOKEN_MARKER}${key}`,
83
+ );
84
+ }
85
+
86
+ // Non-mappable — verbatim, excluded from identity set
87
+ for (const [key, value] of entries) {
88
+ if (classify(key, value) !== 'non-mappable') continue;
89
+ lines.push(` ${NONMAPPABLE_MARKER}${key} = ${value}`);
90
+ }
91
+
92
+ // Shapes block (from radius dimension tokens) — wires dp into RoundedCornerShape
93
+ lines.push('');
94
+ lines.push(' val Shapes = Shapes(');
95
+ for (const [key, value] of entries) {
96
+ if (classify(key, value) !== 'dimension') continue;
97
+ if (!RADIUS_RE.test(key)) continue;
98
+ lines.push(` // ${key}: RoundedCornerShape(${pxToInt(value)}.dp)`);
99
+ }
100
+ lines.push(' )');
101
+
102
+ // Typography block (MaterialTheme wiring) — references the font tokens above
103
+ lines.push(' val Typography = Typography()');
104
+ lines.push('}');
105
+ lines.push('');
106
+ return lines.join('\n');
107
+ }
108
+
109
+ const COMPOSE_COLOR_RE = new RegExp(
110
+ String.raw`val \w+ = Color\((0x[0-9a-fA-F]{8})\)\s*` +
111
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
112
+ String.raw`(\S+)\s+hadAlpha=([01])`,
113
+ );
114
+ const COMPOSE_DIM_RE = new RegExp(
115
+ String.raw`val \w+ = (\d+)\.dp\s*` +
116
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
117
+ String.raw`(\S+)`,
118
+ );
119
+ const COMPOSE_STR_RE = new RegExp(
120
+ String.raw`val \w+ = ("(?:[^"\\]|\\.)*")\s*` +
121
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
122
+ String.raw`(\S+)`,
123
+ );
124
+
125
+ /**
126
+ * Recover the canonical token map from an emitted Compose source string.
127
+ * Non-mappable lines and the Shapes-comment lines are NOT recovered.
128
+ *
129
+ * @param {string} source
130
+ * @returns {{tokens: Record<string,string>}}
131
+ */
132
+ function reextractCompose(source) {
133
+ /** @type {Record<string,string>} */
134
+ const tokens = {};
135
+ for (const line of String(source).split(/\r?\n/)) {
136
+ if (line.includes(NONMAPPABLE_MARKER)) continue;
137
+ let m;
138
+ if ((m = COMPOSE_COLOR_RE.exec(line))) {
139
+ const ch = argbLiteralToChannels(m[1]);
140
+ tokens[m[2]] = channelsToHex(ch.r, ch.g, ch.b, ch.a, m[3] === '1');
141
+ } else if ((m = COMPOSE_DIM_RE.exec(line))) {
142
+ tokens[m[2]] = `${m[1]}px`;
143
+ } else if ((m = COMPOSE_STR_RE.exec(line))) {
144
+ tokens[m[2]] = JSON.parse(m[1]);
145
+ }
146
+ }
147
+ return { tokens };
148
+ }
149
+
150
+ module.exports = { emitCompose, reextractCompose };