File size: 33,264 Bytes
39309b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
#!/usr/bin/env node
/* test-browser-v090.mjs: the 0.9.0 features in a real browser.
 *
 * Aura draws on a canvas, textures are SVG filters, and a slider is vendor
 * pseudo-elements, so none of it can be checked in Node. This drives headless
 * Chrome and looks at what was actually drawn. It is written to break things:
 * removal, hidden elements, off-screen elements, freeze, reduced motion, state
 * changes, token changes, resize, device pixel ratio, and a stress load.
 *
 *   node scripts/test-browser-v090.mjs
 */
import { withBrowser } from './lib/browser-harness.mjs';
import { decodePng, px, dist, stats } from './lib/pixels.mjs';
import { galleryPage } from './texture-gallery.mjs';
import { PRESET_NAMES } from '../public/dssl/texture.js';

let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
  if (cond) { pass++; console.log(`  ok  ${name}`); }
  else { fail++; console.log(`FAIL  ${name}${extra !== undefined ? '; ' + extra : ''}`); }
};
const section = (t) => console.log(`\n== ${t}`);

/** a page with the engine, a dark body and the given DSSL + markup */
const page = (dssl, body, bg = '#000') => `<!doctype html><meta charset=utf-8>
<body style="margin:0;background:${bg};height:4000px;overflow-x:hidden">
<style type="text/dssl">${dssl}</style>${body}
<script type="module" src="/dssl/boot.js"></script>`;

const engine = `DSSL.__debug.engines.get('global')`;
const dbg = (p) => p.evaluate(`(() => { const e = ${engine}; return e && e.aura ? e.aura.debug() : null; })()`);
const ready = (p) => p.waitForFunction(`window.DSSL && ${engine}`);
const shot = async (p, clip) => decodePng(await p.screenshot({ clip }));
const alive = async (p) => { const d = await dbg(p); return d ? d.stats.particles : 0; };

/* ------------------------------------------------------------------ *
 * AURA
 * ------------------------------------------------------------------ */
section('aura: glow');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/glow');
  await ready(p); await p.waitForTimeout(500);
  const img = await shot(p, { x: 150, y: 100, width: 420, height: 320 });
  // element is at 300..420 x 200..320 on the page, so in the clip at (150..270, 100..220)
  const inside = px(img, 210, 160);
  ok('the glow never paints over the element itself', dist(inside, [32, 32, 48]) < 6, `inside pixel ${inside}`);
  const near = px(img, 150 - 12, 160);
  ok('the glow lights the area right outside the element', near[0] > 40 && near[0] > near[2] * 1.5, `pixel ${near}`);
  const far = px(img, 5, 5);
  ok('and fades to nothing far away', dist(far, [0, 0, 0]) < 8, `pixel ${far}`);
  const above = px(img, 210, 100 - 12);
  ok('it surrounds the element on every side, not just one', above[0] > 25 && px(img, 210, 220 + 12)[0] > 25 && px(img, 270 + 12, 160)[0] > 25);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/glow': page('.orb { position: absolute; left: 300px; top: 200px; width: 120px; height: 120px; background: #202030; radius: 20; aura-glow: #ff8800 40 }', '<div class="orb"></div>') } });

section('aura: particles');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/p');
  await ready(p); await p.waitForTimeout(1600);
  const d = await dbg(p);
  const n = d.stats.particles;
  ok('particle count settles at about the configured count (40)', n >= 28 && n <= 40, `alive ${n}`);
  const s = d.emitters[0].layers[0].sample;
  ok('speed and direction are what was written (up at 60 px/s, no spread)', s.length && s.every((q) => Math.abs(q.vy + 60) < 1.5 && Math.abs(q.vx) < 1.5), JSON.stringify(s[0]));
  const y1 = s[0].y; await p.waitForTimeout(150);
  const s2 = (await dbg(p)).emitters[0].layers[0].sample;
  ok('particles actually move upward over time', s2.some((q) => q.y < y1 - 3), `${y1} -> ${s2.map((q) => q.y.toFixed(0))}`);
  ok('the loop is running at a healthy frame time', d.stats.frameMs < 10, `${d.stats.frameMs.toFixed(2)}ms`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/p': page('.src { position: absolute; left: 300px; top: 400px; width: 60px; height: 40px; background: #333; aura-particle: #ffffff count 40 size 4 life 1000 speed 60 dir up spread 0 from bottom seed 3 }', '<div class="src"></div>') } });

section('aura: behind');
for (const behind of [true, false]) {
  await withBrowser(async ({ open }) => {
    const p = await open('/b');
    await ready(p); await p.waitForTimeout(1200);
    const img = await shot(p, { x: 280, y: 180, width: 200, height: 160 });
    // element interior is (300..420) x (200..320) minus the clip origin (280,180): (20..140) x (20..140)
    const inner = stats(img, 24, 24, 90, 90, 90);
    if (behind) ok('behind: nothing is drawn over the element\'s face', inner.lit === 0, `lit ${inner.lit}`);
    else ok('control: without behind the particles DO cover the face', inner.lit > 20, `lit ${inner.lit}`);
  }, { pages: { '/b': page(`.src { position: absolute; left: 300px; top: 200px; width: 120px; height: 120px; background: #111; aura-particle: #ffffff count 120 size 22 life 3000 speed 0 from center seed 2 ${behind ? 'behind' : ''} }`, '<div class="src"></div>') } });
}

section('aura: lifecycle');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/l');
  await ready(p); await p.waitForTimeout(1200);
  ok('hidden (display:none) elements emit nothing', (await alive(p)) === 0);
  await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(1100);
  ok('showing it starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`);
  // removal by removing the rule's class
  await p.evaluate(`document.getElementById('h').className = 'src off'`); await p.waitForTimeout(2200);
  let d = await dbg(p);
  ok('removing the aura lets what is alive finish, then the emitter is gone', d.emitters.length === 0 && d.stats.particles === 0, JSON.stringify(d.stats));
  const f1 = d.stats.frames; await p.waitForTimeout(300);
  ok('and the animation loop stops instead of spinning forever', (await dbg(p)).stats.frames === f1, 'frames kept increasing');
  // delete the element from the DOM mid-flight
  await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(700);
  await p.evaluate(`document.getElementById('h').remove()`); await p.waitForTimeout(700);
  d = await dbg(p);
  ok('deleting the element from the DOM drops its emitter', d.emitters.length === 0, JSON.stringify(d.emitters.length));
  ok('no page errors through all of that', errors.length === 0, errors.join(' | '));
}, { pages: { '/l': page(`.src { position: absolute; left: 300px; top: 300px; width: 80px; height: 80px; background: #345; aura-particle: #8cf count 40 life 600 speed 40 dir up from top }
.hide { display: none } .off { aura: none }`, '<div id="h" class="src hide"></div>') } });

section('aura: freeze and unfreeze');
await withBrowser(async ({ open }) => {
  const p = await open('/f');
  await ready(p); await p.waitForTimeout(900);
  await p.evaluate('DSSL.freeze()'); await p.waitForTimeout(200);
  const a = (await dbg(p)).stats.frames; await p.waitForTimeout(300);
  const b = (await dbg(p)).stats.frames;
  ok('freeze() stops the aura loop', a === b, `${a} -> ${b}`);
  const painted = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i]) n++; return n; })()`);
  ok('and clears the canvas, so a frozen page is still', painted === 0, `${painted} painted pixels`);
  await p.evaluate('DSSL.unfreeze()'); await p.waitForTimeout(500);
  const c = (await dbg(p)).stats.frames; await p.waitForTimeout(300);
  ok('unfreeze() resumes it', (await dbg(p)).stats.frames > c);
}, { pages: { '/f': page('.src { position: absolute; left: 300px; top: 400px; width: 80px; height: 40px; background: #345; aura: glow #f80 30, particles #fc4 count 40 life 800 speed 50 dir up from top }', '<div class="src"></div>') } });

section('aura: reduced motion');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/rm');
  await ready(p); await p.waitForTimeout(1500);
  ok('particles do not move for visitors who ask for reduced motion', (await alive(p)) === 0, `alive ${await alive(p)}`);
  const img = await shot(p, { x: 230, y: 200, width: 300, height: 200 });
  // element is 300..380 x 250..290; in the clip that is (70..150, 50..90); look just left of its middle
  ok('but the static glow is still drawn', px(img, 58, 70)[0] > 30, `pixel ${px(img, 58, 70)}`);
  const frames = (await dbg(p)).stats.frames; await p.waitForTimeout(400);
  ok('and the loop is not spinning for a still picture', (await dbg(p)).stats.frames - frames < 3, 'loop kept running');
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { reducedMotion: 'reduce', pages: { '/rm': page(`.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura: glow #ff8800 40, particles #fc4 count 50 life 800 speed 50 dir up from top }`, '<div class="src"></div>') } });
await withBrowser(async ({ open }) => {
  const p = await open('/rme');
  await ready(p); await p.waitForTimeout(1200);
  ok('an `essential` aura ignores the preference', (await alive(p)) > 3, `alive ${await alive(p)}`);
}, { reducedMotion: 'reduce', pages: { '/rme': page(`.e { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura-particle: #fc4 count 50 life 800 speed 50 dir up from top essential }`, '<div class="e"></div>') } });

