Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 4603b8c

Browse files
fix(app-shell): toasts sit top-centre, clear of the page header's actions (objectui#13340) (#13363)
Closes #13340 Clause-②: no ## The two emitters: measured, and the second one does not reproduce on `origin/main` The 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 `@objectstack` 18.0.0 backend (`objectstack dev --fresh`, Setup app). I ran the same check on the console that 18.0.0 ships at `/_console`. | Form (Setup) | Path | Toast emitter | Error toasts per refusal | |---|---|---|---| | Business Units → New (`A record with this value already exists`) | `ModalForm` → `handleSubmit` → `dataSource.create` rejects → rethrown | components `form.tsx`, the submit catch: `toast.error(errorMessage, { id: outcomeToastId })` | 1 (main), 1 (18.0.0) | | Users → Create User (`Either email or phoneNumber is required`) | `ActionParamDialog` → runner `run` → console `apiHandler` returns the refusal | core `ActionRunner.handlePostExecution` → console `toastHandler` → `toast.error(message)` | 1 (main), 1 (18.0.0) | | Teams → Add Member | same as Create User (`add_team_member`, `type: 'api'`) | same | same path (the live run succeeded, so no refusal was counted) | 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. `ConsoleToaster` change, through the one existing Toaster config, so it applies to every page: - No drawer open: `position="top-center"`, with `offset={{ top: 56 }}` (the `AppShell` top bar is `h-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. - A right-side drawer open: unchanged from objectui#11685. The toaster goes back to `top-right` and steps into the strip beside the drawer, or below the drawer's header. - Bottom-right stays unused (objectui#7482: the assistant's FAB and composer). Phone offsets are unchanged. ## 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. - New `packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx`: the console's own `toastHandler`, `apiHandler` and `paramCollectionHandler` are wired into a real `ActionRunner`. Only the fetch is faked, answering the create-user door's 400. Checks: one refused Confirm gives exactly one `toast.error`, each further refusal adds one, and Cancel adds none. Reverse-checked: adding a `toast.error` in `apiHandler`'s refusal branch turns both tests red. - New `packages/components/src/renderers/form/__tests__/form-one-toast-per-refusal-13340.test.tsx`: one refused submit gives exactly one `toast.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-shell` patch). ## 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. - The other `ConsoleToaster` consumers' tests (`ObjectView.inviteFeedback-11817`, `ObjectView.toolbarScriptSelection-12331`, plugin-form `WizardForm.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. - NOT MEASURED live: the drawer-open position switch in a real browser. The unit tests above cover the mechanism. ## Acceptance notes - The double-toast half of the card did not reproduce, on `origin/main` or 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. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2f4b7d commit 4603b8c

7 files changed

Lines changed: 362 additions & 40 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Console toasts no longer cover the page header's actions (objectui#13340). With no drawer open the toaster now sits top-centre, just below the console's top bar, instead of in the top-right corner, which is where every page header puts its actions. At 1440×900 a toast used to cover a list's New and Import buttons for its whole 4 s life. While a right-side drawer is open the toaster still goes to the top-right corner and steps clear of the drawer as before (objectui#11685), and it still never uses the bottom-right corner, which belongs to the assistant (objectui#7482).
6+
7+
New tests check that one server refusal raises one error toast, on the record form renderer and on an action's parameter dialog (Users → Create User, Teams → Add Member).

‎e2e/toast-under-modal.spec.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ import { fileURLToPath } from 'node:url';
4343
* overlay host `NavigationOverlay` with the props `ObjectView` gives it, and
4444
* through it the Shadcn `Sheet` (drawer mode: both objectui#11685 paths, beside
4545
* the drawer and below its header) and `Dialog` (modal mode: the centred
46-
* dialog, toaster in its own corner), both on `@radix-ui/react-dialog`; sonner
46+
* dialog, toaster at its no-drawer anchor, top-centre below the top bar,
47+
* objectui#13340), both on `@radix-ui/react-dialog`; sonner
4748
* itself; React. The stylesheet is real Tailwind, compiled from the installed
4849
* `tailwindcss` for the class strings in those source files.
4950
*
@@ -276,18 +277,21 @@ interface Configuration {
276277
drawerWidth: string;
277278
/**
278279
* Where objectui#11685 puts the toaster for this window and overlay: beside
279-
* the drawer, below its header, or (no right-edge drawer) its own corner.
280+
* the drawer, below its header, or (no right-edge drawer) its no-drawer
281+
* anchor, top-centre below the top bar (objectui#13340). For a centred
282+
* dialog that puts the toast over the dialog's own title row, so the
283+
* hit-test assertions below are what show it is still the click target.
280284
* Read back off the toaster in every test, so a configuration that stopped
281285
* exercising its path fails instead of silently testing another one.
282286
*/
283-
path: 'beside' | 'below header' | 'corner';
287+
path: 'beside' | 'below header' | 'top centre';
284288
}
285289

