Claude Sonnet 5.5
v0.9.0: texture (30 presets), aura glow and particles, range sliders, lint, CSS import
39309b1 Download scripts/test-browser-v090.mjs from EHS-Studio/dssl: direct link, hf CLI and curl.
- Browser
- Download file 33.3 kB
-
https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-browser-v090.mjs
- Command line
-
hf download hf://EHS-Studio/dssl/scripts/test-browser-v090.mjs
-
curl -L -o test-browser-v090.mjs https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-browser-v090.mjs
33.3 kB
| /* 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); | |