Skip to content

web: the app's own dialogs and selects, never the browser's - #155

Merged
futurebuildai merged 32 commits into
refactor/v1from
refactor/g1-app-dialogs
Oct 15, 2026
Merged

futurebuildai merged 32 commits into
refactor/v1from
refactor/g1-app-dialogs

Conversation

@futurebuildai

Copy link
Copy Markdown
Contributor

What

The desk no longer uses the browser's own chrome. Every confirm(/prompt( call and every native select element in web/apps is replaced by the app's own components, and a guard keeps them from coming back.

New components (web/apps/desk/src/components/ui/)

  • app-dialog.ts: confirmDialog({ title, body, confirmLabel, cancelLabel, tone }) resolves true or false; promptDialog({ title, label, required, confirmLabel, tone, initialValue }) resolves the typed text or null. The dialog renders one at a time over the page as role="alertdialog" (or dialog for a prompt) with aria-modal="true", labelled by its title and described by its body. Focus lands on the safe choice (Cancel for a danger tone, the field for a prompt, Confirm otherwise), Tab stays inside, Escape cancels, Enter confirms when the focus is not on Cancel, and focus returns to the control that opened it. A required prompt keeps Confirm off until text is typed. tone: 'danger' draws the confirm button in safety red for removals and voids.
  • gable-select.ts: <gable-select> is a button showing the chosen option's label (or a placeholder in words) with the app's own popover listbox: aria-haspopup="listbox", aria-expanded, role="listbox"/role="option" with aria-selected and aria-activedescendant. Up/Down/Home/End move the active option, typeahead matches the first letters, Enter or Space chooses, Escape closes without choosing, Tab closes and moves on. Disabled options are listed but cannot be chosen, options may carry a group heading, and the list opens upward when there is more room above. A choice fires a bubbling change event with the value on the element, so each page's handler keeps its meaning; pages read the value from their own state, as they already did (no page read a form's data for these selects).

The sweep

  • 17 browser dialog calls in 7 desk pages (order confirm and cancel reason; the counter's leave-the-open-sale question on yard change, register change and till close; clear sale; void reason; Tech Admin's four removals; contact delete; vehicle and driver removals; market index value; saved report and schedule deletes) now ask through the app's dialog with the same words; removals and voids say what goes, that it cannot be undone, and use the danger tone.
  • 64 native selects in 35 files (the modals, the quote builder, accounting, accounts, admin, credit memos, the driver's route list, the product tabs, invoices, fleet, the portal's catalogue and team, the counter, purchasing, exposure, reports) are <gable-select> with the same options, values, defaults, disabled states, test ids and change handling. The receive bin picker among them.

Tests

  • src/test/browser-chrome.guard.test.ts (committed alone first, red on the old tree): reads every non-test .ts under web/apps and fails on any browser dialog call or native select, naming each file and line.
  • Component legs for the dialog (each resolve path, Escape, veil click, focus rules, Tab trap, required prompt, one-at-a-time) and the select (semantics, keyboard, typeahead, change, disabled options, groups, outside click, state reading): 27 legs.
  • The existing legs that stubbed window.confirm or set a native select's value now drive the new components through helpers in src/test/dom.ts (chooseOption, openAppDialog, answerAppDialog, typeIntoAppDialog) and keep their meaning.

Gates (from web/)

  • npm ci: ok
  • npm run typecheck (every workspace): 0 error TS
  • npx eslint .: clean
  • npm test: 95 desk files (1051 legs) plus the three other workspaces, all green
  • npm run build: exit 0, 0 error TS
  • bash scripts/check-shape.sh: OK

What I could not see: no server was started and no screens were taken; the visual walk (the dialog over the order page, the bin picker in the dark app) is for the lead's own stack.

Written by Kimi K3 for the Gable lead.

