Files
bindery/tests/js/layout.test.mjs
T
tim 2f4271314b Add the web interface
Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
2026-09-27 23:38:28 +02:00

130 lines
6.2 KiB
JavaScript

// Stylesheet contracts. Check the rendered layout in a browser.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const normalize = (selector) => selector.trim().replace(/\s+/g, ' ');
function declarations(selector, source = css) {
const rules = [...source.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const rule = rules.find(([, selectors]) => normalize(selectors) === selector);
assert.ok(rule, `Missing CSS rule: ${selector}`);
return Object.fromEntries(rule[2].split(';').filter((entry) => entry.trim()).map((entry) => {
const colon = entry.indexOf(':');
return [entry.slice(0, colon).trim(), entry.slice(colon + 1).trim()];
}));
}
test('header and main share a centered width without a gap between them', () => {
const shared = declarations('header, main');
assert.equal(shared.width, 'min(1100px, calc(100% - 32px))');
assert.equal(shared.margin, '0px auto');
for (const selector of ['header', 'main']) {
const styles = declarations(selector);
assert.equal(styles.width, undefined);
assert.equal(styles.margin, undefined);
}
});
test('header and main share a theme-aware frame without adding a second login frame', () => {
const frame = declarations('header, main:not(.login)');
assert.equal(frame.border, '1px solid var(--border)');
assert.equal(frame.background, 'var(--surface)');
assert.ok(frame['box-shadow']);
assert.equal(declarations('header')['border-top-left-radius'], '12px');
assert.equal(declarations('header')['border-top-right-radius'], '12px');
assert.equal(declarations('main')['border-bottom-left-radius'], '12px');
assert.equal(declarations('main')['border-bottom-right-radius'], '12px');
assert.equal(declarations('main:not(.login)')['border-top'], '0px');
assert.equal(declarations('main.login')['max-width'], '400px');
assert.equal(declarations('main > :last-child')['margin-bottom'], '0');
});
test('header and main keep matching gutters on narrow screens', () => {
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
const shared = declarations('header, main', mobile);
assert.equal(shared.width, 'calc(100% - 24px)');
assert.equal(shared.margin, undefined);
assert.equal(shared.padding, '12px');
assert.equal(declarations('header', mobile)['padding-bottom'], '0');
});
test('navigation looks like tabs and joins the active link to the main surface', () => {
const navigation = declarations('header nav');
assert.equal(navigation['flex-basis'], '100%');
assert.equal(navigation['margin-bottom'], '-1px');
assert.equal(declarations('header nav a')['border-radius'], '8px 8px 0 0');
const current = declarations("header nav a[aria-current='page']");
assert.equal(current.background, 'var(--surface)');
assert.equal(current.color, 'var(--text)');
assert.equal(current['border-bottom-color'], 'var(--surface)');
assert.equal(current['box-shadow'], undefined);
const focus = declarations('a:focus-visible, button:focus-visible, summary:focus-visible');
assert.equal(focus.outline, '2px solid var(--accent)');
});
test('inactive tabs have a visible outline and fill without a shadow', () => {
const tab = declarations('header nav a');
assert.equal(tab.border, '1px solid var(--border)');
assert.equal(tab.background, 'var(--bg)');
assert.equal(tab['box-shadow'], undefined);
const hover = declarations('header nav a:hover');
assert.equal(hover.background, 'var(--surface)');
assert.equal(hover['box-shadow'], undefined);
});
test('the header title and icon grow without changing the login title', () => {
assert.equal(declarations('header .brand')['font-size'], '1.65rem');
assert.equal(declarations('header .brand img').width, '32px');
assert.equal(declarations('header .brand img').height, '32px');
assert.equal(declarations('.brand')['font-size'], '1.1rem');
});
test('the Add stories textarea uses the smaller text size without shrinking other textareas', () => {
assert.equal(declarations('.ini, .story-urls')['font-size'], 'var(--text-sm)');
assert.equal(declarations('textarea')['font-size'], undefined);
});
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
test('cards keep their borders and use a subtle background in both themes', () => {
const card = declarations('.card');
assert.equal(card.background, 'var(--card-bg)');
assert.equal(card.border, '1px solid var(--border)');
assert.equal(card['border-radius'], '8px');
assert.equal(declarations('.card-danger')['border-color'], 'var(--err)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--card-bg'], theme['--surface']);
assert.notEqual(theme['--card-bg'], theme['--bg']);
const channels = theme['--card-bg'].slice(1).match(/../g).map((channel) => parseInt(channel, 16));
assert.ok(channels.every((channel) => channel === channels[0]), 'Cards use neutral grey without a colour tint');
for (const foreground of ['--text', '--muted', '--accent', '--err']) {
const light = [luminance(theme[foreground]), luminance(theme['--card-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on a card`);
}
}
});
test('header text has at least 4.5:1 contrast in both color themes', () => {
assert.equal(declarations('header').background, 'var(--header-bg)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--header-bg'], theme['--surface']);
for (const foreground of ['--text', '--muted']) {
const light = [luminance(theme[foreground]), luminance(theme['--header-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on the header`);
}
}
});