Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1ff72bc
PT-4193: Add secondary notification action, position, and dismissible
rolfheij-sil Jul 15, 2026
74330d8
PT-4193: Fix dismissible: false silently disabling the secondary button
rolfheij-sil Jul 15, 2026
c90014f
PT-4193: Regenerate papi.d.ts to match reflowed source JSDoc
rolfheij-sil Jul 15, 2026
181ea82
PT-4193: Fix two-button toast crushing message content to a sliver
rolfheij-sil Jul 16, 2026
b07cde0
PT-4193: Drop stale send() updates racing their own dismiss()
rolfheij-sil Jul 16, 2026
44a8809
fix(notifications): remove the 5s dismiss grace window (PT-4193 revie…
rolfheij-sil Jul 16, 2026
e5acbc7
fix(notifications): rework two-button toast CSS + reach every positio…
rolfheij-sil Jul 16, 2026
2585b02
fix(notifications): harden the notification service host (PT-4193 rev…
rolfheij-sil Jul 16, 2026
7daea47
fix(notifications): extend the two-row toast reflow to single-button …
rolfheij-sil Jul 17, 2026
7ca04dc
fix(notifications): keep the buttoned-toast row break through Sonner'…
rolfheij-sil Jul 17, 2026
f780c8d
docs(notifications): tag the secondary-action/position/dismissible su…
rolfheij-sil Jul 17, 2026
90c2bc5
fix(notifications): address PT-4193 round-5 review findings
lyonsil Jul 17, 2026
9c3be33
feat(notifications): style the secondary toast button as shadcn secon…
lyonsil Jul 17, 2026
3918b13
chore(deps): pin sonner to exactly 1.7.4 (PT-4193)
lyonsil Jul 17, 2026
4db3519
fix(notifications): follow the app theme in the notification Toaster …
lyonsil Jul 17, 2026
d741a3a
docs(notifications): drop PR/review-round references from code comments
lyonsil Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions lib/papi-dts/papi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5212,6 +5212,28 @@ declare module 'shared/models/notification.service-model' {
import { CommandHandlers } from 'papi-shared-types';
import { LocalizeKey } from 'platform-bible-utils';
export type Severity = 'info' | 'warning' | 'error';
/**
* The placements a notification can appear in, as a frozen array so it can be the single source of
* truth for both the {@link NotificationPosition} type and the notification service's OpenRPC
* `position` enum (which the service host spreads from this).
*
* @experimental
*/
export const NOTIFICATION_POSITIONS: readonly [
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
];
/**
* Where a notification is shown on screen. Mirrors the placements the host toast library supports.
* Omit to use the app's default placement.
*
* @experimental
*/
export type NotificationPosition = (typeof NOTIFICATION_POSITIONS)[number];
/** Data needed to display a notification to the user */
export interface PlatformNotification {
/**
Expand All @@ -5223,7 +5245,9 @@ declare module 'shared/models/notification.service-model' {
/** Severity of the notification */
severity: Severity;
/**
* Optional label for users to click when the notification shows.
* Optional label for users to click when the notification shows. Always rendered as the
* notification's PRIMARY action button - the visually emphasized one - while
* {@link secondaryClickCommandLabel} always gets the muted secondary styling.
*
* Automatically localized if this is a {@link LocalizeKey}.
*/
Expand All @@ -5237,7 +5261,91 @@ declare module 'shared/models/notification.service-model' {
* The command handler should have the type signature {@link NotificationClickCommandHandler}.
*/
clickCommand?: keyof CommandHandlers;
/** Optional ID of a previous notification to update instead of showing a new notification */
/**
* Optional label for a second action button, shown alongside {@link clickCommandLabel}. Provide
* this together with {@link secondaryClickCommand} to give the notification two actions.
*
* Always rendered as the visually SECONDARY button (muted styling, like the shadcn `secondary`
* button variant) so the {@link clickCommandLabel} button keeps the emphasis - the platform
* decides each button's styling from which field it came from, never from ordering.
*
* Automatically localized if this is a {@link LocalizeKey}.
*
* @experimental
*/
secondaryClickCommandLabel?: string | LocalizeKey;
/**
* Optional command to run if users click on the secondary label in the notification. Like
* {@link clickCommand}, the command is sent one argument:
*
* - NotificationId: The ID of the notification that was clicked
*
* The command handler should have the type signature {@link NotificationClickCommandHandler}.
*
* @experimental
*/
secondaryClickCommand?: keyof CommandHandlers;
/**
* Optional command to run if the user dismisses the notification themselves - by swiping/dragging
* it away, or by clicking the close button (if the host ever enables one). Sent no arguments
* other than the notification id, like {@link clickCommand}:
*
* - NotificationId: The ID of the notification that was dismissed
*
* The command handler should have the type signature {@link NotificationClickCommandHandler}.
*
* IMPORTANT: this fires when the user dismisses the notification themselves (swiping/dragging it
* away, or clicking a close button if the host ever enables one) AND when the notification
* auto-closes because its `duration` elapsed - a timeout is treated as an implicit dismissal, so
* a must-answer toast that times out still runs this command instead of vanishing silently. It
* does NOT fire when the notification is dismissed programmatically via
* {@link INotificationService.dismiss}, nor when the user clicks {@link clickCommand} /
* {@link secondaryClickCommand}. Use this to treat a swipe-away (or timeout) as an explicit
* decision - e.g. pairing it with a "postpone" command lets a two-button, must-answer-style toast
* keep {@link dismissible} `true` (see the warning on {@link dismissible}). If you need the toast
* to persist until the user actually answers, also set `duration` to `0`.
*
* @experimental
*/
dismissClickCommand?: keyof CommandHandlers;
/**
* Optional placement of the notification on screen. When omitted, the app's default placement is
* used.
*
* @experimental
*/
position?: NotificationPosition;
/**
* Whether the user can dismiss the notification directly (e.g. by swiping/dragging it away, or
* via a close button). Defaults to `true`.
*
* The host toast library (Sonner) gates both the {@link secondaryClickCommand} button and the
* user-dismiss gesture that fires {@link dismissClickCommand} on this same flag, so a naive
* `dismissible: false` would silently turn those controls into dead buttons. To prevent that, the
* platform IGNORES `dismissible: false` when the notification renders a secondary action button
* (a {@link secondaryClickCommand} paired with its {@link secondaryClickCommandLabel}) or has a
* {@link dismissClickCommand} - the notification stays user-dismissible so those controls keep
* working. `dismissible: false` therefore only takes effect on a notification with no secondary
* button and no dismiss command. For a notification the user must explicitly answer, prefer
* leaving `dismissible: true` and using {@link dismissClickCommand} so a swipe-away still counts
* as a real (e.g. "postpone") decision.
*
* NOTE: `dismissible: false` does not keep the notification on screen. Auto-close is governed
* solely by `duration` (when omitted, 10-35 seconds computed from message length), so a
* non-dismissible notification still auto-closes on that timer. Also set `duration` to `0` (or
* less) if the notification must stay up until it is answered or programmatically dismissed via
* {@link INotificationService.dismiss}.
*
* @experimental
*/
dismissible?: boolean;
/**
* Optional ID of a previous notification to update instead of showing a new notification.
*
* On an update (a `send` reusing an id that is still showing), any optional field you omit keeps
* the value it had on the previous `send` for that id - omitting a field never clears it. Pass
* the field explicitly to change it.
*/
notificationId?: string | number;
/**
* Optional duration in milliseconds for how long the notification is displayed. To make the
Expand Down
2 changes: 1 addition & 1 deletion lib/platform-bible-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"radix-ui": "^1.4.3",
"react-hotkeys-hook": "^4.6.1",
"react-resizable-panels": "^4.10.0",
"sonner": "^1.7.4",
"sonner": "1.7.4",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions src/renderer/components/notification-display.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// A toast with buttons renders, by Sonner 1.7.4's own stylesheet
// (node_modules/sonner/dist/styles.css), as a single un-wrapped flex row: icon, content, cancel
// button, action button. Sonner's buttons are `flex-shrink: 0` and its toast width is a fixed
// `--width` (356px), so wide buttons crush the message content down to a sliver - confirmed live
// twice: a two-button Send/Receive consent toast squeezed its message to ~1 character, and (round-4
// E2E) a single-action break-lock toast ("Break lock and retry" beside a long warning) was just as
// cramped. So give EVERY toast with at least one button a two-row layout instead: [icon][message]
// on the first row, and the button(s) right-aligned on a row of their own. Scope the fix to
// buttoned toasts via `:has()` so plain-message toasts stay pixel-identical to before.
//
// Sonner exposes no supported API for a two-row toast body (the only real escape hatch,
// `toast.custom`, means re-implementing Sonner's severity icon/color rendering), so this reaches
// into its flex layout. It therefore DEPENDS on Sonner 1.7.4 rendering the icon, content, cancel,
// and action as direct flex children of the toast `<li>`; revisit if Sonner changes its toast DOM.
// Deliberately avoids Sonner's own `::after` (its ALWAYS-ON hover-bridge between stacked toasts)
// and repurposes `::before`, which Sonner only styles in the swiping/removed states - see the
// comment on the row break below for how that collision is resolved.
.notification-toast:has(.notification-toast-cancel-button, .notification-toast-action-button) {
flex-wrap: wrap;
Comment thread
lyonsil marked this conversation as resolved.

.notification-toast-content {
// Grow to fill the first row beside the icon. `flex-basis: 0` keeps the message's hypothetical
// width at 0 so the flex-wrap algorithm never bumps the message onto a line below the icon
// (which would leave the icon orphaned on a row of its own); the message wraps *within* this
// grown box instead.
flex: 1 1 0;
min-inline-size: 0;
}

// Full-width, zero-height flex break that forces the buttons onto their own row. Unlike the
// previous `::after` hack this leaves Sonner's `::after` hover-bridge alone, and it must apply in
// EVERY toast state: Sonner flips the toast to `data-swiping="true"` the instant any mouse button
// is pressed on the toast body (before any movement - a plain press-and-hold counts), and an
// earlier revision that gated this break out of the swiping/removed states collapsed the toast to
// a one-character-wide sliver for the duration of every such press, because the `flex-wrap` and
// `flex-basis: 0` rules above stayed active with no row break. Sonner's own use of `::before` in
// those states is an invisible, ABSOLUTELY-positioned hover/hit-area extender declared at zero
// specificity (`:where(...)`), so this higher-specificity rule wins on every property it declares;
// `position: static` is declared explicitly to keep the pseudo-element in flex flow when Sonner's
// swipe/removal styles try to absolutely position it (its leftover declarations - inset, height,
// transform, z-index - are inert on a zero-height in-flow item, `height` losing to `block-size`
// by specificity). Trade-off: buttoned toasts lose Sonner's enlarged swipe/removal hover hit
// area; swipe-to-dismiss tracking is unaffected because Sonner captures the pointer at press
// time.
&::before {
Comment thread
lyonsil marked this conversation as resolved.
content: '';
position: static;
order: 1;
flex-basis: 100%;
block-size: 0;
Comment thread
lyonsil marked this conversation as resolved.
}

// Collect the button(s) right-aligned on the button row. Sonner puts `margin-inline-start: auto`
// on *every* button, which right-aligns a lone button all by itself but, when both buttons share
// a row, splits the free space *between* them instead of pushing them together; so leave the auto
// margin alone except on an action button whose toast also has a cancel button - zeroing only
// that one leaves the pair flush at the row's end, separated by the toast's own gap.
.notification-toast-cancel-button {
order: 2;
margin-inline-start: auto;
}

.notification-toast-action-button {
order: 3;
}

&:has(.notification-toast-cancel-button) .notification-toast-action-button {
Comment thread
lyonsil marked this conversation as resolved.
margin-inline-start: 0;
}

// Settle the DEFAULT look of the secondary (cancel-slot) button as the shadcn `secondary` button
// variant (button.tsx: bg-secondary / text-secondary-foreground / hover:bg-secondary/80), per UX
// direction. Without this, Sonner 1.7.4's styled mode renders BOTH buttons
// identically dark: its base `[data-sonner-toast][data-styled='true'] [data-button]` rule
// (specificity (0,3,0)) sets the action colors on every `[data-button]` - including the cancel
// button, which also carries `data-button` - and its softer `:where([data-cancel])` defaults are
// zero-specificity so they never win. Which button gets this look is deterministic: the service
// host maps `clickCommand` to Sonner's `action` slot (keeps the emphasized primary look) and
// `secondaryClickCommand` to the `cancel` slot, whose class this targets. The extra `&:has()`
// keeps specificity at (0,4,0) so this beats Sonner's (0,3,0) base rule regardless of which
// stylesheet loads last (same pattern as the margin rule above).
&:has(.notification-toast-cancel-button) .notification-toast-cancel-button {
background: var(--secondary);
color: var(--secondary-foreground);

&:hover {
background: color-mix(in oklab, var(--secondary) 80%, transparent);
}
}
}
Loading
Loading