@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
@@ -7,15 +7,26 @@
7
7
  // This is the fallback when no `display: flex` is present. YogaLayoutEngine
8
8
  // handles flexbox layouts.
9
9
  // ---------------------------------------------------------------------------
10
- import { isDisplayNone, parseAspectRatio } from "./layout-engine.js";
11
- /** Parse a CSS value string ("8px", "8") to a number. */
10
+ import { isDisplayNone, parseAspectRatio, cssDimValue, expandBoxShorthand } from "./layout-engine.js";
11
+ /** Parse a CSS value string ("8px", "8") to a number (percent folds to its
12
+ * numeric part — block layout has no parent-relative resolution). */
12
13
  function cssNum(val) {
13
14
  if (val === undefined)
14
15
  return 0;
15
16
  if (typeof val === "number")
16
17
  return val;
17
- const m = val.match(/(\d+)/);
18
- return m ? parseInt(m[1]) : 0;
18
+ const d = cssDimValue(val);
19
+ return typeof d === "number" ? d : parseFloat(String(d));
20
+ }
21
+ /** Resolve a dimension against a reference size: px passes through, a
22
+ * percentage resolves against `total` (0 when unresolvable). */
23
+ function resolveDim(val, total) {
24
+ const d = cssDimValue(val);
25
+ if (typeof d === "number")
26
+ return d;
27
+ if (typeof d === "string" && d.endsWith("%"))
28
+ return Math.round((parseFloat(d) * total) / 100);
29
+ return 0;
19
30
  }
