@hypen-space/web 0.4.980 → 0.5.3

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 (229) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/accessibility.d.ts +118 -11
  3. package/dist/canvas/accessibility.js +470 -108
  4. package/dist/canvas/accessibility.js.map +1 -1
  5. package/dist/canvas/dispatch.d.ts +24 -0
  6. package/dist/canvas/dispatch.js +62 -0
  7. package/dist/canvas/dispatch.js.map +1 -0
  8. package/dist/canvas/editing.d.ts +178 -0
  9. package/dist/canvas/editing.js +590 -0
  10. package/dist/canvas/editing.js.map +1 -0
  11. package/dist/canvas/events.d.ts +28 -23
  12. package/dist/canvas/events.js +90 -103
  13. package/dist/canvas/events.js.map +1 -1
  14. package/dist/canvas/focus.d.ts +78 -0
  15. package/dist/canvas/focus.js +182 -0
  16. package/dist/canvas/focus.js.map +1 -0
  17. package/dist/canvas/index.d.ts +3 -2
  18. package/dist/canvas/index.js +3 -2
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/canvas/layout.js +68 -98
  21. package/dist/canvas/layout.js.map +1 -1
  22. package/dist/canvas/paint.d.ts +16 -3
  23. package/dist/canvas/paint.js +449 -110
  24. package/dist/canvas/paint.js.map +1 -1
  25. package/dist/canvas/renderer.d.ts +28 -4
  26. package/dist/canvas/renderer.js +201 -81
  27. package/dist/canvas/renderer.js.map +1 -1
  28. package/dist/canvas/selection.js +31 -177
  29. package/dist/canvas/selection.js.map +1 -1
  30. package/dist/canvas/text-geometry.d.ts +72 -0
  31. package/dist/canvas/text-geometry.js +244 -0
  32. package/dist/canvas/text-geometry.js.map +1 -0
  33. package/dist/canvas/text.js +44 -18
  34. package/dist/canvas/text.js.map +1 -1
  35. package/dist/canvas/types.d.ts +10 -1
  36. package/dist/canvas/utils.d.ts +0 -23
  37. package/dist/canvas/utils.js +3 -67
  38. package/dist/canvas/utils.js.map +1 -1
  39. package/dist/canvas/variants.d.ts +52 -0
  40. package/dist/canvas/variants.js +156 -0
  41. package/dist/canvas/variants.js.map +1 -0
  42. package/dist/dom/a11y-styles.d.ts +20 -0
  43. package/dist/dom/a11y-styles.js +61 -0
  44. package/dist/dom/a11y-styles.js.map +1 -0
  45. package/dist/dom/applicators/aria.d.ts +19 -0
  46. package/dist/dom/applicators/aria.js +36 -0
  47. package/dist/dom/applicators/aria.js.map +1 -0
  48. package/dist/dom/applicators/events.d.ts +7 -0
  49. package/dist/dom/applicators/events.js +36 -8
  50. package/dist/dom/applicators/events.js.map +1 -1
  51. package/dist/dom/applicators/font.d.ts +11 -6
  52. package/dist/dom/applicators/font.js +16 -27
  53. package/dist/dom/applicators/font.js.map +1 -1
  54. package/dist/dom/applicators/index.js +51 -45
  55. package/dist/dom/applicators/index.js.map +1 -1
  56. package/dist/dom/canvas/index.js +2 -2
  57. package/dist/dom/canvas/index.js.map +1 -1
  58. package/dist/dom/components/app.js +2 -2
  59. package/dist/dom/components/app.js.map +1 -1
  60. package/dist/dom/components/audio.js +2 -2
  61. package/dist/dom/components/audio.js.map +1 -1
  62. package/dist/dom/components/avatar.js +3 -4
  63. package/dist/dom/components/avatar.js.map +1 -1
  64. package/dist/dom/components/badge.js +3 -4
  65. package/dist/dom/components/badge.js.map +1 -1
  66. package/dist/dom/components/button.js +2 -2
  67. package/dist/dom/components/button.js.map +1 -1
  68. package/dist/dom/components/card.js +2 -2
  69. package/dist/dom/components/card.js.map +1 -1
  70. package/dist/dom/components/center.js +2 -2
  71. package/dist/dom/components/center.js.map +1 -1
  72. package/dist/dom/components/checkbox.js +4 -5
  73. package/dist/dom/components/checkbox.js.map +1 -1
  74. package/dist/dom/components/column.js +2 -2
  75. package/dist/dom/components/column.js.map +1 -1
  76. package/dist/dom/components/container.js +2 -2
  77. package/dist/dom/components/container.js.map +1 -1
  78. package/dist/dom/components/divider.js +2 -2
  79. package/dist/dom/components/divider.js.map +1 -1
  80. package/dist/dom/components/grid.js +10 -10
  81. package/dist/dom/components/grid.js.map +1 -1
  82. package/dist/dom/components/heading.js +4 -6
  83. package/dist/dom/components/heading.js.map +1 -1
  84. package/dist/dom/components/hypenapp.d.ts +25 -1
  85. package/dist/dom/components/hypenapp.js +215 -247
  86. package/dist/dom/components/hypenapp.js.map +1 -1
  87. package/dist/dom/components/icon.js +4 -5
  88. package/dist/dom/components/icon.js.map +1 -1
  89. package/dist/dom/components/image.js +2 -2
  90. package/dist/dom/components/image.js.map +1 -1
  91. package/dist/dom/components/index.d.ts +2 -2
  92. package/dist/dom/components/index.js +9 -2
  93. package/dist/dom/components/index.js.map +1 -1
  94. package/dist/dom/components/input.js +2 -2
  95. package/dist/dom/components/input.js.map +1 -1
  96. package/dist/dom/components/link.js +2 -2
  97. package/dist/dom/components/link.js.map +1 -1
  98. package/dist/dom/components/list.js +2 -2
  99. package/dist/dom/components/list.js.map +1 -1
  100. package/dist/dom/components/paragraph.js +3 -4
  101. package/dist/dom/components/paragraph.js.map +1 -1
  102. package/dist/dom/components/progressbar.js +3 -3
  103. package/dist/dom/components/progressbar.js.map +1 -1
  104. package/dist/dom/components/route.js +2 -2
  105. package/dist/dom/components/route.js.map +1 -1
  106. package/dist/dom/components/router.js +2 -2
  107. package/dist/dom/components/router.js.map +1 -1
  108. package/dist/dom/components/row.js +10 -10
  109. package/dist/dom/components/row.js.map +1 -1
  110. package/dist/dom/components/select.js +3 -4
  111. package/dist/dom/components/select.js.map +1 -1
  112. package/dist/dom/components/slider.js +2 -2
  113. package/dist/dom/components/slider.js.map +1 -1
  114. package/dist/dom/components/spacer.js +2 -2
  115. package/dist/dom/components/spacer.js.map +1 -1
  116. package/dist/dom/components/spinner.js +27 -13
  117. package/dist/dom/components/spinner.js.map +1 -1
  118. package/dist/dom/components/stack.js +10 -10
  119. package/dist/dom/components/stack.js.map +1 -1
  120. package/dist/dom/components/switch.js +5 -6
  121. package/dist/dom/components/switch.js.map +1 -1
  122. package/dist/dom/components/tabs.d.ts +27 -0
  123. package/dist/dom/components/tabs.js +69 -0
  124. package/dist/dom/components/tabs.js.map +1 -0
  125. package/dist/dom/components/text.js +4 -7
  126. package/dist/dom/components/text.js.map +1 -1
  127. package/dist/dom/components/textarea.js +2 -2
  128. package/dist/dom/components/textarea.js.map +1 -1
  129. package/dist/dom/components/video.js +2 -2
  130. package/dist/dom/components/video.js.map +1 -1
  131. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  132. package/dist/dom/components/visuallyhidden.js +26 -0
  133. package/dist/dom/components/visuallyhidden.js.map +1 -0
  134. package/dist/dom/debug.js +6 -8
  135. package/dist/dom/debug.js.map +1 -1
  136. package/dist/dom/index.d.ts +0 -1
  137. package/dist/dom/index.js +0 -1
  138. package/dist/dom/index.js.map +1 -1
  139. package/dist/dom/operability.d.ts +100 -0
  140. package/dist/dom/operability.js +298 -0
  141. package/dist/dom/operability.js.map +1 -0
  142. package/dist/dom/renderer.d.ts +98 -5
  143. package/dist/dom/renderer.js +400 -66
  144. package/dist/dom/renderer.js.map +1 -1
  145. package/dist/dom/route-focus.d.ts +42 -0
  146. package/dist/dom/route-focus.js +88 -0
  147. package/dist/dom/route-focus.js.map +1 -0
  148. package/dist/dom/semantics.d.ts +35 -0
  149. package/dist/dom/semantics.js +184 -0
  150. package/dist/dom/semantics.js.map +1 -0
  151. package/dist/variants.d.ts +90 -0
  152. package/dist/variants.js +206 -0
  153. package/dist/variants.js.map +1 -0
  154. package/package.json +2 -2
  155. package/src/canvas/QUICKSTART.md +16 -11
  156. package/src/canvas/README.md +63 -34
  157. package/src/canvas/accessibility.ts +507 -115
  158. package/src/canvas/dispatch.ts +78 -0
  159. package/src/canvas/editing.ts +697 -0
  160. package/src/canvas/events.ts +104 -115
  161. package/src/canvas/focus.ts +216 -0
  162. package/src/canvas/index.ts +8 -9
  163. package/src/canvas/layout.ts +68 -103
  164. package/src/canvas/paint.ts +509 -109
  165. package/src/canvas/renderer.ts +239 -99
  166. package/src/canvas/selection.ts +52 -210
  167. package/src/canvas/text-geometry.ts +311 -0
  168. package/src/canvas/text.ts +45 -18
  169. package/src/canvas/types.ts +28 -1
  170. package/src/canvas/utils.ts +3 -69
  171. package/src/canvas/variants.ts +172 -0
  172. package/src/dom/README.md +8 -7
  173. package/src/dom/a11y-styles.ts +65 -0
  174. package/src/dom/applicators/aria.ts +41 -0
  175. package/src/dom/applicators/events.ts +39 -7
  176. package/src/dom/applicators/font.ts +16 -29
  177. package/src/dom/applicators/index.ts +61 -46
  178. package/src/dom/canvas/index.ts +2 -2
  179. package/src/dom/components/app.ts +2 -2
  180. package/src/dom/components/audio.ts +2 -2
  181. package/src/dom/components/avatar.ts +3 -4
  182. package/src/dom/components/badge.ts +3 -4
  183. package/src/dom/components/button.ts +2 -2
  184. package/src/dom/components/card.ts +2 -2
  185. package/src/dom/components/center.ts +2 -2
  186. package/src/dom/components/checkbox.ts +4 -5
  187. package/src/dom/components/column.ts +2 -2
  188. package/src/dom/components/container.ts +2 -2
  189. package/src/dom/components/divider.ts +2 -2
  190. package/src/dom/components/grid.ts +10 -10
  191. package/src/dom/components/heading.ts +4 -6
  192. package/src/dom/components/hypenapp.ts +246 -274
  193. package/src/dom/components/icon.ts +4 -5
  194. package/src/dom/components/image.ts +2 -2
  195. package/src/dom/components/index.ts +10 -3
  196. package/src/dom/components/input.ts +2 -2
  197. package/src/dom/components/link.ts +2 -2
  198. package/src/dom/components/list.ts +2 -2
  199. package/src/dom/components/paragraph.ts +3 -4
  200. package/src/dom/components/progressbar.ts +3 -3
  201. package/src/dom/components/route.ts +2 -2
  202. package/src/dom/components/router.ts +2 -2
  203. package/src/dom/components/row.ts +10 -10
  204. package/src/dom/components/select.ts +3 -4
  205. package/src/dom/components/slider.ts +2 -2
  206. package/src/dom/components/spacer.ts +2 -2
  207. package/src/dom/components/spinner.ts +34 -16
  208. package/src/dom/components/stack.ts +10 -10
  209. package/src/dom/components/switch.ts +5 -6
  210. package/src/dom/components/tabs.ts +76 -0
  211. package/src/dom/components/text.ts +4 -7
  212. package/src/dom/components/textarea.ts +2 -2
  213. package/src/dom/components/video.ts +2 -2
  214. package/src/dom/components/visuallyhidden.ts +30 -0
  215. package/src/dom/debug.ts +6 -8
  216. package/src/dom/index.ts +0 -8
  217. package/src/dom/operability.ts +312 -0
  218. package/src/dom/renderer.ts +457 -66
  219. package/src/dom/route-focus.ts +98 -0
  220. package/src/dom/semantics.ts +199 -0
  221. package/src/variants.ts +249 -0
  222. package/dist/canvas/input.d.ts +0 -76
  223. package/dist/canvas/input.js +0 -207
  224. package/dist/canvas/input.js.map +0 -1
  225. package/dist/client.d.ts +0 -36
  226. package/dist/client.js +0 -47
  227. package/dist/client.js.map +0 -1
  228. package/src/canvas/input.ts +0 -251
  229. package/src/client.ts +0 -88
