diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 74e9fca0a..654136201 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -971,3 +971,4 @@ This file is append-only. Never rewrite or delete an existing review record; app
| 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | pending-ci-retrigger-2 | CI unblock + skip-branch-sync | Repeated pr-branch-sync bot merges left CI `action_required`. Applied `skip-branch-sync` label and agent retrigger so required checks can finish for squash-merge; then close #1190. | Hosted CI pending on tip; no provider-backed checks. |
| 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | `e19442240afbd7f28c321e399c6b4dcb0a7c9fdf` / squash `bb6b394617cbd906285ebe19e0e452912793320a` | prlanded after squash merge | MERGED. Two-dot content diff vs `origin/main` empty; clinical gate preserved; `no-hardcoded-hex` + theme CSS on main; PWA manifest theme colours intentionally absent. Hosted PR required SUCCESS (Unit/Build/Static/Production UI). Remote salvage branch deleted by squash `--delete-branch`. | `gh pr view` MERGED; `git diff origin/main e19442240` empty; trustGated grep on main; no provider-backed checks. |
| 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Close without merge after #1214 salvage | CLOSED (not merged). Unsafe tip superseded by #1214. Remote branch `remediate-dark-mode-audit` retained pending optional cleanup; do not merge. | `gh pr view` state=CLOSED mergedAt=null; no provider-backed checks. |
+| 2026-07-25 | cursor/formulation-disabled-accessibility-14d4 | 5be3d6ccccc4c974539612fb6fbbbfb6dfb3d105 | #064 clean replacement PR-readiness review | Current-main clean-room replay contains only the formulation disabled-state improvement, focused browser coverage, and issue disposition. Product/test content is identical to the independently reviewed change; no P0-P2 findings. Conflicted PR #1219 is superseded and must not merge. | Focused Chromium 2/2; `verify:cheap` 3,421/3,421; `verify:pr-local` passed with build, client-secret scan, and offline RAG fixtures; no provider-backed checks. |
diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md
index 0acd9bb2c..5f331193c 100644
--- a/docs/outstanding-issues.md
+++ b/docs/outstanding-issues.md
@@ -61,7 +61,7 @@ removed after current-main verification; it is not missing recommended work.
| 9 | `#029` | A2 | Specialist — answer quality/clinical safety | After `#051`/`#023` and `#018` | 0.5–1 day inventory; 1–3 days per fix | Re-enumerate current fallback stubs and fix one causal cluster at a time without weakening grounding/citation gates. Stop if a change merely makes the metric easier to pass. |
| 10 | `#001` | A2 | Specialist — retrieval/ranking | After `#051`/`#023` and rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. |
| 11 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. |
-| 12 | `#064` | A2 | High — frontend/browser | After higher-acuity local fixes; before the release UI gate | 4–8 hours | Preserve the isolated dirty formulation/contrast patch, reconcile its intent against current `main`, and run focused Playwright plus `verify:ui`. Stop rather than overwriting unrelated work or weakening access-control assertions. |
+| 12 | `#064` | A2 | High — frontend/browser | Clean replacement branch ready for hosted review | Merge only after required CI is green | `cursor/formulation-disabled-accessibility-14d4` contains only the reviewed formulation disabled-state fix, focused Playwright coverage, and consolidated ledger evidence on current `main`; the conflicted PR #1219 must not merge. |
| 13 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. |
| 14 | `#056` | A2 | Operator — Supabase/Railway + Specialist | After cost/ownership approval | 0.5–1 day | Provision isolated `Clinical KB Staging` with synthetic data and distinct secrets. Verify identity, schema, indexing, health, and data boundary; never copy production clinical documents. |
| 15 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. |
@@ -94,7 +94,7 @@ removed after current-main verification; it is not missing recommended work.
| ID | Pri | Type | Summary | Detail / next action | Source | Added |
| ---- | --- | ----- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 |
-| #064 | P2 | task | Reconcile the preserved browser and contrast patch | **Outcome:** the isolated dirty formulation/contrast patch is safely landed or explicitly dispositioned. **Next:** rebase its intent against current `main` without overwriting the worktree, then run focused Playwright coverage and `verify:ui`. **Success:** intended disabled/contrast behavior is accessible, browser assertions remain meaningful, and unrelated work is preserved. **Stop:** do not discard or auto-merge the dirty worktree; pause on ambiguous ownership or scope. | `agent/formulation-disabled-contrast`; session 2026-07-24 | 2026-07-24 |
+| #064 | P2 | task | Reconcile the preserved browser and contrast patch | **Clean replacement ready 2026-07-25:** commit `70dd8cd9` on `cursor/formulation-disabled-accessibility-14d4` applies the independently reviewed opacity-free disabled styling and native-disabled/focus/axe coverage directly to current `main`. The unavailable historical worktree remains untouched. The earlier PR #1219 passed hosted checks but became GitHub-conflicted after automated branch sync and must not merge. **Next:** open the clean replacement PR and require all hosted checks, especially Production UI, to pass before merge. **Stop:** do not merge #1219, weaken PWA/access-control assertions, or alter the historical worktree. | `agent/formulation-disabled-contrast`; clean replacement `cursor/formulation-disabled-accessibility-14d4`; session 2026-07-25 | 2026-07-24 |
| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 |
| #051 | P2 | task | Stabilise the live answer-quality canary before more RAG tuning | Diagnostics landed in PR #1095: structured JSON/Markdown artifacts now record the actual checked-out SHA, run identity and latency context, and the offline trend tool separates content, provider-route and latency outcomes. First validating run `30018289898` recorded the expected tree and cost, with 36/36 retrieval green, but one report cannot establish variability; PR #1097 prevents a single failure being mislabeled as repeated. Next: compare the scheduled 2026-07-26 structured report with this run. Do not spend on an immediate retry or reapply the archived lithium guard before that comparison. | PR #1095; run `30018289898`; PR #1097; archive ref `refs/archive/rejected-rag/20260723/monitoring-subject-gate` | 2026-07-23 |
| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 |
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index 8728bd205..ae41a4aaf 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -678,7 +678,7 @@ export function FormulationBuilderPage({
type="button"
onClick={() => move(-1)}
disabled={activeIndex === 0}
- className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text-muted)] disabled:opacity-40"
+ className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text-muted)] disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-inset)]"
>
Previous
@@ -688,7 +688,7 @@ export function FormulationBuilderPage({
type="button"
onClick={() => move(1)}
disabled={activeStep === "select" && selectedMechanisms.length === 0}
- className="inline-flex min-h-tap items-center gap-2 rounded-lg bg-[color:var(--command)] px-4 text-sm font-bold text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] disabled:cursor-not-allowed disabled:opacity-45"
+ className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-transparent bg-[color:var(--command)] px-4 text-sm font-bold text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-inset)] disabled:text-[color:var(--text-muted)] disabled:shadow-none"
>
{activeStep === "select"
? "Continue to framework"
diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts
index 9ae2f4c7f..1b2f35147 100644
--- a/tests/ui-formulation.spec.ts
+++ b/tests/ui-formulation.spec.ts
@@ -138,13 +138,42 @@ test("keeps long mobile formulation pages inside the app scrollport", async ({ p
expect(geometry.mainScrollHeight).toBeGreaterThan(geometry.mainClientHeight + 40);
});
+test("keeps unavailable builder navigation natively disabled without fading its text", async ({ page }, testInfo) => {
+ await gotoApp(page, "/formulation/builder?template=5Ps");
+
+ const previousStep = page.getByRole("button", { name: "Previous", exact: true });
+ const continueStep = page.getByRole("button", { name: "Continue to framework", exact: true });
+
+ await expect(previousStep).toBeDisabled();
+ await expect(continueStep).toBeDisabled();
+ await expect(previousStep).toHaveCSS("opacity", "1");
+ await expect(continueStep).toHaveCSS("opacity", "1");
+
+ const disabledControlsAcceptedFocus = await page.evaluate(() => {
+ const controls = Array.from(document.querySelectorAll("button:disabled")).filter((button) =>
+ ["Previous", "Continue to framework"].includes(button.textContent?.trim() ?? ""),
+ );
+ return controls.map((control) => {
+ control.focus();
+ return document.activeElement === control;
+ });
+ });
+ expect(disabledControlsAcceptedFocus).toEqual([false, false]);
+ await expectNoBlockingAxeViolations(page, testInfo);
+});
+
test("moves a selected mechanism through framework, quality review, and an editable draft", async ({
page,
}, testInfo) => {
await gotoApp(page, "/formulation/builder?mechanism=rumination&template=5Ps");
await expect(page.getByRole("checkbox", { name: /Rumination/ })).toBeChecked();
- await page.getByRole("button", { name: /Continue to framework/ }).click();
+ const previousStep = page.getByRole("button", { name: "Previous", exact: true });
+ const continueStep = page.getByRole("button", { name: "Continue to framework", exact: true });
+ await expect(previousStep).toBeDisabled();
+ await expect(previousStep).toHaveCSS("opacity", "1");
+ await expect(continueStep).toBeEnabled();
+ await continueStep.click();
await expect(page.getByTestId("formulation-builder-structure")).toBeVisible();
const cbtCycle = page.getByRole("radio", { name: /CBT cycle/ });
await page.getByText("CBT cycle", { exact: true }).click();
@@ -173,12 +202,9 @@ test("moves a selected mechanism through framework, quality review, and an edita
await expect(draft).not.toHaveValue("Stale edited draft");
await expect(draft).toHaveValue(/Select mechanisms and add case evidence/);
await expectNoHorizontalOverflow(page);
- // WebKit can serve a stale :disabled computed style (opacity .4) for the step-nav
- // Previous button after the select->draft transition re-enables it; axe folds that
- // opacity into the glyph color and reports a phantom color-contrast violation for a
- // state WCAG 1.4.3 exempts anyway (matrix run 4012). Poll the computed style so axe
- // measures the live enabled state; a failure HERE is direct proof of the stale style.
- const previousStep = page.getByRole("button", { name: "Previous", exact: true });
+ // Regression guard for matrix run 4012: the old disabled opacity utility could
+ // remain stale in WebKit after this button re-enabled, which made axe measure a
+ // phantom low-contrast enabled state. Keep the live state explicit before scanning.
await expect(previousStep).toBeEnabled();
await expect(previousStep).toHaveCSS("opacity", "1");
await expectNoBlockingAxeViolations(page, testInfo);