@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
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Drawing virtual nodes to canvas
5
5
  */
6
- import { renderText } from "./text.js";
6
+ import { measureText, renderText } from "./text.js";
7
7
  import { ScrollManager, isScrollable } from "./scroll.js";
8
8
  import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
9
- import { cssLengthToPx, resolveLineHeight } from "./utils.js";
9
+ import { SELECTION_HIGHLIGHT_COLOR, offsetToCaretRect, rangeToRects, } from "./text-geometry.js";
10
+ import { cssLengthToPx } from "./utils.js";
10
11
  /**
11
12
  * Module-level reference to the active SelectionManager so paintText
12
13
  * can render selection highlights. Set once from the renderer.
@@ -15,6 +16,15 @@ let activeSelectionManager = null;
15
16
  export function setSelectionManager(mgr) {
16
17
  activeSelectionManager = mgr;
17
18
  }
19
+ /**
20
+ * Module-level reference to the active TextEditController so paintInput can
21
+ * render the edited value, selection highlight, composition underline, and
22
+ * blinking caret. Same late-binding pattern as the SelectionManager hook.
23
+ */
24
+ let activeTextEditor = null;
25
+ export function setTextEditor(editor) {
26
+ activeTextEditor = editor;
27
+ }
18
28
  /**
19
29
  * Custom painters registry
20
30
  */
@@ -26,27 +36,156 @@ export function registerPainter(type, painter) {
26
36
  customPainters.set(type.toLowerCase(), painter);
27
37
  }
28
38
  /**
29
- * Paint a virtual node and its children
39
+ * Per-font character advance widths, used by the manual letter-spacing path
40
+ * so each glyph is shaped once per font instead of once per frame.
41
+ */
42
+ const charAdvanceCache = new Map();
43
+ const MAX_CHAR_ADVANCE_FONTS = 32;
44
+ /**
45
+ * Clear cached per-character advances. Must be called when web fonts finish
46
+ * loading: the cache is keyed by CSS font string, which is identical before
47
+ * and after a webfont replaces its fallback, so advances measured against
48
+ * the fallback font would otherwise poison letter-spaced rendering
49
+ * permanently.
50
+ */
51
+ export function clearCharAdvanceCache() {
52
+ charAdvanceCache.clear();
53
+ }
54
+ function charAdvance(ctx, font, ch) {
55
+ let perFont = charAdvanceCache.get(font);
56
+ if (!perFont) {
57
+ perFont = new Map();
58
+ charAdvanceCache.set(font, perFont);
59
+ evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
60
+ }
61
+ let width = perFont.get(ch);
62
+ if (width === undefined) {
63
+ width = ctx.measureText(ch).width;
64
+ perFont.set(ch, width);
65
+ }
66
+ return width;
67
+ }
68
+ /**
69
+ * Component types whose painters set sticky canvas state (textAlign,
70
+ * textBaseline, lineCap, shadows, …) that must not leak to siblings — they
71
+ * need a scoping save/restore pair. Containers, text, and spacers (and
72
+ * every unknown type, which routes to paintContainer) only touch state each
73
+ * draw call re-sets before use, so they paint without one.
74
+ */
75
+ const STATE_UNSAFE_TYPES = new Set([
76
+ "button",
77
+ "input",
78
+ "textarea",
79
+ "image",
80
+ "divider",
81
+ "separator",
82
+ "checkbox",
83
+ "radio",
84
+ "switch",
85
+ "toggle",
86
+ "slider",
87
+ "progress",
88
+ "progressbar",
89
+ "spinner",
90
+ "loading",
91
+ "card",
92
+ "badge",
93
+ "avatar",
94
+ "icon",
95
+ "link",
96
+ ]);
97
+ /** Whether any transform-related prop is present on the node. */
98
+ function hasTransformProps(props) {
99
+ return (props.transform !== undefined ||
100
+ props.translateX !== undefined ||
101
+ props.translateY !== undefined ||
102
+ props.rotate !== undefined ||
103
+ props.scale !== undefined ||
104
+ props.scaleX !== undefined ||
105
+ props.scaleY !== undefined ||
106
+ props.skew !== undefined ||
107
+ props.skewX !== undefined ||
108
+ props.skewY !== undefined);
109
+ }
110
+ /** Extra pixels a node's painting may reach beyond its layout box. */
111
+ const CULL_SLACK = 8;
112
+ /** How far a shadow can bleed outside the box it is attached to. */
113
+ function shadowExtent(shadow) {
114
+ if (typeof shadow === "string") {
115
+ const parts = shadow.trim().split(/\s+/);
116
+ const ox = Math.abs(parseFloat(parts[0])) || 0;
117
+ const oy = Math.abs(parseFloat(parts[1])) || 0;
118
+ const blur = Math.abs(parseFloat(parts[2])) || 0;
119
+ return Math.max(ox, oy) + blur;
120
+ }
121
+ if (shadow && typeof shadow === "object") {
122
+ return (Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
123
+ Math.abs(shadow.blur || 0));
124
+ }
125
+ return 0;
126
+ }
127
+ /**
128
+ * Whether the whole subtree rooted at `node` can be skipped when repainting
129
+ * only `cull`. Conservative: nodes with transforms or custom painters are
130
+ * never culled (they can draw anywhere), the box is expanded by shadow
131
+ * bleed + slack, and a node with children is only culled when it clips them.
132
+ */
133
+ function canCullSubtree(node, cull) {
134
+ const layout = node.layout;
135
+ if (hasTransformProps(node.props))
136
+ return false;
137
+ if (customPainters.has(node.type.toLowerCase()))
138
+ return false;
139
+ const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
140
+ const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
141
+ if (layout.x - margin < cull.x + cull.width &&
142
+ layout.x + layout.width + margin > cull.x &&
143
+ layout.y - margin < cull.y + cull.height &&
144
+ layout.y + layout.height + margin > cull.y) {
145
+ return false;
146
+ }
147
+ if (node.children.length === 0)
148
+ return true;
149
+ const overflow = node.props.overflow;
150
+ return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
151
+ }
152
+ /**
153
+ * Paint a virtual node and its children.
154
+ *
155
+ * `cull` (optional) is the dirty region being repainted — subtrees that
156
+ * provably cannot reach it are skipped entirely.
30
157
  */
