@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
@@ -7,16 +7,20 @@
7
7
 
8
8
  import { describe, expect, test } from 'bun:test';
9
9
 
10
+ import { ASSET_DISPOSITIONS } from './assets.ts';
10
11
  import {
11
12
  attrValue,
12
13
  clampIntoBounds,
13
14
  cleanText,
14
15
  contrastRatio,
16
+ DISPOSITIONS,
15
17
  ensureContrast,
16
18
  ensureFontSize,
17
19
  hexToRgb01,
18
20
  ImportReport,
19
21
  identifierFromNodeId,
22
+ isCodeOwnedDetail,
23
+ isDisposition,
20
24
  jsxStringLiteral,
21
25
  MIN_FONT_SIZE,
22
26
  rgb01ToHex,
@@ -254,3 +258,68 @@ describe('D6a covers blank-glyph LETTERS, not just format characters (review F10
254
258
  expect(out.strippedHidden).toBe(true);
255
259
  });
256
260
  });
261
+
262
+ // ── DDR-219 D9 — the fixed-enum promise is a CONTROL, not a comment ──────────
263
+ //
264
+ // `Disposition` was a bare union, i.e. a compile-time promise, in a repo that
265
+ // runs no `tsc` gate. `assets.ts` shipped an off-enum `'asset-degraded'` to
266
+ // main — onto the wire, into verb stdout an agent reads (DDR-216 D10) and into
267
+ // the panel. These tests are what makes the promise true at runtime.
268
+
269
+ describe('DDR-219 D9 — disposition enum is enforced, not asserted', () => {
270
+ test('every disposition assets.ts declares is a member', () => {
271
+ // The drift that shipped: ASSET_DISPOSITIONS and the union disagreed, and
272
+ // nothing compared them.
273
+ for (const d of ASSET_DISPOSITIONS) expect(isDisposition(d)).toBe(true);
274
+ });
275
+
276
+ test('asset-degraded is a member — the specific drift found in main', () => {
277
+ expect(isDisposition('asset-degraded')).toBe(true);
278
+ expect(() => new ImportReport().add('1:2', 'ASSET', 'asset-degraded')).not.toThrow();
279
+ });
280
+
281
+ test('an invented disposition throws rather than reaching the wire', () => {
282
+ const r = new ImportReport();
283
+ // @ts-expect-error — the runtime backstop for exactly what tsc would catch
284
+ expect(() => r.add('1:2', 'ASSET', 'asset-vanished')).toThrow(/unknown disposition/);
285
+ expect(r.entries).toHaveLength(0);
286
+ });
287
+
288
+ test('DISPOSITIONS is frozen — a caller cannot widen the set at runtime', () => {
289
+ expect(Object.isFrozen(DISPOSITIONS)).toBe(true);
290
+ });
291
+
292
+ test('every real detail in the codebase satisfies the code-owned rule', () => {
293
+ // Non-ASCII is legitimate here — real notes carry em-dash and arrow. What
294
+ // separates a code-owned note from interpolated node text is length and the
295
+ // absence of a zero-glyph payload.
296
+ for (const d of [
297
+ 'vector unavailable — rasterized',
298
+ 'group → groupIds tag',
299
+ 'page has no frames — rendered whole',
300
+ 'loose content wrapped in one artboard',
301
+ ])
302
+ expect(isCodeOwnedDetail(d)).toBe(true);
303
+ });
304
+
305
+ test('detail rejects an interpolated upstream string', () => {
306
+ const r = new ImportReport();
307
+ // A Figma layer name is exactly what must never ride out in `detail`.
308
+ const layerName = 'V4 — Airy / light — Property 1=Cisty-a-zeleny-kraj 1 — Úprava 3d modelu';
309
+ expect(() => r.add('1:2', 'FRAME', 'imported', layerName)).toThrow(/code-owned/);
310
+ });
311
+
312
+ test('detail rejects a zero-glyph payload even when short', () => {
313
+ const r = new ImportReport();
314
+ expect(() => r.add('1:2', 'FRAME', 'imported', `ok\u{E0041}`)).toThrow(/code-owned/);
315
+ });
316
+
317
+ test('the zero-glyph predicate is not stateful across calls', () => {
318
+ // ZERO_GLYPH_RE carries `g`, so `.test()` on THAT instance alternates
319
+ // true/false via lastIndex. The one-shot predicate must not share it.
320
+ const dirty = 'a​b';
321
+ expect(isCodeOwnedDetail(dirty)).toBe(false);
322
+ expect(isCodeOwnedDetail(dirty)).toBe(false);
323
+ expect(isCodeOwnedDetail(dirty)).toBe(false);
324
+ });
325
+ });
@@ -39,28 +39,81 @@
39
39
  * @invariant DEPENDENCY-FREE — pure string/number work.
