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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +21 -1
  2. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  7. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  8. package/assets/fonts/dejavu/LICENSE +187 -0
  9. package/assets/fonts/dejavu/README.md +49 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +74 -0
  12. package/dist/engine-index.js +2 -0
  13. package/dist/preview/build-program.d.ts +7 -1
  14. package/dist/preview/build-program.js +94 -15
  15. package/dist/preview/host-gfx.d.ts +3 -0
  16. package/dist/preview/host-gfx.js +30 -3
  17. package/dist/preview/host-ui-runtime.d.ts +115 -0
  18. package/dist/preview/host-ui-runtime.js +928 -46
  19. package/dist/ui-engine/block-layout.js +25 -11
  20. package/dist/ui-engine/color.d.ts +6 -0
  21. package/dist/ui-engine/color.js +80 -2
  22. package/dist/ui-engine/compat-report.d.ts +8 -0
  23. package/dist/ui-engine/compat-report.js +124 -0
  24. package/dist/ui-engine/css-imports.d.ts +4 -0
  25. package/dist/ui-engine/css-imports.js +70 -0
  26. package/dist/ui-engine/css-parser.js +58 -14
  27. package/dist/ui-engine/default-font.d.ts +25 -0
  28. package/dist/ui-engine/default-font.js +80 -0
  29. package/dist/ui-engine/font-assets.d.ts +2 -2
  30. package/dist/ui-engine/font-assets.js +22 -5
  31. package/dist/ui-engine/html-parser.d.ts +4 -0
  32. package/dist/ui-engine/html-parser.js +241 -26
  33. package/dist/ui-engine/image-assets.d.ts +8 -1
  34. package/dist/ui-engine/image-assets.js +35 -2
  35. package/dist/ui-engine/image-decode.d.ts +22 -0
  36. package/dist/ui-engine/image-decode.js +194 -0
  37. package/dist/ui-engine/inline-parser.js +1 -1
  38. package/dist/ui-engine/layout-engine.d.ts +19 -0
  39. package/dist/ui-engine/layout-engine.js +59 -3
  40. package/dist/ui-engine/model.d.ts +18 -0
  41. package/dist/ui-engine/model.js +76 -8
  42. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  43. package/dist/ui-engine/runtime-header/canvas-helpers.js +5 -2
  44. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  45. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  46. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  47. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  48. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  49. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  50. package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1377
  51. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  52. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  53. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  54. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  55. package/dist/ui-engine/runtime-header/structs.js +228 -216
  56. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  57. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  58. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  59. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  60. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +79 -23
  61. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  62. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  63. package/dist/ui-engine/shadcn-kit.js +502 -0
  64. package/dist/ui-engine/style-resolver.d.ts +4 -0
  65. package/dist/ui-engine/style-resolver.js +58 -6
  66. package/dist/ui-engine/transpile-ui.js +12 -5
  67. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  68. package/dist/ui-engine/ua-stylesheet.js +119 -32
  69. package/dist/ui-engine/ui-lowering.js +37 -3
  70. package/dist/ui-engine/ui-registry.js +32 -4
  71. package/dist/ui-engine/yoga-layout.js +57 -52
  72. package/dist/wizard/config-writer.d.ts +49 -0
  73. package/dist/wizard/config-writer.js +366 -0
  74. package/dist/wizard/display-catalog.d.ts +75 -0
  75. package/dist/wizard/display-catalog.js +207 -0
  76. package/dist/wizard/index.d.ts +7 -0
  77. package/dist/wizard/index.js +10 -0
  78. package/dist/wizard/integration-wizard.d.ts +15 -0
  79. package/dist/wizard/integration-wizard.js +486 -0
  80. package/dist/wizard/prompts.d.ts +32 -0
  81. package/dist/wizard/prompts.js +95 -0
  82. package/dist/wizard/starter-ui.d.ts +1 -0
  83. package/dist/wizard/starter-ui.js +56 -0
  84. package/package.json +18 -5
  85. package/src/cli.ts +87 -0
  86. package/src/engine-index.ts +53 -51
  87. package/src/preview/build-program.ts +810 -734
  88. package/src/preview/host-gfx.ts +30 -3
  89. package/src/preview/host-ui-runtime.ts +4133 -3289
  90. package/src/ui-engine/block-layout.ts +24 -11
  91. package/src/ui-engine/color.ts +77 -2
  92. package/src/ui-engine/compat-report.ts +139 -0
  93. package/src/ui-engine/css-imports.ts +69 -0
  94. package/src/ui-engine/css-parser.ts +64 -15
  95. package/src/ui-engine/default-font.ts +95 -0
  96. package/src/ui-engine/font-assets.ts +545 -525
  97. package/src/ui-engine/html-parser.ts +615 -397
  98. package/src/ui-engine/image-assets.ts +37 -2
  99. package/src/ui-engine/image-decode.ts +244 -0
  100. package/src/ui-engine/inline-parser.ts +1 -1
  101. package/src/ui-engine/layout-engine.ts +61 -3
  102. package/src/ui-engine/model.ts +1317 -1230
  103. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  104. package/src/ui-engine/runtime-header/canvas-helpers.ts +5 -2
  105. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  106. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  107. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  108. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  109. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  110. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  111. package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1402
  112. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  113. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  114. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  115. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  116. package/src/ui-engine/runtime-header/structs.ts +234 -222
  117. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  118. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  119. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  120. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  121. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +79 -23
  122. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  123. package/src/ui-engine/shadcn-kit.ts +503 -0
  124. package/src/ui-engine/style-resolver.ts +471 -416
  125. package/src/ui-engine/transpile-ui.ts +12 -5
  126. package/src/ui-engine/ua-stylesheet.ts +137 -31
  127. package/src/ui-engine/ui-lowering.ts +486 -452
  128. package/src/ui-engine/ui-registry.ts +556 -524
  129. package/src/ui-engine/yoga-layout.ts +307 -309
  130. package/src/wizard/config-writer.ts +404 -0
  131. package/src/wizard/display-catalog.ts +273 -0
  132. package/src/wizard/index.ts +38 -0
  133. package/src/wizard/integration-wizard.ts +619 -0
  134. package/src/wizard/prompts.ts +145 -0
  135. package/src/wizard/starter-ui.ts +58 -0
