@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,195 @@
1
+ /**
2
+ * @file figma/codegen-fonts.ts — font resolution + honest reporting (plan T18, DDR-219 D9).
3
+ * @scope apps/studio/figma/codegen-fonts.ts
4
+ * @purpose Turn a Figma font family into something the PROJECT can actually
5
+ * render, and say so every single time that is not what was asked
6
+ * for.
7
+ *
8
+ * @rationale Measured on the dogfood machine 2026-08-11: Inter installed;
9
+ * **Nunito, SF Pro, Hanken Grotesk and General Sans absent** — while
10
+ * the DS declares `--font-body: 'Hanken Grotesk','Inter',…` and
11
+ * loads no webfont at all (no `@font-face`, no import). Copying the
12
+ * family name through therefore lands on a serif fallback that
13
+ * *looks fine* and is not the design. Silent visual drift is exactly
14
+ * the failure mode this import has already shipped three times
15
+ * (dropped loose content, stripped `href`, zero-height arrows) —
16
+ * each time reporting success.
17
+ *
18
+ * @invariant A CSS FALLBACK IS NOT A REPORT. `font-family: 'SF Pro', sans-serif`
19
+ * degrades silently by design; that is what a fallback IS. So every
20
+ * substitution emits a `font-substituted` disposition, and the
21
+ * per-import summary names it.
22
+ *
23
+ * @invariant THE FIGMA FAMILY NEVER REACHES THE ARTIFACT. `style-map.ts` states
24
+ * the same rule for the tree lane ("FONT FAMILY IS DELIBERATELY NOT
25
+ * CARRIED") because a family value is free text from the document
26
+ * that reaches a live stylesheet. This lane resolves to a DS token
27
+ * or to a system stack — never to the requested literal.
28
+ *
29
+ * @invariant THE REPORTED NAME IS BOUNDED, NOT VERBATIM (DDR-219 D9). `detail`
30
+ * is the one field on the wire no sanitizer touches, and it reaches
31
+ * verb stdout (which D10 declares entirely code-owned), the HTTP
32
+ * route and the panel. So the family travels as
33
+ * `attrValue(name, 32)` — charset-allowlisted and length-capped —
34
+ * never as the raw string.
35
+ *
36
+ * @invariant DEPENDENCY-FREE — pure string work over an injected token list.
37
+ */
38
+
39
+ import { attrValue, type ImportReport } from './sanitize.ts';
40
+
41
+ /** How much of a family name may appear in a report entry (D9). */
42
+ export const MAX_FAMILY_DETAIL = 32;
43
+
44
+ /**
45
+ * Figma writes a family as `Family:Style` inside a Tailwind arbitrary value —
46
+ * `SF_Pro:Bold`, `Inter:Regular`, `SF_Pro_Display:Semibold`. The style half is a
47
+ * WEIGHT, not part of the family, and passing it through produces a family name
48
+ * no font on earth answers to.
49
+ */
50
+ export function splitFamilyAndStyle(raw: string): { family: string; style: string | null } {
51
+ const colon = raw.indexOf(':');
52
+ if (colon < 0) return { family: raw.trim(), style: null };
53
+ return { family: raw.slice(0, colon).trim(), style: raw.slice(colon + 1).trim() || null };
54
+ }
55
+
56
+ /** Figma's style words → a numeric CSS weight. Anything unrecognised is ignored
57
+ * rather than guessed — a wrong weight is a visible defect with no report. */
58
+ const STYLE_WEIGHTS: ReadonlyMap<string, number> = new Map([
59
+ ['thin', 100],
60
+ ['extralight', 200],
61
+ ['ultralight', 200],
62
+ ['light', 300],
63
+ ['regular', 400],
64
+ ['normal', 400],
65
+ ['book', 400],
66
+ ['medium', 500],
67
+ ['semibold', 600],
68
+ ['demibold', 600],
69
+ ['bold', 700],
70
+ ['extrabold', 800],
71
+ ['heavy', 800],
72
+ ['black', 900],
73
+ ]);
74
+
75
+ export function styleToWeight(style: string | null): number | null {
76
+ if (!style) return null;
77
+ return STYLE_WEIGHTS.get(style.toLowerCase().replace(/[^a-z]/g, '')) ?? null;
78
+ }
79
+
80
+ /**
81
+ * Families that are genuinely present on essentially every target, so resolving
82
+ * to them is not a substitution anyone needs telling about. Deliberately tiny:
83
+ * the honest default when we do not know is "this WAS substituted".
84
+ */
85
+ const UBIQUITOUS: ReadonlySet<string> = new Set(['inter', 'arial', 'helvetica', 'georgia']);
86
+
87
+ /**
88
+ * The stack a substituted family lands on. Not a serif — the measured failure
89
+ * was a design landing on a serif fallback, so the replacement is explicitly the
90
+ * neutral UI stack the rest of Maude uses.
91
+ */
92
+ export const SYSTEM_STACK =
93
+ "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
94
+
95
+ export interface FontToken {
96
+ /** e.g. `--font-body`. */
97
+ name: string;
98
+ /** The declared stack, lowercased, for family matching. */
99
+ value: string;
100
+ }
101
+
102
+ export interface FontResolution {
103
+ /** What to emit as `fontFamily`. Never the requested literal. */
104
+ css: string;
105
+ /** True when the emitted family is not the requested one. */
106
+ substituted: boolean;
107
+ /** Bounded requested family, for the report. Empty when unprintable. */
108
+ requested: string;
109
+ /** Which DS token matched, when one did. */
110
+ token?: string;
111
+ }
112
+
113
+ /**
114
+ * Resolve a requested family against the project's own type tokens first, then
115
+ * against the ubiquitous set, then to the system stack.
116
+ *
117
+ * DS token first is not only a fidelity choice — it is the editability one. An
118
+ * imported frame that inherits the project's type stack looks like it belongs to
119
+ * the project, which is the same reasoning `style-map.ts` gives for not carrying
120
+ * the family at all in the tree lane.
121
+ */
122
+ export function resolveFontFamily(
123
+ rawFamily: string,
124
+ tokens: readonly FontToken[] = []
125
+ ): FontResolution {
126
+ const { family } = splitFamilyAndStyle(rawFamily);
127
+ const requested = attrValue(family, MAX_FAMILY_DETAIL);
128
+ const needle = family.toLowerCase().replace(/['"]/g, '').trim();
129
+
130
+ if (needle.length === 0) {
131
+ return { css: SYSTEM_STACK, substituted: true, requested };
132
+ }
133
+
134
+ // A DS token whose declared stack NAMES this family is not a substitution —
135
+ // it is the same typeface, reached through the project's own variable.
136
+ for (const t of tokens) {
137
+ if (t.value.includes(needle)) {
138
+ return { css: `var(${t.name})`, substituted: false, requested, token: t.name };
139
+ }
140
+ }
141
+ if (UBIQUITOUS.has(needle)) {
142
+ return { css: `${quoteFamily(family)}, ${SYSTEM_STACK}`, substituted: false, requested };
143
+ }
144
+ // Nothing matched. Prefer the project's body token over a bare system stack —
145
+ // an imported frame in the project's own typeface beats one in the OS default.
146
+ const body = tokens.find((t) => /body|sans|text|base/.test(t.name));
147
+ if (body) return { css: `var(${body.name})`, substituted: true, requested, token: body.name };
148
+ return { css: SYSTEM_STACK, substituted: true, requested };
149
+ }
150
+
151
+ /**
152
+ * Quote a family name for a CSS value position. Charset-allowlisted first, so a
153
+ * family carrying a quote, a semicolon or a brace cannot terminate the
154
+ * declaration — the same reason DDR-172 Decision 4 has a font grammar at all.
155
+ */
156
+ export function quoteFamily(family: string): string {
157
+ const safe = family
158
+ .replace(/[^A-Za-z0-9 _-]+/g, '')
159
+ .trim()
160
+ .slice(0, 48);
161
+ return safe.length > 0 ? `'${safe}'` : SYSTEM_STACK;
162
+ }
163
+
164
+ /**
165
+ * Record substitutions ONCE PER FAMILY, with a count — not once per element.
166
+ *
167
+ * A screen using SF Pro on 40 nodes is ONE substitution a human needs to know
168
+ * about; forty identical entries would bury the other dispositions and blow the
169
+ * summary's 200-line cap for no information. The count is what makes the single
170
+ * entry honest.
171
+ */
172
+ export class FontSubstitutions {
173
+ private readonly counts = new Map<string, number>();
174
+
175
+ note(resolution: FontResolution): void {
176
+ if (!resolution.substituted) return;
177
+ const key = resolution.requested || 'unnamed';
178
+ this.counts.set(key, (this.counts.get(key) ?? 0) + 1);
179
+ }
180
+
181
+ get size(): number {
182
+ return this.counts.size;
183
+ }
184
+
185
+ /**
186
+ * Flush into the import report. `nodeId` is the FRAME's id — the substitution
187
+ * is a property of the import, not of one element, and `detail` is bounded to
188
+ * `<= MAX_DETAIL_LEN` by `ImportReport.add` throwing if it is not.
189
+ */
190
+ flush(report: ImportReport, nodeId: string): void {
191
+ for (const [family, n] of [...this.counts].sort((a, b) => a[0].localeCompare(b[0]))) {
192
+ report.add(nodeId, 'FONT', 'font-substituted', `${family} x${n}`.slice(0, 63));
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,179 @@
1
+ // figma/codegen-values.ts — the codegen lane's value grammar.
2
+ //
3
+ // Two things are being defended here, and they pull in opposite directions:
4
+ // a `var()` WITH a fallback must be ADMITTED (it is the fidelity this whole
5
+ // route exists for), and nothing SHARED with the tree translator or DDR-172's
6
+ // token importer may be widened to get it. So the tests assert both the new
7
+ // admissions and that the old refusals still refuse.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import {
12
+ cssPropToCamel,
13
+ isAllowedArbitraryProperty,
14
+ isCodegenColor,
15
+ isCodegenKeyword,
16
+ isCodegenLength,
17
+ isCodegenLengthList,
18
+ isCodegenNumber,
19
+ isCodegenShortValueList,
20
+ MAX_VALUE_LEN,
21
+ normalizeCalc,
22
+ splitVar,
23
+ unescapeArbitrary,
24
+ } from './codegen-values.ts';
25
+ import { isValidColorValue } from './style-map.ts';
26
+
27
+ describe('the shared grammar is not widened', () => {
28
+ test('style-map still refuses a fallback-bearing var() — that is the point', () => {
29
+ // If this ever passes, someone widened `VAR_RE` instead of composing, which
30
+ // is verbatim the root pattern the DDR-216 review named twice.
31
+ expect(isValidColorValue('var(--black,#0f161e)')).toBe(false);
32
+ expect(isValidColorValue('var(--black)')).toBe(true);
33
+ });
34
+
35
+ test('the lane admits it locally', () => {
36
+ expect(isCodegenColor('var(--black,#0f161e)')).toBe(true);
37
+ expect(isCodegenColor('var(--black-10,rgba(15,22,30,0.1))')).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe('isCodegenColor', () => {
42
+ test.each([
43
+ ['#0f161e', true],
44
+ ['rgba(15,22,30,0.1)', true],
45
+ ['var(--accent)', true],
46
+ ['var(--accent, #fff)', true],
47
+ ['white', true],
48
+ ['transparent', true],
49
+ // The fallback is validated as a colour IN ITS OWN RIGHT, so a URL smuggled
50
+ // into it is refused by the grammar and not merely by the canvas CSP.
51
+ ['var(--x,url(https://attacker.example/p))', false],
52
+ ['url(https://attacker.example/p)', false],
53
+ ['var(--x);color:red', false],
54
+ ['expression(alert(1))', false],
55
+ ['var(--ABC)', false],
56
+ ['#0f161e /* */', false],
57
+ ])('%s -> %s', (value, expected) => {
58
+ expect(isCodegenColor(value as string)).toBe(expected);
59
+ });
60
+
61
+ test('nested var() fallbacks terminate', () => {
62
+ expect(isCodegenColor('var(--a,var(--b,var(--c,#fff)))')).toBe(true);
63
+ expect(isCodegenColor('var(--a,var(--b,var(--c,var(--d,var(--e,#fff)))))')).toBe(false);
64
+ });
65
+
66
+ test('a value over the length cap is refused before any grammar runs', () => {
67
+ expect(isCodegenColor(`#${'0'.repeat(MAX_VALUE_LEN)}`)).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('lengths', () => {
72
+ test.each([
73
+ ['375px', true],
74
+ ['0', true],
75
+ ['4.17%', true],
76
+ ['1.5rem', true],
77
+ ['100vh', true],
78
+ ['auto', true],
79
+ ['calc(50% - 32.5px)', true],
80
+ ['99999999px', false],
81
+ ['red', false],
82
+ ['12px;color:red', false],
83
+ ['calc(50% - 32.5px) !important', false],
84
+ ])('%s -> %s', (v, expected) => {
85
+ expect(isCodegenLength(v as string)).toBe(expected);
86
+ });
87
+
88
+ test('a shorthand of up to four lengths', () => {
89
+ expect(isCodegenLengthList('37.5% 18.75% 26.56% 18.75%')).toBe(true);
90
+ expect(isCodegenLengthList('0 4.17%')).toBe(true);
91
+ expect(isCodegenLengthList('1px 2px 3px 4px 5px')).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('normalizeCalc', () => {
96
+ test('re-spaces the operator Figma omits', () => {
97
+ // `calc(50%-32.5px)` is INVALID CSS — a browser drops the declaration — so a
98
+ // pass-through would silently lose the position.
99
+ expect(normalizeCalc('calc(50%-32.5px)')).toBe('calc(50% - 32.5px)');
100
+ expect(normalizeCalc('calc(50%-0.02px)')).toBe('calc(50% - 0.02px)');
101
+ });
102
+
103
+ test('REGRESSION: a custom-property name is not an arithmetic expression', () => {
104
+ // The first version used a lookbehind regex and rewrote this to
105
+ // `var(--black - 10,…)`, which is a broken declaration on the single most
106
+ // common value shape this whole route exists to preserve.
107
+ expect(normalizeCalc('var(--black-10,rgba(15,22,30,0.1))')).toBe(
108
+ 'var(--black-10,rgba(15,22,30,0.1))'
109
+ );
110
+ expect(normalizeCalc('calc(var(--gap-2) + 4px)')).toBe('calc(var(--gap-2) + 4px)');
111
+ });
112
+
113
+ test('a leading sign is not an operator', () => {
114
+ expect(normalizeCalc('calc(-4px)')).toBe('calc(-4px)');
115
+ });
116
+
117
+ test('a value with no calc is returned untouched', () => {
118
+ expect(normalizeCalc('1 0 0')).toBe('1 0 0');
119
+ });
120
+ });
121
+
122
+ describe('unescapeArbitrary', () => {
123
+ test('an underscore is a space', () => {
124
+ expect(unescapeArbitrary('37.5%_18.75%')).toBe('37.5% 18.75%');
125
+ expect(unescapeArbitrary("'SF_Pro:Bold'")).toBe("'SF Pro:Bold'");
126
+ });
127
+
128
+ test('an escaped underscore stays an underscore', () => {
129
+ expect(unescapeArbitrary('a\\_b_c')).toBe('a_b c');
130
+ });
131
+ });
132
+
133
+ describe('splitVar', () => {
134
+ test('splits at depth 1 only', () => {
135
+ expect(splitVar('var(--a,rgba(1,2,3,0.5))')).toEqual({
136
+ name: '--a',
137
+ fallback: 'rgba(1,2,3,0.5)',
138
+ });
139
+ expect(splitVar('var(--a)')).toEqual({ name: '--a', fallback: null });
140
+ expect(splitVar('#fff')).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe('arbitrary properties are an allowlist', () => {
145
+ test('admits what Figma emits', () => {
146
+ expect(isAllowedArbitraryProperty('word-break')).toBe(true);
147
+ expect(isAllowedArbitraryProperty('font-variation-settings')).toBe(true);
148
+ });
149
+
150
+ test('refuses everything else, including things a denylist would forget', () => {
151
+ for (const prop of ['behavior', '-moz-binding', 'content', 'background-image', 'src']) {
152
+ expect(isAllowedArbitraryProperty(prop)).toBe(false);
153
+ }
154
+ });
155
+ });
156
+
157
+ describe('misc predicates', () => {
158
+ test('numbers are magnitude-bounded, not only shape-checked', () => {
159
+ expect(isCodegenNumber('0.5')).toBe(true);
160
+ expect(isCodegenNumber('999999999')).toBe(false);
161
+ });
162
+
163
+ test('keywords are short and lowercase', () => {
164
+ expect(isCodegenKeyword('max-content')).toBe(true);
165
+ expect(isCodegenKeyword('break-word')).toBe(true);
166
+ expect(isCodegenKeyword('URL(x)')).toBe(false);
167
+ });
168
+
169
+ test('short value lists', () => {
170
+ expect(isCodegenShortValueList('1 0 0')).toBe(true);
171
+ expect(isCodegenShortValueList('max-content')).toBe(true);
172
+ expect(isCodegenShortValueList('1 2 3 4 5 6 7')).toBe(false);
173
+ });
174
+
175
+ test('camelCase, because a hyphenated key in a JSX style OBJECT is a syntax error', () => {
176
+ expect(cssPropToCamel('word-break')).toBe('wordBreak');
177
+ expect(cssPropToCamel('font-variation-settings')).toBe('fontVariationSettings');
178
+ });
179
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @file figma/codegen-values.ts — the CODEGEN LANE's value grammar (DDR-219 D5 rule 7).
3
+ * @scope apps/studio/figma/codegen-values.ts
4
+ * @purpose Decide what a Tailwind arbitrary value is allowed to become in an
5
+ * emitted style object — for THIS lane only.
6
+ *
7
+ * @invariant NOTHING SHARED IS WIDENED. `style-map.ts`'s `VAR_RE` rejects a
8
+ * `var()` with a fallback — i.e. every `var(--black,#0f161e)` this
9
+ * route exists to preserve. The fix is NOT to loosen it: that
10
+ * constant is shared with the tree translator AND with DDR-172's
11
+ * token importer, and "widen a shared grammar to satisfy a new
12
+ * caller" is verbatim the root pattern the DDR-216 review named
13
+ * twice. This module COMPOSES the exported predicates
14
+ * (`isValidColorValue`, `isValidDimension`) and adds the lane's own
15
+ * admissions on top.
16
+ *
17
+ * @invariant A FALLBACK-BEARING `var()` IS ADMITTED, DELIBERATELY. It is the
18
+ * fidelity this route exists for — Figma resolves its own design
19
+ * variables and hands them over with a literal fallback. Admitted
20
+ * under a strict shape: the custom-property NAME is charset-bounded
21
+ * and the FALLBACK must itself pass the colour grammar.
22
+ *
23
+ * @invariant ReDoS DISCIPLINE (DDR-172 Decision 4, carried into this lane by
24
+ * D5 rule 6): printable-ASCII pre-filter first, no `m`/`s` flags,
25
+ * bounded quantifiers, and no construct where two adjacent parts can
26
+ * match the same empty input. Tailwind's bracket syntax is unbounded
27
+ * free-text capture wearing a utility name, so the pre-filter and
28
+ * the length bound do most of the work before any grammar runs.
29
+ *
30
+ * @invariant MAGNITUDE, NOT ONLY SHAPE. A grammar-valid `99999999px` is a
31
+ * live-render hang vector in a canvas iframe even though its shape
32
+ * is fine — every numeric admission is paired with a bound.
33
+ *
34
+ * @invariant DEPENDENCY-FREE beyond `style-map.ts`'s pure predicates.
35
+ */
36
+
37
+ import { isValidColorValue, isValidDimension } from './style-map.ts';
38
+
39
+ /** Longest arbitrary value we will even look at. Measured longest real one is
40
+ * `inset-[37.5%_18.75%_26.56%_18.75%]` at 33 chars; 160 is generous. */
41
+ export const MAX_VALUE_LEN = 160;
42
+
43
+ const PRINTABLE_ASCII_RE = /^[\x20-\x7E]*$/;
44
+
45
+ /** A CSS custom-property name. Same charset the shared `VAR_RE` uses — the
46
+ * difference here is only that a FALLBACK may follow, never a looser name. */
47
+ const CUSTOM_PROP_RE = /^--[a-z0-9-]{1,64}$/;
48
+
49
+ /** `1 0 0`, `max-content`, `min-content`, `auto`, `none`, `normal`, `break-word`… */
50
+ const KEYWORD_RE = /^[a-z][a-z-]{0,31}$/;
51
+
52
+ /** Bounds. Layout numbers in a canvas are px-scale; nothing legitimate is huge. */
53
+ const DIM_MAX = 8192;
54
+ const UNITLESS_MAX = 10_000;
55
+
56
+ function printableAscii(v: string): boolean {
57
+ return v.length <= MAX_VALUE_LEN && PRINTABLE_ASCII_RE.test(v);
58
+ }
59
+
60
+ /**
61
+ * Tailwind escapes a space as `_` inside an arbitrary value, so
62
+ * `inset-[0_4.17%]` is `inset: 0 4.17%` and `font-['SF_Pro:Bold']` is
63
+ * `SF Pro:Bold`. `\_` is a literal underscore and is preserved.
64
+ */
65
+ export function unescapeArbitrary(raw: string): string {
66
+ // ONE pass with a replacer rather than a sentinel round-trip: a sentinel is a
67
+ // character that must be impossible in the input, and "impossible" is the kind
68
+ // of assumption this lane is not allowed to make about a third-party string.
69
+ return raw.replace(/\\_|_/g, (m) => (m === '\\_' ? '_' : ' '));
70
+ }
71
+
72
+ /**
73
+ * Figma emits `calc(50%-32.5px)` — no spaces. That is INVALID CSS (a `-` with no
74
+ * surrounding whitespace is part of the number, not an operator), so a
75
+ * pass-through would silently produce a declaration the browser drops. Tailwind's
76
+ * own pipeline re-spaces it; so do we, before the grammar runs.
77
+ *
78
+ * Only a `+`/`-` that sits BETWEEN two operands is touched — a leading sign is
79
+ * left alone.
80
+ */
81
+ export function normalizeCalc(v: string): string {
82
+ if (!v.includes('calc(')) return v;
83
+ // A LINEAR SCAN, not a regex. The naive `(?<=[\w%)])([-+])(?=[\d.(])` version
84
+ // was wrong in a way that only showed up on a real value: it rewrote
85
+ // `var(--black-10,…)` into `var(--black - 10,…)`, because a hyphen inside a
86
+ // custom-property NAME looks exactly like a subtraction operator. The fix is
87
+ // context, and context is a stack — which is also ReDoS-free by construction.
88
+ const out: string[] = [];
89
+ /** Innermost function name, so `var(` nested inside `calc(` is left alone. */
90
+ const fnStack: string[] = [];
91
+ for (let i = 0; i < v.length; i += 1) {
92
+ const ch = v[i];
93
+ if (ch === '(') {
94
+ const m = /([a-z-]{1,16})$/i.exec(v.slice(0, i));
95
+ fnStack.push(m ? m[1].toLowerCase() : '');
96
+ out.push(ch);
97
+ continue;
98
+ }
99
+ if (ch === ')') {
100
+ fnStack.pop();
101
+ out.push(ch);
102
+ continue;
103
+ }
104
+ const inCalc = fnStack[fnStack.length - 1] === 'calc';
105
+ if (inCalc && (ch === '-' || ch === '+')) {
106
+ const prev = v[i - 1] ?? '';
107
+ const next = v[i + 1] ?? '';
108
+ // An operator sits between two operands. A leading sign has no operand to
109
+ // its left; `e` is excluded so `1e-5` keeps its exponent.
110
+ const prevIsOperand = /[\d%)a-df-z]/i.test(prev);
111
+ if (prevIsOperand && /[\d.(]/.test(next)) {
112
+ out.push(' ', ch, ' ');
113
+ continue;
114
+ }
115
+ }
116
+ out.push(ch);
117
+ }
118
+ return out.join('');
119
+ }
120
+
121
+ /** One `calc()` term: an optionally-signed bounded number with an optional unit. */
122
+ const CALC_TERM = '-?\\d{1,7}(?:\\.\\d{1,6})?(?:px|%|rem|em|vw|vh)?';
123
+ /** `calc(a op b op c …)`, at most 8 terms. Written so no two adjacent parts can
124
+ * match empty — the property that keeps a bounded quantifier from backtracking. */
125
+ const CALC_RE = new RegExp(`^calc\\(\\s*${CALC_TERM}(?:\\s*[-+*/]\\s*${CALC_TERM}){0,7}\\s*\\)$`);
126
+
127
+ /** A single length: what the shared grammar admits, plus viewport units and calc. */
128
+ export function isCodegenLength(v: string, max = DIM_MAX): boolean {
129
+ if (!printableAscii(v)) return false;
130
+ if (v === '0' || v === 'auto' || v === 'none') return true;
131
+ if (isValidDimension(v, max)) return true;
132
+ const vw = /^(-?\d{1,7}(?:\.\d{1,6})?)(vw|vh)$/.exec(v);
133
+ if (vw) return Math.abs(Number.parseFloat(vw[1])) <= max;
134
+ return CALC_RE.test(v);
135
+ }
136
+
137
+ /**
138
+ * A shorthand of 1–4 lengths (`inset`, `padding`, `margin`). Split-then-validate
139
+ * rather than one composite regex: the parts are already bounded individually,
140
+ * and a composite would be the exact nested-quantifier shape D5 rule 6 bans.
141
+ */
142
+ export function isCodegenLengthList(v: string, max = DIM_MAX): boolean {
143
+ if (!printableAscii(v)) return false;
144
+ const parts = v.trim().split(/\s+/);
145
+ if (parts.length === 0 || parts.length > 4) return false;
146
+ return parts.every((p) => isCodegenLength(p, max));
147
+ }
148
+
149
+ /**
150
+ * Split `var(--name, fallback)` without a nested-quantifier regex: find the
151
+ * first comma at depth 1. Returns `null` when the string is not a `var()` at all.
152
+ */
153
+ export function splitVar(v: string): { name: string; fallback: string | null } | null {
154
+ if (!v.startsWith('var(') || !v.endsWith(')')) return null;
155
+ const inner = v.slice(4, -1);
156
+ let depth = 0;
157
+ for (let i = 0; i < inner.length; i += 1) {
158
+ const ch = inner[i];
159
+ if (ch === '(') depth += 1;
160
+ else if (ch === ')') depth -= 1;
161
+ else if (ch === ',' && depth === 0) {
162
+ return { name: inner.slice(0, i).trim(), fallback: inner.slice(i + 1).trim() };
163
+ }
164
+ }
165
+ return { name: inner.trim(), fallback: null };
166
+ }
167
+
168
+ /**
169
+ * A colour for this lane: everything the shared grammar admits, PLUS a `var()`
170
+ * carrying a fallback, PLUS the handful of bare keywords Tailwind emits.
171
+ *
172
+ * The fallback is validated as a colour in its own right, so
173
+ * `var(--x,url(https://attacker/))` is rejected here rather than relying on the
174
+ * canvas CSP to refuse the fetch. (The CSP — `default-src 'none'`,
175
+ * `img-src 'self' data: blob:` — WOULD block it, which is why the blast radius
176
+ * of a grammar gap in this lane is spoofing rather than beaconing. That bounds
177
+ * the damage; it does not make the grammar sound, so the grammar is sound.)
178
+ */
179
+ export function isCodegenColor(v: string, depth = 0): boolean {
180
+ // A `var()` fallback may itself be a `var()`. Bounded explicitly rather than
181
+ // relying on the 160-char length cap to run out first — a recursion whose
182
+ // termination is an accident of another constant is one edit from unbounded.
183
+ if (depth > 4) return false;
184
+ if (!printableAscii(v)) return false;
185
+ if (v === 'transparent' || v === 'currentColor' || v === 'inherit') return true;
186
+ if (isValidColorValue(v)) return true;
187
+ const parsed = splitVar(v);
188
+ if (!parsed) return NAMED_COLOR_SET.has(v);
189
+ if (!CUSTOM_PROP_RE.test(parsed.name)) return false;
190
+ if (parsed.fallback === null) return true;
191
+ return isCodegenColor(parsed.fallback, depth + 1);
192
+ }
193
+
194
+ /**
195
+ * The CSS named colours Figma's generator actually reaches for. Deliberately a
196
+ * short allowlist rather than the full 148-name table: a name is a value
197
+ * position, and the shorter the admitted vocabulary the smaller the surface.
198
+ */
199
+ const NAMED_COLOR_SET: ReadonlySet<string> = new Set([
200
+ 'white',
201
+ 'black',
202
+ 'red',
203
+ 'green',
204
+ 'blue',
205
+ 'gray',
206
+ 'grey',
207
+ 'silver',
208
+ 'transparent',
209
+ ]);
210
+
211
+ /** A unitless number (`opacity`, `z-index`, `flex-grow`, `line-height`). */
212
+ export function isCodegenNumber(v: string, max = UNITLESS_MAX): boolean {
213
+ if (!printableAscii(v)) return false;
214
+ if (!/^-?\d{1,7}(?:\.\d{1,6})?$/.test(v)) return false;
215
+ return Math.abs(Number.parseFloat(v)) <= max;
216
+ }
217
+
218
+ /** A bare keyword value (`max-content`, `break-word`, `nowrap`, `normal`). */
219
+ export function isCodegenKeyword(v: string): boolean {
220
+ return printableAscii(v) && KEYWORD_RE.test(v);
221
+ }
222
+
223
+ /**
224
+ * `flex: 1 0 0`, `grid-template-columns: max-content`, and friends: a short
225
+ * sequence of numbers, lengths and keywords. Never a `url()`, never a function
226
+ * this module has not admitted by name.
227
+ */
228
+ export function isCodegenShortValueList(v: string): boolean {
229
+ if (!printableAscii(v)) return false;
230
+ const parts = v.trim().split(/\s+/);
231
+ if (parts.length === 0 || parts.length > 6) return false;
232
+ return parts.every(
233
+ (p) => isCodegenNumber(p) || isCodegenLength(p) || isCodegenKeyword(p) || /^\d+fr$/.test(p)
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Properties an ARBITRARY-PROPERTY utility (`[word-break:break-word]`) may set.
239
+ *
240
+ * An allowlist, never a denylist (D5 rule 3): the bracket syntax is a hole
241
+ * straight into the style object, and a denylist has to remember `behavior`,
242
+ * `-moz-binding`, `content` and whatever the next engine ships.
243
+ */
244
+ const ARBITRARY_PROPERTY_ALLOWLIST: ReadonlySet<string> = new Set([
245
+ 'word-break',
246
+ 'overflow-wrap',
247
+ 'white-space',
248
+ 'text-wrap',
249
+ 'font-variation-settings',
250
+ 'font-feature-settings',
251
+ 'text-overflow',
252
+ 'letter-spacing',
253
+ 'line-height',
254
+ 'vertical-align',
255
+ 'mix-blend-mode',
256
+ 'object-fit',
257
+ 'object-position',
258
+ 'aspect-ratio',
259
+ ]);
260
+
261
+ export function isAllowedArbitraryProperty(prop: string): boolean {
262
+ return ARBITRARY_PROPERTY_ALLOWLIST.has(prop);
263
+ }
264
+
265
+ /** `word-break` → `wordBreak`. Emitted style objects are JSX, and a hyphenated
266
+ * key in a style OBJECT is a syntax error that takes the whole canvas down —
267
+ * the exact defect that made one gradient unparse a whole page (DDR-216 D12). */
268
+ export function cssPropToCamel(prop: string): string {
269
+ return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
270
+ }