@1agh/maude 0.58.2 → 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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- 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 +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- 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/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -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 +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- 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.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -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
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/style-map.ts — Figma paint/effect/typeStyle → CSS.
|
|
3
|
+
* @scope apps/studio/figma/style-map.ts
|
|
4
|
+
* @purpose Turn a node's visual properties into CSS declarations, resolving
|
|
5
|
+
* every colour to the NEAREST active-DS token rather than baking a
|
|
6
|
+
* hex into an imported canvas.
|
|
7
|
+
*
|
|
8
|
+
* @invariant IMPORTED FRAMES MUST NOT HARDCODE HEX. A canvas whose colours are
|
|
9
|
+
* literals is off-brand the moment the DS moves, and it defeats the
|
|
10
|
+
* point of importing INTO a design system. Every paint resolves to
|
|
11
|
+
* `var(--token)` inside a configurable ΔE threshold; outside it, the
|
|
12
|
+
* literal ships WITH a `/* figma: no near token */` marker so the
|
|
13
|
+
* drift is auditable rather than silent. Threshold and fallback are
|
|
14
|
+
* per-import flags, never hidden constants.
|
|
15
|
+
*
|
|
16
|
+
* @invariant EVERY EMITTED VALUE IS GRAMMAR-VALIDATED FIRST (DDR-216 D6, which
|
|
17
|
+
* reuses DDR-172 Decision 4 verbatim): printable-ASCII pre-filter,
|
|
18
|
+
* no `m`/`s` regex flags, shape AND magnitude bounds, no unbounded
|
|
19
|
+
* free-text capture. A value that fails is DROPPED and reported —
|
|
20
|
+
* never "cleaned up" and never partially applied. This matters more
|
|
21
|
+
* here than in `import-tokens`: a canvas stylesheet is rendered live
|
|
22
|
+
* in the canvas iframe.
|
|
23
|
+
*
|
|
24
|
+
* @invariant DEPENDENCY-FREE beyond `draw/palette.ts` (the colorspace helpers
|
|
25
|
+
* `import-tokens` already reuses). No fs, no network.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { parseColor, rgbToOklch } from '../draw/palette.ts';
|
|
29
|
+
import type { FigmaColor, FigmaEffect, FigmaNode, FigmaPaint, FigmaTypeStyle } from './types.ts';
|
|
30
|
+
|
|
31
|
+
/** Default perceptual distance under which a paint snaps to a DS token. */
|
|
32
|
+
export const DEFAULT_TOKEN_THRESHOLD = 0.08;
|
|
33
|
+
|
|
34
|
+
/** The marker that makes an un-snapped literal auditable rather than silent. */
|
|
35
|
+
export const NO_TOKEN_MARKER = '/* figma: no near token */';
|
|
36
|
+
|
|
37
|
+
export interface DsToken {
|
|
38
|
+
/** e.g. `--accent` — the name only, never a value the caller invented. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** Resolved hex for distance comparison. */
|
|
41
|
+
hex: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Value grammars (DDR-172 Decision 4, reused verbatim per DDR-216 D6) ─────
|
|
45
|
+
//
|
|
46
|
+
// Deliberately NO unbounded free-text capture anywhere, and every numeric
|
|
47
|
+
// grammar is paired with a magnitude bound: a grammar-valid `99999999px` is a
|
|
48
|
+
// live-render hang vector even though its SHAPE is fine.
|
|
49
|
+
|
|
50
|
+
const PRINTABLE_ASCII_RE = /^[\x20-\x7E]*$/;
|
|
51
|
+
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
52
|
+
const RGBA_RE = /^rgba?\( ?[\d.]+[ ,]+[\d.]+[ ,]+[\d.]+(?: ?[,/] ?[\d.]+%?)? ?\)$/;
|
|
53
|
+
const VAR_RE = /^var\(--[a-z0-9-]{1,64}\)$/;
|
|
54
|
+
const DIM_MAX = 4096;
|
|
55
|
+
const FONT_SIZE_MAX = 512;
|
|
56
|
+
|
|
57
|
+
/** True when a string is safe to place in a CSS value position at all. */
|
|
58
|
+
function printableAscii(v: string): boolean {
|
|
59
|
+
// Checked BEFORE any grammar regex, and no grammar here uses `m`/`s` — an
|
|
60
|
+
// anchor is only as strong as the flags it runs under, and a multiline value
|
|
61
|
+
// would otherwise match just its first line.
|
|
62
|
+
return PRINTABLE_ASCII_RE.test(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isValidColorValue(v: string): boolean {
|
|
66
|
+
if (!printableAscii(v)) return false;
|
|
67
|
+
return HEX_RE.test(v) || RGBA_RE.test(v) || VAR_RE.test(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isValidDimension(v: string, max = DIM_MAX): boolean {
|
|
71
|
+
if (!printableAscii(v)) return false;
|
|
72
|
+
if (v === '0') return true;
|
|
73
|
+
const m = /^(-?[\d.]{1,7})(px|rem|em|%)$/.exec(v);
|
|
74
|
+
if (!m) return false;
|
|
75
|
+
const n = Number.parseFloat(m[1]);
|
|
76
|
+
return Number.isFinite(n) && Math.abs(n) <= max;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Colour ──────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function figmaColorToHex(c: FigmaColor): string {
|
|
82
|
+
const to = (v: number) =>
|
|
83
|
+
Math.max(0, Math.min(255, Math.round(v * 255)))
|
|
84
|
+
.toString(16)
|
|
85
|
+
.padStart(2, '0');
|
|
86
|
+
return `#${to(c.r)}${to(c.g)}${to(c.b)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Perceptual distance in OKLCH — the same space `import-tokens` compares in. */
|
|
90
|
+
export function perceptualDistance(aHex: string, bHex: string): number {
|
|
91
|
+
try {
|
|
92
|
+
const a = rgbToOklch(parseColor(aHex));
|
|
93
|
+
const b = rgbToOklch(parseColor(bHex));
|
|
94
|
+
// Chroma/hue folded into a Cartesian pair so a hue difference at low chroma
|
|
95
|
+
// (where hue is perceptually meaningless) doesn't dominate the distance.
|
|
96
|
+
const aa = a.c * Math.cos((a.h * Math.PI) / 180);
|
|
97
|
+
const ab = a.c * Math.sin((a.h * Math.PI) / 180);
|
|
98
|
+
const ba = b.c * Math.cos((b.h * Math.PI) / 180);
|
|
99
|
+
const bb = b.c * Math.sin((b.h * Math.PI) / 180);
|
|
100
|
+
return Math.hypot(a.l - b.l, aa - ba, ab - bb);
|
|
101
|
+
} catch {
|
|
102
|
+
return Number.POSITIVE_INFINITY;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ResolvedColor {
|
|
107
|
+
/** The CSS value to emit — `var(--token)` or a literal hex. */
|
|
108
|
+
value: string;
|
|
109
|
+
/** Set when no token was near enough; the caller emits it as a comment. */
|
|
110
|
+
marker?: string;
|
|
111
|
+
/** Which token matched, for the report. */
|
|
112
|
+
token?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a hex to the nearest DS token, or fall back to the literal WITH a
|
|
117
|
+
* marker. Never invents a token name and never silently ships a hex.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveColor(
|
|
120
|
+
hex: string,
|
|
121
|
+
tokens: readonly DsToken[],
|
|
122
|
+
threshold = DEFAULT_TOKEN_THRESHOLD
|
|
123
|
+
): ResolvedColor {
|
|
124
|
+
let best: DsToken | null = null;
|
|
125
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
126
|
+
for (const t of tokens) {
|
|
127
|
+
const d = perceptualDistance(hex, t.hex);
|
|
128
|
+
if (d < bestDist) {
|
|
129
|
+
bestDist = d;
|
|
130
|
+
best = t;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (best && bestDist <= threshold) {
|
|
134
|
+
return { value: `var(${best.name})`, token: best.name };
|
|
135
|
+
}
|
|
136
|
+
return { value: hex, marker: NO_TOKEN_MARKER };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Paints / effects / type ─────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export interface StyleMapOptions {
|
|
142
|
+
tokens: readonly DsToken[];
|
|
143
|
+
threshold?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface MappedStyle {
|
|
147
|
+
/** CSS property → value. Every value has already passed its grammar. */
|
|
148
|
+
declarations: Record<string, string>;
|
|
149
|
+
/** Properties dropped because a value failed its grammar (reported). */
|
|
150
|
+
rejected: string[];
|
|
151
|
+
/** Properties that shipped a literal because no token was near (reported). */
|
|
152
|
+
unTokenized: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function emptyStyle(): MappedStyle {
|
|
156
|
+
return { declarations: {}, rejected: [], unTokenized: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function firstVisible<T extends { visible: boolean }>(list: readonly T[] | undefined): T | null {
|
|
160
|
+
for (const p of list ?? []) if (p.visible) return p;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A linear gradient → the CSS equivalent. Angle is derived from the handles. */
|
|
165
|
+
function gradientCss(paint: FigmaPaint, opts: StyleMapOptions, out: MappedStyle): string | null {
|
|
166
|
+
const stops = paint.gradientStops;
|
|
167
|
+
if (!stops || stops.length < 2) return null;
|
|
168
|
+
const parts: string[] = [];
|
|
169
|
+
for (const s of stops) {
|
|
170
|
+
const resolved = resolveColor(figmaColorToHex(s.color), opts.tokens, opts.threshold);
|
|
171
|
+
if (!isValidColorValue(resolved.value)) return null;
|
|
172
|
+
if (resolved.marker) out.unTokenized.push('background-image');
|
|
173
|
+
const pct = Math.max(0, Math.min(100, Math.round(s.position * 100)));
|
|
174
|
+
parts.push(`${resolved.value} ${pct}%`);
|
|
175
|
+
}
|
|
176
|
+
const handles = paint.gradientHandlePositions;
|
|
177
|
+
let angle = 180;
|
|
178
|
+
if (handles && handles.length >= 2) {
|
|
179
|
+
const dx = handles[1].x - handles[0].x;
|
|
180
|
+
const dy = handles[1].y - handles[0].y;
|
|
181
|
+
angle = Math.round((Math.atan2(dy, dx) * 180) / Math.PI + 90);
|
|
182
|
+
}
|
|
183
|
+
return `linear-gradient(${angle}deg, ${parts.join(', ')})`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Effects → `box-shadow`. Every layer is strict-numeric; no free-text capture. */
|
|
187
|
+
function shadowCss(effects: readonly FigmaEffect[], opts: StyleMapOptions): string | null {
|
|
188
|
+
const layers: string[] = [];
|
|
189
|
+
for (const e of effects) {
|
|
190
|
+
if (!e.visible) continue;
|
|
191
|
+
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') continue;
|
|
192
|
+
const c = e.color ? figmaColorToHex(e.color) : '#000000';
|
|
193
|
+
const resolved = resolveColor(c, opts.tokens, opts.threshold);
|
|
194
|
+
if (!isValidColorValue(resolved.value)) continue;
|
|
195
|
+
const x = Math.round(e.offset?.x ?? 0);
|
|
196
|
+
const y = Math.round(e.offset?.y ?? 0);
|
|
197
|
+
const blur = Math.round(e.radius ?? 0);
|
|
198
|
+
const spread = Math.round(e.spread ?? 0);
|
|
199
|
+
if ([x, y, blur, spread].some((n) => !Number.isFinite(n) || Math.abs(n) > 512)) continue;
|
|
200
|
+
const inset = e.type === 'INNER_SHADOW' ? 'inset ' : '';
|
|
201
|
+
layers.push(`${inset}${x}px ${y}px ${blur}px ${spread}px ${resolved.value}`);
|
|
202
|
+
}
|
|
203
|
+
return layers.length ? layers.join(', ') : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A node's fills/strokes/effects/corner radius → CSS declarations. */
|
|
207
|
+
export function mapNodeStyle(node: FigmaNode, opts: StyleMapOptions): MappedStyle {
|
|
208
|
+
const out = emptyStyle();
|
|
209
|
+
const set = (prop: string, value: string, validator: (v: string) => boolean) => {
|
|
210
|
+
if (!validator(value)) {
|
|
211
|
+
out.rejected.push(prop);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
out.declarations[prop] = value;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const fill = firstVisible(node.fills);
|
|
218
|
+
if (fill) {
|
|
219
|
+
if (fill.type === 'SOLID' && fill.color) {
|
|
220
|
+
const resolved = resolveColor(figmaColorToHex(fill.color), opts.tokens, opts.threshold);
|
|
221
|
+
if (resolved.marker) out.unTokenized.push('background');
|
|
222
|
+
set('background', resolved.value, isValidColorValue);
|
|
223
|
+
} else if (fill.type.startsWith('GRADIENT')) {
|
|
224
|
+
const css = gradientCss(fill, opts, out);
|
|
225
|
+
// A gradient is not a colour value — it has its own (bounded) shape,
|
|
226
|
+
// built entirely from already-validated colour values plus integers.
|
|
227
|
+
// camelCase, NOT `background-image`: these declarations are emitted into
|
|
228
|
+
// a JSX style OBJECT, where a hyphenated key is a syntax error that takes
|
|
229
|
+
// the whole canvas down. One gradient anywhere on a page was enough to
|
|
230
|
+
// make the file unparseable.
|
|
231
|
+
if (css) out.declarations.backgroundImage = css;
|
|
232
|
+
else out.rejected.push('background-image');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stroke = firstVisible(node.strokes);
|
|
237
|
+
if (stroke?.type === 'SOLID' && stroke.color) {
|
|
238
|
+
const resolved = resolveColor(figmaColorToHex(stroke.color), opts.tokens, opts.threshold);
|
|
239
|
+
if (resolved.marker) out.unTokenized.push('border-color');
|
|
240
|
+
const w = Math.round(node.strokeWeight ?? 1);
|
|
241
|
+
if (Number.isFinite(w) && w > 0 && w <= 64 && isValidColorValue(resolved.value)) {
|
|
242
|
+
out.declarations.border = `${w}px solid ${resolved.value}`;
|
|
243
|
+
} else {
|
|
244
|
+
out.rejected.push('border');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (node.cornerRadius !== undefined) {
|
|
249
|
+
set('borderRadius', `${Math.round(node.cornerRadius)}px`, (v) => isValidDimension(v, 512));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (node.effects?.length) {
|
|
253
|
+
const css = shadowCss(node.effects, opts);
|
|
254
|
+
if (css) out.declarations.boxShadow = css;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Node opacity is carried ONLY when it is not a hiding mechanism. D6b makes
|
|
258
|
+
// the translator guarantee visibility, so an opacity that would render the
|
|
259
|
+
// subtree effectively invisible is dropped rather than reproduced.
|
|
260
|
+
if (node.opacity !== undefined && node.opacity < 1) {
|
|
261
|
+
if (node.opacity >= 0.15) out.declarations.opacity = String(Number(node.opacity.toFixed(3)));
|
|
262
|
+
else out.rejected.push('opacity');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A TEXT node's `style` block → CSS declarations. */
|
|
269
|
+
export function mapTypeStyle(
|
|
270
|
+
style: FigmaTypeStyle | undefined,
|
|
271
|
+
opts: StyleMapOptions
|
|
272
|
+
): MappedStyle {
|
|
273
|
+
const out = emptyStyle();
|
|
274
|
+
if (!style) return out;
|
|
275
|
+
|
|
276
|
+
if (style.fontSize !== undefined) {
|
|
277
|
+
const v = `${Math.round(style.fontSize)}px`;
|
|
278
|
+
if (isValidDimension(v, FONT_SIZE_MAX)) out.declarations.fontSize = v;
|
|
279
|
+
else out.rejected.push('fontSize');
|
|
280
|
+
}
|
|
281
|
+
if (style.fontWeight !== undefined) {
|
|
282
|
+
const w = Math.round(style.fontWeight);
|
|
283
|
+
if (w >= 100 && w <= 1000) out.declarations.fontWeight = String(w);
|
|
284
|
+
else out.rejected.push('fontWeight');
|
|
285
|
+
}
|
|
286
|
+
if (style.lineHeightPx !== undefined) {
|
|
287
|
+
const v = `${Math.round(style.lineHeightPx)}px`;
|
|
288
|
+
if (isValidDimension(v, FONT_SIZE_MAX)) out.declarations.lineHeight = v;
|
|
289
|
+
else out.rejected.push('lineHeight');
|
|
290
|
+
}
|
|
291
|
+
if (style.letterSpacing !== undefined && style.letterSpacing !== 0) {
|
|
292
|
+
const v = `${style.letterSpacing.toFixed(2)}px`;
|
|
293
|
+
if (isValidDimension(v, 64)) out.declarations.letterSpacing = v;
|
|
294
|
+
else out.rejected.push('letterSpacing');
|
|
295
|
+
}
|
|
296
|
+
if (style.textAlignHorizontal) {
|
|
297
|
+
const align = style.textAlignHorizontal.toLowerCase();
|
|
298
|
+
if (['left', 'center', 'right', 'justified'].includes(align)) {
|
|
299
|
+
out.declarations.textAlign = align === 'justified' ? 'justify' : align;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (style.textCase === 'UPPER') out.declarations.textTransform = 'uppercase';
|
|
303
|
+
if (style.textCase === 'LOWER') out.declarations.textTransform = 'lowercase';
|
|
304
|
+
if (style.textDecoration === 'UNDERLINE') out.declarations.textDecoration = 'underline';
|
|
305
|
+
if (style.textDecoration === 'STRIKETHROUGH') out.declarations.textDecoration = 'line-through';
|
|
306
|
+
|
|
307
|
+
// FONT FAMILY IS DELIBERATELY NOT CARRIED. A Figma font name is free text
|
|
308
|
+
// from the document, and DDR-172 Decision 4's font grammar exists precisely
|
|
309
|
+
// because a family value reaches a live stylesheet. An imported canvas
|
|
310
|
+
// inherits the DS's own type stack instead — which is also the editability
|
|
311
|
+
// choice: an imported frame should look like it belongs to the project.
|
|
312
|
+
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Auto-layout → flex. The thing that makes the handle surfaces work at all. */
|
|
317
|
+
export function mapAutoLayout(node: FigmaNode): Record<string, string> {
|
|
318
|
+
if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') return {};
|
|
319
|
+
const out: Record<string, string> = {
|
|
320
|
+
display: 'flex',
|
|
321
|
+
flexDirection: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
|
|
322
|
+
};
|
|
323
|
+
if (node.itemSpacing) out.gap = `${Math.round(node.itemSpacing)}px`;
|
|
324
|
+
const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft];
|
|
325
|
+
if (pad.some((p) => p !== undefined && p !== 0)) {
|
|
326
|
+
out.padding = pad.map((p) => `${Math.round(p ?? 0)}px`).join(' ');
|
|
327
|
+
}
|
|
328
|
+
// `Object.create(null)`: these are indexed by DOCUMENT-CONTROLLED strings, so a
|
|
329
|
+
// plain literal returns `Object.prototype.constructor` (a FUNCTION) for
|
|
330
|
+
// `primaryAxisAlignItems: "constructor"`, which then stringifies into an
|
|
331
|
+
// emitted style value (review F11). D5 mandates this shape for exactly this.
|
|
332
|
+
const MAIN: Record<string, string> = Object.assign(Object.create(null), {
|
|
333
|
+
MIN: 'flex-start',
|
|
334
|
+
CENTER: 'center',
|
|
335
|
+
MAX: 'flex-end',
|
|
336
|
+
SPACE_BETWEEN: 'space-between',
|
|
337
|
+
});
|
|
338
|
+
const CROSS: Record<string, string> = Object.assign(Object.create(null), {
|
|
339
|
+
MIN: 'flex-start',
|
|
340
|
+
CENTER: 'center',
|
|
341
|
+
MAX: 'flex-end',
|
|
342
|
+
BASELINE: 'baseline',
|
|
343
|
+
});
|
|
344
|
+
if (node.primaryAxisAlignItems && MAIN[node.primaryAxisAlignItems]) {
|
|
345
|
+
out.justifyContent = MAIN[node.primaryAxisAlignItems];
|
|
346
|
+
}
|
|
347
|
+
if (node.counterAxisAlignItems && CROSS[node.counterAxisAlignItems]) {
|
|
348
|
+
out.alignItems = CROSS[node.counterAxisAlignItems];
|
|
349
|
+
}
|
|
350
|
+
if (node.layoutWrap === 'WRAP') out.flexWrap = 'wrap';
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
@@ -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
|
+
});
|