@typecad/ui 1.0.0-alpha.12 → 1.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
- package/assets/fonts/dejavu/LICENSE +187 -0
- package/assets/fonts/dejavu/README.md +49 -0
- package/dist/engine-index.js +2 -0
- package/dist/preview/build-program.d.ts +7 -1
- package/dist/preview/build-program.js +94 -15
- package/dist/preview/host-gfx.d.ts +3 -0
- package/dist/preview/host-gfx.js +30 -3
- package/dist/preview/host-ui-runtime.d.ts +115 -0
- package/dist/preview/host-ui-runtime.js +928 -46
- package/dist/ui-engine/block-layout.js +25 -11
- package/dist/ui-engine/color.d.ts +6 -0
- package/dist/ui-engine/color.js +80 -2
- package/dist/ui-engine/compat-report.d.ts +8 -0
- package/dist/ui-engine/compat-report.js +124 -0
- package/dist/ui-engine/css-imports.d.ts +4 -0
- package/dist/ui-engine/css-imports.js +70 -0
- package/dist/ui-engine/css-parser.js +58 -14
- package/dist/ui-engine/default-font.d.ts +25 -0
- package/dist/ui-engine/default-font.js +80 -0
- package/dist/ui-engine/font-assets.d.ts +2 -2
- package/dist/ui-engine/font-assets.js +22 -5
- package/dist/ui-engine/html-parser.d.ts +4 -0
- package/dist/ui-engine/html-parser.js +241 -26
- package/dist/ui-engine/image-assets.d.ts +8 -1
- package/dist/ui-engine/image-assets.js +35 -2
- package/dist/ui-engine/image-decode.d.ts +22 -0
- package/dist/ui-engine/image-decode.js +194 -0
- package/dist/ui-engine/inline-parser.js +1 -1
- package/dist/ui-engine/layout-engine.d.ts +19 -0
- package/dist/ui-engine/layout-engine.js +59 -3
- package/dist/ui-engine/model.d.ts +18 -0
- package/dist/ui-engine/model.js +75 -7
- package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
- package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
- package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
- package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
- package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
- package/dist/ui-engine/runtime-header/keyboard.js +682 -380
- package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1386
- package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
- package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
- package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
- package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
- package/dist/ui-engine/runtime-header/structs.js +228 -216
- package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
- package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
- package/dist/ui-engine/runtime-header/types-defines.js +110 -93
- package/dist/ui-engine/shadcn-kit.d.ts +1 -0
- package/dist/ui-engine/shadcn-kit.js +502 -0
- package/dist/ui-engine/style-resolver.d.ts +4 -0
- package/dist/ui-engine/style-resolver.js +58 -6
- package/dist/ui-engine/transpile-ui.js +12 -5
- package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
- package/dist/ui-engine/ua-stylesheet.js +119 -32
- package/dist/ui-engine/ui-lowering.js +37 -3
- package/dist/ui-engine/ui-registry.js +32 -4
- package/dist/ui-engine/yoga-layout.js +57 -52
- package/package.json +9 -3
- package/src/engine-index.ts +53 -51
- package/src/preview/build-program.ts +810 -734
- package/src/preview/host-gfx.ts +30 -3
- package/src/preview/host-ui-runtime.ts +4133 -3289
- package/src/ui-engine/block-layout.ts +24 -11
- package/src/ui-engine/color.ts +77 -2
- package/src/ui-engine/compat-report.ts +139 -0
- package/src/ui-engine/css-imports.ts +69 -0
- package/src/ui-engine/css-parser.ts +64 -15
- package/src/ui-engine/default-font.ts +95 -0
- package/src/ui-engine/font-assets.ts +545 -525
- package/src/ui-engine/html-parser.ts +615 -397
- package/src/ui-engine/image-assets.ts +37 -2
- package/src/ui-engine/image-decode.ts +244 -0
- package/src/ui-engine/inline-parser.ts +1 -1
- package/src/ui-engine/layout-engine.ts +61 -3
- package/src/ui-engine/model.ts +1317 -1230
- package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
- package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
- package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
- package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
- package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
- package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
- package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
- package/src/ui-engine/runtime-header/keyboard.ts +689 -386
- package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1411
- package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
- package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
- package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
- package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
- package/src/ui-engine/runtime-header/structs.ts +234 -222
- package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
- package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
- package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
- package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
- package/src/ui-engine/runtime-header/types-defines.ts +116 -99
- package/src/ui-engine/shadcn-kit.ts +503 -0
- package/src/ui-engine/style-resolver.ts +471 -416
- package/src/ui-engine/transpile-ui.ts +12 -5
- package/src/ui-engine/ua-stylesheet.ts +137 -31
- package/src/ui-engine/ui-lowering.ts +486 -452
- package/src/ui-engine/ui-registry.ts +556 -524
- package/src/ui-engine/yoga-layout.ts +307 -309
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|