@typecad/ui 0.1.0-alpha.1

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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1443 -0
  3. package/dist/engine-index.d.ts +7 -0
  4. package/dist/engine-index.js +41 -0
  5. package/dist/index.d.ts +165 -0
  6. package/dist/index.js +42 -0
  7. package/dist/preview/build-program.d.ts +7 -0
  8. package/dist/preview/build-program.js +665 -0
  9. package/dist/preview/host-gfx.d.ts +90 -0
  10. package/dist/preview/host-gfx.js +552 -0
  11. package/dist/preview/host-ui-runtime.d.ts +241 -0
  12. package/dist/preview/host-ui-runtime.js +3236 -0
  13. package/dist/preview/types.d.ts +83 -0
  14. package/dist/preview/types.js +1 -0
  15. package/dist/types.d.ts +108 -0
  16. package/dist/types.js +10 -0
  17. package/dist/ui-engine/block-layout.d.ts +8 -0
  18. package/dist/ui-engine/block-layout.js +95 -0
  19. package/dist/ui-engine/color.d.ts +49 -0
  20. package/dist/ui-engine/color.js +273 -0
  21. package/dist/ui-engine/css-parser.d.ts +155 -0
  22. package/dist/ui-engine/css-parser.js +1058 -0
  23. package/dist/ui-engine/default-keyboards.d.ts +3 -0
  24. package/dist/ui-engine/default-keyboards.js +53 -0
  25. package/dist/ui-engine/easing.d.ts +12 -0
  26. package/dist/ui-engine/easing.js +76 -0
  27. package/dist/ui-engine/font-assets.d.ts +50 -0
  28. package/dist/ui-engine/font-assets.js +431 -0
  29. package/dist/ui-engine/html-parser.d.ts +116 -0
  30. package/dist/ui-engine/html-parser.js +284 -0
  31. package/dist/ui-engine/image-assets.d.ts +21 -0
  32. package/dist/ui-engine/image-assets.js +125 -0
  33. package/dist/ui-engine/inline-parser.d.ts +30 -0
  34. package/dist/ui-engine/inline-parser.js +66 -0
  35. package/dist/ui-engine/keyframes.d.ts +3 -0
  36. package/dist/ui-engine/keyframes.js +39 -0
  37. package/dist/ui-engine/layout-engine.d.ts +35 -0
  38. package/dist/ui-engine/layout-engine.js +203 -0
  39. package/dist/ui-engine/model.d.ts +239 -0
  40. package/dist/ui-engine/model.js +987 -0
  41. package/dist/ui-engine/rich-layout.d.ts +51 -0
  42. package/dist/ui-engine/rich-layout.js +220 -0
  43. package/dist/ui-engine/run-types.d.ts +13 -0
  44. package/dist/ui-engine/run-types.js +1 -0
  45. package/dist/ui-engine/runtime-header/antialiasing.d.ts +1 -0
  46. package/dist/ui-engine/runtime-header/antialiasing.js +141 -0
  47. package/dist/ui-engine/runtime-header/blend-bodies.d.ts +1 -0
  48. package/dist/ui-engine/runtime-header/blend-bodies.js +39 -0
  49. package/dist/ui-engine/runtime-header/canvas-helpers.d.ts +1 -0
  50. package/dist/ui-engine/runtime-header/canvas-helpers.js +155 -0
  51. package/dist/ui-engine/runtime-header/canvas-scrollbar.d.ts +1 -0
  52. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +105 -0
  53. package/dist/ui-engine/runtime-header/color-mono-refresh.d.ts +1 -0
  54. package/dist/ui-engine/runtime-header/color-mono-refresh.js +154 -0
  55. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.d.ts +1 -0
  56. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +169 -0
  57. package/dist/ui-engine/runtime-header/display-shim.d.ts +1 -0
  58. package/dist/ui-engine/runtime-header/display-shim.js +104 -0
  59. package/dist/ui-engine/runtime-header/forward-decls.d.ts +1 -0
  60. package/dist/ui-engine/runtime-header/forward-decls.js +137 -0
  61. package/dist/ui-engine/runtime-header/guard-close.d.ts +1 -0
  62. package/dist/ui-engine/runtime-header/guard-close.js +8 -0
  63. package/dist/ui-engine/runtime-header/image-drawing.d.ts +1 -0
  64. package/dist/ui-engine/runtime-header/image-drawing.js +300 -0
  65. package/dist/ui-engine/runtime-header/init-press-input.d.ts +1 -0
  66. package/dist/ui-engine/runtime-header/init-press-input.js +126 -0
  67. package/dist/ui-engine/runtime-header/keyboard.d.ts +1 -0
  68. package/dist/ui-engine/runtime-header/keyboard.js +342 -0
  69. package/dist/ui-engine/runtime-header/node-decoration.d.ts +1 -0
  70. package/dist/ui-engine/runtime-header/node-decoration.js +212 -0
  71. package/dist/ui-engine/runtime-header/paint-order-coords.d.ts +1 -0
  72. package/dist/ui-engine/runtime-header/paint-order-coords.js +190 -0
  73. package/dist/ui-engine/runtime-header/paint-rects-repair.d.ts +1 -0
  74. package/dist/ui-engine/runtime-header/paint-rects-repair.js +553 -0
  75. package/dist/ui-engine/runtime-header/scroll-physics.d.ts +1 -0
  76. package/dist/ui-engine/runtime-header/scroll-physics.js +161 -0
  77. package/dist/ui-engine/runtime-header/state-bindings-nav.d.ts +1 -0
  78. package/dist/ui-engine/runtime-header/state-bindings-nav.js +176 -0
  79. package/dist/ui-engine/runtime-header/structs.d.ts +1 -0
  80. package/dist/ui-engine/runtime-header/structs.js +220 -0
  81. package/dist/ui-engine/runtime-header/text-rendering.d.ts +1 -0
  82. package/dist/ui-engine/runtime-header/text-rendering.js +715 -0
  83. package/dist/ui-engine/runtime-header/tick/bindings-phase.d.ts +1 -0
  84. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +103 -0
  85. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.d.ts +1 -0
  86. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +1051 -0
  87. package/dist/ui-engine/runtime-header/tick/flush-phase.d.ts +1 -0
  88. package/dist/ui-engine/runtime-header/tick/flush-phase.js +17 -0
  89. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.d.ts +1 -0
  90. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +15 -0
  91. package/dist/ui-engine/runtime-header/tick/transitions-phase.d.ts +1 -0
  92. package/dist/ui-engine/runtime-header/tick/transitions-phase.js +213 -0
  93. package/dist/ui-engine/runtime-header/tick-motion-helpers.d.ts +1 -0
  94. package/dist/ui-engine/runtime-header/tick-motion-helpers.js +26 -0
  95. package/dist/ui-engine/runtime-header/tick.d.ts +1 -0
  96. package/dist/ui-engine/runtime-header/tick.js +17 -0
  97. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.d.ts +1 -0
  98. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +406 -0
  99. package/dist/ui-engine/runtime-header/types-defines.d.ts +1 -0
  100. package/dist/ui-engine/runtime-header/types-defines.js +79 -0
  101. package/dist/ui-engine/runtime-header.d.ts +1 -0
  102. package/dist/ui-engine/runtime-header.js +69 -0
  103. package/dist/ui-engine/scroll-memory-diagnostics.d.ts +16 -0
  104. package/dist/ui-engine/scroll-memory-diagnostics.js +58 -0
  105. package/dist/ui-engine/select-engine.d.ts +4 -0
  106. package/dist/ui-engine/select-engine.js +19 -0
  107. package/dist/ui-engine/style-resolver.d.ts +77 -0
  108. package/dist/ui-engine/style-resolver.js +341 -0
  109. package/dist/ui-engine/text-layout.d.ts +19 -0
  110. package/dist/ui-engine/text-layout.js +92 -0
  111. package/dist/ui-engine/transpile-ui.d.ts +11 -0
  112. package/dist/ui-engine/transpile-ui.js +33 -0
  113. package/dist/ui-engine/ua-stylesheet.d.ts +3 -0
  114. package/dist/ui-engine/ua-stylesheet.js +56 -0
  115. package/dist/ui-engine/ui-file-splitter.d.ts +14 -0
  116. package/dist/ui-engine/ui-file-splitter.js +47 -0
  117. package/dist/ui-engine/ui-lowering.d.ts +33 -0
  118. package/dist/ui-engine/ui-lowering.js +390 -0
  119. package/dist/ui-engine/ui-registry.d.ts +81 -0
  120. package/dist/ui-engine/ui-registry.js +429 -0
  121. package/dist/ui-engine/yoga-layout.d.ts +12 -0
  122. package/dist/ui-engine/yoga-layout.js +314 -0
  123. package/package.json +86 -0
  124. package/src/engine-index.ts +51 -0
  125. package/src/index.ts +232 -0
  126. package/src/preview/build-program.ts +734 -0
  127. package/src/preview/host-gfx.ts +580 -0
  128. package/src/preview/host-ui-runtime.ts +3245 -0
  129. package/src/preview/types.ts +96 -0
  130. package/src/types.ts +127 -0
  131. package/src/ui-engine/block-layout.ts +104 -0
  132. package/src/ui-engine/color.ts +263 -0
  133. package/src/ui-engine/css-parser.ts +1003 -0
  134. package/src/ui-engine/default-keyboards.ts +58 -0
  135. package/src/ui-engine/easing.ts +53 -0
  136. package/src/ui-engine/font-assets.ts +512 -0
  137. package/src/ui-engine/html-parser.ts +397 -0
  138. package/src/ui-engine/image-assets.ts +170 -0
  139. package/src/ui-engine/inline-parser.ts +78 -0
  140. package/src/ui-engine/keyframes.ts +47 -0
  141. package/src/ui-engine/layout-engine.ts +228 -0
  142. package/src/ui-engine/model.ts +1225 -0
  143. package/src/ui-engine/rich-layout.ts +257 -0
  144. package/src/ui-engine/run-types.ts +14 -0
  145. package/src/ui-engine/runtime-header/antialiasing.ts +141 -0
  146. package/src/ui-engine/runtime-header/blend-bodies.ts +39 -0
  147. package/src/ui-engine/runtime-header/canvas-helpers.ts +155 -0
  148. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +105 -0
  149. package/src/ui-engine/runtime-header/color-mono-refresh.ts +154 -0
  150. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +169 -0
  151. package/src/ui-engine/runtime-header/display-shim.ts +104 -0
  152. package/src/ui-engine/runtime-header/forward-decls.ts +137 -0
  153. package/src/ui-engine/runtime-header/guard-close.ts +8 -0
  154. package/src/ui-engine/runtime-header/image-drawing.ts +300 -0
  155. package/src/ui-engine/runtime-header/init-press-input.ts +126 -0
  156. package/src/ui-engine/runtime-header/keyboard.ts +342 -0
  157. package/src/ui-engine/runtime-header/node-decoration.ts +212 -0
  158. package/src/ui-engine/runtime-header/paint-order-coords.ts +190 -0
  159. package/src/ui-engine/runtime-header/paint-rects-repair.ts +553 -0
  160. package/src/ui-engine/runtime-header/scroll-physics.ts +161 -0
  161. package/src/ui-engine/runtime-header/state-bindings-nav.ts +176 -0
  162. package/src/ui-engine/runtime-header/structs.ts +220 -0
  163. package/src/ui-engine/runtime-header/text-rendering.ts +715 -0
  164. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +103 -0
  165. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +1051 -0
  166. package/src/ui-engine/runtime-header/tick/flush-phase.ts +17 -0
  167. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +15 -0
  168. package/src/ui-engine/runtime-header/tick/transitions-phase.ts +213 -0
  169. package/src/ui-engine/runtime-header/tick-motion-helpers.ts +26 -0
  170. package/src/ui-engine/runtime-header/tick.ts +18 -0
  171. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +406 -0
  172. package/src/ui-engine/runtime-header/types-defines.ts +79 -0
  173. package/src/ui-engine/runtime-header.ts +71 -0
  174. package/src/ui-engine/scroll-memory-diagnostics.ts +79 -0
  175. package/src/ui-engine/select-engine.ts +21 -0
  176. package/src/ui-engine/style-resolver.ts +416 -0
  177. package/src/ui-engine/text-layout.ts +114 -0
  178. package/src/ui-engine/transpile-ui.ts +45 -0
  179. package/src/ui-engine/ua-stylesheet.ts +60 -0
  180. package/src/ui-engine/ui-file-splitter.ts +59 -0
  181. package/src/ui-engine/ui-lowering.ts +459 -0
  182. package/src/ui-engine/ui-registry.ts +530 -0
  183. package/src/ui-engine/yoga-layout.ts +309 -0
