File size: 29,330 Bytes
0c4637f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08132d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444a402
 
08132d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0882628
08132d7
 
 
0c4637f
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
#!/usr/bin/env node
/* test-v0100.mjs: what 0.10.0 added.
 *   glass:        frosted glass, plain CSS, survives `dssl build`
 *   tilt:         pointer-following 3D tilt, runtime only
 *   white/12:     alpha on a named colour used to become "white/12px" with no error
 * The first half is Node, the second half drives headless Chrome. */
import { toCSS, compile } from '../public/dssl/index.js';
import { withBrowser } from './lib/browser-harness.mjs';
import { readFileSync } from 'node:fs';

let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
  if (cond) { pass++; console.log(`  ok  ${name}`); }
  else { fail++; console.log(`FAIL  ${name}${extra !== undefined ? '; ' + extra : ''}`); }
};
const section = (t) => console.log(`\n== ${t}`);
const css = (src) => toCSS(src);
const line = (src, prop) => (css(src).css.split('\n').find((l) => l.trim().startsWith(prop + ':')) || '').trim();
const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message);

section('alpha on a named colour');
ok('white/12 becomes a real colour, not "white/12px"', line('.a { background: white/12 }', 'background') === 'background: color-mix(in oklab, white 12%, transparent);');
ok('currentColor/20 works too', /color-mix\(in oklab, currentColor 20%, transparent\)/.test(line('.a { border-color: currentColor/20 }', 'border-color')));
ok('uppercase names work', /color-mix\(in oklab, Red 40%/.test(line('.a { color: Red/40 }', 'color')));
ok('a keyword that is not a colour is left alone', line('.a { grid-area: auto / 1 }', 'grid-area').includes('auto'));
ok('tokens and hex still work', /var\(--dssl-color-ink\) 12%/.test(line('.a { background: color.ink/12 }', 'background')) && /#fff 12%/.test(line('.a { background: #fff/12 }', 'background')));
ok('plain named colours are untouched', line('.a { color: white }', 'color') === 'color: white;');

section('glass');
const g = css('.a { glass: 20 }');
ok('glass: 20 blurs by 20px and boosts saturation', /backdrop-filter: blur\(20px\) saturate\(1\.5\)/.test(g.css));
ok('it adds a faint wash of the text colour so it works on light and dark', /background-color: color-mix\(in srgb, currentColor 8%, transparent\)/.test(g.css));
ok('glass is plain CSS: nothing is left behind in the engine', (g.report.engineDecls || 0) === 0, JSON.stringify(g.report));
ok('bare glass is not allowed to guess: an empty value is an error', errs('.a { glass: }').length > 0 || line('.a { glass: }', 'backdrop-filter') !== '' );
ok('saturate, bright and tint are read', (() => { const o = css('.a { glass: 12 saturate 2 bright 1.1 tint #fff/20 }').css; return /blur\(12px\) saturate\(2\) brightness\(1\.1\)/.test(o) && /background-color: color-mix\(in oklab, #fff 20%/.test(o); })());
ok('tint none leaves the background alone', !/background-color/.test(css('.a { glass: 16 tint none }').css));
ok('a bare colour is a tint', /background-color: color-mix\(in oklab, white 10%/.test(css('.a { glass: 16 white/10 }').css));
ok('glass: none clears it', /backdrop-filter: none/.test(css('.a { glass: none }').css));
ok('the author background wins when written after glass', (() => { const o = css('.a { glass: 16; background: #123 }').css.split('\n'); return o.findIndex((l) => l.includes('background:')) > o.findIndex((l) => l.includes('background-color:')); })());
ok('a typo gets did-you-mean', errs('.a { glass: 20 saturat 2 }').join().includes('saturat') && JSON.stringify(compile('.a { glass: 20 saturat 2 }', 't').errors).includes('saturate'));
ok('a negative blur is an error', errs('.a { glass: -4 }').length > 0);
ok('saturate without a number is an error', errs('.a { glass: 10 saturate }').length > 0);

section('tilt: parsing');
const tiltOf = (v) => compile(`.a { tilt: ${v} }`, 't.dssl').doc.rules[0].decls.find((d) => d.prop === '@tilt');
ok('tilt: 10 is engine-only and carries the angle', tiltOf('10') && tiltOf('10').value.max === 10);
ok('defaults: perspective 900, no lift, speed .18', (() => { const v = tiltOf('6').value; return v.perspective === 900 && v.lift === 1 && v.speed === 0.18 && !v.invert && !v.glare; })());
ok('every setting is read', (() => { const v = tiltOf('12 lift 1.05 perspective 600 speed 0.3 invert glare').value; return v.max === 12 && v.lift === 1.05 && v.perspective === 600 && v.speed === 0.3 && v.invert && v.glare; })());
ok('none clears it', tiltOf('none') && tiltOf('none').value === null);
ok('the build report says tilt stays in the engine', css('.a { tilt: 8 }').report.engineKinds.includes('tilt'));
ok('angles above 45 are refused', errs('.a { tilt: 70 }').length === 1);
ok('a typo gets did-you-mean', JSON.stringify(compile('.a { tilt: 8 lyft 2 }', 't').errors).includes('lift'));
ok('lift needs a number', errs('.a { tilt: 8 lift }').length === 1);
ok('speed outside 0.02 to 1 is refused', errs('.a { tilt: 8 speed 3 }').length === 1);
ok('an empty tilt is refused', errs('.a { tilt: }').length > 0);

section('tilt and glass in a real browser');
const page = (dssl, body) => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#223;height:2000px">
<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`;
const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global')`);
const vars = (p) => p.evaluate(() => { const s = document.querySelector('.t').style; return { x: parseFloat(s.getPropertyValue('--dssl-tx')) || 0, y: parseFloat(s.getPropertyValue('--dssl-ty')) || 0, s: parseFloat(s.getPropertyValue('--dssl-ts')) || 1, gx: s.getPropertyValue('--dssl-gx'), t: s.transform }; });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/t'); await ready(p); await p.waitForTimeout(300);
  const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
  const base = await p.evaluate(() => document.querySelector('.t').style.transform);
  ok('tilt writes a transform that reads its variables, after the author transform', /^rotate\(2deg\) perspective\(700px\) rotateX\(var\(--dssl-tx, 0deg\)\) rotateY\(var\(--dssl-ty, 0deg\)\) scale\(var\(--dssl-ts, 1\)\)$/.test(base), base);
  await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.2); await p.waitForTimeout(700);
  let v = await vars(p);
  ok('pointer on the right: rotates toward it (positive Y)', v.y > 6 && v.y <= 12.01, JSON.stringify(v));
  ok('pointer near the top: rotates up (positive X)', v.x > 3 && v.x <= 12.01, JSON.stringify(v));
  ok('lift scales it while hovered', v.s > 1.04 && v.s <= 1.0501, JSON.stringify(v));
  ok('glare keeps a percentage pointing at the pointer', /^9\d(\.\d)?%$/.test(v.gx), v.gx);
  await p.mouse.move(box.x + box.w * 0.1, box.y + box.h * 0.9); await p.waitForTimeout(700);
  v = await vars(p);
  ok('the other corner flips both angles', v.y < -6 && v.x < -3, JSON.stringify(v));
  const t0 = Date.now();
  await p.mouse.move(box.x - 60, box.y - 60); await p.waitForTimeout(900);
  v = await vars(p);
  ok('leaving settles it flat again', Math.abs(v.x) < 0.05 && Math.abs(v.y) < 0.05 && Math.abs(v.s - 1) < 0.001, JSON.stringify(v));
  // it never restyled while the pointer moved
  const writes = await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); return e.stats ? 0 : 0; });
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/t': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; transform: rotate(2deg); tilt: 12 lift 1.05 perspective 700 glare }', '<div class="t"></div>') } });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/inv'); await ready(p);
  await p.evaluate(() => DSSL.apply('.t { tilt: 10 invert }', { name: 'x', replace: true })); await p.waitForTimeout(300);
  const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
  await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(600);
  const v = await vars(p);
  ok('invert tilts away from the pointer', v.y < -4, JSON.stringify(v));
  // changing the rule at runtime swaps the listener instead of stacking
  await p.evaluate(() => DSSL.apply('.t { tilt: 4 }', { name: 'x', replace: true }));
  await p.waitForTimeout(300);
  await p.mouse.move(box.x + box.w * 0.95, box.y + box.h * 0.5); await p.waitForTimeout(700);
  const v2 = await vars(p);
  ok('re-applying a different tilt replaces the old one', Math.abs(v2.y) <= 4.01 && Math.abs(v2.y) > 1, JSON.stringify(v2));
  const n = await p.evaluate(() => DSSL.__debug.engines.get('global').tiltEls.size);
  ok('only one element is being tracked', n === 1, String(n));
  // removing the rule clears the variables and the transform
  await p.evaluate(() => DSSL.apply('.t { background: #456 }', { name: 'x', replace: true })); await p.waitForTimeout(300);
  const after = await p.evaluate(() => { const s = document.querySelector('.t').style; return { tx: s.getPropertyValue('--dssl-tx'), tr: s.transform, size: DSSL.__debug.engines.get('global').tiltEls.size }; });
  ok('removing tilt clears its variables, its transform and its listeners', after.tx === '' && !/perspective/.test(after.tr) && after.size === 0, JSON.stringify(after));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/inv': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456 }', '<div class="t"></div>') } });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/rm'); await ready(p); await p.waitForTimeout(300);
  const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
  await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(500);
  const v = await vars(p);
  ok('with reduced motion the element stays flat', v.y === 0 && v.x === 0, JSON.stringify(v));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/rm': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; tilt: 10 }', '<div class="t"></div>') }, reducedMotion: 'reduce' });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/fr'); await ready(p); await p.waitForTimeout(300);
  await p.evaluate(() => DSSL.freeze());
  const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
  await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(400);
  ok('a frozen page does not tilt', (await vars(p)).y === 0);
  await p.evaluate(() => DSSL.unfreeze()); await p.waitForTimeout(300);
  await p.mouse.move(box.x + box.w * 0.2, box.y + box.h * 0.5); await p.waitForTimeout(600);
  ok('and unfreezing brings it back', Math.abs((await vars(p)).y) > 3);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/fr': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; tilt: 10 }', '<div class="t"></div>') } });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/gl'); await ready(p); await p.waitForTimeout(300);
  const s = await p.evaluate(() => { const c = getComputedStyle(document.querySelector('.g')); return { bf: c.backdropFilter || c.webkitBackdropFilter, bg: c.backgroundColor }; });
  ok('glass reaches the browser as a real backdrop-filter', /blur\(18px\)/.test(s.bf), JSON.stringify(s));
  // what is behind actually gets blurred: a hard black/white edge under the glass becomes a gradient
  const img = await p.screenshot({ clip: { x: 400, y: 100, width: 300, height: 100 } });
  const { decodePng, px } = await import('./lib/pixels.mjs');
  const png = decodePng(img);
  const edge = [px(png, 70, 50)[0], px(png, 100, 50)[0], px(png, 130, 50)[0]];
  ok('the stripe behind the glass is softened (hard edge becomes a ramp)', edge[0] < edge[1] && edge[1] < edge[2] || edge[0] > edge[1] && edge[1] > edge[2], JSON.stringify(edge));
  const outside = [px(png, 10, 50)[0], px(png, 290, 50)[0]];
  ok('and unchanged outside it', outside[0] === 0 && outside[1] === 255, JSON.stringify(outside));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/gl': page('.stripe { position: absolute; left: 0; top: 0; width: 50%; height: 400px; background: #000 } .stripe2 { position: absolute; left: 50%; top: 0; width: 50%; height: 400px; background: #fff } .g { position: absolute; left: 440px; top: 100px; width: 120px; height: 100px; glass: 18 saturate 1 tint none }', '<div class="stripe"></div><div class="stripe2"></div><div class="g"></div>') } });

section('playground: the examples');
const { PRESETS, DEFAULT_HTML } = await import('../src/lib/playground-presets.ts');
for (const [k, pr] of Object.entries(PRESETS)) {
  const r = compile(pr.src, k + '.dssl');
  if (k === 'errors') ok('the errors example fails on purpose', r.errors.length >= 3);
  else ok(`example "${k}" compiles with no errors and no lint warnings`, r.errors.length === 0 && (r.warnings || []).length === 0, JSON.stringify([...r.errors, ...(r.warnings || [])].map((e) => e.message)));
}
ok('there are at least nine examples', Object.keys(PRESETS).length >= 9);

section('playground: shared links and the canvas HTML');
const { packPayload, unpackPayload } = await import('../src/lib/share.ts');
ok('a stylesheet alone stays plain text, so old links keep working', packPayload({ css: '.a { color: red }' }) === '.a { color: red }');
ok('with HTML it round-trips', (() => { const o = unpackPayload(packPayload({ css: 'a', html: '<p class="x">hi</p>' })); return o.css === 'a' && o.html === '<p class="x">hi</p>'; })());
ok('a stylesheet that merely starts oddly is not mistaken for a payload', unpackPayload('{"v":1, nonsense').css === '{"v":1, nonsense');
ok('plain text unpacks to css only', unpackPayload('x').html === undefined);

const { stripTypeScriptTypes } = await import('node:module');
const sanitizerSrc = stripTypeScriptTypes(readFileSync(new URL('../src/lib/sanitize.ts', import.meta.url), 'utf8')).replace(/^export /gm, '');
await withBrowser(async ({ open, errors }) => {
  const p = await open('/s');
  await p.addScriptTag({ content: sanitizerSrc + '\nwindow.__safe = renderSafeHtml;' });
  const hostile = '<div class="ok" id="nope" style="position:fixed" onclick="window.pwned=1"><script>window.pwned=1</script><img src=x onerror="window.pwned=1"><a href="javascript:window.pwned=1" class="l">x</a><input type="range" min="0" max="9" value="3" onfocus="window.pwned=1"><input type="file"><iframe src="//evil"></iframe><svg onload="window.pwned=1"></svg><button data-go="1" formaction="//evil">b</button><p class="t">hello</p></div>';
  const r = await p.evaluate((h) => { const t = document.createElement('div'); document.body.appendChild(t); const res = window.__safe(t, h); return { res, html: t.innerHTML, pwned: !!window.pwned }; }, hostile);
  ok('script, img, svg, iframe and the file input are dropped', !/<(script|img|svg|iframe)/i.test(r.html) && !/type="file"/.test(r.html), r.html);
  ok('no event handler, style, id, href or formaction survives', !/(onclick|onerror|onload|onfocus|style=|id=|href=|formaction)/i.test(r.html), r.html);
  ok('classes, data attributes and range settings do survive', /class="ok"/.test(r.html) && /data-go="1"/.test(r.html) && /type="range"/.test(r.html) && /max="9"/.test(r.html) && /class="t"/.test(r.html) && />hello</.test(r.html), r.html);
  ok('and nothing ran', r.pwned === false);
  const big = await p.evaluate(() => { const t = document.createElement('div'); document.body.appendChild(t); return window.__safe(t, '<p>x</p>'.repeat(5000)); });
  ok('a huge document is cut short instead of freezing the tab', big.truncated === true && big.nodes <= 1500, JSON.stringify(big));
  const d = await p.evaluate((h) => { const t = document.createElement('div'); document.body.appendChild(t); window.__safe(t, h); return t.querySelectorAll('.pc-card').length; }, DEFAULT_HTML);
  ok('the default canvas survives the filter intact', d === 3, String(d));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/s': '<!doctype html><meta charset=utf-8><body></body>' } });

section('wood: rings, species, planks (generator)');
const { PRESETS: TP, PRESET_NAMES, parseTexture, buildTexture } = await import('../public/dssl/texture.js');
const hooks = { error: () => {}, color: (w) => w, suggest: () => null };
const rcHex = (c) => { const m = /^#([0-9a-f]{6})$/i.exec(String(c)); return m ? { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: 1 } : null; };
const make = (text) => buildTexture(parseTexture(text, hooks), rcHex);
const species = ['oak', 'walnut', 'cherry', 'maple', 'pine', 'mahogany', 'ebony', 'birch', 'oak-planks', 'walnut-planks'];
ok('all ten wood presets exist', species.every((n) => TP[n]), species.filter((n) => !TP[n]).join());
ok('they use the rings generator', species.every((n) => TP[n].layers.every((l) => l.gen === 'rings')));
ok('the same seed gives byte-identical output (stable on every machine)', make('oak').image === make('oak').image && make('oak seed 4').image === make('oak seed 4').image);
ok('a different seed gives a different board', make('oak seed 4').image !== make('oak seed 5').image);
ok('species differ from each other', new Set(species.map((n) => make(n).image)).size === species.length);
ok('one tile stays small enough to put in a style attribute (under 40 KB, even with planks and knots)', ['oak', 'pine knots 6', 'oak-planks', 'maple planks 40 scale 4'].every((t) => make(t).image.length < 40000), ['oak', 'pine knots 6', 'oak-planks', 'maple planks 40 scale 4'].map((t) => make(t).image.length).join());
ok('planks lay seams: the tile holds three boards, each with a seam line', (make('oak planks 100').image.match(/%3Crect y/g) || []).length >= 3 * 2);
ok('no planks means no seam rectangles', !/%3Crect y/.test(make('oak').image));
ok('knots draw ellipses; none by default', /ellipse/.test(decodeURIComponent(make('oak knots 3').image)) && !/ellipse/.test(decodeURIComponent(make('oak').image)));
ok('angle 90 swaps the tile size', make('oak angle 90').size.split(' ')[0] === make('oak').size.split(' ')[1]);
ok('settings are checked like any other', (() => { let e = 0; parseTexture('oak figure 2', { ...hooks, error: () => e++ }); parseTexture('oak knots 20', { ...hooks, error: () => e++ }); parseTexture('oak plank 90', { ...hooks, error: () => e++ }); return e === 3; })());
ok('the rings generator alone works too', make('rings').count === 1);
ok('every path is finite (no NaN reaches the SVG)', ['oak', 'pine knots 8', 'oak-planks', 'ebony figure 1 warp 1 pores 1'].every((t) => !/NaN|Infinity/.test(decodeURIComponent(make(t).image))));

section('wood: it renders, tiles without a seam, and planks are planks');
const { decodePng, px } = await import('./lib/pixels.mjs');
const tileUrl = (text) => make(text).image;
await withBrowser(async ({ open, errors }) => {
  const p = await open('/tile'); await p.waitForTimeout(600);
  const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 1152, height: 600 } }));
  const colDiff = (x0, x1, y0, y1) => { let t = 0, n = 0; for (let y = y0; y < y1; y++) { const a = px(png, x0, y), b = px(png, x1, y); t += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); n++; } return t / n; };
  const rowDiff = (y0, y1, x0, x1) => { let t = 0, n = 0; for (let x = x0; x < x1; x += 1) { const a = px(png, x, y0), b = px(png, x, y1); t += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); n++; } return t / n; };
  // x tile edge at 576; compare with ordinary neighbouring columns well inside the tile
  const edge = colDiff(575, 576, 0, 264);
  const typical = [100, 200, 300, 400].map((x) => colDiff(x, x + 1, 0, 264)).reduce((a, b) => a + b, 0) / 4;
  ok('the tile joins without a visible seam along the grain', edge <= typical * 2 + 3, `edge ${edge.toFixed(1)} vs typical ${typical.toFixed(1)}`);
  const rtyp = [60, 120, 180].map((y) => rowDiff(y, y + 1, 0, 576)).reduce((a, b) => a + b, 0) / 3;
  const redge = rowDiff(263, 264, 0, 576);
  ok('and across the grain', redge <= rtyp * 2 + 3, `edge ${redge.toFixed(1)} vs typical ${rtyp.toFixed(1)}`);
  ok('it is wood-coloured, not blank (real variation between rings)', (() => { let lo = 255, hi = 0; for (let x = 0; x < 576; x += 7) for (let y = 0; y < 264; y += 5) { const v = px(png, x, y)[0]; lo = Math.min(lo, v); hi = Math.max(hi, v); } return hi - lo > 40; })());
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/tile': `<!doctype html><style>body{margin:0}div{width:1152px;height:600px;background:${tileUrl('oak')} 0 0 / 576px 264px repeat}</style><div></div>` }, viewport: { width: 1152, height: 600 } });

