From 1e50d6351285ef5838af26ed4a5528e61e435ae9 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 20 Sep 2026 19:43:38 +0300 Subject: [PATCH 1/2] fix(a11y): derive the audited routes from the route inventory The gate kept its own hand-typed route list, which is the second copy of a list this repo keeps getting bitten by. It drifted twice: /code-signing-policy had to be added in a follow-up commit, and /helper plus seventeen engine archives shipped in v1.3.0 audited by nothing, because adding a page to site.config.json does not add it to a list that lives somewhere else. scripts/a11y-routes.mjs derives the list instead. /rss.xml is excluded as not an HTML page; blog posts and engine archives are one template over many routes, so one representative stands for each family the way it already did. /404 is covered now that the list is derived rather than curated, and scores 100. Two tests read dist and assert the derivation rather than another literal: every standalone page that builds is audited, and each templated family has a representative that actually exists. Removing /helper from the audit fails the first one. 22 routes, all >= 95. --- scripts/a11y-routes.mjs | 23 ++++++++++++++++++++ scripts/lighthouse-gate.mjs | 33 ++--------------------------- tests/domain.test.ts | 42 +++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 scripts/a11y-routes.mjs diff --git a/scripts/a11y-routes.mjs b/scripts/a11y-routes.mjs new file mode 100644 index 0000000..4c4f790 --- /dev/null +++ b/scripts/a11y-routes.mjs @@ -0,0 +1,23 @@ +import site from '../site.config.json' with { type: 'json' }; + +/** + * Which routes the Lighthouse gate audits. + * + * Derived from site.config.json rather than typed out, because a hand-kept copy + * of the route inventory drifts: /code-signing-policy had to be added in a + * follow-up commit, and /helper shipped with no audit at all. Adding a page to + * the inventory now adds it to the audit, which is the only behaviour that + * cannot fall behind. + */ + +/** In the inventory, but not an HTML page a Lighthouse run means anything for. */ +const NOT_A_PAGE = new Set(['/rss.xml']); + +/** + * Routes built from a template, where auditing every instance would audit the + * same markup over and over. One stands for the family: the accessibility of + * the 104th post is the accessibility of the first. + */ +const TEMPLATE_REPRESENTATIVES = ['/blog/the-tool-goes-to-the-data', '/blog/engine/postgresql']; + +export const a11yRoutes = [...site.routes.filter((route) => !NOT_A_PAGE.has(route)), ...TEMPLATE_REPRESENTATIVES]; diff --git a/scripts/lighthouse-gate.mjs b/scripts/lighthouse-gate.mjs index e2acf6e..d76886a 100644 --- a/scripts/lighthouse-gate.mjs +++ b/scripts/lighthouse-gate.mjs @@ -23,6 +23,7 @@ import { existsSync, statSync } from 'node:fs'; import { extname, join, resolve as resolvePath, sep } from 'node:path'; import { launch } from 'chrome-launcher'; import lighthouse from 'lighthouse'; +import { a11yRoutes } from './a11y-routes.mjs'; const args = process.argv.slice(2); const flag = (name, fallback) => { @@ -32,37 +33,7 @@ const flag = (name, fallback) => { const MIN = Number(flag('min', 95)); const DIST = 'dist'; -const ROUTES = args.includes('--route') - ? args.filter((a, i) => args[i - 1] === '--route') - : [ - '/', - '/blog', - '/blog/the-tool-goes-to-the-data', - // The engine archives are one template over seventeen routes, so one - // stands for all of them the way the post above stands for 104. - '/blog/engine/postgresql', - '/faq', - '/get-started', - // Every standalone content page is audited. /playground earns its slot - // twice over: it is the only route with an interactive surface built by - // script at runtime, so it is the one axe-core findings can appear on - // without anyone touching a template. - '/playground', - '/databases', - '/features', - '/open-source', - '/supporters', - '/deploy', - '/docker-compose', - '/libredb-database', - '/security', - '/code-signing-policy', - '/helper', - '/support', - '/platform', - '/compare', - '/privacy-policy', - ]; +const ROUTES = args.includes('--route') ? args.filter((a, i) => args[i - 1] === '--route') : a11yRoutes; if (!existsSync(join(DIST, 'index.html'))) { console.error(`No ${DIST}/index.html — run \`bun run build\` first.`); diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 8b009ef..c6dced1 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -112,3 +112,45 @@ describe('the test and production deploys stay separate', () => { expect(netlify).toMatch(/NETLIFY_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NETLIFY_AUTH_TOKEN\s*\}\}/); }); }); + +/** + * The Lighthouse gate audits a list of routes, and that list used to be typed + * out by hand. That is the same second-copy-of-a-list this file exists to + * forbid, and it failed the same way: /code-signing-policy had to be added in a + * follow-up commit, and /helper plus seventeen engine archives shipped in + * v1.3.0 with no audit at all, because adding a page to site.config.json does + * not add it to a list that lives somewhere else. + * + * The list is now derived, so these assert the derivation against dist rather + * than against another literal. + */ +describe('the accessibility gate audits what the site actually builds', () => { + const built: string[] = []; + const walk = (dir: string, prefix = '') => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) walk(`${dir}/${entry.name}`, `${prefix}/${entry.name}`); + else if (entry.name === 'index.html') built.push(prefix || '/'); + else if (entry.name === '404.html') built.push('/404'); + } + }; + walk('dist'); + + it('covers every standalone page, so a new one cannot ship unaudited', async () => { + const { a11yRoutes } = await import('../scripts/a11y-routes.mjs'); + // Blog posts and engine archives are templates over many routes; they are + // covered by a representative below, not one entry per post. + const standalone = built.filter((r) => !r.startsWith('/blog/') && !redirectPaths.includes(r)); + const missing = standalone.filter((r) => !a11yRoutes.includes(r)); + expect(missing, 'these pages build but are never audited').toEqual([]); + }); + + it('audits one representative of each templated family', async () => { + const { a11yRoutes } = await import('../scripts/a11y-routes.mjs'); + for (const family of ['/blog/', '/blog/engine/']) { + const audited = a11yRoutes.filter((r: string) => r.startsWith(family) && r !== family.replace(/\/$/, '')); + expect(audited.length, `no ${family} page is audited`).toBeGreaterThan(0); + // A representative that no longer builds audits nothing. + for (const route of audited) expect(built, `${route} is audited but not built`).toContain(route); + } + }); +}); From a43cb3f45dea0f2feb89babe5da438c26afe4873 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 20 Sep 2026 19:43:41 +0300 Subject: [PATCH 2/2] refactor(home): state the diagram wire's axis once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashed gradient and the keyframes that scroll it have to agree on an axis, and they were stated together in six places: base, mobile, and a green override per section per breakpoint. One of the six was missed, which is the bug fixed in #50 — the working state was drawn vertically and animated horizontally, so the wire looked alive and sat still. The axis is now set once on .diag and inherited, so a state that only recolours the wire sets --diag-dash-color and nothing else. FlowBand's reversed direction is a horizontal idea, so it is scoped to the breakpoint where the wire is horizontal instead of being re-overridden below it. Measured, not assumed: four card states across both breakpoints compute the same shape, animation and colour as before, and the dashes travel in each. Two tests assert the gradient is defined exactly once and that no state spells it out again; restoring the old shape fails the second. --- src/components/home/FlowBand.astro | 21 +++++++++------------ src/components/home/WhySection.astro | 10 ++-------- src/styles/diagram.css | 19 +++++++++++++++---- tests/dist-smoke.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/components/home/FlowBand.astro b/src/components/home/FlowBand.astro index cedfee0..b8328d4 100644 --- a/src/components/home/FlowBand.astro +++ b/src/components/home/FlowBand.astro @@ -164,9 +164,15 @@ const modes = ['out', 'in'] as const; } /* "Tool → data" is the working direction: green wire, Studio lands, specs light */ - .flow__card[data-switch-active='in'] .diag__line { - background-image: repeating-linear-gradient(90deg, var(--success-text) 0 5px, transparent 5px 10px); - animation-name: lbDashXR; + .flow__card[data-switch-active='in'] .diag { + --diag-dash-color: var(--success-text); + } + /* Running the dashes backwards is a horizontal idea. Below 560px the wire is + drawn top-to-bottom, so it keeps the shared vertical animation instead. */ + @media (min-width: 561px) { + .flow__card[data-switch-active='in'] .diag { + --diag-dash-anim: lbDashXR; + } } .flow__card[data-switch-active='in'] .diag__chip { color: var(--success-text); @@ -180,15 +186,6 @@ const modes = ['out', 'in'] as const; color: var(--success-text); } - /* The stacked layout turns the wire vertical, but the rule above is what sets - this state's animation and it outranks the one in diagram.css, so the axis - has to be restated here or the green wire stops moving. */ - @media (max-width: 560px) { - .flow__card[data-switch-active='in'] .diag__line { - background-image: repeating-linear-gradient(180deg, var(--success-text) 0 5px, transparent 5px 10px); - animation-name: lbDashY; - } - } .flow__card[data-switch-active='in'] .flow__spec { color: var(--text-brand); border-color: var(--primary-600); diff --git a/src/components/home/WhySection.astro b/src/components/home/WhySection.astro index 35653a4..678e34f 100644 --- a/src/components/home/WhySection.astro +++ b/src/components/home/WhySection.astro @@ -163,8 +163,8 @@ import { why } from '../../data/home'; } /* step 04 is the one that works: the wire turns green and Studio lands */ - .why__card[data-switch-active='3'] .diag__line { - background-image: repeating-linear-gradient(90deg, var(--success-text) 0 5px, transparent 5px 10px); + .why__card[data-switch-active='3'] .diag { + --diag-dash-color: var(--success-text); } .why__card[data-switch-active='3'] .diag__chip { color: var(--success-text); @@ -178,12 +178,6 @@ import { why } from '../../data/home'; color: var(--success-text); } - @media (max-width: 560px) { - .why__card[data-switch-active='3'] .diag__line { - background-image: repeating-linear-gradient(180deg, var(--success-text) 0 5px, transparent 5px 10px); - } - } - .why__status { font: var(--weight-regular) 11.5px / 1.5 var(--font-mono); color: var(--text-tertiary); diff --git a/src/styles/diagram.css b/src/styles/diagram.css index 25204b7..659d4b6 100644 --- a/src/styles/diagram.css +++ b/src/styles/diagram.css @@ -4,7 +4,14 @@ arrangement, so the base rules live here; each section keeps only its own state overrides in its scoped