@hypen-space/web 0.4.45 → 0.4.81

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 (69) hide show
  1. package/README.md +4 -4
  2. package/dist/canvas/dirty.d.ts +40 -0
  3. package/dist/canvas/dirty.js +100 -0
  4. package/dist/canvas/dirty.js.map +1 -0
  5. package/dist/canvas/events.d.ts +5 -0
  6. package/dist/canvas/events.js +30 -3
  7. package/dist/canvas/events.js.map +1 -1
  8. package/dist/canvas/index.d.ts +5 -1
  9. package/dist/canvas/index.js +4 -0
  10. package/dist/canvas/index.js.map +1 -1
  11. package/dist/canvas/layout.d.ts +12 -2
  12. package/dist/canvas/layout.js +593 -63
  13. package/dist/canvas/layout.js.map +1 -1
  14. package/dist/canvas/paint.d.ts +2 -0
  15. package/dist/canvas/paint.js +88 -11
  16. package/dist/canvas/paint.js.map +1 -1
  17. package/dist/canvas/renderer.d.ts +16 -0
  18. package/dist/canvas/renderer.js +154 -15
  19. package/dist/canvas/renderer.js.map +1 -1
  20. package/dist/canvas/scroll.d.ts +82 -0
  21. package/dist/canvas/scroll.js +639 -0
  22. package/dist/canvas/scroll.js.map +1 -0
  23. package/dist/canvas/selection.d.ts +63 -0
  24. package/dist/canvas/selection.js +466 -0
  25. package/dist/canvas/selection.js.map +1 -0
  26. package/dist/canvas/text.d.ts +6 -3
  27. package/dist/canvas/text.js +82 -43
  28. package/dist/canvas/text.js.map +1 -1
  29. package/dist/canvas/types.d.ts +18 -0
  30. package/dist/canvas/utils.d.ts +8 -2
  31. package/dist/canvas/utils.js +42 -16
  32. package/dist/canvas/utils.js.map +1 -1
  33. package/dist/canvas/virtualize.d.ts +25 -0
  34. package/dist/canvas/virtualize.js +65 -0
  35. package/dist/canvas/virtualize.js.map +1 -0
  36. package/dist/dom/applicators/index.js +0 -1
  37. package/dist/dom/applicators/index.js.map +1 -1
  38. package/dist/dom/applicators/margin.js +58 -17
  39. package/dist/dom/applicators/margin.js.map +1 -1
  40. package/dist/dom/applicators/padding.js +42 -17
  41. package/dist/dom/applicators/padding.js.map +1 -1
  42. package/dist/dom/components/icon.d.ts +9 -0
  43. package/dist/dom/components/icon.js +67 -0
  44. package/dist/dom/components/icon.js.map +1 -0
  45. package/dist/dom/components/index.js +2 -0
  46. package/dist/dom/components/index.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +19 -5
  48. package/dist/dom/renderer.js +147 -17
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/canvas/QUICKSTART.md +5 -5
  52. package/src/canvas/dirty.ts +116 -0
  53. package/src/canvas/events.ts +34 -3
  54. package/src/canvas/index.ts +5 -0
  55. package/src/canvas/layout.ts +653 -100
  56. package/src/canvas/paint.ts +109 -11
  57. package/src/canvas/renderer.ts +189 -16
  58. package/src/canvas/scroll.ts +729 -0
  59. package/src/canvas/selection.ts +560 -0
  60. package/src/canvas/text.ts +97 -54
  61. package/src/canvas/types.ts +26 -0
  62. package/src/canvas/utils.ts +47 -17
  63. package/src/canvas/virtualize.ts +81 -0
  64. package/src/dom/applicators/index.ts +1 -1
  65. package/src/dom/applicators/margin.ts +57 -11
  66. package/src/dom/applicators/padding.ts +45 -11
  67. package/src/dom/components/icon.ts +84 -0
  68. package/src/dom/components/index.ts +2 -0
  69. package/src/dom/renderer.ts +155 -17
@@ -1,15 +1,40 @@
1
1
  /**
2
2
  * Text Rendering System
3
3
  *
4
- * Text measurement, wrapping, and rendering
4
+ * Text measurement, wrapping, and rendering using pretext for accurate
5
+ * multilingual line breaking (CJK, bidi, emoji, etc.). Falls back to
6
+ * basic word-splitting in environments without OffscreenCanvas (e.g. tests).
5
7
  */