286290
const CONFIGURATIONS: Configuration[] = [
287291
{ name: '1440 sm drawer', viewport: { width: 1440, height: 900 }, mode: 'drawer', drawerWidth: 'min(92vw, 480px)', path: 'beside' },
288292
{ name: '1024 md drawer', viewport: { width: 1024, height: 768 }, mode: 'drawer', drawerWidth: 'min(92vw, 720px)', path: 'below header' },
289293
{ name: '390 phone drawer', viewport: { width: 390, height: 844 }, mode: 'drawer', drawerWidth: 'min(92vw, 480px)', path: 'below header' },
290-
{ name: '1440 centred dialog', viewport: { width: 1440, height: 900 }, mode: 'modal', drawerWidth: 'min(92vw, 720px)', path: 'corner' },
294+
{ name: '1440 centred dialog', viewport: { width: 1440, height: 900 }, mode: 'modal', drawerWidth: 'min(92vw, 720px)', path: 'top centre' },
291295
];
292296
const BELOW_HEADER = CONFIGURATIONS[1];
293297

@@ -344,14 +348,24 @@ const toastShowing = (page: Page, title: string) =>
344348
title,
345349
);
346350

347-
/** Which objectui#11685 path the toaster took, read off the offsets it was given. */
351+
/**
352+
* Which path the toaster took, read off its anchor and the offsets it was
353+
* given. No drawer to clear: top-centre at 56px, below the 48px top bar
354+
* (objectui#13340). A right-edge drawer: top-right, then beside the drawer or
355+
* below its header (objectui#11685). Anything else is named, not folded in.
356+
*/
348357
const pathOf = (page: Page) =>
349358
page.evaluate(() => {
350359
const toaster = document.querySelector<HTMLElement>('[data-sonner-toaster]');
351360
if (!toaster) return 'no toaster';
352-
if (toaster.style.getPropertyValue('--offset-right') !== '24px') return 'beside';
353-
if (toaster.style.getPropertyValue('--offset-top') !== '24px') return 'below header';
354-
return 'corner';
361+
const anchor = `${toaster.getAttribute('data-y-position')}-${toaster.getAttribute('data-x-position')}`;
362+
const top = toaster.style.getPropertyValue('--offset-top');
363+
const right = toaster.style.getPropertyValue('--offset-right');
364+
if (anchor === 'top-center') return top === '56px' ? 'top centre' : `top centre at ${top}`;
365+
if (anchor !== 'top-right') return `anchored ${anchor}`;
366+
if (right !== '24px') return 'beside';
367+
if (top !== '24px') return 'below header';
368+
return 'top-right at the default offsets';
355369
});
356370

