@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,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/tailwind-map.ts — Tailwind utility → JSX style object.
|
|
3
|
+
* @scope apps/studio/figma/tailwind-map.ts
|
|
4
|
+
* @purpose The half of the codegen route that is genuinely small: turn the
|
|
5
|
+
* class list Figma's generator emits into inline style declarations
|
|
6
|
+
* a Maude canvas renders natively.
|
|
7
|
+
*
|
|
8
|
+
* @rationale Sized by a spike on a real 375×812 screen (`425:2939`, DDR-219
|
|
9
|
+
* § Spike): **1 276 class tokens · 129 distinct · 64 families**, and
|
|
10
|
+
* a throwaway mapper reached **129/129 with zero unmapped in ~155
|
|
11
|
+
* lines**. The plan's fear that "the arbitrary-value syntax is where
|
|
12
|
+
* it will get long" is real and FINITE. Every family in the table
|
|
13
|
+
* below was observed in that measurement; nothing here is
|
|
14
|
+
* speculative Tailwind coverage.
|
|
15
|
+
*
|
|
16
|
+
* @invariant EVERY EMITTED VALUE PASSES `codegen-values.ts` FIRST. A utility
|
|
17
|
+
* whose value fails its grammar is DROPPED and REPORTED
|
|
18
|
+
* (`codegen-utility-unmapped`), never "cleaned up", never partially
|
|
19
|
+
* applied. This matters more here than in `import-tokens`: these
|
|
20
|
+
* declarations render live in the canvas iframe.
|
|
21
|
+
*
|
|
22
|
+
* @invariant KEYS ARE camelCase, ALWAYS. These land in a JSX style OBJECT,
|
|
23
|
+
* where a hyphenated key is a syntax error that takes the whole
|
|
24
|
+
* canvas down — one gradient emitted as `background-image` was
|
|
25
|
+
* enough to make a whole page unparseable (DDR-216 D12's autopsy).
|
|
26
|
+
*
|
|
27
|
+
* @invariant AN UNMAPPED UTILITY IS REPORTED, NEVER SILENTLY DROPPED. That is
|
|
28
|
+
* an acceptance criterion of this phase, and it is also the only way
|
|
29
|
+
* the table's coverage stays measurable as Figma's generator moves.
|
|
30
|
+
*
|
|
31
|
+
* @invariant `Object.create(null)` FOR EVERY MAP INDEXED BY A PARSED STRING
|
|
32
|
+
* (D5 rule 5). A plain object literal returns a FUNCTION for the key
|
|
33
|
+
* `constructor`, which then stringifies into an emitted style value
|
|
34
|
+
* — the exact shape review F11 found in `style-map.ts`.
|
|
35
|
+
*
|
|
36
|
+
* @invariant DEPENDENCY-FREE beyond `codegen-values.ts`, `codegen-fonts.ts` and
|
|
37
|
+
* `style-map.ts`'s pure `resolveColor`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
type FontToken,
|
|
42
|
+
resolveFontFamily,
|
|
43
|
+
splitFamilyAndStyle,
|
|
44
|
+
styleToWeight,
|
|
45
|
+
} from './codegen-fonts.ts';
|
|
46
|
+
import {
|
|
47
|
+
cssPropToCamel,
|
|
48
|
+
isAllowedArbitraryProperty,
|
|
49
|
+
isCodegenColor,
|
|
50
|
+
isCodegenKeyword,
|
|
51
|
+
isCodegenLength,
|
|
52
|
+
isCodegenLengthList,
|
|
53
|
+
isCodegenNumber,
|
|
54
|
+
isCodegenShortValueList,
|
|
55
|
+
MAX_VALUE_LEN,
|
|
56
|
+
normalizeCalc,
|
|
57
|
+
unescapeArbitrary,
|
|
58
|
+
} from './codegen-values.ts';
|
|
59
|
+
import { ensureFontSize } from './sanitize.ts';
|
|
60
|
+
import { type DsToken, resolveColor } from './style-map.ts';
|
|
61
|
+
|
|
62
|
+
/** A class list longer than this is not a design, it is a payload. */
|
|
63
|
+
export const MAX_CLASSES_PER_ELEMENT = 64;
|
|
64
|
+
|
|
65
|
+
export interface TailwindContext {
|
|
66
|
+
/** DS colour tokens, so a bare hex can snap onto the project's palette. */
|
|
67
|
+
tokens?: readonly DsToken[];
|
|
68
|
+
/** DS type tokens, so a family resolves to the project's own stack. */
|
|
69
|
+
fontTokens?: readonly FontToken[];
|
|
70
|
+
threshold?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MappedClasses {
|
|
74
|
+
/** camelCase CSS property → validated value. */
|
|
75
|
+
declarations: Record<string, string>;
|
|
76
|
+
/** Utilities this table does not know, or whose value failed its grammar. */
|
|
77
|
+
unmapped: string[];
|
|
78
|
+
/** Font families that did not survive the copy, for the T18 report. */
|
|
79
|
+
substitutedFonts: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Exact-match utilities. Keys are literals, so a plain frozen record is fine —
|
|
83
|
+
* the `Object.create(null)` rule bites on maps INDEXED by a parsed string, and
|
|
84
|
+
* this one is only ever probed with `Object.hasOwn`. */
|
|
85
|
+
const STATIC: Readonly<Record<string, Readonly<Record<string, string>>>> = Object.freeze({
|
|
86
|
+
// display
|
|
87
|
+
flex: { display: 'flex' },
|
|
88
|
+
'inline-flex': { display: 'inline-flex' },
|
|
89
|
+
grid: { display: 'grid' },
|
|
90
|
+
'inline-grid': { display: 'inline-grid' },
|
|
91
|
+
block: { display: 'block' },
|
|
92
|
+
'inline-block': { display: 'inline-block' },
|
|
93
|
+
inline: { display: 'inline' },
|
|
94
|
+
hidden: { display: 'none' },
|
|
95
|
+
contents: { display: 'contents' },
|
|
96
|
+
|
|
97
|
+
// flex / grid alignment
|
|
98
|
+
'flex-col': { flexDirection: 'column' },
|
|
99
|
+
'flex-row': { flexDirection: 'row' },
|
|
100
|
+
'flex-wrap': { flexWrap: 'wrap' },
|
|
101
|
+
'flex-nowrap': { flexWrap: 'nowrap' },
|
|
102
|
+
'items-start': { alignItems: 'flex-start' },
|
|
103
|
+
'items-center': { alignItems: 'center' },
|
|
104
|
+
'items-end': { alignItems: 'flex-end' },
|
|
105
|
+
'items-stretch': { alignItems: 'stretch' },
|
|
106
|
+
'items-baseline': { alignItems: 'baseline' },
|
|
107
|
+
'justify-start': { justifyContent: 'flex-start' },
|
|
108
|
+
'justify-center': { justifyContent: 'center' },
|
|
109
|
+
'justify-end': { justifyContent: 'flex-end' },
|
|
110
|
+
'justify-between': { justifyContent: 'space-between' },
|
|
111
|
+
'justify-around': { justifyContent: 'space-around' },
|
|
112
|
+
'justify-evenly': { justifyContent: 'space-evenly' },
|
|
113
|
+
// `content-stretch` is Figma's most-emitted class after `relative`.
|
|
114
|
+
'content-stretch': { alignContent: 'stretch' },
|
|
115
|
+
'content-start': { alignContent: 'flex-start' },
|
|
116
|
+
'content-center': { alignContent: 'center' },
|
|
117
|
+
'content-between': { alignContent: 'space-between' },
|
|
118
|
+
'place-items-start': { placeItems: 'start' },
|
|
119
|
+
'place-items-center': { placeItems: 'center' },
|
|
120
|
+
'place-items-end': { placeItems: 'end' },
|
|
121
|
+
'self-stretch': { alignSelf: 'stretch' },
|
|
122
|
+
'self-start': { alignSelf: 'flex-start' },
|
|
123
|
+
'self-center': { alignSelf: 'center' },
|
|
124
|
+
'self-end': { alignSelf: 'flex-end' },
|
|
125
|
+
'self-auto': { alignSelf: 'auto' },
|
|
126
|
+
'shrink-0': { flexShrink: '0' },
|
|
127
|
+
shrink: { flexShrink: '1' },
|
|
128
|
+
'grow-0': { flexGrow: '0' },
|
|
129
|
+
grow: { flexGrow: '1' },
|
|
130
|
+
|
|
131
|
+
// position
|
|
132
|
+
relative: { position: 'relative' },
|
|
133
|
+
absolute: { position: 'absolute' },
|
|
134
|
+
fixed: { position: 'fixed' },
|
|
135
|
+
sticky: { position: 'sticky' },
|
|
136
|
+
static: { position: 'static' },
|
|
137
|
+
'inset-0': { inset: '0px' },
|
|
138
|
+
|
|
139
|
+
// sizing
|
|
140
|
+
'size-full': { width: '100%', height: '100%' },
|
|
141
|
+
'w-full': { width: '100%' },
|
|
142
|
+
'h-full': { height: '100%' },
|
|
143
|
+
'w-auto': { width: 'auto' },
|
|
144
|
+
'h-auto': { height: 'auto' },
|
|
145
|
+
'w-0': { width: '0px' },
|
|
146
|
+
'h-0': { height: '0px' },
|
|
147
|
+
'max-w-none': { maxWidth: 'none' },
|
|
148
|
+
'max-h-none': { maxHeight: 'none' },
|
|
149
|
+
'max-w-full': { maxWidth: '100%' },
|
|
150
|
+
'min-w-px': { minWidth: '1px' },
|
|
151
|
+
'min-w-0': { minWidth: '0px' },
|
|
152
|
+
'min-h-0': { minHeight: '0px' },
|
|
153
|
+
|
|
154
|
+
// spacing shorthands Tailwind spells without a value
|
|
155
|
+
'p-px': { padding: '1px' },
|
|
156
|
+
'gap-px': { gap: '1px' },
|
|
157
|
+
'mt-px': { marginTop: '1px' },
|
|
158
|
+
'm-0': { margin: '0px' },
|
|
159
|
+
'mt-0': { marginTop: '0px' },
|
|
160
|
+
'mr-0': { marginRight: '0px' },
|
|
161
|
+
'mb-0': { marginBottom: '0px' },
|
|
162
|
+
'ml-0': { marginLeft: '0px' },
|
|
163
|
+
'p-0': { padding: '0px' },
|
|
164
|
+
|
|
165
|
+
// border
|
|
166
|
+
border: { borderWidth: '1px', borderStyle: 'solid' },
|
|
167
|
+
'border-0': { borderWidth: '0px' },
|
|
168
|
+
'border-2': { borderWidth: '2px', borderStyle: 'solid' },
|
|
169
|
+
'border-t': { borderTopWidth: '1px', borderTopStyle: 'solid' },
|
|
170
|
+
'border-r': { borderRightWidth: '1px', borderRightStyle: 'solid' },
|
|
171
|
+
'border-b': { borderBottomWidth: '1px', borderBottomStyle: 'solid' },
|
|
172
|
+
'border-l': { borderLeftWidth: '1px', borderLeftStyle: 'solid' },
|
|
173
|
+
'border-solid': { borderStyle: 'solid' },
|
|
174
|
+
'border-dashed': { borderStyle: 'dashed' },
|
|
175
|
+
'border-dotted': { borderStyle: 'dotted' },
|
|
176
|
+
'border-none': { borderStyle: 'none' },
|
|
177
|
+
'rounded-full': { borderRadius: '9999px' },
|
|
178
|
+
'rounded-none': { borderRadius: '0px' },
|
|
179
|
+
|
|
180
|
+
// overflow
|
|
181
|
+
'overflow-clip': { overflow: 'clip' },
|
|
182
|
+
'overflow-hidden': { overflow: 'hidden' },
|
|
183
|
+
'overflow-auto': { overflow: 'auto' },
|
|
184
|
+
'overflow-scroll': { overflow: 'scroll' },
|
|
185
|
+
'overflow-visible': { overflow: 'visible' },
|
|
186
|
+
|
|
187
|
+
// typography
|
|
188
|
+
'text-left': { textAlign: 'left' },
|
|
189
|
+
'text-center': { textAlign: 'center' },
|
|
190
|
+
'text-right': { textAlign: 'right' },
|
|
191
|
+
'text-justify': { textAlign: 'justify' },
|
|
192
|
+
italic: { fontStyle: 'italic' },
|
|
193
|
+
'not-italic': { fontStyle: 'normal' },
|
|
194
|
+
'font-thin': { fontWeight: '100' },
|
|
195
|
+
'font-extralight': { fontWeight: '200' },
|
|
196
|
+
'font-light': { fontWeight: '300' },
|
|
197
|
+
'font-normal': { fontWeight: '400' },
|
|
198
|
+
'font-medium': { fontWeight: '500' },
|
|
199
|
+
'font-semibold': { fontWeight: '600' },
|
|
200
|
+
'font-bold': { fontWeight: '700' },
|
|
201
|
+
'font-extrabold': { fontWeight: '800' },
|
|
202
|
+
'font-black': { fontWeight: '900' },
|
|
203
|
+
'whitespace-nowrap': { whiteSpace: 'nowrap' },
|
|
204
|
+
'whitespace-normal': { whiteSpace: 'normal' },
|
|
205
|
+
'whitespace-pre': { whiteSpace: 'pre' },
|
|
206
|
+
'whitespace-pre-wrap': { whiteSpace: 'pre-wrap' },
|
|
207
|
+
underline: { textDecoration: 'underline' },
|
|
208
|
+
'line-through': { textDecoration: 'line-through' },
|
|
209
|
+
'no-underline': { textDecoration: 'none' },
|
|
210
|
+
uppercase: { textTransform: 'uppercase' },
|
|
211
|
+
lowercase: { textTransform: 'lowercase' },
|
|
212
|
+
capitalize: { textTransform: 'capitalize' },
|
|
213
|
+
'normal-case': { textTransform: 'none' },
|
|
214
|
+
'leading-normal': { lineHeight: 'normal' },
|
|
215
|
+
'leading-none': { lineHeight: '1' },
|
|
216
|
+
truncate: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
217
|
+
|
|
218
|
+
// half-position shorthands
|
|
219
|
+
'top-1/2': { top: '50%' },
|
|
220
|
+
'left-1/2': { left: '50%' },
|
|
221
|
+
'right-1/2': { right: '50%' },
|
|
222
|
+
'bottom-1/2': { bottom: '50%' },
|
|
223
|
+
|
|
224
|
+
// named colours Figma reaches for
|
|
225
|
+
'bg-white': { background: '#ffffff' },
|
|
226
|
+
'bg-black': { background: '#000000' },
|
|
227
|
+
'bg-transparent': { background: 'transparent' },
|
|
228
|
+
'text-white': { color: '#ffffff' },
|
|
229
|
+
'text-black': { color: '#000000' },
|
|
230
|
+
'border-white': { borderColor: '#ffffff' },
|
|
231
|
+
'border-black': { borderColor: '#000000' },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** Transform utilities compose into ONE `transform`, so they are collected
|
|
235
|
+
* rather than written straight into the declaration map. */
|
|
236
|
+
const TRANSFORMS: Readonly<Record<string, string>> = Object.freeze({
|
|
237
|
+
'-translate-x-1/2': 'translateX(-50%)',
|
|
238
|
+
'-translate-y-1/2': 'translateY(-50%)',
|
|
239
|
+
'translate-x-1/2': 'translateX(50%)',
|
|
240
|
+
'translate-y-1/2': 'translateY(50%)',
|
|
241
|
+
'-rotate-180': 'rotate(-180deg)',
|
|
242
|
+
'rotate-180': 'rotate(180deg)',
|
|
243
|
+
'-scale-x-100': 'scaleX(-1)',
|
|
244
|
+
'-scale-y-100': 'scaleY(-1)',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/** `p`/`px`/`m`/`gap`/… → the property (or properties) they set. */
|
|
248
|
+
const SPACING_PROPS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
249
|
+
p: ['padding'],
|
|
250
|
+
px: ['paddingLeft', 'paddingRight'],
|
|
251
|
+
py: ['paddingTop', 'paddingBottom'],
|
|
252
|
+
pt: ['paddingTop'],
|
|
253
|
+
pr: ['paddingRight'],
|
|
254
|
+
pb: ['paddingBottom'],
|
|
255
|
+
pl: ['paddingLeft'],
|
|
256
|
+
m: ['margin'],
|
|
257
|
+
mx: ['marginLeft', 'marginRight'],
|
|
258
|
+
my: ['marginTop', 'marginBottom'],
|
|
259
|
+
mt: ['marginTop'],
|
|
260
|
+
mr: ['marginRight'],
|
|
261
|
+
mb: ['marginBottom'],
|
|
262
|
+
ml: ['marginLeft'],
|
|
263
|
+
gap: ['gap'],
|
|
264
|
+
'gap-x': ['columnGap'],
|
|
265
|
+
'gap-y': ['rowGap'],
|
|
266
|
+
top: ['top'],
|
|
267
|
+
right: ['right'],
|
|
268
|
+
bottom: ['bottom'],
|
|
269
|
+
left: ['left'],
|
|
270
|
+
w: ['width'],
|
|
271
|
+
h: ['height'],
|
|
272
|
+
'min-w': ['minWidth'],
|
|
273
|
+
'min-h': ['minHeight'],
|
|
274
|
+
'max-w': ['maxWidth'],
|
|
275
|
+
'max-h': ['maxHeight'],
|
|
276
|
+
'inset-x': ['left', 'right'],
|
|
277
|
+
'inset-y': ['top', 'bottom'],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** Tailwind's spacing scale step, for the numeric (non-arbitrary) forms. */
|
|
281
|
+
const SCALE_REM = 0.25;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Pull `family-name:` / `color:` / `length:` off an arbitrary value. Tailwind
|
|
285
|
+
* uses these hints to disambiguate exactly what we have to disambiguate.
|
|
286
|
+
*/
|
|
287
|
+
function splitHint(value: string): { hint: string | null; value: string } {
|
|
288
|
+
const m = /^([a-z-]{1,16}):([\s\S]*)$/.exec(value);
|
|
289
|
+
// A bare `var(--x)` and a `data:` URL both contain a colon; only a KNOWN hint
|
|
290
|
+
// counts, everything else is part of the value.
|
|
291
|
+
if (m && (m[1] === 'color' || m[1] === 'family-name' || m[1] === 'length')) {
|
|
292
|
+
return { hint: m[1], value: m[2] };
|
|
293
|
+
}
|
|
294
|
+
return { hint: null, value };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A colour that snaps onto the DS palette when it is a bare literal. */
|
|
298
|
+
function colorValue(raw: string, ctx: TailwindContext): string | null {
|
|
299
|
+
if (!isCodegenColor(raw)) return null;
|
|
300
|
+
// Only a LITERAL hex is a candidate for snapping. A `var()` Figma resolved for
|
|
301
|
+
// us is already the design's own variable — replacing it with our nearest
|
|
302
|
+
// token would be a downgrade, not a normalization.
|
|
303
|
+
if (/^#[0-9a-fA-F]{6}$/.test(raw) && ctx.tokens && ctx.tokens.length > 0) {
|
|
304
|
+
const resolved = resolveColor(raw.toLowerCase(), ctx.tokens, ctx.threshold);
|
|
305
|
+
return resolved.value;
|
|
306
|
+
}
|
|
307
|
+
return raw;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Clamp a parsed font size to D6b's readable floor, preserving the unit.
|
|
312
|
+
* A size below the floor is not a design choice, it is a hiding mechanism.
|
|
313
|
+
*/
|
|
314
|
+
function clampFontSize(value: string): string {
|
|
315
|
+
const m = /^(-?[\d.]+)(px|rem|em|%)?$/.exec(value);
|
|
316
|
+
if (!m) return value;
|
|
317
|
+
const n = Number.parseFloat(m[1]);
|
|
318
|
+
const unit = m[2] ?? 'px';
|
|
319
|
+
if (unit !== 'px') return value; // rem/em/% floors are not comparable to a px one
|
|
320
|
+
const { size, changed } = ensureFontSize(n);
|
|
321
|
+
return changed ? `${size}px` : value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Map ONE utility. Returns `null` when the table does not know it or its value
|
|
326
|
+
* fails a grammar — the caller reports that, it is never swallowed here.
|
|
327
|
+
*/
|
|
328
|
+
function mapOne(
|
|
329
|
+
cls: string,
|
|
330
|
+
ctx: TailwindContext,
|
|
331
|
+
out: Record<string, string>,
|
|
332
|
+
transforms: string[],
|
|
333
|
+
fonts: string[]
|
|
334
|
+
): boolean {
|
|
335
|
+
if (Object.hasOwn(STATIC, cls)) {
|
|
336
|
+
Object.assign(out, STATIC[cls]);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
if (Object.hasOwn(TRANSFORMS, cls)) {
|
|
340
|
+
transforms.push(TRANSFORMS[cls]);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Arbitrary PROPERTY: `[word-break:break-word]` ──
|
|
345
|
+
if (cls.startsWith('[') && cls.endsWith(']')) {
|
|
346
|
+
const body = unescapeArbitrary(cls.slice(1, -1));
|
|
347
|
+
const colon = body.indexOf(':');
|
|
348
|
+
if (colon < 0) return false;
|
|
349
|
+
const prop = body.slice(0, colon).trim();
|
|
350
|
+
const value = body.slice(colon + 1).trim();
|
|
351
|
+
if (!isAllowedArbitraryProperty(prop)) return false;
|
|
352
|
+
if (
|
|
353
|
+
!isCodegenKeyword(value) &&
|
|
354
|
+
!isCodegenLength(value) &&
|
|
355
|
+
!isCodegenNumber(value) &&
|
|
356
|
+
!isCodegenShortValueList(value)
|
|
357
|
+
) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
out[cssPropToCamel(prop)] = value;
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const bracket = /^(-?[a-z-]{1,16})-\[([\s\S]{1,240})\]$/.exec(cls);
|
|
365
|
+
const arbitrary = bracket
|
|
366
|
+
? { family: bracket[1], raw: normalizeCalc(unescapeArbitrary(bracket[2])) }
|
|
367
|
+
: null;
|
|
368
|
+
if (arbitrary && arbitrary.raw.length > MAX_VALUE_LEN) return false;
|
|
369
|
+
|
|
370
|
+
const plain = /^(-?[a-z-]{1,16})-([a-z0-9./]{1,24})$/.exec(cls);
|
|
371
|
+
const family = arbitrary?.family ?? plain?.[1] ?? null;
|
|
372
|
+
if (!family) return false;
|
|
373
|
+
|
|
374
|
+
// ── Families whose value is a COLOUR ──
|
|
375
|
+
if (family === 'bg' || family === 'text' || family === 'border' || family === 'fill') {
|
|
376
|
+
const { hint, value } = splitHint(arbitrary?.raw ?? plain?.[2] ?? '');
|
|
377
|
+
// `text-[16px]` is a FONT SIZE and `text-[#0f161e]` is a COLOUR — the same
|
|
378
|
+
// family, disambiguated by the value. Tailwind's own `color:` hint wins when
|
|
379
|
+
// present, which is what Figma emits for `text-[color:var(--black,#0f161e)]`.
|
|
380
|
+
if (family === 'text' && hint !== 'color' && isCodegenLength(value, 512)) {
|
|
381
|
+
// D6b's readable floor. `sanitize.ts` exports `ensureFontSize` and this
|
|
382
|
+
// lane never called it, so `text-[0px]` passed the grammar and shipped —
|
|
383
|
+
// while the same file already closes both neighbours (`leading-[0]` and a
|
|
384
|
+
// hiding opacity). D4 pre-accepts that D6b "degrades to clamp what we can
|
|
385
|
+
// parse"; a font size IS parseable, so this one is closable rather than
|
|
386
|
+
// residual (post-implementation review F4).
|
|
387
|
+
out.fontSize = clampFontSize(value);
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (family === 'border' && hint !== 'color' && isCodegenLength(value, 64)) {
|
|
391
|
+
out.borderWidth = value;
|
|
392
|
+
out.borderStyle ??= 'solid';
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const resolved = colorValue(value, ctx);
|
|
396
|
+
if (resolved === null) return false;
|
|
397
|
+
if (family === 'bg') out.background = resolved;
|
|
398
|
+
else if (family === 'text') out.color = resolved;
|
|
399
|
+
else if (family === 'fill') out.fill = resolved;
|
|
400
|
+
else out.borderColor = resolved;
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── FONT FAMILY (T18) ──
|
|
405
|
+
if (family === 'font' && arbitrary) {
|
|
406
|
+
const { value } = splitHint(arbitrary.raw);
|
|
407
|
+
// Figma wraps the family in quotes inside the bracket and may hand it to us
|
|
408
|
+
// through a `var()` with the literal as the fallback.
|
|
409
|
+
const inner = /^var\(--[a-z0-9-]{1,64},\s*'?([^')]{1,64})'?\)$/.exec(value);
|
|
410
|
+
const literal = (inner ? inner[1] : value).replace(/^['"]|['"]$/g, '');
|
|
411
|
+
const res = resolveFontFamily(literal, ctx.fontTokens ?? []);
|
|
412
|
+
out.fontFamily = res.css;
|
|
413
|
+
// Figma packs the WEIGHT into the family (`SF_Pro:Bold`) and *usually* also
|
|
414
|
+
// emits `font-bold` alongside it. Only fill the gap — an explicit weight
|
|
415
|
+
// utility, before or after, must win, because it is the one Figma computed.
|
|
416
|
+
const weight = styleToWeight(splitFamilyAndStyle(literal).style);
|
|
417
|
+
if (weight !== null && out.fontWeight === undefined) out.fontWeight = String(weight);
|
|
418
|
+
if (res.substituted) fonts.push(res.requested);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Everything measured that takes a length / list / number ──
|
|
423
|
+
const value = arbitrary?.raw ?? null;
|
|
424
|
+
// `-ml-[26px]` — Tailwind spells a negative utility with a LEADING hyphen, so
|
|
425
|
+
// the sign lives on the family, not on the value.
|
|
426
|
+
const negative = family.startsWith('-');
|
|
427
|
+
const bare = negative ? family.slice(1) : family;
|
|
428
|
+
if (Object.hasOwn(SPACING_PROPS, bare)) {
|
|
429
|
+
const props = SPACING_PROPS[bare];
|
|
430
|
+
if (value !== null) {
|
|
431
|
+
if (!isCodegenLength(value)) return false;
|
|
432
|
+
for (const p of props) out[p] = negative ? `-${value}` : value;
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
const n = Number.parseFloat(plain?.[2] ?? '');
|
|
436
|
+
if (!Number.isFinite(n) || Math.abs(n) > 400) return false;
|
|
437
|
+
for (const p of props) out[p] = `${(negative ? -n : n) * SCALE_REM}rem`;
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (family === 'size' && value !== null) {
|
|
442
|
+
if (!isCodegenLength(value)) return false;
|
|
443
|
+
out.width = value;
|
|
444
|
+
out.height = value;
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (family === 'inset' && value !== null) {
|
|
448
|
+
if (!isCodegenLengthList(value)) return false;
|
|
449
|
+
out.inset = value;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (family === 'rounded' && value !== null) {
|
|
453
|
+
// 9999, not 512: `rounded-[999px]` is the pill idiom and it is everywhere in
|
|
454
|
+
// real files. A radius cannot hang a layout the way a width can, so the
|
|
455
|
+
// bound here is about absurdity, not about safety.
|
|
456
|
+
if (!isCodegenLengthList(value, 9999)) return false;
|
|
457
|
+
out.borderRadius = value;
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
if (family === 'leading') {
|
|
461
|
+
const v = value ?? plain?.[2] ?? '';
|
|
462
|
+
if (isCodegenNumber(v, 1000)) {
|
|
463
|
+
// `leading-[0]` is Figma's "the line box is the glyph box" idiom. Emitted
|
|
464
|
+
// verbatim it collapses the line to zero height and the text disappears —
|
|
465
|
+
// measured, and exactly the invisible-content class D6b exists to close.
|
|
466
|
+
out.lineHeight = v === '0' ? 'normal' : v;
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
if (isCodegenLength(v, 512) || isCodegenKeyword(v)) {
|
|
470
|
+
out.lineHeight = v;
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (family === 'tracking' && value !== null) {
|
|
476
|
+
if (!isCodegenLength(value, 64)) return false;
|
|
477
|
+
out.letterSpacing = value;
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (family === 'flex' && value !== null) {
|
|
481
|
+
if (!isCodegenShortValueList(value)) return false;
|
|
482
|
+
out.flex = value;
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if ((family === 'grid-cols' || family === 'grid-rows') && value !== null) {
|
|
486
|
+
if (!isCodegenShortValueList(value)) return false;
|
|
487
|
+
out[family === 'grid-cols' ? 'gridTemplateColumns' : 'gridTemplateRows'] = value;
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
if (family === 'col' || family === 'row') {
|
|
491
|
+
const v = value ?? plain?.[2] ?? '';
|
|
492
|
+
if (!isCodegenNumber(v, 64) && !isCodegenKeyword(v)) return false;
|
|
493
|
+
out[family === 'col' ? 'gridColumn' : 'gridRow'] = v;
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
if (family === 'opacity') {
|
|
497
|
+
const v = value ?? plain?.[2] ?? '';
|
|
498
|
+
if (!isCodegenNumber(v, 100)) return false;
|
|
499
|
+
const n = Number.parseFloat(v);
|
|
500
|
+
// D6b's spirit: an opacity low enough to hide the subtree is a hiding
|
|
501
|
+
// mechanism, not a design. Clamped rather than reproduced.
|
|
502
|
+
out.opacity = String(Math.max(0.15, value !== null ? n : n / 100));
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
if (family === 'z') {
|
|
506
|
+
const v = value ?? plain?.[2] ?? '';
|
|
507
|
+
if (!isCodegenNumber(v, 9999)) return false;
|
|
508
|
+
out.zIndex = v;
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (family === 'aspect' && value !== null) {
|
|
512
|
+
if (!isCodegenShortValueList(value)) return false;
|
|
513
|
+
out.aspectRatio = value;
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Map a whole `className` string.
|
|
521
|
+
*
|
|
522
|
+
* Order matters only for `transform`, which composes; everything else is
|
|
523
|
+
* last-wins, which is also how the cascade would have resolved it.
|
|
524
|
+
*/
|
|
525
|
+
export function mapClassName(classList: string, ctx: TailwindContext = {}): MappedClasses {
|
|
526
|
+
// `Object.create(null)` per this file's own header rule — the one plain
|
|
527
|
+
// literal that slipped it (post-implementation review, warning tier).
|
|
528
|
+
const declarations: Record<string, string> = Object.create(null);
|
|
529
|
+
const unmapped: string[] = [];
|
|
530
|
+
const substitutedFonts: string[] = [];
|
|
531
|
+
const transforms: string[] = [];
|
|
532
|
+
|
|
533
|
+
const classes = classList.trim().split(/\s+/).filter(Boolean);
|
|
534
|
+
for (const cls of classes.slice(0, MAX_CLASSES_PER_ELEMENT)) {
|
|
535
|
+
if (cls.length > MAX_VALUE_LEN + 32) {
|
|
536
|
+
unmapped.push('oversized');
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!mapOne(cls, ctx, declarations, transforms, substitutedFonts)) unmapped.push(cls);
|
|
540
|
+
}
|
|
541
|
+
if (classes.length > MAX_CLASSES_PER_ELEMENT) unmapped.push('class-cap-reached');
|
|
542
|
+
if (transforms.length > 0) declarations.transform = transforms.join(' ');
|
|
543
|
+
|
|
544
|
+
return { declarations, unmapped, substitutedFonts };
|
|
545
|
+
}
|
|
@@ -78,6 +78,12 @@ export interface PendingExport {
|
|
|
78
78
|
placeholder: string;
|
|
79
79
|
/** True when this export stands in for a whole cluster (D8 mitigation 2). */
|
|
80
80
|
collapsed: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Figma's content hash for an IMAGE fill, when this export is a fill rather
|
|
83
|
+
* than a server-side render. The LOCAL door resolves it straight out of the
|
|
84
|
+
* archive (`images/<imageRef>`) with no network at all — DDR-221 D6.
|
|
85
|
+
*/
|
|
86
|
+
imageRef?: string;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
export interface ToArtboardOptions {
|
|
@@ -245,6 +251,35 @@ function emitNode(node: FigmaNode, depth: number, parentIsFlex: boolean, ctx: Em
|
|
|
245
251
|
const name = identifierFromNodeId(node.id);
|
|
246
252
|
const label = attrValue(node.name) || name;
|
|
247
253
|
|
|
254
|
+
// An IMAGE fill is a real picture, not a colour. It used to fall through to
|
|
255
|
+
// the generic leaf path, where `style-map` has nothing to say about it, and
|
|
256
|
+
// the node emitted as an EMPTY positioned div — the photo silently gone while
|
|
257
|
+
// the import reported success. Measured on a real export (2026-08-12).
|
|
258
|
+
const imageFill = (node.fills ?? []).find((f) => f.type === 'IMAGE' && f.visible && f.imageRef);
|
|
259
|
+
if (imageFill?.imageRef && !isVectorCluster(node)) {
|
|
260
|
+
const placeholder = `/assets/pending-${node.id.replace(/[^0-9]+/g, '-')}.png`;
|
|
261
|
+
ctx.pendingExports.push({
|
|
262
|
+
nodeId: node.id,
|
|
263
|
+
format: 'png',
|
|
264
|
+
placeholder,
|
|
265
|
+
collapsed: false,
|
|
266
|
+
imageRef: imageFill.imageRef,
|
|
267
|
+
});
|
|
268
|
+
ctx.report.add(node.id, node.type, 'asset-pending', 'image fill');
|
|
269
|
+
ctx.metrics.totalLeaves += 1;
|
|
270
|
+
const bb = node.absoluteBoundingBox;
|
|
271
|
+
const pos = positionStyle(node, parentIsFlex, ctx);
|
|
272
|
+
if (pos.absolute) ctx.metrics.absoluteLeaves += 1;
|
|
273
|
+
const style = {
|
|
274
|
+
...pos.decls,
|
|
275
|
+
...(bb ? { width: `${Math.round(bb.width)}px`, height: `${Math.round(bb.height)}px` } : {}),
|
|
276
|
+
objectFit: 'cover',
|
|
277
|
+
};
|
|
278
|
+
return [
|
|
279
|
+
`${pad}<img src=${JSON.stringify(placeholder)} alt=${JSON.stringify(label)} data-dc-element=${JSON.stringify(label)} style=${styleObjectLiteral(style, false)} />`,
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
248
283
|
// A whole vector cluster becomes ONE asset reference, never one per leaf.
|
|
249
284
|
if (isVectorCluster(node)) {
|
|
250
285
|
const placeholder = `/assets/pending-${node.id.replace(/[^0-9]+/g, '-')}.svg`;
|
|
@@ -450,9 +485,14 @@ export function toArtboard(
|
|
|
450
485
|
const artboardGround = rawFillHex(frame);
|
|
451
486
|
const bgProp = artboardGround ? `\n background=${JSON.stringify(artboardGround)}` : '';
|
|
452
487
|
|
|
488
|
+
// Name the door that actually produced this. `--fig` (DDR-221) reads a local
|
|
489
|
+
// export with no network at all, which is a materially different provenance
|
|
490
|
+
// claim from a REST fetch — a banner that says `--frames` on a file nobody
|
|
491
|
+
// fetched is simply wrong.
|
|
492
|
+
const verb = doc.origin === 'fig' ? '--fig (offline, local export)' : '--frames';
|
|
453
493
|
const tsx = `// Imported from Figma — THIRD-PARTY CONTENT (DDR-216).
|
|
454
494
|
//
|
|
455
|
-
// Generated by \`maude design import-figma
|
|
495
|
+
// Generated by \`maude design import-figma ${verb}\` with deterministic code:
|
|
456
496
|
// no vision model and no agent read this document (DDR-216 D1). That is the
|
|
457
497
|
// structural difference from \`/design:import --reconstruct\` (DDR-174).
|
|
458
498
|
//
|
|
@@ -218,8 +218,30 @@ export function toRenderCanvas(
|
|
|
218
218
|
const bodies: string[] = [];
|
|
219
219
|
const positions: Array<{ id: string; x: number; y: number }> = [];
|
|
220
220
|
const pendingRenders: RenderUnit[] = [];
|
|
221
|
-
/**
|
|
222
|
-
|
|
221
|
+
/**
|
|
222
|
+
* The tree we are NOT translating, kept so one artboard can be exploded later.
|
|
223
|
+
*
|
|
224
|
+
* `label` + `w`/`h` are here for a reason that is not cosmetic. DDR-219 probe
|
|
225
|
+
* finding 1: `get_design_context` takes NO file key — it reads whatever
|
|
226
|
+
* document Figma has open — and Figma node ids are NOT unique across files.
|
|
227
|
+
* So `--explode` on a canvas whose `source.fileKey` is file A, while file B is
|
|
228
|
+
* the active tab, would return file B's node of the same id and stamp it as
|
|
229
|
+
* file A's. Low-numbered ids (`0:1`, `1:2`, `6:906`) recur in essentially every
|
|
230
|
+
* file, so that is not exotic. Reading the open file's identity over that
|
|
231
|
+
* transport is unsolved (residual 8) — but a NAME-AND-GEOMETRY cross-check
|
|
232
|
+
* against this record is cheap and catches the collision, and widening the
|
|
233
|
+
* record is a one-line change made HERE, while the render route is being
|
|
234
|
+
* touched anyway, rather than a migration later.
|
|
235
|
+
*/
|
|
236
|
+
const sources: Array<{
|
|
237
|
+
id: string;
|
|
238
|
+
nodeId: string;
|
|
239
|
+
type: string;
|
|
240
|
+
label: string;
|
|
241
|
+
w: number;
|
|
242
|
+
h: number;
|
|
243
|
+
route: 'render';
|
|
244
|
+
}> = [];
|
|
223
245
|
|
|
224
246
|
for (const node of renderTargets) {
|
|
225
247
|
const bb = node.absoluteBoundingBox!;
|
|
@@ -256,7 +278,7 @@ export function toRenderCanvas(
|
|
|
256
278
|
);
|
|
257
279
|
positions.push({ id: abId, x: Math.round(bb.x - originX), y: Math.round(bb.y - originY) });
|
|
258
280
|
pendingRenders.push({ node, placeholder });
|
|
259
|
-
sources.push({ id: abId, nodeId: node.id, type: node.type });
|
|
281
|
+
sources.push({ id: abId, nodeId: node.id, type: node.type, label, w, h, route: 'render' });
|
|
260
282
|
report.add(node.id, node.type, 'imported', 'rendered by figma');
|
|
261
283
|
}
|
|
262
284
|
|
|
@@ -83,7 +83,12 @@ export type FigmaNodeType =
|
|
|
83
83
|
| 'WASHI_TAPE'
|
|
84
84
|
| 'UNKNOWN';
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Exported so the `.fig` door can tell a vocabulary gap from a mapped type
|
|
88
|
+
* WITHOUT keeping a second copy of the list (DDR-221 D3 degrade-and-report).
|
|
89
|
+
* `nodeType()` below still owns the mapping; this is read-only.
|
|
90
|
+
*/
|
|
91
|
+
export const KNOWN_NODE_TYPES: ReadonlySet<string> = new Set<string>([
|
|
87
92
|
'DOCUMENT',
|
|
88
93
|
'CANVAS',
|
|
89
94
|
'FRAME',
|