EHS-Studio Claude Sonnet 5.5 commited on
Commit
39309b1
·
1 Parent(s): 9ec7404

v0.9.0: texture (30 presets), aura glow and particles, range sliders, lint, CSS import

Browse files

Engine-native @texture , @aura-glow, @aura-particles and @slider declarations,
a contrast/focus/tap-target linter, 'dssl import' for CSS, and the packaged
engine rebuilt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

scripts/lib/browser-harness.mjs ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* browser-harness.mjs: the one place the browser tests get a page from.
2
+ *
3
+ * Real behaviour (canvas pixels, SVG-filter textures, ::-webkit-slider-thumb,
4
+ * rAF timing) cannot be checked in Node, so these tests drive a real Chrome.
5
+ * It uses playwright-core, which ships no browser, and finds one that is
6
+ * already installed. Point DSSL_CHROME at a binary if yours is somewhere odd.
7
+ *
8
+ * import { withBrowser } from './lib/browser-harness.mjs';
9
+ * await withBrowser(async ({ open }) => { const page = await open('/x.html'); ... });
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { extname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { chromium } from 'playwright-core';
16
+
17
+ export const ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url)));
18
+
19
+ const MIME = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.html': 'text/html', '.css': 'text/css', '.json': 'application/json', '.dssl': 'text/plain', '.svg': 'image/svg+xml' };
20
+
21
+ const CANDIDATES = [
22
+ process.env.DSSL_CHROME,
23
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
24
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
25
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
26
+ ].filter(Boolean);
27
+
28
+ export function findChrome() {
29
+ return CANDIDATES.find((p) => existsSync(p)) || null;
30
+ }
31
+
32
+ /** static server: /dssl/* is public/dssl/*, /styles/* is public/styles/*, /t/* is a scratch dir of test pages */
33
+ export function startServer(pages = {}) {
34
+ const server = createServer((req, res) => {
35
+ const url = req.url.split('?')[0];
36
+ if (url === '/favicon.ico') { res.writeHead(204); return res.end(); }
37
+ if (pages[url]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[url]); }
38
+ let file;
39
+ if (url.startsWith('/dssl/')) file = join(ROOT, 'public', url);
40
+ else if (url.startsWith('/styles/')) file = join(ROOT, 'public', url);
41
+ else file = join(ROOT, url);
42
+ try {
43
+ const body = readFileSync(file);
44
+ res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'access-control-allow-origin': '*' });
45
+ res.end(body);
46
+ } catch { res.writeHead(404); res.end('not found'); }
47
+ });
48
+ return new Promise((ok) => server.listen(0, '127.0.0.1', () => ok({ server, port: server.address().port })));
49
+ }
50
+
51
+ export async function withBrowser(fn, { pages = {}, viewport = { width: 1000, height: 700 }, reducedMotion = 'no-preference', colorScheme = 'light', deviceScaleFactor = 1 } = {}) {
52
+ const exe = findChrome();
53
+ if (!exe) throw new Error('no Chrome found; set DSSL_CHROME to a Chrome or Chromium binary');
54
+ const { server, port } = await startServer(pages);
55
+ const browser = await chromium.launch({ executablePath: exe, headless: true, args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars'] });
56
+ const context = await browser.newContext({ viewport, reducedMotion, colorScheme, deviceScaleFactor });
57
+ const base = `http://127.0.0.1:${port}`;
58
+ const errors = [];
59
+ try {
60
+ const open = async (path, { wait = 'load' } = {}) => {
61
+ const page = await context.newPage();
62
+ page.on('pageerror', (e) => errors.push(String(e)));
63
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
64
+ await page.goto(base + path, { waitUntil: wait });
65
+ return page;
66
+ };
67
+ return await fn({ open, base, errors, context, browser });
68
+ } finally {
69
+ await browser.close();
70
+ server.close();
71
+ }
72
+ }
scripts/lib/pixels.mjs ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* pixels.mjs: look at what a browser actually drew.
2
+ * const img = decodePng(await page.screenshot({ clip }))
3
+ * px(img, 10, 20) -> [r, g, b, a]
4
+ * stats(img, x, y, w, h) -> { mean:[r,g,b], variance, lit, n } (lit = pixels brighter than the floor)
5
+ */
6
+ import { PNG } from 'pngjs';
7
+
8
+ export const decodePng = (buf) => PNG.sync.read(buf);
9
+
10
+ export const px = (img, x, y) => {
11
+ const i = (Math.round(y) * img.width + Math.round(x)) * 4;
12
+ return [img.data[i], img.data[i + 1], img.data[i + 2], img.data[i + 3]];
13
+ };
14
+
15
+ /** colour distance (0..441) */
16
+ export const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
17
+
18
+ export function stats(img, x0, y0, w, h, floor = 40) {
19
+ let n = 0, lit = 0, sr = 0, sg = 0, sb = 0, ssq = 0;
20
+ for (let y = y0; y < y0 + h && y < img.height; y++) for (let x = x0; x < x0 + w && x < img.width; x++) {
21
+ const [r, g, b] = px(img, x, y);
22
+ n++; sr += r; sg += g; sb += b;
23
+ const l = (r + g + b) / 3;
24
+ ssq += l * l;
25
+ if (l > floor) lit++;
26
+ }
27
+ const mr = sr / n, mg = sg / n, mb = sb / n, ml = (mr + mg + mb) / 3;
28
+ return { mean: [mr, mg, mb].map((v) => Math.round(v)), variance: ssq / n - ml * ml, lit, n };
29
+ }
scripts/test-all.mjs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-all.mjs: run every suite and say plainly what passed.
3
+ * node scripts/test-all.mjs everything (needs Chrome for the browser suites)
4
+ * node scripts/test-all.mjs --no-browser the fast ones only
5
+ * Exit code is 0 only if every suite passed. */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { join, dirname } from 'node:path';
9
+ import { findChrome } from './lib/browser-harness.mjs';
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const skipBrowser = process.argv.includes('--no-browser');
13
+ const SUITES = [
14
+ ['engine 0.5', 'test-v050.mjs'], ['engine 0.6', 'test-v060.mjs'], ['engine 0.7', 'test-v070.mjs'], ['parser lines 0.7.1', 'test-v071.mjs'],
15
+ ['review fixes 0.7.2', 'test-v072.mjs'], ['exit lane 0.8.0', 'test-v073.mjs'], ['follow-up 0.8.1', 'test-v081.mjs'], ['cascade 0.8.2', 'test-v082.mjs'],
16
+ ['color, texture, aura, slider, lint, import, share 0.9.0', 'test-v090.mjs'], ['editor extension', 'test-extension-lib.mjs'],
17
+ ['site stylesheets compile', 'dssl-styles-check.mjs'], ['guide examples compile', 'test-guide-examples.mjs'],
18
+ ['designer: state to DSSL and back', 'test-designer.mjs'], ['designer: editor wiring (fake vscode)', 'test-designer-host.mjs'],
19
+ ['guide examples in a browser', 'test-guide-browser.mjs', true], ['0.9.0 in a browser', 'test-browser-v090.mjs', true], ['designer panel in a browser', 'test-designer-browser.mjs', true],
20
+ ];
21
+
22
+ if (!skipBrowser && !findChrome()) {
23
+ console.error('No Chrome or Chromium found for the browser suites. Install one, set DSSL_CHROME, or run with --no-browser.');
24
+ process.exit(2);
25
+ }
26
+ const rows = [];
27
+ for (const [name, file, browser] of SUITES) {
28
+ if (browser && skipBrowser) { rows.push([name, 'skipped', '']); continue; }
29
+ const t0 = Date.now();
30
+ const r = spawnSync(process.execPath, ['--no-warnings', join(here, file)], { encoding: 'utf8', cwd: join(here, '..') });
31
+ const out = (r.stdout || '') + (r.stderr || '');
32
+ const all = [...out.matchAll(/(\d+) passed, (\d+) failed/g)];
33
+ const m = all.length ? all[all.length - 1] : /(\d+) blocks checked/.exec(out);
34
+ const passed = r.status === 0;
35
+ rows.push([name, passed ? 'ok' : 'FAILED', `${m ? m[0] : (/CHECK OK/.test(out) ? 'all compile' : '')} ${((Date.now() - t0) / 1000).toFixed(1)}s`]);
36
+ if (!passed) console.log(`\n---- ${file} ----\n${out.split('\n').filter((l) => /FAIL|rror/.test(l)).slice(0, 12).join('\n')}\n`);
37
+ }
38
+ const w = Math.max(...rows.map((r) => r[0].length));
39
+ console.log('');
40
+ for (const [n, s, d] of rows) console.log(`${s === 'ok' ? ' ok ' : s === 'skipped' ? ' skipped' : ' FAILED '} ${n.padEnd(w)} ${d}`);
41
+ const bad = rows.filter((r) => r[1] === 'FAILED').length;
42
+ console.log(`\n${bad ? bad + ' suite(s) failed' : 'everything passed'}`);
43
+ process.exit(bad ? 1 : 0);
scripts/test-browser-v090.mjs ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-browser-v090.mjs: the 0.9.0 features in a real browser.
3
+ *
4
+ * Aura draws on a canvas, textures are SVG filters, and a slider is vendor
5
+ * pseudo-elements, so none of it can be checked in Node. This drives headless
6
+ * Chrome and looks at what was actually drawn. It is written to break things:
7
+ * removal, hidden elements, off-screen elements, freeze, reduced motion, state
8
+ * changes, token changes, resize, device pixel ratio, and a stress load.
9
+ *
10
+ * node scripts/test-browser-v090.mjs
11
+ */
12
+ import { withBrowser } from './lib/browser-harness.mjs';
13
+ import { decodePng, px, dist, stats } from './lib/pixels.mjs';
14
+ import { galleryPage } from './texture-gallery.mjs';
15
+ import { PRESET_NAMES } from '../public/dssl/texture.js';
16
+
17
+ let pass = 0, fail = 0;
18
+ const ok = (name, cond, extra) => {
19
+ if (cond) { pass++; console.log(` ok ${name}`); }
20
+ else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); }
21
+ };
22
+ const section = (t) => console.log(`\n== ${t}`);
23
+
24
+ /** a page with the engine, a dark body and the given DSSL + markup */
25
+ const page = (dssl, body, bg = '#000') => `<!doctype html><meta charset=utf-8>
26
+ <body style="margin:0;background:${bg};height:4000px;overflow-x:hidden">
27
+ <style type="text/dssl">${dssl}</style>${body}
28
+ <script type="module" src="/dssl/boot.js"></script>`;
29
+
30
+ const engine = `DSSL.__debug.engines.get('global')`;
31
+ const dbg = (p) => p.evaluate(`(() => { const e = ${engine}; return e && e.aura ? e.aura.debug() : null; })()`);
32
+ const ready = (p) => p.waitForFunction(`window.DSSL && ${engine}`);
33
+ const shot = async (p, clip) => decodePng(await p.screenshot({ clip }));
34
+ const alive = async (p) => { const d = await dbg(p); return d ? d.stats.particles : 0; };
35
+
36
+ /* ------------------------------------------------------------------ *
37
+ * AURA
38
+ * ------------------------------------------------------------------ */
39
+ section('aura: glow');
40
+ await withBrowser(async ({ open, errors }) => {
41
+ const p = await open('/glow');
42
+ await ready(p); await p.waitForTimeout(500);
43
+ const img = await shot(p, { x: 150, y: 100, width: 420, height: 320 });
44
+ // element is at 300..420 x 200..320 on the page, so in the clip at (150..270, 100..220)
45
+ const inside = px(img, 210, 160);
46
+ ok('the glow never paints over the element itself', dist(inside, [32, 32, 48]) < 6, `inside pixel ${inside}`);
47
+ const near = px(img, 150 - 12, 160);
48
+ ok('the glow lights the area right outside the element', near[0] > 40 && near[0] > near[2] * 1.5, `pixel ${near}`);
49
+ const far = px(img, 5, 5);
50
+ ok('and fades to nothing far away', dist(far, [0, 0, 0]) < 8, `pixel ${far}`);
51
+ const above = px(img, 210, 100 - 12);
52
+ 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);
53
+ ok('no page errors', errors.length === 0, errors.join(' | '));
54
+ }, { 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>') } });
55
+
56
+ section('aura: particles');
57
+ await withBrowser(async ({ open, errors }) => {
58
+ const p = await open('/p');
59
+ await ready(p); await p.waitForTimeout(1600);
60
+ const d = await dbg(p);
61
+ const n = d.stats.particles;
62
+ ok('particle count settles at about the configured count (40)', n >= 28 && n <= 40, `alive ${n}`);
63
+ const s = d.emitters[0].layers[0].sample;
64
+ 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]));
65
+ const y1 = s[0].y; await p.waitForTimeout(150);
66
+ const s2 = (await dbg(p)).emitters[0].layers[0].sample;
67
+ ok('particles actually move upward over time', s2.some((q) => q.y < y1 - 3), `${y1} -> ${s2.map((q) => q.y.toFixed(0))}`);
68
+ ok('the loop is running at a healthy frame time', d.stats.frameMs < 10, `${d.stats.frameMs.toFixed(2)}ms`);
69
+ ok('no page errors', errors.length === 0, errors.join(' | '));
70
+ }, { 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>') } });
71
+
72
+ section('aura: behind');
73
+ for (const behind of [true, false]) {
74
+ await withBrowser(async ({ open }) => {
75
+ const p = await open('/b');
76
+ await ready(p); await p.waitForTimeout(1200);
77
+ const img = await shot(p, { x: 280, y: 180, width: 200, height: 160 });
78
+ // element interior is (300..420) x (200..320) minus the clip origin (280,180): (20..140) x (20..140)
79
+ const inner = stats(img, 24, 24, 90, 90, 90);
80
+ if (behind) ok('behind: nothing is drawn over the element\'s face', inner.lit === 0, `lit ${inner.lit}`);
81
+ else ok('control: without behind the particles DO cover the face', inner.lit > 20, `lit ${inner.lit}`);
82
+ }, { 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>') } });
83
+ }
84
+
85
+ section('aura: lifecycle');
86
+ await withBrowser(async ({ open, errors }) => {
87
+ const p = await open('/l');
88
+ await ready(p); await p.waitForTimeout(1200);
89
+ ok('hidden (display:none) elements emit nothing', (await alive(p)) === 0);
90
+ await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(1100);
91
+ ok('showing it starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`);
92
+ // removal by removing the rule's class
93
+ await p.evaluate(`document.getElementById('h').className = 'src off'`); await p.waitForTimeout(2200);
94
+ let d = await dbg(p);
95
+ 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));
96
+ const f1 = d.stats.frames; await p.waitForTimeout(300);
97
+ ok('and the animation loop stops instead of spinning forever', (await dbg(p)).stats.frames === f1, 'frames kept increasing');
98
+ // delete the element from the DOM mid-flight
99
+ await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(700);
100
+ await p.evaluate(`document.getElementById('h').remove()`); await p.waitForTimeout(700);
101
+ d = await dbg(p);
102
+ ok('deleting the element from the DOM drops its emitter', d.emitters.length === 0, JSON.stringify(d.emitters.length));
103
+ ok('no page errors through all of that', errors.length === 0, errors.join(' | '));
104
+ }, { 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 }
105
+ .hide { display: none } .off { aura: none }`, '<div id="h" class="src hide"></div>') } });
106
+
107
+ section('aura: freeze and unfreeze');
108
+ await withBrowser(async ({ open }) => {
109
+ const p = await open('/f');
110
+ await ready(p); await p.waitForTimeout(900);
111
+ await p.evaluate('DSSL.freeze()'); await p.waitForTimeout(200);
112
+ const a = (await dbg(p)).stats.frames; await p.waitForTimeout(300);
113
+ const b = (await dbg(p)).stats.frames;
114
+ ok('freeze() stops the aura loop', a === b, `${a} -> ${b}`);
115
+ 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; })()`);
116
+ ok('and clears the canvas, so a frozen page is still', painted === 0, `${painted} painted pixels`);
117
+ await p.evaluate('DSSL.unfreeze()'); await p.waitForTimeout(500);
118
+ const c = (await dbg(p)).stats.frames; await p.waitForTimeout(300);
119
+ ok('unfreeze() resumes it', (await dbg(p)).stats.frames > c);
120
+ }, { 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>') } });
121
+
122
+ section('aura: reduced motion');
123
+ await withBrowser(async ({ open, errors }) => {
124
+ const p = await open('/rm');
125
+ await ready(p); await p.waitForTimeout(1500);
126
+ ok('particles do not move for visitors who ask for reduced motion', (await alive(p)) === 0, `alive ${await alive(p)}`);
127
+ const img = await shot(p, { x: 230, y: 200, width: 300, height: 200 });
128
+ // element is 300..380 x 250..290; in the clip that is (70..150, 50..90); look just left of its middle
129
+ ok('but the static glow is still drawn', px(img, 58, 70)[0] > 30, `pixel ${px(img, 58, 70)}`);
130
+ const frames = (await dbg(p)).stats.frames; await p.waitForTimeout(400);
131
+ ok('and the loop is not spinning for a still picture', (await dbg(p)).stats.frames - frames < 3, 'loop kept running');
132
+ ok('no page errors', errors.length === 0, errors.join(' | '));
133
+ }, { 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>') } });
134
+ await withBrowser(async ({ open }) => {
135
+ const p = await open('/rme');
136
+ await ready(p); await p.waitForTimeout(1200);
137
+ ok('an `essential` aura ignores the preference', (await alive(p)) > 3, `alive ${await alive(p)}`);
138
+ }, { 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>') } });
139
+
140
+ section('aura: reduced motion still follows the page');
141
+ await withBrowser(async ({ open, errors }) => {
142
+ const p = await open('/rs');
143
+ await ready(p); await p.waitForTimeout(900);
144
+ 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; };
145
+ const a = await at();
146
+ await p.evaluate('window.scrollTo(0, 60)'); await p.waitForTimeout(400);
147
+ const b = await at();
148
+ 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}`);
149
+ ok('no page errors', errors.length === 0, errors.join(' | '));
150
+ }, { 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>') } });
151
+
152
+ section('aura: currentColor, textured particles');
153
+ await withBrowser(async ({ open, errors }) => {
154
+ const p = await open('/cc');
155
+ await ready(p); await p.waitForTimeout(900);
156
+ const img = await shot(p, { x: 230, y: 180, width: 300, height: 200 });
157
+ const c = px(img, 58, 90); // middle of the element's left edge (it spans y 70..110 in this clip)
158
+ 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}`);
159
+ ok('particles with texture(grain) render without errors', (await alive(p)) > 3);
160
+ ok('no page errors', errors.length === 0, errors.join(' | '));
161
+ }, { 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) }') } });
162
+
163
+ section('aura: states, tokens, slots');
164
+ await withBrowser(async ({ open, errors }) => {
165
+ const p = await open('/s');
166
+ await ready(p); await p.waitForTimeout(700);
167
+ ok('no aura until the pointer is over the element', (await alive(p)) === 0);
168
+ await p.hover('.btn'); await p.waitForTimeout(900);
169
+ ok('a hover { } block turns the aura on', (await alive(p)) > 5, `alive ${await alive(p)}`);
170
+ await p.mouse.move(5, 5); await p.waitForTimeout(1800);
171
+ ok('and moving away lets it die out', (await alive(p)) === 0, `alive ${await alive(p)}`);
172
+
173
+ // slots stack through the cascade: glow from one rule, particles from another, then glow cleared
174
+ await p.evaluate(`document.getElementById('slots').className = 'g'`); await p.waitForTimeout(500);
175
+ let d = await dbg(p); let em = d.emitters.find((e) => e.el.id === 'slots') || d.emitters[0];
176
+ 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 }));
177
+ await p.evaluate(`document.getElementById('slots').className = 'g p'`); await p.waitForTimeout(500);
178
+ d = await dbg(p); em = d.emitters[0];
179
+ 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 }));
180
+ await p.evaluate(`document.getElementById('slots').className = 'g p noglow'`); await p.waitForTimeout(500);
181
+ d = await dbg(p); em = d.emitters[0];
182
+ 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 }));
183
+
184
+ // a token change retints the aura
185
+ await p.evaluate(`document.getElementById('slots').className = 'tok'`); await p.waitForTimeout(500);
186
+ const rect = await p.evaluate(`(() => { const r = document.getElementById('slots').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
187
+ const clip = { x: rect.x - 60, y: rect.y - 40, width: rect.w + 120, height: rect.h + 80 };
188
+ let img = await shot(p, clip); let c = px(img, 40, 60);
189
+ ok('a glow written as a token colour draws that colour (red)', c[0] > c[2] * 2, `pixel ${c}`);
190
+ await p.evaluate(`DSSL.apply('tokens { color { aurahue: #0044ff } }', { name: 'retint', replace: true })`); await p.waitForTimeout(500);
191
+ img = await shot(p, clip); c = px(img, 40, 60);
192
+ ok('changing the token retints the glow live (now blue)', c[2] > c[0] * 2, `pixel ${c}`);
193
+ ok('no page errors', errors.length === 0, errors.join(' | '));
194
+ }, { pages: { '/s': page(`tokens { color { aurahue: #ff2200 } }
195
+ .btn { position: absolute; left: 300px; top: 300px; width: 100px; height: 40px; background: #222
196
+ hover { aura-particle: #8cf count 40 life 600 speed 40 dir up from top } }
197
+ #slots { position: absolute; left: 500px; top: 300px; width: 100px; height: 60px; background: #222 }
198
+ .g { aura-glow: #ff8800 24 } .p { aura-particle: #8cf count 20 life 500 speed 30 dir up from top } .noglow { aura-glow: none }
199
+ .tok { aura-glow: color.aurahue 30 }`, '<div class="btn"></div><div id="slots"></div>') } });
200
+
201
+ section('aura: viewport, scroll, pixel ratio');
202
+ await withBrowser(async ({ open, errors }) => {
203
+ const p = await open('/v');
204
+ await ready(p); await p.waitForTimeout(800);
205
+ ok('an element far below the fold emits nothing', (await alive(p)) === 0, `alive ${await alive(p)}`);
206
+ await p.evaluate('window.scrollTo(0, 2900)'); await p.waitForTimeout(900);
207
+ ok('scrolling it into view starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`);
208
+ await p.setViewportSize({ width: 520, height: 420 }); await p.waitForTimeout(400);
209
+ const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, c.height, innerWidth, innerHeight]; })()`);
210
+ ok('the canvas follows a window resize', dims[0] === dims[2] && dims[1] === dims[3], JSON.stringify(dims));
211
+ ok('no page errors', errors.length === 0, errors.join(' | '));
212
+ }, { 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>') } });
213
+ await withBrowser(async ({ open }) => {
214
+ const p = await open('/d');
215
+ await ready(p); await p.waitForTimeout(500);
216
+ const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, innerWidth]; })()`);
217
+ ok('on a 2x display the canvas is drawn at 2x so glow stays sharp', dims[0] === dims[1] * 2, JSON.stringify(dims));
218
+ }, { 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>') } });
219
+
220
+ section('aura: range slider thumb anchor');
221
+ await withBrowser(async ({ open, errors }) => {
222
+ const p = await open('/t');
223
+ await ready(p); await p.waitForTimeout(700);
224
+ 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; })()`);
225
+ await p.fill('#r', '10'); await p.waitForTimeout(1100); const low = await meanX();
226
+ await p.fill('#r', '90'); await p.waitForTimeout(1500); const high = await meanX();
227
+ ok('`from thumb` follows the value: low at 10, high at 90', low > 0 && high > low + 150, `${low.toFixed(0)} vs ${high.toFixed(0)}`);
228
+ ok('and lands within the slider\'s own width (100..400)', low >= 100 && high <= 400, `${low} ${high}`);
229
+ ok('no page errors', errors.length === 0, errors.join(' | '));
230
+ }, { 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">') } });
231
+
232
+ section('aura: many glows');
233
+ await withBrowser(async ({ open, errors }) => {
234
+ const p = await open('/glows');
235
+ await ready(p); await p.waitForTimeout(2500);
236
+ const d = await dbg(p);
237
+ 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`);
238
+ ok('no page errors', errors.length === 0, errors.join(' | '));
239
+ }, { 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 } });
240
+
241
+ section('aura: stress');
242
+ await withBrowser(async ({ open, errors }) => {
243
+ const p = await open('/stress');
244
+ await ready(p); await p.waitForTimeout(2500);
245
+ const d = await dbg(p);
246
+ ok('60 emitters x 100 particles are held to the global budget (1800)', d.stats.particles <= 1800, `alive ${d.stats.particles}`);
247
+ ok('and the frame time stays workable under that load', d.stats.frameMs < 25, `${d.stats.frameMs.toFixed(1)}ms`);
248
+ // churn: flip the aura on and off 200 times
249
+ 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)); } })()`);
250
+ await p.waitForTimeout(2200);
251
+ const e = await dbg(p);
252
+ ok('rapid toggling does not leak emitters (60 elements, 60 emitters at most)', e.emitters.length <= 60, `emitters ${e.emitters.length}`);
253
+ // refresh keeps what is flying instead of resetting it
254
+ const before = e.stats.particles; await p.evaluate('DSSL.refresh()'); await p.waitForTimeout(50);
255
+ ok('DSSL.refresh() does not wipe particles in flight', (await dbg(p)).stats.particles >= before * 0.7, `${before} -> ${(await dbg(p)).stats.particles}`);
256
+ ok('no page errors', errors.length === 0, errors.join(' | '));
257
+ }, { 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>') } });
258
+
259
+ /* ------------------------------------------------------------------ *
260
+ * TEXTURE
261
+ * ------------------------------------------------------------------ */
262
+ section('texture: layering over the author\'s own background');
263
+ await withBrowser(async ({ open, errors }) => {
264
+ const p = await open('/tx');
265
+ await ready(p); await p.waitForTimeout(600);
266
+ const cs = (sel, prop) => p.evaluate(([s, pr]) => getComputedStyle(document.querySelector(s))[pr], [sel, prop]);
267
+ ok('a plain colour keeps its colour', (await cs('#c', 'backgroundColor')) === 'rgb(200, 30, 30)');
268
+ ok('and gains one texture layer', (await cs('#c', 'backgroundImage')).split('url("data:').length === 2);
269
+ const gi = await cs('#g', 'backgroundImage');
270
+ ok('a gradient keeps its gradient UNDER the texture (shorthand form)', gi.includes('linear-gradient') && gi.includes('url('), gi.slice(0, 80));
271
+ ok('the texture layer comes first, so it sits on top', gi.indexOf('url(') < gi.indexOf('linear-gradient'));
272
+ const si = await cs('#s', 'backgroundSize');
273
+ ok('background-image + background-size: cover keeps `cover` for the author\'s layer (list is aligned)', si.endsWith('cover') && si.split(',').length === 2, si);
274
+ const bm = await cs('#s', 'backgroundBlendMode');
275
+ ok('blend modes line up: texture mode first, normal for the author\'s layer', /^overlay, normal$/.test(bm), bm);
276
+ const before = await cs('#r', 'backgroundImage');
277
+ await p.evaluate(`document.getElementById('r').className = 'tex'`); await p.waitForTimeout(300);
278
+ const during = await cs('#r', 'backgroundImage');
279
+ await p.evaluate(`document.getElementById('r').className = ''`); await p.waitForTimeout(300);
280
+ const after = await cs('#r', 'backgroundImage');
281
+ 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}`);
282
+ await p.evaluate(`document.getElementById('l').className = 'a b'`); await p.waitForTimeout(300);
283
+ 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));
284
+ await p.evaluate(`document.getElementById('n').className = 'none'`); await p.waitForTimeout(300);
285
+ ok('`texture: none` removes it', (await cs('#n', 'backgroundImage')) === 'none');
286
+ ok('no page errors', errors.length === 0, errors.join(' | '));
287
+ }, { pages: { '/tx': page(`#c { texture: grain }
288
+ #g { background: linear-gradient(red, blue); texture: noise }
289
+ #s { background-image: linear-gradient(red, blue); background-size: cover; texture: grain }
290
+ #r { background-image: linear-gradient(red, blue) } #r.tex { texture: grain }
291
+ .a { texture: grain } .b { texture: lines }
292
+ #n { texture: grain } #n.none { texture: none }
293
+ .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>') } });
294
+
295
+ section('texture: states, several author layers, freeze');
296
+ await withBrowser(async ({ open, errors }) => {
297
+ const p = await open('/th');
298
+ await ready(p); await p.waitForTimeout(500);
299
+ const img = () => p.evaluate(`getComputedStyle(document.getElementById('h')).backgroundImage`);
300
+ const base = await img();
301
+ await p.hover('#h'); await p.waitForTimeout(350);
302
+ const hov = await img();
303
+ ok('a texture can change on hover', hov !== base && hov.includes('repeating-linear-gradient'), hov.slice(0, 60));
304
+ await p.mouse.move(2, 2); await p.waitForTimeout(350);
305
+ ok('and goes back when the pointer leaves', (await img()) === base);
306
+ const multi = await p.evaluate(`getComputedStyle(document.getElementById('m')).backgroundImage`);
307
+ 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));
308
+ const before = await p.evaluate(`document.getElementById('a').getAnimations().length`);
309
+ await p.evaluate('DSSL.freeze()'); await p.waitForTimeout(150);
310
+ const during = await p.evaluate(`document.getElementById('a').getAnimations().length`);
311
+ await p.evaluate('DSSL.unfreeze()'); await p.waitForTimeout(300);
312
+ const after = await p.evaluate(`document.getElementById('a').getAnimations().length`);
313
+ ok('freeze() stops the texture shimmer and unfreeze() brings it back', before === 1 && during === 0 && after === 1, `${before} ${during} ${after}`);
314
+ ok('no page errors', errors.length === 0, errors.join(' | '));
315
+ }, { 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>') } });
316
+
317
+ section('texture: every preset paints something');
318
+ await withBrowser(async ({ open, errors }) => {
319
+ const p = await open('/gallery');
320
+ await p.waitForFunction('window.ready === true'); await p.waitForTimeout(500);
321
+ 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 }; })`);
322
+ const full = decodePng(await p.screenshot({ fullPage: true }));
323
+ const flat = [184, 165, 138];
324
+ let bad = [];
325
+ boxes.forEach((b, i) => {
326
+ const name = PRESET_NAMES[i];
327
+ const seen = new Set();
328
+ let far = 0;
329
+ 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++) {
330
+ const c = px(full, x, y);
331
+ seen.add(`${c[0] >> 4},${c[1] >> 4},${c[2] >> 4}`);
332
+ if (dist(c, flat) > 12) far++;
333
+ }
334
+ if (far < 8) bad.push(`${name}(${seen.size} tones, ${far} px changed)`);
335
+ });
336
+ ok(`all ${PRESET_NAMES.length} presets draw something distinct from the flat colour`, bad.length === 0, bad.join(', '));
337
+ ok('no page errors', errors.length === 0, errors.join(' | '));
338
+ }, { pages: { '/gallery': galleryPage('#b8a58a', 6) }, viewport: { width: 1100, height: 700 } });
339
+
340
+ section('texture: tokens and motion');
341
+ await withBrowser(async ({ open }) => {
342
+ const p = await open('/tt');
343
+ await ready(p); await p.waitForTimeout(600);
344
+ const img = () => p.evaluate(`getComputedStyle(document.getElementById('t')).backgroundImage`);
345
+ const a = await img();
346
+ await p.evaluate(`DSSL.apply('tokens { color { tint: #00ff55 } }', { name: 'x', replace: true })`); await p.waitForTimeout(500);
347
+ const b = await img();
348
+ ok('a texture colour written as a token follows the token', a !== b && decodeURIComponent(a).includes('rgb') !== undefined, 'image did not change');
349
+ const anims = await p.evaluate(`document.getElementById('m').getAnimations().length`);
350
+ ok('`animate` shimmers the texture with a real animation', anims === 1, `animations ${anims}`);
351
+ }, { 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>') } });
352
+ await withBrowser(async ({ open }) => {
353
+ const p = await open('/tm');
354
+ await ready(p); await p.waitForTimeout(600);
355
+ ok('the shimmer is off when the visitor prefers reduced motion', (await p.evaluate(`document.getElementById('m').getAnimations().length`)) === 0);
356
+ }, { reducedMotion: 'reduce', pages: { '/tm': page('#m { texture: grain animate 90; width: 80px; height: 60px }', '<div id="m"></div>') } });
357
+
358
+ /* ------------------------------------------------------------------ *
359
+ * SLIDER
360
+ * ------------------------------------------------------------------ */
361
+ section('slider: rendering in Chrome (WebKit/Blink pseudo-elements)');
362
+ const FILL = [255, 122, 0], TRACK = [68, 85, 102];
363
+ await withBrowser(async ({ open, errors }) => {
364
+ const p = await open('/sl');
365
+ await ready(p); await p.waitForTimeout(600);
366
+ const box = await p.evaluate(`(() => { const r = document.getElementById('s1').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
367
+ const clip = { x: box.x - 4, y: box.y - 4, width: box.w + 8, height: box.h + 8 };
368
+ const at = async (frac) => { const img = await shot(p, clip); return px(img, 4 + frac * box.w, 4 + box.h / 2); };
369
+ ok('left of the thumb the track is the fill colour', dist(await at(0.2), FILL) < 24, `pixel ${await at(0.2)}`);
370
+ ok('right of the thumb it is the track colour', dist(await at(0.9), TRACK) < 24, `pixel ${await at(0.9)}`);
371
+ await p.fill('#s1', '95'); await p.waitForTimeout(200);
372
+ ok('moving the thumb moves the fill with it', dist(await at(0.8), FILL) < 24, `pixel ${await at(0.8)}`);
373
+ await p.evaluate(`(() => { const e = document.getElementById('s1'); e.value = 10; })()`);
374
+ await p.evaluate('DSSL.refresh()'); await p.waitForTimeout(200);
375
+ ok('a value set from JavaScript is picked up by DSSL.refresh()', dist(await at(0.5), TRACK) < 24, `pixel ${await at(0.5)}`);
376
+ 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);
377
+ ok('native appearance is switched off', (await p.evaluate(`getComputedStyle(document.getElementById('s1')).appearance`)) === 'none');
378
+ 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); })();
379
+ ok('the thumb is drawn in its own colour (white)', dist(thumb, [255, 255, 255]) < 30, `pixel ${thumb}`);
380
+ ok('no page errors', errors.length === 0, errors.join(' | '));
381
+ }, { 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 } });
382
+
383
+ section('slider: min, max and step');
384
+ await withBrowser(async ({ open }) => {
385
+ const p = await open('/mm');
386
+ await ready(p); await p.waitForTimeout(500);
387
+ const pv = () => p.evaluate(`getComputedStyle(document.getElementById('s')).getPropertyValue('--dssl-p')`);
388
+ ok('min 10, max 50, value 30 is exactly half way (0.5)', Math.abs(Number(await pv()) - 0.5) < 0.001, await pv());
389
+ await p.fill('#s', '50'); await p.waitForTimeout(100);
390
+ ok('at the maximum it is 1', Math.abs(Number(await pv()) - 1) < 0.001, await pv());
391
+ await p.fill('#s', '10'); await p.waitForTimeout(100);
392
+ ok('at the minimum it is 0', Math.abs(Number(await pv())) < 0.001, await pv());
393
+ }, { pages: { '/mm': page('#s { width: 300px; slider: fill #f80 }', '<input id="s" type="range" min="10" max="50" step="5" value="30">') } });
394
+
395
+ section('slider: graph (histogram) and shapes');
396
+ await withBrowser(async ({ open, errors }) => {
397
+ const p = await open('/g');
398
+ await ready(p); await p.waitForTimeout(600);
399
+ const box = await p.evaluate(`(() => { const r = document.getElementById('g1').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
400
+ const clip = { x: box.x, y: box.y, width: box.w, height: box.h };
401
+ const img = await shot(p, clip);
402
+ // 12 bars across 300px with 3px gaps: bar i is 22.25px wide and centred at i/11 of the free width
403
+ const n = 12, bw = (box.w - (n - 1) * 3) / n;
404
+ const barX = (i) => Math.round((i / (n - 1)) * (box.w - bw) + bw / 2);
405
+ 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; };
406
+ 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)}`);
407
+ 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)}`);
408
+ const t0 = topOf(0), t7 = topOf(7);
409
+ 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}`);
410
+ ok('no page errors', errors.length === 0, errors.join(' | '));
411
+ }, { 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 } });
412
+ await withBrowser(async ({ open, errors }) => {
413
+ const p = await open('/sh');
414
+ await ready(p); await p.waitForTimeout(500);
415
+ const logs = []; p.on('console', (m) => logs.push(m.text()));
416
+ await p.evaluate(`DSSL.apply('.notinput { slider: thumb 20 #fff }', { name: 'misuse', replace: true })`);
417
+ await p.evaluate(`const d = document.createElement('div'); d.className = 'notinput'; document.body.appendChild(d)`); await p.waitForTimeout(400);
418
+ 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(' | '));
419
+ ok('no page errors', errors.length === 0, errors.join(' | '));
420
+ }, { pages: { '/sh': page('', '<div></div>') } });
421
+
422
+ console.log(`\n${pass} passed, ${fail} failed`);
423
+ process.exit(fail ? 1 : 0);
scripts/test-v081.mjs CHANGED
@@ -12,7 +12,7 @@ import { resolve } from '../public/dssl/resolver.js';
12
  import { toCSS } from '../public/dssl/static.js';
13
  import { VERSION } from '../public/dssl/index.js';
14
  import { execFileSync } from 'node:child_process';
15
- import { writeFileSync, mkdtempSync, rmSync, mkdirSync, copyFileSync } from 'node:fs';
16
  import { tmpdir } from 'node:os';
17
  import { join, dirname } from 'node:path';
18
  import { fileURLToPath } from 'node:url';
@@ -155,7 +155,8 @@ const fluidOf = (src) => val(declsOf(compileDoc(src).doc), 'font-size');
155
  const dir = mkdtempSync(join(tmpdir(), 'dssl-pkg-'));
156
  mkdirSync(join(dir, 'cli')); mkdirSync(join(dir, 'dssl'));
157
  copyFileSync(join(root, 'cli', 'dssl.mjs'), join(dir, 'cli', 'dssl.mjs'));
158
- for (const f of ['boot', 'engine', 'index', 'parser', 'resolver', 'static', 'utils']) copyFileSync(join(root, 'public', 'dssl', f + '.js'), join(dir, 'dssl', f + '.js'));
 
159
  const src = join(dir, 'a.dssl');
160
  writeFileSync(src, 'h1 { font-size: fluid 16 24 }\n');
161
  const run = (args) => {
 
12
  import { toCSS } from '../public/dssl/static.js';
13
  import { VERSION } from '../public/dssl/index.js';
14
  import { execFileSync } from 'node:child_process';
15
+ import { writeFileSync, mkdtempSync, rmSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs';
16
  import { tmpdir } from 'node:os';
17
  import { join, dirname } from 'node:path';
18
  import { fileURLToPath } from 'node:url';
 
155
  const dir = mkdtempSync(join(tmpdir(), 'dssl-pkg-'));
156
  mkdirSync(join(dir, 'cli')); mkdirSync(join(dir, 'dssl'));
157
  copyFileSync(join(root, 'cli', 'dssl.mjs'), join(dir, 'cli', 'dssl.mjs'));
158
+ // the package ships every engine module; a hardcoded list here is how the 0.8.0 package broke
159
+ for (const f of readdirSync(join(root, 'public', 'dssl'))) if (f.endsWith('.js')) copyFileSync(join(root, 'public', 'dssl', f), join(dir, 'dssl', f));
160
  const src = join(dir, 'a.dssl');
161
  writeFileSync(src, 'h1 { font-size: fluid 16 24 }\n');
162
  const run = (args) => {
scripts/test-v090.mjs ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* test-v090.mjs: the pure half of 0.9.0 (no browser needed).
2
+ * color, texture, aura, slider: parsing, vocabulary and error messages
3
+ * the resolver: properties, slider shadow rules, engine-native declarations
4
+ * lint: contrast, focus, tap targets
5
+ * import: CSS to DSSL
6
+ * share links
7
+ * The browser half is scripts/test-browser-v090.mjs. */
8
+ import { parseColor, contrast, evalColorExpr, mix, over, toHex } from '../public/dssl/color.js';
9
+ import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, parseTexture, buildTexture, makeMask, rng } from '../public/dssl/texture.js';
10
+ import { parseAura, resolveLayer, GLOW_KEYS, PARTICLE_KEYS } from '../public/dssl/aura.js';
11
+ import { parseSlider, sliderCss } from '../public/dssl/slider.js';
12
+ import { importCss, parseCss } from '../public/dssl/import.js';
13
+ import { compile, VERSION } from '../public/dssl/index.js';
14
+ import { didYouMean } from '../public/dssl/utils.js';
15
+
16
+ let pass = 0, fail = 0;
17
+ const ok = (name, cond, extra) => {
18
+ if (cond) { pass++; console.log(` ok ${name}`); }
19
+ else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); }
20
+ };
21
+ const section = (t) => console.log(`\n== ${t}`);
22
+
23
+ const hooks = () => {
24
+ const errors = [];
25
+ return { errors, error: (m, h) => errors.push({ message: m, hint: h }), color: (w) => w, suggest: (w, l) => { const h = didYouMean(w, l); return h ? `did you mean "${h}"?` : null; } };
26
+ };
27
+ const decls = (src, i = 0) => compile(src).doc.rules[i]?.decls || [];
28
+ const errs = (src) => compile(src).errors.map((e) => e.message);
29
+ const warns = (src) => compile(src).warnings.map((e) => e.message);
30
+
31
+ /* ------------------------------------------------------------------ */
32
+ section('color');
33
+ ok('hex, rgb, hsl, oklch and named colours parse', [parseColor('#f00'), parseColor('rgb(0 255 0)'), parseColor('hsl(240 100% 50%)'), parseColor('oklch(0.7 0.1 200)'), parseColor('rebeccapurple')].every(Boolean));
34
+ ok('8-digit hex carries alpha', Math.abs(parseColor('#ff000080').a - 0.502) < 0.01);
35
+ ok('color-mix and color(srgb) (what getComputedStyle returns) parse', parseColor('color-mix(in oklab, #ff0000 40%, transparent)').a > 0.39 && parseColor('color(srgb 1 0 0 / 0.5)').a === 0.5);
36
+ ok('garbage and var() are null, not a crash', parseColor('nonsense') === null && parseColor('var(--x)') === null && parseColor('') === null && parseColor(null) === null);
37
+ ok('contrast matches the WCAG reference points (white/black 21, same colour 1)', Math.abs(contrast(parseColor('#fff'), parseColor('#000')) - 21) < 0.01 && Math.abs(contrast(parseColor('#777'), parseColor('#777')) - 1) < 0.001);
38
+ ok('over() composites alpha onto a background', (() => { const c = over({ r: 255, g: 0, b: 0, a: 0.5 }, { r: 0, g: 0, b: 255, a: 1 }); return c.r === 128 && c.b === 128 && c.a === 1; })());
39
+ ok('mix() halfway between black and white in srgb is mid grey', toHex(mix(parseColor('#000'), parseColor('#fff'), 0.5, 'srgb')) === '#808080');
40
+ ok('evalColorExpr reads tokens, token/alpha and literals', (() => { const lk = (n) => ({ 'color.ink': '#102030', 'color.paper': 'color.ink' })[n]; return toHex(evalColorExpr('color.paper', lk)) === '#102030' && evalColorExpr('color.ink/40', lk).a === 0.4 && evalColorExpr('color.nope', lk) === null; })());
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ section('texture: vocabulary');
44
+ ok(`there are 28 to 32 presets (${PRESET_NAMES.length})`, PRESET_NAMES.length >= 28 && PRESET_NAMES.length <= 32);
45
+ ok('every preset has a one-line description', PRESET_NAMES.every((n) => PRESETS[n].doc && PRESETS[n].doc.length > 8));
46
+ ok('every preset is built only from known generators and settings', PRESET_NAMES.every((n) => PRESETS[n].layers.every((l) => GENERATORS.includes(l.gen) && Object.keys(l).every((k) => ['gen', 'stagger', 'preset'].includes(k) || k in TEXTURE_KEYS))));
47
+ ok('every setting documents itself', Object.values(TEXTURE_KEYS).every((k) => k.doc));
48
+ {
49
+ const rc = parseColor;
50
+ const empty = [];
51
+ for (const n of PRESET_NAMES) {
52
+ const h = hooks();
53
+ const layers = parseTexture(n, h);
54
+ const b = layers && buildTexture(layers, rc);
55
+ if (!layers || !b || !b.image || h.errors.length) empty.push(n);
56
+ }
57
+ ok('every preset parses and builds CSS', empty.length === 0, empty.join(', '));
58
+ // split a CSS list at top-level commas, ignoring the commas inside url("data:...,...") and gradients
59
+ const split = (v) => { const out = []; let d = 0, cur = '', q = null; for (const ch of v) { if (q) { cur += ch; if (ch === q) q = null; continue; } if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; } if (ch === '(') d++; if (ch === ')') d--; if (ch === ',' && d === 0) { out.push(cur); cur = ''; continue; } cur += ch; } out.push(cur); return out; };
60
+ const lists = PRESET_NAMES.map((n) => { const b = buildTexture(parseTexture(n, hooks()), rc); return [n, b.count, split(b.image).length, split(b.size).length, split(b.blend).length, split(b.position).length, split(b.repeat).length]; });
61
+ const mismatched = lists.filter((l) => l.slice(2).some((x) => x !== l[1]));
62
+ ok('every list has one entry per layer (images, sizes, blends, positions, repeats line up)', mismatched.length === 0, JSON.stringify(mismatched[0]));
63
+ ok('building is deterministic: the same texture gives the same CSS', buildTexture(parseTexture('wood', hooks()), rc).image === buildTexture(parseTexture('wood', hooks()), rc).image);
64
+ }
65
+ section('texture: your own');
66
+ {
67
+ const h = hooks();
68
+ const l = parseTexture('noise scale 3 octaves 4 color #112233 color2 #ffffff blend multiply contrast 2 seed 9', h);
69
+ ok('a bare generator with settings parses', l && l.length === 1 && l[0].gen === 'noise' && l[0].octaves === 4 && l[0].seed === 9 && l[0].blend === 'multiply', JSON.stringify(h.errors));
70
+ const two = parseTexture('grain, lines angle 30', hooks());
71
+ ok('comma separated textures stack', two.length === 2 && two[1].angle === 30);
72
+ const over2 = parseTexture('paper intensity 0.2', hooks());
73
+ ok('settings on a multi-layer preset apply to every layer', over2.length === 2 && over2.every((x) => x.intensity === 0.2));
74
+ const keep = parseTexture('paper color #123456', hooks());
75
+ ok('colour settings only retint the first layer of a preset, so its second tone survives', keep[0].color === '#123456' && keep[1].color === '#000');
76
+ ok('none clears (an empty list, not an error)', (() => { const x = hooks(); const r = parseTexture('none', x); return Array.isArray(r) && r.length === 0 && !x.errors.length; })());
77
+ ok('contour draws veins: it changes the SVG', buildTexture(parseTexture('noise contour 0.05', hooks()), parseColor).image !== buildTexture(parseTexture('noise', hooks()), parseColor).image);
78
+ }
79
+ section('texture: errors');
80
+ for (const [src, want] of [
81
+ ['grian', /unknown texture "grian".*did you mean "grain"/], ['grain intensty 1', /unknown texture setting "intensty".*intensity/],
82
+ ['grain intensity 9', /between 0 and 2/], ['grain intensity lots', /needs a number/], ['noise blend sparkly', /unknown blend "sparkly"/],
83
+ ['wood stretch 4', /needs 2 values/], ['', /needs a name/], ['grain octaves 99', /between 1 and 8/], ['noise type fuzzy', /unknown type "fuzzy"/],
84
+ ]) {
85
+ const h = hooks();
86
+ const r = parseTexture(src, h);
87
+ const text = h.errors.map((e) => e.message + ' ' + (e.hint || '')).join(' ');
88
+ ok(`"${src}" is refused with a useful message`, r === null && want.test(text), text);
89
+ }
90
+ ok('the same errors come through the real compiler, with the line', errs('.a { texture: grian }')[0].includes('unknown texture'));
91
+ ok('texture on a pseudo-element is refused, it has no DOM handle', errs('.a::before { texture: grain }').some((m) => /engine-native/.test(m)));
92
+ ok('texture compiles to one engine-native declaration', (() => { const d = decls('.a { texture: grain, lines }'); return d.length === 1 && d[0].prop === '@texture' && d[0].value.length === 2; })());
93
+ ok('a token colour resolves to a CSS var, not a literal', decls('.a { texture: noise color color.ink }')[0].value[0].color === 'var(--dssl-color-ink)');
94
+ ok('an unknown token colour is an error with a suggestion', errs('.a { texture: noise color color.inkk }').some((m) => /color\.inkk/.test(m)));
95
+
96
+ section('texture: masks (for aura)');
97
+ {
98
+ const a = makeMask({ gen: 'noise', scale: 3, octaves: 3, seed: 1 }, 64), b = makeMask({ gen: 'noise', scale: 3, octaves: 3, seed: 1 }, 64), c = makeMask({ gen: 'noise', scale: 3, octaves: 3, seed: 2 }, 64);
99
+ ok('masks are deterministic for a seed', a.every((v, i) => v === b[i]));
100
+ ok('and differ between seeds', a.some((v, i) => v !== c[i]));
101
+ const mean = a.reduce((x, y) => x + y, 0) / a.length;
102
+ ok('a noise mask is neither empty nor solid', mean > 30 && mean < 225, mean.toFixed(0));
103
+ const lines = makeMask({ gen: 'lines', scale: 8, thickness: 2, angle: 0 }, 32);
104
+ ok('horizontal lines are constant along a row (angle 0) and vary down the column', lines[0] === lines[31] && (lines[0] !== lines[4 * 32] || lines[0] !== lines[2 * 32]));
105
+ const r1 = rng(5), r2 = rng(5);
106
+ ok('the PRNG repeats for a seed', [r1(), r1(), r1()].join() === [r2(), r2(), r2()].join());
107
+ }
108
+
109
+ /* ------------------------------------------------------------------ */
110
+ section('aura: vocabulary');
111
+ ok('every aura setting documents itself', [...Object.values(GLOW_KEYS), ...Object.values(PARTICLE_KEYS)].every((k) => k.doc));
112
+ ok('there are no built-in aura presets: the vocabulary has no "fire" or "ki"', !('fire' in PARTICLE_KEYS) && !('fire' in GLOW_KEYS) && (() => { const h = hooks(); return parseAura('aura', 'fire', h) === null; })());
113
+ section('aura: parsing');
114
+ {
115
+ const h = hooks();
116
+ const g = parseAura('aura-glow', 'color.accent 24 pulse 1600 flicker 0.4', h);
117
+ ok('aura-glow: positional colour and radius, then named settings', g.glow[0].color === 'color.accent' && g.glow[0].radius === 24 && g.glow[0].pulse === 1600 && g.glow[0].flicker === 0.4 && g.particles === undefined, JSON.stringify(h.errors));
118
+ const p = parseAura('aura-particle', '#fd0 -> #f60 -> transparent 70 up size 12 6 life 600 1100 from bottom shape flame glowing', hooks());
119
+ const L = p.particles[0];
120
+ ok('aura-particle: colour stops, count, direction, ranges, words', L.colors.length === 3 && L.count === 70 && L.dir === -90 && L.size[0] === 6 && L.size[1] === 12 && L.life[0] === 600 && L.from === 'bottom' && L.shape === 'flame' && L.glowing === true, JSON.stringify(L));
121
+ ok('a single number is a fixed value (a range of one)', parseAura('aura-particle', '#fff size 5', hooks()).particles[0].size.join() === '5,5');
122
+ const gp = parseAura('glow-particle', '#fff 30 out', hooks());
123
+ ok('glow-particle is a glowing particles layer', gp.particles[0].glowing === true && gp.particles[0].count === 30 && gp.particles[0].dir === 'out');
124
+ const both = parseAura('aura-glow-particle', '#ff7a00 28 pulse 900 count 50 up', hooks());
125
+ ok('aura-glow-particle fills both slots from one description', both.glow.length === 1 && both.particles.length === 1 && both.glow[0].pulse === 900 && both.particles[0].count === 50 && both.particles[0].glowing === true);
126
+ const full = parseAura('aura', 'glow #f60 30 flicker 0.3, particles #fc4 -> #f30 count 60 shape flame', hooks());
127
+ ok('aura: takes glow and particles layers, comma separated', full.glow.length === 1 && full.particles.length === 1);
128
+ const stacked = parseAura('aura', 'glow #f60 30, glow #fff 10, particles #fff count 5, particles #f00 count 9', hooks());
129
+ ok('you can stack several layers of each kind', stacked.glow.length === 2 && stacked.particles.length === 2);
130
+ ok('plural spellings are the same property (aura-particles, glow-particles)', parseAura('aura-particles', '#fff 5', hooks()).particles.length === 1 && parseAura('glow-particles', '#fff 5', hooks()).particles[0].glowing);
131
+ const tex = parseAura('aura-glow', '#fff 20 texture(grain scale 2)', hooks());
132
+ ok('texture(...) carries its own settings into an aura layer', tex.glow[0].texture[0].gen === 'noise' && tex.glow[0].texture[0].scale === 2);
133
+ ok('behind and essential are flags', (() => { const x = parseAura('aura-particle', '#fff 5 behind essential', hooks()).particles[0]; return x.behind === true && x.essential === true; })());
134
+ ok('a particle layer can be given a texture', parseAura('aura-particle', '#fff 5 texture(dots)', hooks()).particles[0].texture.length === 1);
135
+ }
136
+ section('aura: none clears only its own slot');
137
+ ok('aura: none clears both', (() => { const r = parseAura('aura', 'none', hooks()); return r.glow === null && r.particles === null; })());
138
+ ok('aura-glow: none clears only the glow slot (it used to clear both)', (() => { const r = parseAura('aura-glow', 'none', hooks()); return r.glow === null && r.particles === undefined; })());
139
+ ok('aura-particle: none clears only the particles slot', (() => { const r = parseAura('aura-particle', 'none', hooks()); return r.particles === null && r.glow === undefined; })());
140
+ ok('glow-particle: none clears only the particles slot', (() => { const r = parseAura('glow-particle', 'none', hooks()); return r.particles === null && r.glow === undefined; })());
141
+ ok('through the compiler: aura-glow: none writes one slot only', (() => { const d = decls('.a { aura-glow: none }'); return d.length === 1 && d[0].prop === '@aura-glow' && d[0].value === null; })());
142
+ section('aura: errors');
143
+ for (const [prop, src, want] of [
144
+ ['aura', 'sparkle #fff', /starts with glow or particles/], ['aura-glow', '#fff radius 9999', /between 1 and 400/], ['aura-particle', 'count lots', /needs a number/],
145
+ ['aura-particle', '#fff shape flaim', /unknown shape "flaim".*flame/], ['aura-particle', '#fff dir sideways', /unknown direction/], ['aura-particle', '#fff speedd 4', /unknown aura setting "speedd".*speed/],
146
+ ['aura-particle', '#fff from middle', /unknown from "middle"/], ['aura-glow', '#fff pulse -5', /between 0 and 60000/], ['aura-particle', '#fff count 0', /between 1 and 600/],
147
+ ['aura-particle', '#fff size', /needs a number/], ['aura-glow', '#fff texture(grian)', /unknown texture "grian"/], ['aura-glow', '', /needs a value/],
148
+ ]) {
149
+ const h = hooks();
150
+ const r = parseAura(prop, src, h);
151
+ const text = h.errors.map((e) => e.message + ' ' + (e.hint || '')).join(' ');
152
+ ok(`${prop}: ${src || '(empty)'} is refused clearly`, r === null && want.test(text), text);
153
+ }
154
+ ok('a mistyped token colour in an aura is caught by the compiler with a suggestion', errs('.a { aura-glow: colr.accent 24 }').some((m) => /colr\.accent/.test(m)));
155
+ ok('aura on a pseudo-element is refused', errs('.a::after { aura: glow #f00 10 }').some((m) => /engine-native/.test(m)));
156
+ ok('aura compiles to engine-native declarations, one per slot', (() => { const d = decls('.a { aura: glow #f00 10, particles #fff 5 }'); return d.map((x) => x.prop).join() === '@aura-glow,@aura-particles'; })());
157
+ ok('`dssl build` leaves aura in the runtime and says so', (() => { const r = compile('.a { aura-glow: #f00 10 }'); return r.doc.rules[0].decls.every((d) => d.prop.startsWith('@')); })());
158
+ section('aura: resolving a layer for drawing');
159
+ {
160
+ const rc = (css) => parseColor(css);
161
+ const fb = { r: 9, g: 9, b: 9, a: 1 };
162
+ const glow = resolveLayer(parseAura('aura-glow', '#ff0000 20', hooks()).glow[0], rc, fb);
163
+ ok('glow: defaults fill in (intensity 1, depth .35, no pulse)', glow.intensity === 1 && glow.pulse === 0 && glow.depth === 0.35 && glow.rgba.r === 255);
164
+ const none = resolveLayer(parseAura('aura-glow', 'radius 12', hooks()).glow[0], rc, fb);
165
+ ok('a glow with no colour falls back to the element colour', none.rgba.r === 9);
166
+ const one = resolveLayer(parseAura('aura-particle', '#ff0000 5', hooks()).particles[0], rc, fb);
167
+ ok('one colour fades itself out (a second, transparent stop is added)', one.stops.length === 2 && one.stops[1].a === 0);
168
+ const add = resolveLayer(parseAura('glow-particle', '#fff 5', hooks()).particles[0], rc, fb);
169
+ ok('glowing particles blend additively by default, plain ones normally', add.blend === 'add' && one.blend === 'normal');
170
+ ok('defaults: 40 particles, size 4, life 900, speed 40, up', one.count === 5 && one.size[0] === 4 && one.life[0] === 900 && one.speed[0] === 40 && one.dir === -90);
171
+ }
172
+
173
+ /* ------------------------------------------------------------------ */
174
+ section('slider: parsing');
175
+ {
176
+ const h = hooks();
177
+ const s = parseSlider('track 8 #445566 radius full, fill #ff7a00, thumb 24 #fff, shape diamond, ring 3 #000, glow #ff7a00 14, ticks 5 #888 8', h);
178
+ ok('all clauses parse', s && s.track.h === 8 && s.fill === '#ff7a00' && s.thumb.size === 24 && s.shape === 'diamond' && s.ring.w === 3 && s.glow.r === 14 && s.ticks.n === 5 && s.ticks.len === 8, JSON.stringify(h.errors));
179
+ ok('track takes its parts in any order', (() => { const a = parseSlider('track #123456 6 radius 2', hooks()); return a.track.h === 6 && a.track.color === '#123456' && a.track.radius === 2; })());
180
+ const g = parseSlider('graph 10 20 30 40, graph-height 50, graph-gap 2', hooks());
181
+ ok('graph and its sizing', g.graph.length === 4 && g.graphHeight === 50 && g.graphGap === 2);
182
+ ok('fill none turns the fill off', parseSlider('fill none', hooks()).fill === null);
183
+ }
184
+ section('slider: errors');
185
+ for (const [src, want] of [
186
+ ['thumb big', /thumb needs a size/], ['shape cirle', /unknown thumb shape "cirle".*circle/], ['colour red', /unknown slider setting "colour"/],
187
+ ['thumb 16 #fff shape bar', /unexpected "shape bar" in the thumb setting.*separate settings with commas/], ['fill red blue', /unexpected "blue"/], ['ticks 1', /at least 2/],
188
+ ['graph 5', /at least two numbers/], ['graph 10 200', /0 to 100/], ['graph-height 3', /at least 8/], ['ring 3', /width and a colour/], ['glow', /colour and a radius/], ['', /needs a description/],
189
+ ]) {
190
+ const h = hooks();
191
+ const r = parseSlider(src, h);
192
+ const text = h.errors.map((e) => e.message + ' ' + (e.hint || '')).join(' ');
193
+ ok(`slider: ${src || '(empty)'} is refused clearly`, r === null && want.test(text), text);
194
+ }
195
+ section('slider: generated CSS');
196
+ {
197
+ const spec = parseSlider('fill #f80, thumb 20 #fff, ring 2 #000, shape circle', hooks());
198
+ const css = sliderCss(spec);
199
+ const pseudos = css.shadow.map((x) => x.pseudo);
200
+ ok('both engines are covered: -webkit- track and thumb, -moz- track, thumb and progress', ['-webkit-slider-runnable-track', '-webkit-slider-thumb', '-moz-range-track', '-moz-range-thumb', '-moz-range-progress'].every((p) => pseudos.includes(p)), pseudos.join());
201
+ ok('the input itself loses its native look', css.base.some((d) => d.prop === 'appearance' && d.value === 'none') && css.base.some((d) => d.prop === '-webkit-appearance'));
202
+ const track = css.shadow.find((x) => x.pseudo === '-webkit-slider-runnable-track').decls.find((d) => d.prop === 'background-image').value;
203
+ ok('the fill follows --dssl-p with a 0.5 fallback, so it works before the engine sets it', /var\(--dssl-p, 0\.5\)/.test(track));
204
+ ok('the thumb is vertically centred on the track in WebKit (margin-top)', css.shadow.find((x) => x.pseudo === '-webkit-slider-thumb').decls.some((d) => d.prop === 'margin-top'));
205
+ const graph = sliderCss(parseSlider('graph 10 50 90', hooks()));
206
+ ok('a graph draws one layer per bar plus the baseline', graph.shadow[0].decls.find((d) => d.prop === 'background-image').value.split('linear-gradient(').length - 1 === 4);
207
+ ok('graphs get small corners so the bars are not clipped into a semicircle', /4px/.test(graph.shadow[0].decls.find((d) => d.prop === 'border-radius').value));
208
+ }
209
+ section('slider: through the compiler');
210
+ {
211
+ const r = compile('input[type=range] { slider: fill #f80, thumb 20 #fff }');
212
+ ok('one slider rule yields the rule plus five vendor pseudo-element rules', r.ok && r.doc.shadowRules.length === 5 && r.doc.rules.length === 1);
213
+ ok('the base declarations come FIRST so an author\'s own width wins', (() => { const d = r.doc.rules[0].decls; return d.findIndex((x) => x.prop === 'width') < d.findIndex((x) => x.prop === '@slider'); })());
214
+ const own = compile('input { width: 200px\n slider: fill #f80 }');
215
+ ok('an author width written before slider: still wins (declared later in the base order)', own.ok);
216
+ ok('a selector list gets rules for every part', compile('input.a, input.b { slider: fill #f80 }').doc.shadowRules.length === 10);
217
+ ok('slider on a pseudo-element is refused', errs('.a::before { slider: fill #f80 }').some((m) => /engine-native/.test(m)));
218
+ ok('a bad slider rule is dropped as a whole, loudly', (() => { const x = compile('input { slider: thumb big }'); return !x.ok && x.doc.rules.length === 0; })());
219
+ }
220
+
221
+ /* ------------------------------------------------------------------ */
222
+ section('lint');
223
+ ok('low contrast between colour and background is flagged', warns('.a { color: #999; background: #aaa }').some((m) => /contrast is 1\.2:1/.test(m)));
224
+ ok('good contrast is quiet', warns('.a { color: #111; background: #fff }').length === 0);
225
+ ok('large text is held to 3:1, not 4.5:1', warns('.a { color: #777; background: #fff; font-size: 28 }').length === 0 && warns('.a { color: #777; background: #fff }').length === 1);
226
+ ok('tokens are followed, alpha included', warns('.a { color: color.ink/40; background: color.paper }').some((m) => /contrast/.test(m)));
227
+ ok('rules inside @media or @signal are not judged against the light tokens', warns('.a { @media dark { color: #999; background: #aaa } }').length === 0 && warns('@signal theme == dark { .a { color: #999; background: #aaa } }').length === 0);
228
+ ok('only a colour AND a background in the same rule are compared', warns('.a { color: #fff }').length === 0 && warns('.a { background: #fff }').length === 0);
229
+ ok('a gradient background is not guessed at', warns('.a { color: #fff; background: linear-gradient(red, blue) }').length === 0);
230
+ ok('a removed focus outline on a button is flagged', warns('button { outline: none }').some((m) => /focus outline is removed/.test(m)));
231
+ ok('unless a focus-visible block replaces it', warns('button { outline: none\n focus-visible { outline: 2 solid red } }').length === 0);
232
+ ok('a plain div is not nagged about focus', warns('.card { outline: none }').length === 0);
233
+ ok('outline: 0 and outline-style: none count too', warns('a { outline: 0 }').length === 1 && warns('input { outline-style: none }').length === 1);
234
+ ok('a button smaller than 24px is flagged', warns('button { width: 16; height: 16 }').some((m) => /below 24px/.test(m)));
235
+ ok('a normal button is quiet', warns('button { width: 48; height: 32 }').length === 0);
236
+ ok('lint never makes ok false (warnings only)', compile('.a { color: #999; background: #aaa }').ok === true);
237
+ ok('a lint failure can never break a compile (wrapped)', (() => { try { return compile('.a { color: var(--weird); background: var(--also) }').ok; } catch { return false; } })());
238
+
239
+ /* ------------------------------------------------------------------ */
240
+ section('import: the CSS parser');
241
+ {
242
+ const t = parseCss('a{color:red}/*c*/@media (x){b{margin:0 !important}}');
243
+ ok('rules, comments, at-rules and !important are understood', t[0].type === 'rule' && t[1].type === 'comment' && t[2].type === 'at' && t[2].body[0].body[0].important === true);
244
+ const nested = parseCss('.a{ color:red; &:hover{ color:blue } .b{ margin:0 } }');
245
+ ok('modern nesting is parsed', nested[0].body.length === 3 && nested[0].body[1].type === 'rule');
246
+ ok('semicolons and braces inside strings and url() do not split', (() => { const x = parseCss('a{content:";}{";background:url(a;b.png)}'); return x[0].body.length === 2 && x[0].body[0].value === '";}{"'; })());
247
+ ok('a declaration with no final semicolon is kept', parseCss('a{color:red}')[0].body[0].value === 'red');
248
+ ok('an at-rule statement (@import) is understood', parseCss('@import url("x.css");a{b:c}')[0].name === 'import' && parseCss('@import url("x.css");a{b:c}')[0].body === null);
249
+ }
250
+ section('import: conversion');
251
+ {
252
+ const r = (css) => importCss(css);
253
+ ok('semicolons go and px becomes a bare number', r('a{margin:0px 16px;padding:8px}').dssl.includes('margin: 0 16') && !/;/.test(r('a{margin:0px 16px;padding:8px}').dssl.replace(/\/\/.*/g, '')));
254
+ ok('unitless properties keep their px (line-height: 24px is NOT 24)', r('a{line-height:24px;z-index:3}').dssl.includes('line-height: 24px'));
255
+ ok('px inside calc() is left alone', r('a{width:calc(100% - 20px)}').dssl.includes('calc(100% - 20px)'));
256
+ ok('transition is rewritten into DSSL order', r('a{transition:color .2s ease, transform 300ms ease-in-out}').dssl.includes('transition: 200 ease color, 300 ease-in-out transform'));
257
+ ok('transition: all drops the property word', r('a{transition:all .2s ease}').dssl.includes('transition: 200 ease\n'));
258
+ ok('font: shorthand becomes longhands (DSSL font: means something else)', (() => { const o = r('a{font: italic 700 14px/1.5 Arial, sans-serif}').dssl; return o.includes('font-style: italic') && o.includes('font-weight: 700') && o.includes('font-size: 14') && o.includes('line-height: 1.5') && o.includes('font-family: Arial, sans-serif'); })());
259
+ ok('@keyframes becomes a motion with from, at N% and to', (() => { const o = r('@keyframes p{0%{opacity:1}50%{opacity:.5}100%{opacity:1}}').dssl; return o.includes('motion p {') && o.includes('from {') && o.includes('at 50% {') && o.includes('to {'); })());
260
+ ok('!important moves into pin { }', (() => { const o = r('a{color:red !important;margin:0}').dssl; return o.includes('pin {') && /pin \{\s+a \{\s+color: red/.test(o) && !/a \{\s+color: red\s+margin/.test(o); })());
261
+ ok('@font-face, @supports and @import go to one raw block, verbatim, with their semicolons', (() => { const o = r('@import url("a.css");@font-face{font-family:X;src:url(x.woff2)}@supports (display:grid){a{display:grid}}').dssl; return o.startsWith('//') && o.includes('raw {') && o.includes('font-family:X;src:url(x.woff2)') && o.includes('@supports (display:grid)'); })());
262
+ ok('@media is kept and its rules converted inside', r('@media (max-width:600px){a{padding:12px}}').dssl.includes('@media (max-width:600px) {') && r('@media (max-width:600px){a{padding:12px}}').dssl.includes('padding: 12'));
263
+ ok('custom properties are untouched (no px stripping)', r(':root{--gap:16px}').dssl.includes('--gap: 16px'));
264
+ ok('selectors, pseudo-classes and pseudo-elements pass straight through', r('a:not(.x)::after{content:"x"}').dssl.includes('a:not(.x)::after {'));
265
+ const rep = r('a{-moz-foo:1;color:red}').report;
266
+ ok('a property DSSL does not know is commented out and reported, not lost silently', rep.dropped.length === 1 && /-moz-foo/.test(rep.dropped[0].text) && r('a{-moz-foo:1;color:red}').dssl.includes('// dssl import dropped'));
267
+ ok('what the compiler rejects is commented out so the output always compiles', (() => { const x = r('a{scroll-snap-type:x mandatory;color:red}'); return x.report.compile.ok && x.report.dropped.some((d) => /scroll-snap-type/.test(d.text)); })());
268
+ ok('a typical stylesheet round-trips into something that compiles', (() => { const x = r('body{margin:0;font:16px/1.5 system-ui}.card{border:1px solid #ddd;border-radius:14px;transition:all .2s ease}.card:hover{transform:translateY(-4px)}@media (max-width:600px){.card{padding:12px}}'); return x.report.compile.ok; })());
269
+ ok('the report counts what it did', (() => { const x = r('a{margin:0px !important;color:red}@font-face{font-family:Q;src:url(q)}@keyframes k{from{opacity:0}to{opacity:1}}'); return x.report.pinned === 1 && x.report.raw === 1 && x.report.motions === 1; })());
270
+ ok('empty input gives empty, valid output', (() => { const x = r(''); return x.dssl.trim() === '' && x.report.compile.ok; })());
271
+ ok('garbage does not throw', (() => { try { return importCss('}}}{{{ ;;; @@@ a{').dssl !== undefined; } catch { return false; } })());
272
+ }
273
+
274
+ /* ------------------------------------------------------------------ */
275
+ section('share links');
276
+ {
277
+ const { encodeShare, decodeShare, shareFromHash, MAX_SHARE_URL } = await import('../src/lib/share.ts');
278
+ const samples = ['', 'a', '.card { color: red }\n', 'üñíçødé ✓ 日本語 😀', '.x { content: "-" }'.repeat(200), 'x'.repeat(20000)];
279
+ let allOk = true;
280
+ for (const s of samples) { const back = await decodeShare(await encodeShare(s)); if (back !== s) allOk = false; }
281
+ ok('text survives a round trip (empty, unicode, 20 KB)', allOk);
282
+ const big = await encodeShare('.card { color: red }\n'.repeat(400));
283
+ ok('gzip makes repetitive source much smaller in the URL', big[0] === 'z' && big.length < 400, `${big.length} chars`);
284
+ ok('the packed form is URL safe (no + / = )', /^[zp][A-Za-z0-9_-]+$/.test(big));
285
+ ok('a damaged link is null, not a crash', (await decodeShare('zNOTVALID###')) === null && (await decodeShare('')) === null && (await decodeShare('q123')) === null);
286
+ ok('only #s=... hashes are share links', shareFromHash('#s=zABC') === 'zABC' && shareFromHash('#/docs') === null && shareFromHash('#s=') === null && shareFromHash('') === null);
287
+ ok('the URL limit is a sane number', MAX_SHARE_URL >= 2000 && MAX_SHARE_URL <= 16000);
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ */
291
+ section('dssl build: what stays in the runtime');
292
+ {
293
+ const { toCSS } = await import('../public/dssl/index.js');
294
+ const r = toCSS('behavior fire { aura-glow: #f80 20\n aura-particle: #fd0 5 up }\n.torch { use fire\n background: #222\n texture: grain }\ninput[type=range] { slider: fill #f80 }');
295
+ ok('aura, texture and slider are counted as engine-native even in a rule that keeps other declarations', r.report.engineDecls === 4, JSON.stringify(r.report.engineKinds));
296
+ ok('the report names them', r.report.engineKinds.join() === 'aura-glow,aura-particles,slider,texture');
297
+ ok('the plain declaration of that rule still reaches the CSS', /\.torch \{\s+background: #222;/.test(r.css));
298
+ ok('the slider\'s vendor pseudo-element rules ARE emitted as real CSS (only the fill follower needs the engine)', /::-webkit-slider-thumb/.test(r.css) && /::-moz-range-thumb/.test(r.css));
299
+ ok('the banner states the real version', r.css.includes(`DSSL v${VERSION}`));
300
+ }
301
+
302
+ /* ------------------------------------------------------------------ */
303
+ section('release');
304
+ ok('the engine version is 0.9.x', /^0\.9\./.test(VERSION), VERSION);
305
+
306
+ console.log(`\n${pass} passed, ${fail} failed`);
307
+ process.exit(fail ? 1 : 0);
scripts/texture-gallery.mjs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* texture-gallery.mjs: render every texture preset on a swatch and save a PNG.
3
+ * node scripts/texture-gallery.mjs out.png [--base "#b8a58a"] [--cols 6]
4
+ * Useful for eyeballing a new preset, and the tests use the same page to check
5
+ * that each one actually paints something. */
6
+ import { withBrowser } from './lib/browser-harness.mjs';
7
+
8
+ const args = process.argv.slice(2);
9
+ const out = args.find((a) => a.endsWith('.png')) || 'texture-gallery.png';
10
+ const flag = (name, d) => { const i = args.indexOf(name); return i === -1 ? d : args[i + 1]; };
11
+ const base = flag('--base', '#b8a58a');
12
+ const cols = Number(flag('--cols', 6));
13
+
14
+ export const galleryPage = (base, cols) => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#f4f2f7;font:12px system-ui">
15
+ <div id=g style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:10px;padding:10px"></div>
16
+ <script type=module>
17
+ import { PRESET_NAMES, parseTexture, buildTexture } from '/dssl/texture.js';
18
+ import { parseColor } from '/dssl/color.js';
19
+ const hooks = { error: (m) => console.error(m), color: (w) => w, suggest: () => null };
20
+ const g = document.getElementById('g');
21
+ window.report = {};
22
+ for (const name of PRESET_NAMES) {
23
+ const tex = buildTexture(parseTexture(name, hooks), parseColor);
24
+ const cell = document.createElement('div');
25
+ cell.innerHTML = '<div class=sw style="height:96px;border-radius:6px;background-color:${base}"></div><div style="padding:3px 2px">' + name + '</div>';
26
+ const sw = cell.firstChild;
27
+ sw.style.backgroundImage = tex.image; sw.style.backgroundSize = tex.size; sw.style.backgroundRepeat = tex.repeat;
28
+ sw.style.backgroundPosition = tex.position; sw.style.backgroundBlendMode = tex.blend;
29
+ g.appendChild(cell);
30
+ window.report[name] = tex.count;
31
+ }
32
+ window.ready = true;
33
+ </script>`;
34
+
35
+ if (import.meta.url === `file://${process.argv[1]}`) {
36
+ await withBrowser(async ({ open }) => {
37
+ const page = await open('/gallery');
38
+ await page.waitForFunction('window.ready === true');
39
+ await page.waitForTimeout(400);
40
+ await page.screenshot({ path: out, fullPage: true });
41
+ console.log('wrote', out);
42
+ }, { pages: { '/gallery': galleryPage(base, cols) }, viewport: { width: 1100, height: 700 } });
43
+ }