@1agh/maude 0.58.3 → 0.59.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.
Files changed (67) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +868 -214
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. package/package.json +8 -8
@@ -0,0 +1,545 @@
1
+ /**
2
+ * @file figma/tailwind-map.ts — Tailwind utility → JSX style object.
3
+ * @scope apps/studio/figma/tailwind-map.ts
4
+ * @purpose The half of the codegen route that is genuinely small: turn the
5
+ * class list Figma's generator emits into inline style declarations
6
+ * a Maude canvas renders natively.
7
+ *
8
+ * @rationale Sized by a spike on a real 375×812 screen (`425:2939`, DDR-219
9
+ * § Spike): **1 276 class tokens · 129 distinct · 64 families**, and
10
+ * a throwaway mapper reached **129/129 with zero unmapped in ~155
11
+ * lines**. The plan's fear that "the arbitrary-value syntax is where
12
+ * it will get long" is real and FINITE. Every family in the table
13
+ * below was observed in that measurement; nothing here is
14
+ * speculative Tailwind coverage.
15
+ *
16
+ * @invariant EVERY EMITTED VALUE PASSES `codegen-values.ts` FIRST. A utility
17
+ * whose value fails its grammar is DROPPED and REPORTED
18
+ * (`codegen-utility-unmapped`), never "cleaned up", never partially
19
+ * applied. This matters more here than in `import-tokens`: these
20
+ * declarations render live in the canvas iframe.
21
+ *
22
+ * @invariant KEYS ARE camelCase, ALWAYS. These land in a JSX style OBJECT,
23
+ * where a hyphenated key is a syntax error that takes the whole
24
+ * canvas down — one gradient emitted as `background-image` was
25
+ * enough to make a whole page unparseable (DDR-216 D12's autopsy).
26
+ *
27
+ * @invariant AN UNMAPPED UTILITY IS REPORTED, NEVER SILENTLY DROPPED. That is
28
+ * an acceptance criterion of this phase, and it is also the only way
29
+ * the table's coverage stays measurable as Figma's generator moves.
30
+ *
31
+ * @invariant `Object.create(null)` FOR EVERY MAP INDEXED BY A PARSED STRING
32
+ * (D5 rule 5). A plain object literal returns a FUNCTION for the key
33
+ * `constructor`, which then stringifies into an emitted style value
34
+ * — the exact shape review F11 found in `style-map.ts`.
35
+ *
36
+ * @invariant DEPENDENCY-FREE beyond `codegen-values.ts`, `codegen-fonts.ts` and
37
+ * `style-map.ts`'s pure `resolveColor`.
38
+ */
39
+
40
+ import {
41
+ type FontToken,
42
+ resolveFontFamily,
43
+ splitFamilyAndStyle,
44
+ styleToWeight,
45
+ } from './codegen-fonts.ts';
46
+ import {
47
+ cssPropToCamel,
48
+ isAllowedArbitraryProperty,
49
+ isCodegenColor,
50
+ isCodegenKeyword,
51
+ isCodegenLength,
52
+ isCodegenLengthList,
53
+ isCodegenNumber,
54
+ isCodegenShortValueList,
55
+ MAX_VALUE_LEN,
56
+ normalizeCalc,
57
+ unescapeArbitrary,
58
+ } from './codegen-values.ts';
59
+ import { ensureFontSize } from './sanitize.ts';
60
+ import { type DsToken, resolveColor } from './style-map.ts';
61
+
62
+ /** A class list longer than this is not a design, it is a payload. */
63
+ export const MAX_CLASSES_PER_ELEMENT = 64;
64
+
65
+ export interface TailwindContext {
66
+ /** DS colour tokens, so a bare hex can snap onto the project's palette. */
67
+ tokens?: readonly DsToken[];
68
+ /** DS type tokens, so a family resolves to the project's own stack. */
69
+ fontTokens?: readonly FontToken[];
70
+ threshold?: number;
71
+ }
72
+
73
+ export interface MappedClasses {
74
+ /** camelCase CSS property → validated value. */
75
+ declarations: Record<string, string>;
76
+ /** Utilities this table does not know, or whose value failed its grammar. */
77
+ unmapped: string[];
78
+ /** Font families that did not survive the copy, for the T18 report. */
79
+ substitutedFonts: string[];
80
+ }
81
+
82
+ /** Exact-match utilities. Keys are literals, so a plain frozen record is fine —
83
+ * the `Object.create(null)` rule bites on maps INDEXED by a parsed string, and
84
+ * this one is only ever probed with `Object.hasOwn`. */
85
+ const STATIC: Readonly<Record<string, Readonly<Record<string, string>>>> = Object.freeze({
86
+ // display
87
+ flex: { display: 'flex' },
88
+ 'inline-flex': { display: 'inline-flex' },
89
+ grid: { display: 'grid' },
90
+ 'inline-grid': { display: 'inline-grid' },
91
+ block: { display: 'block' },
92
+ 'inline-block': { display: 'inline-block' },
93
+ inline: { display: 'inline' },
94
+ hidden: { display: 'none' },
95
+ contents: { display: 'contents' },
96
+
97
+ // flex / grid alignment
98
+ 'flex-col': { flexDirection: 'column' },
99
+ 'flex-row': { flexDirection: 'row' },
100
+ 'flex-wrap': { flexWrap: 'wrap' },
101
+ 'flex-nowrap': { flexWrap: 'nowrap' },
102
+ 'items-start': { alignItems: 'flex-start' },
103
+ 'items-center': { alignItems: 'center' },
104
+ 'items-end': { alignItems: 'flex-end' },
105
+ 'items-stretch': { alignItems: 'stretch' },
106
+ 'items-baseline': { alignItems: 'baseline' },
107
+ 'justify-start': { justifyContent: 'flex-start' },
108
+ 'justify-center': { justifyContent: 'center' },
109
+ 'justify-end': { justifyContent: 'flex-end' },
110
+ 'justify-between': { justifyContent: 'space-between' },
111
+ 'justify-around': { justifyContent: 'space-around' },
112
+ 'justify-evenly': { justifyContent: 'space-evenly' },
113
+ // `content-stretch` is Figma's most-emitted class after `relative`.
114
+ 'content-stretch': { alignContent: 'stretch' },
115
+ 'content-start': { alignContent: 'flex-start' },
116
+ 'content-center': { alignContent: 'center' },
117
+ 'content-between': { alignContent: 'space-between' },
118
+ 'place-items-start': { placeItems: 'start' },
119
+ 'place-items-center': { placeItems: 'center' },
120
+ 'place-items-end': { placeItems: 'end' },
121
+ 'self-stretch': { alignSelf: 'stretch' },
122
+ 'self-start': { alignSelf: 'flex-start' },
123
+ 'self-center': { alignSelf: 'center' },
124
+ 'self-end': { alignSelf: 'flex-end' },
125
+ 'self-auto': { alignSelf: 'auto' },
126
+ 'shrink-0': { flexShrink: '0' },
127
+ shrink: { flexShrink: '1' },
128
+ 'grow-0': { flexGrow: '0' },
129
+ grow: { flexGrow: '1' },
130
+
131
+ // position
132
+ relative: { position: 'relative' },
133
+ absolute: { position: 'absolute' },
134
+ fixed: { position: 'fixed' },
135
+ sticky: { position: 'sticky' },
136
+ static: { position: 'static' },
137
+ 'inset-0': { inset: '0px' },
138
+
139
+ // sizing
140
+ 'size-full': { width: '100%', height: '100%' },
141
+ 'w-full': { width: '100%' },
142
+ 'h-full': { height: '100%' },
143
+ 'w-auto': { width: 'auto' },
144
+ 'h-auto': { height: 'auto' },
145
+ 'w-0': { width: '0px' },
146
+ 'h-0': { height: '0px' },
147
+ 'max-w-none': { maxWidth: 'none' },
148
+ 'max-h-none': { maxHeight: 'none' },
149
+ 'max-w-full': { maxWidth: '100%' },
150
+ 'min-w-px': { minWidth: '1px' },
151
+ 'min-w-0': { minWidth: '0px' },
152
+ 'min-h-0': { minHeight: '0px' },
153
+
154
+ // spacing shorthands Tailwind spells without a value
155
+ 'p-px': { padding: '1px' },
156
+ 'gap-px': { gap: '1px' },
157
+ 'mt-px': { marginTop: '1px' },
158
+ 'm-0': { margin: '0px' },
159
+ 'mt-0': { marginTop: '0px' },
160
+ 'mr-0': { marginRight: '0px' },
161
+ 'mb-0': { marginBottom: '0px' },
162
+ 'ml-0': { marginLeft: '0px' },
163
+ 'p-0': { padding: '0px' },
164
+
165
+ // border
166
+ border: { borderWidth: '1px', borderStyle: 'solid' },
167
+ 'border-0': { borderWidth: '0px' },
168
+ 'border-2': { borderWidth: '2px', borderStyle: 'solid' },
169
+ 'border-t': { borderTopWidth: '1px', borderTopStyle: 'solid' },
170
+ 'border-r': { borderRightWidth: '1px', borderRightStyle: 'solid' },
171
+ 'border-b': { borderBottomWidth: '1px', borderBottomStyle: 'solid' },
172
+ 'border-l': { borderLeftWidth: '1px', borderLeftStyle: 'solid' },
173
+ 'border-solid': { borderStyle: 'solid' },
174
+ 'border-dashed': { borderStyle: 'dashed' },
175
+ 'border-dotted': { borderStyle: 'dotted' },
176
+ 'border-none': { borderStyle: 'none' },
177
+ 'rounded-full': { borderRadius: '9999px' },
178
+ 'rounded-none': { borderRadius: '0px' },
179
+
180
+ // overflow
181
+ 'overflow-clip': { overflow: 'clip' },
182
+ 'overflow-hidden': { overflow: 'hidden' },
183
+ 'overflow-auto': { overflow: 'auto' },
184
+ 'overflow-scroll': { overflow: 'scroll' },
185
+ 'overflow-visible': { overflow: 'visible' },
186
+
187
+ // typography
188
+ 'text-left': { textAlign: 'left' },
189
+ 'text-center': { textAlign: 'center' },
190
+ 'text-right': { textAlign: 'right' },
191
+ 'text-justify': { textAlign: 'justify' },
192
+ italic: { fontStyle: 'italic' },
193
+ 'not-italic': { fontStyle: 'normal' },
194
+ 'font-thin': { fontWeight: '100' },
195
+ 'font-extralight': { fontWeight: '200' },
196
+ 'font-light': { fontWeight: '300' },
197
+ 'font-normal': { fontWeight: '400' },
198
+ 'font-medium': { fontWeight: '500' },
199
+ 'font-semibold': { fontWeight: '600' },
200
+ 'font-bold': { fontWeight: '700' },
201
+ 'font-extrabold': { fontWeight: '800' },
202
+ 'font-black': { fontWeight: '900' },
203
+ 'whitespace-nowrap': { whiteSpace: 'nowrap' },
204
+ 'whitespace-normal': { whiteSpace: 'normal' },
205
+ 'whitespace-pre': { whiteSpace: 'pre' },
206
+ 'whitespace-pre-wrap': { whiteSpace: 'pre-wrap' },
207
+ underline: { textDecoration: 'underline' },
208
+ 'line-through': { textDecoration: 'line-through' },
209
+ 'no-underline': { textDecoration: 'none' },
210
+ uppercase: { textTransform: 'uppercase' },
211
+ lowercase: { textTransform: 'lowercase' },
212
+ capitalize: { textTransform: 'capitalize' },
213
+ 'normal-case': { textTransform: 'none' },
214
+ 'leading-normal': { lineHeight: 'normal' },
215
+ 'leading-none': { lineHeight: '1' },
216
+ truncate: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
217
+
218
+ // half-position shorthands
219
+ 'top-1/2': { top: '50%' },
220
+ 'left-1/2': { left: '50%' },
221
+ 'right-1/2': { right: '50%' },
222
+ 'bottom-1/2': { bottom: '50%' },
223
+
224
+ // named colours Figma reaches for
225
+ 'bg-white': { background: '#ffffff' },
226
+ 'bg-black': { background: '#000000' },
227
+ 'bg-transparent': { background: 'transparent' },
228
+ 'text-white': { color: '#ffffff' },
229
+ 'text-black': { color: '#000000' },
230
+ 'border-white': { borderColor: '#ffffff' },
231
+ 'border-black': { borderColor: '#000000' },
232
+ });
233
+
234
+ /** Transform utilities compose into ONE `transform`, so they are collected
235
+ * rather than written straight into the declaration map. */
236
+ const TRANSFORMS: Readonly<Record<string, string>> = Object.freeze({
237
+ '-translate-x-1/2': 'translateX(-50%)',
238
+ '-translate-y-1/2': 'translateY(-50%)',
239
+ 'translate-x-1/2': 'translateX(50%)',
240
+ 'translate-y-1/2': 'translateY(50%)',
241
+ '-rotate-180': 'rotate(-180deg)',
242
+ 'rotate-180': 'rotate(180deg)',
243
+ '-scale-x-100': 'scaleX(-1)',
244
+ '-scale-y-100': 'scaleY(-1)',
245
+ });
246
+
247
+ /** `p`/`px`/`m`/`gap`/… → the property (or properties) they set. */
248
+ const SPACING_PROPS: Readonly<Record<string, readonly string[]>> = Object.freeze({
249
+ p: ['padding'],
250
+ px: ['paddingLeft', 'paddingRight'],
251
+ py: ['paddingTop', 'paddingBottom'],
252
+ pt: ['paddingTop'],
253
+ pr: ['paddingRight'],
254
+ pb: ['paddingBottom'],
255
+ pl: ['paddingLeft'],
256
+ m: ['margin'],
257
+ mx: ['marginLeft', 'marginRight'],
258
+ my: ['marginTop', 'marginBottom'],
259
+ mt: ['marginTop'],
260
+ mr: ['marginRight'],
261
+ mb: ['marginBottom'],
262
+ ml: ['marginLeft'],
263
+ gap: ['gap'],
264
+ 'gap-x': ['columnGap'],
265
+ 'gap-y': ['rowGap'],
266
+ top: ['top'],
267
+ right: ['right'],
268
+ bottom: ['bottom'],
269
+ left: ['left'],
270
+ w: ['width'],
271
+ h: ['height'],
272
+ 'min-w': ['minWidth'],
273
+ 'min-h': ['minHeight'],
274
+ 'max-w': ['maxWidth'],
275
+ 'max-h': ['maxHeight'],
276
+ 'inset-x': ['left', 'right'],
277
+ 'inset-y': ['top', 'bottom'],
278
+ });
279
+
280
+ /** Tailwind's spacing scale step, for the numeric (non-arbitrary) forms. */
281
+ const SCALE_REM = 0.25;
282
+
283
+ /**
284
+ * Pull `family-name:` / `color:` / `length:` off an arbitrary value. Tailwind
285
+ * uses these hints to disambiguate exactly what we have to disambiguate.
286
+ */
287
+ function splitHint(value: string): { hint: string | null; value: string } {
288
+ const m = /^([a-z-]{1,16}):([\s\S]*)$/.exec(value);
289
+ // A bare `var(--x)` and a `data:` URL both contain a colon; only a KNOWN hint
290
+ // counts, everything else is part of the value.
291
+ if (m && (m[1] === 'color' || m[1] === 'family-name' || m[1] === 'length')) {
292
+ return { hint: m[1], value: m[2] };
293
+ }
294
+ return { hint: null, value };
295
+ }
296
+
297
+ /** A colour that snaps onto the DS palette when it is a bare literal. */
298
+ function colorValue(raw: string, ctx: TailwindContext): string | null {
299
+ if (!isCodegenColor(raw)) return null;
300
+ // Only a LITERAL hex is a candidate for snapping. A `var()` Figma resolved for
301
+ // us is already the design's own variable — replacing it with our nearest
302
+ // token would be a downgrade, not a normalization.
303
+ if (/^#[0-9a-fA-F]{6}$/.test(raw) && ctx.tokens && ctx.tokens.length > 0) {
304
+ const resolved = resolveColor(raw.toLowerCase(), ctx.tokens, ctx.threshold);
305
+ return resolved.value;
306
+ }
307
+ return raw;
308
+ }
309
+
310
+ /**
311
+ * Clamp a parsed font size to D6b's readable floor, preserving the unit.
312
+ * A size below the floor is not a design choice, it is a hiding mechanism.
313
+ */
314
+ function clampFontSize(value: string): string {
315
+ const m = /^(-?[\d.]+)(px|rem|em|%)?$/.exec(value);
316
+ if (!m) return value;
317
+ const n = Number.parseFloat(m[1]);
318
+ const unit = m[2] ?? 'px';
319
+ if (unit !== 'px') return value; // rem/em/% floors are not comparable to a px one
320
+ const { size, changed } = ensureFontSize(n);
321
+ return changed ? `${size}px` : value;
322
+ }
323
+
324
+ /**
325
+ * Map ONE utility. Returns `null` when the table does not know it or its value
326
+ * fails a grammar — the caller reports that, it is never swallowed here.
327
+ */
328
+ function mapOne(
329
+ cls: string,
330
+ ctx: TailwindContext,
331
+ out: Record<string, string>,
332
+ transforms: string[],
333
+ fonts: string[]
334
+ ): boolean {
335
+ if (Object.hasOwn(STATIC, cls)) {
336
+ Object.assign(out, STATIC[cls]);
337
+ return true;
338
+ }
339
+ if (Object.hasOwn(TRANSFORMS, cls)) {
340
+ transforms.push(TRANSFORMS[cls]);
341
+ return true;
342
+ }
343
+
344
+ // ── Arbitrary PROPERTY: `[word-break:break-word]` ──
345
+ if (cls.startsWith('[') && cls.endsWith(']')) {
346
+ const body = unescapeArbitrary(cls.slice(1, -1));
347
+ const colon = body.indexOf(':');
348
+ if (colon < 0) return false;
349
+ const prop = body.slice(0, colon).trim();
350
+ const value = body.slice(colon + 1).trim();
351
+ if (!isAllowedArbitraryProperty(prop)) return false;
352
+ if (
353
+ !isCodegenKeyword(value) &&
354
+ !isCodegenLength(value) &&
355
+ !isCodegenNumber(value) &&
356
+ !isCodegenShortValueList(value)
357
+ ) {
358
+ return false;
359
+ }
360
+ out[cssPropToCamel(prop)] = value;
361
+ return true;
362
+ }
363
+
364
+ const bracket = /^(-?[a-z-]{1,16})-\[([\s\S]{1,240})\]$/.exec(cls);
365
+ const arbitrary = bracket
366
+ ? { family: bracket[1], raw: normalizeCalc(unescapeArbitrary(bracket[2])) }
367
+ : null;
368
+ if (arbitrary && arbitrary.raw.length > MAX_VALUE_LEN) return false;
369
+
370
+ const plain = /^(-?[a-z-]{1,16})-([a-z0-9./]{1,24})$/.exec(cls);
371
+ const family = arbitrary?.family ?? plain?.[1] ?? null;
372
+ if (!family) return false;
373
+
374
+ // ── Families whose value is a COLOUR ──
375
+ if (family === 'bg' || family === 'text' || family === 'border' || family === 'fill') {
376
+ const { hint, value } = splitHint(arbitrary?.raw ?? plain?.[2] ?? '');
377
+ // `text-[16px]` is a FONT SIZE and `text-[#0f161e]` is a COLOUR — the same
378
+ // family, disambiguated by the value. Tailwind's own `color:` hint wins when
379
+ // present, which is what Figma emits for `text-[color:var(--black,#0f161e)]`.
380
+ if (family === 'text' && hint !== 'color' && isCodegenLength(value, 512)) {
381
+ // D6b's readable floor. `sanitize.ts` exports `ensureFontSize` and this
382
+ // lane never called it, so `text-[0px]` passed the grammar and shipped —
383
+ // while the same file already closes both neighbours (`leading-[0]` and a
384
+ // hiding opacity). D4 pre-accepts that D6b "degrades to clamp what we can
385
+ // parse"; a font size IS parseable, so this one is closable rather than
386
+ // residual (post-implementation review F4).
387
+ out.fontSize = clampFontSize(value);
388
+ return true;
389
+ }
390
+ if (family === 'border' && hint !== 'color' && isCodegenLength(value, 64)) {
391
+ out.borderWidth = value;
392
+ out.borderStyle ??= 'solid';
393
+ return true;
394
+ }
395
+ const resolved = colorValue(value, ctx);
396
+ if (resolved === null) return false;
397
+ if (family === 'bg') out.background = resolved;
398
+ else if (family === 'text') out.color = resolved;
399
+ else if (family === 'fill') out.fill = resolved;
400
+ else out.borderColor = resolved;
401
+ return true;
402
+ }
403
+
404
+ // ── FONT FAMILY (T18) ──
405
+ if (family === 'font' && arbitrary) {
406
+ const { value } = splitHint(arbitrary.raw);
407
+ // Figma wraps the family in quotes inside the bracket and may hand it to us
408
+ // through a `var()` with the literal as the fallback.
409
+ const inner = /^var\(--[a-z0-9-]{1,64},\s*'?([^')]{1,64})'?\)$/.exec(value);
410
+ const literal = (inner ? inner[1] : value).replace(/^['"]|['"]$/g, '');
411
+ const res = resolveFontFamily(literal, ctx.fontTokens ?? []);
412
+ out.fontFamily = res.css;
413
+ // Figma packs the WEIGHT into the family (`SF_Pro:Bold`) and *usually* also
414
+ // emits `font-bold` alongside it. Only fill the gap — an explicit weight
415
+ // utility, before or after, must win, because it is the one Figma computed.
416
+ const weight = styleToWeight(splitFamilyAndStyle(literal).style);
417
+ if (weight !== null && out.fontWeight === undefined) out.fontWeight = String(weight);
418
+ if (res.substituted) fonts.push(res.requested);
419
+ return true;
420
+ }
421
+
422
+ // ── Everything measured that takes a length / list / number ──
423
+ const value = arbitrary?.raw ?? null;
424
+ // `-ml-[26px]` — Tailwind spells a negative utility with a LEADING hyphen, so
425
+ // the sign lives on the family, not on the value.
426
+ const negative = family.startsWith('-');
427
+ const bare = negative ? family.slice(1) : family;
428
+ if (Object.hasOwn(SPACING_PROPS, bare)) {
429
+ const props = SPACING_PROPS[bare];
430
+ if (value !== null) {
431
+ if (!isCodegenLength(value)) return false;
432
+ for (const p of props) out[p] = negative ? `-${value}` : value;
433
+ return true;
434
+ }
435
+ const n = Number.parseFloat(plain?.[2] ?? '');
436
+ if (!Number.isFinite(n) || Math.abs(n) > 400) return false;
437
+ for (const p of props) out[p] = `${(negative ? -n : n) * SCALE_REM}rem`;
438
+ return true;
439
+ }
440
+
441
+ if (family === 'size' && value !== null) {
442
+ if (!isCodegenLength(value)) return false;
443
+ out.width = value;
444
+ out.height = value;
445
+ return true;
446
+ }
447
+ if (family === 'inset' && value !== null) {
448
+ if (!isCodegenLengthList(value)) return false;
449
+ out.inset = value;
450
+ return true;
451
+ }
452
+ if (family === 'rounded' && value !== null) {
453
+ // 9999, not 512: `rounded-[999px]` is the pill idiom and it is everywhere in
454
+ // real files. A radius cannot hang a layout the way a width can, so the
455
+ // bound here is about absurdity, not about safety.
456
+ if (!isCodegenLengthList(value, 9999)) return false;
457
+ out.borderRadius = value;
458
+ return true;
459
+ }
460
+ if (family === 'leading') {
461
+ const v = value ?? plain?.[2] ?? '';
462
+ if (isCodegenNumber(v, 1000)) {
463
+ // `leading-[0]` is Figma's "the line box is the glyph box" idiom. Emitted
464
+ // verbatim it collapses the line to zero height and the text disappears —
465
+ // measured, and exactly the invisible-content class D6b exists to close.
466
+ out.lineHeight = v === '0' ? 'normal' : v;
467
+ return true;
468
+ }
469
+ if (isCodegenLength(v, 512) || isCodegenKeyword(v)) {
470
+ out.lineHeight = v;
471
+ return true;
472
+ }
473
+ return false;
474
+ }
475
+ if (family === 'tracking' && value !== null) {
476
+ if (!isCodegenLength(value, 64)) return false;
477
+ out.letterSpacing = value;
478
+ return true;
479
+ }
480
+ if (family === 'flex' && value !== null) {
481
+ if (!isCodegenShortValueList(value)) return false;
482
+ out.flex = value;
483
+ return true;
484
+ }
485
+ if ((family === 'grid-cols' || family === 'grid-rows') && value !== null) {
486
+ if (!isCodegenShortValueList(value)) return false;
487
+ out[family === 'grid-cols' ? 'gridTemplateColumns' : 'gridTemplateRows'] = value;
488
+ return true;
489
+ }
490
+ if (family === 'col' || family === 'row') {
491
+ const v = value ?? plain?.[2] ?? '';
492
+ if (!isCodegenNumber(v, 64) && !isCodegenKeyword(v)) return false;
493
+ out[family === 'col' ? 'gridColumn' : 'gridRow'] = v;
494
+ return true;
495
+ }
496
+ if (family === 'opacity') {
497
+ const v = value ?? plain?.[2] ?? '';
498
+ if (!isCodegenNumber(v, 100)) return false;
499
+ const n = Number.parseFloat(v);
500
+ // D6b's spirit: an opacity low enough to hide the subtree is a hiding
501
+ // mechanism, not a design. Clamped rather than reproduced.
502
+ out.opacity = String(Math.max(0.15, value !== null ? n : n / 100));
503
+ return true;
504
+ }
505
+ if (family === 'z') {
506
+ const v = value ?? plain?.[2] ?? '';
507
+ if (!isCodegenNumber(v, 9999)) return false;
508
+ out.zIndex = v;
509
+ return true;
510
+ }
511
+ if (family === 'aspect' && value !== null) {
512
+ if (!isCodegenShortValueList(value)) return false;
513
+ out.aspectRatio = value;
514
+ return true;
515
+ }
516
+ return false;
517
+ }
518
+
519
+ /**
520
+ * Map a whole `className` string.
521
+ *
522
+ * Order matters only for `transform`, which composes; everything else is
523
+ * last-wins, which is also how the cascade would have resolved it.
524
+ */
525
+ export function mapClassName(classList: string, ctx: TailwindContext = {}): MappedClasses {
526
+ // `Object.create(null)` per this file's own header rule — the one plain
527
+ // literal that slipped it (post-implementation review, warning tier).
528
+ const declarations: Record<string, string> = Object.create(null);
529
+ const unmapped: string[] = [];
530
+ const substitutedFonts: string[] = [];
531
+ const transforms: string[] = [];
532
+
533
+ const classes = classList.trim().split(/\s+/).filter(Boolean);
534
+ for (const cls of classes.slice(0, MAX_CLASSES_PER_ELEMENT)) {
535
+ if (cls.length > MAX_VALUE_LEN + 32) {
536
+ unmapped.push('oversized');
537
+ continue;
538
+ }
539
+ if (!mapOne(cls, ctx, declarations, transforms, substitutedFonts)) unmapped.push(cls);
540
+ }
541
+ if (classes.length > MAX_CLASSES_PER_ELEMENT) unmapped.push('class-cap-reached');
542
+ if (transforms.length > 0) declarations.transform = transforms.join(' ');
543
+
544
+ return { declarations, unmapped, substitutedFonts };
545
+ }
@@ -218,8 +218,30 @@ export function toRenderCanvas(
218
218
  const bodies: string[] = [];
219
219
  const positions: Array<{ id: string; x: number; y: number }> = [];
220
220
  const pendingRenders: RenderUnit[] = [];
221
- /** The tree we are NOT translating, kept so one artboard can be exploded later. */
222
- const sources: Array<{ id: string; nodeId: string; type: string }> = [];
221
+ /**
222
+ * The tree we are NOT translating, kept so one artboard can be exploded later.
223
+ *
224
+ * `label` + `w`/`h` are here for a reason that is not cosmetic. DDR-219 probe
225
+ * finding 1: `get_design_context` takes NO file key — it reads whatever
226
+ * document Figma has open — and Figma node ids are NOT unique across files.
227
+ * So `--explode` on a canvas whose `source.fileKey` is file A, while file B is
228
+ * the active tab, would return file B's node of the same id and stamp it as
229
+ * file A's. Low-numbered ids (`0:1`, `1:2`, `6:906`) recur in essentially every
230
+ * file, so that is not exotic. Reading the open file's identity over that
231
+ * transport is unsolved (residual 8) — but a NAME-AND-GEOMETRY cross-check
232
+ * against this record is cheap and catches the collision, and widening the
233
+ * record is a one-line change made HERE, while the render route is being
234
+ * touched anyway, rather than a migration later.
235
+ */
236
+ const sources: Array<{
237
+ id: string;
238
+ nodeId: string;
239
+ type: string;
240
+ label: string;
241
+ w: number;
242
+ h: number;
243
+ route: 'render';
244
+ }> = [];
223
245
 
224
246
  for (const node of renderTargets) {
225
247
  const bb = node.absoluteBoundingBox!;
@@ -256,7 +278,7 @@ export function toRenderCanvas(
256
278
  );
257
279
  positions.push({ id: abId, x: Math.round(bb.x - originX), y: Math.round(bb.y - originY) });
258
280
  pendingRenders.push({ node, placeholder });
259
- sources.push({ id: abId, nodeId: node.id, type: node.type });
281
+ sources.push({ id: abId, nodeId: node.id, type: node.type, label, w, h, route: 'render' });
260
282
  report.add(node.id, node.type, 'imported', 'rendered by figma');
261
283
  }
262
284
 
@@ -83,7 +83,12 @@ export type FigmaNodeType =
83
83
  | 'WASHI_TAPE'
84
84
  | 'UNKNOWN';
85
85
 
86
- const KNOWN_NODE_TYPES = new Set<string>([
86
+ /**
87
+ * Exported so the `.fig` door can tell a vocabulary gap from a mapped type
88
+ * WITHOUT keeping a second copy of the list (DDR-221 D3 degrade-and-report).
89
+ * `nodeType()` below still owns the mapping; this is read-only.
90
+ */
91
+ export const KNOWN_NODE_TYPES: ReadonlySet<string> = new Set<string>([
87
92
  'DOCUMENT',
88
93
  'CANVAS',
89
94
  'FRAME',
@@ -1163,6 +1163,33 @@ export function createHttp(
1163
1163
  },
1164
1164
  };
1165
1165
  },
1166
+ // `--explode` from the panel. Same lazy import and same ONE implementation
1167
+ // as the routes above — and note what is NOT here: the raw codegen response
1168
+ // never leaves the verb, so this route returns the same code-owned
1169
+ // accounting an agent would see on stdout (DDR-219 D10).
1170
+ async explode({ canvas, artboard, dryRun, confirmDocument }) {
1171
+ const mod = await import('./bin/_import-figma.mjs');
1172
+ const r = await mod.explodeArtboard({
1173
+ root: ctx.paths.repoRoot,
1174
+ designRootRel: ctx.paths.designRel,
1175
+ canvasRel: canvas,
1176
+ artboardId: artboard,
1177
+ dryRun: Boolean(dryRun),
1178
+ confirmDocument: Boolean(confirmDocument),
1179
+ });
1180
+ return {
1181
+ summary: {
1182
+ canvas: r.canvas,
1183
+ artboard: r.artboardId,
1184
+ route: 'codegen',
1185
+ endpoint: 'local',
1186
+ responseSha256: r.responseSha256,
1187
+ written: r.written,
1188
+ assets: r.assets ?? null,
1189
+ dispositions: r.report.entries,
1190
+ },
1191
+ };
1192
+ },
1166
1193
  });
1167
1194
  const gitJson = (r: { status: number; json: unknown }) =>
1168
1195
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
@@ -2548,6 +2575,26 @@ export function createHttp(
2548
2575
  return gitJson(await figmaApi.runImport(body));
2549
2576
  },
2550
2577
 
2578
+ // `--explode` — one artboard, via the LOCAL Dev Mode MCP (DDR-219 D2).
2579
+ //
2580
+ // Same triple gate, and it needs it more than the others: this route reaches
2581
+ // an unauthenticated loopback service that reads whatever Figma document the
2582
+ // user has open, and then WRITES into the design root. A canvas-reachable
2583
+ // version of it would hand the untrusted iframe (DDR-054) a primitive that
2584
+ // reads the user's open design — so it is in NEITHER canvas allowlist, the
2585
+ // same standing rule DDR-088 sets for every privileged route, asserted in
2586
+ // `test/canvas-origin-gate.test.ts` and by the grep test in
2587
+ // `cli/lib/figma-codegen-reachability.test.mjs`.
2588
+ '/_api/figma/explode': async (req: Request) => {
2589
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2590
+ if (!sameOriginWrite(req))
2591
+ return new Response('cross-origin write rejected', { status: 403 });
2592
+ if (!isTrustedRequestHost(req))
2593
+ return new Response('local request required', { status: 403 });
2594
+ const body = await readJson<unknown>(req, 8 * 1024);
2595
+ return gitJson(await figmaApi.explode(body));
2596
+ },
2597
+
2551
2598
  '/_api/github/create-project': async (req: Request) => {
2552
2599
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2553
2600
  if (!sameOriginWrite(req))