@@ -0,0 +1,194 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Automatic image conversion: decode any common image format (PNG, JPEG,
3
+ // GIF, BMP, WebP, AVIF/HEIF, TIFF, SVG, ICO) into the raw RGB565 asset form
4
+ // the runtime consumes, so `<img src="photo.png">` just works — no manual
5
+ // conversion to raw .img dumps.
6
+ //
7
+ // Decoding is asynchronous (sharp); the UI module loader is synchronous. The
8
+ // bridge is a warm-up pass: the transpile graph collector and the preview
9
+ // snapshot builder await `warmUpImageDecoding()` on each UI source BEFORE
10
+ // the module loads, priming a cache keyed by absolute path + mtime. The
11
+ // synchronous image-assets reader then consults `getCachedDecodedImage()`.
12
+ // Anything undecodable (including the legacy raw RGB565 .img files) falls
13
+ // back to the raw reader unchanged.
14
+ // ---------------------------------------------------------------------------
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ /** Identify a buffer by magic bytes. null = unknown (raw RGB565 dump). */
18
+ export function detectImageFormat(buf) {
19
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
20
+ return "png";
21
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
22
+ return "jpeg";
23
+ if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF")
24
+ return "gif";
25
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
26
+ return "bmp";
27
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP")
28
+ return "webp";
29
+ // ISO-BMFF family (AVIF/HEIF): bytes 4..8 are "ftyp".
30
+ if (buf.length >= 8 && buf.toString("ascii", 4, 8) === "ftyp")
31
+ return "avif";
32
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "II*\0")
33
+ return "tiff";
34
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "MM\0*")
35
+ return "tiff";
36
+ if (buf.length >= 4 && buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00)
37
+ return "ico";
38
+ // SVG is XML text: after an optional BOM, the first non-space char is '<'.
39
+ for (let i = 0; i < Math.min(buf.length, 8); i++) {
40
+ const b = buf[i];
41
+ if (b === 0xef || b === 0xbb || b === 0xbf)
42
+ continue; // UTF-8 BOM bytes
43
+ if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d)
44
+ continue;
45
+ if (b === 0x3c)
46
+ return "svg";
47
+ break;
48
+ }
49
+ return null;
50
+ }
51
+ let sharpLoader = null;
52
+ function loadSharp() {
53
+ if (!sharpLoader) {
54
+ sharpLoader = import("sharp").then((m) => m.default).catch((err) => {
55
+ sharpLoader = null; // a later call can retry (e.g. after an install fix)
56
+ throw new Error(`the sharp image decoder could not be loaded (${err instanceof Error ? err.message : String(err)}) — ` +
57
+ `reinstall dependencies, or convert the image to a raw RGB565 .img file manually`);
58
+ });
59
+ }
60
+ return sharpLoader;
61
+ }
62
+ /** Run a sharp pipeline to raw pixels: flatten alpha onto black, optionally
63
+ * downscale to fit the display (never enlarge). */
64
+ async function runSharp(pipeline, naturalW, naturalH, opts) {
65
+ if (opts.maxW && opts.maxH && (naturalW > opts.maxW || naturalH > opts.maxH)) {
66
+ pipeline = pipeline.resize({ width: opts.maxW, height: opts.maxH, fit: "inside", withoutEnlargement: true });
67
+ }
68
+ const { data, info } = await pipeline
69
+ .flatten({ background: "#000000" })
70
+ .raw()
71
+ .toBuffer({ resolveWithObject: true });
72
+ return { pixels: data, channels: info.channels, width: info.width, height: info.height };
73
+ }
74
+ /** Decode an .ico: decode-ico returns every frame as RGBA (PNG and BMP
75
+ * frames alike); pick the largest so the asset carries the best detail. */
76
+ async function decodeIco(buf) {
77
+ const decodeIcoMod = await import("decode-ico");
78
+ const decodeIco = decodeIcoMod.default;
79
+ const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
80
+ const frames = decodeIco(u8);
81
+ if (!frames || frames.length === 0)
82
+ throw new Error("no frames in .ico");
83
+ let best = frames[0];
84
+ for (const f of frames) {
85
+ if (f.width * f.height > best.width * best.height)
86
+ best = f;
87
+ }
88
+ return { pixels: best.data, channels: 4, width: best.width, height: best.height };
89
+ }
90
+ function rgbaToRgb565(px, channels) {
91
+ const count = Math.floor(px.length / channels);
92
+ const out = new Array(count);
93
+ for (let i = 0; i < count; i++) {
94
+ const r = px[i * channels] >> 3;
95
+ const g = px[i * channels + 1] >> 2;
96
+ const b = px[i * channels + 2] >> 3;
97
+ out[i] = (r << 11) | (g << 5) | b;
98
+ }
99
+ return out;
100
+ }
101
+ async function decodeImageBuffer(buf, format, opts) {
102
+ const sharp = await loadSharp();
103
+ let raw;
104
+ if (format === "ico") {
105
+ const ico = await decodeIco(buf);
106
+ if (opts.maxW && opts.maxH && (ico.width > opts.maxW || ico.height > opts.maxH)) {
107
+ // The largest frame still exceeds the display; route its RGBA through
108
+ // sharp for the same fit-inside downscale every other format gets.
109
+ const rgbaBuf = Buffer.from(ico.pixels.buffer, ico.pixels.byteOffset, ico.pixels.byteLength);
110
+ raw = await runSharp(sharp(rgbaBuf, { raw: { width: ico.width, height: ico.height, channels: 4 } }), ico.width, ico.height, opts);
111
+ }
112
+ else {
113
+ raw = ico;
114
+ }
115
+ }
116
+ else {
117
+ // File bytes: auto-orient by EXIF first, then the shared flatten/fit path.
118
+ // metadata() reports the STORED dimensions — an EXIF orientation of 5-8
119
+ // means the pipeline's rotate() transposes the output, so the fit decision
120
+ // must compare against the swapped natural size or portrait photos can
121
+ // skip the downscale they need (and landscape ones downscale needlessly).
122
+ const oriented = sharp(buf, { failOn: "none" }).rotate();
123
+ const meta = await oriented.metadata();
124
+ const exifSwap = (meta.orientation ?? 1) >= 5 && (meta.orientation ?? 1) <= 8;
125
+ const naturalW = exifSwap ? meta.height : meta.width;
126
+ const naturalH = exifSwap ? meta.width : meta.height;
127
+ raw = await runSharp(oriented, naturalW ?? 0, naturalH ?? 0, opts);
128
+ }
129
+ return { width: raw.width, height: raw.height, data: rgbaToRgb565(raw.pixels, raw.channels) };
130
+ }
131
+ const decodedCache = new Map();
132
+ function cacheKey(abs, mtimeMs) {
133
+ return `${abs}\0${mtimeMs}`;
134
+ }
135
+ /** The decoded image for a file, or undefined when it was never warmed up or
136
+ * its decode failed (the caller falls back to the raw RGB565 reader). */
137
+ export function getCachedDecodedImage(absPath) {
138
+ let stat;
139
+ try {
140
+ stat = fs.statSync(absPath);
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ const entry = decodedCache.get(cacheKey(absPath, stat.mtimeMs));
146
+ return entry ? (entry.image ?? undefined) : undefined;
147
+ }
148
+ const SRC_ATTR = /\bsrc\s*=\s*["']([^"']+)["']/g;
149
+ /** Scan a UI source for src="…" references and pre-decode every referenced
150
+ * image file. Called with `await` BEFORE the UI module loads (the module
151
+ * loader and asset reader are synchronous); failures warn and fall back. */
152
+ export async function warmUpImageDecoding(sourceText, baseDir, opts = {}) {
153
+ const pending = new Set();
154
+ let match;
155
+ SRC_ATTR.lastIndex = 0;
156
+ while ((match = SRC_ATTR.exec(sourceText)) !== null) {
157
+ const src = match[1];
158
+ if (!src || /^(data:|https?:|#)/.test(src))
159
+ continue;
160
+ const abs = path.isAbsolute(src) ? src : path.resolve(baseDir, src);
161
+ pending.add(abs);
162
+ }
163
+ await Promise.all([...pending].map(async (abs) => {
164
+ // Track the mtime up front so a failed decode is memoized under the key
165
+ // getCachedDecodedImage() looks up — the next warm-up of an unchanged
166
+ // file hits the null entry instead of re-attempting (and re-warning).
167
+ let mtimeMs = 0;
168
+ try {
169
+ if (!fs.existsSync(abs))
170
+ return;
171
+ const stat = fs.statSync(abs);
172
+ mtimeMs = stat.mtimeMs;
173
+ const key = cacheKey(abs, mtimeMs);
174
+ if (decodedCache.has(key))
175
+ return;
176
+ const buf = fs.readFileSync(abs);
177
+ const format = detectImageFormat(buf);
178
+ if (!format)
179
+ return; // legacy raw RGB565 dump — the raw reader handles it
180
+ const image = await decodeImageBuffer(buf, format, opts);
181
+ decodedCache.set(key, { mtimeMs, image });
182
+ }
183
+ catch (err) {
184
+ if (mtimeMs > 0) {
185
+ decodedCache.set(cacheKey(abs, mtimeMs), { mtimeMs, image: null });
186
+ }
187
+ console.warn(`[img] ${path.basename(abs)}: ${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+ }));
190
+ }
191
+ /** Test hook: drop cached decodes. */
192
+ export function resetImageDecodeCache() {
193
+ decodedCache.clear();
194
+ }
@@ -1,7 +1,7 @@
1
1
  /** Tags whose content participates in inline flow inside a text container.
2
2
  * Bare text nodes between them flow too. Recognized as inline children of a
3
3
  * text-effective node; absorbed into the parent's run list by the resolver. */
4
- export const INLINE_TAGS = new Set(["span", "a", "b", "strong", "i", "em", "u"]);
4
+ export const INLINE_TAGS = new Set(["span", "a", "b", "strong", "i", "em", "u", "code", "kbd", "samp"]);
5
5
  /** Walk childNodes in document order, building an inline sequence.
6
6
  *
7
7
  * Returns `undefined` when the element is NOT inline-bearing:
@@ -21,6 +21,25 @@ export interface LayoutEngine {
21
21
  }
22
22
  /** Static display:none predicate shared by layout engines and lowering. */
23
23
  export declare function isDisplayNone(node: StyledNode): boolean;
24
+ /** A resolved CSS length: device pixels, a percentage of the parent
25
+ * (resolved by the layout engine), or the auto keyword (margins). */
26
+ export type CssDim = number | "auto" | `${number}%`;
27
+ /** Parse a single CSS length to a layout dimension. px/bare numbers are device
28
+ * pixels, rem/em resolve × 16 (root font size), percentages are preserved as
29
+ * `${n}%` strings so flexbox can resolve them against the parent, and "auto"
30
+ * passes through (auto margins absorb free space — `margin: 0 auto` centers). */
31
+ export declare function cssDimValue(val: string | undefined): CssDim;
32
+ /** Is this a set (non-default) dimension? Falsy 0 values are skipped when
33
+ * applying to layout; percentages and "auto" always apply. */
34
+ export declare function dimSet(d: CssDim): boolean;
35
+ /** Size- and inset-safe variant of cssDimValue(): Yoga's typings exclude the
36
+ * "auto" keyword for sizes/paddings/insets (only margins accept it), so fold
37
+ * it to undefined — auto sizing is the engine default anyway. */
38
+ export declare function cssSizeValue(val: string | undefined): number | `${number}%` | undefined;
39
+ /** Expand a 1-4 value CSS box shorthand (padding/margin) to
40
+ * [top, right, bottom, left] following the CSS TRBL rotation:
41
+ * 1 = all, 2 = V H, 3 = T H B, 4 = T R B L. */
42
+ export declare function expandBoxShorthand(val: string | undefined): [CssDim, CssDim, CssDim, CssDim];
24
43
  /** Parse CSS aspect-ratio values: "16 / 9", "1/1", or "1.777". */
25
44
  export declare function parseAspectRatio(value: string | undefined): number | undefined;
26
45
  /** Measure a node's intrinsic size. Accounts for the Adafruit GFX font metrics
@@ -17,6 +17,56 @@ import { assetTextWidth, assetLineHeight } from "./font-assets.js";
17
17
  export function isDisplayNone(node) {
18
18
  return node.style.display?.trim().toLowerCase() === "none";
19
19
  }
20
+ /** Parse a single CSS length to a layout dimension. px/bare numbers are device
21
+ * pixels, rem/em resolve × 16 (root font size), percentages are preserved as
22
+ * `${n}%` strings so flexbox can resolve them against the parent, and "auto"
23
+ * passes through (auto margins absorb free space — `margin: 0 auto` centers). */
24
+ export function cssDimValue(val) {
25
+ const v = (val ?? "").trim();
26
+ if (!v)
27
+ return 0;
28
+ if (/^auto$/i.test(v))
29
+ return "auto";
30
+ const remM = /^(-?[\d.]+)rem$/i.exec(v);
31
+ if (remM)
32
+ return Math.round(parseFloat(remM[1]) * 16);
33
+ const emM = /^(-?[\d.]+)em$/i.exec(v);
34
+ if (emM)
35
+ return Math.round(parseFloat(emM[1]) * 16);
36
+ const pctM = /^(-?[\d.]+)%$/.exec(v);
37
+ if (pctM)
38
+ return `${parseFloat(pctM[1])}%`;
39
+ const m = /^(-?[\d.]+)(?:px)?$/i.exec(v);
40
+ return m ? parseFloat(m[1]) : 0;
41
+ }
42
+ /** Is this a set (non-default) dimension? Falsy 0 values are skipped when
43
+ * applying to layout; percentages and "auto" always apply. */
44
+ export function dimSet(d) {
45
+ return d !== 0;
46
+ }
47
+ /** Size- and inset-safe variant of cssDimValue(): Yoga's typings exclude the
48
+ * "auto" keyword for sizes/paddings/insets (only margins accept it), so fold
49
+ * it to undefined — auto sizing is the engine default anyway. */
50
+ export function cssSizeValue(val) {
51
+ const d = cssDimValue(val);
52
+ return d === "auto" ? undefined : d;
53
+ }
54
+ /** Expand a 1-4 value CSS box shorthand (padding/margin) to
55
+ * [top, right, bottom, left] following the CSS TRBL rotation:
56
+ * 1 = all, 2 = V H, 3 = T H B, 4 = T R B L. */
57
+ export function expandBoxShorthand(val) {
58
+ const parts = (val ?? "").trim().split(/\s+/).filter(Boolean);
59
+ const d = parts.map((p) => cssDimValue(p));
60
+ if (d.length <= 1) {
61
+ const v = d[0] ?? 0;
62
+ return [v, v, v, v];
63
+ }
64
+ if (d.length === 2)
65
+ return [d[0], d[1], d[0], d[1]];
66
+ if (d.length === 3)
67
+ return [d[0], d[1], d[2], d[1]];
68
+ return [d[0], d[1], d[2], d[3]];
69
+ }
20
70
  /** Parse CSS aspect-ratio values: "16 / 9", "1/1", or "1.777". */
21
71
  export function parseAspectRatio(value) {
22
72
  const raw = value?.trim().toLowerCase();
@@ -148,12 +198,18 @@ export function measure(node, availableWidth, fontAssets = []) {
148
198
  const widthOf = (value) => assetTextWidth(stripInterp(value), node.style, fontAssets) ?? textWidthOf(stripInterp(value), advance);
149
199
  if (node.tag === "text" || node.tag === "button" || node.tag === "select") {
150
200
  if (node.tag === "select") {
151
- // Size to the longest option, not the full comma-separated text
201
+ // Size to the widest option by RENDERED width, not the full
202
+ // comma-separated text and not character count — with a proportional
203
+ // font, equal-length options can differ in width ("Alpha" vs "Gamma"),
204
+ // and the wider one must fit without wrapping.
152
205
  const options = node.options && node.options.length > 0
153
206
  ? node.options.map((option) => option.text)
154
207
  : (node.text ?? "").split(",").map(s => s.trim()).filter(Boolean);
155
- const longest = options.length > 0 ? options.reduce((a, b) => a.length >= b.length ? a : b) : "";
156
- return { w: widthOf(applyTextTransform(longest, node)), h: lineHeightOf(node, charH) };
208
+ const widest = options.length > 0
209
+ ? options.reduce((a, b) => widthOf(applyTextTransform(a, node)) >= widthOf(applyTextTransform(b, node)) ? a : b)
210
+ : "";
211
+ // +14: the dropdown chevron the runtimes draw at the right end.
212
+ return { w: widthOf(applyTextTransform(widest, node)) + 14, h: lineHeightOf(node, charH) };
157
213
  }
158
214
  const text = applyTextTransform(stripInterp(node.text), node);
159
215
  const layout = layoutText(text, {
@@ -14,6 +14,12 @@ export interface UINodeModel {
14
14
  box: Box;
15
15
  bg: number;
16
16
  fg: number;
17
+ /** Checked-state color pair baked from :checked rules (RGB565; -1 unset).
18
+ * Check/radio/select draw paths swap to it when the value flips — the
19
+ * switch track, checkbox face, radio dot, and the select list's selected
20
+ * row (shadcn data-[state=checked] theming). */
21
+ checkedBg: number;
22
+ checkedFg: number;
17
23
  kind: UINodeKindModel;
18
24
  text?: string;
19
25
  placeholder?: string;
@@ -73,6 +79,18 @@ export interface UINodeModel {
73
79
  nowrap: boolean;
74
80
  whiteSpaceMode: 0 | 1 | 2 | 3;
75
81
  visible: boolean;
82
+ /** HTML disabled attribute: blocks taps/keyboard and dims the control. */
83
+ disabled: boolean;
84
+ /** <drawer side>: 0=bottom 1=top 2=left 3=right; -1 = not a drawer. */
85
+ drawerSide: number;
86
+ /** <toast duration>: ms before auto-close (0 = manual close only). */
87
+ toastDuration: number;
88
+ /** Visibility-reflow axis (device): 0 none, 1 column, 2 row. */
89
+ flowAxis: number;
90
+ /** Visibility-reflow: main-axis gap between in-flow children (px, 0-255). */
91
+ flowGap: number;
92
+ /** Visibility-reflow flags: bit0 auto height, bit1 auto width, bit2 out-of-flow. */
93
+ flowFlags: number;
76
94
  opacity: number;
77
95
  clearColor: number;
78
96
  lastTextWidth: number;
@@ -1,6 +1,6 @@
1
1
  import { effectiveDisplaySize } from "@typecad/cuttlefish/api/shared";
2
2
  import { deriveCapabilities } from "@typecad/cuttlefish/api/shared";
3
- import { resolveColorInternal } from "./color.js";
3
+ import { resolveColorInternal, isTransparentColor } from "./color.js";
4
4
  import { parseAnimation } from "./css-parser.js";
5
5
  import { selectFontAssetForStyle, assetTextWidth, assetLineHeight } from "./font-assets.js";
6
6
  import { isDisplayNone } from "./layout-engine.js";
@@ -14,7 +14,7 @@ export const KEYFRAME_PROP_OPACITY = 4;
14
14
  export const KEYFRAME_PROP_TRANSFORM = 8;
15
15
  export const KEYFRAME_PROP_SIZE = 16;
16
16
  function nodeKind(tag) {
17
- if (tag === "screen" || tag === "view")
17
+ if (tag === "screen" || tag === "view" || tag === "drawer" || tag === "dialog" || tag === "toast")
18
18
  return "fill";
19
19
  if (tag === "button")
20
20
  return "button";
@@ -121,7 +121,12 @@ function borderRadiusOf(style) {
121
121
  if (!style.borderRadius)
122
122
  return 0;
123
123
  const px = parseInt(style.borderRadius, 10);
124
- return isNaN(px) ? 0 : px;
124
+ // Clamp at 0: kit recipes like `calc(var(--radius) - 2px)` go negative for
125
+ // 0px-radius themes (tweakcn's sharper exports) — a negative radius is both
126
+ // meaningless and a uint8 narrowing error in the emitted node table.
127
+ if (isNaN(px) || px < 0)
128
+ return 0;
129
+ return Math.min(255, px);
125
130
  }
126
131
  function paddingOf(style) {
127
132
  if (!style.padding)
@@ -449,7 +454,7 @@ function parseBoxShadow(style, format) {
449
454
  // Split into a numbers zone (offset/blur) and a color zone. The color
450
455
  // zone starts at the first # hex, rgb(, rgba(, or color name. css-tree
451
456
  // may strip spaces between values, so we can't rely on whitespace split.
452
- const colorStart = part.search(/#|rgba?\(|\b(?:black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|cyan|magenta|silver|gold|brown|tan|navy|teal|maroon|lime|olive|aqua|fuchsia|transparent)\b/i);
457
+ const colorStart = part.search(/#|rgba?\(|hsla?\(|\b(?:black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|cyan|magenta|silver|gold|brown|tan|navy|teal|maroon|lime|olive|aqua|fuchsia|transparent)\b/i);
453
458
  const numZone = colorStart >= 0 ? part.slice(0, colorStart) : part;
454
459
  const colorZone = colorStart >= 0 ? part.slice(colorStart) : "";
455
460
  // Extract numbers from the numeric zone only (safe — no hex digits here).
@@ -640,11 +645,13 @@ function animationDeclOf(style) {
640
645
  function flatten(node, boxes, out, cursor, parentBg, parentIndex = -1, screenId = 0, parentZIndex = 0, parentOpacity = 100) {
641
646
  const index = cursor.i++;
642
647
  const box = boxes[index] ?? { x: 0, y: 0, w: 0, h: 0 };
643
- const hasBg = !!node.style.background;
648
+ // `background: transparent` (or alpha-0) means NO fill — the parent's
649
+ // background shows through, matching browsers. (It used to paint black.)
650
+ const bgStr = node.style.background;
651
+ const bgIsGradient = bgStr !== undefined && bgStr.includes("linear-gradient");
652
+ const hasBg = bgStr !== undefined && !bgIsGradient ? !isTransparentColor(bgStr) : !!bgStr;
644
653
  // If background is a gradient, extract the first color stop as the base
645
654
  // clearColor string (avoids resolveColor choking on "linear-gradient(...)").
646
- const bgStr = node.style.background;
647
- const bgIsGradient = bgStr && bgStr.includes("linear-gradient");
648
655
  const bgBaseColor = bgIsGradient ? extractFirstGradientColor(bgStr) : bgStr;
649
656
  const clearColor = hasBg ? bgBaseColor : parentBg;
650
657
  const zIndex = Math.max(-32768, Math.min(32767, parentZIndex + zIndexOf(node.style)));
@@ -692,6 +699,10 @@ export function lowerUIToModel(root, boxes, colorFormat, display, fontAssets = [
692
699
  if (s.id)
693
700
  screenIdToIndex.set(s.id, i);
694
701
  });
702
+ // Raw flex direction per node index (pre-order, parents first) — the
703
+ // visibility-reflow flags use the parent's direction to decide cross-axis
704
+ // stretch vs content sizing (see the flowAxis/flowFlags IIFE below).
705
+ const flowDirByIndex = [];
695
706
  const nodes = flat.map(({ index, node, box, hasBg, clearColor, parentIndex, subtreeEnd, screenId, zIndex, effectiveOpacity }) => {
696
707
  // If background is a gradient, use the first stop as the base bg color
697
708
  // (the runtime draws the actual gradient per-row on top of this).
@@ -700,7 +711,19 @@ export function lowerUIToModel(root, boxes, colorFormat, display, fontAssets = [
700
711
  ? (grad ? grad.color1 : resolveColorInternal(node.style.background, colorFormat))
701
712
  : 0;
702
713
  const fg = node.style.color ? resolveColorInternal(node.style.color, colorFormat) : 0xffff;
703
- const bColor = node.style.borderColor ? resolveColorInternal(node.style.borderColor, colorFormat) : 0;
714
+ // Checked-state pair from :checked rules (e.g. the kit's switch/checkbox/
715
+ // radio primary pair, the select list's accent pair). -1 = unset.
716
+ const checkedStyle = node.style.checked;
717
+ const checkedBg = checkedStyle && checkedStyle.background
718
+ ? resolveColorInternal(checkedStyle.background, colorFormat)
719
+ : -1;
720
+ const checkedFg = checkedStyle && checkedStyle.color
721
+ ? resolveColorInternal(checkedStyle.color, colorFormat)
722
+ : -1;
723
+ // Border color defaults to currentColor (the node's resolved text color),
724
+ // matching browsers — `border: 1px solid` on a white-on-dark theme gets a
725
+ // white border, not a hardcoded black one.
726
+ const bColor = node.style.borderColor ? resolveColorInternal(node.style.borderColor, colorFormat) : fg;
704
727
  const clear = clearColor ? resolveColorInternal(clearColor, colorFormat) : 0;
705
728
  const outline = outlineOf(node.style);
706
729
  const outlineColor = outline.color ? resolveColorInternal(outline.color, colorFormat) : fg;
@@ -792,6 +815,8 @@ export function lowerUIToModel(root, boxes, colorFormat, display, fontAssets = [
792
815
  box: baseTransformOffset.box,
793
816
  bg,
794
817
  fg,
818
+ checkedBg,
819
+ checkedFg,
795
820
  kind,
796
821
  text: applyTextTransform(node.text, node.style),
797
822
  placeholder: applyTextTransform(node.placeholder, node.style),
@@ -871,6 +896,49 @@ export function lowerUIToModel(root, boxes, colorFormat, display, fontAssets = [
871
896
  nowrap: wsMode === 1 || wsMode === 2,
872
897
  whiteSpaceMode: wsMode,
873
898
  visible: node.style.visibility !== "hidden" && node.style.visibility !== "collapse" && !isDisplayNone(node),
899
+ disabled: node.disabled === true,
900
+ drawerSide: (node.tag === "drawer" || node.tag === "dialog" || node.tag === "toast")
901
+ ? { bottom: 0, top: 1, left: 2, right: 3, center: 4 }[node.tag === "dialog" ? "center" : (node.drawerSide ?? "bottom")] ?? 0
902
+ : -1,
903
+ toastDuration: node.tag === "toast" ? (node.toastDuration ?? 2500) : 0,
904
+ // Visibility-reflow metadata: how this node's in-flow children stack, so
905
+ // the device runtime can re-stack them when a child's visibility changes
906
+ // (the preview re-layouts with yoga every frame; the device's boxes are
907
+ // baked at build time). The flex mapping mirrors yoga-layout.ts exactly;
908
+ // wrapped, reversed, or non-flex-start-justified containers opt out
909
+ // (axis 0) because a linear re-stack can't reproduce their layout.
910
+ ...(() => {
911
+ const s = node.style;
912
+ const fd = s.display === "flex" ? s.flexDirection : "column";
913
+ const outOfFlow = s.position === "absolute" || s.position === "fixed";
914
+ const gapPx = (v) => {
915
+ const n = v === undefined ? 0 : parseFloat(v);
916
+ return Number.isFinite(n) && n > 0 ? Math.min(255, Math.round(n)) : 0;
917
+ };
918
+ const dirAxis = (fd === "row" || fd === "row-reverse") ? 2 : 1;
919
+ flowDirByIndex[index] = dirAxis;
920
+ let axis = 0;
921
+ if ((node.children?.length ?? 0) > 0 && !outOfFlow &&
922
+ fd !== "row-reverse" && fd !== "column-reverse" &&
923
+ s.flexWrap !== "wrap" && s.flexWrap !== "wrap-reverse" &&
924
+ (!s.justifyContent || s.justifyContent === "flex-start" || s.justifyContent === "normal")) {
925
+ axis = dirAxis;
926
+ }
927
+ const gap = axis === 2 ? gapPx(s.rowGap) : gapPx(s.columnGap);
928
+ // Content-sized only when the size truly comes from the content: an
929
+ // explicit size, flex-grow space, a flex-basis, or cross-axis
930
+ // stretch (a child of a row parent stretches its height; of a column
931
+ // parent its width) all derive the dimension from elsewhere. Getting
932
+ // this wrong let the reflow resize a flex-grown scroll body to its
933
+ // collapsed content — contentHeight == box.h, nothing scrollable.
934
+ const grown = s.flexGrow !== undefined && parseFloat(String(s.flexGrow)) > 0;
935
+ const basisSet = s.flexBasis !== undefined && s.flexBasis !== "auto";
936
+ const parentDir = parentIndex >= 0 ? (flowDirByIndex[parentIndex] ?? 1) : 0;
937
+ const parentDerived = grown || basisSet || parentDir === 0;
938
+ const autoH = !parentDerived && parentDir !== 2 && (s.height === undefined || s.height === "auto");
939
+ const autoW = !parentDerived && parentDir !== 1 && (s.width === undefined || s.width === "auto");
940
+ return { flowAxis: axis, flowGap: gap, flowFlags: (autoH ? 1 : 0) | (autoW ? 2 : 0) | (outOfFlow ? 4 : 0) };
941
+ })(),
874
942
  opacity: effectiveOpacity,
875
943
  clearColor: clear,
876
944
  lastTextWidth: 0,
@@ -3,21 +3,40 @@
3
3
  // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
4
4
  export function emitBlendBodies() {
5
5
  return `
6
+ // Blend LUTs (LVGL draw-unit trick, memory-frugal variant): two 256-entry
7
+ // per-channel tables rebuilt only when the opacity level CHANGES. Blends
8
+ // typically run per-pixel at a fixed opacity (skeleton pulse frame, one
9
+ // shadow pass, a glyph's coverage step), so the table amortizes to one
10
+ // rebuild per pass and every pixel becomes three array lookups instead of
11
+ // three multiply-divide rounds. Total cost: 513 bytes of static RAM.
12
+ static uint8_t __ui_blend_lut_fg[256];
13
+ static uint8_t __ui_blend_lut_bg[256];
14
+ static uint8_t __ui_blend_lut_opacity = 255; // 255 = never built (valid range 0..100)
15
+ static inline void ui_blend_lut_build(uint8_t opacity) {
16
+ if (__ui_blend_lut_opacity == opacity) return;
17
+ for (int16_t v = 0; v < 256; v++) {
18
+ __ui_blend_lut_fg[v] = static_cast<uint8_t>((v * opacity) / 100);
19
+ __ui_blend_lut_bg[v] = static_cast<uint8_t>((v * (100 - opacity)) / 100);
20
+ }
21
+ __ui_blend_lut_opacity = opacity;
22
+ }
23
+
6
24
  static inline uint16_t ui_blend565(uint16_t fg, uint16_t bg, uint8_t opacity) {
7
25
  if (opacity >= 100) return fg;
8
26
  if (opacity == 0) return bg;
9
- // Blend in 888 internally for higher precision: unpack 565→888 (replicating
27
+ ui_blend_lut_build(opacity);
28
+ // Blend in 888 internally for higher precision: unpack 565->888 (replicating
10
29
  // high bits), blend at 8-bit, then re-quantize to 565. This produces smoother
11
- // intermediate values for AA text edges, opacity, shadows, and gradients
30
+ // intermediate values for AA text edges, opacity, shadows, and gradients --
12
31
  // the 565-channel blend (32 red levels) was too coarse and showed banding.
13
32
  uint8_t fr = (fg >> 11) & 0x1F, fg5 = (fg >> 5) & 0x3F, fb = fg & 0x1F;
14
33
  uint8_t br = (bg >> 11) & 0x1F, bg5 = (bg >> 5) & 0x3F, bb = bg & 0x1F;
15
- // Unpack to 8-bit (5-bit 8-bit: (v << 3) | (v >> 2)).
34
+ // Unpack to 8-bit (5-bit -> 8-bit: (v << 3) | (v >> 2)).
16
35
  uint16_t fr8 = (fr << 3) | (fr >> 2), fg8 = (fg5 << 2) | (fg5 >> 4), fb8 = (fb << 3) | (fb >> 2);
17
36
  uint16_t br8 = (br << 3) | (br >> 2), bg8 = (bg5 << 2) | (bg5 >> 4), bb8 = (bb << 3) | (bb >> 2);
18
- uint16_t r = static_cast<uint16_t>((fr8 * opacity + br8 * (100 - opacity)) / 100);
19
- uint16_t g = static_cast<uint16_t>((fg8 * opacity + bg8 * (100 - opacity)) / 100);
20
- uint16_t b = static_cast<uint16_t>((fb8 * opacity + bb8 * (100 - opacity)) / 100);
37
+ uint16_t r = static_cast<uint16_t>(__ui_blend_lut_fg[fr8] + __ui_blend_lut_bg[br8]);
38
+ uint16_t g = static_cast<uint16_t>(__ui_blend_lut_fg[fg8] + __ui_blend_lut_bg[bg8]);
39
+ uint16_t b = static_cast<uint16_t>(__ui_blend_lut_fg[fb8] + __ui_blend_lut_bg[bb8]);
21
40
  return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
22
41
  }
23
42
 
@@ -28,11 +47,12 @@ static inline uint16_t ui_blend565(uint16_t fg, uint16_t bg, uint8_t opacity) {
28
47
  static inline uint32_t ui_blend888(uint32_t fg, uint32_t bg, uint8_t opacity) {
29
48
  if (opacity >= 100) return fg;
30
49
  if (opacity == 0) return bg;
50
+ ui_blend_lut_build(opacity);
31
51
  uint8_t fr = (fg >> 16) & 0xff, fg8 = (fg >> 8) & 0xff, fb = fg & 0xff;
32
52
  uint8_t br = (bg >> 16) & 0xff, bg8 = (bg >> 8) & 0xff, bb = bg & 0xff;
33
- uint8_t r = (fr * opacity + br * (100 - opacity)) / 100;
34
- uint8_t g = (fg8 * opacity + bg8 * (100 - opacity)) / 100;
35
- uint8_t b = (fb * opacity + bb * (100 - opacity)) / 100;
53
+ uint8_t r = static_cast<uint8_t>(__ui_blend_lut_fg[fr] + __ui_blend_lut_bg[br]);
54
+ uint8_t g = static_cast<uint8_t>(__ui_blend_lut_fg[fg8] + __ui_blend_lut_bg[bg8]);
55
+ uint8_t b = static_cast<uint8_t>(__ui_blend_lut_fg[fb] + __ui_blend_lut_bg[bb]);
36
56
  return (static_cast<uint32_t>(r) << 16) | (static_cast<uint32_t>(g) << 8) | b;
37
57
  }
38
58
  `;
@@ -29,13 +29,16 @@ static inline CuttlefishCanvas16* ui_create_canvas_best(int16_t w, int16_t h) {
29
29
  if (!psram_logged) {
30
30
  psram_logged = 1;
31
31
  #if defined(ESP32) && defined(ARDUINO)
32
- Serial.printf("[psram] canvas %dx%d allocated in PSRAM (free=%u)\n", w, h, ESP.getFreePsram());
32
+ Serial.printf("[psram] canvas %dx%d allocated in PSRAM (free=%u)\\n", w, h, ESP.getFreePsram());
33
33
  #else
34
- printf("[psram] canvas %dx%d allocated in PSRAM\n", w, h);
34
+ printf("[psram] canvas %dx%d allocated in PSRAM\\n", w, h);
35
35
  #endif
36
36
  }
37
37
  return c;
38
38
  }
39
+ // Object allocated but its pixel buffer failed — free the object before
40
+ // falling through to SRAM so it isn't leaked.
41
+ display_deleteCanvas(c);
39
42
  // PSRAM allocation failed (rare — fragmented PSRAM) → fall through to SRAM.
40
43
  }
41
44
  #endif