@hypen-space/web 0.5.2 → 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 (104) hide show
  1. package/dist/canvas/accessibility.d.ts +118 -11
  2. package/dist/canvas/accessibility.js +470 -108
  3. package/dist/canvas/accessibility.js.map +1 -1
  4. package/dist/canvas/dispatch.d.ts +24 -0
  5. package/dist/canvas/dispatch.js +62 -0
  6. package/dist/canvas/dispatch.js.map +1 -0
  7. package/dist/canvas/editing.d.ts +178 -0
  8. package/dist/canvas/editing.js +590 -0
  9. package/dist/canvas/editing.js.map +1 -0
  10. package/dist/canvas/events.d.ts +28 -23
  11. package/dist/canvas/events.js +76 -128
  12. package/dist/canvas/events.js.map +1 -1
  13. package/dist/canvas/focus.d.ts +78 -0
  14. package/dist/canvas/focus.js +182 -0
  15. package/dist/canvas/focus.js.map +1 -0
  16. package/dist/canvas/index.d.ts +3 -1
  17. package/dist/canvas/index.js +3 -1
  18. package/dist/canvas/index.js.map +1 -1
  19. package/dist/canvas/paint.d.ts +16 -3
  20. package/dist/canvas/paint.js +283 -41
  21. package/dist/canvas/paint.js.map +1 -1
  22. package/dist/canvas/renderer.d.ts +20 -1
  23. package/dist/canvas/renderer.js +181 -50
  24. package/dist/canvas/renderer.js.map +1 -1
  25. package/dist/canvas/selection.js +31 -177
  26. package/dist/canvas/selection.js.map +1 -1
  27. package/dist/canvas/text-geometry.d.ts +72 -0
  28. package/dist/canvas/text-geometry.js +244 -0
  29. package/dist/canvas/text-geometry.js.map +1 -0
  30. package/dist/canvas/text.js +42 -9
  31. package/dist/canvas/text.js.map +1 -1
  32. package/dist/canvas/types.d.ts +7 -1
  33. package/dist/canvas/variants.d.ts +4 -1
  34. package/dist/canvas/variants.js +22 -3
  35. package/dist/canvas/variants.js.map +1 -1
  36. package/dist/dom/a11y-styles.d.ts +20 -0
  37. package/dist/dom/a11y-styles.js +61 -0
  38. package/dist/dom/a11y-styles.js.map +1 -0
  39. package/dist/dom/applicators/aria.d.ts +19 -0
  40. package/dist/dom/applicators/aria.js +36 -0
  41. package/dist/dom/applicators/aria.js.map +1 -0
  42. package/dist/dom/applicators/events.d.ts +7 -0
  43. package/dist/dom/applicators/events.js +35 -8
  44. package/dist/dom/applicators/events.js.map +1 -1
  45. package/dist/dom/applicators/index.js +4 -0
  46. package/dist/dom/applicators/index.js.map +1 -1
  47. package/dist/dom/components/hypenapp.d.ts +25 -1
  48. package/dist/dom/components/hypenapp.js +213 -245
  49. package/dist/dom/components/hypenapp.js.map +1 -1
  50. package/dist/dom/components/index.js +7 -0
  51. package/dist/dom/components/index.js.map +1 -1
  52. package/dist/dom/components/spinner.js +24 -10
  53. package/dist/dom/components/spinner.js.map +1 -1
  54. package/dist/dom/components/tabs.d.ts +27 -0
  55. package/dist/dom/components/tabs.js +69 -0
  56. package/dist/dom/components/tabs.js.map +1 -0
  57. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  58. package/dist/dom/components/visuallyhidden.js +26 -0
  59. package/dist/dom/components/visuallyhidden.js.map +1 -0
  60. package/dist/dom/operability.d.ts +100 -0
  61. package/dist/dom/operability.js +298 -0
  62. package/dist/dom/operability.js.map +1 -0
  63. package/dist/dom/renderer.d.ts +98 -5
  64. package/dist/dom/renderer.js +398 -63
  65. package/dist/dom/renderer.js.map +1 -1
  66. package/dist/dom/route-focus.d.ts +42 -0
  67. package/dist/dom/route-focus.js +88 -0
  68. package/dist/dom/route-focus.js.map +1 -0
  69. package/dist/dom/semantics.d.ts +35 -0
  70. package/dist/dom/semantics.js +184 -0
  71. package/dist/dom/semantics.js.map +1 -0
  72. package/package.json +2 -2
  73. package/src/canvas/QUICKSTART.md +4 -4
  74. package/src/canvas/README.md +55 -29
  75. package/src/canvas/accessibility.ts +507 -115
  76. package/src/canvas/dispatch.ts +78 -0
  77. package/src/canvas/editing.ts +697 -0
  78. package/src/canvas/events.ts +89 -142
  79. package/src/canvas/focus.ts +216 -0
  80. package/src/canvas/index.ts +8 -1
  81. package/src/canvas/paint.ts +339 -42
  82. package/src/canvas/renderer.ts +218 -70
  83. package/src/canvas/selection.ts +52 -210
  84. package/src/canvas/text-geometry.ts +311 -0
  85. package/src/canvas/text.ts +43 -9
  86. package/src/canvas/types.ts +14 -1
  87. package/src/canvas/variants.ts +24 -5
  88. package/src/dom/a11y-styles.ts +65 -0
  89. package/src/dom/applicators/aria.ts +41 -0
  90. package/src/dom/applicators/events.ts +38 -7
  91. package/src/dom/applicators/index.ts +5 -0
  92. package/src/dom/components/hypenapp.ts +244 -272
  93. package/src/dom/components/index.ts +7 -0
  94. package/src/dom/components/spinner.ts +31 -13
  95. package/src/dom/components/tabs.ts +76 -0
  96. package/src/dom/components/visuallyhidden.ts +30 -0
  97. package/src/dom/operability.ts +312 -0
  98. package/src/dom/renderer.ts +455 -63
  99. package/src/dom/route-focus.ts +98 -0
  100. package/src/dom/semantics.ts +199 -0
  101. package/dist/canvas/input.d.ts +0 -76
  102. package/dist/canvas/input.js +0 -207
  103. package/dist/canvas/input.js.map +0 -1
  104. package/src/canvas/input.ts +0 -251
