@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,987 @@
1
+ import { effectiveDisplaySize } from "@typecad/cuttlefish/api/shared";
2
+ import { deriveCapabilities } from "@typecad/cuttlefish/api/shared";
3
+ import { resolveColorInternal } from "./color.js";
4
+ import { parseAnimation } from "./css-parser.js";
5
+ import { selectFontAssetForStyle, assetTextWidth } from "./font-assets.js";
6
+ import { isDisplayNone } from "./layout-engine.js";
7
+ import { whiteSpaceMode } from "./text-layout.js";
8
+ import { layoutRuns } from "./rich-layout.js";
9
+ import { timingFunctionCode } from "./easing.js";
10
+ export { easeCurveLerpK, TIMING_EASE, TIMING_EASE_IN, TIMING_EASE_IN_OUT, TIMING_EASE_OUT, TIMING_LINEAR, timingFunctionCode, } from "./easing.js";
11
+ export const KEYFRAME_PROP_BG = 1;
12
+ export const KEYFRAME_PROP_FG = 2;
13
+ export const KEYFRAME_PROP_OPACITY = 4;
14
+ export const KEYFRAME_PROP_TRANSFORM = 8;
15
+ export const KEYFRAME_PROP_SIZE = 16;
16
+ function nodeKind(tag) {
17
+ if (tag === "screen" || tag === "view")
18
+ return "fill";
19
+ if (tag === "button")
20
+ return "button";
21
+ if (tag === "check")
22
+ return "check";
23
+ if (tag === "radio")
24
+ return "radio";
25
+ if (tag === "progress")
26
+ return "progress";
27
+ if (tag === "range")
28
+ return "range";
29
+ if (tag === "input")
30
+ return "input";
31
+ if (tag === "img")
32
+ return "img";
33
+ if (tag === "list")
34
+ return "list";
35
+ if (tag === "canvas")
36
+ return "canvas";
37
+ return "text";
38
+ }
39
+ function intAttr(value, fallback) {
40
+ if (value == null || value.trim() === "")
41
+ return fallback;
42
+ const parsed = parseInt(value, 10);
43
+ return Number.isFinite(parsed) ? parsed : fallback;
44
+ }
45
+ function clampNumber(value, min, max) {
46
+ return Math.max(min, Math.min(max, value));
47
+ }
48
+ function initialValueOf(node, kind, rangeMin, rangeMax) {
49
+ if (kind === "radio")
50
+ return node.checked ? 1 : 0;
51
+ if (kind === "range") {
52
+ const parsed = intAttr(node.value, rangeMin);
53
+ const value = rangeMax > rangeMin ? clampNumber(parsed, rangeMin, rangeMax) : parsed;
54
+ return clampInt16(value);
55
+ }
56
+ if (kind === "progress")
57
+ return clampNumber(intAttr(node.value, 0), 0, 100);
58
+ return 0;
59
+ }
60
+ function textAlign(style) {
61
+ if (style.textAlign === "center")
62
+ return 1;
63
+ if (style.textAlign === "right")
64
+ return 2;
65
+ return 0;
66
+ }
67
+ function borderStyle(style) {
68
+ if (style.borderStyle === "solid")
69
+ return 1;
70
+ if (style.borderStyle === "dashed" || style.borderStyle === "dotted")
71
+ return 2;
72
+ if (style.borderStyle === "none")
73
+ return 0;
74
+ if (style.border || style.borderWidth)
75
+ return 1;
76
+ return 0;
77
+ }
78
+ function borderWidthOf(style) {
79
+ const px = cssPx(style.borderWidth);
80
+ if (px > 0)
81
+ return Math.max(1, Math.min(8, px));
82
+ return borderStyle(style) === 0 ? 0 : 1;
83
+ }
84
+ /** Per-side border width: the per-side field wins when set, else falls back to
85
+ * the uniform borderWidth. Used for border-left/top/right/bottom support. */
86
+ function perSideBorderWidth(style, sideWidth, sideStyle) {
87
+ // Per-side style "none" forces 0 for this side.
88
+ if (sideStyle === "none")
89
+ return 0;
90
+ if (sideWidth !== undefined) {
91
+ const px = cssPx(sideWidth);
92
+ if (px > 0)
93
+ return Math.max(1, Math.min(8, px));
94
+ // sideWidth set but 0px and style isn't none → default to 1 if a style was given.
95
+ return sideStyle !== undefined ? 1 : 0;
96
+ }
97
+ // No per-side width: fall back to the uniform border width when the per-side
98
+ // style is set (author wrote e.g. border-top: solid → inherit uniform width).
99
+ if (sideStyle !== undefined)
100
+ return borderWidthOf(style);
101
+ return borderWidthOf(style);
102
+ }
103
+ /** Parse border-radius px value (0 if absent). */
104
+ /** Map CSS text-decoration to the runtime enum.
105
+ * 0=none, 1=underline, 2=line-through, 3=both. */
106
+ function textDecorationOf(val) {
107
+ if (!val)
108
+ return 0;
109
+ const v = val.toLowerCase();
110
+ const hasU = /underline/.test(v);
111
+ const hasS = /line-through/.test(v) || /strikethrough/.test(v);
112
+ return (hasU && hasS) ? 3 : hasS ? 2 : hasU ? 1 : 0;
113
+ }
114
+ /** text-overflow: ellipsis -> true. clip/absent -> false. */
115
+ function textOverflowOf(val) {
116
+ return !!val && /ellipsis/.test(val.toLowerCase());
117
+ }
118
+ function borderRadiusOf(style) {
119
+ if (!style.borderRadius)
120
+ return 0;
121
+ const px = parseInt(style.borderRadius, 10);
122
+ return isNaN(px) ? 0 : px;
123
+ }
124
+ function paddingOf(style) {
125
+ if (!style.padding)
126
+ return { top: 0, right: 0, bottom: 0, left: 0 };
127
+ const parts = style.padding.trim().split(/\s+/).filter(Boolean).map(cssPx);
128
+ const top = parts[0] ?? 0;
129
+ const right = parts.length > 1 ? parts[1] : top;
130
+ const bottom = parts.length > 2 ? parts[2] : top;
131
+ const left = parts.length > 3 ? parts[3] : right;
132
+ return {
133
+ top: Math.max(0, Math.min(255, top)),
134
+ right: Math.max(0, Math.min(255, right)),
135
+ bottom: Math.max(0, Math.min(255, bottom)),
136
+ left: Math.max(0, Math.min(255, left)),
137
+ };
138
+ }
139
+ /** Parse letter-spacing px value (0 if absent). */
140
+ function letterSpacingOf(style) {
141
+ if (!style.letterSpacing)
142
+ return 0;
143
+ const px = parseInt(style.letterSpacing, 10);
144
+ return isNaN(px) ? 0 : px;
145
+ }
146
+ function lineHeightOf(style, textSize) {
147
+ const base = Math.max(1, textSize || 2) * 8;
148
+ const raw = style.lineHeight?.trim();
149
+ if (!raw || raw === "normal")
150
+ return base;
151
+ if (raw.endsWith("%")) {
152
+ const pct = parseFloat(raw);
153
+ return Number.isFinite(pct) ? Math.max(1, Math.min(255, Math.round(base * pct / 100))) : base;
154
+ }
155
+ if (/^-?\d*\.?\d+$/.test(raw)) {
156
+ const n = parseFloat(raw);
157
+ return Number.isFinite(n) ? Math.max(1, Math.min(255, Math.round(base * n))) : base;
158
+ }
159
+ const px = parseInt(raw, 10);
160
+ return Number.isFinite(px) && px > 0 ? Math.max(1, Math.min(255, px)) : base;
161
+ }
162
+ /** Parse object-fit value: none | fill | contain | cover | scale-down. */
163
+ function objectFitOf(value) {
164
+ const v = value.trim().toLowerCase();
165
+ if (v === "none")
166
+ return 0;
167
+ if (v === "fill")
168
+ return 1;
169
+ if (v === "contain")
170
+ return 2;
171
+ if (v === "cover")
172
+ return 3;
173
+ if (v === "scale-down")
174
+ return 4;
175
+ return 1; // default: fill
176
+ }
177
+ function whiteSpaceModeOf(style) {
178
+ switch (whiteSpaceMode(style.whiteSpace)) {
179
+ case "nowrap": return 1;
180
+ case "pre": return 2;
181
+ case "pre-line": return 3;
182
+ default: return 0;
183
+ }
184
+ }
185
+ function zIndexOf(style) {
186
+ const raw = style.zIndex?.trim();
187
+ if (!raw || raw === "auto")
188
+ return 0;
189
+ const z = parseInt(raw, 10);
190
+ return Number.isFinite(z) ? Math.max(-32768, Math.min(32767, z)) : 0;
191
+ }
192
+ function outlineOf(style) {
193
+ const raw = style.outline?.trim();
194
+ if (!raw || raw === "none")
195
+ return { width: 0, style: 0 };
196
+ let width = 1;
197
+ let outlineStyle = 1;
198
+ let color;
199
+ const parts = raw.split(/\s+/);
200
+ for (const part of parts) {
201
+ const lower = part.toLowerCase();
202
+ if (/^\d+(?:\.\d+)?(?:px)?$/.test(lower)) {
203
+ width = Math.max(1, Math.min(8, cssPx(lower)));
204
+ }
205
+ else if (lower === "solid") {
206
+ outlineStyle = 1;
207
+ }
208
+ else if (lower === "dashed" || lower === "dotted") {
209
+ outlineStyle = 2;
210
+ }
211
+ else if (lower === "none") {
212
+ return { width: 0, style: 0 };
213
+ }
214
+ else {
215
+ color = part;
216
+ }
217
+ }
218
+ return { width, style: outlineStyle, color };
219
+ }
220
+ export function cssPx(value) {
221
+ if (!value)
222
+ return 0;
223
+ const m = /(-?\d+(?:\.\d+)?)/.exec(value);
224
+ return m ? Math.round(Number(m[1])) : 0;
225
+ }
226
+ export function clampInt8(n) {
227
+ if (!Number.isFinite(n))
228
+ return 0;
229
+ return Math.max(-128, Math.min(127, Math.round(n)));
230
+ }
231
+ export function clampInt16(n) {
232
+ if (!Number.isFinite(n))
233
+ return 0;
234
+ return Math.max(-32768, Math.min(32767, Math.round(n)));
235
+ }
236
+ function splitTransformArgs(args) {
237
+ return args
238
+ .split(/[\s,]+/)
239
+ .map((part) => part.trim())
240
+ .filter(Boolean);
241
+ }
242
+ function parseLengthOrPercent(value) {
243
+ if (!value)
244
+ return { px: 0, pct: 0 };
245
+ const trimmed = value.trim();
246
+ const m = /^(-?\d+(?:\.\d+)?)(%)?$/.exec(trimmed);
247
+ if (m?.[2])
248
+ return { px: 0, pct: Math.round(Number(m[1])) };
249
+ return { px: cssPx(trimmed), pct: 0 };
250
+ }
251
+ function parseScale(value) {
252
+ if (!value)
253
+ return 100;
254
+ const trimmed = value.trim();
255
+ const n = Number.parseFloat(trimmed);
256
+ if (!Number.isFinite(n))
257
+ return 100;
258
+ const pct = trimmed.endsWith("%") ? n : n * 100;
259
+ return Math.max(0, Math.min(400, Math.round(pct)));
260
+ }
261
+ function parseAngleDeg(value) {
262
+ if (!value)
263
+ return 0;
264
+ const trimmed = value.trim().toLowerCase();
265
+ const n = Number.parseFloat(trimmed);
266
+ if (!Number.isFinite(n))
267
+ return 0;
268
+ if (trimmed.endsWith("turn"))
269
+ return Math.round(n * 360);
270
+ if (trimmed.endsWith("rad"))
271
+ return Math.round(n * 180 / Math.PI);
272
+ return Math.round(n);
273
+ }
274
+ export function parseTransform(transform) {
275
+ const parsed = { x: 0, y: 0, pctX: 0, pctY: 0, scaleX: 100, scaleY: 100, rotateDeg: 0 };
276
+ if (!transform)
277
+ return parsed;
278
+ const fnRe = /([a-zA-Z][\w-]*)\(\s*([^)]*)\s*\)/g;
279
+ let m;
280
+ while ((m = fnRe.exec(transform)) !== null) {
281
+ const fn = m[1].toLowerCase();
282
+ const args = splitTransformArgs(m[2]);
283
+ if (fn === "translate" || fn === "translate3d") {
284
+ const x = parseLengthOrPercent(args[0]);
285
+ const y = parseLengthOrPercent(args[1]);
286
+ parsed.x += x.px;
287
+ parsed.pctX += x.pct;
288
+ parsed.y += y.px;
289
+ parsed.pctY += y.pct;
290
+ }
291
+ else if (fn === "translatex") {
292
+ const x = parseLengthOrPercent(args[0]);
293
+ parsed.x += x.px;
294
+ parsed.pctX += x.pct;
295
+ }
296
+ else if (fn === "translatey") {
297
+ const y = parseLengthOrPercent(args[0]);
298
+ parsed.y += y.px;
299
+ parsed.pctY += y.pct;
300
+ }
301
+ else if (fn === "scale" || fn === "scale3d") {
302
+ const sx = parseScale(args[0]);
303
+ const sy = args[1] ? parseScale(args[1]) : sx;
304
+ parsed.scaleX = Math.round(parsed.scaleX * sx / 100);
305
+ parsed.scaleY = Math.round(parsed.scaleY * sy / 100);
306
+ }
307
+ else if (fn === "scalex") {
308
+ parsed.scaleX = Math.round(parsed.scaleX * parseScale(args[0]) / 100);
309
+ }
310
+ else if (fn === "scaley") {
311
+ parsed.scaleY = Math.round(parsed.scaleY * parseScale(args[0]) / 100);
312
+ }
313
+ else if (fn === "rotate" || fn === "rotatez") {
314
+ parsed.rotateDeg += parseAngleDeg(args[0]);
315
+ }
316
+ }
317
+ parsed.scaleX = Math.max(0, Math.min(400, parsed.scaleX));
318
+ parsed.scaleY = Math.max(0, Math.min(400, parsed.scaleY));
319
+ parsed.rotateDeg = clampInt16(parsed.rotateDeg);
320
+ return parsed;
321
+ }
322
+ export function transformOffset(transform) {
323
+ const parsed = parseTransform(transform);
324
+ return { x: parsed.x, y: parsed.y };
325
+ }
326
+ function originTokenPercent(token, axis) {
327
+ if (!token)
328
+ return undefined;
329
+ const lower = token.trim().toLowerCase();
330
+ if (lower === "center")
331
+ return 50;
332
+ if (axis === "x") {
333
+ if (lower === "left")
334
+ return 0;
335
+ if (lower === "right")
336
+ return 100;
337
+ }
338
+ else {
339
+ if (lower === "top")
340
+ return 0;
341
+ if (lower === "bottom")
342
+ return 100;
343
+ }
344
+ const m = /^(-?\d+(?:\.\d+)?)%$/.exec(lower);
345
+ if (!m)
346
+ return undefined;
347
+ return Math.max(0, Math.min(100, Math.round(Number(m[1]))));
348
+ }
349
+ function isVerticalOriginToken(token) {
350
+ const lower = token?.trim().toLowerCase();
351
+ return lower === "top" || lower === "bottom";
352
+ }
353
+ function isHorizontalOriginToken(token) {
354
+ const lower = token?.trim().toLowerCase();
355
+ return lower === "left" || lower === "right";
356
+ }
357
+ export function transformOriginPercent(value) {
358
+ if (!value)
359
+ return { x: 50, y: 50 };
360
+ const tokens = splitTransformArgs(value.replace(/\//g, " ")).slice(0, 2);
361
+ if (tokens.length === 0)
362
+ return { x: 50, y: 50 };
363
+ if (tokens.length === 1) {
364
+ if (isVerticalOriginToken(tokens[0]))
365
+ return { x: 50, y: originTokenPercent(tokens[0], "y") ?? 50 };
366
+ return { x: originTokenPercent(tokens[0], "x") ?? 50, y: 50 };
367
+ }
368
+ if (isVerticalOriginToken(tokens[0]) || isHorizontalOriginToken(tokens[1])) {
369
+ return {
370
+ x: originTokenPercent(tokens[1], "x") ?? 50,
371
+ y: originTokenPercent(tokens[0], "y") ?? 50,
372
+ };
373
+ }
374
+ return {
375
+ x: originTokenPercent(tokens[0], "x") ?? 50,
376
+ y: originTokenPercent(tokens[1], "y") ?? 50,
377
+ };
378
+ }
379
+ function transformedBox(base, transform, origin) {
380
+ const scaledW = Math.max(0, Math.min(32767, Math.round(base.w * transform.scaleX / 100)));
381
+ const scaledH = Math.max(0, Math.min(32767, Math.round(base.h * transform.scaleY / 100)));
382
+ const originPxX = Math.round(base.w * origin.x / 100);
383
+ const originPxY = Math.round(base.h * origin.y / 100);
384
+ const scaleOffsetX = originPxX - Math.round(originPxX * transform.scaleX / 100);
385
+ const scaleOffsetY = originPxY - Math.round(originPxY * transform.scaleY / 100);
386
+ const pctOffsetX = Math.round(base.w * transform.pctX / 100);
387
+ const pctOffsetY = Math.round(base.h * transform.pctY / 100);
388
+ return {
389
+ box: { ...base, w: scaledW, h: scaledH },
390
+ x: transform.x + pctOffsetX + scaleOffsetX,
391
+ y: transform.y + pctOffsetY + scaleOffsetY,
392
+ };
393
+ }
394
+ function pressedOffsetOf(style) {
395
+ const pressed = style.pressed;
396
+ if (!pressed)
397
+ return { x: 0, y: 0 };
398
+ let x = 0;
399
+ let y = 0;
400
+ if (pressed.left)
401
+ x += cssPx(pressed.left) - cssPx(style.left);
402
+ else if (pressed.right)
403
+ x -= cssPx(pressed.right) - cssPx(style.right);
404
+ if (pressed.top)
405
+ y += cssPx(pressed.top) - cssPx(style.top);
406
+ else if (pressed.bottom)
407
+ y -= cssPx(pressed.bottom) - cssPx(style.bottom);
408
+ if (pressed.transform) {
409
+ const baseTransform = parseTransform(style.transform);
410
+ const pressedTransform = parseTransform(pressed.transform);
411
+ x += pressedTransform.x - baseTransform.x;
412
+ y += pressedTransform.y - baseTransform.y;
413
+ }
414
+ return {
415
+ x: clampInt8(x),
416
+ y: clampInt8(y),
417
+ };
418
+ }
419
+ /** Parse box-shadow into up to 4 shadow specs. Handles multi-shadow
420
+ * (comma-separated) and the `inset` keyword.
421
+ * Format per shadow: [inset] offsetX offsetY [blur] [spread] color. */
422
+ const MAX_SHADOWS = 4;
423
+ function parseBoxShadow(style, format) {
424
+ const raw = style.boxShadow;
425
+ if (!raw || raw === "none")
426
+ return [];
427
+ // Split on commas that are NOT inside parentheses (so rgba(0,0,0,0.5) isn't split).
428
+ const parts = [];
429
+ let depth = 0;
430
+ let start = 0;
431
+ for (let i = 0; i < raw.length; i++) {
432
+ if (raw[i] === "(")
433
+ depth++;
434
+ else if (raw[i] === ")")
435
+ depth--;
436
+ else if (raw[i] === "," && depth === 0) {
437
+ parts.push(raw.slice(start, i).trim());
438
+ start = i + 1;
439
+ }
440
+ }
441
+ parts.push(raw.slice(start).trim());
442
+ const specs = [];
443
+ for (const part of parts) {
444
+ if (specs.length >= MAX_SHADOWS)
445
+ break;
446
+ const inset = /\binset\b/i.test(part);
447
+ // Split into a numbers zone (offset/blur) and a color zone. The color
448
+ // zone starts at the first # hex, rgb(, rgba(, or color name. css-tree
449
+ // may strip spaces between values, so we can't rely on whitespace split.
450
+ const colorStart = part.search(/#|rgba?\(|\b(?:black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|cyan|magenta|silver|gold|brown|tan|navy|teal|maroon|lime|olive|aqua|fuchsia|transparent)\b/i);
451
+ const numZone = colorStart >= 0 ? part.slice(0, colorStart) : part;
452
+ const colorZone = colorStart >= 0 ? part.slice(colorStart) : "";
453
+ // Extract numbers from the numeric zone only (safe — no hex digits here).
454
+ const numTokens = [];
455
+ const numRe = /(-?\d+)/g;
456
+ let m;
457
+ while ((m = numRe.exec(numZone)) !== null)
458
+ numTokens.push(parseInt(m[1], 10));
459
+ if (numTokens.length < 2)
460
+ continue;
461
+ const x = numTokens[0];
462
+ const y = numTokens[1];
463
+ const blur = numTokens.length >= 3 ? Math.max(0, Math.min(numTokens[2], 8)) : 0;
464
+ // Resolve color from the color zone.
465
+ let color = 0x0000;
466
+ let alpha = 100;
467
+ if (colorZone) {
468
+ // Extract alpha from rgba(r,g,b,a) comma OR hsl(... / a) slash syntax.
469
+ const alphaM = /(?:rgba?\([^,]*,[^,]*,[^,]*,\s*([\d.]+)\s*\)|hsla?\([^)]*\/\s*([\d.]+)\s*\))/.exec(colorZone);
470
+ if (alphaM) {
471
+ alpha = Math.round(parseFloat(alphaM[1] ?? alphaM[2]) * 100);
472
+ alpha = Math.max(0, Math.min(100, alpha));
473
+ }
474
+ try {
475
+ color = resolveColorInternal(colorZone, format);
476
+ }
477
+ catch {
478
+ color = 0x0000;
479
+ }
480
+ }
481
+ specs.push({ x, y, blur, color, alpha, inset });
482
+ }
483
+ return specs;
484
+ }
485
+ /** Parse opacity (0-100, default 100). */
486
+ function opacityOf(style) {
487
+ if (!style.opacity)
488
+ return 100;
489
+ return cssOpacityToPercent(style.opacity);
490
+ }
491
+ export function cssOpacityToPercent(value) {
492
+ if (!value)
493
+ return 100;
494
+ const n = Number.parseFloat(value);
495
+ if (!Number.isFinite(n))
496
+ return 100;
497
+ const pct = n <= 1 ? Math.round(n * 100) : Math.round(n);
498
+ return Math.max(0, Math.min(100, pct));
499
+ }
500
+ /** Extract the first color string from a linear-gradient(...) value.
501
+ * Used by flatten() to pass a valid color string as clearColor/parentBg. */
502
+ function extractFirstGradientColor(bg) {
503
+ const innerM = /linear-gradient\(\s*([^)]+)\)/.exec(bg);
504
+ if (!innerM)
505
+ return undefined;
506
+ const colorRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[a-z]+/gi;
507
+ let cm;
508
+ while ((cm = colorRe.exec(innerM[1])) !== null) {
509
+ if (!["to", "linear", "bottom", "top", "left", "right"].includes(cm[0]))
510
+ return cm[0];
511
+ }
512
+ return undefined;
513
+ }
514
+ function parseGradient(bg, format) {
515
+ if (!bg || !bg.includes("linear-gradient"))
516
+ return null;
517
+ // Extract the content inside linear-gradient(...).
518
+ const innerM = /linear-gradient\(\s*([^)]+)\)/.exec(bg);
519
+ if (!innerM)
520
+ return null;
521
+ const inner = innerM[1];
522
+ // Extract color stops: match #hex, rgb(), rgba(), or named colors.
523
+ const colorRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[a-z]+/gi;
524
+ const colors = [];
525
+ let cm;
526
+ while ((cm = colorRe.exec(inner)) !== null) {
527
+ if (!["to", "linear", "bottom", "top", "left", "right"].includes(cm[0]))
528
+ colors.push(cm[0]);
529
+ }
530
+ if (colors.length < 2)
531
+ return null;
532
+ // Direction: "to bottom" = vertical (1), "to right" = horizontal (2).
533
+ // Default to vertical if not specified.
534
+ let dir = 1;
535
+ if (/to\s+right/i.test(inner) || /to\s+left/i.test(inner))
536
+ dir = 2;
537
+ return {
538
+ dir,
539
+ color1: resolveColorInternal(colors[0], format),
540
+ color2: resolveColorInternal(colors[1], format),
541
+ };
542
+ }
543
+ /** Map font-size (px) to a GFX text size (1-4).
544
+ * ≤12px→1, 13-20px→2, 21-28px→3, 29+→4.
545
+ *
546
+ * font-weight no longer inflates the bucket: matching web behavior, bold only
547
+ * affects glyph stroke weight (via the chosen @font-face), not the rendered
548
+ * glyph size. Previously bold added 1 to the bucket, which made <h3> labels
549
+ * render larger than their declared font-size and surprised authors. */
550
+ function textSizeOf(style) {
551
+ let size = 2; // default
552
+ if (style.fontSize) {
553
+ const px = parseInt(style.fontSize, 10);
554
+ if (!isNaN(px)) {
555
+ if (px <= 12)
556
+ size = 1;
557
+ else if (px <= 20)
558
+ size = 2;
559
+ else if (px <= 28)
560
+ size = 3;
561
+ else
562
+ size = 4;
563
+ }
564
+ }
565
+ return size;
566
+ }
567
+ function fontAntialiasOf(style, display) {
568
+ if (display?.colorFormat === "mono")
569
+ return false;
570
+ const smoothing = style.fontSmoothing?.toLowerCase();
571
+ if (smoothing) {
572
+ if (smoothing.includes("antialiased") || smoothing.includes("smooth") || smoothing.includes("grayscale")) {
573
+ return true;
574
+ }
575
+ if (smoothing.includes("none") || smoothing.includes("aliased") || smoothing.includes("pixel")) {
576
+ return false;
577
+ }
578
+ }
579
+ // Explicit profile.antialias === false still wins for back-compat (existing
580
+ // configs). Otherwise derive from capabilities: TFT default true, eink false.
581
+ if (display?.antialias === false)
582
+ return false;
583
+ return deriveCapabilities(display ?? { width: 0, height: 0, colorFormat: "rgb565" }).features.antialias;
584
+ }
585
+ function fontFaceOf(style, fontAssets) {
586
+ const match = selectFontAssetForStyle(fontAssets, style);
587
+ return match?.id ?? 0;
588
+ }
589
+ /** Apply text-transform (uppercase/lowercase/capitalize) to a static string. */
590
+ function applyTextTransform(text, style) {
591
+ if (!text)
592
+ return text;
593
+ switch (style.textTransform) {
594
+ case "uppercase": return text.toUpperCase();
595
+ case "lowercase": return text.toLowerCase();
596
+ case "capitalize":
597
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
598
+ default: return text;
599
+ }
600
+ }
601
+ /** Default-font (Adafruit GFX bitmap) advance width for a string: 6*textSize
602
+ * per character plus the per-character letter spacing. Used when a run has no
603
+ * matching custom @font-face asset (mirrors layout-engine.ts textWidthOf). */
604
+ function textWidthOfDefault(text, textSize, letterSpacing) {
605
+ const advance = 6 * textSize + letterSpacing;
606
+ let w = 0;
607
+ for (const _ch of text)
608
+ w += advance;
609
+ return w;
610
+ }
611
+ function firstListValue(value) {
612
+ return value?.split(",")[0]?.trim();
613
+ }
614
+ function animationDeclOf(style) {
615
+ const shorthand = firstListValue(style.animation);
616
+ if (shorthand) {
617
+ const decl = parseAnimation(shorthand);
618
+ // Shorthand resets all longhands; if it omitted timing-function, fall back
619
+ // to the explicit longhand (or default linear).
620
+ if (decl && !decl.timingFunction && style.animationTimingFunction) {
621
+ decl.timingFunction = style.animationTimingFunction;
622
+ }
623
+ return decl;
624
+ }
625
+ const name = firstListValue(style.animationName);
626
+ if (!name || name === "none")
627
+ return null;
628
+ const decl = parseAnimation([
629
+ name,
630
+ firstListValue(style.animationDuration),
631
+ firstListValue(style.animationIterationCount),
632
+ firstListValue(style.animationDelay),
633
+ ].filter(Boolean).join(" "));
634
+ if (decl && style.animationTimingFunction)
635
+ decl.timingFunction = style.animationTimingFunction;
636
+ return decl;
637
+ }
638
+ function flatten(node, boxes, out, cursor, parentBg, parentIndex = -1, screenId = 0, parentZIndex = 0, parentOpacity = 100) {
639
+ const index = cursor.i++;
640
+ const box = boxes[index] ?? { x: 0, y: 0, w: 0, h: 0 };
641
+ const hasBg = !!node.style.background;
642
+ // If background is a gradient, extract the first color stop as the base
643
+ // clearColor string (avoids resolveColor choking on "linear-gradient(...)").
644
+ const bgStr = node.style.background;
645
+ const bgIsGradient = bgStr && bgStr.includes("linear-gradient");
646
+ const bgBaseColor = bgIsGradient ? extractFirstGradientColor(bgStr) : bgStr;
647
+ const clearColor = hasBg ? bgBaseColor : parentBg;
648
+ const zIndex = Math.max(-32768, Math.min(32767, parentZIndex + zIndexOf(node.style)));
649
+ // Opacity compounds down the tree: a child's effective opacity is its own
650
+ // value scaled by the parent's effective opacity (50% parent × 100% child = 50%).
651
+ const ownOpacity = opacityOf(node.style);
652
+ const effectiveOpacity = Math.round((parentOpacity * ownOpacity) / 100);
653
+ out.push({ index, node, box, hasBg, clearColor, parentIndex, subtreeEnd: index + 1, screenId, zIndex, effectiveOpacity });
654
+ // For opaque nodes, children clear/redraw against this node's own fill
655
+ // (the normal case). For translucent nodes, children must clear against the
656
+ // same BACKDROP this node blends toward (parentBg) — otherwise a child text
657
+ // node repaints a solid block of the (translucent) parent's raw fill around
658
+ // its glyphs, making the area around the text look opaque.
659
+ const childParentBg = (effectiveOpacity < 100) ? parentBg : (hasBg ? bgBaseColor : parentBg);
660
+ // Iterate children in the same order sequence the flex engine lays them out
661
+ // (the yoga binding can't apply order, so yoga-layout sorts children by order
662
+ // first). flatten must match that order so each node pairs with its box.
663
+ const orderNum = (v) => {
664
+ const n = parseInt(String(v ?? "0"), 10);
665
+ return Number.isFinite(n) ? n : 0;
666
+ };
667
+ const orderedChildren = node.children.slice().sort((a, b) => orderNum(a.style.order) - orderNum(b.style.order));
668
+ for (const child of orderedChildren) {
669
+ flatten(child, boxes, out, cursor, childParentBg, index, screenId, zIndex, effectiveOpacity);
670
+ }
671
+ out[index].subtreeEnd = cursor.i;
672
+ }
673
+ export function lowerUIToModel(root, boxes, colorFormat, display, fontAssets = [], allScreens = [], imageAssetIds = new Map(), keyframeSets = [], imageAssets = []) {
674
+ const flat = [];
675
+ const cursor = { i: 0 };
676
+ if (allScreens.length > 0) {
677
+ for (let s = 0; s < allScreens.length; s++) {
678
+ flatten(allScreens[s], boxes, flat, cursor, undefined, -1, s);
679
+ }
680
+ }
681
+ else {
682
+ flatten(root, boxes, flat, cursor, undefined);
683
+ }
684
+ // Build a screen-id → screen-index map for resolving <a href="#screenId">
685
+ // runs to their target screen index. Screens are indexed by their position in
686
+ // allScreens; their id attribute names them. Built once here so each run's
687
+ // linkTarget can be resolved without crossing into the ir/ layer.
688
+ const screenIdToIndex = new Map();
689
+ allScreens.forEach((s, i) => {
690
+ if (s.id)
691
+ screenIdToIndex.set(s.id, i);
692
+ });
693
+ const nodes = flat.map(({ index, node, box, hasBg, clearColor, parentIndex, subtreeEnd, screenId, zIndex, effectiveOpacity }) => {
694
+ // If background is a gradient, use the first stop as the base bg color
695
+ // (the runtime draws the actual gradient per-row on top of this).
696
+ const grad = parseGradient(node.style.background, colorFormat);
697
+ const bg = node.style.background
698
+ ? (grad ? grad.color1 : resolveColorInternal(node.style.background, colorFormat))
699
+ : 0;
700
+ const fg = node.style.color ? resolveColorInternal(node.style.color, colorFormat) : 0xffff;
701
+ const bColor = node.style.borderColor ? resolveColorInternal(node.style.borderColor, colorFormat) : 0;
702
+ const clear = clearColor ? resolveColorInternal(clearColor, colorFormat) : 0;
703
+ const outline = outlineOf(node.style);
704
+ const outlineColor = outline.color ? resolveColorInternal(outline.color, colorFormat) : fg;
705
+ const baseTransform = parseTransform(node.style.transform);
706
+ const transformOrigin = transformOriginPercent(node.style.transformOrigin);
707
+ const baseTransformOffset = transformedBox(box, baseTransform, transformOrigin);
708
+ const pressedOffset = pressedOffsetOf(node.style);
709
+ const textSize = textSizeOf(node.style);
710
+ const wsMode = whiteSpaceModeOf(node.style);
711
+ const kind = nodeKind(node.tag);
712
+ const rangeMin = intAttr(node.min, 0);
713
+ const rangeMax = intAttr(node.max, 100);
714
+ const value = initialValueOf(node, kind, rangeMin, rangeMax);
715
+ // Rich-text runs: build the run model + precomputed line geometry. Only
716
+ // text nodes with mixed inline content carry runs; all other nodes (and
717
+ // plain single-string text nodes) leave runs/runLines undefined.
718
+ let runsModel;
719
+ let runLinesModel;
720
+ if (node.runs && node.runs.length > 0) {
721
+ // Per-run resolved style = node style + run style (run overrides).
722
+ const runStyleOf = (r) => ({ ...node.style, ...r.style });
723
+ // Build layoutRuns input: each run measured at its own font/size advance.
724
+ const layoutInput = node.runs.map(r => {
725
+ const rs = runStyleOf(r);
726
+ const rTextSize = textSizeOf(rs);
727
+ const rLetterSpacing = letterSpacingOf(rs);
728
+ return {
729
+ text: applyTextTransform(r.text, rs) ?? "",
730
+ hardBreak: r.hardBreak,
731
+ // Per-glyph asset advance when the run has a custom font, else the
732
+ // flat 6*textSize + letterSpacing per-char default-font advance.
733
+ measureText: (s) => assetTextWidth(s, rs, fontAssets) ?? textWidthOfDefault(s, rTextSize, rLetterSpacing),
734
+ height: 8 * rTextSize,
735
+ ascent: 7 * rTextSize,
736
+ };
737
+ });
738
+ const layout = layoutRuns(layoutInput, { maxWidth: box.w, whiteSpace: node.style.whiteSpace });
739
+ runsModel = node.runs.map(r => {
740
+ const rs = runStyleOf(r);
741
+ let target = -1;
742
+ if (r.href) {
743
+ const id = r.href.startsWith("#") ? r.href.slice(1) : r.href;
744
+ target = screenIdToIndex.has(id) ? screenIdToIndex.get(id) : -1;
745
+ }
746
+ return {
747
+ text: applyTextTransform(r.text, rs) ?? "",
748
+ fg: rs.color ? resolveColorInternal(rs.color, colorFormat) : fg,
749
+ textSize: textSizeOf(rs),
750
+ fontFace: fontFaceOf(rs, fontAssets),
751
+ underline: textDecorationOf(rs.textDecoration),
752
+ letterSpacing: letterSpacingOf(rs),
753
+ linkTarget: target,
754
+ };
755
+ });
756
+ // Bake the laid-out lines into the struct-of-arrays. Cursor advance uses
757
+ // the node's explicit line-height if set (uniform lines), else each
758
+ // line's tallest run height (variable — new for mixed font-sizes).
759
+ const rl = {
760
+ segRun: [], segText: [], segX: [], segW: [], segLine: [],
761
+ lineY: [], lineH: [], lineBaseline: [], lineW: [],
762
+ };
763
+ const explicitLineHeight = node.style.lineHeight ? lineHeightOf(node.style, textSize) : 0;
764
+ let y = 0;
765
+ layout.lines.forEach((line, li) => {
766
+ rl.lineY.push(y);
767
+ rl.lineH.push(line.height);
768
+ rl.lineBaseline.push(y + line.ascent);
769
+ rl.lineW.push(line.width);
770
+ y += explicitLineHeight > 0 ? explicitLineHeight : line.height;
771
+ for (const seg of line.segments) {
772
+ rl.segRun.push(seg.runIndex);
773
+ rl.segText.push(seg.text);
774
+ rl.segX.push(seg.x);
775
+ rl.segW.push(seg.width);
776
+ rl.segLine.push(li);
777
+ }
778
+ });
779
+ runLinesModel = rl;
780
+ }
781
+ return {
782
+ index,
783
+ tag: node.tag,
784
+ id: node.id,
785
+ classes: node.classes,
786
+ box: baseTransformOffset.box,
787
+ bg,
788
+ fg,
789
+ kind,
790
+ text: applyTextTransform(node.text, node.style),
791
+ placeholder: applyTextTransform(node.placeholder, node.style),
792
+ valueAttr: node.value,
793
+ name: node.name,
794
+ checked: node.checked,
795
+ textBuffer: applyTextTransform(node.placeholder, node.style) ?? "",
796
+ hasTextBinding: false,
797
+ hasBg,
798
+ textAlign: textAlign(node.style),
799
+ textSize,
800
+ lineHeight: lineHeightOf(node.style, textSize),
801
+ letterSpacing: letterSpacingOf(node.style),
802
+ fontAntialias: fontAntialiasOf(node.style, display),
803
+ fontFace: fontFaceOf(node.style, fontAssets),
804
+ borderColor: bColor,
805
+ borderStyle: borderStyle(node.style),
806
+ borderWidth: borderWidthOf(node.style),
807
+ borderTopWidth: perSideBorderWidth(node.style, node.style.borderTopWidth, node.style.borderTopStyle),
808
+ borderRightWidth: perSideBorderWidth(node.style, node.style.borderRightWidth, node.style.borderRightStyle),
809
+ borderBottomWidth: perSideBorderWidth(node.style, node.style.borderBottomWidth, node.style.borderBottomStyle),
810
+ borderLeftWidth: perSideBorderWidth(node.style, node.style.borderLeftWidth, node.style.borderLeftStyle),
811
+ borderRadius: borderRadiusOf(node.style),
812
+ ...(() => {
813
+ const padding = paddingOf(node.style);
814
+ return {
815
+ paddingTop: padding.top,
816
+ paddingRight: padding.right,
817
+ paddingBottom: padding.bottom,
818
+ paddingLeft: padding.left,
819
+ };
820
+ })(),
821
+ outlineColor,
822
+ outlineStyle: outline.style,
823
+ outlineWidth: outline.width,
824
+ zIndex,
825
+ transformOffsetX: clampInt16(baseTransformOffset.x),
826
+ transformOffsetY: clampInt16(baseTransformOffset.y),
827
+ rotateDeg: clampInt16(baseTransform.rotateDeg),
828
+ pressedOffsetX: pressedOffset.x,
829
+ pressedOffsetY: pressedOffset.y,
830
+ // Background gradient (if background is a linear-gradient).
831
+ ...(() => {
832
+ const grad = parseGradient(node.style.background, colorFormat);
833
+ return grad
834
+ ? { gradientEnabled: grad.dir, gradientColor1: grad.color1, gradientColor2: grad.color2 }
835
+ : { gradientEnabled: 0, gradientColor1: 0, gradientColor2: 0 };
836
+ })(),
837
+ ...(() => {
838
+ const shadows = parseBoxShadow(node.style, colorFormat);
839
+ const pad = (arr, val, n) => {
840
+ const out = [...arr];
841
+ while (out.length < n)
842
+ out.push(val);
843
+ return out.slice(0, n);
844
+ };
845
+ return {
846
+ shadowCount: shadows.length,
847
+ shadowOffsetX: pad(shadows.map(s => s.x), 0, MAX_SHADOWS),
848
+ shadowOffsetY: pad(shadows.map(s => s.y), 0, MAX_SHADOWS),
849
+ shadowBlur: pad(shadows.map(s => s.blur), 0, MAX_SHADOWS),
850
+ shadowColor: pad(shadows.map(s => s.color), 0, MAX_SHADOWS),
851
+ shadowAlpha: pad(shadows.map(s => s.alpha), 0, MAX_SHADOWS),
852
+ shadowInset: pad(shadows.map(s => s.inset), false, MAX_SHADOWS),
853
+ };
854
+ })(),
855
+ // Text shadow: reuse parseBoxShadow for the text-shadow value.
856
+ ...(() => {
857
+ const tsShadows = parseBoxShadow({ boxShadow: node.style.textShadow }, colorFormat);
858
+ const ts = tsShadows[0];
859
+ return ts
860
+ ? { textShadowCount: 1, textShadowOffsetX: ts.x, textShadowOffsetY: ts.y, textShadowBlur: ts.blur, textShadowColor: ts.color, textShadowAlpha: ts.alpha }
861
+ : { textShadowCount: 0, textShadowOffsetX: 0, textShadowOffsetY: 0, textShadowBlur: 0, textShadowColor: 0, textShadowAlpha: 0 };
862
+ })(),
863
+ underline: textDecorationOf(node.style.textDecoration),
864
+ textOverflow: textOverflowOf(node.style.textOverflow),
865
+ nowrap: wsMode === 1 || wsMode === 2,
866
+ whiteSpaceMode: wsMode,
867
+ visible: node.style.visibility !== "hidden" && node.style.visibility !== "collapse" && !isDisplayNone(node),
868
+ opacity: effectiveOpacity,
869
+ clearColor: clear,
870
+ lastTextWidth: 0,
871
+ lastTextHeight: 0,
872
+ dirty: false,
873
+ value,
874
+ options: node.options,
875
+ scrollable: node.style.overflow === "scroll" || node.style.overflow === "hidden",
876
+ scrollY: 0,
877
+ contentHeight: 0, // computed after layout
878
+ overscrollPx: 0,
879
+ settling: false,
880
+ lastPaintedScrollY: 0,
881
+ rangeMin,
882
+ rangeMax,
883
+ maxlen: node.maxlen ?? 0,
884
+ inputType: node.type,
885
+ keyboard: node.keyboard,
886
+ parentIndex,
887
+ subtreeEnd,
888
+ screenId,
889
+ imgDataId: node.id && imageAssetIds.has(node.id) ? imageAssetIds.get(node.id) : 255,
890
+ // Image scaling mode: 0=none, 1=fill, 2=contain, 3=cover, 4=scale-down
891
+ objectFit: node.style.objectFit ? objectFitOf(node.style.objectFit) : 1,
892
+ listItemHeight: node.itemHeight ?? 0,
893
+ virtualized: node.tag === "list",
894
+ canvasW: node.canvasW ?? 0,
895
+ canvasH: node.canvasH ?? 0,
896
+ runs: runsModel,
897
+ runLines: runLinesModel,
898
+ };
899
+ });
900
+ // Compute contentHeight for scrollable nodes from actual tree descendants.
901
+ for (let i = 0; i < flat.length; i++) {
902
+ if (!nodes[i].scrollable)
903
+ continue;
904
+ const parentBox = nodes[i].box;
905
+ let maxBottom = parentBox.y;
906
+ for (let j = i + 1; j < nodes[i].subtreeEnd; j++) {
907
+ const bottom = nodes[j].box.y + nodes[j].box.h;
908
+ if (bottom > maxBottom)
909
+ maxBottom = bottom;
910
+ }
911
+ nodes[i].contentHeight = Math.max(parentBox.h, maxBottom - parentBox.y);
912
+ }
913
+ const transitions = [];
914
+ const pushTransition = (index, nodeModel, prop, durationMs, pressedStyle) => {
915
+ const baseTarget = prop === "background" ? nodeModel.bg : nodeModel.fg;
916
+ const pressedValue = prop === "background" ? pressedStyle?.background : pressedStyle?.color;
917
+ const pressedTarget = pressedValue ? resolveColorInternal(pressedValue, colorFormat) : baseTarget;
918
+ transitions.push({
919
+ node: index,
920
+ prop,
921
+ durationMs,
922
+ pressedTarget,
923
+ baseTarget,
924
+ elapsed: 0,
925
+ prevValue: 0,
926
+ targetValue: 0,
927
+ active: false,
928
+ });
929
+ };
930
+ for (const { index, node } of flat) {
931
+ const nodeModel = nodes[index];
932
+ const pressedStyle = node.style.pressed;
933
+ const explicitProp = node.style.transition?.property === "background"
934
+ ? "background"
935
+ : node.style.transition?.property === "color" ? "color" : undefined;
936
+ if (explicitProp) {
937
+ pushTransition(index, nodeModel, explicitProp, node.style.transition.durationMs, pressedStyle);
938
+ }
939
+ for (const prop of ["background", "color"]) {
940
+ if (prop === explicitProp)
941
+ continue;
942
+ const pressedValue = prop === "background" ? pressedStyle?.background : pressedStyle?.color;
943
+ if (!pressedValue)
944
+ continue;
945
+ const pressedTarget = resolveColorInternal(pressedValue, colorFormat);
946
+ const baseTarget = prop === "background" ? nodeModel.bg : nodeModel.fg;
947
+ if (pressedTarget === baseTarget)
948
+ continue;
949
+ pushTransition(index, nodeModel, prop, 0, pressedStyle);
950
+ }
951
+ }
952
+ // Build animations from nodes that have an 'animation' CSS property.
953
+ const animations = [];
954
+ for (const fn of flat) {
955
+ const animation = animationDeclOf(fn.node.style);
956
+ if (!animation)
957
+ continue;
958
+ const setIdx = keyframeSets.findIndex(k => k.name === animation.name);
959
+ if (setIdx < 0)
960
+ continue;
961
+ animations.push({
962
+ node: fn.index,
963
+ keyframeSet: setIdx,
964
+ durationMs: Math.max(0, Math.min(65535, animation.durationMs)),
965
+ delayMs: Math.max(0, Math.min(65535, animation.delayMs)),
966
+ iterations: Math.max(-1, Math.min(32767, animation.iterations)),
967
+ baseWidth: Math.max(0, Math.min(32767, fn.box.w)),
968
+ baseHeight: Math.max(0, Math.min(32767, fn.box.h)),
969
+ originX: transformOriginPercent(fn.node.style.transformOrigin).x,
970
+ originY: transformOriginPercent(fn.node.style.transformOrigin).y,
971
+ timingFunction: timingFunctionCode(animation.timingFunction),
972
+ });
973
+ }
974
+ const displaySize = display ? effectiveDisplaySize(display) : { width: 0, height: 0 };
975
+ return {
976
+ width: displaySize.width,
977
+ height: displaySize.height,
978
+ colorFormat,
979
+ display,
980
+ fontAssets,
981
+ imageAssets,
982
+ nodes,
983
+ transitions,
984
+ keyframeSets,
985
+ animations,
986
+ };
987
+ }