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