@hypen-space/web 0.2.11 → 0.3.0

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 (39) hide show
  1. package/dist/src/dom/applicators/effects.js +38 -2
  2. package/dist/src/dom/applicators/effects.js.map +3 -3
  3. package/dist/src/dom/applicators/events.js +280 -397
  4. package/dist/src/dom/applicators/events.js.map +5 -4
  5. package/dist/src/dom/applicators/font.js +94 -5
  6. package/dist/src/dom/applicators/font.js.map +3 -3
  7. package/dist/src/dom/applicators/index.js +590 -425
  8. package/dist/src/dom/applicators/index.js.map +10 -9
  9. package/dist/src/dom/applicators/layout.js +33 -5
  10. package/dist/src/dom/applicators/layout.js.map +3 -3
  11. package/dist/src/dom/applicators/size.js +81 -16
  12. package/dist/src/dom/applicators/size.js.map +3 -3
  13. package/dist/src/dom/components/hypenapp.js +296 -0
  14. package/dist/src/dom/components/hypenapp.js.map +10 -0
  15. package/dist/src/dom/components/index.js +263 -1
  16. package/dist/src/dom/components/index.js.map +5 -4
  17. package/dist/src/dom/element-data.js +140 -0
  18. package/dist/src/dom/element-data.js.map +10 -0
  19. package/dist/src/dom/index.js +857 -430
  20. package/dist/src/dom/index.js.map +13 -11
  21. package/dist/src/dom/renderer.js +857 -430
  22. package/dist/src/dom/renderer.js.map +13 -11
  23. package/dist/src/hypen.js +857 -430
  24. package/dist/src/hypen.js.map +13 -11
  25. package/dist/src/index.js +862 -430
  26. package/dist/src/index.js.map +15 -12
  27. package/package.json +3 -3
  28. package/src/canvas/QUICKSTART.md +2 -4
  29. package/src/dom/applicators/effects.ts +45 -1
  30. package/src/dom/applicators/events.ts +348 -537
  31. package/src/dom/applicators/font.ts +127 -2
  32. package/src/dom/applicators/index.ts +117 -7
  33. package/src/dom/applicators/layout.ts +40 -4
  34. package/src/dom/applicators/size.ts +101 -16
  35. package/src/dom/components/hypenapp.ts +348 -0
  36. package/src/dom/components/index.ts +2 -0
  37. package/src/dom/element-data.ts +234 -0
  38. package/src/dom/renderer.ts +8 -5
  39. package/src/index.ts +3 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * HypenApp Component
