@typecad/cuttlefish 1.0.0-alpha.12 → 1.0.0-alpha.14

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 (101) hide show
  1. package/assets/editor-extensions/typecad-debug/README.md +142 -0
  2. package/assets/editor-extensions/typecad-debug/out/extension.js +347 -0
  3. package/assets/editor-extensions/typecad-debug/out/extension.js.map +1 -0
  4. package/assets/editor-extensions/typecad-debug/package.json +76 -0
  5. package/assets/editor-extensions/typecad-ui/LICENSE +27 -0
  6. package/assets/editor-extensions/typecad-ui/README.md +52 -0
  7. package/assets/editor-extensions/typecad-ui/file-icon-dark.png +0 -0
  8. package/assets/editor-extensions/typecad-ui/file-icon-light.png +0 -0
  9. package/assets/editor-extensions/typecad-ui/icon.png +0 -0
  10. package/assets/editor-extensions/typecad-ui/language-configuration.json +43 -0
  11. package/assets/editor-extensions/typecad-ui/package.json +54 -0
  12. package/assets/editor-extensions/typecad-ui/snippets/typecad-ui.json +80 -0
  13. package/assets/editor-extensions/typecad-ui/syntaxes/markdown-ui.json +45 -0
  14. package/assets/editor-extensions/typecad-ui/syntaxes/typecad-ui.tmLanguage.json +1269 -0
  15. package/dist/add-preset.d.ts +4 -0
  16. package/dist/add-preset.js +74 -0
  17. package/dist/api/config.d.ts +8 -5
  18. package/dist/api/shared/display-adapters/sdl.js +1 -1
  19. package/dist/api/shared/display-profile.d.ts +11 -0
  20. package/dist/api/shared/display-profile.js +3 -0
  21. package/dist/api/shared/framework-manifest.d.ts +61 -504
  22. package/dist/api/shared/hal-op-ir.d.ts +19 -0
  23. package/dist/api/shared/toolchain-types.d.ts +0 -1
  24. package/dist/cli.js +31 -9
  25. package/dist/config-loader.d.ts +0 -2
  26. package/dist/config-loader.js +13 -6
  27. package/dist/config-schema.d.ts +17 -230
  28. package/dist/config-schema.js +1 -3
  29. package/dist/contract/contract-parser.d.ts +9 -127
  30. package/dist/create/board-checklist.js +1 -1
  31. package/dist/create/board-codegen.js +4 -4
  32. package/dist/create/board-spec.d.ts +74 -530
  33. package/dist/create/debug-artifacts.d.ts +20 -0
  34. package/dist/create/debug-artifacts.js +69 -0
  35. package/dist/create/editor-integration.d.ts +30 -0
  36. package/dist/create/editor-integration.js +218 -0
  37. package/dist/create/eslint-rules-template.d.ts +2 -2
  38. package/dist/create/framework-catalog.d.ts +8 -0
  39. package/dist/create/framework-catalog.js +28 -5
  40. package/dist/create/index.d.ts +9 -6
  41. package/dist/create/index.js +6 -4
  42. package/dist/create/{init-scaffold.d.ts → scaffold.d.ts} +5 -4
  43. package/dist/create/{init-scaffold.js → scaffold.js} +38 -2
  44. package/dist/create/templates.d.ts +29 -0
  45. package/dist/create/{init-templates.js → templates.js} +15 -2
  46. package/dist/create/wizard.d.ts +8 -0
  47. package/dist/create/{init-wizard.js → wizard.js} +11 -4
  48. package/dist/emit/compliance/rules.js +18 -4
  49. package/dist/emit/emitters/function-emitter-impl.js +7 -1
  50. package/dist/emit/emitters/line-appender.js +6 -0
  51. package/dist/emit/emitters/setup.js +22 -0
  52. package/dist/emit/emitters/ui-emitter.js +40 -15
  53. package/dist/emit/route-hal-op.js +55 -1
  54. package/dist/emit/statement-renderer.js +5 -2
  55. package/dist/ir/build-ir.js +46 -4
  56. package/dist/ir/expression-to-ir.js +17 -0
  57. package/dist/ir/hal/hal-emitter.js +23 -5
  58. package/dist/ir/hal/hal-plugins.js +11 -0
  59. package/dist/ir/pin-mode-validation.js +32 -9
  60. package/dist/ir/pin-state-tracking.d.ts +58 -0
  61. package/dist/ir/pin-state-tracking.js +182 -0
  62. package/dist/ir/program-analysis.d.ts +6 -0
  63. package/dist/ir/program-analysis.js +38 -0
  64. package/dist/ir/statement-to-ir.js +14 -0
  65. package/dist/ir/transformers/control-flow.js +29 -0
  66. package/dist/ir/transformers/ui-call-resolver.js +105 -1
  67. package/dist/ir/ui-element-auto-wire.js +7 -4
  68. package/dist/library/catalog.d.ts +35 -0
  69. package/dist/library/catalog.js +67 -0
  70. package/dist/library/cli.d.ts +2 -0
  71. package/dist/library/cli.js +168 -0
  72. package/dist/library/init.d.ts +25 -0
  73. package/dist/library/init.js +397 -0
  74. package/dist/library/install.d.ts +9 -0
  75. package/dist/library/install.js +80 -0
  76. package/dist/library/registry-search.d.ts +34 -0
  77. package/dist/library/registry-search.js +50 -0
  78. package/dist/library/validate.d.ts +17 -0
  79. package/dist/library/validate.js +175 -0
  80. package/dist/library-packages.d.ts +95 -0
  81. package/dist/library-packages.js +275 -0
  82. package/dist/orchestrator/graph-builder.d.ts +4 -1
  83. package/dist/orchestrator/graph-builder.js +23 -1
  84. package/dist/preview/api-shared-shim.d.ts +1 -0
  85. package/dist/preview/api-shared-shim.js +7 -0
  86. package/dist/preview/client.js +235 -2
  87. package/dist/preview/server.js +154 -62
  88. package/dist/testing.d.ts +2 -2
  89. package/dist/testing.js +1 -1
  90. package/dist/theme-tokens.d.ts +22 -0
  91. package/dist/theme-tokens.js +172 -0
  92. package/dist/transpile.js +90 -5
  93. package/dist/types.d.ts +24 -0
  94. package/dist/ui-hook.d.ts +7 -0
  95. package/dist/utils/cli.d.ts +2 -2
  96. package/dist/utils/cli.js +77 -3
  97. package/dist/utils/ui.d.ts +5 -0
  98. package/dist/utils/ui.js +7 -0
  99. package/package.json +18 -9
  100. package/dist/create/init-templates.d.ts +0 -28
  101. package/dist/create/init-wizard.d.ts +0 -8
