Skip to content

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

Description

@mforce

The last surfaces still using a browser popup instead of the app's own. #131 (PR #132) gave every add/edit form an accessible <Dialog>; these eight kept window.confirm / window.prompt.

The inconsistency runs the wrong way: "add a grade" gets a designed sheet, "void a confirmed order" gets an OS text box.

The call sites

All eight are the one-way and corrective actions from #59 / #60 / #69 — the ones that most deserve a considered UI.

Yes/no — window.confirm (5)

Where Action Consequence
DailyEntryPage.tsx:218 Submit day Freezes the entry, creates egg lots; corrections need a manager adjustment
SalesPage.tsx:242 Confirm order Allocates stock from inventory (FIFO)
SalesPage.tsx:260 Cancel draft Order becomes read-only, can't be confirmed
FlocksPage.tsx:298 Deplete flock Stops accepting new entries
FlocksPage.tsx:307 Archive flock Disappears from pickers and the dashboard

Reason required — window.prompt (3)

These write an audit reason, so the text itself is the point.

Where Action Consequence
HistoryPage.tsx:201 Void daily entry Frees the day for re-entry (#82)
SalesPage.tsx:305 Void payment The order's outstanding amount grows back
SalesPage.tsx:330 Void confirmed order Allocated stock returns to the exact lots it came from

Why replace them

  • Unstyleable — no aubergine palette, no dark mode, no destructive treatment. The night theme (UI design revamp — distinctive visual identity #52) stops at the popup edge.
  • window.prompt can be blocked outright — browsers let users suppress it, and it is unavailable in some contexts. An action that silently no-ops is worse than an ugly one.
  • Validation lands too late — the three prompts check "reason is required" after the popup closes, so a blank reason means retyping from scratch. In a dialog the check is inline and the text survives.
  • Blocks the JS thread — nothing can render or animate while a native popup is up.
  • Mobile — system chrome with the origin in the title, jarring next to the bottom-sheet dialogs (Inline add/edit forms → modal dialogs (less-intrusive capture) #131).
  • Untestable in the good way — each site currently needs a window.confirm / window.prompt stub in its Vitest suite. A React dialog is asserted like any other UI.
  • No room to explain — the copy is already doing real work (FIFO allocation, what "backfill still works" means). A dialog body can hold it; a one-line popup string can't.

Design

One hook over the existing Dialog — no new primitive, no new a11y work.

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

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

const reason = await askReason({ title, body, confirmLabel, destructive });
if (reason === null) return;   // never resolves to an empty string
  • one element rendered per screen ({confirmDialog}) even where both shapes are used — Sales needs all four
  • destructive styles the action red; it varies genuinely (5 destructive, 3 one-way-but-constructive), so it is not dead config
  • initial focus follows DOM order and lands where it should: Cancel for a yes/no (the safe default — a stray Enter must not deplete a flock), the textarea for a reason (the user has to type). No focus special-casing needed.
  • Escape / backdrop / Cancel all resolve as "no", inherited from Dialog

Danger button tokens

A single shared token cannot clear both AA on white text and 3:1 against the surface in both themes, so --danger / --danger-press are themed, matching the lighter-on-press convention --aubergine already sets.

The real cost

window.confirm and window.prompt are synchronous, so today the call sites read:

if (!window.confirm("...")) return;
void run(async () => { ... });

A React dialog can't return inline, so each handler becomes async and awaits. Mechanical, but it touches control flow — worth doing one screen at a time and keeping each screen's existing guards (inFlight, busy, idempotency-key scoping) intact rather than restructuring them.

Two specifics:

  • DailyEntryPage checks a synchronous re-entry guard (inFlight.current) before the confirm. Awaiting opens a window between the check and the set, so the guard must be re-checked after the await.
  • Sales already opens two dialogs from the same panel; a confirm must not stack on top of the payment dialog.

Checklist

  • useConfirm hook + Dialog-based confirm/reason UI
  • Vitest: confirm resolves true/false, Cancel/Escape/backdrop reject, reason trims and rejects blank without closing, focus lands on Cancel (yes/no) and the textarea (reason), a second ask settles the first
  • --danger / --danger-press tokens, both themes, contrast-checked
  • Migrate DailyEntryPage submit (plus the re-entry guard re-check)
  • Migrate SalesPage confirm + cancel + void order + void payment
  • Migrate FlocksPage deplete + archive
  • Migrate HistoryPage void entry
  • Drop the window.confirm / window.prompt stubs from the affected suites; assert the dialog instead
  • Verify the one-way guards still hold (no double-submit, idempotency keys unchanged)
  • Light + dark screenshots, desktop + mobile
  • specs/product/GLOSSARY.md — extend the Dialog entry to cover confirmations and void reasons
  • Help page "Adding & correcting" — say that one-way actions ask first and voids need a reason

Related

Part of epic #14.

Activity

  1. changed the title [-]One-way actions: replace window.confirm with a styled confirmation dialog[/-] [+]One-way actions: replace window.confirm / window.prompt with styled dialogs[/+] on Jul 22, 2026
  2. mforce commented on Jul 22, 2026

    @mforce
    OwnerAuthor

    Scope corrected before starting: the original body listed 5 window.confirm sites. A census for window.prompt as well turns up 3 more, and they are the worse offenders — HistoryPage.tsx:201 (void entry), SalesPage.tsx:305 (void payment), SalesPage.tsx:330 (void order). All three collect a required audit reason, validate it only after the popup has closed, and can be suppressed outright by the browser.

    Folding them in rather than filing a sibling: removing window.confirm while leaving window.prompt behind would leave main with a mixed idiom, and the reason dialogs reuse the same hook. Body and title updated; 8 sites, one hook with two shapes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions