Claude Sonnet 5.5
v0.14.0: made for games (fixes and features from a game-building agent's review)
e6c76da Download scripts/test-v0140.mjs from EHS-Studio/dssl: direct link, hf CLI and curl.
- Browser
- Download file 30.6 kB
-
https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-v0140.mjs
- Command line
-
hf download hf://EHS-Studio/dssl/scripts/test-v0140.mjs
-
curl -L -o test-v0140.mjs https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-v0140.mjs
30.6 kB
| /* test-v0140.mjs: the fixes and features from a review by someone building a game on DSSL (0.14.0). | |
| * Every item they listed has an assertion here, run against real Chrome where the browser decides the answer. */ | |
| import { spawnSync } from 'node:child_process'; | |
| import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; | |
| import { tmpdir } from 'node:os'; | |
| import path from 'node:path'; | |
| import { compile } from '../public/dssl/index.js'; | |
| import * as API from '../public/dssl/index.js'; | |
| import { withBrowser, ROOT } from './lib/browser-harness.mjs'; | |
| import { decodePng, px, dist } from './lib/pixels.mjs'; | |
| 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}`); | |
| const page = (dssl, body = '', head = '') => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#000;height:3000px">${head}<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`; | |
| const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`); | |
| const eng = `DSSL.__debug.engines.get('global')`; | |
| const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message); | |
| const warns = (src) => compile(src, 't.dssl').warnings.map((w) => w.message); | |
| section('1. reduced motion is never silent'); | |
| { | |
| const MOTION = 'motion bob { from { translate: 0 0 } to { translate: 0 10px } }\nmotion boing { from { translate: 0 0 } to { translate: 0 10px } essential }'; | |
| await withBrowser(async ({ open, errors }) => { | |
| const logs = []; const p = await open('/r'); p.on('console', (m) => logs.push(m.text())); | |
| await ready(p); await p.waitForTimeout(400); | |
| ok('with reduced motion on, a plain animate: does not run', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0); | |
| ok('a motion marked essential does', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'ess').length) === 1); | |
| ok('so does one marked essential on the animate: line', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'line').length) === 1); | |
| const told = await p.evaluate(() => 0); void told; | |
| await p.evaluate(() => DSSL.refresh()); await p.waitForTimeout(100); | |
| // the notice came when the engine skipped it, before the listener above was attached: reload and listen from the start | |
| const q = await open('/r'); const l2 = []; q.on('console', (m) => l2.push(m.text())); await q.reload(); await ready(q); await q.waitForTimeout(300); | |
| ok('and it SAYS so, once, in plain words, with the way out', l2.filter((t) => /reduced motion/.test(t)).length === 1 && /essential/.test(l2.find((t) => /reduced motion/.test(t)) || '') && /data-dssl-motion/.test(l2.find((t) => /reduced motion/.test(t)) || ''), JSON.stringify(l2)); | |
| await p.evaluate(() => DSSL.motion('full')); await p.waitForTimeout(300); | |
| ok('DSSL.motion("full") runs every motion whatever the system asks for', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1); | |
| await p.evaluate(() => DSSL.motion('reduced')); await p.waitForTimeout(300); | |
| ok('"reduced" holds the plain ones still again', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/r': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }\n#ess { animate: boing 800 infinite alternate }\n#line { animate: bob 800 infinite alternate essential }`, '<div id=plain></div><div id=ess></div><div id=line></div>') }, reducedMotion: 'reduce' }); | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/f'); await ready(p); await p.waitForTimeout(300); | |
| ok('<html data-dssl-motion="full"> in the markup overrides the system from the first frame', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1); | |
| }, { pages: { '/f': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }`, '<div id=plain></div>').replace('<body', '<script>document.documentElement.setAttribute("data-dssl-motion","full")</script><body') }, reducedMotion: 'reduce' }); | |
| } | |
| section('2. motion: words, registry, keyframes, play'); | |
| { | |
| const spec = (src) => compile(src, 't').doc.rules.map((r) => r.decls.find((d) => d.prop === '@animate')).filter(Boolean).map((d) => d.value)[0]; | |
| const s = spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay 100 jitter 200 iterations 3 essential }'); | |
| ok('animate: reads delay, jitter, iterations and essential', s.delay === 100 && s.jitter === 200 && s.iterations === 3 && s.essential === true, JSON.stringify(s)); | |
| ok('a bare number after the duration is still the delay', spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 250 }').delay === 250); | |
| ok('delay without a number is an error that shows the form', errs('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay }').some((m) => /delay needs a number/.test(m))); | |
| const fr = compile('motion w { 0%, 100% { opacity: 1 } 40% { opacity: 0 } }', 't').doc.motionFrames.w.frames.map((f) => f.offset); | |
| ok('keyframes accept 0%, 40% and a list, next to from, to and at 40%', JSON.stringify(fr) === '[0,40,100]', JSON.stringify(fr)); | |
| ok('a keyframe past 100% is refused', errs('motion w { 120% { opacity: 1 } }\n.x { animate: w 1 }').some((m) => /bad keyframe selector/.test(m))); | |
| const w = warns('motion w { from { opacity: 0 } to { opacity: 1 } }\n.y { animate: w 400; animation-delay: 200ms }'); | |
| ok('animation-delay on a rule that uses animate: gets a warning that names the replacement', w.length === 1 && /does nothing here/.test(w[0]), JSON.stringify(w)); | |
| ok('but not when the rule has a CSS animation to tune', warns('.y { animation: foo 400ms; animation-delay: 200ms }').filter((m) => /does nothing/.test(m)).length === 0); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/m'); await ready(p); await p.waitForTimeout(300); | |
| ok('a motion defined in one stylesheet runs from another (shared registry)', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'b').length) === 1); | |
| const e = await p.evaluate(() => DSSL.errors().map((x) => x.message)); | |
| ok('with no unknown-motion error', !e.some((m) => /unknown motion/.test(m)), JSON.stringify(e)); | |
| ok('the pseudo-element animation really runs (a CSS animation, in the engine\'s stylesheet)', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'c' && a.effect.pseudoElement === '::before'))); | |
| ok('every motion is also a real @keyframes, so animation: bob 300ms works too', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'css' && a.animationName === 'bob'))); | |
| // play() | |
| const t0 = Date.now(); | |
| const r = await p.evaluate(async () => { const el = document.getElementById('c'); const a = await DSSL.play(el, 'bob', { duration: 120 }); return { state: a.playState, id: a.effect.target.id }; }); | |
| ok('DSSL.play returns a promise that settles when the motion ends', r.state === 'finished' && r.id === 'c' && Date.now() - t0 >= 100, JSON.stringify(r)); | |
| const held = await p.evaluate(async () => { const el = document.getElementById('c'); await DSSL.play(el, 'bob', { duration: 80, hold: true }); return getComputedStyle(el).translate; }); | |
| ok('hold: true keeps the final frame', held === '0px 10px', held); | |
| const unheld = await p.evaluate(async () => { const el = document.body.appendChild(document.createElement('div')); await DSSL.play(el, 'bob', { duration: 80 }); return getComputedStyle(el).translate; }); | |
| ok('without it the element snaps back', unheld === 'none' || unheld === '0px', unheld); | |
| const bad = await p.evaluate(async () => { try { await DSSL.play(document.body, 'bobb'); return 'resolved'; } catch (e) { return e.message; } }); | |
| ok('an unknown motion rejects, with did-you-mean', /unknown motion "bobb"/.test(bad) && /did you mean "bob"/.test(bad), bad); | |
| const cancelled = await p.evaluate(async () => { const el = document.getElementById('c'); const pr = DSSL.play(el, 'bob', { duration: 5000 }); el.getAnimations().forEach((a) => a.cancel()); const a = await pr; return a.playState; }); | |
| ok('a cancelled animation settles the promise too, so awaiting never hangs', cancelled === 'idle'); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/m': `<!doctype html><meta charset=utf-8><body style="margin:0"><style type="text/dssl" data-dssl-name="a.dssl">motion bob { from { translate: 0 0 } to { translate: 0 10px } }</style><style type="text/dssl" data-dssl-name="b.dssl">#b { animate: bob 800 infinite alternate }\n#c::before { content: "x"; animate: bob 800 infinite alternate }\n#css { animation: bob 800ms infinite alternate }</style><div id=b></div><div id=c></div><div id=css></div><script type="module" src="/dssl/boot.js"></script>` }, reducedMotion: 'no-preference' }); | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/j'); await ready(p); await p.waitForTimeout(300); | |
| const delays = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay)); | |
| ok('jitter gives every element its own delay (not one shared value)', new Set(delays).size > 3 && delays.every((d) => d >= 0 && d <= 600), JSON.stringify(delays)); | |
| await p.reload(); await ready(p); await p.waitForTimeout(300); | |
| const again = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay)); | |
| ok('and the same page gets the same delays every time', JSON.stringify(delays) === JSON.stringify(again)); | |
| }, { pages: { '/j': page('motion bob { from { translate: 0 0 } to { translate: 0 10px } }\n.coin { animate: bob 800 infinite alternate jitter 600 }', Array.from({ length: 8 }, () => '<i class="coin"></i>').join('')) } }); | |
| } | |
| section('3. values and gradients'); | |
| { | |
| const decls = (src) => { const r = compile(src, 't'); return { e: r.errors.map((x) => x.message), d: r.doc.rules.flatMap((x) => x.decls.filter((y) => !y.prop.startsWith('--')).map((y) => `${y.prop}: ${y.value}`)) }; }; | |
| let r = decls('tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }\n.a { shadow: pop; radius: pill }'); | |
| ok('shadow: pop and radius: pill work for your own tokens', r.e.length === 0 && r.d.includes('box-shadow: var(--dssl-shadow-pop)') && r.d.includes('border-radius: var(--dssl-radius-pill)'), JSON.stringify(r)); | |
| ok('and the built-in names still do', decls('.a { shadow: sm; radius: lg }').d.join().includes('--dssl-shadow-sm')); | |
| ok('a name that is not a token is still an error, not a CSS keyword guess', decls('.a { shadow: nothing }').e.length === 1); | |
| r = decls('.a { paint-order: stroke fill; background: conic-gradient(from 0deg, red, blue) }'); | |
| ok('paint-order: stroke fill and conic-gradient(from 0deg, ...) are accepted', r.e.length === 0, JSON.stringify(r.e)); | |
| r = decls('.a { text-stroke: 6 color.ink }'); | |
| ok('text-stroke writes the outline and paint-order', r.e.length === 0 && r.d.some((x) => /^-webkit-text-stroke: 6px /.test(x)) && r.d.includes('paint-order: stroke fill'), JSON.stringify(r)); | |
| ok('text-stroke inside keeps a centred stroke; none clears it', !decls('.a { text-stroke: 2 #fff inside }').d.includes('paint-order: stroke fill') && decls('.a { text-stroke: none }').d.includes('-webkit-text-stroke: 0')); | |
| ok('text-stroke needs a width', decls('.a { text-stroke: color.ink }').e.length === 1); | |
| r = decls('.t { gradient: linear 180, #e33 -> #a11 + radial-gradient(circle, #fff4 20%, transparent 21%) tile 24 offset 4 4 no-repeat }'); | |
| ok('gradient: layers a sentence and a plain CSS gradient, with tile, offset and no-repeat for the second', r.e.length === 0 && r.d[0].startsWith('background-image: linear-gradient(180deg, #e33, #a11), radial-gradient(circle') && r.d.includes('background-size: auto, 24px 24px') && r.d.includes('background-position: 0 0, 4px 4px') && r.d.includes('background-repeat: repeat, no-repeat'), JSON.stringify(r)); | |
| ok('a layer with no tile leaves size alone', !decls('.t { gradient: linear 90, #f00 -> #00f }').d.some((x) => /background-size/.test(x))); | |
| const w = warns('.u { gradient: linear 90, #f00 -> #00f; background-image: radial-gradient(red, blue) }'); | |
| ok('gradient: and background-image: in one rule is a warning that says how to layer', w.length === 1 && /sets the background image twice/.test(w[0]), JSON.stringify(w)); | |
| ok('gradient: then a background shorthand with an image warns too', warns('.u { gradient: linear 90, #f00 -> #00f; background: url(x.png) }').some((m) => /twice/.test(m))); | |
| ok('a rule with just one of them does not', warns('.u { gradient: linear 90, #f00 -> #00f; background-color: #fff }').length === 0); | |
| } | |
| section('4. your inline styles win'); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/i'); await ready(p); await p.waitForTimeout(300); | |
| const get = (sel, prop) => p.evaluate(([s, pr]) => getComputedStyle(document.querySelector(s))[pr], [sel, prop]); | |
| ok('a style="" attribute beats a rule that sets the same property', await get('#attr', 'left') === '33px'); | |
| ok('and the rule still sets what the attribute did not say', await get('#attr', 'top') === '5px'); | |
| await p.evaluate(() => { document.querySelector('#js').style.left = '71px'; DSSL.refresh(); }); | |
| await p.waitForTimeout(150); | |
| ok('a script that moves an element after the engine styled it keeps winning, even through a refresh', await get('#js', 'left') === '71px'); | |
| ok('a margin-top written inline survives a rule\'s margin shorthand', await get('#short', 'marginTop') === '14px' && await get('#short', 'marginLeft') === '0px'); | |
| await p.evaluate(() => { document.querySelector('#js').style.left = ''; DSSL.refresh(); }); | |
| await p.waitForTimeout(150); | |
| ok('clearing it hands the property back to the rule', await get('#js', 'left') === '0px'); | |
| const logs = await p.evaluate(() => window.__logs.join('|')); | |
| ok('no "folded border-color into border" noise', !/folded/.test(logs), logs); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/i': page('div { position: absolute }\n#attr { left: 0; top: 5px }\n#js { left: 0; top: 40px }\n#short { margin: 0 }\n#brd { border: 1 solid #fff; border-color: color.ink }\n#brd.on { border-color: red }', '<div id=attr style="left:33px"></div><div id=js></div><div id=short style="margin-top:14px;position:static"></div><div id=brd></div>', '<script>window.__logs=[];for (const k of ["log","info","warn"]) { const o=console[k]; console[k]=(...a)=>{window.__logs.push(a.join(" ")); o.apply(console,a)} }; addEventListener("load",()=>{ document.getElementById("brd").classList.toggle("on") })</script>') } }); | |
| section('5. static subtrees'); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/s'); await ready(p); | |
| await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); window.__n = 0; const orig = e.touched.add.bind(e.touched); e.touched.add = (el) => { window.__n++; return orig(el); }; }); | |
| await p.evaluate(() => { document.getElementById('live').classList.add('hot'); }); | |
| await p.waitForTimeout(300); | |
| const liveN = await p.evaluate(() => window.__n); | |
| await p.evaluate(() => { window.__n = 0; }); | |
| await p.evaluate(() => { DSSL.freeze(document.getElementById('world')); document.getElementById('world').classList.add('hot'); }); | |
| await p.waitForTimeout(300); | |
| const staticN = await p.evaluate(() => window.__n); | |
| ok('toggling a class on a normal subtree restyles it (a lot of elements)', liveN > 200, String(liveN)); | |
| ok('on a frozen subtree it restyles almost nothing (the root and no more)', staticN < 20, String(staticN)); | |
| ok('and the frozen children keep the look they had', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)'); | |
| await p.evaluate(() => DSSL.refresh(document.getElementById('world'))); await p.waitForTimeout(150); | |
| ok('DSSL.refresh(el) restyles it on demand', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(0, 128, 0)'); | |
| await p.evaluate(() => { DSSL.unfreeze(document.getElementById('world')); document.getElementById('world').classList.remove('hot'); }); await p.waitForTimeout(250); | |
| ok('unfreeze(el) makes it live again', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)'); | |
| ok('DSSL.freeze() with no argument still parks the whole page', await p.evaluate(() => { const r = DSSL.freeze(); DSSL.unfreeze(); return r !== undefined; })); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/s': page('i { color: red }\n.hot i { color: green }', `<div id=world>${'<i>x</i>'.repeat(250)}</div><div id=live>${'<i>x</i>'.repeat(250)}</div>`) } }); | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/s2'); await ready(p); | |
| ok('data-dssl-static in the markup works from the first paint', await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); return e.staticSeen === true; })); | |
| }, { pages: { '/s2': page('i { color: red }', '<div data-dssl-static><i>x</i></div>') } }); | |
| section('6. aura: clipped to its stage, scaled with it, and placed in the stack'); | |
| { | |
| const AURA = '.stage { position: absolute; left: 100px; top: 100px; width: 200px; height: 200px; overflow: hidden; background: #000 }\n.orb { position: absolute; left: 70px; top: 70px; width: 60px; height: 60px; background: #111; aura-glow: #ff0000 60 essential intensity 1 }'; | |
| const redAt = (png, x, y) => px(png, x, y)[0] > 40 && px(png, x, y)[0] > px(png, x, y)[2] * 1.5; | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/a'); await ready(p); await p.waitForTimeout(900); | |
| const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } })); | |
| ok('the glow lights the area inside the stage', redAt(png, 100 + 70 - 10, 100 + 100), JSON.stringify(px(png, 160, 200))); | |
| ok('and is cut at the stage edge (overflow: hidden): nothing outside it', !redAt(png, 100 - 8, 200) && !redAt(png, 300 + 8, 200) && !redAt(png, 200, 100 - 8), JSON.stringify([px(png, 92, 200), px(png, 308, 200), px(png, 200, 92)])); | |
| }, { pages: { '/a': page(AURA, '<div class=stage><div class=orb></div></div>') }, viewport: { width: 420, height: 420 } }); | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/a'); await ready(p); await p.waitForTimeout(900); | |
| const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } })); | |
| ok('with `unclipped` the light spills over the edge, as asked', redAt(png, 100 - 6, 100 + 100) || redAt(png, 100 - 6, 100 + 90) || redAt(png, 100 - 4, 100 + 100), JSON.stringify([px(png, 94, 200), px(png, 96, 200)])); | |
| }, { pages: { '/a': page(AURA.replace('essential intensity 1', 'essential unclipped'), '<div class=stage><div class=orb style="left:0;top:70px"></div></div>') }, viewport: { width: 420, height: 420 } }); | |
| // scale: the same glow, on a stage drawn at twice the size, reaches about twice as far | |
| const reach = async (scale) => { | |
| let r = 0; | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/a'); await ready(p); await p.waitForTimeout(900); | |
| const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 800, height: 800 } })); | |
| const cx = 400, cy = 400; | |
| for (let d = 0; d < 380; d++) if (px(png, cx + d, cy)[0] > 12 && px(png, cx + d, cy)[0] > px(png, cx + d, cy)[2] * 1.3) r = d; | |
| }, { pages: { '/a': page(`.wrap { position: absolute; left: 400px; top: 400px; width: 0; height: 0; transform: scale(${scale}); transform-origin: 0 0 }\n.orb { position: absolute; left: -20px; top: -20px; width: 40px; height: 40px; background: #111; aura-glow: #ff0000 30 essential }`, '<div class=wrap><div class=orb></div></div>') }, viewport: { width: 800, height: 800 } }); | |
| return r; | |
| }; | |
| const r1 = await reach(1), r2 = await reach(2); | |
| ok('inside a transform: scale(2) stage the glow reaches about twice as far', r1 > 10 && r2 > r1 * 1.6 && r2 < r1 * 2.6, `scale 1 reaches ${r1}px, scale 2 reaches ${r2}px`); | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/z'); await ready(p); await p.waitForTimeout(500); | |
| ok('the canvas is above everything by default', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex) === '2147483000'); | |
| await p.evaluate(() => DSSL.auraZ(20)); | |
| ok('DSSL.auraZ(20) lowers it, so a menu with z-index above 20 covers the light', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex === '20' && document.documentElement.getAttribute('data-dssl-aura-z') === '20')); | |
| await p.evaluate(() => DSSL.auraZ(null)); | |
| ok('and null puts it back', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex) === '2147483000'); | |
| }, { pages: { '/z': page('.o { aura-glow: #f00 20 essential }', '<div class=o style="width:20px;height:20px"></div>') } }); | |
| ok('`unclipped` is a known aura word, with a description', compile('.a { aura-glow: #f80 20 unclipped; aura-particle: #fd0 5 up unclipped }', 't').errors.length === 0); | |
| } | |
| section('7. signals: in, not in, !='); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/g'); await ready(p); | |
| const color = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i)).color, id); | |
| const pre = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i), '::before').content, id); | |
| await p.evaluate(() => DSSL.signal('screen', 'menu')); await p.waitForTimeout(150); | |
| ok('in (...) matches a listed value', await color('a') === 'rgb(255, 0, 0)'); | |
| ok('not in (...) is false for a listed value', await color('b') === 'rgb(0, 0, 0)'); | |
| await p.evaluate(() => DSSL.signal('screen', 'playing')); await p.waitForTimeout(150); | |
| ok('in (...) stops matching for another value', await color('a') === 'rgb(0, 0, 0)'); | |
| ok('not in (...) matches it', await color('b') === 'rgb(0, 0, 255)'); | |
| await p.evaluate(() => DSSL.signal('screen', 'shop')); await p.waitForTimeout(150); | |
| ok('the second listed value matches too', await color('a') === 'rgb(255, 0, 0)'); | |
| ok('!= works on a pseudo-element rule (the shadow stylesheet gets the right selector)', await pre('c') === '"neq"'); | |
| await p.evaluate(() => DSSL.signal('screen', 'menu')); await p.waitForTimeout(150); | |
| ok('and stops when equal', (await pre('c')) === 'none' || (await pre('c')) === 'normal'); | |
| ok('an empty list is an error', errs('@signal screen in () { .a { color: red } }').length === 1); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/g': page('div { color: #000 }\n@signal screen in (menu, shop) { #a { color: #f00 } }\n@signal screen not in (menu, shop) { #b { color: #00f } }\n@signal screen != menu { #c::before { content: "neq" } }', '<div id=a>a</div><div id=b>b</div><div id=c>c</div>') } }); | |
| section('8. DSSL.lint(): what only a live page shows'); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/l'); await ready(p); await p.waitForTimeout(300); | |
| const res = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).map((f) => ({ kind: f.kind, sel: f.selector, ratio: f.ratio }))); | |
| const contrast = res.filter((f) => f.kind === 'contrast'); | |
| ok('cream text on a cream card (two rules, no warning from check) is found', contrast.some((f) => /\.card/.test(f.sel) || /\bp\b/.test(f.sel)) && contrast.every((f) => f.ratio < 4.5), JSON.stringify(res)); | |
| ok('text that is readable is not reported', !contrast.some((f) => /\.fine/.test(f.sel)), JSON.stringify(contrast)); | |
| ok('translucent backgrounds are composited: dark text on a 10% white wash over a dark page fails, light passes', contrast.some((f) => /\.wash-bad/.test(f.sel)) && !contrast.some((f) => /\.wash-ok/.test(f.sel)), JSON.stringify(contrast)); | |
| ok('text over an image or gradient is not guessed at', !contrast.some((f) => /\.pic/.test(f.sel))); | |
| ok('large text gets the lower limit (3:1)', !contrast.some((f) => /\.big/.test(f.sel)), JSON.stringify(contrast)); | |
| ok('an element the engine made into a grid is reported', res.some((f) => f.kind === 'promoted' && /\.promo/.test(f.sel)), JSON.stringify(res)); | |
| ok('a flex box that says display is not', !res.some((f) => f.kind === 'promoted' && /\.flexy/.test(f.sel))); | |
| const quiet = await p.evaluate(async () => { const logs = []; const o = console.groupCollapsed; console.groupCollapsed = (...a) => logs.push(a.join(' ')); await DSSL.lint(document.body, { log: false }); console.groupCollapsed = o; return logs.length; }); | |
| ok('{ log: false } stays quiet', quiet === 0); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/l': page('body { background: #14102a; color: #eee }\n.card { background: #f5efe0; padding: 10 }\n.fine { color: #222 }\n.wash-bad { background: #ffffff1a; color: #333 }\n.wash-ok { background: #ffffff1a; color: #fff }\n.pic { background-image: linear-gradient(red, blue); color: #801 }\n.big { color: #888; font-size: 30; background: #fff }\n.promo { place-items: center }\n.flexy { display: flex; place-items: center }', '<div class=card style="color:#f0e6d0"><p>cream on cream</p></div><div class=card><p class=fine>fine</p></div><div class=wash-bad>bad</div><div class=wash-ok>ok</div><div class=pic>pic</div><div class=big>BIG</div><div class=promo><span>p</span></div><div class=flexy>f</div>') } }); | |
| section('9. dssl check and build read the page'); | |
| { | |
| const dir = mkdtempSync(path.join(tmpdir(), 'dssl-page-')); | |
| mkdirSync(path.join(dir, 'css')); | |
| writeFileSync(path.join(dir, 'css/a.dssl'), 'tokens { color { c: #123456 } }\nmotion pop { from { opacity: 0 } to { opacity: 1 } }\n'); | |
| writeFileSync(path.join(dir, 'css/b.dssl'), '.x { color: color.c; animate: pop 300 }\n'); | |
| writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/a.dssl"><link rel="stylesheet/dssl" href="/css/b.dssl"><style type="text/dssl">.y { opacity: .5 }</style><link rel="stylesheet" href="x.css">'); | |
| const run = (...a) => spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), ...a], { encoding: 'utf8' }); | |
| let r = run('check', path.join(dir, 'index.html')); | |
| ok('check index.html finds three stylesheets in page order and they pass (a token and a motion from the first file, used in the second)', r.status === 0, r.stderr + r.stdout); | |
| writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/b.dssl"><link rel="stylesheet/dssl" href="css/a.dssl">'); | |
| r = run('check', path.join(dir, 'index.html')); | |
| ok('in the WRONG order it fails, as the browser would (the page order is what counts)', r.status === 1 && /unknown/.test(r.stderr), r.stderr); | |
| writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/a.dssl"><link rel="stylesheet/dssl" href="css/b.dssl"><style type="text/dssl">.y { opacity: .5 }</style>'); | |
| r = run('build', path.join(dir, 'index.html'), '-o', path.join(dir, 'out.css')); | |
| ok('build index.html compiles them together', r.status === 0 && /--dssl-color-c: #123456/.test(readFileSync(path.join(dir, 'out.css'), 'utf8')) && /@keyframes pop/.test(readFileSync(path.join(dir, 'out.css'), 'utf8')), r.stderr); | |
| writeFileSync(path.join(dir, 'empty.html'), '<!doctype html><p>none</p>'); | |
| r = run('check', path.join(dir, 'empty.html')); | |
| ok('a page with no DSSL says so (exit 2)', r.status === 2 && /no <link rel="stylesheet\/dssl">/.test(r.stderr), r.stderr); | |
| writeFileSync(path.join(dir, 'missing.html'), '<link rel="stylesheet/dssl" href="nope.dssl">'); | |
| r = run('check', path.join(dir, 'missing.html')); | |
| ok('a link to a file that is not there is reported with where it looked', r.status === 2 && /not on disk/.test(r.stderr), r.stderr); | |
| } | |
| section('10. the docs match the code'); | |
| { | |
| const read = (f) => readFileSync(path.join(ROOT, f), 'utf8'); | |
| const V = API.VERSION; | |
| const guide = read('GUIDE.md'), readme = read('README.md'), llms = read('llms.txt'), usage = read('engine-package/USAGE.md'); | |
| ok('llms.txt names the engine version', new RegExp(`Version: ${V.replace(/\./g, '\\.')} \\(engine\\)`).test(llms), llms.match(/Version: [\d.]+/)?.[0]); | |
| ok('the guide\'s API block shows the real version, and calls it a string', guide.includes(`DSSL.version // "${V}"`), guide.match(/DSSL\.version.*/)?.[0]); | |
| ok('the README\'s API block does too', readme.includes(`DSSL.version // "${V}"`)); | |
| ok('README sections say the current version, not an old one', readme.includes(`What works today (v${V})`) && readme.includes(`v${V}, experimental`)); | |
| ok('llms.txt and public/llms.txt are the same file', llms === read('public/llms.txt')); | |
| const keys = Object.keys(API.DSSL).filter((k) => !k.startsWith('__')); | |
| for (const [label, text] of [['the guide', guide], ['the README', readme], ['USAGE.md', usage], ['llms.txt', llms]]) { | |
| const missing = keys.filter((k) => !new RegExp(`DSSL\\.${k}\\b|\\b${k}\\(`).test(text)); | |
| ok(`${label} documents every function on DSSL (${keys.length})`, missing.length === 0, 'missing: ' + missing.join(', ')); | |
| } | |
| ok('DSSL.errors is documented as a function and DSSL.version as a string', /DSSL\.errors\(\)/.test(usage) && /DSSL\.version`? .*string|a string/.test(usage)); | |
| ok('the guide does not call version() a function', !/DSSL\.version\(\)/.test(guide)); | |
| const files = readdirSync(path.join(ROOT, 'public/dssl')).filter((f) => f.endsWith('.js')); | |
| const table = guide.split('\n').find((l) => l.startsWith('| `public/dssl/`')) || ''; | |
| ok(`the guide's engine file table names all ${files.length} modules, so it cannot say "six" again`, files.every((f) => table.includes('`' + f + '`')), files.filter((f) => !table.includes('`' + f + '`')).join(', ')); | |
| ok('the docs site does not say the engine is two files', !/engine is two static files/.test(read('src/components/dssl/views/DocsView.tsx'))); | |
| } | |
| console.log(`\n${pass} passed, ${fail} failed`); | |
| process.exit(fail ? 1 : 0); | |