Skip to content

F135: replace window.confirm/prompt with the app's own dialogs - #136

Merged
mforce merged 2 commits into
mainfrom
feat/f135-confirm-dialog
Jul 22, 2026
Merged

mforce merged 2 commits into
mainfrom
feat/f135-confirm-dialog

Conversation

@mforce

@mforce mforce commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #135.

The last surfaces still using a browser popup. #131 (PR #132) gave every add/edit form an accessible Dialog; eight one-way and corrective actions kept window.confirm / window.prompt. The inconsistency ran the wrong way — "add a grade" got a designed sheet, "void a confirmed order" got an OS text box.

What moved

Yes/no (5) — submit day, confirm order, cancel draft, deplete flock, archive flock.

Reason required (3) — void daily entry, void payment, void confirmed order. These write an audit reason, so the text itself is the point.

Why the prompts mattered most

The issue was filed for the five confirms. A census for window.prompt turned up three more, and they were the worse offenders:

  • Browsers can suppress window.prompt outright. An action that silently no-ops is worse than an ugly one.
  • Validation landed too late. All three checked "a reason is required" after the popup had closed, so a blank answer threw away everything the user typed. Now the check is inline and the text stays put.
  • Neither native can be styled — the night theme (UI design revamp — distinctive visual identity #52) stopped at the popup edge — and both block the JS thread.

The hook

One hook over the existing Dialog, two shapes, so a screen needing both (Sales needs all four) still renders one element:

const { confirm, askReason, confirmDialog } = useConfirm();

if (!(await confirm({ title, body, confirmLabel, destructive }))) return;

const reason = await askReason({ ... });   // null, or a trimmed non-empty string
if (reason === null) return;

Focus falls out of DOM order and lands where it should with no special-casing: Cancel for a yes/no — a stray Enter must not deplete a flock — and the textarea for a reason, where there is nothing to decide until they have typed.

Two things worth a look in review

The danger tokens are themed, unlike --aubergine. No single red clears both 4.5:1 for its white label and 3:1 against the surface behind it in light and dark at once — the band that satisfies both in dark is narrow. Light 6.71 rest / 5.56 press, dark 5.53 / 4.60; press is the lighter step either way, matching --aubergine-press.

DailyEntryPage's re-entry guard needed re-checking. inFlight.current is tested before the confirm. window.confirm blocked the thread so nothing could slip through the gap; a dialog does not, so the guard is re-checked after the await.

Tests

296 → 319. Worth stating plainly: every one of these actions had zero coverage while it lived behind a native popup — a stubbed window.confirm tests the stub, not the screen. The three FlocksPage tests that asserted expect(window.confirm).toHaveBeenCalled() were the whole of it.

useConfirm is at 100% lines/branches/functions, including two paths that would otherwise strand a caller's promise for ever:

  • asking a second question over a pending one
  • unmounting with a question on screen

The first of those caught a real bug in my own code during the build: confirm/askReason installed the new resolver before open() settled the previous one, so open() answered the brand-new promise and left the outgoing one hanging.

Coverage 86.9 → 89.9 lines, 67.3 → 70.1 functions; gate ratcheted with headroom (branches left at 82 — they moved 83.14 → 83.35, flat).

Verified in a browser

1280×900 and 390×844, light and dark, across all three dialog shapes: no horizontal overflow, no internal scrolling, footer in thumb reach, and the destructive fill resolving to #a8320f in light / #bc3c14 in dark.

Also constrained .dialog textarea to resize: vertical — the default both-axis handle could be dragged wider than the panel.

Docs

specs/product/GLOSSARY.md gains Confirmation and Void reason entries; the Help page's "Adding & correcting" section gains two bullets covering the ask-first behaviour and the reason requirement.

Not in scope

#133 (capture screens: Water / expense-add / Sales add-line, plus the Users flock-assign control) stays an independent decision.

The last surfaces still using a browser popup. #131 gave every add/edit form
an accessible Dialog; eight one-way and corrective actions kept the natives.

Five yes/no confirms (submit day, confirm order, cancel draft, deplete flock,
archive flock) and three that collect a required audit reason (void entry,
void payment, void order).

Why they had to go:

- window.prompt can be suppressed by the browser outright — an action that
  silently no-ops is worse than an ugly one.
- Its "reason is required" check ran AFTER the popup closed, so a blank answer
  threw away everything the user had typed. The dialog validates in place.
- Neither can be styled: the night theme stopped at the popup edge.
- Both block the JS thread, and on mobile render as system chrome with the
  origin in the title, next to #131's bottom sheets.

useConfirm(): one hook over the existing Dialog, two shapes, so a screen that
needs both (Sales needs all four) still renders one element.

  if (!(await confirm({ ... }))) return;
  const reason = await askReason({ ... });   // null, or a trimmed non-empty
  if (reason === null) return;

Initial focus falls out of DOM order and lands where it should without any
special-casing: Cancel for a yes/no, so a stray Enter cannot deplete a flock;
the textarea for a reason, where there is nothing to decide until they type.

--danger/--danger-press are themed, unlike --aubergine: no single red clears
both 4.5:1 for its white label and 3:1 against the surface behind it in light
and dark at once. Light 6.71/5.56, dark 5.53/4.60; press is the lighter step
either way, matching --aubergine-press.

DailyEntryPage's synchronous re-entry guard ran before the confirm.
window.confirm blocked the thread so nothing could slip through; the dialog
does not, so the guard is re-checked after the await.

Tests: 296 -> 319. Every one of these actions had ZERO coverage while it lived
behind a native popup — a stubbed window.confirm tests the stub, not the
screen. Coverage 86.9 -> 89.9 lines, 67.3 -> 70.1 functions; gate ratcheted.
useConfirm itself is at 100% across the board, including the unmount and
ask-over-ask paths that would otherwise strand a caller's promise for ever.

Verified at 1280x900 and 390x844, light and dark: no horizontal overflow, no
internal scrolling, footer in reach, and the destructive fill resolving to the
right token in each theme.

GLOSSARY and the Help page carry the new behaviour.

Closes #135.
@mforce mforce mentioned this pull request Jul 22, 2026
90 tasks done
DailyEntryPage: the post-await guard was 3/4 ineffective (codex, Medium).
selectedFlock, prefillFailed and prefillPending are React state, so re-reading
them after the confirm returns the render-time closure, not what is true now —
the check looked like defence it could not provide. Narrowed to inFlight.current
(a ref, genuinely fresh) and documented why the other three are left out rather
than mirrored: none of them can change while the dialog is up. The flock and
date controls sit behind the backdrop, and a prefill cannot start meanwhile
because both save buttons are disabled while one is pending.

Accessibility, both reviewers:

- The consequence text was not the dialog's accessible description. Focus lands
  on a button, so a screen reader announced the title and the control and never
  what the action does — the only thing a confirmation is for. Dialog takes an
  optional describedBy; useConfirm points it at the body.
- The reason field carried no validation semantics. Added required,
  aria-invalid, and aria-describedby to the error, and focus now returns to the
  field instead of staying on the button that just refused it. That also makes
  the error announced rather than merely displayed.

Corrected a rule this branch contradicted (both reviewers). GLOSSARY and Help
said red meant "destroys or freezes", but submit-day freezes the entry and is
deliberately not red. The rule is UNDO or RETIRE — void, cancel, deplete,
archive. Submitting a day and confirming an order are equally irreversible but
are the ordinary path through the week; a red button on the most routine action
of all would spend the colour where it says nothing. Reworded in three places.

Merged the duplicate `.dialog textarea` rule by taking textarea out of the
width group instead of stacking a second block after it — resize is meaningless
on an input or a select.

Rejected: --on-danger not being re-declared in the dark blocks. It is
deliberately theme-invariant. White is the only label that clears AA on all
four fills (4.60-6.71); black would fail the dark rest fill at 3.79. Documented
so it is not flagged again.

Tests 320 -> 321; useConfirm stays at 100% lines/branches/functions.
@mforce

mforce commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Review complete: codex + two independent agents, per the standing policy.

Applied (4)

Source Severity Finding
codex Medium The post-await guard in DailyEntryPage.onSave was 3/4 ineffective
codex Medium The consequence text was not the dialog's accessible description
codex + agent Medium The reason field carried no validation semantics
codex + agent Low submit-day / confirm-order contradicted this branch's own red-button rule

The guard one is worth calling out, because the PR body claimed it was handled and it was not. selectedFlock, prefillFailed and prefillPending are React state, so re-reading them after the confirm returns the render-time closure, not current values — only inFlight.current was genuinely fresh. Narrowed to the ref, with a comment explaining why the other three are left out rather than mirrored: nothing can change them while the dialog is up.

The accessible-description one is the most consequential for users. Focus lands on a button, so a screen reader announced the title and the control and never what the action does — the only thing a confirmation exists to say. Dialog now takes an optional describedBy; useConfirm points it at the body. Pinned by a test.

The reason field gained required, aria-invalid and aria-describedby, and focus now returns to it instead of staying on the button that just refused it — which is also what makes the error announced rather than merely displayed.

Both reviewers independently caught the red-button contradiction: GLOSSARY and Help said red meant "destroys or freezes", but submitting a day freezes the entry and is deliberately aubergine. The docs were wrong, not the flag — the rule is undo or retire. A red button on the most routine action in the app would spend the colour where it says nothing. Reworded in three places.

Rejected (1)

--on-danger is not re-declared in the two dark blocks, unlike its siblings. That is deliberate, not an oversight: white is the only label clearing AA on all four fills (4.60–6.71), and black would fail the dark rest fill at 3.79. Verified before rejecting and documented in place so it is not flagged again.

Confirmed clean

The third reviewer found no defects in promise lifecycle, races, idempotency-key rotation, or silent behaviour changes, and proved two things the PR body asserted without evidence:

  • The modal genuinely blocks. z-index: 50 is the only one in the stylesheet, there is no pointer-events: none anywhere, focus is trapped and body scroll locked — and there are no setInterval/setTimeout pollers anywhere in web/src/routes, so no timer-driven fetch can mutate state behind an open dialog either. That is what closes the race class a naive async swap of window.confirm would open, not care at the call sites.
  • Idempotency keys are minted only inside run(), never while a question is pending — so a dismissed ask wastes no key, at all 8 sites.

State

321 tests. useConfirm at 100% lines/branches/functions. CI green on aabf14b — backend 1m44s, web 52s, GitGuardian pass.

@mforce
mforce merged commit f0e93f2 into main Jul 22, 2026
3 checks passed
@mforce
mforce deleted the feat/f135-confirm-dialog branch July 22, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-way actions: replace window.confirm / window.prompt with styled dialogs

1 participant