Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ page; both are read only by a running `/loop`.
| Code organization | where routes/api/domain/components live | [topics/code-organization.md](./topics/code-organization.md) |
| Remote env (web) | pre-provisioned Chromium, Playwright pin, MCP limits | [topics/remote-env.md](./topics/remote-env.md) |
| Workflow & docs | /dream, doc-scanner, PRD source of record | [topics/workflow.md](./topics/workflow.md) |
| Quality bar | objective a11y (WCAG AA) + perf (JS budget) targets & gates | [topics/quality-bar.md](./topics/quality-bar.md) |

## Wiki rules
- **Raw source** for ingests is the session + git history (ephemeral); there is no
Expand Down
18 changes: 18 additions & 0 deletions .claude/memory/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,21 @@ change), `rotate` (archived old entries). Format: `## [YYYY-MM-DD] <op> | <summa
while later work merged ~2026-07-27/28. Left intact — the 06-29 cluster is 7 entries
mostly from genuine prior sessions, so rewriting dates risked corrupting correct history
more than it fixed. Reported for the record; no rewrite without a clear per-entry basis.

## [2026-07-28] ingest | Objective a11y/perf quality bar (Gauntlet "the bar")
- Evaluated Matt Shumer's "Gauntlet Loop" against the repo (via our own critic machinery):
we already have most of it (fresh-context subagents, maker/checker, evidence-gated
stopping). The one adoption worth it for spec-driven dev: give the Accessibility &
Performance critics a concrete EXTERNAL bar, wired as a gate. Skipped the rest
(aesthetic reference bar, blind A/B, forever-looping) as misfits — recorded in the eval.
- Added `.claude/memory/topics/quality-bar.md` (WCAG 2.1 AA contrast + first-load JS budget
190 KB gzip via a machine-readable `perf-budget-kb-gzip` marker = single source of truth),
index row, an axe e2e gate (`e2e/a11y.spec.ts`), and `scripts/check-bundle-size.mjs`
wired into `pnpm verify`. Pointed the two critic lenses at the bar.
- Durable gotcha (also filed in topics/remote-env.md): CI uses pnpm 10; the web env's
corepack pnpm 11 errors `ERR_PNPM_IGNORED_BUILDS`. Fixed the never-completed
`pnpm-workspace.yaml` build-approval placeholder and regenerated the lockfile with
pnpm 10 to match CI.
- Maker/checker: `/improve-skills` over the prompt surface returned 0 blockers + 4 real
consistency fixes (all applied — incl. a "placeholders exempt" a11y loophole and a stale
verify-chain doc); a separate critic-panel round over the implementation was also run.
9 changes: 7 additions & 2 deletions .claude/memory/topics/build-and-verify.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# Build & verify

- `pnpm verify` runs: typecheck → lint → unit → build → E2E.
- `pnpm verify` runs: typecheck → lint → unit → build → E2E. **`pnpm build` also runs the
bundle-size gate** (`next build && scripts/check-bundle-size.mjs`) — so the perf budget
defined in [quality-bar.md](./quality-bar.md) is enforced everywhere `build` runs,
including **CI's `build` matrix job** (CI runs the per-script matrix + e2e, not `verify`,
so attaching the check to `build` is what gets it enforced on PRs). `pnpm check:bundle`
runs it standalone. Don't restate the KB number here.
- Next 16 **removed `next lint`**; `pnpm lint` runs `eslint .` directly.
- Use `eslint-config-next`'s **native flat config** in `eslint.config.mjs`
(`import next from "eslint-config-next"`). Do **not** use `FlatCompat` — it
crashes under ESLint 9 ("Converting circular structure to JSON").

Related: [tooling](./tooling.md) · [testing](./testing.md) · [workflow](./workflow.md)
Related: [tooling](./tooling.md) · [testing](./testing.md) · [workflow](./workflow.md) · [quality-bar](./quality-bar.md)
65 changes: 65 additions & 0 deletions .claude/memory/topics/quality-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Quality bar — objective a11y & performance targets

Concrete, measurable targets the **Accessibility** and **Performance** critics judge
against — the "external bar" idea (a bar the agent can't talk its way around) applied to
the two lenses that otherwise rely on unanchored checklist language. Functional work
already has a hard bar (`docs/prd.md` acceptance criteria + `pnpm verify`); this extends
the same discipline to a11y and perf. These are also wired as **gates** (see below), not
just critic opinion.

> Scope: a11y + performance **only**. There is deliberately **no** aesthetic /
> reference-exemplar bar for the Designer or Artistic Direction critics — for this
> minimal app that would be over-engineering; those lenses stay qualitative.

## Accessibility target (WCAG 2.1 AA)
- **Text contrast ≥ 4.5:1** for normal text, **≥ 3:1** for large text (≥ 24px, or ≥ 18.66px
bold). Disabled/inactive controls are exempt per WCAG 1.4.3. Placeholder text is **not**
blanket-exempt: when it conveys information it should meet the same targets, and it must
never be a field's only label. (Our fields carry a persistent visible `<label>`, so an
example-format placeholder is supplementary — a sub-4.5:1 hint is acceptable *only*
because that visible label exists.)
- **Zero `serious` or `critical` axe violations** (WCAG 2.1 A/AA) on the main flow (initial
load, the validation-error state, and the greeting/remembered state) — in particular
`color-contrast`, `label`, and `aria-*` rules.
- **Landmark structure:** zero `region` / `landmark-one-main` violations. These are axe
*best-practice* rules (not in the WCAG-AA tag set and often below serious/critical), so
the gate checks them **explicitly** (a separate `withRules` scan), not via the tag filter.
- **Keyboard-operable happy path:** the flow is completable with the keyboard alone (Tab to
the field, type, Tab to the submit button, Enter → greeting) — smoke-tested in the a11y
spec. Interactive controls keep a visible focus indicator (`:focus-visible` styles).
- **Not covered by this gate** (stays with the qualitative Designer / Artistic lenses):
mobile-viewport layout and tap-target sizing (`target-size` is WCAG 2.2 AA, outside this
desktop WCAG-2.1-AA scan). Don't read "zero violations" as a mobile/touch guarantee.

## Performance target (client JS budget)
<!-- perf-budget-kb-gzip: 155 --> (machine-readable single source of truth; the enforcing
script `scripts/check-bundle-size.mjs` reads THIS marker — edit the number here, nowhere else.)
- **First-load client JS ≤ 155 KB gzipped.**
- **Metric (deterministic, CI-checkable without a browser):** sum of the **per-file**
gzipped sizes of `.next/static/chunks/**/*.js` after `next build`, **excluding the legacy
noModule polyfill chunk(s)** (read from `.next/build-manifest.json` `polyfillFiles`) that
modern module-supporting browsers never download — so the number is the JS a modern
browser actually loads. Per-file gzip, not gzip-of-concatenation. For this **single-route**
app the total *is* the first-load set; if routes are added, narrow it to the `/` route's
chunks so "first-load" stays literal.
- **Baseline measured 2026-07-28:** 143.2 KB gzip (polyfill excluded). The 155 KB budget is
the baseline **+ ~8% headroom** — a ratchet, not aspirational: it passes today and catches
an unintended dependency/bundle regression.
- Most of the bundle is the shared React/Next framework; the app's own code is a few KB.

## How the targets are enforced
- **a11y:** `e2e/a11y.spec.ts` (Playwright + axe) covers the main flow's three states, an
explicit landmark scan, and a keyboard-path test. It runs in `pnpm test:e2e`, which is
part of `pnpm verify` **and** the CI `e2e` job — so a11y is enforced on every PR.
- **perf:** `scripts/check-bundle-size.mjs` runs as part of **`pnpm build`**
(`next build && node scripts/check-bundle-size.mjs`). Because CI runs the per-script
matrix (incl. `build`) + `e2e` rather than `pnpm verify`, attaching the check to `build`
is what enforces the budget on **every PR** (the CI `build` job fails if it's over).
`pnpm check:bundle` runs it standalone against an existing `.next`.
- **critics:** the Accessibility and Performance lenses in `.claude/workflows/critic-panel.js`
judge the current UI against these numbers, not vague "is it accessible / fast".

## Ratchet policy
When the bundle legitimately grows (a justified new dependency/feature), **raise the
budget consciously in this file** with a one-line reason — never silently, and never
lower a target just to make a failing check pass (fix the real violation instead).
7 changes: 7 additions & 0 deletions .claude/memory/topics/remote-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,11 @@
(`typecheck`/`lint`/`test`/`build` matrix + separate `e2e`); `process.env.CI`
makes Playwright start a fresh server instead of reusing one.

- **pnpm version gotcha:** CI pins **pnpm 10** (`pnpm/action-setup@v4`, `version: 10`),
but the web/remote-env `corepack pnpm` is **11**, which errors `ERR_PNPM_IGNORED_BUILDS`
on native deps (sharp, unrs-resolver) that pnpm 10 tolerates. When adding a dependency:
approve builds in `pnpm-workspace.yaml` (`onlyBuiltDependencies:`), and regenerate the
lockfile with `CI=true corepack pnpm@10 install` so it matches CI's `--frozen-lockfile`.
Run local checks with `CI=true corepack pnpm@10 …` to avoid the pnpm-11 strictness.

Related: [tooling](./tooling.md) · [testing](./testing.md) · [build-and-verify](./build-and-verify.md)
4 changes: 2 additions & 2 deletions .claude/workflows/critic-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ const ALL_CRITICS = [
{ key: 'artistic-direction', ui: true, label: 'Artistic Direction Critic', lens: 'Mood, visual identity, beauty, coherence — is it intentional and memorable or generic?' },
{ key: 'frontend-arch', ui: false, label: 'Frontend Architecture Critic', lens: 'Component boundaries, state, hooks, server/client split, TypeScript correctness, SOLID/DRY/KISS, no duplicated logic.' },
{ key: 'qa-e2e', ui: false, label: 'QA / E2E Critic', lens: 'Critical paths, missing Playwright tests, meaningful vs superficial assertions, edge cases, did we actually verify localhost?' },
{ key: 'accessibility', ui: true, label: 'Accessibility Critic', lens: 'Keyboard nav, focus states, accessible names, labels, form-error linkage, contrast, semantic HTML, dialog a11y.' },
{ key: 'performance', ui: false, label: 'Performance Critic', lens: 'Unnecessary re-renders, large client bundles, heavy images, blocking fetches, overuse of client components.' },
{ key: 'accessibility', ui: true, label: 'Accessibility Critic', lens: 'Keyboard nav, focus states, accessible names, labels, form-error linkage, contrast, semantic HTML, dialog a11y. Judge against the CONCRETE bar in .claude/memory/topics/quality-bar.md (WCAG 2.1 AA: 4.5:1 / 3:1 contrast; zero serious/critical axe violations on the main flow), not vague "is it accessible" — cite the specific target a finding violates. Confirm pass/fail against an ACTUAL axe run (`pnpm test:e2e e2e/a11y.spec.ts`, or a result already in priorEvidence); never assert a11y status from memory.' },
{ key: 'performance', ui: false, label: 'Performance Critic', lens: 'Unnecessary re-renders, large client bundles, heavy images, blocking fetches, overuse of client components. Judge against the CONCRETE bar in .claude/memory/topics/quality-bar.md (first-load client JS ≤ the documented gzipped budget), not vague "is it fast" — cite the measured number vs the budget. Obtain that number by running `pnpm build && pnpm check:bundle` (or reuse a bundle figure already in priorEvidence); never estimate or restate the quality-bar.md baseline as if it were a fresh measurement.' },
{ key: 'security', ui: false, label: 'Security Critic', lens: 'Unsafe input handling, secrets exposed to the client, auth-bypass risk, missing server-side validation, unsafe redirects, XSS, insecure storage.' },
{ key: 'regression', ui: false, label: 'Regression Critic', lens: 'Existing routes/flows affected, shared components/APIs changed, snapshots, styling side effects, backward compatibility.' },
]
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,13 @@ Toolchain: Next 16, React 19, TypeScript 6, ESLint 9 (flat config via
{
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build && node scripts/check-bundle-size.mjs",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "playwright test",
"check:bundle": "node scripts/check-bundle-size.mjs",
"verify": "pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm test:e2e"
}
}
Expand Down
74 changes: 74 additions & 0 deletions e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

/**
* Accessibility gate — the objective half of the quality bar
* (.claude/memory/topics/quality-bar.md): zero `serious`/`critical` axe
* violations against WCAG 2.1 A/AA across the main flow's key states.
*/

const WCAG_AA = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];

async function scan(page: import("@playwright/test").Page) {
const results = await new AxeBuilder({ page }).withTags(WCAG_AA).analyze();
return results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical",
);
}

// Compact, actionable failure message: rule id + the offending selectors.
function summarize(violations: Awaited<ReturnType<typeof scan>>) {
return violations
.map((v) => `${v.id} (${v.impact}): ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)
.join("\n");
}

