@hypen-space/web 0.4.980 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/accessibility.d.ts +118 -11
  3. package/dist/canvas/accessibility.js +470 -108
  4. package/dist/canvas/accessibility.js.map +1 -1
  5. package/dist/canvas/dispatch.d.ts +24 -0
  6. package/dist/canvas/dispatch.js +62 -0
  7. package/dist/canvas/dispatch.js.map +1 -0
  8. package/dist/canvas/editing.d.ts +178 -0
  9. package/dist/canvas/editing.js +590 -0
  10. package/dist/canvas/editing.js.map +1 -0
  11. package/dist/canvas/events.d.ts +28 -23
  12. package/dist/canvas/events.js +90 -103
  13. package/dist/canvas/events.js.map +1 -1
  14. package/dist/canvas/focus.d.ts +78 -0
  15. package/dist/canvas/focus.js +182 -0
  16. package/dist/canvas/focus.js.map +1 -0
  17. package/dist/canvas/index.d.ts +3 -2
  18. package/dist/canvas/index.js +3 -2
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/canvas/layout.js +68 -98
  21. package/dist/canvas/layout.js.map +1 -1
  22. package/dist/canvas/paint.d.ts +16 -3
  23. package/dist/canvas/paint.js +449 -110
  24. package/dist/canvas/paint.js.map +1 -1
  25. package/dist/canvas/renderer.d.ts +28 -4
  26. package/dist/canvas/renderer.js +201 -81
  27. package/dist/canvas/renderer.js.map +1 -1
  28. package/dist/canvas/selection.js +31 -177
  29. package/dist/canvas/selection.js.map +1 -1
  30. package/dist/canvas/text-geometry.d.ts +72 -0
  31. package/dist/canvas/text-geometry.js +244 -0
  32. package/dist/canvas/text-geometry.js.map +1 -0
  33. package/dist/canvas/text.js +44 -18
  34. package/dist/canvas/text.js.map +1 -1
  35. package/dist/canvas/types.d.ts +10 -1
  36. package/dist/canvas/utils.d.ts +0 -23
  37. package/dist/canvas/utils.js +3 -67
  38. package/dist/canvas/utils.js.map +1 -1
  39. package/dist/canvas/variants.d.ts +52 -0
  40. package/dist/canvas/variants.js +156 -0
  41. package/dist/canvas/variants.js.map +1 -0
  42. package/dist/dom/a11y-styles.d.ts +20 -0
  43. package/dist/dom/a11y-styles.js +61 -0
  44. package/dist/dom/a11y-styles.js.map +1 -0
  45. package/dist/dom/applicators/aria.d.ts +19 -0
  46. package/dist/dom/applicators/aria.js +36 -0
  47. package/dist/dom/applicators/aria.js.map +1 -0
  48. package/dist/dom/applicators/events.d.ts +7 -0
  49. package/dist/dom/applicators/events.js +36 -8
  50. package/dist/dom/applicators/events.js.map +1 -1
  51. package/dist/dom/applicators/font.d.ts +11 -6
  52. package/dist/dom/applicators/font.js +16 -27
  53. package/dist/dom/applicators/font.js.map +1 -1
  54. package/dist/dom/applicators/index.js +51 -45
  55. package/dist/dom/applicators/index.js.map +1 -1
  56. package/dist/dom/canvas/index.js +2 -2
  57. package/dist/dom/canvas/index.js.map +1 -1
  58. package/dist/dom/components/app.js +2 -2
  59. package/dist/dom/components/app.js.map +1 -1
  60. package/dist/dom/components/audio.js +2 -2
  61. package/dist/dom/components/audio.js.map +1 -1
  62. package/dist/dom/components/avatar.js +3 -4
  63. package/dist/dom/components/avatar.js.map +1 -1
  64. package/dist/dom/components/badge.js +3 -4
  65. package/dist/dom/components/badge.js.map +1 -1
  66. package/dist/dom/components/button.js +2 -2
  67. package/dist/dom/components/button.js.map +1 -1
  68. package/dist/dom/components/card.js +2 -2
  69. package/dist/dom/components/card.js.map +1 -1
  70. package/dist/dom/components/center.js +2 -2
  71. package/dist/dom/components/center.js.map +1 -1
  72. package/dist/dom/components/checkbox.js +4 -5
  73. package/dist/dom/components/checkbox.js.map +1 -1
  74. package/dist/dom/components/column.js +2 -2
  75. package/dist/dom/components/column.js.map +1 -1
  76. package/dist/dom/components/container.js +2 -2
  77. package/dist/dom/components/container.js.map +1 -1
  78. package/dist/dom/components/divider.js +2 -2
  79. package/dist/dom/components/divider.js.map +1 -1
  80. package/dist/dom/components/grid.js +10 -10
  81. package/dist/dom/components/grid.js.map +1 -1
  82. package/dist/dom/components/heading.js +4 -6
  83. package/dist/dom/components/heading.js.map +1 -1
  84. package/dist/dom/components/hypenapp.d.ts +25 -1
  85. package/dist/dom/components/hypenapp.js +215 -247
  86. package/dist/dom/components/hypenapp.js.map +1 -1
  87. package/dist/dom/components/icon.js +4 -5
  88. package/dist/dom/components/icon.js.map +1 -1
  89. package/dist/dom/components/image.js +2 -2
  90. package/dist/dom/components/image.js.map +1 -1
  91. package/dist/dom/components/index.d.ts +2 -2
  92. package/dist/dom/components/index.js +9 -2
  93. package/dist/dom/components/index.js.map +1 -1
  94. package/dist/dom/components/input.js +2 -2
  95. package/dist/dom/components/input.js.map +1 -1
  96. package/dist/dom/components/link.js +2 -2
  97. package/dist/dom/components/link.js.map +1 -1
  98. package/dist/dom/components/list.js +2 -2
  99. package/dist/dom/components/list.js.map +1 -1
  100. package/dist/dom/components/paragraph.js +3 -4
  101. package/dist/dom/components/paragraph.js.map +1 -1
  102. package/dist/dom/components/progressbar.js +3 -3
  103. package/dist/dom/components/progressbar.js.map +1 -1
  104. package/dist/dom/components/route.js +2 -2
  105. package/dist/dom/components/route.js.map +1 -1
  106. package/dist/dom/components/router.js +2 -2
  107. package/dist/dom/components/router.js.map +1 -1
  108. package/dist/dom/components/row.js +10 -10
  109. package/dist/dom/components/row.js.map +1 -1
  110. package/dist/dom/components/select.js +3 -4
  111. package/dist/dom/components/select.js.map +1 -1
  112. package/dist/dom/components/slider.js +2 -2
  113. package/dist/dom/components/slider.js.map +1 -1
  114. package/dist/dom/components/spacer.js +2 -2
  115. package/dist/dom/components/spacer.js.map +1 -1
  116. package/dist/dom/components/spinner.js +27 -13
  117. package/dist/dom/components/spinner.js.map +1 -1
  118. package/dist/dom/components/stack.js +10 -10
  119. package/dist/dom/components/stack.js.map +1 -1
  120. package/dist/dom/components/switch.js +5 -6
  121. package/dist/dom/components/switch.js.map +1 -1
  122. package/dist/dom/components/tabs.d.ts +27 -0
  123. package/dist/dom/components/tabs.js +69 -0
  124. package/dist/dom/components/tabs.js.map +1 -0
  125. package/dist/dom/components/text.js +4 -7
  126. package/dist/dom/components/text.js.map +1 -1
  127. package/dist/dom/components/textarea.js +2 -2
  128. package/dist/dom/components/textarea.js.map +1 -1
  129. package/dist/dom/components/video.js +2 -2
  130. package/dist/dom/components/video.js.map +1 -1
  131. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  132. package/dist/dom/components/visuallyhidden.js +26 -0
  133. package/dist/dom/components/visuallyhidden.js.map +1 -0
  134. package/dist/dom/debug.js +6 -8
  135. package/dist/dom/debug.js.map +1 -1
  136. package/dist/dom/index.d.ts +0 -1
  137. package/dist/dom/index.js +0 -1
  138. package/dist/dom/index.js.map +1 -1
  139. package/dist/dom/operability.d.ts +100 -0
  140. package/dist/dom/operability.js +298 -0
  141. package/dist/dom/operability.js.map +1 -0
  142. package/dist/dom/renderer.d.ts +98 -5
  143. package/dist/dom/renderer.js +400 -66
  144. package/dist/dom/renderer.js.map +1 -1
  145. package/dist/dom/route-focus.d.ts +42 -0
  146. package/dist/dom/route-focus.js +88 -0
  147. package/dist/dom/route-focus.js.map +1 -0
  148. package/dist/dom/semantics.d.ts +35 -0
  149. package/dist/dom/semantics.js +184 -0
  150. package/dist/dom/semantics.js.map +1 -0
  151. package/dist/variants.d.ts +90 -0
  152. package/dist/variants.js +206 -0
  153. package/dist/variants.js.map +1 -0
  154. package/package.json +2 -2
  155. package/src/canvas/QUICKSTART.md +16 -11
  156. package/src/canvas/README.md +63 -34
  157. package/src/canvas/accessibility.ts +507 -115
  158. package/src/canvas/dispatch.ts +78 -0
  159. package/src/canvas/editing.ts +697 -0
  160. package/src/canvas/events.ts +104 -115
  161. package/src/canvas/focus.ts +216 -0
  162. package/src/canvas/index.ts +8 -9
  163. package/src/canvas/layout.ts +68 -103
  164. package/src/canvas/paint.ts +509 -109
  165. package/src/canvas/renderer.ts +239 -99
  166. package/src/canvas/selection.ts +52 -210
  167. package/src/canvas/text-geometry.ts +311 -0
  168. package/src/canvas/text.ts +45 -18
  169. package/src/canvas/types.ts +28 -1
  170. package/src/canvas/utils.ts +3 -69
  171. package/src/canvas/variants.ts +172 -0
  172. package/src/dom/README.md +8 -7
  173. package/src/dom/a11y-styles.ts +65 -0
  174. package/src/dom/applicators/aria.ts +41 -0
  175. package/src/dom/applicators/events.ts +39 -7
  176. package/src/dom/applicators/font.ts +16 -29
  177. package/src/dom/applicators/index.ts +61 -46
  178. package/src/dom/canvas/index.ts +2 -2
  179. package/src/dom/components/app.ts +2 -2
  180. package/src/dom/components/audio.ts +2 -2
  181. package/src/dom/components/avatar.ts +3 -4
  182. package/src/dom/components/badge.ts +3 -4
  183. package/src/dom/components/button.ts +2 -2
  184. package/src/dom/components/card.ts +2 -2
  185. package/src/dom/components/center.ts +2 -2
  186. package/src/dom/components/checkbox.ts +4 -5
  187. package/src/dom/components/column.ts +2 -2
  188. package/src/dom/components/container.ts +2 -2
  189. package/src/dom/components/divider.ts +2 -2
  190. package/src/dom/components/grid.ts +10 -10
  191. package/src/dom/components/heading.ts +4 -6
  192. package/src/dom/components/hypenapp.ts +246 -274
  193. package/src/dom/components/icon.ts +4 -5
  194. package/src/dom/components/image.ts +2 -2
  195. package/src/dom/components/index.ts +10 -3
  196. package/src/dom/components/input.ts +2 -2
  197. package/src/dom/components/link.ts +2 -2
  198. package/src/dom/components/list.ts +2 -2
  199. package/src/dom/components/paragraph.ts +3 -4
  200. package/src/dom/components/progressbar.ts +3 -3
  201. package/src/dom/components/route.ts +2 -2
  202. package/src/dom/components/router.ts +2 -2
  203. package/src/dom/components/row.ts +10 -10
  204. package/src/dom/components/select.ts +3 -4
  205. package/src/dom/components/slider.ts +2 -2
  206. package/src/dom/components/spacer.ts +2 -2
  207. package/src/dom/components/spinner.ts +34 -16
  208. package/src/dom/components/stack.ts +10 -10
  209. package/src/dom/components/switch.ts +5 -6
  210. package/src/dom/components/tabs.ts +76 -0
  211. package/src/dom/components/text.ts +4 -7
  212. package/src/dom/components/textarea.ts +2 -2
  213. package/src/dom/components/video.ts +2 -2
  214. package/src/dom/components/visuallyhidden.ts +30 -0
  215. package/src/dom/debug.ts +6 -8
  216. package/src/dom/index.ts +0 -8
  217. package/src/dom/operability.ts +312 -0
  218. package/src/dom/renderer.ts +457 -66
  219. package/src/dom/route-focus.ts +98 -0
  220. package/src/dom/semantics.ts +199 -0
  221. package/src/variants.ts +249 -0
  222. package/dist/canvas/input.d.ts +0 -76
  223. package/dist/canvas/input.js +0 -207
  224. package/dist/canvas/input.js.map +0 -1
  225. package/dist/client.d.ts +0 -36
  226. package/dist/client.js +0 -47
  227. package/dist/client.js.map +0 -1
  228. package/src/canvas/input.ts +0 -251
  229. package/src/client.ts +0 -88
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * HypenApp Component
3
3
  *
