@hypen-space/web 0.4.980 → 0.5.2

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 (169) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/events.js +46 -7
  3. package/dist/canvas/events.js.map +1 -1
  4. package/dist/canvas/index.d.ts +0 -1
  5. package/dist/canvas/index.js +0 -1
  6. package/dist/canvas/index.js.map +1 -1
  7. package/dist/canvas/layout.js +68 -98
  8. package/dist/canvas/layout.js.map +1 -1
  9. package/dist/canvas/paint.js +168 -71
  10. package/dist/canvas/paint.js.map +1 -1
  11. package/dist/canvas/renderer.d.ts +8 -3
  12. package/dist/canvas/renderer.js +32 -43
  13. package/dist/canvas/renderer.js.map +1 -1
  14. package/dist/canvas/text.js +2 -9
  15. package/dist/canvas/text.js.map +1 -1
  16. package/dist/canvas/types.d.ts +3 -0
  17. package/dist/canvas/utils.d.ts +0 -23
  18. package/dist/canvas/utils.js +3 -67
  19. package/dist/canvas/utils.js.map +1 -1
  20. package/dist/canvas/variants.d.ts +49 -0
  21. package/dist/canvas/variants.js +137 -0
  22. package/dist/canvas/variants.js.map +1 -0
  23. package/dist/dom/applicators/events.js +1 -0
  24. package/dist/dom/applicators/events.js.map +1 -1
  25. package/dist/dom/applicators/font.d.ts +11 -6
  26. package/dist/dom/applicators/font.js +16 -27
  27. package/dist/dom/applicators/font.js.map +1 -1
  28. package/dist/dom/applicators/index.js +47 -45
  29. package/dist/dom/applicators/index.js.map +1 -1
  30. package/dist/dom/canvas/index.js +2 -2
  31. package/dist/dom/canvas/index.js.map +1 -1
  32. package/dist/dom/components/app.js +2 -2
  33. package/dist/dom/components/app.js.map +1 -1
  34. package/dist/dom/components/audio.js +2 -2
  35. package/dist/dom/components/audio.js.map +1 -1
  36. package/dist/dom/components/avatar.js +3 -4
  37. package/dist/dom/components/avatar.js.map +1 -1
  38. package/dist/dom/components/badge.js +3 -4
  39. package/dist/dom/components/badge.js.map +1 -1
  40. package/dist/dom/components/button.js +2 -2
  41. package/dist/dom/components/button.js.map +1 -1
  42. package/dist/dom/components/card.js +2 -2
  43. package/dist/dom/components/card.js.map +1 -1
  44. package/dist/dom/components/center.js +2 -2
  45. package/dist/dom/components/center.js.map +1 -1
  46. package/dist/dom/components/checkbox.js +4 -5
  47. package/dist/dom/components/checkbox.js.map +1 -1
  48. package/dist/dom/components/column.js +2 -2
  49. package/dist/dom/components/column.js.map +1 -1
  50. package/dist/dom/components/container.js +2 -2
  51. package/dist/dom/components/container.js.map +1 -1
  52. package/dist/dom/components/divider.js +2 -2
  53. package/dist/dom/components/divider.js.map +1 -1
  54. package/dist/dom/components/grid.js +10 -10
  55. package/dist/dom/components/grid.js.map +1 -1
  56. package/dist/dom/components/heading.js +4 -6
  57. package/dist/dom/components/heading.js.map +1 -1
  58. package/dist/dom/components/hypenapp.js +2 -2
  59. package/dist/dom/components/hypenapp.js.map +1 -1
  60. package/dist/dom/components/icon.js +4 -5
  61. package/dist/dom/components/icon.js.map +1 -1
  62. package/dist/dom/components/image.js +2 -2
  63. package/dist/dom/components/image.js.map +1 -1
  64. package/dist/dom/components/index.d.ts +2 -2
  65. package/dist/dom/components/index.js +2 -2
  66. package/dist/dom/components/index.js.map +1 -1
  67. package/dist/dom/components/input.js +2 -2
  68. package/dist/dom/components/input.js.map +1 -1
  69. package/dist/dom/components/link.js +2 -2
  70. package/dist/dom/components/link.js.map +1 -1
  71. package/dist/dom/components/list.js +2 -2
  72. package/dist/dom/components/list.js.map +1 -1
  73. package/dist/dom/components/paragraph.js +3 -4
  74. package/dist/dom/components/paragraph.js.map +1 -1
  75. package/dist/dom/components/progressbar.js +3 -3
  76. package/dist/dom/components/progressbar.js.map +1 -1
  77. package/dist/dom/components/route.js +2 -2
  78. package/dist/dom/components/route.js.map +1 -1
  79. package/dist/dom/components/router.js +2 -2
  80. package/dist/dom/components/router.js.map +1 -1
  81. package/dist/dom/components/row.js +10 -10
  82. package/dist/dom/components/row.js.map +1 -1
  83. package/dist/dom/components/select.js +3 -4
  84. package/dist/dom/components/select.js.map +1 -1
  85. package/dist/dom/components/slider.js +2 -2
  86. package/dist/dom/components/slider.js.map +1 -1
  87. package/dist/dom/components/spacer.js +2 -2
  88. package/dist/dom/components/spacer.js.map +1 -1
  89. package/dist/dom/components/spinner.js +4 -4
  90. package/dist/dom/components/spinner.js.map +1 -1
  91. package/dist/dom/components/stack.js +10 -10
  92. package/dist/dom/components/stack.js.map +1 -1
  93. package/dist/dom/components/switch.js +5 -6
  94. package/dist/dom/components/switch.js.map +1 -1
  95. package/dist/dom/components/text.js +4 -7
  96. package/dist/dom/components/text.js.map +1 -1
  97. package/dist/dom/components/textarea.js +2 -2
  98. package/dist/dom/components/textarea.js.map +1 -1
  99. package/dist/dom/components/video.js +2 -2
  100. package/dist/dom/components/video.js.map +1 -1
  101. package/dist/dom/debug.js +6 -8
  102. package/dist/dom/debug.js.map +1 -1
  103. package/dist/dom/index.d.ts +0 -1
  104. package/dist/dom/index.js +0 -1
  105. package/dist/dom/index.js.map +1 -1
  106. package/dist/dom/renderer.js +2 -3
  107. package/dist/dom/renderer.js.map +1 -1
  108. package/dist/variants.d.ts +90 -0
  109. package/dist/variants.js +206 -0
  110. package/dist/variants.js.map +1 -0
  111. package/package.json +2 -2
  112. package/src/canvas/QUICKSTART.md +12 -7
  113. package/src/canvas/README.md +8 -5
  114. package/src/canvas/events.ts +49 -7
  115. package/src/canvas/index.ts +0 -8
  116. package/src/canvas/layout.ts +68 -103
  117. package/src/canvas/paint.ts +172 -69
  118. package/src/canvas/renderer.ts +38 -46
  119. package/src/canvas/text.ts +2 -9
  120. package/src/canvas/types.ts +14 -0
  121. package/src/canvas/utils.ts +3 -69
  122. package/src/canvas/variants.ts +153 -0
  123. package/src/dom/README.md +8 -7
  124. package/src/dom/applicators/events.ts +1 -0
  125. package/src/dom/applicators/font.ts +16 -29
  126. package/src/dom/applicators/index.ts +56 -46
  127. package/src/dom/canvas/index.ts +2 -2
  128. package/src/dom/components/app.ts +2 -2
  129. package/src/dom/components/audio.ts +2 -2
  130. package/src/dom/components/avatar.ts +3 -4
  131. package/src/dom/components/badge.ts +3 -4
  132. package/src/dom/components/button.ts +2 -2
  133. package/src/dom/components/card.ts +2 -2
  134. package/src/dom/components/center.ts +2 -2
  135. package/src/dom/components/checkbox.ts +4 -5
  136. package/src/dom/components/column.ts +2 -2
  137. package/src/dom/components/container.ts +2 -2
  138. package/src/dom/components/divider.ts +2 -2
  139. package/src/dom/components/grid.ts +10 -10
  140. package/src/dom/components/heading.ts +4 -6
  141. package/src/dom/components/hypenapp.ts +2 -2
  142. package/src/dom/components/icon.ts +4 -5
  143. package/src/dom/components/image.ts +2 -2
  144. package/src/dom/components/index.ts +3 -3
  145. package/src/dom/components/input.ts +2 -2
  146. package/src/dom/components/link.ts +2 -2
  147. package/src/dom/components/list.ts +2 -2
  148. package/src/dom/components/paragraph.ts +3 -4
  149. package/src/dom/components/progressbar.ts +3 -3
  150. package/src/dom/components/route.ts +2 -2
  151. package/src/dom/components/router.ts +2 -2
  152. package/src/dom/components/row.ts +10 -10
  153. package/src/dom/components/select.ts +3 -4
  154. package/src/dom/components/slider.ts +2 -2
  155. package/src/dom/components/spacer.ts +2 -2
  156. package/src/dom/components/spinner.ts +4 -4
  157. package/src/dom/components/stack.ts +10 -10
  158. package/src/dom/components/switch.ts +5 -6
  159. package/src/dom/components/text.ts +4 -7
  160. package/src/dom/components/textarea.ts +2 -2
  161. package/src/dom/components/video.ts +2 -2
  162. package/src/dom/debug.ts +6 -8
  163. package/src/dom/index.ts +0 -8
  164. package/src/dom/renderer.ts +2 -3
  165. package/src/variants.ts +249 -0
  166. package/dist/client.d.ts +0 -36
  167. package/dist/client.js +0 -47
  168. package/dist/client.js.map +0 -1
  169. package/src/client.ts +0 -88