6
8
 
7
9
  import type { FontStyle, TextMetrics, TextStyle } from "./types.js";
8
10
  import { createFontString } from "./utils.js";
11
+ import {
12
+ prepareWithSegments,
13
+ layoutWithLines,
14
+ clearCache as pretextClearCache,
15
+ } from "@chenglou/pretext";
9
16
  import { frameworkLoggers } from "@hypen-space/core/logger";
10
17
 
11
18
  const log = frameworkLoggers.canvas;
12
19
 
20
+ /**
21
+ * Whether pretext is available (requires a working canvas with measureText)
22
+ */
23
+ let pretextAvailable: boolean | null = null;
24
+
25
+ function isPretextAvailable(): boolean {
26
+ if (pretextAvailable !== null) return pretextAvailable;
27
+ try {
28
+ // Probe by running a minimal prepare — throws if neither OffscreenCanvas
29
+ // nor a real DOM canvas is available (e.g. jsdom in tests).
30
+ prepareWithSegments("x", "16px sans-serif");
31
+ pretextAvailable = true;
32
+ } catch {
33
+ pretextAvailable = false;
34
+ }
35
+ return pretextAvailable;
36
+ }
37
+
13
38
  /**
14
39
  * Text metrics cache
15
40
  */
@@ -23,59 +48,9 @@ function getCacheKey(text: string, fontStyle: FontStyle, maxWidth?: number): str
23
48
  }
24
49
 
25
50
  /**
26
- * Measure text dimensions
27
- */
28
- export function measureText(
29
- ctx: CanvasRenderingContext2D,
30
- text: string,
31
- fontStyle: FontStyle,
32
- maxWidth?: number
33
- ): TextMetrics {
34
- const cacheKey = getCacheKey(text, fontStyle, maxWidth);
35
- const cached = textMetricsCache.get(cacheKey);
36
- if (cached) return cached;
37
-
38
- const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
39
- ctx.save();
40
- ctx.font = font;
41
-
42
- const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
43
-
44
- // No wrapping needed
45
- if (!maxWidth) {
46
- const metrics = ctx.measureText(text);
47
- const result: TextMetrics = {
48
- width: metrics.width,
49
- height: lineHeight,
50
- lines: [text],
51
- lineHeight,
52
- };
53
- ctx.restore();
54
- textMetricsCache.set(cacheKey, result);
55
- return result;
56
- }
57
-
58
- // Wrap text
59
- const lines = wrapText(ctx, text, maxWidth);
60
- const width = Math.max(...lines.map((line) => ctx.measureText(line).width));
61
- const height = lines.length * lineHeight;
62
-
63
- const result: TextMetrics = {
64
- width,
65
- height,
66
- lines,
67
- lineHeight,
68
- };
69
-
70
- ctx.restore();
71
- textMetricsCache.set(cacheKey, result);
72
- return result;
73
- }
74
-
75
- /**
76
- * Wrap text to fit within maxWidth
51
+ * Fallback word wrap for environments without pretext support
77
52
  */
78
- function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
53
+ function wrapTextFallback(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
79
54
  const lines: string[] = [];
80
55
  const paragraphs = text.split("\n");
81
56
 
@@ -103,6 +78,73 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
103
78
  return lines.length > 0 ? lines : [""];
104
79
  }
105
80
 
