@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
@@ -8,15 +8,25 @@
8
8
  // handles flexbox layouts.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import { Box, IntrinsicSize, isDisplayNone, LayoutEngine, parseAspectRatio } from "./layout-engine.js";
11
+ import { Box, IntrinsicSize, isDisplayNone, LayoutEngine, parseAspectRatio, cssDimValue, expandBoxShorthand } from "./layout-engine.js";
12
12
  import { StyledNode } from "./style-resolver.js";
13
13
 
14
- /** Parse a CSS value string ("8px", "8") to a number. */
14
+ /** Parse a CSS value string ("8px", "8") to a number (percent folds to its
15
+ * numeric part — block layout has no parent-relative resolution). */
15
16
  function cssNum(val: string | number | undefined): number {
16
17
  if (val === undefined) return 0;
17
18
  if (typeof val === "number") return val;
18
- const m = val.match(/(\d+)/);
19
- return m ? parseInt(m[1]) : 0;
19
+ const d = cssDimValue(val);
20
+ return typeof d === "number" ? d : parseFloat(String(d));
21
+ }
22
+
23
+ /** Resolve a dimension against a reference size: px passes through, a
24
+ * percentage resolves against `total` (0 when unresolvable). */
25
+ function resolveDim(val: string | undefined, total: number): number {
26
+ const d = cssDimValue(val);
27
+ if (typeof d === "number") return d;
28
+ if (typeof d === "string" && d.endsWith("%")) return Math.round((parseFloat(d) * total) / 100);
29
+ return 0;
20
30
  }
21
31
 
22
32
  export class BlockLayoutEngine implements LayoutEngine {
@@ -42,12 +52,15 @@ export class BlockLayoutEngine implements LayoutEngine {
42
52
  out.push(box);
43
53
  if (node.children.length === 0) return;
44
54
 
45
- const pad = cssNum(node.style.padding);
55
+ // Padding: full 1-4 value TRBL expansion (vertical/horizontal pairs).
56
+ const [padT, , padB, padL] = expandBoxShorthand(node.style.padding);
57
+ const padV = (typeof padT === "number" ? padT : 0) + (typeof padB === "number" ? padB : 0);
58
+ const padH = typeof padL === "number" ? padL * 2 : 0;
46
59
  const content: Box = {
47
- x: box.x + pad,
48
- y: box.y + pad,
49
- w: box.w - pad * 2,
50
- h: box.h - pad * 2,
60
+ x: box.x + (typeof padL === "number" ? padL : 0),
61
+ y: box.y + (typeof padT === "number" ? padT : 0),
62
+ w: box.w - padH,
63
+ h: box.h - padV,
51
64
  };
52
65
  let cursorY = content.y;
53
66
 
@@ -67,8 +80,8 @@ export class BlockLayoutEngine implements LayoutEngine {
67
80
  ? intrinsic.w + childPad * 2
68
81
  : content.w;
69
82
  const childH = intrinsic.h > 0 ? intrinsic.h + (isButton ? childPad * 2 : 0) : 16;
70
- const explicitW = cssNum(child.style.width);
71
- const explicitH = cssNum(child.style.height);
83
+ const explicitW = resolveDim(child.style.width, content.w);
84
+ const explicitH = resolveDim(child.style.height, content.h);
72
85
  const aspectRatio = parseAspectRatio(child.style.aspectRatio);
73
86
  let resolvedW = childW;
74
87
  let resolvedH = childH;
@@ -88,9 +88,38 @@ const NAMED_COLORS: Record<string, RGB> = {
88
88
  violet: { r: 238, g: 130, b: 238 }, wheat: { r: 245, g: 222, b: 179 },
89
89
  white: { r: 255, g: 255, b: 255 }, whitesmoke: { r: 245, g: 245, b: 245 },
90
90
  yellow: { r: 255, g: 255, b: 0 }, yellowgreen: { r: 154, g: 205, b: 50 },
91
- transparent: { r: 0, g: 0, b: 0 },
92
91
  };
93
92
 
93
+ /** Is this color fully transparent (the `transparent` keyword, alpha-0 rgba/
94
+ * hsla, or #rrggbb00)? Callers treat it as "no fill" rather than black. */
95
+ export function isTransparentColor(input: string): boolean {
96
+ const s = input.trim().toLowerCase();
97
+ if (s === "transparent" || s === "none") return true;
98
+ const rgbaM = /^rgba?\([^)]*,\s*(0(?:\.0+)?)\s*\)$/.exec(s);
99
+ if (rgbaM) return true;
100
+ const hslaM = /^hsla?\([^)]*\/\s*(0(?:\.0+)?|0%)\s*\)$/.exec(s);
101
+ if (hslaM) return true;
102
+ const hex8M = /^#[0-9a-f]{8}$/.exec(s);
103
+ if (hex8M) return s.endsWith("00");
104
+ return false;
105
+ }
106
+
107
+ /** Does this color carry a partial (non-zero, non-one) alpha that the engine
108
+ * will ignore? (No alpha blending on bare metal.) Used for warnings. */
109
+ export function hasIgnoredAlpha(input: string): boolean {
110
+ const s = input.trim().toLowerCase();
111
+ const rgbaM = /^rgba?\([^)]*,\s*([\d.]+)\s*\)$/.exec(s);
112
+ if (rgbaM) return parseFloat(rgbaM[1]) < 1;
113
+ const hslaM = /^hsla?\([^)]*\/\s*([\d.]+)%?\s*\)$/.exec(s);
114
+ if (hslaM) return (hslaM[1].endsWith("%") ? parseFloat(hslaM[1]) / 100 : parseFloat(hslaM[1])) < 1;
115
+ const hex8M = /^#[0-9a-f]{8}$/.exec(s);
116
+ if (hex8M) {
117
+ const a = parseInt(s.slice(7, 9), 16) / 255;
118
+ return a > 0 && a < 1;
119
+ }
120
+ return false;
121
+ }
122
+
94
123
  /** Parse any CSS color string to {r,g,b}. Alpha is ignored (no blending). */
