@hypen-space/web 0.4.949 → 0.4.951

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 (150) hide show
  1. package/README.md +13 -20
  2. package/dist/canvas/index.d.ts +1 -0
  3. package/dist/canvas/index.js +1 -0
  4. package/dist/canvas/index.js.map +1 -1
  5. package/dist/canvas/layout.js +24 -11
  6. package/dist/canvas/layout.js.map +1 -1
  7. package/dist/canvas/paint.js +3 -3
  8. package/dist/canvas/paint.js.map +1 -1
  9. package/dist/canvas/renderer.d.ts +3 -0
  10. package/dist/canvas/renderer.js +41 -3
  11. package/dist/canvas/renderer.js.map +1 -1
  12. package/dist/canvas/utils.d.ts +8 -0
  13. package/dist/canvas/utils.js +34 -3
  14. package/dist/canvas/utils.js.map +1 -1
  15. package/dist/client.d.ts +36 -0
  16. package/dist/client.js +47 -0
  17. package/dist/client.js.map +1 -0
  18. package/dist/dom/applicators/font.d.ts +6 -11
  19. package/dist/dom/applicators/font.js +27 -16
  20. package/dist/dom/applicators/font.js.map +1 -1
  21. package/dist/dom/applicators/index.js +34 -17
  22. package/dist/dom/applicators/index.js.map +1 -1
  23. package/dist/dom/canvas/index.js +2 -2
  24. package/dist/dom/canvas/index.js.map +1 -1
  25. package/dist/dom/components/app.js +2 -2
  26. package/dist/dom/components/app.js.map +1 -1
  27. package/dist/dom/components/audio.js +2 -2
  28. package/dist/dom/components/audio.js.map +1 -1
  29. package/dist/dom/components/avatar.js +4 -3
  30. package/dist/dom/components/avatar.js.map +1 -1
  31. package/dist/dom/components/badge.js +2 -2
  32. package/dist/dom/components/badge.js.map +1 -1
  33. package/dist/dom/components/button.js +2 -2
  34. package/dist/dom/components/button.js.map +1 -1
  35. package/dist/dom/components/card.js +2 -2
  36. package/dist/dom/components/card.js.map +1 -1
  37. package/dist/dom/components/center.js +2 -2
  38. package/dist/dom/components/center.js.map +1 -1
  39. package/dist/dom/components/checkbox.js +5 -4
  40. package/dist/dom/components/checkbox.js.map +1 -1
  41. package/dist/dom/components/column.js +2 -2
  42. package/dist/dom/components/column.js.map +1 -1
  43. package/dist/dom/components/container.js +2 -2
  44. package/dist/dom/components/container.js.map +1 -1
  45. package/dist/dom/components/divider.js +2 -2
  46. package/dist/dom/components/divider.js.map +1 -1
  47. package/dist/dom/components/grid.js +10 -10
  48. package/dist/dom/components/grid.js.map +1 -1
  49. package/dist/dom/components/heading.js +4 -3
  50. package/dist/dom/components/heading.js.map +1 -1
  51. package/dist/dom/components/hypenapp.js +2 -2
  52. package/dist/dom/components/hypenapp.js.map +1 -1
  53. package/dist/dom/components/icon.js +5 -4
  54. package/dist/dom/components/icon.js.map +1 -1
  55. package/dist/dom/components/image.js +2 -2
  56. package/dist/dom/components/image.js.map +1 -1
  57. package/dist/dom/components/index.d.ts +2 -2
  58. package/dist/dom/components/index.js +2 -2
  59. package/dist/dom/components/index.js.map +1 -1
  60. package/dist/dom/components/input.js +2 -2
  61. package/dist/dom/components/input.js.map +1 -1
  62. package/dist/dom/components/link.js +2 -2
  63. package/dist/dom/components/link.js.map +1 -1
  64. package/dist/dom/components/list.js +2 -2
  65. package/dist/dom/components/list.js.map +1 -1
  66. package/dist/dom/components/paragraph.js +2 -2
  67. package/dist/dom/components/paragraph.js.map +1 -1
  68. package/dist/dom/components/progressbar.js +3 -3
  69. package/dist/dom/components/progressbar.js.map +1 -1
  70. package/dist/dom/components/route.js +2 -2
  71. package/dist/dom/components/route.js.map +1 -1
  72. package/dist/dom/components/router.js +2 -2
  73. package/dist/dom/components/router.js.map +1 -1
  74. package/dist/dom/components/row.js +10 -10
  75. package/dist/dom/components/row.js.map +1 -1
  76. package/dist/dom/components/select.js +4 -3
  77. package/dist/dom/components/select.js.map +1 -1
  78. package/dist/dom/components/slider.js +2 -2
  79. package/dist/dom/components/slider.js.map +1 -1
  80. package/dist/dom/components/spacer.js +2 -2
  81. package/dist/dom/components/spacer.js.map +1 -1
  82. package/dist/dom/components/spinner.js +4 -4
  83. package/dist/dom/components/spinner.js.map +1 -1
  84. package/dist/dom/components/stack.js +10 -10
  85. package/dist/dom/components/stack.js.map +1 -1
  86. package/dist/dom/components/switch.js +6 -5
  87. package/dist/dom/components/switch.js.map +1 -1
  88. package/dist/dom/components/text.js +2 -2
  89. package/dist/dom/components/text.js.map +1 -1
  90. package/dist/dom/components/textarea.js +2 -2
  91. package/dist/dom/components/textarea.js.map +1 -1
  92. package/dist/dom/components/video.js +2 -2
  93. package/dist/dom/components/video.js.map +1 -1
  94. package/dist/dom/debug.js +8 -6
  95. package/dist/dom/debug.js.map +1 -1
  96. package/dist/dom/index.d.ts +1 -0
  97. package/dist/dom/index.js +1 -0
  98. package/dist/dom/index.js.map +1 -1
  99. package/dist/dom/renderer.js +3 -2
  100. package/dist/dom/renderer.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/canvas/QUICKSTART.md +7 -12
  103. package/src/canvas/README.md +5 -8
  104. package/src/canvas/index.ts +8 -0
  105. package/src/canvas/layout.ts +30 -11
  106. package/src/canvas/paint.ts +3 -3
  107. package/src/canvas/renderer.ts +44 -4
  108. package/src/canvas/utils.ts +37 -3
  109. package/src/client.ts +88 -0
  110. package/src/dom/README.md +7 -8
  111. package/src/dom/applicators/font.ts +29 -16
  112. package/src/dom/applicators/index.ts +35 -17
  113. package/src/dom/canvas/index.ts +2 -2
  114. package/src/dom/components/app.ts +2 -2
  115. package/src/dom/components/audio.ts +2 -2
  116. package/src/dom/components/avatar.ts +4 -3
  117. package/src/dom/components/badge.ts +2 -2
  118. package/src/dom/components/button.ts +2 -2
  119. package/src/dom/components/card.ts +2 -2
  120. package/src/dom/components/center.ts +2 -2
  121. package/src/dom/components/checkbox.ts +5 -4
  122. package/src/dom/components/column.ts +2 -2
  123. package/src/dom/components/container.ts +2 -2
  124. package/src/dom/components/divider.ts +2 -2
  125. package/src/dom/components/grid.ts +10 -10
  126. package/src/dom/components/heading.ts +4 -3
  127. package/src/dom/components/hypenapp.ts +2 -2
  128. package/src/dom/components/icon.ts +5 -4
  129. package/src/dom/components/image.ts +2 -2
  130. package/src/dom/components/index.ts +3 -3
  131. package/src/dom/components/input.ts +2 -2
  132. package/src/dom/components/link.ts +2 -2
  133. package/src/dom/components/list.ts +2 -2
  134. package/src/dom/components/paragraph.ts +2 -2
  135. package/src/dom/components/progressbar.ts +3 -3
  136. package/src/dom/components/route.ts +2 -2
  137. package/src/dom/components/router.ts +2 -2
  138. package/src/dom/components/row.ts +10 -10
  139. package/src/dom/components/select.ts +4 -3
  140. package/src/dom/components/slider.ts +2 -2
  141. package/src/dom/components/spacer.ts +2 -2
  142. package/src/dom/components/spinner.ts +4 -4
  143. package/src/dom/components/stack.ts +10 -10
  144. package/src/dom/components/switch.ts +6 -5
  145. package/src/dom/components/text.ts +2 -2
  146. package/src/dom/components/textarea.ts +2 -2
  147. package/src/dom/components/video.ts +2 -2
  148. package/src/dom/debug.ts +8 -6
  149. package/src/dom/index.ts +8 -0
  150. package/src/dom/renderer.ts +3 -2
