diff --git a/AGENTS.md b/AGENTS.md
index b9f02159..c2f0100d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,42 +50,347 @@ The .gitignore exists for a reason. Overriding it for pipeline state files (CONT
-# Role: Docs Writer
+# Role: Delivery
-You are a documentation writer in a Cistern Aqueduct. You review changes and
-ensure the documentation is accurate and complete before delivery.
+You are the Delivery cataractae. You own everything from branch to merged.
+Fix whatever is in the way. Resolve merge conflicts and review comments unconditionally. Recirculate after 2 failed fix attempts on the same code-level CI check.
-## Context
+## Step 0 — Pre-flight
-You have **full codebase access**. Your environment contains:
+```bash
+go mod tidy
+go build ./...
+```
+If go mod tidy changed go.mod/go.sum:
+```bash
+git add go.mod go.sum -- ':!CONTEXT.md'
+if git ls-files CONTEXT.md | grep -q CONTEXT.md; then
+ git rm --cached CONTEXT.md
+fi
+git commit -m "chore: go mod tidy"
+```
+If go build fails: fix it before touching git. A broken build should not reach a PR.
+
+## Step 0.5 — Check for zero-commit branch
+
+```bash
+DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}')
+git fetch origin main
+FETCH_EXIT=$?
+```
+
+If the fetch fails (`FETCH_EXIT != 0`), skip this step entirely and continue to Step 1.
+
+If the fetch succeeds:
+
+```bash
+COMMIT_COUNT=$(git log origin/main..HEAD --oneline | wc -l)
+```
+
+- If `COMMIT_COUNT` is **0**: the branch has no commits against `origin/main` — the work was already delivered upstream. Signal immediately and stop:
+ ```bash
+ ct droplet pass $DROPLET_ID --notes "No commits on branch — work already delivered upstream. Signaling pass without PR."
+ ```
+ Do not proceed further.
+
+- If `COMMIT_COUNT` is **non-zero**: continue to Step 1 normally.
+
+## Step 1 — Extract droplet ID and branch
+
+```bash
+DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}')
+BRANCH=$(git branch --show-current)
+BASE=main
+echo "Delivering $DROPLET_ID from $BRANCH"
+```
+
+Do NOT git stash. Per-droplet worktrees are clean by design. Stashing discards
+uncommitted work from prior cataractae silently.
+
+## Step 2 — Rebase onto origin/main before PR
+
+This step is mandatory. Do not open a PR until the branch is based on the current
+tip of `origin/$BASE`.
+
+```bash
+git fetch origin $BASE
+if MERGE_BASE=$(git merge-base HEAD origin/$BASE) && ORIGIN_TIP=$(git rev-parse origin/$BASE); then
+ if [ "$MERGE_BASE" = "$ORIGIN_TIP" ]; then
+ echo "Branch is already based on origin/$BASE — no rebase needed"
+ else
+ echo "Branch is behind origin/$BASE — rebasing"
+ git rebase origin/$BASE
+ fi
+else
+ echo "merge-base check failed — rebasing unconditionally"
+ git rebase origin/$BASE
+fi
+```
+
+If conflicts arise during rebase, resolve them — see Conflict Resolution below.
+After fetch and any rebase:
+```bash
+go build ./... && go test ./...
+if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then
+ echo 'ERROR: conflict markers found after rebase — resolve before pushing'
+ ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required'
+ exit 1
+fi
+git push --force-with-lease origin $BRANCH
+```
+
+## Conflict Resolution
+
+Most conflicts are additive: HEAD added X, this branch adds Y. Keep both.
+
+```bash
+git diff --name-only --diff-filter=U # see conflicted files
+```
+
+For each file:
+1. Understand what HEAD added and what this branch adds
+2. Keep both sets of additions — never discard the branch's work
+3. Verify: go build ./...
+
+After resolving all files:
+```bash
+git add $(git diff --name-only --diff-filter=U)
+if git ls-files CONTEXT.md | grep -q CONTEXT.md; then
+ git rm --cached CONTEXT.md
+fi
+git rebase --continue
+go build ./... && go test ./...
+if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then
+ echo 'ERROR: conflict markers found after rebase — resolve before pushing'
+ ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required'
+ exit 1
+fi
+git push --force-with-lease origin $BRANCH
+```
+
+## Step 3 — Open or locate the PR
+
+```bash
+PR_TITLE=$(grep '^\*\*Title:\*\*' CONTEXT.md | sed 's/\*\*Title:\*\* //')
+PR_URL=$(gh pr create \
+ --title "$PR_TITLE" \
+ --body "Closes droplet $DROPLET_ID." \
+ --base $BASE --head $BRANCH 2>&1) || true
+
+if echo "$PR_URL" | grep -q "already exists"; then
+ PR_URL=$(gh pr view $BRANCH --json url --jq '.url')
+fi
+echo "PR: $PR_URL"
+```
+
+## Step 4 — CI and review
+
+```bash
+CHECKS=$(gh pr checks "$PR_URL")
+GH_EXIT=$?
+if [ $GH_EXIT -ne 0 ] && [ -z "$CHECKS" ]; then
+ echo "ERROR: gh pr checks failed (exit $GH_EXIT)"
+ ct droplet pool $DROPLET_ID --notes "gh pr checks failed (exit $GH_EXIT) — cannot verify CI — $PR_URL"
+ exit 1
+elif [ -z "$CHECKS" ]; then
+ echo "No CI checks configured — proceeding to merge"
+else
+ echo "$CHECKS"
+ # Wait for all checks to pass before merging.
+fi
+```
+
+### Per-check attempt counter
+
+Before entering the fix loop, initialize an associative array keyed by check name:
+
+```bash
+declare -A CHECK_ATTEMPTS # key = check name, value = number of fix attempts made
+```
+
+Each time you take any action to fix a specific failing check — including a `gh run rerun` — increment `CHECK_ATTEMPTS[""]`. The counter is per check name, not per push. A rerun is not a free retry: it counts as attempt 1, and if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation.
+
+### Failure classification
-- The full repository with the implementation committed
-- `CONTEXT.md` describing the work item and requirements
+Classify each failing check before acting on it. Classification determines whether the attempt counter applies.
-Read `CONTEXT.md` first to understand your droplet ID and what was built.
+**Recirculate-eligible** — code-level failures the implementer can address (attempt counter applies):
+- Test failures: output contains `FAIL`, `--- FAIL`, `FAIL\t`, assertion errors, `expected X got Y`, `not equal`
+- API errors: application returns unexpected `4xx` or `5xx` status
+- Schema mismatches: `field missing`, `type mismatch`, `unknown field`, `validation error`
+- Compilation errors in test or application code
-## Protocol
+**Pooled-eligible** — infrastructure failures the implementer cannot address (attempt counter does NOT apply):
+- Port conflicts: `address already in use`, `bind: address already in use`
+- Container startup failures: `container exited with code`, `failed to start container`, `OOMKilled`
+- Service unavailable: `connection refused`, `no such host`, `dial tcp.*refused`, `i/o timeout`
-1. **Read CONTEXT.md** — note your droplet ID and what changed
-2. **Run git diff main...HEAD** — understand all user-visible changes
-3. **Find all .md files** — `find . -name "*.md" -not -path "./.git/*"`
-4. **Check each changed area** — for CLI, config, pipeline, and architecture
- changes: verify docs exist and are accurate
-5. **If no user-visible changes** — pass immediately:
- `ct droplet pass --notes "No documentation updates required."`
-6. **Otherwise** — update outdated sections, add missing docs
-7. **Commit** — `git add -A && git commit -m ": docs: update documentation for changes"`
-8. **Signal outcome**
+**Counter-exempt** — process-level issues that block CI but are not code failures; resolve unconditionally (attempt counter does NOT apply):
+- Merge conflicts: branch is behind `origin/main`, CI detects out-of-date branch
+- Unresolved review comments: reviewer has requested changes
-## Signaling
+For pooled-eligible failures, signal immediately without incrementing the counter:
+```bash
+ct droplet pool $DROPLET_ID --notes "Pooled: — $PR_URL"
+```
+
+### Counter-exempt handling
+
+Before entering the fix loop, resolve all counter-exempt issues unconditionally — no attempt counter applies:
+
+- **Merge conflict detected by CI** → rebase (Step 2) and push, then re-check CI
+- **Unresolved review comment** → address it, commit, push, then re-check CI
+
+Repeat until no counter-exempt issues remain, then proceed to the fix loop.
+
+### Fix loop
+
+For each recirculate-eligible failing check:
+
+1. Increment `CHECK_ATTEMPTS[""]`
+2. If `CHECK_ATTEMPTS[""] > 2`, recirculate — see **Recirculate path** below.
+3. Otherwise, apply the appropriate fix and push:
+ - Compile error → fix code, `go build ./...`, commit, push
+ - Test failure → fix test or code, `go test ./...`, commit, push
+ - Flaky test → `gh run rerun ` and wait for result (**this counts as attempt 1; if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation**)
+
+After each fix commit:
+```bash
+git add -A -- ':!CONTEXT.md'
+if git ls-files CONTEXT.md | grep -q CONTEXT.md; then
+ git rm --cached CONTEXT.md
+fi
+git commit -m "fix: " && git push
+```
+
+Wait for the check to complete, then return to step 1 of the loop for any remaining failures.
+
+### Recirculate path
+
+When `CHECK_ATTEMPTS[""] > 2`, stop and recirculate with a structured diagnostic. All five fields are required — do not recirculate with a partial note.
+
+```bash
+ct droplet recirculate $DROPLET_ID --notes "$(cat <<'EOF'
+CI recirculation: 2 failed fix attempts on the same check.
+
+Failed check:
+
+Error snippet:
+
+
+Fix attempt 1:
+
+Fix attempt 2:
+
+Recommended fix:
+EOF
+)"
+```
+
+Wait for all checks to pass before merging. If `gh pr checks` returns no output, there are no CI checks — proceed directly to Step 5.
+
+## Step 5 — Merge
+
+```bash
+git fetch origin && git rebase origin/$BASE
+if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then
+ echo 'ERROR: conflict markers found after rebase — resolve before pushing'
+ ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required'
+ exit 1
+fi
+git push --force-with-lease && gh pr merge "$PR_URL" --squash --delete-branch
+STATE=$(gh pr view "$PR_URL" --json state --jq '.state')
+if [ "$STATE" != "MERGED" ]; then
+ echo "ERROR: merge failed — state is $STATE"
+ ct droplet pool $DROPLET_ID --notes "Merge failed: state=$STATE — $PR_URL"
+ exit 1
+fi
+echo "Confirmed: PR state is MERGED"
+```
+
+## Step 6 — Signal
+Only after MERGED is confirmed:
+```bash
+ct droplet pass $DROPLET_ID --notes "Delivered: $PR_URL — "
```
-ct droplet pass --notes "Updated docs: ."
-ct droplet recirculate --notes "Ambiguous: "
+
+If merge is impossible after exhausting all options:
+```bash
+ct droplet pool $DROPLET_ID --notes "Cannot merge: — $PR_URL"
```
+## Rules
+- Never signal pass until gh pr view confirms state == "MERGED"
+- Never discard branch additions in conflicts — always keep both sides
+- go build + go test must pass before every push
+- Fix CI, conflicts, and review comments yourself — do not recirculate for routine failures
+- Recirculate after 2 failed fix attempts on the same code-level CI check (see Step 4 recirculate path)
+- Recirculate only for code-level failures — never recirculate for infrastructure/pooled failures (pool instead)
+- Never run git add CONTEXT.md or git add -f CONTEXT.md under any circumstances
+- CONTEXT.md is pipeline state injected at dispatch time; it must never be committed
+
## Skills
+## Skill: cistern-github
+
+---
+name: cistern-github
+description: GitHub CLI operations for Cistern delivery cataractae. Use for PR creation, CI checks, and squash-merge in per-droplet delivery workflows.
+---
+
+# Cistern GitHub Operations
+
+## Tools
+
+Use `gh` CLI for all GitHub operations. Prefer CLI over GitHub MCP servers for lower context usage.
+
+## PR Lifecycle
+
+```bash
+# Create a PR for the current droplet branch
+gh pr create \
+ --title "$PR_TITLE" \
+ --body "Closes droplet $DROPLET_ID." \
+ --base main --head $BRANCH
+
+# If PR already exists
+gh pr view $BRANCH --json url --jq '.url'
+
+# Check CI status
+gh pr checks $PR_URL
+
+# Squash-merge when all checks pass
+gh pr merge $PR_URL --squash --delete-branch
+
+# Confirm merge
+gh pr view $PR_URL --json state --jq '.state' # must be "MERGED"
+```
+
+## Conflict Resolution
+
+**Conflicts MUST be resolved automatically. Never stop and ask the user.**
+
+Cistern agents resolve conflicts by keeping both sets of changes. The canonical
+protocol is in `cataractae/delivery/INSTRUCTIONS.md` — follow it exactly.
+
+Summary:
+1. `git diff --name-only --diff-filter=U` — identify conflicted files
+2. For each file: keep what HEAD added AND keep what this branch adds
+3. `go build ./...` — verify the merge compiles
+4. `git add $(git diff --name-only --diff-filter=U)` — stage resolved files
+5. `git rebase --continue`
+6. `go build ./... && go test ./...` — verify after full rebase
+7. `git push --force-with-lease origin $BRANCH`
+
+Most conflicts are additive: HEAD added X, this branch adds Y — keep both.
+Never discard branch additions.
+
+## Cistern Delivery Model
+
+Cistern uses **per-droplet branches** (`feat/`), not stacked PRs.
+Each droplet is independent. There is no stacked-PR workflow.
+
## Skill: cistern-droplet-state
# Cistern Droplet State
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e98153f1..c194c68d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,24 @@ All notable changes to this project will be documented here.
## [Unreleased]
+### Added
+
+- **Frontend error boundaries**: A root-level `ErrorBoundary` wraps the entire app in `main.tsx`, a TanStack Router `errorComponent` on the root route catches routing errors, and `ErrorBoundary` wrappers around all Recharts chart sections in `dashboard.tsx` and `analytics.tsx` prevent chart crashes from unmounting the app. The error UI includes both "Try Again" and "Reload" buttons.
+
+- **Toast notification system**: A `ToastProvider` component and global `toast()` function provide transient error and success notifications. All mutations surface errors to users via a global `mutations.onError` handler in `main.tsx` that calls `toast(error.message, 'error')`. Previously silent mutation failures (createTeam, evaluateQualityGate, deleteQualityGate, deleteWebhook, profile update, password change) now display toast feedback.
+
+- **Inline error display for evaluateMutation**: The quality gates evaluate button shows an inline error message below the gate card when evaluation fails, persisted until the next successful evaluation or a new attempt. This complements the global toast for visibility.
+
+- **Admin route role guard**: The `/admin` route now uses a `requireOwner` `beforeLoad` guard that redirects non-owners to `/` at the router level, replacing the previous component-side "Access Denied" rendering.
+
+- **Success toasts for destructive actions**: `deleteQualityGate` and `deleteWebhook` mutations now show success toasts on completion.
+
### Changed
+- **Profile page refactored to useMutation**: The profile display name and password forms have been refactored from manual `useState` + `try/catch` to TanStack Query `useMutation` with proper `onSuccess`/`onError` callbacks. Profile changes now invalidate the `queryKeys.admin.users()` cache so other pages showing user names stay current.
+
+- **Evaluate mutation cache invalidation**: `evaluateMutation.onSuccess` now invalidates `queryKeys.qualityGates.evaluations()` so the EvaluationHistory panel shows fresh results after evaluation without a manual refresh.
+
- **Store layer extraction**: All HTTP handlers now use store interfaces instead of embedding `*db.Pool` directly. Store interfaces are defined on the handler side and implemented in `internal/store/`, making handlers testable without a running database and centralizing SQL query knowledge. Affected handlers: auth, teams, analytics, executions, reports, admin, invitations, oauth.
- **Bulk test result inserts**: Report ingestion now uses `pgx.Batch` to insert test results in bulk instead of one query per result. This eliminates the N+1 insert pattern that caused 1000+ round-trips for large reports.
diff --git a/CONTEXT.md b/CONTEXT.md
deleted file mode 100644
index 3d44a71e..00000000
--- a/CONTEXT.md
+++ /dev/null
@@ -1,157 +0,0 @@
-# Context
-
-## Item: sc-8q5yl
-
-**Title:** Fix analytics browser E2E test: 'Analytics' heading not found
-**Status:** in_progress
-**Priority:** 2
-
-### Description
-
-The analytics browser E2E test fails in CI at line 78 of analytics.spec.ts:
- await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
-
-Error: element(s) not found — timeout 5000ms exceeded (3 retries, all failing).
-
-The page isn't loading or the heading isn't rendering. Possible causes:
-- Navigation to /analytics isn't completing before the assertion
-- Auth state is lost during navigation (Zustand in-memory auth doesn't persist)
-- The heading text doesn't match exactly ('Analytics' vs something else)
-- The page requires data from global-setup that isn't seeded in time
-
-Investigation needed:
-- Check what the page looks like at time of failure (screenshot available at test-results/)
-- Verify the nav link click actually navigates to /analytics
-- Check if waitForURL or waitForLoadState is needed after navigation
-- Confirm the heading text in the actual component
-
-Acceptance criteria:
-- analytics.spec.ts browser test passes consistently in CI
-- The 'Analytics' heading is reliably visible before assertions proceed
-
-## Current Step: implement
-
-- **Type:** agent
-- **Role:** implementer
-- **Context:** full_codebase
-
-## ⚠️ REVISION REQUIRED — Fix these issues before anything else
-
-This droplet was recirculated. The following issues were found and **must** be fixed.
-Do not proceed to implementation until you have read and understood each issue.
-
-### Issue 1 (from: reviewer)
-
-No findings. E2E test follows established browser-ui.spec.ts pattern exactly. Auth flow is correct (loadCachedToken + getOrCreateTeam + loginViaUI all default to maintainer user). All asserted headings are rendered unconditionally in analytics.tsx (not gated by loading/error states). waitForURL prevents timing issues. No security, logic, error handling, resource leak, or contract violation issues.
-
-### Issue 2 (from: qa)
-
-Phase 1: No prior issues were flagged by the previous reviewer — nothing to verify. Phase 2 fresh review: All tests pass (20 Go packages, 278 frontend). waitForURL('/analytics') is correct given baseURL in playwright.config.ts. All four section h2 headings in analytics.tsx are unconditionally rendered (not gated by loading/empty state). Nav link 'Analytics' confirmed in root-layout.tsx. Auth pattern (getOrCreateTeam + loginViaUI) matches established browser-ui.spec.ts pattern exactly. No gaps found.
-
-### Issue 3 (from: security)
-
-No security issues found. Diff adds a single Playwright E2E browser test with no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface.
-
-### Issue 4 (from: reviewer)
-
-No findings. Diff adds a single E2E browser test and documentation — no production code changes. All assertions verified against actual component rendering in analytics.tsx (headings are unconditionally rendered). Auth flow follows established browser-ui.spec.ts pattern. The networkidle wait before navigation correctly addresses the auth race condition. Sign Out aria-label casing is compatible (Playwright case-insensitive). waitForURL glob pattern is correct. No security, logic, error handling, resource leak, or contract issues.
-
-### Issue 5 (from: qa)
-
-Phase 1: No open issues from prior QA cycles. Phase 2: Go tests pass (all packages). Frontend tests pass (278/278). The analytics browser test is structurally sound — h1 'Analytics' and all four h2 section headings are unconditionally rendered in analytics.tsx (not gated by loading/empty state). The waitForLoadState('networkidle') before clicking Analytics is a technically correct fix for the documented race condition (dashboard API queries destabilizing Zustand auth before SPA navigation). Auth pattern matches browser-ui.spec.ts exactly. waitForURL('**/analytics') is correct for the configured baseURL. No ambiguous div.p-6.space-y-8 selector. Comments explain non-obvious Zustand auth constraints. No mock substitution issues — this is a real browser test. Ready for delivery.
-
-### Issue 6 (from: security)
-
-No security issues found. Diff adds a single Playwright E2E browser test and documentation — no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface.
-
----
-
-## Recent Step Notes
-
-### From: delivery
-
-♻ CI recirculation: 2 failed fix attempts on the same check.
-
-Failed check: e2e-test
-
-Error snippet:
- Test: analytics browser: navigate to /analytics via nav link and assert page renders
- Expected: page.getByRole('heading', { name: 'Analytics' }).toBeVisible()
- Actual: Element not found — timeout 5000ms exceeded
-
-Verification notes:
-- The analytics.tsx component unconditionally renders h1.text-2xl.font-bold with text "Analytics"
-- The root-layout.tsx nav includes the Analytics link: { to: '/analytics', label: 'Analytics' }
-- All prior reviewers (code review, QA, security, docs) approved with "No findings"
-- QA confirmed: "h1 'Analytics' and all four h2 section headings are unconditionally rendered (not gated by loading/empty state)"
-
-Fix attempt 1:
- - Action: gh run rerun 23717372759 --job 69086558861
- - Result: FAILED with identical error
-
-Fix attempt 2:
- - Action: gh run rerun 23717372759 --job 69087077423
- - Result: FAILED with identical error
-
-Root cause analysis:
-The test failure persists across identical code, suggesting either:
-1. A race condition in the SPA navigation flow (waitForURL or waitForLoadState timing)
-2. An environmental issue in CI (baseURL mismatch, app not serving frontend, auth state loss)
-3. A flaky test that needs additional synchronization beyond current wait conditions
-
-Recommended fix:
-- Inspect the actual CI logs and playwright HTML report for the exact DOM state at failure
-- Add explicit waits for the Analytics page container or check DOM visibility before heading assertion
-- Verify the SPA router actually navigates (check window.location.href or page.url())
-- Consider if Zustand auth state is being cleared during navigation despite waitForLoadState
-
-PR: https://github.com/MichielDean/ScaledTest/pull/224
-
-### From: docs_writer
-
-Documentation complete and accurate. Updated CLAUDE.md and README.md with E2E test commands and comprehensive testing section. All npm scripts verified against e2e/package.json. Analytics E2E test properly documented with explanations of auth flow and timing concerns.
-
-### From: security
-
-No security issues found. Diff adds a single Playwright E2E browser test and documentation — no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface.
-
-### From: qa
-
-Phase 1: No open issues from prior QA cycles. Phase 2: Go tests pass (all packages). Frontend tests pass (278/278). The analytics browser test is structurally sound — h1 'Analytics' and all four h2 section headings are unconditionally rendered in analytics.tsx (not gated by loading/empty state). The waitForLoadState('networkidle') before clicking Analytics is a technically correct fix for the documented race condition (dashboard API queries destabilizing Zustand auth before SPA navigation). Auth pattern matches browser-ui.spec.ts exactly. waitForURL('**/analytics') is correct for the configured baseURL. No ambiguous div.p-6.space-y-8 selector. Comments explain non-obvious Zustand auth constraints. No mock substitution issues — this is a real browser test. Ready for delivery.
-
-
-
- cistern-droplet-state
- Manage droplet state in the Cistern agentic pipeline using the `ct` CLI.
- /home/lobsterdog/.cistern/skills/cistern-droplet-state/SKILL.md
-
-
- cistern-git
- ---
- /home/lobsterdog/.cistern/skills/cistern-git/SKILL.md
-
-
- cistern-github
- ---
- /home/lobsterdog/.cistern/skills/cistern-github/SKILL.md
-
-
-
-## Signaling Completion
-
-When your work is done, signal your outcome using the `ct` CLI:
-
-**Pass (work complete, move to next step):**
- ct droplet pass sc-8q5yl
-
-**Recirculate (needs rework — send back upstream):**
- ct droplet recirculate sc-8q5yl
- ct droplet recirculate sc-8q5yl --to implement
-
-**Block (genuinely blocked, cannot proceed):**
- ct droplet block sc-8q5yl
-
-Add notes before signaling:
- ct droplet note sc-8q5yl "What you did / found"
-
-The `ct` binary is on your PATH.
diff --git a/frontend/src/components/__tests__/error-boundary.test.tsx b/frontend/src/components/__tests__/error-boundary.test.tsx
new file mode 100644
index 00000000..7ac7bf20
--- /dev/null
+++ b/frontend/src/components/__tests__/error-boundary.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ErrorBoundary } from '../error-boundary';
+
+describe('ErrorBoundary', () => {
+ const originalConsoleError = console.error;
+
+ beforeEach(() => {
+ console.error = vi.fn();
+ });
+
+ afterEach(() => {
+ console.error = originalConsoleError;
+ });
+
+ it('renders children when no error', () => {
+ render(
+
+
Hello world
+
+ );
+ expect(screen.getByText('Hello world')).toBeInTheDocument();
+ });
+
+ it('renders default error UI when child throws', () => {
+ function ThrowingComponent(): React.ReactNode {
+ throw new Error('Test explosion');
+ }
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.getByText('Test explosion')).toBeInTheDocument();
+ });
+
+ it('renders custom fallback when provided and child throws', () => {
+ function ThrowingComponent(): React.ReactNode {
+ throw new Error('kaboom');
+ }
+
+ render(
+ Custom fallback
}>
+
+
+ );
+
+ expect(screen.getByText('Custom fallback')).toBeInTheDocument();
+ });
+
+ it('renders Try Again and Reload buttons in default error UI', () => {
+ function ThrowingComponent(): React.ReactNode {
+ throw new Error('failure');
+ }
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('button', { name: 'Try Again' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument();
+ });
+
+ it('Resetting error boundary allows retry via Try Again', () => {
+ let shouldThrow = true;
+
+ function MaybeThrowingComponent(): React.ReactNode {
+ if (shouldThrow) {
+ throw new Error('failure');
+ }
+ return
Recovered
;
+ }
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+
+ shouldThrow = false;
+ fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
+
+ expect(screen.getByText('Recovered')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/__tests__/toast.test.tsx b/frontend/src/components/__tests__/toast.test.tsx
new file mode 100644
index 00000000..be8d38da
--- /dev/null
+++ b/frontend/src/components/__tests__/toast.test.tsx
@@ -0,0 +1,112 @@
+import { render, screen, act } from '@testing-library/react';
+import { ToastProvider, toast } from '../toast';
+
+describe('ToastProvider', () => {
+ it('renders children', () => {
+ render(
+
+