@typecad/cuttlefish 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 (65) hide show
  1. package/dist/add-preset.d.ts +4 -0
  2. package/dist/add-preset.js +74 -0
  3. package/dist/api/config.d.ts +5 -5
  4. package/dist/api/shared/display-adapters/sdl.js +1 -1
  5. package/dist/api/shared/display-profile.d.ts +11 -0
  6. package/dist/api/shared/display-profile.js +3 -0
  7. package/dist/api/shared/framework-manifest.d.ts +85 -78
  8. package/dist/api/shared/framework-manifest.js +1 -0
  9. package/dist/api/shared/hal-op-ir.d.ts +19 -0
  10. package/dist/api/shared/toolchain-types.d.ts +0 -1
  11. package/dist/cli.js +21 -4
  12. package/dist/config-loader.d.ts +8 -2
  13. package/dist/config-loader.js +202 -53
  14. package/dist/config-schema.d.ts +7 -7
  15. package/dist/config-schema.js +3 -3
  16. package/dist/create/board-spec.d.ts +122 -122
  17. package/dist/create/debug-artifacts.d.ts +20 -0
  18. package/dist/create/debug-artifacts.js +69 -0
  19. package/dist/create/eslint-rules-template.js +6 -3
  20. package/dist/create/index.d.ts +2 -0
  21. package/dist/create/index.js +1 -0
  22. package/dist/create/init-scaffold.d.ts +1 -0
  23. package/dist/create/init-scaffold.js +5 -0
  24. package/dist/create/init-templates.js +0 -2
  25. package/dist/emit/compliance/rules.js +52 -4
  26. package/dist/emit/emitters/function-emitter-impl.js +7 -1
  27. package/dist/emit/emitters/line-appender.js +6 -0
  28. package/dist/emit/emitters/setup.js +15 -2
  29. package/dist/emit/emitters/ui-emitter.js +40 -15
  30. package/dist/emit/route-hal-op.js +55 -1
  31. package/dist/emit/statement-renderer.js +5 -2
  32. package/dist/ir/build-ir.js +22 -1
  33. package/dist/ir/expression-to-ir.js +17 -0
  34. package/dist/ir/feature-registry.js +22 -6
  35. package/dist/ir/hal/hal-emitter.js +23 -5
  36. package/dist/ir/hal/hal-plugins.js +11 -0
  37. package/dist/ir/pin-mode-validation.js +32 -9
  38. package/dist/ir/pin-state-tracking.d.ts +58 -0
  39. package/dist/ir/pin-state-tracking.js +182 -0
  40. package/dist/ir/program-analysis.d.ts +10 -2
  41. package/dist/ir/program-analysis.js +40 -4
  42. package/dist/ir/statement-to-ir.js +14 -0
  43. package/dist/ir/transformers/control-flow.js +29 -0
  44. package/dist/ir/transformers/ui-call-resolver.js +105 -1
  45. package/dist/ir/ui-element-auto-wire.js +7 -4
  46. package/dist/orchestrator/graph-builder.d.ts +4 -1
  47. package/dist/orchestrator/graph-builder.js +7 -1
  48. package/dist/platform/async-runtime.d.ts +1 -1
  49. package/dist/platform/async-runtime.js +12 -3
  50. package/dist/platform/generic-strategy.js +1 -1
  51. package/dist/preview/api-shared-shim.d.ts +1 -0
  52. package/dist/preview/api-shared-shim.js +7 -0
  53. package/dist/preview/client.js +220 -1
  54. package/dist/preview/server.js +154 -62
  55. package/dist/theme-tokens.d.ts +22 -0
  56. package/dist/theme-tokens.js +172 -0
  57. package/dist/transpile.js +90 -12
  58. package/dist/types.d.ts +5 -0
  59. package/dist/ui-hook.d.ts +7 -0
  60. package/dist/utils/cli.js +9 -0
  61. package/dist/utils/fs.d.ts +2 -0
  62. package/dist/utils/fs.js +16 -0
  63. package/dist/utils/ui.d.ts +5 -0
  64. package/dist/utils/ui.js +7 -0
  65. package/package.json +7 -5
