@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.
- package/.claude-plugin/marketplace.json +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +48 -0
- package/README.md +14 -0
- package/SKILL.md +1 -0
- package/agents/compose-executor.md +142 -0
- package/agents/design-authority-watcher.md +4 -0
- package/agents/design-context-builder.md +35 -1
- package/agents/design-verifier.md +14 -18
- package/agents/flutter-executor.md +147 -0
- package/agents/swift-executor.md +226 -0
- package/connections/android-emulator.md +107 -0
- package/connections/connections.md +6 -0
- package/connections/openrouter.md +86 -0
- package/connections/xcode-simulator.md +108 -0
- package/hooks/budget-enforcer.ts +103 -0
- package/package.json +3 -2
- package/reference/gdd-threat-model.md +63 -0
- package/reference/native-platforms.md +273 -0
- package/reference/openrouter-tier-mapping.md +98 -0
- package/reference/prices.openrouter.md +26 -0
- package/reference/registry.json +21 -0
- package/scripts/lib/authority-watcher/index.cjs +147 -0
- package/scripts/lib/budget-enforcer.cjs +16 -0
- package/scripts/lib/design-tokens/_native-shared.cjs +206 -0
- package/scripts/lib/design-tokens/compose.cjs +150 -0
- package/scripts/lib/design-tokens/flutter.cjs +128 -0
- package/scripts/lib/design-tokens/index.cjs +13 -0
- package/scripts/lib/design-tokens/swift.cjs +122 -0
- package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
- package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
- package/sdk/event-stream/types.ts +24 -2
- 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 };
|