wrapping a
+ * nests a paragraph inside a paragraph, the HTML parser splits it, and
+ * the client tree stops matching the server's. Nothing about the rendered
+ * page looks wrong. Only the console says so.
+ */
+
+import {test, expect} from './base.js';
+import {collectConsoleErrors, expectNoConsoleErrors} from './helpers.js';
+
+const LOCALES = [
+ {
+ name: 'en',
+ path: '/ai/',
+ title: /How we use AI/,
+ heading: /A human is\s+always responsible/,
+ sections: ['The rule', 'Where we use it', 'Blogs and articles', 'Where this sits in the AI Act'],
+ article4: 'Article 4',
+ academy: '/academy/?series=hydra-tutorial',
+ },
+ {
+ name: 'nl',
+ path: '/nl/ai/',
+ title: /Hoe wij AI gebruiken/,
+ heading: /Een mens is\s+altijd verantwoordelijk/,
+ sections: ['De regel', 'Waar we het gebruiken', 'Blogs en artikelen', 'Hoe dit zich verhoudt tot de AI-verordening'],
+ article4: 'Artikel 4',
+ academy: '/nl/academy/?series=hydra-tutorial',
+ },
+];
+
+for (const locale of LOCALES) {
+ test.describe(`ai page (${locale.name})`, () => {
+ test('renders without console errors', async ({page}) => {
+ const errors = collectConsoleErrors(page);
+ await page.goto(locale.path);
+ await expect(page).toHaveTitle(locale.title);
+ await page.waitForTimeout(1200); // let hydration settle and complain
+ expectNoConsoleErrors(errors);
+ });
+
+ test('has the hero, the sections and the IBM quote', async ({page}) => {
+ await page.goto(locale.path);
+ /* innerText, not toHaveText. The hero splits across a
, which
+ textContent ignores, so toHaveText sees "A human isalways
+ responsible." innerText renders the break the way a browser, a
+ screen reader and a search engine all do. */
+ expect(await page.locator('h1').innerText()).toMatch(locale.heading);
+ for (const section of locale.sections) {
+ await expect(page.getByRole('heading', {name: section, level: 2})).toBeVisible();
+ }
+ await expect(page.locator('blockquote')).toContainText('A computer can never be held accountable');
+ });
+
+ test('states the AI Act position, including Article 4', async ({page}) => {
+ await page.goto(locale.path);
+ const main = page.locator('main');
+ await expect(main).toContainText(locale.article4);
+ // Article 50 is addressed and explicitly does not apply to us.
+ await expect(main).toContainText(/50/);
+ });
+
+ test('links to the ordered Hydra series, not the tag page', async ({page}) => {
+ await page.goto(locale.path);
+ await expect(page.locator(`main a[href="${locale.academy}"]`).first()).toBeVisible();
+ await expect(page.locator('main a[href*="tags/hydra"]')).toHaveCount(0);
+ });
+
+ test('does not scroll horizontally', async ({page}) => {
+ await page.goto(locale.path);
+ const overflows = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
+ expect(overflows).toBe(false);
+ });
+
+ test('is reachable from the footer', async ({page}) => {
+ await page.goto(locale.name === 'nl' ? '/nl/' : '/');
+ const link = page.locator(`footer a[href$="${locale.name === 'nl' ? '/nl/ai/' : '/ai/'}"]`).first();
+ await expect(link).toBeVisible();
+ await link.click();
+ await expect(page).toHaveTitle(locale.title);
+ });
+ });
+}
+
+/**
+ * The series link is a query parameter on a static site, which only works
+ * because the academy page reads and validates it. If that ever regresses,
+ * the link silently degrades to "all posts" and nobody notices.
+ */
+test('the hydra series link actually filters the academy', async ({page}) => {
+ await page.goto('/academy/?series=hydra-tutorial');
+ const headings = page.getByRole('heading', {name: /Hydra tutorial series/});
+ await expect(headings.first()).toBeVisible();
+ expect(await headings.count(), 'the series has seven parts').toBeGreaterThanOrEqual(7);
+});
diff --git a/e2e/base.js b/e2e/base.js
new file mode 100644
index 000000000..b299a81fa
--- /dev/null
+++ b/e2e/base.js
@@ -0,0 +1,49 @@
+/**
+ * The test base every spec imports instead of `@playwright/test`.
+ *
+ * ONE REASON TO EXIST: a test must not emit analytics.
+ *
+ * The production build carries the Portaliq traffic client, because that is
+ * the point of it — the deploy workflow builds with PORTALIQ_TRAFFIC_ORIGIN
+ * set and a guard fails the deploy if the script is missing. The e2e suite
+ * then runs against that same build, so every test that loads a page was
+ * posting a real `page_view` to the live collector.
+ *
+ * That is not hypothetical. Measured 2026-09-22, hours after measurement was
+ * switched on: 46 of the 58 events recorded against the ConductionNl portal
+ * came from `localhost:4173`, which is this suite's own preview server. The
+ * first day of the site's analytics was three quarters test traffic, and CI
+ * would have added a fresh batch on every deploy across three projects.
+ *
+ * Aborting the collector is better than pointing the build at a dead origin:
+ * the page under test stays byte-for-byte what deploys, including the script
+ * tag the guard checks for, and only the beacon is dropped.
+ *
+ * The settings fetch is left alone deliberately. It is a GET that records
+ * nothing, and letting it succeed keeps the client on its real code path, so
+ * a client that throws on a page would still fail this suite.
+ */
+
+import {test as base, expect} from '@playwright/test';
+
+/** The collector, as the traffic client addresses it. */
+const COLLECTOR = /\/api\/traffic(\?|$)/;
+
+export const test = base.extend({
+ page: async ({page}, use) => {
+ /* Answer the beacon rather than aborting it. `route.abort()` stops the
+ request reaching the collector, which is the point, but the browser
+ then logs "Failed to load resource: net::ERR_FAILED" to the console,
+ and `space-invaders.spec.js` asserts a page produces no console
+ errors. That turned development red on 2026-09-22: the test failed
+ all three attempts, so it read as a product fault rather than as the
+ suite tripping over its own stub.
+ 204 is what the real endpoint returns for an accepted beacon, so the
+ client stays on its success path and a client that throws on a real
+ response would still fail this suite. */
+ await page.route(COLLECTOR, (route) => route.fulfill({status: 204, body: ''}));
+ await use(page);
+ },
+});
+
+export {expect};
diff --git a/e2e/cookie-banner.spec.js b/e2e/cookie-banner.spec.js
new file mode 100644
index 000000000..c56d79f62
--- /dev/null
+++ b/e2e/cookie-banner.spec.js
@@ -0,0 +1,110 @@
+/**
+ * The cookie banner.
+ *
+ * Every assertion here corresponds to something that was once wrong:
+ * the banner appearing unprompted, the footer link navigating instead of
+ * opening it, reopening showing stale checkboxes, and the panel growing
+ * past the top of the viewport until the buttons were unreachable.
+ */
+
+import {test, expect} from './base.js';
+import {
+ BANNER, clearConsent, readConsent, openBannerFromFooter,
+ collectConsoleErrors, expectNoConsoleErrors,
+} from './helpers.js';
+
+test.beforeEach(async ({page}) => {
+ await page.goto('/');
+ await clearConsent(page);
+ await page.reload();
+});
+
+test('never appears unprompted, even on a first visit', async ({page}) => {
+ expect(await readConsent(page)).toBeNull();
+ await page.waitForTimeout(1500);
+ await expect(page.locator(BANNER)).toHaveCount(0);
+});
+
+test('the footer Cookies link opens it in place without navigating', async ({page}) => {
+ const before = page.url();
+ await openBannerFromFooter(page);
+ expect(page.url(), 'clicking Cookies must not navigate').toBe(before);
+ await expect(page.locator(BANNER)).toBeVisible();
+});
+
+test('defaults to essential only, with no marketing category', async ({page}) => {
+ await openBannerFromFooter(page);
+ const items = page.locator(`${BANNER} li`);
+ await expect(items).toHaveCount(3);
+ await expect(items.nth(0)).toHaveAttribute('aria-pressed', 'true'); // essential
+ await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'false'); // analytics
+ await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'false'); // preferences
+ await expect(page.locator(BANNER)).not.toContainText('marketing');
+});
+
+test('accept all stores all three and closes', async ({page}) => {
+ await openBannerFromFooter(page);
+ await page.locator(BANNER).getByRole('button', {name: /Accept all/}).click();
+ await expect(page.locator(BANNER)).toHaveCount(0);
+ expect(await readConsent(page)).toMatchObject({essential: true, analytics: true, preferences: true});
+});
+
+test('reject stores essential only', async ({page}) => {
+ await openBannerFromFooter(page);
+ await page.locator(BANNER).getByRole('button', {name: /Reject non-essential/}).click();
+ await expect(page.locator(BANNER)).toHaveCount(0);
+ expect(await readConsent(page)).toMatchObject({essential: true, analytics: false, preferences: false});
+});
+
+/**
+ * The regression that prompted the on-demand rework.
+ *
+ * The preset's reset() clears localStorage without clearing its own
+ * checkbox state, so a reused instance reopened showing whatever was ticked
+ * last time. Accepting everything and then rejecting used to leave
+ * `analytics` visibly ticked on the next open, which reads as consent the
+ * visitor did not give.
+ */
+test('reopening shows the stored choice, not the previous one', async ({page}) => {
+ await openBannerFromFooter(page);
+ await page.locator(BANNER).getByRole('button', {name: /Accept all/}).click();
+
+ await openBannerFromFooter(page);
+ const items = page.locator(`${BANNER} li`);
+ await expect(items.nth(1), 'should mirror the stored accept-all').toHaveAttribute('aria-pressed', 'true');
+
+ await page.locator(BANNER).getByRole('button', {name: /Reject non-essential/}).click();
+
+ await openBannerFromFooter(page);
+ await expect(
+ page.locator(`${BANNER} li`).nth(1),
+ 'analytics must not stay ticked after a reject',
+ ).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('the panel stays inside the viewport with a full scrollback', async ({page}) => {
+ await openBannerFromFooter(page);
+ await page.keyboard.type('ls', {delay: 10});
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('cat README.md', {delay: 10});
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('help', {delay: 10});
+ await page.keyboard.press('Enter');
+
+ const box = await page.locator(BANNER).boundingBox();
+ const viewport = page.viewportSize();
+ expect(box.y, 'the title bar must not be pushed off the top').toBeGreaterThanOrEqual(0);
+ expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1);
+});
+
+test('the privacy page settings panel reopens the banner on a hard load', async ({page}) => {
+ const errors = collectConsoleErrors(page);
+ await page.goto('/privacy/#cookies');
+
+ const button = page.getByRole('button', {name: /Change your cookie choices/});
+ await expect(button).toBeEnabled();
+ await button.click();
+ await expect(page.locator(BANNER)).toBeVisible();
+
+ expectNoConsoleErrors(errors);
+});
diff --git a/e2e/dark-mode.spec.js b/e2e/dark-mode.spec.js
new file mode 100644
index 000000000..9a4fb4256
--- /dev/null
+++ b/e2e/dark-mode.spec.js
@@ -0,0 +1,249 @@
+/**
+ * Dark mode contrast regression suite.
+ *
+ * Why this suite exists. The preset ships `respectPrefersColorScheme: true`,
+ * so every visitor whose OS is set to dark gets dark mode, and the served
+ * HTML really does call `matchMedia("(prefers-color-scheme: dark)")`. Nobody
+ * had looked at it. Measured 2026-09-19 across all 163 pages: 1,362 text
+ * elements below 1.5:1, on 121 of them, with the homepage h1 at exactly
+ * 1.00, meaning text the same colour as the thing behind it.
+ *
+ * The cause was structural rather than cosmetic. Components set colour from
+ * the RAW palette (`color: var(--c-cobalt-700)`), and a palette token cannot
+ * flip, so the background went dark underneath ink that stayed navy. The fix
+ * moved 709 declarations onto semantic tokens that carry a dark value.
+ *
+ * Two properties are asserted here, and the second matters as much as the
+ * first:
+ *
+ * 1. Dark mode has no invisible text.
+ * 2. LIGHT mode is not made worse by any of it. The semantic tokens were
+ * deliberately given light values identical to what they replaced, so
+ * this is a real invariant and not a hope.
+ */
+
+import {test, expect} from './base.js';
+
+/* One page per layout archetype. The navbar and footer are on all of them,
+ and these carry the distinct section and card treatments. */
+const PAGES = [
+ ['home', '/'],
+ ['apps listing', '/apps/'],
+ ['app detail', '/apps/openregister/'],
+ ['solutions listing', '/solutions/'],
+ ['academy index', '/academy/'],
+ ['support (cards + forms)', '/support/'],
+ ['about', '/about/'],
+ ['quality', '/quality/'],
+ ['terms (SLA tables)', '/terms/'],
+ ['ai', '/ai/'],
+ /* Added after a full 163-page sweep of the deployed site found 12
+ invisible elements on exactly these two, both outside the original
+ sample. /connext/ renders the platform diagram, a custom element
+ whose CSS lives in static/lib and was missed by a sweep scoped to
+ src/components and src/theme. /privacy/ renders the cookie panel,
+ whose colours are inline in a .jsx rather than an .mdx. A sample
+ that covers only the layouts you already thought about will keep
+ agreeing with you. */
+ ['connext (platform diagram)', '/connext/'],
+ ['privacy (cookie panel)', '/privacy/'],
+ /* Added after a second full sweep. /contact/ grew a CRM form while
+ this work was in flight, and the form arrived with
+ `background: 'white'` hardcoded, so it was broken the day it
+ shipped. New pages and new features reintroduce the pattern faster
+ than a sample can be extended by hand: the durable fix is a lint
+ rule rejecting raw palette tokens for text and surface roles. */
+ ['contact (CRM form)', '/contact/'],
+
+ /* The Dutch locale. It is a separate set of source files, it is live,
+ and it is NOT in sitemap.xml, so every sweep that used the sitemap as
+ "all pages" measured only English. /nl/apps/ was carrying 22
+ unreachable controls and /nl/terms/ 88 invisible elements the whole
+ time the English suite was green. Four pages is a sample, not
+ coverage, but it is enough to fail if the locale is forgotten again. */
+ ['nl home', '/nl/'],
+ ['nl terms (worst offender)', '/nl/terms/'],
+ ['nl quality', '/nl/quality/'],
+ ['nl support', '/nl/support/'],
+];
+
+/**
+ * WCAG 2.1 SC 1.4.3 (AA): 4.5:1 for normal text, 3:1 for large text.
+ *
+ * The gate here is deliberately set at 1.5:1, not 4.5:1. Below 1.5 the text
+ * is effectively invisible, which is unambiguous and worth blocking a deploy
+ * over. Between 1.5 and 4.5 sits a long tail of brand colours that are a
+ * genuine but marginal AA miss in BOTH themes, including the Nextcloud blue
+ * at 4.17 on white. Gating at 4.5 would fail on pre-existing light-mode debt
+ * and teach everyone to ignore this suite.
+ */
+const INVISIBLE_BELOW = 1.5;
+
+/**
+ * Measure contrast for every text leaf on the page.
+ *
+ * Refuses to guess: when an ancestor paints a gradient or an image the
+ * contrast against it is not computable from `getComputedStyle`, so those
+ * elements are skipped rather than assigned an invented number. An early
+ * version of this without that guard reported dozens of findings in light
+ * mode that were not real.
+ */
+async function lowContrastText(page, theme, threshold) {
+ return page.evaluate(
+ async ({theme, threshold}) => {
+ /* Cards animate `background` over 160ms, so a colour read while the
+ theme swap is still easing is a blend of the two themes and
+ belongs to neither. One run reported white on rgb(231,233,236),
+ a grey that appears nowhere in the palette because it was a
+ half-finished transition. Kill transitions and animations first
+ so every value read is a settled one. */
+ const freeze = document.createElement('style');
+ freeze.textContent =
+ '*,*::before,*::after{transition:none!important;animation:none!important}';
+ document.head.appendChild(freeze);
+
+ document.documentElement.setAttribute('data-theme', theme);
+
+ /* Let the switch actually land before reading anything back.
+ Measuring in the same tick reported the PRE-switch colours on
+ busy pages: /academy/ came back with 197 findings whose cards
+ were already correct. Two frames plus a short settle, so the
+ value read is the value painted. */
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ await new Promise((resolve) => setTimeout(resolve, 250));
+
+ const channels = (c) => (String(c).match(/[\d.]+/g) || []).slice(0, 3).map(Number);
+ const luminance = (rgb) => {
+ const a = rgb.map((v) => {
+ v /= 255;
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ });
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
+ };
+ const contrast = (a, b) => {
+ const l1 = luminance(a);
+ const l2 = luminance(b);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+ };
+
+ /* Walk up for the first ancestor that actually paints. null means
+ "not measurable", never "assume white". */
+ const backgroundBehind = (el) => {
+ let node = el;
+ while (node && node !== document.documentElement) {
+ const style = getComputedStyle(node);
+ if (style.backgroundImage && style.backgroundImage !== 'none') return null;
+ const bg = style.backgroundColor;
+ if (bg && !/rgba\(0, 0, 0, 0\)|transparent/.test(bg)) {
+ const rgb = channels(bg);
+ const alpha = (String(bg).match(/[\d.]+/g) || [])[3];
+ if (rgb.length === 3 && (alpha === undefined || Number(alpha) >= 0.95)) return rgb;
+ }
+ node = node.parentElement;
+ }
+ const root = getComputedStyle(document.documentElement);
+ if (root.backgroundImage && root.backgroundImage !== 'none') return null;
+ const rgb = channels(root.backgroundColor);
+ return rgb.length === 3 ? rgb : null;
+ };
+
+ const out = [];
+ const selector = 'p,h1,h2,h3,h4,li,a,span,strong,em,td,th,dt,dd,figcaption,label,button';
+ for (const el of document.querySelectorAll(selector)) {
+ const text = (el.textContent || '').trim();
+ if (text.length < 8) continue;
+ /* Docusaurus's own skip link is parked off-screen until focused,
+ and its colours come from Infima rather than from us. It is not
+ something a visitor ever sees at this contrast. */
+ if (text === 'Skip to main content' || text === 'Ga naar hoofdinhoud') continue;
+ /* Leaves only, so a wrapper is not blamed for its children. */
+ if (el.querySelector('p,h1,h2,h3,h4,li,div')) continue;
+ const style = getComputedStyle(el);
+ if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) continue;
+ const rect = el.getBoundingClientRect();
+ if (!rect.width || !rect.height) continue;
+ const fg = channels(style.color);
+ if (fg.length !== 3) continue;
+ const bg = backgroundBehind(el);
+ if (bg === null) continue;
+ const ratio = contrast(fg, bg);
+ if (ratio < threshold) {
+ out.push({
+ text: text.slice(0, 40),
+ ratio: Number(ratio.toFixed(2)),
+ color: style.color,
+ background: `rgb(${bg.join(', ')})`,
+ });
+ }
+ }
+ return out;
+ },
+ {theme, threshold},
+ );
+}
+
+for (const [name, path] of PAGES) {
+ test(`${name}: no invisible text in dark mode`, async ({page}) => {
+ await page.goto(path);
+ await page.waitForLoadState('networkidle');
+
+ const found = await lowContrastText(page, 'dark', INVISIBLE_BELOW);
+
+ expect(
+ found,
+ `${found.length} element(s) under ${INVISIBLE_BELOW}:1 in dark mode on ${path}:\n` +
+ found
+ .slice(0, 10)
+ .map((f) => ` ${f.ratio}:1 "${f.text}" ${f.color} on ${f.background}`)
+ .join('\n'),
+ ).toEqual([]);
+ });
+}
+
+/**
+ * The invariant that made the migration safe to do at all.
+ *
+ * Every semantic token introduced for this resolves to the identical value
+ * it replaced in light mode, so migrating 709 declarations must not move a
+ * single light-mode pixel. If this fails, the mapping picked a token whose
+ * light value differs, which is how a dark-mode fix quietly restyles the
+ * site everyone actually sees.
+ */
+test('light mode has no invisible text either', async ({page}) => {
+ await page.goto('/');
+ await page.waitForLoadState('networkidle');
+
+ const found = await lowContrastText(page, 'light', INVISIBLE_BELOW);
+
+ expect(
+ found,
+ `light mode regressed: ${found.length} element(s) under ${INVISIBLE_BELOW}:1:\n` +
+ found.slice(0, 10).map((f) => ` ${f.ratio}:1 "${f.text}"`).join('\n'),
+ ).toEqual([]);
+});
+
+/**
+ * The page ground and the body ink must both come from the theme, because
+ * the whole failure was one of them flipping without the other.
+ */
+test('the dark theme actually applies to the page ground and body ink', async ({page}) => {
+ await page.goto('/');
+ await page.waitForLoadState('networkidle');
+
+ const themed = await page.evaluate(() => {
+ const read = () => ({
+ background: getComputedStyle(document.body).backgroundColor,
+ color: getComputedStyle(document.body).color,
+ });
+ document.documentElement.setAttribute('data-theme', 'light');
+ const light = read();
+ document.documentElement.setAttribute('data-theme', 'dark');
+ const dark = read();
+ return {light, dark};
+ });
+
+ expect(themed.dark.background, 'the page ground must change with the theme').not.toBe(
+ themed.light.background,
+ );
+ expect(themed.dark.color, 'the body ink must change with the theme').not.toBe(themed.light.color);
+});
diff --git a/e2e/helpers.js b/e2e/helpers.js
new file mode 100644
index 000000000..0c9b721c1
--- /dev/null
+++ b/e2e/helpers.js
@@ -0,0 +1,111 @@
+/**
+ * Shared helpers for the e2e suite.
+ */
+
+import {expect} from '@playwright/test';
+
+export const BANNER = '[role="dialog"][aria-label="Cookie consent"]';
+export const STORAGE_KEY = 'conduction:cookie-cli';
+
+/**
+ * Collect console errors for the lifetime of a test.
+ *
+ * This exists because of a specific failure mode: a Docusaurus build can be
+ * completely green, the page can look correct, and React can still be
+ * failing to hydrate it. That shows up only as console errors. Two separate
+ * MDX changes shipped that way before anyone noticed, so every page test
+ * asserts on this.
+ *
+ * Returns a live array; read it at the end of the test.
+ */
+export function collectConsoleErrors(page) {
+ const errors = [];
+ page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
+ page.on('pageerror', (err) => errors.push(String(err)));
+ return errors;
+}
+
+/**
+ * Assert no console errors, ignoring ones the site does not control.
+ *
+ * Immediately after a deploy, Cloudflare edge nodes briefly disagree about
+ * which build they serve, so a chunk can 404 on one request. That is a
+ * property of the CDN rather than of the page, and failing a test on it
+ * would make the suite flaky against production.
+ */
+export function expectNoConsoleErrors(errors) {
+ const real = errors.filter((e) => !/Failed to load resource.*40[34]/i.test(e));
+ expect(real, `console errors:\n${real.join('\n')}`).toEqual([]);
+}
+
+/** Clear any stored consent so a test starts from a first-visit state. */
+export async function clearConsent(page) {
+ await page.evaluate((key) => {
+ try { window.localStorage.removeItem(key); } catch (e) { /* private mode */ }
+ }, STORAGE_KEY);
+}
+
+/** Read the stored consent object, or null. */
+export function readConsent(page) {
+ return page.evaluate((key) => {
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) { return null; }
+ }, STORAGE_KEY);
+}
+
+/**
+ * Open the banner the way a visitor does: the Cookies link in the footer.
+ * That link is intercepted and must NOT navigate.
+ */
+export async function openBannerFromFooter(page) {
+ await page.locator('footer a[href$="#cookies"]').first().click();
+ await page.locator(BANNER).waitFor({state: 'visible'});
+ /* Wait for the terminal input before returning. The banner is visible as
+ soon as it paints, but the shell reads keys through a listener attached
+ in an effect, so a keystroke sent in between is simply dropped and the
+ command arrives mangled. That shows up as a game that "did not boot",
+ which is a confusing way to learn about a race. This element only
+ exists once the component has mounted. */
+ const input = page.locator(`${BANNER} input[aria-label*="Terminal"]`);
+ await input.waitFor({state: 'attached'});
+
+ /* Being attached is not the same as being listened to. The keydown
+ handler lives on `window` and is registered by an effect, which React
+ runs after paint — so there is a window where the input exists and
+ keystrokes still go nowhere. Waiting on the element alone left the
+ suite flaky: a different test failed on roughly one run in thirteen,
+ always with a mangled command.
+
+ So prove the shell is listening instead of assuming it: send one
+ harmless character and wait for the buffer to echo it (the input
+ mirrors the buffer via value={buffer}), then clear it with Escape.
+ Any letter is safe here — the shell deliberately has no letter
+ shortcuts, and digits only toggle from an empty prompt. */
+ await expect(async () => {
+ /* Clear first: once the listener does come up, probes from earlier
+ attempts would otherwise accumulate ("xx") and never match. */
+ await page.keyboard.press('Escape');
+ await page.keyboard.press('x');
+ await expect(input).toHaveValue('x', {timeout: 250});
+ }).toPass({timeout: 5000});
+ await page.keyboard.press('Escape');
+ await expect(input).toHaveValue('');
+}
+
+/**
+ * Type into the terminal.
+ *
+ * The shell listens on window rather than through a focused input, so
+ * keyboard.type() is what a visitor's keystrokes actually look like to it.
+ */
+export async function typeCommand(page, command) {
+ await page.keyboard.type(command, {delay: 15});
+ await page.keyboard.press('Enter');
+}
+
+/** The banner's terminal text, whitespace-normalised. */
+export async function terminalText(page) {
+ return (await page.locator(BANNER).innerText()).replace(/\s+/g, ' ');
+}
diff --git a/e2e/mobile-layout.spec.js b/e2e/mobile-layout.spec.js
new file mode 100644
index 000000000..efa64787e
--- /dev/null
+++ b/e2e/mobile-layout.spec.js
@@ -0,0 +1,326 @@
+/**
+ * Mobile layout regression suite.
+ *
+ * Runs in the `mobile` project only (390x844, the iPhone 13 viewport).
+ *
+ * Why this suite exists. In September 2026 an audit of all 163 pages
+ * found that every one of them put 23 links outside the viewport: the
+ * navbar had no drawer below its breakpoint, and the footer's five
+ * `fr` columns needed 773px. Nothing caught it, because the site sets
+ * `overflow-x: clip` on html and body to stop decorative panels
+ * dragging the page sideways. That rule works, and it also turns any
+ * layout overflow into content that is silently unreachable rather
+ * than a visible scrollbar. There was no symptom to notice.
+ *
+ * So the assertion here is deliberately about REACHABILITY, not about
+ * overflow. Decoration is allowed to bleed past the viewport, and does
+ * (the canal illustration in the footer, the marquee on app pages).
+ * A link or a button never is: if a control's box lies outside the
+ * viewport on a phone, a visitor cannot tap it, and `clip` means they
+ * cannot even scroll to it.
+ */
+
+import {test, expect} from './base.js';
+
+/**
+ * Wait until the footer's stylesheet has actually applied.
+ *
+ * `canal-footer.css` is injected lazily by the preset, so `load` and even
+ * `networkidle` can both be reached while the footer is still unstyled.
+ * Measured that way it reports its pre-CSS metrics: every link 19px tall,
+ * the social row a full 390px wide. One run read that as ten target-size
+ * violations that did not exist.
+ *
+ * The condition is the stylesheet being parsed, not anything the
+ * assertions are about, so this waits for a precondition rather than
+ * waiting for the test to pass.
+ */
+async function footerStylesApplied(page) {
+ await page.waitForFunction(() => {
+ /* Key the wait on the FOOTER, not on the stylesheet link. Keying it
+ on the link meant "link not found yet" was indistinguishable from
+ "this page has no canal footer", so the wait returned true while
+ the stylesheet was still on its way and the test measured the
+ unstyled footer anyway. That is the hole this closes. */
+ const footer = document.querySelector('.canal-footer');
+ if (!footer) return true; // genuinely no canal footer on this page
+ const link = [...document.querySelectorAll('link[rel="stylesheet"]')].find((l) =>
+ l.href.includes('canal-footer'),
+ );
+ if (!link) return false; // footer is here, its stylesheet is not, keep waiting
+ try {
+ if (!link.sheet || link.sheet.cssRules.length === 0) return false;
+ } catch {
+ if (!link.sheet) return false;
+ }
+ /* The rules are parsed; confirm they have actually taken effect.
+ `.game-over` is `display: none` only once this stylesheet applies,
+ so while it is still visible the footer is unstyled, which is
+ exactly the state that produced twelve phantom findings. */
+ const gameOver = footer.querySelector('.game-over');
+ if (gameOver && getComputedStyle(gameOver).display !== 'none') return false;
+ return true;
+ });
+}
+
+/* One page per layout archetype rather than all 163: the navbar and
+ footer are shared by every page, and these cover each distinct body
+ layout that carries its own column rules. */
+const PAGES = [
+ ['home', '/'],
+ ['apps listing (filter rail + card grid)', '/apps/'],
+ ['app detail (body + aside)', '/apps/openregister/'],
+ ['solutions listing', '/solutions/'],
+ ['solution detail', '/solutions/openwoo/'],
+ ['academy index', '/academy/'],
+ ['academy article', '/academy/spec-driven-development/'],
+ ['support (forms)', '/support/'],
+ ['sidecars listing', '/sidecars/'],
+ ['partners', '/partners/'],
+ ['partner detail', '/partners/acato/'],
+ ['connext', '/connext/'],
+ ['quality', '/quality/'],
+ ['contact', '/contact/'],
+ ['install', '/install/'],
+ ['about', '/about/'],
+
+ /* The Dutch locale. It is a separate set of source files, it is live,
+ and it is NOT in sitemap.xml, so every sweep that used the sitemap as
+ "all pages" measured only English. /nl/apps/ was carrying 22
+ unreachable controls and /nl/terms/ 88 invisible elements the whole
+ time the English suite was green. Four pages is a sample, not
+ coverage, but it is enough to fail if the locale is forgotten again. */
+ ['nl home', '/nl/'],
+ ['nl apps listing', '/nl/apps/'],
+ ['nl support', '/nl/support/'],
+];
+
+/**
+ * Controls that a visitor cannot get to.
+ *
+ * Lying outside the viewport is not by itself a fault, so the test
+ * asks what is doing the clipping before it complains:
+ *
+ * - nearest overflow ancestor is `auto` or `scroll`: the control is
+ * inside its own scrollable region (a code block, a wide table,
+ * the platform diagram). Reachable, so allowed.
+ * - nearest overflow ancestor is `hidden` or `clip` on some
+ * component: that component is deliberately masking, which is how
+ * the logo marquee and the app-card track work. Allowed.
+ * - nothing clips it until html or body: the only thing holding it
+ * is the site-wide `overflow-x: clip`. Nothing can scroll to it
+ * and nothing will ever reveal it. That is the failure.
+ *
+ * Skipped: zero-sized and hidden elements, the closed navbar drawer,
+ * and the skip-to-content link, which is parked off-screen on purpose
+ * and moves into view when focused.
+ */
+async function offscreenControls(page) {
+ return page.evaluate(() => {
+ const vw = document.documentElement.clientWidth;
+ const out = [];
+ const selector = 'a[href], button, input, select, textarea, [role="button"]';
+ for (const el of document.querySelectorAll(selector)) {
+ if (el.closest('[id$="navbar-drawer"][hidden]')) continue;
+ if (['Skip to main content', 'Ga naar hoofdinhoud'].includes((el.textContent || '').trim())) continue;
+ const style = getComputedStyle(el);
+ if (style.visibility === 'hidden' || style.display === 'none') continue;
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ /* A 1px tolerance absorbs sub-pixel rounding on fractional
+ layouts; anything beyond that is a real miss. */
+ if (r.right <= vw + 1 && r.left >= -1) continue;
+
+ let node = el.parentElement;
+ let clippedByPage = true;
+ while (node && node !== document.documentElement) {
+ const overflowX = getComputedStyle(node).overflowX;
+ if (overflowX !== 'visible') {
+ /* Some component owns the clipping, so it is intentional. */
+ clippedByPage = node === document.body;
+ break;
+ }
+ node = node.parentElement;
+ }
+ if (!clippedByPage) continue;
+
+ out.push({
+ tag: el.tagName.toLowerCase(),
+ text: (el.textContent || '').trim().slice(0, 40) || el.getAttribute('aria-label') || '(no label)',
+ left: Math.round(r.left),
+ right: Math.round(r.right),
+ viewportWidth: vw,
+ });
+ }
+ return out;
+ });
+}
+
+for (const [name, path] of PAGES) {
+ test(`${name}: every control is reachable on a phone`, async ({page}) => {
+ await page.goto(path);
+ /* The footer illustration and the app-page marquee are built by
+ script after load and shift layout as they mount. */
+ await page.waitForLoadState('networkidle');
+ await footerStylesApplied(page);
+
+ const offscreen = await offscreenControls(page);
+ /* Report the measured viewport, never a literal. A hardcoded "390px"
+ in this message would keep reading 390 if the project's device
+ descriptor ever changed, and the number is the first thing anyone
+ reads when diagnosing a failure. */
+ const measured = offscreen.length ? offscreen[0].viewportWidth : null;
+ expect(
+ offscreen,
+ `${offscreen.length} control(s) lie outside the ${measured}px viewport on ${path}:\n` +
+ offscreen.map((o) => ` ${o.tag} "${o.text}" at x=${o.left}..${o.right}`).join('\n'),
+ ).toEqual([]);
+ });
+}
+
+test('the navbar drawer opens and exposes every navigation item', async ({page}) => {
+ await page.goto('/');
+
+ const toggle = page.locator('button[aria-controls$="navbar-drawer"]');
+ await expect(toggle, 'the drawer toggle should be visible on a phone').toBeVisible();
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+
+ /* Closed: the drawer must be out of the accessibility tree, not just
+ visually hidden, or its links stay in the tab order behind the
+ page. */
+ await expect(page.locator('[id$="navbar-drawer"]')).toBeHidden();
+
+ await toggle.click();
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+
+ const drawer = page.locator('[id$="navbar-drawer"]');
+ await expect(drawer).toBeVisible();
+
+ /* Support and About are the two items that used to fall off the bar,
+ so they are named here rather than counted. */
+ await expect(drawer.getByRole('link', {name: 'Support'})).toBeVisible();
+ await expect(drawer.getByRole('link', {name: 'About'})).toBeVisible();
+
+ /* Escape closes it and hands focus back, so a keyboard user is not
+ stranded inside the panel. */
+ await page.keyboard.press('Escape');
+ await expect(drawer).toBeHidden();
+ await expect(toggle).toBeFocused();
+});
+
+test('the drawer closes when a link inside it is followed', async ({page}) => {
+ await page.goto('/');
+ const toggle = page.locator('button[aria-controls$="navbar-drawer"]');
+ await toggle.click();
+
+ /* Wait for the drawer to finish opening before reaching into it. On WebKit
+ this test spent its full 90s budget and failed with "element was detached
+ from the DOM, retrying": the link resolves while the drawer is still
+ re-rendering, so the node Playwright is about to click is replaced under
+ it. Chromium happened to win that race and Safari did not, which is the
+ kind of difference running one engine hides.
+ `aria-expanded` flipping to true is the component saying it has settled,
+ so it is a precondition rather than a sleep. */
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ const drawer = page.locator('[id$="navbar-drawer"]');
+ await expect(drawer).toBeVisible();
+
+ await drawer.getByRole('link', {name: 'About'}).click();
+ await expect(page).toHaveURL(/\/about\/?$/);
+ await expect(drawer).toBeHidden();
+});
+
+test('every footer link sits inside the viewport', async ({page}) => {
+ await page.goto('/');
+ await page.waitForLoadState('networkidle');
+ await footerStylesApplied(page);
+
+ const strays = await page.evaluate(() => {
+ const vw = document.documentElement.clientWidth;
+ const footer = document.querySelector('footer');
+ if (!footer) return ['no footer element found'];
+ return [...footer.querySelectorAll('a[href]')]
+ .filter((a) => {
+ const r = a.getBoundingClientRect();
+ return (r.width || r.height) && (r.right > vw + 1 || r.left < -1);
+ })
+ .map((a) => `${(a.textContent || '').trim().slice(0, 30)} @ ${Math.round(a.getBoundingClientRect().right)}px`);
+ });
+
+ expect(strays, `footer links outside the viewport: ${strays.join(', ')}`).toEqual([]);
+});
+
+/**
+ * Form fields must not be under 16px.
+ *
+ * Mobile Safari zooms the page in when a field smaller than 16px takes
+ * focus, and does not zoom back out, so one tap leaves the visitor
+ * stranded mid-form at 1.3x. This project runs on Chromium, which does
+ * not reproduce that behaviour, so asserting the zoom itself would
+ * assert nothing. The computed font size is the condition that causes
+ * it, is engine-agnostic, and is exactly what the fix changed.
+ */
+test('form fields are at least 16px, so iOS does not zoom on focus', async ({page}) => {
+ await page.goto('/support/');
+ await page.waitForLoadState('networkidle');
+
+ const tooSmall = await page.evaluate(() => {
+ const out = [];
+ for (const el of document.querySelectorAll('input, select, textarea')) {
+ if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'hidden') continue;
+ const style = getComputedStyle(el);
+ if (style.visibility === 'hidden' || style.display === 'none') continue;
+ const size = parseFloat(style.fontSize);
+ if (size < 16) {
+ out.push(`${el.tagName.toLowerCase()}[name=${el.name || '?'}] ${size}px`);
+ }
+ }
+ return out;
+ });
+
+ expect(tooSmall, `fields under 16px: ${tooSmall.join(', ')}`).toEqual([]);
+});
+
+/**
+ * Touch target size.
+ *
+ * The gate is WCAG 2.2 SC 2.5.8 (AA), which requires 24x24 CSS px.
+ * It is deliberately not 44px: 44 is SC 2.5.5 (AAA) and the size the
+ * primary controls here aim for, but tag chips and inline badges sit
+ * legitimately between the two, and a suite that failed on those would
+ * be reporting a preference rather than a defect.
+ *
+ * Inline links inside running prose are exempt under 2.5.8 itself.
+ */
+const AA_MIN_TARGET_PX = 24;
+
+for (const path of ['/apps/', '/support/', '/']) {
+ test(`${path}: controls meet the ${AA_MIN_TARGET_PX}px AA target size`, async ({page}) => {
+ await page.goto(path);
+ await page.waitForLoadState('networkidle');
+ await footerStylesApplied(page);
+
+ const small = await page.evaluate((min) => {
+ const out = [];
+ for (const el of document.querySelectorAll('a[href], button, input, select, [role="button"]')) {
+ if (el.closest('[id$="navbar-drawer"][hidden]')) continue;
+ if (['Skip to main content', 'Ga naar hoofdinhoud'].includes((el.textContent || '').trim())) continue;
+ const style = getComputedStyle(el);
+ if (style.visibility === 'hidden' || style.display === 'none') continue;
+ if (style.display === 'inline' && el.closest('p, li')) continue;
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ /* A checkbox stays small by design; its row is the target the
+ visitor actually hits, so measure that instead. */
+ const target = el.type === 'checkbox' || el.type === 'radio' ? el.closest('li') || el : el;
+ const tr = target.getBoundingClientRect();
+ if (tr.height < min || tr.width < min) {
+ out.push(`${el.tagName.toLowerCase()} "${(el.textContent || '').trim().slice(0, 24)}" ${Math.round(tr.width)}x${Math.round(tr.height)}`);
+ }
+ }
+ return out;
+ }, AA_MIN_TARGET_PX);
+
+ expect(small, `controls under ${AA_MIN_TARGET_PX}px: ${small.join(' | ')}`).toEqual([]);
+ });
+}
diff --git a/e2e/product-names.spec.js b/e2e/product-names.spec.js
new file mode 100644
index 000000000..78db814ac
--- /dev/null
+++ b/e2e/product-names.spec.js
@@ -0,0 +1,124 @@
+/**
+ * Product names, as rendered.
+ *
+ * The 2026-08-21 renaming board moved fourteen apps onto the -iq/-inq
+ * scheme. The rename is display-name-only: every slug, route and asset
+ * path keeps its old value, so a regression here does not 404 — the page
+ * loads perfectly and simply shows the wrong brand. Nothing else in the
+ * suite would notice.
+ *
+ * Two failure modes this pins, both silent:
+ * - A page reverts to the old name (a revert, a bad merge, or a stale
+ * @conduction/docusaurus-preset whose apps-registry still carries the
+ * old `name:` — that registry is a second source of names, so the site
+ * can regress without this repo changing at all).
+ * - A rename leaks into a slug. The routes below must keep resolving on
+ * their OLD ids; asserting the response is what catches an over-eager
+ * find-and-replace that renamed the URL too.
+ *
+ * Each app is checked for the presence of its NEW name rather than the
+ * absence of the old one: an empty page passes an absence check.
+ */
+
+import {test, expect} from './base.js';
+
+/* OLD slug -> [NEW slug, display name].
+ *
+ * The display names moved 2026-08-21; the PATHS moved 2026-08-23. Both old and
+ * new must work: the old URLs were published and are still linked from
+ * @conduction/docusaurus-preset's apps-registry, which feeds
+ * and the academy product filter and still points at the old paths.
+ *
+ * So this asserts the REDIRECT, not the absence of one. An earlier version of
+ * this file asserted "the rename must not have moved the URL" — true when only
+ * the display name had moved, and false now. A test that pins the old
+ * behaviour after the behaviour changes does not protect anything; it just
+ * fails until someone edits it. */
+const RENAMED = {
+ openconnector: ['integriq', 'Integriq'],
+ docudesk: ['filinq', 'Filinq'],
+ procest: ['dossiq', 'Dossiq'],
+ doriath: ['keepiq', 'Keepiq'],
+ hrmq: ['humaniq', 'Humaniq'],
+ scholiq: ['learniq', 'Learniq'],
+ decidesk: ['decidiq', 'Decidiq'],
+ softwarecatalog: ['stackiq', 'Stackiq'],
+ nldesign: ['thematiq', 'Thematiq'],
+ openbuild: ['buildiq', 'Buildiq'],
+ larpingapp: ['larpinq', 'Larpinq'],
+ 'app-versions': ['versioniq', 'Versioniq'],
+ // Planninq, not Planiq: "PlanIQ" is Anaplan's trademark in the same
+ // planning category, so this one took the -inq form instead.
+ planix: ['planninq', 'Planninq'],
+};
+
+/* Names that were deliberately NOT renamed; a sweep that catches them
+ * has overreached. Checked on their own product page rather than the
+ * index, because the index card set is curated and does not list all. */
+const UNCHANGED = {
+ openregister: 'OpenRegister',
+ opencatalogi: 'OpenCatalogi',
+ pipelinq: 'Pipelinq',
+ shillinq: 'Shillinq',
+ hermiq: 'Hermiq',
+ portaliq: 'Portaliq',
+};
+
+test.describe('renamed products', () => {
+ for (const [oldSlug, [newSlug, name]] of Object.entries(RENAMED)) {
+ test(`/apps/${oldSlug} redirects to /apps/${newSlug} and shows "${name}"`, async ({page}) => {
+ const response = await page.goto(`/apps/${oldSlug}`);
+ expect(response.status(), `/apps/${oldSlug} must still resolve`).toBeLessThan(400);
+ // Client-side redirect, so wait for the destination rather than reading
+ // the first response's URL.
+ await page.waitForURL(`**/apps/${newSlug}/`);
+ await expect(page.locator('h1')).toContainText(name);
+ });
+
+ test(`/apps/${newSlug} serves "${name}" directly`, async ({page}) => {
+ const response = await page.goto(`/apps/${newSlug}`);
+ expect(response.status(), `/apps/${newSlug} must resolve`).toBeLessThan(400);
+ await expect(page.locator('h1')).toContainText(name);
+ });
+ }
+
+ test('the apps index lists every new name', async ({page}) => {
+ await page.goto('/apps');
+ const body = await page.locator('body').innerText();
+ for (const [, name] of Object.values(RENAMED)) {
+ expect(body, `/apps should name ${name}`).toContain(name);
+ }
+ });
+
+ test('connext lists the renamed apps it builds on', async ({page}) => {
+ await page.goto('/connext');
+
+ /* Asserted against the page, not against one container.
+ This used to read `pd-item[name]`, the platform diagram's custom
+ elements, and it stopped meaning anything twice over: that diagram
+ now lists the Nextcloud apps Connext runs on (Files, Mail, Calendar,
+ Talk, Office) rather than ours, and the custom elements are replaced
+ by the diagram's own script on hydration, so the selector matched
+ nothing at all. What the rename sweep actually cares about is that
+ the page says the new names somewhere. */
+ const body = page.locator('body');
+ for (const name of ['Integriq', 'Filinq', 'Keepiq', 'Versioniq', 'Thematiq']) {
+ await expect(body, `/connext should name ${name}`).toContainText(name);
+ }
+ });
+
+ for (const [slug, name] of Object.entries(UNCHANGED)) {
+ test(`${name} was not caught by the rename sweep`, async ({page}) => {
+ await page.goto(`/apps/${slug}`);
+ await expect(page.locator('h1')).toContainText(name);
+ });
+ }
+
+ test('NL Design System keeps its name where the standard is meant', async ({page}) => {
+ // nldesign the app became Thematiq; "NL Design System" the Dutch
+ // government standard must survive the sweep untouched.
+ await page.goto('/apps/nldesign');
+ await expect(page.locator('h1')).toContainText('Thematiq');
+ await expect(page.locator('body')).toContainText('NL Design System');
+ });
+});
diff --git a/e2e/product-page-games.spec.js b/e2e/product-page-games.spec.js
new file mode 100644
index 000000000..df0f9aaeb
--- /dev/null
+++ b/e2e/product-page-games.spec.js
@@ -0,0 +1,668 @@
+/**
+ * The mini-games on the product pages, and the way in to each.
+ *
+ * Each game's rules have their own unit tests upstream in the design
+ * system, and the unlock matchers have theirs. This suite covers the
+ * half neither can see: that a game is mounted on the page it belongs
+ * to, that it stays hidden until somebody finds it, that its own way
+ * in works through a real browser, and that a finished run reaches the
+ * shared game-over dialog with a score.
+ *
+ * Two failures are worth catching before Contributor Week, and they
+ * look identical from the outside: a game nobody can reach, and a game
+ * sitting in plain sight on a product page.
+ */
+
+import {test, expect} from './base.js';
+
+const MODAL = 'div[class*="modal"] div[class*="panel"]';
+
+/** Clear the score table and the found-games list, so a visit is a first visit. */
+async function clearScores(page) {
+ await page.addInitScript(() => {
+ try {
+ window.localStorage.removeItem('conduction:minigames');
+ window.localStorage.removeItem('conduction:minigames-found');
+ window.localStorage.removeItem('conduction:mastodon-instance');
+ } catch (e) {/* private mode: the game copes, so must the test */}
+ });
+}
+
+/* How each game is found. The same riddles the arcade page hints at,
+ written out once so a test never has to know a game's own way in. */
+const UNLOCK = {
+ 'stamp-rush': async (page) => {
+ const glyph = page.locator('[data-hidden-target="app-glyph"]').first();
+ for (let i = 0; i < 3; i++) await glyph.click({force: true});
+ },
+ 'deadline-defender': async (page) => {
+ for (const key of ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
+ 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']) {
+ await page.keyboard.press(key);
+ }
+ },
+ 'blueprint-rush': async (page) => { await page.keyboard.type('build'); },
+ 'paint-by-tokens': async (page) => { await page.keyboard.type('paint'); },
+ 'lock-pick': async (page) => {
+ const glyph = page.locator('[data-hidden-target="app-glyph"]').first();
+ await glyph.hover();
+ await page.mouse.down();
+ await page.waitForTimeout(1800);
+ await page.mouse.up();
+ },
+ redaction: async (page) => {
+ /* Select a paragraph and leave it selected, as if reaching for a
+ marker. The watcher waits for the selection to settle. */
+ await page.evaluate(() => {
+ const p = [...document.querySelectorAll('p')].find((el) => el.textContent.trim().length > 60);
+ const range = document.createRange();
+ range.selectNodeContents(p);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
+ await page.waitForTimeout(1500);
+ },
+ 'pipe-fit': async (page) => {
+ /* Integriq's game hides behind the same quiet opener: the one way
+ in that shows itself, for a page with nothing else to poke at. */
+ await page.getByRole('button', {name: /take a break/i}).click();
+ },
+ 'dice-duel': async (page) => { await page.keyboard.type('20'); },
+ 'full-stack': async (page) => { await page.keyboard.type('stack'); },
+ 'monster-run': async (page) => { await page.keyboard.type('alive'); },
+ reconcile: async (page) => {
+ /* Five knocks, not three: Shillinq's logo wants more patience than
+ Decidiq's, so that one page's riddle cannot open another's. */
+ const glyph = page.locator('[data-hidden-target="app-glyph"]').first();
+ for (let i = 0; i < 5; i++) await glyph.click({force: true});
+ },
+};
+
+/**
+ * Open a game the way a player would, then wait for it to arrive.
+ *
+ * The unlock is retried rather than tried once. Every trigger listens
+ * from the client, so a test that types the instant `goto` resolves
+ * can beat hydration to it and find nothing: under parallel load that
+ * turned four solid tests into flakes. A person who types and sees
+ * nothing types again, and so does this.
+ */
+async function findGame(page, id, locator) {
+ await expect(locator, 'the game was on the page before anyone found it').toHaveCount(0);
+ await expect(async () => {
+ await UNLOCK[id](page);
+ await expect(locator).toBeVisible({timeout: 1500});
+ }).toPass({timeout: 25000});
+}
+
+test.describe('stamp rush, on the Decidiq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/decidiq/');
+ });
+
+ test('stays hidden until the logo is knocked on, then deals decisions', async ({page}) => {
+ const game = page.locator('section[class*="rush"]');
+ await findGame(page, 'stamp-rush', game);
+
+ /* In the hero, not somewhere down the page: the way in is the logo
+ at the top, so the reward cannot be a screen you have to go
+ looking for. `withIllustration` is the product hero's own class;
+ the games carry a `head` of their own, so matching on that would
+ find the game inside itself. */
+ await expect(
+ page.locator('section[class*="withIllustration"] section[class*="rush"]'),
+ 'the game opened somewhere other than the hero',
+ ).toBeVisible();
+
+ /* And in the mock's place, not beside it. */
+ await expect(
+ page.locator('[class*="illustration_"]'),
+ 'the app mock is still there, so the game is not where it should be',
+ ).toBeHidden();
+
+ await game.getByRole('button', {name: /take the pen/i}).click();
+
+ /* A desk holding a decision, whichever kind it is. */
+ const occupied = game.getByRole('button', {name: /Desk \d: (Ready to adopt|No quorum|Interest declared)/});
+ await expect(occupied.first()).toBeVisible({timeout: 5000});
+ });
+
+ test('three bad stamps end the run and open the dialog with a score', async ({page}) => {
+ const game = page.locator('section[class*="rush"]');
+ await findGame(page, 'stamp-rush', game);
+ await game.getByRole('button', {name: /take the pen/i}).click();
+
+ /* Stamp only what should have been held back. Three of those end
+ it, whatever else is on the board. */
+ const bad = game.getByRole('button', {name: /Desk \d: (No quorum|Interest declared)/});
+ await expect(async () => {
+ const count = await bad.count();
+ for (let i = 0; i < count; i++) await bad.nth(i).click({timeout: 1000}).catch(() => {});
+ await expect(page.locator(MODAL)).toBeVisible({timeout: 500});
+ }).toPass({timeout: 30000});
+
+ await expect(page.locator(MODAL)).toContainText(/decisions adopted/);
+ await expect(page.locator(MODAL)).toContainText(/mini-games found/i);
+ });
+});
+
+test.describe('deadline defender, on the Dossiq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/dossiq/');
+ });
+
+ test('stays hidden until the old code is typed, then deals a case', async ({page}) => {
+ const game = page.locator('section[class*="dd_"]');
+ await findGame(page, 'deadline-defender', game);
+ await game.getByRole('button', {name: /open the queue/i}).click();
+
+ /* A case, its clock, and somewhere to send it. Deliberately not an
+ answer key: this test used to map each case's wording to the step
+ it wanted, and that map broke twice — once when the copy was
+ rewritten and once when a fourth lane arrived, because a case that
+ takes no next step at all is now one of the answers. What matters
+ here is that the board is dealt and readable; whether a given case
+ belongs in Intake is the engine's own tests' business. */
+ await expect(game.locator('p[class*="fileText"]')).toBeVisible({timeout: 5000});
+ /* Two of them now: the margin left on the case, and the clock. */
+ await expect(game.getByRole('progressbar').first()).toBeVisible();
+
+ const lanes = game.getByRole('button', {name: /^(Intake|Assessment|Decision|Off the queue)$/});
+ await expect(lanes).toHaveCount(4);
+
+ /* And answering does something: the case in front of you is replaced,
+ whichever lane you chose and whether or not it was right. */
+ const first = await game.locator('p[class*="fileText"]').innerText();
+ await lanes.first().click();
+ await expect(game.locator('p[class*="fileText"]')).not.toHaveText(first, {timeout: 5000});
+ });
+
+ test('a run that goes wrong reaches the dialog', async ({page}) => {
+ const game = page.locator('section[class*="dd_"]');
+ await findGame(page, 'deadline-defender', game);
+ await game.getByRole('button', {name: /open the queue/i}).click();
+
+ /* Send everything to intake. Most cases do not belong there, so the
+ run ends within a handful of cases. */
+ await expect(async () => {
+ await game.getByRole('button', {name: 'Intake', exact: false}).click({timeout: 1000}).catch(() => {});
+ await expect(page.locator(MODAL)).toBeVisible({timeout: 500});
+ }).toPass({timeout: 30000});
+
+ await expect(page.locator(MODAL)).toContainText(/cases on time/);
+ });
+});
+
+test.describe('blueprint rush, on the Buildiq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/buildiq/');
+ });
+
+ test('stays hidden until the word is typed, then deals a rail and a tray', async ({page}) => {
+ const game = page.locator('section[class*="br_"]');
+ await findGame(page, 'blueprint-rush', game);
+ await game.getByRole('button', {name: /open a blueprint/i}).click();
+
+ /* The flow is an ordered rail with places still to fill, and a
+ tray of parts to fill them from. */
+ const rail = game.getByRole('list', {name: /the flow, in order/i});
+ await expect(rail).toBeVisible({timeout: 5000});
+ await expect(game.getByRole('button', {name: /needs/})).not.toHaveCount(0);
+ });
+
+ test('laying what is ready builds the flow and scores', async ({page}) => {
+ const game = page.locator('section[class*="br_"]');
+ await findGame(page, 'blueprint-rush', game);
+ await game.getByRole('button', {name: /open a blueprint/i}).click();
+
+ /* Every part says its own state, so the rail can be built without
+ the test knowing the order: lay whatever needs nothing first,
+ and what that unlocks says so in turn. Reading the labels is the
+ game, which is why they are the thing to drive. */
+ const ready = game.getByRole('button', {name: /needs nothing first/});
+ const score = game.locator('span[class*="hudPill"]').first();
+
+ await expect(async () => {
+ /* The flow is on a clock, so a worker that loses one simply takes
+ the next: this asserts that laying what is ready scores, not that
+ it happens inside any one flow. */
+ const again = game.getByRole('button', {name: /open a blueprint|restart/i});
+ if (await again.isVisible().catch(() => false)) await again.click().catch(() => {});
+
+ const n = await ready.count();
+ for (let i = 0; i < n; i++) await ready.nth(i).click({timeout: 800}).catch(() => {});
+ const points = Number((await score.innerText()).replace(/\D/g, ''));
+ expect(points, 'nothing was laid, so nothing scored').toBeGreaterThan(0);
+ }).toPass({timeout: 60000});
+ });
+});
+
+test.describe('lock pick, on the Keepiq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/keepiq/');
+ });
+
+ /**
+ * Read the lock at one position.
+ *
+ * The dial no longer tells you anything by itself: you set the pick,
+ * lean on the cylinder, and watch how far it comes round. A lean that
+ * is released as soon as it stops is free, which the game says in its
+ * own hint, so a sweep costs nothing.
+ */
+ async function feelAt(game, value) {
+ await game.getByRole('slider').fill(String(value));
+ await game.getByRole('button', {name: /hold to turn the cylinder/i}).click();
+ return (await game.locator('p[class*="feel"]').innerText()).trim();
+ }
+
+ test('stays hidden until the logo is held down, then reads hot and cold', async ({page}) => {
+ const game = page.locator('section[class*="lp_"]');
+ await findGame(page, 'lock-pick', game);
+ await game.getByRole('button', {name: /take a pick/i}).click();
+
+ /* The lock has to feel different in different places, or there is
+ nothing to read and the game is a guess. */
+ const readings = new Set();
+ for (const value of ['5', '25', '50', '75', '95']) {
+ readings.add(await feelAt(game, value));
+ }
+ expect(readings.size, 'the lock felt the same everywhere').toBeGreaterThan(1);
+
+ /* And reading it cost nothing: all three picks are still in hand. */
+ await expect(game.getByText(/Picks 3/)).toBeVisible();
+ });
+
+ test('a careful sweep opens a lock', async ({page}) => {
+ const game = page.locator('section[class*="lp_"]');
+ await findGame(page, 'lock-pick', game);
+ await game.getByRole('button', {name: /take a pick/i}).click();
+
+ const rank = (text) => (/almost turns/.test(text) ? 4
+ : /good way/.test(text) ? 3
+ : /gives a little/.test(text) ? 2
+ : /Barely/.test(text) ? 1 : 0);
+
+ /* Sweep, then commit in the middle of the best-feeling band. The
+ first position that feels strongest sits at its edge, which is
+ often just outside the tolerance: exactly the mistake a player
+ makes on their first lock. */
+ let bestRank = -1;
+ let band = [];
+ for (let v = 2; v < 100; v += 4) {
+ const r = rank(await feelAt(game, v));
+ if (r > bestRank) { bestRank = r; band = [v]; }
+ else if (r === bestRank) band.push(v);
+ }
+ const best = band[Math.floor(band.length / 2)];
+ await game.getByRole('slider').fill(String(best));
+
+ /* Now lean and keep leaning: letting go the moment it stops is what
+ makes a sweep free, so opening it means holding past that. */
+ const turn = game.getByRole('button', {name: /hold to turn the cylinder/i});
+ await expect(async () => {
+ await turn.hover();
+ await page.mouse.down();
+ await page.waitForTimeout(700);
+ await page.mouse.up();
+ await expect(game.locator('p[class*="feedback"]')).toContainText(/Open, in/, {timeout: 500});
+ }).toPass({timeout: 30000});
+ });
+});
+
+test.describe('black it out, on the Filinq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/filinq/');
+ });
+
+ /* What may not be published, by kind rather than by wording. Each
+ word carries its own `data-token`, so the test blacks out the same
+ thing the rules do and does not go stale the next time the copy is
+ rewritten, which is exactly how the previous version of this test
+ broke. Must stay in step with `secret: true` in the engine. */
+ const SECRET = ['name', 'address', 'bsn', 'iban', 'email', 'birthdate',
+ 'phone', 'medical', 'benefit', 'plate'];
+
+ test('stays hidden until a sentence is selected, then publishes clean', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Black it out'});
+ await findGame(page, 'redaction', game);
+ await game.getByRole('button', {name: /open the stack/i}).click();
+
+ const secrets = game.locator(SECRET.map((t) => `button[data-token="${t}"]`).join(', '));
+ const count = await secrets.count();
+ expect(count, 'the document dealt nothing to redact').toBeGreaterThan(0);
+ for (let i = 0; i < count; i++) await secrets.nth(i).click();
+
+ await game.getByRole('button', {name: /publish it/i}).click();
+ await expect(game.locator('p[class*="hint"]')).toContainText(/Clean\./);
+ });
+
+ test('publishing with a name still on it is a breach', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Black it out'});
+ await findGame(page, 'redaction', game);
+ await game.getByRole('button', {name: /open the stack/i}).click();
+
+ /* Straight to publish, with everything still legible. */
+ await game.getByRole('button', {name: /publish it/i}).click();
+ await expect(game.locator('p[class*="hint"]')).toContainText(/Breach\./);
+ });
+});
+
+test.describe('paint by tokens, on the Thematiq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/thematiq/');
+ });
+
+ test('stays hidden until the word is typed, then every cell says its token', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Paint by tokens'});
+ await findGame(page, 'paint-by-tokens', game);
+ await game.getByRole('button', {name: /open a theme/i}).click();
+
+ const cells = game.locator('button[class*="cell"]');
+ await expect(cells).toHaveCount(56); // 7 across, 8 down
+
+ const wanted = await cells.first().getAttribute('aria-label');
+ const token = wanted.replace(/^Wants /, '').trim();
+ await game.getByRole('button', {name: new RegExp(`\\b${token}\\b`), exact: false}).first().click();
+ await cells.first().click();
+ await expect(cells.first()).toHaveAttribute('aria-label', new RegExp(`Filled with ${token}`));
+ });
+});
+
+test('a game found once is hidden again on the next visit', async ({page}) => {
+ /* Deliberate, and the opposite of what this suite used to assert: a
+ game stays open for the visit that found it and no longer. A page
+ pinned open on a game stops doing the job it is there for, which
+ is explaining the product. The way back without the riddle is the
+ play link, asserted below. */
+ await clearScores(page);
+ await page.goto('/apps/decidiq/');
+
+ const game = page.locator('section[class*="rush"]');
+ await findGame(page, 'stamp-rush', game);
+
+ await page.reload();
+ await expect(game, 'the game stayed open across a reload').toHaveCount(0);
+});
+
+test('a direct play link opens a game without solving its riddle', async ({page}) => {
+ /* The way out for anyone stuck, and the way in for a link in a post. */
+ await clearScores(page);
+ await page.goto('/apps/thematiq/#play-paint-by-tokens');
+ await expect(page.getByRole('region', {name: 'Paint by tokens'})).toBeVisible({timeout: 5000});
+});
+
+test('the app mock has the hero to itself until a game is found', async ({page}) => {
+ /* The other half of the swap: a page whose game nobody has found
+ looks exactly like a page with no game at all. */
+ await clearScores(page);
+ await page.goto('/apps/decidiq/');
+ await expect(page.locator('[class*="illustration_"]')).toBeVisible();
+ await expect(page.locator('section[class*="rush"]')).toHaveCount(0);
+});
+
+test('one riddle does not open another page game', async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/buildiq/');
+ /* Buildiq answers to a typed word, not to the Konami code. */
+ await UNLOCK['deadline-defender'](page);
+ await page.waitForTimeout(600);
+ await expect(page.locator('section[class*="br_"]')).toHaveCount(0);
+
+ await UNLOCK['blueprint-rush'](page);
+ await expect(page.locator('section[class*="br_"]')).toBeVisible({timeout: 5000});
+});
+
+test('the arcade page lists every game the site ships', async ({page}) => {
+ await page.goto('/arcade/');
+ /* The roster in docusaurus.config.js and this list have to agree, or
+ the dialog counts a game the page never names. */
+ for (const name of [
+ 'Twelve apps', 'Sink the boats', 'Hex-vaders', 'Logo memory', 'Kade cyclist',
+ 'Stamp rush', 'Deadline defender', 'Blueprint rush',
+ 'Lock pick', 'Paint by tokens', 'Black it out',
+ 'Monster run', 'Dice duel', 'Match the bank', 'Make the connection',
+ 'Full stack',
+ ]) {
+ await expect(page.getByText(name, {exact: false}).first()).toBeVisible();
+ }
+});
+
+test.describe('dice duel, on the Larpinq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/larpinq/');
+ });
+
+ test('stays hidden until the lucky roll is typed, then deals a hand', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Dice duel'});
+ await findGame(page, 'dice-duel', game);
+ await game.getByRole('button', {name: /draw steel/i}).click();
+
+ const dice = game.getByRole('group', {name: /your dice/i}).getByRole('button');
+ await expect(dice).toHaveCount(4);
+
+ /* Holding a die says so in its label, not only in its colour: that
+ is the whole state a player has to read before pushing. */
+ await dice.first().click();
+ await expect(dice.first()).toHaveAttribute('aria-label', /held/);
+ await expect(game.locator('p[class*="worth"]')).toContainText(/worth \d/i);
+ });
+
+ test('pushing your luck until it bites reaches the dialog', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Dice duel'});
+ await findGame(page, 'dice-duel', game);
+ await game.getByRole('button', {name: /draw steel/i}).click();
+
+ /* Hold nothing and keep throwing. A hand that comes up worse than
+ the one before it costs a heart, and three of those end it. */
+ await expect(async () => {
+ await game.getByRole('button', {name: /push your luck/i}).click({timeout: 800}).catch(() => {});
+ await game.getByRole('button', {name: /^Swing$/}).click({timeout: 800}).catch(() => {});
+ await expect(page.locator(MODAL)).toBeVisible({timeout: 400});
+ }).toPass({timeout: 40000});
+
+ await expect(page.locator(MODAL)).toContainText(/monsters felled/);
+ });
+});
+
+test.describe('match the bank, on the Shillinq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/shillinq/');
+ });
+
+ test('wants five knocks, not three, and then settles a payment', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Match the bank'});
+ const glyph = page.locator('[data-hidden-target="app-glyph"]').first();
+
+ /* Decidiq's three knocks must not open Shillinq's game, or every
+ page would answer to the same riddle and the hunt would be one
+ riddle long. Three, a pause, then the two that finish it. */
+ await expect(async () => {
+ for (let i = 0; i < 3; i++) await glyph.click({force: true});
+ await page.waitForTimeout(400);
+ await expect(game).toHaveCount(0);
+ }).toPass({timeout: 15000});
+
+ await findGame(page, 'reconcile', game);
+ await game.getByRole('button', {name: /open the statement/i}).click();
+
+ /* Pick up a payment; the one in hand says so in its pressed state. */
+ const payments = game.getByRole('button', {name: /^Settle invoice/}).first();
+ const picked = game.locator('button[aria-pressed="true"]');
+ await expect(picked).toHaveCount(0);
+ await game.locator('button[class*="pick"]').first().click();
+ await expect(picked).toHaveCount(1);
+ await expect(payments).toBeVisible();
+ });
+
+ test('flagging the real payments burns the corrections and opens the dialog', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Match the bank'});
+ await findGame(page, 'reconcile', game);
+ await game.getByRole('button', {name: /open the statement/i}).click();
+
+ /* At most one line on a statement belongs to nobody, so flagging
+ everything spends the three corrections within a sheet or two. */
+ await expect(async () => {
+ const flags = await game.getByRole('button', {name: /^Flag /}).all();
+ for (const flag of flags) await flag.click({timeout: 600}).catch(() => {});
+ await expect(page.locator(MODAL)).toBeVisible({timeout: 400});
+ }).toPass({timeout: 30000});
+
+ await expect(page.locator(MODAL)).toContainText(/lines matched/);
+ });
+});
+
+test.describe('make the connection, on the Integriq page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/apps/integriq/');
+ });
+
+ test('stays behind a quiet opener, then a route can be made to meet', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Make the connection'});
+ await findGame(page, 'pipe-fit', game);
+ await game.getByRole('button', {name: /send it/i}).click();
+
+ const pieces = game.getByRole('button', {name: /^Connector \d/});
+ await expect(pieces.first()).toBeVisible({timeout: 5000});
+
+ /* Turn left to right: each connector has exactly one turn that
+ meets what the one before it hands over, so a single pass
+ settles the line. The route is replaced the moment it connects,
+ so the walk starts over rather than carrying on into a puzzle
+ nobody asked for. */
+ await expect(async () => {
+ const count = await pieces.count();
+ for (let i = 0; i < count; i++) {
+ for (let t = 0; t < 3; t++) {
+ const label = await pieces.nth(i).getAttribute('aria-label');
+ if (label && !/does not meet/.test(label)) break;
+ await pieces.nth(i).click({timeout: 600});
+ }
+ }
+ await expect(game.locator('p[class*="hint"]')).toContainText(/Through/, {timeout: 500});
+ }).toPass({timeout: 30000});
+
+ const score = Number((await game.locator('span[class*="hudPill"]').first().innerText()).replace(/\D/g, ''));
+ expect(score, 'the route connected without paying the bonus').toBeGreaterThanOrEqual(20);
+ });
+});
+
+test.describe('the monster goes for a run, in the La Frankendesk post', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/academy/blog/la-frankendesk/');
+ });
+
+ test('stays hidden until the word Frankenstein shouts is typed', async ({page}) => {
+ const game = page.getByRole('region', {name: 'The monster goes for a run'});
+ await findGame(page, 'monster-run', game);
+ await game.getByRole('button', {name: /it lives/i}).click();
+
+ await expect(game.getByRole('button', {name: /^Jump$/})).toBeVisible();
+ await expect(game.getByRole('button', {name: /^Duck$/})).toBeVisible();
+ });
+
+ test('the first stride is clear, so nobody loses a stitch to a standing start', async ({page}) => {
+ /* The run opened with an obstacle already on top of the monster,
+ which cost a stitch before anyone could reach a key. The lead-in
+ columns fixed it, and this is the line that keeps them. */
+ const game = page.getByRole('region', {name: 'The monster goes for a run'});
+ await findGame(page, 'monster-run', game);
+ await game.getByRole('button', {name: /it lives/i}).click();
+
+ await page.waitForTimeout(1200);
+ await expect(game.getByText(/Stitches 3/)).toBeVisible();
+ });
+});
+
+test.describe('the game-over card', () => {
+ /* The card is the thing people screenshot and the only place the
+ total and the share buttons live, so it has to fit in the window
+ it opens in. With sixteen games in one column it did not: on a
+ laptop the roster ran past the bottom and took Play again with it.
+ Both tests below fail if the two-column layout is dropped. */
+
+ /** Open the card without playing: the modal listens for this event. */
+ async function showCard(page) {
+ await page.goto('/connext/');
+ /* The listener is attached from the client, so an event dispatched
+ before hydration reaches nobody. There is no longer a visible
+ opener on this page to wait for, so the retry below does the
+ waiting: it fires again until the card answers. */
+ const fire = () => page.evaluate(() => {
+ window.dispatchEvent(new CustomEvent('connext:gameend', {detail: {
+ id: 'full-stack',
+ won: false,
+ score: 102,
+ summary: '9 layers · 2 whole stacks',
+ title: 'The stack is full.',
+ }}));
+ });
+ const panel = page.locator('div[class*="modal"] > div[class*="panel"]');
+ await expect(async () => {
+ await fire();
+ await expect(panel).toBeVisible({timeout: 1500});
+ }).toPass({timeout: 20000});
+ return panel;
+ }
+
+ test('fits in a laptop window, with the buttons in it', async ({page}) => {
+ await clearScores(page);
+ await page.setViewportSize({width: 1440, height: 800});
+ const panel = await showCard(page);
+
+ /* Not a height in pixels: the card carries its own scrollbar as a
+ last resort, so "shorter than 760" would pass on a card that is
+ scrolling. Ask the card whether it had to. */
+ const overflow = await panel.evaluate((el) => el.scrollHeight - el.clientHeight);
+ expect(overflow, 'the card has to scroll to show itself').toBeLessThanOrEqual(1);
+
+ /* Not merely rendered: on screen. A button below the fold of a card
+ that cannot scroll is the same as no button. */
+ await expect(page.getByRole('button', {name: /play again/i})).toBeInViewport();
+ });
+
+ test('lays the sixteen games out in two columns when there is room', async ({page}) => {
+ await clearScores(page);
+ await page.setViewportSize({width: 1440, height: 800});
+ const panel = await showCard(page);
+
+ const items = panel.locator('li[class*="gridItem"]');
+ await expect(items).toHaveCount(16);
+
+ const first = await items.first().boundingBox();
+ const ninth = await items.nth(8).boundingBox();
+ expect(ninth.x, 'the roster is still one column').toBeGreaterThan(first.x + 40);
+ expect(Math.abs(ninth.y - first.y), 'the second column does not start at the top')
+ .toBeLessThan(8);
+ });
+});
+
+test.describe('full stack, on the Connext page', () => {
+ test.beforeEach(async ({page}) => {
+ await clearScores(page);
+ await page.goto('/connext/');
+ });
+
+ test('stays hidden until the word is typed, then deals a well of app tiles', async ({page}) => {
+ const game = page.getByRole('region', {name: 'Full stack'});
+ await findGame(page, 'full-stack', game);
+ await game.getByRole('button', {name: /start stacking/i}).click();
+
+ /* A column per width of the well, each saying how deep it already
+ is, so the board is readable without seeing it. */
+ const columns = game.getByRole('button', {name: /^Column \d/});
+ await expect(columns.first()).toBeVisible({timeout: 5000});
+ await expect(game.getByText(/In play:/)).toBeVisible();
+ });
+});
diff --git a/e2e/product-pages.spec.js b/e2e/product-pages.spec.js
new file mode 100644
index 000000000..43dac4ad6
--- /dev/null
+++ b/e2e/product-pages.spec.js
@@ -0,0 +1,185 @@
+/**
+ * Product pages, in both locales: structure, hydration, and the new
+ * interactive surfaces.
+ *
+ * The 2026-08 rewrite gave every app page the same skeleton (hero, four
+ * ways, widget carousel, workspace showcase, pairs, partners at the
+ * bottom) with deliberate per-app exceptions. This suite pins that
+ * skeleton the same way smoke.spec.js pins the route list: expectations
+ * are listed explicitly per page, so a page that silently loses a section
+ * fails a test instead of a review.
+ *
+ * What each check is for:
+ * - "Unknown app" is AppMock's fallback frame; it appearing means the
+ * preset shipped without the page's hero variant (this happened when the
+ * npm trusted publisher silently broke and node_modules stayed on an
+ * old preset).
+ * - The carousel controls only render when WidgetShelf is in carousel
+ * mode; their absence means the shelf regressed to the static grid.
+ * - Console errors catch hydration failures, which build green and
+ * screenshot perfectly (see smoke.spec.js's header).
+ */
+
+import {test, expect} from './base.js';
+import {collectConsoleErrors, expectNoConsoleErrors} from './helpers.js';
+
+/*
+ * ways: expected heading fragment ('four' | 'three' | null when the page
+ * has no cards section at all).
+ * shelf: page renders a WidgetShelf (carousel controls expected).
+ * demo: hero carries the partner-demo deeplink for this slug.
+ */
+const APPS = {
+ pipelinq: {ways: 'four', shelf: true, demo: true},
+ openregister: {ways: 'four', shelf: true, demo: true},
+ buildiq: {ways: 'four', shelf: true, demo: true},
+ hermiq: {ways: 'four', shelf: true, demo: true},
+ shillinq: {ways: 'four', shelf: true, demo: true},
+ launchpad: {ways: 'four', shelf: true, demo: true},
+ stackiq: {ways: 'four', shelf: true, demo: true},
+ decidiq: {ways: 'four', shelf: true, demo: true},
+ dossiq: {ways: 'four', shelf: true, demo: true},
+ learniq: {ways: 'four', shelf: true, demo: true},
+ filinq: {ways: 'four', shelf: true, demo: true},
+ opencatalogi: {ways: 'four', shelf: true, demo: true},
+ integriq: {ways: 'four', shelf: true, demo: true},
+ planninq: {ways: 'four', shelf: true, demo: true},
+ humaniq: {ways: 'four', shelf: true, demo: true},
+ larpinq: {ways: 'four', shelf: true, demo: true},
+ /* Deliberate exceptions, and why:
+ * - keepiq: a zero-knowledge vault has no client-self-service story,
+ * so it honestly keeps three ways.
+ * - portaliq: it IS the self-service story; the trio stays.
+ * - thematiq: ships zero widgets; its shelf shows the theme tokens.
+ * - zaakafhandelapp: sunset page pointing at Procest; no cards/shelf.
+ * - versioniq: in development; no cards/shelf/demo CTA. */
+ keepiq: {ways: 'three', shelf: true, demo: true},
+ portaliq: {ways: 'three', shelf: true, demo: true},
+ /* thematiq: a theme has no client-self-service card either. */
+ thematiq: {ways: 'three', shelf: true, demo: true},
+ zaakafhandelapp: {ways: null, shelf: false, demo: true},
+ 'versioniq': {ways: null, shelf: false, demo: false},
+};
+
+const WAYS_HEADING = {
+ en: {four: /Four ways .+ earns its place/i, three: /Three ways .+ earns its place/i},
+ nl: {four: /Vier manieren waarop .+ zijn plek verdient/i, three: /Drie manieren waarop .+ zijn plek verdient/i},
+};
+
+/* PAGE SLUG -> the app id the PARTNERS PAGE and still use.
+ *
+ * The page paths moved 2026-08-23; these two identifiers did NOT, and the
+ * difference is deliberate. `?app=` filters the partners directory, and
+ * `` resolves an illustration out of
+ * @conduction/docusaurus-preset — both keyed on the id those consumers know.
+ * Moving them here would filter on a value the partners page has never seen and
+ * render the "Unknown app" fallback frame, which the assertion just above
+ * explicitly forbids.
+ *
+ * Absent from this map means the page slug and the app id are still the same. */
+const PARTNER_APP_ID = {
+ versioniq: 'app-versions',
+ decidiq: 'decidesk',
+ filinq: 'docudesk',
+ keepiq: 'doriath',
+ humaniq: 'hrmq',
+ larpinq: 'larpingapp',
+ thematiq: 'nldesign',
+ buildiq: 'openbuild',
+ integriq: 'openconnector',
+ planninq: 'planix',
+ dossiq: 'procest',
+ learniq: 'scholiq',
+ stackiq: 'softwarecatalog',
+};
+
+for (const locale of ['en', 'nl']) {
+ const prefix = locale === 'nl' ? '/nl' : '';
+ test.describe(`product pages (${locale})`, () => {
+ for (const [slug, exp] of Object.entries(APPS)) {
+ const path = `${prefix}/apps/${slug}/`;
+
+ test(`${slug}: renders, hydrates, and keeps its skeleton`, async ({page}) => {
+ const errors = collectConsoleErrors(page);
+ await page.goto(path);
+
+ // A real page, not a soft 404.
+ await expect(page.locator('h1').first()).toBeVisible();
+
+ // Hero renders a real mock, never the fallback frame.
+ await expect(page.getByText('Unknown app')).toHaveCount(0);
+
+ // The cards section, where the page has one.
+ if (exp.ways) {
+ await expect(
+ page.getByText(WAYS_HEADING[locale][exp.ways]).first(),
+ ).toBeVisible();
+ }
+
+ // Carousel controls prove the shelf is the carousel, not the grid.
+ if (exp.shelf) {
+ await expect(
+ page.getByRole('button', {name: /Previous widgets/i}).first(),
+ ).toBeVisible();
+ }
+
+ // The demo CTA deeplinks to the partners page for this app — by the
+ // app id the partners directory knows, which is not always the page
+ // slug any more. See PARTNER_APP_ID.
+ if (exp.demo) {
+ const appId = PARTNER_APP_ID[slug] ?? slug;
+ await expect(
+ page.locator(`a[href*="/partners/?app=${appId}"]`).first(),
+ ).toBeAttached();
+ }
+
+ expectNoConsoleErrors(errors);
+ });
+ }
+ });
+}
+
+test.describe('widget carousel behaviour', () => {
+ test('pause and nudge controls actually operate the track', async ({page}) => {
+ await page.goto('/apps/pipelinq/');
+ const track = page.locator('[class*="track"]').first();
+ await expect(track).toBeVisible();
+
+ const pause = page.getByRole('button', {name: /Pause/i}).first();
+ await pause.click();
+ const before = await track.evaluate(el => getComputedStyle(el).transform);
+ await page.waitForTimeout(800);
+ const after = await track.evaluate(el => getComputedStyle(el).transform);
+ expect(after).toBe(before); // paused = zero drift
+
+ const next = page.getByRole('button', {name: /Next widgets/i}).first();
+ await next.click();
+ await page.waitForTimeout(600);
+ const nudged = await track.evaluate(el => getComputedStyle(el).transform);
+ expect(nudged).not.toBe(before); // nudge moved the track while paused
+ });
+});
+
+test.describe('partners deeplink', () => {
+ test('?app= names the app: filtered when partners match, welcoming when none do', async ({page}) => {
+ await page.goto('/partners/?app=pipelinq');
+ // Either note form must acknowledge the app the visitor came from.
+ await expect(
+ page.getByText(/(Showing partners for|help you get started with)/i),
+ ).toBeVisible();
+ await expect(page.getByText('PipelinQ').first()).toBeVisible();
+ });
+
+ test('?app= with matching partners filters and offers the escape hatch', async ({page}) => {
+ // OpenRegister is carried by several partners in the catalog.
+ await page.goto('/partners/?app=openregister');
+ await expect(page.getByText(/Showing partners for/i)).toBeVisible();
+ await expect(page.getByRole('link', {name: /Show all partners/i})).toBeVisible();
+ });
+
+ test('an unknown slug falls back to the full directory', async ({page}) => {
+ await page.goto('/partners/?app=no-such-app');
+ await expect(page.getByText(/Showing partners for/i)).toHaveCount(0);
+ await expect(page.locator('h1').first()).toBeVisible();
+ });
+});
diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js
new file mode 100644
index 000000000..c48f06ad8
--- /dev/null
+++ b/e2e/smoke.spec.js
@@ -0,0 +1,174 @@
+/**
+ * Every page, in both locales: does it render, and does it hydrate?
+ *
+ * The rest of the suite goes deep on three surfaces. This one goes wide,
+ * because the failure this repo keeps hitting is invisible: a page that
+ * builds green, screenshots perfectly, and fails to hydrate. React reports
+ * that only on the console, so asserting zero console errors across every
+ * route is worth more here than any number of screenshot comparisons.
+ *
+ * It also makes framework upgrades decidable on evidence. Running this
+ * against React 18 and React 19 gave identical results, which is how the
+ * React 19 bump stopped being a guess.
+ *
+ * Routes are listed rather than read from the sitemap at runtime. A
+ * generated list that silently shrinks would quietly stop testing things,
+ * and noticing when something is missing is the point. (Docusaurus does
+ * generate sitemaps automatically, one per locale, via the classic
+ * preset's plugin-sitemap; both are referenced from robots.txt.)
+ *
+ * ---------------------------------------------------------------------
+ * KNOWN DEBT
+ *
+ * Three sets below record defects that predate this suite. They are listed
+ * rather than silently skipped so that the suite passes while the debt
+ * stays countable: shortening a list is the definition of done for each
+ * fix. All three were confirmed present on React 18 and React 19 alike, so
+ * none of them is upgrade fallout.
+ * ---------------------------------------------------------------------
+ */
+
+import {test, expect} from './base.js';
+import {collectConsoleErrors, expectNoConsoleErrors} from './helpers.js';
+
+const PAGES = [
+ '/', '/about/', '/ai/', '/apps/', '/beheer/', '/build/', '/commonground/',
+ '/connext/', '/contact/', '/demo/', '/install/', '/partners/', '/privacy/',
+ '/quality/', '/sidecars/', '/solutions/', '/support/', '/terms/',
+ '/academy/',
+];
+
+/* The Dutch site is a subset: pages are translated by duplicating the MDX,
+ so only those with an i18n copy exist under /nl/. */
+const NL_PAGES = [
+ '/nl/', '/nl/about/', '/nl/ai/', '/nl/apps/', '/nl/connext/', '/nl/contact/',
+ '/nl/privacy/', '/nl/terms/', '/nl/academy/',
+];
+
+/**
+ * Empty, and it should stay that way.
+ *
+ * Fourteen pages had no at all: they open on a SectionHead, which
+ * renders an , so the document had no level-one heading. Screen-reader
+ * users navigate by heading level and search engines weight h1, so that is
+ * a real defect (WCAG 1.3.1) rather than a stylistic preference.
+ *
+ * They now carry a visually hidden