@hypen-space/web 0.4.956 → 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 (166) 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 +57 -44
  8. package/dist/canvas/layout.js.map +1 -1
  9. package/dist/canvas/paint.js +159 -60
  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/types.d.ts +3 -0
  15. package/dist/canvas/utils.d.ts +0 -8
  16. package/dist/canvas/utils.js +3 -34
  17. package/dist/canvas/utils.js.map +1 -1
  18. package/dist/canvas/variants.d.ts +49 -0
  19. package/dist/canvas/variants.js +137 -0
  20. package/dist/canvas/variants.js.map +1 -0
  21. package/dist/dom/applicators/events.js +1 -0
  22. package/dist/dom/applicators/events.js.map +1 -1
  23. package/dist/dom/applicators/font.d.ts +11 -6
  24. package/dist/dom/applicators/font.js +16 -27
  25. package/dist/dom/applicators/font.js.map +1 -1
  26. package/dist/dom/applicators/index.js +47 -45
  27. package/dist/dom/applicators/index.js.map +1 -1
  28. package/dist/dom/canvas/index.js +2 -2
  29. package/dist/dom/canvas/index.js.map +1 -1
  30. package/dist/dom/components/app.js +2 -2
  31. package/dist/dom/components/app.js.map +1 -1
  32. package/dist/dom/components/audio.js +2 -2
  33. package/dist/dom/components/audio.js.map +1 -1
  34. package/dist/dom/components/avatar.js +3 -4
  35. package/dist/dom/components/avatar.js.map +1 -1
  36. package/dist/dom/components/badge.js +2 -2
  37. package/dist/dom/components/badge.js.map +1 -1
  38. package/dist/dom/components/button.js +2 -2
  39. package/dist/dom/components/button.js.map +1 -1
  40. package/dist/dom/components/card.js +2 -2
  41. package/dist/dom/components/card.js.map +1 -1
  42. package/dist/dom/components/center.js +2 -2
  43. package/dist/dom/components/center.js.map +1 -1
  44. package/dist/dom/components/checkbox.js +4 -5
  45. package/dist/dom/components/checkbox.js.map +1 -1
  46. package/dist/dom/components/column.js +2 -2
  47. package/dist/dom/components/column.js.map +1 -1
  48. package/dist/dom/components/container.js +2 -2
  49. package/dist/dom/components/container.js.map +1 -1
  50. package/dist/dom/components/divider.js +2 -2
  51. package/dist/dom/components/divider.js.map +1 -1
  52. package/dist/dom/components/grid.js +10 -10
  53. package/dist/dom/components/grid.js.map +1 -1
  54. package/dist/dom/components/heading.js +3 -4
  55. package/dist/dom/components/heading.js.map +1 -1
  56. package/dist/dom/components/hypenapp.js +2 -2
  57. package/dist/dom/components/hypenapp.js.map +1 -1
  58. package/dist/dom/components/icon.js +4 -5
  59. package/dist/dom/components/icon.js.map +1 -1
  60. package/dist/dom/components/image.js +2 -2
  61. package/dist/dom/components/image.js.map +1 -1
  62. package/dist/dom/components/index.d.ts +2 -2
  63. package/dist/dom/components/index.js +2 -2
  64. package/dist/dom/components/index.js.map +1 -1
  65. package/dist/dom/components/input.js +2 -2
  66. package/dist/dom/components/input.js.map +1 -1
  67. package/dist/dom/components/link.js +2 -2
  68. package/dist/dom/components/link.js.map +1 -1
  69. package/dist/dom/components/list.js +2 -2
  70. package/dist/dom/components/list.js.map +1 -1
  71. package/dist/dom/components/paragraph.js +2 -2
  72. package/dist/dom/components/paragraph.js.map +1 -1
  73. package/dist/dom/components/progressbar.js +3 -3
  74. package/dist/dom/components/progressbar.js.map +1 -1
  75. package/dist/dom/components/route.js +2 -2
  76. package/dist/dom/components/route.js.map +1 -1
  77. package/dist/dom/components/router.js +2 -2
  78. package/dist/dom/components/router.js.map +1 -1
  79. package/dist/dom/components/row.js +10 -10
  80. package/dist/dom/components/row.js.map +1 -1
  81. package/dist/dom/components/select.js +3 -4
  82. package/dist/dom/components/select.js.map +1 -1
  83. package/dist/dom/components/slider.js +2 -2
  84. package/dist/dom/components/slider.js.map +1 -1
  85. package/dist/dom/components/spacer.js +2 -2
  86. package/dist/dom/components/spacer.js.map +1 -1
  87. package/dist/dom/components/spinner.js +4 -4
  88. package/dist/dom/components/spinner.js.map +1 -1
  89. package/dist/dom/components/stack.js +10 -10
  90. package/dist/dom/components/stack.js.map +1 -1
  91. package/dist/dom/components/switch.js +5 -6
  92. package/dist/dom/components/switch.js.map +1 -1
  93. package/dist/dom/components/text.js +2 -2
  94. package/dist/dom/components/text.js.map +1 -1
  95. package/dist/dom/components/textarea.js +2 -2
  96. package/dist/dom/components/textarea.js.map +1 -1
  97. package/dist/dom/components/video.js +2 -2
  98. package/dist/dom/components/video.js.map +1 -1
  99. package/dist/dom/debug.js +6 -8
  100. package/dist/dom/debug.js.map +1 -1
  101. package/dist/dom/index.d.ts +0 -1
  102. package/dist/dom/index.js +0 -1
  103. package/dist/dom/index.js.map +1 -1
  104. package/dist/dom/renderer.js +2 -3
  105. package/dist/dom/renderer.js.map +1 -1
  106. package/dist/variants.d.ts +90 -0
  107. package/dist/variants.js +206 -0
  108. package/dist/variants.js.map +1 -0
  109. package/package.json +2 -2
  110. package/src/canvas/QUICKSTART.md +12 -7
  111. package/src/canvas/README.md +8 -5
  112. package/src/canvas/events.ts +49 -7
  113. package/src/canvas/index.ts +0 -8
  114. package/src/canvas/layout.ts +57 -49
  115. package/src/canvas/paint.ts +163 -58
  116. package/src/canvas/renderer.ts +38 -46
  117. package/src/canvas/types.ts +14 -0
  118. package/src/canvas/utils.ts +3 -37
  119. package/src/canvas/variants.ts +153 -0
  120. package/src/dom/README.md +8 -7
  121. package/src/dom/applicators/events.ts +1 -0
  122. package/src/dom/applicators/font.ts +16 -29
  123. package/src/dom/applicators/index.ts +56 -46
  124. package/src/dom/canvas/index.ts +2 -2
  125. package/src/dom/components/app.ts +2 -2
  126. package/src/dom/components/audio.ts +2 -2
  127. package/src/dom/components/avatar.ts +3 -4
  128. package/src/dom/components/badge.ts +2 -2
  129. package/src/dom/components/button.ts +2 -2
  130. package/src/dom/components/card.ts +2 -2
  131. package/src/dom/components/center.ts +2 -2
  132. package/src/dom/components/checkbox.ts +4 -5
  133. package/src/dom/components/column.ts +2 -2
  134. package/src/dom/components/container.ts +2 -2
  135. package/src/dom/components/divider.ts +2 -2
  136. package/src/dom/components/grid.ts +10 -10
  137. package/src/dom/components/heading.ts +3 -4
  138. package/src/dom/components/hypenapp.ts +2 -2
  139. package/src/dom/components/icon.ts +4 -5
  140. package/src/dom/components/image.ts +2 -2
  141. package/src/dom/components/index.ts +3 -3
  142. package/src/dom/components/input.ts +2 -2
  143. package/src/dom/components/link.ts +2 -2
  144. package/src/dom/components/list.ts +2 -2
  145. package/src/dom/components/paragraph.ts +2 -2
  146. package/src/dom/components/progressbar.ts +3 -3
  147. package/src/dom/components/route.ts +2 -2
  148. package/src/dom/components/router.ts +2 -2
  149. package/src/dom/components/row.ts +10 -10
  150. package/src/dom/components/select.ts +3 -4
  151. package/src/dom/components/slider.ts +2 -2
  152. package/src/dom/components/spacer.ts +2 -2
  153. package/src/dom/components/spinner.ts +4 -4
  154. package/src/dom/components/stack.ts +10 -10
  155. package/src/dom/components/switch.ts +5 -6
  156. package/src/dom/components/text.ts +2 -2
  157. package/src/dom/components/textarea.ts +2 -2
  158. package/src/dom/components/video.ts +2 -2
  159. package/src/dom/debug.ts +6 -8
  160. package/src/dom/index.ts +0 -8
  161. package/src/dom/renderer.ts +2 -3
  162. package/src/variants.ts +249 -0
  163. package/dist/client.d.ts +0 -36
  164. package/dist/client.js +0 -47
  165. package/dist/client.js.map +0 -1
  166. package/src/client.ts +0 -88
