@viewscript/renderer 0.1.0-202605140639

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 (89) hide show
  1. package/dist/ast/types.d.ts +403 -0
  2. package/dist/ast/types.js +33 -0
  3. package/dist/compiler/chunk-splitter.d.ts +98 -0
  4. package/dist/compiler/chunk-splitter.js +361 -0
  5. package/dist/index.d.ts +55 -0
  6. package/dist/index.js +17 -0
  7. package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
  8. package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
  9. package/dist/rasterizer/canvas-mapper.d.ts +280 -0
  10. package/dist/rasterizer/canvas-mapper.js +414 -0
  11. package/dist/rasterizer/error-distribution.d.ts +143 -0
  12. package/dist/rasterizer/error-distribution.js +231 -0
  13. package/dist/rasterizer/gradient-mapper.d.ts +223 -0
  14. package/dist/rasterizer/gradient-mapper.js +352 -0
  15. package/dist/rasterizer/topology-rounding.d.ts +151 -0
  16. package/dist/rasterizer/topology-rounding.js +347 -0
  17. package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
  18. package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
  19. package/dist/runtime/event-backpressure.d.ts +393 -0
  20. package/dist/runtime/event-backpressure.js +458 -0
  21. package/dist/runtime/render-loop.d.ts +277 -0
  22. package/dist/runtime/render-loop.js +435 -0
  23. package/dist/runtime/wasm-resource-manager.d.ts +122 -0
  24. package/dist/runtime/wasm-resource-manager.js +253 -0
  25. package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
  26. package/dist/runtime/wgpu-renderer-adapter.js +230 -0
  27. package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
  28. package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
  29. package/dist/semantic/semantic-translator.d.ts +229 -0
  30. package/dist/semantic/semantic-translator.js +398 -0
  31. package/package.json +28 -0
  32. package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
  33. package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
  34. package/playwright-report/index.html +90 -0
  35. package/playwright.config.ts +160 -0
  36. package/screenshot-chrome.png +0 -0
  37. package/screenshots/visual-demo-verification.png +0 -0
  38. package/screenshots/visual-demo.png +0 -0
  39. package/src/ast/types.ts +473 -0
  40. package/src/compiler/chunk-splitter.ts +534 -0
  41. package/src/index.ts +62 -0
  42. package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
  43. package/src/rasterizer/canvas-mapper.ts +677 -0
  44. package/src/rasterizer/error-distribution.ts +344 -0
  45. package/src/rasterizer/gradient-mapper.ts +563 -0
  46. package/src/rasterizer/topology-rounding.ts +499 -0
  47. package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
  48. package/src/runtime/event-backpressure.ts +622 -0
  49. package/src/runtime/render-loop.ts +660 -0
  50. package/src/runtime/wasm-resource-manager.ts +349 -0
  51. package/src/runtime/wgpu-renderer-adapter.ts +318 -0
  52. package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
  53. package/src/semantic/semantic-translator.ts +637 -0
  54. package/test-results/.last-run.json +4 -0
  55. package/tests/e2e/async-race.spec.ts +612 -0
  56. package/tests/e2e/bilayer-sync.spec.ts +405 -0
  57. package/tests/e2e/failures/.gitkeep +0 -0
  58. package/tests/e2e/fullstack.spec.ts +681 -0
  59. package/tests/e2e/g1-continuity.spec.ts +703 -0
  60. package/tests/e2e/golden/.gitkeep +0 -0
  61. package/tests/e2e/golden/conic-color-wheel.raw +0 -0
  62. package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
  63. package/tests/e2e/golden/conic-rotated.raw +0 -0
  64. package/tests/e2e/golden/conic-rotated.sha256 +1 -0
  65. package/tests/e2e/golden/linear-45deg.raw +0 -0
  66. package/tests/e2e/golden/linear-45deg.sha256 +1 -0
  67. package/tests/e2e/golden/linear-horizontal.raw +0 -0
  68. package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
  69. package/tests/e2e/golden/linear-multi-stop.raw +0 -0
  70. package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
  71. package/tests/e2e/golden/radial-circle-center.raw +0 -0
  72. package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
  73. package/tests/e2e/golden/radial-offset.raw +0 -0
  74. package/tests/e2e/golden/radial-offset.sha256 +1 -0
  75. package/tests/e2e/golden/tile-mirror.raw +0 -0
  76. package/tests/e2e/golden/tile-mirror.sha256 +1 -0
  77. package/tests/e2e/golden/tile-repeat.raw +0 -0
  78. package/tests/e2e/golden/tile-repeat.sha256 +1 -0
  79. package/tests/e2e/gradient-animation.spec.ts +606 -0
  80. package/tests/e2e/memory-stability.spec.ts +396 -0
  81. package/tests/e2e/path-topology.spec.ts +674 -0
  82. package/tests/e2e/performance-profile.spec.ts +501 -0
  83. package/tests/e2e/screenshot.spec.ts +60 -0
  84. package/tests/e2e/test-harness.html +1005 -0
  85. package/tests/e2e/text-layout.spec.ts +451 -0
  86. package/tests/e2e/visual-demo.html +340 -0
  87. package/tests/e2e/visual-regression.spec.ts +335 -0
  88. package/tsconfig.json +12 -0
  89. package/vitest.config.ts +8 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Bilayer Synchronization E2E Tests
