@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.
Files changed (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -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
+ }