20
31
  export class BlockLayoutEngine {
21
32
  id = "block";
@@ -32,12 +43,15 @@ export class BlockLayoutEngine {
32
43
  out.push(box);
33
44
  if (node.children.length === 0)
34
45
  return;
35
- const pad = cssNum(node.style.padding);
46
+ // Padding: full 1-4 value TRBL expansion (vertical/horizontal pairs).
47
+ const [padT, , padB, padL] = expandBoxShorthand(node.style.padding);
48
+ const padV = (typeof padT === "number" ? padT : 0) + (typeof padB === "number" ? padB : 0);
49
+ const padH = typeof padL === "number" ? padL * 2 : 0;
36
50
  const content = {
37
- x: box.x + pad,
38
- y: box.y + pad,
39
- w: box.w - pad * 2,
40
- h: box.h - pad * 2,
51
+ x: box.x + (typeof padL === "number" ? padL : 0),
52
+ y: box.y + (typeof padT === "number" ? padT : 0),
53
+ w: box.w - padH,
54
+ h: box.h - padV,
41
55
  };
42
56
  let cursorY = content.y;
43
57
  for (const child of node.children) {
@@ -55,8 +69,8 @@ export class BlockLayoutEngine {
55
69
  ? intrinsic.w + childPad * 2
56
70
  : content.w;
57
71
  const childH = intrinsic.h > 0 ? intrinsic.h + (isButton ? childPad * 2 : 0) : 16;
58
- const explicitW = cssNum(child.style.width);
59
- const explicitH = cssNum(child.style.height);
72
+ const explicitW = resolveDim(child.style.width, content.w);
73
+ const explicitH = resolveDim(child.style.height, content.h);
60
74
  const aspectRatio = parseAspectRatio(child.style.aspectRatio);
61
75
  let resolvedW = childW;
62
76
  let resolvedH = childH;
@@ -3,6 +3,12 @@ export interface RGB {
3
3
  g: number;
4
4
  b: number;
5
5
  }
6
+ /** Is this color fully transparent (the `transparent` keyword, alpha-0 rgba/
7
+ * hsla, or #rrggbb00)? Callers treat it as "no fill" rather than black. */
8
+ export declare function isTransparentColor(input: string): boolean;
9
+ /** Does this color carry a partial (non-zero, non-one) alpha that the engine
10
+ * will ignore? (No alpha blending on bare metal.) Used for warnings. */
11
+ export declare function hasIgnoredAlpha(input: string): boolean;
6
12
  /** Parse any CSS color string to {r,g,b}. Alpha is ignored (no blending). */
7
13
  export declare function parseColor(input: string): RGB;
8
14
  /** @deprecated Use parseColor instead — parseHexColor only handles #rrggbb. */
@@ -85,8 +85,41 @@ const NAMED_COLORS = {
85
85
  violet: { r: 238, g: 130, b: 238 }, wheat: { r: 245, g: 222, b: 179 },
86
86
  white: { r: 255, g: 255, b: 255 }, whitesmoke: { r: 245, g: 245, b: 245 },
87
87
  yellow: { r: 255, g: 255, b: 0 }, yellowgreen: { r: 154, g: 205, b: 50 },
88
- transparent: { r: 0, g: 0, b: 0 },
89
88
  };
89
+ /** Is this color fully transparent (the `transparent` keyword, alpha-0 rgba/
90
+ * hsla, or #rrggbb00)? Callers treat it as "no fill" rather than black. */
91
+ export function isTransparentColor(input) {
92
+ const s = input.trim().toLowerCase();
93
+ if (s === "transparent" || s === "none")
94
+ return true;
95
+ const rgbaM = /^rgba?\([^)]*,\s*(0(?:\.0+)?)\s*\)$/.exec(s);
96
+ if (rgbaM)
97
+ return true;
98
+ const hslaM = /^hsla?\([^)]*\/\s*(0(?:\.0+)?|0%)\s*\)$/.exec(s);
99
+ if (hslaM)
100
+ return true;
101
+ const hex8M = /^#[0-9a-f]{8}$/.exec(s);
102
+ if (hex8M)
103
+ return s.endsWith("00");
104
+ return false;
105
+ }
106
+ /** Does this color carry a partial (non-zero, non-one) alpha that the engine
107
+ * will ignore? (No alpha blending on bare metal.) Used for warnings. */
108
+ export function hasIgnoredAlpha(input) {
109
+ const s = input.trim().toLowerCase();
110
+ const rgbaM = /^rgba?\([^)]*,\s*([\d.]+)\s*\)$/.exec(s);
111
+ if (rgbaM)
112
+ return parseFloat(rgbaM[1]) < 1;
113
+ const hslaM = /^hsla?\([^)]*\/\s*([\d.]+)%?\s*\)$/.exec(s);
114
+ if (hslaM)
115
+ return (hslaM[1].endsWith("%") ? parseFloat(hslaM[1]) / 100 : parseFloat(hslaM[1])) < 1;
116
+ const hex8M = /^#[0-9a-f]{8}$/.exec(s);
117
+ if (hex8M) {
118
+ const a = parseInt(s.slice(7, 9), 16) / 255;
119
+ return a > 0 && a < 1;
120
+ }
121
+ return false;
122
+ }
90
123
  /** Parse any CSS color string to {r,g,b}. Alpha is ignored (no blending). */
