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);
|