@@ -4,12 +4,18 @@
4
4
  * Drawing virtual nodes to canvas
5
5
  */
6
6
 
7
- import type { VirtualNode, PainterFunction } from "./types.js";
8
- import { renderText } from "./text.js";
7
+ import type { VirtualNode, PainterFunction, Rectangle } from "./types.js";
8
+ import { measureText, renderText } from "./text.js";
9
9
  import { ScrollManager, isScrollable } from "./scroll.js";
10
10
  import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
11
11
  import type { SelectionManager } from "./selection.js";
12
- import { cssLengthToPx, resolveLineHeight } from "./utils.js";
12
+ import type { TextEditController } from "./editing.js";
13
+ import {
14
+ SELECTION_HIGHLIGHT_COLOR,
15
+ offsetToCaretRect,
16
+ rangeToRects,
17
+ } from "./text-geometry.js";
18
+ import { cssLengthToPx } from "./utils.js";
13
19
 
14
20
  /**
15
21
  * Module-level reference to the active SelectionManager so paintText
@@ -20,6 +26,16 @@ export function setSelectionManager(mgr: SelectionManager | null): void {
20
26
  activeSelectionManager = mgr;
21
27
  }
22
28
 
29
+ /**
30
+ * Module-level reference to the active TextEditController so paintInput can
31
+ * render the edited value, selection highlight, composition underline, and
32
+ * blinking caret. Same late-binding pattern as the SelectionManager hook.
33
+ */
34
+ let activeTextEditor: TextEditController | null = null;
35
+ export function setTextEditor(editor: TextEditController | null): void {
36
+ activeTextEditor = editor;
37
+ }
38
+
23
39
  /**
24
40
  * Custom painters registry
25
41
  */
