Repository navigation
fix(app-shell): toasts sit top-centre, clear of the page header's actions (objectui#13340) - #13363
Conversation
…ions (objectui#13340) With no drawer open the console toaster now anchors top-centre, 8px below the 48px top bar. The top-right corner holds every page header's actions: at 1440x900 a toast covered a list's New and Import buttons for its 4s life. A right-side drawer still moves the toaster to top-right and offsets it clear of the drawer (objectui#11685); bottom-right stays the assistant's (objectui#7482). Pins one error toast per server refusal on the two Setup form paths: the form renderer's submit catch (record forms) and the runner's post-execution through the console toastHandler (action param dialogs such as Create User and Add Member). Measured on origin/main against a live 18.0.0 backend: both paths raise one toast per refusal; no second emitter reproduced. Refs objectui#13340 Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
席位核对ACCEPT — objectui#13340 (p3, S), head Seat: domain:ui#1 · Clause-②: no
|
|
ACCEPT 6365140544 is suspended · Seat: domain:ui#1 The seat re-reviews on the new head and writes a fresh ACCEPT there; nothing is queued on this head. |
…bjectui#13340)
`pathOf` classified every `--offset-top` other than 24px as "below header",
so the centred-dialog configuration read the new no-drawer anchor
(top-centre, 56px below the top bar) as the drawer path and failed before
its hit tests ran. It now reads the toaster's anchor (`data-y-position` /
`data-x-position`) first: top-centre at 56px is the no-drawer path
("top centre", formerly "corner"); top-right is the objectui#11685 drawer
path, beside the drawer or below its header. Any other anchor or offset is
named rather than folded into a known path.
Every hit-test assertion is unchanged. In the centred-dialog configuration
the toast now sits over the dialog's title row, and the spec still shows it
is the click target there and that a click on it reaches nothing beneath.
Refs objectui#13340
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj
席位核对ACCEPT(取代挂起的 6365140544)— objectui#13340 (p3, S), head Seat: domain:ui#1 · Clause-②: no
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Closes #13340
Clause-②: no
The two emitters: measured, and the second one does not reproduce on
origin/mainThe card asks for the second emitter behind each refusal on the Setup forms. I traced both Setup form paths on
origin/main(2c4e5f5) and counted toasts in a real browser, at 1440×900, against a live@objectstack18.0.0 backend (objectstack dev --fresh, Setup app). I ran the same check on the console that 18.0.0 ships at/_console.A record with this value already exists)ModalForm→handleSubmit→dataSource.createrejects → rethrownform.tsx, the submit catch:toast.error(errorMessage, { id: outcomeToastId })Either email or phoneNumber is required)ActionParamDialog→ runnerrun→ consoleapiHandlerreturns the refusalActionRunner.handlePostExecution→ consoletoastHandler→toast.error(message)add_team_member,type: 'api')Each refusal is also written inside the form: the form's banner above the fields (
setSubmitError), or the parameter dialog's refusal line (action-param-refusal, objectui#13161). It is the same sentence as the toast, so the screen shows it twice: once in the form and once as a toast. That matches the report's "×2". It is not a second toast. Under the card's own rule (find the second emitter, never de-duplicate by text) there is no second toast emitter to remove. Dropping the toast while the banner shows would be a design change: the toast exists because the banner can be scrolled out of view in long forms. That decision belongs to the maintainer and is not made here.So this PR pins the measured behaviour: one error toast per refusal on both paths. It also fixes the placement, which did reproduce.
Placement: did reproduce, fixed
At 1440×900 the toaster sat top-right at sonner's 24px offset. On Setup → Business Units the toast box measured
[1060,24,1416,78]. The header buttons measured New[1198,63,1275,99]and Import[1283,63,1376,99], so the toast covered both for its 4s life. After the change the toast measures[542,56,898,110], clear of both buttons. With the record dialog open it sits in the empty middle of the dialog's title row, clear of the title, the close button and the error banner.ConsoleToasterchange, through the one existing Toaster config, so it applies to every page:position="top-center", withoffset={{ top: 56 }}(theAppShelltop bar ish-12, 48px, plus an 8px gap). The top-right corner holds every page header's actions (a list's New and Import, a record's Edit and header actions, dashboard controls). Top-centre is the strip a page header leaves free: title on the left, actions on the right. The offset keeps the toast off the top bar's centred global search.top-rightand steps into the strip beside the drawer, or below the drawer's header.What changed
packages/app-shell/src/chrome/ConsoleToaster.tsx: the default anchor and offset (DEFAULT_OFFSET), and its comments.packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx: the default anchor is now['top', 'center'].packages/app-shell/src/chrome/ConsoleToaster.drawerClearance-11685.test.tsx: with no drawer, top-centre at--offset-top: 56px. With a drawer, top-right as before. The test now re-reads the toaster region, because sonner mounts a new element when the position changes.packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx: the console's owntoastHandler,apiHandlerandparamCollectionHandlerare wired into a realActionRunner. Only the fetch is faked, answering the create-user door's 400. Checks: one refused Confirm gives exactly onetoast.error, each further refusal adds one, and Cancel adds none. Reverse-checked: adding atoast.errorinapiHandler's refusal branch turns both tests red.packages/components/src/renderers/form/__tests__/form-one-toast-per-refusal-13340.test.tsx: one refused submit gives exactly onetoast.error, carrying the server's sentence. A refused retry gives one more under the same outcome id, so it replaces the first instead of stacking..changeset/13340-toaster-top-center.md(@object-ui/app-shellpatch).Tests run
pnpm exec vitest run packages/app-shell/src/chrome/ packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramRefusal-13161.test.tsx packages/app-shell/src/views/ActionParamDialog.refusalKeepsInput-13161.test.tsx packages/components/src/renderers/form/__tests__/: 99 files passed, 680 tests passed, 17 skipped.ConsoleToasterconsumers' tests (ObjectView.inviteFeedback-11817,ObjectView.toolbarScriptSelection-12331, plugin-formWizardForm.outcomeToastSupersede): 3 files, 18 tests passed.turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/components: 31 of 31 tasks successful.node scripts/check-changeset-presence.mjs: pass.pnpm check:new-line-citations: 0 new citations.Acceptance notes
The double-toast half of the card did not reproduce, on
origin/mainor on the 18.0.0 console. What reads as "×2" is the toast plus the in-form banner or refusal line. Whether a form whose own banner is visible should also toast is a maintainer decision, not made here.I could not read the hotcrm QA report (no access from this session). The forms above were measured on the platform's own Setup objects, not on a HotCRM build.
Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer)
🤖 Generated with Claude Code
https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj