@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
|
@@ -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
|
+
}
|