201 lines
10 KiB
JavaScript
201 lines
10 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);
|
|
});
|
|
|
|
test('story timestamps fit their text and chapter details use plain small text', () => {
|
|
const desktop = css.slice(css.indexOf('@media (min-width: 601px)'));
|
|
const date = declarations('.date');
|
|
assert.equal(date['white-space'], 'nowrap');
|
|
assert.equal(date['min-inline-size'], undefined);
|
|
const storyDate = declarations('.stories-table .date', desktop);
|
|
assert.equal(storyDate['inline-size'], '1%');
|
|
assert.equal(storyDate['min-inline-size'], undefined);
|
|
const details = declarations(".stories-table td[data-label='Chapters'] small");
|
|
assert.equal(details.display, 'block');
|
|
assert.equal(details['white-space'], 'nowrap');
|
|
assert.equal(details.background, undefined);
|
|
assert.equal(details.border, undefined);
|
|
assert.equal(details.padding, undefined);
|
|
});
|
|
|
|
test('sortable headings show direction and remain usable above story cards on narrow screens', () => {
|
|
assert.equal(declarations('.stories-table th button').background, 'none');
|
|
assert.equal(declarations('.stories-table th[aria-sort] button::after').content, "'↕'");
|
|
assert.equal(declarations(".stories-table th[aria-sort='ascending'] button::after").content, "'↑'");
|
|
assert.equal(declarations(".stories-table th[aria-sort='descending'] button::after").content, "'↓'");
|
|
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
|
|
assert.equal(declarations('.stories-table thead tr', mobile).display, 'flex');
|
|
assert.notEqual(declarations('.stories-table th', mobile).display, 'none');
|
|
assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/);
|
|
});
|
|
|
|
test('on narrow screens the headings are sort chips, and only the sorted chip is tinted with an arrow', () => {
|
|
const start = css.indexOf('@media (max-width: 600px)', css.indexOf('.stories-table th button'));
|
|
const mobile = css.slice(start);
|
|
assert.equal(declarations('.stories-table thead tr', mobile)['flex-wrap'], 'wrap');
|
|
const caption = declarations('.stories-table thead tr::before', mobile);
|
|
assert.equal(caption.content, "'Sort by'");
|
|
assert.equal(caption['flex-basis'], '100%');
|
|
const chip = declarations('.stories-table th button', mobile);
|
|
assert.equal(chip['border-radius'], '999px');
|
|
assert.equal(chip['inline-size'], 'auto');
|
|
assert.equal(chip.padding, '3px 10px');
|
|
assert.equal(chip['font-size'], 'var(--text-sm)');
|
|
assert.equal(chip.background, 'var(--surface)');
|
|
assert.equal(declarations(".stories-table th[aria-sort='none'] button::after", mobile).display, 'none');
|
|
const sorted = declarations(".stories-table th:not([aria-sort='none']) button", mobile);
|
|
assert.equal(sorted.background, 'var(--info-bg)');
|
|
assert.equal(sorted.color, 'var(--info)');
|
|
assert.equal(sorted['border-color'], undefined, 'The sorted chip keeps the soft border');
|
|
// The wide layout keeps plain headings.
|
|
const wide = css.slice(0, start);
|
|
assert.doesNotMatch(wide, /\.stories-table thead tr::before/);
|
|
assert.equal(declarations('.stories-table th button', wide).border, '0');
|
|
});
|
|
|
|
test('sortable headings fill the column with one clickable highlight without shifting layout', () => {
|
|
assert.equal(declarations('.stories-table th').padding, '0');
|
|
const button = declarations('.stories-table th button');
|
|
assert.equal(button['inline-size'], '100%');
|
|
assert.equal(button['justify-content'], 'flex-start');
|
|
assert.equal(button.padding, '6px 8px');
|
|
assert.equal(button.margin, undefined);
|
|
const highlight = declarations('.stories-table th button:is(:hover, :focus-visible)');
|
|
assert.equal(highlight.background, 'var(--info-bg)');
|
|
assert.equal(highlight.color, 'var(--info)');
|
|
assert.equal(highlight['text-decoration'], undefined);
|
|
assert.equal(highlight.padding, undefined);
|
|
assert.equal(highlight.border, undefined);
|
|
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
|
|
});
|
|
|
|
test('disabled checkbox labels use muted text', () => {
|
|
assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)');
|
|
});
|
|
|
|
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`);
|
|
}
|
|
}
|
|
});
|