diff --git a/actions/runtime/MANIFEST.json b/actions/runtime/MANIFEST.json index 684e2b19..0e6632b0 100644 --- a/actions/runtime/MANIFEST.json +++ b/actions/runtime/MANIFEST.json @@ -127,8 +127,8 @@ } ], "sources": { - "package-lock.json": "e9fcd4a82ed420f8163501283a79acc6b45508be4be742c8ab3f047e6d4a7aeb", - "package.json": "45f6bba881636a01d32bc1a044b988e769f8c1fc2730402920fd92ee0a204f9d", + "package-lock.json": "1c1b03505826ba0a7ac01dca2e589bf726fde74d5724d7268f3e343c9049799d", + "package.json": "710ad1ba8c7f9236fcdbc594e46d61276b1d5fd9f04dfd390d83e53294154040", "src/diffdevil/actions/analyze.ts": "e16cafafd0b0f3a142b33f9cab366fe395d97aec3da43f83eb40449735f0a14f", "src/diffdevil/actions/apply.ts": "40be2ca7e38568d269f612a2cd395575a4d3dac8a599aaacb16012f4cae548d5", "src/diffdevil/actions/inputs.ts": "d4d4a2ede4eb954c74fb540e5c8d92bd93737efb768c2246d81aa6681a5f6f51", diff --git a/apps/website/README.md b/apps/website/README.md index 53e203ff..d0fb6e35 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -2,7 +2,7 @@ ## Meaning -This is the AGPL-3.0-only application source for diffdevil's public website: the homepage, the complete read-only playground, the examples catalogue, the manual rendered from the repository's own Markdown, the public GitHub App page, and the legal/privacy routes. It is built with Astro static output and Starlight, with one lazy React island for the playground. It computes nothing itself: every number on every surface comes from the shared engine in `dist/lib`, at build time on static pages and in the browser inside the playground. The design authority is the repository's [design reference](../../design/README.md); the site applies Surface Grammar v1 and does not re-derive it. +This is the AGPL-3.0-only application source for diffdevil's public website: the homepage, the complete read-only playground, the examples catalogue, the manual rendered from the repository's own Markdown, the public browser-extension and GitHub App pages, and the legal/privacy routes. It is built with Astro static output and Starlight, with one lazy React island for the playground. It computes nothing itself: every number on every surface comes from the shared engine in `dist/lib`, at build time on static pages and in the browser inside the playground. The design authority is the repository's [design reference](../../design/README.md); the site applies Surface Grammar v1 and does not re-derive it. The website is built and qualified locally. Nothing here deploys, previews remotely, routes, or exposes it; publication is a separate decision with its own Work. @@ -28,21 +28,80 @@ Configuration is environment at build time, never a decided domain: | --- | --- | --- | | `DIFFDEVIL_SITE_ORIGIN` | Public origin used for canonical URLs, the Open Graph image and every absolute URL copied to an agent | `https://diffdevil.invalid` (placeholder; canonical links are omitted) | | `PUBLIC_PLAYGROUND_API` | Origin of the playground API Worker the playground calls for live public pull requests | `http://127.0.0.1:4173` (the local `npm run playground` server) | -| `PUBLIC_APP_INSTALL_URL` | GitHub App installation URL | unset: the install action states that the hosted App is not yet open | +| `PUBLIC_APP_INSTALL_URL` | Canonical GitHub App installation route. Every "Install the App" / "Install on GitHub" links here directly | unset | +| `PUBLIC_CHROME_WEB_STORE_URL` | Canonical Chrome Web Store listing of diffdevil for GitHub. Every "Add to Chrome" links here directly | unset | +| `PUBLIC_DASHBOARD_URL` | The authenticated application. When set, **Dashboard** takes the header's one trailing slot from the npm copy command | unset | +| `PUBLIC_DISCORD_URL`, `PUBLIC_BLUESKY_URL` | The shared Wolfsblvt Works Discord server and Bluesky account (Community panel and footer) | unset | +| `PUBLIC_SPONSOR_URL` | Funding destination of the `#support` Sponsor action | unset | + +The last five rows are live destinations, not design decisions. The site is built as though they exist: while one is unset its control renders complete but without an `href` (`aria-disabled`), through `ActionLink.astro`, so nothing points at an invented URL and nothing says "coming soon". Set the variable and the same control is live. "Action wording goes to the action, product wording goes to the product page": no install action is ever routed through `/extension/` or `/app/`. + +## One shell, one page grid + +Every route, the manual included, renders the same header (`src/components/Header.astro`) on the same grid (`.wrap` in `src/styles/global.css`: `--content-max` wide, `--page-x` gutters, centred). The lockup therefore sits at one coordinate everywhere. A page names its top-level section with a plain label beside the lockup (`Docs`, `Examples`, `GitHub App`, `Extension`); that label sits outside the home link and is never a link. Sections put their blocks on that grid; none of them chooses its own width. + +The header applies the Wolfsblvt Works header grammar: **identity → search → navigation → one divider → utilities → one trailing action**. + +```text +[diffdevil Examples] [Search Ctrl K] Docs Playground Examples Install⌄ │ [GitHub] [Community⌄] [Theme] [npm copy | Dashboard] +``` + +- **Geometry:** 62px shell; 190 × 30 search launcher (a button, not a field) that compacts to 156 and then to a 30 × 30 icon; 13px navigation with 18px gaps; one 1 × 18 divider; 28 × 28 direct GitHub source icon; 42 × 28 Community trigger (`social-links` = Lucide `waypoints` + chevron); 64 × 28 theme control (narrower than the shared 72px default, by owner direction: two 24px sides and a small 12px middle that holds only the dot); one 30px trailing action. The current item takes one emphasis step (stronger, semibold text) and every label reserves its semibold width, so the cluster never shifts between routes. The npm command confirms a copy in place at exactly its resting width. +- **Install⌄, Community⌄, Menu⌄** are disclosures of ordinary links (`button` + `aria-expanded`/`aria-controls`), not ARIA menus: click, Enter or Space toggles; Tab walks the rows; Escape closes and refocuses the trigger; an outside click closes without stealing focus; one panel is open at a time; a panel shifts to stay inside the viewport (`lib/client.ts`). Install is a menu of the site's own pages and nothing else: browser extension first, then the GitHub App, then the CLI and Actions, each one whole-row link with a title and a short description. Direct install actions (`Add to Chrome`, `Install on GitHub`) are deliberately not in it; they live on those pages and on the homepage, as plain buttons without a link glyph. +- **Responsive, by content budget:** search compacts first; then the *whole primary navigation* collapses into `Menu⌄` in the navigation's own place, with the same destinations inside. GitHub, Community and Theme stay visible at every width and never move into the menu; the npm convenience command leaves with the navigation (Install, the hero and the manual keep the route). At the narrowest widths the redundant section label yields before any utility does. +- **Destinations** come from one product-owned list, `src/data/destinations.ts`, rendered by the Community panel (Discord, Bluesky, Wolfsblvt Works; GitHub is already the direct icon) and by the footer (icons only, never collapsed, complete: Works, Discord, Bluesky, GitHub). + +### Theme control + +`ThemeControl.astro` is one native button over three preferences and four traversal positions: **Dark → Auto → Light → Auto → Dark → …** The whole control is one target; there are no per-side hit zones and no direct Light → Dark wrap. One capsule previews the real appearance (`#111318` when dark, `#F5F6F8` when light) and, in Automatic, stretches to take the centre dot inside it. ArrowRight/ArrowLeft step forwards/backwards while it is focused; its tooltip and `aria-description` state the current preference and the next click. + +`lib/theme-script.mjs` is the single before-paint rule, shared verbatim by `Base.astro` and the manual's Starlight head: `diffdevil.theme` holds `dark | auto | light`, `diffdevil.theme.next` the manual side the bounce is heading for, and `` is always the resolved appearance. With nothing valid stored the theme is **explicit Dark**. Migration: a legacy `system` value becomes `auto`; an Auto without a stored bounce side gets the opposite of its resolved appearance, once; earlier builds removed the key for Auto, which cannot be told from "never chosen", so an absent key takes the dark-first default. Auto follows the system live; explicit choices ignore it. The preference is origin-local storage; carrying it across product hosts belongs to the documentation-host work, not to this control. + +The page never scrolls sideways. Grid tracks are `minmax(0, …)`, code and prompts wrap, and only a deliberately bounded inner block (the playground's files table on a narrow screen, a long report document, a long presenter pane) may scroll. `qa/run.mjs` measures page-level overflow on every route at 1280, 1024, 768, 640 (the layout of a 1280 window at 200 % zoom) and 390. + +Section anchors: the literal `#id` eyebrow carries the `id`, is the scroll target under the sticky header, and is a link to itself; the magenta `#` after the title is the same link for pointer users, and hovering or focusing either lights both (`SectionHead.astro`). Cross-links into the manual always close a section (`SectionLinks.astro`); the hero is the one exception. + +The manual keeps that shell. `src/components/docs/Header.astro` renders the site header inside Starlight's fixed header; `src/styles/starlight.css` puts Starlight's frame on the page grid: the navigation rail starts under the lockup, everything left of its divider (header included) is the raised ground, the divider runs through the header, and the rail scrolls on its own beneath the fixed header. Article typography in that file is provisional until the docs-page design lands. + +## Search + +`src/components/Search.astro` and `src/lib/search.ts` are the one search on every route. The build's Pagefind index covers the whole output: site pages opt in with `data-pagefind-body` on `
` (from `Base.astro`), manual pages through Starlight. Each hit is marked **Docs** or **Site** from its URL. Legal routes (`/privacy/`, `/impressum/`) pass `noindex` to `Base.astro`: they stay public and fetchable, carry `noindex, nosnippet`, and are left out of the index. The index exists only in built output; `astro dev` shows an honest "not available in this preview" instead. + +## Open design seams, and the slots that wait for them + +Two visual decisions are still with the owner. The site builds their structure and interfaces and does not invent their look: + +- **Icons.** Components never embed a drawing; they ask `Icon.astro` for a meaning (`social-links`, `discord`, `changed`, …). `src/data/icons.ts` resolves it at build time, locally and with no runtime icon request, along the three routes of the Works icon standard: UI glyphs from Lucide (`@iconify-json/lucide`), brand marks from Simple Icons (`@iconify-json/simple-icons`), and the ratified Works-authored diffdevil family (`diffdevil/brand`, `changed`, `raw-churn`, `bands`) vendored from `Wolfsblvt/wolfsblvt-icons@8596b6bc`. `@wolfsblvt/icons` is the intended consumer route and is not published yet; adopting it replaces that one file and `Icon.astro`, no caller. Bluesky and Chrome are rendered from Simple Icons like GitHub and Discord; their curated admission metadata in the shared catalogue is an open join there. The maker's website uses Lucide `globe` until the reserved `wolfsblvt/works` glyph exists. +- **Support / sponsor visual.** (still an open owner design seam) `home/Support.astro` fixes the semantics, layout and responsive behaviour with ordinary tokens and ordinary buttons. The final design plugs into `--support-ground` (the banner ground), `--support-art` on `[data-support-art]` (the right-hand art; empty until set), and the `sponsor` / `star` icon slots. + +`/impressum/` is the site's own legal-notice route and the footer links to it. Its notice text is owed from the Wolfsblvt Works legal-page standard; until this repository can consume that standard the page names the operator and points at the Company legal notice rather than improvising legal copy. + +## The browser extension on the site + +diffdevil for GitHub is one of the three operating routes (run it yourself · bring Changed into GitHub · let it run for you) and the easiest one to adopt, so it is the hero's primary action, route card 02, the `#extension` section directly after `#measure`, the first Install entry and its own page, `/extension/`. The Playground is the trial surface, not a fourth route: it has no homepage card and is reached from the header, the hero and `#measure`. + +`components/extension/Projection.astro` and `Scene.astro` render the extension's projection as semantic HTML and CSS in GitHub's own visual language (no screenshot, iframe, live fetch or script; the site's magenta stays out of it; GitHub's raw diffstat stays present, de-emphasised). Every figure comes from `extensionSpecimen()` in `lib/specimens.ts`: `docs/examples/reports/exact.json` under `full.yml`, the same 178 / +45 −18 ~115 / raw +160 −133 / size/M that `#measure` teaches. A file's virtual band is the engine's own answer for that file under the same policy. The pull request's title and branch are scene dressing. + +`/extension/` is the trust and detail surface. Its statements about what the extension reads, stores and never does follow the extension's own privacy notice, manifest and listing copy (`storage`; content script on github.com; host access to api.github.com; Incognito not allowed; local analysis; raw patches not persisted; native label handoff only); when that source changes, the page follows it. The two settings images under `src/assets/extension/` are the extension's real rendered settings UI from its store kit, cropped. The extension's manual pages are optional entries in `docs-manifest.mjs`; links to them turn live when that source is in the repository. + +## Terminal specimens are executed, not typed + +Commands shown on narrative surfaces (`#query`, the `#agents` comparison) are run at build time by the real CLI through `src/lib/cli-specimen.ts` against `docs/examples/reports/exact.json` under `docs/examples/policies/full.yml`, and the page prints that stdout and exit code. A command the CLI refuses fails the build. The `#policy` YAML and plan panels are projections of the same real policy and of the plan the engine creates for that report; only the third panel (what an explicit `apply` reports) is copy-deck illustration, because a static page cannot apply anything. Presenter output is never re-rendered by the website: when the shared human and agent presenters change, these surfaces change with them. ## Layout | Path | Owns | | --- | --- | | `astro.config.mjs` | Astro + Starlight configuration, engine aliases, the browser shims for the engine's two Node imports | -| `src/pages/` | `/`, `/playground/`, `/examples/`, `/app/`, `/privacy/`, `404`, the per-example JSON documents, and the raw `/skill/*` and `/setup/*.md` routes | -| `src/components/home/` | Homepage sections; numbers computed at build through `src/lib/engine-node.ts` and `src/lib/specimens.ts` | -| `src/islands/playground/` | The React island: URL state, browser engine wrapper, rail, controls, CodeMirror policy editor, result column with four views, export dialog | -| `src/data/` | The keyed copy deck (`copy.ts`) and site facts (`site.ts`) | +| `src/pages/` | `/`, `/playground/`, `/examples/`, `/extension/`, `/app/`, `/privacy/`, `/impressum/`, `404`, the per-example JSON documents, and the raw `/skill/*` and `/setup/*.md` routes | +| `src/components/` | The shared shell: `Header`, `InstallEntries`, `DestinationRows`, `Footer`, `Search`, `ThemeControl`, `ActionLink` (links to configured live destinations), `Icon`, `SectionHead`, `SectionLinks`, `CodeBlock`, `AppPanel` (the one branded App panel used by both the homepage and `/app/`), and `extension/` (the shared projection and pull-request scene) | +| `src/components/home/` | Homepage sections; numbers computed at build through `src/lib/engine-node.ts`, `src/lib/specimens.ts` and `src/lib/cli-specimen.ts` | +| `src/islands/playground/` | The React island: URL state, browser engine wrapper, rail, controls, CodeMirror policy editor, result column with four views, the small JSON/line tokenizer for read-only data (`highlight.tsx`), export dialog | +| `src/data/` | The keyed copy deck (`copy.ts`), site facts and configured destinations (`site.ts`), the social destination list (`destinations.ts`) and the icon registry (`icons.ts`) | | `src/styles/` | Canonical tokens import with the documented light-cascade correction, self-hosted fonts, surface grammar, playground and Starlight mappings | -| `src/lib/` | Build-time engine access, example catalogue reader, agent-source reader, Shiki theme, shared client behaviour | +| `src/lib/` | Build-time engine access and executed CLI specimens, example catalogue reader, agent-source reader, Shiki theme, the pre-paint theme script, search, shared client behaviour | | `src/shims/` | `node:crypto` (SHA-256) and `node:util` stand-ins so the engine runs in the browser unchanged | -| `catalogue/` | Frozen fixture list and captured real-PR snapshots ([contract](catalogue/README.md)) | +| `catalogue/` | Frozen fixture list and captured real-PR snapshots ([contract](catalogue/README.md)). `/examples/` lists the real pull requests only; fixtures stay playground and regression inputs | | `docs-manifest.mjs` | The manual's reader-facing selection; `tools/website-docs.mjs` generates the ignored Starlight collection from it | | `public/` | Static files. The derived favicons, touch icon, web-manifest and GitHub App logos, Open Graph image and manifest are **generated, ignored output** of `tools/website-assets.mjs` from the canonical identity SVGs; the build regenerates them and Astro copies them into the site | | `qa/run.mjs` | Browser qualification | diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index e4fafd10..cb168850 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -5,6 +5,7 @@ import react from '@astrojs/react'; import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { diffdevilSyntax } from './src/lib/shiki-theme.ts'; +import { themeScript } from './src/lib/theme-script.mjs'; const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); const engine = path => fileURLToPath(new URL(`../../dist/lib/${path}`, import.meta.url)); @@ -12,10 +13,6 @@ const sidebarFile = new URL('./src/content/sidebar.json', import.meta.url); if (!existsSync(sidebarFile)) throw new Error('Run `node tools/website-docs.mjs` before building the website; the docs collection is generated from the manifest.'); const sidebar = JSON.parse(readFileSync(sidebarFile, 'utf8')); -// Theme before first paint on every page, including docs: persisted explicit choice -// sets ; absence means "follow the system". -const themeScript = '(function(){try{var t=localStorage.getItem("diffdevil.theme");if(t==="dark"||t==="light"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();'; - export default defineConfig({ site: process.env.DIFFDEVIL_SITE_ORIGIN ?? 'https://diffdevil.invalid', output: 'static', diff --git a/apps/website/catalogue/README.md b/apps/website/catalogue/README.md index fdab2637..025df2da 100644 --- a/apps/website/catalogue/README.md +++ b/apps/website/catalogue/README.md @@ -6,7 +6,7 @@ This directory owns the public examples the website and playground present: the ## Frozen fixtures -[`fixtures.mjs`](fixtures.mjs) lists the deterministic examples. Each names a repository-owned input under `docs/examples/` (a unified diff analyzed at build time, or a saved `diffdevil.report` specimen re-read by the engine) and the policy it is evaluated with. They run without GitHub. Their expected semantic outcomes are asserted by `apps/website/website.test.mjs`. +[`fixtures.mjs`](fixtures.mjs) lists the deterministic examples. Each names a repository-owned input under `docs/examples/` (a unified diff analyzed at build time, or a saved `diffdevil.report` specimen re-read by the engine) and the policy it is evaluated with. They run without GitHub. Their expected semantic outcomes are asserted by `apps/website/website.test.mjs`. Fixtures are controlled inputs: the playground offers them and the homepage links one, but the public `/examples/` page lists real pull requests only. ## Curated real pull requests diff --git a/apps/website/docs-manifest.mjs b/apps/website/docs-manifest.mjs index 01817b10..b1e73792 100644 --- a/apps/website/docs-manifest.mjs +++ b/apps/website/docs-manifest.mjs @@ -65,6 +65,13 @@ export const groups = [ { source: 'docs/PRIVACY-AND-DATA.md', slug: 'github-app/privacy-and-data', title: 'Privacy and data' }, { source: 'apps/github-app/README.md', slug: 'github-app/self-hosting', title: 'Self-hosting the runtime' }, ] }, + // The browser extension's manual pages are authored with the extension application. + // They render as soon as that source is in the repository; until then the group is empty. + { label: 'Browser extension', entries: [ + { source: 'apps/browser-extension/README.md', slug: 'browser-extension', title: 'diffdevil for GitHub', optional: true }, + { source: 'apps/browser-extension/PRIVACY.md', slug: 'browser-extension/privacy', title: 'Extension privacy', optional: true }, + { source: 'docs/integration/browser-extension.md', slug: 'browser-extension/integration', title: 'Browser integration', optional: true }, + ] }, { label: 'Troubleshooting', entries: [ { link: '/docs/get-started/auto-label-pull-requests/#troubleshooting', label: 'Labels did not appear' }, { link: '/docs/policies-and-detail/types-and-measurements/', label: 'A count is unavailable or bounded' }, diff --git a/apps/website/qa/run.mjs b/apps/website/qa/run.mjs index 24bf5a19..b92ec9f3 100644 --- a/apps/website/qa/run.mjs +++ b/apps/website/qa/run.mjs @@ -6,10 +6,12 @@ * * Serves the static output, runs the real playground application server against the * in-memory fake GitHub fixture on the API port the site was built for, and drives - * headless Chromium through the accepted interactions: theme, clipboard, Agents cards, - * homepage PR hand-off, playground views/controls/editor/export/deep links/error states, - * docs shell, narrow layout, and page-level invariants (Impressum link, no external - * requests, no console errors). Results and screenshots go to artifacts/website/qa/. + * headless Chromium through the accepted interactions: the shared shell (one header + * geometry and one page grid on every route, the manual included), the three-state theme + * switch, global search with result kinds, anchors, clipboard, Agents cards, homepage + * composition, PR hand-off, playground views/controls/editor/export/deep links/error + * states, docs shell, narrow layout, and page-level invariants (no page-level horizontal + * overflow at any qualified width, Impressum link, no external requests, no console errors). Results and screenshots go to artifacts/website/qa/. * This proves local behaviour; it does not prove a deployment. */ import { chromium } from '@playwright/test'; @@ -25,7 +27,7 @@ import { replayPublicPullRequest } from '../../playground/app.mjs'; const DIST = 'artifacts/website/dist'; const OUT = 'artifacts/website/qa'; const SITE_PORT = 4399, API_PORT = 4173; -const types = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.md': 'text/markdown', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain' }; +const types = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.webp': 'image/webp', '.ico': 'image/x-icon', '.json': 'application/json', '.md': 'text/markdown', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain' }; await mkdir(OUT, { recursive: true }); const site = createServer(async (req, res) => { @@ -60,26 +62,140 @@ const go = (path, opts = {}) => page.goto(`http://127.0.0.1:${SITE_PORT}${path}` const shot = name => page.screenshot({ path: join(OUT, `${name}.png`), fullPage: true }); // ── every page: Impressum in the footer, one h1, theme script before paint ── -const pages = ['/', '/playground/', '/examples/', '/app/', '/privacy/', '/docs/', '/docs/cli/', '/docs/get-started/auto-label-pull-requests/', '/docs/security/', '/docs/licences/', '/nothing-here/']; +const pages = ['/', '/playground/', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', '/docs/', '/docs/cli/', '/docs/get-started/auto-label-pull-requests/', '/docs/security/', '/docs/licences/', '/nothing-here/']; +const overflowOf = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); +// One header geometry and one page grid: measured on every route and compared with the homepage. +const shellOf = () => page.evaluate(() => { + const box = selector => { const el = document.querySelector(selector); if (!el) return undefined; const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), height: Math.round(r.height) }; }; + const cluster = [...document.querySelectorAll('.header-main > *')].filter(el => getComputedStyle(el).display !== 'none').map(el => el.className.split(' ')[0] || el.tagName.toLowerCase()); + const label = document.querySelector('[data-surface-label]'); + return { brand: box('.brand-link'), cluster: box('.header-main'), header: box('.page > .header') ?? box('.site-header'), order: cluster.join(' '), labelIsLink: !!label?.closest('a'), label: label?.textContent ?? '' }; +}); +let homeShell; for (const path of pages) { const response = await go(path); const impressum = await page.locator('footer a', { hasText: 'Impressum' }).first(); - check(`${path} has an Impressum link`, await impressum.count() === 1 && (await impressum.getAttribute('href')) === 'https://wolfsblvt.com/legal-notice.html'); + check(`${path} has an Impressum link to the site's own route`, await impressum.count() === 1 && (await impressum.getAttribute('href')) === '/impressum/'); + check(`${path} has no Company-privacy footer link`, (await page.locator('footer a', { hasText: 'Company privacy' }).count()) === 0); + check(`${path} has no page-level horizontal overflow at 1280`, (await overflowOf()) <= 0, String(await overflowOf())); + const shell = await shellOf(); + homeShell ??= shell; + check(`${path} header: same lockup coordinate, cluster edge, height and tool order as the homepage`, shell.brand.left === homeShell.brand.left && shell.brand.top === homeShell.brand.top && shell.cluster.right === homeShell.cluster.right && shell.cluster.left === homeShell.cluster.left && shell.header.height === homeShell.header.height && shell.order === homeShell.order, JSON.stringify(shell)); + check(`${path} surface label is plain text, never a link`, !shell.labelIsLink); check(`${path} has exactly one h1`, await page.locator('h1').count() === 1); if (path === '/nothing-here/') check('unknown route serves the 404 page', response.status() === 404 && (await page.locator('h1').textContent()).includes('Nothing at this address')); const fontFamily = await page.evaluate(() => getComputedStyle(document.body).fontFamily); check(`${path} uses IBM Plex Sans`, fontFamily.startsWith('"IBM Plex Sans"') || fontFamily.startsWith('IBM Plex Sans'), fontFamily); } -// ── homepage: hero numbers are the engine's, theme menu keyboard, copy, Agents cards ── +// ── legal routes: public, but out of search engines and the site search ── +for (const path of ['/privacy/', '/impressum/']) { + await go(path); + check(`${path} is noindex, nosnippet and outside the search index`, (await page.locator('meta[name="robots"]').getAttribute('content')) === 'noindex, nosnippet' && (await page.locator('main[data-pagefind-ignore]').count()) === 1); +} + +// ── homepage: composition, engine numbers, anchors, theme switch, search, copy, Agents cards ── await go('/'); await shot('home'); +check('header grammar: search, navigation, one divider, utilities, one trailing action', homeShell.order === 'search-trigger primary-nav header-sep utilities trailing-action', homeShell.order); +const geometry = await page.evaluate(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)}`; }; return { header: b('.site-header'), search: b('.search-trigger'), source: b('[data-direct-source]'), social: b('.utility-social'), theme: b('.theme-control'), action: Math.round(document.querySelector('.trailing-action').getBoundingClientRect().height), sep: b('.header-sep'), navSize: getComputedStyle(document.querySelector('.site-nav > li > a')).fontSize, navGap: getComputedStyle(document.querySelector('.site-nav')).columnGap }; }); +check('header geometry follows the shared standard', JSON.stringify(geometry) === JSON.stringify({ header: '1265x62', search: '190x30', source: '28x28', social: '42x28', theme: '64x28', action: 30, sep: '1x18', navSize: '13px', navGap: '18px' }), JSON.stringify(geometry)); +check('nav has Docs, Playground, Examples and Install; there is no top-level App item', (await page.locator('.site-nav > li').allInnerTexts()).map(t => t.trim()).join('|') === 'Docs|Playground|Examples|Install'); check('hero shows 3 changed = exact from the engine', /3\s*changed/u.test(await page.locator('.specimen-cell').first().innerText())); -check('measure section shows 178 changed', (await page.locator('#measure').innerText()).includes('178')); -await page.locator('[data-theme-button]').click(); -check('theme menu opens with focus on the checked item', await page.evaluate(() => document.activeElement?.getAttribute('role') === 'menuitemradio')); -await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); +const hero = await page.evaluate(() => { + const lines = el => Math.round(el.getBoundingClientRect().height / parseFloat(getComputedStyle(el).lineHeight)); + const r = selector => document.querySelector(selector).getBoundingClientRect(); + const chip = r('.specimen-cell .chip-ev'), cell = r('.specimen-cell'); + return { h1: lines(document.querySelector('.hero h1')), lead: lines(document.querySelector('.hero .why')), specimenRight: Math.round(r('.specimen').right), measureRight: Math.round(r('.measure-panel').right), specimenWidth: r('.specimen').width, routeWidth: r('.route-2').width, measureWidth: r('.measure-panel').width, + chipGap: Math.round(cell.right - chip.right), rawSize: parseFloat(getComputedStyle(document.querySelector('.specimen-value.raw')).fontSize), bigSize: parseFloat(getComputedStyle(document.querySelector('.specimen-value .metric-m')).fontSize) }; +}); +check('hero headline is one line and the lead is at most three', hero.h1 === 1 && hero.lead <= 3, JSON.stringify(hero)); +check('hero specimen shares the right edge of the blocks below and is the wider column', hero.specimenRight === hero.measureRight && hero.specimenWidth > hero.routeWidth, JSON.stringify(hero)); +check('hero evidence chip is right-aligned and raw counters are subordinate', hero.chipGap <= 22 && hero.rawSize < hero.bigSize, JSON.stringify(hero)); +const bottoms = await page.evaluate(() => ['.route-1 .route-actions', '.route-3 .route-actions'].map(s => Math.round(document.querySelector(s).getBoundingClientRect().bottom))); +check('entrance 01 and 03 action rows share one bottom edge', bottoms[0] === bottoms[1], bottoms.join(' ')); +check('measure section shows 178 changed', (await page.locator('[data-section="measure"]').innerText()).includes('178')); +const measure = await page.evaluate(() => ({ columns: getComputedStyle(document.querySelector('.measure-panel')).gridTemplateColumns.split(' ').length, headLines: Math.round(document.querySelector('.measure-head').getBoundingClientRect().height), word: getComputedStyle(document.querySelector('.facts-strong li span')).color, number: getComputedStyle(document.querySelector('.facts-strong .v')).color })); +check('measure specimen is three columns with a one-line header; words grey, decomposition numbers white', measure.columns === 3 && measure.headLines < 44 && measure.word === 'rgb(180, 183, 190)' && measure.number === 'rgb(240, 242, 245)', JSON.stringify(measure)); +const terminals = await page.evaluate(() => [...document.querySelectorAll('.query-terminal')].map(el => ({ overflow: el.scrollWidth - el.clientWidth, left: Math.round(el.getBoundingClientRect().left), blank: /\n\s*\n/u.test(el.textContent.trimEnd()) }))); +check('query terminal: two columns on the grid, no inner scrollbar, no blank lines between commands', terminals.length === 2 && terminals.every(t => t.overflow <= 0 && !t.blank) && terminals[0].left === homeShell.brand.left + 1, JSON.stringify(terminals)); +check('query terminal keeps the false-not-error comment on the result line', /^1\s+# false, not an error$/mu.test(await page.locator('.query-terminal').first().innerText())); +const policy = await page.evaluate(() => [...document.querySelectorAll('.policy-col')].map(el => ({ width: Math.round(el.getBoundingClientRect().width), overflow: el.scrollWidth - el.clientWidth, text: el.innerText }))); +check('policy shows YAML, then the plan as JSON, then apply as text, in three equal non-overflowing panels', policy.length === 3 && new Set(policy.map(c => c.width)).size === 1 && policy.every(c => c.overflow <= 0) && policy[0].text.includes('byBand:') && policy[1].text.includes('"kind": "diffdevil.plan"') && policy[2].text.includes('Readback confirms'), JSON.stringify(policy.map(c => c.width))); +const sectionLinks = await page.evaluate(() => [...document.querySelectorAll('main section[data-section]')].filter(s => s.querySelector('.section-links')).map(s => { const links = s.querySelector(':scope > .wrap > .section-links'); return { id: s.dataset.section, last: links === s.querySelector(':scope > .wrap').lastElementChild }; })); +check('every section closes with its cross-links row', sectionLinks.length >= 5 && sectionLinks.every(item => item.last), JSON.stringify(sectionLinks)); +const evidence = await page.evaluate(() => [...document.querySelectorAll('[data-section="evidence"] .cell')].map(cell => ({ glyph: parseFloat(getComputedStyle(cell.querySelector('.cell-glyph')).fontSize), chip: !!cell.querySelector('.chip-ev'), source: /\.json/u.test(cell.innerText) }))); +check('evidence cells lead with the large glyph, keep chip and value, and carry no source footnotes', evidence.length === 4 && evidence.every(cell => cell.glyph === 64 && cell.chip && !cell.source), JSON.stringify(evidence)); +const app = await page.evaluate(() => { const panel = document.querySelector('.app-panel'); const icon = panel.querySelector('.app-eyebrow svg'); return { benefits: getComputedStyle(panel.querySelector('.benefits')).gridTemplateColumns.split(' ').length, count: panel.querySelectorAll('.benefits li').length, bg: getComputedStyle(panel).backgroundColor, iconInLink: !!icon.closest('a'), iconFirst: panel.querySelector('.app-eyebrow').firstElementChild === icon }; }); +check('app panel: GitHub mark before the anchor and outside it, six benefits in two columns, midnight ground', app.benefits === 2 && app.count === 6 && app.bg === 'rgb(12, 15, 23)' && !app.iconInLink && app.iconFirst, JSON.stringify(app)); +const support = page.locator('[data-section="support"]'); +check('support section: anchor, two actions with icon slots, sponsor honestly unavailable, empty art slot', (await support.locator('#support').count()) === 1 && (await support.locator('[data-support-action] svg[data-icon]').count()) === 2 && (await support.locator('[data-support-action="sponsor"]').getAttribute('aria-disabled')) === 'true' && (await support.locator('[data-support-art]').count()) === 1); +check('footer keeps CLI, Actions and Library API on one row', await page.evaluate(() => { const row = [...document.querySelectorAll('.site-footer .footer-row')].find(li => li.textContent.includes('Library API')); return row.querySelectorAll('a').length === 3 && row.getBoundingClientRect().height < 30; })); +const social = await page.evaluate(() => ({ direct: document.querySelector('[data-direct-source]').getAttribute('href'), directName: document.querySelector('[data-direct-source]').getAttribute('aria-label'), footer: [...document.querySelectorAll('.footer-socials [data-social]')].map(el => el.dataset.social).join(' '), seps: document.querySelectorAll('.header-sep').length, githubInPanel: !!document.querySelector('#panel-community [data-dest="community:github"]') })); +check('one direct GitHub source icon, one divider, and a complete icons-only footer in the order Works, Discord, Bluesky, GitHub', social.direct === 'https://github.com/Wolfsblvt/diffdevil' && social.directName === 'Source on GitHub' && social.footer === 'works discord bluesky github' && social.seps === 1 && !social.githubInPanel, JSON.stringify(social)); +check('icons are real local geometry: Simple Icons brands and Lucide UI, no runtime icon request', await page.evaluate(() => ['github', 'social-links', 'chevron', 'search', 'theme-dark', 'theme-light'].every(name => document.querySelector(`.site-header svg[data-icon="${name}"]`)?.innerHTML.includes('path')))); + +// header destination panels: disclosures of ordinary links, one open at a time +const install = page.locator('[aria-controls="panel-install"]'), communityTrigger = page.locator('[aria-controls="panel-community"]'); +await install.focus(); await page.keyboard.press('Enter'); +check('Install opens by keyboard and exposes its expanded state', (await install.getAttribute('aria-expanded')) === 'true' && await page.locator('#panel-install').isVisible()); +const installLinks = await page.locator('#panel-install a').evaluateAll(links => links.map(a => `${a.dataset.dest}=${a.getAttribute('href') ?? 'unconfigured'}`)); +check('Install is a menu of site pages only: browser extension, GitHub App, CLI and Actions; no install or store action lives in it', installLinks.join(' ') === 'install:extension=/extension/ install:app=/app/ install:open=/docs/get-started/auto-label-pull-requests/' && !/Add to Chrome|Install on GitHub/u.test(await page.locator('#panel-install').innerText()), installLinks.join(' ')); +const rowsShape = await page.evaluate(() => [...document.querySelectorAll('#panel-install .dest-row')].map(row => { const li = row.parentElement.getBoundingClientRect(), a = row.getBoundingClientRect(); return { described: !!row.querySelector('.dest-label') && !!row.querySelector('.dest-note')?.textContent.trim(), whole: Math.round(a.width) === Math.round(li.width) && Math.round(a.height) === Math.round(li.height), gap: getComputedStyle(row).columnGap }; })); +check('every Install entry has a title and a description, and the whole row is the link', rowsShape.length === 3 && rowsShape.every(r => r.described && r.whole && r.gap === '12px'), JSON.stringify(rowsShape)); +await page.locator('#panel-install .dest-row').first().hover(); await page.waitForTimeout(80); +check('hovering an entry highlights the whole row, exactly as in the Community panel', (await page.evaluate(() => getComputedStyle(document.querySelector('#panel-install .dest-row')).backgroundColor)) === (await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg-raised-2').trim().replace(/^#(..)(..)(..)$/u, (_, r, g, b) => `rgb(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)})`)))); +await page.keyboard.press('Tab'); +check('Tab moves into the panel rows', await page.evaluate(() => document.activeElement?.closest('#panel-install') !== null)); +await page.keyboard.press('Escape'); +check('Escape closes the panel and returns focus to its trigger', (await install.getAttribute('aria-expanded')) === 'false' && await page.evaluate(() => document.activeElement?.getAttribute('aria-controls') === 'panel-install')); +await install.click(); await communityTrigger.click(); +check('only one destination panel is open at a time', (await install.getAttribute('aria-expanded')) === 'false' && await page.locator('#panel-community').isVisible() && await page.locator('#panel-install').isHidden()); +const community = await page.locator('#panel-community [data-dest]').evaluateAll(rows => rows.map(a => `${a.dataset.dest.split(':')[1]}:${a.getAttribute('href') ? 'link' : a.getAttribute('aria-disabled')}`)); +check('Community lists Discord, Bluesky and Wolfsblvt Works; destinations without a real URL are disabled links, never invented ones', community.join(' ') === 'discord:true bluesky:true works:link' && !(await page.locator('#panel-community').innerText()).toLowerCase().includes('soon'), community.join(' ')); +check('the Community trigger is named for assistive technology', (await communityTrigger.getAttribute('aria-label')) === 'Community and social links'); +await page.locator('h1').click(); +check('an outside click closes the panel', await page.locator('#panel-community').isHidden()); +check('hidden panel rows are not focusable', await page.evaluate(() => [...document.querySelectorAll('.dest-panel[hidden] a')].every(a => a.offsetParent === null))); + +// anchors: the eyebrow is the target and a link; hovering either lights both +await page.locator('#measure').click(); +await page.waitForTimeout(900); // smooth scroll settles before anything is measured or hovered +check('the #measure eyebrow is the scroll target and sets the hash', page.url().endsWith('#measure') && await page.evaluate(() => { const top = document.getElementById('measure').getBoundingClientRect().top; return top >= 60 && top < 140; })); +await page.locator('#measure').hover(); +await page.waitForTimeout(350); // the colour and opacity transitions finish +const lit = await page.evaluate(() => { const group = document.getElementById('measure').closest('.anchor-group'); return { glyph: getComputedStyle(group.querySelector('.permalink')).opacity, eyebrow: getComputedStyle(group.querySelector('.eyebrow-link')).color }; }); +check('hovering the eyebrow lights the eyebrow and the title glyph together', lit.glyph === '1' && lit.eyebrow === 'rgb(240, 97, 186)', JSON.stringify(lit)); +await page.locator('[data-section="measure"] .permalink').hover(); +await page.waitForTimeout(350); +check('hovering the title glyph lights the eyebrow too', (await page.evaluate(() => getComputedStyle(document.getElementById('measure')).color)) === 'rgb(240, 97, 186)'); + +// the bouncing theme control: Dark → Auto → Light → Auto → Dark, one whole-control button +// the capsule animates for 160 ms and a system change arrives asynchronously: measure the settled state +const themeState = async () => { await page.waitForTimeout(260); return page.evaluate(() => { const d = document.documentElement, b = document.querySelector('[data-theme-control]'), r = s => document.querySelector(s).getBoundingClientRect(), cap = r('.theme-capsule'), dot = r('.theme-dot'); return { pref: d.dataset.themePref, shown: d.dataset.theme, stored: localStorage.getItem('diffdevil.theme'), next: localStorage.getItem('diffdevil.theme.next'), title: b.title, dotInside: dot.left >= cap.left && dot.right <= cap.right, capsule: getComputedStyle(document.querySelector('.theme-capsule')).backgroundColor }; }); }; +const first = await themeState(); +check('with nothing stored the theme is explicit Dark, and the capsule is dark and excludes the dot', first.pref === 'dark' && first.shown === 'dark' && first.stored === null && !first.dotInside && first.capsule === 'rgb(17, 19, 24)' && first.title === 'Dark. Next click: Automatic.', JSON.stringify(first)); +check('the control is one native button, not a radiogroup, switch or menu', await page.evaluate(() => { const b = document.querySelector('[data-theme-control]'); return b.tagName === 'BUTTON' && !b.getAttribute('role') && !b.hasAttribute('aria-pressed') && !b.hasAttribute('aria-haspopup') && b.querySelectorAll('button, [tabindex]').length === 0 && document.querySelectorAll('[role="radiogroup"][aria-label="Theme"], [data-theme-option]').length === 0; })); +const sequence = []; +for (let i = 0; i < 8; i++) { await page.locator('[data-theme-control]').click(); const t = await themeState(); sequence.push(`${t.pref}>${t.next}`); } +check('eight clicks bounce Dark → Auto → Light → Auto → Dark twice, never wrapping Light → Dark', sequence.join(' ') === 'auto>light light>dark auto>dark dark>light auto>light light>dark auto>dark dark>light', sequence.join(' ')); +await page.locator('[data-theme-control]').click(); +const autoDark = await themeState(); +check('Automatic on a dark system: dark capsule stretched over the dot, and the state says so', autoDark.pref === 'auto' && autoDark.shown === 'dark' && autoDark.dotInside && autoDark.title === 'Automatic, using dark. Next click: Light.', JSON.stringify(autoDark)); +await page.emulateMedia({ colorScheme: 'light' }); +const autoLight = await themeState(); +check('a system change while Automatic changes the appearance and capsule, not the next manual side', autoLight.pref === 'auto' && autoLight.shown === 'light' && autoLight.dotInside && autoLight.capsule === 'rgb(245, 246, 248)' && autoLight.next === 'light', JSON.stringify(autoLight)); +await page.reload({ waitUntil: 'networkidle' }); +check('Automatic and its bounce side survive a reload and are applied before first paint', JSON.stringify(await themeState()) === JSON.stringify(autoLight), JSON.stringify(await themeState())); +await page.emulateMedia({ colorScheme: 'dark' }); +await page.locator('[data-theme-control]').focus(); +await page.keyboard.press('Enter'); +check('Enter advances exactly one step and keeps focus on the control', (await themeState()).pref === 'light' && await page.evaluate(() => document.activeElement?.matches('[data-theme-control]'))); +await page.keyboard.press('ArrowLeft'); +check('ArrowLeft steps backwards through the same traversal', (await themeState()).pref === 'auto' && (await themeState()).next === 'light'); +await page.keyboard.press('ArrowRight'); check('choosing Light sets data-theme and persists', (await page.evaluate(() => document.documentElement.getAttribute('data-theme'))) === 'light' && (await page.evaluate(() => localStorage.getItem('diffdevil.theme'))) === 'light'); +check('explicit Light ignores the dark system and shows the bright capsule without the dot', await (async () => { const t = await themeState(); return t.shown === 'light' && !t.dotInside && t.capsule === 'rgb(245, 246, 248)'; })()); const accentText = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim()); check('explicit light theme keeps --accent-text at #aa167d (cascade correction)', accentText === '#aa167d', accentText); const machineBg = await page.evaluate(() => getComputedStyle(document.querySelector('.machine')).backgroundColor); @@ -87,38 +203,78 @@ check('machine surfaces stay midnight in light theme', machineBg === 'rgb(12, 15 await shot('home-light'); await page.reload({ waitUntil: 'networkidle' }); check('theme survives reload before first paint', (await page.evaluate(() => document.documentElement.getAttribute('data-theme'))) === 'light'); -await page.evaluate(() => localStorage.removeItem('diffdevil.theme')); -await page.reload({ waitUntil: 'networkidle' }); -await page.locator('.install-chip').click(); -check('header install chip copies the npm command', (await page.evaluate(() => navigator.clipboard.readText())) === 'npm i -D @wolfsblvt/diffdevil'); +await go('/docs/cli/'); +check('the manual resolves the same theme by the same rule', (await page.evaluate(() => `${document.documentElement.dataset.themePref}/${document.documentElement.dataset.theme}`)) === 'light/light'); +await page.evaluate(() => { localStorage.setItem('diffdevil.theme', 'system'); localStorage.removeItem('diffdevil.theme.next'); }); +await go('/'); +check('a legacy Automatic value migrates to Auto, with the opposite of its appearance as the next manual side', await (async () => { const t = await themeState(); return t.pref === 'auto' && t.shown === 'dark' && t.stored === 'auto' && t.next === 'light'; })()); +await page.evaluate(() => { localStorage.setItem('diffdevil.theme', 'sepia'); }); +await go('/'); +check('an invalid stored value falls back to explicit Dark', (await themeState()).pref === 'dark'); +await page.evaluate(() => { localStorage.removeItem('diffdevil.theme'); localStorage.removeItem('diffdevil.theme.next'); }); +await go('/'); + +// global search: one modal everywhere, results say whether they are manual or site pages +await page.keyboard.press('Control+k'); +await page.waitForSelector('dialog.search-dialog[open]'); +check('Ctrl+K opens the search modal with focus in the field', await page.evaluate(() => document.activeElement?.matches('[data-search-input]'))); +await page.locator('[data-search-input]').fill('playground'); +await page.waitForSelector('.search-hit'); +const kinds = await page.locator('.search-hit').evaluateAll(hits => hits.map(hit => `${hit.dataset.kind}:${hit.querySelector('.search-kind').textContent}`)); +check('search finds manual pages and site pages and marks each kind', kinds.some(k => k === 'docs:Docs') && kinds.some(k => k === 'site:Site'), kinds.join(' ')); +check('legal routes are not search results', !(await page.locator('.search-hit').evaluateAll(hits => hits.map(hit => hit.getAttribute('href')))).some(href => /^\/(privacy|impressum)\//u.test(href))); +await page.keyboard.press('ArrowDown'); +check('ArrowDown moves from the field to the first result', await page.evaluate(() => document.activeElement?.classList.contains('search-hit'))); +await page.keyboard.press('Escape'); +check('Escape closes the search modal', (await page.locator('dialog.search-dialog[open]').count()) === 0); + +const widthOf = selector => page.evaluate(sel => { const r = document.querySelector(sel).getBoundingClientRect(); return `${r.left.toFixed(1)}/${r.width.toFixed(1)}`; }, selector); +const before = [await widthOf('[data-trailing="npm"]'), await widthOf('.search-trigger'), await widthOf('.theme-control')]; +await page.locator('[data-trailing="npm"]').click(); +await page.waitForTimeout(100); +check('header npm command copies the install command', (await page.evaluate(() => navigator.clipboard.readText())) === 'npm i -D @wolfsblvt/diffdevil'); +check('the copy confirmation appears in place and nothing in the header moves', (await page.evaluate(() => getComputedStyle(document.querySelector('.copy-swap-done')).visibility)) === 'visible' && JSON.stringify([await widthOf('[data-trailing="npm"]'), await widthOf('.search-trigger'), await widthOf('.theme-control')]) === JSON.stringify(before), before.join(' ')); check('live region announced the copy', (await page.locator('[data-live-region]').textContent()).includes('Copied')); const skill = page.locator('#agents-skill'); -check('skill card starts closed', await skill.locator('.details-region').isHidden()); +check('skill card starts closed', await skill.locator('.agent-details').isHidden()); await skill.locator('.btn-primary').click(); check('Add the skill copies the instruction with the configured origin', /Install the diffdevil Agent Skill from https?:\/\/[^ ]+\/skill\/SKILL\.md/u.test(await page.evaluate(() => navigator.clipboard.readText()))); -check('Add the skill opened the skill card (proactive expansion)', await skill.locator('.details-region').isVisible() && (await skill.locator('[data-disclosure]').getAttribute('aria-expanded')) === 'true'); +check('Add the skill opened the skill card (proactive expansion)', await skill.locator('.agent-details').isVisible() && (await skill.locator('[data-disclosure]').getAttribute('aria-expanded')) === 'true'); const setup = page.locator('#agents-setup'); await setup.locator('[data-intent="cli"]').click(); -check('selecting a setup chip opens the setup card and swaps the prompt', await setup.locator('.details-region').isVisible() && (await setup.locator('[data-intent-prompt]').textContent()).includes('/setup/cli.md')); +check('setup selector keeps its lead and first three chips on one row', await page.evaluate(() => { const tops = [...document.querySelectorAll('.selector-row > *')].map(el => Math.round(el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2)); return tops.length === 4 && Math.max(...tops) - Math.min(...tops) <= 2; })); +check('agent card titles sit left with their sub-label right on the same line', await page.evaluate(() => [...document.querySelectorAll('.agent-head')].every(head => { const t = head.querySelector('.agent-title').getBoundingClientRect(), s = head.querySelector('.agent-sub').getBoundingClientRect(); return s.left > t.right && Math.abs(s.bottom - t.bottom) < 8 && Math.round(head.getBoundingClientRect().right - s.right) <= 1; }))); +check('source and path strips are midnight machine strips', (await page.evaluate(() => [...document.querySelectorAll('[data-section="agents"] .strip')].map(el => getComputedStyle(el).backgroundColor).join('|'))) === 'rgb(12, 15, 23)|rgb(12, 15, 23)'); +check('selecting a setup chip opens the setup card and swaps the prompt', await setup.locator('.agent-details').isVisible() && (await setup.locator('[data-intent-prompt]').textContent()).includes('/setup/cli.md')); await setup.locator('[data-intent="cli"]').press('ArrowRight'); check('arrow keys move the intent selection', (await setup.locator('[data-intent="actions"]').getAttribute('aria-checked')) === 'true'); -check('both cards may be open at once', await skill.locator('.details-region').isVisible() && await setup.locator('.details-region').isVisible()); +check('open cards never widen the page and the prompt wraps', (await overflowOf()) <= 0 && await page.evaluate(() => { const pre = document.querySelector('#agents-setup-details pre'); return pre.scrollWidth <= pre.clientWidth; })); +check('the copied setup prompt is one sentence', await page.evaluate(() => { const text = document.querySelector('[data-intent-prompt]').textContent.trim(); return text.endsWith('exactly.') && (text.match(/\. /gu) ?? []).length === 0; })); +check('both cards may be open at once', await skill.locator('.agent-details').isVisible() && await setup.locator('.agent-details').isVisible()); await skill.locator('[data-disclosure]').click(); -check('explicit disclosure closes the card', await skill.locator('.details-region').isHidden()); +check('explicit disclosure closes the card', await skill.locator('.agent-details').isHidden()); await go('/#agents-setup'); -check('deep link #agents-setup opens the setup card', await page.locator('#agents-setup .details-region').isVisible()); +check('deep link #agents-setup opens the setup card', await page.locator('#agents-setup .agent-details').isVisible()); await go('/'); -await page.locator('#home-pr-url').fill('https://example.com/not/a/pr'); -await page.locator('[data-pr-entry] button').click(); -check('invalid PR URL is refused inline with role=alert, no navigation', page.url().endsWith('/') && await page.locator('[data-pr-error]').isVisible()); -await page.locator('#home-pr-url').fill('https://github.com/example/repository/pull/42?diff=split'); -await Promise.all([page.waitForURL(/\/playground\/\?pr=example\/repository\/42/u), page.locator('[data-pr-entry] button').click()]); -check('valid PR URL hands off to the one playground journey', page.url().includes('/playground/?pr=example/repository/42')); +// route cards, hero and the homepage extension section +const routes = await page.evaluate(() => ({ titles: [...document.querySelectorAll('.entrances .route-title')].map(h => h.textContent.trim().replace(/^\d+/u, '')), prInput: document.querySelectorAll('#home-pr-url, [data-pr-entry]').length, glyph: document.querySelector('[data-route-projection] svg')?.dataset.icon, projection: document.querySelector('[data-route-projection]').innerText.replace(/\s+/gu, ' '), heights: ['.route-2', '.route-3'].map(s => Math.round(document.querySelector(s).getBoundingClientRect().height)) })); +check('route cards: 01 run it yourself, 02 browser extension, 03 GitHub App; the Playground card and its PR input are gone', routes.titles.join('|') === 'Run diffdevil yourself|Bring Changed into GitHub|Let it run for you' && routes.prInput === 0, JSON.stringify(routes)); +check('the extension card shows the real compact projection with the diffdevil/brand glyph and carries more height than the App card', routes.glyph === 'brand' && /Changed 178 \(\+45 −18 ~115\) size\/M/u.test(routes.projection) && routes.projection.includes('Raw +160 −133') && routes.heights[0] > routes.heights[1], JSON.stringify(routes)); +const heroCtas = await page.locator('.hero-ctas a').evaluateAll(links => links.map(a => `${a.textContent.trim()}=${a.getAttribute('href') ?? 'unconfigured'}`)); +check('hero: Add to Chrome first and direct, then CLI / Actions, then the Playground; card 02 says Install the extension; no install button carries a link glyph', (await page.locator('.btn svg[data-icon="external"]').count()) === 0 && (await page.locator('.route-2 .btn').innerText()).trim() === 'Install the extension' && heroCtas.join('|') === 'Add to Chrome=unconfigured|Use the CLI / Actions=/docs/get-started/auto-label-pull-requests/|Try a public PR →=/playground/' && (await page.locator('.hero .eyebrow').innerText()).toUpperCase() === 'COMPOSABLE DIFF ANALYSIS AND AUTOMATION FOR GITHUB AND THE CLI', heroCtas.join('|')); +check('no Add to Chrome or install action is ever routed through a product page', await page.evaluate(() => [...document.querySelectorAll('a')].filter(a => /^(Add to Chrome|Install the App|Install on GitHub|Chrome Web Store)/u.test(a.textContent.trim())).every(a => !['/extension/', '/app/'].includes(a.getAttribute('href') ?? '')))); +const order = await page.evaluate(() => [...document.querySelectorAll('main section[data-section]')].map(s => s.dataset.section).join(' ')); +check('homepage order puts #extension directly after #measure', order === 'measure extension query policy agents evidence app support', order); +const scene = await page.evaluate(() => { const el = document.querySelector('[data-section="extension"] [data-extension-scene]'); const native = el.querySelector('.gh-native'); return { text: el.innerText.replace(/\s+/gu, ' '), glyphBeforeChanged: el.querySelector('.ddx-main').firstElementChild.dataset.icon === 'brand' && el.querySelector('.ddx-main').children[1].textContent === 'Changed', railCells: el.querySelectorAll('[data-scene-anchor] .ddx-cell').length, selected: [...el.querySelectorAll('[data-scene-anchor] .ddx-cell')].findIndex(c => c.classList.contains('is-selected')), nativeOpacity: getComputedStyle(native).opacity, popover: !!el.querySelector('.gh-popover'), files: el.querySelectorAll('.gh-file').length, images: el.querySelectorAll('img, iframe, canvas').length, magenta: [...el.querySelectorAll('*')].some(n => /240, 97, 186|229, 78, 175/u.test(getComputedStyle(n).color + getComputedStyle(n).backgroundColor + getComputedStyle(n).borderColor)) }; }); +check('the extension scene is one semantic GitHub composition: glyph then Changed 178 (+45 −18 ~115), size/M, a five-cell rail with the third cell selected, dimmed native +160 −133, per-file Changed, and the open exact report', scene.glyphBeforeChanged && /Changed 178 \(\+45 −18 ~115\) size\/M/u.test(scene.text) && scene.railCells === 5 && scene.selected === 2 && Number(scene.nativeOpacity) < 0.6 && scene.text.includes('+160 −133') && scene.files === 3 && /src\/Foo\.cs .*Changed 173/u.test(scene.text) && scene.popover && scene.text.includes('= exact') && scene.images === 0 && !scene.magenta, JSON.stringify({ ...scene, text: undefined })); +await page.goto(`http://127.0.0.1:${SITE_PORT}/playground/?pr=example/repository/42`, { waitUntil: 'networkidle' }); // ── playground: PR mode through the real API server ── await page.waitForSelector('.pg-primary', { timeout: 20000 }); check('PR mode analyzed through the API and shows the compact PR display', (await page.locator('.pg-analyzed').innerText()).includes('example/repository')); check('PR mode primary result is exact 3 changed', /3\s*changed/u.test(await page.locator('.pg-primary').innerText())); +check('playground has no title block: the workbench starts flush under the header, on the page grid, with hard corners', await page.evaluate(() => { const pg = document.querySelector('.pg').getBoundingClientRect(), header = document.querySelector('.site-header').getBoundingClientRect(), brand = document.querySelector('.brand-link').getBoundingClientRect(); const radius = ['.pg', '.pg-rail', '.pg-strip', '.pg-primary'].map(s => getComputedStyle(document.querySelector(s)).borderTopLeftRadius); return Math.round(pg.top) === Math.round(header.bottom) && Math.round(pg.left) === Math.round(brand.left) && document.querySelectorAll('main h1:not(.visually-hidden), main .pg-head').length === 0 && radius.every(r => r === '0px'); })); +check('playground panes are contiguous: no gap between strip, primary result and the views', await page.evaluate(() => { const b = s => document.querySelector(s).getBoundingClientRect(); return Math.round(b('.pg-strip').bottom) === Math.round(b('.pg-primary').top) && Math.round(b('.pg-primary').bottom) === Math.round(b('.pg-views-head').top) && Math.round(b('.pg-rail').right) === Math.round(b('.pg-main').left); })); await shot('playground-pr'); // ── playground: cancel, then a late successful response must not commit ── @@ -140,32 +296,53 @@ check('re-submitting after cancel analyzes normally', /3\s*changed/u.test(await // ── playground: default fixture, tiles, controls, editor, export ── await go('/playground/'); await page.waitForSelector('.pg-primary'); -check('playground opens on the first fixture with a result (never blank)', (await page.locator('.pg-strip-title').innerText()).includes('Replacement counted once') && /10\s*changed/u.test(await page.locator('.pg-primary').innerText())); +check('playground opens on the first curated pull request with a result (never blank)', (await page.locator('.pg-strip').innerText()).includes('curated snapshot') && /changed/u.test(await page.locator('.pg-primary').innerText())); check('url stays clean for the default state', page.url().endsWith('/playground/')); -const tiles = page.locator('.tile'); +await page.locator('#tab-examples').click(); +check('the rail lists the curated pull requests only: no Frozen fixtures section and no example-kind switch', (await page.locator('.pg-examples .pg-card').count()) > 0 && (await page.locator('.pg-kinds, .pg-card-glyph').count()) === 0 && !(await page.locator('.pg-rail-desktop').innerText()).includes('Frozen')); +// the controlled fixture stays addressable for the homepage link and this qualification +await go('/playground/?example=replacement-once'); +await page.waitForSelector('.pg-primary'); +check('a controlled fixture still opens by its id', (await page.locator('.pg-strip-title').innerText()).includes('Replacement counted once') && /10\s*changed/u.test(await page.locator('.pg-primary').innerText())); +const tiles = page.locator('.pg-tile'); await tiles.nth(0).focus(); await page.keyboard.press('ArrowRight'); check('tile arrow keys select the next view', (await tiles.nth(1).getAttribute('aria-selected')) === 'true' && page.url().includes('view=agent')); check('agent view shows the agent presenter', (await page.locator('#pg-view-panel').innerText()).includes('DIFFDEVIL REPORT')); +check('agent data and report.json are syntax-coloured', (await page.locator('.pg-data').first().locator('.tok-key').count()) > 3 && (await page.locator('.pg-data[data-lang="json"] .tok-key').count()) > 10 && (await page.locator('.pg-data[data-lang="json"] .tok-str').count()) > 5); +await page.locator('.pg-agent [role="tab"]', { hasText: 'plan.json' }).click(); +check('plan.json gets the same JSON colouring', (await page.locator('.pg-data[data-lang="json"]').innerText()).includes('"diffdevil.plan"') && (await page.locator('.pg-data[data-lang="json"] .tok-key').count()) > 5); +check('result tiles put the title left and the glyph right on one row', await page.evaluate(() => [...document.querySelectorAll('.pg-tile-head')].every(head => { const t = head.querySelector('.t').getBoundingClientRect(), g = head.querySelector('.g').getBoundingClientRect(); return g.left > t.right && Math.abs((g.top + g.bottom) / 2 - (t.top + t.bottom) / 2) < 4; }))); +await tiles.nth(1).focus(); await page.keyboard.press('End'); check('End selects the explanation view', (await tiles.nth(3).getAttribute('aria-selected')) === 'true'); check('explanation lists rules and the readback lane as not observed', /Not observed/u.test(await page.locator('#pg-view-panel').innerText())); await tiles.nth(2).click(); check('github preview says proposed, not applied, and shows the desired label', (await page.locator('#pg-view-panel').innerText()).includes('Proposed, not applied') && (await page.locator('#pg-view-panel').innerText()).includes('size/XS')); +check('github preview renders the owned comment as a comment: avatar, author, bot standing, marker', (await page.locator('.pg-comment .pg-avatar').count()) === 1 && /diffdevil\s*bot · would comment/u.test(await page.locator('.pg-comment-head').innerText()) && (await page.locator('.pg-comment-marker').innerText()).includes('diffdevil:rule=')); +check('github preview is two columns: labels and check summary left, comment right', await page.evaluate(() => { const cols = [...document.querySelector('.pg-github').children].map(el => el.getBoundingClientRect()); return cols.length === 2 && cols[1].left > cols[0].right; })); await shot('playground-github'); +await tiles.nth(0).click(); +const term = await page.evaluate(() => { const blocks = document.querySelectorAll('#pg-view-panel pre.terminal'); const pre = blocks[0]; return { blocks: blocks.length, text: pre.innerText, overflow: pre.scrollWidth - pre.clientWidth, cmd: getComputedStyle(pre.querySelector('.c')).color, prompt: getComputedStyle(pre.querySelector('.p')).color }; }); +check('terminal view is one session: $ analyze, its output, a blank line, $ plan, its output; wrapped, never scrolled', term.blocks === 1 && /^\$ diffdevil analyze[^\n]*\ndiffdevil analysis/u.test(term.text) && /\n\n\$ diffdevil plan[^\n]*\ndiffdevil effect plan/u.test(term.text) && term.overflow <= 0 && term.cmd === 'rgb(240, 242, 245)' && term.prompt === 'rgb(130, 134, 144)', JSON.stringify({ ...term, text: undefined })); +await tiles.nth(2).click(); // controls: threshold edit re-evaluates locally const xs = page.locator('.pg-bands li').first().locator('input[type="number"]'); await xs.fill('5'); await page.waitForTimeout(150); check('lowering the xs threshold moves the result to band s', /band s/u.test(await page.locator('.pg-result-line').innerText()) && page.url().includes('policy=')); check('the proposed label follows the edited band', (await page.locator('#pg-view-panel').innerText()).includes('size/S')); -await page.locator('.pg-patterns input').fill('**/package-lock.json'); -await page.locator('.pg-patterns input').press('Enter'); +check('a focused control has one magenta outline and no second ring', await page.evaluate(() => { const input = document.querySelector('.pg-bands input[type="number"]'); input.focus(); const style = getComputedStyle(input); return style.borderTopColor === 'rgb(240, 97, 186)' && style.outlineStyle === 'none' && !/240, 242, 245|255, 255, 255/u.test(style.boxShadow); })); +check('configuration is one label column and one control column; rest is a read-only box like the others', await page.evaluate(() => { const lefts = [...document.querySelectorAll('.pg-controls > .pg-ctl-label')].map(el => Math.round(el.getBoundingClientRect().left)); const controls = [...document.querySelectorAll('.pg-controls > .pg-ctl')].map(el => Math.round(el.getBoundingClientRect().left)); const rest = document.querySelector('.pg-bands li:last-child input'); return new Set(lefts).size === 1 && new Set(controls).size === 1 && lefts.length >= 4 && rest?.readOnly === true && rest.value === 'rest'; })); +await page.locator('.pg-add').fill('**/package-lock.json'); +await page.locator('.pg-add').press('Enter'); await page.waitForTimeout(150); -check('adding an exclude pattern annotates matches and excludes the file', (await page.locator('.pg-patterns').innerText()).includes('matches 1 file') && (await page.locator('.pg-files tr.excluded').count()) === 1); -check('files table keeps the excluded row with strikethrough path', (await page.locator('.pg-files tr.excluded td.path').innerText()).includes('package-lock.json')); -await page.locator('.pg-controls input[type="checkbox"]').check(); +check('adding an exclude pattern annotates matches and excludes the file', (await page.locator('.pg-patterns').innerText()).includes('matches 1 file') && (await page.locator('.pg-files .pg-row.is-excluded').count()) === 1); +check('files table keeps the excluded row with strikethrough path', (await page.locator('.pg-files .pg-row.is-excluded .pg-path-name').innerText()).includes('package-lock.json') && (await page.evaluate(() => getComputedStyle(document.querySelector('.pg-row.is-excluded .pg-path-name')).textDecorationLine)) === 'line-through'); +check('files table is a plain table on the page ground with figures aligned in columns', await page.evaluate(() => { const rows = [...document.querySelectorAll('.pg-table .pg-row:not(.pg-row-head)')]; const rights = index => new Set(rows.map(row => Math.round(row.querySelectorAll('.pg-figures')[1].children[index].getBoundingClientRect().right))).size; return getComputedStyle(document.querySelector('.pg-table')).backgroundColor === 'rgba(0, 0, 0, 0)' && rows.length > 1 && [0, 1, 2, 3].every(i => rights(i) === 1); })); +check('the comment preview says it is off in this policy', (await page.locator('.pg-box-head-split').innerText()).toLowerCase().includes('off in this policy')); +await page.locator('.pg-switch').click(); await page.waitForTimeout(150); -check('turning the comment on adds a rendered owned comment to the GitHub preview', (await page.locator('#pg-view-panel').innerText()).includes('Change summary')); +check('turning the comment on makes the GitHub preview render the comment of this policy', (await page.locator('.pg-box-head-split').innerText()).toLowerCase().includes('on in this policy') && (await page.locator('.pg-comment').innerText()).includes('Change summary')); // policy editor await page.locator('.segmented button', { hasText: 'Policy' }).first().click(); await page.waitForSelector('.cm-editor', { timeout: 15000 }); @@ -212,13 +389,39 @@ check('non-PR URL is refused client-side without a request', (await page.locator await go('/docs/get-started/auto-label-pull-requests/'); check('docs page renders the source body with the size workflow', (await page.locator('main').innerText()).includes('pull_request_target')); check('docs provenance row names the source and tested standing', (await page.locator('.docs-provenance').innerText()).includes('docs/guides/auto-label-pull-requests.md') && (await page.locator('.docs-provenance').innerText()).includes('Example tested')); -check('docs search is present', (await page.locator('site-search').count()) === 1); +check('docs use the global search trigger, placed before the navigation', (await page.locator('[data-search-open]').count()) === 1 && (await page.locator('site-search').count()) === 0 && (await shellOf()).order.startsWith('search-trigger primary-nav')); +const docsShell = await page.evaluate(() => { const b = s => document.querySelector(s).getBoundingClientRect(); const pane = b('.sidebar-pane'), brand = b('.brand-link'), first = b('.sidebar-content summary, .sidebar-content a'); return { paneRight: Math.round(pane.right), paneTop: Math.round(pane.top), headerBottom: Math.round(b('.page > .header').bottom), firstLeft: Math.round(first.left), brandLeft: Math.round(brand.left), paneBg: getComputedStyle(document.querySelector('.sidebar-pane')).backgroundColor, headerBg: getComputedStyle(document.querySelector('.page > .header')).backgroundImage, mainLeft: Math.round(b('.main-pane').left), position: getComputedStyle(document.querySelector('.sidebar-pane')).position, overflow: getComputedStyle(document.querySelector('.sidebar-pane')).overflowY }; }); +check('docs rail starts under the lockup on the page grid, on the lighter ground, and scrolls on its own', docsShell.paneTop === docsShell.headerBottom && Math.abs(docsShell.firstLeft - docsShell.brandLeft) <= 6 && docsShell.paneBg === 'rgb(29, 34, 44)' && docsShell.position === 'fixed' && docsShell.overflow === 'auto' && docsShell.mainLeft >= docsShell.paneRight, JSON.stringify(docsShell)); +check('the rail divider continues through the header', docsShell.headerBg.includes('linear-gradient') && docsShell.headerBg.includes('rgb(29, 34, 44)'), docsShell.headerBg.slice(0, 120)); check('docs sidebar has the manual groups', (await page.locator('.sidebar-content').textContent()).includes('Policies and detail')); await shot('docs'); await go('/docs/recipes/policy-recipes/'); const tryIt = page.locator('a[href^="/playground/?example="]'); check('docs example links carry a Try it companion', (await tryIt.count()) > 0 && (await tryIt.first().innerText()).includes('Try it in the playground'), String(await tryIt.count())); +// ── wide layout: the grid stays centred and every route keeps the same edges ── +await page.setViewportSize({ width: 1720, height: 900 }); +let wideShell; +for (const path of ['/', '/playground/', '/examples/', '/extension/', '/app/', '/docs/cli/']) { + await go(path); + const shell = await shellOf(); + wideShell ??= shell; + check(`${path} at 1720: same lockup coordinate and cluster edge as the homepage`, shell.brand.left === wideShell.brand.left && shell.cluster.right === wideShell.cluster.right && shell.brand.left > 200, JSON.stringify(shell)); +} +await go('/docs/cli/'); +check('docs at 1720: the rail still starts at the lockup and the ground left of it reaches the viewport edge', await page.evaluate(() => { const pane = document.querySelector('.sidebar-pane').getBoundingClientRect(), brand = document.querySelector('.brand-link').getBoundingClientRect(), first = document.querySelector('.sidebar-content summary, .sidebar-content a').getBoundingClientRect(); return Math.round(pane.left) === 0 && Math.abs(first.left - brand.left) <= 6 && pane.right > brand.right; })); + +// ── no page-level horizontal overflow: every route, every qualified width ── +// 640 × 2 device pixels is the layout a 1280 window has at 200 % zoom. +for (const width of [1440, 1024, 768, 640, 375, 320]) { + await page.setViewportSize({ width, height: 900 }); + for (const path of ['/', '/playground/', '/playground/?example=lockfile-excluded&view=agent', '/playground/?example=lockfile-excluded&view=github', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', '/docs/cli/', '/#agents-setup']) { + await go(path); + if (path.startsWith('/playground/')) await page.waitForSelector('.pg-primary'); + check(`${path} has no page-level horizontal overflow at ${width}`, (await overflowOf()) <= 0, String(await overflowOf())); + } +} + // ── narrow layout ── await page.setViewportSize({ width: 390, height: 800 }); await go('/playground/'); @@ -228,13 +431,45 @@ await page.locator('.pg-sheet-toggle').click(); check('the bottom sheet opens with the same controls', await page.locator('#pg-sheet .pg-controls').isVisible()); await shot('playground-narrow'); await go('/'); -await page.locator('[data-menu-toggle]').click(); -check('narrow header menu opens the navigation', await page.locator('#site-nav').isVisible()); +const compact = await page.evaluate(() => { const vis = s => { const el = document.querySelector(s); return !!el && el.getBoundingClientRect().width > 0 && getComputedStyle(el).visibility !== 'hidden'; }; const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)}`; }; const left = s => document.querySelector(s).getBoundingClientRect().left; return { search: b('.search-trigger'), menu: vis('.menu-trigger'), nav: vis('.primary-nav'), github: vis('[data-direct-source]'), community: vis('.utility-social'), theme: b('.theme-control'), npm: vis('[data-trailing="npm"]'), order: left('.search-trigger') < left('.menu-trigger') && left('.menu-trigger') < left('[data-direct-source]') }; }); +check('compact header: icon search, Menu in the navigation position, GitHub + Community + Theme still visible, npm convenience gone', JSON.stringify(compact) === JSON.stringify({ search: '30x30', menu: true, nav: false, github: true, community: true, theme: '64x28', npm: false, order: true }), JSON.stringify(compact)); +await page.locator('[aria-controls="panel-menu"]').click(); +const menu = await page.locator('#panel-menu a').evaluateAll(links => links.map(a => a.textContent.trim().split('\n')[0].trim())); +check('the compact Menu exposes every primary destination and the same Install pages, without install actions', ['Docs', 'Playground', 'Examples'].every(t => menu.includes(t)) && menu.some(t => t.startsWith('Browser extension')) && menu.some(t => t.startsWith('GitHub App')) && menu.some(t => t.startsWith('CLI and GitHub Actions')) && !menu.includes('Add to Chrome') && !menu.includes('Install on GitHub') && await page.evaluate(() => { const r = document.getElementById('panel-menu').getBoundingClientRect(); return r.left >= 0 && r.right <= document.documentElement.clientWidth; }), menu.join('|')); +await page.keyboard.press('Escape'); await shot('home-narrow'); await page.setViewportSize({ width: 1280, height: 900 }); +// ── extension page, footer discovery ── +await go('/extension/'); +for (const shot of await page.locator('.ext-shot img').all()) { await shot.scrollIntoViewIfNeeded(); await shot.evaluate(img => img.complete || new Promise(done => { img.onload = done; img.onerror = done; })); } +const ext = await page.evaluate(() => ({ h1: document.querySelector('h1').textContent, sections: [...document.querySelectorAll('main section[data-section]')].map(s => s.dataset.section).join(' '), label: document.querySelector('[data-surface-label]').textContent, add: [...document.querySelectorAll('[data-cta="add-to-chrome"]')].map(a => a.getAttribute('href') ?? 'unconfigured'), scenes: document.querySelectorAll('[data-extension-scene]').length, shots: [...document.querySelectorAll('.ext-shot img')].every(img => img.complete && img.naturalWidth > 400 && img.alt.length > 40), text: document.querySelector('main').innerText })); +check('/extension/ is a full page: hero, Changed, policy, report, settings, data, routes and a closing action', ext.h1 === 'Changed, directly in GitHub' && ext.sections === 'extension changed policy report settings data routes add' && ext.label === 'Extension' && ext.scenes === 3 && ext.shots, JSON.stringify({ ...ext, text: undefined })); +check('/extension/ states its real permissions and boundaries and never requires the App', ['api.github.com', 'Incognito', 'not persisted', 'GitHub Enterprise', 'The extension changes your view. The App runs for the repository.'].every(t => ext.text.includes(t)) && !/coming soon|not open yet/iu.test(ext.text)); +check('every Add to Chrome on the page is a direct store action, never a link to a product page', ext.add.length >= 2 && ext.add.every(href => href === 'unconfigured' || /^https:\/\/chromewebstore\.google\.com\//u.test(href)), ext.add.join(' ')); +const footer = await page.evaluate(() => { const rows = [...document.querySelectorAll('.site-footer nav[aria-label="Learn"] li')].map(li => li.textContent.replace(/\s+/gu, ' ').trim()); const get = [...document.querySelectorAll('.site-footer nav[aria-label="Get"] a')].map(a => `${a.textContent.trim()}=${a.getAttribute('href') ?? 'unconfigured'}`); return { rows, releases: [...document.querySelectorAll('.site-footer a')].find(a => a.textContent === 'Releases').getAttribute('href'), get, appDocs: [...document.querySelectorAll('.site-footer nav[aria-label="Learn"] a')].find(a => a.textContent === 'GitHub App').getAttribute('href') }; }); +check('footer: Releases goes to the manual; Learn carries "Extension · GitHub App" directly above "CLI · Actions · Library API", pointing into the docs', footer.releases === '/docs/releases/' && footer.rows.indexOf('Extension · GitHub App') === footer.rows.indexOf('CLI · Actions · Library API') - 1 && footer.rows.indexOf('Extension · GitHub App') >= 0 && footer.appDocs.startsWith('/docs/github-app/'), JSON.stringify(footer)); +check('footer Get keeps the package and Action first and adds the extension and App with their direct actions', footer.get[0].startsWith('@wolfsblvt/diffdevil=') && footer.get.some(g => g === 'Browser extension=/extension/') && footer.get.some(g => g.startsWith('Chrome Web Store=')) && footer.get.some(g => g === 'GitHub App=/app/') && footer.get.some(g => g.startsWith('Install on GitHub=')), footer.get.join(' | ')); +await go('/'); +await page.keyboard.press('Control+k'); +await page.locator('[data-search-input]').fill('browser extension'); +await page.waitForSelector('.search-hit'); +check('search finds the extension page', (await page.locator('.search-hit').evaluateAll(hits => hits.map(h => h.getAttribute('href')))).includes('/extension/')); +await page.keyboard.press('Escape'); +await page.waitForTimeout(100); +check('closing search after the keyboard shortcut returns focus to the visible launcher', await page.evaluate(() => document.activeElement?.matches('[data-search-open]')), await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 120))); + +// ── examples and app pages ── +await go('/examples/'); +check('examples lead with real pull requests, carry no fixture gallery and no seed disclaimer', (await page.locator('#pull-requests').count()) === 1 && (await page.locator('#fixtures').count()) === 0 && !(await page.locator('main').innerText()).includes('Initial seed')); +check('example cards: mark and identity on one row, owner greyed, evidence chip right-aligned', await page.evaluate(() => [...document.querySelectorAll('.ex-card')].every(card => { const head = card.querySelector('.ex-head').getBoundingClientRect(), title = card.querySelector('.ex-title').getBoundingClientRect(), icon = card.querySelector('.ex-title svg').getBoundingClientRect(), chip = card.querySelector('.ex-head .chip-ev').getBoundingClientRect(); const owner = getComputedStyle(card.querySelector('.ex-owner')).color, name = getComputedStyle(card.querySelector('.ex-title a')).color; return icon.bottom <= title.bottom + 1 && icon.top >= title.top - 1 && Math.round(head.right - chip.right) <= 1 && owner !== name; }))); +await go('/app/'); +check('app page head is the same panel as the homepage', (await page.locator('.app-panel .benefits li').count()) === 6 && (await page.evaluate(() => getComputedStyle(document.querySelector('.app-panel')).backgroundColor)) === 'rgb(12, 15, 23)' && (await page.locator('.app-panel h1').count()) === 1); +check('app page has an honest #history chapter before #permissions with no dashboard imagery', await page.evaluate(() => { const ids = [...document.querySelectorAll('main .eyebrow-link')].map(a => a.id); const section = document.querySelector('[data-section="history"]'); return ids.indexOf('history') > 0 && ids.indexOf('history') < ids.indexOf('permissions') && /in design/u.test(section.innerText) && section.querySelectorAll('img, svg, canvas').length === 0; })); +check('app page: comparison blocks sit side by side below the lead; internal strategy prose is gone', await page.evaluate(() => { const [a, b] = [...document.querySelectorAll('[data-section="why"] .choice')].map(el => el.getBoundingClientRect()); return Math.round(a.top) === Math.round(b.top) && Math.round(a.width) === Math.round(b.width) && !document.querySelector('main').innerText.includes('primary pitch'); })); + // ── repository links ── -for (const path of ['/examples/', '/app/']) { +for (const path of ['/app/']) { await go(path); const hrefs = await page.locator('a[href*="/blob/main"]').evaluateAll(links => links.map(a => a.getAttribute('href'))); check(`${path} repository links carry the separator once`, hrefs.length > 0 && hrefs.every(h => h.includes('/blob/main/') && !h.includes('/blob/main//') && !/\/blob\/main[^/]/u.test(h)), hrefs.slice(0, 3).join(' ')); diff --git a/apps/website/src/assets/extension/settings-display-dark.png b/apps/website/src/assets/extension/settings-display-dark.png new file mode 100644 index 00000000..23e0d8fc Binary files /dev/null and b/apps/website/src/assets/extension/settings-display-dark.png differ diff --git a/apps/website/src/assets/extension/settings-policy-bands-light.png b/apps/website/src/assets/extension/settings-policy-bands-light.png new file mode 100644 index 00000000..dad93a6d Binary files /dev/null and b/apps/website/src/assets/extension/settings-policy-bands-light.png differ diff --git a/apps/website/src/components/ActionLink.astro b/apps/website/src/components/ActionLink.astro new file mode 100644 index 00000000..5c1594de --- /dev/null +++ b/apps/website/src/components/ActionLink.astro @@ -0,0 +1,13 @@ +--- +// SPDX-License-Identifier: AGPL-3.0-only +// A link whose destination is a configured live fact (Chrome Web Store listing, GitHub App +// installation, Dashboard, sponsor page). "Action wording goes to the action": it links +// straight to that destination, as an external link in a new tab. Until the destination is +// configured it renders as the same control without an href (`aria-disabled`), so the page +// is complete and nothing points at an invented URL or a "coming soon" note. +interface Props { href: string | undefined; class?: string | undefined; external?: boolean | undefined; [key: `data-${string}`]: string | undefined } +const { href, class: className, external = true, ...rest } = Astro.props; +--- +{href + ? + : } diff --git a/apps/website/src/components/AppPanel.astro b/apps/website/src/components/AppPanel.astro new file mode 100644 index 00000000..30442516 --- /dev/null +++ b/apps/website/src/components/AppPanel.astro @@ -0,0 +1,54 @@ +--- +// SPDX-License-Identifier: AGPL-3.0-only +// The branded App panel: one component, used by the homepage `#app` interruption and as +// the head of /app/, so the two can never drift. Midnight ground in both themes, 1.1fr / +// 1fr, six benefits in two columns. The GitHub mark sits before the `#app` anchor and is +// not part of it. The install action links to the configured installation URL; while the +// hosted App is not open, the button says so instead of pretending. +import ActionLink from './ActionLink.astro'; +import Icon from './Icon.astro'; +import { copy } from '../data/copy'; +import { APP_INSTALL_URL } from '../data/site'; +interface Props { as?: 'h1' | 'h2' | undefined; secondaryHref: string; selfHostHref: string } +const { as: Tag = 'h2', secondaryHref, selfHostHref } = Astro.props; +const c = copy.app; +const [line1, line2] = c.h2Lines; +--- +
+
+

#app · {c.eyebrowTail}

+
{line1}
{line2}
+

{c.body}

+
+ {c.ctaPrimary} + {c.ctaSecondary} + {c.ctaTertiary} → +
+

{c.foot}

+
+ +
+ diff --git a/apps/website/src/components/CodeBlock.astro b/apps/website/src/components/CodeBlock.astro index f5159c15..776ff9bd 100644 --- a/apps/website/src/components/CodeBlock.astro +++ b/apps/website/src/components/CodeBlock.astro @@ -1,8 +1,9 @@ --- // SPDX-License-Identifier: AGPL-3.0-only // Read-only code on the machine ground, highlighted at build time by Shiki with the -// diffdevil palette. Header row: filename · scope · Copy. No client script is shipped -// for highlighting; the copy button uses the shared clipboard wiring. +// diffdevil palette. Header row: filename left, scope and Copy right. Long lines wrap; a +// code block never widens its page. No client script is shipped for highlighting; the copy +// button uses the shared clipboard wiring. import { Code } from 'astro:components'; import type { BundledLanguage } from 'shiki'; import { diffdevilSyntax } from '../lib/shiki-theme'; @@ -10,14 +11,14 @@ interface Props { code: string; lang: BundledLanguage | 'text'; file?: string | const { code, lang, file, scope, copy = true, class: className = '' } = Astro.props; const id = Astro.props.id ?? `code-${Math.random().toString(36).slice(2, 9)}`; --- -
+
{(file || scope || copy) && (
{file && {file}} - {scope && · {scope}} - {copy && } + {scope && {scope}} + {copy && }
)} - +
diff --git a/apps/website/src/components/DestinationRows.astro b/apps/website/src/components/DestinationRows.astro new file mode 100644 index 00000000..467e59e9 --- /dev/null +++ b/apps/website/src/components/DestinationRows.astro @@ -0,0 +1,28 @@ +--- +// SPDX-License-Identifier: AGPL-3.0-only +// Rows of a header destination panel: 16px icon, 13px label, 12px supporting line, and an +// 11px arrow for an external destination. A row whose destination has no real URL yet is a +// disabled link (no href, aria-disabled), never a dead `#` and never a "coming soon" note. +import Icon from './Icon.astro'; +import type { IconName } from '../data/icons'; +interface Row { readonly id: string; readonly icon: IconName; readonly label: string; readonly note?: string | undefined; readonly href: string | undefined; readonly current?: boolean | undefined } +interface Props { rows: readonly Row[]; kind: string } +const { rows, kind } = Astro.props; +const external = (href: string) => /^https?:/u.test(href); +--- + diff --git a/apps/website/src/components/Footer.astro b/apps/website/src/components/Footer.astro index 067bd106..1de229e8 100644 --- a/apps/website/src/components/Footer.astro +++ b/apps/website/src/components/Footer.astro @@ -1,27 +1,57 @@ --- // SPDX-License-Identifier: AGPL-3.0-only +// Footer. Product wording goes to the product page and action wording straight to the +// action, here as everywhere. "Get" lists every way to obtain diffdevil, package and Action +// first; "Learn" points into the manual, where a row may carry sibling links; socials are +// icons only, never collapsed and complete (Works, Discord, Bluesky, GitHub). +import ActionLink from './ActionLink.astro'; +import Icon from './Icon.astro'; import { copy } from '../data/copy'; -import { COMPANY, COMPANY_PRIVACY, GITHUB, IMPRESSUM, MARKETPLACE, NPM, RELEASES, docPaths, paths } from '../data/site'; -const get: [string, string][] = [ - ['@wolfsblvt/diffdevil', NPM], ['Wolfsblvt/diffdevil@v1', MARKETPLACE], ['GitHub Marketplace', MARKETPLACE], ['GitHub App', paths.app], -]; -const learn: [string, string][] = [ - ['Get started', docPaths.getStarted], ['Recipes', docPaths.recipes], ['CLI', docPaths.cli], ['Actions', docPaths.actions], ['Library API', docPaths.api], ['Policies and detail', docPaths.policies], ['Agent integration', docPaths.agents], -]; -const project: [string, string][] = [ - ['Source on GitHub', GITHUB], ['Releases', RELEASES], ['Security', paths.security], ['Licences', paths.licences], ['Privacy and data', paths.privacy], ['Impressum', IMPRESSUM], +import { footerSocials } from '../data/destinations'; +import { APP_INSTALL_URL, CHROME_WEB_STORE_URL, COMPANY, GITHUB, MARKETPLACE, NPM, docPaths, paths } from '../data/site'; +import { extensionDocsPresent } from '../lib/sources'; +type Item = readonly [label: string, href: string | undefined]; +const c = copy.footer; +const extensionDocs = extensionDocsPresent() ? docPaths.extension : undefined; +// One row may carry several sibling links (Extension · GitHub App, CLI · Actions · Library API). +const learn: Item[][] = [ + [['Get started', docPaths.getStarted]], [['Recipes', docPaths.recipes]], + [['Extension', extensionDocs], ['GitHub App', docPaths.app]], + [['CLI', docPaths.cli], ['Actions', docPaths.actions], ['Library API', docPaths.api]], + [['Policies and detail', docPaths.policies]], [['Agent integration', docPaths.agents]], ]; +const project: Item[] = [['Source on GitHub', GITHUB], ['Releases', docPaths.releases], ['Security', paths.security], ['Licences', paths.licences], ['Privacy and data', paths.privacy], ['Impressum', paths.impressum]]; +const rel = (href: string) => (href.startsWith('http') ? 'noopener' : undefined); --- -