95
124
  export function parseColor(input: string): RGB {
96
125
  const s = input.trim().toLowerCase();
@@ -144,7 +173,53 @@ export function parseColor(input: string): RGB {
144
173
  // Named colors
145
174
  if (NAMED_COLORS[s]) return NAMED_COLORS[s];
146
175
 
147
- throw new Error(`Unsupported color format "${input}" use #hex, rgb(), hsl(), or a named color`);
176
+ // transparent resolves to black as a COLOR (e.g. gradient stops); fill
177
+ // sites check isTransparentColor() first and skip painting entirely.
178
+ if (s === "transparent") return { r: 0, g: 0, b: 0 };
179
+
180
+ // oklch(L C H [/ a]) — CSS Color 4, the dialect of Tailwind v4 era shadcn
181
+ // themes. Alpha ignored like everywhere else; L accepts % form.
182
+ const oklchM = /^oklch\(\s*([\d.]+|none)(%)?\s+([\d.]+|none)\s+([\d.]+|none)(?:deg|turn|rad)?(?:\s*\/\s*[\d.%]+)?\s*\)$/i.exec(s);
183
+ if (oklchM) {
184
+ const l = oklchM[1] === "none" ? 0 : parseFloat(oklchM[1]) / (oklchM[2] ? 100 : 1);
185
+ const c = oklchM[3] === "none" ? 0 : parseFloat(oklchM[3]);
186
+ let h = oklchM[4] === "none" ? 0 : parseFloat(oklchM[4]);
187
+ if (/turn\b/i.test(s)) h = h * 360;
188
+ return oklchToRgb(l, c, h);
189
+ }
190
+
191
+ // shadcn theme dialect: bare HSL channel triplets ("222.2 47.4% 11.2%") —
192
+ // the format stock shadcn themes store in CSS variables, normally consumed
193
+ // as hsl(var(--x)). The shape (number % number%) is unambiguous with any
194
+ // valid CSS color, so both dialects work through var() substitution.
195
+ const tripletM = /^([\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/.exec(s);
196
+ if (tripletM) return hslToRgb(parseFloat(tripletM[1]), parseFloat(tripletM[2]), parseFloat(tripletM[3]));
197
+
198
+ throw new Error(`Unsupported color format "${input}" — use #hex, rgb(), hsl(), oklch(), or a named color`);
199
+ }
200
+
201
+ /** Convert oklch (L 0-1, C 0-~0.4, H degrees) to sRGB via OkLab — Björn
202
+ * Ottosson's reference matrices, then the sRGB transfer function. Values
203
+ * outside the sRGB gamut clamp per channel (compile-time approximation of
204
+ * CSS gamut mapping — good enough for opaque 16-bit panel output). */
205
+ function oklchToRgb(l: number, c: number, h: number): RGB {
206
+ const rad = (h * Math.PI) / 180;
207
+ const a = c * Math.cos(rad);
208
+ const b = c * Math.sin(rad);
209
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
210
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
211
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
212
+ const L = l_ * l_ * l_;
213
+ const M = m_ * m_ * m_;
214
+ const S = s_ * s_ * s_;
215
+ const lr = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
216
+ const lg = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
217
+ const lb = -0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S;
218
+ const gamma = (v: number): number => {
219
+ const x = Math.min(1, Math.max(0, v));
220
+ return Math.round(255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055));
221
+ };
222
+ return { r: gamma(lr), g: gamma(lg), b: gamma(lb) };
148
223
  }
149
224
 
150
225
  /** Convert HSL (h: 0-360, s/l: 0-100) to RGB. Standard CSS algorithm. */
@@ -0,0 +1,139 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CSS compatibility report — post-layout diagnostics that make the engine's
3
+ // divergences from browser behavior VISIBLE instead of silent.
4
+ //
5
+ // Every check here corresponds to a place where the engine approximates or
6
+ // quantizes what a browser would do (font-size bucketing on the stock font,
7
+ // ignored alpha, display-anchored UA scale, touch-target minimums). Surfaces
8
+ // as ordinary warnings in the build output; `--strict-css` upgrades them to
9
+ // errors (see the cuttlefish CLI).
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
13
+ import type { StyledNode } from "./style-resolver.js";
14
+ import type { Box } from "./layout-engine.js";
15
+ import type { UIFontAssetModel } from "./font-assets.js";
16
+ import { selectFontAssetForStyle } from "./font-assets.js";
17
+ import { hasIgnoredAlpha } from "./color.js";
18
+ import { uaScaleFor } from "./ua-stylesheet.js";
19
+
20
+ const COLOR_PROPS = [
21
+ "color", "background", "borderColor", "borderTopColor", "borderRightColor",
22
+ "borderBottomColor", "borderLeftColor", "outline",
23
+ ] as const;
24
+
25
+ /** Stock-font size buckets (model.ts textSizeOf): every font-size in a bucket
26
+ * renders at the same pixel size when no @font-face matches. */
27
+ function bucketOf(px: number): string {
28
+ if (px <= 12) return "1–12";
29
+ if (px <= 20) return "13–20";
30
+ if (px <= 28) return "21–28";
31
+ return "29+";
32
+ }
33
+
34
+ function labelOf(node: StyledNode): string {
35
+ if (node.id) return `#${node.id}`;
36
+ if (node.origTag) return `<${node.origTag}>`;
37
+ return `<${node.tag}>`;
38
+ }
39
+
40
+ /** Produce CSS-compatibility diagnostics for the lowered tree. */
41
+ export function cssCompatDiagnostics(
42
+ screens: StyledNode[],
43
+ fontAssets: UIFontAssetModel[],
44
+ viewport: { width: number; height: number },
45
+ colorFormat: string | undefined,
46
+ sourceFile: string,
47
+ ): Diagnostic[] {
48
+ const out: Diagnostic[] = [];
49
+ const seen = new Set<string>();
50
+ const push = (key: string, d: Diagnostic): void => {
51
+ if (seen.has(key)) return;
52
+ seen.add(key);
53
+ out.push(d);
54
+ };
55
+
56
+ const mono = colorFormat === "mono";
57
+ const scale = uaScaleFor(viewport.height, mono);
58
+
59
+ // Surface the display-anchored UA scale once when it differs from the
60
+ // browser's classic 16px — "why is my text smaller than on desktop" answered
61
+ // up front instead of discovered pixel-by-pixel.
62
+ if (scale.root !== 16 || mono) {
63
+ out.push({
64
+ severity: "info",
65
+ code: "css-ua-scale",
66
+ message: `Display-anchored UA defaults active: root text ${scale.root}px` +
67
+ (mono ? ` (monochrome; stock-font sizes are bucketed 1–12/13–20/21–28/29+)` : ``) +
68
+ `, headings ${scale.h.join("/")}, touch-target min-height ${scale.controlMinHeight}px.`,
69
+ hint: `Override per element with explicit px font-sizes, or restyle the scale in your own CSS.`,
70
+ source: sourceFile,
71
+ });
72
+ }
73
+
74
+ const walk = (node: StyledNode): void => {
75
+ const style = node.style as Record<string, string | undefined>;
76
+ const label = labelOf(node);
77
+
78
+ // Partial alpha is silently dropped (no blending on bare metal).
79
+ for (const prop of COLOR_PROPS) {
80
+ const v = style[prop];
81
+ if (v && hasIgnoredAlpha(v)) {
82
+ push(`alpha:${v}`, {
83
+ severity: "warning",
84
+ code: "css-alpha-ignored",
85
+ message: `${label}: alpha in "${v}" is ignored — the color renders fully opaque (${prop}).`,
86
+ hint: `There is no alpha blending on bare metal; blend against the target background color instead.`,
87
+ source: sourceFile,
88
+ });
89
+ }
90
+ }
91
+
92
+ // font-size sanity against the viewport and the stock-font buckets.
93
+ const fs = style.fontSize;
94
+ if (fs) {
95
+ const px = parseFloat(fs);
96
+ if (Number.isFinite(px) && px > 0) {
97
+ if (px >= viewport.height * 0.15) {
98
+ push(`fsvp:${px}`, {
99
+ severity: "warning",
100
+ code: "css-font-size-viewport",
101
+ message: `${label}: font-size ${px}px is ${Math.round((px / viewport.height) * 100)}% of the ${viewport.height}px display height.`,
102
+ hint: `Large fills of a small screen are sometimes intended — if not, scale it down.`,
103
+ source: sourceFile,
104
+ });
105
+ }
106
+ // Quantization only bites on the stock font (no matching @font-face).
107
+ if (mono && selectFontAssetForStyle(fontAssets, node.style) === undefined) {
108
+ push(`bucket:${px}`, {
109
+ severity: "warning",
110
+ code: "css-font-size-quantized",
111
+ message: `${label}: font-size ${px}px renders at the stock-font bucket for ${bucketOf(px)}px — every size in that range draws identically on monochrome displays.`,
112
+ hint: `Pick sizes from the bucket edges (12/20/28) or register an @font-face for exact sizes.`,
113
+ source: sourceFile,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ // Touch-target minimums that swallow half the screen.
120
+ const mh = style.minHeight;
121
+ if (mh) {
122
+ const px = parseFloat(mh);
123
+ if (Number.isFinite(px) && px >= viewport.height * 0.5) {
124
+ push(`mhvp:${px}`, {
125
+ severity: "warning",
126
+ code: "css-min-height-viewport",
127
+ message: `${label}: min-height ${px}px is ${Math.round((px / viewport.height) * 100)}% of the ${viewport.height}px display height.`,
128
+ hint: `Reduce the min-height for very small panels (the UA default already scales down).`,
129
+ source: sourceFile,
130
+ });
131
+ }
132
+ }
133
+
134
+ for (const child of node.children) walk(child);
135
+ };
136
+ for (const screen of screens) walk(screen);
137
+
138
+ return out;
139
+ }
@@ -0,0 +1,69 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CSS @import expansion — build-time stylesheet inclusion.
3
+ //
4
+ // Local @import statements are inlined before parsing so shared stylesheets
5
+ // (e.g. the shadcn preset added by `cuttlefish add shadcn`) compose with
6
+ // per-module CSS, like a browser resolving imports. Relative paths resolve
7
+ // against the importing stylesheet's directory; recursion is supported with a
8
+ // cycle guard. Remote (http/data:) imports and unreadable files are left in
9
+ // place — the CSS parser's existing "@import is not supported" warning then
10
+ // reports them instead of failing silently.
11
+ //
12
+ // This is compile-time inclusion only: each firmware build still targets one
13
+ // display, so there is no runtime fetch.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { createRequire } from "node:module";
19
+
20
+ /** Guard against runaway include chains (distinct-file budget across the
21
+ * whole expansion; cycles are caught by the seen-set, this bounds depth). */
22
+ const MAX_INCLUDED_FILES = 32;
23
+
24
+ const IMPORT_RE = /@import\s+(?:url\(\s*)?["']?([^"'()\s;]+)["']?\s*\)?\s*[^;]*;/g;
25
+
26
+ /** Resolve a bare package specifier ("@typecad/ui/themes/blue.css") through
27
+ * node_modules relative to baseDir — how pre-packaged themes are imported.
28
+ * Returns null when it doesn't resolve (e.g. the Tailwind scaffolding
29
+ * specifier "tailwindcss" inside stock theme exports). */
30
+ function resolvePackageSpecifier(spec: string, baseDir: string): string | null {
31
+ try {
32
+ const req = createRequire(path.join(baseDir, "package.json"));
33
+ return req.resolve(spec);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Inline local @import statements in `cssText`. `baseDir` is the directory
40
+ * of the stylesheet the text came from. `seen` tracks normalized absolute
41
+ * paths already inlined (cycle guard) across the whole expansion. */
42
+ export function expandCssImports(cssText: string, baseDir: string, seen: Set<string> = new Set()): string {
43
+ return cssText.replace(IMPORT_RE, (match, spec: string) => {
44
+ if (/^(https?|data):/i.test(spec)) return match;
45
+ // Package specifiers ("@typecad/ui/themes/blue.css", but also the
46
+ // "tailwindcss" scaffolding inside stock exports) start with @ or a bare
47
+ // name — NOT "./", "../", "/", or a drive letter. Resolvable ones load
48
+ // from node_modules (pre-packaged themes); the rest drop silently so
49
+ // pasted tweakcn/ui.shadcn.com files import cleanly.
50
+ const pathLike = spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || /^[a-zA-Z]:/.test(spec);
51
+ let abs: string;
52
+ if (!pathLike) {
53
+ const resolved = resolvePackageSpecifier(spec, baseDir);
54
+ if (!resolved) return "";
55
+ abs = resolved;
56
+ } else {
57
+ abs = path.normalize(path.isAbsolute(spec) ? spec : path.join(baseDir, spec));
58
+ }
59
+ if (seen.has(abs) || seen.size >= MAX_INCLUDED_FILES) return "";
60
+ seen.add(abs);
61
+ let text: string;
62
+ try {
63
+ text = fs.readFileSync(abs, "utf-8");
64
+ } catch {
65
+ return match; // unreadable: keep the statement so the parser warns
66
+ }
67
+ return expandCssImports(text, path.dirname(abs), seen);
68
+ });
69
+ }
@@ -442,15 +442,21 @@ function resolveCalc(value: string): string {
442
442
  }
443
443
 
444
444
  /** Evaluate a simple arithmetic expression of lengths to a length string.
445
- * Each term may have a unit (px/rem/em/%); the first unit found wins.
446
- * Supports + - * /. e.g. "10px - 4" → "6px". */
445
+ * Supports + - * /. rem/em terms normalize to px (×16), so any length
446
+ * expression reduces to px: "calc(0.5rem - 2px)" → "6px".
447
+ * Percent + length mixes ("calc(50% - 10px)") cannot resolve numerically at
448
+ * parse time — those stay as literal calc() text. Unrecognized → as-is. */
447
449
  function evalCalcExpr(expr: string): string {
448
450
  // Tokenize into numbers-with-units and operators.
449
451
  const tokens = expr.match(/(?:[\d.]+(?:rem|em|px|%)?|[-+*/])/g);
450
452
  if (!tokens || tokens.length === 0) return `calc(${expr})`;
451
- // Determine the dominant unit from the first length token.
452
- const unitMatch = expr.match(/(\d)(rem|em|px|%)/);
453
- const unit = unitMatch ? unitMatch[2] : "";
453
+ const hasPercent = /[\d.]+%/.test(expr);
454
+ const hasLength = /[\d.]+(?:rem|em|px)/.test(expr);
455
+ if (hasPercent && hasLength) return `calc(${expr})`;
456
+ // rem/em already fold to px in toNum, so the output unit is determined by
457
+ // the term kinds — never "the first unit seen" (that produced 6rem from
458
+ // 0.5rem - 2px).
459
+ const unit = hasPercent ? "%" : hasLength ? "px" : "";
454
460
  // Convert each token to a plain number (rem/em × 16).
455
461
  const toNum = (tok: string): number => {
456
462
  const remM = /^(-?[\d.]+)rem$/.exec(tok);
@@ -843,9 +849,10 @@ function parseFontShorthand(props: CSSProperty, val: string): void {
843
849
  const sizeIndex = parts.findIndex((part) => /^\d+(?:\.\d+)?(?:px|pt|em|rem)?(?:\/.+)?$/.test(part));
844
850
  if (sizeIndex < 0) return;
845
851
  const beforeSize = parts.slice(0, sizeIndex);
846
- const size = parts[sizeIndex].split("/")[0];
852
+ const [size, lineHeight] = parts[sizeIndex].split("/");
847
853
  const family = parts.slice(sizeIndex + 1).join(" ").trim();
848
854
  if (size) props.fontSize = size;
855
+ if (lineHeight) props.lineHeight = lineHeight;
849
856
  if (family) props.fontFamily = family;
850
857
  for (const part of beforeSize) {
851
858
  const lower = part.toLowerCase();
@@ -855,11 +862,16 @@ function parseFontShorthand(props: CSSProperty, val: string): void {
855
862
  }
856
863
 
857
864
  /** Assign a CSS property to the CSSProperty object. Unknown properties emit a warning
858
- * (forward-compatible: they are dropped from output, but the author is notified). */
865
+ * (forward-compatible: they are dropped from output, but the author is notified).
866
+ * Known properties with values the engine can only partially honor also warn —
867
+ * a visible approximation beats a silent one. */
859
868
  function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?: Diagnostic[]): void {
860
869
  const warn = (message: string, hint?: string): void => {
861
870
  if (diagnostics) diagnostics.push({ severity: "warning", message, hint, code: "unknown-css-property", source: prop });
862
871
  };
872
+ const warnValue = (code: string, message: string, hint?: string): void => {
873
+ if (diagnostics) diagnostics.push({ severity: "warning", message, hint, code, source: prop });
874
+ };
863
875
  switch (prop) {
864
876
  // Box model
865
877
  case "padding": props.padding = val; break;
@@ -875,7 +887,12 @@ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?:
875
887
  case "min-height": props.minHeight = val; break;
876
888
  case "max-height": props.maxHeight = val; break;
877
889
  case "aspect-ratio": props.aspectRatio = val; break;
878
- case "box-sizing": props.boxSizing = val; break;
890
+ case "box-sizing":
891
+ if (val.trim().toLowerCase() === "content-box") {
892
+ warnValue("css-box-sizing", `box-sizing: content-box is ignored — the engine always lays out border-box (borders and padding inside width/height).`, `Remove the declaration, or size elements accounting for padding/border.`);
893
+ }
894
+ props.boxSizing = val;
895
+ break;
879
896
  case "overflow": props.overflow = val; break;
880
897
  // Colors
881
898
  case "color": props.color = val; break;
@@ -884,7 +901,21 @@ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?:
884
901
  // Text
885
902
  case "font": props.font = val; parseFontShorthand(props, val); break;
886
903
  case "font-family": props.fontFamily = val; break;
887
- case "font-size": props.fontSize = val; break;
904
+ case "font-size": {
905
+ const v = val.trim();
906
+ if (v.endsWith("%")) {
907
+ warnValue("css-font-size-unit", `font-size "${val}" — percent sizes are not supported; use px. The declaration is ignored.`, `font-size accepts px (or a bare number, treated as px).`);
908
+ } else {
909
+ const em = /^([\d.]+)(em|rem)$/.exec(v);
910
+ if (em) {
911
+ props.fontSize = `${Math.round(parseFloat(em[1]) * 16)}px`;
912
+ warnValue("css-font-size-unit", `font-size "${val}" — em/rem resolve against the root (16px), not the parent; wrote ${props.fontSize}.`, `Use px to size text exactly.`);
913
+ } else {
914
+ props.fontSize = val;
915
+ }
916
+ }
917
+ break;
918
+ }
888
919
  case "text-align": props.textAlign = val; break;
889
920
  case "text-decoration": props.textDecoration = val; break;
890
921
  case "font-weight": props.fontWeight = val; break;
@@ -893,7 +924,12 @@ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?:
893
924
  case "font-smooth":
894
925
  case "-webkit-font-smoothing": props.fontSmoothing = val; break;
895
926
  case "font-subset": props.fontSubset = val; break;
896
- case "line-height": props.lineHeight = val; break;
927
+ case "line-height":
928
+ if (/^\d+(?:\.\d+)?$/.test(val.trim())) {
929
+ warnValue("css-line-height", `line-height "${val}" — unitless multipliers are treated as px, not a multiple of the font size.`, `Write an explicit px value (e.g. line-height: 22px).`);
930
+ }
931
+ props.lineHeight = val;
932
+ break;
897
933
  case "letter-spacing": props.letterSpacing = val; break;
898
934
  case "white-space": props.whiteSpace = val; break;
899
935
  case "text-transform": props.textTransform = val; break;
@@ -907,7 +943,12 @@ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?:
907
943
  case "animation-delay": props.animationDelay = val; break;
908
944
  case "animation-timing-function": props.animationTimingFunction = val; break;
909
945
  // Flexbox / layout
910
- case "display": props.display = val; break;
946
+ case "display":
947
+ if (["inline", "inline-block", "inline-flex", "grid", "flow-root", "table"].includes(val.trim().toLowerCase())) {
948
+ warnValue("css-display", `display: ${val.trim()} is not supported — elements stack vertically like a flex column (children use display:flex semantics).`, `Use the default stacking, or a row container with flex-direction: row + gap for inline flows.`);
949
+ }
950
+ props.display = val;
951
+ break;
911
952
  case "flex-direction": props.flexDirection = val; break;
912
953
  case "gap": props.gap = val; props.rowGap = val; props.columnGap = val; break;
913
954
  case "row-gap": props.rowGap = val; if (!props.columnGap) props.columnGap = val; break;
@@ -922,7 +963,12 @@ function assignProp(props: CSSProperty, prop: string, val: string, diagnostics?:
922
963
  case "justify-content": props.justifyContent = val; break;
923
964
  case "flex-wrap": props.flexWrap = val; break;
924
965
  case "order": props.order = val; break;
925
- case "position": props.position = val; break;
966
+ case "position":
967
+ if (["fixed", "sticky"].includes(val.trim().toLowerCase())) {
968
+ warnValue("css-position", `position: ${val.trim()} is not supported — treated as static.`, `Use position: absolute (against the nearest ancestor) or keep flow layout.`);
969
+ }
970
+ props.position = val;
971
+ break;
926
972
  case "z-index": props.zIndex = val; break;
927
973
  case "top": props.top = val; break;
928
974
  case "right": props.right = val; break;
@@ -973,11 +1019,13 @@ function parseFlexShorthand(props: CSSProperty, val: string): void {
973
1019
  }
974
1020
  }
975
1021
 
976
- /** Parse the `border` shorthand: "2px solid #808080" → width, style, color. */
1022
+ /** Parse the `border` shorthand: "2px solid #808080" → width, style, color.
1023
+ * Width accepts px, bare numbers (unitless = px), and decimals. */
977
1024
  function parseBorderShorthand(props: CSSProperty, val: string): void {
978
1025
  const parts = val.trim().split(/\s+/);
979
1026
  for (const p of parts) {
980
- if (/^\d+px$/.test(p)) props.borderWidth = p;
1027
+ const w = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(p);
1028
+ if (w) props.borderWidth = `${w[1]}px`;
981
1029
  else if (["solid", "dashed", "dotted", "double", "none"].includes(p)) props.borderStyle = p;
982
1030
  // Color: #hex, rgb()/hsl(), a CSS named color, or a var() reference (the
983
1031
  // token is substituted later by substituteVars, like other properties).
@@ -996,7 +1044,8 @@ function parsePerSideBorder(props: CSSProperty, prop: string, val: string): void
996
1044
  const cKey = `border${cap}Color` as keyof CSSProperty;
997
1045
  const parts = val.trim().split(/\s+/);
998
1046
  for (const p of parts) {
999
- if (/^\d+px$/.test(p)) (props[wKey] as string | undefined) = p;
1047
+ const w = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(p);
1048
+ if (w) (props[wKey] as string | undefined) = `${w[1]}px`;
1000
1049
  else if (["solid", "dashed", "dotted", "double", "none"].includes(p)) (props[sKey] as string | undefined) = p;
1001
1050
  else if (p.startsWith("#") || p.startsWith("rgb") || p.startsWith("hsl") || p.startsWith("var(") || /^[a-z]+$/i.test(p)) (props[cKey] as string | undefined) = p;
1002
1051
  }
@@ -0,0 +1,95 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Default bundled font — DejaVu Sans 2.37 (Bitstream Vera License).
3
+ //
4
+ // Every color display gets an implicit @font-face pair (regular + bold) so
5
+ // antialiased, continuously-sized text works out of the box: users don't have
6
+ // to source a TTF before their UI renders like the browser preview. Devices
7
+ // never receive the TTF itself — font-assets.ts rasterizes the exact
8
+ // (family, px, weight, style, characters) subsets a UI uses into glyph tables.
9
+ //
10
+ // Monochrome displays (1-bit OLEDs/e-ink) keep the engine's built-in bitmap
11
+ // font: alpha blending is disabled there, so rasterized AA glyphs add flash
12
+ // cost without a visual payoff.
13
+ //
14
+ // License: the unmodified TTFs live in packages/ui/assets/fonts/dejavu with
15
+ // the full text (LICENSE) and checksums (README.md). They must be redistributed
16
+ // with that license file and must not be renamed or modified (Bitstream Vera
17
+ // License reserved-name terms). Generated font tables carry an attribution
18
+ // comment (see ui-lowering.ts emitFontTables).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import path from "node:path";
22
+ import fs from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { CSSFontFace } from "./css-parser.js";
25
+
26
+ /** The sans family the default faces register under. It matches the font's
27
+ * real name (unmodified file, original name — license-clean). */
28
+ export const DEFAULT_FONT_FAMILY = "DejaVu Sans";
29
+
30
+ /** The monospace family for <pre>/<code>/<kbd> UA styling. */
31
+ export const DEFAULT_MONO_FAMILY = "DejaVu Sans Mono";
32
+
33
+ const FONT_DIR = path.resolve(
34
+ path.dirname(fileURLToPath(import.meta.url)),
35
+ "../../assets/fonts/dejavu",
36
+ );
37
+
38
+ /** Faces injected by default: sans + mono at the workhorse weights. */
39
+ const DEFAULT_FACES: Array<{ family: string; file: string; weight: string; style: string }> = [
40
+ { family: DEFAULT_FONT_FAMILY, file: "DejaVuSans.ttf", weight: "400", style: "normal" },
41
+ { family: DEFAULT_FONT_FAMILY, file: "DejaVuSans-Bold.ttf", weight: "700", style: "normal" },
42
+ { family: DEFAULT_MONO_FAMILY, file: "DejaVuSansMono.ttf", weight: "400", style: "normal" },
43
+ { family: DEFAULT_MONO_FAMILY, file: "DejaVuSansMono-Bold.ttf", weight: "700", style: "normal" },
44
+ ];
45
+
46
+ /** Absolute source paths of the bundled faces. */
47
+ export function bundledFontPath(file: string): string {
48
+ return path.join(FONT_DIR, file);
49
+ }
50
+
51
+ /** True when the bundled font assets are present and usable. */
52
+ export function defaultFontAvailable(): boolean {
53
+ return DEFAULT_FACES.every((f) => fs.existsSync(bundledFontPath(f.file)));
54
+ }
55
+
56
+ /** True when the display profile should use the bundled font. Color displays
57
+ * only: monochrome targets keep the built-in bitmap font (no AA). An absent
58
+ * profile defaults to color — the common case and the current build target. */
59
+ export function defaultFontApplies(colorFormat: string | undefined): boolean {
60
+ return colorFormat !== "mono" && defaultFontAvailable();
61
+ }
62
+
63
+ /** The implicit @font-face declarations for the bundled default fonts. */
64
+ export function defaultFontFaces(): CSSFontFace[] {
65
+ return DEFAULT_FACES.map((f) => ({
66
+ fontFamily: f.family,
67
+ src: bundledFontPath(f.file),
68
+ fontWeight: f.weight,
69
+ fontStyle: f.style,
70
+ }));
71
+ }
72
+
73
+ /** Merge the bundled default faces into a user's @font-face list. Checked
74
+ * per family: if the author registered any face for "DejaVu Sans" (or the
75
+ * mono family), theirs wins and that family's bundle faces are skipped.
76
+ * Injected faces append so user faces sort first in weight/style matching. */
77
+ export function injectDefaultFontFaces(userFaces: CSSFontFace[], colorFormat: string | undefined): CSSFontFace[] {
78
+ if (!defaultFontApplies(colorFormat)) return userFaces;
79
+ const declared = new Set(userFaces.map((f) => f.fontFamily.toLowerCase()));
80
+ const bundled = defaultFontFaces().filter(
81
+ (f) => !declared.has(f.fontFamily.toLowerCase()),
82
+ );
83
+ return bundled.length > 0 ? [...userFaces, ...bundled] : userFaces;
84
+ }
85
+
86
+ /** Attribution lines for generated font tables whose glyphs came from the
87
+ * bundled DejaVu faces (satisfies the license's binary-notice requirement
88
+ * and makes firmware images auditable). */
89
+ export function defaultFontAttribution(sourcePath: string): string | undefined {
90
+ const resolved = path.resolve(sourcePath);
91
+ if (path.dirname(resolved) !== FONT_DIR) return undefined;
92
+ return `${path.basename(resolved)} - DejaVu fonts v2.37` +
93
+ " - (c) 2003 Bitstream, Inc., (c) 2006 Tavmjong Bah." +
94
+ " Bitstream Vera License - see @typecad/ui assets/fonts/dejavu/LICENSE.";
95
+ }