@@ -0,0 +1,1003 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CSS parser — uses css-tree for robust, standards-compliant parsing.
3
+ //
4
+ // Parses .ui.css into CSSRule[] (the same shape the hand-rolled parser
5
+ // produced), but now supports the full CSS property set that Yoga needs:
6
+ // display, flex-direction, gap, align-self, border, border-radius, etc.
7
+ //
8
+ // Unknown properties are silently dropped (forward-compatible) rather than
9
+ // throwing — the hand-rolled parser's throw-on-unknown blocked flexbox.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { parse, walk, generate } from "css-tree";
13
+ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
14
+ import { getDisplayProfile } from "@typecad/cuttlefish/stores/display-profile-store";
15
+ import { effectiveDisplaySize } from "@typecad/cuttlefish/api/shared";
16
+ import { getThemeClass } from "@typecad/cuttlefish/stores/theme-store";
17
+
18
+ export type CSSSelectorKind = "element" | "id" | "class" | "attribute";
19
+
20
+ /** A single simple selector: tag name, #id, or .class. */
21
+ export interface SimpleSelector {
22
+ kind: CSSSelectorKind;
23
+ name: string;
24
+ value?: string;
25
+ }
26
+
27
+ /** A full CSS selector, supporting compound (`.foo.bar`, `tag.class`) and
28
+ * descendant (`parent child`) combinators.
29
+ * - `compounds[last]` is the target compound (the element the rule applies to).
30
+ * - Each compound is an array of simples that must ALL match (AND).
31
+ * - Preceding compounds are ancestor constraints (descendant combinator). */
32
+ export interface CSSSelector {
33
+ compounds: SimpleSelector[][];
34
+ combinators?: (">" | " " | "+" | "~")[];
35
+ pseudo?: "pressed" | "disabled" | "checked" | "focus";
36
+ /** :not(...) negation compounds. Each is a compound that must NOT match. */
37
+ not?: SimpleSelector[][];
38
+ }
39
+
40
+ export interface TransitionDecl {
41
+ property: "background" | "color";
42
+ durationMs: number;
43
+ }
44
+
45
+ export interface KeyframeStop {
46
+ percent: number; // 0-100
47
+ background?: string;
48
+ color?: string;
49
+ opacity?: string;
50
+ transform?: string;
51
+ transformOrigin?: string;
52
+ left?: string;
53
+ top?: string;
54
+ width?: string;
55
+ height?: string;
56
+ }
57
+
58
+ export interface KeyframeSet {
59
+ name: string;
60
+ stops: KeyframeStop[];
61
+ }
62
+
63
+ export interface AnimationDecl {
64
+ name: string;
65
+ durationMs: number;
66
+ iterations: number; // -1 = infinite
67
+ delayMs: number;
68
+ timingFunction: string; // raw keyword, e.g. "ease-in-out" ("" / "linear" = linear)
69
+ }
70
+
71
+ export interface CSSProperty {
72
+ // Box model
73
+ padding?: string;
74
+ margin?: string;
75
+ /** Per-side margin overrides (win over the `margin` shorthand when set). */
76
+ marginTop?: string;
77
+ marginRight?: string;
78
+ marginBottom?: string;
79
+ marginLeft?: string;
80
+ width?: string;
81
+ height?: string;
82
+ minWidth?: string;
83
+ maxWidth?: string;
84
+ minHeight?: string;
85
+ maxHeight?: string;
86
+ aspectRatio?: string;
87
+ boxSizing?: string;
88
+ overflow?: string;
89
+ // Colors
90
+ color?: string;
91
+ background?: string;
92
+ // Text
93
+ font?: string;
94
+ fontFamily?: string;
95
+ fontSize?: string;
96
+ textAlign?: string; // left | center | right
97
+ textDecoration?: string; // underline | none
98
+ fontWeight?: string; // normal | bold
99
+ fontStyle?: string; // normal | italic | oblique
100
+ fontSmoothing?: string; // antialiased | none
101
+ fontSubset?: string; // exact | fallback/auto
102
+ lineHeight?: string;
103
+ letterSpacing?: string;
104
+ whiteSpace?: string; // nowrap | normal
105
+ textTransform?: string; // uppercase | lowercase | capitalize | none
106
+ textOverflow?: string; // ellipsis | clip
107
+ // Animation
108
+ transition?: TransitionDecl;
109
+ animation?: string; // shorthand: "pulse 2s infinite"
110
+ animationName?: string;
111
+ animationDuration?: string;
112
+ animationIterationCount?: string;
113
+ animationDelay?: string;
114
+ animationTimingFunction?: string; // linear | ease | ease-in | ease-out | ease-in-out
115
+ // Flexbox / layout (Yoga)
116
+ display?: string;
117
+ flexDirection?: string;
118
+ gap?: string;
119
+ rowGap?: string;
120
+ columnGap?: string;
121
+ flexGrow?: string;
122
+ flexShrink?: string;
123
+ flexBasis?: string;
124
+ alignSelf?: string;
125
+ alignItems?: string;
126
+ alignContent?: string;
127
+ justifyContent?: string;
128
+ flexWrap?: string;
129
+ order?: string;
130
+ position?: string; // relative | absolute | static
131
+ zIndex?: string;
132
+ top?: string;
133
+ right?: string;
134
+ bottom?: string;
135
+ left?: string;
136
+ // Visual
137
+ border?: string;
138
+ borderRadius?: string;
139
+ borderWidth?: string;
140
+ borderColor?: string;
141
+ borderStyle?: string; // solid | dashed | dotted | none
142
+ /** Per-side border overrides (win over the shorthand when set). */
143
+ borderTopWidth?: string;
144
+ borderTopStyle?: string;
145
+ borderTopColor?: string;
146
+ borderRightWidth?: string;
147
+ borderRightStyle?: string;
148
+ borderRightColor?: string;
149
+ borderBottomWidth?: string;
150
+ borderBottomStyle?: string;
151
+ borderBottomColor?: string;
152
+ borderLeftWidth?: string;
153
+ borderLeftStyle?: string;
154
+ borderLeftColor?: string;
155
+ opacity?: string;
156
+ visibility?: string; // visible | hidden
157
+ outline?: string;
158
+ boxShadow?: string;
159
+ textShadow?: string;
160
+ transform?: string;
161
+ transformOrigin?: string;
162
+ objectFit?: string; // fill | contain | cover | scale-down | none
163
+ }
164
+
165
+ export interface CSSRule {
166
+ selector: CSSSelector;
167
+ properties: CSSProperty;
168
+ }
169
+
170
+ export interface CSSFontFace {
171
+ fontFamily: string;
172
+ src: string;
173
+ fontWeight?: string;
174
+ fontStyle?: string;
175
+ }
176
+
177
+ /** Evaluate an @media condition (css-tree prelude string) against the resolved
178
+ * display profile at transpile time. Each firmware build targets ONE display,
179
+ * so @media is a compile-time variant selector, not responsive design.
180
+ * Returns true if the rule should apply. Supports:
181
+ * - (min|max)-(width|height):Npx, (width|height):Npx
182
+ * - (e-ink), (update: slow|fast), (monochrome), (monochrome: N),
183
+ * (color-gamut: srgb|p3)
184
+ * Clauses AND-combine (comma = OR not supported). Unsupported → null (warn). */
185
+ function evalMediaCondition(prelude: string): boolean | null {
186
+ const s = prelude.trim();
187
+ // @media all / @media (no condition) -> always apply.
188
+ if (s === "" || s === "all" || /(?<![\w-])all(?![\w-])/i.test(s)) return true;
189
+ const profile = getDisplayProfile();
190
+ const displaySize = effectiveDisplaySize(profile);
191
+ const w = displaySize.width;
192
+ const h = displaySize.height;
193
+ const isEink = profile.displayClass === "eink";
194
+ const isMono = profile.colorFormat === "mono";
195
+ // Mono level count: mono colorFormat = 2 (B&W). (4/7-level descriptor arrives in Phase 4.)
196
+ const monoLevels = isMono ? 2 : 0;
197
+
198
+ let result = true;
199
+ let matched = false;
200
+ // Match each (feature: value) pair. AND-combine (comma = OR not supported).
201
+ // Groups: [1]=min|max, [2]=width|height, [3]=value; or [4]=width|height, [5]=value (bare).
202
+ const featRe = /\((?:\s*(min|max)-(width|height)\s*:\s*(\d+)(?:px)?\s*|\s*(width|height)\s*:\s*(\d+)(?:px)?\s*)\)/gi;
203
+ let m: RegExpExecArray | null;
204
+ while ((m = featRe.exec(s)) !== null) {
205
+ matched = true;
206
+ if (m[1] && m[2] && m[3]) {
207
+ const n = parseInt(m[3], 10);
208
+ const axis = m[2] === "width" ? w : h;
209
+ result = result && (m[1] === "min" ? axis >= n : axis <= n);
210
+ } else if (m[4] && m[5]) {
211
+ // bare (width: N) -> exact match
212
+ const n = parseInt(m[5], 10);
213
+ result = result && ((m[4] === "width" ? w : h) === n);
214
+ }
215
+ }
216
+
217
+ // (e-ink) — boolean feature: true on an eink display class.
218
+ if (/\(\s*e-?ink\s*\)/i.test(s)) {
219
+ matched = true;
220
+ result = result && isEink;
221
+ }
222
+ // (update: slow|fast) — slow = eink, fast = tft.
223
+ const updateM = /\(\s*update\s*:\s*(slow|fast)\s*\)/i.exec(s);
224
+ if (updateM) {
225
+ matched = true;
226
+ const want = updateM[1].toLowerCase();
227
+ result = result && (want === "slow" ? isEink : !isEink);
228
+ }
229
+ // (monochrome) and (monochrome: N) — true when colorFormat is mono with >=N levels.
230
+ const monoM = /\(\s*monochrome(?:\s*:\s*(\d+))?\s*\)/i.exec(s);
231
+ if (monoM) {
232
+ matched = true;
233
+ const want = monoM[1] ? parseInt(monoM[1], 10) : 1;
234
+ result = result && isMono && monoLevels >= want;
235
+ }
236
+ // (color-gamut: srgb|p3) — srgb is the baseline for both TFT and eink; p3 unsupported.
237
+ const gamutM = /\(\s*color-gamut\s*:\s*(srgb|p3)\s*\)/i.exec(s);
238
+ if (gamutM) {
239
+ matched = true;
240
+ result = result && gamutM[1].toLowerCase() === "srgb";
241
+ }
242
+
243
+ if (!matched) return null; // unrecognized condition
244
+ return result;
245
+ }
246
+
247
+ export function parseCss(src: string, diagnostics?: Diagnostic[]): CSSRule[] {
248
+ // Strip CSS comments before parsing (they may contain { or }).
249
+ const withoutComments = stripKeyframes(src.replace(/\/\*[\s\S]*?\*\//g, ""));
250
+ const rules: CSSRule[] = [];
251
+ // CSS custom properties (--name: value), extracted from :root-like rules.
252
+ const variables: Record<string, string> = {};
253
+ // Class-scoped variables (".dark { --x: ... }"), keyed by class name.
254
+ const scopedVars: Record<string, Record<string, string>> = {};
255
+
256
+ let ast;
257
+ try {
258
+ ast = parse(withoutComments, { parseCustomProperty: true });
259
+ } catch {
260
+ // css-tree may fail on edge-case CSS; fall back to empty rules.
261
+ return rules;
262
+ }
263
+
264
+ // Track @media nesting so rules inside @media evaluate their condition
265
+ // against the resolved display profile (compile-time variant selection).
266
+ const mediaStack: string[] = [];
267
+ walk(ast, {
268
+ enter(node: any) {
269
+ if (node.type === "Atrule" && node.name === "media") {
270
+ mediaStack.push(generate(node.prelude));
271
+ return;
272
+ }
273
+ // @import / @supports are unsupported at-rules - warn + skip contents.
274
+ if (node.type === "Atrule" && (node.name === "import" || node.name === "supports")) {
275
+ if (diagnostics) diagnostics.push({
276
+ severity: "warning",
277
+ message: `Unsupported @${node.name} at-rule - ignored.`,
278
+ hint: "Only @font-face, @keyframes, and @media are supported.",
279
+ code: "unsupported-at-rule",
280
+ source: node.name,
281
+ });
282
+ return;
283
+ }
284
+ if (node.type !== "Rule") return;
285
+ // If inside @media, evaluate the condition against the display profile.
286
+ if (mediaStack.length > 0) {
287
+ const cond = mediaStack[mediaStack.length - 1];
288
+ const applies = evalMediaCondition(cond);
289
+ if (applies === null) {
290
+ if (diagnostics) diagnostics.push({
291
+ severity: "warning",
292
+ message: `@media ${cond} has an unsupported condition - rule ignored.`,
293
+ hint: "Supported: (max-width:Npx), (min-width:Npx), (max-height:Npx), (min-height:Npx), (e-ink), (update: slow|fast), (monochrome), (monochrome: N), (color-gamut: srgb).",
294
+ code: "unsupported-media-condition",
295
+ source: "media",
296
+ });
297
+ return;
298
+ }
299
+ if (!applies) return;
300
+ }
301
+
302
+ // Extract selector text via generate (robust across css-tree versions).
303
+ const selectorText = generate(node.prelude).trim();
304
+
305
+ // Capture CSS custom properties from :root declarations.
306
+ if (selectorText === ":root") {
307
+ node.block.children.forEach((child: any) => {
308
+ if (child.type === "Declaration" && child.property.startsWith("--")) {
309
+ variables[child.property] = generate(child.value).trim();
310
+ }
311
+ });
312
+ return; // :root is not a styling rule
313
+ }
314
+
315
+ // Class-scoped variables: `.dark { --x: ... }`. Extract into scopedVars
316
+ // keyed by class name so an active theme class (from config) can override
317
+ // :root at substitution time. A rule that contains ONLY --var declarations
318
+ // is treated as a variable scope, not a styling rule.
319
+ const singleClassM = /^\.([\w-]+)$/.exec(selectorText);
320
+ const decls = Array.from(node.block.children as any[]).filter(c => c.type === "Declaration");
321
+ const varDecls = decls.filter(c => typeof c.property === "string" && c.property.startsWith("--"));
322
+ if (singleClassM && varDecls.length > 0) {
323
+ const cls = singleClassM[1];
324
+ if (!scopedVars[cls]) scopedVars[cls] = {};
325
+ for (const c of varDecls) {
326
+ scopedVars[cls][c.property] = generate(c.value).trim();
327
+ }
328
+ // If ALL declarations are variables, this is a pure scope block — skip
329
+ // it as a styling rule (its --var props would match nothing useful).
330
+ if (varDecls.length === decls.length) return;
331
+ }
332
+
333
+ const selector = parseSelector(selectorText);
334
+ if (!selector) return;
335
+
336
+ // Extract declarations.
337
+ const props: CSSProperty = {};
338
+ node.block.children.forEach((child: any) => {
339
+ if (child.type !== "Declaration") return;
340
+ // Strip vendor prefixes (-webkit-, -moz-, -ms-, -o-) so authors can
341
+ // paste cross-browser CSS without manual cleanup.
342
+ const prop = child.property.replace(/^-(?:webkit|moz|ms|o)-/, "");
343
+ const val = generate(child.value).trim();
344
+ assignProp(props, prop, val, diagnostics);
345
+ });
346
+
347
+ // css-tree emits comma-separated selectors as one string ("a, b").
348
+ // Split on commas to produce one CSSRule per selector.
349
+ const selectorParts = selectorText.split(",").map(s => s.trim()).filter(Boolean);
350
+ for (const part of selectorParts) {
351
+ const sel = parseSelector(part);
352
+ if (sel) rules.push({ selector: sel, properties: props });
353
+ }
354
+ },
355
+ leave(node: any) {
356
+ if (node.type === "Atrule" && node.name === "media") mediaStack.pop();
357
+ },
358
+ });
359
+
360
+ // Build the effective variable map: :root overridden by the active theme
361
+ // class (e.g. "dark") if one is configured and present in scopedVars.
362
+ const themeClass = getThemeClass();
363
+ const effectiveVars: Record<string, string> = { ...variables };
364
+ if (themeClass && scopedVars[themeClass]) {
365
+ Object.assign(effectiveVars, scopedVars[themeClass]);
366
+ }
367
+
368
+ // Substitute var(--name) references in all property values.
369
+ if (Object.keys(effectiveVars).length > 0) {
370
+ for (const rule of rules) {
371
+ substituteVars(rule.properties, effectiveVars);
372
+ }
373
+ }
374
+
375
+ return rules;
376
+ }
377
+
378
+ export function parseFontFaces(src: string): CSSFontFace[] {
379
+ const withoutComments = src.replace(/\/\*[\s\S]*?\*\//g, "");
380
+ const faces: CSSFontFace[] = [];
381
+
382
+ let ast;
383
+ try {
384
+ ast = parse(withoutComments, { parseCustomProperty: true });
385
+ } catch {
386
+ return faces;
387
+ }
388
+
389
+ walk(ast, {
390
+ enter(node: any) {
391
+ if (node.type !== "Atrule" || node.name !== "font-face" || !node.block) return;
392
+ const decls: Record<string, string> = {};
393
+ node.block.children.forEach((child: any) => {
394
+ if (child.type !== "Declaration") return;
395
+ decls[child.property] = generate(child.value).trim();
396
+ });
397
+ const fontFamily = decls["font-family"] ? unquoteCss(decls["font-family"]) : "";
398
+ const src = extractFontSrc(decls.src ?? "");
399
+ if (!fontFamily || !src) return;
400
+ faces.push({
401
+ fontFamily,
402
+ src,
403
+ fontWeight: decls["font-weight"],
404
+ fontStyle: decls["font-style"],
405
+ });
406
+ },
407
+ });
408
+
409
+ return faces;
410
+ }
411
+
412
+ /** Replace var(--name) in all string-valued CSS properties. */
413
+ function substituteVars(props: CSSProperty, variables: Record<string, string>): void {
414
+ for (const key of Object.keys(props) as (keyof CSSProperty)[]) {
415
+ const val = props[key];
416
+ if (typeof val === "string" && (val.includes("var(") || val.includes("calc("))) {
417
+ let resolved = val.replace(/var\(\s*(--[\w-]+)\s*\)/g, (_, name) => variables[name] ?? "");
418
+ // After var substitution, evaluate any calc(...) expressions.
419
+ resolved = resolveCalc(resolved);
420
+ (props[key] as string) = resolved;
421
+ } else if (val && typeof val === "object" && "property" in val) {
422
+ // TransitionDecl — no var() in its fields, skip
423
+ }
424
+ }
425
+ }
426
+
427
+ /** Evaluate calc(...) expressions in a value string. Handles + - * / on
428
+ * lengths (px/rem/em/%) after var() substitution. Returns the input with
429
+ * calc(...) replaced by the computed value (with the dominant unit appended).
430
+ * e.g. "calc(0.625rem - 4px)" → "6px". Unrecognized calc → left as-is. */
431
+ function resolveCalc(value: string): string {
432
+ if (!value.includes("calc(")) return value;
433
+ // Repeat to handle nested calc().
434
+ let out = value;
435
+ for (let i = 0; i < 8; i++) {
436
+ const m = /calc\(([^()]*)\)/.exec(out);
437
+ if (!m) break;
438
+ const computed = evalCalcExpr(m[1].trim());
439
+ out = out.slice(0, m.index) + computed + out.slice(m.index + m[0].length);
440
+ }
441
+ return out;
442
+ }
443
+
444
+ /** Evaluate a simple arithmetic expression of lengths to a length string.
445
+ * Each term may have a unit (px/rem/em/%); the first unit found wins.
446
+ * Supports + - * /. e.g. "10px - 4" → "6px". */
447
+ function evalCalcExpr(expr: string): string {
448
+ // Tokenize into numbers-with-units and operators.
449
+ const tokens = expr.match(/(?:[\d.]+(?:rem|em|px|%)?|[-+*/])/g);
450
+ if (!tokens || tokens.length === 0) return `calc(${expr})`;
451
+ // Determine the dominant unit from the first length token.
452
+ const unitMatch = expr.match(/(\d)(rem|em|px|%)/);
453
+ const unit = unitMatch ? unitMatch[2] : "";
454
+ // Convert each token to a plain number (rem/em × 16).
455
+ const toNum = (tok: string): number => {
456
+ const remM = /^(-?[\d.]+)rem$/.exec(tok);
457
+ if (remM) return parseFloat(remM[1]) * 16;
458
+ const emM = /^(-?[\d.]+)em$/.exec(tok);
459
+ if (emM) return parseFloat(emM[1]) * 16;
460
+ const numM = /^(-?[\d.]+)(?:rem|em|px|%)?$/.exec(tok);
461
+ return numM ? parseFloat(numM[1]) : NaN;
462
+ };
463
+ // Left-to-right evaluation (no operator precedence — matches calc() for the
464
+ // simple two-term cases this targets; * / bind tighter via a tiny pass).
465
+ const nums: number[] = [];
466
+ const ops: string[] = [];
467
+ for (const tok of tokens) {
468
+ if (tok === "+" || tok === "-" || tok === "*" || tok === "/") ops.push(tok);
469
+ else nums.push(toNum(tok));
470
+ }
471
+ if (nums.length === 0 || nums.some(isNaN)) return `calc(${expr})`;
472
+ // First pass: * and /.
473
+ const vals = [nums[0]];
474
+ const lateOps: string[] = [];
475
+ for (let i = 0; i < ops.length; i++) {
476
+ if (ops[i] === "*") vals[vals.length - 1] = vals[vals.length - 1] * nums[i + 1];
477
+ else if (ops[i] === "/") vals[vals.length - 1] = vals[vals.length - 1] / nums[i + 1];
478
+ else { lateOps.push(ops[i]); vals.push(nums[i + 1]); }
479
+ }
480
+ // Second pass: + and -.
481
+ let result = vals[0];
482
+ for (let i = 0; i < lateOps.length; i++) {
483
+ if (lateOps[i] === "+") result += vals[i + 1];
484
+ else result -= vals[i + 1];
485
+ }
486
+ const rounded = Math.round(result * 1000) / 1000;
487
+ return `${rounded}${unit}`;
488
+ }
489
+
490
+ function unquoteCss(value: string): string {
491
+ const trimmed = value.trim();
492
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
493
+ return trimmed.slice(1, -1);
494
+ }
495
+ return trimmed;
496
+ }
497
+
498
+ function extractFontSrc(value: string): string {
499
+ const url = /url\(\s*(['"]?)(.*?)\1\s*\)/.exec(value);
500
+ if (url?.[2]) return url[2].trim();
501
+ return unquoteCss(value.split(",")[0] ?? "");
502
+ }
503
+
504
+ /** Parse an inline style string ("color: red; font-size: 16px") into CSSProperty.
505
+ * Uses the same assignProp pipeline as rule parsing. */
506
+ export function parseInlineStyle(src: string, diagnostics?: Diagnostic[]): CSSProperty {
507
+ const props: CSSProperty = {};
508
+ for (const decl of src.split(";")) {
509
+ const colonIdx = decl.indexOf(":");
510
+ if (colonIdx < 0) continue;
511
+ const prop = decl.slice(0, colonIdx).trim();
512
+ const val = decl.slice(colonIdx + 1).trim();
513
+ if (prop && val) assignProp(props, prop, val, diagnostics);
514
+ }
515
+ return props;
516
+ }
517
+
518
+ /** Parse a selector string into compounds + simples.
519
+ * Supports: `.foo`, `#bar`, `tag`, `.foo.bar` (compound), `tag.cls` is not valid
520
+ * CSS (no dot in tag names), `parent child` (descendant), and `:pressed`.
521
+ * Returns null for empty/invalid selectors. */
522
+ /** Re-space the child combinator so whitespace tokenization can isolate it.
523
+ * css-tree serializes "a > b" as "a>b"; we add spaces around '>' while
524
+ * leaving any '>' inside [...] attribute brackets untouched. */
525
+ function normalizeCombinators(s: string): string {
526
+ let out = "";
527
+ let depth = 0;
528
+ for (let i = 0; i < s.length; i++) {
529
+ const ch = s[i];
530
+ if (ch === "[") depth++;
531
+ else if (ch === "]") depth = Math.max(0, depth - 1);
532
+ // Re-space combinators so whitespace tokenization can isolate them.
533
+ // Only outside attribute brackets so values like [data-x=">"] survive.
534
+ if (depth === 0 && (ch === ">" || ch === "+" || ch === "~")) {
535
+ out += " " + ch + " ";
536
+ } else {
537
+ out += ch;
538
+ }
539
+ }
540
+ return out;
541
+ }
542
+
543
+ /** Parse the inner part of :not(...) into a SimpleSelector compound. */
544
+ function parseInnerCompound(inner: string): SimpleSelector[] {
545
+ const simples: SimpleSelector[] = [];
546
+ const attrRe = /\[([\w-]+)(?:([~|^$*]?=)[\"']?([^'\"\]]*)[\"']?)?\]/g;
547
+ let am: RegExpExecArray | null;
548
+ while ((am = attrRe.exec(inner)) !== null) {
549
+ simples.push({ kind: "attribute", name: am[1], value: am[3] });
550
+ }
551
+ const remaining = inner.replace(/\[[^\]]*\]/g, "");
552
+ if (remaining) {
553
+ const tokenRe = /([.#]?)([a-zA-Z_][\w-]*)/g;
554
+ let m: RegExpExecArray | null;
555
+ while ((m = tokenRe.exec(remaining)) !== null) {
556
+ if (m[1] === "#") simples.push({ kind: "id", name: m[2] });
557
+ else if (m[1] === ".") simples.push({ kind: "class", name: m[2] });
558
+ else simples.push({ kind: "element", name: m[2] });
559
+ }
560
+ }
561
+ return simples;
562
+ }
563
+
564
+ function parseSelector(s: string): CSSSelector | null {
565
+ // Extract :not(...) negation groups BEFORE the trailing-pseudo regex.
566
+ const notGroups: SimpleSelector[][] = [];
567
+ const notRe = /:not\(([^)]*)\)/g;
568
+ let notM: RegExpExecArray | null;
569
+ let sNoNot = s;
570
+ while ((notM = notRe.exec(s)) !== null) {
571
+ const inner = parseInnerCompound(notM[1]);
572
+ if (inner.length > 0) notGroups.push(inner);
573
+ }
574
+ if (notGroups.length > 0) sNoNot = s.replace(/:not\([^)]*\)/g, "");
575
+ const pseudoM = /:(pressed|active|disabled|checked|focus)$/.exec(sNoNot);
576
+ let pseudo: CSSSelector["pseudo"];
577
+ if (pseudoM) {
578
+ const p = pseudoM[1];
579
+ pseudo = (p === "active") ? "pressed" : p as any;
580
+ }
581
+ const base = pseudoM ? sNoNot.slice(0, pseudoM.index) : sNoNot;
582
+ const trimmed = base.trim().replace(/^[\"']/, "").replace(/[\"']$/, "");
583
+ if (!trimmed) return null;
584
+
585
+ // css-tree's generate() emits the child combinator without surrounding
586
+ // spaces ("view>text"), so a plain whitespace split would merge the two
587
+ // compounds into one. Normalize ">" into " > ", but only OUTSIDE attribute
588
+ // brackets so values like [data-x=">"] are preserved.
589
+ const tokens = normalizeCombinators(trimmed).split(/\s+/).filter(Boolean);
590
+ const compounds: SimpleSelector[][] = [];
591
+ const combinators: (">" | " " | "+" | "~")[] = [];
592
+ let expectCombinator = false;
593
+
594
+ for (const tok of tokens) {
595
+ if (tok === ">" || tok === "+" || tok === "~") {
596
+ combinators.push(tok);
597
+ expectCombinator = false;
598
+ continue;
599
+ }
600
+ if (expectCombinator) combinators.push(" ");
601
+
602
+ const simples: SimpleSelector[] = [];
603
+ // Attribute selectors: [disabled], [type="number"]
604
+ const attrRe = /\[([\w-]+)(?:([~|^$*]?=)["']?([^'"\]]*)["']?)?\]/g;
605
+ let am: RegExpExecArray | null;
606
+ while ((am = attrRe.exec(tok)) !== null) {
607
+ simples.push({ kind: "attribute", name: am[1], value: am[3] });
608
+ }
609
+ // Remaining simples from non-attribute parts
610
+ const remaining = tok.replace(/\[[^\]]*\]/g, "");
611
+ if (remaining) {
612
+ const tokenRe = /([.#]?)([a-zA-Z_][\w-]*)/g;
613
+ let m: RegExpExecArray | null;
614
+ while ((m = tokenRe.exec(remaining)) !== null) {
615
+ if (m[1] === "#") simples.push({ kind: "id", name: m[2] });
616
+ else if (m[1] === ".") simples.push({ kind: "class", name: m[2] });
617
+ else simples.push({ kind: "element", name: m[2] });
618
+ }
619
+ }
620
+ if (simples.length > 0) compounds.push(simples);
621
+ expectCombinator = true;
622
+ }
623
+
624
+ if (compounds.length === 0) return null;
625
+ const result: CSSSelector = { compounds };
626
+ if (combinators.length > 0) result.combinators = combinators;
627
+ if (pseudo) result.pseudo = pseudo;
628
+ if (notGroups.length > 0) result.not = notGroups;
629
+ return result;
630
+ }
631
+
632
+ /** Parse a numeric value from a CSS string to device pixels.
633
+ * Supports px (as-is), bare numbers (as-is), rem/em (× root font size = 16),
634
+ * and % (number used as-is, meaningful only in flex/position contexts). */
635
+ function num(val: string): number {
636
+ const v = val.trim();
637
+ const remM = /^(-?[\d.]+)rem$/.exec(v);
638
+ if (remM) return Math.round(parseFloat(remM[1]) * 16);
639
+ const emM = /^(-?[\d.]+)em$/.exec(v);
640
+ if (emM) return Math.round(parseFloat(emM[1]) * 16);
641
+ const digits = v.replace(/px$|%$|rem$|em$/g, "").trim();
642
+ const n = Number(digits);
643
+ return isNaN(n) ? 0 : n;
644
+ }
645
+
646
+ /** Parse a transition value: "background 80ms" → { property, durationMs }. */
647
+ function parseTransition(val: string): TransitionDecl {
648
+ const parts = val.split(/\s+/);
649
+ if (parts.length < 2) return { property: "background", durationMs: 0 };
650
+ const property = parts[0];
651
+ if (property !== "background" && property !== "color") {
652
+ return { property: "background", durationMs: 0 };
653
+ }
654
+ const durStr = parts[1].replace(/ms$|s$/g, "").trim();
655
+ let durationMs = Number(durStr);
656
+ if (parts[1].endsWith("s") && !parts[1].endsWith("ms")) durationMs *= 1000;
657
+ if (isNaN(durationMs)) durationMs = 0;
658
+ return { property: property as "background" | "color", durationMs };
659
+ }
660
+
661
+ /** Parse the animation shorthand: "pulse 2s infinite 500ms".
662
+ * Fields: name (identifier), duration (Nms/Ns), iterations (number|infinite), delay (Nms/Ns). */
663
+ export function parseAnimation(val: string): AnimationDecl | null {
664
+ const first = val.split(",")[0]?.trim() ?? "";
665
+ const parts = first.split(/\s+/).filter(Boolean);
666
+ if (parts.length === 0) return null;
667
+ const decl: AnimationDecl = { name: "", durationMs: 1000, iterations: 1, delayMs: 0, timingFunction: "" };
668
+ let foundDuration = false;
669
+ for (const part of parts) {
670
+ const lower = part.toLowerCase();
671
+ if (lower === "none") {
672
+ return null;
673
+ } else if (lower === "infinite") {
674
+ decl.iterations = -1;
675
+ } else if (/^\d+(?:\.\d+)?ms$/.test(lower)) {
676
+ const ms = Math.round(parseFloat(lower));
677
+ if (!foundDuration) { decl.durationMs = ms; foundDuration = true; }
678
+ else decl.delayMs = ms;
679
+ } else if (/^\d+(?:\.\d+)?s$/.test(lower)) {
680
+ const ms = Math.round(parseFloat(lower) * 1000);
681
+ if (!foundDuration) { decl.durationMs = ms; foundDuration = true; }
682
+ else decl.delayMs = ms;
683
+ } else if (/^\d+$/.test(lower)) {
684
+ decl.iterations = parseInt(lower, 10);
685
+ } else if (
686
+ lower === "linear" ||
687
+ lower === "ease" ||
688
+ lower === "ease-in" ||
689
+ lower === "ease-out" ||
690
+ lower === "ease-in-out"
691
+ ) {
692
+ decl.timingFunction = lower; // capture (applied to the lerp factor between stops)
693
+ } else if (
694
+ lower === "normal" ||
695
+ lower === "reverse" ||
696
+ lower === "alternate" ||
697
+ lower === "alternate-reverse" ||
698
+ lower === "forwards" ||
699
+ lower === "backwards" ||
700
+ lower === "both" ||
701
+ lower === "running" ||
702
+ lower === "paused"
703
+ ) {
704
+ continue;
705
+ } else {
706
+ // Identifier: the first non-keyword token is the animation name.
707
+ decl.name = part;
708
+ }
709
+ }
710
+ if (!decl.name) return null;
711
+ return decl;
712
+ }
713
+
714
+ /** Parse @keyframes blocks from CSS source.
715
+ * Returns KeyframeSet[] — one per @keyframes name. */
716
+ export function parseKeyframes(src: string): KeyframeSet[] {
717
+ const withoutComments = src.replace(/\/\*[\s\S]*?\*\//g, "");
718
+ const sets: KeyframeSet[] = [];
719
+
720
+ let ast;
721
+ try {
722
+ ast = parse(withoutComments, { parseCustomProperty: true });
723
+ } catch {
724
+ return sets;
725
+ }
726
+
727
+ const variables = extractVariables(withoutComments);
728
+
729
+ walk(ast, {
730
+ enter(node: any) {
731
+ if (node.type !== "Atrule" || node.name !== "keyframes" || !node.block) return;
732
+ const name = node.prelude ? generate(node.prelude).trim() : "";
733
+ if (!name) return;
734
+
735
+ const merged = new Map<number, KeyframeStop>();
736
+ node.block.children.forEach((rule: any) => {
737
+ if (rule.type !== "Rule" || !rule.block) return;
738
+ const percents = keyframePercents(rule.prelude ? generate(rule.prelude) : "");
739
+ if (percents.length === 0) return;
740
+
741
+ const props: Omit<KeyframeStop, "percent"> = {};
742
+ rule.block.children.forEach((decl: any) => {
743
+ if (decl.type !== "Declaration") return;
744
+ const prop = decl.property.replace(/^-(?:webkit|moz|ms|o)-/, "");
745
+ const val = substituteVarsInValue(generate(decl.value).trim(), variables);
746
+ if (prop === "background" || prop === "background-color") props.background = val;
747
+ else if (prop === "color") props.color = val;
748
+ else if (prop === "opacity") props.opacity = val;
749
+ else if (prop === "transform") props.transform = val;
750
+ else if (prop === "transform-origin") props.transformOrigin = val;
751
+ else if (prop === "left") props.left = val;
752
+ else if (prop === "top") props.top = val;
753
+ else if (prop === "width") props.width = val;
754
+ else if (prop === "height") props.height = val;
755
+ });
756
+
757
+ for (const percent of percents) {
758
+ const existing = merged.get(percent) ?? { percent };
759
+ merged.set(percent, { ...existing, ...props, percent });
760
+ }
761
+ });
762
+
763
+ const stops = [...merged.values()].sort((a, b) => a.percent - b.percent);
764
+ if (stops.length > 0) sets.push({ name, stops });
765
+ },
766
+ });
767
+
768
+ return sets;
769
+ }
770
+
771
+ function keyframePercents(selectorText: string): number[] {
772
+ return selectorText
773
+ .split(",")
774
+ .map((part) => {
775
+ const trimmed = part.trim().toLowerCase();
776
+ if (trimmed === "from") return 0;
777
+ if (trimmed === "to") return 100;
778
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(trimmed);
779
+ if (!match) return undefined;
780
+ const value = Math.round(Number(match[1]));
781
+ return value >= 0 && value <= 100 ? value : undefined;
782
+ })
783
+ .filter((value): value is number => value !== undefined);
784
+ }
785
+
786
+ function extractVariables(src: string): Record<string, string> {
787
+ const variables: Record<string, string> = {};
788
+ let ast;
789
+ try {
790
+ ast = parse(src, { parseCustomProperty: true });
791
+ } catch {
792
+ return variables;
793
+ }
794
+
795
+ walk(ast, {
796
+ enter(node: any) {
797
+ if (node.type !== "Rule") return;
798
+ const selectorText = generate(node.prelude).trim();
799
+ if (selectorText !== ":root") return;
800
+ node.block.children.forEach((child: any) => {
801
+ if (child.type === "Declaration" && child.property.startsWith("--")) {
802
+ variables[child.property] = generate(child.value).trim();
803
+ }
804
+ });
805
+ },
806
+ });
807
+
808
+ return variables;
809
+ }
810
+
811
+ function substituteVarsInValue(value: string, variables: Record<string, string>): string {
812
+ return value.includes("var(")
813
+ ? value.replace(/var\(\s*(--[\w-]+)\s*\)/g, (_, name) => variables[name] ?? "")
814
+ : value;
815
+ }
816
+
817
+ function stripKeyframes(src: string): string {
818
+ let out = "";
819
+ let i = 0;
820
+ while (i < src.length) {
821
+ const match = /@keyframes\s+[\w-]+\s*\{/iy;
822
+ match.lastIndex = i;
823
+ const found = match.exec(src);
824
+ if (!found) {
825
+ out += src[i++];
826
+ continue;
827
+ }
828
+
829
+ out += src.slice(i, found.index);
830
+ let depth = 1;
831
+ let j = match.lastIndex;
832
+ while (j < src.length && depth > 0) {
833
+ if (src[j] === "{") depth++;
834
+ else if (src[j] === "}") depth--;
835
+ j++;
836
+ }
837
+ i = j;
838
+ }
839
+ return out;
840
+ }
841
+ function parseFontShorthand(props: CSSProperty, val: string): void {
842
+ const parts = val.trim().split(/\s+/);
843
+ const sizeIndex = parts.findIndex((part) => /^\d+(?:\.\d+)?(?:px|pt|em|rem)?(?:\/.+)?$/.test(part));
844
+ if (sizeIndex < 0) return;
845
+ const beforeSize = parts.slice(0, sizeIndex);
846
+ const size = parts[sizeIndex].split("/")[0];
847
+ const family = parts.slice(sizeIndex + 1).join(" ").trim();
848
+ if (size) props.fontSize = size;
849
+ if (family) props.fontFamily = family;
850
+ for (const part of beforeSize) {
851
+ const lower = part.toLowerCase();
852
+ if (lower === "italic" || lower === "oblique" || lower === "normal") props.fontStyle = lower;
853
+ else if (lower === "bold" || lower === "bolder" || lower === "lighter" || /^\d{3}$/.test(lower)) props.fontWeight = lower;
854
+ }
855
+ }
856
+
857
+ /** Assign a CSS property to the CSSProperty object. Unknown properties emit a warning
858
+ * (forward-compatible: they are dropped from output, but the author is notified). */
859
+ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?: Diagnostic[]): void {
860
+ const warn = (message: string, hint?: string): void => {
861
+ if (diagnostics) diagnostics.push({ severity: "warning", message, hint, code: "unknown-css-property", source: prop });
862
+ };
863
+ switch (prop) {
864
+ // Box model
865
+ case "padding": props.padding = val; break;
866
+ case "margin": props.margin = val; break;
867
+ case "margin-top": props.marginTop = val; break;
868
+ case "margin-right": props.marginRight = val; break;
869
+ case "margin-bottom": props.marginBottom = val; break;
870
+ case "margin-left": props.marginLeft = val; break;
871
+ case "width": props.width = val; break;
872
+ case "height": props.height = val; break;
873
+ case "min-width": props.minWidth = val; break;
874
+ case "max-width": props.maxWidth = val; break;
875
+ case "min-height": props.minHeight = val; break;
876
+ case "max-height": props.maxHeight = val; break;
877
+ case "aspect-ratio": props.aspectRatio = val; break;
878
+ case "box-sizing": props.boxSizing = val; break;
879
+ case "overflow": props.overflow = val; break;
880
+ // Colors
881
+ case "color": props.color = val; break;
882
+ case "background":
883
+ case "background-color": props.background = val; break;
884
+ // Text
885
+ case "font": props.font = val; parseFontShorthand(props, val); break;
886
+ case "font-family": props.fontFamily = val; break;
887
+ case "font-size": props.fontSize = val; break;
888
+ case "text-align": props.textAlign = val; break;
889
+ case "text-decoration": props.textDecoration = val; break;
890
+ case "font-weight": props.fontWeight = val; break;
891
+ case "font-style": props.fontStyle = val; break;
892
+ case "font-smoothing":
893
+ case "font-smooth":
894
+ case "-webkit-font-smoothing": props.fontSmoothing = val; break;
895
+ case "font-subset": props.fontSubset = val; break;
896
+ case "line-height": props.lineHeight = val; break;
897
+ case "letter-spacing": props.letterSpacing = val; break;
898
+ case "white-space": props.whiteSpace = val; break;
899
+ case "text-transform": props.textTransform = val; break;
900
+ case "text-overflow": props.textOverflow = val; break;
901
+ // Animation
902
+ case "transition": props.transition = parseTransition(val); break;
903
+ case "animation": props.animation = val; break;
904
+ case "animation-name": props.animationName = val; break;
905
+ case "animation-duration": props.animationDuration = val; break;
906
+ case "animation-iteration-count": props.animationIterationCount = val; break;
907
+ case "animation-delay": props.animationDelay = val; break;
908
+ case "animation-timing-function": props.animationTimingFunction = val; break;
909
+ // Flexbox / layout
910
+ case "display": props.display = val; break;
911
+ case "flex-direction": props.flexDirection = val; break;
912
+ case "gap": props.gap = val; props.rowGap = val; props.columnGap = val; break;
913
+ case "row-gap": props.rowGap = val; if (!props.columnGap) props.columnGap = val; break;
914
+ case "column-gap": props.columnGap = val; if (!props.rowGap) props.rowGap = val; break;
915
+ case "flex": parseFlexShorthand(props, val); break;
916
+ case "flex-grow": props.flexGrow = val; break;
917
+ case "flex-shrink": props.flexShrink = val; break;
918
+ case "flex-basis": props.flexBasis = val; break;
919
+ case "align-self": props.alignSelf = val; break;
920
+ case "align-items": props.alignItems = val; break;
921
+ case "align-content": props.alignContent = val; break;
922
+ case "justify-content": props.justifyContent = val; break;
923
+ case "flex-wrap": props.flexWrap = val; break;
924
+ case "order": props.order = val; break;
925
+ case "position": props.position = val; break;
926
+ case "z-index": props.zIndex = val; break;
927
+ case "top": props.top = val; break;
928
+ case "right": props.right = val; break;
929
+ case "bottom": props.bottom = val; break;
930
+ case "left": props.left = val; break;
931
+ // Visual
932
+ case "border": props.border = val; parseBorderShorthand(props, val); break;
933
+ case "border-radius": props.borderRadius = val; break;
934
+ case "border-width": props.borderWidth = val; break;
935
+ case "border-color": props.borderColor = val; break;
936
+ case "border-style": props.borderStyle = val; break;
937
+ case "border-left": case "border-top": case "border-right": case "border-bottom":
938
+ parsePerSideBorder(props, prop, val);
939
+ break;
940
+ case "opacity": props.opacity = val; break;
941
+ case "visibility": props.visibility = val; break;
942
+ case "outline": props.outline = val; break;
943
+ case "box-shadow": props.boxShadow = val; break;
944
+ case "text-shadow": props.textShadow = val; break;
945
+ case "transform": props.transform = val; break;
946
+ case "transform-origin": props.transformOrigin = val; break;
947
+ case "object-fit": props.objectFit = val; break;
948
+ // Unknown properties are dropped (forward-compatible) but reported as a warning
949
+ // so authors notice typos and unsupported features.
950
+ default:
951
+ warn(`Unknown CSS property "${prop}" — ignored.`);
952
+ break;
953
+ }
954
+ }
955
+
956
+ /** Parse the `flex` shorthand: "1" → grow=1,shrink=1,basis=0%.
957
+ * "1 0 auto" → grow=1, shrink=0, basis=auto. */
958
+ function parseFlexShorthand(props: CSSProperty, val: string): void {
959
+ const parts = val.trim().split(/\s+/);
960
+ if (parts.length === 1) {
961
+ // Single value: either a number (grow) or a dimension (basis) or "none"
962
+ if (parts[0] === "none") {
963
+ props.flexGrow = "0"; props.flexShrink = "0"; props.flexBasis = "auto";
964
+ } else if (/^\d+(\.\d+)?$/.test(parts[0])) {
965
+ props.flexGrow = parts[0]; props.flexShrink = "1"; props.flexBasis = "0%";
966
+ } else {
967
+ props.flexBasis = parts[0];
968
+ }
969
+ } else if (parts.length >= 2) {
970
+ props.flexGrow = parts[0];
971
+ props.flexShrink = parts[1];
972
+ if (parts[2]) props.flexBasis = parts[2];
973
+ }
974
+ }
975
+
976
+ /** Parse the `border` shorthand: "2px solid #808080" → width, style, color. */
977
+ function parseBorderShorthand(props: CSSProperty, val: string): void {
978
+ const parts = val.trim().split(/\s+/);
979
+ for (const p of parts) {
980
+ if (/^\d+px$/.test(p)) props.borderWidth = p;
981
+ else if (["solid", "dashed", "dotted", "double", "none"].includes(p)) props.borderStyle = p;
982
+ // Color: #hex, rgb()/hsl(), a CSS named color, or a var() reference (the
983
+ // token is substituted later by substituteVars, like other properties).
984
+ else if (p.startsWith("#") || p.startsWith("rgb") || p.startsWith("hsl") || p.startsWith("var(") || /^[a-z]+$/i.test(p)) props.borderColor = p;
985
+ }
986
+ }
987
+
988
+ /** Parse a per-side border shorthand (border-left/top/right/bottom) into the
989
+ * per-side width/style/color fields. Same `<width> <style> <color>` format as
990
+ * the `border` shorthand. */
991
+ function parsePerSideBorder(props: CSSProperty, prop: string, val: string): void {
992
+ const side = prop.slice("border-".length); // "left" | "top" | "right" | "bottom"
993
+ const cap = side.charAt(0).toUpperCase() + side.slice(1); // "Left" | "Top" | ...
994
+ const wKey = `border${cap}Width` as keyof CSSProperty;
995
+ const sKey = `border${cap}Style` as keyof CSSProperty;
996
+ const cKey = `border${cap}Color` as keyof CSSProperty;
997
+ const parts = val.trim().split(/\s+/);
998
+ for (const p of parts) {
999
+ if (/^\d+px$/.test(p)) (props[wKey] as string | undefined) = p;
1000
+ else if (["solid", "dashed", "dotted", "double", "none"].includes(p)) (props[sKey] as string | undefined) = p;
1001
+ else if (p.startsWith("#") || p.startsWith("rgb") || p.startsWith("hsl") || p.startsWith("var(") || /^[a-z]+$/i.test(p)) (props[cKey] as string | undefined) = p;
1002
+ }
1003
+ }