Commit ·
39309b1
1
Parent(s): 9ec7404
v0.9.0: texture (30 presets), aura glow and particles, range sliders, lint, CSS import
Browse filesEngine-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 +72 -0
- scripts/lib/pixels.mjs +29 -0
- scripts/test-all.mjs +43 -0
- scripts/test-browser-v090.mjs +423 -0
- scripts/test-v081.mjs +3 -2
- scripts/test-v090.mjs +307 -0
- scripts/texture-gallery.mjs +43 -0
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 |
-
|
|
|
|
| 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 |
+
}
|