4
- * Embeds a remote Hypen app via WebSocket
4
+ * Embeds a remote Hypen app via WebSocket.
5
5
  *
6
6
  * Usage in Hypen DSL:
7
7
  * ```hypen
@@ -9,28 +9,182 @@
9
9
  *
10
10
  * // Or with named prop:
11
11
  * HypenApp(url: "ws://localhost:3000")
12
+ *
13
+ * // With custom loading / error UI via slot children:
14
+ * HypenApp("ws://localhost:3000") {
15
+ * Column { Spinner() Text("Connecting to app...") }.slot("loading")
16
+ * Column { Text("Couldn't reach the app") }.slot("error")
17
+ * }
12
18
  * ```
19
+ *
20
+ * Slot children are rendered by the *host* app (they're ordinary host
21
+ * nodes with full access to host state and actions); the handler only
22
+ * toggles their visibility as the connection moves through
23
+ * loading → connected / error. Children without a recognized slot render
24
+ * unconditionally. When a slot isn't provided, a built-in fallback is
25
+ * used ("Connecting..." text / red error message).
26
+ *
27
+ * The embedded app is rendered by a full `DOMRenderer` driven by its own
28
+ * `RemoteEngine` — the exact pairing the generic client uses at top level —
29
+ * so embedded apps get identical styling, event dispatch, router patch
30
+ * handling, and a11y behavior to a standalone page. (Route-change focus is
31
+ * disabled: the *host* app owns focus management; an embedded frame stealing
32
+ * focus on its internal navigations would fight it.) It renders into a
33
+ * dedicated content wrapper so slot toggling and error handling never
34
+ * touch host-owned slot children, and vice versa.
13
35
  */