31
- export function paintNode(ctx, node) {
158
+ export function paintNode(ctx, node, cull) {
32
159
  if (!node.visible || !node.layout)
33
160
  return;
34
- ctx.save();
35
- // Apply transforms
36
- applyTransforms(ctx, node);
37
- // Apply opacity
38
- if (node.opacity < 1) {
39
- ctx.globalAlpha = node.opacity;
161
+ if (cull && canCullSubtree(node, cull))
162
+ return;
163
+ const type = node.type.toLowerCase();
164
+ const customPainter = customPainters.get(type);
165
+ const needsTransform = hasTransformProps(node.props);
166
+ const needsSave = customPainter !== undefined ||
167
+ needsTransform ||
168
+ node.opacity < 1 ||
169
+ STATE_UNSAFE_TYPES.has(type);
170
+ if (needsSave) {
171
+ ctx.save();
172
+ // Apply transforms
173
+ if (needsTransform) {
174
+ applyTransforms(ctx, node);
175
+ }
176
+ // Apply opacity
177
+ if (node.opacity < 1) {
178
+ ctx.globalAlpha = node.opacity;
179
+ }
40
180
  }
41
181
  // Check for custom painter
42
- const customPainter = customPainters.get(node.type.toLowerCase());
43
182
  if (customPainter) {
44
183
  customPainter(ctx, node);
45
184
  ctx.restore();
46
185
  return;
47
186
  }
48
187
  // Default painting based on type
49
- switch (node.type.toLowerCase()) {
188
+ switch (type) {
50
189
  case "column":
51
190
  case "row":
52
191
  case "stack":
@@ -59,6 +198,7 @@ export function paintNode(ctx, node) {
59
198
  paintButton(ctx, node);
60
199
  break;
61
200
  case "input":
201
+ case "textarea":
62
202
  paintInput(ctx, node);
63
203
  break;
64
204
  case "image":
@@ -115,13 +255,25 @@ export function paintNode(ctx, node) {
115
255
  default:
116
256
  paintContainer(ctx, node);
117
257
  }
118
- ctx.restore();
258
+ if (needsSave) {
259
+ ctx.restore();
260
+ }
119
261
  // Apply scroll translation for scrollable containers
120
262
  const ss = node.scrollState;
263
+ let childCull = cull ?? null;
121
264
  if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
122
265
  ctx.save();
123
266
  ctx.translate(-ss.scrollX, -ss.scrollY);
124
267
  node._scrollRestore = true;
268
+ // Children paint in scrolled space — shift the cull region to match.
269
+ if (childCull) {
270
+ childCull = {
271
+ x: childCull.x + ss.scrollX,
272
+ y: childCull.y + ss.scrollY,
273
+ width: childCull.width,
274
+ height: childCull.height,
275
+ };
276
+ }
125
277
  }
126
278
  // Paint children -- use windowed rendering for large scrollable lists
127
279
  const shouldVirtualize = isScrollable(node) &&
@@ -141,18 +293,21 @@ export function paintNode(ctx, node) {
141
293
  // tree (Story uses this for its close button) gets covered by the
142
294
  // following in-flow Image. Push absolute kids to the end so they land
143
295
  // on top — same effect as `z-index: auto` painting absolute after flow.
144
- const flowKids = [];
145
- const overlayKids = [];
296
+ let hasOverlays = false;
146
297
  for (const child of childrenToPaint) {
147
- if (child.props.position === "absolute")
148
- overlayKids.push(child);
149
- else
150
- flowKids.push(child);
298
+ if (child.props.position === "absolute") {
299
+ hasOverlays = true;
300
+ continue;
301
+ }
302
+ paintNode(ctx, child, childCull);
303
+ }
304
+ if (hasOverlays) {
305
+ for (const child of childrenToPaint) {
306
+ if (child.props.position === "absolute") {
307
+ paintNode(ctx, child, childCull);
308
+ }
309
+ }
151
310
  }
152
- for (const child of flowKids)
153
- paintNode(ctx, child);
154
- for (const child of overlayKids)
155
- paintNode(ctx, child);
156
311
  // Undo scroll translation before drawing scrollbars
157
312
  if (node._scrollRestore) {
158
313
  ctx.restore();
@@ -185,15 +340,9 @@ function paintContainer(ctx, node) {
185
340
  applyShadow(ctx, shadow);
186
341
  }
187
342
  // Draw background
188
- const backgroundColor = props.backgroundColor || props.background;
189
- if (backgroundColor) {
190
- // Support for gradients
191
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
192
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
193
- }
194
- else {
195
- ctx.fillStyle = backgroundColor;
196
- }
343
+ const background = resolveBackgroundPaint(props);
344
+ if (background) {
345
+ ctx.fillStyle = resolveCanvasPaint(ctx, background, x, y, width, height);
197
346
  if (radius > 0) {
198
347
  drawRoundedRect(ctx, x, y, width, height, radius);
199
348
  ctx.fill();
@@ -247,14 +396,13 @@ function paintText(ctx, node) {
247
396
  }
248
397
  const layout = node.layout;
249
398
  const props = node.props;
250
- // `??` (not `||`) so a bound `0`, `false`, or `""` still renders.
251
- let text = String(props[0] ?? props.text ?? "");
399
+ let text = String(props[0] || props.text || "");
252
400
  const color = props.color || "#000000";
253
401
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
254
402
  const fontWeight = props.fontWeight || "normal";
255
403
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
256
404
  const textAlign = props.textAlign || "left";
257
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
405
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
258
406
  const textDecoration = props.textDecoration || "none";
259
407
  const textTransform = props.textTransform || "none";
260
408
  const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
@@ -279,13 +427,14 @@ function paintText(ctx, node) {
279
427
  if (letterSpacing !== 0) {
280
428
  // Manual letter spacing
281
429
  ctx.fillStyle = color;
282
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
430
+ const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
431
+ ctx.font = font;
283
432
  ctx.textAlign = "left";
284
433
  ctx.textBaseline = "top";
285
434
  let currentX = x;
286
435
  for (let i = 0; i < text.length; i++) {
287
436
  ctx.fillText(text[i], currentX, y);
288
- currentX += ctx.measureText(text[i]).width;
437
+ currentX += charAdvance(ctx, font, text[i]);
289
438
  // Add letter spacing after each character except the last
290
439
  if (i < text.length - 1) {
291
440
  currentX += letterSpacing;
@@ -315,11 +464,11 @@ function paintText(ctx, node) {
315
464
  verticalAlign: "top",
316
465
  lineHeight,
317
466
  }, maxLines, textOverflow);
318
- // Text decoration (need to set font again for measurement)
467
+ // Text decoration reuse the wrapped metrics renderText just resolved
468
+ // (cache hit) instead of re-shaping the raw, unwrapped string.
319
469
  if (textDecoration !== "none") {
320
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
321
- const textWidth = ctx.measureText(text).width;
322
- applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
470
+ const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, layout.contentWidth, maxLines, textOverflow);
471
+ applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
323
472
  }
324
473
  }
325
474
  // Reset shadow
@@ -340,7 +489,7 @@ function paintButton(ctx, node) {
340
489
  const y = layout.y;
341
490
  const width = layout.width;
342
491
  const height = layout.height;
343
- const radius = layout.border.radius;
492
+ const radius = layout.border.radius || 4;
344
493
  // Apply shadow if specified
345
494
  const shadow = props.shadow || props.boxShadow;
346
495
  if (shadow) {
@@ -359,16 +508,11 @@ function paintButton(ctx, node) {
359
508
  else if (node.focused && props.focusColor !== undefined) {
360
509
  backgroundColor = props.focusColor;
361
510
  }
362
- else if (props.backgroundColor !== undefined) {
363
- backgroundColor = props.backgroundColor;
511
+ else {
512
+ backgroundColor = resolveBackgroundPaint(props);
364
513
  }
365
514
  if (backgroundColor !== undefined) {
366
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
367
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
368
- }
369
- else {
370
- ctx.fillStyle = backgroundColor;
371
- }
515
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
372
516
  drawRoundedRect(ctx, x, y, width, height, radius);
373
517
  ctx.fill();
374
518
  }
@@ -386,6 +530,17 @@ function paintButton(ctx, node) {
386
530
  drawRoundedRect(ctx, x, y, width, height, radius);
387
531
  ctx.stroke();
388
532
  }
533
+ // Keyboard focus ring. Focus lives on the node's (unrendered) mirror
534
+ // element in the canvas fallback content, so the UA can't draw a ring at
535
+ // the painted box — paint one so keyboard users can see where they are.
536
+ if (node.focused) {
537
+ ctx.save();
538
+ ctx.strokeStyle = props.focusRingColor || "#007bff";
539
+ ctx.lineWidth = 2;
540
+ drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
541
+ ctx.stroke();
542
+ ctx.restore();
543
+ }
389
544
  // Paint children (typically Text)
390
545
  for (const child of node.children) {
391
546
  paintNode(ctx, child);
@@ -401,7 +556,7 @@ function paintInput(ctx, node) {
401
556
  const y = layout.y;
402
557
  const width = layout.width;
403
558
  const height = layout.height;
404
- const radius = layout.border.radius;
559
+ const radius = layout.border.radius || 4;
405
560
  // Background
406
561
  ctx.fillStyle = props.backgroundColor || "#ffffff";
407
562
  drawRoundedRect(ctx, x, y, width, height, radius);
@@ -413,26 +568,82 @@ function paintInput(ctx, node) {
413
568
  ctx.lineWidth = borderWidth;
414
569
  drawRoundedRect(ctx, x, y, width, height, radius);
415
570
  ctx.stroke();
416
- // Input value text
417
- const value = props.value || "";
571
+ const isSingleLine = node.type.toLowerCase() === "input";
572
+ // While editing, the mirror element (via TextEditController) is the value
573
+ // source — paint stays ahead of the engine's SetProp echo.
574
+ const edit = activeTextEditor?.getStateFor(node) ?? null;
575
+ const value = edit ? edit.value : (props.value || "");
418
576
  const placeholder = props.placeholder || "";
419
577
  const text = value || placeholder;
420
578
  const textColor = value ? (props.color || "#000000") : "#999999";
579
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
580
+ const fontWeight = props.fontWeight || "normal";
581
+ const fontFamily = props.fontFamily || "system-ui, sans-serif";
582
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
583
+ if (!edit) {
584
+ if (text) {
585
+ renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
586
+ color: textColor,
587
+ fontSize,
588
+ fontWeight,
589
+ fontFamily,
590
+ textAlign: "left",
591
+ verticalAlign: isSingleLine ? "middle" : "top",
592
+ lineHeight,
593
+ });
594
+ }
595
+ return;
596
+ }
597
+ // ---- Editing visuals: clip → selection highlight → text → composition
598
+ // ---- underline → caret. Geometry comes from the edit controller so
599
+ // ---- pointer mapping and painting share identical math (incl. scrollX).
600
+ ctx.save();
601
+ drawRoundedRect(ctx, x, y, width, height, radius);
602
+ ctx.clip();
603
+ const g = activeTextEditor.geometry(layout.x + layout.contentX - edit.scrollX, layout.y + layout.contentY);
604
+ // Selection highlight behind the text
605
+ if (edit.selStart !== edit.selEnd) {
606
+ const from = Math.min(edit.selStart, edit.selEnd);
607
+ const to = Math.max(edit.selStart, edit.selEnd);
608
+ ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
609
+ for (const r of rangeToRects(ctx, g, from, to)) {
610
+ ctx.fillRect(r.x, r.y, r.width, r.height);
611
+ }
612
+ }
421
613
  if (text) {
422
- const fontSize = cssLengthToPx(props.fontSize) ?? 16;
423
- const fontWeight = props.fontWeight || "normal";
424
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
425
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
426
- renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
614
+ renderText(ctx, value || placeholder, g.contentX, layout.y + layout.contentY,
615
+ // Single-line inputs never wrap — the clip + scrollX pan handle
616
+ // overflow. Textareas wrap at content width like static paint.
617
+ isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth, layout.contentHeight, {
427
618
  color: textColor,
428
619
  fontSize,
429
620
  fontWeight,
430
621
  fontFamily,
431
622
  textAlign: "left",
432
- verticalAlign: "middle",
623
+ verticalAlign: isSingleLine ? "middle" : "top",
433
624
  lineHeight,
434
625
  });
435
626
  }
627
+ // IME composition underline (dashed, under the composing range)
628
+ if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
629
+ ctx.strokeStyle = props.color || "#000000";
630
+ ctx.lineWidth = 1;
631
+ ctx.setLineDash([2, 2]);
632
+ for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
633
+ ctx.beginPath();
634
+ ctx.moveTo(r.x, r.y + r.height - 2);
635
+ ctx.lineTo(r.x + r.width, r.y + r.height - 2);
636
+ ctx.stroke();
637
+ }
638
+ ctx.setLineDash([]);
639
+ }
640
+ // Caret (hidden while a range is selected, blinks via the edit timer)
641
+ if (edit.caretVisible && edit.selStart === edit.selEnd) {
642
+ const caret = offsetToCaretRect(ctx, g, edit.selEnd);
643
+ ctx.fillStyle = props.color || "#000000";
644
+ ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
645
+ }
646
+ ctx.restore();
436
647
  }
437
648
  /**
438
649
  * Image cache for canvas renderer
@@ -584,8 +795,10 @@ function paintImage(ctx, node) {
584
795
  imagePending.delete(src);
585
796
  imageFailCache.delete(src);
586
797
  evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
587
- // Request a repaint so the loaded image is drawn
588
- canvas.dispatchEvent(new CustomEvent("hypen:redraw"));
798
+ // Request a repaint so the loaded image is drawn. `layout: true`
799
+ // because the decoded intrinsic size can change the image's box
800
+ // (see getImageNaturalAspect in layout.ts).
801
+ canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
589
802
  };
590
803
  img.onerror = () => {
591
804
  imageInFlight.delete(src);
@@ -636,14 +849,6 @@ function drawImageCover(ctx, img, dx, dy, dw, dh) {
636
849
  */
637
850
  function drawRoundedRect(ctx, x, y, width, height, radius) {
638
851
  if (radius <= 0 || width <= 0 || height <= 0) {
639
- // beginPath is critical here — without it, this rect appends to the
640
- // existing path and the next `ctx.fill()` re-fills every previously
641
- // queued rect with the current fillStyle. That manifested as a
642
- // calculator grid where every button painted in the last button's
643
- // colour and only the last button's child text survived (each
644
- // subsequent fill repainted over earlier children). Mirrors the
645
- // beginPath() the rounded branch already does below.
646
- ctx.beginPath();
647
852
  ctx.rect(x, y, width, height);
648
853
  return;
649
854
  }
@@ -690,6 +895,53 @@ function applyShadow(ctx, shadow) {
690
895
  ctx.shadowColor = shadow.color || "rgba(0,0,0,0.3)";
691
896
  }
692
897
  }
898
+ /**
899
+ * Resolve DOM background applicator props into the CSS-like paint strings
900
+ * Canvas understands. DOM applicators map `.linearGradient("...")` to
901
+ * `background-image: linear-gradient(...)`; Canvas receives the original prop
902
+ * and must do the same translation before painting.
903
+ */
904
+ function resolveBackgroundPaint(props) {
905
+ if (props.linearGradient !== undefined) {
906
+ return `linear-gradient(${props.linearGradient})`;
907
+ }
908
+ if (props.radialGradient !== undefined) {
909
+ return `radial-gradient(${props.radialGradient})`;
910
+ }
911
+ if (props.conicGradient !== undefined) {
912
+ return `conic-gradient(${props.conicGradient})`;
913
+ }
914
+ if (props.backgroundImage !== undefined) {
915
+ return String(props.backgroundImage);
916
+ }
917
+ if (props.backgroundColor !== undefined) {
918
+ return String(props.backgroundColor);
919
+ }
920
+ if (props.background !== undefined) {
921
+ return String(props.background);
922
+ }
923
+ return undefined;
924
+ }
925
+ /**
926
+ * Parsed gradients keyed by (source string, box geometry). CanvasGradient
927
+ * objects are not bound to a specific context, so identical fills across
928
+ * frames — the common case, since layout geometry is stable — skip the
929
+ * regex parse and createLinearGradient/createRadialGradient entirely.
930
+ */
931
+ const gradientCache = new Map();
932
+ const MAX_GRADIENT_CACHE_SIZE = 256;
933
+ function resolveCanvasPaint(ctx, paint, x, y, width, height) {
934
+ if (!paint.includes("gradient("))
935
+ return paint;
936
+ const key = `${paint}|${x}|${y}|${width}|${height}`;
937
+ let resolved = gradientCache.get(key);
938
+ if (resolved === undefined) {
939
+ resolved = parseGradient(ctx, paint, x, y, width, height);
940
+ gradientCache.set(key, resolved);
941
+ evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
942
+ }
943
+ return resolved;
944
+ }
693
945
  /**
694
946
  * Parse gradient string and create canvas gradient
695
947
  * Supports:
@@ -703,36 +955,25 @@ function parseGradient(ctx, gradientStr, x, y, width, height) {
703
955
  const match = gradientStr.match(/linear-gradient\((.*)\)/);
704
956
  if (!match)
705
957
  return gradientStr;
706
- const parts = match[1].split(",").map((s) => s.trim());
958
+ const parts = splitCssArgs(match[1]);
707
959
  // Determine direction (default to bottom)
708
960
  let x0 = x, y0 = y, x1 = x, y1 = y + height;
709
961
  let colorStart = 0;
710
962
  if (parts[0].includes("deg") || parts[0].includes("to ")) {
711
963
  colorStart = 1;
712
964
  const direction = parts[0];
713
- if (direction.includes("to right") || direction === "90deg") {
714
- x1 = x + width;
715
- y1 = y;
716
- }
717
- else if (direction.includes("to left") || direction === "270deg") {
718
- x0 = x + width;
719
- x1 = x;
720
- y0 = y;
721
- y1 = y;
722
- }
723
- else if (direction.includes("to top") || direction === "0deg") {
724
- y0 = y + height;
725
- y1 = y;
965
+ const angle = parseCssGradientAngle(direction);
966
+ if (angle !== null) {
967
+ const line = gradientLineForAngle(angle, x, y, width, height);
968
+ x0 = line.x0;
969
+ y0 = line.y0;
970
+ x1 = line.x1;
971
+ y1 = line.y1;
726
972
  }
727
- // Default is "to bottom" which we already set
728
973
  }
729
974
  const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
730
975
  // Add color stops
731
- const colors = parts.slice(colorStart);
732
- colors.forEach((color, i) => {
733
- const stop = i / (colors.length - 1);
734
- gradient.addColorStop(stop, color.trim());
735
- });
976
+ addColorStops(gradient, parts.slice(colorStart));
736
977
  return gradient;
737
978
  }
738
979
  else if (gradientStr.startsWith("radial-gradient")) {
@@ -740,21 +981,125 @@ function parseGradient(ctx, gradientStr, x, y, width, height) {
740
981
  const match = gradientStr.match(/radial-gradient\((.*)\)/);
741
982
  if (!match)
742
983
  return gradientStr;
743
- const parts = match[1].split(",").map((s) => s.trim());
984
+ const parts = splitCssArgs(match[1]);
744
985
  // Create radial gradient from center
745
986
  const centerX = x + width / 2;
746
987
  const centerY = y + height / 2;
747
988
  const radius = Math.max(width, height) / 2;
748
989
  const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
749
990
  // Add color stops
750
- parts.forEach((color, i) => {
751
- const stop = i / (parts.length - 1);
752
- gradient.addColorStop(stop, color.trim());
753
- });
991
+ addColorStops(gradient, parts);
992
+ return gradient;
993
+ }
994
+ else if (gradientStr.startsWith("conic-gradient")) {
995
+ const match = gradientStr.match(/conic-gradient\((.*)\)/);
996
+ if (!match)
997
+ return gradientStr;
998
+ const createConicGradient = ctx.createConicGradient;
999
+ if (!createConicGradient)
1000
+ return firstGradientColor(match[1]) ?? gradientStr;
1001
+ const centerX = x + width / 2;
1002
+ const centerY = y + height / 2;
1003
+ const gradient = createConicGradient.call(ctx, 0, centerX, centerY);
1004
+ addColorStops(gradient, splitCssArgs(match[1]));
754
1005
  return gradient;
755
1006
  }
756
1007
  return gradientStr;
757
1008
  }
1009
+ function splitCssArgs(input) {
1010
+ const parts = [];
1011
+ let depth = 0;
1012
+ let start = 0;
1013
+ for (let i = 0; i < input.length; i++) {
1014
+ const ch = input[i];
1015
+ if (ch === "(")
1016
+ depth++;
1017
+ else if (ch === ")")
1018
+ depth = Math.max(0, depth - 1);
1019
+ else if (ch === "," && depth === 0) {
1020
+ const part = input.slice(start, i).trim();
1021
+ if (part)
1022
+ parts.push(part);
1023
+ start = i + 1;
1024
+ }
1025
+ }
1026
+ const tail = input.slice(start).trim();
1027
+ if (tail)
1028
+ parts.push(tail);
1029
+ return parts;
1030
+ }
1031
+ function parseCssGradientAngle(direction) {
1032
+ const trimmed = direction.trim().toLowerCase();
1033
+ const deg = trimmed.match(/^(-?(?:\d+|\d*\.\d+))deg$/);
1034
+ if (deg)
1035
+ return Number(deg[1]);
1036
+ if (!trimmed.startsWith("to "))
1037
+ return null;
1038
+ const words = new Set(trimmed.slice(3).split(/\s+/).filter(Boolean));
1039
+ if (words.has("top") && words.has("right"))
1040
+ return 45;
1041
+ if (words.has("right") && words.has("bottom"))
1042
+ return 135;
1043
+ if (words.has("bottom") && words.has("left"))
1044
+ return 225;
1045
+ if (words.has("left") && words.has("top"))
1046
+ return 315;
1047
+ if (words.has("top"))
1048
+ return 0;
1049
+ if (words.has("right"))
1050
+ return 90;
1051
+ if (words.has("bottom"))
1052
+ return 180;
1053
+ if (words.has("left"))
1054
+ return 270;
1055
+ return null;
1056
+ }
1057
+ function gradientLineForAngle(cssDegrees, x, y, width, height) {
1058
+ const radians = (cssDegrees * Math.PI) / 180;
1059
+ const cx = x + width / 2;
1060
+ const cy = y + height / 2;
1061
+ const half = Math.sqrt(width * width + height * height) / 2;
1062
+ const dx = Math.sin(radians) * half;
1063
+ const dy = -Math.cos(radians) * half;
1064
+ return {
1065
+ x0: cx - dx,
1066
+ y0: cy - dy,
1067
+ x1: cx + dx,
1068
+ y1: cy + dy,
1069
+ };
1070
+ }
1071
+ function parseColorStop(raw, fallbackStop) {
1072
+ const trimmed = raw.trim();
1073
+ const match = trimmed.match(/^(.*\S)\s+(-?(?:\d+|\d*\.\d+)%)$/);
1074
+ if (!match)
1075
+ return { color: trimmed, stop: fallbackStop };
1076
+ return {
1077
+ color: match[1].trim(),
1078
+ stop: Math.max(0, Math.min(1, parseFloat(match[2]) / 100)),
1079
+ };
1080
+ }
1081
+ function addColorStops(gradient, rawStops) {
1082
+ const stops = rawStops.filter(Boolean);
1083
+ if (stops.length === 0)
1084
+ return;
1085
+ if (stops.length === 1) {
1086
+ const parsed = parseColorStop(stops[0], 0);
1087
+ gradient.addColorStop(0, parsed.color);
1088
+ gradient.addColorStop(1, parsed.color);
1089
+ return;
1090
+ }
1091
+ stops.forEach((raw, i) => {
1092
+ const fallback = i / (stops.length - 1);
1093
+ const { color, stop } = parseColorStop(raw, fallback);
1094
+ gradient.addColorStop(stop, color);
1095
+ });
1096
+ }
1097
+ function firstGradientColor(input) {
1098
+ const first = splitCssArgs(input)[0];
1099
+ if (!first)
1100
+ return undefined;
1101
+ return parseColorStop(first, 0).color;
1102
+ }
758
1103
  /**
759
1104
  * Apply CSS-like transforms to canvas context
760
1105
  * Supports:
@@ -997,7 +1342,7 @@ function paintSlider(ctx, node) {
997
1342
  const fillColor = props.fillColor || props.color || "#007bff";
998
1343
  // Support gradient fills
999
1344
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1000
- ctx.fillStyle = parseGradient(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1345
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1001
1346
  }
1002
1347
  else {
1003
1348
  ctx.fillStyle = fillColor;
@@ -1043,7 +1388,7 @@ function paintProgress(ctx, node) {
1043
1388
  // Support gradient fills
1044
1389
  const fillColor = props.fillColor || props.color || "#007bff";
1045
1390
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1046
- ctx.fillStyle = parseGradient(ctx, fillColor, x, y, fillWidth, height);
1391
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
1047
1392
  }
1048
1393
  else {
1049
1394
  ctx.fillStyle = fillColor;
@@ -1099,18 +1444,13 @@ function paintCard(ctx, node) {
1099
1444
  const y = layout.y;
1100
1445
  const width = layout.width;
1101
1446
  const height = layout.height;
1102
- const radius = layout.border.radius;
1447
+ const radius = layout.border.radius || 8;
1103
1448
  // Default card shadow
1104
1449
  const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
1105
1450
  applyShadow(ctx, shadow);
1106
1451
  // Background
1107
- const backgroundColor = props.backgroundColor || "#ffffff";
1108
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
1109
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
1110
- }
1111
- else {
1112
- ctx.fillStyle = backgroundColor;
1113
- }
1452
+ const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
1453
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
1114
1454
  drawRoundedRect(ctx, x, y, width, height, radius);
1115
1455
  ctx.fill();
1116
1456
  // Reset shadow
@@ -1146,8 +1486,8 @@ function paintBadge(ctx, node) {
1146
1486
  ctx.fillStyle = backgroundColor;
1147
1487
  drawRoundedRect(ctx, x, y, width, height, radius);
1148
1488
  ctx.fill();
1149
- // `??` so a literal `0`/`false`/`""` value still renders.
1150
- const text = String(props[0] ?? props.text ?? "");
1489
+ // Text content
1490
+ const text = String(props[0] || props.text || "");
1151
1491
  if (text) {
1152
1492
  ctx.fillStyle = props.color || "#ffffff";
1153
1493
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
@@ -1177,8 +1517,8 @@ function paintAvatar(ctx, node) {
1177
1517
  ctx.beginPath();
1178
1518
  ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1179
1519
  ctx.fill();
1180
- // `??` so a literal `0`/`false`/`""` value still renders.
1181
- const text = String(props[0] ?? props.text ?? props.initials ?? "");
1520
+ // Text initials if provided
1521
+ const text = String(props[0] || props.text || props.initials || "");
1182
1522
  if (text) {
1183
1523
  ctx.fillStyle = props.color || "#ffffff";
1184
1524
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
@@ -1293,8 +1633,7 @@ function paintIcon(ctx, node) {
1293
1633
  function paintLink(ctx, node) {
1294
1634
  const layout = node.layout;
1295
1635
  const props = node.props;
1296
- // `??` so a literal `0`/`false`/`""` value still renders.
1297
- const text = String(props[0] ?? props.text ?? "");
1636
+ const text = String(props[0] || props.text || "");
1298
1637
  const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
1299
1638
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1300
1639
  const fontWeight = props.fontWeight || "normal";