sketchmark 2.0.0 → 2.1.1

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 (56) hide show
  1. package/README.md +274 -188
  2. package/bin/editor-ui.cjs +2285 -0
  3. package/bin/preview-ui.cjs +74 -0
  4. package/bin/sketchmark.cjs +648 -2008
  5. package/dist/src/animatable.d.ts +21 -0
  6. package/dist/src/animatable.js +439 -0
  7. package/dist/src/builders/index.d.ts +1 -11
  8. package/dist/src/builders/index.js +1 -19
  9. package/dist/src/diagnostics.js +1 -64
  10. package/dist/src/edit.d.ts +27 -0
  11. package/dist/src/edit.js +162 -0
  12. package/dist/src/index.d.ts +4 -13
  13. package/dist/src/index.js +4 -13
  14. package/dist/src/keyframes.d.ts +48 -0
  15. package/dist/src/keyframes.js +182 -0
  16. package/dist/src/motion.d.ts +4 -0
  17. package/dist/src/motion.js +262 -0
  18. package/dist/src/normalize.js +120 -151
  19. package/dist/src/presets/characters.d.ts +15 -0
  20. package/dist/src/presets/characters.js +113 -0
  21. package/dist/src/presets/compose.d.ts +5 -0
  22. package/dist/src/presets/compose.js +80 -0
  23. package/dist/src/presets/effects.d.ts +40 -0
  24. package/dist/src/presets/effects.js +79 -0
  25. package/dist/src/presets/helpers.d.ts +33 -0
  26. package/dist/src/presets/helpers.js +165 -0
  27. package/dist/src/presets/index.d.ts +9 -0
  28. package/dist/src/presets/index.js +48 -0
  29. package/dist/src/presets/motions.d.ts +33 -0
  30. package/dist/src/presets/motions.js +75 -0
  31. package/dist/src/presets/scenes.d.ts +35 -0
  32. package/dist/src/presets/scenes.js +134 -0
  33. package/dist/src/presets/shapes.d.ts +71 -0
  34. package/dist/src/presets/shapes.js +96 -0
  35. package/dist/src/presets/transitions.d.ts +29 -0
  36. package/dist/src/presets/transitions.js +113 -0
  37. package/dist/src/presets/types.d.ts +34 -0
  38. package/dist/src/presets/types.js +2 -0
  39. package/dist/src/render/html.js +1 -4
  40. package/dist/src/render/svg.d.ts +2 -2
  41. package/dist/src/render/svg.js +86 -82
  42. package/dist/src/render/three-html.js +67 -113
  43. package/dist/src/scenes.js +1 -0
  44. package/dist/src/schema.js +218 -280
  45. package/dist/src/shapes/builtins.js +11 -47
  46. package/dist/src/shapes/common.js +12 -11
  47. package/dist/src/shapes/registry.d.ts +0 -1
  48. package/dist/src/shapes/registry.js +0 -4
  49. package/dist/src/shapes/types.d.ts +1 -3
  50. package/dist/src/types.d.ts +57 -288
  51. package/dist/src/utils.d.ts +2 -11
  52. package/dist/src/utils.js +13 -70
  53. package/dist/src/validate.js +321 -275
  54. package/dist/tests/run.js +576 -510
  55. package/package.json +46 -52
  56. package/schema/visual.schema.json +1086 -930