@@ -3,9 +3,10 @@
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 { SELECTION_HIGHLIGHT_COLOR, offsetToCaretRect, rangeToRects, } from "./text-geometry.js";
9
10
  import { cssLengthToPx } from "./utils.js";
10
11
  /**
11
12
  * Module-level reference to the active SelectionManager so paintText
@@ -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.
30
132
  */
31
- export function paintNode(ctx, node) {
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.
157
+ */
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();
@@ -272,13 +427,14 @@ function paintText(ctx, node) {
272
427
  if (letterSpacing !== 0) {
273
428
  // Manual letter spacing
274
429
  ctx.fillStyle = color;
275
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
430
+ const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
431
+ ctx.font = font;
276
432
  ctx.textAlign = "left";
277
433
  ctx.textBaseline = "top";
278
434
  let currentX = x;
279
435
  for (let i = 0; i < text.length; i++) {
280
436
  ctx.fillText(text[i], currentX, y);
281
- currentX += ctx.measureText(text[i]).width;
437
+ currentX += charAdvance(ctx, font, text[i]);
282
438
  // Add letter spacing after each character except the last
283
439
  if (i < text.length - 1) {
284
440
  currentX += letterSpacing;
@@ -308,11 +464,11 @@ function paintText(ctx, node) {
308
464
  verticalAlign: "top",
309
465
  lineHeight,
310
466
  }, maxLines, textOverflow);
311
- // 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.
312
469
  if (textDecoration !== "none") {
313
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
314
- const textWidth = ctx.measureText(text).width;
315
- 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);
316
472
  }
317
473
  }
318
474
  // Reset shadow
@@ -374,6 +530,17 @@ function paintButton(ctx, node) {
374
530
  drawRoundedRect(ctx, x, y, width, height, radius);
375
531
  ctx.stroke();
376
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
+ }
377
544
  // Paint children (typically Text)
378
545
  for (const child of node.children) {
379
546
  paintNode(ctx, child);
@@ -401,26 +568,82 @@ function paintInput(ctx, node) {
401
568
  ctx.lineWidth = borderWidth;
402
569
  drawRoundedRect(ctx, x, y, width, height, radius);
403
570
  ctx.stroke();
404
- // Input value text
405
- 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 || "");
406
576
  const placeholder = props.placeholder || "";
407
577
  const text = value || placeholder;
408
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
+ }
409
613
  if (text) {
410
- const fontSize = cssLengthToPx(props.fontSize) ?? 16;
411
- const fontWeight = props.fontWeight || "normal";
412
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
413
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
414
- 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, {
415
618
  color: textColor,
416
619
  fontSize,
417
620
  fontWeight,
418
621
  fontFamily,
419
622
  textAlign: "left",
420
- verticalAlign: "middle",
623
+ verticalAlign: isSingleLine ? "middle" : "top",
421
624
  lineHeight,
422
625
  });
423
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();
424
647
  }
425
648
  /**
426
649
  * Image cache for canvas renderer
@@ -572,8 +795,10 @@ function paintImage(ctx, node) {
572
795
  imagePending.delete(src);
573
796
  imageFailCache.delete(src);
574
797
  evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
575
- // Request a repaint so the loaded image is drawn
576
- 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 } }));
577
802
  };
578
803
  img.onerror = () => {
579
804
  imageInFlight.delete(src);
@@ -697,8 +922,25 @@ function resolveBackgroundPaint(props) {
697
922
  }
698
923
  return undefined;
699
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;
700
933
  function resolveCanvasPaint(ctx, paint, x, y, width, height) {
701
- return paint.includes("gradient(") ? parseGradient(ctx, paint, x, y, width, height) : paint;
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;
702
944
  }
703
945
  /**
704
946
  * Parse gradient string and create canvas gradient
@@ -1100,7 +1342,7 @@ function paintSlider(ctx, node) {
1100
1342
  const fillColor = props.fillColor || props.color || "#007bff";
1101
1343
  // Support gradient fills
1102
1344
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1103
- ctx.fillStyle = parseGradient(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1345
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1104
1346
  }
1105
1347
  else {
1106
1348
  ctx.fillStyle = fillColor;
@@ -1146,7 +1388,7 @@ function paintProgress(ctx, node) {
1146
1388
  // Support gradient fills
1147
1389
  const fillColor = props.fillColor || props.color || "#007bff";
1148
1390
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1149
- ctx.fillStyle = parseGradient(ctx, fillColor, x, y, fillWidth, height);
1391
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
1150
1392
  }
1151
1393
  else {
1152
1394
  ctx.fillStyle = fillColor;