@@ -0,0 +1,275 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Cuttlefish library packages
3
+ //
4
+ // A library package is an npm package whose root carries a
5
+ // `cuttlefish.library.json` manifest. It contributes native artifacts to the
6
+ // generated firmware application:
7
+ //
8
+ // - an include mapping (the import emits `#include` of the shim header)
9
+ // - shim files (native C++ written into the emitted source directory)
10
+ // - framework build contributions (Kconfig lines, a devicetree overlay
11
+ // fragment) consumed by the framework toolchain via the sidecar
12
+ //
13
+ // Discovery is import-driven: when the transpile graph builder resolves an
14
+ // import whose package ships the manifest, the package is registered at that
15
+ // moment and its TypeScript is NOT transpiled — the package's own types are
16
+ // the compile-time contract, and the native shim is the implementation.
17
+ // Nothing scans node_modules; an installed-but-never-imported library
18
+ // contributes nothing.
19
+ //
20
+ // The transpiler writes a `libraries.json` sidecar next to the emitted
21
+ // sources (the board-constants.json convention) recording the libraries that
22
+ // were actually used, so build-time consumers (framework toolchains) can
23
+ // re-derive their contributions idempotently from disk.
24
+ // ---------------------------------------------------------------------------
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { createRequire } from "node:module";
28
+ import { toModuleKey } from "./utils/strings.js";
29
+ /** Sidecar file name — written next to the emitted sources. */
30
+ export const LIBRARY_SIDECAR_NAME = "libraries.json";
31
+ const registered = new Map();
32
+ /** Specifiers known NOT to be library packages (avoids repeated resolve attempts). */
33
+ const knownNonLibraries = new Set();
34
+ /** Clear all registrations. Called once per transpilation, before graph build. */
35
+ export function resetCuttlefishLibraries() {
36
+ registered.clear();
37
+ knownNonLibraries.clear();
38
+ }
39
+ /** All libraries registered during this transpilation (import-driven). */
40
+ export function getRegisteredCuttlefishLibraries() {
41
+ return [...registered.values()];
42
+ }
43
+ /**
44
+ * Whether the specifier resolves to a library registered in this
45
+ * transpilation. Library packages are exempt from the @typecad/* "SDK
46
+ * imports are type-level only" include skip — their import IS the include
47
+ * of the shim header.
48
+ */
49
+ export function isRegisteredCuttlefishLibrary(moduleSpecifier) {
50
+ return registered.has(moduleSpecifier.toLowerCase());
51
+ }
52
+ function parseManifest(packageRoot) {
53
+ const manifestPath = path.join(packageRoot, "cuttlefish.library.json");
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ if (typeof raw !== "object" || raw === null)
62
+ return undefined;
63
+ const m = raw;
64
+ if (typeof m.id !== "string" ||
65
+ typeof m.module !== "string" ||
66
+ typeof m.framework !== "string" ||
67
+ typeof m.include !== "string" ||
68
+ typeof m.gateToken !== "string" ||
69
+ !Array.isArray(m.shims) ||
70
+ m.shims.length === 0) {
71
+ return undefined;
72
+ }
73
+ for (const shim of m.shims) {
74
+ if (typeof shim?.path !== "string" || typeof shim?.outName !== "string") {
75
+ return undefined;
76
+ }
77
+ }
78
+ return {
79
+ id: m.id,
80
+ module: m.module,
81
+ framework: m.framework,
82
+ targets: m.targets,
83
+ include: m.include,
84
+ gateToken: m.gateToken,
85
+ shims: m.shims,
86
+ kconfig: Array.isArray(m.kconfig) ? m.kconfig.filter((k) => typeof k === "string") : [],
87
+ overlay: typeof m.overlay === "string" ? m.overlay : undefined,
88
+ };
89
+ }
90
+ /**
91
+ * Resolve the package root for a bare npm specifier from the importing file,
92
+ * then register the package as a cuttlefish library if it ships a
93
+ * `cuttlefish.library.json`. Returns true when the specifier resolved to a
94
+ * registered library (the import must then be skipped by the graph builder).
95
+ */
96
+ export function registerCuttlefishLibraryFromSpecifier(fromFile, moduleSpecifier) {
97
+ // Only bare npm specifiers ('pkg' or '@scope/pkg'[/subpath]) — relative
98
+ // imports and node builtins cannot be library packages.
99
+ if (moduleSpecifier.startsWith(".") || moduleSpecifier.startsWith("node:")) {
100
+ return false;
101
+ }
102
+ const cacheKey = moduleSpecifier.toLowerCase();
103
+ if (knownNonLibraries.has(cacheKey))
104
+ return false;
105
+ if (registered.has(cacheKey))
106
+ return true;
107
+ let packageRoot;
108
+ try {
109
+ const req = createRequire(path.resolve(fromFile));
110
+ // Resolve the package entry, then walk up to the nearest package.json —
111
+ // the package root. (Resolving the manifest subpath directly would be
112
+ // blocked by exports encapsulation; the entry point always resolves.)
113
+ const entry = req.resolve(moduleSpecifier);
114
+ let dir = path.dirname(entry);
115
+ while (dir !== path.dirname(dir)) {
116
+ if (fs.existsSync(path.join(dir, "package.json")))
117
+ break;
118
+ dir = path.dirname(dir);
119
+ }
120
+ if (dir !== path.dirname(dir))
121
+ packageRoot = dir;
122
+ }
123
+ catch {
124
+ packageRoot = undefined;
125
+ }
126
+ if (packageRoot === undefined) {
127
+ knownNonLibraries.add(cacheKey);
128
+ return false;
129
+ }
130
+ const manifest = parseManifest(packageRoot);
131
+ if (manifest === undefined) {
132
+ knownNonLibraries.add(cacheKey);
133
+ return false;
134
+ }
135
+ registered.set(cacheKey, { manifest, packageRoot });
136
+ return true;
137
+ }
138
+ /** Libdef entries for the registry: import → shim include. */
139
+ export function cuttlefishLibraryLibdefs() {
140
+ return getRegisteredCuttlefishLibraries().map(({ manifest }) => ({
141
+ module: manifest.module,
142
+ include: manifest.include,
143
+ }));
144
+ }
145
+ /**
146
+ * Validate registered libraries against the loaded framework (and, when
147
+ * known, the build target). Throws with an actionable message when a library
148
+ * cannot work in this project — far earlier and clearer than the native
149
+ * compiler would.
150
+ */
151
+ export function validateCuttlefishLibraries(frameworkId, buildTarget) {
152
+ for (const { manifest } of getRegisteredCuttlefishLibraries()) {
153
+ if (manifest.framework !== frameworkId) {
154
+ throw new Error(`Library ${manifest.module} requires the '${manifest.framework}' framework, ` +
155
+ `but this project uses '${frameworkId}'. ` +
156
+ `Install the matching framework or a '${frameworkId}' counterpart of this library.`);
157
+ }
158
+ if (buildTarget !== undefined &&
159
+ manifest.targets !== undefined &&
160
+ manifest.targets.length > 0 &&
161
+ !manifest.targets.some((t) => buildTarget.toLowerCase().startsWith(t.toLowerCase()))) {
162
+ throw new Error(`Library ${manifest.module} supports board targets ` +
163
+ `[${manifest.targets.join(", ")}], but this project builds for '${buildTarget}'.`);
164
+ }
165
+ }
166
+ }
167
+ function writeIfChanged(filePath, content) {
168
+ try {
169
+ if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === content)
170
+ return;
171
+ }
172
+ catch {
173
+ /* fall through and write */
174
+ }
175
+ fs.writeFileSync(filePath, content);
176
+ }
177
+ function readEmittedTokens(srcDir) {
178
+ let out = "";
179
+ let names = [];
180
+ try {
181
+ names = fs.readdirSync(srcDir);
182
+ }
183
+ catch {
184
+ return "";
185
+ }
186
+ for (const name of names) {
187
+ if (!/\.(cpp|cc|c|h|hpp|ino)$/.test(name))
188
+ continue;
189
+ try {
190
+ out += fs.readFileSync(path.join(srcDir, name), "utf8");
191
+ }
192
+ catch {
193
+ /* best-effort */
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ function readSidecar(sidecarPath) {
199
+ try {
200
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
201
+ if (!Array.isArray(raw))
202
+ return [];
203
+ return raw.filter((e) => typeof e === "object" && e !== null && typeof e.module === "string");
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ }
209
+ /**
210
+ * Emit the library artifacts for the libraries actually used in this
211
+ * transpilation, and write the `libraries.json` sidecar:
212
+ *
213
+ * 1. scan the emitted sources for each library's gate token;
214
+ * 2. write (write-if-changed) the shims of every gated library into the
215
+ * emitted source directory — the framework scaffold's CMake/sketch
216
+ * regen picks the sources up automatically;
217
+ * 3. delete shim files recorded in the previous sidecar that are no longer
218
+ * used, so removing an import removes its native code;
219
+ * 4. persist the sidecar (even when empty — that clears stale state).
220
+ */
221
+ export function writeCuttlefishLibraryArtifacts(outDir, srcDir) {
222
+ const sidecarPath = path.join(outDir, LIBRARY_SIDECAR_NAME);
223
+ const previous = readSidecar(sidecarPath);
224
+ const previousShims = new Set(previous.flatMap((e) => e.shims));
225
+ const emitted = readEmittedTokens(srcDir);
226
+ const usedShims = new Set();
227
+ const entries = [];
228
+ for (const { manifest, packageRoot } of getRegisteredCuttlefishLibraries()) {
229
+ if (!emitted.includes(manifest.gateToken))
230
+ continue;
231
+ for (const shim of manifest.shims) {
232
+ let content;
233
+ try {
234
+ content = fs.readFileSync(path.join(packageRoot, shim.path), "utf8");
235
+ }
236
+ catch {
237
+ continue; // broken library packaging — the missing header surfaces at compile
238
+ }
239
+ writeIfChanged(path.join(srcDir, shim.outName), content);
240
+ usedShims.add(shim.outName);
241
+ }
242
+ entries.push({
243
+ id: manifest.id,
244
+ module: manifest.module,
245
+ framework: manifest.framework,
246
+ gateToken: manifest.gateToken,
247
+ kconfig: manifest.kconfig ?? [],
248
+ overlay: manifest.overlay ? path.join(packageRoot, manifest.overlay) : null,
249
+ shims: manifest.shims.map((s) => s.outName).filter((n) => usedShims.has(n)),
250
+ });
251
+ }
252
+ // Remove shims from a previous build whose library is no longer used.
253
+ for (const name of previousShims) {
254
+ if (usedShims.has(name))
255
+ continue;
256
+ try {
257
+ fs.rmSync(path.join(srcDir, name), { force: true });
258
+ }
259
+ catch {
260
+ /* best-effort */
261
+ }
262
+ }
263
+ writeIfChanged(sidecarPath, JSON.stringify(entries, null, 2) + "\n");
264
+ }
265
+ /**
266
+ * Read the `libraries.json` sidecar for build-time consumers (framework
267
+ * toolchains). Tolerant of a missing or corrupt file (returns []).
268
+ */
269
+ export function readCuttlefishLibrarySidecar(outDir) {
270
+ return readSidecar(path.join(outDir, LIBRARY_SIDECAR_NAME));
271
+ }
272
+ /** Re-exported for transpile.ts libdef injection convenience. */
273
+ export function libraryDefinitionKey(module) {
274
+ return toModuleKey(module);
275
+ }
@@ -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>;
@@ -3,6 +3,7 @@ import ts from "typescript";
3
3
  import { readText } from "../utils/fs.js";
4
4
  import { detectNativeCppModule, getNpmPackageInfoForFile, isInNodeModules, resolveImport, isCuttlefishSDKPath, } from "../transpile/resolution.js";
5
5
  import { requireUIHook } from "../ui-hook.js";
6
+ import { registerCuttlefishLibraryFromSpecifier } from "../library-packages.js";
6
7
  /**
7
8
  * Sort files in dependency order using Kahn's algorithm.
8
9
  * Dependencies come before dependents so that include ordering is correct
@@ -68,7 +69,7 @@ export function topologicalSortFiles(files, dependencies) {
68
69
  * @param boardPackage When provided, `@typecad/board` imports resolve to this
69
70
  * board package (e.g. `'@typecad/board-arduino-uno'`).
70
71
  */
71
- export function collectTranspileGraph(entryFile, boardPackage) {
72
+ export async function collectTranspileGraph(entryFile, boardPackage, imageDecodeOpts) {
72
73
  const ordered = [];
73
74
  const pending = [path.resolve(entryFile)];
74
75
  const visited = new Set();
@@ -102,6 +103,10 @@ export function collectTranspileGraph(entryFile, boardPackage) {
102
103
  const parts = ui.splitUiFile(sourceText);
103
104
  // Register the template as a UI module at <file>.ui.html (synthetic path).
104
105
  const uiHtmlPath = filePath + ".html";
106
+ // Prime the image-conversion cache before the (synchronous) module
107
+ // load — <img src="*.png|jpg|ico|…"> decodes here, and the module's
108
+ // asset reader + natural-size layout pull from the cache.
109
+ await ui.warmUpImageDecoding(parts.html, path.dirname(filePath), imageDecodeOpts ?? {});
105
110
  ui.loadUIModuleFromText(uiHtmlPath, parts.html, parts.style, filePath);
106
111
  uiModules.add(uiHtmlPath);
107
112
  // Use the <script> as the TS source for import-graph walking. Inject an
@@ -121,6 +126,14 @@ export function collectTranspileGraph(entryFile, boardPackage) {
121
126
  }
122
127
  if (!moduleSpecifier)
123
128
  continue;
129
+ // Cuttlefish library package: an import whose package ships a
130
+ // cuttlefish.library.json contributes native shims + build fragments
131
+ // instead of transpiling — its own TypeScript types are the contract.
132
+ // Must precede the @typecad/* skips: scoped libraries would otherwise
133
+ // be dropped as "SDK packages" here (the .ui script loop skips ALL
134
+ // @typecad/* specifiers, unlike the selective main-loop skip list).
135
+ if (registerCuttlefishLibraryFromSpecifier(filePath, moduleSpecifier))
136
+ continue;
124
137
  if (moduleSpecifier === "@typecad/expect" || moduleSpecifier === "@typecad/ui" || moduleSpecifier === "@typecad/safety")
125
138
  continue;
126
139
  if (moduleSpecifier.startsWith("@typecad/"))
@@ -154,6 +167,13 @@ export function collectTranspileGraph(entryFile, boardPackage) {
154
167
  nativeModules.set(moduleSpecifier, nativeModule);
155
168
  continue; // Don't try to resolve as TypeScript
156
169
  }
170
+ // Cuttlefish library package: an import whose package ships a
171
+ // cuttlefish.library.json contributes native shims + build fragments
172
+ // instead of transpiling — its own TypeScript types are the contract.
173
+ // Checked before the @typecad/* skips so scoped libraries register.
174
+ if (registerCuttlefishLibraryFromSpecifier(filePath, moduleSpecifier)) {
175
+ continue;
176
+ }
157
177
  // Skip @typecad/expect — it provides type-level stubs only.
158
178
  // The AST preprocessor rewrites all expect calls before transpilation.
159
179
  if (moduleSpecifier === "@typecad/expect") {
@@ -189,6 +209,8 @@ export function collectTranspileGraph(entryFile, boardPackage) {
189
209
  // .ui.html modules: load into the UI registry, record the path, and don't
190
210
  // push onto `pending` (they are never parsed as TypeScript).
191
211
  if (resolved?.uiModule) {
212
+ const uiHtmlText = readText(resolved.sourcePath);
213
+ await requireUIHook().warmUpImageDecoding(uiHtmlText, path.dirname(resolved.sourcePath), imageDecodeOpts ?? {});
192
214
  requireUIHook().loadUIModule(resolved.sourcePath);
193
215
  uiModules.add(resolved.sourcePath);
194
216
  // Track the dependency edge so topological sort orders the importer
@@ -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,172 @@ 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
+ /** Re-syncs the overlay to the app canvas whenever layout changes its box
12
+ * (aspect-ratio application, max-height, container reflow). */
13
+ let overlayResizeObserver;
14
+ let dirtyFlashAlpha = 0;
15
+ /** CSS px per logical display px — the overlay backing store runs at this
16
+ * resolution so debug strokes are 1 CSS px thin (a 1-logical-px stroke on
17
+ * the app canvas's own backing store would upscale 3x thick and bury the
18
+ * content under investigation). */
19
+ let debugScale = 1;
20
+ // Stroke color per node kind: a screenshot should be self-describing.
21
+ // views cyan · text yellow · interactive magenta · img/canvas orange ·
22
+ // list green · screen transparent (skip; it's the whole panel).
23
+ function debugColor(tag, kind) {
24
+ if (tag === "screen")
25
+ return undefined;
26
+ if (tag === "img" || tag === "canvas")
27
+ return "#ff9a3c";
28
+ if (tag === "list")
29
+ return "#3ddc84";
30
+ if (["button", "input", "select", "check", "radio", "range", "progress", "drawer"].includes(tag))
31
+ return "#ff4fd8";
32
+ if (kind === "text")
33
+ return "#ffe14d";
34
+ return "#3cc8ff";
35
+ }
36
+ function applyDebugCapture() {
37
+ runtime?.setDebugCapture?.(debugModes.boxes || debugModes.clips || debugModes.dirty);
38
+ const state = document.getElementById("debugState");
39
+ if (state) {
40
+ const on = ["boxes", "clips", "dirty", "inspect"].filter((m) => debugModes[m]);
41
+ state.textContent = on.length === 0
42
+ ? "overlay: off — check a box or press D"
43
+ : `overlay: ${on.join("+")}`;
44
+ }
45
+ }
46
+ function drawDebugOverlay(width, height) {
47
+ if (!debugOverlay || !debugCtx)
48
+ return;
49
+ const ctx = debugCtx;
50
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
51
+ ctx.clearRect(0, 0, debugOverlay.width, debugOverlay.height);
52
+ ctx.setTransform(debugScale, 0, 0, debugScale, 0, 0);
53
+ const anyVisual = debugModes.boxes || debugModes.clips || debugModes.dirty;
54
+ if (!anyVisual || !runtime?.debugInfo)
55
+ return;
56
+ const info = runtime.debugInfo();
57
+ if (debugModes.dirty) {
58
+ // Flash the regions repainted this frame; fades until the next repaint.
59
+ dirtyFlashAlpha = Math.min(0.55, dirtyFlashAlpha + 0.35);
60
+ ctx.fillStyle = `rgba(255, 225, 77, ${dirtyFlashAlpha.toFixed(2)})`;
61
+ for (const r of info.painted)
62
+ ctx.fillRect(r.x, r.y, r.w, r.h);
63
+ }
64
+ else {
65
+ dirtyFlashAlpha = 0;
66
+ }
67
+ // 1 CSS px regardless of the app canvas's logical upscaling — thicker
68
+ // strokes bury the content being inspected.
69
+ ctx.lineWidth = Math.max(1 / debugScale, 0.5);
70
+ if (debugModes.clips) {
71
+ // Viewport tint guarantees visibility at any stroke sampling; the dashed
72
+ // outline marks the exact clip edges.
73
+ for (const c of info.clips) {
74
+ ctx.fillStyle = "rgba(61, 220, 132, 0.08)";
75
+ ctx.fillRect(c.x, c.y, c.w, c.h);
76
+ }
77
+ ctx.strokeStyle = "#3ddc84";
78
+ ctx.setLineDash([3, 2]);
79
+ for (const c of info.clips)
80
+ ctx.strokeRect(c.x, c.y, c.w - 1, c.h - 1);
81
+ ctx.setLineDash([]);
82
+ }
83
+ if (debugModes.boxes) {
84
+ for (const n of info.nodes) {
85
+ const color = debugColor(n.tag, n.kind);
86
+ if (!color)
87
+ continue;
88
+ ctx.strokeStyle = color;
89
+ ctx.strokeRect(n.x + 0.5, n.y + 0.5, n.w - 1, n.h - 1);
90
+ // Untappable interactive elements get a red corner mark — the
91
+ // id-less-element trap (the preview wires by id).
92
+ if (!n.tappable && ["button", "input", "select", "check", "radio", "list"].includes(n.tag)) {
93
+ ctx.strokeStyle = "#ff5252";
94
+ ctx.beginPath();
95
+ ctx.moveTo(n.x, n.y);
96
+ ctx.lineTo(n.x + 6, n.y);
97
+ ctx.moveTo(n.x, n.y);
98
+ ctx.lineTo(n.x, n.y + 6);
99
+ ctx.stroke();
100
+ }
101
+ }
102
+ }
103
+ }
104
+ function inspectAt(px, py, width, height, report) {
105
+ if (!runtime?.debugHit)
106
+ return false;
107
+ const i = runtime.debugHit(px, py);
108
+ if (i < 0) {
109
+ report(`inspect (${px},${py}): empty space`);
110
+ return true;
111
+ }
112
+ const info = runtime.debugInfo();
113
+ const n = info.nodes.find((x) => x.i === i);
114
+ if (!n)
115
+ return false;
116
+ 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}`);
117
+ return true;
118
+ }
119
+ function wireDebugControls(canvas, width, height, report, redraw) {
120
+ const modesBox = document.getElementById("debugModes");
121
+ if (modesBox) {
122
+ // ?debug=boxes,clips,dirty,inspect seeds the initial state.
123
+ const params = new URLSearchParams(location.search);
124
+ const seeded = (params.get("debug") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
125
+ for (const input of Array.from(modesBox.querySelectorAll("input[data-mode]"))) {
126
+ const mode = input.dataset.mode;
127
+ if (seeded.includes(mode))
128
+ debugModes[mode] = true;
129
+ input.checked = debugModes[mode];
130
+ input.addEventListener("change", () => {
131
+ debugModes[mode] = input.checked;
132
+ applyDebugCapture();
133
+ redraw();
134
+ });
135
+ }
136
+ }
137
+ // D cycles boxes → clips → dirty → off (inspect stays manual — it blocks
138
+ // normal input and shouldn't be cycled into by accident).
139
+ window.addEventListener("keydown", (e) => {
140
+ if (e.key !== "d" && e.key !== "D")
141
+ return;
142
+ const on = debugModes.boxes || debugModes.clips || debugModes.dirty;
143
+ if (!on)
144
+ debugModes = { ...debugModes, boxes: true };
145
+ else if (debugModes.boxes)
146
+ debugModes = { ...debugModes, boxes: false, clips: true };
147
+ else if (debugModes.clips)
148
+ debugModes = { ...debugModes, clips: false, dirty: true };
149
+ else
150
+ debugModes = { ...debugModes, dirty: false };
151
+ for (const input of Array.from((modesBox ?? document).querySelectorAll("input[data-mode]"))) {
152
+ const mode = input.dataset.mode;
153
+ input.checked = debugModes[mode];
154
+ }
155
+ applyDebugCapture();
156
+ redraw();
157
+ });
158
+ // Inspect taps: while inspect mode is on, taps report instead of
159
+ // interacting. Ctrl-click always inspects without entering the mode.
160
+ const inspectPointer = (event, force) => {
161
+ if (!force && !debugModes.inspect)
162
+ return false;
163
+ const p = canvasPoint(canvas, event, width, height);
164
+ inspectAt(p.x, p.y, width, height, report);
165
+ return true;
166
+ };
167
+ canvas.addEventListener("pointerdown", (event) => {
168
+ if (inspectPointer(event, event.ctrlKey || event.metaKey)) {
169
+ event.stopImmediatePropagation();
170
+ event.preventDefault();
171
+ }
172
+ }, true);
173
+ }
8
174
  function byId(id) {
9
175
  const el = document.getElementById(id);
10
176
  if (!el)
@@ -74,9 +240,48 @@ async function start() {
74
240
  if (!ctx)
75
241
  throw new Error("2D canvas context unavailable");
76
242
  ctx.imageSmoothingEnabled = false;
243
+ // Debug overlay: same backing resolution + CSS as the app canvas, stacked
244
+ // exactly on top; pointer-events pass through to the app canvas below.
245
+ debugOverlay?.remove();
246
+ debugOverlay = document.createElement("canvas");
247
+ debugOverlay.id = "debugOverlay";
248
+ debugOverlay.width = snapshot.program.width;
249
+ debugOverlay.height = snapshot.program.height;
250
+ canvas.parentElement?.appendChild(debugOverlay);
251
+ debugCtx = debugOverlay.getContext("2d") ?? undefined;
252
+ const syncOverlayBox = () => {
253
+ if (!debugOverlay)
254
+ return;
255
+ const rect = canvas.getBoundingClientRect();
256
+ const parent = canvas.parentElement;
257
+ const prect = parent.getBoundingClientRect();
258
+ debugOverlay.style.left = `${rect.left - prect.left}px`;
259
+ debugOverlay.style.top = `${rect.top - prect.top}px`;
260
+ debugOverlay.style.width = `${rect.width}px`;
261
+ debugOverlay.style.height = `${rect.height}px`;
262
+ // Backing store at CSS-pixel resolution: strokes draw at device-crisp
263
+ // 1 CSS px regardless of the app canvas's logical upscaling.
264
+ const dpr = window.devicePixelRatio || 1;
265
+ debugOverlay.width = Math.max(1, Math.round(rect.width * dpr));
266
+ debugOverlay.height = Math.max(1, Math.round(rect.height * dpr));
267
+ debugScale = (rect.width * dpr) / snapshot.program.width;
268
+ };
77
269
  // Set the canvas CSS aspect-ratio to match the display (e.g. 128:64 for
78
- // OLED, 320:240 for TFT) so the browser scales it proportionally.
270
+ // OLED, 320:240 for TFT) BEFORE syncing the overlay: the canvas is
271
+ // vertically centered (place-items: center), so applying the real aspect
272
+ // after the sync SHRANK it and moved it DOWN — the overlay stayed at the
273
+ // old 4/3-fallback position and every debug box sat that far above its
274
+ // element. A ResizeObserver re-syncs on any later layout-driven size
275
+ // change (max-height kicking in, container reflow), not just window
276
+ // resizes.
79
277
  document.documentElement.style.setProperty("--display-aspect", `${snapshot.program.width} / ${snapshot.program.height}`);
278
+ syncOverlayBox();
279
+ window.addEventListener("resize", syncOverlayBox);
280
+ if (typeof ResizeObserver !== "undefined") {
281
+ overlayResizeObserver?.disconnect();
282
+ overlayResizeObserver = new ResizeObserver(() => syncOverlayBox());
283
+ overlayResizeObserver.observe(canvas);
284
+ }
80
285
  const status = byId("status");
81
286
  status.textContent = `${snapshot.profileName ?? snapshot.program.display?.driver ?? "display"} ${snapshot.program.width}x${snapshot.program.height} ${snapshot.program.colorFormat}`;
82
287
  const extraDiagnostics = [];
@@ -92,16 +297,39 @@ async function start() {
92
297
  return;
93
298
  imageData.data.set(rgba);
94
299
  ctx.putImageData(imageData, 0, 0);
300
+ drawDebugOverlay(snapshot.program.width, snapshot.program.height);
95
301
  },
96
302
  onDiagnostics: (message) => {
97
303
  extraDiagnostics.push(message);
98
304
  renderDiagnostics(snapshot, extraDiagnostics);
99
305
  },
100
306
  });
307
+ // Mouse wheel scrolls the scroll owner under the cursor — same hit-scan
308
+ // as a drag (lists, scroll containers). Without this, wheel-scrolling a
309
+ // canvas preview does nothing.
310
+ canvas.onwheel = (event) => {
311
+ if (!runtime)
312
+ return;
313
+ event.preventDefault();
314
+ const p = canvasPoint(canvas, event, snapshot.program.width, snapshot.program.height);
315
+ // deltaMode: 0 = pixels, 1 = lines, 2 = pages.
316
+ const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
317
+ runtime.wheel(p.x, p.y, event.deltaY * scale);
318
+ };
101
319
  canvas.onpointerdown = (event) => {
102
320
  if (!runtime)
103
321
  return;
104
- canvas.setPointerCapture(event.pointerId);
322
+ // Capture keeps drags flowing when the pointer leaves the canvas, but
323
+ // synthetic pointers (CDP/browser automation) have no capturable
324
+ // pointerId — setPointerCapture throws InvalidPointerId for them and
325
+ // would kill the tap before runtime.pointerDown runs. Losing capture
326
+ // only degrades off-canvas drags, so swallow the failure.
327
+ try {
328
+ canvas.setPointerCapture(event.pointerId);
329
+ }
330
+ catch {
331
+ /* not a capturable pointer — canvas-local move/up still arrive */
332
+ }
105
333
  const p = canvasPoint(canvas, event, snapshot.program.width, snapshot.program.height);
106
334
  runtime.pointerDown(p.x, p.y);
107
335
  };
@@ -123,6 +351,11 @@ async function start() {
123
351
  canvas.onpointercancel = pointerUp;
124
352
  renderPinControls(snapshot.pinControls);
125
353
  renderDiagnostics(snapshot);
354
+ wireDebugControls(canvas, snapshot.program.width, snapshot.program.height, (message) => {
355
+ extraDiagnostics.push(message);
356
+ renderDiagnostics(snapshot, extraDiagnostics);
357
+ }, () => drawDebugOverlay(snapshot.program.width, snapshot.program.height));
358
+ applyDebugCapture();
126
359
  runtime.start();
127
360
  }
128
361
  void start().catch((error) => {