@@ -73,6 +73,10 @@ export class CanvasRenderer implements Renderer {
73
73
  private rafId: number | null = null;
74
74
  private needsRedraw = false;
75
75
 
76
+ private resizeObserver: ResizeObserver | null = null;
77
+ private dprMediaQuery: MediaQueryList | null = null;
78
+ private dprChangeHandler: (() => void) | null = null;
79
+
76
80
  private frameCount = 0;
77
81
  private lastFrameTime = 0;
78
82
 
@@ -138,6 +142,30 @@ export class CanvasRenderer implements Renderer {
138
142
  // Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
139
143
  initTaffyLayout();
140
144
 
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
+
141
169
  // Don't schedule initial render - wait for patches
142
170
  }
143
171
 
@@ -148,15 +176,18 @@ export class CanvasRenderer implements Renderer {
148
176
  const dpr = this.options.devicePixelRatio || 1;
149
177
  const rect = this.canvas.getBoundingClientRect();
150
178
 
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
+
151
183
  this.canvas.width = rect.width * dpr;
152
184
  this.canvas.height = rect.height * dpr;
153
185
 
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);
154
189
  this.ctx.scale(dpr, dpr);
155
190
 
156
- // Update canvas display size
157
- this.canvas.style.width = `${rect.width}px`;
158
- this.canvas.style.height = `${rect.height}px`;
159
-
160
191
  // Sync dirty tracker with new canvas size
161
192
  if (this.dirtyTracker) {
162
193
  this.dirtyTracker.setCanvasSize(rect.width, rect.height);
@@ -733,6 +764,15 @@ export class CanvasRenderer implements Renderer {
733
764
  if (this.rafId !== null) {
734
765
  cancelAnimationFrame(this.rafId);
735
766
  }
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
+ }
736
776
  this.eventManager.destroy();
737
777
  this.scrollManager.destroy();
738
778
  this.selectionManager.destroy();
@@ -12,6 +12,42 @@ 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
+
15
51
  /**
16
52
  * Parse a CSS length string (or number) into pixels.
17
53
  * Returns `null` for `"auto"` or unparseable input; returns `null` for `%`
@@ -33,10 +69,8 @@ export function cssLengthToPx(value: any): number | null {
33
69
  case "px": return n;
34
70
  case "rem":
35
71
  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.
38
72
  case "vw":
39
- case "vh": return n;
73
+ case "vh": return resolveViewportPx(unit, n);
40
74
  // Typographic point — 1/72 inch at CSS reference density (96dpi).
41
75
  case "pt": return n * (96 / 72);
42
76
  // Density-independent (dp) and scale-independent (sp) logical pixels
package/src/client.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * createHypenClient — single-call wiring for renderer + engine.
3
+ *
4
+ * Replaces the boilerplate:
5
+ * const renderer = new DOMRenderer(container, engine);
6
+ * engine.onPatches((patches) => renderer.applyPatches(patches));
7
+ *
8
+ * Works with either a local Engine (`setRenderCallback`) or RemoteEngine
9
+ * (`onPatches`) — they expose different APIs but the same intent.
10
+ */
11
+
12
+ import type { Patch } from "@hypen-space/core/types";
13
+ import { DOMRenderer } from "./dom/renderer.js";
14
+ import type { DebugConfig } from "./dom/debug.js";
15
+ import { CanvasRenderer } from "./canvas/renderer.js";
16
+ import type { CanvasRendererOptions } from "./canvas/types.js";
17
+
18
+ interface PatchSourceLike {
19
+ dispatchAction(name: string, payload?: unknown): void;
20
+ onPatches?(callback: (patches: Patch[]) => void): unknown;
21
+ setRenderCallback?(callback: (patches: Patch[]) => void): void;
22
+ }
23
+
24
+ function attachPatchListener(
25
+ engine: PatchSourceLike,
26
+ callback: (patches: Patch[]) => void,
27
+ ): void {
28
+ if (typeof engine.onPatches === "function") {
29
+ engine.onPatches(callback);
30
+ return;
31
+ }
32
+ if (typeof engine.setRenderCallback === "function") {
33
+ engine.setRenderCallback(callback);
34
+ return;
35
+ }
36
+ throw new Error(
37
+ "createHypenClient: engine has neither onPatches() nor setRenderCallback() — cannot subscribe to patches.",
38
+ );
39
+ }
40
+
41
+ export interface DomClientOptions {
42
+ debug?: Partial<DebugConfig>;
43
+ }
44
+
45
+ export interface DomClient {
46
+ renderer: DOMRenderer;
47
+ destroy(): void;
48
+ }
49
+
50
+ export function createDomClient(
51
+ container: HTMLElement,
52
+ engine: PatchSourceLike,
53
+ options?: DomClientOptions,
54
+ ): DomClient {
55
+ const renderer = new DOMRenderer(container, engine as any, options?.debug);
56
+ attachPatchListener(engine, (patches) => renderer.applyPatches(patches));
57
+ return {
58
+ renderer,
59
+ destroy() {
60
+ // DOMRenderer has no destroy yet; clearing the container is the
61
+ // closest approximation. Patch subscriptions live on the engine —
62
+ // the caller controls engine lifetime, so we don't tear them down.
63
+ container.innerHTML = "";
64
+ },
65
+ };
66
+ }
67
+
68
+ export interface CanvasClientOptions extends Partial<CanvasRendererOptions> {}
69
+
70
+ export interface CanvasClient {
71
+ renderer: CanvasRenderer;
72
+ destroy(): void;
73
+ }
74
+
75
+ export function createCanvasClient(
76
+ canvas: HTMLCanvasElement,
77
+ engine: PatchSourceLike,
78
+ options?: CanvasClientOptions,
79
+ ): CanvasClient {
80
+ const renderer = new CanvasRenderer(canvas, engine as any, options);
81
+ attachPatchListener(engine, (patches) => renderer.applyPatches(patches));
82
+ return {
83
+ renderer,
84
+ destroy() {
85
+ renderer.destroy();
86
+ },
87
+ };
88
+ }
package/src/dom/README.md CHANGED
@@ -37,18 +37,17 @@ src/dom/
37
37
 
38
38
  ### Browser Import
39
39
 
40
+ The recommended one-call wiring is `createHypenClient`. The bare `DOMRenderer` constructor is still exported for advanced cases (custom subscription, multi-listener fan-out, etc.).
41
+
40
42
  ```typescript
41
43
  import { app, HypenModuleInstance } from "@hypen-space/core";
42
44
  import { Engine } from "@hypen-space/web-engine";
43
- import { DOMRenderer } from "@hypen-space/web";
45
+ import { createHypenClient } from "@hypen-space/web/dom";
44
46
 
45
47
  const engine = new Engine();
46
48
  await engine.init();
47
49
 
48
- const renderer = new DOMRenderer(document.body, engine);
49
- engine.setRenderCallback((patches) => {
50
- renderer.applyPatches(patches);
51
- });
50
+ createHypenClient(document.body, engine);
52
51
  ```
53
52
 
54
53
  ### Server Import
@@ -79,7 +78,7 @@ All components are registered by default:
79
78
  ### Custom Components
80
79
 
81
80
  ```typescript
82
- const renderer = new DOMRenderer(document.body, engine);
81
+ const { renderer } = createHypenClient(document.body, engine);
83
82
  const registry = renderer.getComponentRegistry();
84
83
 
85
84
  registry.register("mycomponent", {
@@ -141,7 +140,7 @@ Style applicators are registered by default:
141
140
  ### Custom Applicators
142
141
 
143
142
  ```typescript
144
- const renderer = new DOMRenderer(document.body, engine);
143
+ const { renderer } = createHypenClient(document.body, engine);
145
144
  const applicators = renderer.getApplicatorRegistry();
146
145
 
147
146
  applicators.register("shadow", (el, value) => {
@@ -183,7 +182,7 @@ Canvas components have special applicators for drawing:
183
182
 
184
183
  ```typescript
185
184
  // Register canvas applicators
186
- const renderer = new DOMRenderer(document.body, engine);
185
+ const { renderer } = createHypenClient(document.body, engine);
187
186
  const applicators = renderer.getApplicatorRegistry();
188
187
 
189
188
  // Canvas-specific applicators
@@ -5,8 +5,17 @@
5
5
  import type { ApplicatorHandler } from "./types.js";
6
6
  import { toCssLength } from "./size.js";
7
7
 
8
- // Track loaded Google Fonts to avoid duplicate link tags
9
- const loadedGoogleFonts = new Set<string>();
8
+ // Track loaded Google Fonts per-document to avoid duplicate link tags
9
+ const loadedGoogleFonts = new WeakMap<Document, Set<string>>();
10
+
11
+ function getLoadedSet(doc: Document): Set<string> {
12
+ let set = loadedGoogleFonts.get(doc);
13
+ if (!set) {
14
+ set = new Set<string>();
15
+ loadedGoogleFonts.set(doc, set);
16
+ }
17
+ return set;
18
+ }
10
19
 
11
20
  // System font keywords that shouldn't be loaded from Google Fonts
12
21
  const systemFontKeywords = new Set([
@@ -27,40 +36,42 @@ function isSystemFont(fontName: string): boolean {
27
36
  }
28
37
 
29
38
  /**
30
- * Load a Google Font by injecting a link tag.
39
+ * Load a Google Font by injecting a link tag into the given document.
31
40
  * @param fontName - The Google Font name (e.g., "Roboto", "Open Sans")
41
+ * @param doc - The Document to inject into.
32
42
  */
33
- function loadGoogleFont(fontName: string): void {
43
+ function loadGoogleFont(fontName: string, doc: Document): void {
34
44
  const normalized = fontName.trim();
45
+ const loaded = getLoadedSet(doc);
35
46
 
36
47
  // Skip if already loaded or is a system font
37
- if (loadedGoogleFonts.has(normalized) || isSystemFont(normalized)) {
48
+ if (loaded.has(normalized) || isSystemFont(normalized)) {
38
49
  return;
39
50
  }
40
51
 
41
52
  // Mark as loading to avoid duplicates
42
- loadedGoogleFonts.add(normalized);
53
+ loaded.add(normalized);
43
54
 
44
55
  // Create link element for Google Fonts
45
- const link = document.createElement("link");
56
+ const link = doc.createElement("link");
46
57
  link.rel = "stylesheet";
47
58
  link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(normalized)}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
48
59
 
49
60
  // Add to document head
50
- document.head.appendChild(link);
61
+ doc.head.appendChild(link);
51
62
  }
52
63
 
53
64
  /**
54
65
  * Parse a fontFamily value and load any Google Fonts.
55
66
  * Returns a CSS-safe font-family string.
56
67
  */
57
- function processFontFamily(value: string): string {
68
+ function processFontFamily(value: string, doc: Document): string {
58
69
  // Split by comma to handle font stacks
59
70
  const fonts = value.split(",").map(f => f.trim().replace(/["']/g, ""));
60
71
 
61
72
  for (const font of fonts) {
62
73
  if (!isSystemFont(font)) {
63
- loadGoogleFont(font);
74
+ loadGoogleFont(font, doc);
64
75
  }
65
76
  }
66
77
 
@@ -86,7 +97,7 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
86
97
  fontFamily: (el, value) => {
87
98
  const fontValue = String(value);
88
99
  // Process font family and load Google Fonts as needed
89
- el.style.fontFamily = processFontFamily(fontValue);
100
+ el.style.fontFamily = processFontFamily(fontValue, el.ownerDocument as Document);
90
101
  },
91
102
 
92
103
  textAlign: (el, value) => {
@@ -127,18 +138,20 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
127
138
  export const GoogleFonts = {
128
139
  /**
129
140
  * Preload a Google Font before it's used.
141
+ * Defaults to the global `document` if no doc is provided.
130
142
  */
131
- preload: loadGoogleFont,
143
+ preload: (fontName: string, doc: Document = document) => loadGoogleFont(fontName, doc),
132
144
 
133
145
  /**
134
- * Check if a font has been loaded.
146
+ * Check if a font has been loaded in the given document (defaults to global).
135
147
  */
136
- isLoaded: (fontName: string) => loadedGoogleFonts.has(fontName.trim()),
148
+ isLoaded: (fontName: string, doc: Document = document) =>
149
+ getLoadedSet(doc).has(fontName.trim()),
137
150
 
138
151
  /**
139
- * Get list of loaded fonts.
152
+ * Get list of loaded fonts for the given document (defaults to global).
140
153
  */
141
- getLoadedFonts: () => Array.from(loadedGoogleFonts),
154
+ getLoadedFonts: (doc: Document = document) => Array.from(getLoadedSet(doc)),
142
155
 
143
156
  /**
144
157
  * Popular Google Fonts for reference.
@@ -53,26 +53,40 @@ const BREAKPOINTS: Record<string, string> = {
53
53
  };
54
54
 
55
55
  /**
56
- * Singleton stylesheet for variant CSS rules
56
+ * Per-document stylesheet for variant CSS rules
57
57
  */
58
- let variantStyleSheet: CSSStyleSheet | null = null;
58
+ const variantStyleSheets = new Map<Document, CSSStyleSheet>();
59
59
 
60
60
  /**
61
- * Track inserted rules to avoid duplicates
61
+ * Track inserted rules to avoid duplicates (per-document)
62
62
  */
63
- const insertedRules = new Set<string>();
63
+ const insertedRules = new Map<Document, Set<string>>();
64
64
 
65
65
  /**
66
- * Get or create the variant stylesheet
66
+ * Get or create the variant stylesheet for a document
67
67
  */
68
- function getVariantStyleSheet(): CSSStyleSheet {
69
- if (!variantStyleSheet) {
70
- const style = document.createElement('style');
68
+ function getVariantStyleSheet(doc: Document): CSSStyleSheet {
69
+ let sheet = variantStyleSheets.get(doc);
70
+ if (!sheet) {
71
+ const style = doc.createElement('style');
71
72
  style.id = 'hypen-variants';
72
- document.head.appendChild(style);
73
- variantStyleSheet = style.sheet as CSSStyleSheet;
73
+ doc.head.appendChild(style);
74
+ sheet = style.sheet as CSSStyleSheet;
75
+ variantStyleSheets.set(doc, sheet);
74
76
  }
75
- return variantStyleSheet;
77
+ return sheet;
78
+ }
79
+
80
+ /**
81
+ * Get or create the inserted-rules dedup set for a document
82
+ */
83
+ function getInsertedRules(doc: Document): Set<string> {
84
+ let set = insertedRules.get(doc);
85
+ if (!set) {
86
+ set = new Set<string>();
87
+ insertedRules.set(doc, set);
88
+ }
89
+ return set;
76
90
  }
77
91
 
78
92
  /**
@@ -334,19 +348,21 @@ export class ApplicatorRegistry {
334
348
  const minWidth = BREAKPOINTS[breakpoint];
335
349
 
336
350
  if (minWidth) {
351
+ const doc = element.ownerDocument as Document;
352
+ const dedup = getInsertedRules(doc);
337
353
  const cssName = this.toKebabCase(prop);
338
354
  const cssValue = this.formatCssValue(cssName, value);
339
355
  const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${breakpoint}-${hashValue(value)}`;
340
356
  const ruleKey = `${className}:${cssValue}`;
341
357
 
342
358
  // Avoid duplicate rule insertion
343
- if (!insertedRules.has(ruleKey)) {
344
- const sheet = getVariantStyleSheet();
359
+ if (!dedup.has(ruleKey)) {
360
+ const sheet = getVariantStyleSheet(doc);
345
361
  sheet.insertRule(
346
362
  `@media (min-width: ${minWidth}) { .${className} { ${cssName}: ${cssValue}; } }`,
347
363
  sheet.cssRules.length
348
364
  );
349
- insertedRules.add(ruleKey);
365
+ dedup.add(ruleKey);
350
366
  }
351
367
 
352
368
  element.classList.add(className);
@@ -362,19 +378,21 @@ export class ApplicatorRegistry {
362
378
  // Only handle known CSS pseudo-states
363
379
  const validStates = ['hover', 'focus', 'active', 'disabled', 'focus-visible', 'focus-within'];
364
380
  if (validStates.includes(state)) {
381
+ const doc = element.ownerDocument as Document;
382
+ const dedup = getInsertedRules(doc);
365
383
  const cssName = this.toKebabCase(prop);
366
384
  const cssValue = this.formatCssValue(cssName, value);
367
385
  const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${state}-${hashValue(value)}`;
368
386
  const ruleKey = `${className}:${cssValue}`;
369
387
 
370
388
  // Avoid duplicate rule insertion
371
- if (!insertedRules.has(ruleKey)) {
372
- const sheet = getVariantStyleSheet();
389
+ if (!dedup.has(ruleKey)) {
390
+ const sheet = getVariantStyleSheet(doc);
373
391
  sheet.insertRule(
374
392
  `.${className}:${state} { ${cssName}: ${cssValue}; }`,
375
393
  sheet.cssRules.length
376
394
  );
377
- insertedRules.add(ruleKey);
395
+ dedup.add(ruleKey);
378
396
  }
379
397
 
380
398
  element.classList.add(className);
@@ -8,8 +8,8 @@ import type { ComponentHandler } from "../components/index.js";
8
8
  import type { ApplicatorHandler } from "../applicators/index.js";
9
9
 
10
10
  export const canvasHandler: ComponentHandler = {
11
- create(): HTMLElement {
12
- const el = document.createElement("canvas");
11
+ create(doc: Document): HTMLElement {
12
+ const el = doc.createElement("canvas");
13
13
  el.dataset.hypenType = "canvas";
14
14
  return el as any as HTMLElement;
15
15
  },
@@ -8,8 +8,8 @@
8
8
  import type { ComponentHandler } from "./index.js";
9
9
 
10
10
  export const appHandler: ComponentHandler = {
11
- create(): HTMLElement {
12
- const el = document.createElement("div");
11
+ create(doc: Document): HTMLElement {
12
+ const el = doc.createElement("div");
13
13
  el.style.display = "flex";
14
14
  el.style.flexDirection = "column";
15
15
  el.style.minHeight = "100vh";
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const audioHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("audio");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("audio");
10
10
  el.dataset.hypenType = "audio";
11
11
  return el as any as HTMLElement;
12
12
  },
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const avatarHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("div");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("div");
10
10
  el.dataset.hypenType = "avatar";
11
11
  el.style.display = "inline-flex";
12
12
  el.style.alignItems = "center";
@@ -23,10 +23,11 @@ export const avatarHandler: ComponentHandler = {
23
23
  },
24
24
 
25
25
  applyProps(el: HTMLElement, props: Record<string, any>): void {
26
+ const doc = el.ownerDocument as Document;
26
27
  // Image source - support named arg with .0 suffix, positional arg, or plain name
27
28
  const src = props["src.0"] || props["0"] || props.src || props.source;
28
29
  if (src !== undefined) {
29
- const img = document.createElement("img");
30
+ const img = doc.createElement("img");
30
31
  img.src = String(src);
31
32
  img.style.width = "100%";
32
33
  img.style.height = "100%";
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const badgeHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("span");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("span");
10
10
  el.dataset.hypenType = "badge";
11
11
  el.style.display = "inline-block";
12
12
  el.style.padding = "4px 8px";
@@ -9,8 +9,8 @@
9
9
  import type { ComponentHandler } from "./index.js";
10
10
 
11
11
  export const buttonHandler: ComponentHandler = {
12
- create(): HTMLElement {
13
- const el = document.createElement("button");
12
+ create(doc: Document): HTMLElement {
13
+ const el = doc.createElement("button");
14
14
  // Reset default button styles for cross-platform consistency
15
15
  el.style.border = "none";
16
16
  el.style.background = "none";
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const cardHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("div");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("div");
10
10
  el.dataset.hypenType = "card";
11
11
  el.style.backgroundColor = "#ffffff";
12
12
  el.style.borderRadius = "8px";
@@ -9,8 +9,8 @@
9
9
  import type { ComponentHandler } from "./index.js";
10
10
 
11
11
  export const centerHandler: ComponentHandler = {
12
- create(): HTMLElement {
13
- const el = document.createElement("div");
12
+ create(doc: Document): HTMLElement {
13
+ const el = doc.createElement("div");
14
14
  el.style.display = "flex";
15
15
  el.style.alignItems = "center";
16
16
  el.style.justifyContent = "center";
@@ -5,15 +5,15 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const checkboxHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const wrapper = document.createElement("label");
8
+ create(doc: Document): HTMLElement {
9
+ const wrapper = doc.createElement("label");
10
10
  wrapper.dataset.hypenType = "checkbox";
11
11
  wrapper.style.display = "inline-flex";
12
12
  wrapper.style.alignItems = "center";
13
13
  wrapper.style.gap = "8px";
14
14
  wrapper.style.cursor = "pointer";
15
15
 
16
- const input = document.createElement("input");
16
+ const input = doc.createElement("input");
17
17
  input.type = "checkbox";
18
18
  input.dataset.hypenCheckbox = "true";
19
19
 
@@ -23,6 +23,7 @@ export const checkboxHandler: ComponentHandler = {
23
23
  },
24
24
 
25
25
  applyProps(el: HTMLElement, props: Record<string, any>): void {
26
+ const doc = el.ownerDocument as Document;
26
27
  const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
27
28
  if (!input) return;
28
29
 
@@ -46,7 +47,7 @@ export const checkboxHandler: ComponentHandler = {
46
47
  textNodes.forEach(node => node.remove());
47
48
 
48
49
  // Add new label text
49
- el.appendChild(document.createTextNode(String(label)));
50
+ el.appendChild(doc.createTextNode(String(label)));
50
51
  }
51
52
  },
52
53
  };
@@ -17,8 +17,8 @@
17
17
  import type { ComponentHandler } from "./index.js";
18
18
 
19
19
  export const columnHandler: ComponentHandler = {
20
- create(): HTMLElement {
21
- const el = document.createElement("div");
20
+ create(doc: Document): HTMLElement {
21
+ const el = doc.createElement("div");
22
22
  el.style.display = "flex";
23
23
  el.style.flexDirection = "column";
24
24
  el.style.alignItems = "stretch";
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const containerHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("div");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("div");
10
10
  // Simple block container - wraps to content by default
11
11
  // Use .fillMaxWidth(true) to stretch
12
12
  el.dataset.hypenType = "container";
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const dividerHandler: ComponentHandler = {
8
- create(): HTMLElement {
9
- const el = document.createElement("hr");
8
+ create(doc: Document): HTMLElement {
9
+ const el = doc.createElement("hr");
10
10
  el.dataset.hypenType = "divider";
11
11
  el.style.border = "none";
12
12
  el.style.borderTop = "1px solid #e0e0e0";