@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
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Canvas variant application.
3
+ *
4
+ * The Canvas renderer reads winning values directly off `node.props.<base>`
5
+ * (e.g. `props.padding`, `props.backgroundColor`) in both layout.ts and
6
+ * paint.ts. To support responsive breakpoints and interaction-state variants
7
+ * WITHOUT rewriting every call site, we resolve variant keys once per frame
8
+ * (just before layout) and stamp the winning value onto the base key.
9
+ *
10
+ * Resolution itself is delegated to the shared, renderer-agnostic helper in
11
+ * `../variants.ts`, so breakpoint pixels, state names, and precedence stay
12
+ * byte-compatible with the DOM renderer.
13
+ *
14
+ * Per-node bookkeeping (`variantBases`, `variantOriginals`) lives on the
15
+ * VirtualNode so each frame:
16
+ * 1. restores the original (variant-free) base value, then
17
+ * 2. re-applies the current winner for the present width + active states.
18
+ * This makes the pass idempotent and reversible — when a hover ends or the
19
+ * canvas shrinks below a breakpoint, the base value comes back automatically.
20
+ */
21
+
22
+ import type { VirtualNode } from "./types.js";
23
+ import {
24
+ resolveVariantProps,
25
+ parseVariantKey,
26
+ type ActiveStates,
27
+ } from "../variants.js";
28
+
29
+ /**
30
+ * Lazily compute (and cache) the set of applicator base names on this node that
31
+ * carry at least one variant key. Returns the cached set on subsequent calls.
32
+ *
33
+ * The cache is invalidated by `invalidateVariantCache`, called from the
34
+ * renderer whenever a SetProp/RemoveProp changes the node's prop keys.
35
+ */
36
+ function getVariantBases(node: VirtualNode): Set<string> {
37
+ if (node.variantBases != null) return node.variantBases;
38
+ const bases = new Set<string>();
39
+ for (const key of Object.keys(node.props)) {
40
+ const parsed = parseVariantKey(key);
41
+ if (parsed.breakpoint || parsed.state) bases.add(parsed.base);
42
+ }
43
+ node.variantBases = bases;
44
+ return bases;
45
+ }
46
+
47
+ /**
48
+ * Drop the cached variant state so it is recomputed on the next frame.
49
+ * Call after any prop mutation that may add/remove variant keys.
50
+ *
51
+ * Also drops the `variantOriginals` snapshot: a SetProp can change the *base*
52
+ * value of a base that carries a variant (e.g. `padding.0` updated while
53
+ * `padding@md.0` exists). The snapshot was taken from the previous frame's
54
+ * props, so restoring it would clobber the engine's new base value with the
55
+ * stale one whenever the variant isn't currently active. Clearing it forces the
56
+ * next frame to re-snapshot from the current (post-SetProp) props.
57
+ */
58
+ export function invalidateVariantCache(node: VirtualNode): void {
59
+ node.variantBases = null;
60
+ node.variantOriginals = undefined;
61
+ }
62
+
63
+ /**
64
+ * Derive the active interaction states for a node from its tracked flags and
65
+ * props, mirroring the DOM/CSS pseudo-class semantics:
66
+ * - hover : pointer is over the node (or a child resolved to it)
67
+ * - focus : node holds canvas focus
68
+ * - active : pointer is currently pressed on the node
69
+ * - disabled : node opts out via `disabled`/`enabled:false`
70
+ */
71
+ function activeStatesFor(node: VirtualNode): ActiveStates {
72
+ const disabled =
73
+ node.props.disabled === true ||
74
+ node.props.disabled === "true" ||
75
+ node.props.enabled === false ||
76
+ node.props.enabled === "false";
77
+ return {
78
+ hover: !!node.hovered,
79
+ focus: !!node.focused,
80
+ active: !!node.pressed,
81
+ disabled,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Resolve and apply variant overrides for a single node, in place.
87
+ *
88
+ * No-op for nodes without any variant keys (the common case), so the per-frame
89
+ * cost on a plain tree is one cached set lookup per node.
90
+ */
91
+ function applyVariantsToNode(node: VirtualNode, width: number): void {
92
+ const bases = getVariantBases(node);
93
+ if (bases.size === 0) return;
94
+
95
+ // Restore originals from the previous frame so we resolve from a clean base.
96
+ if (node.variantOriginals) {
97
+ for (const base of bases) {
98
+ const orig = node.variantOriginals[base];
99
+ if (orig === undefined) {
100
+ // The base had no plain value originally — remove any override we wrote.
101
+ delete node.props[base];
102
+ } else {
103
+ node.props[base] = orig;
104
+ }
105
+ }
106
+ }
107
+
108
+ const resolved = resolveVariantProps(
109
+ node.props as Record<string, unknown>,
110
+ width,
111
+ activeStatesFor(node),
112
+ );
113
+
114
+ // Capture originals (once we know which bases are involved) and apply winners.
115
+ const originals: Record<string, unknown> = {};
116
+ for (const base of bases) {
117
+ originals[base] = node.props[base];
118
+ }
119
+ node.variantOriginals = originals;
120
+
121
+ for (const base of bases) {
122
+ if (base in resolved) {
123
+ node.props[base] = resolved[base];
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Walk the tree from `root` and apply variant overrides to every node using
130
+ * `width` (the canvas/content width) as the viewport for breakpoint matching.
131
+ * Called once per frame before layout.
132
+ */
133
+ export function applyVariants(root: VirtualNode, width: number): void {
134
+ applyVariantsToNode(root, width);
135
+ for (const child of root.children) {
136
+ applyVariants(child, width);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Re-derive a node's cached computed fields (`visible`, `opacity`) from its
142
+ * current `props`. These are read on the hot paint/hit-test path, so they are
143
+ * cached on the node — but {@link applyVariants} can rewrite the underlying
144
+ * props (`visible@md`, `opacity:disabled`) after the create/setProp caches were
145
+ * set, so this must run after every variant pass or the cache goes stale.
146
+ * Single source of truth so the create / setProp / post-variant sites can't
147
+ * drift apart.
148
+ */
149
+ export function deriveNodeComputed(node: VirtualNode): void {
150
+ const o = parseFloat(node.props.opacity as string);
151
+ node.opacity = Number.isFinite(o) ? o : 1;
152
+ node.visible = node.props.visible === undefined ? true : !!node.props.visible;
153
+ }
package/src/dom/README.md CHANGED
@@ -37,17 +37,18 @@ 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
-
42
40
  ```typescript
43
41
  import { app, HypenModuleInstance } from "@hypen-space/core";
44
42
  import { Engine } from "@hypen-space/web-engine";
45
- import { createHypenClient } from "@hypen-space/web/dom";
43
+ import { DOMRenderer } from "@hypen-space/web";
46
44
 
47
45
  const engine = new Engine();
48
46
  await engine.init();
49
47
 
50
- createHypenClient(document.body, engine);
48
+ const renderer = new DOMRenderer(document.body, engine);
49
+ engine.setRenderCallback((patches) => {
50
+ renderer.applyPatches(patches);
51
+ });
51
52
  ```
52
53
 
53
54
  ### Server Import
@@ -78,7 +79,7 @@ All components are registered by default:
78
79
  ### Custom Components
79
80
 
80
81
  ```typescript
81
- const { renderer } = createHypenClient(document.body, engine);
82
+ const renderer = new DOMRenderer(document.body, engine);
82
83
  const registry = renderer.getComponentRegistry();
83
84
 
84
85
  registry.register("mycomponent", {
@@ -140,7 +141,7 @@ Style applicators are registered by default:
140
141
  ### Custom Applicators
141
142
 
142
143
  ```typescript
143
- const { renderer } = createHypenClient(document.body, engine);
144
+ const renderer = new DOMRenderer(document.body, engine);
144
145
  const applicators = renderer.getApplicatorRegistry();
145
146
 
146
147
  applicators.register("shadow", (el, value) => {
@@ -182,7 +183,7 @@ Canvas components have special applicators for drawing:
182
183
 
183
184
  ```typescript
184
185
  // Register canvas applicators
185
- const { renderer } = createHypenClient(document.body, engine);
186
+ const renderer = new DOMRenderer(document.body, engine);
186
187
  const applicators = renderer.getApplicatorRegistry();
187
188
 
188
189
  // Canvas-specific applicators
@@ -496,6 +496,7 @@ export const eventHandlers: Record<string, ApplicatorHandler> = {
496
496
  // Mouse hover events
497
497
  onMouseEnter: createEventHandler("mouseenter", { extractPayload: mousePayload }),
498
498
  onMouseLeave: createEventHandler("mouseleave", { extractPayload: mousePayload }),
499
+ onHover: createEventHandler("mouseenter", { extractPayload: mousePayload }), // Alias for onMouseEnter
499
500
 
500
501
  // Two-way binding for .bind(@state.x)
501
502
  bind: ((element: HTMLElement, value: unknown) => {
@@ -5,17 +5,8 @@
5
5
  import type { ApplicatorHandler } from "./types.js";
6
6
  import { toCssLength } from "./size.js";
7
7
 
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
- }
8
+ // Track loaded Google Fonts to avoid duplicate link tags
9
+ const loadedGoogleFonts = new Set<string>();
19
10
 
20
11
  // System font keywords that shouldn't be loaded from Google Fonts
21
12
  const systemFontKeywords = new Set([
@@ -36,42 +27,40 @@ function isSystemFont(fontName: string): boolean {
36
27
  }
37
28
 
38
29
  /**
39
- * Load a Google Font by injecting a link tag into the given document.
30
+ * Load a Google Font by injecting a link tag.
40
31
  * @param fontName - The Google Font name (e.g., "Roboto", "Open Sans")
41
- * @param doc - The Document to inject into.
42
32
  */
43
- function loadGoogleFont(fontName: string, doc: Document): void {
33
+ function loadGoogleFont(fontName: string): void {
44
34
  const normalized = fontName.trim();
45
- const loaded = getLoadedSet(doc);
46
35
 
47
36
  // Skip if already loaded or is a system font
48
- if (loaded.has(normalized) || isSystemFont(normalized)) {
37
+ if (loadedGoogleFonts.has(normalized) || isSystemFont(normalized)) {
49
38
  return;
50
39
  }
51
40
 
52
41
  // Mark as loading to avoid duplicates
53
- loaded.add(normalized);
42
+ loadedGoogleFonts.add(normalized);
54
43
 
55
44
  // Create link element for Google Fonts
56
- const link = doc.createElement("link");
45
+ const link = document.createElement("link");
57
46
  link.rel = "stylesheet";
58
47
  link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(normalized)}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
59
48
 
60
49
  // Add to document head
61
- doc.head.appendChild(link);
50
+ document.head.appendChild(link);
62
51
  }
63
52
 
64
53
  /**
65
54
  * Parse a fontFamily value and load any Google Fonts.
66
55
  * Returns a CSS-safe font-family string.
67
56
  */
68
- function processFontFamily(value: string, doc: Document): string {
57
+ function processFontFamily(value: string): string {
69
58
  // Split by comma to handle font stacks
70
59
  const fonts = value.split(",").map(f => f.trim().replace(/["']/g, ""));
71
60
 
72
61
  for (const font of fonts) {
73
62
  if (!isSystemFont(font)) {
74
- loadGoogleFont(font, doc);
63
+ loadGoogleFont(font);
75
64
  }
76
65
  }
77
66
 
@@ -97,7 +86,7 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
97
86
  fontFamily: (el, value) => {
98
87
  const fontValue = String(value);
99
88
  // Process font family and load Google Fonts as needed
100
- el.style.fontFamily = processFontFamily(fontValue, el.ownerDocument as Document);
89
+ el.style.fontFamily = processFontFamily(fontValue);
101
90
  },
102
91
 
103
92
  textAlign: (el, value) => {
@@ -138,20 +127,18 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
138
127
  export const GoogleFonts = {
139
128
  /**
140
129
  * Preload a Google Font before it's used.
141
- * Defaults to the global `document` if no doc is provided.
142
130
  */
143
- preload: (fontName: string, doc: Document = document) => loadGoogleFont(fontName, doc),
131
+ preload: loadGoogleFont,
144
132
 
145
133
  /**
146
- * Check if a font has been loaded in the given document (defaults to global).
134
+ * Check if a font has been loaded.
147
135
  */
148
- isLoaded: (fontName: string, doc: Document = document) =>
149
- getLoadedSet(doc).has(fontName.trim()),
136
+ isLoaded: (fontName: string) => loadedGoogleFonts.has(fontName.trim()),
150
137
 
151
138
  /**
152
- * Get list of loaded fonts for the given document (defaults to global).
139
+ * Get list of loaded fonts.
153
140
  */
154
- getLoadedFonts: (doc: Document = document) => Array.from(getLoadedSet(doc)),
141
+ getLoadedFonts: () => Array.from(loadedGoogleFonts),
155
142
 
156
143
  /**
157
144
  * Popular Google Fonts for reference.
@@ -40,53 +40,38 @@ import { advancedLayoutHandlers } from "./advanced-layout.js";
40
40
  import { backgroundHandlers } from "./background.js";
41
41
  import { displayHandlers } from "./display.js";
42
42
  import { transitionHandlers } from "./transition.js";
43
+ import { BREAKPOINTS as VARIANT_BREAKPOINTS, VALID_STATES } from "../../variants.js";
43
44
 
44
45
  /**
45
- * Tailwind breakpoint values for responsive variants
46
+ * Tailwind breakpoint values for responsive variants.
47
+ * Derived from the shared `variants.ts` table (single source of truth across
48
+ * DOM + Canvas renderers) and expressed as CSS `px` strings for media queries.
46
49
  */
47
- const BREAKPOINTS: Record<string, string> = {
48
- sm: '640px',
49
- md: '768px',
50
- lg: '1024px',
51
- xl: '1280px',
52
- '2xl': '1536px',
53
- };
50
+ const BREAKPOINTS: Record<string, string> = Object.fromEntries(
51
+ Object.entries(VARIANT_BREAKPOINTS).map(([name, px]) => [name, `${px}px`]),
52
+ );
54
53
 
55
54
  /**
56
- * Per-document stylesheet for variant CSS rules
55
+ * Singleton stylesheet for variant CSS rules
57
56
  */
58
- const variantStyleSheets = new Map<Document, CSSStyleSheet>();
57
+ let variantStyleSheet: CSSStyleSheet | null = null;
59
58
 
60
59
  /**
61
- * Track inserted rules to avoid duplicates (per-document)
60
+ * Track inserted rules to avoid duplicates
62
61
  */
63
- const insertedRules = new Map<Document, Set<string>>();
62
+ const insertedRules = new Set<string>();
64
63
 
65
64
  /**
66
- * Get or create the variant stylesheet for a document
65
+ * Get or create the variant stylesheet
67
66
  */
68
- function getVariantStyleSheet(doc: Document): CSSStyleSheet {
69
- let sheet = variantStyleSheets.get(doc);
70
- if (!sheet) {
71
- const style = doc.createElement('style');
67
+ function getVariantStyleSheet(): CSSStyleSheet {
68
+ if (!variantStyleSheet) {
69
+ const style = document.createElement('style');
72
70
  style.id = 'hypen-variants';
73
- doc.head.appendChild(style);
74
- sheet = style.sheet as CSSStyleSheet;
75
- variantStyleSheets.set(doc, sheet);
71
+ document.head.appendChild(style);
72
+ variantStyleSheet = style.sheet as CSSStyleSheet;
76
73
  }
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;
74
+ return variantStyleSheet;
90
75
  }
91
76
 
92
77
  /**
@@ -341,6 +326,36 @@ export class ApplicatorRegistry {
341
326
  const atIndex = name.indexOf('@');
342
327
  const colonIndex = name.indexOf(':');
343
328
 
329
+ // Combined responsive + state variant: backgroundColor@md:hover.
330
+ // Canonical key order is `<prop>@<bp>:<state>`, so the breakpoint sits
331
+ // between '@' and ':'. Emitted as a pseudo-class rule nested in a media
332
+ // query so it applies only when BOTH the width matches AND the state holds.
333
+ if (atIndex !== -1 && colonIndex !== -1 && colonIndex > atIndex) {
334
+ const prop = name.slice(0, atIndex);
335
+ const breakpoint = name.slice(atIndex + 1, colonIndex);
336
+ const state = name.slice(colonIndex + 1);
337
+ const minWidth = BREAKPOINTS[breakpoint];
338
+
339
+ if (minWidth && VALID_STATES.includes(state)) {
340
+ const cssName = this.toKebabCase(prop);
341
+ const cssValue = this.formatCssValue(cssName, value);
342
+ const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${breakpoint}-${state}-${hashValue(value)}`;
343
+ const ruleKey = `${className}:${cssValue}`;
344
+
345
+ if (!insertedRules.has(ruleKey)) {
346
+ const sheet = getVariantStyleSheet();
347
+ sheet.insertRule(
348
+ `@media (min-width: ${minWidth}) { .${className}:${state} { ${cssName}: ${cssValue}; } }`,
349
+ sheet.cssRules.length
350
+ );
351
+ insertedRules.add(ruleKey);
352
+ }
353
+
354
+ element.classList.add(className);
355
+ }
356
+ return;
357
+ }
358
+
344
359
  // Responsive variant: padding@md, width@lg, etc.
345
360
  if (atIndex !== -1) {
346
361
  const prop = name.slice(0, atIndex);
@@ -348,21 +363,19 @@ export class ApplicatorRegistry {
348
363
  const minWidth = BREAKPOINTS[breakpoint];
349
364
 
350
365
  if (minWidth) {
351
- const doc = element.ownerDocument as Document;
352
- const dedup = getInsertedRules(doc);
353
366
  const cssName = this.toKebabCase(prop);
354
367
  const cssValue = this.formatCssValue(cssName, value);
355
368
  const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${breakpoint}-${hashValue(value)}`;
356
369
  const ruleKey = `${className}:${cssValue}`;
357
370
 
358
371
  // Avoid duplicate rule insertion
359
- if (!dedup.has(ruleKey)) {
360
- const sheet = getVariantStyleSheet(doc);
372
+ if (!insertedRules.has(ruleKey)) {
373
+ const sheet = getVariantStyleSheet();
361
374
  sheet.insertRule(
362
375
  `@media (min-width: ${minWidth}) { .${className} { ${cssName}: ${cssValue}; } }`,
363
376
  sheet.cssRules.length
364
377
  );
365
- dedup.add(ruleKey);
378
+ insertedRules.add(ruleKey);
366
379
  }
367
380
 
368
381
  element.classList.add(className);
@@ -375,24 +388,21 @@ export class ApplicatorRegistry {
375
388
  const prop = name.slice(0, colonIndex);
376
389
  const state = name.slice(colonIndex + 1);
377
390
 
378
- // Only handle known CSS pseudo-states
379
- const validStates = ['hover', 'focus', 'active', 'disabled', 'focus-visible', 'focus-within'];
380
- if (validStates.includes(state)) {
381
- const doc = element.ownerDocument as Document;
382
- const dedup = getInsertedRules(doc);
391
+ // Only handle known CSS pseudo-states (shared with the Canvas resolver)
392
+ if (VALID_STATES.includes(state)) {
383
393
  const cssName = this.toKebabCase(prop);
384
394
  const cssValue = this.formatCssValue(cssName, value);
385
395
  const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${state}-${hashValue(value)}`;
386
396
  const ruleKey = `${className}:${cssValue}`;
387
397
 
388
398
  // Avoid duplicate rule insertion
389
- if (!dedup.has(ruleKey)) {
390
- const sheet = getVariantStyleSheet(doc);
399
+ if (!insertedRules.has(ruleKey)) {
400
+ const sheet = getVariantStyleSheet();
391
401
  sheet.insertRule(
392
402
  `.${className}:${state} { ${cssName}: ${cssValue}; }`,
393
403
  sheet.cssRules.length
394
404
  );
395
- dedup.add(ruleKey);
405
+ insertedRules.add(ruleKey);
396
406
  }
397
407
 
398
408
  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(doc: Document): HTMLElement {
12
- const el = doc.createElement("canvas");
11
+ create(): HTMLElement {
12
+ const el = document.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(doc: Document): HTMLElement {
12
- const el = doc.createElement("div");
11
+ create(): HTMLElement {
12
+ const el = document.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(doc: Document): HTMLElement {
9
- const el = doc.createElement("audio");
8
+ create(): HTMLElement {
9
+ const el = document.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(doc: Document): HTMLElement {
9
- const el = doc.createElement("div");
8
+ create(): HTMLElement {
9
+ const el = document.createElement("div");
10
10
  el.dataset.hypenType = "avatar";
11
11
  el.style.display = "inline-flex";
12
12
  el.style.alignItems = "center";
@@ -23,11 +23,10 @@ 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;
27
26
  // Image source - support named arg with .0 suffix, positional arg, or plain name
28
27
  const src = props["src.0"] || props["0"] || props.src || props.source;
29
28
  if (src !== undefined) {
30
- const img = doc.createElement("img");
29
+ const img = document.createElement("img");
31
30
  img.src = String(src);
32
31
  img.style.width = "100%";
33
32
  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(doc: Document): HTMLElement {
9
- const el = doc.createElement("span");
8
+ create(): HTMLElement {
9
+ const el = document.createElement("span");
10
10
  el.dataset.hypenType = "badge";
11
11
  el.style.display = "inline-block";
12
12
  el.style.padding = "4px 8px";
@@ -35,8 +35,7 @@ export const badgeHandler: ComponentHandler = {
35
35
  }
36
36
 
37
37
  // Text content
38
- // `??` (not `||`) so the literal `0`, `false`, `""` still render.
39
- const text = props["0"] ?? props.text;
38
+ const text = props["0"] || props.text;
40
39
  if (text !== undefined) {
41
40
  el.textContent = String(text);
42
41
  }
@@ -9,8 +9,8 @@
9
9
  import type { ComponentHandler } from "./index.js";
10
10
 
11
11
  export const buttonHandler: ComponentHandler = {
12
- create(doc: Document): HTMLElement {
13
- const el = doc.createElement("button");
12
+ create(): HTMLElement {
13
+ const el = document.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(doc: Document): HTMLElement {
9
- const el = doc.createElement("div");
8
+ create(): HTMLElement {
9
+ const el = document.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(doc: Document): HTMLElement {
13
- const el = doc.createElement("div");
12
+ create(): HTMLElement {
13
+ const el = document.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(doc: Document): HTMLElement {
9
- const wrapper = doc.createElement("label");
8
+ create(): HTMLElement {
9
+ const wrapper = document.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 = doc.createElement("input");
16
+ const input = document.createElement("input");
17
17
  input.type = "checkbox";
18
18
  input.dataset.hypenCheckbox = "true";
19
19
 
@@ -23,7 +23,6 @@ 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;
27
26
  const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
28
27
  if (!input) return;
29
28
 
@@ -47,7 +46,7 @@ export const checkboxHandler: ComponentHandler = {
47
46
  textNodes.forEach(node => node.remove());
48
47
 
49
48
  // Add new label text
50
- el.appendChild(doc.createTextNode(String(label)));
49
+ el.appendChild(document.createTextNode(String(label)));
51
50
  }
52
51
  },
53
52
  };
@@ -17,8 +17,8 @@
17
17
  import type { ComponentHandler } from "./index.js";
18
18
 
19
19
  export const columnHandler: ComponentHandler = {
20
- create(doc: Document): HTMLElement {
21
- const el = doc.createElement("div");
20
+ create(): HTMLElement {
21
+ const el = document.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(doc: Document): HTMLElement {
9
- const el = doc.createElement("div");
8
+ create(): HTMLElement {
9
+ const el = document.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(doc: Document): HTMLElement {
9
- const el = doc.createElement("hr");
8
+ create(): HTMLElement {
9
+ const el = document.createElement("hr");
10
10
  el.dataset.hypenType = "divider";
11
11
  el.style.border = "none";
12
12
  el.style.borderTop = "1px solid #e0e0e0";