@@ -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);
@@ -383,7 +378,7 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
383
378
  const y = layout.y;
384
379
  const width = layout.width;
385
380
  const height = layout.height;
386
- const radius = layout.border.radius;
381
+ const radius = layout.border.radius || 4;
387
382
 
388
383
  // Apply shadow if specified
389
384
  const shadow = props.shadow || props.boxShadow;
@@ -402,16 +397,12 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
402
397
  backgroundColor = props.hoverColor;
403
398
  } else if (node.focused && props.focusColor !== undefined) {
404
399
  backgroundColor = props.focusColor;
405
- } else if (props.backgroundColor !== undefined) {
406
- backgroundColor = props.backgroundColor;
400
+ } else {
401
+ backgroundColor = resolveBackgroundPaint(props);
407
402
  }
408
403
 
409
404
  if (backgroundColor !== undefined) {
410
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
411
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
412
- } else {
413
- ctx.fillStyle = backgroundColor;
414
- }
405
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
415
406
  drawRoundedRect(ctx, x, y, width, height, radius);
416
407
  ctx.fill();
417
408
  }
@@ -449,7 +440,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
449
440
  const y = layout.y;
450
441
  const width = layout.width;
451
442
  const height = layout.height;
452
- const radius = layout.border.radius;
443
+ const radius = layout.border.radius || 4;
453
444
 
