@1agh/maude 0.58.3 → 0.60.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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +1180 -242
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +320 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +40 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +788 -0
- package/apps/studio/figma/fig-decode.ts +839 -0
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +146 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +94 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +441 -39
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +39 -6
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +639 -47
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +123 -0
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +87 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- 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 = 'ab';
|
|
321
|
+
expect(isCodeOwnedDetail(dirty)).toBe(false);
|
|
322
|
+
expect(isCodeOwnedDetail(dirty)).toBe(false);
|
|
323
|
+
expect(isCodeOwnedDetail(dirty)).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -39,28 +39,88 @@
|
|
|
39
39
|
* @invariant DEPENDENCY-FREE — pure string/number work.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
/** Every disposition this feature can report. A FIXED
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
/**
|
|
75
|
+
* The LOCAL `.fig` door (DDR-221) cannot produce this asset: a vector cluster
|
|
76
|
+
* is rendered by Figma's servers and is simply absent from an export. The
|
|
77
|
+
* archive carries image FILLS, never renders. Distinct from `asset-skipped`
|
|
78
|
+
* ("we tried and it failed") — nothing was attempted and nothing could be.
|
|
79
|
+
*/
|
|
80
|
+
'asset-unavailable-offline',
|
|
81
|
+
'jsx-cap-reached',
|
|
82
|
+
'value-rejected',
|
|
83
|
+
// ── The codegen route (DDR-219 D9). Three dispositions, one rule: what makes
|
|
84
|
+
// them safe is that `detail` carries a BOUNDED token plus a count, never the
|
|
85
|
+
// upstream family or utility name verbatim — `detail` is the one field on the
|
|
86
|
+
// wire no sanitizer touches, and it reaches verb stdout (D10), the HTTP route
|
|
87
|
+
// and the panel.
|
|
88
|
+
/** A Tailwind utility the mapper does not know, or whose value failed its grammar. */
|
|
89
|
+
'codegen-utility-unmapped',
|
|
90
|
+
/** A font family that did not survive the copy — a CSS fallback is NOT a report. */
|
|
91
|
+
'font-substituted',
|
|
92
|
+
/** Dev Mode unreachable / no seat / wrong document. The COMMON case, not an error state. */
|
|
93
|
+
'codegen-unavailable',
|
|
94
|
+
/** The converter itself could not be LOADED. Contract: REFUSE, never "let a model do it". */
|
|
95
|
+
'codegen-converter-unavailable',
|
|
96
|
+
/**
|
|
97
|
+
* The converter ran and REFUSED this frame — a parse error, an element outside
|
|
98
|
+
* the allowlist, a construct it does not understand (DDR-219 D5 rule 4).
|
|
99
|
+
*
|
|
100
|
+
* Deliberately NOT folded into `codegen-unavailable`: "Figma was unreachable"
|
|
101
|
+
* and "we looked at what Figma sent and would not emit it" are different
|
|
102
|
+
* facts, and conflating outcomes is how this feature reported success three
|
|
103
|
+
* times while losing content.
|
|
104
|
+
*/
|
|
105
|
+
'codegen-frame-refused',
|
|
106
|
+
] as const);
|
|
107
|
+
|
|
108
|
+
export type Disposition = (typeof DISPOSITIONS)[number];
|
|
109
|
+
|
|
110
|
+
const DISPOSITION_SET: ReadonlySet<string> = new Set(DISPOSITIONS);
|
|
111
|
+
|
|
112
|
+
export function isDisposition(v: string): v is Disposition {
|
|
113
|
+
return DISPOSITION_SET.has(v);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `detail` is a code-owned note, and the only field on the wire that no
|
|
117
|
+
* sanitizer touches — so it is the one place an upstream string would ride out
|
|
118
|
+
* to a model unescaped (DDR-219 D9). It is NOT charset-restricted to ASCII:
|
|
119
|
+
* real notes carry `—` and `→` legitimately. What distinguishes a code-owned
|
|
120
|
+
* note from interpolated node text is that it is SHORT and carries no
|
|
121
|
+
* zero-glyph payload, so those are what get asserted. Longest real note today
|
|
122
|
+
* is 36 chars. */
|
|
123
|
+
export const MAX_DETAIL_LEN = 64;
|
|
64
124
|
|
|
65
125
|
export interface ReportEntry {
|
|
66
126
|
/** `^[0-9]+:[0-9]+$` — an identifier, never node text (DDR-216 D7). */
|
|
@@ -76,6 +136,17 @@ export class ImportReport {
|
|
|
76
136
|
readonly entries: ReportEntry[] = [];
|
|
77
137
|
|
|
78
138
|
add(nodeId: string, type: string, disposition: Disposition, detail?: string): void {
|
|
139
|
+
// The backstop the missing `tsc` gate cannot provide. A disposition outside
|
|
140
|
+
// the set is a programming error, never bad input — so it throws rather than
|
|
141
|
+
// degrading: silent acceptance is exactly how `asset-degraded` reached main.
|
|
142
|
+
if (!DISPOSITION_SET.has(disposition)) {
|
|
143
|
+
throw new Error(`ImportReport: unknown disposition ${JSON.stringify(disposition)}`);
|
|
144
|
+
}
|
|
145
|
+
if (detail !== undefined && !isCodeOwnedDetail(detail)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`ImportReport: detail must be a short code-owned note (<=${MAX_DETAIL_LEN} chars, no zero-glyph); got ${detail.length} chars`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
79
150
|
this.entries.push(
|
|
80
151
|
detail ? { nodeId, type, disposition, detail } : { nodeId, type, disposition }
|
|
81
152
|
);
|
|
@@ -86,6 +157,11 @@ export class ImportReport {
|
|
|
86
157
|
}
|
|
87
158
|
}
|
|
88
159
|
|
|
160
|
+
/** True when `detail` looks like the code-owned note it is contracted to be. */
|
|
161
|
+
export function isCodeOwnedDetail(detail: string): boolean {
|
|
162
|
+
return detail.length <= MAX_DETAIL_LEN && !ZERO_GLYPH_ONESHOT_RE.test(detail);
|
|
163
|
+
}
|
|
164
|
+
|
|
89
165
|
// ── D6a — character classes ─────────────────────────────────────────────────
|
|
90
166
|
|
|
91
167
|
/**
|
|
@@ -111,33 +187,37 @@ export class ImportReport {
|
|
|
111
187
|
* *letters or symbols* by category but render blank. A category rule cannot rot
|
|
112
188
|
* the way an enumeration does.
|
|
113
189
|
*/
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
190
|
+
const ZERO_GLYPH_SOURCE = [
|
|
191
|
+
// Format / control / private-use / unassigned — the categories that cover
|
|
192
|
+
// every bidi control, every zero-width, the Tags block, and anything Unicode
|
|
193
|
+
// adds later without this list needing an edit.
|
|
194
|
+
'[\\p{Cf}\\p{Cc}\\p{Co}\\p{Cn}]',
|
|
195
|
+
// Blank-rendering code points that are LETTERS or SYMBOLS by category, so no
|
|
196
|
+
// category rule catches them. Listed one per alternative rather than as a
|
|
197
|
+
// character class: `\u034F` is a COMBINING character, and a class mixing it
|
|
198
|
+
// with base characters is the `noMisleadingCharacterClass` footgun.
|
|
199
|
+
'\\u00AD', // soft hyphen
|
|
200
|
+
'\\u034F', // combining grapheme joiner
|
|
201
|
+
'\\u061C', // arabic letter mark (Trojan Source)
|
|
202
|
+
'\\u115F', // hangul choseong filler
|
|
203
|
+
'\\u1160', // hangul jungseong filler
|
|
204
|
+
'\\u17B4', // khmer inherent AQ
|
|
205
|
+
'\\u17B5', // khmer inherent AA
|
|
206
|
+
'[\\u180B-\\u180E]', // mongolian selectors + vowel separator
|
|
207
|
+
'\\u2800', // braille pattern blank
|
|
208
|
+
'\\u3164', // HANGUL FILLER — category Lo, i.e. a LETTER
|
|
209
|
+
'[\\uFE00-\\uFE0F]', // variation selectors
|
|
210
|
+
'\\uFFA0', // halfwidth hangul filler
|
|
211
|
+
'[\\uFFF9-\\uFFFB]', // interlinear annotation
|
|
212
|
+
'[\\u{E0100}-\\u{E01EF}]', // variation selectors supplement
|
|
213
|
+
].join('|');
|
|
214
|
+
|
|
215
|
+
const ZERO_GLYPH_RE = new RegExp(ZERO_GLYPH_SOURCE, 'gu');
|
|
216
|
+
|
|
217
|
+
/** The same rule without `g`. `RegExp.prototype.test` on a GLOBAL regex advances
|
|
218
|
+
* `lastIndex` and so alternates true/false across calls on the same instance —
|
|
219
|
+
* a one-shot predicate must never share the `g` instance. */
|
|
220
|
+
const ZERO_GLYPH_ONESHOT_RE = new RegExp(ZERO_GLYPH_SOURCE, 'u');
|
|
141
221
|
|
|
142
222
|
/** Tab / newline / carriage return are legitimate content — never stripped. */
|
|
143
223
|
const KEEP_WHITESPACE_RE = /[\t\n\r]/;
|
|
@@ -184,6 +264,25 @@ export function attrValue(raw: string, maxLen = 64): string {
|
|
|
184
264
|
.slice(0, maxLen);
|
|
185
265
|
}
|
|
186
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A report token for `detail` — strict, and deliberately NOT `attrValue`.
|
|
269
|
+
*
|
|
270
|
+
* `attrValue` rewrites rejected characters to SPACES, which is right for a label
|
|
271
|
+
* and wrong here: a class token like `ignore.all.prior.instructions.and` comes
|
|
272
|
+
* back as that sentence, and `detail` reaches verb stdout (which D10 declares
|
|
273
|
+
* entirely code-owned), the HTTP summary and the panel. Charset-bounding is a
|
|
274
|
+
* markup control; it was never a semantic one (post-implementation review F3).
|
|
275
|
+
*
|
|
276
|
+
* So: a Tailwind FAMILY shape only — lowercase, digits, hyphens, NO SPACES ever
|
|
277
|
+
* — and anything else collapses to a fixed word. `unrecognized x7` is less
|
|
278
|
+
* informative than the raw token and cannot be read as an instruction, which is
|
|
279
|
+
* the right trade for a field an agent reads.
|
|
280
|
+
*/
|
|
281
|
+
export function reportToken(raw: string): string {
|
|
282
|
+
const head = /^[a-z][a-z0-9-]{0,23}/.exec(raw.trim().toLowerCase());
|
|
283
|
+
return head ? head[0] : 'unrecognized';
|
|
284
|
+
}
|
|
285
|
+
|
|
187
286
|
/**
|
|
188
287
|
* A JSX-safe identifier derived from a NODE ID — never from a Figma string.
|
|
189
288
|
* `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
|
+
});
|