@typecad/ui 1.0.0-alpha.11 → 1.0.0-alpha.13

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 (135) hide show
  1. package/README.md +21 -1
  2. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  7. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  8. package/assets/fonts/dejavu/LICENSE +187 -0
  9. package/assets/fonts/dejavu/README.md +49 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +74 -0
  12. package/dist/engine-index.js +2 -0
  13. package/dist/preview/build-program.d.ts +7 -1
  14. package/dist/preview/build-program.js +94 -15
  15. package/dist/preview/host-gfx.d.ts +3 -0
  16. package/dist/preview/host-gfx.js +30 -3
  17. package/dist/preview/host-ui-runtime.d.ts +115 -0
  18. package/dist/preview/host-ui-runtime.js +928 -46
  19. package/dist/ui-engine/block-layout.js +25 -11
  20. package/dist/ui-engine/color.d.ts +6 -0
  21. package/dist/ui-engine/color.js +80 -2
  22. package/dist/ui-engine/compat-report.d.ts +8 -0
  23. package/dist/ui-engine/compat-report.js +124 -0
  24. package/dist/ui-engine/css-imports.d.ts +4 -0
  25. package/dist/ui-engine/css-imports.js +70 -0
  26. package/dist/ui-engine/css-parser.js +58 -14
  27. package/dist/ui-engine/default-font.d.ts +25 -0
  28. package/dist/ui-engine/default-font.js +80 -0
  29. package/dist/ui-engine/font-assets.d.ts +2 -2
  30. package/dist/ui-engine/font-assets.js +22 -5
  31. package/dist/ui-engine/html-parser.d.ts +4 -0
  32. package/dist/ui-engine/html-parser.js +241 -26
  33. package/dist/ui-engine/image-assets.d.ts +8 -1
  34. package/dist/ui-engine/image-assets.js +35 -2
  35. package/dist/ui-engine/image-decode.d.ts +22 -0
  36. package/dist/ui-engine/image-decode.js +194 -0
  37. package/dist/ui-engine/inline-parser.js +1 -1
  38. package/dist/ui-engine/layout-engine.d.ts +19 -0
  39. package/dist/ui-engine/layout-engine.js +59 -3
  40. package/dist/ui-engine/model.d.ts +18 -0
  41. package/dist/ui-engine/model.js +76 -8
  42. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  43. package/dist/ui-engine/runtime-header/canvas-helpers.js +5 -2
  44. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  45. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  46. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  47. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  48. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  49. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  50. package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1377
  51. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  52. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  53. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  54. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  55. package/dist/ui-engine/runtime-header/structs.js +228 -216
  56. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  57. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  58. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  59. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  60. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +79 -23
  61. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  62. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  63. package/dist/ui-engine/shadcn-kit.js +502 -0
  64. package/dist/ui-engine/style-resolver.d.ts +4 -0
  65. package/dist/ui-engine/style-resolver.js +58 -6
  66. package/dist/ui-engine/transpile-ui.js +12 -5
  67. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  68. package/dist/ui-engine/ua-stylesheet.js +119 -32
  69. package/dist/ui-engine/ui-lowering.js +37 -3
  70. package/dist/ui-engine/ui-registry.js +32 -4
  71. package/dist/ui-engine/yoga-layout.js +57 -52
  72. package/dist/wizard/config-writer.d.ts +49 -0
  73. package/dist/wizard/config-writer.js +366 -0
  74. package/dist/wizard/display-catalog.d.ts +75 -0
  75. package/dist/wizard/display-catalog.js +207 -0
  76. package/dist/wizard/index.d.ts +7 -0
  77. package/dist/wizard/index.js +10 -0
  78. package/dist/wizard/integration-wizard.d.ts +15 -0
  79. package/dist/wizard/integration-wizard.js +486 -0
  80. package/dist/wizard/prompts.d.ts +32 -0
  81. package/dist/wizard/prompts.js +95 -0
  82. package/dist/wizard/starter-ui.d.ts +1 -0
  83. package/dist/wizard/starter-ui.js +56 -0
  84. package/package.json +18 -5
  85. package/src/cli.ts +87 -0
  86. package/src/engine-index.ts +53 -51
  87. package/src/preview/build-program.ts +810 -734
  88. package/src/preview/host-gfx.ts +30 -3
  89. package/src/preview/host-ui-runtime.ts +4133 -3289
  90. package/src/ui-engine/block-layout.ts +24 -11
  91. package/src/ui-engine/color.ts +77 -2
  92. package/src/ui-engine/compat-report.ts +139 -0
  93. package/src/ui-engine/css-imports.ts +69 -0
  94. package/src/ui-engine/css-parser.ts +64 -15
  95. package/src/ui-engine/default-font.ts +95 -0
  96. package/src/ui-engine/font-assets.ts +545 -525
  97. package/src/ui-engine/html-parser.ts +615 -397
  98. package/src/ui-engine/image-assets.ts +37 -2
  99. package/src/ui-engine/image-decode.ts +244 -0
  100. package/src/ui-engine/inline-parser.ts +1 -1
  101. package/src/ui-engine/layout-engine.ts +61 -3
  102. package/src/ui-engine/model.ts +1317 -1230
  103. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  104. package/src/ui-engine/runtime-header/canvas-helpers.ts +5 -2
  105. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  106. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  107. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  108. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  109. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  110. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  111. package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1402
  112. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  113. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  114. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  115. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  116. package/src/ui-engine/runtime-header/structs.ts +234 -222
  117. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  118. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  119. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  120. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  121. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +79 -23
  122. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  123. package/src/ui-engine/shadcn-kit.ts +503 -0
  124. package/src/ui-engine/style-resolver.ts +471 -416
  125. package/src/ui-engine/transpile-ui.ts +12 -5
  126. package/src/ui-engine/ua-stylesheet.ts +137 -31
  127. package/src/ui-engine/ui-lowering.ts +486 -452
  128. package/src/ui-engine/ui-registry.ts +556 -524
  129. package/src/ui-engine/yoga-layout.ts +307 -309
  130. package/src/wizard/config-writer.ts +404 -0
  131. package/src/wizard/display-catalog.ts +273 -0
  132. package/src/wizard/index.ts +38 -0
  133. package/src/wizard/integration-wizard.ts +619 -0
  134. package/src/wizard/prompts.ts +145 -0
  135. package/src/wizard/starter-ui.ts +58 -0