14
36
 
15
37
  import type { ComponentHandler } from "./index.js";
16
38
  import { RemoteEngine } from "@hypen-space/core/remote/client";
17
39
  import type { Patch } from "@hypen-space/core/types";
40
+ import { getElementDisposables } from "@hypen-space/core/disposable";
18
41
  import { frameworkLoggers } from "@hypen-space/core/logger";
42
+ import type { DOMRenderer } from "../renderer.js";
19
43
 
20
44
  const log = frameworkLoggers.remote;
21
45
 
46
+ export const LOADING_SLOT = "loading";
47
+ export const ERROR_SLOT = "error";
48
+
49
+ type EmbedStatus = "loading" | "connected" | "error";
50
+
51
+ interface HypenAppInstance {
52
+ engine: RemoteEngine | null;
53
+ renderer: DOMRenderer | null;
54
+ url: string | null;
55
+ dispose(): void;
56
+ }
57
+
22
58
  // Store active HypenApp instances for cleanup
23
- const activeInstances = new WeakMap<
24
- HTMLElement,
25
- {
26
- engine: RemoteEngine;
27
- nodes: Map<string, HTMLElement>;
59
+ const activeInstances = new WeakMap<HTMLElement, HypenAppInstance>();
60
+
61
+ /** Direct children of the container tagged with `.slot(name)`. */
62
+ function slotChildren(element: HTMLElement, name: string): HTMLElement[] {
63
+ const out: HTMLElement[] = [];
64
+ for (const child of Array.from(element.children)) {
65
+ if ((child as HTMLElement).dataset?.hypenSlot === name) {
66
+ out.push(child as HTMLElement);
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Hide/show an element while preserving its inline display value —
74
+ * applicators set `display: flex` etc. inline, so a plain `display = ""`
75
+ * on re-show would lose the element's layout.
76
+ */
77
+ function setVisible(el: HTMLElement, visible: boolean): void {
78
+ const hidden = el.dataset.hypenSlotHidden === "true";
79
+ if (visible && hidden) {
80
+ el.style.display = el.dataset.hypenPrevDisplay ?? "";
81
+ delete el.dataset.hypenPrevDisplay;
82
+ delete el.dataset.hypenSlotHidden;
83
+ } else if (!visible && !hidden) {
84
+ el.dataset.hypenPrevDisplay = el.style.display;
85
+ el.dataset.hypenSlotHidden = "true";
86
+ el.style.display = "none";
28
87
  }
29
- >();
88
+ }
89
+
90
+ /**
91
+ * Connection-state → visibility controller for one HypenApp container.
92
+ *
93
+ * Slot children arrive via Insert patches *after* the container's Create
94
+ * (and can arrive/leave any time under ForEach/When), so visibility is
95
+ * re-applied from a MutationObserver rather than computed once.
96
+ */
97
+ class SlotVisibilityController {
98
+ private status: EmbedStatus = "loading";
99
+ private errorMessage = "";
100
+ private defaultLoading: HTMLElement | null = null;
101
+ private defaultError: HTMLElement | null = null;
102
+ private observer: MutationObserver | null = null;
103
+
104
+ constructor(
105
+ private element: HTMLElement,
106
+ private contentHost: HTMLElement,
107
+ ) {
108
+ // Slot children arrive via Insert patches *after* this controller is
109
+ // constructed, so re-apply visibility whenever the container's child
110
+ // list changes (falls back to status-change-only updates where
111
+ // MutationObserver is unavailable, e.g. non-DOM test envs).
112
+ if (typeof MutationObserver !== "undefined") {
113
+ this.observer = new MutationObserver(() => this.update());
114
+ this.observer.observe(element, { childList: true });
115
+ }
116
+ this.update();
117
+ }
118
+
119
+ setStatus(status: EmbedStatus, errorMessage?: string): void {
120
+ this.status = status;
121
+ if (errorMessage !== undefined) this.errorMessage = errorMessage;
122
+ this.element.dataset.hypenAppStatus = status;
123
+ this.update();
124
+ }
125
+
126
+ private update(): void {
127
+ const showLoading = this.status === "loading";
128
+ const showError = this.status === "error";
129
+
130
+ const loadingSlots = slotChildren(this.element, LOADING_SLOT);
131
+ const errorSlots = slotChildren(this.element, ERROR_SLOT);
132
+
133
+ for (const el of loadingSlots) setVisible(el, showLoading);
134
+ for (const el of errorSlots) setVisible(el, showError);
135
+
136
+ // Built-in fallbacks only cover states the host didn't style.
137
+ this.toggleDefaultLoading(showLoading && loadingSlots.length === 0);
138
+ this.toggleDefaultError(showError && errorSlots.length === 0);
139
+
140
+ // A failed connection may leave a partial embedded tree behind;
141
+ // don't show it alongside the error UI.
142
+ setVisible(this.contentHost, !showError);
143
+ }
144
+
145
+ private toggleDefaultLoading(show: boolean): void {
146
+ if (show && !this.defaultLoading) {
147
+ const el = document.createElement("div");
148
+ el.className = "hypen-app-loading";
149
+ el.textContent = "Connecting...";
150
+ this.defaultLoading = el;
151
+ this.element.appendChild(el);
152
+ } else if (!show && this.defaultLoading) {
153
+ this.defaultLoading.remove();
154
+ this.defaultLoading = null;
155
+ }
156
+ }
157
+
158
+ private toggleDefaultError(show: boolean): void {
159
+ if (show && !this.defaultError) {
160
+ const el = document.createElement("div");
161
+ el.className = "hypen-app-error";
162
+ el.style.color = "red";
163
+ this.defaultError = el;
164
+ this.element.appendChild(el);
165
+ }
166
+ if (this.defaultError) {
167
+ if (show) {
168
+ this.defaultError.textContent = this.errorMessage || "HypenApp: Connection failed";
169
+ } else {
170
+ this.defaultError.remove();
171
+ this.defaultError = null;
172
+ }
173
+ }
174
+ }
175
+
176
+ dispose(): void {
177
+ this.observer?.disconnect();
178
+ this.defaultLoading?.remove();
179
+ this.defaultError?.remove();
180
+ this.defaultLoading = null;
181
+ this.defaultError = null;
182
+ }
183
+ }
30
184
 
31
185
  export const hypenAppHandler: ComponentHandler = {
32
- create(doc: Document): HTMLElement {
33
- const el = doc.createElement("div");
186
+ create(): HTMLElement {
187
+ const el = document.createElement("div");
34
188
  el.dataset.hypenType = "hypenapp";
35
189
  el.style.display = "contents"; // Don't affect layout
36
190
  return el;
@@ -40,57 +194,101 @@ export const hypenAppHandler: ComponentHandler = {
40
194
  // Get URL from props (can be positional "0" or named "url")
41
195
  const url = props["0"] || props.url;
42
196
 
197
+ const existing = activeInstances.get(element);
198
+ if (existing) {
199
+ if (existing.url === url) return; // Already connected to this app
200
+ // URL changed (or became invalid) — tear down and reconnect below.
201
+ existing.dispose();
202
+ activeInstances.delete(element);
203
+ }
204
+
205
+ // The nested renderer owns this wrapper; host-owned slot children are
206
+ // siblings of it, so neither side's cleanup can clobber the other.
207
+ const contentHost = document.createElement("div");
208
+ contentHost.className = "hypen-app-content";
209
+ contentHost.style.display = "contents";
210
+ element.appendChild(contentHost);
211
+
212
+ const visibility = new SlotVisibilityController(element, contentHost);
213
+
43
214
  if (!url || typeof url !== "string") {
44
215
  log.error("HypenApp: URL is required");
45
- element.innerHTML = '<div style="color: red;">HypenApp: URL required</div>';
216
+ visibility.setStatus("error", "HypenApp: URL required");
217
+ activeInstances.set(element, {
218
+ engine: null,
219
+ renderer: null,
220
+ url: null,
221
+ dispose() {
222
+ visibility.dispose();
223
+ contentHost.remove();
224
+ },
225
+ });
46
226
  return;
47
227
  }
48
228
 
49
- // Check if already connected
50
- const existing = activeInstances.get(element);
51
- if (existing) {
52
- // Already connected, don't reconnect
53
- return;
54
- }
229
+ // Lazy-required to break the static cycle:
230
+ // renderer.ts components/index.ts → hypenapp.ts → renderer.ts
231
+ const { DOMRenderer: Renderer } =
232
+ require("../renderer.js") as typeof import("../renderer.js");
55
233
 
56
- // Create the remote engine
57
234
  const engine = new RemoteEngine(url, {
58
235
  autoReconnect: props.autoReconnect ?? true,
59
236
  reconnectInterval: props.reconnectInterval ?? 3000,
60
237
  maxReconnectAttempts: props.maxReconnectAttempts ?? 10,
61
238
  });
62
239
 
63
- // Map to track created nodes
64
- const nodes = new Map<string, HTMLElement>();
65
- let rootId: string | null = null;
66
-
67
- // Store instance for cleanup
68
- activeInstances.set(element, { engine, nodes });
69
-
70
- // Set up patch handling
71
- engine.onPatches((patches) => {
72
- applyPatches(element, nodes, patches, engine, (id) => {
73
- if (!rootId) rootId = id;
74
- });
240
+ const renderer = new Renderer(contentHost, engine, undefined, { routeFocus: "off" });
241
+ const instance: HypenAppInstance = {
242
+ engine,
243
+ renderer,
244
+ url,
245
+ dispose() {
246
+ engine.disconnect();
247
+ visibility.dispose();
248
+ contentHost.remove();
249
+ },
250
+ };
251
+ activeInstances.set(element, instance);
252
+
253
+ // Same replacement-root handling as the generic client: a fresh root
254
+ // insert after we've already rendered one means "replace the tree".
255
+ let hasRenderedRoot = false;
256
+ engine.onPatches((patches: Patch[]) => {
257
+ visibility.setStatus("connected");
258
+ const createdIds = new Set(
259
+ patches.filter((patch) => patch.type === "create").map((patch) => patch.id),
260
+ );
261
+ const hasReplacementRoot = patches.some(
262
+ (patch) =>
263
+ patch.type === "insert" &&
264
+ patch.parentId === "root" &&
265
+ createdIds.has(patch.id),
266
+ );
267
+ if (hasReplacementRoot && hasRenderedRoot) {
268
+ renderer.clear();
269
+ }
270
+ renderer.applyPatches(patches);
271
+ hasRenderedRoot = hasRenderedRoot || hasReplacementRoot;
75
272
  });
76
273
 
77
- // Show loading state
78
- element.innerHTML = '<div class="hypen-app-loading">Connecting...</div>';
79
-
80
- // Connect
81
274
  engine
82
275
  .connect()
83
- .then(() => {
84
- // Clear loading state - patches will populate content
85
- element.innerHTML = "";
276
+ .then((result: any) => {
277
+ if (result && result.ok === false) {
278
+ visibility.setStatus(
279
+ "error",
280
+ `HypenApp: Connection failed - ${result.error?.message ?? result.error}`,
281
+ );
282
+ log.error("HypenApp connection failed:", result.error);
283
+ return;
284
+ }
86
285
  log.debug(`HypenApp connected to ${url}`);
87
286
  })
88
- .catch((error) => {
89
- element.innerHTML = `<div style="color: red;">HypenApp: Connection failed - ${error.message}</div>`;
287
+ .catch((error: Error) => {
288
+ visibility.setStatus("error", `HypenApp: Connection failed - ${error.message}`);
90
289
  log.error("HypenApp connection failed:", error);
91
290
  });
92
291
 
93
- // Handle disconnection
94
292
  engine.onDisconnect(() => {
95
293
  log.debug("HypenApp disconnected");
96
294
  });
@@ -99,251 +297,25 @@ export const hypenAppHandler: ComponentHandler = {
99
297
  log.error("HypenApp error:", error);
100
298
  });
101
299
 
102
- // Cleanup on element removal
103
- const observer = new MutationObserver((mutations) => {
104
- for (const mutation of mutations) {
105
- for (const removedNode of mutation.removedNodes) {
106
- if (removedNode === element || (removedNode as Element).contains?.(element)) {
107
- engine.disconnect();
108
- activeInstances.delete(element);
109
- observer.disconnect();
110
- log.debug("HypenApp cleaned up");
111
- return;
112
- }
113
- }
114
- }
300
+ // Cleanup rides the renderer's element-disposal channel: the host
301
+ // DOMRenderer runs this on a real `remove` patch (and on `clear()`),
302
+ // but NOT on a Router `detach` — so a cached route keeps its live
303
+ // connection and re-attaches warm, while LRU eviction closes it.
304
+ getElementDisposables(element).addCallback(() => {
305
+ instance.dispose();
306
+ activeInstances.delete(element);
307
+ log.debug("HypenApp cleaned up");
115
308
  });
116
-
117
- // Observe parent for removal
118
- if (element.parentNode) {
119
- observer.observe(element.parentNode, { childList: true, subtree: true });
120
- }
121
309
  },
122
310
  };
123
311
 
124
- /**
125
- * Minimal patch application for HypenApp container
126
- */
127
- function applyPatches(
128
- container: HTMLElement,
129
- nodes: Map<string, HTMLElement>,
130
- patches: Patch[],
131
- engine: RemoteEngine,
132
- onRoot: (id: string) => void
133
- ): void {
134
- for (const patch of patches) {
135
- switch (patch.type) {
136
- case "create": {
137
- const el = createElement(patch.elementType!, patch.props || {});
138
- el.dataset.hypenId = patch.id!;
139
- (el as any).__hypenEngine = engine;
140
- nodes.set(patch.id!, el);
141
- break;
142
- }
143
-
144
- case "setProp": {
145
- const el = nodes.get(patch.id!);
146
- if (el) {
147
- applyProp(el, patch.name!, patch.value);
148
- }
149
- break;
150
- }
151
-
152
- case "setText": {
153
- const el = nodes.get(patch.id!);
154
- if (el) {
155
- el.textContent = patch.text!;
156
- }
157
- break;
158
- }
159
-
160
- case "insert": {
161
- const parentId = patch.parentId!;
162
- const parent = parentId === "root" ? container : nodes.get(parentId);
163
- const child = nodes.get(patch.id!);
164
- const beforeId = patch.beforeId;
165
-
166
- if (parent && child) {
167
- if (parentId === "root") {
168
- onRoot(patch.id!);
169
- }
170
-
171
- if (beforeId) {
172
- const before = nodes.get(beforeId);
173
- if (before && before.parentNode === parent) {
174
- parent.insertBefore(child, before);
175
- } else if (!parent.contains(child)) {
176
- parent.appendChild(child);
177
- }
178
- } else if (!parent.contains(child)) {
179
- parent.appendChild(child);
180
- }
181
- }
182
- break;
183
- }
184
-
185
- case "move": {
186
- const parentId = patch.parentId!;
187
- const parent = parentId === "root" ? container : nodes.get(parentId);
188
- const child = nodes.get(patch.id!);
189
- const beforeId = patch.beforeId;
190
-
191
- if (parent && child) {
192
- if (beforeId) {
193
- const before = nodes.get(beforeId);
194
- if (before && before.parentNode === parent) {
195
- parent.insertBefore(child, before);
196
- }
197
- } else {
198
- parent.appendChild(child);
199
- }
200
- }
201
- break;
202
- }
203
-
204
- case "remove": {
205
- const el = nodes.get(patch.id!);
206
- if (el && el.parentNode) {
207
- el.parentNode.removeChild(el);
208
- }
209
- nodes.delete(patch.id!);
210
- break;
211
- }
212
- }
213
- }
214
- }
215
-
216
- /**
217
- * Create element by type
218
- */
219
- function createElement(type: string, props: Record<string, any>): HTMLElement {
220
- const normalizedType = type.toLowerCase();
221
-
222
- // Map Hypen types to HTML elements
223
- const tagMap: Record<string, string> = {
224
- column: "div",
225
- row: "div",
226
- text: "span",
227
- button: "button",
228
- input: "input",
229
- image: "img",
230
- container: "div",
231
- box: "div",
232
- center: "div",
233
- list: "div",
234
- spacer: "div",
235
- stack: "div",
236
- divider: "hr",
237
- grid: "div",
238
- card: "div",
239
- heading: "h2",
240
- link: "a",
241
- textarea: "textarea",
242
- checkbox: "input",
243
- select: "select",
244
- slider: "input",
245
- switch: "input",
246
- spinner: "div",
247
- badge: "span",
248
- avatar: "img",
249
- progressbar: "div",
250
- video: "video",
251
- audio: "audio",
252
- };
253
-
254
- const tag = tagMap[normalizedType] || "div";
255
- const el = document.createElement(tag);
256
- el.dataset.hypenType = normalizedType;
257
-
258
- // Apply basic styles
259
- if (normalizedType === "column") {
260
- el.style.display = "flex";
261
- el.style.flexDirection = "column";
262
- } else if (normalizedType === "row") {
263
- el.style.display = "flex";
264
- el.style.flexDirection = "row";
265
- } else if (normalizedType === "center") {
266
- el.style.display = "flex";
267
- el.style.alignItems = "center";
268
- el.style.justifyContent = "center";
269
- } else if (normalizedType === "text") {
270
- // Text content from props
271
- if (props["0"]) {
272
- el.textContent = String(props["0"]);
273
- }
274
- } else if (normalizedType === "button") {
275
- el.style.cursor = "pointer";
276
- } else if (normalizedType === "checkbox" || normalizedType === "switch") {
277
- (el as HTMLInputElement).type = "checkbox";
278
- } else if (normalizedType === "slider") {
279
- (el as HTMLInputElement).type = "range";
280
- }
281
-
282
- return el;
283
- }
284
-
285
- /**
286
- * Apply a prop to an element
287
- */
288
- function applyProp(el: HTMLElement, name: string, value: any): void {
289
- // Text content
290
- if (name === "0" || name === "text") {
291
- el.textContent = String(value);
292
- return;
293
- }
294
-
295
- // Style props
296
- const styleProps: Record<string, string> = {
297
- padding: "padding",
298
- margin: "margin",
299
- backgroundColor: "backgroundColor",
300
- background: "background",
301
- color: "color",
302
- fontSize: "fontSize",
303
- fontWeight: "fontWeight",
304
- width: "width",
305
- height: "height",
306
- minWidth: "minWidth",
307
- minHeight: "minHeight",
308
- maxWidth: "maxWidth",
309
- maxHeight: "maxHeight",
310
- borderRadius: "borderRadius",
311
- border: "border",
312
- gap: "gap",
313
- flex: "flex",
314
- opacity: "opacity",
315
- overflow: "overflow",
316
- };
317
-
318
- if (styleProps[name]) {
319
- const cssValue = typeof value === "number" ? `${value}px` : String(value);
320
- (el.style as any)[styleProps[name]] = cssValue;
321
- return;
322
- }
323
-
324
- // Event handlers
325
- if (name === "onClick" || name === "onclick") {
326
- el.onclick = () => {
327
- const engine = (el as any).__hypenEngine as RemoteEngine;
328
- if (engine && typeof value === "string" && value.startsWith("@actions.")) {
329
- const action = value.replace("@actions.", "");
330
- engine.dispatchAction(action);
331
- }
332
- };
333
- return;
334
- }
335
-
336
- // Other attributes
337
- el.setAttribute(name, String(value));
338
- }
339
-
340
312
  /**
341
313
  * Disconnect a HypenApp instance
342
314
  */
343
315
  export function disconnectHypenApp(element: HTMLElement): void {
344
316
  const instance = activeInstances.get(element);
345
317
  if (instance) {
346
- instance.engine.disconnect();
318
+ instance.engine?.disconnect();
347
319
  activeInstances.delete(element);
348
320
  }
349
321
  }
@@ -11,8 +11,8 @@ import type { ComponentHandler } from "./index.js";
11
11
  const SVG_NS = "http://www.w3.org/2000/svg";
12
12
 
13
13
  export const iconHandler: ComponentHandler = {
14
- create(doc: Document): HTMLElement {
15
- const el = doc.createElement("span");
14
+ create(): HTMLElement {
15
+ const el = document.createElement("span");
16
16
  el.style.display = "inline-flex";
17
17
  el.style.alignItems = "center";
18
18
  el.style.justifyContent = "center";
@@ -22,7 +22,6 @@ export const iconHandler: ComponentHandler = {
22
22
  },
23
23
 
24
24
  applyProps(el: HTMLElement, props: Record<string, any>): void {
25
- const doc = el.ownerDocument as Document;
26
25
  // Server-resolved icon data (from engine's ResourceRegistry)
27
26
  const paths: Array<{
28
27
  d: string;
@@ -40,7 +39,7 @@ export const iconHandler: ComponentHandler = {
40
39
 
41
40
  if (paths && Array.isArray(paths)) {
42
41
  // Render from pre-resolved SVG path data
43
- const svg = doc.createElementNS(SVG_NS, "svg");
42
+ const svg = document.createElementNS(SVG_NS, "svg");
44
43
  svg.setAttribute("xmlns", SVG_NS);
45
44
  svg.setAttribute("width", String(size));
46
45
  svg.setAttribute("height", String(size));
@@ -49,7 +48,7 @@ export const iconHandler: ComponentHandler = {
49
48
  svg.style.display = "block";
50
49
 
51
50
  for (const pathData of paths) {
52
- const path = doc.createElementNS(SVG_NS, "path");
51
+ const path = document.createElementNS(SVG_NS, "path");
53
52
  path.setAttribute("d", pathData.d);
54
53
  path.setAttribute("fill", pathData.fill || "none");
55
54
  path.setAttribute(
@@ -5,8 +5,8 @@
5
5
  import type { ComponentHandler } from "./index.js";
6
6
 
7
7
  export const imageHandler: ComponentHandler = {
8
- create(doc: Document): HTMLElement {
9
- const el = doc.createElement("img");
8
+ create(): HTMLElement {
9
+ const el = document.createElement("img");
10
10
  el.dataset.hypenType = "image";
11
11
  return el as any as HTMLElement;
12
12
  },
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  export interface ComponentHandler {
8
- create(doc: Document): HTMLElement;
8
+ create(): HTMLElement;
9
9
  applyProps?(element: HTMLElement, props: Record<string, any>): void;
10
10
  }
11
11
 
@@ -34,12 +34,12 @@ export class ComponentRegistry {
34
34
  /**
35
35
  * Create element for a component type
36
36
  */
37
- createElement(type: string, props: Record<string, any> = {}, doc: Document = document): HTMLElement | null {
37
+ createElement(type: string, props: Record<string, any> = {}): HTMLElement | null {
38
38
  if (!type) return null;
39
39
  const handler = this.get(type);
40
40
  if (!handler) return null;
41
41
 
42
- const element = handler.create(doc);
42
+ const element = handler.create();
43
43
  if (handler.applyProps) {
44
44
  handler.applyProps(element, props);
45
45
  }
@@ -84,8 +84,11 @@ export class ComponentRegistry {
84
84
  const { hypenAppHandler } = require("./hypenapp.js");
85
85
  const { appHandler } = require("./app.js");
86
86
  const { iconHandler } = require("./icon.js");
87
+ const { visuallyHiddenHandler } = require("./visuallyhidden.js");
88
+ const { tabsHandler, tabHandler, tabPanelHandler, optionHandler } = require("./tabs.js");
87
89
 
88
90
  this.register("app", appHandler);
91
+ this.register("visuallyhidden", visuallyHiddenHandler);
89
92
  this.register("icon", iconHandler);
90
93
  this.register("column", columnHandler);
91
94
  this.register("row", rowHandler);
@@ -116,6 +119,10 @@ export class ComponentRegistry {
116
119
  this.register("video", videoHandler);
117
120
  this.register("audio", audioHandler);
118
121
  this.register("paragraph", paragraphHandler);
122
+ this.register("tabs", tabsHandler);
123
+ this.register("tab", tabHandler);
124
+ this.register("tabpanel", tabPanelHandler);
125
+ this.register("option", optionHandler);
119
126
  this.register("router", routerHandler);
120
127
  this.register("route", routeHandler);
121
128
  this.register("hypenapp", hypenAppHandler);