454
445
  // Background
455
446
  ctx.fillStyle = props.backgroundColor || "#ffffff";
@@ -724,14 +715,6 @@ function drawRoundedRect(
724
715
  radius: number
725
716
  ): void {
726
717
  if (radius <= 0 || width <= 0 || height <= 0) {
727
- // beginPath is critical here — without it, this rect appends to the
728
- // existing path and the next `ctx.fill()` re-fills every previously
729
- // queued rect with the current fillStyle. That manifested as a
730
- // calculator grid where every button painted in the last button's
731
- // colour and only the last button's child text survived (each
732
- // subsequent fill repainted over earlier children). Mirrors the
733
- // beginPath() the rounded branch already does below.
734
- ctx.beginPath();
735
718
  ctx.rect(x, y, width, height);
736
719
  return;
737
720
  }
@@ -782,6 +765,45 @@ function applyShadow(ctx: CanvasRenderingContext2D, shadow: any): void {
782
765
  }
783
766
  }
784
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
+
785
807
  /**
786
808
  * Parse gradient string and create canvas gradient
787
809
  * Supports:
@@ -802,7 +824,7 @@ function parseGradient(
802
824
  const match = gradientStr.match(/linear-gradient\((.*)\)/);
803
825
  if (!match) return gradientStr;
804
826
 
805
- const parts = match[1].split(",").map((s) => s.trim());
827
+ const parts = splitCssArgs(match[1]);
806
828
 
807
829
  // Determine direction (default to bottom)
808
830
  let x0 = x, y0 = y, x1 = x, y1 = y + height;
@@ -812,29 +834,20 @@ function parseGradient(
812
834
  colorStart = 1;
813
835
  const direction = parts[0];
814
836
 
815
- if (direction.includes("to right") || direction === "90deg") {
816
- x1 = x + width;
817
- y1 = y;
818
- } else if (direction.includes("to left") || direction === "270deg") {
819
- x0 = x + width;
820
- x1 = x;
821
- y0 = y;
822
- y1 = y;
823
- } else if (direction.includes("to top") || direction === "0deg") {
824
- y0 = y + height;
825
- 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;
826
844
  }
827
- // Default is "to bottom" which we already set
828
845
  }
829
846
 
830
847
  const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
831
848
 
832
849
  // Add color stops
833
- const colors = parts.slice(colorStart);
834
- colors.forEach((color, i) => {
835
- const stop = i / (colors.length - 1);
836
- gradient.addColorStop(stop, color.trim());
837
- });
850
+ addColorStops(gradient, parts.slice(colorStart));
838
851
 
839
852
  return gradient;
840
853
  } else if (gradientStr.startsWith("radial-gradient")) {
@@ -842,7 +855,7 @@ function parseGradient(
842
855
  const match = gradientStr.match(/radial-gradient\((.*)\)/);
843
856
  if (!match) return gradientStr;
844
857
 
845
- const parts = match[1].split(",").map((s) => s.trim());
858
+ const parts = splitCssArgs(match[1]);
846
859
 
847
860
  // Create radial gradient from center
848
861
  const centerX = x + width / 2;
@@ -852,17 +865,116 @@ function parseGradient(
852
865
  const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
853
866
 
854
867
  // Add color stops
855
- parts.forEach((color, i) => {
856
- const stop = i / (parts.length - 1);
857
- gradient.addColorStop(stop, color.trim());
858
- });
868
+ addColorStops(gradient, parts);
859
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]));
860
882
  return gradient;
861
883
  }
862
884
 
863
885
  return gradientStr;
864
886
  }
865
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
+
866
978
  /**
867
979
  * Apply CSS-like transforms to canvas context
868
980
  * Supports:
@@ -1276,19 +1388,15 @@ function paintCard(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1276
1388
  const y = layout.y;
1277
1389
  const width = layout.width;
1278
1390
  const height = layout.height;
1279
- const radius = layout.border.radius;
1391
+ const radius = layout.border.radius || 8;
1280
1392
 
1281
1393
  // Default card shadow
1282
1394
  const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
1283
1395
  applyShadow(ctx, shadow);
1284
1396
 
1285
1397
  // Background
1286
- const backgroundColor = props.backgroundColor || "#ffffff";
1287
- if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
1288
- ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
1289
- } else {
1290
- ctx.fillStyle = backgroundColor;
1291
- }
1398
+ const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
1399
+ ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
1292
1400
 
1293
1401
  drawRoundedRect(ctx, x, y, width, height, radius);
1294
1402
  ctx.fill();
@@ -1639,6 +1747,3 @@ function drawStar(
1639
1747
 
1640
1748
 
1641
1749
 
1642
-
1643
-
1644
-
@@ -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();
@@ -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