@@ -33,23 +49,170 @@ export function registerPainter(type: string, painter: PainterFunction): void {
33
49
  }
34
50
 
35
51
  /**
36
- * Paint a virtual node and its children
52
+ * Per-font character advance widths, used by the manual letter-spacing path
53
+ * so each glyph is shaped once per font instead of once per frame.
37
54
  */
38
- export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
39
- if (!node.visible || !node.layout) return;
55
+ const charAdvanceCache = new Map<string, Map<string, number>>();
56
+ const MAX_CHAR_ADVANCE_FONTS = 32;
40
57
 
41
- ctx.save();
58
+ /**
59
+ * Clear cached per-character advances. Must be called when web fonts finish
60
+ * loading: the cache is keyed by CSS font string, which is identical before
61
+ * and after a webfont replaces its fallback, so advances measured against
62
+ * the fallback font would otherwise poison letter-spaced rendering
63
+ * permanently.
64
+ */
65
+ export function clearCharAdvanceCache(): void {
66
+ charAdvanceCache.clear();
67
+ }
42
68
 
43
- // Apply transforms
44
- applyTransforms(ctx, node);
69
+ function charAdvance(ctx: CanvasRenderingContext2D, font: string, ch: string): number {
70
+ let perFont = charAdvanceCache.get(font);
71
+ if (!perFont) {
72
+ perFont = new Map();
73
+ charAdvanceCache.set(font, perFont);
74
+ evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
75
+ }
76
+ let width = perFont.get(ch);
77
+ if (width === undefined) {
78
+ width = ctx.measureText(ch).width;
79
+ perFont.set(ch, width);
80
+ }
81
+ return width;
82
+ }
45
83
 