@@ -1,734 +1,810 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import ts from "typescript";
4
- import type { DisplayProfile } from "@typecad/cuttlefish/api/shared";
5
- import { effectiveDisplaySize, resolveDisplayProfile } from "@typecad/cuttlefish/api/shared";
6
- import { ResolvedCuttlefishConfig } from "@typecad/cuttlefish/config-loader";
7
- import { parseCss, parseFontFaces, parseKeyframes } from "../ui-engine/css-parser.js";
8
- import { extractStyleBlocks, parseHtmlWithKeyboards } from "../ui-engine/html-parser.js";
9
- import { splitUiFile } from "../ui-engine/ui-file-splitter.js";
10
- import { buildUIFontAssets } from "../ui-engine/font-assets.js";
11
- import { loadImageAssets } from "../ui-engine/image-assets.js";
12
- import { buildKeyframeSets } from "../ui-engine/keyframes.js";
13
- import { measure, measureWithFonts, type Box } from "../ui-engine/layout-engine.js";
14
- import { lowerUIToModel } from "../ui-engine/model.js";
15
- import { selectEngine } from "../ui-engine/select-engine.js";
16
- import { resolveStyles, type StyledNode } from "../ui-engine/style-resolver.js";
17
- import { getThemeClass, setThemeClass } from "@typecad/cuttlefish/stores/theme-store";
18
- import type {
19
- PreviewBindingSpec,
20
- PreviewCallbackSpec,
21
- PreviewDiagnostic,
22
- PreviewInitialAssignment,
23
- PreviewIntervalSpec,
24
- PreviewListBindingSpec,
25
- PreviewPinControlSpec,
26
- PreviewCanvasBindingSpec,
27
- PreviewModuleVarSpec,
28
- PreviewSnapshot,
29
- } from "./types.js";
30
-
31
- export interface BuildPreviewSnapshotOptions {
32
- config: ResolvedCuttlefishConfig;
33
- projectRoot: string;
34
- }
35
-
36
- interface UIModuleImport {
37
- treeName: string;
38
- htmlPath: string;
39
- }
40
-
41
- function nodeIndexById(programNodes: Array<{ id?: string }>, id: string): number | undefined {
42
- const found = programNodes.find((node) => node.id === id);
43
- return found?.id ? programNodes.indexOf(found) : undefined;
44
- }
45
-
46
- /** Convert a `{expr}` interpolation text into a TS template-literal expression
47
- * string the preview can evaluate. E.g. `taps: {count}` → `` `taps: ${count}` ``.
48
- * Mirrors lowerInterpolationText's scanning but produces a JS expression rather
49
- * than a snprintf format string. Returns undefined when there's no interpolation.
50
- *
51
- * Each interpolation is wrapped in a booleannumber coercion because hardware
52
- * lowers HTML `{expr}` interpolations via snprintf("%d") (numericFormat default;
53
- * see lowerInterpolationText), so a bool prints as 1/0 on the device. JS
54
- * `${true}` would otherwise stringify as "true". The coercion is a no-op for
55
- * numbers and strings (Number("x") is NaN, but the HTML {expr} path is
56
- * documented numeric-only string-signal interpolation belongs to the
57
- * ui.bind path, which has type information). */
58
- function interpolationToExpression(raw: string): string | undefined {
59
- const parts: string[] = [];
60
- let hasInterp = false;
61
- let i = 0;
62
- while (i < raw.length) {
63
- const ch = raw[i];
64
- if (ch === "{") {
65
- let depth = 1;
66
- let j = i + 1;
67
- while (j < raw.length && depth > 0) {
68
- if (raw[j] === "{") depth++;
69
- else if (raw[j] === "}") depth--;
70
- if (depth === 0) break;
71
- j++;
72
- }
73
- const content = raw.slice(i + 1, j);
74
- if (depth === 0 && content.length > 0 && !content.includes("{")) {
75
- hasInterp = true;
76
- // Coerce via Number(): HTML {expr} interpolations lower to snprintf
77
- // "%d" on hardware (numericFormat default), so bool→1/0 and the numeric
78
- // value passes through unchanged. Number(5)===5, Number(true)===1.
79
- parts.push("${Number(" + content.trim() + ")}");
80
- i = j + 1;
81
- continue;
82
- }
83
- }
84
- // Literal text escape backticks and ${ in the literal parts.
85
- parts.push(ch === "`" ? "\\`" : (ch === "$" && raw[i + 1] === "{") ? "\\${" : ch);
86
- i++;
87
- }
88
- if (!hasInterp) return undefined;
89
- return "`" + parts.join("") + "`";
90
- }
91
-
92
- /** Walk styled trees for {expr} interpolation nodes and synthesize preview text
93
- * bindings (mirroring the runtime's auto-wire synthesis). Each produces a
94
- * PreviewBindingSpec whose expression is a template-literal the preview evals. */
95
- function collectInterpolationBindings(
96
- trees: StyledNode[],
97
- programNodes: Array<{ id?: string }>,
98
- ): PreviewBindingSpec[] {
99
- const out: PreviewBindingSpec[] = [];
100
- const walk = (node: StyledNode): void => {
101
- if (node.hasInterpolation && node.text) {
102
- const expression = interpolationToExpression(node.text);
103
- if (expression) {
104
- // Resolve nodeIndex by id; interpolation nodes may lack an id (when the
105
- // text references a signal directly), but the demo keeps the id for CSS.
106
- const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
107
- if (nodeIndex !== undefined) {
108
- out.push({ nodeId: node.id ?? `__interp_${nodeIndex}`, nodeIndex, property: "text", expression });
109
- }
110
- }
111
- }
112
- node.children?.forEach(walk);
113
- };
114
- trees.forEach(walk);
115
- return out;
116
- }
117
-
118
- /** Walk styled trees for on:* declarative event handlers and synthesize preview
119
- * callback specs. Each on:click="saveSettings" becomes the named function's
120
- * body when the source is available, so preview callbacks execute the same
121
- * author code the device emits as a standalone function. */
122
- function collectEventCallbacks(
123
- trees: StyledNode[],
124
- programNodes: Array<{ id?: string }>,
125
- namedFunctionBodies: Map<string, string>,
126
- ): PreviewCallbackSpec[] {
127
- const out: PreviewCallbackSpec[] = [];
128
- const walk = (node: StyledNode): void => {
129
- if (node.events) {
130
- const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
131
- if (nodeIndex !== undefined) {
132
- for (const kind of ["click", "hold", "release", "change"] as const) {
133
- const fn = node.events[kind];
134
- if (fn) {
135
- out.push({ nodeId: node.id ?? `__event_${nodeIndex}`, nodeIndex, kind, body: namedFunctionBodies.get(fn) ?? `${fn}()` });
136
- }
137
- }
138
- }
139
- }
140
- node.children?.forEach(walk);
141
- };
142
- trees.forEach(walk);
143
- return out;
144
- }
145
-
146
- /** Walk styled trees for bind:* declarative two-way bindings. Returns the READ
147
- * half (signal → node) as preview bindings AND the WRITE half (node → signal)
148
- * as preview callbacks. bind:text write-back fires on the keyboard-commit
149
- * "change" dispatch (keyboardClose → dispatch("change", target)); the callback
150
- * body reads the committed text via screen.<id>.text and calls signal.set.
151
- * bind:value write-back fires on the check "click" or range "rangechange"
152
- * event — matching the runtime kinds in ui-element-auto-wire.ts:136 so the
153
- * preview dispatches them on the same gestures the device does. */
154
- function collectBindBindings(
155
- trees: StyledNode[],
156
- programNodes: Array<{ id?: string }>,
157
- ): { bindings: PreviewBindingSpec[]; callbacks: PreviewCallbackSpec[] } {
158
- const bindings: PreviewBindingSpec[] = [];
159
- const callbacks: PreviewCallbackSpec[] = [];
160
- const walk = (node: StyledNode): void => {
161
- if (node.bind) {
162
- const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
163
- if (nodeIndex !== undefined && node.id) {
164
- if (node.bind.text) {
165
- // Read: signal input text.
166
- bindings.push({ nodeId: node.id, nodeIndex, property: "text", expression: node.bind.text });
167
- // Write: keyboard commit → signal.set(committed text).
168
- callbacks.push({ nodeId: node.id, nodeIndex, kind: "change", body: `${node.bind.text}.set(screen.${node.id}.text)` });
169
- }
170
- if (node.bind.value) {
171
- // Read: signal → node value.
172
- bindings.push({ nodeId: node.id, nodeIndex, property: "value", expression: node.bind.value });
173
- // Write: range drag (rangechange) / check toggle (click) → signal.set(value).
174
- // Kinds must match the runtime so the preview fires on the same gesture.
175
- const valueKind = node.tag === "check" ? "click" : "rangechange";
176
- callbacks.push({ nodeId: node.id, nodeIndex, kind: valueKind, body: `${node.bind.value}.set(screen.${node.id}.value)` });
177
- }
178
- }
179
- }
180
- node.children?.forEach(walk);
181
- };
182
- trees.forEach(walk);
183
- return { bindings, callbacks };
184
- }
185
-
186
- async function loadProfileRegistry(frameworkPackage: string | undefined): Promise<Map<string, DisplayProfile>> {
187
- const registry = new Map<string, DisplayProfile>();
188
- if (!frameworkPackage) return registry;
189
- const profileMod = await import(frameworkPackage + "/displays/ili9341-spi").catch(() => null);
190
- if (profileMod?.BUILT_IN_PROFILES) {
191
- for (const [key, value] of Object.entries(profileMod.BUILT_IN_PROFILES)) {
192
- registry.set(key, value as DisplayProfile);
193
- }
194
- }
195
- return registry;
196
- }
197
-
198
- function loadFont(projectRoot: string, diagnostics: PreviewDiagnostic[]): number[] {
199
- const fontPath = path.join(projectRoot, "lib", "Adafruit_GFX_Library", "glcdfont.c");
200
- if (!fs.existsSync(fontPath)) {
201
- diagnostics.push({
202
- severity: "warning",
203
- message: `Default GFX font not found at ${fontPath}; text pixels will be blank.`,
204
- });
205
- return Array.from(new Uint8Array(1280));
206
- }
207
- const source = fs.readFileSync(fontPath, "utf-8");
208
- const match = /font\[\]\s+PROGMEM\s*=\s*\{([\s\S]*?)\};/.exec(source);
209
- const body = match?.[1] ?? source;
210
- const bytes = [...body.matchAll(/0x([0-9a-fA-F]{1,2})/g)].map((m) => parseInt(m[1], 16));
211
- if (bytes.length < 1280) {
212
- diagnostics.push({
213
- severity: "warning",
214
- message: `Parsed ${bytes.length} font bytes from ${fontPath}; expected 1280.`,
215
- });
216
- }
217
- return bytes.slice(0, 1280);
218
- }
219
-
220
- function callbackBodyText(cb: ts.Expression | undefined, source: ts.SourceFile): string {
221
- if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return "";
222
- if (ts.isExpression(cb.body)) return `${cb.body.getText(source)};`;
223
- return cb.body.statements.map((statement) => statement.getText(source)).join("\n");
224
- }
225
-
226
- function collectNamedFunctionBodies(source: ts.SourceFile): Map<string, string> {
227
- const bodies = new Map<string, string>();
228
- for (const statement of source.statements) {
229
- if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body) continue;
230
- bodies.set(statement.name.text, statement.body.statements.map((s) => s.getText(source)).join("\n"));
231
- }
232
- return bodies;
233
- }
234
-
235
- function callbackExpressionText(cb: ts.Expression | undefined, source: ts.SourceFile): string | undefined {
236
- if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return undefined;
237
- if (ts.isExpression(cb.body)) return cb.body.getText(source);
238
- for (const statement of cb.body.statements) {
239
- if (ts.isReturnStatement(statement) && statement.expression) {
240
- return statement.expression.getText(source);
241
- }
242
- }
243
- return undefined;
244
- }
245
-
246
- function callbackFirstParamName(cb: ts.Expression | undefined): string | undefined {
247
- if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return undefined;
248
- const param = cb.parameters[0];
249
- if (!param || !ts.isIdentifier(param.name)) return undefined;
250
- return param.name.text;
251
- }
252
-
253
- function numericArgText(arg: ts.Expression | undefined, source: ts.SourceFile, fallback: string): string {
254
- if (!arg) return fallback;
255
- if (ts.isNumericLiteral(arg)) return arg.text;
256
- if (ts.isIdentifier(arg)) return arg.text;
257
- return arg.getText(source);
258
- }
259
-
260
- function readTreeElement(expr: ts.Expression): { treeName: string; elemId: string } | undefined {
261
- if (!ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.expression)) return undefined;
262
- return { treeName: expr.expression.text, elemId: expr.name.text };
263
- }
264
-
265
- function readTreeElementMethod(call: ts.CallExpression): { treeName: string; elemId: string; method: string } | undefined {
266
- if (!ts.isPropertyAccessExpression(call.expression)) return undefined;
267
- const method = call.expression.name.text;
268
- const receiver = call.expression.expression;
269
- const element = readTreeElement(receiver);
270
- return element ? { ...element, method } : undefined;
271
- }
272
-
273
- function findUIModuleImports(
274
- source: ts.SourceFile,
275
- entryDir: string,
276
- diagnostics: PreviewDiagnostic[],
277
- ): UIModuleImport[] {
278
- const imports: UIModuleImport[] = [];
279
- for (const statement of source.statements) {
280
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
281
- const specifier = statement.moduleSpecifier.text;
282
- if (specifier.endsWith(".ui.html")) {
283
- if (statement.importClause?.name) {
284
- imports.push({
285
- treeName: statement.importClause.name.text,
286
- htmlPath: path.resolve(entryDir, specifier),
287
- });
288
- }
289
- const named = statement.importClause?.namedBindings;
290
- if (named && ts.isNamedImports(named)) {
291
- for (const element of named.elements) {
292
- imports.push({
293
- treeName: element.name.text,
294
- htmlPath: path.resolve(entryDir, specifier),
295
- });
296
- }
297
- }
298
- } else if (specifier.includes("/lib/") || specifier.startsWith("../lib/") || specifier.startsWith("./lib/")) {
299
- diagnostics.push({
300
- severity: "info",
301
- message: `Preview is stubbing embedded-only import "${specifier}".`,
302
- });
303
- }
304
- }
305
- return imports;
306
- }
307
-
308
- function themeCssPath(displayThemeCss: string | undefined, htmlPath: string, configDir: string): string {
309
- if (!displayThemeCss) return htmlPath.replace(/\.ui\.html$/, ".ui.css");
310
- return path.isAbsolute(displayThemeCss)
311
- ? displayThemeCss
312
- : path.resolve(configDir, displayThemeCss);
313
- }
314
-
315
- function collectHrefCallbacks(
316
- screens: StyledNode[],
317
- programNodes: Array<{ id?: string }>,
318
- ): PreviewCallbackSpec[] {
319
- const screenIds = new Map<string, number>();
320
- screens.forEach((screen, index) => {
321
- if (screen.id) screenIds.set(screen.id, index);
322
- });
323
-
324
- const callbacks: PreviewCallbackSpec[] = [];
325
-
326
- // Walk in document order with a running index, matching how lowerUIToModel
327
- // assigns node indices (flatten(): index = cursor.i++ for every node, depth-
328
- // first). The cursor advances by each subtree's size, so a child's index is
329
- // always (parent + 1 + sum of earlier siblings' subtree sizes). This lets us
330
- // resolve id-less <a href> links the same way the device does
331
- // (ui-element-auto-wire.ts wires any node with href, id or not).
332
- const visit = (node: StyledNode, nodeIndex: number): number => {
333
- if (node.href) {
334
- const target = node.href.startsWith("#") ? node.href.slice(1) : node.href;
335
- const targetScreen = screenIds.get(target);
336
- if (targetScreen !== undefined && nodeIndex < programNodes.length) {
337
- callbacks.push({
338
- nodeId: node.id ?? `__ui_link${nodeIndex}_nav`,
339
- nodeIndex,
340
- kind: "click",
341
- body: `ui.navigate(${targetScreen});`,
342
- });
343
- }
344
- } else if (node.runs?.some(r => r.href)) {
345
- // Run-bearing link node (inline <a href> inside a paragraph). The link
346
- // targets are resolved into the model at lower time; the preview tap path
347
- // calls richLinkHit to pick the target. Register a no-op click callback so
348
- // the node is hit-testable.
349
- if (nodeIndex < programNodes.length) {
350
- callbacks.push({
351
- nodeId: node.id ?? `__ui_richlink${nodeIndex}_nav`,
352
- nodeIndex,
353
- kind: "click",
354
- body: `/* rich-text link; target resolved by richLinkHit */`,
355
- });
356
- }
357
- }
358
- let nextIndex = nodeIndex + 1;
359
- for (const child of node.children) nextIndex = visit(child, nextIndex);
360
- return nextIndex;
361
- };
362
-
363
- // Each screen is a contiguous subtree in the flattened node table; find where
364
- // screen i starts (first node with that screenId) and walk its styled tree.
365
- for (let i = 0; i < screens.length; i++) {
366
- const startIndex = programNodes.findIndex((n) => (n as { screenId?: number }).screenId === i);
367
- if (startIndex >= 0) visit(screens[i], startIndex);
368
- }
369
- return callbacks;
370
- }
371
-
372
- function extractAuthorSpecs(
373
- source: ts.SourceFile,
374
- uiImports: UIModuleImport[],
375
- programNodes: Array<{ id?: string; tag?: string; kind?: string; options?: Array<{ text: string; value: string }> }>,
376
- ): {
377
- bindings: PreviewBindingSpec[];
378
- listBindings: PreviewListBindingSpec[];
379
- callbacks: PreviewCallbackSpec[];
380
- initialAssignments: PreviewInitialAssignment[];
381
- intervals: PreviewIntervalSpec[];
382
- pinControls: PreviewPinControlSpec[];
383
- canvasBindings: PreviewCanvasBindingSpec[];
384
- moduleVars: PreviewModuleVarSpec[];
385
- diagnostics: PreviewDiagnostic[];
386
- } {
387
- const diagnostics: PreviewDiagnostic[] = [];
388
- const importedTrees = new Set(uiImports.map((imp) => imp.treeName));
389
- const bindings: PreviewBindingSpec[] = [];
390
- const listBindings: PreviewListBindingSpec[] = [];
391
- const callbacks: PreviewCallbackSpec[] = [];
392
- const initialAssignments: PreviewInitialAssignment[] = [];
393
- const intervals: PreviewIntervalSpec[] = [];
394
- const pinControls: PreviewPinControlSpec[] = [];
395
- const canvasBindings: PreviewCanvasBindingSpec[] = [];
396
- const moduleVars: PreviewModuleVarSpec[] = [];
397
-
398
- const resolveNode = (treeName: string, elemId: string): number | undefined => {
399
- if (!importedTrees.has(treeName)) return undefined;
400
- const idx = nodeIndexById(programNodes, elemId);
401
- if (idx === undefined) {
402
- diagnostics.push({ severity: "warning", message: `Preview could not find UI element "${treeName}.${elemId}".` });
403
- }
404
- return idx;
405
- };
406
-
407
- for (const statement of source.statements) {
408
- // Top-level `let`/`const`/`var` declarations become module-scoped bindings
409
- // the device hoists to C++ globals. Capture each declared name + initializer
410
- // so callback bodies (setInterval, onClick, ui.bind, ...) that reference them
411
- // resolve at preview runtime instead of throwing ReferenceError.
412
- if (ts.isVariableStatement(statement)) {
413
- for (const decl of statement.declarationList.declarations) {
414
- if (ts.isIdentifier(decl.name)) {
415
- moduleVars.push({
416
- name: decl.name.text,
417
- initializer: decl.initializer ? decl.initializer.getText(source) : undefined,
418
- });
419
- }
420
- }
421
- continue;
422
- }
423
- if (ts.isExpressionStatement(statement) && ts.isBinaryExpression(statement.expression)) {
424
- const expr = statement.expression;
425
- if (
426
- expr.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
427
- ts.isPropertyAccessExpression(expr.left) &&
428
- expr.left.name.text === "value"
429
- ) {
430
- const element = readTreeElement(expr.left.expression);
431
- if (element) {
432
- const nodeIndex = resolveNode(element.treeName, element.elemId);
433
- if (nodeIndex !== undefined) {
434
- initialAssignments.push({
435
- nodeId: element.elemId,
436
- nodeIndex,
437
- expression: expr.right.getText(source),
438
- });
439
- }
440
- }
441
- }
442
- continue;
443
- }
444
-
445
- if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) continue;
446
- const call = statement.expression;
447
-
448
- if (ts.isIdentifier(call.expression) && call.expression.text === "setInterval") {
449
- const delay = call.arguments[1] && ts.isNumericLiteral(call.arguments[1])
450
- ? Number(call.arguments[1].text)
451
- : 0;
452
- intervals.push({ body: callbackBodyText(call.arguments[0], source), delayMs: delay });
453
- continue;
454
- }
455
-
456
- if (ts.isPropertyAccessExpression(call.expression) && ts.isIdentifier(call.expression.expression)) {
457
- const objectName = call.expression.expression.text;
458
- const method = call.expression.name.text;
459
- if (objectName === "ui" && method === "bind") {
460
- const elementArg = call.arguments[0];
461
- const propArg = call.arguments[1];
462
- const fnArg = call.arguments[2];
463
- const element = elementArg ? readTreeElement(elementArg) : undefined;
464
- const property = propArg && ts.isStringLiteral(propArg) ? propArg.text : "text";
465
- const expression = callbackExpressionText(fnArg, source);
466
- if (element && expression) {
467
- const nodeIndex = resolveNode(element.treeName, element.elemId);
468
- if (nodeIndex !== undefined) {
469
- bindings.push({ nodeId: element.elemId, nodeIndex, property, expression });
470
- }
471
- }
472
- continue;
473
- }
474
- if (objectName === "ui" && method === "bindList") {
475
- const elementArg = call.arguments[0];
476
- const countArg = call.arguments[1];
477
- const itemArg = call.arguments[2];
478
- const tapArg = call.arguments[3];
479
- const element = elementArg ? readTreeElement(elementArg) : undefined;
480
- const countExpression = callbackExpressionText(countArg, source);
481
- const itemExpression = callbackExpressionText(itemArg, source);
482
- if (element && countExpression && itemExpression) {
483
- const nodeIndex = resolveNode(element.treeName, element.elemId);
484
- if (nodeIndex !== undefined) {
485
- listBindings.push({
486
- nodeId: element.elemId,
487
- nodeIndex,
488
- countExpression,
489
- itemExpression,
490
- itemParam: callbackFirstParamName(itemArg),
491
- tapBody: tapArg ? callbackBodyText(tapArg, source) : undefined,
492
- tapParam: callbackFirstParamName(tapArg),
493
- });
494
- }
495
- }
496
- continue;
497
- }
498
- if (objectName === "ui" && method === "bindInput") {
499
- // ui.bindInput(node, cb): cb fires with the input's committed text on
500
- // keyboard close. Collect as a "change" callback (the keyboard-commit
501
- // dispatch kind) and record the cb's first param so the dispatch path
502
- // can bind the text to it as a local. Mirrors resolveBindInputCall,
503
- // which renames the arrow's param to `text` in the lowered C++.
504
- const elementArg = call.arguments[0];
505
- const cbArg = call.arguments[1];
506
- const element = elementArg ? readTreeElement(elementArg) : undefined;
507
- if (element && cbArg && (ts.isArrowFunction(cbArg) || ts.isFunctionExpression(cbArg))) {
508
- const nodeIndex = resolveNode(element.treeName, element.elemId);
509
- if (nodeIndex !== undefined) {
510
- const param = callbackFirstParamName(cbArg);
511
- callbacks.push({
512
- nodeId: element.elemId,
513
- nodeIndex,
514
- kind: "change",
515
- body: callbackBodyText(cbArg, source),
516
- // The runtime renames the param to `text`; record the author's
517
- // name so we can bind the committed text under whichever name the
518
- // body actually references. If absent, default to `text`.
519
- param: param ?? "text",
520
- });
521
- }
522
- }
523
- continue;
524
- }
525
- if (objectName === "ui" && method === "drawCanvas") {
526
- const elementArg = call.arguments[0];
527
- const cbArg = call.arguments[1];
528
- const element = elementArg ? readTreeElement(elementArg) : undefined;
529
- if (element && cbArg && (ts.isArrowFunction(cbArg) || ts.isFunctionExpression(cbArg))) {
530
- const nodeIndex = resolveNode(element.treeName, element.elemId);
531
- if (nodeIndex !== undefined) {
532
- canvasBindings.push({
533
- nodeId: element.elemId,
534
- nodeIndex,
535
- drawBody: callbackBodyText(cbArg, source),
536
- });
537
- }
538
- }
539
- continue;
540
- }
541
- if (objectName === "ui" && method === "watchPin") {
542
- const pin = numericArgText(call.arguments[0], source, "0");
543
- pinControls.push({
544
- label: `ui.watchPin(${pin})`,
545
- kind: "watch",
546
- pin,
547
- body: callbackBodyText(call.arguments[1], source),
548
- });
549
- }
550
- }
551
-
552
- const elementMethod = readTreeElementMethod(call);
553
- if (!elementMethod) continue;
554
- const nodeIndex = resolveNode(elementMethod.treeName, elementMethod.elemId);
555
- if (nodeIndex === undefined) continue;
556
-
557
- if (elementMethod.method === "onClick" || elementMethod.method === "onHold" || elementMethod.method === "onRelease") {
558
- callbacks.push({
559
- nodeId: elementMethod.elemId,
560
- nodeIndex,
561
- kind: elementMethod.method === "onClick" ? "click" : elementMethod.method === "onHold" ? "hold" : "release",
562
- body: callbackBodyText(call.arguments[0], source),
563
- });
564
- } else if (elementMethod.method === "onToggle") {
565
- const pin = numericArgText(call.arguments[0], source, "0");
566
- pinControls.push({
567
- label: `${elementMethod.elemId}.onToggle(${pin})`,
568
- kind: "toggle",
569
- pin,
570
- nodeId: elementMethod.elemId,
571
- nodeIndex,
572
- body: callbackBodyText(call.arguments[1], source),
573
- });
574
- } else if (elementMethod.method === "onChange") {
575
- const nodeInfo = programNodes[nodeIndex];
576
- if (nodeInfo?.tag === "input" || nodeInfo?.kind === "input") {
577
- callbacks.push({
578
- nodeId: elementMethod.elemId,
579
- nodeIndex,
580
- kind: "change",
581
- body: callbackBodyText(call.arguments[0], source),
582
- });
583
- continue;
584
- }
585
- const pin = numericArgText(call.arguments[0], source, "0");
586
- const count = call.arguments[1] && ts.isNumericLiteral(call.arguments[1])
587
- ? Number(call.arguments[1].text)
588
- : Math.max(programNodes[nodeIndex].options?.length ?? 0, 2);
589
- pinControls.push({
590
- label: `${elementMethod.elemId}.onChange(${pin})`,
591
- kind: "change",
592
- pin,
593
- nodeId: elementMethod.elemId,
594
- nodeIndex,
595
- optionCount: count,
596
- body: callbackBodyText(call.arguments[2], source),
597
- });
598
- } else if (elementMethod.method === "onPress" || elementMethod.method === "onRelease") {
599
- const pin = numericArgText(call.arguments[0], source, "0");
600
- pinControls.push({
601
- label: `${elementMethod.elemId}.${elementMethod.method}(${pin})`,
602
- kind: elementMethod.method === "onPress" ? "press" : "release",
603
- pin,
604
- nodeId: elementMethod.elemId,
605
- nodeIndex,
606
- });
607
- }
608
- }
609
-
610
- return { bindings, listBindings, callbacks, initialAssignments, intervals, pinControls, canvasBindings, moduleVars, diagnostics };
611
- }
612
-
613
- export async function buildPreviewSnapshot(options: BuildPreviewSnapshotOptions): Promise<PreviewSnapshot> {
614
- const { config, projectRoot } = options;
615
- const diagnostics: PreviewDiagnostic[] = [];
616
- const configDir = path.dirname(config.configPath);
617
- if (!config.entry) {
618
- throw new Error(`cuttlefish preview requires an entry field in ${config.configPath}`);
619
- }
620
-
621
- const entryFile = path.resolve(configDir, config.entry);
622
- const entrySource = fs.readFileSync(entryFile, "utf-8");
623
- const entryDir = path.dirname(entryFile);
624
- const entryExt = path.extname(entryFile).toLowerCase();
625
-
626
- let sourceFile: ts.SourceFile;
627
- let htmlText: string;
628
- let cssText: string;
629
- let htmlFilePath: string; // the effective .ui.html path (real or synthetic)
630
- let uiImports: UIModuleImport[];
631
-
632
- if (entryExt === ".ui") {
633
- // .ui single-file component: split into script/style/template.
634
- const parts = splitUiFile(entrySource);
635
- const baseName = path.basename(entryFile, ".ui");
636
- htmlFilePath = entryFile + ".html"; // synthetic .ui.html path
637
- // Inject the implicit screen import so the script's `screen` reference
638
- // resolves during preview-expression evaluation.
639
- const scriptWithImport = `import { screen } from './${baseName}.ui.html';\n` + parts.script;
640
- sourceFile = ts.createSourceFile(entryFile, scriptWithImport, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
641
- htmlText = parts.html;
642
- cssText = parts.style;
643
- uiImports = [{ treeName: "screen", htmlPath: htmlFilePath }];
644
- } else {
645
- sourceFile = ts.createSourceFile(entryFile, entrySource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
646
- uiImports = findUIModuleImports(sourceFile, entryDir, diagnostics);
647
- const firstImport = uiImports[0];
648
- if (!firstImport) {
649
- throw new Error(`Preview could not find a .ui.html import in ${entryFile}`);
650
- }
651
- if (uiImports.length > 1) {
652
- diagnostics.push({
653
- severity: "warning",
654
- message: "Preview v1 renders the first imported UI tree only.",
655
- });
656
- }
657
- htmlFilePath = firstImport.htmlPath;
658
- htmlText = fs.readFileSync(firstImport.htmlPath, "utf-8");
659
- const cssPath = themeCssPath(config.display?.themeCss, firstImport.htmlPath, configDir);
660
- cssText = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf-8") : "";
661
- }
662
-
663
- const registry = await loadProfileRegistry(config.framework);
664
- const resolved = resolveDisplayProfile(config.display ?? { profile: "ili9341-spi" }, registry);
665
- const profile = resolved.profile;
666
- const displaySize = effectiveDisplaySize(profile);
667
- const parsedHtml = parseHtmlWithKeyboards(htmlText);
668
- const fullCss = cssText + "\n" + extractStyleBlocks(htmlText);
669
- const cssRules = (() => {
670
- const previousThemeClass = getThemeClass();
671
- setThemeClass(config.display?.themeClass ?? null);
672
- try {
673
- return parseCss(fullCss);
674
- } finally {
675
- setThemeClass(previousThemeClass);
676
- }
677
- })();
678
- const fontFaces = parseFontFaces(fullCss);
679
- const rawKeyframes = parseKeyframes(fullCss);
680
- const styled = resolveStyles(parsedHtml.tree, cssRules);
681
- const allStyledScreens = parsedHtml.screens.map((screen) => resolveStyles(screen, cssRules));
682
- const fontRoot: StyledNode = { tag: "screen", classes: [], style: {}, children: allStyledScreens };
683
- const fontAssets = buildUIFontAssets(fontRoot, fontFaces, path.dirname(htmlFilePath));
684
- const viewport: Box = { x: 0, y: 0, w: displaySize.width, h: displaySize.height };
685
- const boxes = allStyledScreens.flatMap((screen) => {
686
- const engine = selectEngine(screen);
687
- return engine.arrange(screen, viewport, measureWithFonts(fontAssets));
688
- });
689
- const keyframeSets = buildKeyframeSets(rawKeyframes, profile.colorFormat);
690
- const imageAssets = loadImageAssets(allStyledScreens.length > 0 ? allStyledScreens : [styled], path.dirname(htmlFilePath));
691
- const program = lowerUIToModel(styled, boxes, profile.colorFormat, profile, fontAssets, allStyledScreens, imageAssets.nodeIdToAssetIndex, keyframeSets, imageAssets.assets);
692
- const specs = extractAuthorSpecs(sourceFile, uiImports, program.nodes);
693
- const hrefCallbacks = collectHrefCallbacks(allStyledScreens, program.nodes);
694
- // {expr} interpolation bindings synthesized from the HTML (mirrors the runtime's
695
- // auto-wire synthesis). Authors write `taps: {count}` in markup; this collects
696
- // them so the preview evaluates the template-literal expression each frame.
697
- const interpolationBindings = collectInterpolationBindings(
698
- allStyledScreens.length > 0 ? allStyledScreens : [styled],
699
- program.nodes,
700
- );
701
- // on:* declarative event handlers from markup → preview callbacks (named fn).
702
- const eventCallbacks = collectEventCallbacks(
703
- allStyledScreens.length > 0 ? allStyledScreens : [styled],
704
- program.nodes,
705
- collectNamedFunctionBodies(sourceFile),
706
- );
707
- // bind:* declarative two-way bindings: read (signal → node) + write-back
708
- // (node signal via change callbacks).
709
- const bindResult = collectBindBindings(
710
- allStyledScreens.length > 0 ? allStyledScreens : [styled],
711
- program.nodes,
712
- );
713
-
714
- return {
715
- projectRoot,
716
- entryFile,
717
- htmlFile: htmlFilePath,
718
- profileName: typeof config.display?.profile === "string" ? config.display.profile : undefined,
719
- program,
720
- keyboardTemplates: parsedHtml.keyboards,
721
- cssRules,
722
- uiTreeNames: [...new Set(uiImports.map((imp) => imp.treeName))],
723
- font: loadFont(projectRoot, diagnostics),
724
- bindings: [...specs.bindings, ...interpolationBindings, ...bindResult.bindings],
725
- listBindings: specs.listBindings,
726
- callbacks: [...hrefCallbacks, ...specs.callbacks, ...eventCallbacks, ...bindResult.callbacks],
727
- initialAssignments: specs.initialAssignments,
728
- intervals: specs.intervals,
729
- pinControls: specs.pinControls,
730
- canvasBindings: specs.canvasBindings,
731
- moduleVars: specs.moduleVars,
732
- diagnostics: [...diagnostics, ...specs.diagnostics],
733
- };
734
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import type { DisplayProfile } from "@typecad/cuttlefish/api/shared";
5
+ import { effectiveDisplaySize, resolveDisplayProfile, GLCDFONT_BYTES } from "@typecad/cuttlefish/api/shared";
6
+ import { ResolvedCuttlefishConfig } from "@typecad/cuttlefish/config-loader";
7
+ import { parseCss, parseFontFaces, parseKeyframes } from "../ui-engine/css-parser.js";
8
+ import { expandCssImports } from "../ui-engine/css-imports.js";
9
+ import { injectDefaultFontFaces } from "../ui-engine/default-font.js";
10
+ import { extractStyleBlocks, parseHtmlWithKeyboards } from "../ui-engine/html-parser.js";
11
+ import { splitUiFile } from "../ui-engine/ui-file-splitter.js";
12
+ import { buildUIFontAssets } from "../ui-engine/font-assets.js";
13
+ import { loadImageAssets, applyDecodedImageSizes } from "../ui-engine/image-assets.js";
14
+ import { warmUpImageDecoding } from "../ui-engine/image-decode.js";
15
+ import { SHADCN_KIT_CSS } from "../ui-engine/shadcn-kit.js";
16
+ import { buildKeyframeSets } from "../ui-engine/keyframes.js";
17
+ import { measure, measureWithFonts, type Box } from "../ui-engine/layout-engine.js";
18
+ import { lowerUIToModel } from "../ui-engine/model.js";
19
+ import { selectEngine } from "../ui-engine/select-engine.js";
20
+ import { resolveStyles, type StyledNode } from "../ui-engine/style-resolver.js";
21
+ import { getThemeClass, setThemeClass } from "@typecad/cuttlefish/stores/theme-store";
22
+ import type {
23
+ PreviewBindingSpec,
24
+ PreviewCallbackSpec,
25
+ PreviewDiagnostic,
26
+ PreviewInitialAssignment,
27
+ PreviewIntervalSpec,
28
+ PreviewListBindingSpec,
29
+ PreviewPinControlSpec,
30
+ PreviewCanvasBindingSpec,
31
+ PreviewModuleVarSpec,
32
+ PreviewSnapshot,
33
+ } from "./types.js";
34
+
35
+ export interface BuildPreviewSnapshotOptions {
36
+ config: ResolvedCuttlefishConfig;
37
+ projectRoot: string;
38
+ }
39
+
40
+ interface UIModuleImport {
41
+ treeName: string;
42
+ htmlPath: string;
43
+ }
44
+
45
+ function nodeIndexById(programNodes: Array<{ id?: string }>, id: string): number | undefined {
46
+ const found = programNodes.find((node) => node.id === id);
47
+ return found?.id ? programNodes.indexOf(found) : undefined;
48
+ }
49
+
50
+ /** Convert a `{expr}` interpolation text into a TS template-literal expression
51
+ * string the preview can evaluate. E.g. `taps: {count}` `` `taps: ${count}` ``.
52
+ * Mirrors lowerInterpolationText's scanning but produces a JS expression rather
53
+ * than a snprintf format string. Returns undefined when there's no interpolation.
54
+ *
55
+ * Each interpolation is wrapped in a boolean→number coercion because hardware
56
+ * lowers HTML `{expr}` interpolations via snprintf("%d") (numericFormat default;
57
+ * see lowerInterpolationText), so a bool prints as 1/0 on the device. JS
58
+ * `${true}` would otherwise stringify as "true". The coercion is a no-op for
59
+ * numbers and strings (Number("x") is NaN, but the HTML {expr} path is
60
+ * documented numeric-only string-signal interpolation belongs to the
61
+ * ui.bind path, which has type information). */
62
+ function interpolationToExpression(raw: string): string | undefined {
63
+ const parts: string[] = [];
64
+ let hasInterp = false;
65
+ let i = 0;
66
+ while (i < raw.length) {
67
+ const ch = raw[i];
68
+ if (ch === "{") {
69
+ let depth = 1;
70
+ let j = i + 1;
71
+ while (j < raw.length && depth > 0) {
72
+ if (raw[j] === "{") depth++;
73
+ else if (raw[j] === "}") depth--;
74
+ if (depth === 0) break;
75
+ j++;
76
+ }
77
+ const content = raw.slice(i + 1, j);
78
+ if (depth === 0 && content.length > 0 && !content.includes("{")) {
79
+ hasInterp = true;
80
+ // Coerce via Number(): HTML {expr} interpolations lower to snprintf
81
+ // "%d" on hardware (numericFormat default), so bool→1/0 and the numeric
82
+ // value passes through unchanged. Number(5)===5, Number(true)===1.
83
+ parts.push("${Number(" + content.trim() + ")}");
84
+ i = j + 1;
85
+ continue;
86
+ }
87
+ }
88
+ // Literal text — escape backticks and ${ in the literal parts.
89
+ parts.push(ch === "`" ? "\\`" : (ch === "$" && raw[i + 1] === "{") ? "\\${" : ch);
90
+ i++;
91
+ }
92
+ if (!hasInterp) return undefined;
93
+ return "`" + parts.join("") + "`";
94
+ }
95
+
96
+ /** Warn about interactive nodes the preview cannot wire because they lack an
97
+ * id: on:* handlers, bind:* bindings, and {expr} interpolations all resolve
98
+ * their node by id in the preview (the device build auto-wires them without
99
+ * one). Without this the element renders but never reacts. */
100
+ function warnUnnamedInteractiveNodes(trees: StyledNode[], diagnostics: PreviewDiagnostic[]): void {
101
+ const walk = (node: StyledNode): void => {
102
+ if (!node.id) {
103
+ const features: string[] = [];
104
+ if (node.events && Object.keys(node.events).length > 0) features.push(`on:${Object.keys(node.events).join("/on:")}`);
105
+ if (node.bind && Object.keys(node.bind).length > 0) features.push(`bind:${Object.keys(node.bind).join("/bind:")}`);
106
+ if (node.hasInterpolation) features.push("{expr} interpolation");
107
+ // Images key their loaded asset by node id too (image-assets.ts skips
108
+ // id-less <img src> silently) warn so empty frames are explainable.
109
+ if (node.tag === "img" && (node as unknown as { src?: string }).src) features.push("img src");
110
+ if (features.length > 0) {
111
+ diagnostics.push({
112
+ severity: "warning",
113
+ message: `<${node.tag}> uses ${features.join(", ")} but has no id — the preview loads/wires these by id, so the element renders empty or inert. Add an id, e.g. <${node.tag} id="${node.tag}1"> (the device build behaves the same).`,
114
+ });
115
+ }
116
+ }
117
+ node.children?.forEach(walk);
118
+ };
119
+ trees.forEach(walk);
120
+ }
121
+
122
+ /** Walk styled trees for {expr} interpolation nodes and synthesize preview text
123
+ * bindings (mirroring the runtime's auto-wire synthesis). Each produces a
124
+ * PreviewBindingSpec whose expression is a template-literal the preview evals. */
125
+ function collectInterpolationBindings(
126
+ trees: StyledNode[],
127
+ programNodes: Array<{ id?: string }>,
128
+ ): PreviewBindingSpec[] {
129
+ const out: PreviewBindingSpec[] = [];
130
+ const walk = (node: StyledNode): void => {
131
+ if (node.hasInterpolation && node.text) {
132
+ const expression = interpolationToExpression(node.text);
133
+ if (expression) {
134
+ // Resolve nodeIndex by id; interpolation nodes may lack an id (when the
135
+ // text references a signal directly), but the demo keeps the id for CSS.
136
+ const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
137
+ if (nodeIndex !== undefined) {
138
+ out.push({ nodeId: node.id ?? `__interp_${nodeIndex}`, nodeIndex, property: "text", expression });
139
+ }
140
+ }
141
+ }
142
+ node.children?.forEach(walk);
143
+ };
144
+ trees.forEach(walk);
145
+ return out;
146
+ }
147
+
148
+ /** Walk styled trees for on:* declarative event handlers and synthesize preview
149
+ * callback specs. Each on:click="saveSettings" becomes the named function's
150
+ * body when the source is available, so preview callbacks execute the same
151
+ * author code the device emits as a standalone function. */
152
+ function collectEventCallbacks(
153
+ trees: StyledNode[],
154
+ programNodes: Array<{ id?: string }>,
155
+ namedFunctionBodies: Map<string, string>,
156
+ ): PreviewCallbackSpec[] {
157
+ const out: PreviewCallbackSpec[] = [];
158
+ const walk = (node: StyledNode): void => {
159
+ if (node.events) {
160
+ const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
161
+ if (nodeIndex !== undefined) {
162
+ for (const kind of ["click", "hold", "release", "change"] as const) {
163
+ const fn = node.events[kind];
164
+ if (fn) {
165
+ out.push({ nodeId: node.id ?? `__event_${nodeIndex}`, nodeIndex, kind, body: namedFunctionBodies.get(fn) ?? `${fn}()` });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ node.children?.forEach(walk);
171
+ };
172
+ trees.forEach(walk);
173
+ return out;
174
+ }
175
+
176
+ /** Walk styled trees for bind:* declarative two-way bindings. Returns the READ
177
+ * half (signal → node) as preview bindings AND the WRITE half (node → signal)
178
+ * as preview callbacks. bind:text write-back fires on the keyboard-commit
179
+ * "change" dispatch (keyboardClose → dispatch("change", target)); the callback
180
+ * body reads the committed text via screen.<id>.text and calls signal.set.
181
+ * bind:value write-back fires on the check "click" or range "rangechange"
182
+ * event — matching the runtime kinds in ui-element-auto-wire.ts:136 so the
183
+ * preview dispatches them on the same gestures the device does. */
184
+ function collectBindBindings(
185
+ trees: StyledNode[],
186
+ programNodes: Array<{ id?: string }>,
187
+ ): { bindings: PreviewBindingSpec[]; callbacks: PreviewCallbackSpec[] } {
188
+ const bindings: PreviewBindingSpec[] = [];
189
+ const callbacks: PreviewCallbackSpec[] = [];
190
+ const walk = (node: StyledNode): void => {
191
+ if (node.bind) {
192
+ const nodeIndex = node.id ? nodeIndexById(programNodes, node.id) : undefined;
193
+ if (nodeIndex !== undefined && node.id) {
194
+ if (node.bind.text) {
195
+ // Read: signal → input text.
196
+ bindings.push({ nodeId: node.id, nodeIndex, property: "text", expression: node.bind.text });
197
+ // Write: keyboard commit → signal.set(committed text).
198
+ callbacks.push({ nodeId: node.id, nodeIndex, kind: "change", body: `${node.bind.text}.set(screen.${node.id}.text)` });
199
+ }
200
+ if (node.bind.value) {
201
+ // Read: signal → node value.
202
+ bindings.push({ nodeId: node.id, nodeIndex, property: "value", expression: node.bind.value });
203
+ // Write: range drag (rangechange) / check toggle (click) signal.set(value).
204
+ // Kinds must match the runtime so the preview fires on the same gesture.
205
+ const valueKind = node.tag === "check" ? "click" : "rangechange";
206
+ callbacks.push({ nodeId: node.id, nodeIndex, kind: valueKind, body: `${node.bind.value}.set(screen.${node.id}.value)` });
207
+ }
208
+ }
209
+ }
210
+ node.children?.forEach(walk);
211
+ };
212
+ trees.forEach(walk);
213
+ return { bindings, callbacks };
214
+ }
215
+
216
+ async function loadProfileRegistry(frameworkPackage: string | undefined): Promise<Map<string, DisplayProfile>> {
217
+ const registry = new Map<string, DisplayProfile>();
218
+ if (!frameworkPackage) return registry;
219
+ // Every framework package exports its named profiles as BUILT_IN_PROFILES
220
+ // in the shared DisplayProfile shape (Arduino: the displays modules; Zephyr:
221
+ // the display barrel, mapped from its DT-binding descriptors). Try each
222
+ // known entry point — the miss is expected per framework — so this loader
223
+ // stays layout-agnostic and profile names resolve identically here and via
224
+ // the strategy's getProfileRegistry() (which transpile.ts uses).
225
+ for (const modPath of [frameworkPackage + "/displays/ili9341-spi", frameworkPackage + "/display"]) {
226
+ const mod = await import(modPath).catch(() => null);
227
+ const profiles = (mod as { BUILT_IN_PROFILES?: Record<string, DisplayProfile> } | null)?.BUILT_IN_PROFILES;
228
+ if (profiles) {
229
+ for (const [key, value] of Object.entries(profiles)) {
230
+ registry.set(key, value);
231
+ }
232
+ break;
233
+ }
234
+ }
235
+ return registry;
236
+ }
237
+
238
+ /** Load the stock 5x7 GFX font for the preview runtime. A project-local
239
+ * Adafruit_GFX copy wins (byte-identical to what the firmware compiles
240
+ * against); otherwise fall back to the table bundled in
241
+ * @typecad/cuttlefish (api/shared glcdfont.ts) so stock-font text renders
242
+ * without a vendored library. */
243
+ export function loadFont(projectRoot: string, diagnostics: PreviewDiagnostic[]): number[] {
244
+ const fontPath = path.join(projectRoot, "lib", "Adafruit_GFX_Library", "glcdfont.c");
245
+ if (!fs.existsSync(fontPath)) {
246
+ return GLCDFONT_BYTES.slice(0, 1280);
247
+ }
248
+ const source = fs.readFileSync(fontPath, "utf-8");
249
+ const match = /font\[\]\s+PROGMEM\s*=\s*\{([\s\S]*?)\};/.exec(source);
250
+ const body = match?.[1] ?? source;
251
+ const bytes = [...body.matchAll(/0x([0-9a-fA-F]{1,2})/g)].map((m) => parseInt(m[1], 16));
252
+ if (bytes.length < 1280) {
253
+ diagnostics.push({
254
+ severity: "warning",
255
+ message: `Parsed ${bytes.length} font bytes from ${fontPath}; expected 1280.`,
256
+ });
257
+ }
258
+ return bytes.slice(0, 1280);
259
+ }
260
+
261
+ function callbackBodyText(cb: ts.Expression | undefined, source: ts.SourceFile): string {
262
+ if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return "";
263
+ if (ts.isExpression(cb.body)) return `${cb.body.getText(source)};`;
264
+ return cb.body.statements.map((statement) => statement.getText(source)).join("\n");
265
+ }
266
+
267
+ function collectNamedFunctionBodies(source: ts.SourceFile): Map<string, string> {
268
+ const bodies = new Map<string, string>();
269
+ for (const statement of source.statements) {
270
+ if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body) continue;
271
+ bodies.set(statement.name.text, statement.body.statements.map((s) => s.getText(source)).join("\n"));
272
+ }
273
+ return bodies;
274
+ }
275
+
276
+ function callbackExpressionText(cb: ts.Expression | undefined, source: ts.SourceFile): string | undefined {
277
+ if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return undefined;
278
+ if (ts.isExpression(cb.body)) return cb.body.getText(source);
279
+ for (const statement of cb.body.statements) {
280
+ if (ts.isReturnStatement(statement) && statement.expression) {
281
+ return statement.expression.getText(source);
282
+ }
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ function callbackFirstParamName(cb: ts.Expression | undefined): string | undefined {
288
+ if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return undefined;
289
+ const param = cb.parameters[0];
290
+ if (!param || !ts.isIdentifier(param.name)) return undefined;
291
+ return param.name.text;
292
+ }
293
+
294
+ function numericArgText(arg: ts.Expression | undefined, source: ts.SourceFile, fallback: string): string {
295
+ if (!arg) return fallback;
296
+ if (ts.isNumericLiteral(arg)) return arg.text;
297
+ if (ts.isIdentifier(arg)) return arg.text;
298
+ return arg.getText(source);
299
+ }
300
+
301
+ function readTreeElement(expr: ts.Expression): { treeName: string; elemId: string } | undefined {
302
+ if (!ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.expression)) return undefined;
303
+ return { treeName: expr.expression.text, elemId: expr.name.text };
304
+ }
305
+
306
+ function readTreeElementMethod(call: ts.CallExpression): { treeName: string; elemId: string; method: string } | undefined {
307
+ if (!ts.isPropertyAccessExpression(call.expression)) return undefined;
308
+ const method = call.expression.name.text;
309
+ const receiver = call.expression.expression;
310
+ const element = readTreeElement(receiver);
311
+ return element ? { ...element, method } : undefined;
312
+ }
313
+
314
+ function findUIModuleImports(
315
+ source: ts.SourceFile,
316
+ entryDir: string,
317
+ diagnostics: PreviewDiagnostic[],
318
+ ): UIModuleImport[] {
319
+ const imports: UIModuleImport[] = [];
320
+ for (const statement of source.statements) {
321
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
322
+ const specifier = statement.moduleSpecifier.text;
323
+ if (specifier.endsWith(".ui.html")) {
324
+ if (statement.importClause?.name) {
325
+ imports.push({
326
+ treeName: statement.importClause.name.text,
327
+ htmlPath: path.resolve(entryDir, specifier),
328
+ });
329
+ }
330
+ const named = statement.importClause?.namedBindings;
331
+ if (named && ts.isNamedImports(named)) {
332
+ for (const element of named.elements) {
333
+ imports.push({
334
+ treeName: element.name.text,
335
+ htmlPath: path.resolve(entryDir, specifier),
336
+ });
337
+ }
338
+ }
339
+ } else if (specifier.includes("/lib/") || specifier.startsWith("../lib/") || specifier.startsWith("./lib/")) {
340
+ diagnostics.push({
341
+ severity: "info",
342
+ message: `Preview is stubbing embedded-only import "${specifier}".`,
343
+ });
344
+ }
345
+ }
346
+ return imports;
347
+ }
348
+
349
+ function themeCssPath(displayThemeCss: string | undefined, htmlPath: string, configDir: string): string {
350
+ if (!displayThemeCss) return htmlPath.replace(/\.ui\.html$/, ".ui.css");
351
+ return path.isAbsolute(displayThemeCss)
352
+ ? displayThemeCss
353
+ : path.resolve(configDir, displayThemeCss);
354
+ }
355
+
356
+ function collectHrefCallbacks(
357
+ screens: StyledNode[],
358
+ programNodes: Array<{ id?: string }>,
359
+ ): PreviewCallbackSpec[] {
360
+ const screenIds = new Map<string, number>();
361
+ screens.forEach((screen, index) => {
362
+ if (screen.id) screenIds.set(screen.id, index);
363
+ });
364
+
365
+ const callbacks: PreviewCallbackSpec[] = [];
366
+
367
+ // Walk in document order with a running index, matching how lowerUIToModel
368
+ // assigns node indices (flatten(): index = cursor.i++ for every node, depth-
369
+ // first). The cursor advances by each subtree's size, so a child's index is
370
+ // always (parent + 1 + sum of earlier siblings' subtree sizes). This lets us
371
+ // resolve id-less <a href> links the same way the device does
372
+ // (ui-element-auto-wire.ts wires any node with href, id or not).
373
+ const visit = (node: StyledNode, nodeIndex: number): number => {
374
+ if (node.href) {
375
+ const target = node.href.startsWith("#") ? node.href.slice(1) : node.href;
376
+ const targetScreen = screenIds.get(target);
377
+ if (targetScreen !== undefined && nodeIndex < programNodes.length) {
378
+ callbacks.push({
379
+ nodeId: node.id ?? `__ui_link${nodeIndex}_nav`,
380
+ nodeIndex,
381
+ kind: "click",
382
+ body: `ui.navigate(${targetScreen});`,
383
+ });
384
+ }
385
+ } else if (node.runs?.some(r => r.href)) {
386
+ // Run-bearing link node (inline <a href> inside a paragraph). The link
387
+ // targets are resolved into the model at lower time; the preview tap path
388
+ // calls richLinkHit to pick the target. Register a no-op click callback so
389
+ // the node is hit-testable.
390
+ if (nodeIndex < programNodes.length) {
391
+ callbacks.push({
392
+ nodeId: node.id ?? `__ui_richlink${nodeIndex}_nav`,
393
+ nodeIndex,
394
+ kind: "click",
395
+ body: `/* rich-text link; target resolved by richLinkHit */`,
396
+ });
397
+ }
398
+ }
399
+ let nextIndex = nodeIndex + 1;
400
+ for (const child of node.children) nextIndex = visit(child, nextIndex);
401
+ return nextIndex;
402
+ };
403
+
404
+ // Each screen is a contiguous subtree in the flattened node table; find where
405
+ // screen i starts (first node with that screenId) and walk its styled tree.
406
+ for (let i = 0; i < screens.length; i++) {
407
+ const startIndex = programNodes.findIndex((n) => (n as { screenId?: number }).screenId === i);
408
+ if (startIndex >= 0) visit(screens[i], startIndex);
409
+ }
410
+ return callbacks;
411
+ }
412
+
413
+ function extractAuthorSpecs(
414
+ source: ts.SourceFile,
415
+ uiImports: UIModuleImport[],
416
+ programNodes: Array<{ id?: string; tag?: string; kind?: string; options?: Array<{ text: string; value: string }> }>,
417
+ ): {
418
+ bindings: PreviewBindingSpec[];
419
+ listBindings: PreviewListBindingSpec[];
420
+ callbacks: PreviewCallbackSpec[];
421
+ initialAssignments: PreviewInitialAssignment[];
422
+ intervals: PreviewIntervalSpec[];
423
+ pinControls: PreviewPinControlSpec[];
424
+ canvasBindings: PreviewCanvasBindingSpec[];
425
+ moduleVars: PreviewModuleVarSpec[];
426
+ diagnostics: PreviewDiagnostic[];
427
+ } {
428
+ const diagnostics: PreviewDiagnostic[] = [];
429
+ const importedTrees = new Set(uiImports.map((imp) => imp.treeName));
430
+ const bindings: PreviewBindingSpec[] = [];
431
+ const listBindings: PreviewListBindingSpec[] = [];
432
+ const callbacks: PreviewCallbackSpec[] = [];
433
+ const initialAssignments: PreviewInitialAssignment[] = [];
434
+ const intervals: PreviewIntervalSpec[] = [];
435
+ const pinControls: PreviewPinControlSpec[] = [];
436
+ const canvasBindings: PreviewCanvasBindingSpec[] = [];
437
+ const moduleVars: PreviewModuleVarSpec[] = [];
438
+
439
+ const resolveNode = (treeName: string, elemId: string): number | undefined => {
440
+ if (!importedTrees.has(treeName)) return undefined;
441
+ const idx = nodeIndexById(programNodes, elemId);
442
+ if (idx === undefined) {
443
+ diagnostics.push({ severity: "warning", message: `Preview could not find UI element "${treeName}.${elemId}".` });
444
+ }
445
+ return idx;
446
+ };
447
+
448
+ for (const statement of source.statements) {
449
+ // Top-level `let`/`const`/`var` declarations become module-scoped bindings
450
+ // the device hoists to C++ globals. Capture each declared name + initializer
451
+ // so callback bodies (setInterval, onClick, ui.bind, ...) that reference them
452
+ // resolve at preview runtime instead of throwing ReferenceError.
453
+ if (ts.isVariableStatement(statement)) {
454
+ for (const decl of statement.declarationList.declarations) {
455
+ if (ts.isIdentifier(decl.name)) {
456
+ moduleVars.push({
457
+ name: decl.name.text,
458
+ initializer: decl.initializer ? decl.initializer.getText(source) : undefined,
459
+ });
460
+ }
461
+ }
462
+ continue;
463
+ }
464
+ if (ts.isExpressionStatement(statement) && ts.isBinaryExpression(statement.expression)) {
465
+ const expr = statement.expression;
466
+ if (
467
+ expr.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
468
+ ts.isPropertyAccessExpression(expr.left) &&
469
+ expr.left.name.text === "value"
470
+ ) {
471
+ const element = readTreeElement(expr.left.expression);
472
+ if (element) {
473
+ const nodeIndex = resolveNode(element.treeName, element.elemId);
474
+ if (nodeIndex !== undefined) {
475
+ initialAssignments.push({
476
+ nodeId: element.elemId,
477
+ nodeIndex,
478
+ expression: expr.right.getText(source),
479
+ });
480
+ }
481
+ }
482
+ }
483
+ continue;
484
+ }
485
+
486
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) continue;
487
+ const call = statement.expression;
488
+
489
+ if (ts.isIdentifier(call.expression) && call.expression.text === "setInterval") {
490
+ const delay = call.arguments[1] && ts.isNumericLiteral(call.arguments[1])
491
+ ? Number(call.arguments[1].text)
492
+ : 0;
493
+ intervals.push({ body: callbackBodyText(call.arguments[0], source), delayMs: delay });
494
+ continue;
495
+ }
496
+
497
+ if (ts.isPropertyAccessExpression(call.expression) && ts.isIdentifier(call.expression.expression)) {
498
+ const objectName = call.expression.expression.text;
499
+ const method = call.expression.name.text;
500
+ if (objectName === "ui" && method === "bind") {
501
+ const elementArg = call.arguments[0];
502
+ const propArg = call.arguments[1];
503
+ const fnArg = call.arguments[2];
504
+ const element = elementArg ? readTreeElement(elementArg) : undefined;
505
+ const property = propArg && ts.isStringLiteral(propArg) ? propArg.text : "text";
506
+ const expression = callbackExpressionText(fnArg, source);
507
+ if (element && expression) {
508
+ const nodeIndex = resolveNode(element.treeName, element.elemId);
509
+ if (nodeIndex !== undefined) {
510
+ bindings.push({ nodeId: element.elemId, nodeIndex, property, expression });
511
+ }
512
+ }
513
+ continue;
514
+ }
515
+ if (objectName === "ui" && method === "bindList") {
516
+ const elementArg = call.arguments[0];
517
+ const countArg = call.arguments[1];
518
+ const itemArg = call.arguments[2];
519
+ const tapArg = call.arguments[3];
520
+ const element = elementArg ? readTreeElement(elementArg) : undefined;
521
+ const countExpression = callbackExpressionText(countArg, source);
522
+ const itemExpression = callbackExpressionText(itemArg, source);
523
+ if (element && countExpression && itemExpression) {
524
+ const nodeIndex = resolveNode(element.treeName, element.elemId);
525
+ if (nodeIndex !== undefined) {
526
+ listBindings.push({
527
+ nodeId: element.elemId,
528
+ nodeIndex,
529
+ countExpression,
530
+ itemExpression,
531
+ itemParam: callbackFirstParamName(itemArg),
532
+ tapBody: tapArg ? callbackBodyText(tapArg, source) : undefined,
533
+ tapParam: callbackFirstParamName(tapArg),
534
+ });
535
+ }
536
+ }
537
+ continue;
538
+ }
539
+ if (objectName === "ui" && method === "bindInput") {
540
+ // ui.bindInput(node, cb): cb fires with the input's committed text on
541
+ // keyboard close. Collect as a "change" callback (the keyboard-commit
542
+ // dispatch kind) and record the cb's first param so the dispatch path
543
+ // can bind the text to it as a local. Mirrors resolveBindInputCall,
544
+ // which renames the arrow's param to `text` in the lowered C++.
545
+ const elementArg = call.arguments[0];
546
+ const cbArg = call.arguments[1];
547
+ const element = elementArg ? readTreeElement(elementArg) : undefined;
548
+ if (element && cbArg && (ts.isArrowFunction(cbArg) || ts.isFunctionExpression(cbArg))) {
549
+ const nodeIndex = resolveNode(element.treeName, element.elemId);
550
+ if (nodeIndex !== undefined) {
551
+ const param = callbackFirstParamName(cbArg);
552
+ callbacks.push({
553
+ nodeId: element.elemId,
554
+ nodeIndex,
555
+ kind: "change",
556
+ body: callbackBodyText(cbArg, source),
557
+ // The runtime renames the param to `text`; record the author's
558
+ // name so we can bind the committed text under whichever name the
559
+ // body actually references. If absent, default to `text`.
560
+ param: param ?? "text",
561
+ });
562
+ }
563
+ }
564
+ continue;
565
+ }
566
+ if (objectName === "ui" && method === "drawCanvas") {
567
+ const elementArg = call.arguments[0];
568
+ const cbArg = call.arguments[1];
569
+ const element = elementArg ? readTreeElement(elementArg) : undefined;
570
+ if (element && cbArg && (ts.isArrowFunction(cbArg) || ts.isFunctionExpression(cbArg))) {
571
+ const nodeIndex = resolveNode(element.treeName, element.elemId);
572
+ if (nodeIndex !== undefined) {
573
+ canvasBindings.push({
574
+ nodeId: element.elemId,
575
+ nodeIndex,
576
+ drawBody: callbackBodyText(cbArg, source),
577
+ });
578
+ }
579
+ }
580
+ continue;
581
+ }
582
+ if (objectName === "ui" && method === "watchPin") {
583
+ const pin = numericArgText(call.arguments[0], source, "0");
584
+ pinControls.push({
585
+ label: `ui.watchPin(${pin})`,
586
+ kind: "watch",
587
+ pin,
588
+ body: callbackBodyText(call.arguments[1], source),
589
+ });
590
+ }
591
+ }
592
+
593
+ const elementMethod = readTreeElementMethod(call);
594
+ if (!elementMethod) continue;
595
+ const nodeIndex = resolveNode(elementMethod.treeName, elementMethod.elemId);
596
+ if (nodeIndex === undefined) continue;
597
+
598
+ if (elementMethod.method === "onClick" || elementMethod.method === "onHold" || elementMethod.method === "onRelease") {
599
+ callbacks.push({
600
+ nodeId: elementMethod.elemId,
601
+ nodeIndex,
602
+ kind: elementMethod.method === "onClick" ? "click" : elementMethod.method === "onHold" ? "hold" : "release",
603
+ body: callbackBodyText(call.arguments[0], source),
604
+ });
605
+ } else if (elementMethod.method === "onToggle") {
606
+ const pin = numericArgText(call.arguments[0], source, "0");
607
+ pinControls.push({
608
+ label: `${elementMethod.elemId}.onToggle(${pin})`,
609
+ kind: "toggle",
610
+ pin,
611
+ nodeId: elementMethod.elemId,
612
+ nodeIndex,
613
+ body: callbackBodyText(call.arguments[1], source),
614
+ });
615
+ } else if (elementMethod.method === "onChange") {
616
+ const nodeInfo = programNodes[nodeIndex];
617
+ if (nodeInfo?.tag === "input" || nodeInfo?.kind === "input") {
618
+ callbacks.push({
619
+ nodeId: elementMethod.elemId,
620
+ nodeIndex,
621
+ kind: "change",
622
+ body: callbackBodyText(call.arguments[0], source),
623
+ });
624
+ continue;
625
+ }
626
+ const pin = numericArgText(call.arguments[0], source, "0");
627
+ const count = call.arguments[1] && ts.isNumericLiteral(call.arguments[1])
628
+ ? Number(call.arguments[1].text)
629
+ : Math.max(programNodes[nodeIndex].options?.length ?? 0, 2);
630
+ pinControls.push({
631
+ label: `${elementMethod.elemId}.onChange(${pin})`,
632
+ kind: "change",
633
+ pin,
634
+ nodeId: elementMethod.elemId,
635
+ nodeIndex,
636
+ optionCount: count,
637
+ body: callbackBodyText(call.arguments[2], source),
638
+ });
639
+ } else if (elementMethod.method === "onPress" || elementMethod.method === "onRelease") {
640
+ const pin = numericArgText(call.arguments[0], source, "0");
641
+ pinControls.push({
642
+ label: `${elementMethod.elemId}.${elementMethod.method}(${pin})`,
643
+ kind: elementMethod.method === "onPress" ? "press" : "release",
644
+ pin,
645
+ nodeId: elementMethod.elemId,
646
+ nodeIndex,
647
+ });
648
+ }
649
+ }
650
+
651
+ return { bindings, listBindings, callbacks, initialAssignments, intervals, pinControls, canvasBindings, moduleVars, diagnostics };
652
+ }
653
+
654
+ export async function buildPreviewSnapshot(options: BuildPreviewSnapshotOptions): Promise<PreviewSnapshot> {
655
+ const { config, projectRoot } = options;
656
+ const diagnostics: PreviewDiagnostic[] = [];
657
+ const configDir = path.dirname(config.configPath);
658
+ if (!config.entry) {
659
+ throw new Error(`cuttlefish preview requires an entry field in ${config.configPath}`);
660
+ }
661
+
662
+ const entryFile = path.resolve(configDir, config.entry);
663
+ const entrySource = fs.readFileSync(entryFile, "utf-8");
664
+ const entryDir = path.dirname(entryFile);
665
+ const entryExt = path.extname(entryFile).toLowerCase();
666
+
667
+ let sourceFile: ts.SourceFile;
668
+ let htmlText: string;
669
+ let cssText: string;
670
+ let cssFileDir: string; // directory @imports inside cssText resolve from
671
+ let htmlFilePath: string; // the effective .ui.html path (real or synthetic)
672
+ let uiImports: UIModuleImport[];
673
+
674
+ if (entryExt === ".ui") {
675
+ // .ui single-file component: split into script/style/template.
676
+ const parts = splitUiFile(entrySource);
677
+ const baseName = path.basename(entryFile, ".ui");
678
+ htmlFilePath = entryFile + ".html"; // synthetic .ui.html path
679
+ // Inject the implicit screen import so the script's `screen` reference
680
+ // resolves during preview-expression evaluation.
681
+ const scriptWithImport = `import { screen } from './${baseName}.ui.html';\n` + parts.script;
682
+ sourceFile = ts.createSourceFile(entryFile, scriptWithImport, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
683
+ htmlText = parts.html;
684
+ cssText = parts.style;
685
+ cssFileDir = entryDir;
686
+ uiImports = [{ treeName: "screen", htmlPath: htmlFilePath }];
687
+ } else {
688
+ sourceFile = ts.createSourceFile(entryFile, entrySource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
689
+ uiImports = findUIModuleImports(sourceFile, entryDir, diagnostics);
690
+ const firstImport = uiImports[0];
691
+ if (!firstImport) {
692
+ throw new Error(`Preview could not find a .ui.html import in ${entryFile}`);
693
+ }
694
+ if (uiImports.length > 1) {
695
+ diagnostics.push({
696
+ severity: "warning",
697
+ message: "Preview v1 renders the first imported UI tree only.",
698
+ });
699
+ }
700
+ htmlFilePath = firstImport.htmlPath;
701
+ htmlText = fs.readFileSync(firstImport.htmlPath, "utf-8");
702
+ const cssPath = themeCssPath(config.display?.themeCss, firstImport.htmlPath, configDir);
703
+ cssText = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf-8") : "";
704
+ cssFileDir = path.dirname(cssPath);
705
+ }
706
+
707
+ const registry = await loadProfileRegistry(config.framework);
708
+ const resolved = resolveDisplayProfile(config.display ?? { profile: "ili9341-spi" }, registry);
709
+ const profile = resolved.profile;
710
+ const displaySize = effectiveDisplaySize(profile);
711
+ const parsedHtml = parseHtmlWithKeyboards(htmlText);
712
+ // @import parity with the CLI build (loadUIModuleFromText in ui-registry.ts
713
+ // expands imports before parsing). Without this, `@import "./styles/shadcn.css"`
714
+ // in a .ui <style> stays literal in the preview, the kit's tokens never load,
715
+ // and model lowering rejects the raw var() color strings. The sidecar css and
716
+ // the html style blocks can live in different directories, so each part
717
+ // expands against its own base.
718
+ // Built-in shadcn kit first (device parity with ui-registry): user token
719
+ // blocks and recipe overrides win by cascade order.
720
+ const fullCss =
721
+ SHADCN_KIT_CSS + "\n" +
722
+ expandCssImports(cssText, cssFileDir) + "\n" +
723
+ expandCssImports(extractStyleBlocks(htmlText), path.dirname(htmlFilePath));
724
+ const cssRules = (() => {
725
+ const previousThemeClass = getThemeClass();
726
+ setThemeClass(config.display?.themeClass ?? null);
727
+ try {
728
+ return parseCss(fullCss);
729
+ } finally {
730
+ setThemeClass(previousThemeClass);
731
+ }
732
+ })();
733
+ // Default-font parity with the CLI build (ui-registry.ts / transpile-ui.ts
734
+ // both inject the bundled DejaVu faces before planning font assets). Without
735
+ // this the UA root's font-family: "DejaVu Sans" resolves to no face and the
736
+ // preview falls back to the smoothed 5x7 — the device renders real AA glyphs.
737
+ const fontFaces = injectDefaultFontFaces(parseFontFaces(fullCss), profile.colorFormat);
738
+ const rawKeyframes = parseKeyframes(fullCss);
739
+ const styled = resolveStyles(parsedHtml.tree, cssRules);
740
+ const allStyledScreens = parsedHtml.screens.map((screen) => resolveStyles(screen, cssRules));
741
+ const fontRoot: StyledNode = { tag: "screen", classes: [], style: {}, children: allStyledScreens };
742
+ // Script-side ui.bind(..., 'text', ...) targets render runtime strings the
743
+ // static template can't predict — collect their ids so font planning widens
744
+ // those faces to the fallback charset (markup {expr} and bind:text are
745
+ // detected from the tree inside the planner).
746
+ const dynamicTextIds = new Set<string>();
747
+ const bindTextRe = /ui\.bind\(\s*screen\.([A-Za-z_$][\w$]*)\s*,\s*["']text["']/g;
748
+ for (const m of sourceFile.text.matchAll(bindTextRe)) dynamicTextIds.add(m[1]);
749
+ const fontAssets = buildUIFontAssets(fontRoot, fontFaces, path.dirname(htmlFilePath), dynamicTextIds);
750
+ // Prime the image-conversion cache (<img src="*.png|jpg|ico|…"> decodes
751
+ // here) and give id-less-size img nodes their natural geometry before the
752
+ // synchronous layout + asset pass below.
753
+ await warmUpImageDecoding(htmlText, path.dirname(htmlFilePath), { maxW: displaySize.width, maxH: displaySize.height });
754
+ applyDecodedImageSizes(allStyledScreens.length > 0 ? allStyledScreens : [styled], path.dirname(htmlFilePath));
755
+ const viewport: Box = { x: 0, y: 0, w: displaySize.width, h: displaySize.height };
756
+ const boxes = allStyledScreens.flatMap((screen) => {
757
+ const engine = selectEngine(screen);
758
+ return engine.arrange(screen, viewport, measureWithFonts(fontAssets));
759
+ });
760
+ const keyframeSets = buildKeyframeSets(rawKeyframes, profile.colorFormat);
761
+ const imageAssets = loadImageAssets(allStyledScreens.length > 0 ? allStyledScreens : [styled], path.dirname(htmlFilePath));
762
+ const program = lowerUIToModel(styled, boxes, profile.colorFormat, profile, fontAssets, allStyledScreens, imageAssets.nodeIdToAssetIndex, keyframeSets, imageAssets.assets);
763
+ const specs = extractAuthorSpecs(sourceFile, uiImports, program.nodes);
764
+ const hrefCallbacks = collectHrefCallbacks(allStyledScreens, program.nodes);
765
+ // on:*/bind:*/{expr} features resolve their node by id — the device build
766
+ // auto-wires id-less nodes, but the preview's binding/callback tables key on
767
+ // id, so an unnamed interactive node is silently inert here. Make that
768
+ // visible instead of a dead button.
769
+ warnUnnamedInteractiveNodes(allStyledScreens, diagnostics);
770
+ // {expr} interpolation bindings synthesized from the HTML (mirrors the runtime's
771
+ // auto-wire synthesis). Authors write `taps: {count}` in markup; this collects
772
+ // them so the preview evaluates the template-literal expression each frame.
773
+ const interpolationBindings = collectInterpolationBindings(
774
+ allStyledScreens.length > 0 ? allStyledScreens : [styled],
775
+ program.nodes,
776
+ );
777
+ // on:* declarative event handlers from markup → preview callbacks (named fn).
778
+ const eventCallbacks = collectEventCallbacks(
779
+ allStyledScreens.length > 0 ? allStyledScreens : [styled],
780
+ program.nodes,
781
+ collectNamedFunctionBodies(sourceFile),
782
+ );
783
+ // bind:* declarative two-way bindings: read (signal → node) + write-back
784
+ // (node → signal via change callbacks).
785
+ const bindResult = collectBindBindings(
786
+ allStyledScreens.length > 0 ? allStyledScreens : [styled],
787
+ program.nodes,
788
+ );
789
+
790
+ return {
791
+ projectRoot,
792
+ entryFile,
793
+ htmlFile: htmlFilePath,
794
+ profileName: typeof config.display?.profile === "string" ? config.display.profile : undefined,
795
+ program,
796
+ keyboardTemplates: parsedHtml.keyboards,
797
+ cssRules,
798
+ uiTreeNames: [...new Set(uiImports.map((imp) => imp.treeName))],
799
+ font: loadFont(projectRoot, diagnostics),
800
+ bindings: [...specs.bindings, ...interpolationBindings, ...bindResult.bindings],
801
+ listBindings: specs.listBindings,
802
+ callbacks: [...hrefCallbacks, ...specs.callbacks, ...eventCallbacks, ...bindResult.callbacks],
803
+ initialAssignments: specs.initialAssignments,
804
+ intervals: specs.intervals,
805
+ pinControls: specs.pinControls,
806
+ canvasBindings: specs.canvasBindings,
807
+ moduleVars: specs.moduleVars,
808
+ diagnostics: [...diagnostics, ...specs.diagnostics],
809
+ };
810
+ }