@@ -9,7 +9,7 @@ import { 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 { cssLengthToPx } from "./utils.js";
13
13
 
14
14
  /**
15
15
  * Module-level reference to the active SelectionManager so paintText
@@ -205,14 +205,9 @@ function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void
205
205
  }
206
206
 
207
207
  // 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
- }
208
+ const background = resolveBackgroundPaint(props);
209
+ if (background) {
210
+ ctx.fillStyle = resolveCanvasPaint(ctx, background, x, y, width, height);
216
211
 
217
212
  if (radius > 0) {
218
213
  drawRoundedRect(ctx, x, y, width, height, radius);
@@ -271,14 +266,13 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
271
266
  const layout = node.layout!;
272
267
  const props = node.props;
273
268
 
274
- // `??` (not `||`) so a bound `0`, `false`, or `""` still renders.
275
- let text = String(props[0] ?? props.text ?? "");
269
+ let text = String(props[0] || props.text || "");
276
270
  const color = props.color || "#000000";
277
271
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
278
272
  const fontWeight = props.fontWeight || "normal";
279
273
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
280
274
  const textAlign = props.textAlign || "left";
281
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
275
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
282
276
  const textDecoration = props.textDecoration || "none";
283
277
  const textTransform = props.textTransform || "none";
284
278
  const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
@@ -384,7 +378,7 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
384
378
  const y = layout.y;
385
379
  const width = layout.width;
386
380
  const height = layout.height;
387
- const radius = layout.border.radius;
381
+ const radius = layout.border.radius || 4;
388
382
 
389
383
  // Apply shadow if specified
390
384
  const shadow = props.shadow || props.boxShadow;
@@ -403,16 +397,12 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
403
397
  backgroundColor = props.hoverColor;
404
398
  } else if (node.focused && props.focusColor !== undefined) {
405
399
  backgroundColor = props.focusColor;
406
- } else if (props.backgroundColor !== undefined) {
407
- backgroundColor = props.backgroundColor;
400
+ } else {
401
+ backgroundColor = resolveBackgroundPaint(props);
408
402
  }
409
403
 
410
404
  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
- }
405
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
416
406
  drawRoundedRect(ctx, x, y, width, height, radius);
417
407
  ctx.fill();
418
408
  }
@@ -450,7 +440,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
450
440
  const y = layout.y;
451
441
  const width = layout.width;
452
442
  const height = layout.height;
453
- const radius = layout.border.radius;
443
+ const radius = layout.border.radius || 4;
454
444
 
455
445
  // Background
456
446
  ctx.fillStyle = props.backgroundColor || "#ffffff";
@@ -475,7 +465,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
475
465
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
476
466
  const fontWeight = props.fontWeight || "normal";
477
467
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
478
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
468
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
479
469
 
480
470
  renderText(
481
471
  ctx,
@@ -725,14 +715,6 @@ function drawRoundedRect(
725
715
  radius: number
726
716
  ): void {
727
717
  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
718
  ctx.rect(x, y, width, height);
737
719
  return;
738
720
  }
@@ -783,6 +765,45 @@ function applyShadow(ctx: CanvasRenderingContext2D, shadow: any): void {
783
765
  }
784
766
  }
785
767
 
768
+ /**
769
+ * Resolve DOM background applicator props into the CSS-like paint strings
770
+ * Canvas understands. DOM applicators map `.linearGradient("...")` to
771
+ * `background-image: linear-gradient(...)`; Canvas receives the original prop
772
+ * and must do the same translation before painting.
773
+ */
774
+ function resolveBackgroundPaint(props: Record<string, any>): string | undefined {
775
+ if (props.linearGradient !== undefined) {
776
+ return `linear-gradient(${props.linearGradient})`;
777
+ }
778
+ if (props.radialGradient !== undefined) {
779
+ return `radial-gradient(${props.radialGradient})`;
780
+ }
781
+ if (props.conicGradient !== undefined) {
782
+ return `conic-gradient(${props.conicGradient})`;
783
+ }
784
+ if (props.backgroundImage !== undefined) {
785
+ return String(props.backgroundImage);
786
+ }
787
+ if (props.backgroundColor !== undefined) {
788
+ return String(props.backgroundColor);
789
+ }
790
+ if (props.background !== undefined) {
791
+ return String(props.background);
792
+ }
793
+ return undefined;
794
+ }
795
+
796
+ function resolveCanvasPaint(
797
+ ctx: CanvasRenderingContext2D,
798
+ paint: string,
799
+ x: number,
800
+ y: number,
801
+ width: number,
802
+ height: number,
803
+ ): CanvasGradient | string {
804
+ return paint.includes("gradient(") ? parseGradient(ctx, paint, x, y, width, height) : paint;
805
+ }
806
+
786
807
  /**
787
808
  * Parse gradient string and create canvas gradient
788
809
  * Supports:
@@ -803,7 +824,7 @@ function parseGradient(
803
824
  const match = gradientStr.match(/linear-gradient\((.*)\)/);
804
825
  if (!match) return gradientStr;
805
826
 
806
- const parts = match[1].split(",").map((s) => s.trim());
827
+ const parts = splitCssArgs(match[1]);
807
828
 
808
829
  // Determine direction (default to bottom)
809
830
  let x0 = x, y0 = y, x1 = x, y1 = y + height;
@@ -813,29 +834,20 @@ function parseGradient(
813
834
  colorStart = 1;
814
835
  const direction = parts[0];
815
836
 
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;
837
+ const angle = parseCssGradientAngle(direction);
838
+ if (angle !== null) {
839
+ const line = gradientLineForAngle(angle, x, y, width, height);
840
+ x0 = line.x0;
841
+ y0 = line.y0;
842
+ x1 = line.x1;
843
+ y1 = line.y1;
827
844
  }
828
- // Default is "to bottom" which we already set
829
845
  }
830
846
 
831
847
  const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
832
848
 
833
849
  // 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
- });
850
+ addColorStops(gradient, parts.slice(colorStart));
839
851
 
840
852
  return gradient;
841
853
  } else if (gradientStr.startsWith("radial-gradient")) {
@@ -843,7 +855,7 @@ function parseGradient(
843
855
  const match = gradientStr.match(/radial-gradient\((.*)\)/);
844
856
  if (!match) return gradientStr;
845
857
 
846
- const parts = match[1].split(",").map((s) => s.trim());
858
+ const parts = splitCssArgs(match[1]);
847
859
 
848
860
  // Create radial gradient from center
849
861
  const centerX = x + width / 2;
@@ -853,17 +865,116 @@ function parseGradient(
853
865
  const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
854
866
 
855
867
  // Add color stops
856
- parts.forEach((color, i) => {
857
- const stop = i / (parts.length - 1);
858
- gradient.addColorStop(stop, color.trim());
859
- });
868
+ addColorStops(gradient, parts);
860
869
 
870
+ return gradient;
871
+ } else if (gradientStr.startsWith("conic-gradient")) {
872
+ const match = gradientStr.match(/conic-gradient\((.*)\)/);
873
+ if (!match) return gradientStr;
874
+ const createConicGradient = (ctx as CanvasRenderingContext2D & {
875
+ createConicGradient?: (startAngle: number, x: number, y: number) => CanvasGradient;
876
+ }).createConicGradient;
877
+ if (!createConicGradient) return firstGradientColor(match[1]) ?? gradientStr;
878
+ const centerX = x + width / 2;
879
+ const centerY = y + height / 2;
880
+ const gradient = createConicGradient.call(ctx, 0, centerX, centerY);
881
+ addColorStops(gradient, splitCssArgs(match[1]));
861
882
  return gradient;
862
883
  }
863
884
 
864
885
  return gradientStr;
865
886
  }
866
887
 
888
+ function splitCssArgs(input: string): string[] {
889
+ const parts: string[] = [];
890
+ let depth = 0;
891
+ let start = 0;
892
+ for (let i = 0; i < input.length; i++) {
893
+ const ch = input[i];
894
+ if (ch === "(") depth++;
895
+ else if (ch === ")") depth = Math.max(0, depth - 1);
896
+ else if (ch === "," && depth === 0) {
897
+ const part = input.slice(start, i).trim();
898
+ if (part) parts.push(part);
899
+ start = i + 1;
900
+ }
901
+ }
902
+ const tail = input.slice(start).trim();
903
+ if (tail) parts.push(tail);
904
+ return parts;
905
+ }
906
+
907
+ function parseCssGradientAngle(direction: string): number | null {
908
+ const trimmed = direction.trim().toLowerCase();
909
+ const deg = trimmed.match(/^(-?(?:\d+|\d*\.\d+))deg$/);
910
+ if (deg) return Number(deg[1]);
911
+ if (!trimmed.startsWith("to ")) return null;
912
+
913
+ const words = new Set(trimmed.slice(3).split(/\s+/).filter(Boolean));
914
+ if (words.has("top") && words.has("right")) return 45;
915
+ if (words.has("right") && words.has("bottom")) return 135;
916
+ if (words.has("bottom") && words.has("left")) return 225;
917
+ if (words.has("left") && words.has("top")) return 315;
918
+ if (words.has("top")) return 0;
919
+ if (words.has("right")) return 90;
920
+ if (words.has("bottom")) return 180;
921
+ if (words.has("left")) return 270;
922
+ return null;
923
+ }
924
+
925
+ function gradientLineForAngle(
926
+ cssDegrees: number,
927
+ x: number,
928
+ y: number,
929
+ width: number,
930
+ height: number,
931
+ ): { x0: number; y0: number; x1: number; y1: number } {
932
+ const radians = (cssDegrees * Math.PI) / 180;
933
+ const cx = x + width / 2;
934
+ const cy = y + height / 2;
935
+ const half = Math.sqrt(width * width + height * height) / 2;
936
+ const dx = Math.sin(radians) * half;
937
+ const dy = -Math.cos(radians) * half;
938
+ return {
939
+ x0: cx - dx,
940
+ y0: cy - dy,
941
+ x1: cx + dx,
942
+ y1: cy + dy,
943
+ };
944
+ }
945
+
946
+ function parseColorStop(raw: string, fallbackStop: number): { color: string; stop: number } {
947
+ const trimmed = raw.trim();
948
+ const match = trimmed.match(/^(.*\S)\s+(-?(?:\d+|\d*\.\d+)%)$/);
949
+ if (!match) return { color: trimmed, stop: fallbackStop };
950
+ return {
951
+ color: match[1].trim(),
952
+ stop: Math.max(0, Math.min(1, parseFloat(match[2]) / 100)),
953
+ };
954
+ }
955
+
956
+ function addColorStops(gradient: CanvasGradient, rawStops: string[]): void {
957
+ const stops = rawStops.filter(Boolean);
958
+ if (stops.length === 0) return;
959
+ if (stops.length === 1) {
960
+ const parsed = parseColorStop(stops[0], 0);
961
+ gradient.addColorStop(0, parsed.color);
962
+ gradient.addColorStop(1, parsed.color);
963
+ return;
964
+ }
965
+ stops.forEach((raw, i) => {
966
+ const fallback = i / (stops.length - 1);
967
+ const { color, stop } = parseColorStop(raw, fallback);
968
+ gradient.addColorStop(stop, color);
969
+ });
970
+ }
971
+
972
+ function firstGradientColor(input: string): string | undefined {
973
+ const first = splitCssArgs(input)[0];
974
+ if (!first) return undefined;
975
+ return parseColorStop(first, 0).color;
976
+ }
977
+
867
978
  /**
868
979
  * Apply CSS-like transforms to canvas context
869
980
  * Supports:
@@ -1277,19 +1388,15 @@ function paintCard(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1277
1388
  const y = layout.y;
1278
1389
  const width = layout.width;
1279
1390
  const height = layout.height;
1280
- const radius = layout.border.radius;
1391
+ const radius = layout.border.radius || 8;
1281
1392
 
1282
1393
  // Default card shadow
1283
1394
  const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
1284
1395
  applyShadow(ctx, shadow);
1285
1396
 
1286
1397
  // 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
- }
1398
+ const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
1399
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
1293
1400
 
1294
1401
  drawRoundedRect(ctx, x, y, width, height, radius);
1295
1402
  ctx.fill();
@@ -1333,8 +1440,8 @@ function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1333
1440
  drawRoundedRect(ctx, x, y, width, height, radius);
1334
1441
  ctx.fill();
1335
1442
 
1336
- // `??` so a literal `0`/`false`/`""` value still renders.
1337
- const text = String(props[0] ?? props.text ?? "");
1443
+ // Text content
1444
+ const text = String(props[0] || props.text || "");
1338
1445
  if (text) {
1339
1446
  ctx.fillStyle = props.color || "#ffffff";
1340
1447
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
@@ -1369,8 +1476,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1369
1476
  ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1370
1477
  ctx.fill();
1371
1478
 
1372
- // `??` so a literal `0`/`false`/`""` value still renders.
1373
- const text = String(props[0] ?? props.text ?? props.initials ?? "");
1479
+ // Text initials if provided
1480
+ const text = String(props[0] || props.text || props.initials || "");
1374
1481
  if (text) {
1375
1482
  ctx.fillStyle = props.color || "#ffffff";
1376
1483
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
@@ -1510,8 +1617,7 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1510
1617
  const layout = node.layout!;
1511
1618
  const props = node.props;
1512
1619
 
1513
- // `??` so a literal `0`/`false`/`""` value still renders.
1514
- const text = String(props[0] ?? props.text ?? "");
1620
+ const text = String(props[0] || props.text || "");
1515
1621
  const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
1516
1622
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1517
1623
  const fontWeight = props.fontWeight || "normal";
@@ -1641,6 +1747,3 @@ function drawStar(
1641
1747
 
1642
1748
 
1643
1749
 
1644
-
1645
-
1646
-
@@ -35,6 +35,7 @@ import {
35
35
  refreshApplicator,
36
36
  parseApplicatorBase,
37
37
  } from "./props.js";
38
+ import { applyVariants, invalidateVariantCache, deriveNodeComputed } from "./variants.js";
38
39
 
39
40
  const DEFAULT_OPTIONS: CanvasRendererOptions = {
40
41
  devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
@@ -73,10 +74,6 @@ export class CanvasRenderer implements Renderer {
73
74
  private rafId: number | null = null;
74
75
  private needsRedraw = false;
75
76
 
76
- private resizeObserver: ResizeObserver | null = null;
77
- private dprMediaQuery: MediaQueryList | null = null;
78
- private dprChangeHandler: (() => void) | null = null;
79
-
80
77
  private frameCount = 0;
81
78
  private lastFrameTime = 0;
82
79
 
@@ -142,30 +139,6 @@ export class CanvasRenderer implements Renderer {
142
139
  // Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
143
140
  initTaffyLayout();
144
141
 
145
- // Observe size changes — without this, the canvas backing store stays
146
- // pinned at construction-time dimensions while CSS stretches the
147
- // display box, and content gets bitmap-stretched / hit-testing drifts.
148
- if (typeof ResizeObserver !== "undefined") {
149
- this.resizeObserver = new ResizeObserver(() => {
150
- this.setupHiDPI();
151
- this.scheduleRedraw();
152
- });
153
- this.resizeObserver.observe(this.canvas);
154
- }
155
-
156
- // Track DPR changes (e.g. dragging window across monitors with
157
- // different scaling). matchMedia fires once per crossing.
158
- if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
159
- const dpr = window.devicePixelRatio || 1;
160
- this.dprMediaQuery = window.matchMedia(`(resolution: ${dpr}dppx)`);
161
- this.dprChangeHandler = () => {
162
- this.options.devicePixelRatio = window.devicePixelRatio || 1;
163
- this.setupHiDPI();
164
- this.scheduleRedraw();
165
- };
166
- this.dprMediaQuery.addEventListener("change", this.dprChangeHandler);
167
- }
168
-
169
142
  // Don't schedule initial render - wait for patches
170
143
  }
171
144
 
@@ -176,18 +149,15 @@ export class CanvasRenderer implements Renderer {
176
149
  const dpr = this.options.devicePixelRatio || 1;
177
150
  const rect = this.canvas.getBoundingClientRect();
178
151
 
179
- // Skip when the host is collapsed — a 0-pixel backing store throws
180
- // in some 2D contexts and the next non-zero resize will reinitialise.
181
- if (rect.width === 0 || rect.height === 0) return;
182
-
183
152
  this.canvas.width = rect.width * dpr;
184
153
  this.canvas.height = rect.height * dpr;
185
154
 
186
- // Reset transform first — `scale()` is cumulative across calls, so on
187
- // a resize the second call would compound (dpr²) and shrink content.
188
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
189
155
  this.ctx.scale(dpr, dpr);
190
156
 
157
+ // Update canvas display size
158
+ this.canvas.style.width = `${rect.width}px`;
159
+ this.canvas.style.height = `${rect.height}px`;
160
+
191
161
  // Sync dirty tracker with new canvas size
192
162
  if (this.dirtyTracker) {
193
163
  this.dirtyTracker.setCanvasSize(rect.width, rect.height);
@@ -325,6 +295,10 @@ export class CanvasRenderer implements Renderer {
325
295
 
326
296
  node.props[name] = value;
327
297
 
298
+ // A new key may introduce a variant marker (e.g. `padding@md.0`) — drop the
299
+ // cached variant-base set so the next frame rescans.
300
+ invalidateVariantCache(node);
301
+
328
302
  // If this is an applicator-namespaced key (e.g. `flex.0`, `onClick.to`),
329
303
  // rebuild the derived flat/aggregate entry under the base name so layout,
330
304
  // paint, and event dispatch see the updated value.
@@ -373,6 +347,8 @@ export class CanvasRenderer implements Renderer {
373
347
 
374
348
  delete node.props[name];
375
349
 
350
+ invalidateVariantCache(node);
351
+
376
352
  const base = parseApplicatorBase(name);
377
353
  if (base !== null) {
378
354
  refreshApplicator(node.props, base);
@@ -611,6 +587,20 @@ export class CanvasRenderer implements Renderer {
611
587
  }
612
588
  }
613
589
 
590
+ /**
591
+ * Re-derive cached computed fields that may have been overridden by variant
592
+ * resolution. `node.opacity` is read straight off `node.props.opacity` in
593
+ * paint, but it is cached on the node at create/setProp time — after a
594
+ * variant pass changes `props.opacity` (e.g. `opacity:disabled`), the cache
595
+ * must be refreshed or the paint would use the stale value.
596
+ */
597
+ private refreshComputedProps(node: VirtualNode): void {
598
+ deriveNodeComputed(node);
599
+ for (const child of node.children) {
600
+ this.refreshComputedProps(child);
601
+ }
602
+ }
603
+
614
604
  /**
615
605
  * Full canvas repaint (default behavior when dirty rects disabled)
616
606
  */
@@ -626,10 +616,17 @@ export class CanvasRenderer implements Renderer {
626
616
 
627
617
  // Layout and paint
628
618
  if (this.rootNode) {
619
+ // Resolve responsive + state variants against the current content width
620
+ // BEFORE layout so spacing/size winners feed the layout engine and
621
+ // colour/opacity winners feed paint.
622
+ const contentWidth = this.canvas.width / dpr;
623
+ applyVariants(this.rootNode, contentWidth);
624
+ this.refreshComputedProps(this.rootNode);
625
+
629
626
  computeLayout(
630
627
  this.ctx,
631
628
  this.rootNode,
632
- this.canvas.width / dpr,
629
+ contentWidth,
633
630
  this.canvas.height / dpr
634
631
  );
635
632
 
@@ -649,10 +646,14 @@ export class CanvasRenderer implements Renderer {
649
646
  private renderWithDirtyRects(dpr: number): void {
650
647
  // Always run layout so nodes have up-to-date bounds
651
648
  if (this.rootNode) {
649
+ const contentWidth = this.canvas.width / dpr;
650
+ applyVariants(this.rootNode, contentWidth);
651
+ this.refreshComputedProps(this.rootNode);
652
+
652
653
  computeLayout(
653
654
  this.ctx,
654
655
  this.rootNode,
655
- this.canvas.width / dpr,
656
+ contentWidth,
656
657
  this.canvas.height / dpr
657
658
  );
658
659
  ScrollManager.updateScrollBounds(this.rootNode);
@@ -764,15 +765,6 @@ export class CanvasRenderer implements Renderer {
764
765
  if (this.rafId !== null) {
765
766
  cancelAnimationFrame(this.rafId);
766
767
  }
767
- if (this.resizeObserver) {
768
- this.resizeObserver.disconnect();
769
- this.resizeObserver = null;
770
- }
771
- if (this.dprMediaQuery && this.dprChangeHandler) {
772
- this.dprMediaQuery.removeEventListener("change", this.dprChangeHandler);
773
- this.dprMediaQuery = null;
774
- this.dprChangeHandler = null;
775
- }
776
768
  this.eventManager.destroy();
777
769
  this.scrollManager.destroy();
778
770
  this.selectionManager.destroy();
@@ -191,13 +191,7 @@ export function renderText(
191
191
  ctx.save();
192
192
  ctx.font = font;
193
193
  ctx.fillStyle = style.color;
194
- // Use `middle` baseline so we can position each glyph at the visual centre
195
- // of its line box. With `top`, canvas draws from the top of the EM square
196
- // and any extra `line-height − font-size` space lands as padding BELOW the
197
- // glyph — visible as `+` inside a round FAB drifting toward the top of
198
- // the circle. CSS centres glyphs within the line box; matching that
199
- // requires `middle` + half-lineHeight offset.
200
- ctx.textBaseline = "middle";
194
+ ctx.textBaseline = "top";
201
195
 
202
196
  const metrics = measureText(ctx, text, style, width, maxLines, textOverflow);
203
197
 
@@ -212,8 +206,7 @@ export function renderText(
212
206
  // Render each line
213
207
  for (let i = 0; i < metrics.lines.length; i++) {
214
208
  const line = metrics.lines[i];
215
- // Centre of the line box (top of line + half of line-height).
216
- const lineY = startY + i * metrics.lineHeight + metrics.lineHeight / 2;
209
+ const lineY = startY + i * metrics.lineHeight;
217
210
 
218
211
  // Calculate X based on text alignment
219
212
  let lineX = x;
@@ -27,6 +27,20 @@ export interface VirtualNode {
27
27
 
28
28
  // Scroll state (managed by ScrollManager, not serialised)
29
29
  scrollState?: ScrollState;
30
+
31
+ // Pointer-pressed (active) state, tracked by the event manager from
32
+ // mousedown/mouseup so `:active` paint variants can resolve.
33
+ pressed?: boolean;
34
+
35
+ // --- Variant resolution bookkeeping (managed by applyVariants) ---
36
+ // Set of applicator base names that have at least one `@bp`/`:state` variant
37
+ // key on this node. Computed lazily; null means "not yet scanned", an empty
38
+ // set means "scanned, no variants".
39
+ variantBases?: Set<string> | null;
40
+ // Snapshot of the node's original (variant-free) base values, captured the
41
+ // first time a variant override is applied so each frame resolves from the
42
+ // un-overridden base instead of compounding overrides.
43
+ variantOriginals?: Record<string, unknown>;
30
44
  }
31
45
 
32
46
  export interface Layout {
@@ -12,42 +12,6 @@ import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
12
12
  // and treating `3.5rem` as 3.5px, collapsing whole layouts to 1/16 size.
13
13
  const ROOT_FONT_PX = 16;
14
14
 
15
- /**
16
- * Active layout viewport — used to resolve `vw` / `vh` correctly during a
17
- * layout pass. Set by `computeLayout` (layout.ts) at entry and cleared on
18
- * exit. Layout traversal is synchronous, so a single module-level slot is
19
- * sufficient and avoids threading a `viewport` parameter through every
20
- * `cssLengthToPx` call site (there are dozens).
21
- *
22
- * Falls back to `window.innerWidth` / `window.innerHeight` outside a layout
23
- * pass (e.g. for direct callers that resolve sizes ahead of layout). When
24
- * neither is available — e.g. headless tests — `vw`/`vh` degrade to the
25
- * pre-fix unitless-pixel behavior.
26
- */
27
- let currentViewport: { width: number; height: number } | null = null;
28
-
29
- export function setCurrentViewport(viewport: { width: number; height: number } | null): void {
30
- currentViewport = viewport;
31
- }
32
-
33
- export function getCurrentViewport(): { width: number; height: number } | null {
34
- return currentViewport;
35
- }
36
-
37
- function resolveViewportPx(unit: "vw" | "vh", n: number): number {
38
- if (currentViewport) {
39
- const dim = unit === "vw" ? currentViewport.width : currentViewport.height;
40
- return (n / 100) * dim;
41
- }
42
- if (typeof window !== "undefined") {
43
- const dim = unit === "vw" ? window.innerWidth : window.innerHeight;
44
- if (Number.isFinite(dim) && dim > 0) return (n / 100) * dim;
45
- }
46
- // Last resort: treat as unitless px so the layout still gets a numeric
47
- // value (matches pre-fix behavior in the no-viewport case).
48
- return n;
49
- }
50
-
51
15
  /**
52
16
  * Parse a CSS length string (or number) into pixels.
53
17
  * Returns `null` for `"auto"` or unparseable input; returns `null` for `%`
@@ -69,8 +33,10 @@ export function cssLengthToPx(value: any): number | null {
69
33
  case "px": return n;
70
34
  case "rem":
71
35
  case "em": return n * ROOT_FONT_PX;
36
+ // We don't have a viewport reference here; treat vw/vh as unitless px
37
+ // fallback so the layout gets _some_ numeric value rather than zero.
72
38
  case "vw":
73
- case "vh": return resolveViewportPx(unit, n);
39
+ case "vh": return n;
74
40
  // Typographic point — 1/72 inch at CSS reference density (96dpi).
75
41
  case "pt": return n * (96 / 72);
76
42
  // Density-independent (dp) and scale-independent (sp) logical pixels
@@ -86,38 +52,6 @@ export function cssLengthToPx(value: any): number | null {
86
52
  }
87
53
  }
88
54
 
89
- /**
90
- * Resolve a CSS `line-height` value to a pixel value.
91
- *
92
- * Unitless numbers (e.g. `1`, `1.5`, `"1.25"`) are CSS multipliers of the
93
- * font-size — `line-height: 1` (Tailwind's `leading-none`) means line box =
94
- * 1 × font-size, NOT 1 pixel. Without this branch, `cssLengthToPx("1")`
95
- * returned literally `1`, and a Column of `leading-none` Texts collapsed
96
- * each line to 1px tall — emoji + label in a bottom-tab button rendered at
97
- * the same y and overlapped.
98
- *
99
- * Anything with an explicit unit (`16px`, `1.5em`, `1rem`, `120%` is
100
- * rejected → null since Taffy/measure expects px) falls through to
101
- * `cssLengthToPx`.
102
- */
103
- export function resolveLineHeight(value: any, fontSize: number): number | null {
104
- if (value === undefined || value === null) return null;
105
- if (typeof value === "number" && Number.isFinite(value)) {
106
- // Unitless number: multiplier of font-size (matches CSS spec).
107
- return value * fontSize;
108
- }
109
- if (typeof value === "string") {
110
- const s = value.trim();
111
- if (s === "" || s === "auto" || s === "normal") return null;
112
- // Pure numeric string with no unit → unitless multiplier.
113
- if (/^-?\d*\.?\d+$/.test(s)) {
114
- const n = parseFloat(s);
115
- return Number.isFinite(n) ? n * fontSize : null;
116
- }
117
- }
118
- return cssLengthToPx(value);
119
- }
120
-
121
55
  /**
122
56
  * Parse a CSS length string for Taffy's Dimension type. Keeps `%` values as
123
57
  * the tagged percentage string, turns `"auto"`/empty/invalid into `"auto"`,