@1agh/maude 0.58.2 → 0.58.3
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/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -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 +1261 -1261
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- 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 +13 -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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-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/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -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
|
+
}
|