@1agh/maude 0.58.2 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,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
|
+
}
|