46
- // Apply opacity
47
- if (node.opacity < 1) {
48
- ctx.globalAlpha = node.opacity;
84
+ /**
85
+ * Component types whose painters set sticky canvas state (textAlign,
86
+ * textBaseline, lineCap, shadows, …) that must not leak to siblings — they
87
+ * need a scoping save/restore pair. Containers, text, and spacers (and
88
+ * every unknown type, which routes to paintContainer) only touch state each
89
+ * draw call re-sets before use, so they paint without one.
90
+ */
91
+ const STATE_UNSAFE_TYPES = new Set([
92
+ "button",
93
+ "input",
94
+ "textarea",
95
+ "image",
96
+ "divider",
97
+ "separator",
98
+ "checkbox",
99
+ "radio",
100
+ "switch",
101
+ "toggle",
102
+ "slider",
103
+ "progress",
104
+ "progressbar",
105
+ "spinner",
106
+ "loading",
107
+ "card",
108
+ "badge",
109
+ "avatar",
110
+ "icon",
111
+ "link",
112
+ ]);
113
+
114
+ /** Whether any transform-related prop is present on the node. */
115
+ function hasTransformProps(props: Record<string, any>): boolean {
116
+ return (
117
+ props.transform !== undefined ||
118
+ props.translateX !== undefined ||
119
+ props.translateY !== undefined ||
120
+ props.rotate !== undefined ||
121
+ props.scale !== undefined ||
122
+ props.scaleX !== undefined ||
123
+ props.scaleY !== undefined ||
124
+ props.skew !== undefined ||
125
+ props.skewX !== undefined ||
126
+ props.skewY !== undefined
127
+ );
128
+ }
129
+
130
+ /** Extra pixels a node's painting may reach beyond its layout box. */
131
+ const CULL_SLACK = 8;
132
+
133
+ /** How far a shadow can bleed outside the box it is attached to. */
134
+ function shadowExtent(shadow: any): number {
135
+ if (typeof shadow === "string") {
136
+ const parts = shadow.trim().split(/\s+/);
137
+ const ox = Math.abs(parseFloat(parts[0])) || 0;
138
+ const oy = Math.abs(parseFloat(parts[1])) || 0;
139
+ const blur = Math.abs(parseFloat(parts[2])) || 0;
140
+ return Math.max(ox, oy) + blur;
141
+ }
142
+ if (shadow && typeof shadow === "object") {
143
+ return (
144
+ Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
145
+ Math.abs(shadow.blur || 0)
146
+ );
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ /**
152
+ * Whether the whole subtree rooted at `node` can be skipped when repainting
153
+ * only `cull`. Conservative: nodes with transforms or custom painters are
154
+ * never culled (they can draw anywhere), the box is expanded by shadow
155
+ * bleed + slack, and a node with children is only culled when it clips them.
156
+ */
157
+ function canCullSubtree(node: VirtualNode, cull: Rectangle): boolean {
158
+ const layout = node.layout!;
159
+ if (hasTransformProps(node.props)) return false;
160
+ if (customPainters.has(node.type.toLowerCase())) return false;
161
+
162
+ const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
163
+ const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
164
+ if (
165
+ layout.x - margin < cull.x + cull.width &&
166
+ layout.x + layout.width + margin > cull.x &&
167
+ layout.y - margin < cull.y + cull.height &&
168
+ layout.y + layout.height + margin > cull.y
169
+ ) {
170
+ return false;
171
+ }
172
+
173
+ if (node.children.length === 0) return true;
174
+ const overflow = node.props.overflow;
175
+ return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
176
+ }
177
+
178
+ /**
179
+ * Paint a virtual node and its children.
180
+ *
181
+ * `cull` (optional) is the dirty region being repainted — subtrees that
182
+ * provably cannot reach it are skipped entirely.
183
+ */
184
+ export function paintNode(
185
+ ctx: CanvasRenderingContext2D,
186
+ node: VirtualNode,
187
+ cull?: Rectangle | null,
188
+ ): void {
189
+ if (!node.visible || !node.layout) return;
190
+ if (cull && canCullSubtree(node, cull)) return;
191
+
192
+ const type = node.type.toLowerCase();
193
+ const customPainter = customPainters.get(type);
194
+ const needsTransform = hasTransformProps(node.props);
195
+ const needsSave =
196
+ customPainter !== undefined ||
197
+ needsTransform ||
198
+ node.opacity < 1 ||
199
+ STATE_UNSAFE_TYPES.has(type);
200
+
201
+ if (needsSave) {
202
+ ctx.save();
203
+
204
+ // Apply transforms
205
+ if (needsTransform) {
206
+ applyTransforms(ctx, node);
207
+ }
208
+
209
+ // Apply opacity
210
+ if (node.opacity < 1) {
211
+ ctx.globalAlpha = node.opacity;
212
+ }
49
213
  }
50
214
 
51
215
  // Check for custom painter
52
- const customPainter = customPainters.get(node.type.toLowerCase());
53
216
  if (customPainter) {
54
217
  customPainter(ctx, node);
55
218
  ctx.restore();
@@ -57,7 +220,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
57
220
  }
58
221
 
59
222
  // Default painting based on type
60
- switch (node.type.toLowerCase()) {
223
+ switch (type) {
61
224
  case "column":
62
225
  case "row":
63
226
  case "stack":
@@ -70,6 +233,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
70
233
  paintButton(ctx, node);
71
234
  break;
72
235
  case "input":
236
+ case "textarea":
73
237
  paintInput(ctx, node);
74
238
  break;
75
239
  case "image":
@@ -127,14 +291,26 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
127
291
  paintContainer(ctx, node);
128
292
  }
129
293
 
130
- ctx.restore();
294
+ if (needsSave) {
295
+ ctx.restore();
296
+ }
131
297
 
132
298
  // Apply scroll translation for scrollable containers
133
299
  const ss = node.scrollState;
300
+ let childCull = cull ?? null;
134
301
  if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
135
302
  ctx.save();
136
303
  ctx.translate(-ss.scrollX, -ss.scrollY);
137
304
  (node as any)._scrollRestore = true;
305
+ // Children paint in scrolled space — shift the cull region to match.
306
+ if (childCull) {
307
+ childCull = {
308
+ x: childCull.x + ss.scrollX,
309
+ y: childCull.y + ss.scrollY,
310
+ width: childCull.width,
311
+ height: childCull.height,
312
+ };
313
+ }
138
314
  }
139
315
 
140
316
  // Paint children -- use windowed rendering for large scrollable lists
@@ -158,14 +334,21 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
158
334
  // tree (Story uses this for its close button) gets covered by the
159
335
  // following in-flow Image. Push absolute kids to the end so they land
160
336
  // on top — same effect as `z-index: auto` painting absolute after flow.
161
- const flowKids: VirtualNode[] = [];
162
- const overlayKids: VirtualNode[] = [];
337
+ let hasOverlays = false;
163
338
  for (const child of childrenToPaint) {
164
- if (child.props.position === "absolute") overlayKids.push(child);
165
- else flowKids.push(child);
339
+ if (child.props.position === "absolute") {
340
+ hasOverlays = true;
341
+ continue;
342
+ }
343
+ paintNode(ctx, child, childCull);
344
+ }
345
+ if (hasOverlays) {
346
+ for (const child of childrenToPaint) {
347
+ if (child.props.position === "absolute") {
348
+ paintNode(ctx, child, childCull);
349
+ }
350
+ }
166
351
  }
167
- for (const child of flowKids) paintNode(ctx, child);
168
- for (const child of overlayKids) paintNode(ctx, child);
169
352
 
170
353
  // Undo scroll translation before drawing scrollbars
171
354
  if ((node as any)._scrollRestore) {
@@ -205,14 +388,9 @@ function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void
205
388
  }
206
389
 
207
390
  // Draw background
208
- const backgroundColor = props.backgroundColor || props.background;
209
- if (backgroundColor) {
210
- // Support for gradients
211
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
212
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
213
- } else {
214
- ctx.fillStyle = backgroundColor;
215
- }
391
+ const background = resolveBackgroundPaint(props);
392
+ if (background) {
393
+ ctx.fillStyle = resolveCanvasPaint(ctx, background, x, y, width, height);
216
394
 
217
395
  if (radius > 0) {
218
396
  drawRoundedRect(ctx, x, y, width, height, radius);
@@ -271,14 +449,13 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
271
449
  const layout = node.layout!;
272
450
  const props = node.props;
273
451
 
274
- // `??` (not `||`) so a bound `0`, `false`, or `""` still renders.
275
- let text = String(props[0] ?? props.text ?? "");
452
+ let text = String(props[0] || props.text || "");
276
453
  const color = props.color || "#000000";
277
454
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
278
455
  const fontWeight = props.fontWeight || "normal";
279
456
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
280
457
  const textAlign = props.textAlign || "left";
281
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
458
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
282
459
  const textDecoration = props.textDecoration || "none";
283
460
  const textTransform = props.textTransform || "none";
284
461
  const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
@@ -305,14 +482,15 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
305
482
  if (letterSpacing !== 0) {
306
483
  // Manual letter spacing
307
484
  ctx.fillStyle = color;
308
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
485
+ const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
486
+ ctx.font = font;
309
487
  ctx.textAlign = "left";
310
488
  ctx.textBaseline = "top";
311
489
 
312
490
  let currentX = x;
313
491
  for (let i = 0; i < text.length; i++) {
314
492
  ctx.fillText(text[i], currentX, y);
315
- currentX += ctx.measureText(text[i]).width;
493
+ currentX += charAdvance(ctx, font, text[i]);
316
494
  // Add letter spacing after each character except the last
317
495
  if (i < text.length - 1) {
318
496
  currentX += letterSpacing;
@@ -356,11 +534,18 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
356
534
  textOverflow
357
535
  );
358
536
 
359
- // Text decoration (need to set font again for measurement)
537
+ // Text decoration reuse the wrapped metrics renderText just resolved
538
+ // (cache hit) instead of re-shaping the raw, unwrapped string.
360
539
  if (textDecoration !== "none") {
361
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
362
- const textWidth = ctx.measureText(text).width;
363
- applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
540
+ const metrics = measureText(
541
+ ctx,
542
+ text,
543
+ { fontSize, fontWeight, fontFamily, lineHeight },
544
+ layout.contentWidth,
545
+ maxLines,
546
+ textOverflow,
547
+ );
548
+ applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
364
549
  }
365
550
  }
366
551
 
@@ -384,7 +569,7 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
384
569
  const y = layout.y;
385
570
  const width = layout.width;
386
571
  const height = layout.height;
387
- const radius = layout.border.radius;
572
+ const radius = layout.border.radius || 4;
388
573
 
389
574
  // Apply shadow if specified
390
575
  const shadow = props.shadow || props.boxShadow;
@@ -403,16 +588,12 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
403
588
  backgroundColor = props.hoverColor;
404
589
  } else if (node.focused && props.focusColor !== undefined) {
405
590
  backgroundColor = props.focusColor;
406
- } else if (props.backgroundColor !== undefined) {
407
- backgroundColor = props.backgroundColor;
591
+ } else {
592
+ backgroundColor = resolveBackgroundPaint(props);
408
593
  }
409
594
 
410
595
  if (backgroundColor !== undefined) {
411
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
412
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
413
- } else {
414
- ctx.fillStyle = backgroundColor;
415
- }
596
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
416
597
  drawRoundedRect(ctx, x, y, width, height, radius);
417
598
  ctx.fill();
418
599
  }
@@ -433,6 +614,18 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
433
614
  ctx.stroke();
434
615
  }
435
616
 
617
+ // Keyboard focus ring. Focus lives on the node's (unrendered) mirror
618
+ // element in the canvas fallback content, so the UA can't draw a ring at
619
+ // the painted box — paint one so keyboard users can see where they are.
620
+ if (node.focused) {
621
+ ctx.save();
622
+ ctx.strokeStyle = props.focusRingColor || "#007bff";
623
+ ctx.lineWidth = 2;
624
+ drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
625
+ ctx.stroke();
626
+ ctx.restore();
627
+ }
628
+
436
629
  // Paint children (typically Text)
437
630
  for (const child of node.children) {
438
631
  paintNode(ctx, child);
@@ -450,7 +643,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
450
643
  const y = layout.y;
451
644
  const width = layout.width;
452
645
  const height = layout.height;
453
- const radius = layout.border.radius;
646
+ const radius = layout.border.radius || 4;
454
647
 
455
648
  // Background
456
649
  ctx.fillStyle = props.backgroundColor || "#ffffff";
@@ -465,24 +658,76 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
465
658
  drawRoundedRect(ctx, x, y, width, height, radius);
466
659
  ctx.stroke();
467
660
 
468
- // Input value text
469
- const value = props.value || "";
661
+ const isSingleLine = node.type.toLowerCase() === "input";
662
+
663
+ // While editing, the mirror element (via TextEditController) is the value
664
+ // source — paint stays ahead of the engine's SetProp echo.
665
+ const edit = activeTextEditor?.getStateFor(node) ?? null;
666
+
667
+ const value = edit ? edit.value : (props.value || "");
470
668
  const placeholder = props.placeholder || "";
471
669
  const text = value || placeholder;
472
670
  const textColor = value ? (props.color || "#000000") : "#999999";
473
671
 
474
- if (text) {
475
- const fontSize = cssLengthToPx(props.fontSize) ?? 16;
476
- const fontWeight = props.fontWeight || "normal";
477
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
478
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
672
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
673
+ const fontWeight = props.fontWeight || "normal";
674
+ const fontFamily = props.fontFamily || "system-ui, sans-serif";
675
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
676
+
677
+ if (!edit) {
678
+ if (text) {
679
+ renderText(
680
+ ctx,
681
+ text,
682
+ layout.x + layout.contentX,
683
+ layout.y + layout.contentY,
684
+ layout.contentWidth,
685
+ layout.contentHeight,
686
+ {
687
+ color: textColor,
688
+ fontSize,
689
+ fontWeight,
690
+ fontFamily,
691
+ textAlign: "left",
692
+ verticalAlign: isSingleLine ? "middle" : "top",
693
+ lineHeight,
694
+ }
695
+ );
696
+ }
697
+ return;
698
+ }
479
699
 
700
+ // ---- Editing visuals: clip → selection highlight → text → composition
701
+ // ---- underline → caret. Geometry comes from the edit controller so
702
+ // ---- pointer mapping and painting share identical math (incl. scrollX).
703
+ ctx.save();
704
+ drawRoundedRect(ctx, x, y, width, height, radius);
705
+ ctx.clip();
706
+
707
+ const g = activeTextEditor!.geometry(
708
+ layout.x + layout.contentX - edit.scrollX,
709
+ layout.y + layout.contentY,
710
+ );
711
+
712
+ // Selection highlight behind the text
713
+ if (edit.selStart !== edit.selEnd) {
714
+ const from = Math.min(edit.selStart, edit.selEnd);
715
+ const to = Math.max(edit.selStart, edit.selEnd);
716
+ ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
717
+ for (const r of rangeToRects(ctx, g, from, to)) {
718
+ ctx.fillRect(r.x, r.y, r.width, r.height);
719
+ }
720
+ }
721
+
722
+ if (text) {
480
723
  renderText(
481
724
  ctx,
482
- text,
483
- layout.x + layout.contentX,
725
+ value || placeholder,
726
+ g.contentX,
484
727
  layout.y + layout.contentY,
485
- layout.contentWidth,
728
+ // Single-line inputs never wrap — the clip + scrollX pan handle
729
+ // overflow. Textareas wrap at content width like static paint.
730
+ isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth,
486
731
  layout.contentHeight,
487
732
  {
488
733
  color: textColor,
@@ -490,11 +735,34 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
490
735
  fontWeight,
491
736
  fontFamily,
492
737
  textAlign: "left",
493
- verticalAlign: "middle",
738
+ verticalAlign: isSingleLine ? "middle" : "top",
494
739
  lineHeight,
495
740
  }
496
741
  );
497
742
  }
743
+
744
+ // IME composition underline (dashed, under the composing range)
745
+ if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
746
+ ctx.strokeStyle = props.color || "#000000";
747
+ ctx.lineWidth = 1;
748
+ ctx.setLineDash([2, 2]);
749
+ for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
750
+ ctx.beginPath();
751
+ ctx.moveTo(r.x, r.y + r.height - 2);
752
+ ctx.lineTo(r.x + r.width, r.y + r.height - 2);
753
+ ctx.stroke();
754
+ }
755
+ ctx.setLineDash([]);
756
+ }
757
+
758
+ // Caret (hidden while a range is selected, blinks via the edit timer)
759
+ if (edit.caretVisible && edit.selStart === edit.selEnd) {
760
+ const caret = offsetToCaretRect(ctx, g, edit.selEnd);
761
+ ctx.fillStyle = props.color || "#000000";
762
+ ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
763
+ }
764
+
765
+ ctx.restore();
498
766
  }
499
767
 
500
768
  /**
@@ -658,8 +926,10 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
658
926
  imagePending.delete(src);
659
927
  imageFailCache.delete(src);
660
928
  evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
661
- // Request a repaint so the loaded image is drawn
662
- canvas.dispatchEvent(new CustomEvent("hypen:redraw"));
929
+ // Request a repaint so the loaded image is drawn. `layout: true`
930
+ // because the decoded intrinsic size can change the image's box
931
+ // (see getImageNaturalAspect in layout.ts).
932
+ canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
663
933
  };
664
934
  img.onerror = () => {
665
935
  imageInFlight.delete(src);
@@ -725,14 +995,6 @@ function drawRoundedRect(
725
995
  radius: number
726
996
  ): void {
727
997
  if (radius <= 0 || width <= 0 || height <= 0) {
728
- // beginPath is critical here — without it, this rect appends to the
729
- // existing path and the next `ctx.fill()` re-fills every previously
730
- // queued rect with the current fillStyle. That manifested as a
731
- // calculator grid where every button painted in the last button's
732
- // colour and only the last button's child text survived (each
733
- // subsequent fill repainted over earlier children). Mirrors the
734
- // beginPath() the rounded branch already does below.
735
- ctx.beginPath();
736
998
  ctx.rect(x, y, width, height);
737
999
  return;
738
1000
  }
@@ -783,6 +1045,62 @@ function applyShadow(ctx: CanvasRenderingContext2D, shadow: any): void {
783
1045
  }
784
1046
  }
785
1047
 
1048
+ /**
1049
+ * Resolve DOM background applicator props into the CSS-like paint strings
1050
+ * Canvas understands. DOM applicators map `.linearGradient("...")` to
1051
+ * `background-image: linear-gradient(...)`; Canvas receives the original prop
1052
+ * and must do the same translation before painting.
1053
+ */
1054
+ function resolveBackgroundPaint(props: Record<string, any>): string | undefined {
1055
+ if (props.linearGradient !== undefined) {
1056
+ return `linear-gradient(${props.linearGradient})`;
1057
+ }
1058
+ if (props.radialGradient !== undefined) {
1059
+ return `radial-gradient(${props.radialGradient})`;
1060
+ }
1061
+ if (props.conicGradient !== undefined) {
1062
+ return `conic-gradient(${props.conicGradient})`;
1063
+ }
1064
+ if (props.backgroundImage !== undefined) {
1065
+ return String(props.backgroundImage);
1066
+ }
1067
+ if (props.backgroundColor !== undefined) {
1068
+ return String(props.backgroundColor);
1069
+ }
1070
+ if (props.background !== undefined) {
1071
+ return String(props.background);
1072
+ }
1073
+ return undefined;
1074
+ }
1075
+
1076
+ /**
1077
+ * Parsed gradients keyed by (source string, box geometry). CanvasGradient
1078
+ * objects are not bound to a specific context, so identical fills across
1079
+ * frames — the common case, since layout geometry is stable — skip the
1080
+ * regex parse and createLinearGradient/createRadialGradient entirely.
1081
+ */
1082
+ const gradientCache = new Map<string, CanvasGradient | string>();
1083
+ const MAX_GRADIENT_CACHE_SIZE = 256;
1084
+
1085
+ function resolveCanvasPaint(
1086
+ ctx: CanvasRenderingContext2D,
1087
+ paint: string,
1088
+ x: number,
1089
+ y: number,
1090
+ width: number,
1091
+ height: number,
1092
+ ): CanvasGradient | string {
1093
+ if (!paint.includes("gradient(")) return paint;
1094
+ const key = `${paint}|${x}|${y}|${width}|${height}`;
1095
+ let resolved = gradientCache.get(key);
1096
+ if (resolved === undefined) {
1097
+ resolved = parseGradient(ctx, paint, x, y, width, height);
1098
+ gradientCache.set(key, resolved);
1099
+ evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
1100
+ }
1101
+ return resolved;
1102
+ }
1103
+
786
1104
  /**
787
1105
  * Parse gradient string and create canvas gradient
788
1106
  * Supports:
@@ -803,7 +1121,7 @@ function parseGradient(
803
1121
  const match = gradientStr.match(/linear-gradient\((.*)\)/);
804
1122
  if (!match) return gradientStr;
805
1123
 
806
- const parts = match[1].split(",").map((s) => s.trim());
1124
+ const parts = splitCssArgs(match[1]);
807
1125
 
808
1126
  // Determine direction (default to bottom)
809
1127
  let x0 = x, y0 = y, x1 = x, y1 = y + height;
@@ -813,29 +1131,20 @@ function parseGradient(
813
1131
  colorStart = 1;
814
1132
  const direction = parts[0];
815
1133
 
816
- if (direction.includes("to right") || direction === "90deg") {
817
- x1 = x + width;
818
- y1 = y;
819
- } else if (direction.includes("to left") || direction === "270deg") {
820
- x0 = x + width;
821
- x1 = x;
822
- y0 = y;
823
- y1 = y;
824
- } else if (direction.includes("to top") || direction === "0deg") {
825
- y0 = y + height;
826
- y1 = y;
1134
+ const angle = parseCssGradientAngle(direction);
1135
+ if (angle !== null) {
1136
+ const line = gradientLineForAngle(angle, x, y, width, height);
1137
+ x0 = line.x0;
1138
+ y0 = line.y0;
1139
+ x1 = line.x1;
1140
+ y1 = line.y1;
827
1141
  }
828
- // Default is "to bottom" which we already set
829
1142
  }
830
1143
 
831
1144
  const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
832
1145
 
833
1146
  // Add color stops
834
- const colors = parts.slice(colorStart);
835
- colors.forEach((color, i) => {
836
- const stop = i / (colors.length - 1);
837
- gradient.addColorStop(stop, color.trim());
838
- });
1147
+ addColorStops(gradient, parts.slice(colorStart));
839
1148
 
840
1149
  return gradient;
841
1150
  } else if (gradientStr.startsWith("radial-gradient")) {
@@ -843,7 +1152,7 @@ function parseGradient(
843
1152
  const match = gradientStr.match(/radial-gradient\((.*)\)/);
844
1153
  if (!match) return gradientStr;
845
1154
 
846
- const parts = match[1].split(",").map((s) => s.trim());
1155
+ const parts = splitCssArgs(match[1]);
847
1156
 
848
1157
  // Create radial gradient from center
849
1158
  const centerX = x + width / 2;
@@ -853,17 +1162,116 @@ function parseGradient(
853
1162
  const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
854
1163
 
855
1164
  // Add color stops
856
- parts.forEach((color, i) => {
857
- const stop = i / (parts.length - 1);
858
- gradient.addColorStop(stop, color.trim());
859
- });
1165
+ addColorStops(gradient, parts);
860
1166
 
1167
+ return gradient;
1168
+ } else if (gradientStr.startsWith("conic-gradient")) {
1169
+ const match = gradientStr.match(/conic-gradient\((.*)\)/);
1170
+ if (!match) return gradientStr;
1171
+ const createConicGradient = (ctx as CanvasRenderingContext2D & {
1172
+ createConicGradient?: (startAngle: number, x: number, y: number) => CanvasGradient;
1173
+ }).createConicGradient;
1174
+ if (!createConicGradient) return firstGradientColor(match[1]) ?? gradientStr;
1175
+ const centerX = x + width / 2;
1176
+ const centerY = y + height / 2;
1177
+ const gradient = createConicGradient.call(ctx, 0, centerX, centerY);
1178
+ addColorStops(gradient, splitCssArgs(match[1]));
861
1179
  return gradient;
862
1180
  }
863
1181
 
864
1182
  return gradientStr;
865
1183
  }
866
1184
 
1185
+ function splitCssArgs(input: string): string[] {
1186
+ const parts: string[] = [];
1187
+ let depth = 0;
1188
+ let start = 0;
1189
+ for (let i = 0; i < input.length; i++) {
1190
+ const ch = input[i];
1191
+ if (ch === "(") depth++;
1192
+ else if (ch === ")") depth = Math.max(0, depth - 1);
1193
+ else if (ch === "," && depth === 0) {
1194
+ const part = input.slice(start, i).trim();
1195
+ if (part) parts.push(part);
1196
+ start = i + 1;
1197
+ }
1198
+ }
1199
+ const tail = input.slice(start).trim();
1200
+ if (tail) parts.push(tail);
1201
+ return parts;
1202
+ }
1203
+
1204
+ function parseCssGradientAngle(direction: string): number | null {
1205
+ const trimmed = direction.trim().toLowerCase();
1206
+ const deg = trimmed.match(/^(-?(?:\d+|\d*\.\d+))deg$/);
1207
+ if (deg) return Number(deg[1]);
1208
+ if (!trimmed.startsWith("to ")) return null;
1209
+
1210
+ const words = new Set(trimmed.slice(3).split(/\s+/).filter(Boolean));
1211
+ if (words.has("top") && words.has("right")) return 45;
1212
+ if (words.has("right") && words.has("bottom")) return 135;
1213
+ if (words.has("bottom") && words.has("left")) return 225;
1214
+ if (words.has("left") && words.has("top")) return 315;
1215
+ if (words.has("top")) return 0;
1216
+ if (words.has("right")) return 90;
1217
+ if (words.has("bottom")) return 180;
1218
+ if (words.has("left")) return 270;
1219
+ return null;
1220
+ }
1221
+
1222
+ function gradientLineForAngle(
1223
+ cssDegrees: number,
1224
+ x: number,
1225
+ y: number,
1226
+ width: number,
1227
+ height: number,
1228
+ ): { x0: number; y0: number; x1: number; y1: number } {
1229
+ const radians = (cssDegrees * Math.PI) / 180;
1230
+ const cx = x + width / 2;
1231
+ const cy = y + height / 2;
1232
+ const half = Math.sqrt(width * width + height * height) / 2;
1233
+ const dx = Math.sin(radians) * half;
1234
+ const dy = -Math.cos(radians) * half;
1235
+ return {
1236
+ x0: cx - dx,
1237
+ y0: cy - dy,
1238
+ x1: cx + dx,
1239
+ y1: cy + dy,
1240
+ };
1241
+ }
1242
+
1243
+ function parseColorStop(raw: string, fallbackStop: number): { color: string; stop: number } {
1244
+ const trimmed = raw.trim();
1245
+ const match = trimmed.match(/^(.*\S)\s+(-?(?:\d+|\d*\.\d+)%)$/);
1246
+ if (!match) return { color: trimmed, stop: fallbackStop };
1247
+ return {
1248
+ color: match[1].trim(),
1249
+ stop: Math.max(0, Math.min(1, parseFloat(match[2]) / 100)),
1250
+ };
1251
+ }
1252
+
1253
+ function addColorStops(gradient: CanvasGradient, rawStops: string[]): void {
1254
+ const stops = rawStops.filter(Boolean);
1255
+ if (stops.length === 0) return;
1256
+ if (stops.length === 1) {
1257
+ const parsed = parseColorStop(stops[0], 0);
1258
+ gradient.addColorStop(0, parsed.color);
1259
+ gradient.addColorStop(1, parsed.color);
1260
+ return;
1261
+ }
1262
+ stops.forEach((raw, i) => {
1263
+ const fallback = i / (stops.length - 1);
1264
+ const { color, stop } = parseColorStop(raw, fallback);
1265
+ gradient.addColorStop(stop, color);
1266
+ });
1267
+ }
1268
+
1269
+ function firstGradientColor(input: string): string | undefined {
1270
+ const first = splitCssArgs(input)[0];
1271
+ if (!first) return undefined;
1272
+ return parseColorStop(first, 0).color;
1273
+ }
1274
+
867
1275
  /**
868
1276
  * Apply CSS-like transforms to canvas context
869
1277
  * Supports:
@@ -1157,7 +1565,7 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1157
1565
 
1158
1566
  // Support gradient fills
1159
1567
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1160
- ctx.fillStyle = parseGradient(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1568
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1161
1569
  } else {
1162
1570
  ctx.fillStyle = fillColor;
1163
1571
  }
@@ -1212,7 +1620,7 @@ function paintProgress(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1212
1620
  // Support gradient fills
1213
1621
  const fillColor = props.fillColor || props.color || "#007bff";
1214
1622
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1215
- ctx.fillStyle = parseGradient(ctx, fillColor, x, y, fillWidth, height);
1623
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
1216
1624
  } else {
1217
1625
  ctx.fillStyle = fillColor;
1218
1626
  }
@@ -1277,19 +1685,15 @@ function paintCard(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1277
1685
  const y = layout.y;
1278
1686
  const width = layout.width;
1279
1687
  const height = layout.height;
1280
- const radius = layout.border.radius;
1688
+ const radius = layout.border.radius || 8;
1281
1689
 
1282
1690
  // Default card shadow
1283
1691
  const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
1284
1692
  applyShadow(ctx, shadow);
1285
1693
 
1286
1694
  // Background
1287
- const backgroundColor = props.backgroundColor || "#ffffff";
1288
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
1289
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
1290
- } else {
1291
- ctx.fillStyle = backgroundColor;
1292
- }
1695
+ const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
1696
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
1293
1697
 
1294
1698
  drawRoundedRect(ctx, x, y, width, height, radius);
1295
1699
  ctx.fill();
@@ -1333,8 +1737,8 @@ function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1333
1737
  drawRoundedRect(ctx, x, y, width, height, radius);
1334
1738
  ctx.fill();
1335
1739
 
1336
- // `??` so a literal `0`/`false`/`""` value still renders.
1337
- const text = String(props[0] ?? props.text ?? "");
1740
+ // Text content
1741
+ const text = String(props[0] || props.text || "");
1338
1742
  if (text) {
1339
1743
  ctx.fillStyle = props.color || "#ffffff";
1340
1744
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
@@ -1369,8 +1773,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1369
1773
  ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1370
1774
  ctx.fill();
1371
1775
 
1372
- // `??` so a literal `0`/`false`/`""` value still renders.
1373
- const text = String(props[0] ?? props.text ?? props.initials ?? "");
1776
+ // Text initials if provided
1777
+ const text = String(props[0] || props.text || props.initials || "");
1374
1778
  if (text) {
1375
1779
  ctx.fillStyle = props.color || "#ffffff";
1376
1780
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
@@ -1510,8 +1914,7 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1510
1914
  const layout = node.layout!;
1511
1915
  const props = node.props;
1512
1916
 
1513
- // `??` so a literal `0`/`false`/`""` value still renders.
1514
- const text = String(props[0] ?? props.text ?? "");
1917
+ const text = String(props[0] || props.text || "");
1515
1918
  const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
1516
1919
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1517
1920
  const fontWeight = props.fontWeight || "normal";
@@ -1641,6 +2044,3 @@ function drawStar(
1641
2044
 
1642
2045
 
1643
2046
 
1644
-
1645
-
1646
-