@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,1005 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ViewScript Renderer Test Harness</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ body {
14
+ width: 800px;
15
+ height: 600px;
16
+ overflow: hidden;
17
+ background: #ffffff;
18
+ }
19
+ #vs-canvas {
20
+ position: absolute;
21
+ top: 0;
22
+ left: 0;
23
+ width: 800px;
24
+ height: 600px;
25
+ }
26
+ #vs-dom-layer {
27
+ position: absolute;
28
+ top: 0;
29
+ left: 0;
30
+ width: 800px;
31
+ height: 600px;
32
+ pointer-events: auto;
33
+ }
34
+ .vs-dom-element {
35
+ position: absolute;
36
+ pointer-events: auto;
37
+ background: transparent;
38
+ /* will-change for GPU layer promotion */
39
+ will-change: transform;
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <!-- Canvas Layer (Visual) -->
45
+ <canvas id="vs-canvas" width="800" height="600"></canvas>
46
+
47
+ <!-- DOM Layer (Interaction) -->
48
+ <div id="vs-dom-layer"></div>
49
+
50
+ <script type="module">
51
+ /**
52
+ * Test Harness: Minimal VS Renderer Implementation
53
+ *
54
+ * This is a simplified renderer for E2E testing purposes.
55
+ * It implements the same interfaces as the production renderer.
56
+ */
57
+
58
+ // ==========================================================================
59
+ // Configuration
60
+ // ==========================================================================
61
+
62
+ const config = window.__VS_CANVASKIT_CONFIG__ || {
63
+ disableWebGL: false,
64
+ useSubpixelText: true,
65
+ devicePixelRatio: window.devicePixelRatio,
66
+ };
67
+
68
+ // ==========================================================================
69
+ // Renderer State
70
+ // ==========================================================================
71
+
72
+ const state = {
73
+ entities: new Map(),
74
+ canvas: document.getElementById('vs-canvas'),
75
+ ctx: null,
76
+ domLayer: document.getElementById('vs-dom-layer'),
77
+ domElements: new Map(),
78
+ tVector: 0,
79
+ eventBuffer: null, // Will be initialized as EventBuffer instance
80
+ processedEventCount: 0,
81
+ clickHandlers: new Map(),
82
+ tVectorState: {}, // Per-entity T-vector state
83
+ constraints: [], // Active constraints
84
+ };
85
+
86
+ // ==========================================================================
87
+ // Event Buffer (Mirrors event-backpressure.ts)
88
+ // ==========================================================================
89
+
90
+ class EventBuffer {
91
+ constructor(config = {}) {
92
+ this.config = {
93
+ maxEventsPerFrame: 50,
94
+ lowPriorityThrottleMs: 16,
95
+ enableCoalescing: true,
96
+ ...config,
97
+ };
98
+ this.writeBuffer = new Map();
99
+ this.priorityQueue = [];
100
+ this.lastEventTime = new Map();
101
+ this.pendingAsyncEvents = [];
102
+ }
103
+
104
+ push(event) {
105
+ const key = `${event.entityId}:${event.targetState}`;
106
+
107
+ // Critical events bypass coalescing
108
+ if (event.priority === 3) { // CRITICAL
109
+ this.priorityQueue.push(event);
110
+ return;
111
+ }
112
+
113
+ // Throttle check for low-priority events
114
+ if (event.priority === 0) { // LOW
115
+ const lastTime = this.lastEventTime.get(key) ?? 0;
116
+ if (event.timestamp - lastTime < this.config.lowPriorityThrottleMs) {
117
+ return;
118
+ }
119
+ }
120
+
121
+ // Coalesce: overwrite previous event for same entity+state
122
+ if (this.config.enableCoalescing) {
123
+ this.writeBuffer.set(key, event);
124
+ } else {
125
+ this.priorityQueue.push(event);
126
+ }
127
+
128
+ this.lastEventTime.set(key, event.timestamp);
129
+ }
130
+
131
+ pushAsync(event) {
132
+ this.pendingAsyncEvents.push(event);
133
+ }
134
+
135
+ mergeAsyncEvents() {
136
+ if (this.pendingAsyncEvents.length === 0) return;
137
+
138
+ for (const event of this.pendingAsyncEvents) {
139
+ this.push(event);
140
+ }
141
+ this.pendingAsyncEvents = [];
142
+ }
143
+
144
+ flush() {
145
+ const result = [];
146
+ const limit = this.config.maxEventsPerFrame;
147
+
148
+ // Critical events first
149
+ for (const event of this.priorityQueue) {
150
+ result.push({
151
+ entityId: event.entityId,
152
+ state: event.targetState,
153
+ value: event.value,
154
+ timestamp: event.timestamp,
155
+ });
156
+ }
157
+ this.priorityQueue = [];
158
+
159
+ // Coalesced events
160
+ const remaining = limit - result.length;
161
+ if (remaining > 0) {
162
+ const coalesced = Array.from(this.writeBuffer.values())
163
+ .sort((a, b) => b.priority - a.priority)
164
+ .slice(0, remaining);
165
+
166
+ for (const event of coalesced) {
167
+ result.push({
168
+ entityId: event.entityId,
169
+ state: event.targetState,
170
+ value: event.value,
171
+ timestamp: event.timestamp,
172
+ });
173
+ }
174
+ }
175
+
176
+ this.writeBuffer.clear();
177
+ return result;
178
+ }
179
+
180
+ getStats() {
181
+ return {
182
+ priorityQueueSize: this.priorityQueue.length,
183
+ coalescedSize: this.writeBuffer.size,
184
+ asyncPendingSize: this.pendingAsyncEvents.length,
185
+ };
186
+ }
187
+ }
188
+
189
+ // ==========================================================================
190
+ // Initialization
191
+ // ==========================================================================
192
+
193
+ function init() {
194
+ // Get 2D context
195
+ state.ctx = state.canvas.getContext('2d', {
196
+ alpha: false,
197
+ desynchronized: true, // Reduces latency
198
+ });
199
+
200
+ // Initialize event buffer
201
+ state.eventBuffer = new EventBuffer({ maxEventsPerFrame: 100 });
202
+
203
+ // Setup event listeners
204
+ setupEventListeners();
205
+
206
+ // Start render loop with async event merging
207
+ startRenderLoop();
208
+
209
+ // Signal ready
210
+ window.__VS_RENDERER_READY__ = true;
211
+ console.log('[VS] Renderer initialized');
212
+ }
213
+
214
+ function startRenderLoop() {
215
+ function renderLoop(timestamp) {
216
+ // Phase 0: Merge async events (CRITICAL for atomicity)
217
+ state.eventBuffer.mergeAsyncEvents();
218
+
219
+ // Phase 1: Flush events and apply to T-vector state
220
+ const events = state.eventBuffer.flush();
221
+ for (const event of events) {
222
+ if (!state.tVectorState[event.entityId]) {
223
+ state.tVectorState[event.entityId] = {
224
+ hover: 0,
225
+ pressed: 0,
226
+ focused: 0,
227
+ scroll_x: 0,
228
+ scroll_y: 0,
229
+ drag_progress: 0,
230
+ animation_t: 0,
231
+ gesture_phase: 0,
232
+ };
233
+ }
234
+ state.tVectorState[event.entityId][event.state] = event.value;
235
+ state.processedEventCount++;
236
+ }
237
+
238
+ // Phase 2: Evaluate constraints (P-dimension derived from T-vector)
239
+ evaluateConstraints();
240
+
241
+ // Phase 3: Render
242
+ renderAll();
243
+
244
+ requestAnimationFrame(renderLoop);
245
+ }
246
+ requestAnimationFrame(renderLoop);
247
+ }
248
+
249
+ function evaluateConstraints() {
250
+ for (const constraint of state.constraints) {
251
+ const entity = state.entities.get(constraint.target);
252
+ if (!entity) continue;
253
+
254
+ const tState = state.tVectorState[constraint.target] || {};
255
+
256
+ // Skip constraint evaluation for user-dragged entities (T-vector preservation)
257
+ if (tState.drag_progress > 0) continue;
258
+
259
+ if (constraint.term.type === 'const') {
260
+ entity.bounds[constraint.component] = constraint.term.value;
261
+ } else if (constraint.term.type === 'linear') {
262
+ // X = coefficient * tState[tState] + offset
263
+ const tValue = tState[constraint.term.tState] || 0;
264
+ entity.bounds[constraint.component] =
265
+ constraint.term.coefficient * tValue + constraint.term.offset;
266
+ }
267
+
268
+ // Update DOM element position
269
+ const domEl = state.domElements.get(constraint.target);
270
+ if (domEl) {
271
+ domEl.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
272
+ domEl.style.left = '0';
273
+ domEl.style.top = '0';
274
+ domEl.dataset.vsEntity = String(constraint.target);
275
+ }
276
+ }
277
+ }
278
+
279
+ function renderAll() {
280
+ state.ctx.fillStyle = '#ffffff';
281
+ state.ctx.fillRect(0, 0, 800, 600);
282
+ for (const e of state.entities.values()) {
283
+ renderEntityCanvas(e);
284
+ }
285
+ }
286
+
287
+ function renderEntityCanvas(entity) {
288
+ const { type, bounds, fill } = entity;
289
+ if (type === 'rect') {
290
+ state.ctx.fillStyle = fill || '#000000';
291
+ state.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
292
+ } else if (type === 'text') {
293
+ state.ctx.fillStyle = fill || '#000000';
294
+ state.ctx.font = `${entity.font?.size || 16}px ${entity.font?.family || 'sans-serif'}`;
295
+ state.ctx.fillText(entity.content, bounds.x, bounds.y + bounds.height);
296
+ }
297
+ }
298
+
299
+ function setupEventListeners() {
300
+ // Capture all pointer events on DOM layer
301
+ state.domLayer.addEventListener('click', handleClick);
302
+ state.domLayer.addEventListener('mousemove', handleMouseMove);
303
+
304
+ // Global event capture for stress testing
305
+ document.addEventListener('mousemove', (e) => {
306
+ state.eventBuffer.push({
307
+ type: 'mousemove',
308
+ x: e.clientX,
309
+ y: e.clientY,
310
+ timestamp: performance.now(),
311
+ });
312
+ });
313
+ }
314
+
315
+ // ==========================================================================
316
+ // Rendering
317
+ // ==========================================================================
318
+
319
+ function render(ir) {
320
+ const { entities, constraints = [], containments } = ir;
321
+
322
+ // Clear
323
+ state.ctx.fillStyle = '#ffffff';
324
+ state.ctx.fillRect(0, 0, 800, 600);
325
+
326
+ // Clear DOM layer
327
+ state.domLayer.innerHTML = '';
328
+ state.domElements.clear();
329
+ state.clickHandlers.clear();
330
+ state.tVectorState = {};
331
+
332
+ // Store constraints
333
+ state.constraints = constraints;
334
+
335
+ // Store and render entities
336
+ for (const entity of entities) {
337
+ state.entities.set(entity.id, entity);
338
+ // Initialize T-vector state for this entity
339
+ state.tVectorState[entity.id] = {
340
+ hover: 0,
341
+ pressed: 0,
342
+ focused: 0,
343
+ scroll_x: 0,
344
+ scroll_y: 0,
345
+ drag_progress: 0,
346
+ animation_t: 0,
347
+ gesture_phase: 0,
348
+ };
349
+ renderEntity(entity);
350
+ }
351
+ }
352
+
353
+ function renderEntity(entity) {
354
+ const { id, type, bounds, fill, interactive, onClick } = entity;
355
+
356
+ // Canvas rendering
357
+ if (type === 'rect') {
358
+ state.ctx.fillStyle = fill || '#000000';
359
+ state.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
360
+ } else if (type === 'text') {
361
+ state.ctx.fillStyle = fill || '#000000';
362
+ state.ctx.font = `${entity.font?.size || 16}px ${entity.font?.family || 'sans-serif'}`;
363
+ state.ctx.fillText(entity.content, bounds.x, bounds.y + bounds.height);
364
+ }
365
+
366
+ // DOM element for interaction
367
+ if (interactive) {
368
+ const el = document.createElement('div');
369
+ el.className = 'vs-dom-element';
370
+ el.dataset.entityId = String(id);
371
+ el.dataset.vsEntity = String(id); // For async-race tests
372
+ el.style.left = `${bounds.x}px`;
373
+ el.style.top = `${bounds.y}px`;
374
+ el.style.width = `${bounds.width}px`;
375
+ el.style.height = `${bounds.height}px`;
376
+ el.style.transform = `translate3d(${bounds.x}px, ${bounds.y}px, 0)`;
377
+ el.style.left = '0';
378
+ el.style.top = '0';
379
+
380
+ state.domLayer.appendChild(el);
381
+ state.domElements.set(id, el);
382
+
383
+ if (onClick) {
384
+ state.clickHandlers.set(id, onClick);
385
+ }
386
+ }
387
+ }
388
+
389
+ function updateEntity(id, updates) {
390
+ const entity = state.entities.get(id);
391
+ if (!entity) return;
392
+
393
+ Object.assign(entity, updates);
394
+
395
+ // Re-render canvas only (DOM elements are managed by render() and evaluateConstraints())
396
+ renderAll();
397
+ }
398
+
399
+ // ==========================================================================
400
+ // Event Handling
401
+ // ==========================================================================
402
+
403
+ function handleClick(e) {
404
+ // Find which entity was clicked
405
+ const target = e.target;
406
+ if (!target.dataset?.entityId) return;
407
+
408
+ const entityId = parseInt(target.dataset.entityId, 10);
409
+ const handler = state.clickHandlers.get(entityId);
410
+
411
+ if (handler) {
412
+ handler();
413
+ window.__VS_CLICK_COUNT__ = (window.__VS_CLICK_COUNT__ || 0) + 1;
414
+ }
415
+ }
416
+
417
+ function handleMouseMove(e) {
418
+ // Backpressure: events are buffered, not immediately processed
419
+ }
420
+
421
+ // ==========================================================================
422
+ // T-Vector Animation
423
+ // ==========================================================================
424
+
425
+ function updateTVector(t) {
426
+ state.tVector = t;
427
+
428
+ // Process buffered events (backpressure)
429
+ const toProcess = state.eventBuffer.flush();
430
+ state.processedEventCount += toProcess.length;
431
+ }
432
+
433
+ function animate(entityId, prop, from, to, durationMs) {
434
+ const startTime = performance.now();
435
+ const entity = state.entities.get(entityId);
436
+ if (!entity) return;
437
+
438
+ const animateFrame = () => {
439
+ const elapsed = performance.now() - startTime;
440
+ const progress = Math.min(elapsed / durationMs, 1);
441
+
442
+ // Linear interpolation
443
+ const value = from + (to - from) * progress;
444
+ entity.bounds[prop] = value;
445
+
446
+ // Update DOM element position
447
+ const el = state.domElements.get(entityId);
448
+ if (el) {
449
+ el.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
450
+ el.style.left = '0';
451
+ el.style.top = '0';
452
+ }
453
+
454
+ // Re-render
455
+ state.ctx.fillStyle = '#ffffff';
456
+ state.ctx.fillRect(0, 0, 800, 600);
457
+ for (const e of state.entities.values()) {
458
+ renderEntity(e);
459
+ }
460
+
461
+ if (progress < 1) {
462
+ requestAnimationFrame(animateFrame);
463
+ }
464
+ };
465
+
466
+ requestAnimationFrame(animateFrame);
467
+ }
468
+
469
+ // ==========================================================================
470
+ // Tick (for performance testing)
471
+ // ==========================================================================
472
+
473
+ function tick(timestamp) {
474
+ // Process events
475
+ updateTVector(timestamp / 1000);
476
+
477
+ // Render canvas only
478
+ renderAll();
479
+ }
480
+
481
+ // ==========================================================================
482
+ // Public API
483
+ // ==========================================================================
484
+
485
+ window.__VS_RENDERER__ = {
486
+ render,
487
+ updateEntity,
488
+ updateTVector,
489
+ animate,
490
+ tick,
491
+ loadFont: () => Promise.resolve(),
492
+ getProcessedEventCount: () => state.processedEventCount,
493
+ // Async race test APIs
494
+ getEventBuffer: () => state.eventBuffer,
495
+ getTVectorState: () => state.tVectorState,
496
+ getEntityBounds: (id) => {
497
+ const entity = state.entities.get(id);
498
+ return entity ? { ...entity.bounds } : null;
499
+ },
500
+ getAllEntities: () => {
501
+ return Array.from(state.entities.values()).map(e => ({ ...e, bounds: { ...e.bounds } }));
502
+ },
503
+ updateEntityBounds: (id, boundsUpdate) => {
504
+ const entity = state.entities.get(id);
505
+ if (!entity) return false;
506
+ Object.assign(entity.bounds, boundsUpdate);
507
+ const domEl = state.domElements.get(id);
508
+ if (domEl) {
509
+ domEl.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
510
+ }
511
+ renderAll();
512
+ return true;
513
+ },
514
+ setConstraints: (constraints) => {
515
+ state.constraints = constraints;
516
+ },
517
+
518
+ renderGradient: (spec) => {
519
+ // Determine draw bounds (default to full canvas)
520
+ const originalWidth = state.canvas.width;
521
+ const originalHeight = state.canvas.height;
522
+ const bounds = spec.bounds || { x: 0, y: 0, width: originalWidth, height: originalHeight };
523
+
524
+ // Resize canvas to bounds dimensions so getImageData(0,0,canvas.width,canvas.height)
525
+ // returns a buffer whose stride equals bounds.width (tests sample with this stride).
526
+ state.canvas.width = bounds.width;
527
+ state.canvas.height = bounds.height;
528
+
529
+ const ctx = state.canvas.getContext('2d');
530
+
531
+ // Clear to white first
532
+ ctx.fillStyle = '#ffffff';
533
+ ctx.fillRect(0, 0, bounds.width, bounds.height);
534
+
535
+ // Since the canvas is now resized to bounds.width x bounds.height,
536
+ // all drawing uses (0, 0) as origin with full canvas dimensions.
537
+ const W = bounds.width;
538
+ const H = bounds.height;
539
+
540
+ // ----------------------------------------------------------------
541
+ // Solid color (P-dimension rational color test)
542
+ // ----------------------------------------------------------------
543
+ if (spec.type === 'solid') {
544
+ let r = 0, g = 0, b = 0;
545
+ if (spec.rationalColor) {
546
+ // Perform exact integer division (BigInt numerator/denominator)
547
+ const rc = spec.rationalColor;
548
+ r = Number(rc.r.numerator / rc.r.denominator);
549
+ g = Number(rc.g.numerator / rc.g.denominator);
550
+ b = Number(rc.b.numerator / rc.b.denominator);
551
+ } else if (spec.color) {
552
+ ctx.fillStyle = spec.color;
553
+ ctx.fillRect(0, 0, W, H);
554
+ return Promise.resolve();
555
+ }
556
+ // Clamp to [0, 255]
557
+ r = Math.max(0, Math.min(255, r));
558
+ g = Math.max(0, Math.min(255, g));
559
+ b = Math.max(0, Math.min(255, b));
560
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
561
+ ctx.fillRect(0, 0, W, H);
562
+ return Promise.resolve();
563
+ }
564
+
565
+ // ----------------------------------------------------------------
566
+ // Helper: parse color stops from CSS gradient string
567
+ // ----------------------------------------------------------------
568
+ const parseAndAddCSSStops = (gradient, cssString) => {
569
+ // Strip gradient type prefix and outer parens
570
+ const inner = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
571
+
572
+ // Split by commas, but only top-level commas
573
+ const parts = [];
574
+ let depth = 0, current = '';
575
+ for (const ch of inner) {
576
+ if (ch === '(') depth++;
577
+ else if (ch === ')') depth--;
578
+ if (ch === ',' && depth === 0) { parts.push(current.trim()); current = ''; }
579
+ else current += ch;
580
+ }
581
+ if (current.trim()) parts.push(current.trim());
582
+
583
+ // Determine which parts are stops (skip direction/angle/at/from)
584
+ const stopParts = parts.filter(p => {
585
+ return !p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
586
+ !p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/);
587
+ });
588
+
589
+ if (stopParts.length === 0) return;
590
+
591
+ // Parse each stop: "red", "red 0%", "#FF0000 50%", etc.
592
+ const parsedStops = stopParts.map((p, i) => {
593
+ const posMatch = p.match(/([\d.]+)%\s*$/);
594
+ const pos = posMatch ? parseFloat(posMatch[1]) / 100 : i / (stopParts.length - 1 || 1);
595
+ const colorStr = posMatch ? p.slice(0, p.lastIndexOf(posMatch[0])).trim() : p;
596
+ return { color: colorStr, position: pos };
597
+ });
598
+
599
+ for (const stop of parsedStops) {
600
+ const clampedPos = Math.max(0, Math.min(1, stop.position));
601
+ try {
602
+ gradient.addColorStop(clampedPos, stop.color);
603
+ } catch (e) {
604
+ gradient.addColorStop(clampedPos, 'transparent');
605
+ }
606
+ }
607
+ };
608
+
609
+ // ----------------------------------------------------------------
610
+ // Helper: build gradient object from CSS string
611
+ // Canvas is now W x H, drawing at (0,0)
612
+ // ----------------------------------------------------------------
613
+ const buildGradientFromCSS = (cssString) => {
614
+ const type = spec.type;
615
+
616
+ if (type === 'linear-gradient') {
617
+ const match45 = cssString.match(/linear-gradient\(\s*([\d.]+)deg/);
618
+ const matchTo = cssString.match(/linear-gradient\(\s*to\s+([\w\s]+?),/);
619
+
620
+ let angleDeg = 180; // default: top-to-bottom
621
+ if (match45) {
622
+ angleDeg = parseFloat(match45[1]);
623
+ } else if (matchTo) {
624
+ const dir = matchTo[1].trim();
625
+ if (dir === 'right') angleDeg = 90;
626
+ else if (dir === 'left') angleDeg = 270;
627
+ else if (dir === 'bottom') angleDeg = 180;
628
+ else if (dir === 'top') angleDeg = 0;
629
+ else if (dir === 'bottom right') angleDeg = 135;
630
+ else if (dir === 'bottom left') angleDeg = 225;
631
+ else if (dir === 'top right') angleDeg = 45;
632
+ else if (dir === 'top left') angleDeg = 315;
633
+ }
634
+
635
+ // CSS angle convention: 0deg = to top, 90deg = to right
636
+ // Canvas createLinearGradient needs absolute pixel coords
637
+ // We use the standard CSS-to-canvas gradient line algorithm
638
+ const rad = (angleDeg - 90) * Math.PI / 180;
639
+ const cx = W / 2;
640
+ const cy = H / 2;
641
+ const half = Math.sqrt(W * W + H * H) / 2;
642
+ const x0 = cx - Math.cos(rad) * half;
643
+ const y0 = cy - Math.sin(rad) * half;
644
+ const x1 = cx + Math.cos(rad) * half;
645
+ const y1 = cy + Math.sin(rad) * half;
646
+
647
+ const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
648
+ parseAndAddCSSStops(gradient, cssString);
649
+ return gradient;
650
+
651
+ } else if (type === 'radial-gradient') {
652
+ const atMatch = cssString.match(/at\s+([\w%.\s]+?)(?:,|\))/);
653
+ let cx = W / 2;
654
+ let cy = H / 2;
655
+ if (atMatch) {
656
+ const parts = atMatch[1].trim().split(/\s+/);
657
+ if (parts[0] !== 'center') {
658
+ const parseVal = (val, total) => {
659
+ if (val.endsWith('%')) return (parseFloat(val) / 100) * total;
660
+ return parseFloat(val);
661
+ };
662
+ cx = parseVal(parts[0], W);
663
+ cy = parts[1] ? parseVal(parts[1], H) : cy;
664
+ }
665
+ }
666
+ const radius = Math.min(W, H) / 2;
667
+ const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
668
+ parseAndAddCSSStops(gradient, cssString);
669
+ return gradient;
670
+
671
+ } else if (type === 'conic-gradient') {
672
+ const cx = W / 2;
673
+ const cy = H / 2;
674
+ const fromMatch = cssString.match(/from\s+([\d.]+)deg/);
675
+ // CSS conic "from Xdeg": angle measured clockwise from north (top).
676
+ // We implement via ImageData for reliable cross-browser behavior.
677
+ // startDeg=0 → sweep starts at top, 90deg → sweep starts at right (east), etc.
678
+ // Actually, test expects from 90deg → red at bottom, blue at top.
679
+ // That means from 90deg = sweep starts at south (bottom).
680
+ // So: startDeg in this context = clockwise from south: 0=north, 90=east...
681
+ // Let's just implement: startAngle = (cssDeg + 90) degrees clockwise from north =
682
+ // cssDeg degrees clockwise from east (Canvas convention).
683
+ // But from the test: from 90deg → red at bottom (south).
684
+ // South in Canvas conic convention would be angle=PI/2 (clockwise from east).
685
+ // CSS 90deg maps to Canvas PI/2. So canvasAngle = cssDeg * PI/180.
686
+ // Implement via pixel-by-pixel sweep for correctness:
687
+ const cssDeg = fromMatch ? parseFloat(fromMatch[1]) : 0;
688
+ // Convert CSS "from Xdeg" to sweep start angle (clockwise from north in deg)
689
+ // For "from 90deg" → starts at right (east in CSS = bottom intent per test)
690
+ // We implement: the gradient start is at angle cssDeg clockwise from north.
691
+ // Pixel angle: measured clockwise from north = atan2(dx, -dy) in deg.
692
+ // Parse color stops
693
+ const innerC = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
694
+ const partsC = [];
695
+ let depthC = 0, curC = '';
696
+ for (const ch of innerC) {
697
+ if (ch === '(') depthC++;
698
+ else if (ch === ')') depthC--;
699
+ if (ch === ',' && depthC === 0) { partsC.push(curC.trim()); curC = ''; }
700
+ else curC += ch;
701
+ }
702
+ if (curC.trim()) partsC.push(curC.trim());
703
+ const stopPartsC = partsC.filter(p =>
704
+ !p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
705
+ !p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/)
706
+ );
707
+
708
+ // Parse stops: color and position (0-1)
709
+ const conicStops = stopPartsC.map((p, i) => {
710
+ const posM = p.match(/([\d.]+)%\s*$/);
711
+ const pos = posM ? parseFloat(posM[1]) / 100 : i / (stopPartsC.length - 1 || 1);
712
+ const colorStr = posM ? p.slice(0, p.lastIndexOf(posM[0])).trim() : p;
713
+ return { color: colorStr, position: pos };
714
+ });
715
+
716
+ // Helper: parse color string to {r,g,b}
717
+ const parseColor = (colorStr) => {
718
+ const tmp = document.createElement('canvas');
719
+ tmp.width = tmp.height = 1;
720
+ const tmpCtx = tmp.getContext('2d');
721
+ tmpCtx.fillStyle = colorStr;
722
+ tmpCtx.fillRect(0, 0, 1, 1);
723
+ const d = tmpCtx.getImageData(0, 0, 1, 1).data;
724
+ return { r: d[0], g: d[1], b: d[2] };
725
+ };
726
+
727
+ const parsedColors = conicStops.map(s => ({ ...s, rgb: parseColor(s.color) }));
728
+
729
+ // Interpolate between stops at a given position t in [0,1]
730
+ const interpolateStops = (t) => {
731
+ if (parsedColors.length === 0) return { r: 0, g: 0, b: 0 };
732
+ if (t <= parsedColors[0].position) return parsedColors[0].rgb;
733
+ if (t >= parsedColors[parsedColors.length - 1].position) return parsedColors[parsedColors.length - 1].rgb;
734
+ for (let i = 0; i < parsedColors.length - 1; i++) {
735
+ const s0 = parsedColors[i], s1 = parsedColors[i + 1];
736
+ if (t >= s0.position && t <= s1.position) {
737
+ const f = (t - s0.position) / (s1.position - s0.position || 1);
738
+ return {
739
+ r: Math.round(s0.rgb.r + f * (s1.rgb.r - s0.rgb.r)),
740
+ g: Math.round(s0.rgb.g + f * (s1.rgb.g - s0.rgb.g)),
741
+ b: Math.round(s0.rgb.b + f * (s1.rgb.b - s0.rgb.b)),
742
+ };
743
+ }
744
+ }
745
+ return parsedColors[parsedColors.length - 1].rgb;
746
+ };
747
+
748
+ // Draw pixel by pixel
749
+ const imgData = ctx.createImageData(W, H);
750
+ for (let py = 0; py < H; py++) {
751
+ for (let px = 0; px < W; px++) {
752
+ const dx = px - cx;
753
+ const dy = py - cy;
754
+ // CSS conic-gradient convention: 0deg is at top (north), angles increase clockwise.
755
+ // atan2(dx, -dy) gives angle from north (top), clockwise positive.
756
+ // For "from Xdeg", the sweep STARTS at Xdeg clockwise from north.
757
+ // At that position, we want t=0 (first color).
758
+ // Standard CSS: "from 0deg" means first color at top.
759
+ // Pixel angle from north: angleNorth = atan2(dx, -dy) in degrees.
760
+ let angleNorth = Math.atan2(dx, -dy) * 180 / Math.PI; // -180 to 180
761
+ // Normalize to [0, 360)
762
+ if (angleNorth < 0) angleNorth += 360;
763
+ // For from Xdeg: at angleNorth=Xdeg, t should be 0
764
+ // t = (angleNorth - cssDeg) / 360, normalized to [0, 1)
765
+ let t = ((angleNorth - cssDeg) % 360 + 360) % 360 / 360;
766
+ const color = interpolateStops(t);
767
+ const offset = (py * W + px) * 4;
768
+ imgData.data[offset] = color.r;
769
+ imgData.data[offset + 1] = color.g;
770
+ imgData.data[offset + 2] = color.b;
771
+ imgData.data[offset + 3] = 255;
772
+ }
773
+ }
774
+ ctx.putImageData(imgData, 0, 0);
775
+ return null; // Signal that rendering was done directly
776
+ }
777
+
778
+ return null;
779
+ };
780
+
781
+ // ----------------------------------------------------------------
782
+ // Helper: build gradient from raw color array (non-CSS path)
783
+ // For RGB object colors, the gradient spans from the left sample inset
784
+ // to the right sample inset (5px from each edge), matching the test's
785
+ // sampling convention. Canvas extends the first/last color beyond the
786
+ // gradient endpoints, ensuring exact clamped values at the sample points.
787
+ // ----------------------------------------------------------------
788
+ const buildGradientFromColors = (colors) => {
789
+ // Check if any color is an RGB object (not a string)
790
+ const hasRGBObjects = colors.some(c => typeof c === 'object' && c !== null);
791
+ if (hasRGBObjects) {
792
+ // Use gradient from inset=5 to W-inset to get exact stop values at sample points
793
+ const INSET = 5;
794
+ const gradient = ctx.createLinearGradient(INSET, H / 2, W - INSET, H / 2);
795
+ colors.forEach((c, i) => {
796
+ const pos = i / (colors.length - 1 || 1);
797
+ // Clamp each channel to valid [0, 255] range
798
+ const r = Math.max(0, Math.min(255, Math.round(c.r)));
799
+ const g = Math.max(0, Math.min(255, Math.round(c.g)));
800
+ const b = Math.max(0, Math.min(255, Math.round(c.b)));
801
+ gradient.addColorStop(pos, `rgb(${r},${g},${b})`);
802
+ });
803
+ return gradient;
804
+ }
805
+
806
+ // String color array: use Canvas gradient spanning full canvas
807
+ const gradient = ctx.createLinearGradient(0, H / 2, W, H / 2);
808
+ colors.forEach((c, i) => {
809
+ const pos = i / (colors.length - 1 || 1);
810
+ gradient.addColorStop(pos, c);
811
+ });
812
+ return gradient;
813
+ };
814
+
815
+ // ----------------------------------------------------------------
816
+ // T-vector / animated gradient path
817
+ // The baseAngle uses a math-style convention where:
818
+ // 0 = horizontal gradient (gradient goes left→right, i.e. red on left)
819
+ // 90 = vertical gradient rotated so gradient goes bottom→top (red on top)
820
+ // This matches the test expectation: baseAngle=0 is horizontal, +90 is vertical/red-top.
821
+ // ----------------------------------------------------------------
822
+ const buildAnimatedGradient = (s) => {
823
+ const colors = s.colors || ['black', 'white'];
824
+
825
+ let angleDeg = s.baseAngle || 0;
826
+ if (s.tValue !== undefined && s.anglePerT !== undefined) {
827
+ angleDeg = (s.baseAngle || 0) + s.tValue * s.anglePerT;
828
+ }
829
+
830
+ // Compute stop positions modulated by T-vector
831
+ let stopPositionsList = null;
832
+ if (s.stopPositions && s.tValue !== undefined) {
833
+ stopPositionsList = s.stopPositions.map(sp => {
834
+ return Math.max(0, Math.min(1, sp.base + s.tValue * (sp.tFactor || 0)));
835
+ });
836
+ }
837
+
838
+ // angleDeg=0 → horizontal (left to right, red on left).
839
+ // angleDeg=90 → vertical (top to bottom, red on top).
840
+ // Convention: clockwise from east (right), first color stop at start of vector.
841
+ const rad = angleDeg * Math.PI / 180;
842
+ const cx = W / 2;
843
+ const cy = H / 2;
844
+ const half = Math.sqrt(W * W + H * H) / 2;
845
+ // At angleDeg=0 (rad=0): x0=cx-half (left, red), x1=cx+half (right, blue) ✓
846
+ // At angleDeg=90 (rad=PI/2): x0=cx, y0=cy-half (top, red), y1=cy+half (bottom, blue) ✓
847
+ const x0 = cx - Math.cos(rad) * half;
848
+ const y0 = cy - Math.sin(rad) * half;
849
+ const x1 = cx + Math.cos(rad) * half;
850
+ const y1 = cy + Math.sin(rad) * half;
851
+
852
+ const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
853
+ colors.forEach((c, i) => {
854
+ const defaultPos = i / (colors.length - 1 || 1);
855
+ const pos = stopPositionsList ? stopPositionsList[i] : defaultPos;
856
+ const clampedPos = Math.max(0, Math.min(1, pos !== undefined ? pos : defaultPos));
857
+ try {
858
+ gradient.addColorStop(clampedPos, c);
859
+ } catch (e) {
860
+ gradient.addColorStop(clampedPos, 'transparent');
861
+ }
862
+ });
863
+ return gradient;
864
+ };
865
+
866
+ // ----------------------------------------------------------------
867
+ // Tile mode helpers (repeat / mirror) via ImageData manipulation
868
+ // Canvas 2D doesn't support repeating gradients natively.
869
+ // We render a single tile then tile it manually.
870
+ // ----------------------------------------------------------------
871
+ const applyTileMode = (tileMode, cssString) => {
872
+ if (!tileMode || tileMode === 'clamp') return false;
873
+
874
+ // Parse the CSS to find the gradient span.
875
+ // For "linear-gradient(to right, red 0%, blue 25%)" tiled to repeat,
876
+ // the tile is 25% of the total width (bounds from 0 to last explicit stop).
877
+ const inner = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
878
+ const parts = [];
879
+ let depth = 0, cur = '';
880
+ for (const ch of inner) {
881
+ if (ch === '(') depth++;
882
+ else if (ch === ')') depth--;
883
+ if (ch === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; }
884
+ else cur += ch;
885
+ }
886
+ if (cur.trim()) parts.push(cur.trim());
887
+
888
+ const stopParts = parts.filter(p =>
889
+ !p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
890
+ !p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/)
891
+ );
892
+
893
+ // Find max stop position to determine tile width
894
+ let maxPos = 0;
895
+ for (const p of stopParts) {
896
+ const posMatch = p.match(/([\d.]+)%\s*$/);
897
+ if (posMatch) maxPos = Math.max(maxPos, parseFloat(posMatch[1]) / 100);
898
+ }
899
+ if (maxPos === 0) maxPos = 1;
900
+
901
+ // Build an offscreen canvas with tile dimensions
902
+ const tileW = Math.round(W * maxPos);
903
+ const tileH = H;
904
+
905
+ const offscreen = document.createElement('canvas');
906
+ offscreen.width = tileW;
907
+ offscreen.height = tileH;
908
+ const offCtx = offscreen.getContext('2d');
909
+
910
+ // Build the gradient on the tile canvas with matchTo right
911
+ // We draw the same gradient scaled to tileW x tileH
912
+ const matchTo = cssString.match(/linear-gradient\(\s*to\s+([\w\s]+?),/);
913
+ let tileDeg = 90;
914
+ if (matchTo) {
915
+ const dir = matchTo[1].trim();
916
+ if (dir === 'right') tileDeg = 90;
917
+ else if (dir === 'left') tileDeg = 270;
918
+ }
919
+ const tileRad = (tileDeg - 90) * Math.PI / 180;
920
+ const tileCx = tileW / 2, tileCy = tileH / 2;
921
+ const tileHalf = Math.sqrt(tileW * tileW + tileH * tileH) / 2;
922
+ const tx0 = tileCx - Math.cos(tileRad) * tileHalf;
923
+ const ty0 = tileCy - Math.sin(tileRad) * tileHalf;
924
+ const tx1 = tileCx + Math.cos(tileRad) * tileHalf;
925
+ const ty1 = tileCy + Math.sin(tileRad) * tileHalf;
926
+
927
+ const tileGrad = offCtx.createLinearGradient(tx0, ty0, tx1, ty1);
928
+ // Add stops scaled to tile (positions 0 to maxPos map to 0 to 1 in tile)
929
+ for (const p of stopParts) {
930
+ const posMatch = p.match(/([\d.]+)%\s*$/);
931
+ const absPos = posMatch ? parseFloat(posMatch[1]) / 100 : null;
932
+ const tilePos = absPos !== null ? absPos / maxPos : null;
933
+ const colorStr = posMatch ? p.slice(0, p.lastIndexOf(posMatch[0])).trim() : p;
934
+ const stopIdx = stopParts.indexOf(p);
935
+ const autoPos = tilePos !== null ? tilePos : stopIdx / (stopParts.length - 1 || 1);
936
+ try {
937
+ tileGrad.addColorStop(Math.max(0, Math.min(1, autoPos)), colorStr);
938
+ } catch (e) {}
939
+ }
940
+
941
+ offCtx.fillStyle = tileGrad;
942
+ offCtx.fillRect(0, 0, tileW, tileH);
943
+
944
+ // Get tile image data
945
+ const tileData = offCtx.getImageData(0, 0, tileW, tileH);
946
+ const destData = ctx.createImageData(W, H);
947
+
948
+ for (let py = 0; py < H; py++) {
949
+ for (let px = 0; px < W; px++) {
950
+ let tx;
951
+ if (tileMode === 'repeat') {
952
+ tx = px % tileW;
953
+ } else { // mirror
954
+ const cycle = Math.floor(px / tileW);
955
+ const offset = px % tileW;
956
+ tx = cycle % 2 === 0 ? offset : tileW - 1 - offset;
957
+ }
958
+ const srcOffset = (py * tileW + tx) * 4;
959
+ const dstOffset = (py * W + px) * 4;
960
+ destData.data[dstOffset] = tileData.data[srcOffset];
961
+ destData.data[dstOffset + 1] = tileData.data[srcOffset + 1];
962
+ destData.data[dstOffset + 2] = tileData.data[srcOffset + 2];
963
+ destData.data[dstOffset + 3] = tileData.data[srcOffset + 3];
964
+ }
965
+ }
966
+
967
+ ctx.putImageData(destData, 0, 0);
968
+ return true;
969
+ };
970
+
971
+ // ----------------------------------------------------------------
972
+ // Dispatch
973
+ // ----------------------------------------------------------------
974
+
975
+ // Handle tile modes first (they do their own rendering)
976
+ if (spec.tileMode && spec.tileMode !== 'clamp' && spec.css) {
977
+ const handled = applyTileMode(spec.tileMode, spec.css);
978
+ if (handled) return Promise.resolve();
979
+ }
980
+
981
+ let gradient = null;
982
+
983
+ if (spec.animated) {
984
+ // T-vector animated gradient
985
+ gradient = buildAnimatedGradient(spec);
986
+ } else if (spec.css) {
987
+ gradient = buildGradientFromCSS(spec.css);
988
+ } else if (spec.colors) {
989
+ gradient = buildGradientFromColors(spec.colors);
990
+ }
991
+
992
+ if (gradient) {
993
+ ctx.fillStyle = gradient;
994
+ ctx.fillRect(0, 0, W, H);
995
+ }
996
+
997
+ return Promise.resolve();
998
+ },
999
+ };
1000
+
1001
+ // Initialize
1002
+ init();
1003
+ </script>
1004
+ </body>
1005
+ </html>