#!/usr/bin/env node /* test-v0160.mjs: the third round of feedback from the game builder (0.16.0): plain CSS next to DSSL, valid CSS that was refused, * a token error that pointed nowhere, an aura that lit the box instead of the shape. Plus the new aura types and textures. * Real Chrome decides anything that is drawn. */ import { spawnSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { compile } from '../public/dssl/index.js'; import { parseAura, aroundPoints, AROUND_NAMES, SHAPES } from '../public/dssl/aura.js'; import { PRESET_NAMES, PRESETS, GENERATORS, buildTexture, parseTexture } from '../public/dssl/texture.js'; import { parseColor } from '../public/dssl/color.js'; import { withBrowser, ROOT } from './lib/browser-harness.mjs'; import { decodePng, px } 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 = '', css = '') => `
${head}${css ? `` : ''}${body}`; const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`); const errs = (src) => compile(src, 't.dssl').errors; const rules = (src) => compile(src, 't.dssl').doc.rules; const decl = (src, prop) => { for (const r of rules(src)) for (const d of r.decls) if (d.prop === prop) return d.value; return undefined; }; const lit = (c) => c[0] > 40 && c[0] > c[2] * 1.5; // red-ish light on black section('1. a token line with two tokens, and an error that says where'); { const r = compile('tokens { radius { pill: 999 card: 22 } }\n.a { radius: card }', 't.dssl'); ok('radius { pill: 999 card: 22 } on one line is two tokens', r.errors.length === 0 && decl('tokens { radius { pill: 999 card: 22 } }', '--dssl-radius-pill') === '999px' && decl('tokens { radius { pill: 999 card: 22 } }', '--dssl-radius-card') === '22px', JSON.stringify(r.errors)); ok('and the second one can be used', r.doc.rules.some((x) => x.decls.some((d) => d.prop === 'border-radius' && d.value === 'var(--dssl-radius-card)'))); ok('semicolons still work', errs('tokens { radius { a: 1; b: 2 } }').length === 0 && decl('tokens { radius { a: 1; b: 2 } }', '--dssl-radius-b') === '2px'); ok('several lines, several colours on one line', errs('tokens { color { a: #111 b: oklch(60% 0.2 30) c: #333 } }').length === 0 && decl('tokens { color { a: #111 b: oklch(60% 0.2 30) c: #333 } }', '--dssl-color-c') === '#333'); ok('a font stack with commas and quotes stays one token', decl('tokens { font { body: "Fraunces", Georgia, serif } }', '--dssl-font-body') === '"Fraunces", Georgia, serif'); const bad = compile('tokens {\n color {\n x: nonsense\n y: #fff\n }\n}\n', 't.dssl').errors; ok('a bad token points at its own line (it said line 0), once', bad.length === 1 && bad[0].line === 3 && /nonsense/.test(bad[0].message), JSON.stringify(bad)); const two = compile('tokens { color { ok: #111 bad: nonsense } }', 't.dssl').errors; ok('on one line it names the right token', two.length === 1 && /color\.bad/.test(two[0].message), JSON.stringify(two)); } section('2. valid CSS is not refused'); { const good = [ '.a { padding-top: env(safe-area-inset-top) }', '.a { padding: env(safe-area-inset-top, 20) env(safe-area-inset-right, 0) max(env(safe-area-inset-bottom), 12) 0 }', '.a { font-variant-numeric: tabular-nums }', '.a { font-variant-numeric: lining-nums slashed-zero; font-variant-caps: all-small-caps; font-variant-ligatures: no-common-ligatures }', '.a { touch-action: manipulation; overscroll-behavior: contain; scroll-snap-type: x mandatory; scroll-snap-align: start }', '.a { contain: layout paint; content-visibility: auto; text-decoration-thickness: from-font; writing-mode: vertical-rl }', '.a { text-wrap: balance; hyphens: auto; user-select: none; accent-color: auto; mix-blend-mode: multiply; image-rendering: pixelated }', '.a { direction: rtl; caption-side: bottom; max-block-size: 10; text-size-adjust: 100%; widows: 2; orphans: 3 }', '.a { -webkit-user-select: none; -webkit-touch-callout: none; -webkit-overflow-scrolling: touch; -webkit-appearance: none; -moz-appearance: none }', '.a { background: rebeccapurple; color: darkslateblue; border-color: lightgoldenrodyellow }', '.a { font-feature-settings: "tnum" 1, "liga" 0; font-variation-settings: "wght" 650; tab-size: 4; -webkit-line-clamp: 3 }', ]; for (const src of good) ok(`accepted: ${src.slice(0, 70)}`, errs(src).length === 0, JSON.stringify(errs(src).map((e) => e.message))); ok('env() keeps the name and gives the fallback a unit', decl('.a { padding-top: env(safe-area-inset-top, 20) }', 'padding-top') === 'env(safe-area-inset-top, 20px)'); ok('a tabular figure feature number is not turned into px', decl('.a { font-feature-settings: "tnum" 1 }', 'font-feature-settings') === '"tnum" 1'); ok('a typo is still loud, with did-you-mean', errs('.a { text-align: cetner }').some((e) => /cetner/.test(e.message) && /center/.test(e.hint || ''))); ok('a property typo is still loud', errs('.a { colour: red }').length === 1 && errs('.a { max-heigth: 1 }').length === 1); ok('so is a made-up value for a real property', errs('.a { display: flexx }').length === 1); } section('3. yield: plain CSS wins where you say so'); await withBrowser(async ({ open, errors }) => { const p = await open('/y'); await ready(p); await p.waitForTimeout(250); const cs = (id, prop) => p.evaluate(([i, pr]) => getComputedStyle(document.getElementById(i))[pr], [id, prop]); ok('without yield, DSSL\'s inline padding beats the stylesheet (the friction)', await cs('plain', 'paddingTop') === '10px'); ok('with yield: padding-top the stylesheet decides, and no !important is needed', await cs('one', 'paddingTop') === '33px'); ok('the other sides are still DSSL\'s (the shorthand was split, not dropped)', await cs('one', 'paddingRight') === '12px' && await cs('one', 'paddingBottom') === '14px' && await cs('one', 'paddingLeft') === '16px'); ok('yield: padding hands over the whole family', await cs('fam', 'paddingTop') === '4px' && await cs('fam', 'paddingLeft') === '5px'); ok('env() works in plain CSS beside DSSL (a desktop has no notch, so it is 0px, and not the 7px DSSL would have written)', await cs('env', 'paddingBottom') === '0px'); ok('what is not yielded is still DSSL\'s', await cs('one', 'marginTop') === '9px'); ok('yield: none gives it back to DSSL', await cs('none', 'paddingTop') === '10px'); ok('a longhand a rule also yields is dropped from the cascade, whichever rule wrote it', await cs('late', 'paddingTop') === '33px' && await cs('late', 'color') === 'rgb(0, 0, 255)'); await p.evaluate(() => document.getElementById('late').classList.add('off')); await p.waitForTimeout(150); ok('and when the yielding rule stops matching DSSL takes the property back', await cs('late', 'paddingTop') === '10px'); const found = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').map((f) => f.selector + ' | ' + f.message)); ok('DSSL.lint names the CSS rule that loses, and the way out', found.some((m) => /^div#plain/.test(m) && /padding-top/.test(m) && /yield: padding-top/.test(m)), JSON.stringify(found)); ok('and says nothing about the ones that yield', !found.some((m) => /#one|#fam|#env/.test(m)), JSON.stringify(found)); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/y': page( '#plain { padding: 10 }\n#one { padding: 10 12 14 16; margin-top: 9; yield: padding-top }\n#fam { padding: 3; yield: padding }\n#env { padding-bottom: 7; yield: padding-bottom }\n#none { padding: 10; yield: padding-top; yield: none }\n#late { padding: 10; color: #00f }\n#late:not(.off) { yield: padding-top }', '', '', '#plain { padding-top: 40px } #one { padding-top: 33px } #fam { padding-top: 4px; padding-left: 5px } #env { padding-bottom: env(safe-area-inset-bottom, 21px) } #none { padding-top: 50px } #late { padding-top: 33px }') } }); { ok('yield needs properties', errs('.a { yield: }').length === 1); ok('yield checks the names (did-you-mean)', errs('.a { yield: pading-top }').some((e) => /pading-top/.test(e.message) && /padding-top/.test(e.hint || ''))); const dir = mkdtempSync(path.join(tmpdir(), 'dssl-yield-')); writeFileSync(path.join(dir, 'a.dssl'), '.app { padding: 10; yield: padding-top }\n'); const r = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), '-o', path.join(dir, 'a.css')], { encoding: 'utf8' }); const css = readFileSync(path.join(dir, 'a.css'), 'utf8'); ok('dssl build writes the padding and does not count or print yield (a stylesheet has nothing to hand back)', r.status === 0 && /padding: 10px/.test(css) && !/yield/.test(css) && !/engine-native/.test(r.stdout), r.stdout + r.stderr); } { // a page that ships `dssl build` output AND runs the engine says every rule twice: that is not a conflict const built = (await import('../public/dssl/index.js')).toCSS('.dup { padding: 10; color: #123456 }', { file: 'a.dssl' }).css; await withBrowser(async ({ open }) => { const p = await open('/b'); await ready(p); await p.waitForTimeout(250); const n = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').length); ok('compiled CSS and the engine saying the same thing is not reported as css-loses', n === 0, String(n)); }, { pages: { '/b': page('.dup { padding: 10; color: #123456 }', '', '', built) } }); // and the built reset sits under everything on purpose: it must not be called a conflict either const builtReset = (await import('../public/dssl/index.js')).toCSS('.dup { padding: 10; color: #123456 }', { file: 'a.dssl', reset: true }).css; await withBrowser(async ({ open }) => { const p = await open('/b2'); await ready(p); await p.waitForTimeout(250); const n = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').length); ok('the reset layer of a built stylesheet is not called a conflict', n === 0, String(n)); }, { pages: { '/b2': page('p { margin: 7 }\n.dup { padding: 10; color: #123456 }', 'x
a', '', builtReset) } }); } section('4. aura follows the real shape'); { const around = (w) => { const out = parseAura('aura-glow', `#f00 30 around ${w}`, { error() { }, color: (c) => c, suggest: () => null }); return out && out.glow && out.glow[0] && out.glow[0].around; }; ok('every named shape parses', AROUND_NAMES.every((n) => around(n) && around(n).name === n)); ok('polygon(...) parses to fractions', JSON.stringify(around('polygon(50% 0, 100% 100%, 0 100%)')) === '{"name":"polygon","pts":[[0.5,0],[1,1],[0,1]]}'); ok('the named polygons really are polygons', ['diamond', 'triangle', 'hexagon', 'octagon', 'pentagon', 'star', 'star4', 'star6', 'cross', 'heart', 'drop', 'bolt'].every((n) => (aroundPoints({ name: n }) || []).length >= 3)); const e = []; parseAura('aura-glow', '#f00 30 around blob', { error: (m, h) => e.push(m + ' | ' + h), color: (c) => c, suggest: () => 'box' }); ok('an unknown shape is an error that lists the choices', e.length === 1 && /unknown around "blob"/.test(e[0])); ok('a polygon with two points is an error', errs('.a { aura-glow: #f00 30 around polygon(0 0, 100% 100%) }').length === 1); ok('a polygon in px is an error that says percent', errs('.a { aura-glow: #f00 30 around polygon(0px 0px, 10px 10px, 0px 10px) }').some((x) => /percent/.test(x.message))); const around2 = '.o { position: absolute; left: 150px; top: 150px; width: 100px; height: 100px; background: #222; aura-glow: #f00 40 essential intensity 1.6 }'; const shot = async (dssl, clip = { x: 0, y: 0, width: 420, height: 420 }) => { let png; await withBrowser(async ({ open }) => { const p = await open('/a'); await ready(p); await p.waitForTimeout(1100); png = decodePng(await p.screenshot({ clip })); }, { pages: { '/a': page(dssl, '') }, viewport: { width: 420, height: 420 } }); return png; }; // 4a. clip-path diamond const dia = await shot(`${around2}\n.o { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%) }`); const boxed = await shot(`${around2}\n.o { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); aura-glow: #f00 40 essential intensity 1.6 around box }`); ok('a clip-path diamond: light just above its top tip', lit(px(dia, 200, 142))); ok('a clip-path diamond: nothing out at the box corner (it used to be a square halo)', !lit(px(dia, 140, 140)) && !lit(px(dia, 260, 140)) && !lit(px(dia, 140, 260)), JSON.stringify([px(dia, 140, 140), px(dia, 260, 260)])); ok('`around box` still lights the box on purpose', lit(px(boxed, 146, 146)), JSON.stringify(px(boxed, 146, 146))); // 4b. a square turned 45 degrees const rot = await shot('.o { position: absolute; left: 165px; top: 165px; width: 70px; height: 70px; background: #222; rotate: 45deg; aura-glow: #f00 40 essential intensity 1.6 }'); ok('a square turned 45deg is lit around the diamond: above its top tip', lit(px(rot, 200, 200 - 49 - 8)), JSON.stringify(px(rot, 200, 143))); ok('and not at the corner of the box around it', !lit(px(rot, 200 - 56, 200 - 56)) && !lit(px(rot, 200 + 56, 200 + 56)), JSON.stringify([px(rot, 144, 144), px(rot, 256, 256)])); // 4c. round const rnd = await shot(`${around2}\n.o { border-radius: 50% }`); ok('border-radius: 50% follows the circle (it used to read 50 as 50px)', lit(px(rnd, 200, 142)) && !lit(px(rnd, 144, 144)), JSON.stringify([px(rnd, 200, 142), px(rnd, 144, 144)])); // 4d. named star around a plain square const star = await shot(`${around2}\n.o { aura-glow: #f00 30 essential intensity 2.6 around star; background: transparent }`); ok('around star on a plain box: light follows the star, so the corner between two points is dark', lit(px(star, 200, 150 - 3)) && !lit(px(star, 150 - 6, 150 - 6)), JSON.stringify([px(star, 200, 147), px(star, 144, 144)])); // 4e. inner glow const inner = await shot(`${around2}\n.o { border-radius: 20px; aura-glow: #f00 30 essential inner intensity 2.4 }`); ok('inner: light just inside the edge', lit(px(inner, 154, 200)) || lit(px(inner, 200, 154)), JSON.stringify([px(inner, 154, 200), px(inner, 200, 154)])); ok('inner: nothing outside, and the middle is dark', !lit(px(inner, 140, 200)) && px(inner, 200, 200)[0] < 70, JSON.stringify([px(inner, 140, 200), px(inner, 200, 200)])); // 4f. rays const rays = await shot('.o { position: absolute; left: 180px; top: 180px; width: 40px; height: 40px; background: #222; aura-glow: #f00 70 essential rays 8 spin 0 nohalo intensity 2 }'); ok('rays: lit along a ray, dark between two', lit(px(rays, 200 + 50, 200)) && lit(px(rays, 200, 200 - 50)) && !lit(px(rays, 200 + Math.round(50 * Math.cos(Math.PI / 8)), 200 + Math.round(50 * Math.sin(Math.PI / 8)))), JSON.stringify([px(rays, 250, 200), px(rays, 246, 219)])); ok('rays: not over the element itself', px(rays, 200, 200)[0] < 70, JSON.stringify(px(rays, 200, 200))); // 4g. ripple const rip = await shot('.o { position: absolute; left: 160px; top: 160px; width: 80px; height: 80px; background: #222; border-radius: 50%; aura-glow: #f00 60 essential ripple 4000 around round nohalo intensity 2 }'); let ringPx = 0; for (let x = 245; x < 300; x++) if (lit(px(rip, x, 200))) ringPx++; ok('ripple: a ring outside the shape', ringPx >= 1, String(ringPx)); // 4h. alpha of an image let alpha; await withBrowser(async ({ open }) => { const p = await open('/i'); await ready(p); await p.evaluate(() => { const c = document.createElement('canvas'); c.width = c.height = 100; const x = c.getContext('2d'); x.fillStyle = '#336'; x.beginPath(); x.arc(50, 50, 38, 0, Math.PI * 2); x.fill(); const im = document.getElementById('im'); im.src = c.toDataURL(); return im.decode(); }); await p.evaluate(() => DSSL.refresh()); await p.waitForTimeout(1100); alpha = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } })); }, { pages: { '/i': page('#im { position: absolute; left: 150px; top: 150px; width: 100px; height: 100px; aura-glow: #f00 25 essential around alpha intensity 2 }', '