@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
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import type { StyledNode } from "./style-resolver.js";
9
+ import { getCachedDecodedImage } from "./image-decode.js";
9
10
 
10
11
  type ColorFormat = "rgb565" | "rgb666" | "rgb888" | "mono";
11
12
 
@@ -66,9 +67,14 @@ export function loadImageAssets(roots: StyledNode | StyledNode[], htmlDir: strin
66
67
  const walk = (node: StyledNode) => {
67
68
  const src = (node as any).src as string | undefined;
68
69
  if (src && node.id) {
70
+ const abs = path.isAbsolute(src) ? src : path.resolve(htmlDir, src);
71
+ const decoded = getCachedDecodedImage(abs);
69
72
  const width = imageNaturalWidth(node);
70
73
  const height = imageNaturalHeight(node);
71
- const key = imageAssetKey(src, htmlDir, width, height);
74
+ // Decoded (converted) assets are keyed by file alone — their size is
75
+ // the image's natural size, not the node's attrs. Raw .img dumps keep
76
+ // the (src, dims) key — the dims ARE the asset geometry there.
77
+ const key = decoded ? `decoded:\0${abs}` : imageAssetKey(src, htmlDir, width, height);
72
78
  let assetIdx = keyToAssetIndex.get(key);
73
79
  if (assetIdx === undefined) {
74
80
  const assetId = uniqueAssetId(node.id, assets.length, usedIds);
@@ -136,7 +142,31 @@ export function emitImageTables(assets: UIImageAsset[], colorFormat: ColorFormat
136
142
  return lines.join("\n");
137
143
  }
138
144
 
139
- /** Read a raw RGB565 binary file and return a padded UIImageAsset. */
145
+ /** Give <img> nodes without explicit width/height attributes their decoded
146
+ * image's natural size, so the layout box matches the asset. Must run
147
+ * BEFORE layout (both pipelines arrange first, then load assets); explicit
148
+ * author attrs keep controlling the layout box, with object-fit scaling. */
149
+ export function applyDecodedImageSizes(roots: StyledNode | StyledNode[], htmlDir: string): void {
150
+ const walk = (node: StyledNode) => {
151
+ const src = (node as any).src as string | undefined;
152
+ if (src && (node as any).imgWidth === undefined && (node as any).imgHeight === undefined) {
153
+ const abs = path.isAbsolute(src) ? src : path.resolve(htmlDir, src);
154
+ const decoded = getCachedDecodedImage(abs);
155
+ if (decoded) {
156
+ (node as any).imgWidth = decoded.width;
157
+ (node as any).imgHeight = decoded.height;
158
+ }
159
+ }
160
+ node.children.forEach(walk);
161
+ };
162
+ for (const root of Array.isArray(roots) ? roots : [roots]) {
163
+ walk(root);
164
+ }
165
+ }
166
+
167
+ /** Read a raw RGB565 binary file and return a padded UIImageAsset. A decoded
168
+ * (converted) image in the warm-up cache wins — natural size, converted
169
+ * pixels. */
140
170
  export function readRgb565Image(
141
171
  srcPath: string,
142
172
  htmlDir: string,
@@ -148,6 +178,11 @@ export function readRgb565Image(
148
178
  ? srcPath
149
179
  : path.resolve(htmlDir, srcPath);
150
180
 
181
+ const decoded = getCachedDecodedImage(abs);
182
+ if (decoded) {
183
+ return { id, width: decoded.width, height: decoded.height, data: decoded.data };
184
+ }
185
+
151
186
  if (!fs.existsSync(abs)) {
152
187
  console.warn(`[img] Image file not found: ${abs}`);
153
188
  return null;
@@ -0,0 +1,244 @@
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
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ export type DetectedImageFormat =
20
+ | "png"
21
+ | "jpeg"
22
+ | "gif"
23
+ | "bmp"
24
+ | "webp"
25
+ | "avif"
26
+ | "tiff"
27
+ | "ico"
28
+ | "svg";
29
+
30
+ /** A decoded image in the asset pipeline's internal currency (RGB565). */
31
+ export interface DecodedImage {
32
+ width: number;
33
+ height: number;
34
+ /** RGB565 pixels, row-major, width*height values. */
35
+ data: number[];
36
+ }
37
+
38
+ /** Identify a buffer by magic bytes. null = unknown (raw RGB565 dump). */
39
+ export function detectImageFormat(buf: Buffer): DetectedImageFormat | null {
40
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "png";
41
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpeg";
42
+ if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF") return "gif";
43
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "bmp";
44
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "webp";
45
+ // ISO-BMFF family (AVIF/HEIF): bytes 4..8 are "ftyp".
46
+ if (buf.length >= 8 && buf.toString("ascii", 4, 8) === "ftyp") return "avif";
47
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "II*\0") return "tiff";
48
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "MM\0*") return "tiff";
49
+ if (buf.length >= 4 && buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) return "ico";
50
+ // SVG is XML text: after an optional BOM, the first non-space char is '<'.
51
+ for (let i = 0; i < Math.min(buf.length, 8); i++) {
52
+ const b = buf[i];
53
+ if (b === 0xef || b === 0xbb || b === 0xbf) continue; // UTF-8 BOM bytes
54
+ if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue;
55
+ if (b === 0x3c) return "svg";
56
+ break;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** RGBA/RGB pixel block plus its channel count. */
62
+ interface RawPixels {
63
+ pixels: Buffer | Uint8Array;
64
+ channels: number;
65
+ width: number;
66
+ height: number;
67
+ }
68
+
69
+ let sharpLoader: Promise<any> | null = null;
70
+ function loadSharp(): Promise<any> {
71
+ if (!sharpLoader) {
72
+ sharpLoader = import("sharp").then((m) => (m as { default: any }).default).catch((err) => {
73
+ sharpLoader = null; // a later call can retry (e.g. after an install fix)
74
+ throw new Error(
75
+ `the sharp image decoder could not be loaded (${err instanceof Error ? err.message : String(err)}) — ` +
76
+ `reinstall dependencies, or convert the image to a raw RGB565 .img file manually`,
77
+ );
78
+ });
79
+ }
80
+ return sharpLoader;
81
+ }
82
+
83
+ /** Run a sharp pipeline to raw pixels: flatten alpha onto black, optionally
84
+ * downscale to fit the display (never enlarge). */
85
+ async function runSharp(
86
+ pipeline: any,
87
+ naturalW: number,
88
+ naturalH: number,
89
+ opts: { maxW?: number; maxH?: number },
90
+ ): Promise<RawPixels> {
91
+ if (opts.maxW && opts.maxH && (naturalW > opts.maxW || naturalH > opts.maxH)) {
92
+ pipeline = pipeline.resize({ width: opts.maxW, height: opts.maxH, fit: "inside", withoutEnlargement: true });
93
+ }
94
+ const { data, info } = await pipeline
95
+ .flatten({ background: "#000000" })
96
+ .raw()
97
+ .toBuffer({ resolveWithObject: true });
98
+ return { pixels: data, channels: info.channels, width: info.width, height: info.height };
99
+ }
100
+
101
+ /** Decode an .ico: decode-ico returns every frame as RGBA (PNG and BMP
102
+ * frames alike); pick the largest so the asset carries the best detail. */
103
+ async function decodeIco(buf: Buffer): Promise<RawPixels> {
104
+ const decodeIcoMod = await import("decode-ico");
105
+ const decodeIco = (decodeIcoMod as unknown as {
106
+ default: (src: Uint8Array) => Array<{ width: number; height: number; data: Uint8Array }>;
107
+ }).default;
108
+ const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
109
+ const frames = decodeIco(u8);
110
+ if (!frames || frames.length === 0) throw new Error("no frames in .ico");
111
+ let best = frames[0];
112
+ for (const f of frames) {
113
+ if (f.width * f.height > best.width * best.height) best = f;
114
+ }
115
+ return { pixels: best.data, channels: 4, width: best.width, height: best.height };
116
+ }
117
+
118
+ function rgbaToRgb565(px: Buffer | Uint8Array, channels: number): number[] {
119
+ const count = Math.floor(px.length / channels);
120
+ const out = new Array<number>(count);
121
+ for (let i = 0; i < count; i++) {
122
+ const r = px[i * channels] >> 3;
123
+ const g = px[i * channels + 1] >> 2;
124
+ const b = px[i * channels + 2] >> 3;
125
+ out[i] = (r << 11) | (g << 5) | b;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ async function decodeImageBuffer(
131
+ buf: Buffer,
132
+ format: DetectedImageFormat,
133
+ opts: { maxW?: number; maxH?: number },
134
+ ): Promise<DecodedImage> {
135
+ const sharp = await loadSharp();
136
+ let raw: RawPixels;
137
+ if (format === "ico") {
138
+ const ico = await decodeIco(buf);
139
+ if (opts.maxW && opts.maxH && (ico.width > opts.maxW || ico.height > opts.maxH)) {
140
+ // The largest frame still exceeds the display; route its RGBA through
141
+ // sharp for the same fit-inside downscale every other format gets.
142
+ const rgbaBuf = Buffer.from(ico.pixels.buffer, ico.pixels.byteOffset, ico.pixels.byteLength);
143
+ raw = await runSharp(
144
+ sharp(rgbaBuf, { raw: { width: ico.width, height: ico.height, channels: 4 } }),
145
+ ico.width,
146
+ ico.height,
147
+ opts,
148
+ );
149
+ } else {
150
+ raw = ico;
151
+ }
152
+ } else {
153
+ // File bytes: auto-orient by EXIF first, then the shared flatten/fit path.
154
+ // metadata() reports the STORED dimensions — an EXIF orientation of 5-8
155
+ // means the pipeline's rotate() transposes the output, so the fit decision
156
+ // must compare against the swapped natural size or portrait photos can
157
+ // skip the downscale they need (and landscape ones downscale needlessly).
158
+ const oriented = sharp(buf, { failOn: "none" }).rotate();
159
+ const meta = await oriented.metadata();
160
+ const exifSwap = (meta.orientation ?? 1) >= 5 && (meta.orientation ?? 1) <= 8;
161
+ const naturalW = exifSwap ? meta.height : meta.width;
162
+ const naturalH = exifSwap ? meta.width : meta.height;
163
+ raw = await runSharp(oriented, naturalW ?? 0, naturalH ?? 0, opts);
164
+ }
165
+ return { width: raw.width, height: raw.height, data: rgbaToRgb565(raw.pixels, raw.channels) };
166
+ }
167
+
168
+ // ── Warm-up cache ──────────────────────────────────────────────────────────
169
+ // Keyed by absolute path + mtime so a preview watch session picks up edited
170
+ // images; entries for prior mtimes simply go stale (a snapshot build is
171
+ // short-lived, and the transpile pipeline is one-shot).
172
+
173
+ interface CacheEntry {
174
+ mtimeMs: number;
175
+ image: DecodedImage | null; // null = failed decode (raw fallback, one warning)
176
+ }
177
+
178
+ const decodedCache = new Map<string, CacheEntry>();
179
+
180
+ function cacheKey(abs: string, mtimeMs: number): string {
181
+ return `${abs}\0${mtimeMs}`;
182
+ }
183
+
184
+ /** The decoded image for a file, or undefined when it was never warmed up or
185
+ * its decode failed (the caller falls back to the raw RGB565 reader). */
186
+ export function getCachedDecodedImage(absPath: string): DecodedImage | undefined {
187
+ let stat: fs.Stats;
188
+ try {
189
+ stat = fs.statSync(absPath);
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ const entry = decodedCache.get(cacheKey(absPath, stat.mtimeMs));
194
+ return entry ? (entry.image ?? undefined) : undefined;
195
+ }
196
+
197
+ const SRC_ATTR = /\bsrc\s*=\s*["']([^"']+)["']/g;
198
+
199
+ /** Scan a UI source for src="…" references and pre-decode every referenced
200
+ * image file. Called with `await` BEFORE the UI module loads (the module
201
+ * loader and asset reader are synchronous); failures warn and fall back. */
202
+ export async function warmUpImageDecoding(
203
+ sourceText: string,
204
+ baseDir: string,
205
+ opts: { maxW?: number; maxH?: number } = {},
206
+ ): Promise<void> {
207
+ const pending = new Set<string>();
208
+ let match: RegExpExecArray | null;
209
+ SRC_ATTR.lastIndex = 0;
210
+ while ((match = SRC_ATTR.exec(sourceText)) !== null) {
211
+ const src = match[1];
212
+ if (!src || /^(data:|https?:|#)/.test(src)) continue;
213
+ const abs = path.isAbsolute(src) ? src : path.resolve(baseDir, src);
214
+ pending.add(abs);
215
+ }
216
+ await Promise.all([...pending].map(async (abs) => {
217
+ // Track the mtime up front so a failed decode is memoized under the key
218
+ // getCachedDecodedImage() looks up — the next warm-up of an unchanged
219
+ // file hits the null entry instead of re-attempting (and re-warning).
220
+ let mtimeMs = 0;
221
+ try {
222
+ if (!fs.existsSync(abs)) return;
223
+ const stat = fs.statSync(abs);
224
+ mtimeMs = stat.mtimeMs;
225
+ const key = cacheKey(abs, mtimeMs);
226
+ if (decodedCache.has(key)) return;
227
+ const buf = fs.readFileSync(abs);
228
+ const format = detectImageFormat(buf);
229
+ if (!format) return; // legacy raw RGB565 dump — the raw reader handles it
230
+ const image = await decodeImageBuffer(buf, format, opts);
231
+ decodedCache.set(key, { mtimeMs, image });
232
+ } catch (err) {
233
+ if (mtimeMs > 0) {
234
+ decodedCache.set(cacheKey(abs, mtimeMs), { mtimeMs, image: null });
235
+ }
236
+ console.warn(`[img] ${path.basename(abs)}: ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+ }));
239
+ }
240
+
241
+ /** Test hook: drop cached decodes. */
242
+ export function resetImageDecodeCache(): void {
243
+ decodedCache.clear();
244
+ }
@@ -3,7 +3,7 @@ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
3
3
  /** Tags whose content participates in inline flow inside a text container.
4
4
  * Bare text nodes between them flow too. Recognized as inline children of a
5
5
  * text-effective node; absorbed into the parent's run list by the resolver. */
6
- export const INLINE_TAGS = new Set(["span", "a", "b", "strong", "i", "em", "u"]);
6
+ export const INLINE_TAGS = new Set(["span", "a", "b", "strong", "i", "em", "u", "code", "kbd", "samp"]);
7
7
 
8
8
  export type InlineItem =
9
9
  | { kind: "text"; text: string }
@@ -40,6 +40,57 @@ export function isDisplayNone(node: StyledNode): boolean {
40
40
  return node.style.display?.trim().toLowerCase() === "none";
41
41
  }
42
42
 
43
+ /** A resolved CSS length: device pixels, a percentage of the parent
44
+ * (resolved by the layout engine), or the auto keyword (margins). */
45
+ export type CssDim = number | "auto" | `${number}%`;
46
+
47
+ /** Parse a single CSS length to a layout dimension. px/bare numbers are device
48
+ * pixels, rem/em resolve × 16 (root font size), percentages are preserved as
49
+ * `${n}%` strings so flexbox can resolve them against the parent, and "auto"
50
+ * passes through (auto margins absorb free space — `margin: 0 auto` centers). */
51
+ export function cssDimValue(val: string | undefined): CssDim {
52
+ const v = (val ?? "").trim();
53
+ if (!v) return 0;
54
+ if (/^auto$/i.test(v)) return "auto";
55
+ const remM = /^(-?[\d.]+)rem$/i.exec(v);
56
+ if (remM) return Math.round(parseFloat(remM[1]) * 16);
57
+ const emM = /^(-?[\d.]+)em$/i.exec(v);
58
+ if (emM) return Math.round(parseFloat(emM[1]) * 16);
59
+ const pctM = /^(-?[\d.]+)%$/.exec(v);
60
+ if (pctM) return `${parseFloat(pctM[1])}%` as `${number}%`;
61
+ const m = /^(-?[\d.]+)(?:px)?$/i.exec(v);
62
+ return m ? parseFloat(m[1]) : 0;
63
+ }
64
+
65
+ /** Is this a set (non-default) dimension? Falsy 0 values are skipped when
66
+ * applying to layout; percentages and "auto" always apply. */
67
+ export function dimSet(d: CssDim): boolean {
68
+ return d !== 0;
69
+ }
70
+
71
+ /** Size- and inset-safe variant of cssDimValue(): Yoga's typings exclude the
72
+ * "auto" keyword for sizes/paddings/insets (only margins accept it), so fold
73
+ * it to undefined — auto sizing is the engine default anyway. */
74
+ export function cssSizeValue(val: string | undefined): number | `${number}%` | undefined {
75
+ const d = cssDimValue(val);
76
+ return d === "auto" ? undefined : d;
77
+ }
78
+
79
+ /** Expand a 1-4 value CSS box shorthand (padding/margin) to
80
+ * [top, right, bottom, left] following the CSS TRBL rotation:
81
+ * 1 = all, 2 = V H, 3 = T H B, 4 = T R B L. */
82
+ export function expandBoxShorthand(val: string | undefined): [CssDim, CssDim, CssDim, CssDim] {
83
+ const parts = (val ?? "").trim().split(/\s+/).filter(Boolean);
84
+ const d = parts.map((p) => cssDimValue(p));
85
+ if (d.length <= 1) {
86
+ const v = d[0] ?? 0;
87
+ return [v, v, v, v];
88
+ }
89
+ if (d.length === 2) return [d[0], d[1], d[0], d[1]];
90
+ if (d.length === 3) return [d[0], d[1], d[2], d[1]];
91
+ return [d[0], d[1], d[2], d[3]] as [CssDim, CssDim, CssDim, CssDim];
92
+ }
93
+
43
94
  /** Parse CSS aspect-ratio values: "16 / 9", "1/1", or "1.777". */
44
95
  export function parseAspectRatio(value: string | undefined): number | undefined {
45
96
  const raw = value?.trim().toLowerCase();
@@ -172,12 +223,19 @@ export function measure(node: StyledNode, availableWidth?: number, fontAssets: U
172
223
  assetTextWidth(stripInterp(value), node.style, fontAssets) ?? textWidthOf(stripInterp(value), advance);
173
224
  if (node.tag === "text" || node.tag === "button" || node.tag === "select") {
174
225
  if (node.tag === "select") {
175
- // Size to the longest option, not the full comma-separated text
226
+ // Size to the widest option by RENDERED width, not the full
227
+ // comma-separated text and not character count — with a proportional
228
+ // font, equal-length options can differ in width ("Alpha" vs "Gamma"),
229
+ // and the wider one must fit without wrapping.
176
230
  const options = node.options && node.options.length > 0
177
231
  ? node.options.map((option) => option.text)
178
232
  : (node.text ?? "").split(",").map(s => s.trim()).filter(Boolean);
179
- const longest = options.length > 0 ? options.reduce((a, b) => a.length >= b.length ? a : b) : "";
180
- return { w: widthOf(applyTextTransform(longest, node)), h: lineHeightOf(node, charH) };
233
+ const widest = options.length > 0
234
+ ? options.reduce((a, b) =>
235
+ widthOf(applyTextTransform(a, node)) >= widthOf(applyTextTransform(b, node)) ? a : b)
236
+ : "";
237
+ // +14: the dropdown chevron the runtimes draw at the right end.
238
+ return { w: widthOf(applyTextTransform(widest, node)) + 14, h: lineHeightOf(node, charH) };
181
239
  }
182
240
  const text = applyTextTransform(stripInterp(node.text), node);
183
241
  const layout = layoutText(text, {