3
+ *
4
+ * Embeds a remote Hypen app via WebSocket
5
+ *
6
+ * Usage in Hypen DSL:
7
+ * ```hypen
8
+ * HypenApp("ws://localhost:3000")
9
+ *
10
+ * // Or with named prop:
11
+ * HypenApp(url: "ws://localhost:3000")
12
+ * ```
13
+ */
14
+
15
+ import type { ComponentHandler } from "./index.js";
16
+ import { RemoteEngine } from "@hypen-space/core/remote/client";
17
+ import type { Patch } from "@hypen-space/core/remote";
18
+
19
+ // Store active HypenApp instances for cleanup
20
+ const activeInstances = new WeakMap<
21
+ HTMLElement,
22
+ {
23
+ engine: RemoteEngine;
24
+ nodes: Map<string, HTMLElement>;
25
+ }
26
+ >();
27
+
28
+ export const hypenAppHandler: ComponentHandler = {
29
+ create(): HTMLElement {
30
+ const el = document.createElement("div");
31
+ el.dataset.hypenType = "hypenapp";
32
+ el.style.display = "contents"; // Don't affect layout
33
+ return el;
34
+ },
35
+
36
+ applyProps(element: HTMLElement, props: Record<string, any>): void {
37
+ // Get URL from props (can be positional "0" or named "url")
38
+ const url = props["0"] || props.url;
39
+
40
+ if (!url || typeof url !== "string") {
41
+ console.error("[HypenApp] URL is required");
42
+ element.innerHTML = '<div style="color: red;">HypenApp: URL required</div>';
43
+ return;
44
+ }
45
+
46
+ // Check if already connected
47
+ const existing = activeInstances.get(element);
48
+ if (existing) {
49
+ // Already connected, don't reconnect
50
+ return;
51
+ }
52
+
53
+ // Create the remote engine
54
+ const engine = new RemoteEngine(url, {
55
+ autoReconnect: props.autoReconnect ?? true,
56
+ reconnectInterval: props.reconnectInterval ?? 3000,
57
+ maxReconnectAttempts: props.maxReconnectAttempts ?? 10,
58
+ });
59
+
60
+ // Map to track created nodes
61
+ const nodes = new Map<string, HTMLElement>();
62
+ let rootId: string | null = null;
63
+
64
+ // Store instance for cleanup
65
+ activeInstances.set(element, { engine, nodes });
66
+
67
+ // Set up patch handling
68
+ engine.onPatches((patches) => {
69
+ applyPatches(element, nodes, patches, engine, (id) => {
70
+ if (!rootId) rootId = id;
71
+ });
72
+ });
73
+
74
+ // Show loading state
75
+ element.innerHTML = '<div class="hypen-app-loading">Connecting...</div>';
76
+
77
+ // Connect
78
+ engine
79
+ .connect()
80
+ .then(() => {
81
+ // Clear loading state - patches will populate content
82
+ element.innerHTML = "";
83
+ console.log(`[HypenApp] Connected to ${url}`);
84
+ })
85
+ .catch((error) => {
86
+ element.innerHTML = `<div style="color: red;">HypenApp: Connection failed - ${error.message}</div>`;
87
+ console.error("[HypenApp] Connection failed:", error);
88
+ });
89
+
90
+ // Handle disconnection
91
+ engine.onDisconnect(() => {
92
+ console.log("[HypenApp] Disconnected");
93
+ });
94
+
95
+ engine.onError((error) => {
96
+ console.error("[HypenApp] Error:", error);
97
+ });
98
+
99
+ // Cleanup on element removal
100
+ const observer = new MutationObserver((mutations) => {
101
+ for (const mutation of mutations) {
102
+ for (const removedNode of mutation.removedNodes) {
103
+ if (removedNode === element || (removedNode as Element).contains?.(element)) {
104
+ engine.disconnect();
105
+ activeInstances.delete(element);
106
+ observer.disconnect();
107
+ console.log("[HypenApp] Cleaned up");
108
+ return;
109
+ }
110
+ }
111
+ }
112
+ });
113
+
114
+ // Observe parent for removal
115
+ if (element.parentNode) {
116
+ observer.observe(element.parentNode, { childList: true, subtree: true });
117
+ }
118
+ },
119
+ };
120
+
121
+ /**
122
+ * Minimal patch application for HypenApp container
123
+ */
124
+ function applyPatches(
125
+ container: HTMLElement,
126
+ nodes: Map<string, HTMLElement>,
127
+ patches: Patch[],
128
+ engine: RemoteEngine,
129
+ onRoot: (id: string) => void
130
+ ): void {
131
+ for (const patch of patches) {
132
+ switch (patch.type) {
133
+ case "create": {
134
+ const el = createElement((patch as any).element_type, patch.props || {});
135
+ el.dataset.hypenId = patch.id!;
136
+ (el as any).__hypenEngine = engine;
137
+ nodes.set(patch.id!, el);
138
+ break;
139
+ }
140
+
141
+ case "setProp": {
142
+ const el = nodes.get(patch.id!);
143
+ if (el) {
144
+ applyProp(el, patch.name!, patch.value);
145
+ }
146
+ break;
147
+ }
148
+
149
+ case "setText": {
150
+ const el = nodes.get(patch.id!);
151
+ if (el) {
152
+ el.textContent = patch.text!;
153
+ }
154
+ break;
155
+ }
156
+
157
+ case "insert": {
158
+ const parentId = (patch as any).parent_id;
159
+ const parent = parentId === "root" ? container : nodes.get(parentId);
160
+ const child = nodes.get(patch.id!);
161
+ const beforeId = (patch as any).before_id;
162
+
163
+ if (parent && child) {
164
+ if (parentId === "root") {
165
+ onRoot(patch.id!);
166
+ }
167
+
168
+ if (beforeId) {
169
+ const before = nodes.get(beforeId);
170
+ if (before && before.parentNode === parent) {
171
+ parent.insertBefore(child, before);
172
+ } else if (!parent.contains(child)) {
173
+ parent.appendChild(child);
174
+ }
175
+ } else if (!parent.contains(child)) {
176
+ parent.appendChild(child);
177
+ }
178
+ }
179
+ break;
180
+ }
181
+
182
+ case "move": {
183
+ const parentId = (patch as any).parent_id;
184
+ const parent = parentId === "root" ? container : nodes.get(parentId);
185
+ const child = nodes.get(patch.id!);
186
+ const beforeId = (patch as any).before_id;
187
+
188
+ if (parent && child) {
189
+ if (beforeId) {
190
+ const before = nodes.get(beforeId);
191
+ if (before && before.parentNode === parent) {
192
+ parent.insertBefore(child, before);
193
+ }
194
+ } else {
195
+ parent.appendChild(child);
196
+ }
197
+ }
198
+ break;
199
+ }
200
+
201
+ case "remove": {
202
+ const el = nodes.get(patch.id!);
203
+ if (el && el.parentNode) {
204
+ el.parentNode.removeChild(el);
205
+ }
206
+ nodes.delete(patch.id!);
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Create element by type
215
+ */
216
+ function createElement(type: string, props: Record<string, any>): HTMLElement {
217
+ const normalizedType = type.toLowerCase();
218
+
219
+ // Map Hypen types to HTML elements
220
+ const tagMap: Record<string, string> = {
221
+ column: "div",
222
+ row: "div",
223
+ text: "span",
224
+ button: "button",
225
+ input: "input",
226
+ image: "img",
227
+ container: "div",
228
+ box: "div",
229
+ center: "div",
230
+ list: "div",
231
+ spacer: "div",
232
+ stack: "div",
233
+ divider: "hr",
234
+ grid: "div",
235
+ card: "div",
236
+ heading: "h2",
237
+ link: "a",
238
+ textarea: "textarea",
239
+ checkbox: "input",
240
+ select: "select",
241
+ slider: "input",
242
+ switch: "input",
243
+ spinner: "div",
244
+ badge: "span",
245
+ avatar: "img",
246
+ progressbar: "div",
247
+ video: "video",
248
+ audio: "audio",
249
+ };
250
+
251
+ const tag = tagMap[normalizedType] || "div";
252
+ const el = document.createElement(tag);
253
+ el.dataset.hypenType = normalizedType;
254
+
255
+ // Apply basic styles
256
+ if (normalizedType === "column") {
257
+ el.style.display = "flex";
258
+ el.style.flexDirection = "column";
259
+ } else if (normalizedType === "row") {
260
+ el.style.display = "flex";
261
+ el.style.flexDirection = "row";
262
+ } else if (normalizedType === "center") {
263
+ el.style.display = "flex";
264
+ el.style.alignItems = "center";
265
+ el.style.justifyContent = "center";
266
+ } else if (normalizedType === "text") {
267
+ // Text content from props
268
+ if (props["0"]) {
269
+ el.textContent = String(props["0"]);
270
+ }
271
+ } else if (normalizedType === "button") {
272
+ el.style.cursor = "pointer";
273
+ } else if (normalizedType === "checkbox" || normalizedType === "switch") {
274
+ (el as HTMLInputElement).type = "checkbox";
275
+ } else if (normalizedType === "slider") {
276
+ (el as HTMLInputElement).type = "range";
277
+ }
278
+
279
+ return el;
280
+ }
281
+
282
+ /**
283
+ * Apply a prop to an element
284
+ */
285
+ function applyProp(el: HTMLElement, name: string, value: any): void {
286
+ // Text content
287
+ if (name === "0" || name === "text") {
288
+ el.textContent = String(value);
289
+ return;
290
+ }
291
+
292
+ // Style props
293
+ const styleProps: Record<string, string> = {
294
+ padding: "padding",
295
+ margin: "margin",
296
+ backgroundColor: "backgroundColor",
297
+ background: "background",
298
+ color: "color",
299
+ fontSize: "fontSize",
300
+ fontWeight: "fontWeight",
301
+ width: "width",
302
+ height: "height",
303
+ minWidth: "minWidth",
304
+ minHeight: "minHeight",
305
+ maxWidth: "maxWidth",
306
+ maxHeight: "maxHeight",
307
+ borderRadius: "borderRadius",
308
+ border: "border",
309
+ gap: "gap",
310
+ flex: "flex",
311
+ alignItems: "alignItems",
312
+ justifyContent: "justifyContent",
313
+ opacity: "opacity",
314
+ overflow: "overflow",
315
+ };
316
+
317
+ if (styleProps[name]) {
318
+ const cssValue = typeof value === "number" ? `${value}px` : String(value);
319
+ (el.style as any)[styleProps[name]] = cssValue;
320
+ return;
321
+ }
322
+
323
+ // Event handlers
324
+ if (name === "onClick" || name === "onclick") {
325
+ el.onclick = () => {
326
+ const engine = (el as any).__hypenEngine as RemoteEngine;
327
+ if (engine && typeof value === "string" && value.startsWith("@actions.")) {
328
+ const action = value.replace("@actions.", "");
329
+ engine.dispatchAction(action);
330
+ }
331
+ };
332
+ return;
333
+ }
334
+
335
+ // Other attributes
336
+ el.setAttribute(name, String(value));
337
+ }
338
+
339
+ /**
340
+ * Disconnect a HypenApp instance
341
+ */
342
+ export function disconnectHypenApp(element: HTMLElement): void {
343
+ const instance = activeInstances.get(element);
344
+ if (instance) {
345
+ instance.engine.disconnect();
346
+ activeInstances.delete(element);
347
+ }
348
+ }
@@ -79,6 +79,7 @@ export class ComponentRegistry {
79
79
  const { paragraphHandler } = require("./paragraph.js");
80
80
  const { routerHandler } = require("./router.js");
81
81
  const { routeHandler } = require("./route.js");
82
+ const { hypenAppHandler } = require("./hypenapp.js");
82
83
 
83
84
  this.register("column", columnHandler);
84
85
  this.register("row", rowHandler);
@@ -111,5 +112,6 @@ export class ComponentRegistry {
111
112
  this.register("paragraph", paragraphHandler);
112
113
  this.register("router", routerHandler);
113
114
  this.register("route", routeHandler);
115
+ this.register("hypenapp", hypenAppHandler);
114
116
  }
115
117
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Type-Safe Element Data
3
+ *
4
+ * Provides strongly-typed access to Hypen-specific data attached to DOM elements.
5
+ * Eliminates `as any` casts throughout the codebase.
6
+ */
7
+
8
+ import { type DisposableStack, getElementDisposables } from "@hypen/core";
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Engine interface for dispatching actions
16
+ */
17
+ export interface IEngine {
18
+ dispatchAction(name: string, payload?: unknown): void;
19
+ }
20
+
21
+ /**
22
+ * All Hypen-specific data attached to an element
23
+ */
24
+ export interface HypenElementData {
25
+ /** Engine reference for action dispatch */
26
+ engine?: IEngine;
27
+ /** Target key for keyboard events (e.g., "Enter", "Escape") */
28
+ keyTarget?: string;
29
+ /** Set of registered event type:action pairs */
30
+ registeredEvents?: Set<string>;
31
+ /** Custom element metadata */
32
+ meta?: Record<string, unknown>;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Private Storage
37
+ // ============================================================================
38
+
39
+ /**
40
+ * WeakMap to store Hypen data without polluting the element
41
+ */
42
+ const elementDataMap = new WeakMap<HTMLElement, HypenElementData>();
43
+
44
+ // ============================================================================
45
+ // Core API
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Get Hypen data for an element, creating if needed
50
+ */
51
+ export function getHypenData(element: HTMLElement): HypenElementData {
52
+ let data = elementDataMap.get(element);
53
+ if (!data) {
54
+ data = {};
55
+ elementDataMap.set(element, data);
56
+ }
57
+ return data;
58
+ }
59
+
60
+ /**
61
+ * Check if element has Hypen data
62
+ */
63
+ export function hasHypenData(element: HTMLElement): boolean {
64
+ return elementDataMap.has(element);
65
+ }
66
+
67
+ /**
68
+ * Clear Hypen data from an element
69
+ */
70
+ export function clearHypenData(element: HTMLElement): void {
71
+ elementDataMap.delete(element);
72
+ }
73
+
74
+ // ============================================================================
75
+ // Engine Access
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Get the engine attached to an element
80
+ */
81
+ export function getEngine(element: HTMLElement): IEngine | undefined {
82
+ return getHypenData(element).engine;
83
+ }
84
+
85
+ /**
86
+ * Set the engine on an element
87
+ */
88
+ export function setEngine(element: HTMLElement, engine: IEngine): void {
89
+ getHypenData(element).engine = engine;
90
+ }
91
+
92
+ /**
93
+ * Find engine by walking up the DOM tree
94
+ */
95
+ export function findEngine(element: HTMLElement): IEngine | undefined {
96
+ let current: HTMLElement | null = element;
97
+ while (current) {
98
+ const engine = getEngine(current);
99
+ if (engine) return engine;
100
+ current = current.parentElement;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ // ============================================================================
106
+ // Event Registration Tracking
107
+ // ============================================================================
108
+
109
+ /**
110
+ * Get registered events set for an element
111
+ */
112
+ export function getRegisteredEvents(element: HTMLElement): Set<string> {
113
+ const data = getHypenData(element);
114
+ if (!data.registeredEvents) {
115
+ data.registeredEvents = new Set();
116
+ }
117
+ return data.registeredEvents;
118
+ }
119
+
120
+ /**
121
+ * Check if an event handler is registered
122
+ */
123
+ export function isEventRegistered(element: HTMLElement, eventKey: string): boolean {
124
+ return getRegisteredEvents(element).has(eventKey);
125
+ }
126
+
127
+ /**
128
+ * Mark an event handler as registered
129
+ */
130
+ export function registerEvent(element: HTMLElement, eventKey: string): void {
131
+ getRegisteredEvents(element).add(eventKey);
132
+ }
133
+
134
+ /**
135
+ * Unregister an event handler
136
+ */
137
+ export function unregisterEvent(element: HTMLElement, eventKey: string): void {
138
+ getRegisteredEvents(element).delete(eventKey);
139
+ }
140
+
141
+ // ============================================================================
142
+ // Keyboard Event Key Target
143
+ // ============================================================================
144
+
145
+ /**
146
+ * Get the target key for keyboard events
147
+ */
148
+ export function getKeyTarget(element: HTMLElement): string | undefined {
149
+ return getHypenData(element).keyTarget;
150
+ }
151
+
152
+ /**
153
+ * Set the target key for keyboard events
154
+ */
155
+ export function setKeyTarget(element: HTMLElement, key: string): void {
156
+ getHypenData(element).keyTarget = key;
157
+ }
158
+
159
+ // ============================================================================
160
+ // Metadata
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Get custom metadata value
165
+ */
166
+ export function getMeta<T>(element: HTMLElement, key: string): T | undefined {
167
+ return getHypenData(element).meta?.[key] as T | undefined;
168
+ }
169
+
170
+ /**
171
+ * Set custom metadata value
172
+ */
173
+ export function setMeta<T>(element: HTMLElement, key: string, value: T): void {
174
+ const data = getHypenData(element);
175
+ if (!data.meta) {
176
+ data.meta = {};
177
+ }
178
+ data.meta[key] = value;
179
+ }
180
+
181
+ // ============================================================================
182
+ // Cleanup
183
+ // ============================================================================
184
+
185
+ /**
186
+ * Dispose all resources attached to an element and clear data
187
+ * Call this when removing an element from the DOM
188
+ */
189
+ export function disposeHypenElement(element: HTMLElement): void {
190
+ // Dispose any registered disposables
191
+ try {
192
+ const disposables = getElementDisposables(element);
193
+ disposables.dispose();
194
+ } catch {
195
+ // Ignore if no disposables
196
+ }
197
+
198
+ // Clear Hypen data
199
+ clearHypenData(element);
200
+ }
201
+
202
+ // ============================================================================
203
+ // Legacy Compatibility Layer
204
+ // ============================================================================
205
+
206
+ /**
207
+ * Symbol-based legacy accessor (for backwards compatibility)
208
+ * Use the typed functions above instead when possible
209
+ */
210
+ const HYPEN_ENGINE_SYMBOL = Symbol.for("hypen.engine");
211
+ const REGISTERED_EVENTS_SYMBOL = Symbol.for("hypen.registeredEvents");
212
+ const KEY_TARGET_SYMBOL = Symbol.for("hypen.keyTarget");
213
+
214
+ /**
215
+ * Legacy accessor for engine (backwards compatible with existing code)
216
+ */
217
+ export function getLegacyEngine(element: HTMLElement): IEngine | undefined {
218
+ // Try new storage first
219
+ const engine = getEngine(element);
220
+ if (engine) return engine;
221
+
222
+ // Fall back to legacy storage
223
+ return (element as any)[HYPEN_ENGINE_SYMBOL] ??
224
+ (element as any).__hypenEngine;
225
+ }
226
+
227
+ /**
228
+ * Legacy setter for engine (backwards compatible)
229
+ */
230
+ export function setLegacyEngine(element: HTMLElement, engine: IEngine): void {
231
+ // Set in both locations for compatibility
232
+ setEngine(element, engine);
233
+ (element as any).__hypenEngine = engine;
234
+ }
@@ -149,14 +149,17 @@ export class DOMRenderer {
149
149
  private onCreate(id: string, elementType: string, props: Record<string, any> | Map<string, any>): void {
150
150
  const propsObj = props instanceof Map ? Object.fromEntries(props) : props;
151
151
 
152
- const element = this.components.createElement(elementType, propsObj);
152
+ let element = this.components.createElement(elementType, propsObj);
153
153
 
154
154
  if (!element) {
155
+ // For unknown component types, create a transparent container (div).
156
+ // This handles module wrappers (like "App") and custom components
157
+ // that aren't registered but should act as layout containers.
155
158
  const fallback = document.createElement("div");
156
- fallback.dataset.hypenType = elementType;
157
- fallback.textContent = `Unknown component: ${elementType}`;
158
- this.nodes.set(id, fallback);
159
- return;
159
+ fallback.dataset.hypenType = elementType.toLowerCase();
160
+ fallback.style.display = "contents"; // Make container transparent in layout
161
+ element = fallback;
162
+ console.log(`[Renderer] Unknown component "${elementType}" - using transparent container`);
160
163
  }
161
164
 
162
165
  element.dataset.hypenType = elementType.toLowerCase();
package/src/index.ts CHANGED
@@ -38,6 +38,9 @@ export { ApplicatorRegistry } from "./dom/applicators/index.js";
38
38
  export { EventManager } from "./dom/events.js";
39
39
  export { RerenderTracker, type DebugConfig, defaultDebugConfig } from "./dom/debug.js";
40
40
 
41
+ // HypenApp - Embed remote Hypen apps
42
+ export { hypenAppHandler, disconnectHypenApp } from "./dom/components/hypenapp.js";
43
+
41
44
  // ============================================================================
42
45
  // CANVAS RENDERER
43
46
  // ============================================================================