Add a Vitest guard that reads every .ts source under web/apps (tests
excluded) and fails on any browser dialog call (confirm, alert, prompt,
with or without window., a file's own method excepted) or any native
select element, naming each file and line. Fails on today's code: 17
browser dialog calls in 8 desk pages and 64 native selects in 35 files.

Signed-off-by: colton <colton@futurebuild.ai>
Add gable-app-dialog and the confirmDialog/promptDialog helpers under
components/ui, so pages ask with an awaited promise instead of the
browser's blocking dialogs. The dialog is an alertdialog (or dialog for
a prompt) with aria-modal, labelled by its title and described by its
body; focus lands on the safe choice, Tab stays inside, Escape cancels,
Enter confirms away from Cancel, focus returns to the opener, and a
required prompt keeps Confirm off until text is typed. A danger tone
draws the confirm button in safety red. One dialog is open at a time.

Signed-off-by: colton <colton@futurebuild.ai>
Add gable-select under components/ui: a button showing the chosen
option's label (or a placeholder in words) and the app's own popover
listbox in place of the browser's native list. aria-haspopup listbox
with aria-expanded, role listbox and option with aria-selected and
aria-activedescendant; Up, Down, Home and End move the active option,
typeahead matches on the first letters, Enter or Space chooses, Escape
closes without choosing, Tab closes and moves on. Disabled options are
listed but cannot be chosen, and an option may carry a group heading.
The list opens upward when there is more room above. A choice fires a
bubbling change event with the value on the element, so a handler
written for a native select keeps its meaning.

