// 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('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`); } } });