@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.
- package/dist/add-preset.d.ts +4 -0
- package/dist/add-preset.js +74 -0
- package/dist/api/config.d.ts +5 -5
- package/dist/api/shared/display-adapters/sdl.js +1 -1
- package/dist/api/shared/display-profile.d.ts +11 -0
- package/dist/api/shared/display-profile.js +3 -0
- package/dist/api/shared/framework-manifest.d.ts +85 -78
- package/dist/api/shared/framework-manifest.js +1 -0
- package/dist/api/shared/hal-op-ir.d.ts +19 -0
- package/dist/api/shared/toolchain-types.d.ts +0 -1
- package/dist/cli.js +21 -4
- package/dist/config-loader.d.ts +8 -2
- package/dist/config-loader.js +202 -53
- package/dist/config-schema.d.ts +7 -7
- package/dist/config-schema.js +3 -3
- package/dist/create/board-spec.d.ts +122 -122
- package/dist/create/debug-artifacts.d.ts +20 -0
- package/dist/create/debug-artifacts.js +69 -0
- package/dist/create/eslint-rules-template.js +6 -3
- package/dist/create/index.d.ts +2 -0
- package/dist/create/index.js +1 -0
- package/dist/create/init-scaffold.d.ts +1 -0
- package/dist/create/init-scaffold.js +5 -0
- package/dist/create/init-templates.js +0 -2
- package/dist/emit/compliance/rules.js +52 -4
- package/dist/emit/emitters/function-emitter-impl.js +7 -1
- package/dist/emit/emitters/line-appender.js +6 -0
- package/dist/emit/emitters/setup.js +15 -2
- package/dist/emit/emitters/ui-emitter.js +40 -15
- package/dist/emit/route-hal-op.js +55 -1
- package/dist/emit/statement-renderer.js +5 -2
- package/dist/ir/build-ir.js +22 -1
- package/dist/ir/expression-to-ir.js +17 -0
- package/dist/ir/feature-registry.js +22 -6
- package/dist/ir/hal/hal-emitter.js +23 -5
- package/dist/ir/hal/hal-plugins.js +11 -0
- package/dist/ir/pin-mode-validation.js +32 -9
- package/dist/ir/pin-state-tracking.d.ts +58 -0
- package/dist/ir/pin-state-tracking.js +182 -0
- package/dist/ir/program-analysis.d.ts +10 -2
- package/dist/ir/program-analysis.js +40 -4
- package/dist/ir/statement-to-ir.js +14 -0
- package/dist/ir/transformers/control-flow.js +29 -0
- package/dist/ir/transformers/ui-call-resolver.js +105 -1
- package/dist/ir/ui-element-auto-wire.js +7 -4
- package/dist/orchestrator/graph-builder.d.ts +4 -1
- package/dist/orchestrator/graph-builder.js +7 -1
- package/dist/platform/async-runtime.d.ts +1 -1
- package/dist/platform/async-runtime.js +12 -3
- package/dist/platform/generic-strategy.js +1 -1
- package/dist/preview/api-shared-shim.d.ts +1 -0
- package/dist/preview/api-shared-shim.js +7 -0
- package/dist/preview/client.js +220 -1
- package/dist/preview/server.js +154 -62
- package/dist/theme-tokens.d.ts +22 -0
- package/dist/theme-tokens.js +172 -0
- package/dist/transpile.js +90 -12
- package/dist/types.d.ts +5 -0
- package/dist/ui-hook.d.ts +7 -0
- package/dist/utils/cli.js +9 -0
- package/dist/utils/fs.d.ts +2 -0
- package/dist/utils/fs.js +16 -0
- package/dist/utils/ui.d.ts +5 -0
- package/dist/utils/ui.js +7 -0
- 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
|
|
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
|
|
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";
|
package/dist/preview/client.js
CHANGED
|
@@ -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
|
|
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) => {
|
package/dist/preview/server.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
<meta
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
-
|
|
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 {};
|