package/dist/tests/run.js CHANGED
@@ -3,8 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const src_1 = require("../src");
6
- const shapes_1 = require("../src/shapes");
7
- const builders_1 = require("../src/builders");
6
+ const presets_1 = require("../src/presets");
8
7
  function test(name, fn) {
9
8
  try {
10
9
  fn();
@@ -16,629 +15,696 @@ function test(name, fn) {
16
15
  throw error;
17
16
  }
18
17
  }
19
- test("validates primitive geometry", () => {
18
+ test("validates minimal render-kernel documents", () => {
20
19
  const doc = {
21
20
  version: 1,
22
- canvas: { width: 400, height: 240 },
23
- elements: [{ id: "card", type: "rect", x: 20, y: 30, width: 100, height: 60 }]
21
+ canvas: { width: 320, height: 180, background: "#ffffff" },
22
+ elements: [
23
+ { id: "line", type: "path", d: "M 20 120 L 300 40", stroke: "#111827", strokeWidth: 4 },
24
+ { id: "label", type: "text", text: "Kernel", x: 160, y: 90, align: "center", valign: "middle" },
25
+ { id: "pin", type: "point", x: 20, y: 120 }
26
+ ]
24
27
  };
25
28
  const result = (0, src_1.validateVisualDocument)(doc);
26
- assert(result.ok, "document should be valid");
29
+ assert(result.ok, `document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
27
30
  });
28
- test("rejects compound types in canonical JSON", () => {
31
+ test("rejects non-kernel fields and types", () => {
29
32
  const doc = {
30
33
  version: 1,
31
- canvas: { width: 400, height: 240 },
32
- elements: [{ id: "bad", type: "node", label: "Browser", x: 0, y: 0, width: 100, height: 50 }]
34
+ canvas: { width: 320, height: 180, space: "3d", renderer: "three" },
35
+ elements: [
36
+ { id: "box", type: "rect", x: 20, y: 20, width: 80, height: 40 },
37
+ { id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1] }
38
+ ],
39
+ motion: { drivers: [] },
40
+ scenes: {},
41
+ imports: {},
42
+ assets: {},
43
+ exports: {}
33
44
  };
34
45
  const result = (0, src_1.validateVisualDocument)(doc);
35
46
  assert(!result.ok, "document should be invalid");
36
- assert(result.issues.some((item) => item.code === "compound_type_not_allowed"), "should report compound type");
37
- });
38
- test("builders expand compounds into primitives", () => {
39
- const items = [
40
- ...(0, builders_1.node)({ id: "browser", label: "Browser", x: 120, y: 160, width: 180, height: 80 }),
41
- ...(0, builders_1.node)({ id: "resolver", label: "DNS Resolver", x: 380, y: 160, width: 180, height: 80 }),
42
- ...(0, builders_1.flow)({ id: "query", from: "browser_box.right", to: "resolver_box.left", label: "Query", labelX: 340, labelY: 145 }),
43
- (0, builders_1.packet)({ id: "query_packet", on: "query", progress: (0, builders_1.animate)(0, 1, { duration: 1, delay: 1 }) }),
44
- ...(0, builders_1.callout)({ id: "note", text: "Cached result", x: 520, y: 80, width: 140, height: 48, target: "resolver_box.top" })
45
- ];
46
- const compoundTypes = new Set(["node", "flow", "packet", "callout"]);
47
- assert(items.every((item) => !compoundTypes.has(item.type)), "builders must return primitives");
47
+ assert(result.issues.some((item) => item.code === "non_kernel_field" && item.path === "/motion"), "motion should be rejected");
48
+ assert(result.issues.some((item) => item.code === "non_kernel_canvas_field" && item.path === "/canvas/space"), "3D canvas fields should be rejected");
49
+ assert(result.issues.some((item) => item.code === "unsupported_type" && item.path.endsWith("/type")), "non-kernel element types should be rejected");
48
50
  });
49
- test("compileCompounds is explicit and outputs primitives", () => {
50
- const doc = (0, src_1.compileCompounds)({
51
- version: 1,
52
- canvas: { width: 640, height: 360 },
53
- elements: [
54
- { id: "browser", type: "node", label: "Browser", x: 80, y: 120, width: 160, height: 70 },
55
- { id: "resolver", type: "node", label: "Resolver", x: 320, y: 120, width: 160, height: 70 },
56
- { id: "query", type: "flow", from: "browser_box.right", to: "resolver_box.left", label: "Query", labelX: 280, labelY: 110 }
57
- ]
58
- });
59
- const forbidden = new Set(["node", "flow"]);
60
- assert(doc.elements?.every((item) => !forbidden.has(item.type)), "compiled document should contain only primitives");
61
- assert((0, src_1.validateVisualDocument)(doc).ok, "compiled document should validate");
62
- });
63
- test("resolves references to numeric endpoints", () => {
64
- const doc = (0, builders_1.scene)({
65
- canvas: { width: 640, height: 360 },
66
- elements: [
67
- { id: "left", type: "rect", x: 100, y: 100, width: 100, height: 60 },
68
- { id: "right", type: "rect", x: 300, y: 100, width: 100, height: 60 },
69
- { id: "arrow", type: "arrow", from: "left.right", to: "right.left", stroke: "#111827" }
70
- ]
71
- });
72
- const normalized = (0, src_1.normalizeVisualDocument)(doc);
73
- const arrow = normalized.elements.find((item) => item.id === "arrow");
74
- assert(JSON.stringify(arrow.from) === JSON.stringify([200, 130]), "from should resolve to right edge");
75
- assert(JSON.stringify(arrow.to) === JSON.stringify([300, 130]), "to should resolve to left edge");
76
- });
77
- test("animated x requires a static x", () => {
51
+ test("rejects non-kernel fields on supported elements", () => {
78
52
  const doc = {
79
53
  version: 1,
80
- canvas: { width: 400, height: 240 },
81
- elements: [{ id: "label", type: "text", text: "Move", y: 40, animate: { x: (0, builders_1.animate)(0, 100, { duration: 1 }) } }]
54
+ canvas: { width: 320, height: 180 },
55
+ elements: [{ id: "old_line", type: "path", d: "M 0 0 L 10 10", from: [0, 0], to: [10, 10] }]
82
56
  };
83
57
  const result = (0, src_1.validateVisualDocument)(doc);
84
- assert(!result.ok, "document should be invalid");
85
- assert(result.issues.some((item) => item.code === "missing_static_animation_property"), "should require static animated property");
58
+ assert(!result.ok, "extra authoring fields should be invalid");
59
+ assert(result.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/from")), "should reject old element fields");
60
+ });
61
+ test("rejects project/editor metadata and image cornerRadius", () => {
62
+ const metadataDoc = {
63
+ version: 1,
64
+ canvas: { width: 320, height: 180 },
65
+ elements: [{ id: "dot", type: "point", x: 10, y: 10, metadata: { selected: true } }]
66
+ };
67
+ const metadata = (0, src_1.validateVisualDocument)(metadataDoc);
68
+ assert(!metadata.ok, "metadata should not be a kernel element field");
69
+ assert(metadata.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/metadata")), "metadata should be rejected");
70
+ const src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3C/svg%3E";
71
+ const roundedDoc = {
72
+ version: 1,
73
+ canvas: { width: 320, height: 180 },
74
+ elements: [{ id: "photo", type: "image", src, x: 20, y: 20, width: 80, height: 50, cornerRadius: 8 }]
75
+ };
76
+ const rounded = (0, src_1.validateVisualDocument)(roundedDoc);
77
+ assert(!rounded.ok, "image cornerRadius should not be a kernel field");
78
+ assert(rounded.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/cornerRadius")), "cornerRadius should be rejected");
79
+ const clipDoc = {
80
+ version: 1,
81
+ canvas: { width: 320, height: 180 },
82
+ elements: [{ id: "photo", type: "image", src, x: 20, y: 20, width: 80, height: 50, clip: (0, src_1.imageRoundedClip)({ x: 20, y: 20, width: 80, height: 50 }, 8) }]
83
+ };
84
+ assert((0, src_1.validateVisualDocument)(clipDoc).ok, "rounded image clips should compile through clip.d");
86
85
  });
87
- test("rejects raw keyframe arrays in animate", () => {
86
+ test("renders path, text, image, and group to SVG and HTML", () => {
87
+ const src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='%232563eb'/%3E%3C/svg%3E";
88
88
  const doc = {
89
89
  version: 1,
90
- canvas: { width: 400, height: 240, duration: 2 },
90
+ canvas: { width: 240, height: 140, background: "#f8fafc" },
91
91
  elements: [
92
92
  {
93
- id: "dot",
94
- type: "circle",
95
- cx: 20,
96
- cy: 40,
97
- radius: 6,
98
- animate: { cx: [[0, 20], [1, 120], [2, 20]] }
93
+ id: "card",
94
+ type: "group",
95
+ x: 20,
96
+ y: 20,
97
+ width: 200,
98
+ height: 100,
99
+ children: [
100
+ { id: "panel", type: "path", d: "M 0 0 H 200 V 100 H 0 Z", fill: "#ffffff", stroke: "#cbd5e1" },
101
+ { id: "photo", type: "image", src, x: 16, y: 16, width: 40, height: 40 },
102
+ { id: "copy", type: "text", text: "Render kernel", x: 116, y: 38, align: "center", valign: "middle" }
103
+ ]
99
104
  }
100
105
  ]
101
106
  };
102
- const result = (0, src_1.validateVisualDocument)(doc);
103
- assert(!result.ok, "document should be invalid");
104
- assert(result.issues.some((item) => item.code === "invalid_animation"), "should reject raw keyframe arrays");
107
+ const svg = (0, src_1.renderToSvg)(doc);
108
+ assert(svg.includes("<svg"), "should render SVG root");
109
+ assert(svg.includes("<path") && svg.includes("<image") && svg.includes("Render&#160;kernel"), "should render kernel elements");
110
+ assert(svg.includes('stroke-width="1"'), "path stroke should default to width 1 when stroke is set");
111
+ assert((0, src_1.renderToHtml)(doc).includes("Sketchmark Kernel Visual"), "should render HTML shell");
105
112
  });
106
- test("rejects effects arrays", () => {
113
+ test("resolves element-local timeline tracks", () => {
107
114
  const doc = {
108
115
  version: 1,
109
- canvas: { width: 400, height: 240 },
116
+ canvas: { width: 320, height: 180, duration: 3 },
110
117
  elements: [
111
118
  {
112
- id: "sun",
113
- type: "circle",
114
- cx: 200,
115
- cy: 120,
116
- radius: 30,
117
- effects: [{ type: "shadow", blur: 20, color: "#facc15" }]
119
+ id: "dot",
120
+ type: "point",
121
+ x: 0,
122
+ y: 0,
123
+ timeline: {
124
+ start: 1,
125
+ end: 3,
126
+ tracks: {
127
+ position: { keyframes: [[0, [20, 30]], [2, [120, 90]]], ease: "linear" },
128
+ opacity: { keyframes: [[0, 0], [1, 1]] }
129
+ }
130
+ }
131
+ },
132
+ {
133
+ id: "mark",
134
+ type: "path",
135
+ d: "M 10 10 L 30 10",
136
+ stroke: "#000000",
137
+ timeline: {
138
+ tracks: {
139
+ stroke: { keyframes: [[0, "#000000"], [2, "#ffffff"]], ease: "linear" }
140
+ }
141
+ }
118
142
  }
119
143
  ]
120
144
  };
121
- const result = (0, src_1.validateVisualDocument)(doc);
122
- assert(!result.ok, "document should be invalid");
123
- assert(result.issues.some((item) => item.code === "invalid_effects"), "should reject effects arrays");
124
- });
125
- test("rejects structured canvas backgrounds", () => {
145
+ assert((0, src_1.resolveVisualFrame)(doc, 0.5).elements.every((item) => item.id !== "dot"), "timeline start should hide early elements");
146
+ const mid = (0, src_1.resolveVisualFrame)(doc, 2);
147
+ const dot = mid.elements.find((item) => item.id === "dot");
148
+ const mark = mid.elements.find((item) => item.id === "mark");
149
+ assert(near(dot.x, 70) && near(dot.y, 60), `position should interpolate, got ${dot.x},${dot.y}`);
150
+ assert(dot.opacity === 1, "opacity should clamp after the last keyframe");
151
+ assert(mark.stroke === "#ffffff", `color should interpolate/clamp, got ${mark.stroke}`);
152
+ assert(!("timeline" in dot), "resolved frame should not keep timeline instructions");
153
+ });
154
+ test("resolves timeline interpolation graphs", () => {
126
155
  const doc = {
127
156
  version: 1,
128
- canvas: {
129
- width: 400,
130
- height: 240,
131
- background: {
132
- type: "linearGradient",
133
- from: [0, 0],
134
- to: [0, 240],
135
- stops: [[0, "#87ceeb"], [1, "#e8f4f8"]]
157
+ canvas: { width: 320, height: 180, duration: 1 },
158
+ elements: [
159
+ {
160
+ id: "graph",
161
+ type: "point",
162
+ x: 0,
163
+ y: 0,
164
+ timeline: {
165
+ tracks: {
166
+ position: {
167
+ keyframes: [[0, [0, 0]], [1, [100, 0]]],
168
+ curve: { type: "graph", points: [[0, 0], [0.5, 0], [1, 1]] }
169
+ }
170
+ }
171
+ }
172
+ },
173
+ {
174
+ id: "hold",
175
+ type: "point",
176
+ x: 0,
177
+ y: 20,
178
+ timeline: {
179
+ tracks: {
180
+ position: {
181
+ keyframes: [[0, [0, 20]], [1, [100, 20]]],
182
+ curve: { type: "hold" }
183
+ }
184
+ }
185
+ }
136
186
  }
137
- },
138
- elements: []
187
+ ]
139
188
  };
140
- const result = (0, src_1.validateVisualDocument)(doc);
141
- assert(!result.ok, "document should be invalid");
142
- assert(result.issues.some((item) => item.code === "invalid_canvas_background"), "should reject structured canvas backgrounds");
143
- });
144
- test("long text warns without changing layout", () => {
189
+ const mid = (0, src_1.resolveVisualFrame)(doc, 0.5);
190
+ const graph = mid.elements.find((item) => item.id === "graph");
191
+ const hold = mid.elements.find((item) => item.id === "hold");
192
+ assert(near(graph.x, 0), `graph curve should map halfway time to zero progress, got ${graph.x}`);
193
+ assert(near(hold.x, 0), `hold curve should stay at previous value, got ${hold.x}`);
194
+ const late = (0, src_1.resolveVisualFrame)(doc, 0.75).elements.find((item) => item.id === "graph");
195
+ assert(near(late.x, 50), `graph curve should interpolate along normalized graph, got ${late.x}`);
196
+ });
197
+ test("resolves object keyframes and per-segment curves", () => {
145
198
  const doc = {
146
199
  version: 1,
147
- canvas: { width: 400, height: 240 },
148
- elements: [{ id: "label", type: "text", text: "This is a long label that should warn instead of resizing anything automatically", x: 20, y: 20 }]
149
- };
150
- const result = (0, src_1.validateVisualDocument)(doc);
151
- assert(result.ok, "warning should not invalidate document");
152
- assert(result.warnings.some((item) => item.code === "long_text_no_wrap"), "should warn for long text");
153
- });
154
- test("row and column builders position explicit primitive children", () => {
155
- const rowItems = (0, builders_1.row)({
156
- x: 10,
157
- y: 20,
158
- gap: 10,
159
- children: [
160
- (0, builders_1.node)({ id: "a", label: "A", x: 0, y: 0, width: 100, height: 40 }),
161
- (0, builders_1.node)({ id: "b", label: "B", x: 0, y: 0, width: 100, height: 40 })
162
- ]
163
- });
164
- const bBox = rowItems.find((item) => item.id === "b_box");
165
- assert(bBox.x === 120 && bBox.y === 20, "row should move second box deterministically");
166
- const columnItems = (0, builders_1.column)({
167
- x: 10,
168
- y: 20,
169
- gap: 10,
170
- children: [
171
- (0, builders_1.node)({ id: "c", label: "C", x: 0, y: 0, width: 100, height: 40 }),
172
- (0, builders_1.node)({ id: "d", label: "D", x: 0, y: 0, width: 100, height: 40 })
173
- ]
174
- });
175
- const dBox = columnItems.find((item) => item.id === "d_box");
176
- assert(dBox.x === 10 && dBox.y === 70, "column should move second box deterministically");
177
- });
178
- test("renders primitive SVG", () => {
179
- const doc = (0, builders_1.scene)({
180
- canvas: { width: 320, height: 180, background: "#ffffff" },
200
+ canvas: { width: 320, height: 180, duration: 2 },
181
201
  elements: [
182
- { id: "box", type: "rect", x: 20, y: 20, width: 100, height: 60, fill: "#ffffff", stroke: "#111827" },
183
- { id: "label", type: "text", text: "Hello", x: 70, y: 50, align: "center", valign: "middle" }
202
+ {
203
+ id: "dot",
204
+ type: "point",
205
+ x: 0,
206
+ y: 0,
207
+ timeline: {
208
+ tracks: {
209
+ position: {
210
+ curve: { type: "graph", points: [[0, 0], [1, 1]] },
211
+ keyframes: [
212
+ { time: 0, value: [0, 0], out: { type: "hold" } },
213
+ { time: 1, value: [100, 0] },
214
+ { time: 2, value: [200, 0] }
215
+ ]
216
+ }
217
+ }
218
+ }
219
+ }
184
220
  ]
185
- });
186
- const svg = (0, src_1.renderToSvg)(doc);
187
- assert(svg.includes("<svg"), "should render svg");
188
- assert(svg.includes("Hello"), "should include text");
189
- assert(!(0, src_1.renderToSvg)(doc, { transparent: true }).includes('width="320" height="180" fill="#ffffff"'), "transparent SVG should omit background rect");
221
+ };
222
+ const held = (0, src_1.resolveVisualFrame)(doc, 0.5).elements[0];
223
+ const linear = (0, src_1.resolveVisualFrame)(doc, 1.5).elements[0];
224
+ assert(near(held.x, 0), `object keyframe out curve should override track curve, got ${held.x}`);
225
+ assert(near(linear.x, 150), `track curve should apply when segment has no keyframe curve, got ${linear.x}`);
190
226
  });
191
- test("renders abstract canvas primitive styling", () => {
192
- const doc = (0, builders_1.scene)({
193
- canvas: { width: 520, height: 320, background: "#f8fafc" },
227
+ test("resolves nested animatable property tracks", () => {
228
+ const imageSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23fff'/%3E%3C/svg%3E";
229
+ const doc = {
230
+ version: 1,
231
+ canvas: { width: 320, height: 180, duration: 2 },
194
232
  elements: [
195
233
  {
196
- id: "panel",
197
- type: "rect",
198
- x: 40,
199
- y: 40,
200
- width: 180,
201
- height: 120,
202
- radius: 14,
203
- fill: { type: "linearGradient", from: [40, 40], to: [220, 160], stops: [[0, "#dbeafe"], [1, "#a7f3d0"]] },
204
- stroke: "#2563eb",
205
- strokeWidth: 3,
206
- effects: { shadow: { dx: 0, dy: 8, blur: 16, color: "#64748b", opacity: 0.25 } },
207
- rotation: -4
234
+ id: "shape",
235
+ type: "path",
236
+ d: "M 0 0 H 100 V 100 H 0 Z",
237
+ fill: { type: "linearGradient", from: [0, 0], to: [100, 0], stops: [[0, "#000000"], [1, "#ffffff"]] },
238
+ stroke: "#000000",
239
+ dashArray: [0, 10],
240
+ timeline: {
241
+ tracks: {
242
+ "fill.to": { keyframes: [[0, [100, 0]], [2, [100, 100]]], ease: "linear" },
243
+ "fill.stops.0.color": { keyframes: [[0, "#000000"], [2, "#ffffff"]], ease: "linear" },
244
+ "effects.blur": { keyframes: [[0, 0], [2, 10]], ease: "linear" },
245
+ "effects.shadow.opacity": { keyframes: [[0, 1], [2, 0]], ease: "linear" },
246
+ "mask.opacity": { keyframes: [[0, 1], [2, 0]], ease: "linear" },
247
+ dashArray: { keyframes: [[0, [0, 10]], [2, [10, 20]]], ease: "linear" }
248
+ }
249
+ }
208
250
  },
209
251
  {
210
- id: "copy",
211
- type: "text",
212
- lines: ["Gradient panel", "with two lines"],
213
- x: 130,
214
- y: 100,
215
- align: "center",
216
- valign: "middle",
217
- fontSize: 18,
218
- lineHeight: 1.25,
219
- letterSpacing: 0.2
220
- },
221
- { id: "anchor", type: "point", x: 260, y: 100 },
222
- { id: "trend", type: "polyline", points: [[300, 170], [340, 130], [390, 150], [440, 90]], stroke: "#dc2626", strokeWidth: 4, strokeCap: "round", drawEnd: 0.6 },
223
- { id: "triangle", type: "polygon", points: [[330, 230], [390, 190], [450, 230]], fill: "#fef3c7", stroke: "#f59e0b", strokeJoin: "round", blendMode: "multiply" },
224
- { id: "link", type: "arrow", from: "panel.right", to: "anchor", stroke: "#16a34a", strokeWidth: 3, strokeCap: "round" }
252
+ id: "photo",
253
+ type: "image",
254
+ src: imageSrc,
255
+ x: 120,
256
+ y: 20,
257
+ width: 100,
258
+ height: 100,
259
+ timeline: {
260
+ tracks: {
261
+ "source.width": { keyframes: [[0, 100], [2, 50]], ease: "linear" }
262
+ }
263
+ }
264
+ }
225
265
  ]
226
- });
266
+ };
227
267
  const result = (0, src_1.validateVisualDocument)(doc);
228
- assert(result.ok, `styled document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
229
- const svg = (0, src_1.renderToSvg)(doc);
230
- assert(svg.includes("<linearGradient"), "should render gradients");
231
- assert(svg.includes("feDropShadow"), "should render shadow filters");
232
- assert(svg.includes("<tspan"), "should render multiline text");
233
- assert(svg.includes("pathLength=\"1\""), "should render draw reveal");
234
- assert(svg.includes("stroke-linecap=\"round\""), "should render stroke caps");
235
- assert(svg.includes("mix-blend-mode:multiply"), "should render blend mode");
236
- });
237
- test("color animation resolves fill and stroke values", () => {
238
- const doc = (0, builders_1.scene)({
239
- canvas: { width: 200, height: 120 },
268
+ assert(result.ok, `nested animatable tracks should validate: ${result.issues.map((item) => item.message).join("; ")}`);
269
+ const mid = (0, src_1.resolveVisualFrame)(doc, 1);
270
+ const shape = mid.elements.find((item) => item.id === "shape");
271
+ const photo = mid.elements.find((item) => item.id === "photo");
272
+ assert(shape.fill.to[1] === 50, `linear gradient endpoint should interpolate, got ${shape.fill.to}`);
273
+ assert(shape.fill.stops[0][1] === "#808080", `gradient color stop should interpolate, got ${shape.fill.stops[0][1]}`);
274
+ assert(shape.effects.blur === 5, `effects.blur should interpolate, got ${shape.effects.blur}`);
275
+ assert(shape.effects.shadow.opacity === 0.5, `shadow opacity should interpolate, got ${shape.effects.shadow.opacity}`);
276
+ assert(shape.mask.opacity === 0.5, `mask opacity should interpolate, got ${shape.mask.opacity}`);
277
+ assert(shape.dashArray[0] === 5 && shape.dashArray[1] === 15, `dash arrays should interpolate, got ${shape.dashArray}`);
278
+ assert(photo.source.width === 75, `image source crop should interpolate, got ${photo.source.width}`);
279
+ });
280
+ test("rejects unknown tracks and invalid known track values", () => {
281
+ const unknownDoc = {
282
+ version: 1,
283
+ canvas: { width: 320, height: 180 },
240
284
  elements: [
241
285
  {
242
- id: "signal",
243
- type: "circle",
244
- cx: 60,
245
- cy: 60,
246
- radius: 20,
247
- fill: "#ff0000",
248
- stroke: "#0000ff",
249
- animate: {
250
- fill: (0, builders_1.animate)("#ff0000", "#00ff00", { duration: 2 }),
251
- stroke: (0, builders_1.animate)("#0000ff", "#ff0000", { duration: 2 })
252
- }
286
+ id: "dot",
287
+ type: "point",
288
+ x: 0,
289
+ y: 0,
290
+ timeline: { tracks: { "future.magic": { keyframes: [{ time: 0, value: { enabled: true, amount: 1 } }] } } }
253
291
  }
254
292
  ]
255
- });
256
- const frame = (0, src_1.resolveVisualFrame)(doc, 1);
257
- const signal = frame.elements.find((item) => item.id === "signal");
258
- assert(signal.fill === "#808000", `fill should interpolate, got ${signal.fill}`);
259
- assert(signal.stroke === "#800080", `stroke should interpolate, got ${signal.stroke}`);
260
- });
261
- test("point references resolve after point animation", () => {
262
- const doc = (0, builders_1.scene)({
263
- canvas: { width: 240, height: 120 },
293
+ };
294
+ const unknown = (0, src_1.validateVisualDocument)(unknownDoc);
295
+ assert(!unknown.ok, "unknown compatibility track should be invalid in the frozen kernel");
296
+ assert(unknown.issues.some((item) => item.code === "unknown_timeline_track"), "unknown compatibility track should be rejected");
297
+ const invalidKnown = {
298
+ version: 1,
299
+ canvas: { width: 320, height: 180 },
264
300
  elements: [
265
- { id: "moving", type: "point", x: 20, y: 40, animate: { x: (0, builders_1.animate)(20, 120, { duration: 2 }) } },
266
- { id: "wire", type: "line", from: "moving", to: [200, 40], stroke: "#111827" }
301
+ {
302
+ id: "shape",
303
+ type: "path",
304
+ d: "M 0 0 L 100 0",
305
+ timeline: { tracks: { "effects.blur": { keyframes: [[0, "#fff"]] } } }
306
+ }
267
307
  ]
268
- });
269
- const frame = (0, src_1.resolveVisualFrame)(doc, 1);
270
- const wire = frame.elements.find((item) => item.id === "wire");
271
- assert(JSON.stringify(wire.from) === JSON.stringify([70, 40]), `animated point reference should resolve, got ${JSON.stringify(wire.from)}`);
308
+ };
309
+ const invalid = (0, src_1.validateVisualDocument)(invalidKnown);
310
+ assert(!invalid.ok, "invalid value for a known supported property should fail");
311
+ assert(invalid.issues.some((item) => item.code === "invalid_timeline_value_for_property"), "known property value mismatch should be reported");
272
312
  });
273
- test("renders image fit, image crop, arc, curve, mask, and pattern paint", () => {
274
- const texture = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Crect width='32' height='32' fill='%23dbeafe'/%3E%3Cpath d='M0 32 L32 0' stroke='%232563eb' stroke-width='6'/%3E%3C/svg%3E";
275
- const photo = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='240'%3E%3Crect width='400' height='240' fill='%23fef3c7'/%3E%3Ccircle cx='260' cy='120' r='90' fill='%23fb923c'/%3E%3C/svg%3E";
276
- const doc = (0, builders_1.scene)({
277
- canvas: { width: 640, height: 360, background: "#ffffff" },
313
+ test("resolves newly supported animatable kernel properties", () => {
314
+ const patternSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='%23fff'/%3E%3C/svg%3E";
315
+ const doc = {
316
+ version: 1,
317
+ canvas: { width: 320, height: 180, duration: 1 },
278
318
  elements: [
279
319
  {
280
- id: "pattern_card",
281
- type: "rect",
282
- x: 40,
283
- y: 40,
284
- width: 150,
285
- height: 110,
286
- radius: 12,
287
- fill: { type: "pattern", src: texture, width: 32, height: 32, fit: "cover" },
288
- stroke: "#2563eb"
289
- },
290
- {
291
- id: "cropped_photo",
292
- type: "image",
293
- src: photo,
294
- x: 230,
295
- y: 40,
296
- width: 160,
297
- height: 110,
298
- fit: "cover",
299
- source: { x: 100, y: 40, width: 180, height: 120, imageWidth: 400, imageHeight: 240 },
300
- mask: { type: "circle", cx: 310, cy: 95, radius: 55 }
320
+ id: "path",
321
+ type: "path",
322
+ d: "M 0 0 L 20 0",
323
+ stroke: "#000000",
324
+ timeline: {
325
+ tracks: {
326
+ d: { keyframes: [[0, "M 0 0 L 20 0"], [1, "M 0 0 L 40 40"]] }
327
+ }
328
+ }
301
329
  },
302
330
  {
303
- id: "angle",
304
- type: "arc",
305
- cx: 120,
306
- cy: 250,
307
- radius: 70,
308
- startAngle: -20,
309
- endAngle: 230,
310
- stroke: "#dc2626",
311
- strokeWidth: 5,
312
- strokeCap: "round",
313
- drawEnd: 0.75
331
+ id: "text",
332
+ type: "text",
333
+ text: "A",
334
+ x: 10,
335
+ y: 20,
336
+ align: "left",
337
+ valign: "top",
338
+ timeline: {
339
+ tracks: {
340
+ align: { keyframes: [[0, "left"], [1, "right"]] },
341
+ valign: { keyframes: [[0, "top"], [1, "bottom"]] },
342
+ fontStyle: { keyframes: [[0, "normal"], [1, "italic"]] }
343
+ }
344
+ }
314
345
  },
315
346
  {
316
- id: "flow_curve",
317
- type: "curve",
318
- from: [250, 250],
319
- control1: [340, 150],
320
- control2: [440, 340],
321
- to: [560, 230],
322
- stroke: "#16a34a",
323
- strokeWidth: 5,
324
- strokeCap: "round",
325
- fill: "none"
347
+ id: "pattern",
348
+ type: "path",
349
+ d: "M 0 0 H 100 V 100 H 0 Z",
350
+ fill: { type: "pattern", src: patternSrc, x: 0, y: 0, width: 10, height: 10, opacity: 1 },
351
+ timeline: {
352
+ tracks: {
353
+ "fill.x": { keyframes: [[0, 0], [1, 12]] },
354
+ "fill.width": { keyframes: [[0, 10], [1, 20]] },
355
+ "fill.opacity": { keyframes: [[0, 1], [1, 0.5]] }
356
+ }
357
+ }
326
358
  },
327
359
  {
328
- id: "curve_dot",
329
- type: "circle",
330
- radius: 7,
331
- fill: "#111827",
332
- follow: "flow_curve",
333
- progress: 0.5
360
+ id: "paint",
361
+ type: "path",
362
+ d: "M 0 0 H 10 V 10 H 0 Z",
363
+ fill: "#000000",
364
+ timeline: {
365
+ tracks: {
366
+ fill: {
367
+ keyframes: [
368
+ [0, "#000000"],
369
+ [1, { type: "linearGradient", from: [0, 0], to: [10, 0], stops: [[0, "#000000"], [1, "#ffffff"]] }]
370
+ ]
371
+ }
372
+ }
373
+ }
334
374
  }
335
375
  ]
336
- });
337
- const result = (0, src_1.validateVisualDocument)(doc);
338
- assert(result.ok, `canvas-like document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
339
- const frame = (0, src_1.resolveVisualFrame)(doc, 0);
340
- const dot = frame.elements.find((item) => item.id === "curve_dot");
341
- assert(dot.cx > 250 && dot.cx < 560, "circle should follow curve primitive");
342
- const svg = (0, src_1.renderToSvg)(doc);
343
- assert(svg.includes("<pattern"), "should render pattern paint");
344
- assert(svg.includes("preserveAspectRatio=\"xMidYMid slice\""), "should render cover image fit");
345
- assert(svg.includes("clip-path=\"url(#"), "should crop image with clip path");
346
- assert(svg.includes("<mask"), "should render masks");
347
- assert(svg.includes(" A "), "should render arc path");
348
- assert(svg.includes(" C "), "should render cubic curve path");
349
- });
350
- test("lowers authoring primitives into the 2D kernel", () => {
351
- const doc = (0, builders_1.scene)({
352
- canvas: { width: 640, height: 360 },
353
- elements: [
354
- { id: "box", type: "rect", x: 20, y: 20, width: 80, height: 50, radius: 8, fill: "#ffffff", clip: { type: "rect", x: 20, y: 20, width: 80, height: 50 } },
355
- { id: "dot", type: "circle", cx: 140, cy: 45, radius: 20, fill: "#2563eb" },
356
- { id: "oval", type: "ellipse", cx: 210, cy: 45, rx: 28, ry: 16 },
357
- { id: "wire", type: "line", from: [260, 20], to: [320, 80] },
358
- { id: "arrow", type: "arrow", from: [340, 20], to: [400, 80], stroke: "#dc2626" },
359
- { id: "angle", type: "arc", cx: 100, cy: 160, radius: 40, startAngle: 0, endAngle: 180 },
360
- { id: "curve", type: "curve", from: [170, 160], control1: [230, 80], to: [300, 160], stroke: "#16a34a" },
361
- { id: "poly", type: "polyline", points: [[330, 150], [360, 110], [390, 150]] },
362
- { id: "shape", type: "polygon", points: [[420, 150], [450, 110], [480, 150]] },
363
- { id: "raw", type: "path", d: "M 20 260 L 100 260" },
364
- { id: "label", type: "text", text: "Kernel", x: 160, y: 260 },
365
- { id: "anchor", type: "point", x: 240, y: 260 },
366
- { id: "photo", type: "image", src: "data:image/svg+xml,%3Csvg/%3E", x: 280, y: 230, width: 80, height: 50, mask: { type: "circle", cx: 320, cy: 255, radius: 24 } },
367
- { id: "group", type: "group", x: 420, y: 220, children: [{ id: "child", type: "rect", x: 0, y: 0, width: 40, height: 30 }] }
368
- ]
369
- });
370
- const lowered = (0, src_1.lowerVisualDocument)(doc);
371
- const allowed = new Set(["group", "path", "text", "image", "point"]);
372
- assert((0, src_1.validateKernelVisualDocument)(lowered).ok, "lowered 2D document should validate as kernel");
373
- assert(lowered.elements.every((item) => allowed.has(item.type)), "top-level 2D kernel should contain only low primitives");
374
- assert(lowered.elements.filter((item) => item.type === "path").length >= 10, "friendly shapes should lower to paths");
375
- assert(lowered.elements.find((item) => item.id === "arrow").metadata.markerEnd === "arrow", "arrow marker metadata should survive lowering");
376
- assert(lowered.elements.find((item) => item.id === "box").clip.type === "path", "clip shape should lower to path");
377
- assert(lowered.elements.find((item) => item.id === "photo").mask.type === "path", "mask shape should lower to path");
378
- });
379
- test("uses an internal registry for authoring shape lowerers", () => {
380
- const types = (0, shapes_1.registeredAuthoringShapeTypes)();
381
- assert(types.includes("rect") && types.includes("circle") && types.includes("sphere"), "built-in shape registry should expose registered authoring shapes");
382
- });
383
- test("validates built-in shape geometry through the registry", () => {
384
- const doc = {
385
- version: 1,
386
- canvas: { width: 200, height: 120 },
387
- elements: [{ id: "bad_rect", type: "rect", x: 10, y: 10, height: 40 }]
388
376
  };
389
377
  const result = (0, src_1.validateVisualDocument)(doc);
390
- assert(!result.ok, "document should be invalid");
391
- assert(result.issues.some((item) => item.path === "/elements/0/width" && item.code === "missing_number"), "rect width should be validated by shape definition");
392
- });
393
- test("enforces shape-owned animation properties", () => {
378
+ assert(result.ok, `new animatable properties should validate: ${result.issues.map((item) => item.message).join("; ")}`);
379
+ const midPath = (0, src_1.resolveVisualFrame)(doc, 0.5).elements.find((item) => item.id === "path");
380
+ assert(midPath.d === "M 0 0 L 20 0", `path.d should be discrete before the target keyframe, got ${midPath.d}`);
381
+ const end = (0, src_1.resolveVisualFrame)(doc, 1);
382
+ const text = end.elements.find((item) => item.id === "text");
383
+ const pattern = end.elements.find((item) => item.id === "pattern");
384
+ const paint = end.elements.find((item) => item.id === "paint");
385
+ assert(text.align === "right" && text.valign === "bottom" && text.fontStyle === "italic", "text layout/style tracks should resolve discretely");
386
+ assert(pattern.fill.x === 12 && pattern.fill.width === 20 && pattern.fill.opacity === 0.5, "pattern internals should resolve");
387
+ assert(paint.fill.type === "linearGradient", "whole paint tracks should switch structured paints");
388
+ });
389
+ test("warns about overlapping timeline representations", () => {
394
390
  const doc = {
395
391
  version: 1,
396
- canvas: { width: 200, height: 120 },
397
- elements: [{ id: "label", type: "text", text: "Hello", x: 20, y: 20, animate: { radius: (0, builders_1.animate)(2, 10, { duration: 1 }) } }]
398
- };
399
- const result = (0, src_1.validateVisualDocument)(doc);
400
- assert(!result.ok, "document should be invalid");
401
- assert(result.issues.some((item) => item.code === "unsupported_animation_property"), "text should not accept circle-only animation properties");
402
- });
403
- test("generated schema matches the committed schema artifact", () => {
404
- const schemaPath = path.resolve(__dirname, "..", "..", "schema", "visual.schema.json");
405
- const committed = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
406
- assert(stableStringify((0, src_1.generateVisualSchema)()) === stableStringify(committed), "committed visual.schema.json should match generated schema");
407
- });
408
- test("resolves animation before lowering kernel frames", () => {
409
- const doc = (0, builders_1.scene)({
410
- canvas: { width: 320, height: 180, duration: 2 },
411
- elements: [
412
- { id: "moving_box", type: "rect", x: 20, y: 30, width: 40, height: 30, animate: { x: (0, builders_1.animate)(20, 120, { duration: 2 }) } }
413
- ]
414
- });
415
- const frame = (0, src_1.resolveKernelFrame)(doc, 1);
416
- const pathElement = frame.elements.find((item) => item.id === "moving_box");
417
- assert(pathElement.type === "path", "animated rect should lower to a kernel path");
418
- assert(pathElement.d.startsWith("M 70 "), `lowered frame should use resolved animated x, got ${pathElement.d}`);
419
- assert(!("animate" in pathElement), "resolved kernel frame should not retain authoring animation instructions");
420
- });
421
- test("lowered kernel paths keep only kernel-compatible animations", () => {
422
- const doc = (0, builders_1.scene)({
423
- canvas: { width: 320, height: 180, duration: 2 },
392
+ canvas: { width: 320, height: 180 },
424
393
  elements: [
425
394
  {
426
- id: "moving_box",
427
- type: "rect",
428
- x: 20,
429
- y: 30,
430
- width: 40,
431
- height: 30,
432
- opacity: 0.4,
433
- animate: {
434
- x: (0, builders_1.animate)(20, 120, { duration: 2 }),
435
- opacity: (0, builders_1.animate)(0.4, 1, { duration: 2 })
395
+ id: "shape",
396
+ type: "path",
397
+ d: "M 0 0 L 100 0",
398
+ timeline: {
399
+ tracks: {
400
+ position: { keyframes: [[0, [0, 0]]] },
401
+ x: { keyframes: [[0, 10]] }
402
+ }
436
403
  }
437
404
  }
438
405
  ]
439
- });
440
- const lowered = (0, src_1.lowerVisualDocument)(doc);
441
- const pathElement = lowered.elements.find((item) => item.id === "moving_box");
442
- assert(pathElement.type === "path", "rect should lower to a kernel path");
443
- assert(!("x" in (pathElement.animate ?? {})), "path lowering should remove authoring-only x animation");
444
- assert(pathElement.animate?.opacity, "path lowering should keep compatible opacity animation");
406
+ };
407
+ const result = (0, src_1.validateVisualDocument)(doc);
408
+ assert(result.ok, "overlapping tracks should remain valid");
409
+ assert(result.warnings.some((item) => item.code === "conflicting_timeline_tracks"), "overlapping tracks should warn");
445
410
  });
446
- test("lowers structured 3D authoring primitives into mesh kernel", () => {
411
+ test("compiles visual keyframe states to kernel timelines", () => {
447
412
  const doc = {
448
413
  version: 1,
449
- canvas: { width: 320, height: 180, space: "3d", renderer: "three" },
414
+ canvas: { width: 320, height: 180, duration: 1 },
450
415
  elements: [
451
- { id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1], fill: "#2563eb" },
452
- { id: "ball", type: "sphere", position: [2, 0, 0], radius: 0.5, fill: "#ef4444" },
453
- { id: "floor", type: "plane", position: [0, -1, 0], size: [4, 3], fill: "#dbeafe" },
454
- { id: "axis", type: "line3d", from: [-1, 0, 0], to: [1, 0, 0], stroke: "#111827" },
455
- { id: "caption", type: "text3d", text: "3D", position: [0, 1, 0] },
456
- { id: "lamp", type: "light", kind: "directional", position: [2, 4, 3] }
416
+ {
417
+ id: "card",
418
+ type: "group",
419
+ x: 20,
420
+ y: 80,
421
+ width: 80,
422
+ height: 40,
423
+ children: [{ id: "card_bg", type: "path", d: "M 0 0 H 80 V 40 H 0 Z", fill: "#ffffff" }]
424
+ }
457
425
  ]
458
426
  };
459
- const lowered = (0, src_1.lowerVisualDocument)(doc);
460
- assert((0, src_1.validateKernelVisualDocument)(lowered).ok, "lowered 3D document should validate as kernel");
461
- assert(lowered.elements.filter((item) => item.type === "mesh3d").length === 3, "cuboid, sphere, and plane should lower to mesh3d");
462
- assert(lowered.elements.some((item) => item.type === "line3d"), "line3d should remain a kernel line");
463
- assert(lowered.elements.find((item) => item.id === "cube").vertices.length === 8, "cuboid mesh should expose vertices");
464
- });
465
- test("circle followers can move along paths", () => {
466
- const doc = (0, builders_1.scene)({
467
- canvas: { width: 320, height: 180, duration: 2 },
468
- elements: [
469
- { id: "route", type: "path", d: "M 20 120 C 90 20 180 160 300 60", fill: "none", stroke: "#2563eb" },
470
- { id: "dot", type: "circle", radius: 6, fill: "#ef4444", follow: "route", progress: (0, builders_1.animate)(0, 1, { duration: 2 }) }
471
- ]
472
- });
473
- const start = (0, src_1.resolveVisualFrame)(doc, 0).elements.find((item) => item.id === "dot");
474
- const middle = (0, src_1.resolveVisualFrame)(doc, 1).elements.find((item) => item.id === "dot");
475
- const end = (0, src_1.resolveVisualFrame)(doc, 2).elements.find((item) => item.id === "dot");
476
- assert(near(start.cx, 20) && near(start.cy, 120), "path follower should start at path start");
477
- assert(near(end.cx, 300) && near(end.cy, 60), "path follower should end at path end");
478
- assert(middle.cx > 20 && middle.cx < 300, "path follower should move through sampled path");
479
- });
480
- test("circle followers sample lowered path-like authoring shapes", () => {
481
- const doc = (0, builders_1.scene)({
482
- canvas: { width: 320, height: 220 },
483
- elements: [
484
- { id: "arc_route", type: "arc", cx: 90, cy: 100, radius: 50, startAngle: 0, endAngle: 180, stroke: "#2563eb" },
485
- { id: "arc_dot", type: "circle", radius: 4, fill: "#ef4444", follow: "arc_route", progress: 0.5 },
486
- { id: "poly_route", type: "polyline", points: [[180, 160], [220, 80], [280, 160]], stroke: "#16a34a" },
487
- { id: "poly_dot", type: "circle", radius: 4, fill: "#111827", follow: "poly_route", progress: 1 }
488
- ]
489
- });
490
- const frame = (0, src_1.resolveVisualFrame)(doc, 0);
491
- const arcDot = frame.elements.find((item) => item.id === "arc_dot");
492
- const polyDot = frame.elements.find((item) => item.id === "poly_dot");
493
- assert(near(arcDot.cx, 90) && near(arcDot.cy, 150), `arc follower should sample the lowered arc path, got ${arcDot.cx},${arcDot.cy}`);
494
- assert(near(polyDot.cx, 280) && near(polyDot.cy, 160), "polyline follower should sample lowered polyline path");
495
- });
496
- test("applies primitive id patches", () => {
497
- const doc = (0, builders_1.scene)({
498
- canvas: { width: 320, height: 180 },
499
- elements: [{ id: "label", type: "text", text: "Old", x: 20, y: 20 }]
500
- });
501
- const patched = (0, src_1.applyVisualPatch)(doc, { op: "update", id: "label", set: { text: "New" } }).document;
502
- const label = patched.elements?.find((item) => item.id === "label");
503
- assert(label.text === "New", "patch should update by id");
504
- });
505
- test("resolves scenes and sequences", () => {
427
+ const animated = (0, src_1.compileKeyframeStates)(doc, [
428
+ { time: 1, set: { card: { position: [220, 80], scale: 1.2, opacity: 0.5 } }, ease: "linear" }
429
+ ]);
430
+ const result = (0, src_1.validateVisualDocument)(animated);
431
+ assert(result.ok, `compiled keyframes should validate: ${result.issues.map((item) => item.message).join("; ")}`);
432
+ const card = animated.elements?.[0];
433
+ assert(card.timeline.tracks.position.keyframes.length === 2, "compiler should include the base position keyframe");
434
+ assert(card.timeline.tracks.position.keyframes[0].value[0] === 20, "base position should come from the element");
435
+ assert(card.timeline.tracks.position.keyframes[0].out?.type === "graph", "named linear ease should compile to an explicit graph curve");
436
+ const mid = (0, src_1.resolveVisualFrame)(animated, 0.5).elements[0];
437
+ assert(near(mid.x, 120) && near(mid.scale, 1.1) && near(mid.opacity, 0.75), `compiled timeline should interpolate, got ${mid.x}, ${mid.scale}, ${mid.opacity}`);
438
+ });
439
+ test("compiles per-property curves and timing offsets", () => {
506
440
  const doc = {
507
441
  version: 1,
508
- canvas: { width: 320, height: 180 },
509
- scenes: {
510
- intro: { elements: [{ id: "intro_title", type: "text", text: "Intro", x: 20, y: 20 }] },
511
- outro: { elements: [{ id: "outro_title", type: "text", text: "Outro", x: 20, y: 20 }] }
512
- },
513
- sequences: {
514
- main: { id: "main", clips: [{ scene: "intro", duration: 2 }, { scene: "outro", duration: 3 }] }
515
- }
442
+ canvas: { width: 320, height: 180, duration: 2 },
443
+ elements: [{ id: "dot", type: "point", x: 0, y: 0 }]
516
444
  };
517
- assert((0, src_1.documentForScene)(doc, "intro").elements?.[0]?.id === "intro_title", "scene should resolve");
518
- assert((0, src_1.resolvedFrameForScene)(doc, "intro", 0).elements[0]?.id === "intro_title", "scene resolved frame should resolve");
519
- assert((0, src_1.compileVisualSequence)(doc, "main").duration === 5, "sequence duration should compile");
520
- const frame = (0, src_1.documentForSequenceTime)(doc, "main", 2.5);
521
- assert(frame.scene === "outro" && frame.localTime === 0.5, "sequence frame should map global to local time");
522
- assert((0, src_1.resolvedFrameForSequenceTime)(doc, "main", 2.5).document.elements[0]?.id === "outro_title", "sequence resolved frame should expose resolved authoring frame");
523
- });
524
- test("sequence fade transitions produce inspectable frames and timeline", () => {
445
+ const animated = (0, src_1.compileKeyframeStates)(doc, [
446
+ {
447
+ time: 1,
448
+ set: {
449
+ dot: {
450
+ position: {
451
+ value: [100, 0],
452
+ curve: { type: "hold" },
453
+ offset: 0.25
454
+ }
455
+ }
456
+ }
457
+ }
458
+ ], { offsets: { dot: { position: 0.25 } } });
459
+ const dot = animated.elements?.[0];
460
+ const keyframes = dot.timeline.tracks.position.keyframes;
461
+ assert(keyframes[0].time === 0 && keyframes[0].out.type === "hold", "base keyframe should receive the property curve");
462
+ assert(keyframes[1].time === 1.5, `global and property offsets should shift the target keyframe, got ${keyframes[1].time}`);
463
+ assert(near((0, src_1.resolveVisualFrame)(animated, 1).elements[0].x, 0), "hold curve should keep the point at the base value before the target");
464
+ });
465
+ test("edits nested element properties and timeline keyframes", () => {
525
466
  const doc = {
526
467
  version: 1,
527
- canvas: { width: 320, height: 180, duration: 2, fps: 10 },
528
- scenes: {
529
- a: { elements: [{ id: "a_title", type: "text", text: "A", x: 20, y: 20 }] },
530
- b: { elements: [{ id: "b_title", type: "text", text: "B", x: 20, y: 20 }] }
531
- },
532
- sequences: {
533
- main: { id: "main", clips: [{ scene: "a", duration: 1 }, { scene: "b", duration: 1, transition: { type: "fade", duration: 0.5 } }] }
534
- }
468
+ canvas: { width: 320, height: 180, duration: 2 },
469
+ elements: [
470
+ {
471
+ id: "root",
472
+ type: "group",
473
+ x: 10,
474
+ y: 20,
475
+ children: [{ id: "nested", type: "point", x: 5, y: 6 }]
476
+ }
477
+ ]
535
478
  };
536
- const frame = (0, src_1.documentForSequenceTime)(doc, "main", 1.25);
537
- assert(frame.transition?.type === "fade", "frame should expose fade transition");
538
- assert(frame.document.elements?.some((item) => item.id === "fade_from_a_title"), "fade should include previous primitive clone");
539
- assert(frame.document.elements?.some((item) => item.id === "fade_to_b_title"), "fade should include next primitive clone");
540
- assert((0, src_1.sequenceTimeline)(doc, "main", 10).some((item) => item.transition?.type === "fade"), "timeline should expose transition frames");
541
- });
542
- test("applies deck step visibility without mutating primitives", () => {
479
+ assert((0, src_1.listElementReferences)(doc).some((item) => item.id === "nested" && item.depth === 1), "nested element should be listed");
480
+ const moved = (0, src_1.setElementProperty)(doc, "nested", "position", [30, 40]);
481
+ assert((0, src_1.findElementById)(moved, "nested").x === 30, "nested position should update immutably");
482
+ assert((0, src_1.findElementById)(doc, "nested").x === 5, "original document should not mutate");
483
+ const animated = (0, src_1.setTimelineKeyframe)(moved, "nested", "position", 1, [90, 40], { out: { type: "hold" } });
484
+ const tracks = (0, src_1.listTimelineTracks)(animated, "nested");
485
+ assert(tracks.length === 1 && tracks[0].property === "position", "timeline track should be listed");
486
+ assert((0, src_1.resolveVisualFrame)(animated, 1).elements[0].children[0].x === 90, "timeline keyframe should resolve");
487
+ const cleaned = (0, src_1.removeTimelineKeyframe)(animated, "nested", "position", 1);
488
+ assert(!(0, src_1.listTimelineTracks)(cleaned, "nested").length, "timeline keyframe removal should prune empty tracks");
489
+ });
490
+ test("edits and renders path position offsets", () => {
543
491
  const doc = {
544
492
  version: 1,
545
- canvas: { width: 320, height: 180 },
546
- scenes: {
547
- slide: {
548
- elements: [
549
- { id: "title", type: "text", text: "Title", x: 20, y: 20 },
550
- { id: "detail", type: "text", text: "Detail", x: 20, y: 60, opacity: 0 }
551
- ],
552
- steps: [{ id: "show_detail", show: ["detail"] }]
553
- }
554
- }
493
+ canvas: { width: 120, height: 80 },
494
+ elements: [{ id: "mark", type: "path", d: "M 0 0 L 20 0", stroke: "#000000" }]
555
495
  };
556
- const frame = (0, src_1.documentForDeckStep)(doc, "slide", 0);
557
- const detail = frame.elements?.find((item) => item.id === "detail");
558
- assert(detail?.opacity === 1, "deck step should show explicit ids");
559
- assert((0, src_1.resolvedFrameForDeckStep)(doc, "slide", 0).elements.find((item) => item.id === "detail")?.opacity === 1, "deck resolved frame should expose step visibility");
560
- assert((0, src_1.renderDeckToHtml)(doc, "slide").includes("Next"), "deck html should include controls");
496
+ const moved = (0, src_1.setElementProperty)(doc, "mark", "position", [30, 40]);
497
+ const mark = (0, src_1.findElementById)(moved, "mark");
498
+ assert(mark.x === 30 && mark.y === 40, "path position should edit x/y offsets");
499
+ assert((0, src_1.renderToSvg)(moved).includes("translate(30 40)"), "path position should render as translation");
561
500
  });
562
- test("reports visual diagnostics without moving elements", () => {
501
+ test("validates timeline graph mistakes", () => {
563
502
  const doc = {
564
503
  version: 1,
565
- canvas: { width: 100, height: 100 },
504
+ canvas: { width: 320, height: 180 },
566
505
  elements: [
567
- { id: "outside", type: "rect", x: 80, y: 80, width: 40, height: 40 },
568
- { id: "panel", type: "rect", x: 10, y: 10, width: 50, height: 20, fill: "#ffffff" },
569
- { id: "low", type: "text", text: "Low contrast text", x: 35, y: 20, align: "center", valign: "middle", fill: "#eeeeee" }
506
+ {
507
+ id: "dot",
508
+ type: "point",
509
+ x: 0,
510
+ y: 0,
511
+ timeline: {
512
+ tracks: {
513
+ position: {
514
+ keyframes: [[0, [0, 0]], [1, [100, 0]]],
515
+ curve: { type: "graph", points: [[0, 0], [0.25, 1], [0.2, 0.5], [1, 1]] }
516
+ }
517
+ }
518
+ }
519
+ }
570
520
  ]
571
521
  };
572
- const report = (0, src_1.lintVisualDocument)(doc);
573
- assert(report.warnings.some((item) => item.code === "element_outside_canvas"), "should warn about off-canvas elements");
574
- assert(report.warnings.some((item) => item.code === "low_text_contrast"), "should warn about contrast");
522
+ const result = (0, src_1.validateVisualDocument)(doc);
523
+ assert(!result.ok, "unsorted graph points should be invalid");
524
+ assert(result.issues.some((item) => item.code === "unsorted_curve_points"), "should report unsorted curve points");
575
525
  });
576
- test("builds project symbol index and validates project scopes", () => {
526
+ test("validates timeline mistakes", () => {
577
527
  const doc = {
578
528
  version: 1,
579
529
  canvas: { width: 320, height: 180 },
580
- scenes: {
581
- intro: { elements: [{ id: "title", type: "text", text: "Intro", x: 20, y: 20 }] }
582
- }
530
+ elements: [
531
+ { id: "dot", type: "point", x: 0, y: 0, timeline: { tracks: { x: { keyframes: [[1, 10], [0, 0]] } } } }
532
+ ]
583
533
  };
584
- const project = { document: doc, files: { "project.visual.json": doc }, symbols: [] };
585
- project.symbols = (0, src_1.buildSymbolIndex)(project);
586
- assert(project.symbols.some((symbol) => symbol.id === "title" && symbol.scene === "intro"), "symbol index should include scene ids");
587
- assert((0, src_1.validateVisualProject)(project).ok, "project should validate");
534
+ const result = (0, src_1.validateVisualDocument)(doc);
535
+ assert(!result.ok, "unsorted timeline should be invalid");
536
+ assert(result.issues.some((item) => item.code === "unsorted_timeline_keyframes"), "should report unsorted timeline keyframes");
588
537
  });
589
- test("renders structured three html", () => {
538
+ test("diagnostics report off-canvas elements", () => {
590
539
  const doc = {
591
540
  version: 1,
592
- canvas: { width: 320, height: 180, space: "3d", renderer: "three", background: "#ffffff" },
593
- elements: [{ id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1], fill: "#2563eb" }]
541
+ canvas: { width: 100, height: 100 },
542
+ elements: [{ id: "outside", type: "text", text: "Outside", x: 120, y: 20 }]
594
543
  };
595
- const html = (0, src_1.renderToThreeHtml)(doc);
596
- assert(html.includes("THREE") && html.includes("mesh3d"), "should render three html from kernel mesh");
597
- assert((0, src_1.renderToHtml)(doc).includes("THREE"), "html renderer should delegate to three");
598
- assert((0, src_1.renderThreePreviewSvg)(doc).includes("<polygon"), "structured three should have deterministic svg preview");
544
+ assert((0, src_1.lintVisualDocument)(doc).warnings.some((item) => item.code === "element_outside_canvas"), "should warn about off-canvas text");
599
545
  });
600
- test("renders raw three module escape hatch html outside primitive docs", () => {
601
- const html = (0, src_1.renderRawThreeModuleHtml)({ width: 320, height: 180, moduleUrl: "./scene.js" });
602
- assert(html.includes("createSketchmarkThreeScene"), "raw module html should use explicit module entrypoint");
546
+ test("generated schema matches the committed schema artifact", () => {
547
+ const schemaPath = path.resolve(__dirname, "..", "..", "schema", "visual.schema.json");
548
+ const committed = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
549
+ assert(stableStringify((0, src_1.generateVisualSchema)()) === stableStringify(committed), "committed visual.schema.json should match generated schema");
603
550
  });
604
- test("all good examples validate and render", () => {
605
- const examplesRoot = path.resolve(__dirname, "..", "..", "examples");
606
- const files = collectVisualFiles(examplesRoot).filter((file) => !normalizePath(file).includes("/bad/"));
607
- const featureFiles = files.filter((file) => normalizePath(file).includes("/features/"));
608
- assert(featureFiles.length >= 20, "should include at least 20 feature examples");
551
+ test("all example visual documents validate against the frozen kernel", () => {
552
+ const examplesDir = path.resolve(__dirname, "..", "..", "examples");
553
+ const files = fs.readdirSync(examplesDir).filter((file) => file.endsWith(".visual.json")).sort();
554
+ assert(files.length > 0, "expected at least one example visual document");
555
+ const failures = [];
609
556
  for (const file of files) {
610
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
611
- const doc = raw.imports ? (0, src_1.loadVisualProject)(file).document : raw;
557
+ const fullPath = path.join(examplesDir, file);
558
+ const document = JSON.parse(fs.readFileSync(fullPath, "utf8"));
559
+ const result = (0, src_1.validateVisualDocument)(document);
560
+ if (!result.ok) {
561
+ const details = result.issues.map((item) => `${item.path} ${item.code}: ${item.message}`).join("; ");
562
+ failures.push(`${file}: ${details}`);
563
+ }
564
+ }
565
+ assert(!failures.length, failures.join("\n"));
566
+ });
567
+ test("root export stays kernel-focused and presets live under the presets entrypoint", () => {
568
+ const root = require("../src");
569
+ assert(root.shapes === undefined && root.characters === undefined && root.applyPresetFragments === undefined, "root package export should not expose preset namespaces");
570
+ assert(typeof presets_1.shapes.rect === "function" && typeof presets_1.motions.fadeIn === "function", "presets entrypoint should expose official presets");
571
+ });
572
+ test("built-in shape, character, and scene presets compile to valid kernel elements", () => {
573
+ const doc = (0, presets_1.applyPresetFragments)(emptyPresetDocument(), [
574
+ presets_1.shapes.rect({ id: "shape.rect", x: 20, y: 20, width: 90, height: 50, fill: "#ffffff", stroke: "#111827" }),
575
+ presets_1.shapes.roundedRect({ id: "shape.rounded", x: 130, y: 20, width: 110, height: 50, radius: 12, fill: "#eff6ff", stroke: "#2563eb" }),
576
+ presets_1.shapes.ellipse({ id: "shape.ellipse", cx: 310, cy: 45, rx: 48, ry: 25, fill: "#dcfce7", stroke: "#16a34a" }),
577
+ presets_1.shapes.circle({ id: "shape.circle", cx: 400, cy: 45, radius: 24, fill: "#fef3c7", stroke: "#ca8a04" }),
578
+ presets_1.shapes.line({ id: "shape.line", from: [20, 100], to: [140, 120], stroke: "#0f172a" }),
579
+ presets_1.shapes.polyline({ id: "shape.polyline", points: [[170, 115], [205, 90], [240, 125]], stroke: "#7c3aed" }),
580
+ presets_1.shapes.arrow({ id: "shape.arrow", from: [280, 110], to: [410, 95], stroke: "#dc2626" }),
581
+ presets_1.shapes.regularPolygon({ id: "shape.polygon", cx: 500, cy: 100, radius: 30, sides: 6, fill: "#e0f2fe", stroke: "#0284c7" }),
582
+ presets_1.shapes.star({ id: "shape.star", cx: 590, cy: 100, outerRadius: 32, fill: "#fde68a", stroke: "#92400e" }),
583
+ presets_1.shapes.speechBubble({ id: "shape.speech", x: 650, y: 65, width: 190, height: 70, text: "Hello" }),
584
+ presets_1.characters.stickPerson({ id: "hero", x: 40, y: 190 }),
585
+ presets_1.characters.talkingHead({ id: "speaker", x: 170, y: 190 }),
586
+ presets_1.characters.simpleDog({ id: "dog", x: 330, y: 220 }),
587
+ presets_1.characters.simpleSpider({ id: "spider", x: 510, y: 200 }),
588
+ presets_1.characters.cursorHand({ id: "cursor", x: 680, y: 210 }),
589
+ presets_1.characters.simpleMascot({ id: "mascot", x: 790, y: 190 }),
590
+ presets_1.scenes.titleCard({ id: "scene.title", x: 20, y: 380, width: 220, height: 110, title: "Title", subtitle: "Preset" }),
591
+ presets_1.scenes.lowerThird({ id: "scene.lower", x: 260, y: 400, width: 210, height: 72, title: "Lower", subtitle: "Third" }),
592
+ presets_1.scenes.captionBubble({ id: "scene.caption", x: 500, y: 405, width: 180, height: 58, text: "Caption" }),
593
+ presets_1.scenes.comparisonSplit({ id: "scene.compare", x: 705, y: 380, width: 220, height: 110 }),
594
+ presets_1.scenes.deviceFrame({ id: "scene.device", x: 20, y: 510, width: 120, height: 160, label: "App" }),
595
+ presets_1.scenes.gridBackground({ id: "scene.grid", x: 170, y: 520, width: 180, height: 120, step: 30 })
596
+ ]);
597
+ const result = (0, src_1.validateVisualDocument)(doc);
598
+ assert(result.ok, `preset element output should validate: ${result.issues.map((item) => item.message).join("; ")}`);
599
+ assert(JSON.stringify(doc).includes("hero.head"), "character presets should use dot-separated namespaced ids");
600
+ });
601
+ test("built-in motion presets compile to explicit kernel timelines", () => {
602
+ const cases = [
603
+ presets_1.motions.fadeIn({ id: "card", start: 0, duration: 0.3 }),
604
+ presets_1.motions.fadeOut({ id: "card", start: 0.4, duration: 0.3 }),
605
+ presets_1.motions.slideIn({ id: "card", from: [-80, 20], to: [20, 20] }),
606
+ presets_1.motions.riseIn({ id: "card", to: [20, 20] }),
607
+ presets_1.motions.scaleIn({ id: "card" }),
608
+ presets_1.motions.pulse({ id: "card" }),
609
+ presets_1.motions.bob({ id: "panel", to: [180, 20] }),
610
+ presets_1.motions.shake({ id: "card" }),
611
+ presets_1.motions.drawOn({ id: "line" }),
612
+ presets_1.motions.stagger({ ids: ["card", "panel"], each: 0.05 })
613
+ ];
614
+ for (const fragment of cases) {
615
+ assertNoLegacyEase(fragment);
616
+ assert((0, src_1.validateVisualDocument)((0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment)).ok, "motion preset should validate on a target document");
617
+ }
618
+ });
619
+ test("built-in effect presets compile to kernel effects, paint, clip, and mask tracks", () => {
620
+ const cases = [
621
+ presets_1.effects.dropShadow({ id: "card" }),
622
+ presets_1.effects.softBlur({ id: "card", amount: 4 }),
623
+ presets_1.effects.glow({ id: "card", color: "#38bdf8" }),
624
+ presets_1.effects.dim({ id: "card", opacity: 0.45 }),
625
+ presets_1.effects.tintFill({ id: "card", color: "#fee2e2" }),
626
+ presets_1.effects.gradientSweep({ id: "card" }),
627
+ presets_1.effects.roundedImageClip({ id: "photo", x: 320, y: 20, width: 120, height: 90, radius: 18 }),
628
+ presets_1.effects.maskReveal({ id: "card", x: 20, y: 20, width: 120, height: 70 })
629
+ ];
630
+ for (const fragment of cases) {
631
+ assertNoLegacyEase(fragment);
632
+ const doc = (0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment);
612
633
  const result = (0, src_1.validateVisualDocument)(doc);
613
- const relative = normalizePath(path.relative(examplesRoot, file));
614
- assert(result.ok, `${relative} should validate: ${result.issues.map((issue) => issue.message).join("; ")}`);
615
- const frame = raw.sequences ? (0, src_1.documentForSequenceTime)(doc, Object.keys(raw.sequences)[0], 0).document : raw.scenes ? (0, src_1.documentForScene)(doc, Object.keys(raw.scenes)[0]) : doc;
616
- const output = frame.canvas.renderer === "three" ? (0, src_1.renderThreePreviewSvg)(frame) : (0, src_1.renderToSvg)(frame);
617
- assert(output.includes("<svg"), `${relative} should render an SVG preview`);
634
+ assert(result.ok, `effect preset should validate: ${result.issues.map((item) => item.message).join("; ")}`);
635
+ assert(!JSON.stringify(doc).includes("cornerRadius"), "rounded image clip preset must not write cornerRadius");
636
+ }
637
+ });
638
+ test("built-in transition presets compile to coordinated kernel timelines", () => {
639
+ const cases = [
640
+ presets_1.transitions.crossfade({ fromId: "outgoing", toId: "incoming" }),
641
+ presets_1.transitions.pushLeft({ fromId: "outgoing", toId: "incoming" }),
642
+ presets_1.transitions.pushRight({ fromId: "outgoing", toId: "incoming" }),
643
+ presets_1.transitions.slideUp({ fromId: "outgoing", toId: "incoming" }),
644
+ presets_1.transitions.wipeLeft({ fromId: "outgoing", toId: "incoming", x: 0, y: 0, width: 160, height: 90 }),
645
+ presets_1.transitions.wipeRight({ fromId: "outgoing", toId: "incoming", x: 0, y: 0, width: 160, height: 90 }),
646
+ presets_1.transitions.zoomCut({ fromId: "outgoing", toId: "incoming" }),
647
+ presets_1.transitions.fadeThroughBlack({ fromId: "outgoing", toId: "incoming", width: 640, height: 360 }),
648
+ presets_1.transitions.irisIn({ id: "incoming", x: 0, y: 0, width: 160, height: 90 }),
649
+ presets_1.transitions.irisOut({ id: "outgoing", x: 0, y: 0, width: 160, height: 90 })
650
+ ];
651
+ for (const fragment of cases) {
652
+ assertNoLegacyEase(fragment);
653
+ const result = (0, src_1.validateVisualDocument)((0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment));
654
+ assert(result.ok, `transition preset should validate: ${result.issues.map((item) => item.message).join("; ")}`);
655
+ }
656
+ });
657
+ test("preset fragment composition validates targets and can prefix ids to avoid collisions", () => {
658
+ const duplicateA = (0, presets_1.prefixPresetFragment)(presets_1.shapes.rect({ id: "box", x: 0, y: 0, width: 40, height: 40 }), "a");
659
+ const duplicateB = (0, presets_1.prefixPresetFragment)(presets_1.shapes.rect({ id: "box", x: 50, y: 0, width: 40, height: 40 }), "b");
660
+ const doc = (0, presets_1.applyPresetFragments)(emptyPresetDocument(), [duplicateA, duplicateB]);
661
+ assert((0, src_1.validateVisualDocument)(doc).ok, "prefixed duplicate fragments should validate");
662
+ assert(JSON.stringify(doc).includes("a.box") && JSON.stringify(doc).includes("b.box"), "prefixing should rewrite element ids");
663
+ let threw = false;
664
+ try {
665
+ (0, presets_1.applyPresetFragments)(emptyPresetDocument(), presets_1.motions.fadeIn({ id: "missing" }));
666
+ }
667
+ catch {
668
+ threw = true;
618
669
  }
670
+ assert(threw, "applying a timeline fragment to an unknown target should throw");
619
671
  });
620
- console.log("All primitive visual language tests passed.");
672
+ test("preset example visual documents validate as pure kernel output", () => {
673
+ for (const file of ["presets-demo.visual.json", "preset-character-motion.visual.json"]) {
674
+ const document = JSON.parse(fs.readFileSync(path.resolve(__dirname, "..", "..", "examples", file), "utf8"));
675
+ const result = (0, src_1.validateVisualDocument)(document);
676
+ assert(result.ok, `${file} should validate: ${result.issues.map((item) => item.message).join("; ")}`);
677
+ }
678
+ });
679
+ console.log("All render-kernel tests passed.");
621
680
  function assert(condition, message) {
622
681
  if (!condition)
623
682
  throw new Error(message);
624
683
  }
625
- function collectVisualFiles(directory) {
626
- const out = [];
627
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
628
- const fullPath = path.join(directory, entry.name);
629
- if (entry.isDirectory())
630
- out.push(...collectVisualFiles(fullPath));
631
- else if (entry.name.endsWith(".visual.json"))
632
- out.push(fullPath);
633
- }
634
- return out;
635
- }
636
- function normalizePath(value) {
637
- return value.replace(/\\/g, "/");
638
- }
639
684
  function near(left, right) {
640
685
  return Math.abs(left - right) < 0.0001;
641
686
  }
687
+ function emptyPresetDocument() {
688
+ return { version: 1, canvas: { width: 960, height: 720, background: "#f8fafc", duration: 2, fps: 30 }, elements: [] };
689
+ }
690
+ function presetTargetDocument() {
691
+ const imageSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='90'%3E%3Crect width='120' height='90' fill='%23bfdbfe'/%3E%3C/svg%3E";
692
+ return {
693
+ version: 1,
694
+ canvas: { width: 640, height: 360, background: "#f8fafc", duration: 2, fps: 30 },
695
+ elements: [
696
+ { id: "card", type: "path", d: "M 20 20 H 140 V 90 H 20 Z", fill: "#ffffff", stroke: "#111827", strokeWidth: 2 },
697
+ { id: "line", type: "path", d: "M 20 130 C 80 80 150 170 230 120", fill: "none", stroke: "#2563eb", strokeWidth: 5 },
698
+ { id: "panel", type: "group", x: 180, y: 20, width: 100, height: 70, children: [{ id: "panel.bg", type: "path", d: "M 0 0 H 100 V 70 H 0 Z", fill: "#dcfce7" }] },
699
+ { id: "photo", type: "image", src: imageSrc, x: 320, y: 20, width: 120, height: 90 },
700
+ { id: "outgoing", type: "group", x: 40, y: 200, width: 120, height: 80, children: [{ id: "outgoing.bg", type: "path", d: "M 0 0 H 120 V 80 H 0 Z", fill: "#fee2e2" }] },
701
+ { id: "incoming", type: "group", x: 220, y: 200, width: 120, height: 80, opacity: 0, children: [{ id: "incoming.bg", type: "path", d: "M 0 0 H 120 V 80 H 0 Z", fill: "#dbeafe" }] }
702
+ ]
703
+ };
704
+ }
705
+ function assertNoLegacyEase(fragment) {
706
+ assert(!JSON.stringify(fragment).includes("\"ease\""), "preset fragments should emit explicit curves instead of legacy ease strings");
707
+ }
642
708
  function stableStringify(value) {
643
709
  return JSON.stringify(sortJson(value));
644
710
  }