test.describe("accessibility (WCAG 2.1 AA, no serious/critical)", () => {
test("initial load", async ({ page }) => {
await page.goto("/");
await expect(page.getByLabel("Your name")).toBeVisible();
const v = await scan(page);
expect(v, `axe violations:\n${summarize(v)}`).toEqual([]);
});

test("validation-error state", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Say hello" }).click();
await expect(page.locator("#name-error")).toBeVisible();
const v = await scan(page);
expect(v, `axe violations:\n${summarize(v)}`).toEqual([]);
});

test("greeting state", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Your name").fill("Ada Lovelace");
await page.getByRole("button", { name: "Say hello" }).click();
await expect(page.getByTestId("greeting")).toBeVisible();
const v = await scan(page);
expect(v, `axe violations:\n${summarize(v)}`).toEqual([]);
});

// `region` / `landmark-one-main` are axe best-practice rules (not in the WCAG-AA tag
// set), so scan for them explicitly — quality-bar.md promises landmark coverage.
test("landmark structure (all content in a landmark, exactly one main)", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withRules(["region", "landmark-one-main"])
.analyze();
expect(results.violations, `landmark violations:\n${summarize(results.violations)}`).toEqual([]);
});

// Keyboard-operable happy path (the focus/keyboard target the axe static scan can't verify).
test("keyboard-only happy path", async ({ page }) => {
await page.goto("/");
await page.keyboard.press("Tab");
await expect(page.getByLabel("Your name")).toBeFocused();
await page.keyboard.type("Ada Lovelace");
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Say hello" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByTestId("greeting")).toHaveText(
"Hello, Ada Lovelace! Welcome aboard.",
);
});
});
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build && node scripts/check-bundle-size.mjs",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"check:bundle": "node scripts/check-bundle-size.mjs",
"verify": "pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm test:e2e"
},
"dependencies": {
Expand All @@ -20,6 +21,7 @@
"react-dom": "19.2.7"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@playwright/test": "1.56.0",
"@types/node": "^22.10.0",
"@types/react": "^19.2.0",
Expand Down
Loading
Loading