@hegemonart/get-design-done 1.33.5 → 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.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +48 -0
  4. package/README.md +14 -0
  5. package/SKILL.md +1 -0
  6. package/agents/compose-executor.md +142 -0
  7. package/agents/design-authority-watcher.md +4 -0
  8. package/agents/design-context-builder.md +35 -1
  9. package/agents/design-verifier.md +14 -18
  10. package/agents/flutter-executor.md +147 -0
  11. package/agents/swift-executor.md +226 -0
  12. package/connections/android-emulator.md +107 -0
  13. package/connections/connections.md +6 -0
  14. package/connections/openrouter.md +86 -0
  15. package/connections/xcode-simulator.md +108 -0
  16. package/hooks/budget-enforcer.ts +103 -0
  17. package/package.json +3 -2
  18. package/reference/gdd-threat-model.md +63 -0
  19. package/reference/native-platforms.md +273 -0
  20. package/reference/openrouter-tier-mapping.md +98 -0
  21. package/reference/prices.openrouter.md +26 -0
  22. package/reference/registry.json +21 -0
  23. package/scripts/lib/authority-watcher/index.cjs +147 -0
  24. package/scripts/lib/budget-enforcer.cjs +16 -0
  25. package/scripts/lib/design-tokens/_native-shared.cjs +206 -0
  26. package/scripts/lib/design-tokens/compose.cjs +150 -0
  27. package/scripts/lib/design-tokens/flutter.cjs +128 -0
  28. package/scripts/lib/design-tokens/index.cjs +13 -0
  29. package/scripts/lib/design-tokens/swift.cjs +122 -0
  30. package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
  31. package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
  32. package/sdk/event-stream/types.ts +24 -2
  33. package/skills/openrouter-status/SKILL.md +86 -0
@@ -170,6 +170,147 @@ function buildKfmCandidate(article, options) {
170
170
  };
171
171
  }
172
172
 