91
124
  export function parseColor(input) {
92
125
  const s = input.trim().toLowerCase();
@@ -136,7 +169,52 @@ export function parseColor(input) {
136
169
  // Named colors
137
170
  if (NAMED_COLORS[s])
138
171
  return NAMED_COLORS[s];
139
- throw new Error(`Unsupported color format "${input}" use #hex, rgb(), hsl(), or a named color`);
172
+ // transparent resolves to black as a COLOR (e.g. gradient stops); fill
173
+ // sites check isTransparentColor() first and skip painting entirely.
174
+ if (s === "transparent")
175
+ return { r: 0, g: 0, b: 0 };
176
+ // oklch(L C H [/ a]) — CSS Color 4, the dialect of Tailwind v4 era shadcn
177
+ // themes. Alpha ignored like everywhere else; L accepts % form.
178
+ const oklchM = /^oklch\(\s*([\d.]+|none)(%)?\s+([\d.]+|none)\s+([\d.]+|none)(?:deg|turn|rad)?(?:\s*\/\s*[\d.%]+)?\s*\)$/i.exec(s);
179
+ if (oklchM) {
180
+ const l = oklchM[1] === "none" ? 0 : parseFloat(oklchM[1]) / (oklchM[2] ? 100 : 1);
181
+ const c = oklchM[3] === "none" ? 0 : parseFloat(oklchM[3]);
182
+ let h = oklchM[4] === "none" ? 0 : parseFloat(oklchM[4]);
183
+ if (/turn\b/i.test(s))
184
+ h = h * 360;
185
+ return oklchToRgb(l, c, h);
186
+ }
187
+ // shadcn theme dialect: bare HSL channel triplets ("222.2 47.4% 11.2%") —
188
+ // the format stock shadcn themes store in CSS variables, normally consumed
189
+ // as hsl(var(--x)). The shape (number % number%) is unambiguous with any
190
+ // valid CSS color, so both dialects work through var() substitution.
191
+ const tripletM = /^([\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/.exec(s);
192
+ if (tripletM)
193
+ return hslToRgb(parseFloat(tripletM[1]), parseFloat(tripletM[2]), parseFloat(tripletM[3]));
194
+ throw new Error(`Unsupported color format "${input}" — use #hex, rgb(), hsl(), oklch(), or a named color`);
195
+ }
196
+ /** Convert oklch (L 0-1, C 0-~0.4, H degrees) to sRGB via OkLab — Björn
197
+ * Ottosson's reference matrices, then the sRGB transfer function. Values
198
+ * outside the sRGB gamut clamp per channel (compile-time approximation of
199
+ * CSS gamut mapping — good enough for opaque 16-bit panel output). */
200
+ function oklchToRgb(l, c, h) {
201
+ const rad = (h * Math.PI) / 180;
202
+ const a = c * Math.cos(rad);
203
+ const b = c * Math.sin(rad);
204
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
205
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
206
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
207
+ const L = l_ * l_ * l_;
208
+ const M = m_ * m_ * m_;
209
+ const S = s_ * s_ * s_;
210
+ const lr = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
211
+ const lg = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
212
+ const lb = -0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S;
213
+ const gamma = (v) => {
214
+ const x = Math.min(1, Math.max(0, v));
215
+ return Math.round(255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055));
216
+ };
217
+ return { r: gamma(lr), g: gamma(lg), b: gamma(lb) };
140
218
  }
141
219
  /** Convert HSL (h: 0-360, s/l: 0-100) to RGB. Standard CSS algorithm. */
142
220
  function hslToRgb(h, s, l) {
@@ -0,0 +1,8 @@
1
+ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
2
+ import type { StyledNode } from "./style-resolver.js";
3
+ import type { UIFontAssetModel } from "./font-assets.js";
4
+ /** Produce CSS-compatibility diagnostics for the lowered tree. */
5
+ export declare function cssCompatDiagnostics(screens: StyledNode[], fontAssets: UIFontAssetModel[], viewport: {
6
+ width: number;
7
+ height: number;
8
+ }, colorFormat: string | undefined, sourceFile: string): Diagnostic[];
@@ -0,0 +1,124 @@
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
+ import { selectFontAssetForStyle } from "./font-assets.js";
12
+ import { hasIgnoredAlpha } from "./color.js";
13
+ import { uaScaleFor } from "./ua-stylesheet.js";
14
+ const COLOR_PROPS = [
15
+ "color", "background", "borderColor", "borderTopColor", "borderRightColor",
16
+ "borderBottomColor", "borderLeftColor", "outline",
17
+ ];
18
+ /** Stock-font size buckets (model.ts textSizeOf): every font-size in a bucket
19
+ * renders at the same pixel size when no @font-face matches. */
20
+ function bucketOf(px) {
21
+ if (px <= 12)
22
+ return "1–12";
23
+ if (px <= 20)
24
+ return "13–20";
25
+ if (px <= 28)
26
+ return "21–28";
27
+ return "29+";
28
+ }
29
+ function labelOf(node) {
30
+ if (node.id)
31
+ return `#${node.id}`;
32
+ if (node.origTag)
33
+ return `<${node.origTag}>`;
34
+ return `<${node.tag}>`;
35
+ }
36
+ /** Produce CSS-compatibility diagnostics for the lowered tree. */
37
+ export function cssCompatDiagnostics(screens, fontAssets, viewport, colorFormat, sourceFile) {
38
+ const out = [];
39
+ const seen = new Set();
40
+ const push = (key, d) => {
41
+ if (seen.has(key))
42
+ return;
43
+ seen.add(key);
44
+ out.push(d);
45
+ };
46
+ const mono = colorFormat === "mono";
47
+ const scale = uaScaleFor(viewport.height, mono);
48
+ // Surface the display-anchored UA scale once when it differs from the
49
+ // browser's classic 16px — "why is my text smaller than on desktop" answered
50
+ // up front instead of discovered pixel-by-pixel.
51
+ if (scale.root !== 16 || mono) {
52
+ out.push({
53
+ severity: "info",
54
+ code: "css-ua-scale",
55
+ message: `Display-anchored UA defaults active: root text ${scale.root}px` +
56
+ (mono ? ` (monochrome; stock-font sizes are bucketed 1–12/13–20/21–28/29+)` : ``) +
57
+ `, headings ${scale.h.join("/")}, touch-target min-height ${scale.controlMinHeight}px.`,
58
+ hint: `Override per element with explicit px font-sizes, or restyle the scale in your own CSS.`,
59
+ source: sourceFile,
60
+ });
61
+ }
62
+ const walk = (node) => {
63
+ const style = node.style;
64
+ const label = labelOf(node);
65
+ // Partial alpha is silently dropped (no blending on bare metal).
66
+ for (const prop of COLOR_PROPS) {
67
+ const v = style[prop];
68
+ if (v && hasIgnoredAlpha(v)) {
69
+ push(`alpha:${v}`, {
70
+ severity: "warning",
71
+ code: "css-alpha-ignored",
72
+ message: `${label}: alpha in "${v}" is ignored — the color renders fully opaque (${prop}).`,
73
+ hint: `There is no alpha blending on bare metal; blend against the target background color instead.`,
74
+ source: sourceFile,
75
+ });
76
+ }
77
+ }
78
+ // font-size sanity against the viewport and the stock-font buckets.
79
+ const fs = style.fontSize;
80
+ if (fs) {
81
+ const px = parseFloat(fs);
82
+ if (Number.isFinite(px) && px > 0) {
83
+ if (px >= viewport.height * 0.15) {
84
+ push(`fsvp:${px}`, {
85
+ severity: "warning",
86
+ code: "css-font-size-viewport",
87
+ message: `${label}: font-size ${px}px is ${Math.round((px / viewport.height) * 100)}% of the ${viewport.height}px display height.`,
88
+ hint: `Large fills of a small screen are sometimes intended — if not, scale it down.`,
89
+ source: sourceFile,
90
+ });
91
+ }
92
+ // Quantization only bites on the stock font (no matching @font-face).
93
+ if (mono && selectFontAssetForStyle(fontAssets, node.style) === undefined) {
94
+ push(`bucket:${px}`, {
95
+ severity: "warning",
96
+ code: "css-font-size-quantized",
97
+ 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.`,
98
+ hint: `Pick sizes from the bucket edges (12/20/28) or register an @font-face for exact sizes.`,
99
+ source: sourceFile,
100
+ });
101
+ }
102
+ }
103
+ }
104
+ // Touch-target minimums that swallow half the screen.
105
+ const mh = style.minHeight;
106
+ if (mh) {
107
+ const px = parseFloat(mh);
108
+ if (Number.isFinite(px) && px >= viewport.height * 0.5) {
109
+ push(`mhvp:${px}`, {
110
+ severity: "warning",
111
+ code: "css-min-height-viewport",
112
+ message: `${label}: min-height ${px}px is ${Math.round((px / viewport.height) * 100)}% of the ${viewport.height}px display height.`,
113
+ hint: `Reduce the min-height for very small panels (the UA default already scales down).`,
114
+ source: sourceFile,
115
+ });
116
+ }
117
+ }
118
+ for (const child of node.children)
119
+ walk(child);
120
+ };
121
+ for (const screen of screens)
122
+ walk(screen);
123
+ return out;
124
+ }
@@ -0,0 +1,4 @@
1
+ /** Inline local @import statements in `cssText`. `baseDir` is the directory
2
+ * of the stylesheet the text came from. `seen` tracks normalized absolute
3
+ * paths already inlined (cycle guard) across the whole expansion. */
4
+ export declare function expandCssImports(cssText: string, baseDir: string, seen?: Set<string>): string;
@@ -0,0 +1,70 @@
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
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { createRequire } from "node:module";
18
+ /** Guard against runaway include chains (distinct-file budget across the
19
+ * whole expansion; cycles are caught by the seen-set, this bounds depth). */
20
+ const MAX_INCLUDED_FILES = 32;
21
+ const IMPORT_RE = /@import\s+(?:url\(\s*)?["']?([^"'()\s;]+)["']?\s*\)?\s*[^;]*;/g;
22
+ /** Resolve a bare package specifier ("@typecad/ui/themes/blue.css") through
23
+ * node_modules relative to baseDir — how pre-packaged themes are imported.
24
+ * Returns null when it doesn't resolve (e.g. the Tailwind scaffolding
25
+ * specifier "tailwindcss" inside stock theme exports). */
26
+ function resolvePackageSpecifier(spec, baseDir) {
27
+ try {
28
+ const req = createRequire(path.join(baseDir, "package.json"));
29
+ return req.resolve(spec);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Inline local @import statements in `cssText`. `baseDir` is the directory
36
+ * of the stylesheet the text came from. `seen` tracks normalized absolute
37
+ * paths already inlined (cycle guard) across the whole expansion. */
38
+ export function expandCssImports(cssText, baseDir, seen = new Set()) {
39
+ return cssText.replace(IMPORT_RE, (match, spec) => {
40
+ if (/^(https?|data):/i.test(spec))
41
+ return match;
42
+ // Package specifiers ("@typecad/ui/themes/blue.css", but also the
43
+ // "tailwindcss" scaffolding inside stock exports) start with @ or a bare
44
+ // name — NOT "./", "../", "/", or a drive letter. Resolvable ones load
45
+ // from node_modules (pre-packaged themes); the rest drop silently so
46
+ // pasted tweakcn/ui.shadcn.com files import cleanly.
47
+ const pathLike = spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || /^[a-zA-Z]:/.test(spec);
48
+ let abs;
49
+ if (!pathLike) {
50
+ const resolved = resolvePackageSpecifier(spec, baseDir);
51
+ if (!resolved)
52
+ return "";
53
+ abs = resolved;
54
+ }
55
+ else {
56
+ abs = path.normalize(path.isAbsolute(spec) ? spec : path.join(baseDir, spec));
57
+ }
58
+ if (seen.has(abs) || seen.size >= MAX_INCLUDED_FILES)
59
+ return "";
60
+ seen.add(abs);
61
+ let text;
62
+ try {
63
+ text = fs.readFileSync(abs, "utf-8");
64
+ }
65
+ catch {
66
+ return match; // unreadable: keep the statement so the parser warns
67
+ }
68
+ return expandCssImports(text, path.dirname(abs), seen);
69
+ });
70
+ }
@@ -279,16 +279,23 @@ function resolveCalc(value) {
279
279
  return out;
280
280
  }
281
281
  /** Evaluate a simple arithmetic expression of lengths to a length string.
282
- * Each term may have a unit (px/rem/em/%); the first unit found wins.
283
- * Supports + - * /. e.g. "10px - 4" → "6px". */
282
+ * Supports + - * /. rem/em terms normalize to px (×16), so any length
283
+ * expression reduces to px: "calc(0.5rem - 2px)" → "6px".
284
+ * Percent + length mixes ("calc(50% - 10px)") cannot resolve numerically at
285
+ * parse time — those stay as literal calc() text. Unrecognized → as-is. */
284
286
  function evalCalcExpr(expr) {
285
287
  // Tokenize into numbers-with-units and operators.
286
288
  const tokens = expr.match(/(?:[\d.]+(?:rem|em|px|%)?|[-+*/])/g);
287
289
  if (!tokens || tokens.length === 0)
288
290
  return `calc(${expr})`;
289
- // Determine the dominant unit from the first length token.
290
- const unitMatch = expr.match(/(\d)(rem|em|px|%)/);
291
- const unit = unitMatch ? unitMatch[2] : "";
291
+ const hasPercent = /[\d.]+%/.test(expr);
292
+ const hasLength = /[\d.]+(?:rem|em|px)/.test(expr);
293
+ if (hasPercent && hasLength)
294
+ return `calc(${expr})`;
295
+ // rem/em already fold to px in toNum, so the output unit is determined by
296
+ // the term kinds — never "the first unit seen" (that produced 6rem from
297
+ // 0.5rem - 2px).
298
+ const unit = hasPercent ? "%" : hasLength ? "px" : "";
292
299
  // Convert each token to a plain number (rem/em × 16).
293
300
  const toNum = (tok) => {
294
301
  const remM = /^(-?[\d.]+)rem$/.exec(tok);
@@ -728,10 +735,12 @@ function parseFontShorthand(props, val) {
728
735
  if (sizeIndex < 0)
729
736
  return;
730
737
  const beforeSize = parts.slice(0, sizeIndex);
731
- const size = parts[sizeIndex].split("/")[0];
738
+ const [size, lineHeight] = parts[sizeIndex].split("/");
732
739
  const family = parts.slice(sizeIndex + 1).join(" ").trim();
733
740
  if (size)
734
741
  props.fontSize = size;
742
+ if (lineHeight)
743
+ props.lineHeight = lineHeight;
735
744
  if (family)
736
745
  props.fontFamily = family;
737
746
  for (const part of beforeSize) {
@@ -743,12 +752,18 @@ function parseFontShorthand(props, val) {
743
752
  }
744
753
  }
745
754
  /** Assign a CSS property to the CSSProperty object. Unknown properties emit a warning
746
- * (forward-compatible: they are dropped from output, but the author is notified). */
755
+ * (forward-compatible: they are dropped from output, but the author is notified).
756
+ * Known properties with values the engine can only partially honor also warn —
757
+ * a visible approximation beats a silent one. */
747
758
  function assignProp(props, prop, val, diagnostics) {
748
759
  const warn = (message, hint) => {
749
760
  if (diagnostics)
750
761
  diagnostics.push({ severity: "warning", message, hint, code: "unknown-css-property", source: prop });
751
762
  };
763
+ const warnValue = (code, message, hint) => {
764
+ if (diagnostics)
765
+ diagnostics.push({ severity: "warning", message, hint, code, source: prop });
766
+ };
752
767
  switch (prop) {
753
768
  // Box model
754
769
  case "padding":
@@ -791,6 +806,9 @@ function assignProp(props, prop, val, diagnostics) {
791
806
  props.aspectRatio = val;
792
807
  break;
793
808
  case "box-sizing":
809
+ if (val.trim().toLowerCase() === "content-box") {
810
+ 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.`);
811
+ }
794
812
  props.boxSizing = val;
795
813
  break;
796
814
  case "overflow":
@@ -812,9 +830,23 @@ function assignProp(props, prop, val, diagnostics) {
812
830
  case "font-family":
813
831
  props.fontFamily = val;
814
832
  break;
815
- case "font-size":
816
- props.fontSize = val;
833
+ case "font-size": {
834
+ const v = val.trim();
835
+ if (v.endsWith("%")) {
836
+ 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).`);
837
+ }
838
+ else {
839
+ const em = /^([\d.]+)(em|rem)$/.exec(v);
840
+ if (em) {
841
+ props.fontSize = `${Math.round(parseFloat(em[1]) * 16)}px`;
842
+ 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.`);
843
+ }
844
+ else {
845
+ props.fontSize = val;
846
+ }
847
+ }
817
848
  break;
849
+ }
818
850
  case "text-align":
819
851
  props.textAlign = val;
820
852
  break;
@@ -836,6 +868,9 @@ function assignProp(props, prop, val, diagnostics) {
836
868
  props.fontSubset = val;
837
869
  break;
838
870
  case "line-height":
871
+ if (/^\d+(?:\.\d+)?$/.test(val.trim())) {
872
+ 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).`);
873
+ }
839
874
  props.lineHeight = val;
840
875
  break;
841
876
  case "letter-spacing":
@@ -874,6 +909,9 @@ function assignProp(props, prop, val, diagnostics) {
874
909
  break;
875
910
  // Flexbox / layout
876
911
  case "display":
912
+ if (["inline", "inline-block", "inline-flex", "grid", "flow-root", "table"].includes(val.trim().toLowerCase())) {
913
+ 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.`);
914
+ }
877
915
  props.display = val;
878
916
  break;
879
917
  case "flex-direction":
@@ -925,6 +963,9 @@ function assignProp(props, prop, val, diagnostics) {
925
963
  props.order = val;
926
964
  break;
927
965
  case "position":
966
+ if (["fixed", "sticky"].includes(val.trim().toLowerCase())) {
967
+ warnValue("css-position", `position: ${val.trim()} is not supported — treated as static.`, `Use position: absolute (against the nearest ancestor) or keep flow layout.`);
968
+ }
928
969
  props.position = val;
929
970
  break;
930
971
  case "z-index":
@@ -1023,12 +1064,14 @@ function parseFlexShorthand(props, val) {
1023
1064
  props.flexBasis = parts[2];
1024
1065
  }
1025
1066
  }
1026
- /** Parse the `border` shorthand: "2px solid #808080" → width, style, color. */
1067
+ /** Parse the `border` shorthand: "2px solid #808080" → width, style, color.
1068
+ * Width accepts px, bare numbers (unitless = px), and decimals. */
1027
1069
  function parseBorderShorthand(props, val) {
1028
1070
  const parts = val.trim().split(/\s+/);
1029
1071
  for (const p of parts) {
1030
- if (/^\d+px$/.test(p))
1031
- props.borderWidth = p;
1072
+ const w = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(p);
1073
+ if (w)
1074
+ props.borderWidth = `${w[1]}px`;
1032
1075
  else if (["solid", "dashed", "dotted", "double", "none"].includes(p))
1033
1076
  props.borderStyle = p;
1034
1077
  // Color: #hex, rgb()/hsl(), a CSS named color, or a var() reference (the
@@ -1048,8 +1091,9 @@ function parsePerSideBorder(props, prop, val) {
1048
1091
  const cKey = `border${cap}Color`;
1049
1092
  const parts = val.trim().split(/\s+/);
1050
1093
  for (const p of parts) {
1051
- if (/^\d+px$/.test(p))
1052
- props[wKey] = p;
1094
+ const w = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(p);
1095
+ if (w)
1096
+ props[wKey] = `${w[1]}px`;
1053
1097
  else if (["solid", "dashed", "dotted", "double", "none"].includes(p))
1054
1098
  props[sKey] = p;
1055
1099
  else if (p.startsWith("#") || p.startsWith("rgb") || p.startsWith("hsl") || p.startsWith("var(") || /^[a-z]+$/i.test(p))
@@ -0,0 +1,25 @@
1
+ import type { CSSFontFace } from "./css-parser.js";
2
+ /** The sans family the default faces register under. It matches the font's
3
+ * real name (unmodified file, original name — license-clean). */
4
+ export declare const DEFAULT_FONT_FAMILY = "DejaVu Sans";
5
+ /** The monospace family for <pre>/<code>/<kbd> UA styling. */
6
+ export declare const DEFAULT_MONO_FAMILY = "DejaVu Sans Mono";
7
+ /** Absolute source paths of the bundled faces. */
8
+ export declare function bundledFontPath(file: string): string;
9
+ /** True when the bundled font assets are present and usable. */
10
+ export declare function defaultFontAvailable(): boolean;
11
+ /** True when the display profile should use the bundled font. Color displays
12
+ * only: monochrome targets keep the built-in bitmap font (no AA). An absent
13
+ * profile defaults to color — the common case and the current build target. */
14
+ export declare function defaultFontApplies(colorFormat: string | undefined): boolean;
15
+ /** The implicit @font-face declarations for the bundled default fonts. */
16
+ export declare function defaultFontFaces(): CSSFontFace[];
17
+ /** Merge the bundled default faces into a user's @font-face list. Checked
18
+ * per family: if the author registered any face for "DejaVu Sans" (or the
19
+ * mono family), theirs wins and that family's bundle faces are skipped.
20
+ * Injected faces append so user faces sort first in weight/style matching. */
21
+ export declare function injectDefaultFontFaces(userFaces: CSSFontFace[], colorFormat: string | undefined): CSSFontFace[];
22
+ /** Attribution lines for generated font tables whose glyphs came from the
23
+ * bundled DejaVu faces (satisfies the license's binary-notice requirement
24
+ * and makes firmware images auditable). */
25
+ export declare function defaultFontAttribution(sourcePath: string): string | undefined;
@@ -0,0 +1,80 @@
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
+ import path from "node:path";
21
+ import fs from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ /** The sans family the default faces register under. It matches the font's
24
+ * real name (unmodified file, original name — license-clean). */
25
+ export const DEFAULT_FONT_FAMILY = "DejaVu Sans";
26
+ /** The monospace family for <pre>/<code>/<kbd> UA styling. */
27
+ export const DEFAULT_MONO_FAMILY = "DejaVu Sans Mono";
28
+ const FONT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/fonts/dejavu");
29
+ /** Faces injected by default: sans + mono at the workhorse weights. */
30
+ const DEFAULT_FACES = [
31
+ { family: DEFAULT_FONT_FAMILY, file: "DejaVuSans.ttf", weight: "400", style: "normal" },
32
+ { family: DEFAULT_FONT_FAMILY, file: "DejaVuSans-Bold.ttf", weight: "700", style: "normal" },
33
+ { family: DEFAULT_MONO_FAMILY, file: "DejaVuSansMono.ttf", weight: "400", style: "normal" },
34
+ { family: DEFAULT_MONO_FAMILY, file: "DejaVuSansMono-Bold.ttf", weight: "700", style: "normal" },
35
+ ];
36
+ /** Absolute source paths of the bundled faces. */
37
+ export function bundledFontPath(file) {
38
+ return path.join(FONT_DIR, file);
39
+ }
40
+ /** True when the bundled font assets are present and usable. */
41
+ export function defaultFontAvailable() {
42
+ return DEFAULT_FACES.every((f) => fs.existsSync(bundledFontPath(f.file)));
43
+ }
44
+ /** True when the display profile should use the bundled font. Color displays
45
+ * only: monochrome targets keep the built-in bitmap font (no AA). An absent
46
+ * profile defaults to color — the common case and the current build target. */
47
+ export function defaultFontApplies(colorFormat) {
48
+ return colorFormat !== "mono" && defaultFontAvailable();
49
+ }
50
+ /** The implicit @font-face declarations for the bundled default fonts. */
51
+ export function defaultFontFaces() {
52
+ return DEFAULT_FACES.map((f) => ({
53
+ fontFamily: f.family,
54
+ src: bundledFontPath(f.file),
55
+ fontWeight: f.weight,
56
+ fontStyle: f.style,
57
+ }));
58
+ }
59
+ /** Merge the bundled default faces into a user's @font-face list. Checked
60
+ * per family: if the author registered any face for "DejaVu Sans" (or the
61
+ * mono family), theirs wins and that family's bundle faces are skipped.
62
+ * Injected faces append so user faces sort first in weight/style matching. */
63
+ export function injectDefaultFontFaces(userFaces, colorFormat) {
64
+ if (!defaultFontApplies(colorFormat))
65
+ return userFaces;
66
+ const declared = new Set(userFaces.map((f) => f.fontFamily.toLowerCase()));
67
+ const bundled = defaultFontFaces().filter((f) => !declared.has(f.fontFamily.toLowerCase()));
68
+ return bundled.length > 0 ? [...userFaces, ...bundled] : userFaces;
69
+ }
70
+ /** Attribution lines for generated font tables whose glyphs came from the
71
+ * bundled DejaVu faces (satisfies the license's binary-notice requirement
72
+ * and makes firmware images auditable). */
73
+ export function defaultFontAttribution(sourcePath) {
74
+ const resolved = path.resolve(sourcePath);
75
+ if (path.dirname(resolved) !== FONT_DIR)
76
+ return undefined;
77
+ return `${path.basename(resolved)} - DejaVu fonts v2.37` +
78
+ " - (c) 2003 Bitstream, Inc., (c) 2006 Tavmjong Bah." +
79
+ " Bitstream Vera License - see @typecad/ui assets/fonts/dejavu/LICENSE.";
80
+ }