section('aura: reduced motion still follows the page');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/rs');
  await ready(p); await p.waitForTimeout(900);
  const at = async () => { const img = await shot(p, { x: 230, y: 0, width: 300, height: 400 }); let top = -1; for (let y = 0; y < 400; y++) { if (px(img, 58, y)[0] > 40) { top = y; break; } } return top; };
  const a = await at();
  await p.evaluate('window.scrollTo(0, 60)'); await p.waitForTimeout(400);
  const b = await at();
  ok('after a scroll the still glow has moved with its element (about 60px up)', a > 0 && b > 0 && Math.abs((a - b) - 60) < 6, `${a} -> ${b}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { reducedMotion: 'reduce', pages: { '/rs': page('.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura-glow: #ff8800 40 }', '<div class="src"></div>') } });

section('aura: currentColor, textured particles');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/cc');
  await ready(p); await p.waitForTimeout(900);
  const img = await shot(p, { x: 230, y: 180, width: 300, height: 200 });
  const c = px(img, 58, 90); // middle of the element's left edge (it spans y 70..110 in this clip)
  ok('a glow with no colour (or currentColor) takes the element\'s text colour (green)', c[1] > 40 && c[1] > c[0] * 2 && c[1] > c[2] * 2, `pixel ${c}`);
  ok('particles with texture(grain) render without errors', (await alive(p)) > 3);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/cc': page('.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; color: #22dd44; aura-glow: currentColor 40, ; }', '<div class="src"></div>').replace(', ; }', ' }').replace('aura-glow: currentColor 40 }', 'aura-glow: currentColor 40\n  aura-particle: color currentColor count 30 life 600 speed 30 dir up from top texture(grain scale 2) }') } });

section('aura: states, tokens, slots');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/s');
  await ready(p); await p.waitForTimeout(700);
  ok('no aura until the pointer is over the element', (await alive(p)) === 0);
  await p.hover('.btn'); await p.waitForTimeout(900);
  ok('a hover { } block turns the aura on', (await alive(p)) > 5, `alive ${await alive(p)}`);
  await p.mouse.move(5, 5); await p.waitForTimeout(1800);
  ok('and moving away lets it die out', (await alive(p)) === 0, `alive ${await alive(p)}`);

  // slots stack through the cascade: glow from one rule, particles from another, then glow cleared
  await p.evaluate(`document.getElementById('slots').className = 'g'`); await p.waitForTimeout(500);
  let d = await dbg(p); let em = d.emitters.find((e) => e.el.id === 'slots') || d.emitters[0];
  ok('aura-glow alone gives a glow and no particles', em && em.glow === 1 && em.layers.length === 0, JSON.stringify(em && { g: em.glow, l: em.layers.length }));
  await p.evaluate(`document.getElementById('slots').className = 'g p'`); await p.waitForTimeout(500);
  d = await dbg(p); em = d.emitters[0];
  ok('adding a rule with aura-particle keeps the glow and adds the particles', em && em.glow === 1 && em.layers.length === 1, JSON.stringify(em && { g: em.glow, l: em.layers.length }));
  await p.evaluate(`document.getElementById('slots').className = 'g p noglow'`); await p.waitForTimeout(500);
  d = await dbg(p); em = d.emitters[0];
  ok('aura-glow: none clears only the glow slot', em && em.glow === 0 && em.layers.length === 1, JSON.stringify(em && { g: em.glow, l: em.layers.length }));

  // a token change retints the aura
  await p.evaluate(`document.getElementById('slots').className = 'tok'`); await p.waitForTimeout(500);
  const rect = await p.evaluate(`(() => { const r = document.getElementById('slots').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
  const clip = { x: rect.x - 60, y: rect.y - 40, width: rect.w + 120, height: rect.h + 80 };
  let img = await shot(p, clip); let c = px(img, 40, 60);
  ok('a glow written as a token colour draws that colour (red)', c[0] > c[2] * 2, `pixel ${c}`);
  await p.evaluate(`DSSL.apply('tokens { color { aurahue: #0044ff } }', { name: 'retint', replace: true })`); await p.waitForTimeout(500);
  img = await shot(p, clip); c = px(img, 40, 60);
  ok('changing the token retints the glow live (now blue)', c[2] > c[0] * 2, `pixel ${c}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/s': page(`tokens { color { aurahue: #ff2200 } }
.btn { position: absolute; left: 300px; top: 300px; width: 100px; height: 40px; background: #222
  hover { aura-particle: #8cf count 40 life 600 speed 40 dir up from top } }
#slots { position: absolute; left: 500px; top: 300px; width: 100px; height: 60px; background: #222 }
.g { aura-glow: #ff8800 24 } .p { aura-particle: #8cf count 20 life 500 speed 30 dir up from top } .noglow { aura-glow: none }
.tok { aura-glow: color.aurahue 30 }`, '<div class="btn"></div><div id="slots"></div>') } });

section('aura: viewport, scroll, pixel ratio');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/v');
  await ready(p); await p.waitForTimeout(800);
  ok('an element far below the fold emits nothing', (await alive(p)) === 0, `alive ${await alive(p)}`);
  await p.evaluate('window.scrollTo(0, 2900)'); await p.waitForTimeout(900);
  ok('scrolling it into view starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`);
  await p.setViewportSize({ width: 520, height: 420 }); await p.waitForTimeout(400);
  const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, c.height, innerWidth, innerHeight]; })()`);
  ok('the canvas follows a window resize', dims[0] === dims[2] && dims[1] === dims[3], JSON.stringify(dims));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/v': page('.far { position: absolute; left: 300px; top: 3100px; width: 80px; height: 40px; background: #345; aura-particle: #fc4 count 40 life 800 speed 40 dir up from top }', '<div class="far"></div>') } });
await withBrowser(async ({ open }) => {
  const p = await open('/d');
  await ready(p); await p.waitForTimeout(500);
  const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, innerWidth]; })()`);
  ok('on a 2x display the canvas is drawn at 2x so glow stays sharp', dims[0] === dims[1] * 2, JSON.stringify(dims));
}, { deviceScaleFactor: 2, pages: { '/d': page('.o { position: absolute; left: 200px; top: 200px; width: 80px; height: 40px; background: #345; aura-glow: #f80 20 }', '<div class="o"></div>') } });

section('aura: range slider thumb anchor');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/t');
  await ready(p); await p.waitForTimeout(700);
  const meanX = async () => p.evaluate(`(() => { const L = ${engine}.aura.emitters.get(document.getElementById('r')).layers[0].parts; return L.length ? L.reduce((a, q) => a + q.x, 0) / L.length : -1; })()`);
  await p.fill('#r', '10'); await p.waitForTimeout(1100); const low = await meanX();
  await p.fill('#r', '90'); await p.waitForTimeout(1500); const high = await meanX();
  ok('`from thumb` follows the value: low at 10, high at 90', low > 0 && high > low + 150, `${low.toFixed(0)} vs ${high.toFixed(0)}`);
  ok('and lands within the slider\'s own width (100..400)', low >= 100 && high <= 400, `${low} ${high}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/t': page('#r { position: absolute; left: 100px; top: 300px; width: 300px; aura-particle: #7fe -> transparent count 30 life 400 speed 10 dir up spread 20 from thumb }', '<input id="r" type="range" min="0" max="100" value="50">') } });

section('aura: many glows');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/glows');
  await ready(p); await p.waitForTimeout(2500);
  const d = await dbg(p);
  ok('40 pulsing, flickering glows of different sizes cost under 6ms a frame (the blur is cached, not redone)', d.stats.frameMs < 6, `${d.stats.frameMs.toFixed(2)}ms`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/glows': page('.c { display: inline-block; margin: 14px; background: #223; radius: 6; aura-glow: #ff8800 30 pulse 1200 flicker 0.3 }', '<div style="width:960px">' + Array.from({ length: 40 }, (_, i) => `<div class="c" style="width:${40 + (i % 7) * 13}px;height:${24 + (i % 5) * 9}px"></div>`).join('') + '</div>') }, viewport: { width: 1000, height: 700 } });

section('aura: stress');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/stress');
  await ready(p); await p.waitForTimeout(2500);
  const d = await dbg(p);
  ok('60 emitters x 100 particles are held to the global budget (1800)', d.stats.particles <= 1800, `alive ${d.stats.particles}`);
  ok('and the frame time stays workable under that load', d.stats.frameMs < 25, `${d.stats.frameMs.toFixed(1)}ms`);
  // churn: flip the aura on and off 200 times
  await p.evaluate(`(async () => { const el = document.querySelector('.cell'); for (let i = 0; i < 200; i++) { el.classList.toggle('x'); await new Promise((r) => setTimeout(r, 4)); } })()`);
  await p.waitForTimeout(2200);
  const e = await dbg(p);
  ok('rapid toggling does not leak emitters (60 elements, 60 emitters at most)', e.emitters.length <= 60, `emitters ${e.emitters.length}`);
  // refresh keeps what is flying instead of resetting it
  const before = e.stats.particles; await p.evaluate('DSSL.refresh()'); await p.waitForTimeout(50);
  ok('DSSL.refresh() does not wipe particles in flight', (await dbg(p)).stats.particles >= before * 0.7, `${before} -> ${(await dbg(p)).stats.particles}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/stress': page('.cell { display: inline-block; width: 40px; height: 20px; margin: 4px; background: #345; aura-particle: #fc4 count 100 life 700 speed 30 dir up from top } .cell.x { aura: none }', '<div style="width:900px;padding:10px">' + '<div class="cell"></div>'.repeat(60) + '</div>') } });

/* ------------------------------------------------------------------ *
 * TEXTURE
 * ------------------------------------------------------------------ */
section('texture: layering over the author\'s own background');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/tx');
  await ready(p); await p.waitForTimeout(600);
  const cs = (sel, prop) => p.evaluate(([s, pr]) => getComputedStyle(document.querySelector(s))[pr], [sel, prop]);
  ok('a plain colour keeps its colour', (await cs('#c', 'backgroundColor')) === 'rgb(200, 30, 30)');
  ok('and gains one texture layer', (await cs('#c', 'backgroundImage')).split('url("data:').length === 2);
  const gi = await cs('#g', 'backgroundImage');
  ok('a gradient keeps its gradient UNDER the texture (shorthand form)', gi.includes('linear-gradient') && gi.includes('url('), gi.slice(0, 80));
  ok('the texture layer comes first, so it sits on top', gi.indexOf('url(') < gi.indexOf('linear-gradient'));
  const si = await cs('#s', 'backgroundSize');
  ok('background-image + background-size: cover keeps `cover` for the author\'s layer (list is aligned)', si.endsWith('cover') && si.split(',').length === 2, si);
  const bm = await cs('#s', 'backgroundBlendMode');
  ok('blend modes line up: texture mode first, normal for the author\'s layer', /^overlay, normal$/.test(bm), bm);
  const before = await cs('#r', 'backgroundImage');
  await p.evaluate(`document.getElementById('r').className = 'tex'`); await p.waitForTimeout(300);
  const during = await cs('#r', 'backgroundImage');
  await p.evaluate(`document.getElementById('r').className = ''`); await p.waitForTimeout(300);
  const after = await cs('#r', 'backgroundImage');
  ok('adding then removing a texture leaves the author\'s background exactly as it was', before === after && during !== before, `${before} | ${during.slice(0, 40)} | ${after}`);
  await p.evaluate(`document.getElementById('l').className = 'a b'`); await p.waitForTimeout(300);
  ok('a later texture rule replaces an earlier one (later wins)', (await cs('#l', 'backgroundImage')).includes('repeating-linear-gradient') && !(await cs('#l', 'backgroundImage')).includes('feTurbulence'), (await cs('#l', 'backgroundImage')).slice(0, 80));
  await p.evaluate(`document.getElementById('n').className = 'none'`); await p.waitForTimeout(300);
  ok('`texture: none` removes it', (await cs('#n', 'backgroundImage')) === 'none');
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/tx': page(`#c { texture: grain }
#g { background: linear-gradient(red, blue); texture: noise }
#s { background-image: linear-gradient(red, blue); background-size: cover; texture: grain }
#r { background-image: linear-gradient(red, blue) } #r.tex { texture: grain }
.a { texture: grain } .b { texture: lines }
#n { texture: grain } #n.none { texture: none }
.box { width: 80px; height: 60px; margin: 4px; display: inline-block }`, '<div id="c" class="box" style="background-color: rgb(200, 30, 30)"></div><div id="g" class="box"></div><div id="s" class="box"></div><div id="r" class="box"></div><div id="l" class="box"></div><div id="n" class="box"></div>') } });

section('texture: states, several author layers, freeze');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/th');
  await ready(p); await p.waitForTimeout(500);
  const img = () => p.evaluate(`getComputedStyle(document.getElementById('h')).backgroundImage`);
  const base = await img();
  await p.hover('#h'); await p.waitForTimeout(350);
  const hov = await img();
  ok('a texture can change on hover', hov !== base && hov.includes('repeating-linear-gradient'), hov.slice(0, 60));
  await p.mouse.move(2, 2); await p.waitForTimeout(350);
  ok('and goes back when the pointer leaves', (await img()) === base);
  const multi = await p.evaluate(`getComputedStyle(document.getElementById('m')).backgroundImage`);
  ok('an author background with several layers keeps all of them under the texture', multi.split('linear-gradient').length - 1 >= 3 && multi.includes('url("data:'), multi.slice(0, 120));
  const before = await p.evaluate(`document.getElementById('a').getAnimations().length`);
  await p.evaluate('DSSL.freeze()'); await p.waitForTimeout(150);
  const during = await p.evaluate(`document.getElementById('a').getAnimations().length`);
  await p.evaluate('DSSL.unfreeze()'); await p.waitForTimeout(300);
  const after = await p.evaluate(`document.getElementById('a').getAnimations().length`);
  ok('freeze() stops the texture shimmer and unfreeze() brings it back', before === 1 && during === 0 && after === 1, `${before} ${during} ${after}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/th': page('.b { width: 80px; height: 60px; margin: 4px; display: inline-block } #h { texture: grain } #h:hover { texture: lines } #m { background: linear-gradient(red, blue), linear-gradient(green, yellow), linear-gradient(white, black); texture: noise } #a { texture: grain animate 90 }', '<div id="h" class="b"></div><div id="m" class="b"></div><div id="a" class="b"></div>') } });

section('texture: every preset paints something');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/gallery');
  await p.waitForFunction('window.ready === true'); await p.waitForTimeout(500);
  const boxes = await p.evaluate(`[...document.querySelectorAll('.sw')].map((e) => { const r = e.getBoundingClientRect(); return { x: r.left, y: r.top + scrollY, w: r.width, h: r.height }; })`);
  const full = decodePng(await p.screenshot({ fullPage: true }));
  const flat = [184, 165, 138];
  let bad = [];
  boxes.forEach((b, i) => {
    const name = PRESET_NAMES[i];
    const seen = new Set();
    let far = 0;
    for (let y = Math.round(b.y) + 4; y < b.y + b.h - 4; y++) for (let x = Math.round(b.x) + 4; x < b.x + b.w - 4; x++) {
      const c = px(full, x, y);
      seen.add(`${c[0] >> 4},${c[1] >> 4},${c[2] >> 4}`);
      if (dist(c, flat) > 12) far++;
    }
    if (far < 8) bad.push(`${name}(${seen.size} tones, ${far} px changed)`);
  });
  ok(`all ${PRESET_NAMES.length} presets draw something distinct from the flat colour`, bad.length === 0, bad.join(', '));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/gallery': galleryPage('#b8a58a', 6) }, viewport: { width: 1100, height: 700 } });

section('texture: tokens and motion');
await withBrowser(async ({ open }) => {
  const p = await open('/tt');
  await ready(p); await p.waitForTimeout(600);
  const img = () => p.evaluate(`getComputedStyle(document.getElementById('t')).backgroundImage`);
  const a = await img();
  await p.evaluate(`DSSL.apply('tokens { color { tint: #00ff55 } }', { name: 'x', replace: true })`); await p.waitForTimeout(500);
  const b = await img();
  ok('a texture colour written as a token follows the token', a !== b && decodeURIComponent(a).includes('rgb') !== undefined, 'image did not change');
  const anims = await p.evaluate(`document.getElementById('m').getAnimations().length`);
  ok('`animate` shimmers the texture with a real animation', anims === 1, `animations ${anims}`);
}, { pages: { '/tt': page('tokens { color { tint: #ff0044 } } #t { texture: noise color color.tint color2 #fff } #m { texture: grain animate 90 } .b { width: 80px; height: 60px }', '<div id="t" class="b"></div><div id="m" class="b"></div>') } });
await withBrowser(async ({ open }) => {
  const p = await open('/tm');
  await ready(p); await p.waitForTimeout(600);
  ok('the shimmer is off when the visitor prefers reduced motion', (await p.evaluate(`document.getElementById('m').getAnimations().length`)) === 0);
}, { reducedMotion: 'reduce', pages: { '/tm': page('#m { texture: grain animate 90; width: 80px; height: 60px }', '<div id="m"></div>') } });

/* ------------------------------------------------------------------ *
 * SLIDER
 * ------------------------------------------------------------------ */
section('slider: rendering in Chrome (WebKit/Blink pseudo-elements)');
const FILL = [255, 122, 0], TRACK = [68, 85, 102];
await withBrowser(async ({ open, errors }) => {
  const p = await open('/sl');
  await ready(p); await p.waitForTimeout(600);
  const box = await p.evaluate(`(() => { const r = document.getElementById('s1').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
  const clip = { x: box.x - 4, y: box.y - 4, width: box.w + 8, height: box.h + 8 };
  const at = async (frac) => { const img = await shot(p, clip); return px(img, 4 + frac * box.w, 4 + box.h / 2); };
  ok('left of the thumb the track is the fill colour', dist(await at(0.2), FILL) < 24, `pixel ${await at(0.2)}`);
  ok('right of the thumb it is the track colour', dist(await at(0.9), TRACK) < 24, `pixel ${await at(0.9)}`);
  await p.fill('#s1', '95'); await p.waitForTimeout(200);
  ok('moving the thumb moves the fill with it', dist(await at(0.8), FILL) < 24, `pixel ${await at(0.8)}`);
  await p.evaluate(`(() => { const e = document.getElementById('s1'); e.value = 10; })()`);
  await p.evaluate('DSSL.refresh()'); await p.waitForTimeout(200);
  ok('a value set from JavaScript is picked up by DSSL.refresh()', dist(await at(0.5), TRACK) < 24, `pixel ${await at(0.5)}`);
  ok('--dssl-p tracks the value (0..1)', Math.abs(Number(await p.evaluate(`getComputedStyle(document.getElementById('s1')).getPropertyValue('--dssl-p')`)) - 0.1) < 0.01);
  ok('native appearance is switched off', (await p.evaluate(`getComputedStyle(document.getElementById('s1')).appearance`)) === 'none');
  const thumb = await (async () => { await p.fill('#s1', '50'); await p.waitForTimeout(150); const img = await shot(p, clip); return px(img, 4 + 0.5 * box.w, 4 + box.h / 2); })();
  ok('the thumb is drawn in its own colour (white)', dist(thumb, [255, 255, 255]) < 30, `pixel ${thumb}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/sl': page('#s1 { width: 300px; margin: 40px; slider: track 8 #445566 radius full, fill #ff7a00, thumb 22 #ffffff }', '<input id="s1" type="range" value="60">', '#101018') }, viewport: { width: 500, height: 200 } });

section('slider: min, max and step');
await withBrowser(async ({ open }) => {
  const p = await open('/mm');
  await ready(p); await p.waitForTimeout(500);
  const pv = () => p.evaluate(`getComputedStyle(document.getElementById('s')).getPropertyValue('--dssl-p')`);
  ok('min 10, max 50, value 30 is exactly half way (0.5)', Math.abs(Number(await pv()) - 0.5) < 0.001, await pv());
  await p.fill('#s', '50'); await p.waitForTimeout(100);
  ok('at the maximum it is 1', Math.abs(Number(await pv()) - 1) < 0.001, await pv());
  await p.fill('#s', '10'); await p.waitForTimeout(100);
  ok('at the minimum it is 0', Math.abs(Number(await pv())) < 0.001, await pv());
}, { pages: { '/mm': page('#s { width: 300px; slider: fill #f80 }', '<input id="s" type="range" min="10" max="50" step="5" value="30">') } });

section('slider: graph (histogram) and shapes');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/g');
  await ready(p); await p.waitForTimeout(600);
  const box = await p.evaluate(`(() => { const r = document.getElementById('g1').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
  const clip = { x: box.x, y: box.y, width: box.w, height: box.h };
  const img = await shot(p, clip);
  // 12 bars across 300px with 3px gaps: bar i is 22.25px wide and centred at i/11 of the free width
  const n = 12, bw = (box.w - (n - 1) * 3) / n;
  const barX = (i) => Math.round((i / (n - 1)) * (box.w - bw) + bw / 2);
  const topOf = (i) => { for (let y = 0; y < box.h; y++) if (dist(px(img, barX(i), y), [16, 16, 24]) > 40) return y; return -1; };
  ok('bars left of the value are drawn in the fill colour', dist(px(img, barX(1), box.h - 12), [119, 204, 255]) < 30, `pixel ${px(img, barX(1), box.h - 12)}`);
  ok('bars right of the value are drawn in the track colour', dist(px(img, barX(10), box.h - 12), [52, 68, 90]) < 30, `pixel ${px(img, barX(10), box.h - 12)}`);
  const t0 = topOf(0), t7 = topOf(7);
  ok('bar heights follow the numbers: the 95 bar is far taller than the 20 bar', t0 > 0 && t7 > 0 && t0 - t7 > 20, `tops ${t0} vs ${t7}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/g': page('#g1 { width: 300px; margin: 20px; slider: graph 20 40 70 90 60 30 50 95 40 65 80 45, graph-height 56, graph-gap 3, fill #77ccff, track 3 #34445a, thumb 14 #ffffff, shape bar }', '<input id="g1" type="range" value="40">', '#101018') }, viewport: { width: 400, height: 160 } });
await withBrowser(async ({ open, errors }) => {
  const p = await open('/sh');
  await ready(p); await p.waitForTimeout(500);
  const logs = []; p.on('console', (m) => logs.push(m.text()));
  await p.evaluate(`DSSL.apply('.notinput { slider: thumb 20 #fff }', { name: 'misuse', replace: true })`);
  await p.evaluate(`const d = document.createElement('div'); d.className = 'notinput'; document.body.appendChild(d)`); await p.waitForTimeout(400);
  ok('slider: on something that is not a range input says so instead of failing silently', logs.some((l) => /does nothing, it styles <input type="range">/.test(l)), logs.join(' | '));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/sh': page('', '<div></div>') } });

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);