@@ -15,4 +15,7 @@ export declare function topologicalSortFiles(files: string[], dependencies: Map<
15
15
  * @param boardPackage When provided, `@typecad/board` imports resolve to this
16
16
  * board package (e.g. `'@typecad/board-arduino-uno'`).
17
17
  */
18
- export declare function collectTranspileGraph(entryFile: string, boardPackage?: string): TranspileGraphResult;
18
+ export declare function collectTranspileGraph(entryFile: string, boardPackage?: string, imageDecodeOpts?: {
19
+ maxW?: number;
20
+ maxH?: number;
21
+ }): Promise<TranspileGraphResult>;
@@ -68,7 +68,7 @@ export function topologicalSortFiles(files, dependencies) {
68
68
  * @param boardPackage When provided, `@typecad/board` imports resolve to this
69
69
  * board package (e.g. `'@typecad/board-arduino-uno'`).
70
70
  */
71
- export function collectTranspileGraph(entryFile, boardPackage) {
71
+ export async function collectTranspileGraph(entryFile, boardPackage, imageDecodeOpts) {
72
72
  const ordered = [];
73
73
  const pending = [path.resolve(entryFile)];
74
74
  const visited = new Set();
@@ -102,6 +102,10 @@ export function collectTranspileGraph(entryFile, boardPackage) {
102
102
  const parts = ui.splitUiFile(sourceText);
103
103
  // Register the template as a UI module at <file>.ui.html (synthetic path).
104
104
  const uiHtmlPath = filePath + ".html";
105
+ // Prime the image-conversion cache before the (synchronous) module
106
+ // load — <img src="*.png|jpg|ico|…"> decodes here, and the module's
107
+ // asset reader + natural-size layout pull from the cache.
108
+ await ui.warmUpImageDecoding(parts.html, path.dirname(filePath), imageDecodeOpts ?? {});
105
109
  ui.loadUIModuleFromText(uiHtmlPath, parts.html, parts.style, filePath);
106
110
  uiModules.add(uiHtmlPath);
107
111
  // Use the <script> as the TS source for import-graph walking. Inject an
@@ -189,6 +193,8 @@ export function collectTranspileGraph(entryFile, boardPackage) {
189
193
  // .ui.html modules: load into the UI registry, record the path, and don't
190
194
  // push onto `pending` (they are never parsed as TypeScript).
191
195
  if (resolved?.uiModule) {
196
+ const uiHtmlText = readText(resolved.sourcePath);
197
+ await requireUIHook().warmUpImageDecoding(uiHtmlText, path.dirname(resolved.sourcePath), imageDecodeOpts ?? {});
192
198
  requireUIHook().loadUIModule(resolved.sourcePath);
193
199
  uiModules.add(resolved.sourcePath);
194
200
  // Track the dependency edge so topological sort orders the importer
@@ -5,4 +5,4 @@ import type { RuntimePolyfillIR } from "../api/shared/index.js";
5
5
  * Build a RuntimePolyfillIR for the async Promise runtime, if the program
6
6
  * has async functions and the target architecture has stdlib support.
7
7
  */
8
- export declare function buildAsyncRuntimePolyfill(program: ProgramIR, ctx: PlatformContext | undefined, target: string, queueCapacity?: number): RuntimePolyfillIR | null;
8
+ export declare function buildAsyncRuntimePolyfill(program: ProgramIR, ctx: PlatformContext | undefined, target: string, queueCapacity?: number, strategy?: import("../api/shared/platform-strategy.js").PlatformStrategy): RuntimePolyfillIR | null;
@@ -3,7 +3,7 @@ import { getStdLibSupport, generatePromiseRuntime } from "../api/shared/index.js
3
3
  * Build a RuntimePolyfillIR for the async Promise runtime, if the program
4
4
  * has async functions and the target architecture has stdlib support.
5
5
  */
6
- export function buildAsyncRuntimePolyfill(program, ctx, target, queueCapacity) {
6
+ export function buildAsyncRuntimePolyfill(program, ctx, target, queueCapacity, strategy) {
7
7
  const hasAsync = program.functions.some(fn => fn.isAsync);
8
8
  if (!hasAsync)
9
9
  return null;
@@ -11,13 +11,22 @@ export function buildAsyncRuntimePolyfill(program, ctx, target, queueCapacity) {
11
11
  const stdlib = getStdLibSupport(architecture);
12
12
  if (!stdlib.hasVector || !stdlib.hasString)
13
13
  return null;
14
+ // Pass the strategy through so the runtime's now-expression matches the
15
+ // target (generic bakes a std::chrono expression via currentTimeMillis()
16
+ // instead of a millis() token the generic target never defines). When the
17
+ // expression uses std::chrono, the polyfill must carry <chrono> itself —
18
+ // the generic strategy's forcedIncludes are empty by design.
19
+ const now = strategy?.currentTimeMillis?.() ?? "millis()";
20
+ const requiredIncludes = ["<functional>", "<vector>", "<utility>", "<string>"];
21
+ if (now.includes("std::chrono"))
22
+ requiredIncludes.push("<chrono>");
14
23
  return {
15
24
  kind: "polyfill",
16
25
  id: "async_runtime",
17
26
  domain: "standard",
18
- requiredIncludes: ["<functional>", "<vector>", "<utility>", "<string>"],
27
+ requiredIncludes,
19
28
  forwardDeclarations: [],
20
- helperStructs: [generatePromiseRuntime(queueCapacity ?? 256)],
29
+ helperStructs: [generatePromiseRuntime(queueCapacity ?? 256, false, strategy)],
21
30
  helperFunctions: [],
22
31
  shimMacros: [],
23
32
  dependencies: [],
@@ -184,7 +184,7 @@ export class GenericStrategy {
184
184
  }
185
185
  generateNativePolyfills(program, ctx) {
186
186
  const helpers = [];
187
- const asyncRuntime = buildAsyncRuntimePolyfill(program, ctx, "generic", this.asyncQueueCapacity());
187
+ const asyncRuntime = buildAsyncRuntimePolyfill(program, ctx, "generic", this.asyncQueueCapacity(), this);
188
188
  if (asyncRuntime)
189
189
  helpers.push(asyncRuntime);
190
190
  return helpers;
@@ -0,0 +1 @@
1
+ export { resolveScrollConfig } from "../api/shared/display-profile.js";
@@ -0,0 +1,7 @@
1
+ // Browser shim served to the preview page. The host runtime imports
2
+ // resolveScrollConfig from "@typecad/ui/..."→"@typecad/cuttlefish/api/shared",
3
+ // whose barrel index re-exports node-only modules (fs, zod) that cannot load
4
+ // in a browser. The preview's import map points the bare specifier at this
5
+ // file, which re-exports just the browser-safe piece the runtime needs, from
6
+ // the module that actually defines it.
7
+ export { resolveScrollConfig } from "../api/shared/display-profile.js";
@@ -5,6 +5,169 @@ let runtime;
5
5
  let imageData;
6
6
  let pendingPointerMove;
7
7
  let pendingPointerFrame = 0;
8
+ let debugModes = { boxes: false, clips: false, dirty: false, inspect: false };
9
+ let debugOverlay;
10
+ let debugCtx;
11
+ let dirtyFlashAlpha = 0;
12
+ /** CSS px per logical display px — the overlay backing store runs at this
13
+ * resolution so debug strokes are 1 CSS px thin (a 1-logical-px stroke on
14
+ * the app canvas's own backing store would upscale 3x thick and bury the
15
+ * content under investigation). */
16
+ let debugScale = 1;
17
+ // Stroke color per node kind: a screenshot should be self-describing.
18
+ // views cyan · text yellow · interactive magenta · img/canvas orange ·
19
+ // list green · screen transparent (skip; it's the whole panel).
20
+ function debugColor(tag, kind) {
21
+ if (tag === "screen")
22
+ return undefined;
23
+ if (tag === "img" || tag === "canvas")
24
+ return "#ff9a3c";
25
+ if (tag === "list")
26
+ return "#3ddc84";
27
+ if (["button", "input", "select", "check", "radio", "range", "progress", "drawer"].includes(tag))
28
+ return "#ff4fd8";
29
+ if (kind === "text")
30
+ return "#ffe14d";
31
+ return "#3cc8ff";
32
+ }
33
+ function applyDebugCapture() {
34
+ runtime?.setDebugCapture?.(debugModes.boxes || debugModes.clips || debugModes.dirty);
35
+ const state = document.getElementById("debugState");
36
+ if (state) {
37
+ const on = ["boxes", "clips", "dirty", "inspect"].filter((m) => debugModes[m]);
38
+ state.textContent = on.length === 0
39
+ ? "overlay: off — check a box or press D"
40
+ : `overlay: ${on.join("+")}`;
41
+ }
42
+ }
43
+ function drawDebugOverlay(width, height) {
44
+ if (!debugOverlay || !debugCtx)
45
+ return;
46
+ const ctx = debugCtx;
47
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
48
+ ctx.clearRect(0, 0, debugOverlay.width, debugOverlay.height);
49
+ ctx.setTransform(debugScale, 0, 0, debugScale, 0, 0);
50
+ const anyVisual = debugModes.boxes || debugModes.clips || debugModes.dirty;
51
+ if (!anyVisual || !runtime?.debugInfo)
52
+ return;
53
+ const info = runtime.debugInfo();
54
+ if (debugModes.dirty) {
55
+ // Flash the regions repainted this frame; fades until the next repaint.
56
+ dirtyFlashAlpha = Math.min(0.55, dirtyFlashAlpha + 0.35);
57
+ ctx.fillStyle = `rgba(255, 225, 77, ${dirtyFlashAlpha.toFixed(2)})`;
58
+ for (const r of info.painted)
59
+ ctx.fillRect(r.x, r.y, r.w, r.h);
60
+ }
61
+ else {
62
+ dirtyFlashAlpha = 0;
63
+ }
64
+ // 1 CSS px regardless of the app canvas's logical upscaling — thicker
65
+ // strokes bury the content being inspected.
66
+ ctx.lineWidth = Math.max(1 / debugScale, 0.5);
67
+ if (debugModes.clips) {
68
+ // Viewport tint guarantees visibility at any stroke sampling; the dashed
69
+ // outline marks the exact clip edges.
70
+ for (const c of info.clips) {
71
+ ctx.fillStyle = "rgba(61, 220, 132, 0.08)";
72
+ ctx.fillRect(c.x, c.y, c.w, c.h);
73
+ }
74
+ ctx.strokeStyle = "#3ddc84";
75
+ ctx.setLineDash([3, 2]);
76
+ for (const c of info.clips)
77
+ ctx.strokeRect(c.x, c.y, c.w - 1, c.h - 1);
78
+ ctx.setLineDash([]);
79
+ }
80
+ if (debugModes.boxes) {
81
+ for (const n of info.nodes) {
82
+ const color = debugColor(n.tag, n.kind);
83
+ if (!color)
84
+ continue;
85
+ ctx.strokeStyle = color;
86
+ ctx.strokeRect(n.x + 0.5, n.y + 0.5, n.w - 1, n.h - 1);
87
+ // Untappable interactive elements get a red corner mark — the
88
+ // id-less-element trap (the preview wires by id).
89
+ if (!n.tappable && ["button", "input", "select", "check", "radio", "list"].includes(n.tag)) {
90
+ ctx.strokeStyle = "#ff5252";
91
+ ctx.beginPath();
92
+ ctx.moveTo(n.x, n.y);
93
+ ctx.lineTo(n.x + 6, n.y);
94
+ ctx.moveTo(n.x, n.y);
95
+ ctx.lineTo(n.x, n.y + 6);
96
+ ctx.stroke();
97
+ }
98
+ }
99
+ }
100
+ }
101
+ function inspectAt(px, py, width, height, report) {
102
+ if (!runtime?.debugHit)
103
+ return false;
104
+ const i = runtime.debugHit(px, py);
105
+ if (i < 0) {
106
+ report(`inspect (${px},${py}): empty space`);
107
+ return true;
108
+ }
109
+ const info = runtime.debugInfo();
110
+ const n = info.nodes.find((x) => x.i === i);
111
+ if (!n)
112
+ return false;
113
+ report(`inspect #${i}${n.id ? ` '${n.id}'` : ""} <${n.tag}> kind=${n.kind} box=${n.x},${n.y} ${n.w}x${n.h} tappable=${n.tappable}`);
114
+ return true;
115
+ }
116
+ function wireDebugControls(canvas, width, height, report, redraw) {
117
+ const modesBox = document.getElementById("debugModes");
118
+ if (modesBox) {
119
+ // ?debug=boxes,clips,dirty,inspect seeds the initial state.
120
+ const params = new URLSearchParams(location.search);
121
+ const seeded = (params.get("debug") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
122
+ for (const input of Array.from(modesBox.querySelectorAll("input[data-mode]"))) {
123
+ const mode = input.dataset.mode;
124
+ if (seeded.includes(mode))
125
+ debugModes[mode] = true;
126
+ input.checked = debugModes[mode];
127
+ input.addEventListener("change", () => {
128
+ debugModes[mode] = input.checked;
129
+ applyDebugCapture();
130
+ redraw();
131
+ });
132
+ }
133
+ }
134
+ // D cycles boxes → clips → dirty → off (inspect stays manual — it blocks
135
+ // normal input and shouldn't be cycled into by accident).
136
+ window.addEventListener("keydown", (e) => {
137
+ if (e.key !== "d" && e.key !== "D")
138
+ return;
139
+ const on = debugModes.boxes || debugModes.clips || debugModes.dirty;
140
+ if (!on)
141
+ debugModes = { ...debugModes, boxes: true };
142
+ else if (debugModes.boxes)
143
+ debugModes = { ...debugModes, boxes: false, clips: true };
144
+ else if (debugModes.clips)
145
+ debugModes = { ...debugModes, clips: false, dirty: true };
146
+ else
147
+ debugModes = { ...debugModes, dirty: false };
148
+ for (const input of Array.from((modesBox ?? document).querySelectorAll("input[data-mode]"))) {
149
+ const mode = input.dataset.mode;
150
+ input.checked = debugModes[mode];
151
+ }
152
+ applyDebugCapture();
153
+ redraw();
154
+ });
155
+ // Inspect taps: while inspect mode is on, taps report instead of
156
+ // interacting. Ctrl-click always inspects without entering the mode.
157
+ const inspectPointer = (event, force) => {
158
+ if (!force && !debugModes.inspect)
159
+ return false;
160
+ const p = canvasPoint(canvas, event, width, height);
161
+ inspectAt(p.x, p.y, width, height, report);
162
+ return true;
163
+ };
164
+ canvas.addEventListener("pointerdown", (event) => {
165
+ if (inspectPointer(event, event.ctrlKey || event.metaKey)) {
166
+ event.stopImmediatePropagation();
167
+ event.preventDefault();
168
+ }
169
+ }, true);
170
+ }
8
171
  function byId(id) {
9
172
  const el = document.getElementById(id);
10
173
  if (!el)
@@ -74,6 +237,34 @@ async function start() {
74
237
  if (!ctx)
75
238
  throw new Error("2D canvas context unavailable");
76
239
  ctx.imageSmoothingEnabled = false;
240
+ // Debug overlay: same backing resolution + CSS as the app canvas, stacked
241
+ // exactly on top; pointer-events pass through to the app canvas below.
242
+ debugOverlay?.remove();
243
+ debugOverlay = document.createElement("canvas");
244
+ debugOverlay.id = "debugOverlay";
245
+ debugOverlay.width = snapshot.program.width;
246
+ debugOverlay.height = snapshot.program.height;
247
+ canvas.parentElement?.appendChild(debugOverlay);
248
+ debugCtx = debugOverlay.getContext("2d") ?? undefined;
249
+ const syncOverlayBox = () => {
250
+ if (!debugOverlay)
251
+ return;
252
+ const rect = canvas.getBoundingClientRect();
253
+ const parent = canvas.parentElement;
254
+ const prect = parent.getBoundingClientRect();
255
+ debugOverlay.style.left = `${rect.left - prect.left}px`;
256
+ debugOverlay.style.top = `${rect.top - prect.top}px`;
257
+ debugOverlay.style.width = `${rect.width}px`;
258
+ debugOverlay.style.height = `${rect.height}px`;
259
+ // Backing store at CSS-pixel resolution: strokes draw at device-crisp
260
+ // 1 CSS px regardless of the app canvas's logical upscaling.
261
+ const dpr = window.devicePixelRatio || 1;
262
+ debugOverlay.width = Math.max(1, Math.round(rect.width * dpr));
263
+ debugOverlay.height = Math.max(1, Math.round(rect.height * dpr));
264
+ debugScale = (rect.width * dpr) / snapshot.program.width;
265
+ };
266
+ syncOverlayBox();
267
+ window.addEventListener("resize", syncOverlayBox);
77
268
  // Set the canvas CSS aspect-ratio to match the display (e.g. 128:64 for
78
269
  // OLED, 320:240 for TFT) so the browser scales it proportionally.
79
270
  document.documentElement.style.setProperty("--display-aspect", `${snapshot.program.width} / ${snapshot.program.height}`);
@@ -92,16 +283,39 @@ async function start() {
92
283
  return;
93
284
  imageData.data.set(rgba);
94
285
  ctx.putImageData(imageData, 0, 0);
286
+ drawDebugOverlay(snapshot.program.width, snapshot.program.height);
95
287
  },
96
288
  onDiagnostics: (message) => {
97
289
  extraDiagnostics.push(message);
98
290
  renderDiagnostics(snapshot, extraDiagnostics);
99
291
  },
100
292
  });
293
+ // Mouse wheel scrolls the scroll owner under the cursor — same hit-scan
294
+ // as a drag (lists, scroll containers). Without this, wheel-scrolling a
295
+ // canvas preview does nothing.
296
+ canvas.onwheel = (event) => {
297
+ if (!runtime)
298
+ return;
299
+ event.preventDefault();
300
+ const p = canvasPoint(canvas, event, snapshot.program.width, snapshot.program.height);
301
+ // deltaMode: 0 = pixels, 1 = lines, 2 = pages.
302
+ const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
303
+ runtime.wheel(p.x, p.y, event.deltaY * scale);
304
+ };
101
305
  canvas.onpointerdown = (event) => {
102
306
  if (!runtime)
103
307
  return;
104
- canvas.setPointerCapture(event.pointerId);
308
+ // Capture keeps drags flowing when the pointer leaves the canvas, but
309
+ // synthetic pointers (CDP/browser automation) have no capturable
310
+ // pointerId — setPointerCapture throws InvalidPointerId for them and
311
+ // would kill the tap before runtime.pointerDown runs. Losing capture
312
+ // only degrades off-canvas drags, so swallow the failure.
313
+ try {
314
+ canvas.setPointerCapture(event.pointerId);
315
+ }
316
+ catch {
317
+ /* not a capturable pointer — canvas-local move/up still arrive */
318
+ }
105
319
  const p = canvasPoint(canvas, event, snapshot.program.width, snapshot.program.height);
106
320
  runtime.pointerDown(p.x, p.y);
107
321
  };
@@ -123,6 +337,11 @@ async function start() {
123
337
  canvas.onpointercancel = pointerUp;
124
338
  renderPinControls(snapshot.pinControls);
125
339
  renderDiagnostics(snapshot);
340
+ wireDebugControls(canvas, snapshot.program.width, snapshot.program.height, (message) => {
341
+ extraDiagnostics.push(message);
342
+ renderDiagnostics(snapshot, extraDiagnostics);
343
+ }, () => drawDebugOverlay(snapshot.program.width, snapshot.program.height));
344
+ applyDebugCapture();
126
345
  runtime.start();
127
346
  }
128
347
  void start().catch((error) => {
@@ -1,55 +1,83 @@
1
1
  import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { createRequire } from "node:module";
5
6
  import chalk from "chalk";
6
7
  import { findConfigFile, parseConfigFile } from "../config-loader.js";
7
- import { requireUIHook } from "../ui-hook.js";
8
- const HTML = `<!doctype html>
9
- <html lang="en">
10
- <head>
11
- <meta charset="utf-8">
12
- <meta name="viewport" content="width=device-width, initial-scale=1">
13
- <title>Cuttlefish Preview</title>
14
- <style>
15
- :root { color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; background: #111315; color: #e8ecef; }
16
- body { margin: 0; height: 100vh; overflow: hidden; display: grid; grid-template-columns: minmax(360px, 1fr) 280px; }
17
- main { display: grid; place-items: center; padding: 24px; background: #191d20; }
18
- canvas { image-rendering: pixelated; width: min(92vw, 960px); max-height: calc(100vh - 48px); aspect-ratio: var(--display-aspect, 4 / 3); background: #000; box-shadow: 0 12px 36px rgba(0,0,0,.35); }
19
- aside { border-left: 1px solid #2d3338; padding: 18px; display: flex; flex-direction: column; gap: 18px; min-height: 0; }
20
- aside > section:first-child, aside > section:nth-child(2) { flex-shrink: 0; } /* Display + GPIO stay visible */
21
- aside > section:last-child { min-height: 0; display: flex; flex-direction: column; }
22
- #diagnostics { overflow-y: auto; min-height: 0; }
23
- h1 { font-size: 15px; margin: 0 0 8px; font-weight: 650; }
24
- #status, #diagnostics, .empty { color: #aab3ba; font-size: 12px; line-height: 1.4; }
25
- #pins { display: grid; gap: 8px; }
26
- button { appearance: none; border: 1px solid #44505a; background: #252b30; color: #f3f6f8; border-radius: 6px; padding: 9px 10px; text-align: left; font: inherit; cursor: pointer; }
27
- button:hover { background: #303841; }
28
- @media (max-width: 760px) {
29
- body { height: auto; min-height: 100vh; overflow: auto; grid-template-rows: auto 1fr; grid-template-columns: 1fr; }
30
- aside { border-left: 0; border-top: 1px solid #2d3338; }
31
- canvas { width: min(94vw, 640px); }
32
- }
33
- </style>
34
- </head>
35
- <body>
36
- <main><canvas id="display" width="320" height="240"></canvas></main>
37
- <aside>
38
- <section>
39
- <h1>Display</h1>
40
- <div id="status"></div>
41
- </section>
42
- <section>
43
- <h1>GPIO</h1>
44
- <div id="pins"></div>
45
- </section>
46
- <section>
47
- <h1>Diagnostics</h1>
48
- <div id="diagnostics"></div>
49
- </section>
50
- </aside>
51
- <script type="module" src="/__cuttlefish/preview/client.js"></script>
52
- </body>
8
+ import { requireUIHook, hasUIHook } from "../ui-hook.js";
9
+ import { loadUIEngine } from "../ui/ui-bridge.js";
10
+ const HTML = `<!doctype html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1">
15
+ <title>Cuttlefish Preview</title>
16
+ <style>
17
+ :root { color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; background: #111315; color: #e8ecef; }
18
+ body { margin: 0; height: 100vh; overflow: hidden; display: grid; grid-template-columns: minmax(360px, 1fr) 280px; }
19
+ main { display: grid; place-items: center; padding: 24px; background: #191d20; }
20
+ canvas { image-rendering: pixelated; width: min(92vw, 960px); max-height: calc(100vh - 48px); aspect-ratio: var(--display-aspect, 4 / 3); background: #000; box-shadow: 0 12px 36px rgba(0,0,0,.35); }
21
+ #debugOverlay { position: absolute; pointer-events: none; image-rendering: pixelated; background: transparent; }
22
+ main { position: relative; }
23
+ #debugModes { display: grid; gap: 2px; }
24
+ #debugModes label { display: flex; align-items: center; gap: 8px; color: #aab3ba; font-size: 12px; cursor: pointer; padding: 5px 6px; border-radius: 5px; }
25
+ #debugModes label:hover { background: #232a30; color: #dfe6eb; }
26
+ #debugModes input[type="checkbox"] { width: 14px; height: 14px; margin: 0; }
27
+ #debugState { color: #7d8790; font-size: 11px; margin-top: 6px; }
28
+ aside { border-left: 1px solid #2d3338; padding: 18px; display: flex; flex-direction: column; gap: 18px; min-height: 0; }
29
+ aside > section:first-child, aside > section:nth-child(2) { flex-shrink: 0; } /* Display + GPIO stay visible */
30
+ aside > section:last-child { min-height: 0; display: flex; flex-direction: column; }
31
+ #diagnostics { overflow-y: auto; min-height: 0; }
32
+ h1 { font-size: 15px; margin: 0 0 8px; font-weight: 650; }
33
+ #status, #diagnostics, .empty { color: #aab3ba; font-size: 12px; line-height: 1.4; }
34
+ #pins { display: grid; gap: 8px; }
35
+ button { appearance: none; border: 1px solid #44505a; background: #252b30; color: #f3f6f8; border-radius: 6px; padding: 9px 10px; text-align: left; font: inherit; cursor: pointer; }
36
+ button:hover { background: #303841; }
37
+ @media (max-width: 760px) {
38
+ body { height: auto; min-height: 100vh; overflow: auto; grid-template-rows: auto 1fr; grid-template-columns: 1fr; }
39
+ aside { border-left: 0; border-top: 1px solid #2d3338; }
40
+ canvas { width: min(94vw, 640px); }
41
+ }
42
+ </style>
43
+ <script type="importmap">
44
+ {
45
+ "imports": {
46
+ "@typecad/ui/": "/__cuttlefish-ui/",
47
+ "@typecad/cuttlefish/api/shared": "/__cuttlefish/preview/api-shared-shim.js"
48
+ }
49
+ }
50
+ </script>
51
+ </head>
52
+ <body>
53
+ <main><canvas id="display" width="320" height="240"></canvas></main>
54
+ <aside>
55
+ <section>
56
+ <h1>Display</h1>
57
+ <div id="status"></div>
58
+ </section>
59
+ <section>
60
+ <h1>GPIO</h1>
61
+ <div id="pins"></div>
62
+ </section>
63
+ <section>
64
+ <h1>Debug overlay</h1>
65
+ <div id="debugModes">
66
+ <label><input type="checkbox" data-mode="boxes"> boxes (colors by kind)</label>
67
+ <label><input type="checkbox" data-mode="clips"> scroll clips (dashed)</label>
68
+ <label><input type="checkbox" data-mode="dirty"> dirty flash (repaints)</label>
69
+ <label><input type="checkbox" data-mode="inspect"> inspect taps (blocks input)</label>
70
+ </div>
71
+ <div id="debugState">overlay: off — check a box or press D</div>
72
+ <div style="color:#7d8790;font-size:11px;margin-top:4px">D cycles boxes/clips/dirty · Ctrl-click always inspects</div>
73
+ </section>
74
+ <section>
75
+ <h1>Diagnostics</h1>
76
+ <div id="diagnostics"></div>
77
+ </section>
78
+ </aside>
79
+ <script type="module" src="/__cuttlefish/preview/client.js"></script>
80
+ </body>
53
81
  </html>`;
54
82
  function contentType(filePath) {
55
83
  if (filePath.endsWith(".js"))
@@ -60,6 +88,42 @@ function contentType(filePath) {
60
88
  return "application/json; charset=utf-8";
61
89
  return "text/plain; charset=utf-8";
62
90
  }
91
+ /** Serve a file from a dist root, with ESM-specifier fallbacks: extensionless
92
+ * paths try +".js" (package-exports style "./preview/host-ui-runtime" →
93
+ * host-ui-runtime.js) and bare directories try +"/index.js". */
94
+ function serveFromRoot(res, root, relRaw) {
95
+ const rel = decodeURIComponent(relRaw);
96
+ const candidates = [rel, `${rel}.js`, `${rel}/index.js`];
97
+ for (const candidate of candidates) {
98
+ const filePath = path.resolve(root, candidate);
99
+ if (!filePath.startsWith(root))
100
+ break; // path traversal — 404 below
101
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
102
+ // no-store: these are compiled dist files that change between builds;
103
+ // heuristic browser caching serves stale modules after a rebuild and
104
+ // breaks the preview dev loop (and any debugging of it).
105
+ res.writeHead(200, { "content-type": contentType(filePath), "cache-control": "no-store" });
106
+ fs.createReadStream(filePath).pipe(res);
107
+ return;
108
+ }
109
+ }
110
+ writeText(res, 404, "Not found");
111
+ }
112
+ /** Resolve a dependency package's dist directory (served to the browser).
113
+ * Resolves through the package's exports map (subpath → dist file) and walks
114
+ * up to the enclosing "dist" — packages don't export "./package.json". */
115
+ function packageDistDir(specifier) {
116
+ const require = createRequire(import.meta.url);
117
+ const entry = require.resolve(specifier); // e.g. .../dist/engine.js
118
+ let dir = path.dirname(entry);
119
+ for (let i = 0; i < 4 && path.basename(dir) !== "dist"; i++) {
120
+ dir = path.dirname(dir);
121
+ }
122
+ if (path.basename(dir) !== "dist") {
123
+ throw new Error(`Could not locate the dist directory for ${specifier} (resolved ${entry}).`);
124
+ }
125
+ return dir;
126
+ }
63
127
  function writeText(res, status, text, type = "text/plain; charset=utf-8") {
64
128
  res.writeHead(status, { "content-type": type });
65
129
  res.end(text);
@@ -127,13 +191,28 @@ async function listen(server, preferredPort) {
127
191
  throw new Error(`No available preview port found starting at ${preferredPort}.`);
128
192
  }
129
193
  export async function runPreviewServer(options = {}) {
194
+ // The preview pipeline drives the UI engine directly (snapshot builds,
195
+ // type-decl generation) without going through transpileFile(), which is the
196
+ // only path that lazily registers the hook — so load it here first. Preview
197
+ // is a UI feature: when the engine is absent, fail with a clear message
198
+ // instead of the generic "hook is not registered" error.
199
+ await loadUIEngine();
200
+ if (!hasUIHook()) {
201
+ throw new Error(`cuttlefish preview requires the @typecad/ui package — install it in this project (npm install @typecad/ui).`);
202
+ }
130
203
  const configPath = resolveConfigPath(options.configPath);
131
204
  const config = parseConfigFile(configPath);
132
205
  if (!config)
133
206
  throw new Error(`Could not parse ${configPath}`);
207
+ // Snapshots re-read the config on every build (see /snapshot.json below);
208
+ // this holds the most recent config that parsed, for fallback mid-edit.
209
+ let lastGoodConfig = config;
134
210
  const projectRoot = path.dirname(configPath);
135
211
  requireUIHook().generateProjectUITypeDeclarations(projectRoot);
136
212
  const distRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
213
+ // The browser client imports the host runtime from @typecad/ui via the
214
+ // import map — resolve where that package's dist lives on this machine.
215
+ const uiDistRoot = packageDistDir("@typecad/ui/engine");
137
216
  const clients = new Set();
138
217
  const server = http.createServer(async (req, res) => {
139
218
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -144,13 +223,26 @@ export async function runPreviewServer(options = {}) {
144
223
  }
145
224
  if (url.pathname === "/snapshot.json") {
146
225
  requireUIHook().generateProjectUITypeDeclarations(projectRoot);
147
- // Routed through a variable (not a string literal) so tsc types this
148
- // as `any` and never resolves @typecad/ui's declaration files see
149
- // the comment in ui/ui-bridge.ts for why a literal specifier here
150
- // causes TS5055 on rebuilds where dist/ already exists.
151
- const buildProgramPath = "@typecad/ui/preview/build-program";
152
- const { buildPreviewSnapshot } = await import(buildProgramPath);
153
- const snapshot = await buildPreviewSnapshot({ config, projectRoot });
226
+ // Re-read the config for every snapshot build so edits (themeClass,
227
+ // entry, display) take effect on page refresh without restarting the
228
+ // preview server. A config that momentarily fails to parse (mid-edit)
229
+ // falls back to the last good one — the page keeps working; refresh
230
+ // again once the edit settles.
231
+ const latest = parseConfigFile(configPath) ?? lastGoodConfig;
232
+ lastGoodConfig = latest;
233
+ // Imported via a computed file:// URL (never a string-literal bare
234
+ // specifier) so tsc types this as `any` and never resolves
235
+ // @typecad/ui's declaration files — see the comment in ui/ui-bridge.ts
236
+ // for why a literal specifier causes TS5055 on rebuilds where dist/
237
+ // already exists. The ?t= cache-bust forces a fresh ESM load per
238
+ // snapshot build: without it the server caches the module graph from
239
+ // startup, and engine rebuilds (font planning, layout fixes, ...)
240
+ // never take effect until the server restarts — a recurring source of
241
+ // "fixed but the preview still shows it" confusion. Dev-only cost:
242
+ // re-evaluating the module graph per snapshot request.
243
+ const buildProgramUrl = pathToFileURL(path.join(uiDistRoot, "preview", "build-program.js")).href;
244
+ const { buildPreviewSnapshot } = await import(buildProgramUrl + "?t=" + Date.now());
245
+ const snapshot = await buildPreviewSnapshot({ config: latest, projectRoot });
154
246
  writeText(res, 200, JSON.stringify(snapshot), "application/json; charset=utf-8");
155
247
  return;
156
248
  }
@@ -165,15 +257,15 @@ export async function runPreviewServer(options = {}) {
165
257
  req.on("close", () => clients.delete(res));
166
258
  return;
167
259
  }
260
+ if (url.pathname.startsWith("/__cuttlefish-ui/")) {
261
+ // The @typecad/ui package's dist — the browser client resolves
262
+ // "@typecad/ui/..." bare specifiers onto this prefix via the import
263
+ // map (host runtime, gfx, and their relative engine imports).
264
+ serveFromRoot(res, uiDistRoot, url.pathname.slice("/__cuttlefish-ui/".length));
265
+ return;
266
+ }
168
267
  if (url.pathname.startsWith("/__cuttlefish/")) {
169
- const rel = decodeURIComponent(url.pathname.slice("/__cuttlefish/".length));
170
- const filePath = path.resolve(distRoot, rel);
171
- if (!filePath.startsWith(distRoot) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
172
- writeText(res, 404, "Not found");
173
- return;
174
- }
175
- res.writeHead(200, { "content-type": contentType(filePath) });
176
- fs.createReadStream(filePath).pipe(res);
268
+ serveFromRoot(res, distRoot, url.pathname.slice("/__cuttlefish/".length));
177
269
  return;
178
270
  }
179
271
  writeText(res, 404, "Not found");
@@ -0,0 +1,22 @@
1
+ /** One declaration (`--token: value`), order-preserving. */
2
+ type Decl = [name: string, value: string];
3
+ export interface ShadcnTheme {
4
+ name: string;
5
+ light: Decl[];
6
+ dark: Decl[];
7
+ }
8
+ /** Where a project keeps its own themes: src/styles/themes/*.css next to
9
+ * the kit stylesheet. Project themes win over same-named package ones —
10
+ * pasting a theme is dropping a file into YOUR project, never node_modules. */
11
+ export declare function projectThemesDir(projectRoot: string): string;
12
+ /** Theme names, project-local (src/styles/themes/) first, then the ones
13
+ * shipped in the package (assets/shadcn/themes/). */
14
+ export declare function listShadcnThemes(projectRoot?: string): string[];
15
+ /** Load a theme by name — project-local directory first, then the package
16
+ * included set. Throws with the available names when missing. */
17
+ export declare function loadShadcnTheme(name: string, projectRoot?: string): ShadcnTheme;
18
+ /** Merge a theme's tokens into a kit stylesheet's :root/.dark blocks and
19
+ * return the rewritten text. Throws when a merged block is missing any
20
+ * token the kit's recipes reference. */
21
+ export declare function applyShadcnTheme(cssText: string, theme: ShadcnTheme): string;
22
+ export {};