Signed-off-by: colton <colton@futurebuild.ai>
Every confirm( and prompt( call in the desk now asks through
confirmDialog/promptDialog: the order confirm and cancel reason, the
counter's leave-the-open-sale question (yard change, register change,
till close), clearing every line of a sale, the void reason, Tech
Admin's key and partner removals, the contact delete, the vehicle and
driver removals, the market index value, and the saved report and
schedule deletes. Removals and voids ask with the danger tone; the
cancel and void reasons are required prompts. The register picker's
decline path is now async and puts the picker's value back. The guard's
root is corrected to web/apps so the generated api-client schema is out
of its scope.

Signed-off-by: colton <colton@futurebuild.ai>
The activity, product, transfer, adjustment, payment and route modals
and the escalator toggle offer their choices through the app's own
select: same options, values, defaults and change handling, the empty
first option kept where it was a real choice. The required markers go:
the submit handlers already guard on the chosen values.

Signed-off-by: colton <colton@futurebuild.ai>
…lect

The quote builder's unit and truck pickers, accounts payable, the bank
reconciliation, the chart of accounts, journal entries, the account
pages and forms, the pricing matrix, Tech Admin's AI provider and
model, and the credit memo pages offer their choices through the app's
own select. Aria labels carry over as the component's label; width
constraints move to a wrapper div; every handler keeps its meaning.

Signed-off-by: colton <colton@futurebuild.ai>
…elects become gable-select

The driver route list's profile picker, the product tabs, the invoice
list filters, the fleet vehicle and driver forms, the portal catalogue
filters and team roles, the counter's job picker, refund method and
register picker, the purchase order line products, the receive bin
picker, the exposure filter, the report builder and the schedule form
offer their choices through the app's own select. The receive bin
picker keeps its disabled state when a line is fully received; the
report builder's add-column and add-grouping pickers keep their
disabled lead option and reset after a choice.

Signed-off-by: colton <colton@futurebuild.ai>
The legs that stubbed window.confirm or set a native select's value now
go through the app's components with three small helpers in the desk's
test utilities: chooseOption (a pick from a gable-select's list),
openAppDialog and answerAppDialog (the question over the page and its
answer), plus typeIntoAppDialog for prompts. Every leg keeps its
meaning: the counter still asks before leaving an open sale and a
declined answer still keeps the till, the register picker still
remembers per yard, the filters still send their contract names.

Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
The hosted e2e specs drove the browser's own chrome: selectOption on
native selects and page.on('dialog') auto-accepts for the confirms and
the void prompt. A small helper (e2e/app-controls.ts) picks a
gable-select option by label or value and answers the app's dialog; the
specs use it for the admin revokes and removals, the contact delete,
the order confirms, the counter's void reason, the customer form's tier
and terms, the product UOM, the driver's profile, the invoice and
credit memo filters and the quote line's unit. The quote spec now
asserts the unit control is the app's select of the 16 codes plus the
prompt. The customer form's tier and terms selects carry their labels
as aria-labels so the specs (and screen readers) find them.

Signed-off-by: colton <colton@futurebuild.ai>
Chromium answers a click on a non-interactive option inside a label
element with a synthetic click at the label's first labelable control,
which is the component's own button: every choice reopened the list.
Clicks inside the list now cancel their default action, and a leg pins
it. Found by the hosted e2e (the invoice and credit memo filters wrap
their selects in labels); jsdom does not emulate label activation.

Signed-off-by: colton <colton@futurebuild.ai>
…p-dialogs

Signed-off-by: colton <colton@futurebuild.ai>
The tender round's two new Clear sale legs stubbed window.confirm,
which the app's dialog replaced; they now answer the open dialog with
answerAppDialog(true), keeping their meaning: tenders reset once the
clear is confirmed.

Signed-off-by: colton <colton@futurebuild.ai>
A dialog asked from inside a modal (Fleet's Delete vehicle and Remove
driver sit inside z-[60] panels) renders under it today: the wrapper is
z-50 in the root stacking context, so the question cannot be answered
by mouse. These legs want a native dialog element, open while asked and
gone after the answer; they fail on today's div-based render.

Signed-off-by: colton <colton@futurebuild.ai>
The panel is a native dialog opened with showModal(): it sits above
every page layer whatever the z-index, so a question asked from inside
a modal (Fleet's Delete vehicle and Remove driver, whose panels are
z-[60]) can be answered, and the page behind is inert while it is up.
Escape's native cancel event routes through the same settle; a click on
the dialog's own backdrop cancels; the dim veil stays a plain div below
it. The fleet handlers take the vehicle or driver before the await, so
a modal closed mid-question cannot turn the answer into an error.

Signed-off-by: colton <colton@futurebuild.ai>
The counter's register picker asks the leave-the-open-sale question on
change; the select's deferred focus restore runs after the dialog takes
focus and steals it back, so the dialog's Tab trap, Escape and Enter
never fire. These legs pick by click and by Enter and want the focus
inside the dialog; they fail on today's code.

Signed-off-by: colton <colton@futurebuild.ai>
The button is in the DOM while the list is open, so the focus restore
on close no longer waits for a render: it happens synchronously in
_close, before the change event dispatches. A handler that opens the
app's dialog (the register picker's leave-the-open-sale question) finds
the focus where the dialog expects it, and the dialog's Tab, Escape and
Enter work.

Signed-off-by: colton <colton@futurebuild.ai>
aria-activedescendant sits on the listbox, but the focus stays on the
button, so assistive technology announces nothing as the arrows move.
The leg wants the select-only combobox pattern: role combobox and
aria-controls and aria-activedescendant on the focused button, with the
list carrying the referenced id. Fails on today's code.

Signed-off-by: colton <colton@futurebuild.ai>
The button carries role combobox, aria-controls naming the list's id,
aria-expanded and, while the list is open, aria-activedescendant
pointing at the active option, so a screen reader reads the option as
the arrows move; the listbox holds the id. The e2e helper finds the
control by tag now that its role is combobox.

Signed-off-by: colton <colton@futurebuild.ai>
Seven selects carry an id a label[for] points at (the counter's job
picker, the credit memo customer and reason, the schedule's format and
frequency, the activity contact, the stackable tri-state), and a custom
element is not labelable, so the combobox lost its field name and the
label click. The legs want the name from the label property as
aria-label, or from the label element with the chosen value, and the
label click focusing the control. The label[for] leg fails today.

Signed-off-by: colton <colton@futurebuild.ai>
When the host carries an id a label element points at and no label
property is set, the combobox is named by that label together with the
chosen value (aria-labelledby on the label and the value span), and the
label's click focuses the control, as a native select's label does. The
seven sites that lost their names get them back with no page changes;
the register picker's screen-reader label, dropped in the sweep, is
restored and it is named with the register it shows; the stackable
tri-state drops its label property so its visible label names it.

Signed-off-by: colton <colton@futurebuild.ai>
The list is absolute inside the select, so a modal with hidden or
scrolling overflow (Create Route, Log Activity, the Fleet modals) cuts
it off, and a transformed panel makes even fixed positioning moot. The
legs want the popover attribute (the browser's top layer) and a fixed
position read from the button's box, flipping up when the room is
above. They fail on today's in-flow list.

Signed-off-by: colton <colton@futurebuild.ai>
The list is a manual popover positioned fixed from the button's box, so
a modal with hidden or scrolling overflow (Create Route, Log Activity,
the Fleet modals) or a transformed panel no longer clips it; it flips
up when the room is above the button. jsdom has no popover, so the
calls are guarded and the fixed position carries the tests. K2's date
field (PR 156) can take the same handful of lines; nothing shared was
worth extracting yet.

Signed-off-by: colton <colton@futurebuild.ai>
The native selects on Create Route (vehicle, driver) and Transfer Stock
(from, to) carried required, so the browser refused an empty submit in
its own words; with the app's selects the submit returns silently. The
legs want aria-required on the combobox, an invalid read the page can
turn on, and a message in words when the form is sent with nothing
chosen, with no request sent. They fail on today's code.

Signed-off-by: colton <colton@futurebuild.ai>
gable-select takes required (aria-required on the combobox) and invalid
(aria-invalid with a safety red border). Create Route's vehicle and
driver and Transfer Stock's source and destination carry them again;
sending either form short a pick shows what is missing in words beside
the buttons (role alert) and marks the empty picks, and sends nothing.
The transfer's handler no longer returns silently, and both forms clear
the message on the next choice.

Signed-off-by: colton <colton@futurebuild.ai>
…cker

The old select needed a drawn arrow; gable-select draws its own, so the
overlay doubled it. (Review P3, small enough to take with this round.)

Signed-off-by: colton <colton@futurebuild.ai>
A list that flips up sets only bottom today; the popover's own sheet
holds top at 0, so the list lands at the top of the screen, detached
from its control (the receive bin picker low on a long purchase order,
the counter's lower pickers). The legs pin the right state: inset
cleared, the bottom edge at the button's top when flipped, and the
bottom edge cleared when opening downward. They fail on today's code.

Signed-off-by: colton <colton@futurebuild.ai>
The popover's own sheet holds top at 0; setting only bottom left top in
force and the flipped list landed at the top of the screen. The unused
edge is now set to auto explicitly (the inset shorthand would do, but
jsdom does not expand it, and the legs run there).

Signed-off-by: colton <colton@futurebuild.ai>
The dialog element carries the panel's padding, so a click in that
padding has the dialog itself as its target, the same target a backdrop
click has: a slightly wide click near the edge of the void or cancel
reason prompt throws away what was typed. The leg wants the padding on
the panel and the dialog unpadded, and pins that a true backdrop click
still cancels. It fails on today's code.

Signed-off-by: colton <colton@futurebuild.ai>
…cancels

A click whose target is the dialog element itself is a backdrop click,
and the padding made the panel's edge part of that box. The padding
moves to the inner panel and the dialog carries none: a click at the
edge of a typed prompt keeps it open, and a click truly outside still
cancels.

Signed-off-by: colton <colton@futurebuild.ai>
…p-dialogs

Gable light (PR 147) re-styled the pages this branch swept; the
conflicts were its re-classed native selects against the app's own
components. Kept both sides: light's token classes on the shared lines
(labels, cards, hover shades, the till's refund label) and the app's
dialog and selects on top. The dialog's veil moves to bg-backdrop/80
with the other modals.

Signed-off-by: colton <colton@futurebuild.ai>
@futurebuildai
futurebuildai merged commit 36e4b68 into refactor/v1 Oct 15, 2026
9 checks passed
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.

1 participant