173
+ // -------------------------------------------------------------------
174
+ // Plan 33.6-03 (SC#8) — OpenRouter catalog drift
175
+ // -------------------------------------------------------------------
176
+
177
+ /**
178
+ * Index a catalog `models[]` by id, keeping only well-formed rows
179
+ * (objects with a non-empty string `id`). Last write wins on dup ids.
180
+ *
181
+ * @param {unknown} models
182
+ * @returns {Map<string, object>}
183
+ */
184
+ function indexById(models) {
185
+ const map = new Map();
186
+ if (!Array.isArray(models)) return map;
187
+ for (const m of models) {
188
+ if (m && typeof m === 'object' && typeof m.id === 'string' && m.id.length > 0) {
189
+ map.set(m.id, m);
190
+ }
191
+ }
192
+ return map;
193
+ }
194
+
195
+ /**
196
+ * Best-effort "is this model flagged deprecated?" signal. Tolerant of the
197
+ * several shapes an upstream catalog might use: a boolean `deprecated`,
198
+ * or a `status`/`lifecycle`/`availability` string containing "deprecat".
199
+ *
200
+ * @param {object} model
201
+ * @returns {boolean}
202
+ */
203
+ function isDeprecatedFlag(model) {
204
+ if (!model || typeof model !== 'object') return false;
205
+ if (model.deprecated === true) return true;
206
+ for (const key of ['status', 'lifecycle', 'availability', 'state']) {
207
+ const v = model[key];
208
+ if (typeof v === 'string' && /deprecat/i.test(v)) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Stable string-compare of a single pricing field. A pricing field is the
215
+ * catalog's `pricing.prompt` / `pricing.completion` (strings like "0.000003").
216
+ * Compares as strings (the catalog stores them as strings) after a defensive
217
+ * String() coercion; missing → ''.
218
+ */
219
+ function priceStr(model, field) {
220
+ if (!model || typeof model !== 'object' || !model.pricing || typeof model.pricing !== 'object') {
221
+ return '';
222
+ }
223
+ const v = model.pricing[field];
224
+ return v === undefined || v === null ? '' : String(v);
225
+ }
226
+
227
+ function pricingChanged(prevModel, currModel) {
228
+ return (
229
+ priceStr(prevModel, 'prompt') !== priceStr(currModel, 'prompt') ||
230
+ priceStr(prevModel, 'completion') !== priceStr(currModel, 'completion')
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Diff a prior vs current OpenRouter catalog (`models[]` arrays) and classify
236
+ * each delta. Plan 33.6-03 / SC#8.
237
+ *
238
+ * Classification per id:
239
+ * - `new-model` — id in curr, not in prev.
240
+ * - `withdrawn` — id in prev, not in curr.
241
+ * - `deprecated` — id in BOTH, and the curr row carries a deprecated/status
242
+ * flag (best-effort `isDeprecatedFlag`). Takes precedence
243
+ * over a coincident pricing change.
244
+ * - `pricing-change` — id in BOTH, not deprecated, with a changed
245
+ * pricing.prompt or pricing.completion.
246
+ * - (unchanged ids produce NO entry.)
247
+ *
248
+ * Surfacing (the actionable "a model you pinned is going away" signal):
249
+ * `surfaced === true` ONLY when `change ∈ {deprecated, withdrawn}` AND the id
250
+ * is in `options.overrides` (the configured `openrouter_tier_overrides`
251
+ * values). `new-model` / `pricing-change` are classified but `surfaced:false`
252
+ * (noise control per CONTEXT). A deprecated/withdrawn id NOT in overrides is
253
+ * also `surfaced:false`.
254
+ *
255
+ * Pure, zero deps, NEVER throws — garbage inputs degrade to `[]`.
256
+ *
257
+ * @param {Array<object>} prevModels prior catalog `models[]`
258
+ * @param {Array<object>} currModels current catalog `models[]`
259
+ * @param {object} [options]
260
+ * @param {Array<string>} [options.overrides] configured openrouter_tier_overrides id values
261
+ * @returns {Array<{id:string, change:('new-model'|'pricing-change'|'deprecated'|'withdrawn'), surfaced:boolean}>}
262
+ */
263
+ function diffOpenRouterCatalog(prevModels, currModels, options) {
264
+ try {
265
+ const prev = indexById(prevModels);
266
+ const curr = indexById(currModels);
267
+
268
+ const opts = options && typeof options === 'object' ? options : {};
269
+ const overrideList = Array.isArray(opts.overrides) ? opts.overrides : [];
270
+ const overrides = new Set(overrideList.filter(x => typeof x === 'string' && x.length > 0));
271
+
272
+ /** @type {Array<{id:string, change:string, surfaced:boolean}>} */
273
+ const out = [];
274
+
275
+ // Deterministic id order: union of all ids, sorted ascending. Keeps the
276
+ // output stable for a fixed input pair (no Date, no randomness).
277
+ const allIds = new Set([...prev.keys(), ...curr.keys()]);
278
+ const sortedIds = [...allIds].sort();
279
+
280
+ for (const id of sortedIds) {
281
+ const inPrev = prev.has(id);
282
+ const inCurr = curr.has(id);
283
+ let change = null;
284
+
285
+ if (inCurr && !inPrev) {
286
+ change = 'new-model';
287
+ } else if (inPrev && !inCurr) {
288
+ change = 'withdrawn';
289
+ } else {
290
+ // id in both.
291
+ const currModel = curr.get(id);
292
+ if (isDeprecatedFlag(currModel)) {
293
+ change = 'deprecated';
294
+ } else if (pricingChanged(prev.get(id), currModel)) {
295
+ change = 'pricing-change';
296
+ } else {
297
+ continue; // unchanged → no delta
298
+ }
299
+ }
300
+
301
+ const actionable = change === 'deprecated' || change === 'withdrawn';
302
+ const surfaced = actionable && overrides.has(id);
303
+ out.push({ id, change, surfaced });
304
+ }
305
+
306
+ return out;
307
+ } catch {
308
+ // Absolute backstop — diffOpenRouterCatalog NEVER throws (parity with the
309
+ // never-throws discipline of the 33.6 adapter/fetcher).
310
+ return [];
311
+ }
312
+ }
313
+
173
314
  /**
174
315
  * Classify a list of fetched articles into events. Emits one kfm-candidate
175
316
  * per whitelist-matched article. Other articles produce no events here
@@ -192,10 +333,16 @@ module.exports = {
192
333
  classifyArticles,
193
334
  matchesKfmWhitelist,
194
335
  buildKfmCandidate,
336
+ // Plan 33.6-03 (SC#8) — OpenRouter catalog weekly-diff classifier. Added as a
337
+ // sibling pure function; the existing API above is unchanged.
338
+ diffOpenRouterCatalog,
195
339
  // Exposed for tests / advanced consumers.
196
340
  KFM_WHITELIST_PATTERNS,
197
341
  MAX_RAW_EXCERPT,
198
342
  _deriveSymptom: deriveSymptom,
199
343
  _derivePatternHint: derivePatternHint,
200
344
  _truncateExcerpt: truncateExcerpt,
345
+ _indexById: indexById,
346
+ _isDeprecatedFlag: isDeprecatedFlag,
347
+ _pricingChanged: pricingChanged,
201
348
  };
@@ -407,6 +407,15 @@ function computeCost(args, opts) {
407
407
  * Phase 27. The peer-CLI ID when `runtime_role === "peer"` (e.g.
408
408
  * `"gemini"`, `"codex"`). Omitted from output when absent or when
409
409
  * `runtime_role === "host"`.
410
+ * @param {string} [args.provider]
411
+ * Phase 33.6 / Plan 33.6-03 (SC#6). The resolution provider — set to
412
+ * `"openrouter"` by the caller when the model was resolved via the
413
+ * OpenRouter tier-resolver adapter
414
+ * (`scripts/lib/tier-resolver-openrouter.cjs`). Additive/back-compat:
415
+ * when absent (the native-resolution default + every pre-33.6 caller)
416
+ * the key is OMITTED — exactly how `peer_id` is omitted for host rows —
417
+ * so the legacy on-disk cost-row shape stays byte-stable. Only a
418
+ * non-empty string is threaded; anything else is dropped.
410
419
  * @returns {object}
411
420
  */
412
421
  function buildCostEventPayload(args) {
@@ -433,6 +442,13 @@ function buildCostEventPayload(args) {
433
442
  : null;
434
443
  out.peer_id = pid;
435
444
  }
445
+ // Phase 33.6 SC#6 — additive provider tag. Threaded ONLY when the caller
446
+ // passes a non-empty string (e.g. "openrouter" when the OpenRouter adapter
447
+ // resolved the model). Omitted otherwise, mirroring peer_id, so the legacy
448
+ // cost-row shape stays stable for native-resolution + pre-33.6 callers.
449
+ if (args && typeof args.provider === 'string' && args.provider.length > 0) {
450
+ out.provider = args.provider;
451
+ }
436
452
  return out;
437
453
  }
438
454
 
@@ -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 };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * design-tokens/flutter.cjs — Flutter (Dart) theme emitter + re-extractor
3
+ * (Phase 34.1 Plan 01).
4
+ *
5
+ * Consumes the canonical Phase-23 token map ({ tokens: Record<string,string> })
6
+ * and emits a deterministic Dart theme source string: a `GDDTheme` constants
7
+ * class plus a `themeData()` builder wiring `ColorScheme` + `TextTheme` into a
8
+ * `ThemeData` — per the PRECISION CONTRACT in reference/native-platforms.md.
9
+ *
10
+ * Precision contract:
11
+ * COLOR hex -> Color(0xAARRGGBB). 8-bit channels EXACT; #RGB expands;
12
+ * alpha 0xFF when absent.
13
+ * DIMENSION Npx -> logical-px double (`16` -> `16.0`); NOT rounded.
14
+ * TYPOGRAPHY strings -> Dart string literal, pass-through.
15
+ * NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim comment, EXCLUDED.
16
+ *
17
+ * Each emitted token line carries a `// token: <original-key>` marker so the
18
+ * symmetric `reextractFlutter(source)` recovers the exact canonical key+value.
19
+ * Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const {
25
+ TOKEN_MARKER,
26
+ NONMAPPABLE_MARKER,
27
+ readTokens,
28
+ sortedEntries,
29
+ classify,
30
+ parseHexChannels,
31
+ channelsToArgbLiteral,
32
+ argbLiteralToChannels,
33
+ channelsToHex,
34
+ pxToDouble,
35
+ swiftIdent,
36
+ } = require('./_native-shared.cjs');
37
+
38
+ /**
39
+ * Emit a Flutter / Dart theme source string from a token set.
40
+ *
41
+ * @param {{tokens: Record<string,string>}|Record<string,string>} tokenSet
42
+ * @returns {string}
43
+ */
44
+ function emitFlutter(tokenSet) {
45
+ const tokens = readTokens(tokenSet);
46
+ const entries = sortedEntries(tokens);
47
+ const lines = [];
48
+ lines.push('// Generated by get-design-done — Flutter theme tokens.');
49
+ lines.push('// See reference/native-platforms.md (token-bridge precision contract).');
50
+ lines.push("import 'package:flutter/material.dart';");
51
+ lines.push('');
52
+ lines.push('class GDDTheme {');
53
+
54
+ for (const [key, value] of entries) {
55
+ const kind = classify(key, value);
56
+ const ident = swiftIdent(key);
57
+ if (kind === 'color') {
58
+ const { r, g, b, a, hadAlpha } = parseHexChannels(value);
59
+ lines.push(
60
+ ` static const ${ident} = Color(${channelsToArgbLiteral({ r, g, b, a })}); ${TOKEN_MARKER}${key} hadAlpha=${hadAlpha ? 1 : 0}`,
61
+ );
62
+ } else if (kind === 'dimension') {
63
+ lines.push(
64
+ ` static const ${ident} = ${pxToDouble(value)}; ${TOKEN_MARKER}${key}`,
65
+ );
66
+ } else if (kind === 'typography') {
67
+ lines.push(
68
+ ` static const ${ident} = ${JSON.stringify(value)}; ${TOKEN_MARKER}${key}`,
69
+ );
70
+ } else {
71
+ lines.push(` ${NONMAPPABLE_MARKER}${key} = ${value}`);
72
+ }
73
+ }
74
+
75
+ // ThemeData builder wiring ColorScheme + TextTheme (MaterialTheme analog).
76
+ lines.push('');
77
+ lines.push(' static ThemeData themeData() => ThemeData(');
78
+ lines.push(' colorScheme: ColorScheme.fromSeed(seedColor: GDDTheme.colorPrimary),');
79
+ lines.push(' textTheme: const TextTheme(),');
80
+ lines.push(' );');
81
+ lines.push('}');
82
+ lines.push('');
83
+ return lines.join('\n');
84
+ }
85
+
86
+ const FLUTTER_COLOR_RE = new RegExp(
87
+ String.raw`static const \w+ = Color\((0x[0-9a-fA-F]{8})\);\s*` +
88
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
89
+ String.raw`(\S+)\s+hadAlpha=([01])`,
90
+ );
91
+ const FLUTTER_DIM_RE = new RegExp(
92
+ String.raw`static const \w+ = (\d+(?:\.\d+)?);\s*` +
93
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
94
+ String.raw`(\S+)`,
95
+ );
96
+ const FLUTTER_STR_RE = new RegExp(
97
+ String.raw`static const \w+ = ("(?:[^"\\]|\\.)*");\s*` +
98
+ TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
99
+ String.raw`(\S+)`,
100
+ );
101
+
102
+ /**
103
+ * Recover the canonical token map from an emitted Flutter source string.
104
+ * Non-mappable lines + the ThemeData builder lines are NOT recovered.
105
+ *
106
+ * @param {string} source
107
+ * @returns {{tokens: Record<string,string>}}
108
+ */
109
+ function reextractFlutter(source) {
110
+ /** @type {Record<string,string>} */
111
+ const tokens = {};
112
+ for (const line of String(source).split(/\r?\n/)) {
113
+ if (line.includes(NONMAPPABLE_MARKER)) continue;
114
+ let m;
115
+ if ((m = FLUTTER_COLOR_RE.exec(line))) {
116
+ const ch = argbLiteralToChannels(m[1]);
117
+ tokens[m[2]] = channelsToHex(ch.r, ch.g, ch.b, ch.a, m[3] === '1');
118
+ } else if ((m = FLUTTER_DIM_RE.exec(line))) {
119
+ // Recover canonical Npx: a logical-px double `16.0` -> `16px`.
120
+ tokens[m[2]] = `${parseFloat(m[1])}px`;
121
+ } else if ((m = FLUTTER_STR_RE.exec(line))) {
122
+ tokens[m[2]] = JSON.parse(m[1]);
123
+ }
124
+ }
125
+ return { tokens };
126
+ }
127
+
128
+ module.exports = { emitFlutter, reextractFlutter };