diff --git a/.claude/README.md b/.claude/README.md index b2323c2..0aa1b3f 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -25,13 +25,45 @@ this directory). issues are caught and auto-fixed immediately instead of first at commit time. An instance's own slower, pre-push-only hooks stay pre-push-only — too slow to run after every edit. +- `agents/plan-executor.md` — the subagent that executes one work item + of a `docs/plans/` plan on a faster model (`model: sonnet`), under + the main session as orchestrator (see `../CLAUDE.md`, "Execute + multi-item work as an orchestrator"). It holds the executor's side of + the protocol: what a brief contains, the write-set and no-push rules, + the escalation triggers, and the report format. The same agent, briefed + to land a finished item, is a lander (its "Lander mode" section): + diff check, merge, verification lane, PR, `/code-review`, CI and, + within the plan's standing approvals, merge and release. +- `skills/` — project skills Claude loads when a request matches their + `description` (or that you invoke as `/`): + - `plan-creation` — writing a `docs/plans/` plan and, when the user + has setup to do first, its pre-flight runbook. + - `plan-execution` — the orchestrator's side of executing a plan: + the rolling queue of executors and landers, researched answers to + escalations, serialized lanes, gates and close-out. The main session + decides and raises gates; landers do the reviewing, merging, + verifying and releasing. + - `owasp-scan` — a parallel OWASP Top Ten scan of `src/`. + - `frontend-design` — how to design UI here: the "Field Ledger" + direction and its anti-defaults, proposing 2-3 distinct options for + new screens so output doesn't converge on one stock layout, and the + token, motion and accessibility rules. Points at + `../docs/design/README.md` as the source of truth. + - `ui-verify` — the visual verification loop: start the dev server, + screenshot 3 viewports (390, 834, 1440) in light and dark, run axe, + compare against the intent, iterate. Ships `shoot.mjs`, which drives + the stack's existing Selenium over W3C WebDriver, because the + Playwright MCP's CDP attach to it fails with a 400. + + `../CLAUDE.md` points at the two plan skills, so they're used even when + a request doesn't match their descriptions word for word. - The `PreToolUse` hook on `Bash` runs `snip hook`, the [snip](https://github.com/edouard-claude/snip) token-filtering proxy: it rewrites supported commands (`git` and others; an instance's own language tooling once it's added) to return only their signal (failures, diffs, summaries) instead of full output. `snip` itself is - installed by `scripts/develop.sh`, pinned to `SNIP_VERSION` in the - Dockerfile, and checksum-verified against its published release + installed by `scripts/develop.sh`, pinned to `SNIP_VERSION` in + `../.devcontainer/Dockerfile`, and checksum-verified against its published release checksums — see "Versions and config" in `docs/TEMPLATE.md`. Chosen over the more popular [rtk](https://github.com/rtk-ai/rtk) because its filters are @@ -45,8 +77,13 @@ this directory). - `clear-thought` (`@waldzellai/clear-thought-onepointfive`) — a local stdio MCP server exposing structured reasoning/mental-model tools, launched via `npx`. `npx` needs Node.js, which is the only - reason `scripts/develop.sh` installs it (pinned via the Dockerfile's - `NODE_VERSION` ARG) — Claude Code itself doesn't need it. + reason `scripts/develop.sh` installs it (pinned via + `../.devcontainer/Dockerfile`'s `NODE_VERSION` ARG) — Claude Code itself doesn't need it. + + - `mui-mcp` (`@mui/mcp`) — MUI's official local stdio MCP server: + current Material UI component docs and APIs, launched via `npx`. + Pinned to an exact version; `frontend-design` tells Claude to use it + rather than guess props. An instance adds its own stack-specific MCP servers here (database, object storage, browser automation, ...), pinned to an exact @@ -67,10 +104,11 @@ this directory). ## Removing a tool - **snip**: delete the `PreToolUse` hook entry in `settings.json` and the - `snip` install block (and `SNIP_VERSION` ARG in the Dockerfile) from + `snip` install block (and `SNIP_VERSION` ARG in `../.devcontainer/Dockerfile`) from `scripts/develop.sh`. - **context7** / **security-guidance**: delete the plugin's `enabledPlugins` entry in `settings.json`. +- **mui-mcp**: delete its entry from `../.mcp.json`. - **clear-thought**: delete its entry from `../.mcp.json`; if nothing else needs Node.js, also remove its install block (and `NODE_VERSION` - ARG) from `../Dockerfile`/`scripts/develop.sh`. + ARG) from `../.devcontainer/Dockerfile`/`scripts/develop.sh`. diff --git a/.claude/agents/plan-executor.md b/.claude/agents/plan-executor.md new file mode 100644 index 0000000..080ff9e --- /dev/null +++ b/.claude/agents/plan-executor.md @@ -0,0 +1,137 @@ +--- +name: plan-executor +description: Executes exactly one work item of a docs/plans/ plan, briefed by the orchestrating main session, or (lander mode) lands one finished item or one repo's wave PR. Works only inside its brief's write set, in its own worktree, and hands back to the orchestrator instead of improvising when it hits an escalation trigger. Use one per independent work item; run independent items in parallel. +model: sonnet +--- + +You execute one work item from a plan under `docs/plans/`, or, when +your brief says you are the lander ("Lander mode" below), land one. The +orchestrator (the main session) wrote your brief. It owns every decision +outside that brief and talks to the user. Your job is to finish your +item and report it accurately. + +## Your brief contains + +1. **Goal:** the work item, quoted from the plan, plus any Decision log + entries that apply. Decision log entries are settled. Don't reopen + them. +2. **Write set:** the only files and directories you may create, change + or delete. Everything else is read-only to you. +3. **Interfaces:** names and signatures you must provide, or may rely on + from items running in parallel. Code against them exactly as stated, + even if the other side doesn't exist yet in your worktree. +4. **Done when:** the checks that must pass before you report `done`. + +## Rules + +- Before writing anything, read `CLAUDE.md`, then every `README.md` on + the path to each file in your write set, root first (`CLAUDE.md`, + "Before writing anything"). +- Before relying on a third-party library's API, look it up with + context7. Don't code from memory. +- Commit only to your own `wip/` branch in your worktree. Never + push, open a PR, create a repo, tag, publish, or delete a branch. +- Don't run integration tests, e2e tests or load tests unless your + brief says to. They share the devcontainer's services, and the + orchestrator runs them one at a time in its own lanes. Run lint, + type-check and unit tests for your write set yourself. +- Show real check output. "Looks done" is not verification. + +## Escalate: stop and hand back + +Stop working and report `STATUS: escalate` as soon as any of these +happens. Don't work around it: + +1. A required check still fails after two distinct fix attempts. +2. The fix needs a file outside your write set, or an interface that + differs from your brief. +3. Your brief contradicts the code, a `README.md`, an ADR or an NFR, or + is ambiguous enough that you'd be guessing. +4. A library or framework behaves differently from what your brief + assumes. +5. The only way to pass a check is a suppression comment, a lower + coverage threshold, or a weakened guardrail. +6. Anything under `.devcontainer/` would need to change. That needs a + rebuild only the user can trigger. +7. Any outward-facing or hard-to-reverse action would be needed. +8. A measurement contradicts the expected effect, or the run-to-run + spread is larger than the effect. + +The orchestrator researches the problem, decides, and resumes you with +a message. Keep your worktree as it is when you escalate. + +## Report: your final message, exactly this shape + +``` +STATUS: done | escalate +SUMMARY: <2-5 lines> +CHANGED: +CHECKS: verbatim output tail, one per check> +COMMIT: +ESCALATION: +``` + +## Lander mode + +A brief that says "lander" makes you the one who lands a finished item, +or one repo's wave PR, instead of implementing. The executor rules above +still apply except where this section says otherwise: you may push the +branches and open or update the wave PRs the brief names and, only as +its standing approvals allow, merge and release. Never tag, publish, +delete a branch or create a repo unless the brief names that step as a +standing approval. Merging `wip/` into the integration branch (step 2) +is always yours, in the repo checkout the brief names, and isn't a +"merge" in the sense below; merging the wave PR (step 7) and releasing +need a standing approval, so none in the brief means no PR merge and no +release. + +**The brief contains:** the item ID(s) and the executor's report; the +original brief's write set and "Done when"; the repo, its integration +branch and the wave PR (existing or to open); the verification lane's +exact commands, and whether you hold the slot for suites that share the +devcontainer's services or fixed ports; and the standing approvals that +say whether you may merge and release in this repo. A PR the user merges +is never yours to merge. + +**Steps:** + +1. Check the executor's diff against the write set and "Done when": every + changed file inside the write set, `CHECKS` showing real output. +2. Merge `wip/` into the repo's integration branch. +3. Run the verification lane. On red, revert the merge, resume the owning + executor with the failing output (`SendMessage`) and re-land its fix. + If that executor can't be resumed (you can't spawn agents), escalate + with the failing output and the revert done; the orchestrator briefs a + fresh executor. Escalate also if the fix can't be made inside the + write set. +4. Push and open or update the wave PR. +5. Run `/code-review` on the PR, naming the PR or the `origin/main...HEAD` + diff explicitly, never the shell's cwd. Fix findings inside the write + set and re-run the lane after each round. +6. Wait for CI. +7. Merge and trigger releases only where the standing approvals allow, + once CI is green and the review is clean. + +**Escalate** (same report shape, same rule of keeping the worktree as it +is) on: a red lane you can't fix inside the write set; a review finding +that needs a decision, an interface or dependency change, or a wider write +set; two review rounds in a row that find new correctness issues in the +same area; anything outside the standing approvals; and every user gate. If a +finding that needs a decision arrives after the merge, revert the merge +(`git revert -m 1`) before escalating, so nobody builds on it; if other +merges sit on top of it and the revert conflicts, say so in the +escalation instead of forcing it. + +**Report:** + +``` +STATUS: done | escalate +PR: +MERGE: "> +CI: +REVIEW: +LANE: verbatim output tail> +NOTES: +ESCALATION: +``` diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..392144f --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,75 @@ +--- +name: frontend-design +description: How to design UI in this repo. Use when building or restyling a screen, component or layout, or when asked for new UI options. Covers the "Field Ledger" direction, its anti-defaults, theme-token rules, motion and accessibility targets. +--- + +# Frontend design + +`docs/design/README.md` is the source of truth for the design system +(direction, tokens, contrast table, motion rules); `docs/adrs/0004-design-system-and-token-lint.md` +says why. Read the design README before designing anything, and do not +copy its rules into code comments or other docs: link to it. + +## Direction: Field Ledger + +An editorial, archival feel: a ledger kept in a field office. Warm paper +surfaces, ink-dark text, vermilion for the one primary action, bottle +green as the quiet secondary. Fraunces for headings, Instrument Sans for +body. 2px radius; hairline rules do the work that shadows and rounding do +elsewhere. Comfortable density. Aim for a screen that looks *kept* and +*considered*, not assembled from stock components. + +## Anti-defaults + +Generic MUI output is the failure mode. Do not ship: + +- the default MUI blue or any purple/blue gradient; +- default elevation shadows (`elevation`, `Paper` raised, drop-shadow + cards); use a rule or a 1px divider; +- Roboto, Inter or system-ui as the face of the UI; +- gratuitous gradients, glass blur or decoration; +- cards-everywhere: not every group of content needs a box. Prefer + typographic hierarchy, rules and whitespace; a table or ledger list + often beats a grid of cards; +- pill buttons, large radii, ALL-CAPS outside table column heads, or a + second accent color (status colors are for status only). + +## Vary across generations + +Left alone, generated UI converges on one stock layout (centered column, +stacked cards, hero plus three tiles). Resist it: + +- When asked for a new screen, propose 2-3 distinct options first + (different structure, not just different colors: e.g. ledger table vs. + two-pane index/detail vs. an annotated single column), with one line on + the trade-off each. Let the user pick before building the full thing. +- Pick a deliberate point of view per screen (what is the one thing the + eye should land on?) and let scale, weight and whitespace carry it. +- Do not reuse the layout of the last screen you built just because it + worked. + +## Always + +- Use theme tokens: palette via `sx` names or `theme.vars.palette.*`, + spacing via `theme.spacing`/`sx` numbers, radius via + `theme.shape.borderRadius`, motion via `tokens.*`. Never a hex/rgb + literal or raw `px` outside `src/theme/`. `scripts/check-design-tokens.sh` + (hook `design-tokens`) enforces it; a justified exception carries + `// design-tokens: allow `. Do not add the marker to silence it + by default. +- Typography via the theme's variants, never a hand-set `fontFamily`. +- Motion: one quiet row reveal (`rowReveal`/`rowRevealContainer` from + `src/theme/motion.ts`). No looping or decorative motion. The theme wraps + the app in `MotionConfig reducedMotion="user"`; keep it that way and + never let motion carry meaning. +- Accessibility: WCAG 2.2 AA (4.5:1 text, 3:1 large text and UI + boundaries); keep the focus ring; real landmarks (`main`, one `h1`); + rem sizes so text scales; check 200% zoom. Divider color is never the + sole boundary of an interactive control. +- Both color schemes are first-class: a component is not done until it + is checked in light and dark. +- Use the `@mui/mcp` server (see `.claude/README.md`) for MUI component + APIs and docs instead of guessing props. + +When the UI is built, run the `ui-verify` skill and iterate against what +you actually see. diff --git a/.claude/skills/plan-creation/SKILL.md b/.claude/skills/plan-creation/SKILL.md new file mode 100644 index 0000000..c8747b0 --- /dev/null +++ b/.claude/skills/plan-creation/SKILL.md @@ -0,0 +1,109 @@ +--- +name: plan-creation +description: Write a plan under docs/plans/ (and, for plans that need user setup, its pre-flight runbook) for multi-step or multi-item work, laid out so an orchestrator can execute it with parallel plan-executor subagents. Use when the user asks for a plan, a roadmap, or how to approach a large change. +--- + +# Plan creation + +Produce a plan file under `docs/plans/` that someone else, usually a fresh +orchestrating session using the `plan-execution` skill, can execute +without coming back to ask. The plan file's format and lifecycle are in +`docs/plans/README.md` and `docs/plans/template.md`; this skill is how to +get to a good one. + +## 1. Explore before writing + +- Read the `README.md`s on the path to every directory the plan touches + (`CLAUDE.md`, "Before writing anything"), the ADRs and NFRs it builds + on, and any plan still in progress under `docs/plans/`. A new plan that + touches files an unfinished plan is still changing must say it starts + after that plan's close-out. +- For other repos the plan touches, look at their actual state (shallow + clones in the scratchpad), not their names: what they contain, whether + they're wired into template sync, what their latest releases are. +- Use a subagent for any sweep that would pull many files into context. +- Check the machine: free memory, CPU count, which toolchains this + devcontainer has. These set the concurrency limit and decide whether + executors need toolchain containers for other repos. + +## 2. Research the open questions + +- Libraries and frameworks: context7 first. +- Practices, standards, registry behavior: WebSearch/WebFetch, preferring + primary sources (official docs, RFCs, maintainers). Cite the URLs in the + plan. +- Anything outward-facing the plan will create (repo names, package names + on each registry): check it's free now, and say so in the plan. +- Registry and platform quirks that would stop an unattended run (a + registry with no pending trusted publishers needs a first-publish token; + a ruleset the agent can't apply; a permission rule that would prompt) + belong in the pre-flight runbook, found now rather than mid-run. + +## 3. Ask the user what isn't yours to decide + +Per `CLAUDE.md` ("Decide alone only where a common best practice +exists"): naming, public API shape, visual design, licensing and genuine +trade-offs go to the user. Ask them now, during planning, with +`AskUserQuestion` (2–4 options, trade-offs, your lean first and marked), +so the run doesn't stop for them later. Record each answer in the plan's +Decision log as "user decision". Decisions that can only be made after +research during execution become explicit gates in the plan instead. + +## 4. Write the plan + +Follow `docs/plans/template.md`. For a plan with more than one +independent work item, the Execution section needs everything +`docs/plans/README.md`'s "Execution section" lists, plus: + +- **Orchestrator and executor models**, and the **concurrency target**: + a rolling queue (start the next ready item whenever one finishes), with + limits inside it for memory-heavy work (e.g. compiled-language builds) + and zero parallel agents during measurements. +- **Worktrees:** `isolation: "worktree"` for this repo; for other repos, + one local clone at `.claude/worktrees/ext-` and one + `git worktree add` per item on `wip/`. +- **Toolchains:** where an executor runs another repo's checks (that + repo's own devcontainer image, joined to this devcontainer's services). +- **"Done when"** for every brief: the repo's own + `prek run --all-files --hook-stage manual`, run with new files staged + (`git add -A` first: some hooks skip untracked files). +- **Work items:** ID (unique across active plans), what it implements, + exact write set, dependencies. The first item in every repo adds all of + that repo's new dependencies and its lockfile, alone. Research items + are report-only and feed the Decision log. +- **Interfaces** between items running in parallel, as names and + signatures both sides code against. +- **Pre-assigned numbers** for ADRs, NFRs and migrations, per repo. +- **Serialized lanes** (verification, release, benchmarks, e2e). +- **User gates** table, and **standing approvals** (what the orchestrator + may do without asking: creating named repos, pushing branches, opening + PRs, triggering template releases). +- **Decision log** seeded with every decision made while planning. + +Keep the "why" short and link sources; put rationale that outlives the +plan into the ADRs the plan's items write. + +## 5. Write the pre-flight runbook (when the user has setup to do) + +If anything only the user can do must happen before kickoff (accounts, +registry publishers or tokens, org secrets, renaming repos, rebuilding the +devcontainer, local permission rules, machine resources), write +`docs/plans/-preflight.md` next to the plan: + +- **Checklist** at the top, one line per step. +- **Part 1, decisions:** each open question as tick boxes, recommended + option first, so the user answers everything once. +- **Part 2, setup:** per step, where to click or what to run (exact URLs, + field values, commands), and a **Check** line saying how to confirm it + worked. +- **Kickoff:** a fresh session, what to verify (`/agents`, `/mcp`, + `gh auth status`), and the exact message to send. +- A closing list of what the orchestrator will still come back for. + +The runbook belongs to the plan and is deleted with it. + +## 6. Verify and hand over + +Run the repo's checks on the new files and show the output. Close with a +commit message for the user (`CLAUDE.md`, "Report changes after +executing"); don't commit unless asked. diff --git a/.claude/skills/plan-execution/SKILL.md b/.claude/skills/plan-execution/SKILL.md new file mode 100644 index 0000000..a0eca5c --- /dev/null +++ b/.claude/skills/plan-execution/SKILL.md @@ -0,0 +1,123 @@ +--- +name: plan-execution +description: Execute a docs/plans/ plan, or any task that splits into several independent items, as the orchestrator - briefing plan-executor subagents (executors and landers) in their own worktrees, answering their escalations with researched decisions and running the user gates. Use when the user asks to execute or continue a plan. +--- + +# Plan execution (orchestrator) + +The main session orchestrates; it doesn't implement work items, review +diffs, run verification lanes or `/code-review`, merge or trigger +releases itself. It keeps the queue, writes briefs, researches and +decides escalations, raises user gates and keeps the plan's Status and +Decision log current. Executors and landers are `plan-executor` +subagents on the faster model by default: their side of the protocol +(brief contents, write-set rules, escalation triggers, lander mode, +report formats) is in `.claude/agents/plan-executor.md` and isn't +repeated here. Plan layout +is in `docs/plans/README.md`. + +## Before the first item + +1. Read the plan in full, its Decision log (settled; don't reopen) and + its pre-flight runbook if there is one. Confirm the kickoff gate (G0) + with the user unless the kickoff message grants it. +2. Note the plan's concurrency target, limits and standing approvals. + Anything outside the standing approvals is asked for individually. +3. Set up what items need: clones and worktrees for other repos, + toolchain images, the integration branch named in the plan. + +## The queue + +- Keep the plan's target number of agents (executors and landers + together) running. Waves define + dependencies, not batches: whenever an agent reports, start the + next **ready** item (dependencies landed, write set + disjoint from every running item), critical path first, then the item + that unblocks the most. +- Start each with the `Agent` tool, `subagent_type: "plan-executor"`, + the model the plan names, and `isolation: "worktree"` (this repo) or an + explicit worktree path (other repos), in the background. +- A brief holds: the goal quoted from the plan plus the Decision log + rows that apply, the exact write set, the interfaces to provide or rely + on, and "Done when". Nothing the executor would have to guess. +- Respect the plan's limits inside the target (e.g. one agent compiling + a heavy language at a time; no agents during a measurement window). + +## When an executor reports + +**`done`:** don't read the diff yourself. Start a lander for the item +(or for the repo's wave PR once its items are done): a `plan-executor` +run on the faster model unless the plan names another, briefed per "Lander mode" in +`.claude/agents/plan-executor.md` with the executor's report, the +original write set and "Done when", the integration branch and PR, the +verification lane's commands, and the standing approvals. At most one +lander per repo at a time. Then start the next ready item. + +**A lander's report:** `done` means landed: PR, merge commit (where it may +merge), CI, review rounds with dispositions, the lane tail and NOTES. +Record it in the plan's Status and start whatever it unblocks. A red lane +is the lander's to handle: it reverts the merge and resumes the owning +executor with the failing output. Treat what it escalates like an +executor's escalation below, with its evidence in hand. + +**`escalate`** (executor or lander): research before deciding: + +1. context7 for the library or framework involved; +2. WebSearch/WebFetch for the exact error text, the tool's issue tracker, + changelog and official docs (primary sources over blog posts); +3. this repo's ADRs, NFRs, READMEs and the plan's Decision log. + +Then: + +- **If the research converges** on a common best practice, or the plan, + an ADR or an NFR requires one option: decide, add a Decision log row + (decision, reason, source URLs), and **resume the same executor** with + `SendMessage`, quoting the row. Start a new executor only if the old + one's context is unusable. +- **If it doesn't** (sources disagree, a real trade-off, taste or + convention), it's the user's decision (`CLAUDE.md`). Ask with the + question in one sentence, 2–4 options with trade-offs, the sources and + why they don't settle it, and your lean labeled as a lean. Pause only + that item; keep the queue running. Record the answer as "user + decision". +- Scope changes, security or licensing trade-offs, deleting anything the + plan didn't create, and anything outside the standing approvals also go + to the user. +- If the same item escalates twice on the same approach, re-plan the item + (split it, change the approach, record why) instead of trying a third + variation. + +## Serialized lanes + +Landers run these, one at a time, never in parallel with each other where +they share resources; you sequence them through the queue: + +- **Verification:** after each merge into a repo's integration branch, + the repo's full suite including integration and e2e. Only one lander at + a time runs suites that share the devcontainer's services or fixed + ports, across all repos. On red the lander reverts that merge, sends the + failing output to the owning executor and keeps going; it escalates + only when the executor can't fix it inside its write set. +- **Releases**, in the plan's dependency order, by a lander, only where + the standing approvals allow; every other release is a gate you raise. +- **Measurements**, only in quiet windows with no other agents running. + +## Gates and the user + +- Landers never merge a PR the user merges and never act outside the + standing approvals; they escalate and you raise the gate. +- Batch same-kind gates in a wave (PR merges, releases) into one request. + Batch non-blocking questions too. +- Show evidence with every gate: it comes from lander reports (check and + lane output, CI links, review dispositions, release runs). If a report + lacks it, send the lander back for it rather than checking yourself. +- After each landed batch, add a dated progress bullet to the plan's + Status, so a fresh session can resume from the plan alone. + +## Close-out + +A final verification lane over every repo the plan touched, run by a +lander; fold what's +worth keeping into ADRs and docs; ask for the final gate; then delete the +plan (and its runbook) per `docs/plans/README.md`, and give the user a +commit message. diff --git a/.claude/skills/ui-verify/SKILL.md b/.claude/skills/ui-verify/SKILL.md new file mode 100644 index 0000000..2677482 --- /dev/null +++ b/.claude/skills/ui-verify/SKILL.md @@ -0,0 +1,79 @@ +--- +name: ui-verify +description: Verify a UI change visually. Starts the dev server, screenshots 3 viewports in light and dark, runs axe, compares with the stated design intent and iterates. Use after any UI change and before calling it done. +--- + +# UI verify + +A UI change is not done until you have looked at it. Loop: build -> +screenshot -> axe -> compare with intent -> fix -> repeat. + +## 1. Start the dev server + +```bash +pnpm dev --host --port 5173 --strictPort +``` + +`--host` so the browser container can reach it; a fixed port with +`--strictPort` so it fails instead of drifting. Use an IP address in the +URL the browser opens (Vite blocks unknown hostnames): the dev +container's own, from `hostname -i`. Run it in the background and wait +for `curl -sf http://127.0.0.1:5173/`. + +## 2. Screenshots: 3 viewports x light/dark, plus axe + +Viewports: 390 (phone), 834 (tablet), 1440 (desktop). Each in light and +dark: 6 screenshots per page. + +**Use the committed helper, not the Playwright MCP.** The Playwright MCP +attaches over CDP (`ws://selenium:4444/.../se/cdp`), which the stack's +Selenium Grid answers with HTTP 400, so it cannot drive that browser. Do +not start a second browser service. `shoot.mjs` instead talks W3C +WebDriver to the stack's existing Selenium, sets the viewport with a +CDP `Emulation.setDeviceMetricsOverride` command (Grid's `goog/cdp/execute` +endpoint works), switches the scheme through the `mui-mode` localStorage +key MUI persists, checks that `html[data-dark]` or `html[data-light]` +matches the requested scheme, injects `axe-core` (a transitive dependency of +`@axe-core/playwright`) and writes the PNGs: + +```bash +node .claude/skills/ui-verify/shoot.mjs http://:5173/ +# writes /-{390,834,1440}-{light,dark}.png and -axe.json +# exit 1 on any axe violation or if the wrong scheme was applied +``` + +`SELENIUM_URL` overrides the default `http://selenium:4444`. The Grid +has one session slot: if a run is killed, the stale session blocks the +next one for up to 5 minutes; delete it with `DELETE +http://selenium:4444/session/` (id from `/status`). + +Pages behind sign-in need the same flow driven first; extend the helper +or script the login before the loop. Signed-out pages are fine for a +first pass. + +Then look at every PNG (Read the image files). The Playwright MCP is fine +for anything that does not need the stack's Selenium (for example if it +is configured with its own local browser). + +## 3. Compare and iterate + +For each screenshot, check against the stated intent and +`docs/design/README.md`: + +- Field Ledger: paper surfaces, ink text, one vermilion action, 2px + radius, rules not shadows, Fraunces headings / Instrument Sans body. +- No anti-defaults crept in (default blue, shadows, Roboto, cards + everywhere, gradients). +- Layout: no wrapped or clipped controls or headings at 390, no + horizontal scroll, sensible use of space at 1440. +- Dark is not an inversion: contrast holds, accents are the lightened ones. +- Axe: zero violations. Fix real ones in the app; do not disable rules. + +Fix, re-run, and repeat until both the screenshots and axe are clean. + +## 4. Report + +In your final message list the 6 screenshot paths per page, the axe +summary per screenshot (violation ids and counts), what you compared +against, and what you changed or still see wrong. Never claim a pass +without having read the screenshots. diff --git a/.claude/skills/ui-verify/shoot.mjs b/.claude/skills/ui-verify/shoot.mjs new file mode 100644 index 0000000..2fd34c9 --- /dev/null +++ b/.claude/skills/ui-verify/shoot.mjs @@ -0,0 +1,102 @@ +// Screenshots one page at 3 viewports x light/dark through the stack's +// Selenium over W3C WebDriver, and runs axe-core on each. See SKILL.md. +// +// usage: node shoot.mjs [name] +// env: SELENIUM_URL (default http://selenium:4444) +import { createRequire } from "node:module"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + +const [url, outDir, name = "page"] = process.argv.slice(2); +if (!url || !outDir) { + console.error("usage: node shoot.mjs [name]"); + process.exit(2); +} +const hub = process.env.SELENIUM_URL ?? "http://selenium:4444"; +const viewports = [ + { label: "390", width: 390, height: 844 }, + { label: "834", width: 834, height: 1112 }, + { label: "1440", width: 1440, height: 900 }, +]; +const modes = ["light", "dark"]; + +// axe-core is a transitive dependency of @axe-core/playwright. +const require = createRequire(import.meta.url); +const axePath = createRequire(require.resolve("@axe-core/playwright")).resolve( + "axe-core/axe.min.js", +); +const axeSource = readFileSync(axePath, "utf8"); + +async function call(method, path, body) { + const res = await fetch(hub + path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} ${text.slice(0, 500)}`); + return JSON.parse(text).value; +} + +const created = await call("POST", "/session", { + capabilities: { + alwaysMatch: { + browserName: "chrome", + "goog:chromeOptions": { args: ["--hide-scrollbars"] }, + }, + }, +}); +const sid = created.sessionId; +const s = (p) => `/session/${sid}${p}`; +const run = (script, args = []) => call("POST", s("/execute/sync"), { script, args }); +mkdirSync(outDir, { recursive: true }); + +const schemeScript = + "var h=document.documentElement;return h.hasAttribute('data-dark')?'dark':h.hasAttribute('data-light')?'light':null"; + +const summary = []; +try { + for (const vp of viewports) { + // Emulate the viewport exactly; a window resize cannot go below the + // browser chrome's minimum width. + await call("POST", s("/goog/cdp/execute"), { + cmd: "Emulation.setDeviceMetricsOverride", + params: { width: vp.width, height: vp.height, deviceScaleFactor: 1, mobile: vp.width < 600 }, + }); + for (const mode of modes) { + await call("POST", s("/url"), { url }); + await run("localStorage.setItem('mui-mode', arguments[0])", [mode]); + await call("POST", s("/url"), { url }); + // Wait (up to 10 s) for the scheme to resolve, then for fonts. + for (let i = 0; i < 40; i++) { + const cur = await run(schemeScript); + if (cur === mode) break; + await new Promise((r) => setTimeout(r, 250)); + } + await call("POST", s("/execute/async"), { + script: "var done=arguments[arguments.length-1];document.fonts.ready.then(function(){done(true)})", + args: [], + }); + // With colorSchemeSelector "data", MUI sets an empty html[data-dark] or + // html[data-light]; a null scheme or a mismatch fails the run below. + const scheme = await run(schemeScript); + const png = await call("GET", s("/screenshot")); + const file = `${outDir}/${name}-${vp.label}-${mode}.png`; + writeFileSync(file, Buffer.from(png, "base64")); + const axe = await call("POST", s("/execute/async"), { + script: + axeSource + + ";var done=arguments[arguments.length-1];axe.run(document).then(function(r){done(r.violations.map(function(v){return {id:v.id,impact:v.impact,help:v.help,nodes:v.nodes.length,targets:v.nodes.slice(0,3).map(function(n){return n.target.join(' ')})}}))})", + args: [], + }); + summary.push({ file, mode, scheme, violations: axe }); + console.log(`${file} scheme=${scheme} axe violations=${axe.length}`); + for (const v of axe) console.log(` - [${v.impact}] ${v.id}: ${v.help} (${v.nodes} nodes) ${v.targets.join(" | ")}`); + } + } +} finally { + // Written even when a shot throws, so earlier results are kept. + writeFileSync(`${outDir}/${name}-axe.json`, JSON.stringify(summary, null, 2)); + await call("DELETE", s("")); +} +const bad = summary.some((r) => r.violations.length > 0 || r.scheme !== r.mode); +process.exit(bad ? 1 : 0); diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..3611eff --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1.28 +# Single-stage build: develop (the devcontainer image). Built with the repo +# root as context (see compose.yml). The repo-root Dockerfile is reserved for +# an instance's own runtime image -- see docs/TEMPLATE.md's "Contents" and +# "Instance extension points". + +ARG DEBIAN_VERSION=trixie + +# renovate: datasource=github-releases depName=j178/prek +ARG PREK_VERSION=0.5.5 + +# renovate: datasource=npm depName=@anthropic-ai/claude-code +ARG CLAUDE_CODE_VERSION=2.1.303 + +# renovate: datasource=github-releases depName=edouard-claude/snip +ARG SNIP_VERSION=0.25.2 + +# renovate: datasource=github-releases depName=cli/cli +ARG GH_VERSION=2.102.0 + +# uv itself isn't a project dependency manager here -- it's only the +# mechanism scripts/develop.sh uses to install an exact, checksum-verified +# CPython build (see PYTHON_VERSION below) rather than an unpinned apt +# package. Left on PATH afterward; harmless, and an instance that adds +# Python as its own application runtime can reuse it directly. +# renovate: datasource=github-releases depName=astral-sh/uv +ARG UV_VERSION=0.13.1 + +# python3 is infrastructure tooling, not an application runtime this +# template assumes: prek needs an interpreter to build the venv for any +# "language: python" hook (.pre-commit-config.yaml's pre-commit-hooks +# repo), and .github/scripts/*.py need one directly. +# renovate: datasource=python-version depName=python +ARG PYTHON_VERSION=3.14.8 + +# Node.js is infrastructure tooling too, not an application runtime: npx +# (the clear-thought MCP server in .mcp.json) is the only thing that +# needs it. +# renovate: datasource=node-version depName=node +ARG NODE_VERSION=24.22.0 + +ARG SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ARG SSL_CERT_DIR=/etc/ssl/certs + +######################################## +# develop — interactive devcontainer image, based on Microsoft's generic +# base devcontainer image. No *application* language runtime is assumed +# here — an instance adds its own (Python via uv, Rust via rustup, Go, +# Node.js, OpenTofu/Ansible, ...) on top of this stage, following the same +# ARG/scripts/develop.sh pattern established below. The Python and Node.js +# this stage does install are infrastructure tooling only -- see the ARG +# comments above. +######################################## +FROM mcr.microsoft.com/devcontainers/base:${DEBIAN_VERSION} AS develop +ARG PREK_VERSION +ARG CLAUDE_CODE_VERSION +ARG SNIP_VERSION +ARG UV_VERSION +ARG PYTHON_VERSION +ARG NODE_VERSION +ARG GH_VERSION +ARG SSL_CERT_FILE +ARG SSL_CERT_DIR + +ENV SSL_CERT_FILE=${SSL_CERT_FILE} \ + SSL_CERT_DIR=${SSL_CERT_DIR} \ + REQUESTS_CA_BUNDLE=${SSL_CERT_FILE} \ + CURL_CA_BUNDLE=${SSL_CERT_FILE} + +COPY scripts/develop.sh /tmp/develop.sh +RUN bash /tmp/develop.sh "$PREK_VERSION" "$CLAUDE_CODE_VERSION" "$SNIP_VERSION" "$UV_VERSION" "$PYTHON_VERSION" "$NODE_VERSION" "$GH_VERSION" + +# Instance hook: scripts/develop.d/*.sh, see that directory's README.md. +COPY scripts/develop.d/ /tmp/develop.d/ +RUN for f in /tmp/develop.d/*.sh; do [ -e "$f" ] || continue; echo "==> $f"; bash "$f" || exit 1; done + +USER vscode +WORKDIR /workspace +CMD ["sleep", "infinity"] diff --git a/.devcontainer/Dockerfile.dockerignore b/.devcontainer/Dockerfile.dockerignore new file mode 100644 index 0000000..3aeee72 --- /dev/null +++ b/.devcontainer/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +# Build context excludes for the devcontainer image (.devcontainer/Dockerfile). +# BuildKit reads .dockerignore next to the Dockerfile in place of +# the context root's .dockerignore, which stays free for an instance's own +# runtime image. +.git +.github +.devcontainer +.secrets +docs +README.md +CLAUDE.md diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 8108e60..0a1b9fb 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -3,8 +3,12 @@ - `devcontainer.json` — references `compose.yml` and `compose.instance.yml` as the devcontainer's `dockerComposeFile`, and configures the devcontainer itself (features, mounts, editor settings). -- `compose.yml` — the dev service (`myapp`), built from the top-level - `Dockerfile`'s `develop` stage. An instance that needs backing services +- `Dockerfile` — the devcontainer image (a single `develop` stage), built + with the repo root as context; `Dockerfile.dockerignore` holds its + build-context excludes. The top-level `Dockerfile` is reserved for an + instance's runtime image (`../docs/TEMPLATE.md`). +- `compose.yml` — the dev service (`myapp`), built from `Dockerfile`'s + `develop` stage. An instance that needs backing services (a database, a queue, ...) adds its own `stack/` directory (one subdirectory per service, each with its own compose fragment) and an `include:` list in `compose.instance.yml` pointing at them — see `stack/README.md`'s @@ -119,7 +123,7 @@ step there. Still worth doing once the container is back: - Run `prek run --all-files --hook-stage manual` — the same check-suite that gates CI (`checks.yml`) — to confirm whatever motivated the - rebuild (a `Dockerfile`/`scripts/develop.sh` change, a new tool) works + rebuild (a `.devcontainer/Dockerfile`/`scripts/develop.sh` change, a new tool) works end-to-end inside the container, not just that the image built. - If the change added, removed, or reordered a `mounts` entry, confirm it actually took effect — mounts apply only at container creation, not @@ -129,7 +133,7 @@ step there. Still worth doing once the container is back: picked up the new list. A plain "Reopen in Container" (no rebuild) is enough for changes outside -`.devcontainer/`, `Dockerfile`, and `scripts/develop.sh` — those only +`.devcontainer/` and `scripts/develop.sh` — those only need the window reconnected, not a fresh image. ## Do @@ -143,18 +147,18 @@ need the window reconnected, not a fresh image. - Commit `devcontainer-lock.json` in the same change as any feature edit in `devcontainer.json`. Regenerate it with `npx @devcontainers/cli upgrade --workspace-folder .`, or by - rebuilding the devcontainer. Renovate bumps feature versions but - never regenerates this file, so its feature-bump PRs fail the - `devcontainer-lock` check (and don't automerge) until someone - pushes the regenerated lockfile to the Renovate branch. + rebuilding the devcontainer. Feature versions are bumped by hand, + not by Renovate: Renovate can't regenerate this file, so + `../.github/renovate.json` turns its devcontainer feature updates + off rather than let them merge with a stale lockfile. ## Don't - Bind- or volume-mount a language runtime's dependency tree, or anything else you don't want a host antivirus/DLP tool scanning or syncing — it lives purely in the container's writable layer instead. (`claude-config` - is a deliberate, narrow exception — see `compose.yml`'s comment on that - volume.) + and `gh-config` are deliberate, narrow exceptions — small login and + session state; see `compose.yml`'s comments on those volumes.) - Add a `ports:` mapping or a `networks:` block anywhere here. - Pin a feature or image version as `latest` — pin the exact version so Renovate can bump it deliberately. diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml index b7d7d32..98c1cad 100644 --- a/.devcontainer/compose.yml +++ b/.devcontainer/compose.yml @@ -1,5 +1,5 @@ -# Dev service: the devcontainer itself, built from the top-level -# Dockerfile's `develop` stage. The workspace is bind-mounted so source +# Dev service: the devcontainer itself, built from .devcontainer/Dockerfile's +# `develop` stage, with the repo root as build context. The workspace is bind-mounted so source # edits are picked up without a rebuild. An instance that adds backing # services (a database, a queue, ...) adds its own `include:` list to # compose.instance.yml instead, pointing at its own `stack/*/compose.yml` @@ -12,7 +12,7 @@ services: myapp: build: context: .. - dockerfile: Dockerfile + dockerfile: .devcontainer/Dockerfile target: develop volumes: - ..:/workspace:cached @@ -23,6 +23,7 @@ services: # README for its specific env var/path convention once an instance # adds one. - claude-config:/home/vscode/.claude + - gh-config:/home/vscode/.config/gh command: sleep infinity # No ports: mapping and no networks: block — see .devcontainer/ # README.md's "Don't". An instance that needs host access adds @@ -36,3 +37,6 @@ volumes: # runtime's dependency tree, so the DLP/malware-scanning concern above # doesn't apply the same way here. claude-config: + # Persists the GitHub CLI's login (`gh auth login`) across rebuilds, for + # the same reason. + gh-config: diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 5dc5aa1..8515835 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,9 +1,9 @@ { "features": { - "ghcr.io/devcontainers/features/docker-in-docker:4.1.1": { - "version": "4.1.1", - "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:2b44bcb32e75d5a3c028479f7f957cf7d5e42d81761373025a8d501dd3748f88", - "integrity": "sha256:2b44bcb32e75d5a3c028479f7f957cf7d5e42d81761373025a8d501dd3748f88" + "ghcr.io/devcontainers/features/docker-in-docker:4.1.3": { + "version": "4.1.3", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:fae40f3c8be5a7c3b0db344c1d30341bc2c91ba38d1d5b14cd2ad72e6d65d084", + "integrity": "sha256:fae40f3c8be5a7c3b0db344c1d30341bc2c91ba38d1d5b14cd2ad72e6d65d084" } } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e32e04f..ad24269 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ "workspaceFolder": "/workspace", "shutdownAction": "stopCompose", "features": { - "ghcr.io/devcontainers/features/docker-in-docker:4.1.1": { + "ghcr.io/devcontainers/features/docker-in-docker:4.1.3": { "moby": false, "dockerDashComposeVersion": "v2" } diff --git a/.dockerignore b/.dockerignore index 1c107c1..177001b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,27 @@ -# Build context excludes for `docker build` (see top-level Dockerfile). +# Build context excludes for the runtime image (top-level Dockerfile). The +# devcontainer image has its own: .devcontainer/Dockerfile.dockerignore. +**/node_modules +coverage +target .git .github .devcontainer .secrets +.vscode +.claude docs +tests +storybook-static +playwright-report +test-results README.md CLAUDE.md -# Node (app runtime): the devcontainer build never needs these, and -# node_modules alone would bloat its build context. app.Dockerfile's own -# build uses app.Dockerfile.dockerignore instead. +# Node (app runtime): the builder stage runs its own `pnpm install` / +# `pnpm build`; without these, `COPY . .` (after that install) would overwrite +# the container's node_modules with the host's, which may not match its +# platform/arch. node_modules +.pnpm-store +coverage dist diff --git a/.github/CONTENTS.md b/.github/CONTENTS.md index 8095b82..ae77239 100644 --- a/.github/CONTENTS.md +++ b/.github/CONTENTS.md @@ -5,14 +5,22 @@ `README.md`. - `renovate.json` — weekly update PRs for GitHub Actions, every Dockerfile/compose image tag repo-wide (scanned recursively, unlike - Dependabot), and the `# renovate:` comment-annotated version pins in the - Dockerfile. An instance's own `renovate.json` additions cover its + Dependabot), and the `# renovate:` comment-annotated version pins in + `.devcontainer/Dockerfile` (and an instance's top-level runtime + `Dockerfile`). `Makefile` and `image.mk` `*_VERSION` pins carry the same + marker; the hadolint image in `.pre-commit-config.yaml`, the Trivy + image in `scripts/image-scan.sh` and the skopeo image in the `Makefile` + (the last two by tag and digest) have their own regex managers. An instance's own `renovate.json` additions cover its language's own package manifest (e.g. `pyproject.toml`, `Cargo.toml`, `package.json`). All patch/minor updates are grouped into a single weekly PR that automerges once required checks pass; all major updates - are grouped into a separate PR for manual review. Devcontainer feature - bumps are the exception: they need a manual lockfile regeneration - first (see `../.devcontainer/README.md`). + are grouped into a separate PR for manual review. A Python minor + upgrade (e.g. 3.14 to 3.15) also gets its own PR for manual review: + it passes this repo's checks, but for an instance running Python as + its app runtime it's a runtime upgrade (its runtime `Dockerfile` must + match, and its tools must support the new version). Devcontainer + features are left out: Renovate can't regenerate their lockfile, so + they're bumped by hand (see `../.devcontainer/README.md`). - `ISSUE_TEMPLATE/` — issue forms (bug report, feature request) shown when opening a new issue. - `PULL_REQUEST_TEMPLATE.md` — prefills the description box for new PRs. diff --git a/.github/renovate.json b/.github/renovate.json index 64ef019..ec75634 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -11,13 +11,28 @@ { "matchUpdateTypes": ["major"], "groupName": "major updates" + }, + { + "description": "A Python minor (3.14 -> 3.15) is a runtime upgrade for every instance that runs Python as its app runtime: its own runtime Dockerfile must move in step, and its tools must support the new version. Keep it out of the automerged group, as its own PR for manual review.", + "matchDatasources": ["python-version"], + "matchDepNames": ["python"], + "matchUpdateTypes": ["minor"], + "groupName": "python minor upgrade", + "automerge": false + }, + { + "description": "Renovate can't regenerate .devcontainer/devcontainer-lock.json, so features are bumped by hand together with it (see .devcontainer/README.md).", + "matchManagers": ["devcontainer"], + "matchDepTypes": ["feature"], + "enabled": false } ], "customManagers": [ { "customType": "regex", "managerFilePatterns": [ - "/^(app\\.)?Dockerfile$/" + "/^Dockerfile$/", + "/^\\.devcontainer/Dockerfile$/" ], "matchStrings": [ "# renovate: datasource=(?.*?) depName=(?.*?)\\s*\\nARG \\w+_VERSION=(?.*)\\s" @@ -50,6 +65,53 @@ "matchStrings": [ "# renovate: datasource=(?.*?) depName=(?.*?)\\s*\\n\\s*(?:runs-on|runner): \\$\\{\\{ vars\\.[A-Z0-9_]+ \\|\\| '(?[^']+)' \\}\\}" ] + }, + { + "description": "Pinned tool versions in the image-release Makefile and image.mk.", + "customType": "regex", + "managerFilePatterns": [ + "/^Makefile$/", + "/^image\\.mk$/" + ], + "matchStrings": [ + "# renovate: datasource=(?.*?) depName=(?.*?)\\s*\\n[A-Z0-9_]+_VERSION\\s*:?=\\s*(?\\S+)" + ] + }, + { + "description": "The skopeo image pinned by tag and digest in the Makefile; Renovate updates both.", + "customType": "regex", + "managerFilePatterns": [ + "/^Makefile$/" + ], + "matchStrings": [ + "SKOPEO_IMAGE := quay\\.io/skopeo/stable:(?v[0-9][^@\\s]*)@(?sha256:[a-f0-9]{64})" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "quay.io/skopeo/stable" + }, + { + "description": "The hadolint image pinned in the hadolint hook.", + "customType": "regex", + "managerFilePatterns": [ + "/^\\.pre-commit-config\\.yaml$/" + ], + "matchStrings": [ + "entry: hadolint/hadolint:(?\\S+) hadolint" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "hadolint/hadolint" + }, + { + "description": "The Trivy image pinned by tag and digest in scripts/image-scan.sh; Renovate updates both.", + "customType": "regex", + "managerFilePatterns": [ + "/^scripts/image-scan\\.sh$/" + ], + "matchStrings": [ + "trivy=aquasec/trivy:(?[0-9][^@\\s]*)@(?sha256:[a-f0-9]{64})" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "aquasec/trivy" } ] } diff --git a/.github/template-sync-manifest.yml b/.github/template-sync-manifest.yml index cf5f88f..6e634f8 100644 --- a/.github/template-sync-manifest.yml +++ b/.github/template-sync-manifest.yml @@ -10,18 +10,16 @@ # wants a template-owned path gone for good should move its pattern to # "ignore" in a template release rather than deleting the file locally. # -# This is template-react's own manifest, classifying its full tracked-file -# set for its instances (paralleling template-fastapi's for the backend). -# template-react is itself an instance of template-base, whose manifest is -# "merge" tier, so this file keeps its React-specific entries across base -# syncs and takes base's additions by hand. It is "merge" tier here too: a -# repo that's both an instance of template-react and its own template for -# further instances edits this file directly and resolves this file's -# additions by hand. +# This started as template-docker's manifest (itself template-base's, grown): +# template-react is an instance of template-docker and its own template (the +# React app layer) for further instances, so it edits this file directly, +# covering its own (larger) tracked-file set, and resolves template-docker's +# additions by hand. Paths template-docker classifies "ignore" because an +# instance owns them (Dockerfile, image.mk, the hook scripts) are classified +# here for template-react's own instances instead. # # An instance that isn't itself a template doesn't edit this file; it lists -# its own files (e.g. extra hook scripts) in -# ../template-sync-manifest.local.yml instead -- +# its own extra files in ../template-sync-manifest.local.yml instead -- # same format, "ignore" tier only, read by the completeness check but never # by sync. # @@ -35,6 +33,7 @@ replace: # them, so a sync can overwrite them safely. - .claude/hooks/self-check.sh - .claude/skills/ + - .claude/agents/ - .devcontainer/compose.yml - .devcontainer/devcontainer-lock.json - .devcontainer/resolve-ssh-agent.sh @@ -57,7 +56,8 @@ replace: - .github/PULL_REQUEST_TEMPLATE.md # Build/dependency/hook plumbing, all instance-agnostic. - - Dockerfile + - .devcontainer/Dockerfile + - .devcontainer/Dockerfile.dockerignore - .gitattributes - scripts/develop.sh - scripts/post-create.sh @@ -79,21 +79,35 @@ replace: - docs/plans/README.md - docs/plans/template.md - # template-react's own layer on top of base: the runtime image and its - # assets, the release Makefile, the dev-service compose additions, the + # template-docker's own layer on top of base: the image-release Makefile + # (identical for every image-shipping app; instances customise it only + # through variables in image.mk), the smoke test, and the runner-setup + # driver. Hook scripts are listed one by one, not as globs: an instance's + # own scripts/runner-setup.d/*.sh are ignore tier (below). + # Same precedent as template-python-package: the Makefile implements the + # release contract identically for every instance, so it's template-owned. + - Makefile + - scripts/image-smoke.sh + - scripts/runner-setup.sh + - scripts/runner-setup.d/README.md + - scripts/check-dockerfile-versions.sh + - scripts/image-scan.sh + - scripts/skopeo.sh + - scripts/test-image-scripts.sh + + # template-react's own layer on top of docker's: the runtime image (an + # nginx-served SPA) and its assets, the dev-service compose additions, the # hook scripts, and the Vite/TypeScript/lint/format plumbing -- an # instance changing these is changing the build tooling, not its app. - # Listed one by one where base's hook directories are "ignore" for plain - # base instances -- an instance's own extra hook scripts go in its + # Listed one by one where docker's hook directories are "ignore" for plain + # docker instances -- an instance's own extra hook scripts go in its # template-sync-manifest.local.yml. - - app.Dockerfile - - app.Dockerfile.dockerignore - - Makefile + - Dockerfile - .devcontainer/compose.instance.yml - scripts/docker/ - - scripts/check-dockerfile-versions.sh - scripts/develop.d/10-corepack.sh - scripts/post-create.d/10-pnpm-install.sh + - scripts/check-design-tokens.sh - .vscode/launch.json - .prettierignore - .oxlintrc.json @@ -103,11 +117,41 @@ replace: - vite.config.ts - vitest.config.ts - index.html + # Storybook and the visual-regression suite (ADR 0005). Generic: the story + # list comes from the build. .storybook/stubs.tsx is not here, it is + # instance content (below). + - .storybook/main.ts + - .storybook/preview.tsx + - .storybook/README.md + - tests/visual/README.md + - tests/visual/playwright.config.ts + - tests/visual/serve.mjs + - tests/visual/stories.visual.ts + - tests/visual/run.sh + - .github/workflows/visual.yml ignore: # Each instance chooses its own license and copyright holder. - LICENSE + # Derived from package.json, which is merge-tier -- a 3-way merge of a + # lock file is unreliable, so an instance regenerates it locally with + # `pnpm install` after a sync PR changes package.json. + - pnpm-lock.yaml + + # The app itself: the generic CRUD client and Heroes example are a + # starting point an instance owns from the moment it's instantiated, so a + # sync never overwrites its code or static assets. + - src/ + + # Story fakes for this app's API and auth (preview.tsx imports its + # exports), and the baselines rendered from this app's stories. + - .storybook/stubs.tsx + - tests/visual/__screenshots__/ + + # Env-var documentation for the instance's own deployment. + - .env.example + # Product/domain knowledge and working plans: instance-owned from the # moment it's instantiated. - docs/architecture.md @@ -115,6 +159,7 @@ ignore: - docs/glossary.md - docs/stakeholders.md - docs/adrs/[0-9]*.md + - docs/design/ - docs/frs/FR-*.md - docs/nfrs/NFR-*.md - docs/plans/[0-9]*.md @@ -122,18 +167,10 @@ ignore: # Instance-owned bookkeeping, written only by the sync workflow itself. - .github/template-sync-state.json - # The app itself: the generic CRUD client and Heroes example are a - # starting point an instance owns from the moment it's instantiated, so a - # sync never overwrites its code or static assets. - - src/ - - # Env-var documentation for the instance's own deployment. - - .env.example - - # Derived from package.json, which is merge-tier -- a 3-way merge of a - # lock file is unreliable, so an instance regenerates it locally with - # `pnpm install` after a sync PR changes package.json. - - pnpm-lock.yaml + # Hook files an instance drops into runner-setup.d/. (develop.d/ and + # post-create.d/ scripts are template-react's own, listed under replace; + # an instance's extra ones go in its local manifest.) + - scripts/runner-setup.d/*.sh # An instance's own extra "ignore" patterns for paths this manifest can't # know about (see docs/TEMPLATE.md's "Template sync" section). @@ -144,6 +181,14 @@ merge: # instance; everything else in these two files is template-owned. - README.md - package.json + - pnpm-workspace.yaml + + # template-react's image.mk values (smoke port/path, release-check's unit + # tests); an instance adds its own on top. + - image.mk + + # Baseline excludes the instance extends with its own entries. + - .dockerignore # Files with no include mechanism of their own (JSON configs, prek's # .pre-commit-config.yaml), and docs an instance appends to: a three-way @@ -160,7 +205,6 @@ merge: - .pre-commit-config.yaml - .mcp.json - .gitignore - - .dockerignore - scripts/README.md - docs/TEMPLATE.md diff --git a/.github/template-sync-state.json b/.github/template-sync-state.json new file mode 100644 index 0000000..3edc349 --- /dev/null +++ b/.github/template-sync-state.json @@ -0,0 +1,4 @@ +{ + "template_repo": "craftainer/template-docker", + "synced_tag": "v1.2.0" +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 5314c03..885b702 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -6,15 +6,6 @@ [`devcontainers/ci`](https://github.com/devcontainers/ci)), under a per-run `COMPOSE_PROJECT_NAME` that a final step tears down. Runs as a two-leg `amd64`/`arm64` matrix (see "Architecture matrix" below). -- `smoke.yml` — builds the `runner` stage of the root `Dockerfile` via - the root `compose.yml`, starts it against the real Postgres, RustFS, - Redis, and Keycloak backing services, and confirms `/health/live` and - `/health/ready` both return 200 -- on push, on pull requests, and on - demand. Runs as the same two-leg `amd64`/`arm64` matrix as - `checks.yml` — this is the check that would actually catch an - arch-specific runtime break (e.g. a C-extension dependency missing an - arm64 wheel), since `checks.yml`'s test run uses `MODE=mock` - in-process fakes for some paths rather than the built image. - `release.yml` — manually triggered. Takes a release channel (`alpha`/`beta`/`rc`/`full`) and a SemVer 2 bump (`major`/`minor`/`patch`/`none`), computes the next tag via @@ -26,6 +17,10 @@ `dist/` as `release-assets-`; then one job downloads them all, runs `make release-check` inside the devcontainer and `make finalize` on the host, and creates a GitHub release with everything in `dist/`. +- `visual.yml` — template-react only: builds Storybook and runs the + Playwright screenshot and axe suite (`tests/visual/`) inside the pinned + Playwright image on the `arm64` runner, uploading the HTML report on + failure (see `docs/adrs/0005-storybook-visual-regression-and-axe.md`). - `moderate-bug-triage.yml` / `moderate-bug-fix.yml` / `moderate-bug-fix-apply.yml` / `moderate-feature-triage.yml` / `moderate-feature-build.yml` / `moderate-feature-build-apply.yml` / @@ -44,7 +39,7 @@ e.g. to point at self-hosted runners. ## Architecture matrix -`checks.yml`, `smoke.yml`, and `release.yml`'s `build` job each run as a +`checks.yml` and `release.yml`'s `build` job each run as a two-leg matrix over `amd64`/`arm64`, both legs building/testing natively — `ubuntu-24.04-arm` is a real (non-emulated) GitHub-hosted Linux arm64 runner, free on this public repo and billed as ordinary Actions minutes @@ -61,7 +56,7 @@ still overrides the single-runner jobs (`perf.yml`, `template-sync.yml`, and `release.yml`'s `version`/`manifest`/`release` jobs) that aren't matrixed by arch. -The devcontainer used for local development (`Dockerfile`'s `develop` +The devcontainer used for local development (`.devcontainer/Dockerfile`'s `develop` stage) is deliberately *not* pinned to a single platform — plain `docker build`/`docker compose build` already targets whichever architecture the host is on, so an arm64 contributor gets a native @@ -164,7 +159,7 @@ branch/PR may already be gone via cleanup. **Environment.** All four Claude-invoking jobs run `claude` the same way `checks.yml` runs `prek`: inside the actual devcontainer (`develop` stage) via `devcontainers/ci`, so Claude runs the exact CLI build pinned -in `Dockerfile`'s `CLAUDE_CODE_VERSION`. Each writes the +in `.devcontainer/Dockerfile`'s `CLAUDE_CODE_VERSION`. Each writes the `ANTHROPIC_API_KEY` secret to `.secrets/claude.txt` first (matching `.secrets/README.md`'s "one file per secret" convention) and removes it in an `if: always()` step; `../scripts/run_claude.sh` reads it, sets a @@ -233,20 +228,27 @@ workflow works with no registry configured at all. When it is set: ## Image labels -Both copies of the published image (the OCI tarball and, if configured, -the registry push) carry the same label set, computed once by -`docker/metadata-action`: - -- Standard `org.opencontainers.image.title` / `.description` / `.url` / - `.source` / `.revision` (`github.sha`) / `.created` / `.version`, - derived from git/GitHub context. -- `io.github..sbom` and - `io.github..coverage-report` — URLs to that - release's SBOM and `coverage.xml` assets. Namespaced under the repo - owner (not `org.opencontainers.image.*`, which is reserved for the - spec's own keys) and derived from `github.repository_owner` alone, so - it's one stable prefix per org across every repo/template instance - that org owns, rather than a per-repo namespace to look up each time. +The image is built once, with the label set computed by the Makefile's +`LABELS` (not by `docker/metadata-action`). The registry copy is that same +tar pushed with skopeo (`make publish`), not a rebuild, so the labels and +the digest are those of the tested artifact: + +- `org.opencontainers.image.title` (the repository name), `.source` and + `.url` (the repository), `.version` (`RELEASE_VERSION`), `.revision` + (`git rev-parse HEAD`), `.created` (the commit time, not the build + time), and `.licenses` (`OCI_LICENSE`, + default `GPL-3.0-or-later`). +- `org.opencontainers.image.description`, only when the instance sets + `OCI_DESCRIPTION` in `image.mk` (the Makefile has no offline source + for the repository description). +- `io.github..sbom` — the URL of that release's SPDX + SBOM asset. Namespaced under the repo owner (not + `org.opencontainers.image.*`, which is reserved for the spec's own + keys) and derived from the repository owner alone, so it's one stable + prefix per org across every repo/template instance that org owns. +- `io.github..coverage-report`, only when the instance + sets `COVERAGE_REPORT` in `image.mk` to the release asset's name. +- Whatever the instance adds through `EXTRA_LABELS`. ## Release variables and secrets @@ -261,12 +263,31 @@ cleanly when the registry isn't configured. - `CI_RUNNER_AMD64` / `CI_RUNNER_ARM64` (variables, optional) — runner labels for each arch leg, defaulting to `ubuntu-24.04` / `ubuntu-24.04-arm`. +- `PYPI_PUBLISH` (variable, optional) — `testpypi` or `pypi`; selects the + index `make publish` uploads to via trusted publishing. Unset skips it. +- `NPM_PUBLISH` / `CARGO_PUBLISH` (variables, optional) — non-empty makes + `make publish` push to npm / crates.io; unset skips it. Passed to + `build` and, like `PYPI_PUBLISH`, forwarded to `release-check` so it + can install what publish uploaded. +- `NPM_TOKEN` / `CARGO_REGISTRY_TOKEN` (secrets, optional) — bootstrap + tokens for the first release of a package, before trusted publishing + is configured. Exposed only on the `make publish` step's `env:`, never + job-wide or to any other step. An unset secret arrives as an empty + string, so the Makefile must treat empty as absent. `NPM_TOKEN` is + deliberately not mapped to `NODE_AUTH_TOKEN`: npm's OIDC trusted + publishing breaks when that variable is set, even to an empty string, + so the npm template's Makefile decides what to do with `NPM_TOKEN`. ## Do - Give `release.yml` and `template-sync.yml` `contents: write` and nothing broader (`template-sync.yml` also needs `pull-requests: write`, for nothing else); leave `checks.yml` at `contents: read`. +- Keep `id-token: write` on `release.yml`'s `build` job only (the one + running `make publish`): PyPI, npm and crates.io trusted publishing + via GitHub OIDC. It is unused by repos that publish to none of them. +- Keep `NPM_TOKEN` and `CARGO_REGISTRY_TOKEN` on the `make publish` + step's `env:` only. ## Don't diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cd4e8ac..45770a3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,6 +47,7 @@ jobs: uses: docker/build-push-action@v7.4.0 with: context: . + file: .devcontainer/Dockerfile target: develop cache-from: type=gha,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }} diff --git a/.github/workflows/moderate-bug-fix-apply.yml b/.github/workflows/moderate-bug-fix-apply.yml index 91000d5..415c7c2 100644 --- a/.github/workflows/moderate-bug-fix-apply.yml +++ b/.github/workflows/moderate-bug-fix-apply.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Download moderation result - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@v8.0.2 with: name: moderation-result run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/moderate-bug-fix.yml b/.github/workflows/moderate-bug-fix.yml index f9a1442..10e674c 100644 --- a/.github/workflows/moderate-bug-fix.yml +++ b/.github/workflows/moderate-bug-fix.yml @@ -111,7 +111,7 @@ jobs: - name: Upload result artifact if: always() - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7.0.2 with: name: moderation-result path: moderation-result diff --git a/.github/workflows/moderate-feature-build-apply.yml b/.github/workflows/moderate-feature-build-apply.yml index 6bc7e55..f4b95a0 100644 --- a/.github/workflows/moderate-feature-build-apply.yml +++ b/.github/workflows/moderate-feature-build-apply.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Download moderation result - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@v8.0.2 with: name: moderation-result run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/moderate-feature-build.yml b/.github/workflows/moderate-feature-build.yml index a91d2b4..297579c 100644 --- a/.github/workflows/moderate-feature-build.yml +++ b/.github/workflows/moderate-feature-build.yml @@ -111,7 +111,7 @@ jobs: - name: Upload result artifact if: always() - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7.0.2 with: name: moderation-result path: moderation-result diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2946d03..9c210a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,14 @@ jobs: fail-fast: false matrix: arch: ${{ fromJSON(needs.version.outputs.arches) }} + # Job-level permissions replace the workflow-level ones, so + # `contents: write` is repeated. `id-token: write` lets `make publish` + # mint a GitHub OIDC token for PyPI, npm and crates.io trusted + # publishing; repos that publish to none of them never request one, + # so it is unused for them. + permissions: + contents: write + id-token: write # renovate: datasource=github-runners depName=ubuntu runs-on: ${{ matrix.arch == 'arm64' && (vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm') || (vars.CI_RUNNER_AMD64 || 'ubuntu-24.04') }} env: @@ -78,6 +86,15 @@ jobs: OCI_IMAGE_NAME: ${{ vars.OCI_IMAGE_NAME }} OCI_REGISTRY_USERNAME: ${{ secrets.OCI_REGISTRY_USERNAME }} OCI_REGISTRY_PASSWORD: ${{ secrets.OCI_REGISTRY_PASSWORD }} + # Package repos only: `testpypi` or `pypi` selects the index + # `make publish` uploads to via trusted publishing; unset skips it. + PYPI_PUBLISH: ${{ vars.PYPI_PUBLISH }} + # Package repos only: any non-empty value makes `make publish` push + # to npm / crates.io; unset skips it. The bootstrap secrets + # (NPM_TOKEN, CARGO_REGISTRY_TOKEN) are NOT here -- see the Publish + # step. + NPM_PUBLISH: ${{ vars.NPM_PUBLISH }} + CARGO_PUBLISH: ${{ vars.CARGO_PUBLISH }} GITHUB_TOKEN: ${{ github.token }} steps: - name: Check out repository @@ -105,11 +122,18 @@ jobs: - name: Populate release assets run: make release-assets + # The only step that sees the registry bootstrap tokens. Unset + # secrets arrive as empty strings, which the Makefile must treat as + # absent. Never map NPM_TOKEN to NODE_AUTH_TOKEN: npm's OIDC trusted + # publishing breaks when NODE_AUTH_TOKEN is set, even if empty. - name: Publish + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: make publish - name: Upload release assets - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7.0.2 with: name: release-assets-${{ matrix.arch }} path: dist/ @@ -124,7 +148,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Download release assets - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@v8.0.2 with: pattern: release-assets-* merge-multiple: true @@ -143,11 +167,18 @@ jobs: env: RELEASE_VERSION: ${{ needs.version.outputs.version }} RELEASE_TAG: ${{ needs.version.outputs.tag }} + # So a package's release-check can also install what publish uploaded. + PYPI_PUBLISH: ${{ vars.PYPI_PUBLISH }} + NPM_PUBLISH: ${{ vars.NPM_PUBLISH }} + CARGO_PUBLISH: ${{ vars.CARGO_PUBLISH }} with: runCmd: make release-check env: | RELEASE_VERSION RELEASE_TAG + PYPI_PUBLISH + NPM_PUBLISH + CARGO_PUBLISH # devcontainers/ci starts the full compose stack but never tears it # down -- see checks.yml's identical step. diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml new file mode 100644 index 0000000..7053fe9 --- /dev/null +++ b/.github/workflows/visual.yml @@ -0,0 +1,47 @@ +# Visual regression and axe over the built Storybook (ADR 0005). Runs inside +# the same pinned Playwright image as tests/visual/run.sh, on arm64: the +# committed baselines were rendered by that image on arm64, and a different +# CPU architecture can differ at the pixel level. +name: Visual + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + visual: + # renovate: datasource=github-runners depName=ubuntu + runs-on: ${{ vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm' }} + # Keep the tag equal to @playwright/test in package.json, and the tag + # and digest equal to IMAGE in tests/visual/run.sh. + container: + image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:7852dc5a7d72f5fe458a597d690f6c42fbc8e9e26ddfa95f8e6ef552558d9bbf + options: --ipc=host + steps: + - name: Check out repository + uses: actions/checkout@v7.0.1 + + - name: Install dependencies + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Build Storybook + run: pnpm exec storybook build --quiet + + - name: Run visual and axe tests + run: pnpm exec playwright test --config tests/visual/playwright.config.ts + + # The report holds expected/actual/diff images for each failure. + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7.0.2 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 036e059..740d468 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,8 @@ lerna-debug.log* # Claude Code CLI: personal, per-user overrides — never the shared # .claude/settings.json. .claude/settings.local.json + +# Storybook build and Playwright output (ADR 0005) +storybook-static/ +playwright-report/ +test-results/ diff --git a/.mcp.json b/.mcp.json index bfbfce5..6b9ca9b 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,6 +3,10 @@ "clear-thought": { "command": "npx", "args": ["-y", "@waldzellai/clear-thought-onepointfive@0.2.1"] + }, + "mui-mcp": { + "command": "npx", + "args": ["-y", "@mui/mcp@0.1.8"] } } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa8d3d5..74b3c30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -94,6 +94,16 @@ repos: always_run: true stages: [pre-push, manual] + # Colors and sizes live only in src/theme/ (ADR 0004). The self-test + # runs the checker against fixtures so the hook itself can't rot. + - id: design-tokens + name: design-tokens (no raw colors or px outside src/theme/) + entry: bash -c 'bash scripts/check-design-tokens.sh --self-test && bash scripts/check-design-tokens.sh src' + language: system + pass_filenames: false + files: ^(src/.*\.(ts|tsx|css)|scripts/check-design-tokens\.sh)$ + stages: [pre-commit, manual] + - id: template-sync-manifest name: template-sync-manifest (every tracked path classified) entry: python3 .github/scripts/template_sync_manifest.py @@ -110,10 +120,54 @@ repos: pass_filenames: false stages: [pre-commit, manual] + # template-docker's image-layer hooks (docs/TEMPLATE.md's "Image layer"). + - repo: local + hooks: + # Official hadolint image, pinned (a regex manager in renovate.json + # tracks the tag); docker_image runs it with the repo mounted, so no + # host install is needed. Fails on warning and above, not info: the + # base-owned devcontainer Dockerfile trips info-level DL3066. There is no repo-wide + # DL3008 (pin apt package versions) ignore: where a pin would break rebuilds, add a + # per-line `# hadolint ignore=DL3008` with a reason. + - id: hadolint + name: hadolint (Dockerfile lint) + entry: hadolint/hadolint:v2.15.1 hadolint --failure-threshold warning + language: docker_image + files: ^(\.devcontainer/)?Dockerfile$ + stages: [pre-commit, manual] + + # The toolchain version ARGs must match between the devcontainer image + # and the runtime image. An instance appends its own ARG names (e.g. + # RUST_VERSION) to this entry. - id: check-dockerfile-versions - name: check-dockerfile-versions (Dockerfile and app.Dockerfile agree) - entry: scripts/check-dockerfile-versions.sh + name: check-dockerfile-versions (ARG defaults match across Dockerfiles) + entry: bash scripts/check-dockerfile-versions.sh DEBIAN_VERSION NODE_VERSION + language: system + # Also when the check itself, or this entry (the ARG list), changes. + files: ^((\.devcontainer/)?Dockerfile|scripts/check-dockerfile-versions\.sh|\.pre-commit-config\.yaml)$ + pass_filenames: false + stages: [pre-commit, manual] + + # Vulnerability scan of the built image tar for the current + # RELEASE_VERSION/RELEASE_ARCH in dist/ with Trivy (ADR 0001; the Trivy + # pin and why are in scripts/image-scan.sh). Manual stage only: it needs + # `make build` to have produced the tar and network for the + # vulnerability DB. Skips cleanly with no tar. + - id: image-scan + name: image-scan (Trivy, HIGH/CRITICAL with a fix) + entry: bash scripts/image-scan.sh + language: system + pass_filenames: false + always_run: true + stages: [manual] + + # Offline tests of the scripts and Makefile logic above (repo + # derivation, version-check edge cases, smoke-test failure on a missing + # tar); needs no Docker. + - id: image-scripts-test + name: image-scripts-test (Makefile and scripts/ behaviour) + entry: bash scripts/test-image-scripts.sh language: system - files: ^(app\.)?Dockerfile$ + files: ^(Makefile|scripts/(check-dockerfile-versions|image-scan|image-smoke|skopeo|test-image-scripts)\.sh)$ pass_filenames: false stages: [pre-commit, manual] diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 0000000..969c0da --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,22 @@ +# .storybook/ + +Storybook 10 configuration (ADR 0005). + +- `main.ts` — story globs (`src/**/*.stories.tsx`) and addons. +- `preview.tsx` — wraps every story in the app's `ThemeProvider`, adds the + light/dark toolbar switch (`theme` global) and installs the per-story fakes. +- `stubs.tsx` — the fakes: `parameters.auth` (`signedOut` | `signedIn` | + `loading`) and `parameters.api` (`health`, `heroes`), plus fixtures. This is + the instance-specific file; `preview.tsx` only needs `installApiStub`, + `StubAuthProvider` and `StubParameters` from it. + +## Do + +- Write a `*.stories.tsx` next to every component, covering its loading, + empty and error states, and signed-in and signed-out where auth matters. +- Use the theme (`sx`, `theme.vars`) in stories; the design-token lint covers + them. + +## Don't + +- Hit a real backend or Keycloak from a story. diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..43b8e39 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,10 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-a11y"], + framework: "@storybook/react-vite", + core: { disableTelemetry: true }, +}; + +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..156bb56 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,62 @@ +import { useEffect, type ReactNode } from "react"; +import { useColorScheme } from "@mui/material/styles"; +import type { Preview } from "@storybook/react-vite"; +import { ThemeProvider } from "../src/theme"; +import { installApiStub, StubAuthProvider, type StubParameters } from "./stubs"; + +type Scheme = "light" | "dark"; + +/** Pushes the toolbar's choice into MUI, which then sets the data attribute. */ +function SchemeSync({ + scheme, + children, +}: { + scheme: Scheme; + children: ReactNode; +}) { + const { setMode } = useColorScheme(); + useEffect(() => setMode(scheme), [scheme, setMode]); + return children; +} + +const preview: Preview = { + globalTypes: { + theme: { + description: "Color scheme", + toolbar: { + title: "Scheme", + icon: "circlehollow", + items: [ + { value: "light", title: "Light" }, + { value: "dark", title: "Dark" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { theme: "light" }, + decorators: [ + (Story, { globals, parameters }) => { + const { api, auth } = parameters as StubParameters; + installApiStub(api); + return ( + + + + + + + + ); + }, + ], + parameters: { + layout: "fullscreen", + controls: { disable: true }, + // Fails the story's a11y panel on any violation; the Playwright suite + // (tests/visual/) enforces serious and critical ones in CI. + a11y: { test: "error" }, + }, +}; + +export default preview; diff --git a/.storybook/stubs.tsx b/.storybook/stubs.tsx new file mode 100644 index 0000000..420b99a --- /dev/null +++ b/.storybook/stubs.tsx @@ -0,0 +1,120 @@ +// Stubs for stories: a fake signed-in/out OIDC session and a fake backend, +// both chosen per story through `parameters` (see preview.tsx). Stories never +// touch Keycloak or the real API, so every render is deterministic. +import type { ReactNode } from "react"; +import { AuthContext, type AuthContextProps } from "react-oidc-context"; +import type { Hero } from "../src/api/heroes"; + +export type AuthState = "signedOut" | "signedIn" | "loading"; + +export interface ApiStub { + /** `GET /api/health/live`: "pending" never answers (the "checking" state). */ + health?: "up" | "down" | "pending"; + /** `GET` of the heroes list: rows, "pending" (loading) or an HTTP error. */ + heroes?: Hero[] | "pending" | { status: number; detail: string }; +} + +/** What a story may set in `parameters`; preview.tsx reads both. */ +export interface StubParameters { + auth?: AuthState; + api?: ApiStub; +} + +/** Two fixed heroes (stable ids and dates: screenshots must not drift). */ +const base = { + owner_id: "stub-owner", + is_draft: false, + archived_at: null, + publish_at: null, + unpublish_at: null, + is_locked: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; +export const heroFixtures: Hero[] = [ + { + ...base, + id: 1, + name: "Captain Ledger", + powers: ["Foresight", "Filing"], + power_level: 72, + }, + { + ...base, + id: 2, + name: "The Margin", + powers: ["Annotation"], + power_level: 41, + }, + { + ...base, + id: 3, + name: null, + powers: null, + power_level: null, + is_draft: true, + }, +]; + +const noop = async () => undefined; + +export function stubAuth(state: AuthState): AuthContextProps { + const signedIn = state === "signedIn"; + return { + isLoading: state === "loading", + isAuthenticated: signedIn, + user: signedIn + ? ({ + access_token: "stub-token", + profile: { preferred_username: "ada.lovelace" }, + } as AuthContextProps["user"]) + : null, + signinRedirect: noop, + removeUser: noop, + } as unknown as AuthContextProps; +} + +export function StubAuthProvider({ + state, + children, +}: { + state: AuthState; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +const never = () => new Promise(() => {}); + +/** Replaces `window.fetch` with a fake of the two endpoints the app calls. */ +export function installApiStub(api: ApiStub = {}) { + const { health = "up", heroes = [] } = api; + window.fetch = async (input, init) => { + const url = new URL(String(input), window.location.origin); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.pathname === "/api/health/live") { + if (health === "pending") return never(); + return health === "up" + ? json(200, { status: "ok" }) + : json(503, { title: "down" }); + } + if (url.pathname === "/api/crud/v1/heroes/v2/json" && method === "GET") { + if (heroes === "pending") return never(); + if (!Array.isArray(heroes)) { + return json(heroes.status, { detail: heroes.detail }); + } + return json(200, heroes); + } + return json(404, { title: "Not stubbed" }); + }; +} diff --git a/CLAUDE.md b/CLAUDE.md index f5b61af..8c13110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,11 +54,11 @@ Practices below are distilled from Anthropic's own Claude Code guidance touch (see "Before writing anything" above) and write a plan before editing. Skip planning for a change you could describe as a one-sentence diff. - When asked to produce a plan, write it to `docs/plans/` per - [`docs/plans/README.md`](docs/plans/README.md) rather than only - answering inline — that file also covers what happens to the plan - document once its work is executed (folded into an ADR/docs, or - removed). + When asked to produce a plan, use the `plan-creation` skill + (`.claude/skills/plan-creation/`): it writes the plan to `docs/plans/` + per [`docs/plans/README.md`](docs/plans/README.md) rather than only + answering inline, and that file also covers what happens to the plan + once its work is executed. - **Report changes after executing.** After any turn that modified, created, or deleted files, close with a brief commit message for the user's use (a one-line summary plus a short bullet list of the most @@ -81,12 +81,22 @@ Practices below are distilled from Anthropic's own Claude Code guidance - **Course-correct early.** If the same correction has to be made twice on one approach, stop and reconsider the approach itself rather than trying a third variation. -- **Resolve test coverage gaps in parallel.** When closing multiple - test coverage gaps, first plan out which gaps are independent (touch - disjoint files/modules with no shared state or ordering dependency), - then dispatch one subagent per independent gap to write/fix its tests - concurrently rather than working through the list serially. Gaps that - share a file or depend on each other's changes stay sequential. +- **Decide alone only where a common best practice exists.** Settle a + decision yourself only when primary sources (official docs, RFCs, + maintainers) or a clear majority of reputable practice agree, or when + a plan, ADR or NFR already requires one option. Otherwise the user + decides: sources that disagree, genuine trade-offs, and matters of + taste or convention (naming, public API shape, visual design, + user-facing defaults). Ask with the options and their trade-offs, the + sources, and your own lean labeled as a lean. +- **Execute multi-item work as an orchestrator.** When executing a + `docs/plans/` plan, or any task that splits into several independent + items (closing multiple test coverage gaps, for example), the main + session only orchestrates, deciding and raising user gates; executors + and landers (`plan-executor` subagents, + `.claude/agents/plan-executor.md`) implement, review, merge, verify + and release. Use the `plan-execution` skill + (`.claude/skills/plan-execution/`) for how. - **Flag devcontainer changes as needing a rebuild.** Editing anything under `.devcontainer/` (compose files, `stack/`, Dockerfiles) doesn't take effect in the running container — tell the user a rebuild is diff --git a/Dockerfile b/Dockerfile index f09ff39..a97c245 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,74 +1,67 @@ -# syntax=docker/dockerfile:1.27 -# Single-stage build: develop (devcontainer). A downstream instance adds its -# own builder/runner stages on top of this same base image and -# postCreateCommand structure -- see docs/TEMPLATE.md's "Contents" section. +# syntax=docker/dockerfile:1.28 +# Runtime image (builder → runner), the repo's top-level Dockerfile; the +# devcontainer image is `.devcontainer/Dockerfile`'s `develop` stage. +# NODE_VERSION/DEBIAN_VERSION must match .devcontainer/Dockerfile's, so +# the bundle is built with the same Node the devcontainer tests against -- +# enforced by scripts/check-dockerfile-versions.sh. +# renovate: datasource=node-version depName=node +ARG NODE_VERSION=24.22.0 ARG DEBIAN_VERSION=trixie -# renovate: datasource=github-releases depName=j178/prek -ARG PREK_VERSION=0.5.3 - -# renovate: datasource=npm depName=@anthropic-ai/claude-code -ARG CLAUDE_CODE_VERSION=2.1.278 - -# renovate: datasource=github-releases depName=edouard-claude/snip -ARG SNIP_VERSION=0.25.2 - -# uv itself isn't a project dependency manager here -- it's only the -# mechanism scripts/develop.sh uses to install an exact, checksum-verified -# CPython build (see PYTHON_VERSION below) rather than an unpinned apt -# package. Left on PATH afterward; harmless, and an instance that adds -# Python as its own application runtime can reuse it directly. -# renovate: datasource=github-releases depName=astral-sh/uv -ARG UV_VERSION=0.12.17 - -# python3 is infrastructure tooling, not an application runtime this -# template assumes: prek needs an interpreter to build the venv for any -# "language: python" hook (.pre-commit-config.yaml's pre-commit-hooks -# repo), and .github/scripts/*.py need one directly. -# renovate: datasource=python-version depName=python -ARG PYTHON_VERSION=3.14.7 - -# Node.js is infrastructure tooling too, not an application runtime: npx -# (the clear-thought MCP server in .mcp.json) is the only thing that -# needs it. -# renovate: datasource=node-version depName=node -ARG NODE_VERSION=24.21.0 +# renovate: datasource=docker depName=nginxinc/nginx-unprivileged +ARG NGINX_VERSION=1.30.6-alpine ARG SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ARG SSL_CERT_DIR=/etc/ssl/certs ######################################## -# develop — interactive devcontainer image, based on Microsoft's generic -# base devcontainer image. No *application* language runtime is assumed -# here — an instance adds its own (Python via uv, Rust via rustup, Go, -# Node.js, OpenTofu/Ansible, ...) on top of this stage, following the same -# ARG/scripts/develop.sh pattern established below. The Python and Node.js -# this stage does install are infrastructure tooling only -- see the ARG -# comments above. +# builder — installs deps with pnpm (the pinned version comes from +# package.json's own `packageManager` field, which Corepack reads) and +# builds the static SPA bundle. ######################################## -FROM mcr.microsoft.com/devcontainers/base:${DEBIAN_VERSION} AS develop -ARG PREK_VERSION -ARG CLAUDE_CODE_VERSION -ARG SNIP_VERSION -ARG UV_VERSION -ARG PYTHON_VERSION -ARG NODE_VERSION +FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS builder ARG SSL_CERT_FILE ARG SSL_CERT_DIR - ENV SSL_CERT_FILE=${SSL_CERT_FILE} \ - SSL_CERT_DIR=${SSL_CERT_DIR} \ - REQUESTS_CA_BUNDLE=${SSL_CERT_FILE} \ - CURL_CA_BUNDLE=${SSL_CERT_FILE} + SSL_CERT_DIR=${SSL_CERT_DIR} -COPY scripts/develop.sh /tmp/develop.sh -RUN bash /tmp/develop.sh "$PREK_VERSION" "$CLAUDE_CODE_VERSION" "$SNIP_VERSION" "$UV_VERSION" "$PYTHON_VERSION" "$NODE_VERSION" +RUN corepack enable +WORKDIR /workspace +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build -# Instance hook: scripts/develop.d/*.sh, see that directory's README.md. -COPY scripts/develop.d/ /tmp/develop.d/ -RUN for f in /tmp/develop.d/*.sh; do [ -e "$f" ] || continue; echo "==> $f"; bash "$f" || exit 1; done +######################################## +# runner — nginx-unprivileged serving the built SPA and proxying /api to +# the real backend (see docs/adrs/0002-same-origin-api-proxy.md). Its own +# docker-entrypoint.d/20-envsubst-on-templates.sh already renders +# scripts/docker/nginx.conf.template -> /etc/nginx/conf.d/default.conf from +# real container env vars on every start; docker-entrypoint.d/ here adds +# one more such script for this app's own /config.js (its client-side +# runtime config), same mechanism, one level up the stack. +######################################## +FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner -USER vscode -WORKDIR /workspace -CMD ["sleep", "infinity"] +# Defaults match the backend's own devcontainer (see docs/adrs/0001-...md +# and docs/adrs/0002-...md) -- correct for a local smoke-test of this +# image, not a real deployment, which must override all three. +ENV OIDC_AUTHORITY=http://localhost:8080/realms/template-fastapi \ + OIDC_CLIENT_ID=api \ + API_PROXY_TARGET=http://host.docker.internal:8000 + +COPY scripts/docker/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY scripts/docker/docker-entrypoint.d/20-envsubst-app-config.sh /docker-entrypoint.d/20-envsubst-app-config.sh +# The base image already switches to its unprivileged `nginx` user +# (uid/gid 101) -- root only for this one COPY+chown, since --chown alone +# reaches the *copied files* but not the pre-existing +# /usr/share/nginx/html directory entry itself (still root-owned there), +# and this stage's own docker-entrypoint.d script needs write access on +# that directory to create config.js at container start. +USER root +COPY --from=builder --chown=nginx:nginx /workspace/dist /usr/share/nginx/html +RUN chown nginx:nginx /usr/share/nginx/html +USER nginx + +EXPOSE 8080 diff --git a/Makefile b/Makefile index 88c6e1c..3632a62 100644 --- a/Makefile +++ b/Makefile @@ -1,69 +1,178 @@ -# template-react's implementation of template-base's release contract -# (docs/TEMPLATE.md, "Release: a Makefile contract"): the app.Dockerfile -# `runner` image, per arch, combined into one multi-arch tag by finalize. -# release.yml sets RELEASE_VERSION / RELEASE_TAG / RELEASE_ARCH / OCI_*. +# Image implementation of template-base's release contract (docs/TEMPLATE.md, +# "Release: a Makefile contract"). release.yml sets RELEASE_VERSION / +# RELEASE_TAG / RELEASE_ARCH / OCI_* -- see there. Per-instance values go in +# the optional image.mk (every variable below is `?=`); steps beyond the +# generic ones are extra prerequisites of release-check, set there too. +# Template-owned: a sync overwrites this file. +# +# What gets published is the tar `build` wrote to dist/ -- the one `sbom` +# described and `image-smoke` ran -- copied to the registry with skopeo +# rather than rebuilt, so the registry digest is the tested artifact's. The +# copy is `--all`: the tar's whole index (the image for RELEASE_ARCH plus any +# buildx attestation manifests: provenance and SBOM) is pushed, whatever the +# host's arch, so a non-native RELEASE_ARCH works and attestations survive. +# `created` is the commit time, so rebuilding the same commit yields the same +# labels; layer contents are not guaranteed bit-for-bit reproducible. -.PHONY: release-arches build sbom release-assets publish release-check finalize +-include image.mk -ARCHES := amd64 arm64 -REPO := $(or $(GITHUB_REPOSITORY),craftainer/template-react) -IMAGE := $(notdir $(REPO)) -ASSET := dist/$(IMAGE)-$(RELEASE_VERSION)-$(RELEASE_ARCH) -OCI_NAME := $(shell printf '%s' '$(or $(OCI_IMAGE_NAME),$(REPO))' | tr '[:upper:]' '[:lower:]') +.PHONY: repo-check release-arches build sbom release-assets publish release-check finalize image-smoke check-arch +.DEFAULT_GOAL := release-arches + +DOCKERFILE ?= Dockerfile +TARGET ?= runner +ARCHES ?= amd64 arm64 +BUILD_ARGS ?= +EXTRA_LABELS ?= +OCI_DESCRIPTION ?= +OCI_LICENSE ?= GPL-3.0-or-later +COVERAGE_REPORT ?= +IMAGE_SMOKE_PORT ?= 8000 +IMAGE_SMOKE_PATH ?= /health/live +IMAGE_SMOKE_RUN_ARGS ?= +IMAGE_SMOKE_TIMEOUT ?= 60 # renovate: datasource=docker depName=anchore/syft -SYFT_VERSION := v1.18.1 +SYFT_VERSION := v1.52.0 -# Only set in CI, where release.yml exposes the Actions cache runtime. +# Pinned by tag and digest; Renovate updates both together. Apache-2.0. The +# `-immutable` tag, not `vX.Y.Z`: quay.io rebuilds the plain tags daily and +# garbage-collects their old digests, which would break the digest pin. +SKOPEO_IMAGE := quay.io/skopeo/stable:v1.22.3-immutable@sha256:c0ee1f4edca5c01cb8d5611124f92f3cc47196ecab68aee5d0f90834e00574d5 + +# owner/repo: GITHUB_REPOSITORY in CI; inside the devcontainer (where +# release-check runs) it is unset, so derive it from the origin remote +# (ssh://[user@]host[:PORT]/o/r, scp-like and https forms, with or without +# credentials and .git; scripts/test-image-scripts.sh checks them). If git +# fails (no origin, "dubious ownership", ...) there is no guess: REPO stays +# empty, git's own error is kept in REPO_ERROR, and every target that needs +# REPO (via repo-check) prints it to stderr and fails. +ifeq ($(GITHUB_REPOSITORY),) +GIT_REMOTE := $(shell url=$$(git remote get-url origin 2>&1) && { printf 'ok '; printf '%s' "$$url" | sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://([^@/]*@)?([^/:]+):[0-9]+/|\2/|; s|^[A-Za-z][A-Za-z0-9+.-]*://||; s|^[^@/]*@||; s|^[^/:]+[:/]||; s|/$$||; s|\.git$$||'; } || printf 'err %s' "$$url") +ifeq ($(firstword $(GIT_REMOTE)),ok) +REPO := $(word 2,$(GIT_REMOTE)) +else +REPO := +REPO_ERROR := make: cannot derive the repository from the origin remote: $(wordlist 2,$(words $(GIT_REMOTE)),$(GIT_REMOTE)) (set GITHUB_REPOSITORY=owner/repo to override) +export REPO_ERROR +endif +else +REPO := $(GITHUB_REPOSITORY) +endif +OWNER := $(firstword $(subst /, ,$(REPO))) +IMAGE := $(notdir $(REPO)) +ASSET_PREFIX := dist/$(IMAGE)-$(RELEASE_VERSION) +ASSET := $(ASSET_PREFIX)-$(RELEASE_ARCH) +RELEASE_URL := https://github.com/$(REPO)/releases/download/$(RELEASE_TAG) +OCI_NAME = $(shell printf '%s' '$(or $(OCI_IMAGE_NAME),$(REPO))' | tr '[:upper:]' '[:lower:]') + +# Values that may hold arbitrary characters travel through the environment, +# never through recipe text. +export OCI_DESCRIPTION +export IMAGE_SMOKE_RUN_ARGS IMAGE_SMOKE_PORT IMAGE_SMOKE_PATH IMAGE_SMOKE_TIMEOUT RELEASE_VERSION +export IMAGE_TAR_PREFIX = $(ASSET_PREFIX) +export SKOPEO_IMAGE ARCHES + +# Only set in CI, by crazy-max/ghaction-github-runtime in release.yml. ifdef ACTIONS_RUNTIME_TOKEN CACHE := --cache-from type=gha,scope=$(RELEASE_ARCH) --cache-to type=gha,mode=max,scope=$(RELEASE_ARCH) endif -# `created` is the commit time, so build and publish (separate make -# invocations) produce identical labels and therefore the same image. -LABELS := \ +# Recursive (`=`) so git runs only in recipes that build. Description and +# coverage-report are emitted only when set; the description is read from +# the environment so quotes, `$` and backticks in it are safe. +LABELS = \ --label org.opencontainers.image.title=$(IMAGE) \ --label org.opencontainers.image.source=https://github.com/$(REPO) \ + --label org.opencontainers.image.url=https://github.com/$(REPO) \ --label org.opencontainers.image.version=$(RELEASE_VERSION) \ --label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \ --label org.opencontainers.image.created=$(shell git log -1 --format=%cI) \ - --label org.opencontainers.image.licenses=GPL-3.0 + --label org.opencontainers.image.licenses=$(OCI_LICENSE) \ + --label io.github.$(OWNER).sbom=$(RELEASE_URL)/$(IMAGE)-$(RELEASE_VERSION)-$(RELEASE_ARCH).spdx.json +LABELS += $${OCI_DESCRIPTION:+--label "org.opencontainers.image.description=$$OCI_DESCRIPTION"} +ifneq ($(COVERAGE_REPORT),) +LABELS += --label io.github.$(OWNER).coverage-report=$(RELEASE_URL)/$(COVERAGE_REPORT) +endif +LABELS += $(EXTRA_LABELS) + +BUILD = docker buildx build -f $(DOCKERFILE) --target $(TARGET) \ + --platform linux/$(RELEASE_ARCH) $(BUILD_ARGS) $(LABELS) $(CACHE) -BUILD := docker buildx build -f app.Dockerfile --target runner \ - --platform linux/$(RELEASE_ARCH) $(LABELS) $(CACHE) +REPO_GUARD = test -n "$(REPO)" || { printf '%s\n' "$$REPO_ERROR" >&2; exit 1; } + +# Prerequisite of every target whose output names the repo. +repo-check: + @$(REPO_GUARD) + +check-arch: + @test -n "$(RELEASE_ARCH)" || { echo "RELEASE_ARCH is not set." >&2; exit 1; } release-arches: - @python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' $(ARCHES) + @test -n "$(strip $(ARCHES))" || { echo "ARCHES is empty; list at least one arch in image.mk." >&2; exit 1; } + @printf '[%s]\n' "$$(printf '"%s",' $(ARCHES) | sed 's/,$$//')" -build: +build: check-arch repo-check mkdir -p dist $(BUILD) --output type=oci,dest=$(ASSET).tar . -sbom: +sbom: check-arch repo-check docker run --rm -v "$(CURDIR)/dist:/dist" anchore/syft:$(SYFT_VERSION) \ oci-archive:/$(ASSET).tar -o spdx-json=/$(ASSET).spdx.json -release-assets: +# Each arch is smoke-tested here, natively on its own runner, before publish. +release-assets: check-arch repo-check image-smoke test -f $(ASSET).tar && test -f $(ASSET).spdx.json -# OCI_REGISTRY_PASSWORD is a PAT (write:packages for ghcr.io) -- release.yml -# grants GITHUB_TOKEN no packages scope. -publish: +# Copies the tested tar from dist/ to the registry (skopeo copy --all +# oci-archive:... docker://...), records the pushed index digest in +# dist/.digest for finalize, and logs in through a throwaway authfile +# so the password never appears in a command line. Without --all skopeo +# copies one image chosen by the host's platform, which can fail or pick the +# wrong image for a non-native RELEASE_ARCH, and it drops the attestations. +publish: check-arch ifeq ($(OCI_REGISTRY),) @echo "OCI_REGISTRY not set -- skipping publish." else - @printf '%s' "$$OCI_REGISTRY_PASSWORD" | docker login "$(OCI_REGISTRY)" -u "$$OCI_REGISTRY_USERNAME" --password-stdin - $(BUILD) --push -t $(OCI_REGISTRY)/$(OCI_NAME):$(RELEASE_VERSION)-$(RELEASE_ARCH) . + @$(REPO_GUARD) + @test -f $(ASSET).tar || { echo "$(ASSET).tar not found; run 'make build' first." >&2; exit 1; } + @auth="$$(mktemp -d)" && chmod 0777 "$$auth" && trap 'rm -rf "$$auth"' EXIT && \ + printf '%s' "$$OCI_REGISTRY_PASSWORD" | bash scripts/skopeo.sh -i -v "$$auth:/auth" -- \ + login --authfile /auth/auth.json -u "$$OCI_REGISTRY_USERNAME" --password-stdin "$(OCI_REGISTRY)" && \ + bash scripts/skopeo.sh -v "$$auth:/auth:ro" -- \ + copy --all --authfile /auth/auth.json --digestfile /$(ASSET).digest \ + oci-archive:/$(ASSET).tar docker://$(OCI_REGISTRY)/$(OCI_NAME):$(RELEASE_VERSION)-$(RELEASE_ARCH) endif -release-check: - pnpm test +# Smoke-tests the tar in dist/ for RELEASE_ARCH (default: the host's arch). +# image.mk's IMAGE_SMOKE_* values reach the script through the environment. +image-smoke: repo-check + bash scripts/image-smoke.sh + +# No recipe by design: image.mk adds steps as prerequisites, so Make never +# sees two recipes for one target. Runs the host-arch smoke test again +# inside the devcontainer, with the instance's backing services up. +release-check: image-smoke +# One manifest list over the per-arch digests publish recorded (each an +# index of image + attestations; imagetools create merges their manifests), +# so it references exactly the images that were tested and pushed. Logs in +# with a throwaway DOCKER_CONFIG so the password isn't left in +# ~/.docker/config.json. The Docker CLI looks for plugins in +# $DOCKER_CONFIG/cli-plugins, so a user-installed buildx would vanish with +# the swap: the user's cli-plugins directory is linked into the throwaway one. finalize: ifeq ($(OCI_REGISTRY),) @echo "OCI_REGISTRY not set -- no manifest list to create." else - @printf '%s' "$$OCI_REGISTRY_PASSWORD" | docker login "$(OCI_REGISTRY)" -u "$$OCI_REGISTRY_USERNAME" --password-stdin - docker buildx imagetools create --tag $(OCI_REGISTRY)/$(OCI_NAME):$(RELEASE_VERSION) \ - $(foreach a,$(ARCHES),$(OCI_REGISTRY)/$(OCI_NAME):$(RELEASE_VERSION)-$(a)) + @$(REPO_GUARD) + @orig="$${DOCKER_CONFIG:-$$HOME/.docker}" && export DOCKER_CONFIG="$$(mktemp -d)" && trap 'rm -rf "$$DOCKER_CONFIG"' EXIT && \ + if [ -d "$$orig/cli-plugins" ]; then ln -s "$$orig/cli-plugins" "$$DOCKER_CONFIG/cli-plugins"; fi && \ + printf '%s' "$$OCI_REGISTRY_PASSWORD" | docker login "$(OCI_REGISTRY)" -u "$$OCI_REGISTRY_USERNAME" --password-stdin && \ + srcs="" && for a in $(ARCHES); do \ + d="$$(cat dist/$(IMAGE)-$(RELEASE_VERSION)-$$a.digest)" || exit 1; \ + srcs="$$srcs $(OCI_REGISTRY)/$(OCI_NAME)@$$d"; \ + done && \ + docker buildx imagetools create --tag $(OCI_REGISTRY)/$(OCI_NAME):$(RELEASE_VERSION) $$srcs && \ + rm -f dist/$(IMAGE)-$(RELEASE_VERSION)-*.digest endif diff --git a/README.md b/README.md index 395000a..6ee4ec5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ The React frontend for [`template-fastapi`](https://github.com/craftainer/templa Vite + React + TypeScript + MUI, authenticating against that backend's own Keycloak realm and talking to it exclusively through a same-origin `/api` proxy. Itself an instance of -[`template-base`](https://github.com/craftainer/template-base) — see +[`template-docker`](https://github.com/craftainer/template-docker) (the +shared image layer, itself built on +[`template-base`](https://github.com/craftainer/template-base)) — see [`docs/TEMPLATE.md`](docs/TEMPLATE.md) for everything about this repository that's identical across every `template-base` instance (devcontainer, CI, the release Makefile contract, template sync, @@ -21,6 +23,16 @@ decisions: - [`0002-same-origin-api-proxy.md`](docs/adrs/0002-same-origin-api-proxy.md) — the `/api` proxy (dev and prod), cross-repo dev networking, and the production image's runtime-configurable settings. +- [`0003-re-parent-onto-template-docker.md`](docs/adrs/0003-re-parent-onto-template-docker.md) — + the image layer from `template-docker`, the `Dockerfile` layout, `image.mk`, + and the UI dependency set. +- [`0004-design-system-and-token-lint.md`](docs/adrs/0004-design-system-and-token-lint.md) — + the "Field Ledger" design system in `src/theme/` and the hook that keeps raw + colors and `px` out of the rest of `src/`; principles in + [`docs/design/`](docs/design/README.md). +- [`0005-storybook-visual-regression-and-axe.md`](docs/adrs/0005-storybook-visual-regression-and-axe.md) — + Storybook for every component, Playwright screenshot baselines and axe + checks over the stories, rendered in a pinned Playwright image. ## Getting started @@ -37,6 +49,9 @@ pnpm lint # oxlint pnpm format # prettier --check . pnpm test # vitest run pnpm build # production bundle -> dist/ +pnpm storybook # component workshop, http://localhost:6006 +tests/visual/run.sh # visual regression + axe over the stories (Docker) +tests/visual/run.sh update # rewrite the screenshot baselines ``` ## Connecting to the backend @@ -66,11 +81,12 @@ image" section. ## Production image and releases -The runtime image is `app.Dockerfile`'s `runner` stage -(`docker build -f app.Dockerfile --target runner .`); the base-owned -`Dockerfile` only builds the devcontainer. `release.yml` drives the -`Makefile`, which builds it natively for `amd64` and `arm64` and, when -the `OCI_REGISTRY` repository variable is set (e.g. `ghcr.io`), pushes +The runtime image is the top-level `Dockerfile`'s `runner` stage +(`docker build --target runner .`); `.devcontainer/Dockerfile` only builds +the devcontainer. `release.yml` drives the template-docker-owned +`Makefile` (tuned by this repo's `image.mk`), which builds it natively for +`amd64` and `arm64`, smoke-tests each image, and, when the `OCI_REGISTRY` +repository variable is set (e.g. `ghcr.io`), copies the tested images to per-arch tags and combines them into one multi-arch `//template-react:` tag. Registry credentials are the `OCI_REGISTRY_USERNAME` / `OCI_REGISTRY_PASSWORD` secrets — for diff --git a/app.Dockerfile b/app.Dockerfile deleted file mode 100644 index d8c405e..0000000 --- a/app.Dockerfile +++ /dev/null @@ -1,67 +0,0 @@ -# syntax=docker/dockerfile:1.27 -# Runtime image (builder → runner); the devcontainer image is the -# base-owned `Dockerfile`'s `develop` stage. - -# NODE_VERSION/DEBIAN_VERSION must match the base-owned Dockerfile's, so -# the bundle is built with the same Node the devcontainer tests against -- -# enforced by scripts/check-dockerfile-versions.sh. -# renovate: datasource=node-version depName=node -ARG NODE_VERSION=24.21.0 -ARG DEBIAN_VERSION=trixie - -# renovate: datasource=docker depName=nginxinc/nginx-unprivileged -ARG NGINX_VERSION=1.27.4-alpine - -ARG SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -ARG SSL_CERT_DIR=/etc/ssl/certs - -######################################## -# builder — installs deps with pnpm (the pinned version comes from -# package.json's own `packageManager` field, which Corepack reads) and -# builds the static SPA bundle. -######################################## -FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS builder -ARG SSL_CERT_FILE -ARG SSL_CERT_DIR -ENV SSL_CERT_FILE=${SSL_CERT_FILE} \ - SSL_CERT_DIR=${SSL_CERT_DIR} - -RUN corepack enable -WORKDIR /workspace -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm build - -######################################## -# runner — nginx-unprivileged serving the built SPA and proxying /api to -# the real backend (see docs/adrs/0002-same-origin-api-proxy.md). Its own -# docker-entrypoint.d/20-envsubst-on-templates.sh already renders -# scripts/docker/nginx.conf.template -> /etc/nginx/conf.d/default.conf from -# real container env vars on every start; docker-entrypoint.d/ here adds -# one more such script for this app's own /config.js (its client-side -# runtime config), same mechanism, one level up the stack. -######################################## -FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner - -# Defaults match the backend's own devcontainer (see docs/adrs/0001-...md -# and docs/adrs/0002-...md) -- correct for a local smoke-test of this -# image, not a real deployment, which must override all three. -ENV OIDC_AUTHORITY=http://localhost:8080/realms/template-fastapi \ - OIDC_CLIENT_ID=api \ - API_PROXY_TARGET=http://host.docker.internal:8000 - -COPY scripts/docker/nginx.conf.template /etc/nginx/templates/default.conf.template -COPY scripts/docker/docker-entrypoint.d/20-envsubst-app-config.sh /docker-entrypoint.d/20-envsubst-app-config.sh -# The base image already switches to its unprivileged `nginx` user -# (uid/gid 101) -- root only for this one COPY+chown, since --chown alone -# reaches the *copied files* but not the pre-existing -# /usr/share/nginx/html directory entry itself (still root-owned there), -# and this stage's own docker-entrypoint.d script needs write access on -# that directory to create config.js at container start. -USER root -COPY --from=builder --chown=nginx:nginx /workspace/dist /usr/share/nginx/html -RUN chown nginx:nginx /usr/share/nginx/html -USER nginx - -EXPOSE 8080 diff --git a/app.Dockerfile.dockerignore b/app.Dockerfile.dockerignore deleted file mode 100644 index 596cf12..0000000 --- a/app.Dockerfile.dockerignore +++ /dev/null @@ -1,19 +0,0 @@ -# Build context excludes for `docker build -f app.Dockerfile` (see -# app.Dockerfile). BuildKit reads this file *instead of* .dockerignore for -# that build, so it repeats every exclusion the runtime build needs. -.git -.github -.devcontainer -.secrets -.vscode -.claude -docs -README.md -CLAUDE.md - -# The builder stage runs its own `pnpm install`/`pnpm build` -- without -# these, `COPY . .` (after that install) would overwrite the container's -# own node_modules with the host's, which may not match its platform/arch. -node_modules -dist -coverage diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index d75c9ad..a209136 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -29,16 +29,24 @@ artifact on top of the shape described here. `README.md` + `template.md` scaffolding — no numbered content, which is entirely instance-owned. - `.secrets/` — local secret files, never committed; see its `README.md`. -- `Dockerfile` — a single `develop` stage (the devcontainer image). An - instance adds its own `builder`/`runner` (or equivalent) stages on top - for its own release artifact. +- `.devcontainer/Dockerfile` — a single `develop` stage (the devcontainer + image), with its build-context excludes in + `.devcontainer/Dockerfile.dockerignore`. The repo root has no + `Dockerfile`: that one name is reserved for an instance's own runtime + image (see "Instance extension points"), so a repo never has more than + one top-level Dockerfile. - `scripts/develop.sh`, `scripts/post-create.sh` — the `develop` stage's setup script and the devcontainer's `postCreateCommand`, each running an instance hook directory (`develop.d/`, `post-create.d/`); see `scripts/README.md` and "Instance extension points" below. - `Makefile` — the release contract `release.yml` drives - (`build`/`sbom`/`release-assets`/`publish`), each a documented no-op - here — see "Release: a Makefile contract" below. + (`build`/`sbom`/`release-assets`/`publish`); in `template-docker` the + image implementation — see "Release: a Makefile contract" and "Image + layer (template-docker)" below. +- `Dockerfile`, `image.mk`, `.dockerignore`, `scripts/image-smoke.sh`, + `scripts/runner-setup.sh`, `scripts/runner-setup.d/`, + `scripts/check-dockerfile-versions.sh` — the image layer; see "Image + layer (template-docker)" below. - `.pre-commit-config.yaml` — git hooks, run by `prek` or `pre-commit`; language-agnostic hooks only. - `CLAUDE.md` — the AI-assisted coding workflow Claude Code follows in @@ -49,22 +57,36 @@ artifact on top of the shape described here. Base-owned files stay `replace` tier (a sync overwrites them) and expose hooks an instance fills with files it owns, so an instance never edits a -base-owned file directly. Don't edit `Dockerfile`, `scripts/develop.sh` +base-owned file directly. Don't edit `.devcontainer/Dockerfile`, `scripts/develop.sh` or `compose.yml` in an instance; use the hooks below. | Hook | Base side (template-owned) | Instance side (instance-owned) | |---|---|---| -| Extra devcontainer tooling | `Dockerfile` `develop` stage runs every `scripts/develop.d/*.sh` after `develop.sh` | `scripts/develop.d/NN-name.sh` | +| Extra devcontainer tooling | `.devcontainer/Dockerfile` `develop` stage runs every `scripts/develop.d/*.sh` after `develop.sh` | `scripts/develop.d/NN-name.sh` | | Extra post-create steps | `scripts/post-create.sh`, run by `devcontainer.json`'s `postCreateCommand`, runs every `scripts/post-create.d/*.sh` | `scripts/post-create.d/NN-name.sh` | | Extra compose services / dev-service settings | `devcontainer.json` `"dockerComposeFile": ["compose.yml", "compose.instance.yml"]` | `.devcontainer/compose.instance.yml` (base ships a stub, `ignore` tier) | -| Runtime image | nothing (base `Dockerfile` is `develop`-only) | `app.Dockerfile` + `app.Dockerfile.dockerignore` | +| Runtime image | nothing (the devcontainer image lives in `.devcontainer/`) | top-level `Dockerfile` + `.dockerignore`, listed in `.github/template-sync-manifest.local.yml`'s `ignore:` | Files with no include mechanism of their own (JSON configs, `.pre-commit-config.yaml` — prek has no include) are `merge` tier, so an -instance's additions survive a sync. The runtime image goes in its own -`app.Dockerfile` so it never collides with the template-owned -`Dockerfile`; BuildKit picks up `app.Dockerfile.dockerignore` -automatically for `-f app.Dockerfile`. +instance's additions survive a sync. The runtime image is the +top-level `Dockerfile` with a plain `.dockerignore`, both instance-owned: +base keeps the devcontainer image and its excludes under +`.devcontainer/`, where BuildKit picks up +`.devcontainer/Dockerfile.dockerignore` for that build automatically. + +**One-time upgrade (instances synced from before this layout).** A sync +adds `.devcontainer/Dockerfile` but can't delete the old top-level +`Dockerfile`/`.dockerignore`: from this release on, base's manifest no +longer classifies those paths, and a merge-tier file is never deleted +automatically. In the sync PR, by hand: + +- With a runtime image: rename `app.Dockerfile` → `Dockerfile` and + `app.Dockerfile.dockerignore` → `.dockerignore`, overwriting the old + base files. Update every reference (`-f app.Dockerfile` in the + `Makefile`, `dockerfile:` in compose files, CI, docs) and list both + paths under `ignore:` in `.github/template-sync-manifest.local.yml`. +- Without one: delete the old top-level `Dockerfile` and `.dockerignore`. ## Getting started @@ -117,8 +139,8 @@ unchanged across every instance), then drives these targets: | Target | Runs | Where | Env available | |---|---|---|---| | `release-arches` | once, first | host runner | none; must print a JSON array on stdout, e.g. `["amd64"]` | -| `build`, `sbom`, `release-assets`, `publish` | once **per arch**, in that order | host runner for that arch | `RELEASE_VERSION`, `RELEASE_TAG`, `RELEASE_ARCH`, `OCI_REGISTRY`, `OCI_IMAGE_NAME`, `OCI_REGISTRY_USERNAME`, `OCI_REGISTRY_PASSWORD`, all `GITHUB_*` | -| `release-check` | once, after all arches | **inside the devcontainer** (`devcontainers/ci`), stack up | `RELEASE_VERSION`, `RELEASE_TAG` | +| `build`, `sbom`, `release-assets`, `publish` | once **per arch**, in that order | host runner for that arch | `RELEASE_VERSION`, `RELEASE_TAG`, `RELEASE_ARCH`, `OCI_REGISTRY`, `OCI_IMAGE_NAME`, `OCI_REGISTRY_USERNAME`, `OCI_REGISTRY_PASSWORD`, `PYPI_PUBLISH`, `NPM_PUBLISH`, `CARGO_PUBLISH`, all `GITHUB_*`; on `publish` only, `NPM_TOKEN` and `CARGO_REGISTRY_TOKEN` (bootstrap secrets; empty when unset, so treat empty as absent) | +| `release-check` | once, after all arches | **inside the devcontainer** (`devcontainers/ci`), stack up | `RELEASE_VERSION`, `RELEASE_TAG`, `PYPI_PUBLISH`, `NPM_PUBLISH`, `CARGO_PUBLISH` | | `finalize` | once, after `release-check` | host runner | same as per-arch minus `RELEASE_ARCH` | What each target is for: `build` produces the release artifact(s) (an @@ -144,7 +166,9 @@ The arch legs run natively, not under QEMU: `arm64` on `ubuntu-24.04-arm`, `amd64` on `ubuntu-24.04`, each overridable with the `CI_RUNNER_ARM64` / `CI_RUNNER_AMD64` repository/organization variables. -This repo's own `Makefile` implements every target as a documented +`template-docker` replaces that no-op `Makefile` with the image +implementation (see "Image layer (template-docker)" below). The base +`Makefile` implements every target as a documented no-op, since `template-base` ships no artifact — it's both a working example of the contract's shape and what `release.yml` needs to run cleanly if this repo itself is ever released. An instance's `Makefile` is @@ -152,6 +176,208 @@ not template-owned (it's the whole point that it differs per artifact type), so it's tracked like any other instance-owned file — untouched by this template's own sync manifest. +## Image layer (template-docker) + +`template-docker` is a mid-layer template: an instance of `template-base` +that is itself the template for every app that ships a container image +(`template-fastapi`, `template-axum`, `template-react`). Sync runs in +three hops, base to docker to app, and `ADR 0001` records why. It adds +these files over base: + +| Path | Tier for instances | What it is | +|---|---|---| +| `Makefile` | `replace` | the image implementation of the release contract above | +| `image.mk` | `ignore` | the instance's settings for the Makefile, every variable commented | +| `Dockerfile` | `ignore` | skeleton to replace: `builder` and `runner` stages, `ARG DEBIAN_VERSION`, `ARG APP_UID`, SSL ARGs, `APP_VERSION`/`APP_COMMIT` | +| `.dockerignore` | `merge` | baseline build-context excludes for the runtime image (including `node_modules`, `coverage`, `target` and `dist`); the instance appends its own | +| `scripts/image-smoke.sh` | `replace` | loads the image tar from `dist/`, runs it and polls a URL | +| `scripts/runner-setup.sh` | `replace` | optional: creates the group-0 runtime user and its writable directories for the `runner` stage | +| `scripts/image-scan.sh` | `replace` | the Trivy scan the `image-scan` hook runs | +| `scripts/test-image-scripts.sh` | `replace` | offline tests of the Makefile and `scripts/` logic, run by the `image-scripts-test` hook | +| `scripts/runner-setup.d/*.sh` | `ignore` | optional: the instance's runner-stage hooks | +| `scripts/check-dockerfile-versions.sh` | `replace` | the version-ARG consistency check | + +### `image.mk` variables + +The Makefile starts with `-include image.mk` (a missing file means all +defaults) and every variable is `?=`, so set them in `image.mk`: + +| Variable | Default | Meaning | +|---|---|---| +| `DOCKERFILE`, `TARGET` | `Dockerfile`, `runner` | build file and stage | +| `ARCHES` | `amd64 arm64` | space-separated; `release-arches` prints it as a JSON array and `finalize` merges the per-arch images into one tag; must not be empty | +| `BUILD_ARGS` | empty | full flag words, e.g. `--build-arg APP_VERSION=$(RELEASE_VERSION)` | +| `EXTRA_LABELS` | empty | full flag words, e.g. `--label k=v` | +| `OCI_DESCRIPTION` | empty | `org.opencontainers.image.description`; the label is omitted while empty | +| `OCI_LICENSE` | `GPL-3.0-or-later` | SPDX id for `org.opencontainers.image.licenses` | +| `COVERAGE_REPORT` | empty | release asset name for the `io.github..coverage-report` label; omitted while empty | +| `IMAGE_SMOKE_PORT`, `IMAGE_SMOKE_PATH` | `8000`, `/health/live` | URL polled for a 2xx | +| `IMAGE_SMOKE_RUN_ARGS`, `IMAGE_SMOKE_TIMEOUT` | empty, `60` | extra `docker run` words, whitespace-separated with no embedded spaces or quotes (passed through the environment); seconds to wait | + +`SYFT_VERSION` (the SBOM tool image tag) and `SKOPEO_IMAGE` (tag and +digest) are pinned in the Makefile for Renovate; don't override them. The +image name comes from `GITHUB_REPOSITORY` (the repository name), never a +hardcoded one. Inside the devcontainer, where `release-check` runs and +`GITHUB_REPOSITORY` is unset, it is derived from the `origin` remote +(`ssh://[user@]host[:PORT]/owner/repo`, scp-like or https form; tested by +`scripts/test-image-scripts.sh`). If git fails (no `origin`, "dubious +ownership"), there is no guess: `build`, `sbom`, `release-assets`, `publish` +and `finalize` print git's error to stderr and fail, and +`GITHUB_REPOSITORY=owner/repo` overrides; +`image-smoke` also accepts the single `dist/*--.tar` when +the expected name is absent. `OCI_IMAGE_NAME` overrides the registry path. +`build`, `sbom`, `release-assets` and `publish` need `RELEASE_ARCH` and +fail through the `check-arch` prerequisite when it is empty; +`release-arches`, `release-check`/`image-smoke` (host arch when unset) and +`finalize` don't. `publish` copies the tar from `dist/` that was SBOM'd +and smoke-tested to the registry with skopeo (`copy --all`, `oci-archive:` +to `docker://`, Apache-2.0, pinned image; `crane push` only reads +docker-format tarballs, and `build` writes an OCI layout), so the pushed +digest is the tested one. `--all` pushes the tar's whole index as +`-`: the image for `RELEASE_ARCH` plus the buildx attestation +manifests (provenance, SBOM) when the build wrote any. Without it skopeo +picks one image by the *host's* platform, which can fail or pick the wrong +image when `RELEASE_ARCH` isn't native, and it drops the attestations. It +records that index's digest in `dist/--.digest` (which +`finalize` deletes after use, so release.yml never attaches digest files to +the GitHub release), and +`finalize` builds the manifest list from those digests (`imagetools create` +merges the per-arch indexes, so the attestations carry over). `finalize` +logs in with a throwaway `DOCKER_CONFIG`, into which it links the user's +`cli-plugins` directory (Docker looks up plugins in +`$DOCKER_CONFIG/cli-plugins`), so a user-installed buildx is still found. Registry login uses a throwaway authfile fed the +password on stdin. The labels are listed in `.github/workflows/README.md`'s "Image +labels". Registry auth stays the opt-in `OCI_REGISTRY*` secrets contract. + +### Adding `release-check` steps + +`release-check` has no recipe of its own: it depends on `image-smoke`. +An instance adds steps as prerequisites in `image.mk`, so Make never +sees two recipes for one target: + +```make +release-check: instance-tests +instance-tests: + uv run pytest --cov-report=xml:dist/coverage.xml +``` + +`release-assets` depends on `image-smoke`, so each arch is booted +natively on its own runner in the build job before `publish`; the +`release-check` run inside the devcontainer smoke-tests the host arch +again with the backing services up. `image-smoke` converts the arch's +image in the OCI-layout tar with the pinned skopeo (through +`scripts/skopeo.sh`, which `publish` uses too; `--override-arch`, to a +docker-archive under `dist/`) and `docker load`s that, because +`docker load` can't read a buildx OCI tar on the classic image store, +with or without attestations. It reads `IMAGE_TAR_PREFIX`, +`SKOPEO_IMAGE` and `ARCHES` (set by the Makefile), the arch from +`RELEASE_ARCH` (else the host's). A missing tar for that arch fails, so a +run never passes without having tested an image; the one exception is +`RELEASE_ARCH` unset with a host arch outside `ARCHES` (the instance +doesn't build it, and its own build leg tested each arch it does). It maps +a random host port to `IMAGE_SMOKE_PORT`, prints the container logs on failure and always +removes the container and the private `image-smoke-*` tag it created +(never an image that existed before). + +### Runner setup hook + +Optional. It serves a runtime image that needs the group-0 user and +writable directories; an instance whose `runner` stage doesn't call it +(e.g. `template-react`'s nginx runner, whose base image already has its own +non-root user) simply never runs it. Both files stay in the tree, since +they are template-owned, and nothing checks or requires them. + +`scripts/runner-setup.sh` creates the user (UID `APP_UID`, group 0), makes +its home and `/app/data` group-writable, and leaves `/app` root-owned, so +the image also runs under an arbitrary UID (OpenShift's restricted SCC). +Run it **before** copying app code, then copy the code after it with +`COPY --chown=:0` of content whose modes are already 0550 +directories and 0440 files: only data and cache directories are writable, the runtime user +can't modify what it runs, and no recursive `chgrp`/`chmod` duplicates +the files into another layer. Set those modes numerically, per kind +(`find . -type d -exec chmod 0550 {} +`, `find . -type f -exec chmod 0440 +{} +`, e.g. in the builder stage, or `COPY --chmod=0550`/`0440` for sources +that are all one kind), not with the symbolic `--chmod=u=rX,g=rX,o=`: that +form left files unreadable to the app user on GitHub's stock-runner Docker +(template-fastapi's CI smoke, even with `# syntax=docker/dockerfile:1.28`), +though it worked on a current local daemon. It then runs every +`scripts/runner-setup.d/*.sh` in glob order as `bash NN-name.sh APP_UID`, +still as root and in the same `RUN` layer. The instance puts extra +packages and writable directories there instead of editing the script. + +### Dockerfile version check + +The `check-dockerfile-versions` hook runs +`scripts/check-dockerfile-versions.sh DEBIAN_VERSION` whenever either +Dockerfile, the script or `.pre-commit-config.yaml` (where the ARG list +lives) changes: each named ARG must have the same default in +`.devcontainer/Dockerfile` and `Dockerfile` (the runtime must match the +toolchain the devcontainer tests against). An ARG may be declared several +times in one file (a bare `ARG NAME` re-declares it inside a stage); bare +ones are ignored and every default given must agree. An instance appends the ARG +names it keeps in both files (e.g. `RUST_VERSION`) to the hook's `entry:` +in its `.pre-commit-config.yaml`; ARGs not listed aren't checked. + +### Image hooks + +- `hadolint` lints both Dockerfiles, run from the pinned official image + (so it needs Docker). It fails on warnings and above, not on info-level + findings, and has no repo-wide ignores. An instance that must + install an unpinned apt package adds a per-line + `# hadolint ignore=DL3008` with a reason (as the skeleton `Dockerfile` + does: Debian drops superseded versions, so pins break rebuilds). +- `image-scripts-test` runs `scripts/test-image-scripts.sh`: offline tests + of the Makefile's repo derivation, the version check, the smoke test's + failure on a missing tar and `skopeo.sh`'s argument handling. It needs no + Docker. +- `image-scan` runs only at the `manual` stage, because it needs + the tar from `make build` and network access for the vulnerability + database. It scans only `dist/*--.tar` (arch from + `RELEASE_ARCH`, else the host's; without `RELEASE_VERSION`, the newest + `dist/*-.tar`), never stale tars of other versions. With no such tar + it skips. Otherwise `scripts/image-scan.sh` runs a + pinned Trivy image on that tar and fails on HIGH or CRITICAL + findings that have a fix (`--severity HIGH,CRITICAL --ignore-unfixed + --exit-code 1`). Trivy can't read the OCI-layout tar `make build` + writes, so the script unpacks it and scans the directory, selecting + `linux/` from the tar's name so a buildx attestation manifest is + never scanned instead of the image. The database is cached in the `trivy-cache` Docker + volume. Trivy is Apache-2.0, so invoking it as a tool is compatible + with this repo's GPL-3.0 licence. The scanner is the maintainers' + choice, made over Grype. Trivy's releases and Docker Hub images were + compromised in March 2026 (GHSA-69fq-xp46-6x23), so the image is pinned + by digest as well as tag (`aquasec/trivy:@sha256:…`); the + advisory lists images referenced by digest as unaffected. Renovate + updates tag and digest together. When bumping by hand, take the digest + from the registry and verify it with cosign as the advisory describes: + `cosign verify --certificate-identity-regexp + 'https://github\.com/aquasecurity/' --certificate-oidc-issuer + 'https://token.actions.githubusercontent.com' + ghcr.io/aquasecurity/trivy@sha256:…` (the Docker Hub, GHCR and ECR + digests of a release are identical). + +### Re-parenting an existing app onto template-docker + +One-time steps for an app that is today an instance of `template-base` +(or of a sibling template) and moves under `template-docker`: + +1. Make the tracked files equal `template-docker`'s tag: take the + template's `Makefile`, `scripts/` files, `.dockerignore` and + `.pre-commit-config.yaml` hooks, move the app's Makefile settings into + `image.mk` and its runtime user setup into `scripts/runner-setup.d/`, + and list its version ARGs on the `check-dockerfile-versions` hook. +2. For `template-react` and `template-axum`, which were not synced from + base, migrate to base's layout first (devcontainer, workflows, docs, + manifest) and let a base sync PR settle before this step. +3. Bootstrap the sync state: run `template-sync.yml` manually with + `template_repo=craftainer/template-docker` and `initial_sync_tag` set + to the tag the files now equal. That writes + `.github/template-sync-state.json` without changing files. From then on + syncs track `template-docker`, which in turn tracks base. +4. Resolve base's additions to the manifest by hand when + `template-docker` is the one syncing from base, as the header of + `.github/template-sync-manifest.yml` says. + ## Template sync Once instantiated, a repo created from this template can pull in later @@ -186,7 +412,7 @@ hand) — see that repo's own docs for the two-hop chain this produces. Every version and config value is defined in exactly one place; nothing duplicates or re-pins it elsewhere. Everything pinned here (base image, -Actions, hook revisions, `prek`/Claude Code CLI/`snip`/`uv`/Python/ +Actions, hook revisions, `prek`/Claude Code CLI/`snip`/`gh`/`uv`/Python/ Node.js versions) is pinned once, at its single point of use, to an exact patch version — never a floating range or `latest` — so Renovate can bump them one at a time and the diff shows exactly what changed. An @@ -194,7 +420,7 @@ instance's own language runtime/package versions follow the same rule in its own Dockerfile/manifest. `python3` and Node.js, both installed by `scripts/develop.sh` and pinned -via the Dockerfile's `PYTHON_VERSION`/`NODE_VERSION` ARGs, are +via `.devcontainer/Dockerfile`'s `PYTHON_VERSION`/`NODE_VERSION` ARGs, are infrastructure tooling only — not an application runtime this template assumes. `python3` exists because `prek` needs an interpreter to build the venv for any `language: python` hook (`.pre-commit-config.yaml`'s diff --git a/docs/adrs/0002-same-origin-api-proxy.md b/docs/adrs/0002-same-origin-api-proxy.md index 922b5b8..f263d31 100644 --- a/docs/adrs/0002-same-origin-api-proxy.md +++ b/docs/adrs/0002-same-origin-api-proxy.md @@ -27,7 +27,7 @@ configuring CORS on the backend: stripping the `/api` prefix. `src/api/client.ts`'s `apiFetch` always fetches `/api${path}`, so the app code never knows or cares where the backend actually is. -- **Prod**: `app.Dockerfile`'s `runner` stage's nginx (`scripts/docker/nginx.conf.template`) +- **Prod**: the top-level `Dockerfile`'s `runner` stage's nginx (`scripts/docker/nginx.conf.template`) proxies `/api/` the same way, to `API_PROXY_TARGET`, alongside serving the built SPA with a history-mode fallback. diff --git a/docs/adrs/0003-re-parent-onto-template-docker.md b/docs/adrs/0003-re-parent-onto-template-docker.md new file mode 100644 index 0000000..c4a9f75 --- /dev/null +++ b/docs/adrs/0003-re-parent-onto-template-docker.md @@ -0,0 +1,112 @@ +# 0003. Re-parent onto template-docker, move the app image to `Dockerfile`, and add the UI dependency set + +## Status + +Accepted + +## Context + +template-react was an instance of `template-base` that carried its own +release `Makefile`, `scripts/check-dockerfile-versions.sh` and a separate +`app.Dockerfile` (+ `app.Dockerfile.dockerignore`) for the runtime image, +because base's top-level `Dockerfile` was the devcontainer image. Every +image-shipping template (fastapi, axum, react) repeated that image plumbing. +`template-docker` v1.0.0 is the shared image layer on top of base: the +devcontainer image lives in `.devcontainer/Dockerfile`, the top-level +`Dockerfile` is the instance's runtime image, and a template-owned `Makefile` +implements the release contract (OCI build, SBOM, labels, smoke test, publish +of the tested tar with skopeo) tuned by an optional instance-owned `image.mk` +(its ADR 0001). Track UI of `docs/plans/2026-10-templates-sdks-ui.md` also needs +Storybook, Playwright, axe, Motion, self-hosted fonts and TanStack Query, and +wants them, with the lockfile, in one reviewable change before the items that +use them (UI1 onward) run in parallel. + +## Decision + +We will sync from `template-docker` (`.github/template-sync-state.json`: +`craftainer/template-docker`, `v1.0.0`) and stay a template ourselves, editing +`.github/template-sync-manifest.yml` directly (it started as docker's and adds +react's own paths, so every tracked path is classified). + +**Layout upgrade (one time).** `app.Dockerfile` becomes the top-level +`Dockerfile`; `app.Dockerfile.dockerignore` is deleted and its excludes +(`node_modules`, `coverage`) join the merge-tier `.dockerignore`. The +devcontainer image is now `.devcontainer/Dockerfile` (docker/base's, replace +tier). `NODE_VERSION` in `Dockerfile` follows `.devcontainer/Dockerfile`'s +(24.22.0); `scripts/check-dockerfile-versions.sh DEBIAN_VERSION NODE_VERSION` +keeps them equal. The builder also copies `pnpm-workspace.yaml` (see below). +The runner stays nginx-unprivileged (it does not use `runner-setup.sh`), now on +`1.30.6-alpine` (was `1.27.4-alpine`): the manual-stage `image-scan` hook +(Trivy, HIGH/CRITICAL with a fix) rejected the old base's libraries. + +**`image.mk`** (merge tier, so instances can add values) sets only what differs +from docker's defaults: + +| Variable | Value | Why | +| --------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `IMAGE_SMOKE_PORT` | `8080` | nginx-unprivileged | +| `IMAGE_SMOKE_PATH` | `/` | the SPA's `index.html` | +| `IMAGE_SMOKE_RUN_ARGS` | `-e API_PROXY_TARGET=http://127.0.0.1:8000` | nginx exits on an unresolvable upstream, and the image's default `host.docker.internal` does not resolve in a plain `docker run` on Linux | +| `release-check: unit-tests` | `unit-tests:` runs `pnpm test` | the old `release-check` recipe, now a prerequisite beside docker's `image-smoke` | + +`DOCKERFILE`, `TARGET`, `ARCHES` (`amd64 arm64`) and `OCI_LICENSE` +(`GPL-3.0-or-later`) keep docker's defaults. No `BUILD_ARGS` (the bundle +embeds no version or commit) and no `COVERAGE_REPORT` (no coverage asset is +released), so the coverage label is omitted. + +**Moved or deleted.** Deleted: `app.Dockerfile`, `app.Dockerfile.dockerignore` +and the repo's own release `Makefile` recipes (docker's `Makefile` replaces +them; labels now include `url`, `sbom` and the SPDX `GPL-3.0-or-later` id, and +publish copies the tested tar instead of rebuilding). Old +`check-dockerfile-versions.sh` (which took no arguments) is replaced by docker's. +New, replace-tier from docker: `scripts/image-smoke.sh`, `runner-setup.sh`, +`runner-setup.d/README.md`, `skopeo.sh`, `image-scan.sh`, `.devcontainer/Dockerfile` +and `.devcontainer/Dockerfile.dockerignore`, plus base's newer files (agents, +skills, workflows, `develop.sh`, compose, lock). In the manifest, react's +`Dockerfile`, `.devcontainer/compose.instance.yml` and its two hook scripts are +`replace` for react's instances; `image.mk`, `package.json` and +`pnpm-workspace.yaml` are `merge`. + +**Dependencies** (exact versions, as the rest of `package.json`; installed with +pnpm 12.3.4 and locked; no config, stories, theme or tests yet, those are UI1 +and later): + +| Package | Version | Kind | Purpose | +| ----------------------------------------------------------------------- | ------- | ---- | --------------------------------------------------------------------------------------------------------------------- | +| `storybook`, `@storybook/react-vite`, `@storybook/addon-a11y` | 10.6.3 | dev | component workshop on the existing Vite 8 (the builder supports `^8`) and React 19; the a11y addon runs axe per story | +| `@playwright/test` | 1.64.1 | dev | e2e and visual tests | +| `@axe-core/playwright` | 4.14.0 | dev | accessibility assertions inside Playwright | +| `motion` | 14.4.0 | prod | animation. 14.5.0 is current but was under a day old; see below | +| `@fontsource-variable/fraunces`, `@fontsource-variable/instrument-sans` | 5.3.0 | prod | self-hosted "Field Ledger" fonts (variable builds exist), no third-party font requests | +| `@tanstack/react-query` | 5.104.5 | prod | server-state cache for the CRUD UI | + +We stay on MUI (plan decision P-D4). `@fontsource/roboto` stays until UI1 swaps +the theme's fonts. + +Update: UI1 (ADR 0004) swapped the fonts and removed `@fontsource/roboto`. + +`pnpm-workspace.yaml` (new, merge tier) sets `allowBuilds: { esbuild: false }`. +pnpm 12 fails an install on an unreviewed dependency build script, and +Storybook pulls in esbuild, whose postinstall is only a fast path (its binary +comes from per-platform optional packages). `pnpm-lock.yaml` is run through +Prettier after `pnpm add`, as it was before, because `prettier --check .` +covers it. + +## Consequences + +- Release behaviour is preserved (static nginx image, per-arch build, SBOM, + labels, multi-arch tag) and gains an image smoke test in `release-check`, + per-arch smoke before publish, and publishing of the tested artifact. + Publishing needs `skopeo`, run from a pinned image by docker's script. +- Future template-docker releases arrive through template sync; the + `Makefile` and image scripts are no longer edited here. Per-repo values + go in `image.mk`. +- Anything referencing `app.Dockerfile` must use `Dockerfile` now; the other + Track UI templates' instances inherit the same layout. +- The dependency set adds roughly 100 packages. `motion` is pinned one minor + behind the newest release because pnpm's `minimumReleaseAge` supply-chain + gate rejects packages published under a day ago; do not add an + exclusion for it, let Renovate bump it. `pnpm install` in the builder stage + now needs `pnpm-workspace.yaml` copied beside the lockfile. +- The three unused-until-UI1 groups (Storybook, Playwright, axe) are installed + but unconfigured, so nothing runs them yet and the build is unchanged. diff --git a/docs/adrs/0004-design-system-and-token-lint.md b/docs/adrs/0004-design-system-and-token-lint.md new file mode 100644 index 0000000..32c36cf --- /dev/null +++ b/docs/adrs/0004-design-system-and-token-lint.md @@ -0,0 +1,63 @@ +# 0004. Design system ("Field Ledger") and the design-token lint hook + +## Status + +Accepted + +## Context + +Track UI of `docs/plans/2026-10-templates-sdks-ui.md` wants a UI that does +not look like default model output (Roboto/Inter, purple gradients, no +motion) and stays consistent as Claude and people add screens. The user +picked aesthetic direction A, "Field Ledger", from three RS3 prototypes +(2026-10-20): Fraunces + Instrument Sans, warm paper / vermilion / bottle +green, comfortable density, 2px radius, one quiet row reveal. The prototype +was a scratch theme; this ADR records how it became the production system. + +## Decision + +**Theme.** `src/theme/` holds one MUI theme (`createTheme` with +`cssVariables` and `colorSchemes` light and dark) built from `tokens.ts`, +the only place raw colors and px values live. `colorSchemeSelector` is +`"data"` (MUI sets an empty `data-dark` or `data-light` attribute on +``; not the prototype's `"media"`) so the app can offer a light/dark +toggle: with `"media"` MUI cannot set the mode; with `"data"` and +`defaultMode="system"` it still follows the OS until the user chooses. +`useColorMode()` wraps MUI's `useColorScheme`. Fonts are imported from +`@fontsource-variable/*` (self-hosted; the Roboto import is gone from +`main.tsx`). + +**Motion.** Duration and easing tokens are shared by Motion and CSS. The row +reveal is a pair of Motion variants. `` also mounts +`MotionConfig reducedMotion="user"`, which drops transforms (keeping +opacity) under `prefers-reduced-motion`. + +**Lint.** `scripts/check-design-tokens.sh` rejects hex/`rgb()`/`hsl()` +literals and raw `px` values in `src/**/*.{ts,tsx,css}` outside +`src/theme/`, as the pre-commit hook `design-tokens`. An exception is an +inline `design-tokens: allow ` marker; a marker without a reason +fails. The hook runs the script's `--self-test` (fixtures for every +violation kind, the marker and the `src/theme/` exemption) before scanning, +so the checker cannot silently stop catching things. Test files +(`*.test.ts`, `*.test.tsx`) are skipped because they may assert computed +colors; stories (`*.stories.tsx`) stay covered since they model token usage. + +**Contrast** is asserted in `src/theme/contrast.test.ts` (WCAG 2.2 AA, 4.5:1 +for text); the ratios are in `docs/design/README.md`. + +## Consequences + +- Palette or radius changes happen in one file; components never hardcode. +- The check is a line-based grep, not a parser: it can flag a color-like + string in a comment (use the marker) and miss a color built at runtime + from string pieces. Accepted: it is a guardrail for Claude-written code, + not a security boundary. +- `rem`, `%` and theme spacing replace `px` in app code; `1px` hairlines + come from theme overrides. +- No pre-paint init script: a user whose saved choice differs from the OS + can see one frame of the OS scheme. Adding MUI's `InitColorSchemeScript` + to `index.html` fixes it if that matters; deferred to keep `index.html` + untouched here. +- The `ThemeProvider` wrapper is a third place (with `main.tsx` and + Storybook, UI3) that must mount the theme; stories should use the same + wrapper. diff --git a/docs/adrs/0005-storybook-visual-regression-and-axe.md b/docs/adrs/0005-storybook-visual-regression-and-axe.md new file mode 100644 index 0000000..5534c81 --- /dev/null +++ b/docs/adrs/0005-storybook-visual-regression-and-axe.md @@ -0,0 +1,89 @@ +# 0005. Storybook, visual regression and axe checks + +## Status + +Accepted + +## Context + +Track UI of `docs/plans/2026-10-templates-sdks-ui.md` wants every component +visible in isolation and a pass/fail signal for how it looks, so that neither +a person nor Claude can change the Field Ledger system (ADR 0004) without +noticing. Pixel baselines are only worth having if the same inputs always give +the same pixels, and CI has to reproduce what a developer generated locally. + +The plan said to generate baselines "in the toolchain container against the +stack's Chromium". Two things stood in the way. The stack's Selenium Grid +cannot be driven by Playwright: its CDP endpoint answers `ws://selenium:4444/.../se/cdp` +with HTTP 400 (found in UI1 and UI2), and `toHaveScreenshot` needs Playwright's +own browser connection. And the toolchain image has no fonts or browser +libraries, and installing them with `playwright install --with-deps` pulls +unpinned Debian packages, so the renderer would depend on the day it was built. + +## Decision + +**Storybook 10** (`@storybook/react-vite`, `@storybook/addon-a11y`) with a +story next to every component (`*.stories.tsx`). `.storybook/preview.tsx` wraps +every story in the app's own `ThemeProvider` and a toolbar switch for the +`theme` global (light or dark, applied through MUI's `useColorScheme`, so the +`html[data-dark]` or `html[data-light]` attribute is the real one). Sign-in state and the +backend are faked per story with `parameters.auth` (`signedOut`, `signedIn`, +`loading`) and `parameters.api` (`health`, `heroes`), implemented in +`.storybook/stubs.tsx`; stories never reach Keycloak or the API. Stories are +covered by the design-token lint. + +**Browser: Playwright's own Chromium, in the pinned official Playwright image** +(`mcr.microsoft.com/playwright:v1.64.1-noble`, also pinned by digest), not the stack's Selenium +Chromium and not a browser installed into the toolchain image. The image tag +equals the `@playwright/test` version, ships exactly that version's browser +build and a fixed font set, and is the single renderer for both baselines and +checks: `tests/visual/run.sh` runs it locally against the working tree +(`docker run`, no new service) and `visual.yml` runs the job inside the same +image. The app's own fonts are self-hosted (`@fontsource-variable/*`), so system +fonts only matter for fallbacks. This differs from the plan's wording in the +image, not in intent: the baselines are rendered in a container, by one browser +build, with fonts that cannot drift. + +**Architecture.** The baselines are rendered on `arm64`, so the CI job runs on +the `arm64` runner (`vars.CI_RUNNER_ARM64`, default `ubuntu-24.04-arm`). Chromium +output can differ between CPU architectures at the pixel level; one pinned +architecture is what makes a zero-pixel tolerance reachable. Developers on +`amd64` hosts should run `run.sh` with `--platform linux/arm64` (emulated and +slow) or let CI be the judge and download its report. + +**Tests.** `tests/visual/stories.visual.ts` reads the built Storybook's +`index.json`, so a new story is covered without editing the spec. For each +story it runs, in light and dark, `toHaveScreenshot` (full page, animations +disabled, `maxDiffPixels: 0`) at 1280x720, and an axe scan +(`@axe-core/playwright`) that fails on serious and critical violations. +Whole-app stories (title `App/...`) also run at 390x844. Baselines are +committed under `tests/visual/__screenshots__/`. + +**Updating baselines.** After an intended visual change: +`tests/visual/run.sh update`, then look at every changed PNG in the diff +(`git diff --stat`, open the images) before committing them with the change +that caused them. Never update to make a red run green without looking. A +Playwright version bump changes the image tag and its `@sha256:` digest +(`docker buildx imagetools inspect `, the index digest) in `run.sh` and +`visual.yml` together and regenerates all baselines in the same commit. + +**CI.** `.github/workflows/visual.yml` runs on pull requests and on demand: +install, `storybook build`, `playwright test`; on failure it uploads +`playwright-report/` and `test-results/` (expected, actual and diff images) as +an artifact. + +## Consequences + +- A visual change is a reviewable diff of PNGs, and an accessibility + regression fails the build before it reaches the page. +- The suite needs Docker locally and a Playwright image pull (about 1.5 GB); + Playwright's version is pinned in three places (`package.json`, `run.sh`, + `visual.yml`) that must move together. A mismatch fails loudly: Playwright + refuses a browser build that is not its own. +- Baselines are valid for `arm64` only. Moving CI to `amd64` means + regenerating them there. +- axe only sees what a story renders. It finds roughly the automatable half of + WCAG issues; keyboard and screen-reader behaviour still need a human. +- `.storybook/stubs.tsx` and the baselines are instance content; the rest of + the setup is template-owned and copies to other instances (it is the base for + `lib-crud-views-ts`). diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..e22ce09 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,108 @@ +# docs/design/ + +The design system's principles: what the app should look and feel like, +and the rules that keep it that way. The tokens themselves live in code +(`src/theme/`); this page says how to use them. Why the system is shaped +this way is in +[`../adrs/0004-design-system-and-token-lint.md`](../adrs/0004-design-system-and-token-lint.md). + +## Direction: "Field Ledger" + +Chosen by the user on 2026-10-20 from three candidates (RS3). An editorial, +archival feel: a ledger kept in a field office. + +- **Type:** Fraunces (display, headings) and Instrument Sans (body, UI), + both self-hosted variable fonts from `@fontsource-variable/*`. No CDN. +- **Color:** warm paper surfaces, vermilion for the one primary action, + bottle green as the quiet secondary. Ink-dark text, never pure black. +- **Density:** comfortable. Table cells pad 14px/16px; buttons 8px/20px. +- **Shape:** 2px radius everywhere. Rules (hairlines) do the work that + shadows and rounding do elsewhere: a 2px ink rule under the app bar and + table header, 1px dividers between rows. +- **Motion:** one quiet row reveal (fade plus a 4px settle, staggered 30ms). + +## Anti-defaults + +Generic output is the failure mode this system exists to avoid. Do not: + +- fall back to Roboto, Inter or system-ui as the face of the UI; +- use purple/blue gradients, glass blur, or drop-shadow cards; +- round things beyond the 2px base, or use pill buttons; +- use ALL-CAPS anywhere except table column heads; +- add decoration motion (parallax, bouncing, looping). One reveal only; +- reach for a second accent color. Status colors (error, warning, info, + success) are for status, not decoration. + +## Token usage + +- Colors come from the MUI palette via CSS variables: `sx={{ color: + "text.secondary" }}`, or `theme.vars.palette.*` in style callbacks. + Never a hex, `rgb()` or `hsl()` literal outside `src/theme/`. +- Spacing is `theme.spacing(n)` (8px unit); `sx={{ p: 2 }}` already uses + it. Never a raw `px` outside `src/theme/`. Use `rem`, `%`, theme + spacing or a token. +- Radius comes from `theme.shape.borderRadius` (2px) or `tokens.radius`. +- Fonts: headings use the theme's typography variants (`h1`–`h6`, which + carry Fraunces); do not set `fontFamily` by hand. +- Motion durations and easings are `tokens.duration`, `tokens.easing`, + `tokens.cssMotion`. The row reveal is `rowRevealContainer` + `rowReveal` + from `src/theme/motion.ts`. +- `scripts/check-design-tokens.sh` (pre-commit hook `design-tokens`) + rejects violations in `src/**/*.{ts,tsx,css}`. A justified exception + carries an inline marker on the same line, with a reason: + `// design-tokens: allow `. A marker with no reason fails. + +Public API (`import { ... } from "./theme"`): `ThemeProvider`, `theme`, +`tokens`, `useColorMode`, `rowReveal`, `rowRevealContainer`. + +## Light and dark + +- Both schemes are first-class and ship together; a component is not done + until it is checked in both. +- The default is the OS preference. `useColorMode()` returns `mode` + (`"light" | "dark" | "system"`), `resolved`, `setMode` and `toggle`; + MUI persists the choice in `localStorage`. +- Colors switch through CSS variables under an `html[data-dark]` or `html[data-light]` attribute (MUI's + `colorSchemeSelector: "data"`), so + `sx` and `theme.vars.*` need no per-scheme branches. Dark is not an + inversion: ink and paper swap, and accents lighten (vermilion + `#E8795C`, green `#9CC0A8`) to keep contrast. +- Known limit: there is no pre-paint init script, so a user who chose a + scheme different from the OS can see one frame of the OS scheme on + load. + +## Contrast + +Target: WCAG 2.2 AA, 4.5:1 for text, 3:1 for large text and UI +boundaries. `src/theme/contrast.test.ts` asserts every pair below at +4.5:1 and fails the build if a palette edit breaks one. + +| Pair | Light | Dark | +| --------------------------------- | -----: | ----: | +| text.primary on paper | 15.74 | 13.56 | +| text.secondary on paper | 7.11 | 7.29 | +| text.secondary on default | 6.65 | 7.92 | +| primary on paper | 5.68 | 5.80 | +| primary contrastText on primary | 5.75 | 6.30 | +| secondary on paper | 9.09 | 8.33 | +| error on paper | 7.45 | 6.85 | +| warning on paper | 5.92 | 7.88 | +| info on paper | 6.35 | 7.82 | +| success on paper | 5.97 | 8.52 | + +Divider on paper is 1.47 (light) and 1.35 (dark). It is decoration, not +the only cue for anything; never use it as the sole boundary of an +interactive control (use the 2px ink rule or a text-colored border, +which clear 3:1). + +## Motion and accessibility + +- `` wraps the app in `MotionConfig reducedMotion="user"`: + under `prefers-reduced-motion`, transforms are dropped and only + opacity animates. CSS transitions in the theme are short (160ms) color + changes only. +- Motion never carries meaning on its own and never blocks input. +- Keep MUI's focus ring; do not remove outlines. +- Text scales: sizes are `rem`, so the browser's font-size setting + applies. Check layouts at 200% zoom. +- Run axe over every story and page (UI3 automates this). diff --git a/docs/plans/README.md b/docs/plans/README.md index 80befde..7b1e516 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -8,6 +8,47 @@ made. A plan can reference an ADR it depends on or that it prompts. - Filename: `YYYY-MM-short-title.md` (the month the plan was started), e.g. `2026-01-add-billing.md`. +## Execution section + +A plan with more than one independent work item is executed by an +orchestrating session that only decides and raises gates, through +`plan-executor` subagents as executors and landers (see +`../../CLAUDE.md`, "Execute multi-item work as an orchestrator", and the +`plan-execution` skill; plans themselves are written with the +`plan-creation` skill, both in `../../.claude/skills/`). It +needs an `## Execution` section laid out so the orchestrator can turn +each item straight into a brief: + +- **Work items:** each has an ID, the plan text it implements, a + **write set** (exact files and directories), and its dependencies. +- **Waves:** items grouped so that every item in a wave has a write set + disjoint from the others in that wave. A file nearly everything + touches (a rename, dependency additions to `pyproject.toml` and the + lockfile) goes in its own serial wave first, so later items never + fight over it. +- **Interfaces:** where items in the same wave depend on each other, + name the function or module contract between them, so both sides can + be built in parallel against it. +- **Pre-assigned numbers:** sequentially numbered files (ADRs, NFRs, + migrations) that parallel items create get their numbers fixed in the + plan, so two items never pick the same one. +- **Serialized lanes:** anything sharing devcontainer services + (integration and e2e suites, load tests and benchmarks) runs one at a + time, outside the parallel waves, by a lander (never the orchestrator). + Measurements also need a quiet window, with no parallel agents + competing for CPU. +- **Standing approvals:** which merges and releases landers may do + without asking (for example, template releases and merges in repos the + plan creates); a PR the user merges is never among them. +- **User gates:** a table of every outward-facing or hard-to-reverse + step (creating repos, pushing, opening PRs, publishing, tagging) that + waits for the user's explicit yes, except what the plan's kickoff + gate (G0) or its standing approvals grant landers (pushing and + opening the wave PRs). +- **Decision log:** a dated table of settled decisions. Executors treat + it as final, and the orchestrator appends to it as it resolves + escalations. + ## Do - Update a plan's own Status as work progresses, in place — a plan is a diff --git a/docs/plans/template.md b/docs/plans/template.md index 1fb2faa..a7f6429 100644 --- a/docs/plans/template.md +++ b/docs/plans/template.md @@ -15,6 +15,20 @@ The steps, in order, at the level of detail someone else could pick this up from. Link to relevant ADRs (`../adrs/NNNN-....md`) instead of repeating their reasoning here. +## Execution + +Only for plans with more than one independent work item: waves of work +items (ID, write set, dependencies), interfaces between parallel items, +pre-assigned numbers, serialized lanes, and user gates. See +`README.md`'s "Execution section". Remove this section for a +single-item plan. + +## Decision log + +| Date | Decision | +| --- | --- | +| YYYY-MM-DD | A settled decision executors must not reopen. | + ## Open questions Anything genuinely undecided that the plan depends on. Remove this diff --git a/image.mk b/image.mk new file mode 100644 index 0000000..d728e1e --- /dev/null +++ b/image.mk @@ -0,0 +1,25 @@ +# template-react's values for template-docker's image release Makefile +# (`-include image.mk`); see docs/TEMPLATE.md's "Image layer". Merge tier: an +# instance edits this file for its own values and a sync keeps them. + +# DOCKERFILE and TARGET keep template-docker's defaults (`Dockerfile`, +# `runner`); ARCHES, OCI_LICENSE too. No APP_VERSION/APP_COMMIT build args: +# the SPA bundle embeds neither. No COVERAGE_REPORT: no coverage asset is +# released. + +# The runner is nginx-unprivileged on 8080 serving the SPA. Its env defaults +# point API_PROXY_TARGET at host.docker.internal, which a plain `docker run` +# on Linux can't resolve, and nginx refuses to start on an unresolvable +# upstream -- so the smoke run points it at loopback instead (the smoke test +# never calls /api). +IMAGE_SMOKE_PORT := 8080 +IMAGE_SMOKE_PATH := / +IMAGE_SMOKE_RUN_ARGS := -e API_PROXY_TARGET=http://127.0.0.1:8000 + +# The unit tests that were release-check's whole recipe before the image +# smoke test joined it as a prerequisite. +# .PHONY so a directory named unit-tests can't make Make skip the tests. +.PHONY: unit-tests +release-check: unit-tests +unit-tests: + pnpm test diff --git a/package.json b/package.json index 51fc785..54be735 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,29 @@ "format": "prettier --check .", "format:write": "prettier --write .", "test": "vitest run", - "preview": "vite preview" + "preview": "vite preview", + "storybook": "storybook dev --port 6006 --no-open", + "storybook:build": "storybook build" }, "dependencies": { "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", - "@fontsource/roboto": "5.3.0", + "@fontsource-variable/fraunces": "5.3.0", + "@fontsource-variable/instrument-sans": "5.3.0", "@mui/icons-material": "9.4.0", "@mui/material": "9.4.0", + "@tanstack/react-query": "5.104.5", + "motion": "14.4.0", "oidc-client-ts": "3.5.0", "react": "19.2.8", "react-dom": "19.2.8", "react-oidc-context": "3.3.1" }, "devDependencies": { + "@axe-core/playwright": "4.14.0", + "@playwright/test": "1.64.1", + "@storybook/addon-a11y": "10.6.3", + "@storybook/react-vite": "10.6.3", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.7", @@ -36,6 +45,7 @@ "jsdom": "30.0.1", "oxlint": "1.81.0", "prettier": "3.9.6", + "storybook": "10.6.3", "typescript": "6.0.3", "vite": "8.2.2", "vitest": "5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4f2ecd..dff9eb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,10 @@ importers: "@emotion/styled": specifier: 11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) - "@fontsource/roboto": + "@fontsource-variable/fraunces": + specifier: 5.3.0 + version: 5.3.0 + "@fontsource-variable/instrument-sans": specifier: 5.3.0 version: 5.3.0 "@mui/icons-material": @@ -147,6 +150,12 @@ importers: "@mui/material": specifier: 9.4.0 version: 9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + "@tanstack/react-query": + specifier: 5.104.5 + version: 5.104.5(@types/react@19.2.18)(react@19.2.8) + motion: + specifier: 14.4.0 + version: 14.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) oidc-client-ts: specifier: 3.5.0 version: 3.5.0 @@ -160,9 +169,21 @@ importers: specifier: 3.3.1 version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.8) devDependencies: + "@axe-core/playwright": + specifier: 4.14.0 + version: 4.14.0(playwright-core@1.64.1) + "@playwright/test": + specifier: 1.64.1 + version: 1.64.1 + "@storybook/addon-a11y": + specifier: 10.6.3 + version: 10.6.3(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + "@storybook/react-vite": + specifier: 10.6.3 + version: 10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) "@testing-library/jest-dom": specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3))) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))) "@testing-library/react": specifier: 16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -180,7 +201,7 @@ importers: version: 19.2.7(@types/react@19.2.18) "@vitejs/plugin-react": specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) jsdom: specifier: 30.0.1 version: 30.0.1 @@ -190,15 +211,18 @@ importers: prettier: specifier: 3.9.6 version: 3.9.6 + storybook: + specifier: 10.6.3 + version: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) typescript: specifier: 6.0.3 version: 6.0.3 vite: specifier: 8.2.2 - version: 8.2.2(@types/node@24.13.3) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) vitest: specifier: 5.0.0 - version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)) + version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) packages: "@adobe/css-tools@4.5.0": @@ -221,6 +245,15 @@ packages: } engines: { node: ^22.13.0 || >=24.0.0 } + "@axe-core/playwright@4.14.0": + resolution: + { + integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==, + } + engines: { node: ">=22.12.0" } + peerDependencies: + playwright-core: ">= 1.0.0" + "@babel/code-frame@7.29.7": resolution: { @@ -228,6 +261,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/compat-data@7.29.7": + resolution: + { + integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==, + } + engines: { node: ">=6.9.0" } + + "@babel/core@7.29.7": + resolution: + { + integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==, + } + engines: { node: ">=6.9.0" } + "@babel/generator@7.29.8": resolution: { @@ -235,6 +282,13 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-compilation-targets@7.29.7": + resolution: + { + integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==, + } + engines: { node: ">=6.9.0" } + "@babel/helper-globals@7.29.7": resolution: { @@ -249,6 +303,15 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-module-transforms@7.29.7": + resolution: + { + integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0 + "@babel/helper-string-parser@7.29.7": resolution: { @@ -263,6 +326,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-validator-option@7.29.7": + resolution: + { + integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==, + } + engines: { node: ">=6.9.0" } + + "@babel/helpers@7.29.10": + resolution: + { + integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==, + } + engines: { node: ">=6.9.0" } + "@babel/parser@7.29.8": resolution: { @@ -360,6 +437,42 @@ packages: } engines: { node: ">=20.19.0" } + "@emnapi/core@1.11.0": + resolution: + { + integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==, + } + + "@emnapi/core@1.9.2": + resolution: + { + integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==, + } + + "@emnapi/runtime@1.11.0": + resolution: + { + integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==, + } + + "@emnapi/runtime@1.9.2": + resolution: + { + integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==, + } + + "@emnapi/wasi-threads@1.2.1": + resolution: + { + integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==, + } + + "@emnapi/wasi-threads@1.2.2": + resolution: + { + integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==, + } + "@emotion/babel-plugin@11.13.5": resolution: { @@ -453,6 +566,240 @@ packages: integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==, } + "@esbuild/aix-ppc64@0.28.2": + resolution: + { + integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [aix] + + "@esbuild/android-arm64@0.28.2": + resolution: + { + integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [android] + + "@esbuild/android-arm@0.28.2": + resolution: + { + integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [android] + + "@esbuild/android-x64@0.28.2": + resolution: + { + integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [android] + + "@esbuild/darwin-arm64@0.28.2": + resolution: + { + integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [darwin] + + "@esbuild/darwin-x64@0.28.2": + resolution: + { + integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [darwin] + + "@esbuild/freebsd-arm64@0.28.2": + resolution: + { + integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [freebsd] + + "@esbuild/freebsd-x64@0.28.2": + resolution: + { + integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [freebsd] + + "@esbuild/linux-arm64@0.28.2": + resolution: + { + integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [linux] + + "@esbuild/linux-arm@0.28.2": + resolution: + { + integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [linux] + + "@esbuild/linux-ia32@0.28.2": + resolution: + { + integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [linux] + + "@esbuild/linux-loong64@0.28.2": + resolution: + { + integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==, + } + engines: { node: ">=18" } + cpu: [loong64] + os: [linux] + + "@esbuild/linux-mips64el@0.28.2": + resolution: + { + integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==, + } + engines: { node: ">=18" } + cpu: [mips64el] + os: [linux] + + "@esbuild/linux-ppc64@0.28.2": + resolution: + { + integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [linux] + + "@esbuild/linux-riscv64@0.28.2": + resolution: + { + integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==, + } + engines: { node: ">=18" } + cpu: [riscv64] + os: [linux] + + "@esbuild/linux-s390x@0.28.2": + resolution: + { + integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==, + } + engines: { node: ">=18" } + cpu: [s390x] + os: [linux] + + "@esbuild/linux-x64@0.28.2": + resolution: + { + integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [linux] + + "@esbuild/netbsd-arm64@0.28.2": + resolution: + { + integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [netbsd] + + "@esbuild/netbsd-x64@0.28.2": + resolution: + { + integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [netbsd] + + "@esbuild/openbsd-arm64@0.28.2": + resolution: + { + integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openbsd] + + "@esbuild/openbsd-x64@0.28.2": + resolution: + { + integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [openbsd] + + "@esbuild/openharmony-arm64@0.28.2": + resolution: + { + integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + + "@esbuild/sunos-x64@0.28.2": + resolution: + { + integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [sunos] + + "@esbuild/win32-arm64@0.28.2": + resolution: + { + integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [win32] + + "@esbuild/win32-ia32@0.28.2": + resolution: + { + integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [win32] + + "@esbuild/win32-x64@0.28.2": + resolution: + { + integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [win32] + "@exodus/bytes@1.15.1": resolution: { @@ -465,18 +812,42 @@ packages: "@noble/hashes": optional: true - "@fontsource/roboto@5.3.0": + "@fontsource-variable/fraunces@5.3.0": + resolution: + { + integrity: sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==, + } + + "@fontsource-variable/instrument-sans@5.3.0": resolution: { - integrity: sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==, + integrity: sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw==, } + "@joshwooding/vite-plugin-react-docgen-typescript@0.7.0": + resolution: + { + integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==, + } + peerDependencies: + typescript: ">= 4.3.x" + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + "@jridgewell/gen-mapping@0.3.13": resolution: { integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, } + "@jridgewell/remapping@2.3.5": + resolution: + { + integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, + } + "@jridgewell/resolve-uri@3.1.2": resolution: { @@ -611,13 +982,376 @@ packages: "@types/react": optional: true - "@oxc-project/types@0.148.0": + "@napi-rs/wasm-runtime@1.2.5": resolution: { - integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==, + integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==, } + engines: { node: ^20.19.0 || ^22.13.0 || >=23.5.0 } + peerDependencies: + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 - "@oxlint/binding-android-arm-eabi@1.81.0": + "@oxc-parser/binding-android-arm-eabi@0.127.0": + resolution: + { + integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [android] + + "@oxc-parser/binding-android-arm64@0.127.0": + resolution: + { + integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + "@oxc-parser/binding-darwin-arm64@0.127.0": + resolution: + { + integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + "@oxc-parser/binding-darwin-x64@0.127.0": + resolution: + { + integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + "@oxc-parser/binding-freebsd-x64@0.127.0": + resolution: + { + integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + "@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + resolution: + { + integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + resolution: + { + integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@oxc-parser/binding-linux-arm64-gnu@0.127.0": + resolution: + { + integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@oxc-parser/binding-linux-arm64-musl@0.127.0": + resolution: + { + integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + resolution: + { + integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + resolution: + { + integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + "@oxc-parser/binding-linux-riscv64-musl@0.127.0": + resolution: + { + integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [riscv64] + os: [linux] + libc: [musl] + + "@oxc-parser/binding-linux-s390x-gnu@0.127.0": + resolution: + { + integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@oxc-parser/binding-linux-x64-gnu@0.127.0": + resolution: + { + integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@oxc-parser/binding-linux-x64-musl@0.127.0": + resolution: + { + integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + "@oxc-parser/binding-openharmony-arm64@0.127.0": + resolution: + { + integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + "@oxc-parser/binding-wasm32-wasi@0.127.0": + resolution: + { + integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [wasm32] + + "@oxc-parser/binding-win32-arm64-msvc@0.127.0": + resolution: + { + integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + "@oxc-parser/binding-win32-ia32-msvc@0.127.0": + resolution: + { + integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ia32] + os: [win32] + + "@oxc-parser/binding-win32-x64-msvc@0.127.0": + resolution: + { + integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + "@oxc-project/types@0.127.0": + resolution: + { + integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==, + } + + "@oxc-project/types@0.148.0": + resolution: + { + integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==, + } + + "@oxc-resolver/binding-android-arm-eabi@11.21.2": + resolution: + { + integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==, + } + cpu: [arm] + os: [android] + + "@oxc-resolver/binding-android-arm64@11.21.2": + resolution: + { + integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==, + } + cpu: [arm64] + os: [android] + + "@oxc-resolver/binding-darwin-arm64@11.21.2": + resolution: + { + integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==, + } + cpu: [arm64] + os: [darwin] + + "@oxc-resolver/binding-darwin-x64@11.21.2": + resolution: + { + integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==, + } + cpu: [x64] + os: [darwin] + + "@oxc-resolver/binding-freebsd-x64@11.21.2": + resolution: + { + integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==, + } + cpu: [x64] + os: [freebsd] + + "@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2": + resolution: + { + integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==, + } + cpu: [arm] + os: [linux] + + "@oxc-resolver/binding-linux-arm-musleabihf@11.21.2": + resolution: + { + integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==, + } + cpu: [arm] + os: [linux] + + "@oxc-resolver/binding-linux-arm64-gnu@11.21.2": + resolution: + { + integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@oxc-resolver/binding-linux-arm64-musl@11.21.2": + resolution: + { + integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@oxc-resolver/binding-linux-ppc64-gnu@11.21.2": + resolution: + { + integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==, + } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@oxc-resolver/binding-linux-riscv64-gnu@11.21.2": + resolution: + { + integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==, + } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + "@oxc-resolver/binding-linux-riscv64-musl@11.21.2": + resolution: + { + integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==, + } + cpu: [riscv64] + os: [linux] + libc: [musl] + + "@oxc-resolver/binding-linux-s390x-gnu@11.21.2": + resolution: + { + integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==, + } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@oxc-resolver/binding-linux-x64-gnu@11.21.2": + resolution: + { + integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@oxc-resolver/binding-linux-x64-musl@11.21.2": + resolution: + { + integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + "@oxc-resolver/binding-openharmony-arm64@11.21.2": + resolution: + { + integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==, + } + cpu: [arm64] + os: [openharmony] + + "@oxc-resolver/binding-wasm32-wasi@11.21.2": + resolution: + { + integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==, + } + engines: { node: ">=14.0.0" } + cpu: [wasm32] + + "@oxc-resolver/binding-win32-arm64-msvc@11.21.2": + resolution: + { + integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==, + } + cpu: [arm64] + os: [win32] + + "@oxc-resolver/binding-win32-x64-msvc@11.21.2": + resolution: + { + integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==, + } + cpu: [x64] + os: [win32] + + "@oxlint/binding-android-arm-eabi@1.81.0": resolution: { integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==, @@ -796,6 +1530,14 @@ packages: cpu: [x64] os: [win32] + "@playwright/test@1.64.1": + resolution: + { + integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==, + } + engines: { node: ">=20" } + hasBin: true + "@popperjs/core@2.11.8": resolution: { @@ -949,6 +1691,119 @@ packages: integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==, } + "@rollup/pluginutils@5.4.0": + resolution: + { + integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==, + } + engines: { node: ">=14.0.0" } + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + "@storybook/addon-a11y@10.6.3": + resolution: + { + integrity: sha512-nqvjpmu5g+PPtEKwgE0qAi4wfuQ9yngOb/1Gm24pBBZOuRsd3UMEBjKORSuAGrIAz9X3LCFfQi7sCcBFXvFbwg==, + } + peerDependencies: + storybook: ^10.6.3 + + "@storybook/builder-vite@10.6.3": + resolution: + { + integrity: sha512-1+WCZZg/fPxxyHDB5sFKniFt9/4RFUIzoNqMujv+BLRROM+gQeyUHvdvoyZOZ51+P8ee+/PgnNKHjsTupV+nOA==, + } + peerDependencies: + storybook: ^10.6.3 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + "@storybook/global@5.0.0": + resolution: + { + integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==, + } + + "@storybook/icons@2.2.0": + resolution: + { + integrity: sha512-FpUB/xizIrV1ILhCIYFo+TtQNN9YEoLjfcnAW0JtOXL+BcM2aisSysI0B54JMWDnljriXySBHgN6ps4s9lb75A==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + "@storybook/react-dom-shim@10.6.3": + resolution: + { + integrity: sha512-cJUuPrieiTl06kzlgaU89iG+CtLAgUTt0Xdvm8qFT9yR2qwafSaswhwYWlZCNX4nWuOu7eK7uzWTwzKqyDBOWQ==, + } + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + "@types/react-dom": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.3 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + + "@storybook/react-vite@10.6.3": + resolution: + { + integrity: sha512-geWwLi9JnaRG2t9bYBfz+94skARzzXNT1Toaf0lk7Oog13rtNK7ay2pWNm9SP6/L2S7mEjoWu9+zHLpwxXvfIw==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.3 + typescript: ">= 4.9.x" + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + "@storybook/react@10.6.3": + resolution: + { + integrity: sha512-3tfAkZNdUyx3I4IjM8dgbNWg4k0gj89Hi1MPiawfTybRRWhvnX2YqV9Ut4mrud7YzRv0v+U7+ZYEvj+maUPaow==, + } + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + "@types/react-dom": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.3 + typescript: ">= 4.9.x" + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + typescript: + optional: true + + "@tanstack/query-core@5.104.5": + resolution: + { + integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==, + } + + "@tanstack/react-query@5.104.5": + resolution: + { + integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==, + } + peerDependencies: + "@types/react": ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + "@types/react": + optional: true + "@testing-library/dom@10.4.1": resolution: { @@ -956,6 +1811,13 @@ packages: } engines: { node: ">=18" } + "@testing-library/jest-dom@6.9.1": + resolution: + { + integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==, + } + engines: { node: ">=14", npm: ">=6", yarn: ">=1" } + "@testing-library/jest-dom@7.0.1": resolution: { @@ -996,12 +1858,42 @@ packages: peerDependencies: "@testing-library/dom": ">=7.21.4" + "@tybys/wasm-util@0.10.4": + resolution: + { + integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==, + } + "@types/aria-query@5.0.4": resolution: { integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, } + "@types/babel__core@7.20.5": + resolution: + { + integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + } + + "@types/babel__generator@7.27.0": + resolution: + { + integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==, + } + + "@types/babel__template@7.4.4": + resolution: + { + integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, + } + + "@types/babel__traverse@7.28.0": + resolution: + { + integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, + } + "@types/chai@5.2.3": resolution: { @@ -1014,6 +1906,12 @@ packages: integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, } + "@types/doctrine@0.0.9": + resolution: + { + integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==, + } + "@types/estree@1.0.9": resolution: { @@ -1060,6 +1958,12 @@ packages: integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==, } + "@types/resolve@1.20.6": + resolution: + { + integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, + } + "@vitejs/plugin-react@6.1.1": resolution: { @@ -1079,6 +1983,12 @@ packages: oxc-transform-react: optional: true + "@vitest/expect@3.2.4": + resolution: + { + integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, + } + "@vitest/mocker@5.0.0": resolution: { @@ -1093,12 +2003,36 @@ packages: vite: optional: true + "@vitest/pretty-format@3.2.4": + resolution: + { + integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, + } + + "@vitest/spy@3.2.4": + resolution: + { + integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, + } + "@vitest/spy@5.0.0": resolution: { integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==, } + "@vitest/utils@3.2.4": + resolution: + { + integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, + } + + "@webcontainer/env@1.1.1": + resolution: + { + integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==, + } + ansi-regex@5.0.1: resolution: { @@ -1133,6 +2067,20 @@ packages: } engines: { node: ">=12" } + ast-types@0.16.3: + resolution: + { + integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==, + } + engines: { node: ">=4" } + + axe-core@4.14.0: + resolution: + { + integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==, + } + engines: { node: ">=4" } + babel-plugin-macros@3.1.0: resolution: { @@ -1140,12 +2088,49 @@ packages: } engines: { node: ">=10", npm: ">=6" } + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + baseline-browser-mapping@2.11.30: + resolution: + { + integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==, + } + engines: { node: ">=6.0.0" } + hasBin: true + bidi-js@1.0.3: resolution: { integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==, } + brace-expansion@5.0.12: + resolution: + { + integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==, + } + engines: { node: 20 || >=22 } + + browserslist@4.29.4: + resolution: + { + integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==, + } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + hasBin: true + + bundle-name@4.1.1: + resolution: + { + integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==, + } + engines: { node: ">=18" } + callsites@3.1.0: resolution: { @@ -1153,6 +2138,19 @@ packages: } engines: { node: ">=6" } + caniuse-lite@1.0.30001817: + resolution: + { + integrity: sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==, + } + + chai@5.3.3: + resolution: + { + integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, + } + engines: { node: ">=18" } + chai@6.2.2: resolution: { @@ -1160,6 +2158,13 @@ packages: } engines: { node: ">=18" } + check-error@2.1.3: + resolution: + { + integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==, + } + engines: { node: ">= 16" } + clsx@2.1.1: resolution: { @@ -1173,6 +2178,12 @@ packages: integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==, } + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + cosmiconfig@7.1.0: resolution: { @@ -1224,6 +2235,34 @@ packages: integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==, } + deep-eql@5.0.2: + resolution: + { + integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, + } + engines: { node: ">=6" } + + default-browser-id@5.0.1: + resolution: + { + integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==, + } + engines: { node: ">=18" } + + default-browser@5.5.1: + resolution: + { + integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==, + } + engines: { node: ">=18" } + + define-lazy-prop@3.0.0: + resolution: + { + integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==, + } + engines: { node: ">=12" } + dequal@2.0.3: resolution: { @@ -1238,6 +2277,13 @@ packages: } engines: { node: ">=8" } + doctrine@3.0.0: + resolution: + { + integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==, + } + engines: { node: ">=6.0.0" } + dom-accessibility-api@0.5.16: resolution: { @@ -1256,6 +2302,19 @@ packages: integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==, } + electron-to-chromium@1.5.462: + resolution: + { + integrity: sha512-9qpCBlXPy5v7n1Q7WTO5rW2StI4WNQhl96HpgXRZLPlNoXAD0LbsQUflovQYAO44Yia073ISusqohncvGSMbGA==, + } + + empathic@2.1.0: + resolution: + { + integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==, + } + engines: { node: ">=14" } + entities@8.0.0: resolution: { @@ -1282,6 +2341,21 @@ packages: integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==, } + esbuild@0.28.2: + resolution: + { + integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==, + } + engines: { node: ">=18" } + hasBin: true + + escalade@3.2.0: + resolution: + { + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, + } + engines: { node: ">=6" } + escape-string-regexp@4.0.0: resolution: { @@ -1289,12 +2363,33 @@ packages: } engines: { node: ">=10" } + esprima@4.0.1: + resolution: + { + integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, + } + engines: { node: ">=4" } + hasBin: true + + estree-walker@2.0.2: + resolution: + { + integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, + } + estree-walker@3.0.3: resolution: { integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, } + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: ">=0.10.0" } + expect-type@1.4.0: resolution: { @@ -1320,6 +2415,20 @@ packages: integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==, } + framer-motion@14.4.0: + resolution: + { + integrity: sha512-HtO1fxord2rnXYRyr1laG4uY7+OtgwDc9Szcg1wlMq/LF7BO3/S7a+KEo691U7pSi+i0ueRuRqaD1UaLEmZHuQ==, + } + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: { @@ -1334,6 +2443,20 @@ packages: integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, } + gensync@1.0.0-beta.2: + resolution: + { + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, + } + engines: { node: ">=6.9.0" } + + glob@13.0.6: + resolution: + { + integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==, + } + engines: { node: 18 || 20 || >=22 } + hasown@2.0.4: resolution: { @@ -1381,12 +2504,35 @@ packages: } engines: { node: ">= 0.4" } + is-docker@3.0.0: + resolution: + { + integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + hasBin: true + + is-inside-container@1.0.0: + resolution: + { + integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==, + } + engines: { node: ">=14.16" } + hasBin: true + is-potential-custom-element-name@1.0.1: resolution: { integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, } + is-wsl@3.1.1: + resolution: + { + integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==, + } + engines: { node: ">=16" } + js-tokens@4.0.0: resolution: { @@ -1419,6 +2565,20 @@ packages: integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, } + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + } + engines: { node: ">=6" } + hasBin: true + + jsonc-parser@3.3.1: + resolution: + { + integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==, + } + jwt-decode@4.0.0: resolution: { @@ -1549,6 +2709,12 @@ packages: } hasBin: true + loupe@3.2.1: + resolution: + { + integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, + } + lru-cache@11.5.2: resolution: { @@ -1556,6 +2722,12 @@ packages: } engines: { node: 20 || >=22 } + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, + } + lz-string@1.5.0: resolution: { @@ -1582,6 +2754,52 @@ packages: } engines: { node: ">=4" } + minimatch@10.2.6: + resolution: + { + integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==, + } + engines: { node: 18 || 20 || >=22 } + + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, + } + + minipass@7.1.3: + resolution: + { + integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==, + } + engines: { node: ">=16 || 14 >=14.17" } + + motion-dom@14.4.0: + resolution: + { + integrity: sha512-1q8RyHSdr5COpzrv3JgJ9XRjOO88ZN9CwflpkGgyO/SR/SNRP/dD6EU8SEo092kcKY34IzHC5diJPKrQRMA8lQ==, + } + + motion-utils@14.1.1: + resolution: + { + integrity: sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==, + } + + motion@14.4.0: + resolution: + { + integrity: sha512-JJTIdeNjtF2zi/py0ndRKxQF+8z2pcDUgJYXul7zqbhMvyTLRsPFR9ifE/j4cFCpCMlehQVJkTwd7BZFiPKgQg==, + } + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: { @@ -1596,6 +2814,13 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + node-releases@2.0.59: + resolution: + { + integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==, + } + engines: { node: ">=18" } + object-assign@4.1.1: resolution: { @@ -1617,6 +2842,26 @@ packages: } engines: { node: ">=18" } + open@10.2.0: + resolution: + { + integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==, + } + engines: { node: ">=18" } + + oxc-parser@0.127.0: + resolution: + { + integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + + oxc-resolver@11.21.2: + resolution: + { + integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==, + } + oxlint@1.81.0: resolution: { @@ -1659,6 +2904,13 @@ packages: integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, } + path-scurry@2.0.2: + resolution: + { + integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==, + } + engines: { node: 18 || 20 || >=22 } + path-type@4.0.0: resolution: { @@ -1666,6 +2918,13 @@ packages: } engines: { node: ">=8" } + pathval@2.0.1: + resolution: + { + integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==, + } + engines: { node: ">= 14.16" } + picocolors@1.1.1: resolution: { @@ -1679,6 +2938,22 @@ packages: } engines: { node: ">=12" } + playwright-core@1.64.1: + resolution: + { + integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==, + } + engines: { node: ">=20" } + hasBin: true + + playwright@1.64.1: + resolution: + { + integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==, + } + engines: { node: ">=20" } + hasBin: true + postcss@8.5.28: resolution: { @@ -1714,6 +2989,21 @@ packages: } engines: { node: ">=6" } + react-docgen-typescript@2.4.0: + resolution: + { + integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==, + } + peerDependencies: + typescript: ">= 4.3.x" + + react-docgen@8.0.4: + resolution: + { + integrity: sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ==, + } + engines: { node: ^20.9.0 || >=22 } + react-dom@19.2.8: resolution: { @@ -1766,6 +3056,13 @@ packages: } engines: { node: ">=0.10.0" } + recast@0.23.21: + resolution: + { + integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==, + } + engines: { node: ">= 4" } + redent@3.0.0: resolution: { @@ -1803,6 +3100,13 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true + run-applescript@7.1.0: + resolution: + { + integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==, + } + engines: { node: ">=18" } + saxes@6.0.0: resolution: { @@ -1816,6 +3120,21 @@ packages: integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, } + semver@6.3.1: + resolution: + { + integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, + } + hasBin: true + + semver@7.8.5: + resolution: + { + integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, + } + engines: { node: ">=10" } + hasBin: true + siginfo@2.0.0: resolution: { @@ -1836,6 +3155,13 @@ packages: } engines: { node: ">=0.10.0" } + source-map@0.6.1: + resolution: + { + integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, + } + engines: { node: ">=0.10.0" } + stackback@0.0.2: resolution: { @@ -1848,6 +3174,31 @@ packages: integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==, } + storybook@10.6.3: + resolution: + { + integrity: sha512-lNBvcZFG44C98KvQHYzfq1O46m+sl94zY1+na5/9E3ihtNMbvpOgYyMFMIcWYUvOMo3OM4q9jzvB73KU20bU8w==, + } + hasBin: true + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 || ^0.2.0 + peerDependenciesMeta: + "@types/react": + optional: true + prettier: + optional: true + vite-plus: + optional: true + + strip-bom@3.0.0: + resolution: + { + integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, + } + engines: { node: ">=4" } + strip-indent@3.0.0: resolution: { @@ -1855,6 +3206,13 @@ packages: } engines: { node: ">=8" } + strip-indent@4.1.1: + resolution: + { + integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==, + } + engines: { node: ">=12" } + stylis@4.2.0: resolution: { @@ -1874,6 +3232,12 @@ packages: integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, } + tiny-invariant@1.3.3: + resolution: + { + integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, + } + tinybench@6.1.4: resolution: { @@ -1895,6 +3259,20 @@ packages: } engines: { node: ">=12.0.0" } + tinyrainbow@2.0.0: + resolution: + { + integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==, + } + engines: { node: ">=14.0.0" } + + tinyspy@4.0.6: + resolution: + { + integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==, + } + engines: { node: ">=14.0.0" } + tldts-core@7.4.11: resolution: { @@ -1922,6 +3300,26 @@ packages: } engines: { node: ">=20" } + ts-dedent@2.3.0: + resolution: + { + integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==, + } + engines: { node: ">=6.10" } + + tsconfig-paths@4.2.0: + resolution: + { + integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==, + } + engines: { node: ">=6" } + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + typescript@6.0.3: resolution: { @@ -1939,9 +3337,26 @@ packages: undici@8.10.2: resolution: { - integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, + integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, + } + engines: { node: ">=22.19.0" } + + update-browserslist-db@1.3.4: + resolution: + { + integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==, + } + hasBin: true + peerDependencies: + browserslist: ">= 4.21.0" + + use-sync-external-store@1.7.0: + resolution: + { + integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==, } - engines: { node: ">=22.19.0" } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 vite@8.2.2: resolution: @@ -2076,6 +3491,28 @@ packages: engines: { node: ">=8" } hasBin: true + ws@8.22.0: + resolution: + { + integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==, + } + engines: { node: ">=10.0.0" } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: + { + integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==, + } + engines: { node: ">=18" } + xml-name-validator@5.0.0: resolution: { @@ -2089,6 +3526,12 @@ packages: integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, } + yallist@3.1.1: + resolution: + { + integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, + } + yaml@1.10.3: resolution: { @@ -2114,12 +3557,39 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 + "@axe-core/playwright@4.14.0(playwright-core@1.64.1)": + dependencies: + axe-core: 4.14.0 + playwright-core: 1.64.1 + "@babel/code-frame@7.29.7": dependencies: "@babel/helper-validator-identifier": 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 + "@babel/compat-data@7.29.7": {} + + "@babel/core@7.29.7": + dependencies: + "@babel/code-frame": 7.29.7 + "@babel/generator": 7.29.8 + "@babel/helper-compilation-targets": 7.29.7 + "@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7) + "@babel/helpers": 7.29.10 + "@babel/parser": 7.29.8 + "@babel/template": 7.29.7 + "@babel/traverse": 7.29.8 + "@babel/types": 7.29.8 + "@jridgewell/remapping": 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + "@babel/generator@7.29.8": dependencies: "@babel/parser": 7.29.8 @@ -2128,6 +3598,14 @@ snapshots: "@jridgewell/trace-mapping": 0.3.31 jsesc: 3.1.0 + "@babel/helper-compilation-targets@7.29.7": + dependencies: + "@babel/compat-data": 7.29.7 + "@babel/helper-validator-option": 7.29.7 + browserslist: 4.29.4 + lru-cache: 5.1.1 + semver: 6.3.1 + "@babel/helper-globals@7.29.7": {} "@babel/helper-module-imports@7.29.7": @@ -2137,10 +3615,26 @@ snapshots: transitivePeerDependencies: - supports-color + "@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)": + dependencies: + "@babel/core": 7.29.7 + "@babel/helper-module-imports": 7.29.7 + "@babel/helper-validator-identifier": 7.29.7 + "@babel/traverse": 7.29.8 + transitivePeerDependencies: + - supports-color + "@babel/helper-string-parser@7.29.7": {} "@babel/helper-validator-identifier@7.29.7": {} + "@babel/helper-validator-option@7.29.7": {} + + "@babel/helpers@7.29.10": + dependencies: + "@babel/template": 7.29.7 + "@babel/types": 7.29.8 + "@babel/parser@7.29.8": dependencies: "@babel/types": 7.29.8 @@ -2198,6 +3692,38 @@ snapshots: "@csstools/css-tokenizer@4.0.0": {} + "@emnapi/core@1.11.0": + dependencies: + "@emnapi/wasi-threads": 1.2.2 + tslib: 2.8.1 + optional: true + + "@emnapi/core@1.9.2": + dependencies: + "@emnapi/wasi-threads": 1.2.1 + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.11.0": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.9.2": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.1": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.2": + dependencies: + tslib: 2.8.1 + optional: true + "@emotion/babel-plugin@11.13.5": dependencies: "@babel/helper-module-imports": 7.29.7 @@ -2281,15 +3807,108 @@ snapshots: "@emotion/weak-memoize@0.4.0": {} + "@esbuild/aix-ppc64@0.28.2": + optional: true + + "@esbuild/android-arm64@0.28.2": + optional: true + + "@esbuild/android-arm@0.28.2": + optional: true + + "@esbuild/android-x64@0.28.2": + optional: true + + "@esbuild/darwin-arm64@0.28.2": + optional: true + + "@esbuild/darwin-x64@0.28.2": + optional: true + + "@esbuild/freebsd-arm64@0.28.2": + optional: true + + "@esbuild/freebsd-x64@0.28.2": + optional: true + + "@esbuild/linux-arm64@0.28.2": + optional: true + + "@esbuild/linux-arm@0.28.2": + optional: true + + "@esbuild/linux-ia32@0.28.2": + optional: true + + "@esbuild/linux-loong64@0.28.2": + optional: true + + "@esbuild/linux-mips64el@0.28.2": + optional: true + + "@esbuild/linux-ppc64@0.28.2": + optional: true + + "@esbuild/linux-riscv64@0.28.2": + optional: true + + "@esbuild/linux-s390x@0.28.2": + optional: true + + "@esbuild/linux-x64@0.28.2": + optional: true + + "@esbuild/netbsd-arm64@0.28.2": + optional: true + + "@esbuild/netbsd-x64@0.28.2": + optional: true + + "@esbuild/openbsd-arm64@0.28.2": + optional: true + + "@esbuild/openbsd-x64@0.28.2": + optional: true + + "@esbuild/openharmony-arm64@0.28.2": + optional: true + + "@esbuild/sunos-x64@0.28.2": + optional: true + + "@esbuild/win32-arm64@0.28.2": + optional: true + + "@esbuild/win32-ia32@0.28.2": + optional: true + + "@esbuild/win32-x64@0.28.2": + optional: true + "@exodus/bytes@1.15.1": {} - "@fontsource/roboto@5.3.0": {} + "@fontsource-variable/fraunces@5.3.0": {} + + "@fontsource-variable/instrument-sans@5.3.0": {} + + "@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))": + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) + optionalDependencies: + typescript: 6.0.3 "@jridgewell/gen-mapping@0.3.13": dependencies: "@jridgewell/sourcemap-codec": 1.6.0 "@jridgewell/trace-mapping": 0.3.31 + "@jridgewell/remapping@2.3.5": + dependencies: + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.31 + "@jridgewell/resolve-uri@3.1.2": {} "@jridgewell/sourcemap-codec@1.6.0": {} @@ -2330,63 +3949,204 @@ snapshots: "@emotion/styled": 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) "@types/react": 19.2.18 - "@mui/private-theming@9.4.0(@types/react@19.2.18)(react@19.2.8)": - dependencies: - "@babel/runtime": 7.29.7 - "@mui/utils": 9.4.0(@types/react@19.2.18)(react@19.2.8) - prop-types: 15.8.1 - react: 19.2.8 - optionalDependencies: - "@types/react": 19.2.18 + "@mui/private-theming@9.4.0(@types/react@19.2.18)(react@19.2.8)": + dependencies: + "@babel/runtime": 7.29.7 + "@mui/utils": 9.4.0(@types/react@19.2.18)(react@19.2.8) + prop-types: 15.8.1 + react: 19.2.8 + optionalDependencies: + "@types/react": 19.2.18 + + "@mui/styled-engine@9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)": + dependencies: + "@babel/runtime": 7.29.7 + "@emotion/cache": 11.14.0 + "@emotion/serialize": 1.3.3 + "@emotion/sheet": 1.4.0 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.8 + optionalDependencies: + "@emotion/react": 11.14.0(@types/react@19.2.18)(react@19.2.8) + "@emotion/styled": 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) + + "@mui/system@9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)": + dependencies: + "@babel/runtime": 7.29.7 + "@mui/private-theming": 9.4.0(@types/react@19.2.18)(react@19.2.8) + "@mui/styled-engine": 9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + "@mui/types": 9.4.0(@types/react@19.2.18) + "@mui/utils": 9.4.0(@types/react@19.2.18)(react@19.2.8) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.8 + optionalDependencies: + "@emotion/react": 11.14.0(@types/react@19.2.18)(react@19.2.8) + "@emotion/styled": 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) + "@types/react": 19.2.18 + + "@mui/types@9.4.0(@types/react@19.2.18)": + dependencies: + "@babel/runtime": 7.29.7 + optionalDependencies: + "@types/react": 19.2.18 + + "@mui/utils@9.4.0(@types/react@19.2.18)(react@19.2.8)": + dependencies: + "@babel/runtime": 7.29.7 + "@mui/types": 9.4.0(@types/react@19.2.18) + "@types/prop-types": 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.8 + react-is: 19.2.8 + optionalDependencies: + "@types/react": 19.2.18 + + "@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)": + dependencies: + "@emnapi/core": 1.11.0 + "@emnapi/runtime": 1.11.0 + "@tybys/wasm-util": 0.10.4 + optional: true + + "@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)": + dependencies: + "@emnapi/core": 1.9.2 + "@emnapi/runtime": 1.9.2 + "@tybys/wasm-util": 0.10.4 + optional: true + + "@oxc-parser/binding-android-arm-eabi@0.127.0": + optional: true + + "@oxc-parser/binding-android-arm64@0.127.0": + optional: true + + "@oxc-parser/binding-darwin-arm64@0.127.0": + optional: true + + "@oxc-parser/binding-darwin-x64@0.127.0": + optional: true + + "@oxc-parser/binding-freebsd-x64@0.127.0": + optional: true + + "@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + optional: true + + "@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + optional: true + + "@oxc-parser/binding-linux-arm64-gnu@0.127.0": + optional: true + + "@oxc-parser/binding-linux-arm64-musl@0.127.0": + optional: true + + "@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + optional: true + + "@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + optional: true + + "@oxc-parser/binding-linux-riscv64-musl@0.127.0": + optional: true + + "@oxc-parser/binding-linux-s390x-gnu@0.127.0": + optional: true + + "@oxc-parser/binding-linux-x64-gnu@0.127.0": + optional: true + + "@oxc-parser/binding-linux-x64-musl@0.127.0": + optional: true + + "@oxc-parser/binding-openharmony-arm64@0.127.0": + optional: true + + "@oxc-parser/binding-wasm32-wasi@0.127.0": + dependencies: + "@emnapi/core": 1.9.2 + "@emnapi/runtime": 1.9.2 + "@napi-rs/wasm-runtime": 1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + "@oxc-parser/binding-win32-arm64-msvc@0.127.0": + optional: true + + "@oxc-parser/binding-win32-ia32-msvc@0.127.0": + optional: true + + "@oxc-parser/binding-win32-x64-msvc@0.127.0": + optional: true + + "@oxc-project/types@0.127.0": {} + + "@oxc-project/types@0.148.0": {} + + "@oxc-resolver/binding-android-arm-eabi@11.21.2": + optional: true + + "@oxc-resolver/binding-android-arm64@11.21.2": + optional: true + + "@oxc-resolver/binding-darwin-arm64@11.21.2": + optional: true + + "@oxc-resolver/binding-darwin-x64@11.21.2": + optional: true + + "@oxc-resolver/binding-freebsd-x64@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-arm-musleabihf@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-arm64-gnu@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-arm64-musl@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-ppc64-gnu@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-riscv64-gnu@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-riscv64-musl@11.21.2": + optional: true + + "@oxc-resolver/binding-linux-s390x-gnu@11.21.2": + optional: true - "@mui/styled-engine@9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)": - dependencies: - "@babel/runtime": 7.29.7 - "@emotion/cache": 11.14.0 - "@emotion/serialize": 1.3.3 - "@emotion/sheet": 1.4.0 - csstype: 3.2.3 - prop-types: 15.8.1 - react: 19.2.8 - optionalDependencies: - "@emotion/react": 11.14.0(@types/react@19.2.18)(react@19.2.8) - "@emotion/styled": 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) + "@oxc-resolver/binding-linux-x64-gnu@11.21.2": + optional: true - "@mui/system@9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)": - dependencies: - "@babel/runtime": 7.29.7 - "@mui/private-theming": 9.4.0(@types/react@19.2.18)(react@19.2.8) - "@mui/styled-engine": 9.4.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) - "@mui/types": 9.4.0(@types/react@19.2.18) - "@mui/utils": 9.4.0(@types/react@19.2.18)(react@19.2.8) - clsx: 2.1.1 - csstype: 3.2.3 - prop-types: 15.8.1 - react: 19.2.8 - optionalDependencies: - "@emotion/react": 11.14.0(@types/react@19.2.18)(react@19.2.8) - "@emotion/styled": 11.14.1(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) - "@types/react": 19.2.18 + "@oxc-resolver/binding-linux-x64-musl@11.21.2": + optional: true - "@mui/types@9.4.0(@types/react@19.2.18)": - dependencies: - "@babel/runtime": 7.29.7 - optionalDependencies: - "@types/react": 19.2.18 + "@oxc-resolver/binding-openharmony-arm64@11.21.2": + optional: true - "@mui/utils@9.4.0(@types/react@19.2.18)(react@19.2.8)": + "@oxc-resolver/binding-wasm32-wasi@11.21.2": dependencies: - "@babel/runtime": 7.29.7 - "@mui/types": 9.4.0(@types/react@19.2.18) - "@types/prop-types": 15.7.15 - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.8 - react-is: 19.2.8 - optionalDependencies: - "@types/react": 19.2.18 + "@emnapi/core": 1.11.0 + "@emnapi/runtime": 1.11.0 + "@napi-rs/wasm-runtime": 1.2.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + optional: true - "@oxc-project/types@0.148.0": {} + "@oxc-resolver/binding-win32-arm64-msvc@11.21.2": + optional: true + + "@oxc-resolver/binding-win32-x64-msvc@11.21.2": + optional: true "@oxlint/binding-android-arm-eabi@1.81.0": optional: true @@ -2445,6 +4205,10 @@ snapshots: "@oxlint/binding-win32-x64-msvc@1.81.0": optional: true + "@playwright/test@1.64.1": + dependencies: + playwright: 1.64.1 + "@popperjs/core@2.11.8": {} "@rolldown/binding-android-arm-eabi@1.2.7": @@ -2494,6 +4258,87 @@ snapshots: "@rolldown/pluginutils@1.0.1": {} + "@rollup/pluginutils@5.4.0": + dependencies: + "@types/estree": 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + + "@storybook/addon-a11y@10.6.3(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))": + dependencies: + "@storybook/global": 5.0.0 + axe-core: 4.14.0 + storybook: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + + "@storybook/builder-vite@10.6.3(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))": + dependencies: + storybook: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + ts-dedent: 2.3.0 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) + + "@storybook/global@5.0.0": {} + + "@storybook/icons@2.2.0(react@19.2.8)": + dependencies: + react: 19.2.8 + + "@storybook/react-dom-shim@10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))": + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + "@types/react": 19.2.18 + "@types/react-dom": 19.2.7(@types/react@19.2.18) + + "@storybook/react-vite@10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))": + dependencies: + "@joshwooding/vite-plugin-react-docgen-typescript": 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) + "@rollup/pluginutils": 5.4.0 + "@storybook/builder-vite": 10.6.3(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) + "@storybook/react": 10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3) + empathic: 2.1.0 + magic-string: 1.2.3 + react: 19.2.8 + react-docgen: 8.0.4 + react-dom: 19.2.8(react@19.2.8) + resolve: 1.22.12 + storybook: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + tsconfig-paths: 4.2.0 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - "@types/react" + - "@types/react-dom" + - rollup + - supports-color + + "@storybook/react@10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)": + dependencies: + "@storybook/global": 5.0.0 + "@storybook/react-dom-shim": 10.6.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-docgen: 8.0.4 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.8(react@19.2.8) + storybook: 10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + "@types/react": 19.2.18 + "@types/react-dom": 19.2.7(@types/react@19.2.18) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@tanstack/query-core@5.104.5": {} + + "@tanstack/react-query@5.104.5(@types/react@19.2.18)(react@19.2.8)": + dependencies: + "@tanstack/query-core": 5.104.5 + react: 19.2.8 + optionalDependencies: + "@types/react": 19.2.18 + "@testing-library/dom@10.4.1": dependencies: "@babel/code-frame": 7.29.7 @@ -2505,7 +4350,16 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - "@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)))": + "@testing-library/jest-dom@6.9.1": + dependencies: + "@adobe/css-tools": 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + "@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)))": dependencies: "@adobe/css-tools": 4.5.0 "@testing-library/dom": 10.4.1 @@ -2515,7 +4369,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)) + vitest: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) "@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)": dependencies: @@ -2531,8 +4385,34 @@ snapshots: dependencies: "@testing-library/dom": 10.4.1 + "@tybys/wasm-util@0.10.4": + dependencies: + tslib: 2.8.1 + optional: true + "@types/aria-query@5.0.4": {} + "@types/babel__core@7.20.5": + dependencies: + "@babel/parser": 7.29.8 + "@babel/types": 7.29.8 + "@types/babel__generator": 7.27.0 + "@types/babel__template": 7.4.4 + "@types/babel__traverse": 7.28.0 + + "@types/babel__generator@7.27.0": + dependencies: + "@babel/types": 7.29.8 + + "@types/babel__template@7.4.4": + dependencies: + "@babel/parser": 7.29.8 + "@babel/types": 7.29.8 + + "@types/babel__traverse@7.28.0": + dependencies: + "@babel/types": 7.29.8 + "@types/chai@5.2.3": dependencies: "@types/deep-eql": 4.0.2 @@ -2540,6 +4420,8 @@ snapshots: "@types/deep-eql@4.0.2": {} + "@types/doctrine@0.0.9": {} + "@types/estree@1.0.9": {} "@types/node@24.13.3": @@ -2562,22 +4444,48 @@ snapshots: dependencies: csstype: 3.2.3 - "@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3))": + "@types/resolve@1.20.6": {} + + "@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))": dependencies: "@rolldown/pluginutils": 1.0.1 - vite: 8.2.2(@types/node@24.13.3) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) + + "@vitest/expect@3.2.4": + dependencies: + "@types/chai": 5.2.3 + "@vitest/spy": 3.2.4 + "@vitest/utils": 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 - "@vitest/mocker@5.0.0(vite@8.2.2(@types/node@24.13.3))": + "@vitest/mocker@5.0.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2))": dependencies: "@jridgewell/trace-mapping": 0.3.31 "@vitest/spy": 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@24.13.3) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) + + "@vitest/pretty-format@3.2.4": + dependencies: + tinyrainbow: 2.0.0 + + "@vitest/spy@3.2.4": + dependencies: + tinyspy: 4.0.6 "@vitest/spy@5.0.0": {} + "@vitest/utils@3.2.4": + dependencies: + "@vitest/pretty-format": 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + "@webcontainer/env@1.1.1": {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -2590,24 +4498,64 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.16.3: + dependencies: + tslib: 2.8.1 + + axe-core@4.14.0: {} + babel-plugin-macros@3.1.0: dependencies: "@babel/runtime": 7.29.7 cosmiconfig: 7.1.0 resolve: 1.22.12 + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.30: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.29.4: + dependencies: + baseline-browser-mapping: 2.11.30 + caniuse-lite: 1.0.30001817 + electron-to-chromium: 1.5.462 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.4) + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001817: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} + check-error@2.1.3: {} + clsx@2.1.1: {} convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} + cosmiconfig@7.1.0: dependencies: "@types/parse-json": 4.0.2 @@ -2638,10 +4586,25 @@ snapshots: decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -2651,6 +4614,10 @@ snapshots: "@babel/runtime": 7.29.7 csstype: 3.2.3 + electron-to-chromium@1.5.462: {} + + empathic@2.1.0: {} + entities@8.0.0: {} error-ex@1.3.4: @@ -2661,12 +4628,49 @@ snapshots: es-module-lexer@2.3.2: {} + esbuild@0.28.2: + optionalDependencies: + "@esbuild/aix-ppc64": 0.28.2 + "@esbuild/android-arm": 0.28.2 + "@esbuild/android-arm64": 0.28.2 + "@esbuild/android-x64": 0.28.2 + "@esbuild/darwin-arm64": 0.28.2 + "@esbuild/darwin-x64": 0.28.2 + "@esbuild/freebsd-arm64": 0.28.2 + "@esbuild/freebsd-x64": 0.28.2 + "@esbuild/linux-arm": 0.28.2 + "@esbuild/linux-arm64": 0.28.2 + "@esbuild/linux-ia32": 0.28.2 + "@esbuild/linux-loong64": 0.28.2 + "@esbuild/linux-mips64el": 0.28.2 + "@esbuild/linux-ppc64": 0.28.2 + "@esbuild/linux-riscv64": 0.28.2 + "@esbuild/linux-s390x": 0.28.2 + "@esbuild/linux-x64": 0.28.2 + "@esbuild/netbsd-arm64": 0.28.2 + "@esbuild/netbsd-x64": 0.28.2 + "@esbuild/openbsd-arm64": 0.28.2 + "@esbuild/openbsd-x64": 0.28.2 + "@esbuild/openharmony-arm64": 0.28.2 + "@esbuild/sunos-x64": 0.28.2 + "@esbuild/win32-arm64": 0.28.2 + "@esbuild/win32-ia32": 0.28.2 + "@esbuild/win32-x64": 0.28.2 + + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: "@types/estree": 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} fdir@6.5.0(picomatch@4.0.7): @@ -2675,11 +4679,28 @@ snapshots: find-root@1.1.0: {} + framer-motion@14.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 14.4.0 + motion-utils: 14.1.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -2707,8 +4728,18 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-potential-custom-element-name@1.0.1: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + js-tokens@4.0.0: {} jsdom@30.0.1: @@ -2741,6 +4772,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + jwt-decode@4.0.0: {} lightningcss-android-arm64@1.33.0: @@ -2798,8 +4833,14 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lz-string@1.5.0: {} magic-string@1.2.3: @@ -2810,10 +4851,34 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + motion-dom@14.4.0: + dependencies: + motion-utils: 14.1.1 + + motion-utils@14.1.1: {} + + motion@14.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 14.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + ms@2.1.3: {} nanoid@3.3.18: {} + node-releases@2.0.59: {} + object-assign@4.1.1: {} obug@2.1.4: {} @@ -2822,6 +4887,60 @@ snapshots: dependencies: jwt-decode: 4.0.0 + open@10.2.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + oxc-parser@0.127.0: + dependencies: + "@oxc-project/types": 0.127.0 + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi": 0.127.0 + "@oxc-parser/binding-android-arm64": 0.127.0 + "@oxc-parser/binding-darwin-arm64": 0.127.0 + "@oxc-parser/binding-darwin-x64": 0.127.0 + "@oxc-parser/binding-freebsd-x64": 0.127.0 + "@oxc-parser/binding-linux-arm-gnueabihf": 0.127.0 + "@oxc-parser/binding-linux-arm-musleabihf": 0.127.0 + "@oxc-parser/binding-linux-arm64-gnu": 0.127.0 + "@oxc-parser/binding-linux-arm64-musl": 0.127.0 + "@oxc-parser/binding-linux-ppc64-gnu": 0.127.0 + "@oxc-parser/binding-linux-riscv64-gnu": 0.127.0 + "@oxc-parser/binding-linux-riscv64-musl": 0.127.0 + "@oxc-parser/binding-linux-s390x-gnu": 0.127.0 + "@oxc-parser/binding-linux-x64-gnu": 0.127.0 + "@oxc-parser/binding-linux-x64-musl": 0.127.0 + "@oxc-parser/binding-openharmony-arm64": 0.127.0 + "@oxc-parser/binding-wasm32-wasi": 0.127.0 + "@oxc-parser/binding-win32-arm64-msvc": 0.127.0 + "@oxc-parser/binding-win32-ia32-msvc": 0.127.0 + "@oxc-parser/binding-win32-x64-msvc": 0.127.0 + + oxc-resolver@11.21.2: + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi": 11.21.2 + "@oxc-resolver/binding-android-arm64": 11.21.2 + "@oxc-resolver/binding-darwin-arm64": 11.21.2 + "@oxc-resolver/binding-darwin-x64": 11.21.2 + "@oxc-resolver/binding-freebsd-x64": 11.21.2 + "@oxc-resolver/binding-linux-arm-gnueabihf": 11.21.2 + "@oxc-resolver/binding-linux-arm-musleabihf": 11.21.2 + "@oxc-resolver/binding-linux-arm64-gnu": 11.21.2 + "@oxc-resolver/binding-linux-arm64-musl": 11.21.2 + "@oxc-resolver/binding-linux-ppc64-gnu": 11.21.2 + "@oxc-resolver/binding-linux-riscv64-gnu": 11.21.2 + "@oxc-resolver/binding-linux-riscv64-musl": 11.21.2 + "@oxc-resolver/binding-linux-s390x-gnu": 11.21.2 + "@oxc-resolver/binding-linux-x64-gnu": 11.21.2 + "@oxc-resolver/binding-linux-x64-musl": 11.21.2 + "@oxc-resolver/binding-openharmony-arm64": 11.21.2 + "@oxc-resolver/binding-wasm32-wasi": 11.21.2 + "@oxc-resolver/binding-win32-arm64-msvc": 11.21.2 + "@oxc-resolver/binding-win32-x64-msvc": 11.21.2 + oxlint@1.81.0: optionalDependencies: "@oxlint/binding-android-arm-eabi": 1.81.0 @@ -2861,12 +4980,25 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-type@4.0.0: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss@8.5.28: dependencies: nanoid: 3.3.18 @@ -2889,6 +5021,25 @@ snapshots: punycode@2.3.1: {} + react-docgen-typescript@2.4.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + react-docgen@8.0.4: + dependencies: + "@babel/core": 7.29.7 + "@babel/traverse": 7.29.8 + "@babel/types": 7.29.8 + "@types/babel__core": 7.20.5 + "@types/babel__traverse": 7.28.0 + "@types/doctrine": 0.0.9 + "@types/resolve": 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.12 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -2916,6 +5067,14 @@ snapshots: react@19.2.8: {} + recast@0.23.21: + dependencies: + ast-types: 0.16.3 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -2953,32 +5112,73 @@ snapshots: "@rolldown/binding-win32-arm64-msvc": 1.2.7 "@rolldown/binding-win32-x64-msvc": 1.2.7 + run-applescript@7.1.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 scheduler@0.27.0: {} + semver@6.3.1: {} + + semver@7.8.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} source-map@0.5.7: {} + source-map@0.6.1: {} + stackback@0.0.2: {} std-env@4.2.0: {} + storybook@10.6.3(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): + dependencies: + "@storybook/global": 5.0.0 + "@storybook/icons": 2.2.0(react@19.2.8) + "@testing-library/dom": 10.4.1 + "@testing-library/jest-dom": 6.9.1 + "@testing-library/user-event": 14.6.7(@testing-library/dom@10.4.1) + "@vitest/expect": 3.2.4 + "@vitest/spy": 3.2.4 + "@webcontainer/env": 1.1.1 + esbuild: 0.28.2 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.21.2 + recast: 0.23.21 + semver: 7.8.5 + use-sync-external-store: 1.7.0(react@19.2.8) + ws: 8.22.0 + optionalDependencies: + "@types/react": 19.2.18 + prettier: 3.9.6 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + strip-bom@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 + strip-indent@4.1.1: {} + stylis@4.2.0: {} supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} + tiny-invariant@1.3.3: {} + tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -2988,6 +5188,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + tldts-core@7.4.11: {} tldts@7.4.11: @@ -3002,13 +5206,33 @@ snapshots: dependencies: punycode: 2.3.1 + ts-dedent@2.3.0: {} + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + typescript@6.0.3: {} undici-types@7.18.2: {} undici@8.10.2: {} - vite@8.2.2(@types/node@24.13.3): + update-browserslist-db@1.3.4(browserslist@4.29.4): + dependencies: + browserslist: 4.29.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.7.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3017,12 +5241,13 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: "@types/node": 24.13.3 + esbuild: 0.28.2 fsevents: 2.3.3 - vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)): + vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)): dependencies: "@types/chai": 5.2.3 - "@vitest/mocker": 5.0.0(vite@8.2.2(@types/node@24.13.3)) + "@vitest/mocker": 5.0.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3033,7 +5258,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@24.13.3) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.13.3 @@ -3070,8 +5295,16 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.22.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + yallist@3.1.1: {} + yaml@1.10.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..36b6af9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + # esbuild (pulled in by Storybook) ships its binary via per-platform optional + # packages; its postinstall is only a fast-path optimisation. + esbuild: false diff --git a/scripts/README.md b/scripts/README.md index 55f23a1..42b3d51 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,31 +1,47 @@ # scripts/ -- `develop.sh` — the Dockerfile's `develop`-stage setup: installs `prek`, - the Claude Code CLI, and `snip`, the only tooling this template requires +- `develop.sh` — `../.devcontainer/Dockerfile`'s `develop`-stage setup: installs `prek`, + the Claude Code CLI, `snip` and the GitHub CLI (`gh`), the only tooling this template requires regardless of instance language. An instance adds its own language runtime/tooling install on top, either by extending this script or - copying its shape into a second one invoked from the instance's own - `Dockerfile` layer. Prefer the hook below over either. + copying its shape into a second script. Prefer the hook below over + either. - `post-create.sh` — the devcontainer's `postCreateCommand`: git `safe.directory`, `prek install`, then every `post-create.d/*.sh`. -- `develop.d/`, `post-create.d/` — instance hook directories, empty apart - from their own `README.md`; see `docs/TEMPLATE.md`'s "Instance extension - points". +- `develop.d/`, `post-create.d/`, `runner-setup.d/` — instance hook + directories, empty apart from their own `README.md`; see + `docs/TEMPLATE.md`'s "Instance extension points". +- `runner-setup.sh` — optional; the top-level `Dockerfile`'s `runner` stage + setup: group-0 runtime user for arbitrary-UID platforms (run before copying + app code, which is then copied read-only), then every + `runner-setup.d/*.sh`. An instance whose runner image doesn't call it + (an nginx runner, say) ignores it and `runner-setup.d/`. +- `image-scan.sh` — the manual-stage `image-scan` hook: Trivy over the + tar for the current `RELEASE_VERSION`/arch in `dist/`. +- `test-image-scripts.sh` — the `image-scripts-test` hook: offline tests of + the Makefile's repo derivation and these scripts. +- `image-smoke.sh` — `make image-smoke` (a `release-check` prerequisite): + converts the arch's image in the release OCI tar from `dist/` to a + docker-archive with the pinned skopeo (`docker load` can't read a buildx + OCI tar on the classic image store), loads and runs it, and polls + `IMAGE_SMOKE_PATH` until 2xx. Fails when there is no tar for the arch under + test. Needs `SKOPEO_IMAGE`, which the Makefile exports; usage in the + script's header. +- `skopeo.sh` — runs the pinned skopeo image as the caller's user with + `dist/` mounted at `/dist`; used by `make publish` and `image-smoke.sh`. +- `check-dockerfile-versions.sh ARG...` — fails when a named `ARG` default + differs between `.devcontainer/Dockerfile` and the top-level `Dockerfile`. - `develop.d/10-corepack.sh` — enables pnpm via Corepack in the devcontainer image; `post-create.d/10-pnpm-install.sh` — installs the app's dependencies. Both template-react's own, run by the hooks above. -- `check-dockerfile-versions.sh` — prek hook failing when `NODE_VERSION`/ - `DEBIAN_VERSION` differ between the base-owned `Dockerfile` and - `../app.Dockerfile`, so the bundle is built with the Node the - devcontainer tests against. - `docker/` — the runtime image's own assets: `nginx.conf.template` and `docker-entrypoint.d/20-envsubst-app-config.sh`, both `COPY`'d into - `../app.Dockerfile`'s `runner` stage. + the top-level `Dockerfile`'s `runner` stage. ## Do - Keep this script's own tools pinned to an exact version (a `# renovate:` - ARG in the Dockerfile) and checksum-verified where the upstream project + ARG in `../.devcontainer/Dockerfile`) and checksum-verified where the upstream project publishes checksums, matching the pattern each install step here already follows. diff --git a/scripts/check-design-tokens.sh b/scripts/check-design-tokens.sh new file mode 100755 index 0000000..526d91e --- /dev/null +++ b/scripts/check-design-tokens.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Design-token guard (ADR 0004): rejects hex/rgb/hsl color literals and raw +# `px` values in src/**/*.{ts,tsx,css} outside src/theme/, so every color and +# size comes from the theme (src/theme/tokens.ts). Test files (*.test.ts, +# *.test.tsx) are skipped because they may assert computed colors; stories +# (*.stories.tsx) stay covered since they model token usage. +# +# usage: check-design-tokens.sh [SRC_DIR] (default: src) +# check-design-tokens.sh --self-test (runs against built-in fixtures) +# +# Any `#` plus 3, 4, 6 or 8 hex characters counts as a color, so an id like +# `#fade` or `#123` also matches; opt those out with the marker below. +# +# A line may opt out with an inline marker and a non-empty reason: +# width: "1px", // design-tokens: allow hairline needed by third-party widget +# A marker without a reason is itself a violation. +set -euo pipefail + +COLOR_RE='#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|\b(rgb|rgba|hsl|hsla)\(' +PX_RE='(^|[^A-Za-z0-9_.])-?[0-9]*\.?[0-9]+px\b' +MARKER='design-tokens: allow' + +scan() { + local root="$1" status=0 file + root="${root%/}" + while IFS= read -r -d '' file; do + local n=0 line + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + local kind="" + if [[ "$line" == *"$MARKER"* ]]; then + local reason="${line#*"$MARKER"}" + reason="${reason//[[:space:]*\/]/}" + if [ -z "$reason" ]; then + echo "$file:$n: '$MARKER' needs a reason after it" >&2 + status=1 + fi + continue + fi + if grep -Eq -- "$COLOR_RE" <<<"$line"; then kind="color literal"; fi + if [ -z "$kind" ] && grep -Eq -- "$PX_RE" <<<"$line"; then kind="raw px value"; fi + if [ -n "$kind" ]; then + echo "$file:$n: $kind outside src/theme/ (use a theme token): ${line#"${line%%[![:space:]]*}"}" >&2 + status=1 + fi + done <"$file" + done < <(find "$root" -path "$root/theme" -prune -o -type f \ + \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) \ + ! -name '*.test.ts' ! -name '*.test.tsx' -print0 | sort -z) + return "$status" +} + +self_test() { + local d fail=0 + d="$(mktemp -d)" + trap 'rm -rf "$d"' RETURN + mkdir -p "$d/theme" "$d/ok" "$d/bad" + echo 'export const c = "#B3361B"; export const p = "4px";' >"$d/theme/tokens.ts" + printf 'const w = "100%%"; const id = "#root"; const v = "var(--x)";\n' >"$d/ok/clean.tsx" + printf 'const w = "1px"; // design-tokens: allow hairline for a third-party widget\n' >"$d/ok/marked.tsx" + echo 'const c = "#abcdef"; const p = "12px";' >"$d/ok/color.test.tsx" + echo 'const c = "#abcdef"; const p = "12px";' >"$d/ok/color.test.ts" + echo 'const c = "#abcdef";' >"$d/bad/color.stories.tsx" + echo 'a { color: #fff; }' >"$d/bad/hex.css" + echo 'const c = "#abcdef";' >"$d/bad/hex6.ts" + echo 'const c = "rgba(0, 0, 0, 0.5)";' >"$d/bad/rgb.ts" + echo 'const c = "hsl(10 20% 30%)";' >"$d/bad/hsl.ts" + echo 'const s = { width: "12px" };' >"$d/bad/px.tsx" + echo 'const s = { mt: "-4px" };' >"$d/bad/negpx.tsx" + echo 'a { margin: 0.5px; }' >"$d/bad/px.css" + echo 'const w = "1px"; // design-tokens: allow' >"$d/bad/noreason.ts" + + scan "$d/ok" >/dev/null 2>&1 || { echo "self-test: clean fixtures were rejected" >&2; fail=1; } + local f + for f in hex.css hex6.ts rgb.ts hsl.ts px.tsx negpx.tsx px.css noreason.ts color.stories.tsx; do + rm -rf "$d/one" && mkdir -p "$d/one" && cp "$d/bad/$f" "$d/one/" + if scan "$d/one" >/dev/null 2>&1; then + echo "self-test: $f was not rejected" >&2 + fail=1 + fi + done + # A trailing slash on the source dir must not defeat the theme/ exemption. + mkdir -p "$d/slash" && cp -r "$d/theme" "$d/slash/theme" + scan "$d/slash/" >/dev/null 2>&1 || { echo "self-test: trailing slash broke the theme/ exemption" >&2; fail=1; } + # theme/ itself must be exempt. + mkdir -p "$d/only" && cp -r "$d/theme" "$d/only/theme" + scan "$d/only" >/dev/null 2>&1 || { echo "self-test: src/theme/ was not exempt" >&2; fail=1; } + [ "$fail" -eq 0 ] && echo "check-design-tokens self-test: ok" + return "$fail" +} + +if [ "${1:-}" = "--self-test" ]; then + self_test +else + scan "${1:-src}" +fi diff --git a/scripts/check-dockerfile-versions.sh b/scripts/check-dockerfile-versions.sh index 722c886..61f6cbc 100755 --- a/scripts/check-dockerfile-versions.sh +++ b/scripts/check-dockerfile-versions.sh @@ -1,17 +1,64 @@ #!/usr/bin/env bash -# NODE_VERSION and DEBIAN_VERSION are declared in both the base-owned -# Dockerfile (develop stage) and app.Dockerfile (builder stage); the -# runtime bundle must be built with the Node the devcontainer tests -# against. Exits 1 if either differs. +# Usage: check-dockerfile-versions.sh ARG [ARG...] +# +# Each named ARG must be declared (`ARG NAME=value`) with the same default +# in both the base-owned .devcontainer/Dockerfile (develop stage) and the +# top-level Dockerfile (builder/runner stages): the runtime image must match +# the toolchain the devcontainer tests against. Values are compared after +# stripping trailing comments, whitespace and surrounding quotes. Exits 1 if +# one differs, is missing from either file, is declared without a default or +# has defaults that disagree within a file. ARGs not named are not checked. set -euo pipefail +if [[ $# -eq 0 ]]; then + echo "usage: $0 ARG [ARG...]" >&2 + exit 2 +fi + +# Prints the normalized default of `ARG name=...` in file $2, or exits: +# 0 with the value, 3 when the ARG is not declared, 4 when it is declared +# without a default, 5 when its defaults within the file disagree. A file +# may declare an ARG several times (a bare `ARG name` re-declares it inside +# a stage, and a stage may repeat the default): bare re-declarations are +# ignored and every default must agree. Normalizing strips a trailing +# comment, surrounding whitespace and one pair of surrounding quotes. +default_of() { + local line value found=0 first="" have=0 + while IFS= read -r line; do + found=1 + [[ "$line" == *=* ]] || continue + line="${line#*=}" + value="$(sed -E 's/[[:space:]]+#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/' <<<"$line")" + if [[ $have -eq 0 ]]; then + first="$value" + have=1 + elif [[ "$value" != "$first" ]]; then + return 5 + fi + done < <(grep -E "^ARG[[:space:]]+$1([=[:space:]]|\$)" "$2" || true) + [[ $found -eq 1 ]] || return 3 + [[ $have -eq 1 ]] || return 4 + printf '%s\n' "$first" +} + status=0 -for arg in NODE_VERSION DEBIAN_VERSION; do - dev="$(grep -m1 "^ARG ${arg}=" Dockerfile | cut -d= -f2-)" - app="$(grep -m1 "^ARG ${arg}=" app.Dockerfile | cut -d= -f2-)" - if [[ "$dev" != "$app" ]]; then - echo "${arg} mismatch: Dockerfile has '${dev}', app.Dockerfile has '${app}'" >&2 - status=1 +for arg in "$@"; do + ok=1 + values=() + for f in .devcontainer/Dockerfile Dockerfile; do + rc=0 + v="$(default_of "$arg" "$f")" || rc=$? + case $rc in + 0) values+=("$v") ;; + 3) echo "${arg} missing: not declared in $f" >&2; ok=0 ;; + 4) echo "${arg} declared without a default in $f" >&2; ok=0 ;; + *) echo "${arg} declared with conflicting defaults in $f" >&2; ok=0 ;; + esac + done + if [[ $ok -eq 1 && "${values[0]}" != "${values[1]}" ]]; then + echo "${arg} mismatch: .devcontainer/Dockerfile has '${values[0]}', Dockerfile has '${values[1]}'" >&2 + ok=0 fi + [[ $ok -eq 1 ]] || status=1 done exit "$status" diff --git a/scripts/develop.d/10-corepack.sh b/scripts/develop.d/10-corepack.sh index 9e3ba62..874949f 100755 --- a/scripts/develop.d/10-corepack.sh +++ b/scripts/develop.d/10-corepack.sh @@ -2,7 +2,7 @@ # Enables pnpm via Corepack (bundled with the Node.js develop.sh installs): # the prek hooks and CI invoke `pnpm` directly, and Corepack reads the # pinned version from package.json's `packageManager` field -- matching -# app.Dockerfile's builder stage. +# the top-level Dockerfile's builder stage. set -euo pipefail node_bin_dir="$(dirname "$(readlink -f "$(command -v node)")")" diff --git a/scripts/develop.d/README.md b/scripts/develop.d/README.md index facf126..15865b8 100644 --- a/scripts/develop.d/README.md +++ b/scripts/develop.d/README.md @@ -1,8 +1,11 @@ # scripts/develop.d/ Instance-owned hook: extra devcontainer tooling an instance layers on top -of `../develop.sh`. This directory ships only this file; an instance adds -its scripts here (see `docs/TEMPLATE.md`'s "Instance extension points"). +of `../develop.sh`. This directory ships no scripts in base; a mid-layer template or an instance +adds `NN-name.sh` scripts here (see `docs/TEMPLATE.md`'s "Instance extension +points"). A script a template ships is `replace` tier in its sync manifest, so +a sync overwrites it; an instance's own script is listed in +`.github/template-sync-manifest.local.yml` so a sync leaves it alone. - Every `*.sh` here runs as root, in lexical filename order (use an `NN-` prefix, e.g. `10-apt.sh`), after `scripts/develop.sh`, during the diff --git a/scripts/develop.sh b/scripts/develop.sh index 265e7d0..ad93c3b 100644 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -13,6 +13,7 @@ snip_version=$3 uv_version=$4 python_version=$5 node_version=$6 +gh_version=$7 # xz-utils: Node's own release tarballs are .tar.xz. apt-get update @@ -44,7 +45,7 @@ ln -s "$python_bin" /usr/local/bin/python # directly from nodejs.org's own release tarballs (checksum-verified # against that release's published SHASUMS256.txt) rather than a # devcontainer feature, so every tool this stage installs is pinned the -# same way, in the same place -- see the Dockerfile's NODE_VERSION ARG. +# same way, in the same place -- see .devcontainer/Dockerfile's NODE_VERSION ARG. node_dpkg_arch="$(dpkg --print-architecture)" case "$node_dpkg_arch" in amd64) node_arch=x64 ;; @@ -97,3 +98,24 @@ tar -xzf "${snip_tmpdir}/${snip_asset}" -C "$snip_tmpdir" snip sudo -u vscode install -Dm755 "${snip_tmpdir}/snip" /home/vscode/.local/bin/snip rm -rf "$snip_tmpdir" ln -s /home/vscode/.local/bin/snip /usr/local/bin/snip + +# GitHub CLI: CLAUDE.md's workflow drives GitHub (PRs, workflow runs, +# releases) through `gh`, so it's part of the base toolset rather than a +# per-instance add-on. Fetched as a release tarball and checksum-verified +# against the release's own checksums file, like snip above. Its login +# state lives in the gh-config volume (.devcontainer/compose.yml), so a +# rebuild doesn't require `gh auth login` again. +gh_arch="$(dpkg --print-architecture)" +gh_asset="gh_${gh_version}_linux_${gh_arch}" +gh_tmpdir="$(mktemp -d)" +curl -LsSf -o "${gh_tmpdir}/${gh_asset}.tar.gz" \ + "https://github.com/cli/cli/releases/download/v${gh_version}/${gh_asset}.tar.gz" +curl -LsSf -o "${gh_tmpdir}/checksums.txt" \ + "https://github.com/cli/cli/releases/download/v${gh_version}/gh_${gh_version}_checksums.txt" +(cd "$gh_tmpdir" && grep " ${gh_asset}.tar.gz\$" checksums.txt | sha256sum -c -) +tar -xzf "${gh_tmpdir}/${gh_asset}.tar.gz" -C "$gh_tmpdir" "${gh_asset}/bin/gh" +install -Dm755 "${gh_tmpdir}/${gh_asset}/bin/gh" /usr/local/bin/gh +rm -rf "$gh_tmpdir" +# Pre-create the gh-config volume's mount point owned by vscode, so a new +# named volume is initialized with that ownership instead of root's. +sudo -u vscode mkdir -p /home/vscode/.config/gh diff --git a/scripts/image-scan.sh b/scripts/image-scan.sh new file mode 100755 index 0000000..1a7bf49 --- /dev/null +++ b/scripts/image-scan.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Scans the image tar `make build` left in dist/ for the current release +# version and arch for HIGH/CRITICAL vulnerabilities that have a fix, with +# Trivy. Skips when there is none. Which tar: RELEASE_ARCH (default: the +# host's) selects the arch; RELEASE_VERSION selects dist/*--.tar, +# and when unset the newest dist/*-.tar is used, so tars of older +# versions or other arches left in dist/ are never scanned. +# Run by the manual-stage `image-scan` pre-commit hook; needs Docker and +# network access (the vulnerability DB is cached in the trivy-cache volume). +# +# Trivy reads a `docker save` tar or an OCI layout *directory*, not the OCI +# layout tar buildx writes, so those tars are unpacked first. The arch in its +# name (-.tar) selects the image manifest, so a buildx +# provenance/SBOM attestation manifest in the same tar is never scanned +# instead of the image. +# +# The Trivy image is pinned by digest as well as tag, as the mitigation for +# the March 2026 compromise of Trivy's releases and images +# (GHSA-69fq-xp46-6x23). Renovate bumps both together. +set -euo pipefail + +trivy=aquasec/trivy:0.76.0@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222 + +case "$(uname -m)" in + aarch64 | arm64) host_arch=arm64 ;; + *) host_arch=amd64 ;; +esac +arch="${RELEASE_ARCH:-$host_arch}" + +shopt -s nullglob +tars=() +if [[ -n "${RELEASE_VERSION:-}" ]]; then + tars=(dist/*-"$RELEASE_VERSION-$arch".tar) +else + newest="" + for t in dist/*-"$arch".tar; do + if [[ -z "$newest" || "$t" -nt "$newest" ]]; then newest="$t"; fi + done + if [[ -n "$newest" ]]; then tars=("$newest"); fi +fi +if [[ ${#tars[@]} -eq 0 ]]; then + echo "image-scan: no linux/$arch image tar in dist/${RELEASE_VERSION:+ for $RELEASE_VERSION}, skipping" + exit 0 +fi + +# Under dist/ (gitignored), not /tmp, so the path also exists for a Docker +# daemon that shares the workspace but not the caller's /tmp. +work="$(mktemp -d dist/.image-scan.XXXXXX)" +trap 'rm -rf "$work"' EXIT + +status=0 +for t in "${tars[@]}"; do + name="$(basename "$t" .tar)" + arch="${name##*-}" + echo "image-scan: $t (linux/$arch)" + if tar -tf "$t" manifest.json >/dev/null 2>&1; then + # A `docker save` tar: Trivy reads it as is. + mount=("$PWD/$t:/image.tar:ro") + input=/image.tar + else + layout="$PWD/$work/$name" + mkdir -p "$layout" + tar -xf "$t" -C "$layout" + chmod -R a+rX "$layout" + mount=("$layout:/image:ro") + input=/image + fi + docker run --rm -v trivy-cache:/root/.cache -v "${mount[0]}" "$trivy" \ + image --input "$input" --platform "linux/$arch" \ + --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 || status=1 +done +exit "$status" diff --git a/scripts/image-smoke.sh b/scripts/image-smoke.sh new file mode 100755 index 0000000..a67110c --- /dev/null +++ b/scripts/image-smoke.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Loads the release image tar that `make build` left in dist/, runs it and +# polls a URL until it answers 2xx. Always removes the container; prints its +# logs on failure. +# +# Usage: image-smoke.sh (configured by environment; `make image-smoke` +# sets these) +# SKOPEO_IMAGE required (the Makefile sets it); converts the tar for docker load +# IMAGE_TAR_PREFIX required; tar is "$IMAGE_TAR_PREFIX-.tar" +# (e.g. dist/template-docker-1.2.3) +# RELEASE_VERSION if that tar is missing (the image name may differ +# when the repository name can't be derived), the one +# dist/*-$RELEASE_VERSION-.tar is used instead +# RELEASE_ARCH arch to test; default: the host's. A missing tar +# for that arch is an error, so a run never passes +# without having tested an image. The one exception: +# RELEASE_ARCH unset and the host's arch not in +# ARCHES, i.e. this instance doesn't build it +# ARCHES space-separated arches the instance builds (the +# Makefile sets it); only used for that exception +# IMAGE_SMOKE_PORT container port (default 8000) +# IMAGE_SMOKE_PATH URL path (default /health/live) +# IMAGE_SMOKE_RUN_ARGS extra `docker run` words, whitespace-separated and +# without embedded spaces or quotes, e.g. "-e FOO=bar" +# (read from the environment, never from recipe text) +# IMAGE_SMOKE_TIMEOUT seconds to wait (default 60) +set -euo pipefail + +prefix="${IMAGE_TAR_PREFIX:?IMAGE_TAR_PREFIX is required}" +port="${IMAGE_SMOKE_PORT:-8000}" +path="${IMAGE_SMOKE_PATH:-/health/live}" +timeout="${IMAGE_SMOKE_TIMEOUT:-60}" +read -r -a run_args <<<"${IMAGE_SMOKE_RUN_ARGS:-}" + +case "$(uname -m)" in + aarch64 | arm64) host_arch=arm64 ;; + *) host_arch=amd64 ;; +esac +arch="${RELEASE_ARCH:-$host_arch}" +tar="$prefix-$arch.tar" +if [[ ! -f "$tar" && -n "${RELEASE_VERSION:-}" ]]; then + shopt -s nullglob + candidates=("$(dirname "$prefix")"/*-"$RELEASE_VERSION-$arch".tar) + if [[ ${#candidates[@]} -eq 1 ]]; then + tar="${candidates[0]}" + fi +fi +if [[ ! -f "$tar" ]]; then + if [[ -z "${RELEASE_ARCH:-}" && -n "${ARCHES:-}" && " ${ARCHES} " != *" $host_arch "* ]]; then + echo "image-smoke: this instance doesn't build $host_arch (ARCHES=$ARCHES), so there is no image to run on this host; each arch is smoke-tested by its own build leg. Skipping." + exit 0 + fi + echo "image-smoke: $tar not found; run 'make build' first." >&2 + exit 1 +fi + +cid="" +work="" +image="" +# shellcheck disable=SC2329 # invoked by the EXIT trap below +cleanup() { + status=$? + set +e + if [[ -n "$cid" ]]; then + if [[ $status -ne 0 ]]; then + echo "image-smoke: container logs:" >&2 + docker logs "$cid" >&2 || true + fi + docker rm -f "$cid" >/dev/null 2>&1 || true + fi + [[ -z "$image" ]] || docker image rm "$image" >/dev/null 2>&1 || true + [[ -z "$work" ]] || rm -rf "$work" || true + exit "$status" +} +trap cleanup EXIT + +# `docker load` can't read the OCI-layout tar buildx writes (no manifest.json; +# with attestations the index nests another index), so skopeo converts the +# one image for $arch into a docker-archive first. Both live under dist/, +# which is mounted at /dist, so the paths also exist for a Docker daemon +# that shares the workspace but not this process's /tmp. +: "${SKOPEO_IMAGE:?SKOPEO_IMAGE is required (set by the Makefile)}" +echo "image-smoke: loading $tar (linux/$arch)" +tar_dir="$(cd "$(dirname "$tar")" && pwd)" +work="$(mktemp -d "$tar_dir/.image-smoke.XXXXXX")" +# mktemp makes the directory 0700; with rootless or userns-remapped Docker +# the container's user can't write into it. It only exists inside dist/, for +# the length of this script. +chmod 0777 "$work" +# Run (and later remove) a private reference, so cleanup never touches an +# image that already existed. +image="image-smoke-$RANDOM$RANDOM:smoke" +SKOPEO_DIST_DIR="$tar_dir" bash "$(dirname "${BASH_SOURCE[0]}")/skopeo.sh" -- \ + --override-os linux --override-arch "$arch" \ + copy "oci-archive:/dist/$(basename "$tar")" \ + "docker-archive:/dist/$(basename "$work")/image.tar:$image" +docker load -i "$work/image.tar" + +# ${run_args[@]+...}: an empty array is unbound under `set -u` on bash < 4.4. +cid="$(docker run -d -p "127.0.0.1::${port}" ${run_args[@]+"${run_args[@]}"} "$image")" +# No `| head -n1`: head closing the pipe early can SIGPIPE `docker port`, +# which fails the pipeline under pipefail. Take the first line in the shell. +ports="$(docker port "$cid" "${port}/tcp")" +mapped="${ports%%$'\n'*}" +mapped="${mapped##*:}" +if [[ -z "$mapped" ]]; then + echo "image-smoke: container publishes no host port for ${port}/tcp" >&2 + exit 1 +fi +url="http://127.0.0.1:${mapped}${path}" + +echo "image-smoke: waiting up to ${timeout}s for $url" +deadline=$((SECONDS + timeout)) +while ((SECONDS < deadline)); do + if [[ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]]; then + echo "image-smoke: container exited before answering" >&2 + exit 1 + fi + if curl -fsS -o /dev/null "$url" 2>/dev/null; then + echo "image-smoke: $url answered 2xx" + exit 0 + fi + sleep 1 +done +echo "image-smoke: timed out after ${timeout}s waiting for $url" >&2 +exit 1 diff --git a/scripts/post-create.d/README.md b/scripts/post-create.d/README.md index 626fb03..c566a9b 100644 --- a/scripts/post-create.d/README.md +++ b/scripts/post-create.d/README.md @@ -1,8 +1,11 @@ # scripts/post-create.d/ Instance-owned hook: extra steps an instance runs when the devcontainer -is created. This directory ships only this file; an instance adds its -scripts here (see `docs/TEMPLATE.md`'s "Instance extension points"). +is created. This directory ships no scripts in base; a mid-layer template or an instance +adds `NN-name.sh` scripts here (see `docs/TEMPLATE.md`'s "Instance extension +points"). A script a template ships is `replace` tier in its sync manifest, so +a sync overwrites it; an instance's own script is listed in +`.github/template-sync-manifest.local.yml` so a sync leaves it alone. - Every `*.sh` here runs as `vscode` inside the running devcontainer, with the compose stack up, in lexical filename order (use an `NN-` prefix, diff --git a/scripts/runner-setup.d/README.md b/scripts/runner-setup.d/README.md new file mode 100644 index 0000000..df9ce57 --- /dev/null +++ b/scripts/runner-setup.d/README.md @@ -0,0 +1,25 @@ +# scripts/runner-setup.d/ + +Optional instance hook directory for the runner stage of the top-level +`Dockerfile`; an instance whose runner image doesn't call `../runner-setup.sh` +(template-react's nginx runner, say) leaves it empty and unused. `../runner-setup.sh` (template-owned, `replace` tier) creates +the group-0 runtime user, makes the home directory and `/app/data` +group-writable (and nothing else under `/app`), then runs every `*.sh` here in glob order as +`bash NN-name.sh APP_UID`, still as root, in the same `RUN` layer. + +Empty apart from this file in the template. An instance adds files such as +`10-ca-certificates.sh` (install extra packages, create extra writable +directories) instead of editing `runner-setup.sh`. + +## Do + +- Number files (`NN-name.sh`) so the order is explicit. +- Use `set -euo pipefail` and give any extra *writable* directory (cache, + uploads) group-0 ownership and `g+rwX`, as `runner-setup.sh` does for + `/app/data`. App code is not writable: the `Dockerfile` copies it after + this runs, with `--chown=:0` and numeric read-only modes (0550/0440). + +## Don't + +- Edit `../runner-setup.sh`; a template sync overwrites it. +- Put build-stage steps here; this runs in the `runner` stage only. diff --git a/scripts/runner-setup.sh b/scripts/runner-setup.sh new file mode 100755 index 0000000..b398071 --- /dev/null +++ b/scripts/runner-setup.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Prepares the runner stage's runtime user for arbitrary-UID platforms +# (e.g. OpenShift, which ignores an image's own UID and always runs it +# under group 0), and a numeric (not named) final USER in the Dockerfile. +# +# Run it BEFORE copying app content. It creates the user and group 0 +# ownership only where the app writes at runtime: the home directory and +# /app/data (group read/write/execute). /app itself stays root-owned and not +# writable. Then COPY app code with `--chown=:0` as +# read-only: 0550 directories, 0440 files, set with numeric modes per kind +# (`find -type d/-type f ... chmod`, or `--chmod=0550`/`0440` per source). Not +# the symbolic `--chmod=u=rX,g=rX,o=`: it left files unreadable on GitHub's +# stock-runner Docker even with dockerfile:1.28. Either way +# it needs no recursive chgrp/chmod (which would copy every file into a new +# layer) and the runtime user can't modify it. Other writable directories +# (caches, upload dirs) are created by runner-setup.d hooks. +# +# Optional: an instance whose runner image needs no such user (an nginx runner +# with its own non-root user) never calls this script. +# +# Usage: runner-setup.sh APP_UID +# +# Afterwards runs every runner-setup.d/*.sh next to this script, in glob +# order, as `bash NN-name.sh APP_UID`; see runner-setup.d/README.md. +set -euo pipefail + +app_uid=$1 +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +useradd --create-home --uid "$app_uid" --gid 0 appuser +chgrp -R 0 /home/appuser +chmod -R g+rwX /home/appuser + +install -d -m 0755 /app +install -d -o "$app_uid" -g 0 -m 0770 /app/data + +shopt -s nullglob +for hook in "$here"/runner-setup.d/*.sh; do + bash "$hook" "$app_uid" +done diff --git a/scripts/skopeo.sh b/scripts/skopeo.sh new file mode 100755 index 0000000..2749821 --- /dev/null +++ b/scripts/skopeo.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Runs the pinned skopeo image (SKOPEO_IMAGE, set by the Makefile) as the +# caller's user with HOME=/tmp, and dist/ mounted at /dist. Shared by +# `make publish` and image-smoke.sh so the invocation lives in one place. +# +# Usage: skopeo.sh [DOCKER_RUN_ARGS...] -- SKOPEO_ARGS... +# DOCKER_RUN_ARGS extra `docker run` words before `--` (e.g. -i, +# -v "$auth:/auth:ro") +# SKOPEO_ARGS passed to skopeo (e.g. copy oci-archive:/dist/x.tar ...) +# SKOPEO_DIST_DIR directory to mount at /dist (default: ./dist) +set -euo pipefail + +: "${SKOPEO_IMAGE:?SKOPEO_IMAGE is required (set by the Makefile)}" + +docker_args=() +while [[ $# -gt 0 && "$1" != "--" ]]; do + docker_args+=("$1") + shift +done +[[ $# -gt 0 ]] || { echo "usage: $0 [DOCKER_RUN_ARGS...] -- SKOPEO_ARGS..." >&2; exit 2; } +shift + +# `${arr[@]+"${arr[@]}"}` below: an empty array is an unbound variable under +# `set -u` on bash older than 4.4 (macOS ships 3.2). +dist="$(cd "${SKOPEO_DIST_DIR:-dist}" && pwd)" + +exec docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$dist:/dist" ${docker_args[@]+"${docker_args[@]}"} "$SKOPEO_IMAGE" "$@" diff --git a/scripts/test-image-scripts.sh b/scripts/test-image-scripts.sh new file mode 100755 index 0000000..989db97 --- /dev/null +++ b/scripts/test-image-scripts.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Offline tests for the template-owned image logic that has no other check: +# the Makefile's repo derivation, check-dockerfile-versions.sh, image-smoke.sh's +# missing-tar failure and skopeo.sh's argument handling. Needs no Docker daemon +# (docker is stubbed) and writes only to a temp directory. Run by the +# `image-scripts-test` hook. +set -euo pipefail + +# Hermetic: a pre-commit run exports GIT_* (the test inits scratch repos and +# must never touch the real one), and a release run exports RELEASE_ARCH etc. +unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR RELEASE_ARCH RELEASE_VERSION ARCHES GITHUB_REPOSITORY +unset IMAGE_SMOKE_PORT IMAGE_SMOKE_PATH IMAGE_SMOKE_TIMEOUT IMAGE_SMOKE_RUN_ARGS IMAGE_TAR_PREFIX SKOPEO_IMAGE + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +failures=0 + +fail() { + echo "FAIL: $*" >&2 + failures=$((failures + 1)) +} + +# expect_eq NAME EXPECTED ACTUAL +expect_eq() { + [[ "$2" == "$3" ]] || fail "$1: expected '$2', got '$3'" +} + +# Makefile: REPO from the origin remote, in every URL form we claim to parse. +repo_of() { + local dir="$tmp/repo-$RANDOM" + mkdir -p "$dir" + git -C "$dir" init -q + git -C "$dir" remote add origin "$1" + (cd "$dir" && env -u GITHUB_REPOSITORY make -s -f "$root/Makefile" \ + -f <(printf 'show-repo:\n\t@echo $(REPO)\n') show-repo) +} +while IFS='|' read -r url want; do + expect_eq "repo from $url" "$want" "$(repo_of "$url")" +done <<'EOF_URLS' +https://github.com/o/r.git|o/r +https://github.com/o/r|o/r +https://user:token@github.com/o/r.git/|o/r +git@github.com:o/r.git|o/r +ssh://git@github.com/o/r.git|o/r +ssh://git@github.com:22/o/r.git|o/r +ssh://git@ssh.github.com:443/o/r|o/r +ssh://github.com:2222/o/r.git|o/r +git+ssh://git@host:22/o/r.git|o/r +SSH://git@host:22/o/r.git|o/r +EOF_URLS + +# GITHUB_REPOSITORY wins over the remote; a failing git (no origin here) is an +# error for targets that need REPO, with git's message on stderr, never a guess. +dir="$tmp/repo-pref" +mkdir -p "$dir" +git -C "$dir" init -q +git -C "$dir" remote add origin https://github.com/o/r.git +out="$(cd "$dir" && GITHUB_REPOSITORY=x/y make -s -f "$root/Makefile" -f <(printf 'show-repo:\n\t@echo $(REPO)\n') show-repo)" +expect_eq "GITHUB_REPOSITORY preferred" "x/y" "$out" +dir="$tmp/repo-norem" +mkdir -p "$dir" +git -C "$dir" init -q +out="$(cd "$dir" && make -s -f "$root/Makefile" repo-check 2>&1)" && fail "repo-check: git failure should fail" +[[ "$out" == *"cannot derive the repository"* && "$out" == *"origin"* ]] || fail "repo-check: wrong message: $out" +out="$(cd "$dir" && make -s -f "$root/Makefile" publish RELEASE_ARCH=amd64 OCI_REGISTRY=r 2>&1)" && fail "publish: git failure should fail" +[[ "$out" == *"cannot derive the repository"* ]] || fail "publish: wrong message: $out" +out="$(cd "$dir" && make -s -f "$root/Makefile" publish RELEASE_ARCH=amd64 2>&1)" || fail "publish: skipped publish should not need REPO: $out" +(cd "$dir" && GITHUB_REPOSITORY=o/r make -s -f "$root/Makefile" repo-check) || fail "repo-check: GITHUB_REPOSITORY should pass" + +# check-dockerfile-versions.sh in a scratch repo layout. +dv="$tmp/dv" +mkdir -p "$dv/.devcontainer" +run_dv() { (cd "$dv" && bash "$root/scripts/check-dockerfile-versions.sh" "$@" 2>&1); } +printf 'ARG A=1\nARG B="x" # c\nFROM scratch\nARG A\n' >"$dv/.devcontainer/Dockerfile" +printf 'ARG A\nARG A=1\nARG B=x\nFROM scratch\nARG A=1\n' >"$dv/Dockerfile" +run_dv A B >/dev/null || fail "check-dockerfile-versions: matching ARGs (bare re-declaration first) should pass" +printf 'ARG A=1\nFROM scratch\nARG A=2\n' >"$dv/Dockerfile" +out="$(run_dv A)" && fail "check-dockerfile-versions: conflicting defaults in one file should fail" +[[ "$out" == *"conflicting defaults"* ]] || fail "check-dockerfile-versions: wrong message: $out" +printf 'ARG A=2\n' >"$dv/Dockerfile" +out="$(run_dv A)" && fail "check-dockerfile-versions: mismatch should fail" +[[ "$out" == *"mismatch"* ]] || fail "check-dockerfile-versions: wrong message: $out" +printf 'ARG A\n' >"$dv/Dockerfile" +run_dv A >/dev/null && fail "check-dockerfile-versions: no default should fail" +printf 'ARG B=1\n' >"$dv/Dockerfile" +run_dv A >/dev/null && fail "check-dockerfile-versions: missing ARG should fail" + +# image-smoke.sh must fail when the tar for the arch under test is missing. +case "$(uname -m)" in + aarch64 | arm64) host=arm64 other=amd64 ;; + *) host=amd64 other=arm64 ;; +esac +mkdir -p "$tmp/dist" +smoke() { + env IMAGE_TAR_PREFIX="$tmp/dist/app-1.0.0" SKOPEO_IMAGE=unused RELEASE_VERSION=1.0.0 "$@" \ + bash "$root/scripts/image-smoke.sh" 2>&1 +} +out="$(smoke RELEASE_ARCH="$host")" && fail "image-smoke: missing tar with RELEASE_ARCH should fail" +[[ "$out" == *"not found"* ]] || fail "image-smoke: wrong message: $out" +out="$(smoke ARCHES="$host")" && fail "image-smoke: missing host-arch tar should fail" +[[ "$out" == *"not found"* ]] || fail "image-smoke: wrong message: $out" +smoke ARCHES="$other" >/dev/null || fail "image-smoke: host arch not built by the instance should skip" + +# skopeo.sh: no extra docker args (empty array under set -u) and with some. +mkdir -p "$tmp/bin" "$tmp/dist-s" +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*"\n' >"$tmp/bin/docker" +chmod +x "$tmp/bin/docker" +sk() { PATH="$tmp/bin:$PATH" SKOPEO_IMAGE=img SKOPEO_DIST_DIR="$tmp/dist-s" bash "$root/scripts/skopeo.sh" "$@"; } +out="$(sk -- copy a b)" || fail "skopeo.sh: no docker args should work" +[[ "$out" == *"img copy a b" ]] || fail "skopeo.sh: unexpected command: $out" +out="$(sk -i -- login)" || fail "skopeo.sh: docker args should work" +[[ "$out" == *" -i img login" ]] || fail "skopeo.sh: unexpected command: $out" + +if [[ $failures -ne 0 ]]; then + echo "$failures failure(s)" >&2 + exit 1 +fi +echo "image scripts: all tests passed" diff --git a/src/App.stories.tsx b/src/App.stories.tsx new file mode 100644 index 0000000..833756a --- /dev/null +++ b/src/App.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { heroFixtures } from "../.storybook/stubs"; +import App from "./App"; + +const meta = { + title: "App/App", + component: App, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SignedOut: Story = { parameters: { auth: "signedOut" } }; +export const AuthLoading: Story = { parameters: { auth: "loading" } }; +export const SignedIn: Story = { + parameters: { auth: "signedIn", api: { heroes: heroFixtures } }, +}; +export const BackendDown: Story = { + parameters: { auth: "signedOut", api: { health: "down" } }, +}; diff --git a/src/App.test.tsx b/src/App.test.tsx index d616720..e81f21e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,11 +1,14 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import App from "./App"; const { useAuthMock } = vi.hoisted(() => ({ useAuthMock: vi.fn() })); vi.mock("react-oidc-context", () => ({ useAuth: useAuthMock })); describe("App", () => { + // Vitest globals are off, so Testing Library does not clean up on its own. + afterEach(cleanup); + it("prompts sign-in when unauthenticated", () => { useAuthMock.mockReturnValue({ isLoading: false, isAuthenticated: false }); @@ -19,6 +22,16 @@ describe("App", () => { ).toBeInTheDocument(); }); + it("has a main landmark and one h1 when unauthenticated", () => { + useAuthMock.mockReturnValue({ isLoading: false, isAuthenticated: false }); + + render(); + + expect(screen.getByRole("main")).toBeInTheDocument(); + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); + }); + it("shows a loading indicator while auth is resolving", () => { useAuthMock.mockReturnValue({ isLoading: true, isAuthenticated: false }); diff --git a/src/App.tsx b/src/App.tsx index d8a523a..56768a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,16 +15,24 @@ function App() { return ( <> - + - + template-react - + {auth.isAuthenticated ? ( <> - + {auth.user?.profile.preferred_username} @@ -44,10 +53,10 @@ function App() { - + {auth.isLoading ? ( - + ) : auth.isAuthenticated ? ( diff --git a/src/components/HealthStatus.stories.tsx b/src/components/HealthStatus.stories.tsx new file mode 100644 index 0000000..721f761 --- /dev/null +++ b/src/components/HealthStatus.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HealthStatus } from "./HealthStatus"; + +const meta = { + title: "Components/HealthStatus", + component: HealthStatus, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Up: Story = { parameters: { api: { health: "up" } } }; +export const Down: Story = { parameters: { api: { health: "down" } } }; +/** The request never answers, so the chip stays in its first state. */ +export const Checking: Story = { parameters: { api: { health: "pending" } } }; diff --git a/src/components/HealthStatus.tsx b/src/components/HealthStatus.tsx index a38a262..1be4b6d 100644 --- a/src/components/HealthStatus.tsx +++ b/src/components/HealthStatus.tsx @@ -2,11 +2,23 @@ // connection (wrong VITE_API_BASE_URL, backend down) is visible immediately // instead of surfacing only once someone opens the Heroes page. import { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; import { checkLive } from "../api/health"; const POLL_INTERVAL_MS = 30_000; +// Visually hidden, still in the accessibility tree. +const srOnly = { + position: "absolute", + width: "0.0625rem", + height: "0.0625rem", + margin: "-0.0625rem", + overflow: "hidden", + clip: "rect(0 0 0 0)", + whiteSpace: "nowrap", +} as const; + type Status = "checking" | "up" | "down"; export function HealthStatus() { @@ -33,12 +45,27 @@ export function HealthStatus() { }; }, []); - const label = + // "Backend" is always read by screen readers but only shown from the sm + // breakpoint up, so the app bar stays on one line on a phone. + const detail = status === "checking" - ? "Checking backend…" + ? "checking…" : status === "up" - ? "Backend up" - : "Backend unreachable"; + ? "up" + : "unreachable"; + const label = ( + <> + ({ + [theme.breakpoints.down("sm")]: srOnly, + })} + > + Backend{" "} + + {detail} + + ); const color = status === "up" ? "success" : status === "down" ? "error" : "default"; diff --git a/src/main.tsx b/src/main.tsx index 78bf5af..4b96e29 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,21 +1,14 @@ -import "@fontsource/roboto/300.css"; -import "@fontsource/roboto/400.css"; -import "@fontsource/roboto/500.css"; -import "@fontsource/roboto/700.css"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import CssBaseline from "@mui/material/CssBaseline"; -import { ThemeProvider } from "@mui/material/styles"; import { AuthProvider } from "react-oidc-context"; import App from "./App.tsx"; import { oidcConfig } from "./auth/config"; -import { theme } from "./theme/theme"; +import { ThemeProvider } from "./theme"; createRoot(document.getElementById("root")!).render( - - + diff --git a/src/pages/HeroesPage.stories.tsx b/src/pages/HeroesPage.stories.tsx new file mode 100644 index 0000000..917a7be --- /dev/null +++ b/src/pages/HeroesPage.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { heroFixtures } from "../../.storybook/stubs"; +import { HeroesPage } from "./HeroesPage"; + +const meta = { + title: "Pages/HeroesPage", + component: HeroesPage, + parameters: { auth: "signedIn", layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithHeroes: Story = { + parameters: { api: { heroes: heroFixtures } }, +}; +export const Empty: Story = { parameters: { api: { heroes: [] } } }; +export const Loading: Story = { parameters: { api: { heroes: "pending" } } }; +export const LoadError: Story = { + parameters: { + api: { heroes: { status: 403, detail: "You need the viewer role." } }, + }, +}; + +/** The "Add hero" dialog, opened the way a user opens it. */ +export const AddDialogOpen: Story = { + parameters: { api: { heroes: heroFixtures } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Add hero" }), + ); + await within(document.body).findByRole("dialog", { name: "Add hero" }); + }, +}; diff --git a/src/pages/HeroesPage.tsx b/src/pages/HeroesPage.tsx index 32f2816..8b73a8c 100644 --- a/src/pages/HeroesPage.tsx +++ b/src/pages/HeroesPage.tsx @@ -100,7 +100,9 @@ export function HeroesPage() { direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }} > - Heroes + + Heroes + @@ -110,7 +112,7 @@ export function HeroesPage() { {loading ? ( - + ) : ( diff --git a/src/theme/ThemeProvider.tsx b/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..e5c5322 --- /dev/null +++ b/src/theme/ThemeProvider.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; +import { MotionConfig } from "motion/react"; +import "./fonts"; +import { theme } from "./theme"; + +/** Theme, CssBaseline and the reduced-motion policy, in one wrapper. */ +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/src/theme/contrast.test.ts b/src/theme/contrast.test.ts new file mode 100644 index 0000000..abb89bb --- /dev/null +++ b/src/theme/contrast.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { palette, type SchemePalette } from "./tokens"; + +function luminance(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; +} + +export function ratio(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi! + 0.05) / (lo! + 0.05); +} + +// WCAG 2.2 AA: 4.5:1 for text. +describe.each(Object.entries(palette))( + "%s scheme text contrast", + (_name, p: SchemePalette) => { + const pairs: [string, string, string][] = [ + ["text.primary on default", p.text.primary, p.background.default], + ["text.primary on paper", p.text.primary, p.background.paper], + ["text.secondary on default", p.text.secondary, p.background.default], + ["text.secondary on paper", p.text.secondary, p.background.paper], + ["primary on paper", p.primary.main, p.background.paper], + ["primary on default", p.primary.main, p.background.default], + [ + "primary contrastText on primary", + p.primary.contrastText, + p.primary.main, + ], + ["secondary on paper", p.secondary.main, p.background.paper], + [ + "secondary contrastText on secondary", + p.secondary.contrastText, + p.secondary.main, + ], + ["error on paper", p.error.main, p.background.paper], + ["warning on paper", p.warning.main, p.background.paper], + ["info on paper", p.info.main, p.background.paper], + ["success on paper", p.success.main, p.background.paper], + ["success on default", p.success.main, p.background.default], + ["error on default", p.error.main, p.background.default], + ["warning on default", p.warning.main, p.background.default], + ["info on default", p.info.main, p.background.default], + ["error contrastText on error", p.error.contrastText, p.error.main], + [ + "warning contrastText on warning", + p.warning.contrastText, + p.warning.main, + ], + ["info contrastText on info", p.info.contrastText, p.info.main], + [ + "success contrastText on success", + p.success.contrastText, + p.success.main, + ], + ]; + it.each(pairs)("%s >= 4.5:1", (_label, fg, bg) => { + expect(ratio(fg, bg)).toBeGreaterThanOrEqual(4.5); + }); + }, +); diff --git a/src/theme/fonts.ts b/src/theme/fonts.ts new file mode 100644 index 0000000..b7e58df --- /dev/null +++ b/src/theme/fonts.ts @@ -0,0 +1,5 @@ +// Self-hosted variable fonts (no CDN): Fraunces for display, Instrument Sans +// for body/UI. Imported for side effects (the @font-face rules). +// opsz.css carries the optical-size axis that the theme's h1 uses. +import "@fontsource-variable/fraunces/opsz.css"; +import "@fontsource-variable/instrument-sans"; diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..76fb914 --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,5 @@ +export { ThemeProvider } from "./ThemeProvider"; +export { theme } from "./theme"; +export { tokens } from "./tokens"; +export { useColorMode, type ColorMode } from "./useColorMode"; +export { rowReveal, rowRevealContainer } from "./motion"; diff --git a/src/theme/motion.ts b/src/theme/motion.ts new file mode 100644 index 0000000..8025e68 --- /dev/null +++ b/src/theme/motion.ts @@ -0,0 +1,27 @@ +// The one quiet row reveal: a row fades in and settles 4px, staggered by +// the parent. Apply with Motion's `variants`: +// +// +// +// +// wraps the app in MotionConfig reducedMotion="user", so +// under prefers-reduced-motion the y-shift is dropped and only the fade stays. +import type { Variants } from "motion/react"; +import { duration, easing } from "./tokens"; + +const REVEAL_OFFSET = 4; +const STAGGER = 0.03; + +export const rowRevealContainer: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: STAGGER } }, +}; + +export const rowReveal: Variants = { + hidden: { opacity: 0, y: REVEAL_OFFSET }, + visible: { + opacity: 1, + y: 0, + transition: { duration: duration.base, ease: [...easing.enter] }, + }, +}; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index a0f2adb..4ac58c4 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -1,8 +1,125 @@ -// Single MUI theme, light/dark driven by the OS preference -- an instance -// swaps the palette/typography here rather than scattering `sx` overrides. +// The single MUI theme: CSS variables, light/dark schemes that follow the OS +// until the user picks one (see useColorMode), and the Field Ledger +// component overrides for comfortable density and the 2px radius. import { createTheme } from "@mui/material/styles"; +import { cssMotion, fontFamily, palette, radius, spacingUnit } from "./tokens"; export const theme = createTheme({ - colorSchemes: { light: true, dark: true }, - cssVariables: { colorSchemeSelector: "media" }, + cssVariables: { colorSchemeSelector: "data" }, + colorSchemes: { + light: { palette: palette.light }, + dark: { palette: palette.dark }, + }, + shape: { borderRadius: radius.base }, + spacing: spacingUnit, + typography: { + fontFamily: fontFamily.body, + fontSize: 15, + h1: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.02em", + fontVariationSettings: '"opsz" 72', + }, + h2: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.02em", + }, + h3: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.02em", + }, + h4: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.02em", + }, + h5: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.02em", + }, + h6: { + fontFamily: fontFamily.display, + fontWeight: 600, + letterSpacing: "-0.01em", + }, + button: { textTransform: "none", fontWeight: 600 }, + }, + components: { + MuiAppBar: { + defaultProps: { elevation: 0, color: "transparent" }, + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: "transparent", + color: theme.vars.palette.text.primary, + borderBottom: `2px solid ${theme.vars.palette.text.primary}`, + }), + }, + }, + MuiPaper: { styleOverrides: { root: { backgroundImage: "none" } } }, + // Rules, not shadows (docs/design): floating surfaces get an ink rule. + MuiDialog: { + styleOverrides: { + paper: ({ theme }) => ({ + boxShadow: "none", + border: `2px solid ${theme.vars.palette.text.primary}`, + }), + }, + }, + MuiPopover: { + styleOverrides: { + paper: ({ theme }) => ({ + boxShadow: "none", + border: `1px solid ${theme.vars.palette.text.primary}`, + }), + }, + }, + MuiTableCell: { + styleOverrides: { + root: ({ theme }) => ({ + padding: "14px 16px", + borderBottom: `1px solid ${theme.vars.palette.divider}`, + }), + head: ({ theme }) => ({ + fontFamily: fontFamily.body, + fontWeight: 600, + fontSize: "0.75rem", + letterSpacing: "0.08em", + textTransform: "uppercase", + color: theme.vars.palette.text.secondary, + borderBottom: `2px solid ${theme.vars.palette.text.primary}`, + }), + }, + }, + MuiChip: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: radius.base, + backgroundColor: "transparent", + border: `1px solid ${theme.vars.palette.divider}`, + fontWeight: 500, + }), + }, + }, + MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { borderRadius: radius.base, padding: "8px 20px" }, + }, + }, + MuiCssBaseline: { + styleOverrides: { + "tr.MuiTableRow-root td": { + transition: `background-color ${cssMotion.quick}`, + }, + "tr.MuiTableRow-root:hover td": { + backgroundColor: + "color-mix(in srgb, var(--mui-palette-primary-main) 6%, transparent)", + }, + }, + }, + }, }); diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts new file mode 100644 index 0000000..9e404f2 --- /dev/null +++ b/src/theme/tokens.ts @@ -0,0 +1,84 @@ +// Design tokens for the "Field Ledger" direction (docs/design/README.md, +// ADR 0004). This is the only place raw colors and px values live; every +// other file under src/ reads them through the MUI theme (CSS variables) or +// the exports below. scripts/check-design-tokens.sh enforces that. + +export const fontFamily = { + display: '"Fraunces Variable", Georgia, serif', + body: '"Instrument Sans Variable", system-ui, sans-serif', +} as const; + +export interface SchemePalette { + primary: { main: string; contrastText: string }; + secondary: { main: string; contrastText: string }; + error: { main: string; contrastText: string }; + warning: { main: string; contrastText: string }; + info: { main: string; contrastText: string }; + success: { main: string; contrastText: string }; + background: { default: string; paper: string }; + text: { primary: string; secondary: string }; + divider: string; +} + +// Warm paper, vermilion, bottle green. Contrast ratios are asserted in +// contrast.test.ts and recorded in docs/design/README.md. +export const palette: { light: SchemePalette; dark: SchemePalette } = { + light: { + primary: { main: "#B3361B", contrastText: "#FFF8EE" }, + secondary: { main: "#2F4A3A", contrastText: "#FFF8EE" }, + error: { main: "#9E1C1C", contrastText: "#FFF8EE" }, + warning: { main: "#8A5300", contrastText: "#FFF8EE" }, + info: { main: "#2D5F86", contrastText: "#FFF8EE" }, + success: { main: "#2F6B3C", contrastText: "#FFF8EE" }, + background: { default: "#F6EFE2", paper: "#FBF7EE" }, + text: { primary: "#231C14", secondary: "#5E5242" }, + divider: "#D9CDB6", + }, + dark: { + primary: { main: "#E8795C", contrastText: "#1B1510" }, + secondary: { main: "#9CC0A8", contrastText: "#1B1510" }, + error: { main: "#F08A7E", contrastText: "#1B1510" }, + warning: { main: "#E0A94A", contrastText: "#1B1510" }, + info: { main: "#8FB6DA", contrastText: "#1B1510" }, + success: { main: "#8CC79B", contrastText: "#1B1510" }, + background: { default: "#1B1510", paper: "#241D16" }, + text: { primary: "#F1E7D3", secondary: "#B8AA92" }, + divider: "#3D3328", + }, +}; + +// MUI multiplies theme.spacing(n) by this: an 8px grid. +export const spacingUnit = 8; + +// Radius scale in px, consumed by MUI's `shape.borderRadius` (2px base). +export const radius = { none: 0, base: 2, pill: 999 } as const; + +export const duration = { + instant: 0.08, + quick: 0.16, + base: 0.24, + slow: 0.4, +} as const; + +// cubic-bezier control points, usable by both Motion and CSS. +export const easing = { + standard: [0.2, 0, 0, 1], + enter: [0, 0, 0, 1], + exit: [0.3, 0, 1, 1], +} as const; + +const css = (e: readonly number[]) => `cubic-bezier(${e.join(", ")})`; +export const cssMotion = { + quick: `${duration.quick * 1000}ms ${css(easing.standard)}`, + base: `${duration.base * 1000}ms ${css(easing.standard)}`, +} as const; + +export const tokens = { + fontFamily, + palette, + spacingUnit, + radius, + duration, + easing, + cssMotion, +}; diff --git a/src/theme/useColorMode.ts b/src/theme/useColorMode.ts new file mode 100644 index 0000000..dfdd878 --- /dev/null +++ b/src/theme/useColorMode.ts @@ -0,0 +1,30 @@ +import { useColorScheme } from "@mui/material/styles"; + +export type ColorMode = "light" | "dark" | "system"; + +/** + * The color-scheme API for the app: `mode` is the user's choice (default + * "system" = follow the OS), `resolved` the scheme actually shown. `toggle` + * flips between light and dark from whatever is currently shown. + */ +export function useColorMode() { + const { mode, systemMode, setMode } = useColorScheme(); + const resolved: "light" | "dark" | undefined = + mode === "system" ? systemMode : mode; + return { + mode: mode as ColorMode | undefined, + resolved, + setMode: (next: ColorMode) => setMode(next), + // Before the mode resolves, fall back to the OS preference so the first + // click flips what the user sees. + toggle: () => { + const shown = + resolved ?? + (typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"); + setMode(shown === "dark" ? "light" : "dark"); + }, + }; +} diff --git a/tests/visual/README.md b/tests/visual/README.md new file mode 100644 index 0000000..abdecde --- /dev/null +++ b/tests/visual/README.md @@ -0,0 +1,23 @@ +# tests/visual/ + +Playwright visual regression and axe checks over the built Storybook +(ADR 0005). + +- `run.sh` — builds Storybook and runs the suite inside the pinned Playwright + image; `run.sh update` rewrites the baselines. Arm64 hosts only (the + baselines are arm64 renders); the script refuses to run elsewhere. +- `stories.visual.ts` — one `toHaveScreenshot` and one axe test per story, + scheme and viewport, generated from the build's `index.json`. +- `playwright.config.ts`, `serve.mjs` — config and a static server for + `storybook-static/`. +- `__screenshots__/` — the committed baselines. + +## Do + +- Generate and check baselines only through `run.sh` (or the `visual.yml` + job). Look at every changed PNG before committing it. + +## Don't + +- Generate baselines with a locally installed browser, or raise + `maxDiffPixels` to quiet a diff. diff --git a/tests/visual/__screenshots__/app-app--auth-loading-desktop-dark.png b/tests/visual/__screenshots__/app-app--auth-loading-desktop-dark.png new file mode 100644 index 0000000..1e9abe2 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--auth-loading-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--auth-loading-desktop-light.png b/tests/visual/__screenshots__/app-app--auth-loading-desktop-light.png new file mode 100644 index 0000000..321d8c0 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--auth-loading-desktop-light.png differ diff --git a/tests/visual/__screenshots__/app-app--auth-loading-phone-dark.png b/tests/visual/__screenshots__/app-app--auth-loading-phone-dark.png new file mode 100644 index 0000000..c85ba1f Binary files /dev/null and b/tests/visual/__screenshots__/app-app--auth-loading-phone-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--auth-loading-phone-light.png b/tests/visual/__screenshots__/app-app--auth-loading-phone-light.png new file mode 100644 index 0000000..e8715e2 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--auth-loading-phone-light.png differ diff --git a/tests/visual/__screenshots__/app-app--backend-down-desktop-dark.png b/tests/visual/__screenshots__/app-app--backend-down-desktop-dark.png new file mode 100644 index 0000000..6767c90 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--backend-down-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--backend-down-desktop-light.png b/tests/visual/__screenshots__/app-app--backend-down-desktop-light.png new file mode 100644 index 0000000..6552d65 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--backend-down-desktop-light.png differ diff --git a/tests/visual/__screenshots__/app-app--backend-down-phone-dark.png b/tests/visual/__screenshots__/app-app--backend-down-phone-dark.png new file mode 100644 index 0000000..27d5b89 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--backend-down-phone-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--backend-down-phone-light.png b/tests/visual/__screenshots__/app-app--backend-down-phone-light.png new file mode 100644 index 0000000..aa0aa21 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--backend-down-phone-light.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-in-desktop-dark.png b/tests/visual/__screenshots__/app-app--signed-in-desktop-dark.png new file mode 100644 index 0000000..3bb0067 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-in-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-in-desktop-light.png b/tests/visual/__screenshots__/app-app--signed-in-desktop-light.png new file mode 100644 index 0000000..c6c6541 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-in-desktop-light.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-in-phone-dark.png b/tests/visual/__screenshots__/app-app--signed-in-phone-dark.png new file mode 100644 index 0000000..7a0516c Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-in-phone-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-in-phone-light.png b/tests/visual/__screenshots__/app-app--signed-in-phone-light.png new file mode 100644 index 0000000..a3250d5 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-in-phone-light.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-out-desktop-dark.png b/tests/visual/__screenshots__/app-app--signed-out-desktop-dark.png new file mode 100644 index 0000000..f93bca6 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-out-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-out-desktop-light.png b/tests/visual/__screenshots__/app-app--signed-out-desktop-light.png new file mode 100644 index 0000000..4924bbb Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-out-desktop-light.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-out-phone-dark.png b/tests/visual/__screenshots__/app-app--signed-out-phone-dark.png new file mode 100644 index 0000000..2d0b06a Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-out-phone-dark.png differ diff --git a/tests/visual/__screenshots__/app-app--signed-out-phone-light.png b/tests/visual/__screenshots__/app-app--signed-out-phone-light.png new file mode 100644 index 0000000..e181817 Binary files /dev/null and b/tests/visual/__screenshots__/app-app--signed-out-phone-light.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--checking-desktop-dark.png b/tests/visual/__screenshots__/components-healthstatus--checking-desktop-dark.png new file mode 100644 index 0000000..9f36ecd Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--checking-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--checking-desktop-light.png b/tests/visual/__screenshots__/components-healthstatus--checking-desktop-light.png new file mode 100644 index 0000000..661285f Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--checking-desktop-light.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--down-desktop-dark.png b/tests/visual/__screenshots__/components-healthstatus--down-desktop-dark.png new file mode 100644 index 0000000..3c50a1e Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--down-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--down-desktop-light.png b/tests/visual/__screenshots__/components-healthstatus--down-desktop-light.png new file mode 100644 index 0000000..56fe9ca Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--down-desktop-light.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--up-desktop-dark.png b/tests/visual/__screenshots__/components-healthstatus--up-desktop-dark.png new file mode 100644 index 0000000..1f9dfbc Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--up-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/components-healthstatus--up-desktop-light.png b/tests/visual/__screenshots__/components-healthstatus--up-desktop-light.png new file mode 100644 index 0000000..0840cf9 Binary files /dev/null and b/tests/visual/__screenshots__/components-healthstatus--up-desktop-light.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-dark.png b/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-dark.png new file mode 100644 index 0000000..ea0f24e Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-light.png b/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-light.png new file mode 100644 index 0000000..6c58cb9 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--add-dialog-open-desktop-light.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--empty-desktop-dark.png b/tests/visual/__screenshots__/pages-heroespage--empty-desktop-dark.png new file mode 100644 index 0000000..557315e Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--empty-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--empty-desktop-light.png b/tests/visual/__screenshots__/pages-heroespage--empty-desktop-light.png new file mode 100644 index 0000000..72398d0 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--empty-desktop-light.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-dark.png b/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-dark.png new file mode 100644 index 0000000..42584b6 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-light.png b/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-light.png new file mode 100644 index 0000000..cc026e2 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--load-error-desktop-light.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--loading-desktop-dark.png b/tests/visual/__screenshots__/pages-heroespage--loading-desktop-dark.png new file mode 100644 index 0000000..f265250 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--loading-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--loading-desktop-light.png b/tests/visual/__screenshots__/pages-heroespage--loading-desktop-light.png new file mode 100644 index 0000000..aa55555 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--loading-desktop-light.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-dark.png b/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-dark.png new file mode 100644 index 0000000..1825369 Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-dark.png differ diff --git a/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-light.png b/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-light.png new file mode 100644 index 0000000..ae5e93a Binary files /dev/null and b/tests/visual/__screenshots__/pages-heroespage--with-heroes-desktop-light.png differ diff --git a/tests/visual/playwright.config.ts b/tests/visual/playwright.config.ts new file mode 100644 index 0000000..a6579cb --- /dev/null +++ b/tests/visual/playwright.config.ts @@ -0,0 +1,47 @@ +// Visual regression and axe over the built Storybook (ADR 0005). +// Run it through tests/visual/run.sh: baselines are only valid when rendered +// by the pinned Playwright image, never by a locally installed browser. +import { defineConfig } from "@playwright/test"; + +const PORT = 6006; + +export default defineConfig({ + testDir: ".", + // Not *.spec.ts: Vitest would pick that up. + testMatch: "*.visual.ts", + // One folder per spec, one file per story/scheme/viewport; no OS suffix + // because the image is the only supported renderer. + snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}", + outputDir: "../../test-results", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: [ + ["list"], + ["html", { outputFolder: "../../playwright-report", open: "never" }], + ], + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + scale: "css", + // Anything above zero would hide real regressions; the pinned image is + // what makes zero reachable. + maxDiffPixels: 0, + }, + }, + use: { + baseURL: `http://127.0.0.1:${PORT}`, + colorScheme: "light", + locale: "en-US", + timezoneId: "UTC", + reducedMotion: "reduce", + deviceScaleFactor: 1, + }, + webServer: { + command: `node serve.mjs ${PORT}`, + cwd: import.meta.dirname, + url: `http://127.0.0.1:${PORT}/index.json`, + reuseExistingServer: false, + }, +}); diff --git a/tests/visual/run.sh b/tests/visual/run.sh new file mode 100755 index 0000000..bb39ae2 --- /dev/null +++ b/tests/visual/run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Runs the visual-regression and axe suite inside the pinned Playwright image, +# the only renderer whose output is a valid baseline (ADR 0005). +# +# tests/visual/run.sh build Storybook, compare against baselines +# tests/visual/run.sh update build Storybook, rewrite the baselines +# +# Extra arguments after the mode go to `playwright test` (e.g. -g HeroesPage). +set -euo pipefail + +# The tag must equal the @playwright/test version in package.json: the image +# ships exactly that version's browser build, and Playwright refuses a +# mismatch. The digest (the multi-arch index) pins the exact bytes and is +# bumped together with the tag. +IMAGE="mcr.microsoft.com/playwright:v1.64.1-noble@sha256:7852dc5a7d72f5fe458a597d690f6c42fbc8e9e26ddfa95f8e6ef552558d9bbf" + +cd "$(dirname "$0")/../.." +mode=test +case "${1:-}" in test | update) mode="$1" && shift ;; esac + +# The baselines are arm64 renders (ADR 0005) and node_modules is mounted from +# the host, so an amd64 host can neither compare nor update them. +if [ "$(uname -m)" != aarch64 ] && [ "$(uname -m)" != arm64 ]; then + echo "tests/visual/run.sh needs an arm64 host (baselines are arm64, ADR 0005); CI runs it." >&2 + exit 1 +fi + +args=(--config tests/visual/playwright.config.ts) +[ "$mode" = update ] && args+=(--update-snapshots) + +exec docker run --rm --ipc=host --init \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e CI="${CI:-}" \ + -v "$PWD:$PWD" -w "$PWD" "$IMAGE" \ + bash -c 'node_modules/.bin/storybook build --quiet && exec node_modules/.bin/playwright test "$@"' _ "${args[@]}" "$@" diff --git a/tests/visual/serve.mjs b/tests/visual/serve.mjs new file mode 100644 index 0000000..3973c90 --- /dev/null +++ b/tests/visual/serve.mjs @@ -0,0 +1,47 @@ +// Minimal static file server for the built Storybook (storybook-static/). +// usage: node tests/visual/serve.mjs [port] +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve, sep } from "node:path"; + +const root = resolve(import.meta.dirname, "../../storybook-static"); +const port = Number(process.argv[2] ?? 6006); +const types = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".png": "image/png", + ".ico": "image/x-icon", +}; + +createServer((req, res) => { + const { pathname } = new URL(req.url ?? "/", "http://localhost"); + let decoded; + try { + decoded = decodeURIComponent(pathname); + } catch { + res.writeHead(400).end(); + return; + } + const rel = normalize(decoded).replace(/^([/\\])+/, ""); + let file = resolve(join(root, rel)); + if (file !== root && !file.startsWith(root + sep)) { + res.writeHead(403).end(); + return; + } + if (existsSync(file) && statSync(file).isDirectory()) + file = join(file, "index.html"); + if (!existsSync(file)) { + res.writeHead(404).end("not found"); + return; + } + res.writeHead(200, { + "Content-Type": types[extname(file)] ?? "application/octet-stream", + }); + createReadStream(file).pipe(res); +}).listen(port, "127.0.0.1", () => console.log(`serving ${root} on :${port}`)); diff --git a/tests/visual/stories.visual.ts b/tests/visual/stories.visual.ts new file mode 100644 index 0000000..ac5b055 --- /dev/null +++ b/tests/visual/stories.visual.ts @@ -0,0 +1,88 @@ +// One screenshot test and one axe test per story, in light and dark, at a +// fixed viewport (plus a phone width for the whole-app stories). The story +// list comes from the built Storybook's index.json, so a new story is +// covered the moment it exists; its baseline is created with `run.sh update`. +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +interface IndexEntry { + id: string; + type: string; + title: string; +} + +const index = JSON.parse( + readFileSync( + new URL("../../storybook-static/index.json", import.meta.url), + "utf8", + ), +) as { entries: Record }; +const stories = Object.values(index.entries).filter((e) => e.type === "story"); + +const schemes = ["light", "dark"] as const; +const viewports = { + desktop: { width: 1280, height: 720 }, + phone: { width: 390, height: 844 }, +}; +// Whole-app stories (title "App/...") are also checked at phone width. +const viewportsFor = (title: string) => + title.startsWith("App/") + ? (["desktop", "phone"] as const) + : (["desktop"] as const); + +async function openStory(page: Page, id: string, scheme: string) { + await page.goto( + `/iframe.html?id=${id}&viewMode=story&globals=theme:${scheme}`, + ); + const phaseOf = () => + ( + window as unknown as { + __STORYBOOK_PREVIEW__?: { currentRender?: { phase?: string } }; + } + ).__STORYBOOK_PREVIEW__?.currentRender?.phase; + // Storybook has rendered the story and run its play function. A story that + // threw or whose play function failed never reaches "finished", so it fails + // the test instead of being baselined as an error overlay. + await expect + .poll(() => page.evaluate(phaseOf), { timeout: 30_000 }) + .toBe("finished"); + // colorSchemeSelector "data" makes MUI set an empty data- attribute. + await expect(page.locator(`html[data-${scheme}]`)).toBeAttached(); + await page.evaluate(() => document.fonts.ready); + await page.waitForLoadState("networkidle"); +} + +for (const story of stories) { + for (const vp of viewportsFor(story.title)) { + for (const scheme of schemes) { + const name = `${story.id}-${vp}-${scheme}`; + + test.describe(`${story.title} / ${story.id}`, () => { + test.use({ viewport: viewports[vp], colorScheme: scheme }); + + test(`screenshot ${vp} ${scheme}`, async ({ page }) => { + await openStory(page, story.id, scheme); + await expect(page).toHaveScreenshot(`${name}.png`, { + fullPage: true, + }); + }); + + test(`axe ${vp} ${scheme}`, async ({ page }) => { + await openStory(page, story.id, scheme); + const { violations } = await new AxeBuilder({ page }).analyze(); + const blocking = violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + expect( + blocking.map((v) => ({ + id: v.id, + impact: v.impact, + nodes: v.nodes.map((n) => n.target), + })), + ).toEqual([]); + }); + }); + } + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 6830b6f..e5a30f0 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,5 +22,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", ".storybook/*.ts", ".storybook/*.tsx"] }