81
+ /**
82
+ * Measure text dimensions. Uses pretext for accurate multilingual line
83
+ * breaking when available, falls back to basic word-splitting otherwise.
84
+ */
85
+ export function measureText(
86
+ ctx: CanvasRenderingContext2D,
87
+ text: string,
88
+ fontStyle: FontStyle,
89
+ maxWidth?: number
90
+ ): TextMetrics {
91
+ const cacheKey = getCacheKey(text, fontStyle, maxWidth);
92
+ const cached = textMetricsCache.get(cacheKey);
93
+ if (cached) return cached;
94
+
95
+ const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
96
+ const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
97
+
98
+ let result: TextMetrics;
99
+
100
+ if (isPretextAvailable()) {
101
+ // pretext handles text shaping, segmentation, and line breaking
102
+ const prepared = prepareWithSegments(text, font);
103
+ const effectiveMaxWidth = maxWidth || Infinity;
104
+ const linesResult = layoutWithLines(prepared, effectiveMaxWidth, lineHeight);
105
+
106
+ const lines = linesResult.lines.map((l) => l.text);
107
+ const width = lines.length > 0
108
+ ? Math.max(...linesResult.lines.map((l) => l.width))
109
+ : 0;
110
+
111
+ result = {
112
+ width,
113
+ height: linesResult.height || lineHeight,
114
+ lines: lines.length > 0 ? lines : [""],
115
+ lineHeight,
116
+ };
117
+ } else {
118
+ // Fallback: use ctx.measureText with basic word splitting
119
+ ctx.save();
120
+ ctx.font = font;
121
+
122
+ if (!maxWidth) {
123
+ const metrics = ctx.measureText(text);
124
+ result = {
125
+ width: metrics.width,
126
+ height: lineHeight,
127
+ lines: [text],
128
+ lineHeight,
129
+ };
130
+ } else {
131
+ const lines = wrapTextFallback(ctx, text, maxWidth);
132
+ const width = Math.max(...lines.map((line) => ctx.measureText(line).width));
133
+ result = {
134
+ width,
135
+ height: lines.length * lineHeight,
136
+ lines,
137
+ lineHeight,
138
+ };
139
+ }
140
+
141
+ ctx.restore();
142
+ }
143
+
144
+ textMetricsCache.set(cacheKey, result);
145
+ return result;
146
+ }
147
+
106
148
  /**
107
149
  * Render text with style
108
150
  */
@@ -153,10 +195,11 @@ export function renderText(
153
195
  }
154
196
 
155
197
  /**
156
- * Clear text metrics cache
198
+ * Clear text metrics cache (both local and pretext internal caches)
157
199
  */
158
200
  export function clearTextCache(): void {
159
201
  textMetricsCache.clear();
202
+ pretextClearCache();
160
203
  }
161
204
 
162
205
  /**
@@ -24,6 +24,9 @@ export interface VirtualNode {
24
24
  focusable: boolean;
25
25
  focused: boolean;
26
26
  hovered: boolean;
27
+
28
+ // Scroll state (managed by ScrollManager, not serialised)
29
+ scrollState?: ScrollState;
27
30
  }
28
31
 
29
32
  export interface Layout {
@@ -121,6 +124,29 @@ export interface LayoutFunction {
121
124
  };
122
125
  }
123
126
 
127
+ export interface ScrollState {
128
+ /** Current scroll offset (pixels from origin) */
129
+ scrollX: number;
130
+ scrollY: number;
131
+
132
+ /** Scroll velocity for momentum (px/ms) */
133
+ velocityX: number;
134
+ velocityY: number;
135
+
136
+ /** Total scrollable content size (set during layout) */
137
+ scrollWidth: number;
138
+ scrollHeight: number;
139
+
140
+ /** Whether a touch/pointer sequence is active */
141
+ touching: boolean;
142
+
143
+ /** Timestamp of last velocity sample */
144
+ lastTouchTime: number;
145
+
146
+ /** Scrollbar fade-out opacity (0–1) */
147
+ scrollbarOpacity: number;
148
+ }
149
+
124
150
  export interface DirtyRect extends Rectangle {
125
151
  frameId: number;
126
152
  }
@@ -7,8 +7,14 @@
7
7
  import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
8
8
 
9
9
  /**
10
- * Parse spacing value (margin, padding)
11
- * Supports: number, "10", "10 20", "10 20 30 40"
10
+ * Parse spacing value (margin, padding).
11
+ *
12
+ * Supports the same forms as the DOM and native renderers:
13
+ * - `number` — all sides
14
+ * - `"10"`, `"10 20"`, `"10 20 30"`, `"10 20 30 40"` — CSS shorthand string
15
+ * - `{top, right, bottom, left}` — named keys
16
+ * - `{0, 1, ...}` — positional applicator args from the engine, mapped via
17
+ * CSS shorthand semantics (1=all, 2=v/h, 3=t/h/b, 4=t/r/b/l)
12
18
  */