40
40
  */
41
41
 
42
- /** Every disposition this feature can report. A FIXED enum: the per-import
42
+ /** Every disposition this feature can report. A FIXED set: the per-import
43
43
  * summary's "never silently dropped" promise is only true because a caller
44
- * cannot invent a reason string (DDR-216 D5/D7). */
45
- export type Disposition =
46
- | 'imported'
47
- | 'hidden-chars-dropped'
48
- | 'hidden-node-skipped'
49
- | 'text-normalized'
50
- | 'geometry-clamped'
51
- | 'truncated-text'
52
- | 'truncated-attr'
53
- | 'unmappable-type'
54
- | 'unmappable-shape'
44
+ * cannot invent a reason string (DDR-216 D5/D7).
45
+ *
46
+ * A frozen ARRAY, with the type derived from it, rather than a bare union —
47
+ * because a union alone is a compile-time promise and this repo runs **no
48
+ * `tsc` gate** (CLAUDE.md states the omission deliberately: `quality` is
49
+ * lint/tests/build/parity/tarball/tokens/site-content, no typecheck). That gap
50
+ * is not hypothetical: `assets.ts` shipped `'asset-degraded'` — absent from the
51
+ * union — to `main`, onto the wire (`http.ts` `/_api/figma/import`), into verb
52
+ * stdout that an agent reads (D10), and into `FigmaImportPanel`. Found by the
53
+ * DDR-219 design-stage review, fixed here with `asset-degraded` admitted as the
54
+ * legitimate outcome it always was. See DDR-219 D9. */
55
+ export const DISPOSITIONS = Object.freeze([
56
+ 'imported',
57
+ 'hidden-chars-dropped',
58
+ 'hidden-node-skipped',
59
+ 'text-normalized',
60
+ 'geometry-clamped',
61
+ 'truncated-text',
62
+ 'truncated-attr',
63
+ 'unmappable-type',
64
+ 'unmappable-shape',
55
65
  /** A comment whose pinned node no longer exists in the file — no position to place it at. */
56
- | 'comment-target-deleted'
57
- | 'bind-degraded-to-bbox'
58
- | 'bind-dropped-self-connector'
59
- | 'asset-pending'
60
- | 'asset-skipped'
61
- | 'asset-cap-reached'
62
- | 'jsx-cap-reached'
63
- | 'value-rejected';
66
+ 'comment-target-deleted',
67
+ 'bind-degraded-to-bbox',
68
+ 'bind-dropped-self-connector',
69
+ 'asset-pending',
70
+ 'asset-skipped',
71
+ 'asset-cap-reached',
72
+ /** A vector Figma declined to render as SVG, re-requested as PNG. */
73
+ 'asset-degraded',
74
+ 'jsx-cap-reached',
75
+ 'value-rejected',
76
+ // ── The codegen route (DDR-219 D9). Three dispositions, one rule: what makes
77
+ // them safe is that `detail` carries a BOUNDED token plus a count, never the
78
+ // upstream family or utility name verbatim — `detail` is the one field on the
79
+ // wire no sanitizer touches, and it reaches verb stdout (D10), the HTTP route
80
+ // and the panel.
81
+ /** A Tailwind utility the mapper does not know, or whose value failed its grammar. */
82
+ 'codegen-utility-unmapped',
83
+ /** A font family that did not survive the copy — a CSS fallback is NOT a report. */
84
+ 'font-substituted',
85
+ /** Dev Mode unreachable / no seat / wrong document. The COMMON case, not an error state. */
86
+ 'codegen-unavailable',
87
+ /** The converter itself could not be LOADED. Contract: REFUSE, never "let a model do it". */
88
+ 'codegen-converter-unavailable',
89
+ /**
90
+ * The converter ran and REFUSED this frame — a parse error, an element outside
91
+ * the allowlist, a construct it does not understand (DDR-219 D5 rule 4).
92
+ *
93
+ * Deliberately NOT folded into `codegen-unavailable`: "Figma was unreachable"
94
+ * and "we looked at what Figma sent and would not emit it" are different
95
+ * facts, and conflating outcomes is how this feature reported success three
96
+ * times while losing content.
97
+ */
98
+ 'codegen-frame-refused',
99
+ ] as const);
100
+
101
+ export type Disposition = (typeof DISPOSITIONS)[number];
102
+
103
+ const DISPOSITION_SET: ReadonlySet<string> = new Set(DISPOSITIONS);
104
+
105
+ export function isDisposition(v: string): v is Disposition {
106
+ return DISPOSITION_SET.has(v);
107
+ }
108
+
109
+ /** `detail` is a code-owned note, and the only field on the wire that no
110
+ * sanitizer touches — so it is the one place an upstream string would ride out
111
+ * to a model unescaped (DDR-219 D9). It is NOT charset-restricted to ASCII:
112
+ * real notes carry `—` and `→` legitimately. What distinguishes a code-owned
113
+ * note from interpolated node text is that it is SHORT and carries no
114
+ * zero-glyph payload, so those are what get asserted. Longest real note today
115
+ * is 36 chars. */
116
+ export const MAX_DETAIL_LEN = 64;
64
117
 