357371
interface ToastReading {

‎packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
* one defect, not two.
2828
*
2929
* So this file pins the two properties the fix rests on: the default anchor is
30-
* the top-right corner (nothing interactive lives there), and a success toast
31-
* really does dismiss itself inside the 3–5s band the card asked for.
30+
* at the top, away from the assistant's corner, and a success toast really
31+
* does dismiss itself inside the 3–5s band the card asked for. The top anchor
32+
* is top-centre since objectui#13340: the top-right corner, chosen here
33+
* first, turned out to hold every page header's actions.
3234
*/
3335
import '@testing-library/jest-dom/vitest';
3436
import * as React from 'react';
@@ -64,8 +66,9 @@ async function anchorOf(props: Record<string, unknown> = {}): Promise<[string |
6466
}
6567

6668
describe('ConsoleToaster anchor (objectui#7482)', () => {
67-
it('defaults to the top-right corner, away from the assistant composer', async () => {
68-
expect(await anchorOf()).toEqual(['top', 'right']);
69+
it('defaults to the top edge, away from the assistant composer', async () => {
70+
// Top-centre (objectui#13340), not top-right: see `DEFAULT_OFFSET` in `ConsoleToaster`.
71+
expect(await anchorOf()).toEqual(['top', 'center']);
6972
});
7073

7174
it('is still overridable — the default is a default, not a lock', async () => {

‎packages/app-shell/src/chrome/ConsoleToaster.drawerClearance-11685.test.tsx‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
* corner (objectui#7482) is also where every right-side drawer keeps its
1313
* chrome.
1414
*
15-
* The fix keeps the corner and offsets the toaster while a right-edge drawer is
16-
* open: into the strip of page left of the drawer when a toast fits there,
15+
* The fix anchors the toaster top-right and offsets it while a right-edge
16+
* drawer is open (with none open it sits top-centre since objectui#13340): into the strip of page left of the drawer when a toast fits there,
1717
* otherwise below the drawer's header. This file pins that MECHANISM — which
1818
* dialogs count as a right-edge drawer, and which sonner offset each case
1919
* produces, appearing on open and gone on close.
@@ -93,14 +93,29 @@ async function renderWithToast(props: Record<string, unknown> = {}): Promise<HTM
9393
});
9494
}
9595

96-
const offsets = (toaster: HTMLElement) => ({
97-
top: toaster.style.getPropertyValue('--offset-top'),
98-
right: toaster.style.getPropertyValue('--offset-right'),
99-
mobileTop: toaster.style.getPropertyValue('--mobile-offset-top'),
96+
/**
97+
* The toaster region as it is NOW. Sonner keys its `<ol>` by position, so the
98+
* move between top-centre (no drawer, objectui#13340) and top-right (a drawer
99+
* to clear) mounts a new element: a handle taken before the move goes stale.
100+
*/
101+
const region = (): HTMLElement => {
102+
const el = document.querySelector<HTMLElement>('[data-sonner-toaster]');
103+
expect(el, 'sonner did not mount its toaster region').toBeTruthy();
104+
return el as HTMLElement;
105+
};
106+
107+
const offsets = (_toaster?: HTMLElement) => ({
108+
top: region().style.getPropertyValue('--offset-top'),
109+
right: region().style.getPropertyValue('--offset-right'),
110+
mobileTop: region().style.getPropertyValue('--mobile-offset-top'),
100111
});
101112

102-
/** sonner's defaults, i.e. the toaster with no drawer to clear. */
103-
const DEFAULTS = { top: '24px', right: '24px', mobileTop: '16px' };
113+
/**
114+
* The toaster with no drawer to clear: top-centre, below the console's 48px
115+
* top bar (objectui#13340); sonner's own offsets on the other sides and on
116+
* phones.
117+
*/
118+
const DEFAULTS = { top: '56px', right: '24px', mobileTop: '16px' };
104119

105120
afterEach(() => {
106121
act(() => {
@@ -112,23 +127,23 @@ afterEach(() => {
112127
});
113128

114129
describe('ConsoleToaster clears an open right-edge drawer (objectui#11685)', () => {
115-
it('the control: with no drawer open it keeps sonner`s own offsets', async () => {
130+
it('the control: with no drawer open it sits top-centre, below the top bar', async () => {
116131
setViewportWidth(1440);
117132
const toaster = await renderWithToast();
118-
expect(toaster.getAttribute('data-x-position')).toBe('right');
133+
expect(toaster.getAttribute('data-x-position')).toBe('center');
119134
expect(toaster.getAttribute('data-y-position')).toBe('top');
120135
expect(offsets(toaster)).toEqual(DEFAULTS);
121136
});
122137

123-
it('moves into the strip left of the drawer when a toast fits there, corner unchanged', async () => {
138+
it('moves into the strip left of the drawer when a toast fits there, from the top-right corner', async () => {
124139
// 1440px window, 864px drawer: 576px of page left of it, room for a 356px toast.
125140
setViewportWidth(1440);
126141
const toaster = await renderWithToast();
127142
mountDialog({ layout: RIGHT_SHEET, width: 864 });
128143
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));
129144
expect(offsets(toaster).top).toBe('24px');
130-
expect(toaster.getAttribute('data-x-position')).toBe('right');
131-
expect(toaster.getAttribute('data-y-position')).toBe('top');
145+
expect(region().getAttribute('data-x-position')).toBe('right');
146+
expect(region().getAttribute('data-y-position')).toBe('top');
132147
});
133148

134149
it('drops below the drawer header when the strip beside it is too narrow', async () => {
@@ -142,14 +157,15 @@ describe('ConsoleToaster clears an open right-edge drawer (objectui#11685)', ()
142157
expect(offsets(toaster).mobileTop).toBe(`${HEADER_HEIGHT + 16}px`);
143158
});
144159

145-
it('goes back to the corner when the drawer closes, and when it unmounts', async () => {
160+
it('goes back to top-centre when the drawer closes, and when it unmounts', async () => {
146161
setViewportWidth(1440);
147162
const toaster = await renderWithToast();
148163
const drawer = mountDialog({ layout: RIGHT_SHEET, width: 864 });
149164
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));
150165

151166
drawer.setAttribute('data-state', 'closed');
152167
await waitFor(() => expect(offsets(toaster)).toEqual(DEFAULTS));
168+
expect(region().getAttribute('data-x-position')).toBe('center');
153169

154170
drawer.setAttribute('data-state', 'open');
155171
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));

‎packages/app-shell/src/chrome/ConsoleToaster.tsx‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,34 @@ import { useObjectTranslation } from '@object-ui/i18n';
1515
type ToasterProps = React.ComponentProps<typeof Sonner>;
1616

1717
/**
18-
* objectui#11685 — the toaster steps clear of an open right-edge drawer.
18+
* objectui#13340 — with no drawer open, the toaster sits top-centre, just
19+
* below the console's top bar.
1920
*
20-
* `top-right` (objectui#7482, below) is also the corner every right-side
21-
* drawer puts its chrome in: the record drawer's expand and close buttons and
22-
* its title, the record's own header with its Approve-style actions under it.
23-
* A success toast raised while one is open, which is exactly what creating a
24-
* record or running a record action does, landed on top of all of them.
21+
* The top-right corner was not free: every page header puts its actions there
22+
* (a list's New and Import, a record's Edit and its header actions, a
23+
* dashboard's controls). At 1440×900 a toast at sonner's default top-right
24+
* offset covered New and Import for its whole 4s life, so the button the user
25+
* needed next could not be clicked. Bottom-right stays out (objectui#7482: the
26+
* assistant's FAB and composer). Top-centre is the strip nothing interactive
27+
* uses: page headers put their title on the left and their actions on the
28+
* right. The offset clears the top bar (`AppShell`'s `h-12` header), whose
29+
* centre holds the global search. One anchor for every page, so no page needs
30+
* to know where the toaster is.
31+
*/
32+
const APP_HEADER_HEIGHT = 48; // `AppShell`'s sticky `<header>`, `h-12`
33+
const APP_HEADER_GAP = 8;
34+
const DEFAULT_OFFSET: ToasterProps['offset'] = { top: APP_HEADER_HEIGHT + APP_HEADER_GAP };
35+
36+
/**
37+
* objectui#11685 — the toaster steps clear of an open right-edge drawer.
2538
*
26-
* The corner stays. While such a drawer is open the toaster is offset, never
27-
* re-anchored:
39+
* While a right-side drawer is open the toaster goes back to the `top-right`
40+
* corner and is offset from there, because that corner is also where every
41+
* right-side drawer puts its chrome: the record drawer's expand and close
42+
* buttons and its title, the record's own header with its Approve-style
43+
* actions under it. A success toast raised while one is open, which is exactly
44+
* what creating a record or running a record action does, would otherwise land
45+
* on top of all of them. The offset:
2846
*
2947
* - When the strip of page left of the drawer is wide enough for a toast, the
3048
* toaster moves into that strip, to the drawer's left edge. The strip is
@@ -180,19 +198,23 @@ export function ConsoleToaster(props: ToasterProps) {
180198
// Notion pattern users expect. Callers can still override any of
181199
// these via the spread `{...props}` below.
182200
//
183-
// objectui#7482 — `top-right` is load-bearing, not cosmetic. The
201+
// objectui#7482 — a TOP anchor is load-bearing, not cosmetic. The
184202
// bottom-right corner belongs to the ChatDock's composer and the FAB
185203
// that launches it (ADR-0057 P3a/P3b), and a toaster anchored there does
186204
// more than overlap them: sonner pauses a toast's dismiss timer while
187205
// the pointer is inside the toaster region (`expanded || interacting ||
188206
// isDocumentHidden` in its Toast effect), so a pointer resting on the
189207
// composer underneath keeps the toast on screen until the user clicks ×.
190208
// Override the position only onto a corner nothing interactive occupies.
191-
position="top-right"
192-
// objectui#11685 — while a right-edge drawer is open, the same corner
193-
// shifted clear of the drawer's chrome (see `useDrawerClearance`).
194-
// `undefined` when none is open, which is sonner's own default.
195-
offset={clearance?.offset}
209+
//
210+
// objectui#13340 — top-centre below the top bar, not top-right: the
211+
// page header's actions live in the top-right corner (see
212+
// `DEFAULT_OFFSET`). objectui#11685 — while a right-edge drawer is
213+
// open, top-right shifted clear of the drawer's chrome (see
214+
// `useDrawerClearance`). The phone offset stays sonner's own until a
215+
// drawer needs clearing.
216+
position={clearance ? 'top-right' : 'top-center'}
217+
offset={clearance?.offset ?? DEFAULT_OFFSET}
196218
mobileOffset={clearance?.mobileOffset}
197219
closeButton
198220
richColors

0 commit comments

Comments
 (0)