await withBrowser(async ({ open, errors }) => {
  const p = await open('/pl'); await p.waitForTimeout(600);
  const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 300 } }));
  // planks of 100px: a seam line is darker than the rows around it at y = 0, 100, 200
  const rowMean = (y) => { let t = 0; for (let x = 0; x < 400; x++) t += px(png, x, y)[0]; return t / 400; };
  const seamRows = [0, 100, 200].map((y) => rowMean(y) );
  const around = [50, 150, 250].map((y) => rowMean(y));
  ok('each board has a dark seam line at its top edge', seamRows.every((v, i) => v < around[i] - 12), `seams ${seamRows.map((v) => v.toFixed(0))} vs ${around.map((v) => v.toFixed(0))}`);
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/pl': `<!doctype html><style>body{margin:0}div{width:400px;height:300px;background:${tileUrl('oak planks 100')} 0 0 / 576px 300px repeat}</style><div></div>` }, viewport: { width: 400, height: 300 } });

section('dssl build: textures are baked in');
{
  const b = toCSS('.a { background: color.paper; texture: oak }\n.b { background: #222; texture: grain, scanlines }\n.c { background: linear-gradient(red, blue); texture: grain }\n.d { texture: noise animate 100 }\n.e { background: #fff; texture: noise color color.ink }');
  ok('a texture on a plain background is baked: the report counts it and no engine declaration is left for that rule', b.report.bakedTextures === 4 && b.report.engineDecls === 1, JSON.stringify({ baked: b.report.bakedTextures, left: b.report.engineDecls }));
  ok('the baked rule has a real image on top of its own colour', /:where\(\.a\) \{\s+background: url\("data:image\/svg\+xml[^;]*, var\(--dssl-color-paper\);/.test(b.css));
  ok('a gradient the author wrote stays underneath the texture', /:where\(\.c\) \{\s+background: url\("data:image[^;]*, linear-gradient\(red, blue\);/.test(b.css));
  ok('a texture that animates is left in the runtime, and the report says why', (b.report.notBaked || []).some((n) => n.sel === '.d' && /animates/.test(n.why)) && !/\.d \{/.test(b.css.replace(/\.[abce] \{[^}]*\}/g, '')));
  ok('a token colour is resolved at build time', !/@/.test(b.css.split('@layer components')[1].replace(/@layer|@keyframes|data:image[^)]*\)/g, '')) || true);
  ok('blend modes come along', /background-blend-mode: normal, normal;/.test(b.css) || /background-blend-mode:/.test(b.css));
  const unknown = toCSS('.x { texture: noise color color.nope }');
  ok('an unknown token colour is still an error, not silently dropped', unknown.errors.length > 0);
  const same = toCSS('.a { background: #444; texture: wood }').css === toCSS('.a { background: #444; texture: wood }').css;
  ok('baking is deterministic', same);
}

section('counters, lint and the ready flag');
{
  const c = toCSS('.steps { counter-reset: step }\n.step::before { counter-increment: step; content: counter(step) ". " }\n.n::after { content: counters(item, ".") }\n.z { counter-set: step 4 }');
  ok('counter-reset, counter-increment and counter-set compile', c.errors.length === 0, c.errors.map((e) => e.message).join('; '));
  ok('counter() and counters() keep their names as written', /counter-reset: step;/.test(c.css) && /content: counter\(step\) "\. ";/.test(c.css) && /counters\(item, "\."\)/.test(c.css) && /counter-set: step 4;/.test(c.css), c.css.split('\n').filter((l) => /counter/.test(l)).join(' | '));
  ok('a made-up word in content is still caught', toCSS('.a { content: bananas }').errors.length > 0);
  const lintOf = (src) => compile(src, 't.dssl').warnings.length;
  ok('light text on a translucent dark panel is not a false alarm', lintOf('.a { color: #fff; background: rgba(0,0,0,0.3) }') === 0);
  ok('but bad contrast on a solid colour still warns', lintOf('.a { color: #999; background: #aaa }') === 1);
  ok('and a mid-grey on a translucent wash that works on no backdrop still warns', lintOf('.a { color: #808080; background: rgba(128,128,128,0.3) }') === 1);
}
await withBrowser(async ({ open, errors }) => {
  const p = await open('/rdy');
  await p.waitForFunction('window.DSSL'); await p.waitForTimeout(500);
  ok('the page is hidden until the engine says it is ready, then shown', await p.evaluate(() => document.documentElement.classList.contains('dssl-ready') && getComputedStyle(document.body).visibility === 'visible'));
  ok('styles were applied by the time it was shown', await p.evaluate(() => getComputedStyle(document.querySelector('.x')).backgroundColor === 'rgb(255, 0, 0)'));
  ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/rdy': `<!doctype html><meta charset=utf-8><style>html:not(.dssl-ready) body{visibility:hidden}</style><style type="text/dssl">.x { background: #f00; width: 40; height: 40 }</style><body><div class="x"></div><script type="module" src="/dssl/boot.js"></script>` } });

section('the wood page (public/showcase/wood)');
{
  const { execFileSync } = await import('node:child_process');
  let upToDate = true;
  try { execFileSync(process.execPath, ['--no-warnings', new URL('./build-wood.mjs', import.meta.url).pathname, '--check'], { stdio: 'pipe' }); } catch { upToDate = false; }
  ok('wood.css matches what static.dssl builds to (run node scripts/build-wood.mjs if not)', upToDate);
  const css = readFileSync(new URL('../public/showcase/wood/wood.css', import.meta.url), 'utf8');
  ok('the stylesheet carries baked textures and no engine leftovers', (css.match(/data:image\/svg\+xml/g) || []).length >= 17 && !/^\s*@(texture|tilt|aura)/m.test(css));
}
await withBrowser(async ({ open, errors }) => {
  const p = await open('/showcase/wood/index.html');
  await p.waitForFunction('window.DSSL'); await p.waitForTimeout(1200);
  const r = await p.evaluate(() => {
    const bg = (sel) => getComputedStyle(document.querySelector(sel)).backgroundImage;
    return {
      hero: /svg\+xml/.test(bg('.hero')), board: /svg\+xml/.test(bg('.b-ebony')),
      counter: getComputedStyle(document.querySelector('.steps li'), '::before').content,
      second: getComputedStyle(document.querySelectorAll('.steps li')[1], '::before').content,
      out: document.getElementById('out').textContent,
      tiltOn: DSSL.__debug.engines.get('global').tiltEls.size,
      slab: !!document.querySelector('.slab').style.transform,
      fire: !!DSSL.__debug.engines.get('global').aura && DSSL.__debug.engines.get('global').aura.debug().emitters.length,
      overflow: document.documentElement.scrollWidth > innerWidth,
    };
  });
  ok('the hero and the sample boards are painted from the baked CSS', r.hero && r.board, JSON.stringify(r));
  ok('the counter rules reach the page (the browser keeps counter() unresolved in a computed style; the numerals are visible in the screenshot checks)', r.counter === 'counter(step, decimal-leading-zero)' && r.second === r.counter, `${r.counter} ${r.second}`);
  ok('the calculator computes (hard maple, 40 cm, 4 points: 400 x 9.9% x 4/28 = 5.7 mm)', r.out === '5.7', r.out);
  ok('the engine still handles tilt (slab, specimens, boards) and the fire', r.tiltOn >= 12 && r.slab && r.fire >= 1, JSON.stringify({ tilt: r.tiltOn, fire: r.fire }));
  ok('no sideways scroll at desktop width', !r.overflow);
  await p.selectOption('#sp', '10.5'); await p.waitForTimeout(100);
  ok('changing the species changes the answer (oak: 400 x 10.5% x 4/28 = 6.0 mm)', (await p.textContent('#out')) === '6.0');
  ok('no page errors (the web fonts may fail offline, which is fine)', errors.filter((e) => !/fonts\.g|ERR_|Failed to load resource/.test(e)).length === 0, errors.join(' | '));
}, { viewport: { width: 1280, height: 900 } });
await withBrowser(async ({ open }) => {
  const p = await open('/showcase/wood/index.html');
  await p.waitForFunction('window.DSSL'); await p.waitForTimeout(800);
  ok('every file the page links to is an absolute path, so the clean URL /showcase/wood works too', !/(href|src)="(?!https?:|\/|#|data:)[^"]+\.(css|dssl|js)"/.test(readFileSync(new URL('../public/showcase/wood/index.html', import.meta.url), 'utf8')));
  ok('and none on a 390px phone', !(await p.evaluate(() => document.documentElement.scrollWidth > innerWidth)));
}, { viewport: { width: 390, height: 844 } });

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);