65
118
  export interface ReportEntry {
66
119
  /** `^[0-9]+:[0-9]+$` — an identifier, never node text (DDR-216 D7). */
@@ -76,6 +129,17 @@ export class ImportReport {
76
129
  readonly entries: ReportEntry[] = [];
77
130
 
78
131
  add(nodeId: string, type: string, disposition: Disposition, detail?: string): void {
132
+ // The backstop the missing `tsc` gate cannot provide. A disposition outside
133
+ // the set is a programming error, never bad input — so it throws rather than
134
+ // degrading: silent acceptance is exactly how `asset-degraded` reached main.
135
+ if (!DISPOSITION_SET.has(disposition)) {
136
+ throw new Error(`ImportReport: unknown disposition ${JSON.stringify(disposition)}`);
137
+ }
138
+ if (detail !== undefined && !isCodeOwnedDetail(detail)) {
139
+ throw new Error(
140
+ `ImportReport: detail must be a short code-owned note (<=${MAX_DETAIL_LEN} chars, no zero-glyph); got ${detail.length} chars`
141
+ );
142
+ }
79
143
  this.entries.push(
80
144
  detail ? { nodeId, type, disposition, detail } : { nodeId, type, disposition }
81
145
  );
@@ -86,6 +150,11 @@ export class ImportReport {
86
150
  }
87
151
  }
88
152
 
153
+ /** True when `detail` looks like the code-owned note it is contracted to be. */
154
+ export function isCodeOwnedDetail(detail: string): boolean {
155
+ return detail.length <= MAX_DETAIL_LEN && !ZERO_GLYPH_ONESHOT_RE.test(detail);
156
+ }
157
+
89
158
  // ── D6a — character classes ─────────────────────────────────────────────────
90
159
 
91
160
  /**
@@ -111,33 +180,37 @@ export class ImportReport {
111
180
  * *letters or symbols* by category but render blank. A category rule cannot rot
112
181
  * the way an enumeration does.
113
182
  */
114
- const ZERO_GLYPH_RE = new RegExp(
115
- [
116
- // Format / control / private-use / unassigned — the categories that cover
117
- // every bidi control, every zero-width, the Tags block, and anything Unicode
118
- // adds later without this list needing an edit.
119
- '[\\p{Cf}\\p{Cc}\\p{Co}\\p{Cn}]',
120
- // Blank-rendering code points that are LETTERS or SYMBOLS by category, so no
121
- // category rule catches them. Listed one per alternative rather than as a
122
- // character class: `\u034F` is a COMBINING character, and a class mixing it
123
- // with base characters is the `noMisleadingCharacterClass` footgun.
124
- '\\u00AD', // soft hyphen
125
- '\\u034F', // combining grapheme joiner
126
- '\\u061C', // arabic letter mark (Trojan Source)
127
- '\\u115F', // hangul choseong filler
128
- '\\u1160', // hangul jungseong filler
129
- '\\u17B4', // khmer inherent AQ
130
- '\\u17B5', // khmer inherent AA
131
- '[\\u180B-\\u180E]', // mongolian selectors + vowel separator
132
- '\\u2800', // braille pattern blank
133
- '\\u3164', // HANGUL FILLER — category Lo, i.e. a LETTER
134
- '[\\uFE00-\\uFE0F]', // variation selectors
135
- '\\uFFA0', // halfwidth hangul filler
136
- '[\\uFFF9-\\uFFFB]', // interlinear annotation
137
- '[\\u{E0100}-\\u{E01EF}]', // variation selectors supplement
138
- ].join('|'),
139
- 'gu'
140
- );
183
+ const ZERO_GLYPH_SOURCE = [
184
+ // Format / control / private-use / unassigned — the categories that cover
185
+ // every bidi control, every zero-width, the Tags block, and anything Unicode
186
+ // adds later without this list needing an edit.
187
+ '[\\p{Cf}\\p{Cc}\\p{Co}\\p{Cn}]',
188
+ // Blank-rendering code points that are LETTERS or SYMBOLS by category, so no
189
+ // category rule catches them. Listed one per alternative rather than as a
190
+ // character class: `\u034F` is a COMBINING character, and a class mixing it
191
+ // with base characters is the `noMisleadingCharacterClass` footgun.
192
+ '\\u00AD', // soft hyphen
193
+ '\\u034F', // combining grapheme joiner
194
+ '\\u061C', // arabic letter mark (Trojan Source)
195
+ '\\u115F', // hangul choseong filler
196
+ '\\u1160', // hangul jungseong filler
197
+ '\\u17B4', // khmer inherent AQ
198
+ '\\u17B5', // khmer inherent AA
199
+ '[\\u180B-\\u180E]', // mongolian selectors + vowel separator
200
+ '\\u2800', // braille pattern blank
201
+ '\\u3164', // HANGUL FILLER — category Lo, i.e. a LETTER
202
+ '[\\uFE00-\\uFE0F]', // variation selectors
203
+ '\\uFFA0', // halfwidth hangul filler
204
+ '[\\uFFF9-\\uFFFB]', // interlinear annotation
205
+ '[\\u{E0100}-\\u{E01EF}]', // variation selectors supplement
206
+ ].join('|');
207
+
208
+ const ZERO_GLYPH_RE = new RegExp(ZERO_GLYPH_SOURCE, 'gu');
209
+
210
+ /** The same rule without `g`. `RegExp.prototype.test` on a GLOBAL regex advances
211
+ * `lastIndex` and so alternates true/false across calls on the same instance —
212
+ * a one-shot predicate must never share the `g` instance. */
213
+ const ZERO_GLYPH_ONESHOT_RE = new RegExp(ZERO_GLYPH_SOURCE, 'u');
141
214
 
142
215
  /** Tab / newline / carriage return are legitimate content — never stripped. */
143
216
  const KEEP_WHITESPACE_RE = /[\t\n\r]/;
@@ -184,6 +257,25 @@ export function attrValue(raw: string, maxLen = 64): string {
184
257
  .slice(0, maxLen);
185
258
  }
186
259
 
260
+ /**
261
+ * A report token for `detail` — strict, and deliberately NOT `attrValue`.
262
+ *
263
+ * `attrValue` rewrites rejected characters to SPACES, which is right for a label
264
+ * and wrong here: a class token like `ignore.all.prior.instructions.and` comes
265
+ * back as that sentence, and `detail` reaches verb stdout (which D10 declares
266
+ * entirely code-owned), the HTTP summary and the panel. Charset-bounding is a
267
+ * markup control; it was never a semantic one (post-implementation review F3).
268
+ *
269
+ * So: a Tailwind FAMILY shape only — lowercase, digits, hyphens, NO SPACES ever
270
+ * — and anything else collapses to a fixed word. `unrecognized x7` is less
271
+ * informative than the raw token and cannot be read as an instruction, which is
272
+ * the right trade for a field an agent reads.
273
+ */
274
+ export function reportToken(raw: string): string {
275
+ const head = /^[a-z][a-z0-9-]{0,23}/.exec(raw.trim().toLowerCase());
276
+ return head ? head[0] : 'unrecognized';
277
+ }
278
+
187
279
  /**
188
280
  * A JSX-safe identifier derived from a NODE ID — never from a Figma string.
189
281
  * `2:17` → `Node_2_17`. This is the whole identifier story: there is no Figma
@@ -0,0 +1,142 @@
1
+ // figma/tailwind-map.ts — Tailwind utility → JSX style object.
2
+ //
3
+ // The coverage claim is a MEASUREMENT, not an aspiration: 129 distinct classes
4
+ // across 64 families were counted on a real 375×812 screen (DDR-219 § Spike),
5
+ // and the census below is that exact list. If a future Figma release emits
6
+ // something new, this test is where it shows up — as an `unmapped` entry, which
7
+ // is the reportable outcome the acceptance criteria demand, not a silent drop.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { MAX_CLASSES_PER_ELEMENT, mapClassName } from './tailwind-map.ts';
12
+
13
+ const DS_FONTS = [{ name: '--font-body', value: "'hanken grotesk','inter',sans-serif" }];
14
+
15
+ /** Every distinct class observed on `425:2939`. */
16
+ const MEASURED_CLASSES = `relative shrink-0 flex content-stretch items-center flex-col absolute
17
+ justify-center size-full max-w-none block inset-0 [word-break:break-word] not-italic leading-[0]
18
+ whitespace-nowrap size-[24px] w-full text-black items-start border-solid leading-[normal] gap-[8px]
19
+ text-[12px] font-normal border bg-[var(--black-10,rgba(15,22,30,0.1))] py-[8px] px-[12px]
20
+ rounded-[8px] overflow-clip leading-[20px] font-bold tracking-[0.288px] text-[14px] justify-between
21
+ font-[family-name:var(--font,'Inter:Regular')] font-[family-name:var(--font,'Inter:Bold')] p-[10px]
22
+ border-black w-[185px] rounded-[9px] min-w-px flex-[1_0_0] text-center self-stretch rounded-[999px]
23
+ w-[40px] text-[color:var(--black,#0f161e)] size-[21.5px] rounded-[99px] p-px h-[32px] gap-px
24
+ font-['Inter:Regular'] border-[var(--black,#0f161e)] row-1 py-[16px] px-[24px] col-1 w-[34.018px]
25
+ w-[16px] text-[10px] text-[#0f161e] size-[32px] px-[10px] mt-0 h-[12px] border-b
26
+ border-[var(--black-10,rgba(15,22,30,0.1))] -translate-y-1/2 -translate-x-1/2 w-[54px] w-[375px]
27
+ w-[364px] w-[358px] w-[341.871px] w-[327px] w-[25px] w-[18px] w-0 tracking-[0.24px] tracking-[-0.3px]
28
+ top-[695.49px] top-[25.01px] top-[191px] top-1/2 text-[16px] size-[16px] size-[14.018px]
29
+ rounded-[16px] py-[12px] px-[20px] place-items-start pb-[8px] mt-px ml-[50px] ml-[26px] ml-0
30
+ left-[calc(50%-32.5px)] left-[calc(50%-0.02px)] left-[6.35px] left-1/2 leading-[12px] justify-end
31
+ inset-[37.5%_18.75%_26.56%_18.75%] inset-[10.42%_16.67%_8.33%_16.67%] inset-[0_4.17%] inset-[0_-0.5px]
32
+ inset-[0.49%_0.06%_-0.36%_0.07%] inset-[-6.64%_-7.07%_-7.49%_-7.07%] inline-grid h-full h-[768px]
33
+ h-[58.766px] h-[480px] h-[296px] h-[14.454px] h-[14.018px] h-[10px] grid-rows-[max-content]
34
+ grid-cols-[max-content] gap-[7px] gap-[4px] font-['SF_Pro_Display:Semibold'] font-['SF_Pro:Bold']
35
+ flex-row border-[red] bg-white`
36
+ .trim()
37
+ .split(/\s+/);
38
+
39
+ describe('the measured surface', () => {
40
+ test('every class from the real screen maps — 129/129, zero unmapped', () => {
41
+ const unmapped: string[] = [];
42
+ for (const cls of MEASURED_CLASSES) {
43
+ const r = mapClassName(cls, { fontTokens: DS_FONTS });
44
+ if (r.unmapped.length > 0) unmapped.push(cls);
45
+ }
46
+ expect(unmapped).toEqual([]);
47
+ expect(MEASURED_CLASSES.length).toBe(129);
48
+ });
49
+ });
50
+
51
+ describe('the disambiguations that are easy to get wrong', () => {
52
+ test('text-[16px] is a font size and text-[#hex] is a colour', () => {
53
+ expect(mapClassName('text-[16px]').declarations).toEqual({ fontSize: '16px' });
54
+ expect(mapClassName('text-[#0f161e]').declarations).toEqual({ color: '#0f161e' });
55
+ });
56
+
57
+ test("Tailwind's own color: hint wins", () => {
58
+ expect(mapClassName('text-[color:var(--black,#0f161e)]').declarations).toEqual({
59
+ color: 'var(--black,#0f161e)',
60
+ });
61
+ });
62
+
63
+ test('border-[red] is a colour, border-[2px] is a width', () => {
64
+ expect(mapClassName('border-[red]').declarations).toEqual({ borderColor: 'red' });
65
+ expect(mapClassName('border-[2px]').declarations).toEqual({
66
+ borderWidth: '2px',
67
+ borderStyle: 'solid',
68
+ });
69
+ });
70
+
71
+ test('a negative utility carries its sign on the FAMILY, not the value', () => {
72
+ expect(mapClassName('-ml-[26px]').declarations).toEqual({ marginLeft: '-26px' });
73
+ });
74
+
75
+ test('transforms compose into ONE declaration', () => {
76
+ expect(mapClassName('-translate-x-1/2 -translate-y-1/2').declarations.transform).toBe(
77
+ 'translateX(-50%) translateY(-50%)'
78
+ );
79
+ });
80
+
81
+ test('leading-[0] becomes normal — a zero line box hides the text', () => {
82
+ // D6b's rule in miniature: the translator can GUARANTEE visibility, so it
83
+ // does, rather than faithfully reproducing a collapsed line and reporting
84
+ // success on an empty-looking screen.
85
+ expect(mapClassName('leading-[0]').declarations).toEqual({ lineHeight: 'normal' });
86
+ expect(mapClassName('leading-[20px]').declarations).toEqual({ lineHeight: '20px' });
87
+ });
88
+
89
+ test('size-[N] sets both axes', () => {
90
+ expect(mapClassName('size-[16px]').declarations).toEqual({ width: '16px', height: '16px' });
91
+ });
92
+ });
93
+
94
+ describe('what must NOT get through', () => {
95
+ test.each([
96
+ 'bg-[url(https://attacker.example/beacon.png)]',
97
+ '[behavior:url(#x)]',
98
+ '[content:"pwned"]',
99
+ 'w-[99999999px]',
100
+ 'text-[expression(alert(1))]',
101
+ 'bg-[#fff;position:fixed]',
102
+ 'grid-cols-[repeat(999999,1fr)]',
103
+ ])('%s is reported, never emitted', (cls) => {
104
+ const r = mapClassName(cls);
105
+ expect(r.declarations).toEqual({});
106
+ expect(r.unmapped.length).toBeGreaterThan(0);
107
+ });
108
+
109
+ test('an unknown utility is REPORTED — "never silently dropped" is the criterion', () => {
110
+ const r = mapClassName('flex definitely-not-a-tailwind-class');
111
+ expect(r.declarations).toEqual({ display: 'flex' });
112
+ expect(r.unmapped).toEqual(['definitely-not-a-tailwind-class']);
113
+ });
114
+
115
+ test('a class list long enough to be a payload is capped, and the cap is reported', () => {
116
+ const many = Array.from({ length: MAX_CLASSES_PER_ELEMENT + 10 }, () => 'flex').join(' ');
117
+ const r = mapClassName(many);
118
+ expect(r.unmapped).toContain('class-cap-reached');
119
+ });
120
+
121
+ test('an oversized single token never reaches a grammar', () => {
122
+ const r = mapClassName(`w-[${'1'.repeat(500)}px]`);
123
+ expect(r.declarations).toEqual({});
124
+ expect(r.unmapped).toEqual(['oversized']);
125
+ });
126
+ });
127
+
128
+ describe('DS tokens', () => {
129
+ test('a bare hex snaps onto a near DS token', () => {
130
+ const r = mapClassName('bg-[#0f161e]', {
131
+ tokens: [{ name: '--fg-0', hex: '#0f161e' }],
132
+ });
133
+ expect(r.declarations.background).toBe('var(--fg-0)');
134
+ });
135
+
136
+ test('a var() Figma already resolved is NOT re-snapped — that would be a downgrade', () => {
137
+ const r = mapClassName('bg-[var(--black,#0f161e)]', {
138
+ tokens: [{ name: '--fg-0', hex: '#0f161e' }],
139
+ });
140
+ expect(r.declarations.background).toBe('var(--black,#0f161e)');
141
+ });
142
+ });