3
+ *
4
+ * This module verifies that the Canvas (visual) and DOM (interaction)
5
+ * layers are perfectly synchronized. The critical invariant:
6
+ *
7
+ * "Clicking a pixel on Canvas MUST trigger the correct DOM event handler"
8
+ *
9
+ * ## The Problem: Layer Desynchronization
10
+ *
11
+ * If a button visually appears at (100, 50) on Canvas but its DOM hit
12
+ * region is at (100, 51), clicking the visual button misses the handler.
13
+ * This is catastrophic UX failure.
14
+ *
15
+ * ## Test Strategy
16
+ *
17
+ * 1. Render a button with known bounds via constraint graph
18
+ * 2. Click at visual boundary edges (1px inside each edge)
19
+ * 3. Verify event fires and visual state changes
20
+ * 4. Repeat for edge cases (animation mid-frame, rapid movement)
21
+ */
22
+
23
+ import { test, expect, type Page, type Browser } from '@playwright/test';
24
+
25
+ // =============================================================================
26
+ // Test Configuration
27
+ // =============================================================================
28
+
29
+ interface ButtonBounds {
30
+ x: number;
31
+ y: number;
32
+ width: number;
33
+ height: number;
34
+ }
35
+
36
+ const TEST_BUTTON_BOUNDS: ButtonBounds = {
37
+ x: 100,
38
+ y: 100,
39
+ width: 200,
40
+ height: 50,
41
+ };
42
+
43
+ // Colors for state verification
44
+ const BUTTON_IDLE_COLOR = { r: 0, g: 100, b: 200 }; // Blue
45
+ const BUTTON_PRESSED_COLOR = { r: 200, g: 100, b: 0 }; // Orange
46
+
47
+ // =============================================================================
48
+ // Bilayer Synchronization Tests
49
+ // =============================================================================
50
+
51
+ test.describe('Bilayer Synchronization: Canvas-DOM Coherence', () => {
52
+
53
+ /**
54
+ * Test: Click at visual center triggers event
55
+ *
56
+ * Basic sanity check that clicking the center of a button works.
57
+ */
58
+ test('click at button center triggers event and changes visual state', async ({ page }) => {
59
+ await setupTestPage(page);
60
+
61
+ // Render a button at known position
62
+ await renderButton(page, TEST_BUTTON_BOUNDS);
63
+
64
+ // Calculate center coordinates
65
+ const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
66
+ const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
67
+
68
+ // Verify initial color (idle)
69
+ const initialColor = await samplePixel(page, centerX, centerY);
70
+ expectColorMatch(initialColor, BUTTON_IDLE_COLOR);
71
+
72
+ // Click the center
73
+ await page.mouse.click(centerX, centerY);
74
+
75
+ // Wait for next frame (T-vector update + render)
76
+ await waitForNextFrame(page);
77
+
78
+ // Verify color changed (pressed)
79
+ const finalColor = await samplePixel(page, centerX, centerY);
80
+ expectColorMatch(finalColor, BUTTON_PRESSED_COLOR);
81
+ });
82
+
83
+ /**
84
+ * Test: Click at visual boundary (1px inside left edge)
85
+ *
86
+ * Critical edge case: Does the DOM hit region extend exactly
87
+ * to the visual boundary?
88
+ */
89
+ test('click 1px inside left edge triggers event', async ({ page }) => {
90
+ await setupTestPage(page);
91
+ await renderButton(page, TEST_BUTTON_BOUNDS);
92
+
93
+ // 1px inside left edge
94
+ const edgeX = TEST_BUTTON_BOUNDS.x + 1;
95
+ const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
96
+
97
+ // Click at edge
98
+ await page.mouse.click(edgeX, centerY);
99
+ await waitForNextFrame(page);
100
+
101
+ // Verify event fired (color changed)
102
+ const color = await samplePixel(page, edgeX, centerY);
103
+ expectColorMatch(color, BUTTON_PRESSED_COLOR);
104
+ });
105
+
106
+ /**
107
+ * Test: Click at visual boundary (1px inside right edge)
108
+ */
109
+ test('click 1px inside right edge triggers event', async ({ page }) => {
110
+ await setupTestPage(page);
111
+ await renderButton(page, TEST_BUTTON_BOUNDS);
112
+
113
+ const edgeX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1;
114
+ const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
115
+
116
+ await page.mouse.click(edgeX, centerY);
117
+ await waitForNextFrame(page);
118
+
119
+ const color = await samplePixel(page, edgeX, centerY);
120
+ expectColorMatch(color, BUTTON_PRESSED_COLOR);
121
+ });
122
+
123
+ /**
124
+ * Test: Click at visual boundary (1px inside top edge)
125
+ */
126
+ test('click 1px inside top edge triggers event', async ({ page }) => {
127
+ await setupTestPage(page);
128
+ await renderButton(page, TEST_BUTTON_BOUNDS);
129
+
130
+ const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
131
+ const edgeY = TEST_BUTTON_BOUNDS.y + 1;
132
+
133
+ await page.mouse.click(centerX, edgeY);
134
+ await waitForNextFrame(page);
135
+
136
+ const color = await samplePixel(page, centerX, edgeY);
137
+ expectColorMatch(color, BUTTON_PRESSED_COLOR);
138
+ });
139
+
140
+ /**
141
+ * Test: Click at visual boundary (1px inside bottom edge)
142
+ */
143
+ test('click 1px inside bottom edge triggers event', async ({ page }) => {
144
+ await setupTestPage(page);
145
+ await renderButton(page, TEST_BUTTON_BOUNDS);
146
+
147
+ const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
148
+ const edgeY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1;
149
+
150
+ await page.mouse.click(centerX, edgeY);
151
+ await waitForNextFrame(page);
152
+
153
+ const color = await samplePixel(page, centerX, edgeY);
154
+ expectColorMatch(color, BUTTON_PRESSED_COLOR);
155
+ });
156
+
157
+ /**
158
+ * Test: Click 1px OUTSIDE visual boundary does NOT trigger event
159
+ *
160
+ * Negative test: Verify that the DOM region doesn't extend
161
+ * beyond the visual bounds.
162
+ */
163
+ test('click 1px outside left edge does NOT trigger event', async ({ page }) => {
164
+ await setupTestPage(page);
165
+ await renderButton(page, TEST_BUTTON_BOUNDS);
166
+
167
+ const outsideX = TEST_BUTTON_BOUNDS.x - 1;
168
+ const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
169
+
170
+ // Click outside
171
+ await page.mouse.click(outsideX, centerY);
172
+ await waitForNextFrame(page);
173
+
174
+ // Sample inside the button to verify it didn't change
175
+ const insideX = TEST_BUTTON_BOUNDS.x + 10;
176
+ const color = await samplePixel(page, insideX, centerY);
177
+ expectColorMatch(color, BUTTON_IDLE_COLOR); // Still idle!
178
+ });
179
+
180
+ /**
181
+ * Test: Rapid clicks during animation
182
+ *
183
+ * Stress test: Click while button is animating (T-vector changing).
184
+ * Verifies that DOM position updates atomically with Canvas.
185
+ *
186
+ * DETERMINISM: Query actual button position from renderer state
187
+ * rather than assuming time-based position.
188
+ */
189
+ test('click during animation hits moving target', async ({ page }) => {
190
+ await setupTestPage(page);
191
+
192
+ // Start animation: button moves from x=100 to x=300 over 500ms
193
+ await startButtonAnimation(page, {
194
+ from: { ...TEST_BUTTON_BOUNDS },
195
+ to: { ...TEST_BUTTON_BOUNDS, x: 300 },
196
+ durationMs: 500,
197
+ });
198
+
199
+ // Wait for animation to reach midpoint and query actual position
200
+ await page.waitForTimeout(250);
201
+
202
+ // Query the ACTUAL current position from renderer state (deterministic)
203
+ const currentBounds = await page.evaluate(() => {
204
+ const renderer = (window as any).__VS_RENDERER__;
205
+ return renderer.getEntityBounds(1);
206
+ });
207
+
208
+ // Click at actual current center (not assumed position)
209
+ const actualCenterX = currentBounds.x + TEST_BUTTON_BOUNDS.width / 2;
210
+ const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
211
+
212
+ await page.mouse.click(actualCenterX, centerY);
213
+ await waitForNextFrame(page);
214
+
215
+ // Verify the click registered (check global event counter)
216
+ const clickCount = await page.evaluate(() => (window as any).__VS_CLICK_COUNT__);
217
+ expect(clickCount).toBeGreaterThan(0);
218
+ });
219
+
220
+ /**
221
+ * Test: All four corners (subpixel precision boundary)
222
+ *
223
+ * Tests the exact corner pixels to verify topology-preserving
224
+ * rounding produces correct hit regions.
225
+ */
226
+ test('all four corner pixels are clickable', async ({ page }) => {
227
+ await setupTestPage(page);
228
+ await renderButton(page, TEST_BUTTON_BOUNDS);
229
+
230
+ const corners = [
231
+ { x: TEST_BUTTON_BOUNDS.x + 1, y: TEST_BUTTON_BOUNDS.y + 1 }, // Top-left
232
+ { x: TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1, y: TEST_BUTTON_BOUNDS.y + 1 }, // Top-right
233
+ { x: TEST_BUTTON_BOUNDS.x + 1, y: TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1 }, // Bottom-left
234
+ { x: TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1, y: TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1 }, // Bottom-right
235
+ ];
236
+
237
+ for (const corner of corners) {
238
+ // Reset button state
239
+ await resetButtonState(page);
240
+
241
+ // Click corner
242
+ await page.mouse.click(corner.x, corner.y);
243
+ await waitForNextFrame(page);
244
+
245
+ // Verify click registered
246
+ const clickCount = await page.evaluate(() => (window as any).__VS_CLICK_COUNT__);
247
+ expect(clickCount).toBeGreaterThan(0);
248
+ }
249
+ });
250
+ });
251
+
252
+ // =============================================================================
253
+ // Helper Functions
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Setup the test page with VS renderer.
258
+ */
259
+ async function setupTestPage(page: Page): Promise<void> {
260
+ await page.goto('/test-harness.html');
261
+ await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
262
+ timeout: 10000,
263
+ });
264
+
265
+ // Initialize click counter
266
+ await page.evaluate(() => {
267
+ (window as any).__VS_CLICK_COUNT__ = 0;
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Render a clickable button at specified bounds.
273
+ */
274
+ async function renderButton(page: Page, bounds: ButtonBounds): Promise<void> {
275
+ await page.evaluate((b) => {
276
+ const renderer = (window as any).__VS_RENDERER__;
277
+
278
+ // Create button entity with click handler
279
+ renderer.render({
280
+ entities: [
281
+ {
282
+ id: 1,
283
+ type: 'rect',
284
+ bounds: b,
285
+ fill: '#0064C8', // BUTTON_IDLE_COLOR
286
+ interactive: true,
287
+ onClick: () => {
288
+ (window as any).__VS_CLICK_COUNT__++;
289
+ // Change color to indicate pressed
290
+ renderer.updateEntity(1, { fill: '#C86400' }); // BUTTON_PRESSED_COLOR
291
+ },
292
+ },
293
+ ],
294
+ constraints: [],
295
+ });
296
+ }, bounds);
297
+
298
+ // Wait for render
299
+ await waitForNextFrame(page);
300
+ }
301
+
302
+ /**
303
+ * Start an animation on the button.
304
+ */
305
+ async function startButtonAnimation(
306
+ page: Page,
307
+ config: { from: ButtonBounds; to: ButtonBounds; durationMs: number },
308
+ ): Promise<void> {
309
+ await page.evaluate((cfg) => {
310
+ const renderer = (window as any).__VS_RENDERER__;
311
+
312
+ // First render the button at starting position
313
+ renderer.render({
314
+ entities: [
315
+ {
316
+ id: 1,
317
+ type: 'rect',
318
+ bounds: cfg.from,
319
+ fill: '#0064C8',
320
+ interactive: true,
321
+ onClick: () => {
322
+ (window as any).__VS_CLICK_COUNT__++;
323
+ },
324
+ },
325
+ ],
326
+ constraints: [],
327
+ });
328
+
329
+ // Start animation via T-vector binding
330
+ renderer.animate(1, 'x', cfg.from.x, cfg.to.x, cfg.durationMs);
331
+ }, config);
332
+ }
333
+
334
+ /**
335
+ * Reset button to idle state.
336
+ */
337
+ async function resetButtonState(page: Page): Promise<void> {
338
+ await page.evaluate(() => {
339
+ (window as any).__VS_CLICK_COUNT__ = 0;
340
+ const renderer = (window as any).__VS_RENDERER__;
341
+ renderer.updateEntity(1, { fill: '#0064C8' });
342
+ });
343
+ await waitForNextFrame(page);
344
+ }
345
+
346
+ /**
347
+ * Sample a pixel color at (x, y).
348
+ *
349
+ * CRITICAL: Accounts for devicePixelRatio when sampling canvas pixels.
350
+ * Viewport coordinates must be scaled to canvas backing store coordinates.
351
+ */
352
+ async function samplePixel(
353
+ page: Page,
354
+ x: number,
355
+ y: number,
356
+ ): Promise<{ r: number; g: number; b: number }> {
357
+ return await page.evaluate(({ px, py }) => {
358
+ const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
359
+ const ctx = canvas.getContext('2d');
360
+ if (!ctx) throw new Error('No 2D context');
361
+
362
+ // Account for canvas position in viewport
363
+ const rect = canvas.getBoundingClientRect();
364
+ const canvasX = px - rect.left;
365
+ const canvasY = py - rect.top;
366
+
367
+ // Scale by devicePixelRatio for backing store coordinates
368
+ const dpr = window.devicePixelRatio || 1;
369
+ const backingX = Math.floor(canvasX * dpr);
370
+ const backingY = Math.floor(canvasY * dpr);
371
+
372
+ const imageData = ctx.getImageData(backingX, backingY, 1, 1);
373
+ return {
374
+ r: imageData.data[0],
375
+ g: imageData.data[1],
376
+ b: imageData.data[2],
377
+ };
378
+ }, { px: x, py: y });
379
+ }
380
+
381
+ /**
382
+ * Wait for next animation frame to ensure render completed.
383
+ */
384
+ async function waitForNextFrame(page: Page): Promise<void> {
385
+ await page.evaluate(() => {
386
+ return new Promise<void>((resolve) => {
387
+ requestAnimationFrame(() => {
388
+ requestAnimationFrame(() => resolve());
389
+ });
390
+ });
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Assert color match with tolerance for antialiasing.
396
+ */
397
+ function expectColorMatch(
398
+ actual: { r: number; g: number; b: number },
399
+ expected: { r: number; g: number; b: number },
400
+ tolerance: number = 5,
401
+ ): void {
402
+ expect(Math.abs(actual.r - expected.r)).toBeLessThanOrEqual(tolerance);
403
+ expect(Math.abs(actual.g - expected.g)).toBeLessThanOrEqual(tolerance);
404
+ expect(Math.abs(actual.b - expected.b)).toBeLessThanOrEqual(tolerance);
405
+ }
File without changes