13
19
  export function parseSpacing(value: any): BoxSpacing {
14
20
  if (typeof value === "number") {
@@ -17,29 +23,53 @@ export function parseSpacing(value: any): BoxSpacing {
17
23
 
18
24
  if (typeof value === "string") {
19
25
  const parts = value.split(/\s+/).map((v) => parseFloat(v) || 0);
20
- if (parts.length === 1) {
21
- return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
22
- }
23
- if (parts.length === 2) {
24
- return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
25
- }
26
- if (parts.length === 4) {
27
- return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
28
- }
26
+ return spacingFromArray(parts);
29
27
  }
30
28
 
31
29
  if (typeof value === "object" && value !== null) {
32
- return {
33
- top: parseFloat(value.top) || 0,
34
- right: parseFloat(value.right) || 0,
35
- bottom: parseFloat(value.bottom) || 0,
36
- left: parseFloat(value.left) || 0,
37
- };
30
+ // Named-keys form takes priority when any side is named.
31
+ if (
32
+ value.top !== undefined ||
33
+ value.right !== undefined ||
34
+ value.bottom !== undefined ||
35
+ value.left !== undefined
36
+ ) {
37
+ return {
38
+ top: parseFloat(value.top) || 0,
39
+ right: parseFloat(value.right) || 0,
40
+ bottom: parseFloat(value.bottom) || 0,
41
+ left: parseFloat(value.left) || 0,
42
+ };
43
+ }
44
+
45
+ // Positional form: walk contiguous "0", "1", … keys.
46
+ const args: number[] = [];
47
+ for (let i = 0; value[String(i)] !== undefined; i++) {
48
+ args.push(parseFloat(value[String(i)]) || 0);
49
+ }
50
+ if (args.length > 0) {
51
+ return spacingFromArray(args);
52
+ }
38
53
  }
39
54
 
40
55
  return { top: 0, right: 0, bottom: 0, left: 0 };
41
56
  }
42
57
 
58
+ /** CSS shorthand: 1=all, 2=v/h, 3=t/h/b, 4=t/r/b/l. */
59
+ function spacingFromArray(parts: number[]): BoxSpacing {
60
+ if (parts.length === 1) {
61
+ return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
62
+ }
63
+ if (parts.length === 2) {
64
+ return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
65
+ }
66
+ if (parts.length === 3) {
67
+ return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
68
+ }
69
+ // 4+ values: top, right, bottom, left
70
+ return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
71
+ }
72
+
43
73
  /**
44
74
  * Parse size value (width, height)
45
75
  * Returns pixel value or null for "auto"
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Windowed / Virtual Rendering
3
+ *
4
+ * For scrollable containers with many children, determines which children
5
+ * intersect the visible viewport so the paint system can skip off-screen nodes.
6
+ * This is paint-level virtualization only -- all children still get layout
7
+ * computed (Taffy handles that efficiently). We just skip painting off-screen
8
+ * children.
9
+ */
10
+
11
+ import type { VirtualNode, Rectangle } from "./types.js";
12
+ import { isScrollable } from "./scroll.js";
13
+
14
+ /** Number of pixels to render beyond the visible viewport edges. */
15
+ const OVERSCAN = 100;
16
+
17
+ /** Minimum child count before virtualization kicks in. */
18
+ export const VIRTUALIZE_THRESHOLD = 20;
19
+
20
+ /**
21
+ * Return the subset of `parent.children` whose layouts overlap the visible
22
+ * viewport of the parent container.
23
+ *
24
+ * - For non-scrollable containers every child is returned unchanged.
25
+ * - For scrollable containers the visible rect is the parent's layout bounds
26
+ * offset by the current scroll position, expanded by OVERSCAN pixels on
27
+ * each axis to prevent pop-in during fast scrolling.
28
+ *
29
+ * Children without a computed layout are always included (defensive -- they
30
+ * may need painting for side-effects).
31
+ */
32
+ export function getVisibleChildren(
33
+ parent: VirtualNode,
34
+ viewport: Rectangle,
35
+ ): VirtualNode[] {
36
+ if (!isScrollable(parent)) {
37
+ return parent.children;
38
+ }
39
+
40
+ const ss = parent.scrollState;
41
+ if (!ss) {
42
+ return parent.children;
43
+ }
44
+
45
+ // The visible window in *content coordinates* (i.e. coordinates that
46
+ // children are laid out in). The viewport rectangle is already in the
47
+ // parent's coordinate space; we shift it by the scroll offset so that
48
+ // it matches where the children actually are in the layout.
49
+ const visibleMinX = viewport.x + ss.scrollX - OVERSCAN;
50
+ const visibleMaxX = viewport.x + ss.scrollX + viewport.width + OVERSCAN;
51
+ const visibleMinY = viewport.y + ss.scrollY - OVERSCAN;
52
+ const visibleMaxY = viewport.y + ss.scrollY + viewport.height + OVERSCAN;
53
+
54
+ const visible: VirtualNode[] = [];
55
+
56
+ for (const child of parent.children) {
57
+ // Always include children without layout (safety net).
58
+ if (!child.layout) {
59
+ visible.push(child);
60
+ continue;
61
+ }
62
+
63
+ const cl = child.layout;
64
+ const childMinX = cl.x;
65
+ const childMaxX = cl.x + cl.width;
66
+ const childMinY = cl.y;
67
+ const childMaxY = cl.y + cl.height;
68
+
69
+ // Standard AABB intersection test.
70
+ if (
71
+ childMaxX > visibleMinX &&
72
+ childMinX < visibleMaxX &&
73
+ childMaxY > visibleMinY &&
74
+ childMinY < visibleMaxY
75
+ ) {
76
+ visible.push(child);
77
+ }
78
+ }
79
+
80
+ return visible;
81
+ }
@@ -274,7 +274,7 @@ export class ApplicatorRegistry {
274
274
  * Apply multiple applicators
275
275
  */
276
276
  applyAll(element: HTMLElement, applicators: Record<string, any>): void {
277
- //console.log(`[ApplicatorRegistry] Applying applicators:`, applicators);
277
+
278
278
 
279
279
  // Group applicators by their base name (treat applicators like components with arguments)
280
280
  // e.g., "onClick.0", "onClick.id", "onClick.title" -> onClick: {0: ..., id: ..., title: ...}
@@ -12,21 +12,67 @@ const getNumericValue = (value: any): number | null => {
12
12
  return null;
13
13
  };
14
14
 
15
+ // Extract a CSS length value, supporting numbers, strings ("auto", "10px",
16
+ // percentages), or `{0: value}` positional form. Returns null if no value.
17
+ const getCssLengthValue = (value: any): string | null => {
18
+ if (value === null || value === undefined) return null;
19
+ if (typeof value === "number") return `${value}px`;
20
+ if (typeof value === "string") return value;
21
+ if (typeof value === "object") {
22
+ if (value["0"] !== undefined) {
23
+ const inner = value["0"];
24
+ if (typeof inner === "number") return `${inner}px`;
25
+ if (typeof inner === "string") return inner;
26
+ }
27
+ }
28
+ return null;
29
+ };
30
+
31
+ // Format a value as a CSS length: numbers become "Npx", strings pass through
32
+ const toCssLength = (v: any): string => {
33
+ if (typeof v === "number") return `${v}px`;
34
+ return String(v);
35
+ };
36
+
37
+ // Collect positional args ("0", "1", "2", "3") in order, stopping at the first gap.
38
+ const positionalArgs = (value: any): any[] => {
39
+ const args: any[] = [];
40
+ for (let i = 0; value[String(i)] !== undefined; i++) {
41
+ args.push(value[String(i)]);
42
+ }
43
+ return args;
44
+ };
45
+
15
46
  export const marginHandler: ApplicatorHandler = (el, value) => {
16
47
  if (typeof value === "number") {
17
48
  el.style.margin = `${value}px`;
18
- } else if (typeof value === "object") {
19
- // Handle {0: value} format from .margin(value)
20
- if (value["0"] !== undefined && Object.keys(value).length === 1) {
21
- el.style.margin = `${value["0"]}px`;
22
- } else {
23
- if (value.left !== undefined) el.style.marginLeft = `${value.left}px`;
24
- if (value.right !== undefined) el.style.marginRight = `${value.right}px`;
25
- if (value.top !== undefined) el.style.marginTop = `${value.top}px`;
26
- if (value.bottom !== undefined) el.style.marginBottom = `${value.bottom}px`;
27
- }
28
- } else {
49
+ return;
50
+ }
51
+
52
+ if (typeof value !== "object" || value === null) {
29
53
  el.style.margin = String(value);
54
+ return;
55
+ }
56
+
57
+ // Named-keys form: { top, right, bottom, left }
58
+ if (
59
+ value.left !== undefined ||
60
+ value.right !== undefined ||
61
+ value.top !== undefined ||
62
+ value.bottom !== undefined
63
+ ) {
64
+ if (value.left !== undefined) el.style.marginLeft = toCssLength(value.left);
65
+ if (value.right !== undefined) el.style.marginRight = toCssLength(value.right);
66
+ if (value.top !== undefined) el.style.marginTop = toCssLength(value.top);
67
+ if (value.bottom !== undefined) el.style.marginBottom = toCssLength(value.bottom);
68
+ return;
69
+ }
70
+
71
+ // Positional form mirrors CSS shorthand: .margin(v), .margin(v, h),
72
+ // .margin(t, h, b), .margin(t, r, b, l)
73
+ const args = positionalArgs(value);
74
+ if (args.length > 0) {
75
+ el.style.margin = args.map(toCssLength).join(" ");
30
76
  }
31
77
  };
32
78
 
@@ -12,21 +12,55 @@ const getNumericValue = (value: any): number | null => {
12
12
  return null;
13
13
  };
14
14
 
15
+ // Format a value as a CSS length: numbers become "Npx", strings pass through
16
+ const toCssLength = (v: any): string => {
17
+ if (typeof v === "number") return `${v}px`;
18
+ return String(v);
19
+ };
20
+
21
+ // Collect positional args ("0", "1", "2", "3") in order, stopping at the first
22
+ // gap. Returns an empty array if no positional args are present.
23
+ const positionalArgs = (value: any): any[] => {
24
+ const args: any[] = [];
25
+ for (let i = 0; value[String(i)] !== undefined; i++) {
26
+ args.push(value[String(i)]);
27
+ }
28
+ return args;
29
+ };
30
+
15
31
  export const paddingHandler: ApplicatorHandler = (el, value) => {
16
32
  if (typeof value === "number") {
17
33
  el.style.padding = `${value}px`;
18
- } else if (typeof value === "object") {
19
- // Handle {0: value} format from .padding(value)
20
- if (value["0"] !== undefined && Object.keys(value).length === 1) {
21
- el.style.padding = `${value["0"]}px`;
22
- } else {
23
- if (value.left !== undefined) el.style.paddingLeft = `${value.left}px`;
24
- if (value.right !== undefined) el.style.paddingRight = `${value.right}px`;
25
- if (value.top !== undefined) el.style.paddingTop = `${value.top}px`;
26
- if (value.bottom !== undefined) el.style.paddingBottom = `${value.bottom}px`;
27
- }
28
- } else {
34
+ return;
35
+ }
36
+
37
+ if (typeof value !== "object" || value === null) {
29
38
  el.style.padding = String(value);
39
+ return;
40
+ }
41
+
42
+ // Named-keys form: { top, right, bottom, left }
43
+ if (
44
+ value.left !== undefined ||
45
+ value.right !== undefined ||
46
+ value.top !== undefined ||
47
+ value.bottom !== undefined
48
+ ) {
49
+ if (value.left !== undefined) el.style.paddingLeft = toCssLength(value.left);
50
+ if (value.right !== undefined) el.style.paddingRight = toCssLength(value.right);
51
+ if (value.top !== undefined) el.style.paddingTop = toCssLength(value.top);
52
+ if (value.bottom !== undefined) el.style.paddingBottom = toCssLength(value.bottom);
53
+ return;
54
+ }
55
+
56
+ // Positional form: .padding(v), .padding(v, h), .padding(t, h, b), .padding(t, r, b, l)
57
+ // Mirrors CSS shorthand semantics so users can write
58
+ // .padding(10, 16) // -> 10px 16px
59
+ // .padding(8, 12, 4) // -> 8px 12px 4px
60
+ // .padding(8, 12, 4, 6) // -> 8px 12px 4px 6px
61
+ const args = positionalArgs(value);
62
+ if (args.length > 0) {
63
+ el.style.padding = args.map(toCssLength).join(" ");
30
64
  }
31
65
  };
32
66
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Icon Component
3
+ *
4
+ * Renders SVG icons from pre-resolved path data injected by the engine.
5
+ * The engine resolves Icon("heart") → SVG paths at render time and sends
6
+ * the path data in the Create patch props. This handler just renders them.
7
+ */
8
+
9
+ import type { ComponentHandler } from "./index.js";
10
+
11
+ const SVG_NS = "http://www.w3.org/2000/svg";
12
+
13
+ export const iconHandler: ComponentHandler = {
14
+ create(): HTMLElement {
15
+ const el = document.createElement("span");
16
+ el.style.display = "inline-flex";
17
+ el.style.alignItems = "center";
18
+ el.style.justifyContent = "center";
19
+ el.style.verticalAlign = "middle";
20
+ el.dataset.hypenType = "icon";
21
+ return el;
22
+ },
23
+
24
+ applyProps(el: HTMLElement, props: Record<string, any>): void {
25
+ // Server-resolved icon data (from engine's ResourceRegistry)
26
+ const paths: Array<{
27
+ d: string;
28
+ fill?: string;
29
+ stroke?: string;
30
+ strokeWidth?: number;
31
+ strokeLinecap?: string;
32
+ strokeLinejoin?: string;
33
+ }> = props.__iconPaths;
34
+ const viewBox: string = props.__iconViewBox || "0 0 24 24";
35
+
36
+ // Size and color from DSL props
37
+ const size = props.size || props["size.0"] || 24;
38
+ const color = props.color || props["color.0"] || "currentColor";
39
+
40
+ if (paths && Array.isArray(paths)) {
41
+ // Render from pre-resolved SVG path data
42
+ const svg = document.createElementNS(SVG_NS, "svg");
43
+ svg.setAttribute("xmlns", SVG_NS);
44
+ svg.setAttribute("width", String(size));
45
+ svg.setAttribute("height", String(size));
46
+ svg.setAttribute("viewBox", viewBox);
47
+ svg.setAttribute("fill", "none");
48
+ svg.style.display = "block";
49
+
50
+ for (const pathData of paths) {
51
+ const path = document.createElementNS(SVG_NS, "path");
52
+ path.setAttribute("d", pathData.d);
53
+ path.setAttribute("fill", pathData.fill || "none");
54
+ path.setAttribute(
55
+ "stroke",
56
+ (pathData.stroke || "currentColor") === "currentColor"
57
+ ? color
58
+ : pathData.stroke || color
59
+ );
60
+ if (pathData.strokeWidth != null) {
61
+ path.setAttribute("stroke-width", String(pathData.strokeWidth));
62
+ }
63
+ if (pathData.strokeLinecap) {
64
+ path.setAttribute("stroke-linecap", pathData.strokeLinecap);
65
+ }
66
+ if (pathData.strokeLinejoin) {
67
+ path.setAttribute("stroke-linejoin", pathData.strokeLinejoin);
68
+ }
69
+ svg.appendChild(path);
70
+ }
71
+
72
+ el.innerHTML = "";
73
+ el.appendChild(svg);
74
+ } else {
75
+ // Fallback: no resolved icon data — show placeholder
76
+ const name = props["0"] || props.name || "?";
77
+ el.textContent = name;
78
+ el.style.width = `${size}px`;
79
+ el.style.height = `${size}px`;
80
+ el.style.fontSize = `${Math.round(Number(size) * 0.6)}px`;
81
+ el.style.color = color;
82
+ }
83
+ },
84
+ };
@@ -83,8 +83,10 @@ export class ComponentRegistry {
83
83
  const { routeHandler } = require("./route.js");
84
84
  const { hypenAppHandler } = require("./hypenapp.js");
85
85
  const { appHandler } = require("./app.js");
86
+ const { iconHandler } = require("./icon.js");
86
87
 
87
88
  this.register("app", appHandler);
89
+ this.register("icon", iconHandler);
88
90
  this.register("column", columnHandler);
89
91
  this.register("row", rowHandler);
90
92
  this.register("text", textHandler);