@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
@@ -54,5 +54,5 @@ export declare function assetTextWidth(text: string, style: CSSProperty, fontAss
54
54
  * title spilling past its header's padded box). Mirrors the preview's
55
55
  * textHeight(size, fontFace) = asset.lineHeight ?? gfx.textHeight(size). */
56
56
  export declare function assetLineHeight(style: CSSProperty, fontAssets: UIFontAssetModel[]): number | undefined;
57
- export declare function buildUIFontAssets(root: StyledNode, fontFaces: CSSFontFace[], baseDir: string): UIFontAssetModel[];
58
- export declare function planUIFontAssets(root: StyledNode, fontFaces: CSSFontFace[], baseDir: string): UIFontAssetPlan[];
57
+ export declare function buildUIFontAssets(root: StyledNode, fontFaces: CSSFontFace[], baseDir: string, dynamicNodeIds?: Set<string>): UIFontAssetModel[];
58
+ export declare function planUIFontAssets(root: StyledNode, fontFaces: CSSFontFace[], baseDir: string, dynamicNodeIds?: Set<string>): UIFontAssetPlan[];
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import opentype from "opentype.js";
4
4
  import { getDisplayProfile } from "@typecad/cuttlefish/stores/display-profile-store";
5
- const FALLBACK_CHARS = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:;!?+-*/=%()[]{}<>_#@&";
5
+ const FALLBACK_CHARS = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:;!?+-*/=%()[]{}<>_#@&^~$'|";
6
6
  const SUPERSAMPLE = 4;
7
7
  export function normalizeFontFamily(value) {
8
8
  if (!value)
@@ -110,8 +110,8 @@ export function assetLineHeight(style, fontAssets) {
110
110
  return undefined;
111
111
  return asset.lineHeight;
112
112
  }
113
- export function buildUIFontAssets(root, fontFaces, baseDir) {
114
- const plans = planUIFontAssets(root, fontFaces, baseDir);
113
+ export function buildUIFontAssets(root, fontFaces, baseDir, dynamicNodeIds) {
114
+ const plans = planUIFontAssets(root, fontFaces, baseDir, dynamicNodeIds);
115
115
  const parsedFonts = new Map();
116
116
  const assets = [];
117
117
  let id = 1;
@@ -137,13 +137,21 @@ export function buildUIFontAssets(root, fontFaces, baseDir) {
137
137
  }
138
138
  return assets;
139
139
  }
140
- export function planUIFontAssets(root, fontFaces, baseDir) {
140
+ export function planUIFontAssets(root, fontFaces, baseDir, dynamicNodeIds) {
141
141
  if (fontFaces.length === 0)
142
142
  return [];
143
143
  const requests = new Map();
144
144
  const collect = (node) => {
145
145
  const face = selectFontFaceForStyle(fontFaces, node.style);
146
146
  if (face) {
147
+ // Runtime-dynamic text renders strings the static template can't
148
+ // predict — widen those faces to the fallback charset so bound values
149
+ // ("Gamma" against a subset planned from authored "mode: alpha") don't
150
+ // drop glyphs. Dynamic = ui.bind(..., 'text') targets (via
151
+ // dynamicNodeIds), {expr} interpolations, and bind:text attributes.
152
+ const dynamicText = (node.id !== undefined && dynamicNodeIds?.has(node.id)) ||
153
+ node.hasInterpolation === true ||
154
+ node.bind?.text !== undefined;
147
155
  const px = fontPxOf(node.style);
148
156
  const sourcePath = resolveFontPath(face.src, baseDir);
149
157
  const fontWeight = normalizeFontWeight(face.fontWeight ?? node.style.fontWeight);
@@ -162,7 +170,16 @@ export function planUIFontAssets(root, fontFaces, baseDir) {
162
170
  };
163
171
  requests.set(key, request);
164
172
  }
165
- if (fontSubsetOf(node.style) === "fallback" && request.subset !== "fallback") {
173
+ if ((fontSubsetOf(node.style) === "fallback" || dynamicText) && request.subset !== "fallback") {
174
+ request.subset = "fallback";
175
+ addText(request.chars, FALLBACK_CHARS);
176
+ }
177
+ // Virtualized lists render RUNTIME text — item expressions produce
178
+ // strings the static template can't predict (the same reason `{expr}`
179
+ // interpolations add digits). Without this the list's face only carries
180
+ // glyphs from unrelated static text and items render blanks (e.g. a
181
+ // subset with '0' but no '1'-'9' draws "Item 10" as "Item 0").
182
+ if (node.tag === "list" && request.subset !== "fallback") {
166
183
  request.subset = "fallback";
167
184
  addText(request.chars, FALLBACK_CHARS);
168
185
  }
@@ -45,6 +45,10 @@ export interface UIElementNode {
45
45
  canvasH?: number;
46
46
  /** Disabled state */
47
47
  disabled?: boolean;
48
+ /** <drawer side="bottom|top|left|right"> — the edge the panel slides from. */
49
+ drawerSide?: string;
50
+ /** <toast duration="2500"> — ms a toast stays open before auto-closing. */
51
+ toastDuration?: number;
48
52
  children: UIElementNode[];
49
53
  /** Ordered inline content sequence (text/element/break items). Present only
50
54
  * for text nodes with mixed inline children; absent for plain-text nodes. */
@@ -13,7 +13,23 @@
13
13
  // ---------------------------------------------------------------------------
14
14
  import { parseHTML } from "linkedom";
15
15
  import { collectInlineSequence, INLINE_TAGS } from "./inline-parser.js";
16
- const SUPPORTED_TAGS = new Set(["screen", "text", "button", "view", "check", "select", "option", "label", "radio", "progress", "range", "input", "keyboard", "row", "key", "style", "a", "img", "list", "canvas", "br"]);
16
+ const SUPPORTED_TAGS = new Set(["screen", "text", "button", "view", "check", "select", "option", "label", "radio", "progress", "range", "input", "keyboard", "row", "key", "style", "a", "img", "list", "canvas", "br", "drawer", "dialog", "toast"]);
17
+ /** Document-furniture tags that never render. Skipped silently (unlike unknown
18
+ * tags, which fall back to generic containers with a warning). */
19
+ const METADATA_TAGS = new Set(["link", "meta", "title", "head", "script", "source", "track", "col", "colgroup"]);
20
+ /** Web elements that cannot work on microcontroller targets (vector graphics,
21
+ * media, embedded browsing). Their subtree still renders as generic containers
22
+ * — content is never dropped — but the diagnostic says WHY, with the native
23
+ * alternative, instead of a generic unknown-tag warning. */
24
+ const UNSUPPORTED_TAGS = {
25
+ svg: "vector graphics have no renderer — draw via <canvas> or export a bitmap for <img>",
26
+ video: "there is no video pipeline on MCU targets",
27
+ audio: "there is no audio pipeline on MCU targets",
28
+ iframe: "there is no browser engine to embed",
29
+ embed: "there is no plugin/content engine on MCU targets",
30
+ object: "there is no plugin/content engine on MCU targets",
31
+ picture: "art-direction source selection is unsupported — a child <img> still renders",
32
+ };
17
33
  /** HTML tag aliases — common HTML elements remapped to internal primitives.
18
34
  * Semantic block containers -> view; inline/heading text tags -> text.
19
35
  * Applied before the SUPPORTED_TAGS check so authors can write familiar HTML. */
@@ -21,12 +37,38 @@ const TAG_REMAP = {
21
37
  // Block-level containers -> view (flexbox/positioning surface)
22
38
  body: "view", div: "view", header: "view", footer: "view", nav: "view",
23
39
  main: "view", section: "view", article: "view", aside: "view",
40
+ // Forms are layout-transparent here (no submission model) — the wrapper is
41
+ // just a container; children render normally.
42
+ form: "view", fieldset: "view",
43
+ // Lists: the container is a plain view; <li> gets a marker prefix below.
44
+ ul: "view", ol: "view", li: "text",
45
+ // Definition lists: dt = bold term, dd = indented description (UA rules).
46
+ dl: "view", dt: "text", dd: "text",
47
+ // Tables: equal-width flex approximation — tr is a row, td/th are stretched
48
+ // cells (UA rules); thead/tbody/tfoot are plain row groups. Approximation
49
+ // surfaced via the html-table-approximation diagnostic.
50
+ table: "view", tr: "view", td: "text", th: "text",
51
+ thead: "view", tbody: "view", tfoot: "view", caption: "text",
52
+ // Horizontal rule -> 1px rule (styled by the UA stylesheet).
53
+ hr: "view",
24
54
  // Inline/heading text -> text
25
- span: "text", p: "text",
55
+ span: "text", p: "text", small: "text", output: "text",
26
56
  h1: "text", h2: "text", h3: "text", h4: "text", h5: "text", h6: "text",
27
57
  // Styling tags -> text (inline; resolver applies bold/italic/underline defaults
28
58
  // and absorbs them into the parent's run list).
29
59
  b: "text", strong: "text", i: "text", em: "text", u: "text",
60
+ // Code phrase groups -> text; the UA stylesheet gives them the mono family.
61
+ code: "text", kbd: "text", samp: "text", pre: "text",
62
+ // Meter is the same bar primitive as progress (min/max/value).
63
+ meter: "progress",
64
+ // Multiline text area -> single-line input (single-line OSK at runtime).
65
+ textarea: "input",
66
+ };
67
+ /** <input type="..."> spellings that remap to the dedicated control tags. */
68
+ const INPUT_TYPE_REMAP = {
69
+ checkbox: "check",
70
+ radio: "radio",
71
+ range: "range",
30
72
  };
31
73
  /** Extract <style>...</style> block contents from HTML source.
32
74
  * Returns the concatenated CSS text (empty if no style blocks). */
@@ -37,10 +79,23 @@ export function extractStyleBlocks(src) {
37
79
  export function parseHtml(src, diagnostics) {
38
80
  return parseAllScreens(src, diagnostics)[0];
39
81
  }
82
+ // HTML-spec parsers treat `<view .../>` as an OPEN tag for non-void elements
83
+ // (XML-style self-closing is not HTML), so JSX-habit markup silently nests:
84
+ // `<view class="a"/><view class="b"/>` puts b INSIDE a. Expand every
85
+ // self-closing non-void tag to an explicit pair before parsing. Void
86
+ // elements (img, hr, input, ...) already parse correctly and are skipped.
87
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
88
+ function expandSelfClosingTags(src) {
89
+ return src.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)\/>/g, (m, tag, attrs) => {
90
+ if (VOID_TAGS.has(tag.toLowerCase()))
91
+ return m;
92
+ return `<${tag}${attrs}></${tag}>`;
93
+ });
94
+ }
40
95
  /** Parse all <screen> roots from HTML. Returns one tree per screen.
41
96
  * Used for multi-screen navigation (<a href="#screenId">). */
42
97
  export function parseAllScreens(src, diagnostics) {
43
- const withoutComments = src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
98
+ const withoutComments = expandSelfClosingTags(src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ""));
44
99
  const wrapped = `<div id="__root__">${withoutComments}</div>`;
45
100
  const { document } = parseHTML(wrapped);
46
101
  const root = document.getElementById("__root__");
@@ -55,7 +110,7 @@ export function parseAllScreens(src, diagnostics) {
55
110
  }
56
111
  /** Parse HTML, returning both the <screen> tree and any <keyboard> templates. */
57
112
  export function parseHtmlWithKeyboards(src, diagnostics) {
58
- const withoutComments = src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
113
+ const withoutComments = expandSelfClosingTags(src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ""));
59
114
  const wrapped = `<div id="__root__">${withoutComments}</div>`;
60
115
  const { document } = parseHTML(wrapped);
61
116
  const root = document.getElementById("__root__");
@@ -127,10 +182,52 @@ function domToUIElementNode(el, diagnostics) {
127
182
  // <label> and <a> are treated as <text> internally; HTML aliases
128
183
  // (div/header/span/p/h1-h6/...) remap to view or text.
129
184
  const remapped = TAG_REMAP[tag];
130
- const effectiveTag = remapped ? remapped
185
+ let effectiveTag = remapped ? remapped
131
186
  : (tag === "label" || tag === "a") ? "text" : tag;
187
+ // <input type="checkbox|radio|range"> — the spelling web authors type // reflexively — remaps to the dedicated control tags with identical behavior.
188
+ // Other non-text types (email, date, ...) have no MCU counterpart: they
189
+ // normalize to a single-line text input with a warning.
190
+ if (tag === "input") {
191
+ const inputType = (el.getAttribute("type") || "text").toLowerCase();
192
+ const remap = INPUT_TYPE_REMAP[inputType];
193
+ if (remap) {
194
+ effectiveTag = remap;
195
+ }
196
+ else if (inputType !== "text" && inputType !== "number") {
197
+ diagnostics?.push({
198
+ severity: "warning",
199
+ message: `<input type="${inputType}"> has no embedded equivalent — treated as a single-line text input.`,
200
+ hint: `Supported types: text, number, checkbox, radio, range.`,
201
+ code: "html-input-type-unsupported",
202
+ source: "input",
203
+ });
204
+ }
205
+ }
132
206
  if (!SUPPORTED_TAGS.has(effectiveTag)) {
133
- throw new Error(`Unsupported tag <${tag}> supported: ${[...SUPPORTED_TAGS].join(", ")}`);
207
+ // Web behavior: unknown elements are generic boxes (this is why custom
208
+ // elements work in HTML). Content is NEVER dropped — a text-only leaf
209
+ // becomes a text node, anything with element children a container.
210
+ const hasElementChildren = Array.from(el.children).some((c) => !METADATA_TAGS.has(c.tagName.toLowerCase()));
211
+ effectiveTag = hasElementChildren ? "view" : "text";
212
+ const knownUnsupported = UNSUPPORTED_TAGS[tag];
213
+ if (knownUnsupported) {
214
+ diagnostics?.push({
215
+ severity: "warning",
216
+ message: `<${tag}> is not supported on microcontroller targets — rendered as a generic container.`,
217
+ hint: `${knownUnsupported}.`,
218
+ code: "unsupported-html-tag",
219
+ source: tag,
220
+ });
221
+ }
222
+ else {
223
+ diagnostics?.push({
224
+ severity: "warning",
225
+ message: `Unknown HTML tag <${tag}> — rendered as a generic ${hasElementChildren ? "container" : "text"} element.`,
226
+ hint: `Supported tags: ${[...SUPPORTED_TAGS].sort().join(", ")}.`,
227
+ code: "unknown-html-tag",
228
+ source: tag,
229
+ });
230
+ }
134
231
  }
135
232
  const id = el.getAttribute("id") || undefined;
136
233
  const refAttr = el.getAttribute("ref") || undefined;
@@ -141,16 +238,27 @@ function domToUIElementNode(el, diagnostics) {
141
238
  const checkedAttr = el.hasAttribute("checked");
142
239
  const minAttr = el.getAttribute("min") || undefined;
143
240
  const maxAttr = el.getAttribute("max") || undefined;
144
- const typeAttr = tag === "input"
241
+ // Text-input specifics only apply to the text/number input (not to
242
+ // checkbox/radio/range inputs remapped above, and not to <textarea>).
243
+ const isTextInput = tag === "input" && effectiveTag === "input";
244
+ if (tag === "textarea") {
245
+ diagnostics?.push({
246
+ severity: "warning",
247
+ message: `<textarea> renders as a single-line <input> (the on-screen keyboard is single-line).`,
248
+ code: "html-textarea-single-line",
249
+ source: "textarea",
250
+ });
251
+ }
252
+ const typeAttr = isTextInput
145
253
  ? (el.getAttribute("type") === "number" ? "number" : "text")
146
254
  : undefined;
147
- const placeholderAttr = el.getAttribute("placeholder") || undefined;
255
+ const placeholderAttr = isTextInput ? (el.getAttribute("placeholder") || undefined) : undefined;
148
256
  const maxlengthAttr = el.getAttribute("maxlength");
149
257
  // <input> defaults maxlength to 16 when absent or unparseable.
150
- const maxlengthNum = tag === "input"
258
+ const maxlengthNum = isTextInput
151
259
  ? (maxlengthAttr ? (parseInt(maxlengthAttr, 10) || 16) : 16)
152
260
  : undefined;
153
- const keyboardAttr = el.getAttribute("keyboard") || undefined;
261
+ const keyboardAttr = isTextInput ? (el.getAttribute("keyboard") || undefined) : undefined;
154
262
  const hiddenAttr = el.hasAttribute("hidden");
155
263
  const inlineStyleAttr = el.getAttribute("style") || undefined;
156
264
  const hrefAttr = (tag === "a" || tag === "button") ? (el.getAttribute("href") || undefined) : undefined;
@@ -228,21 +336,27 @@ function domToUIElementNode(el, diagnostics) {
228
336
  }
229
337
  // Text content: only direct text, not children's text.
230
338
  let text;
339
+ // Element children that become nodes: everything except document metadata
340
+ // (skipped silently) and option/br (consumed by the select/text paths).
341
+ // Unknown tags are KEPT — their own recursive call renders them as generic
342
+ // containers, so no subtree is ever dropped for being unrecognized.
231
343
  const childElements = Array.from(el.children).filter((c) => {
232
344
  const ct = c.tagName.toLowerCase();
233
- // Accept native tags and HTML aliases (div/span/p/h1-h6/...) that remap later.
234
- const accepted = SUPPORTED_TAGS.has(ct) || TAG_REMAP[ct] !== undefined;
235
- if (!accepted && ct !== "option" && ct !== "br" && diagnostics) {
236
- diagnostics.push({
237
- severity: "warning",
238
- message: `Unknown HTML tag <${ct}> — ignored.`,
239
- hint: `Supported tags: ${[...SUPPORTED_TAGS].sort().join(", ")}.`,
240
- code: "unknown-html-tag",
241
- source: ct,
242
- });
243
- }
244
- return accepted && ct !== "option" && ct !== "br";
345
+ if (METADATA_TAGS.has(ct))
346
+ return false;
347
+ return ct !== "option" && ct !== "br";
245
348
  });
349
+ // Non-empty direct text runs (trimmed). Runs that sit next to element
350
+ // children become anonymous text children below — the CSS anonymous-box
351
+ // model: a container's stray text is never silently dropped.
352
+ const directTextRuns = [];
353
+ for (const child of Array.from(el.childNodes)) {
354
+ if (child.nodeType === 3) {
355
+ const t = (child.textContent ?? "").trim();
356
+ if (t)
357
+ directTextRuns.push(t);
358
+ }
359
+ }
246
360
  // Inline-bearing text nodes collect an ordered inline sequence instead of a
247
361
  // single text string. Only effective-tag "text" can be inline-bearing;
248
362
  // collectInlineSequence returns undefined for plain text or block-child nodes.
@@ -250,6 +364,9 @@ function domToUIElementNode(el, diagnostics) {
250
364
  if (effectiveTag === "text") {
251
365
  inline = collectInlineSequence(el, diagnostics ?? []);
252
366
  }
367
+ // A text tag whose inline collection failed (block child inside <p>/<text>)
368
+ // keeps its tag — selectors must keep matching it — and its stray text is
369
+ // preserved as anonymous text children below (the anonymous-block-box model).
253
370
  // Text content: only direct text, and only when there's no inline sequence
254
371
  // (inline content is captured above; the plain-text path is unchanged).
255
372
  let hasInterpolation = false;
@@ -272,13 +389,111 @@ function domToUIElementNode(el, diagnostics) {
272
389
  hasInterpolation = /\{[^{}]+\}/.test(tc);
273
390
  }
274
391
  }
392
+ // <table> approximation note: rows are flex rows of equal-width stretched
393
+ // cells. Visible, not silent — see the UA stylesheet for the styling.
394
+ if (tag === "table") {
395
+ diagnostics?.push({
396
+ severity: "info",
397
+ message: `<table> renders as equal-width flex columns (tr = row, td/th = cells) — no auto column sizing, colspan, or rowspan.`,
398
+ hint: `Set explicit widths or flex-grow on cells for custom column sizes.`,
399
+ code: "html-table-approximation",
400
+ source: "table",
401
+ });
402
+ }
403
+ // colspan/rowspan have no equal-width-flex equivalent — warn when present.
404
+ if (tag === "td" || tag === "th") {
405
+ for (const span of ["colspan", "rowspan"]) {
406
+ if (el.hasAttribute(span)) {
407
+ diagnostics?.push({
408
+ severity: "warning",
409
+ message: `<${tag}> ${span} is not supported — the cell renders as a single equal-width column/row.`,
410
+ hint: `Split the content across cells, or restructure with a flex row.`,
411
+ code: "html-table-span-unsupported",
412
+ source: tag,
413
+ });
414
+ }
415
+ }
416
+ }
417
+ // <li> list markers: text-only items get a prefix ("• " for <ul>, "N. " for
418
+ // <ol>) so plain lists read correctly with zero styling. Items with block
419
+ // children render as containers without a marker (style those yourself).
420
+ if (tag === "li" && (text || inline)) {
421
+ const parentTag = el.parentElement?.tagName?.toLowerCase();
422
+ let marker = "• ";
423
+ if (parentTag === "ol") {
424
+ let n = 0;
425
+ for (const sib of Array.from(el.parentElement.children)) {
426
+ if (sib === el)
427
+ break;
428
+ if (sib.tagName.toLowerCase() === "li")
429
+ n++;
430
+ }
431
+ marker = `${n + 1}. `;
432
+ }
433
+ if (text)
434
+ text = marker + text;
435
+ if (inline)
436
+ inline = [{ kind: "text", text: marker }, ...inline];
437
+ }
275
438
  const remappedFrom = (remapped || tag === "label" || tag === "a") && tag !== effectiveTag ? tag : undefined;
276
- const node = { tag: effectiveTag, origTag: remappedFrom, id, classes, text, value: valueAttr, name: nameAttr, checked: checkedAttr, min: minAttr, max: maxAttr, type: typeAttr, placeholder: placeholderAttr, maxlength: maxlengthNum, keyboard: keyboardAttr, hidden: hiddenAttr, inlineStyle: inlineStyleAttr, href: hrefAttr, src: srcAttr, imgWidth: imgWidthAttr || undefined, imgHeight: imgHeightAttr || undefined, itemHeight: itemHeightAttr, canvasW: canvasWAttr, canvasH: canvasHAttr, disabled: disabledAttr, inline, hasInterpolation, events: hasEvents ? events : undefined, bind: hasBind ? bind : undefined, ref: refAttr, children: [] };
277
- for (const child of childElements) {
278
- // Inline children are absorbed into `inline`; don't also emit them as nodes.
279
- if (inline && INLINE_TAGS.has(child.tagName.toLowerCase()))
439
+ const drawerSideAttr = tag === "dialog"
440
+ ? "center"
441
+ : (tag === "drawer" || tag === "toast") ? (el.getAttribute("side") ?? "bottom").toLowerCase() : undefined;
442
+ const toastDurationAttr = tag === "toast"
443
+ ? Math.max(500, parseInt(el.getAttribute("duration") || "2500", 10) || 2500)
444
+ : undefined;
445
+ const node = { tag: effectiveTag, origTag: remappedFrom, id, classes, text, value: valueAttr, name: nameAttr, drawerSide: drawerSideAttr, toastDuration: toastDurationAttr, checked: checkedAttr, min: minAttr, max: maxAttr, type: typeAttr, placeholder: placeholderAttr, maxlength: maxlengthNum, keyboard: keyboardAttr, hidden: hiddenAttr, inlineStyle: inlineStyleAttr, href: hrefAttr, src: srcAttr, imgWidth: imgWidthAttr || undefined, imgHeight: imgHeightAttr || undefined, itemHeight: itemHeightAttr, canvasW: canvasWAttr, canvasH: canvasHAttr, disabled: disabledAttr, inline, hasInterpolation, events: hasEvents ? events : undefined, bind: hasBind ? bind : undefined, ref: refAttr, children: [] };
446
+ // Anonymous inline wrap: stray text next to ONLY-inline element children
447
+ // flows as one text line (CSS anonymous inline boxes) instead of stacking
448
+ // "Total:" above the <b>3</b> run. Requires stray text to trigger — inline
449
+ // elements alone keep the legacy stacked behavior (deliberate chip layout).
450
+ if (!inline && childElements.length > 0 && directTextRuns.length > 0) {
451
+ const allInline = childElements.every((c) => INLINE_TAGS.has(c.tagName.toLowerCase()));
452
+ if (allInline) {
453
+ const seq = collectInlineSequence(el, diagnostics ?? []);
454
+ if (seq) {
455
+ node.children.push({
456
+ tag: "text",
457
+ classes: [],
458
+ inline: seq,
459
+ hasInterpolation: directTextRuns.some((t) => /\{[^{}]+\}/.test(t)),
460
+ children: [],
461
+ });
462
+ return node;
463
+ }
464
+ }
465
+ }
466
+ // Children in document order, interleaving anonymous text children for
467
+ // stray text runs (never dropped — the CSS anonymous-block-box model).
468
+ // Inline children already absorbed by an inline[] sequence are skipped.
469
+ let textBuffer = "";
470
+ const flushText = () => {
471
+ const t = textBuffer.trim();
472
+ textBuffer = "";
473
+ if (!t)
474
+ return;
475
+ node.children.push({
476
+ tag: "text",
477
+ classes: [],
478
+ text: t,
479
+ hasInterpolation: /\{[^{}]+\}/.test(t),
480
+ children: [],
481
+ });
482
+ };
483
+ for (const child of Array.from(el.childNodes)) {
484
+ if (child.nodeType === 3) {
485
+ if (childElements.length > 0 && !inline)
486
+ textBuffer += child.textContent ?? "";
487
+ continue;
488
+ }
489
+ const childTag = child.tagName?.toLowerCase();
490
+ if (!childTag || METADATA_TAGS.has(childTag) || childTag === "option" || childTag === "br")
491
+ continue;
492
+ if (inline && INLINE_TAGS.has(childTag))
280
493
  continue;
494
+ flushText();
281
495
  node.children.push(domToUIElementNode(child, diagnostics));
282
496
  }
497
+ flushText();
283
498
  return node;
284
499
  }
@@ -16,6 +16,13 @@ export interface LoadedUIImageAssets {
16
16
  }
17
17
  export declare function loadImageAssets(roots: StyledNode | StyledNode[], htmlDir: string): LoadedUIImageAssets;
18
18
  export declare function emitImageTables(assets: UIImageAsset[], colorFormat?: ColorFormat): string;
19
- /** Read a raw RGB565 binary file and return a padded UIImageAsset. */
19
+ /** Give <img> nodes without explicit width/height attributes their decoded
20
+ * image's natural size, so the layout box matches the asset. Must run
21
+ * BEFORE layout (both pipelines arrange first, then load assets); explicit
22
+ * author attrs keep controlling the layout box, with object-fit scaling. */
23
+ export declare function applyDecodedImageSizes(roots: StyledNode | StyledNode[], htmlDir: string): void;
24
+ /** Read a raw RGB565 binary file and return a padded UIImageAsset. A decoded
25
+ * (converted) image in the warm-up cache wins — natural size, converted
26
+ * pixels. */
20
27
  export declare function readRgb565Image(srcPath: string, htmlDir: string, id: string, width: number, height: number): UIImageAsset | null;
21
28
  export {};
@@ -4,6 +4,7 @@
4
4
  // ---------------------------------------------------------------------------
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { getCachedDecodedImage } from "./image-decode.js";
7
8
  function sanitizeCppIdentifier(value, fallback) {
8
9
  const sanitized = value.replace(/[^a-zA-Z0-9_]/g, "_");
9
10
  const base = sanitized.length > 0 ? sanitized : fallback;
@@ -39,9 +40,14 @@ export function loadImageAssets(roots, htmlDir) {
39
40
  const walk = (node) => {
40
41
  const src = node.src;
41
42
  if (src && node.id) {
43
+ const abs = path.isAbsolute(src) ? src : path.resolve(htmlDir, src);
44
+ const decoded = getCachedDecodedImage(abs);
42
45
  const width = imageNaturalWidth(node);
43
46
  const height = imageNaturalHeight(node);
44
- const key = imageAssetKey(src, htmlDir, width, height);
47
+ // Decoded (converted) assets are keyed by file alone — their size is
48
+ // the image's natural size, not the node's attrs. Raw .img dumps keep
49
+ // the (src, dims) key — the dims ARE the asset geometry there.
50
+ const key = decoded ? `decoded:\0${abs}` : imageAssetKey(src, htmlDir, width, height);
45
51
  let assetIdx = keyToAssetIndex.get(key);
46
52
  if (assetIdx === undefined) {
47
53
  const assetId = uniqueAssetId(node.id, assets.length, usedIds);
@@ -101,11 +107,38 @@ export function emitImageTables(assets, colorFormat = "rgb565") {
101
107
  lines.push(`const uint16_t __ui_image_count = ${assets.length};`);
102
108
  return lines.join("\n");
103
109
  }
104
- /** Read a raw RGB565 binary file and return a padded UIImageAsset. */
110
+ /** Give <img> nodes without explicit width/height attributes their decoded
111
+ * image's natural size, so the layout box matches the asset. Must run
112
+ * BEFORE layout (both pipelines arrange first, then load assets); explicit
113
+ * author attrs keep controlling the layout box, with object-fit scaling. */
114
+ export function applyDecodedImageSizes(roots, htmlDir) {
115
+ const walk = (node) => {
116
+ const src = node.src;
117
+ if (src && node.imgWidth === undefined && node.imgHeight === undefined) {
118
+ const abs = path.isAbsolute(src) ? src : path.resolve(htmlDir, src);
119
+ const decoded = getCachedDecodedImage(abs);
120
+ if (decoded) {
121
+ node.imgWidth = decoded.width;
122
+ node.imgHeight = decoded.height;
123
+ }
124
+ }
125
+ node.children.forEach(walk);
126
+ };
127
+ for (const root of Array.isArray(roots) ? roots : [roots]) {
128
+ walk(root);
129
+ }
130
+ }
131
+ /** Read a raw RGB565 binary file and return a padded UIImageAsset. A decoded
132
+ * (converted) image in the warm-up cache wins — natural size, converted
133
+ * pixels. */
105
134
  export function readRgb565Image(srcPath, htmlDir, id, width, height) {
106
135
  const abs = path.isAbsolute(srcPath)
107
136
  ? srcPath
108
137
  : path.resolve(htmlDir, srcPath);
138
+ const decoded = getCachedDecodedImage(abs);
139
+ if (decoded) {
140
+ return { id, width: decoded.width, height: decoded.height, data: decoded.data };
141
+ }
109
142
  if (!fs.existsSync(abs)) {
110
143
  console.warn(`[img] Image file not found: ${abs}`);
111
144
  return null;
@@ -0,0 +1,22 @@
1
+ export type DetectedImageFormat = "png" | "jpeg" | "gif" | "bmp" | "webp" | "avif" | "tiff" | "ico" | "svg";
2
+ /** A decoded image in the asset pipeline's internal currency (RGB565). */
3
+ export interface DecodedImage {
4
+ width: number;
5
+ height: number;
6
+ /** RGB565 pixels, row-major, width*height values. */
7
+ data: number[];
8
+ }
9
+ /** Identify a buffer by magic bytes. null = unknown (raw RGB565 dump). */
10
+ export declare function detectImageFormat(buf: Buffer): DetectedImageFormat | null;
11
+ /** The decoded image for a file, or undefined when it was never warmed up or
12
+ * its decode failed (the caller falls back to the raw RGB565 reader). */
13
+ export declare function getCachedDecodedImage(absPath: string): DecodedImage | undefined;
14
+ /** Scan a UI source for src="…" references and pre-decode every referenced
15
+ * image file. Called with `await` BEFORE the UI module loads (the module
16
+ * loader and asset reader are synchronous); failures warn and fall back. */
17
+ export declare function warmUpImageDecoding(sourceText: string, baseDir: string, opts?: {
18
+ maxW?: number;
19
+ maxH?: number;
20
+ }): Promise<void>;
21
+ /** Test hook: drop cached decodes. */
22
+ export declare function resetImageDecodeCache(): void;