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.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
// 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`);
}
}
});