Skip to main content

Notifications

How the app tells the user that something happened: in-app toasts, desktop notifications, sounds and Telegram, the copy of every notification, and the Settings > Notifications screen. Built in Phase 4 against Notification, NotificationKind, NotificationRule and NotificationSettings in api/openapi.yaml. The history list and the unread count are in alerts.md. Wireframe notation (README.md): [name] in lower case is a lucide icon, [Label] is a button, [ ] / [*] / [-] is an unchecked / checked / mixed checkbox, (dot) is a 6 px status dot.

One source for every notification

  • The backend notify actor writes every notification, with its final title and body, and publishes it on the notifications topic. The browser shows title and body as sent and never rebuilds them; Telegram sends the same two strings. So the copy below is implemented once, in notify (BE-Core).
  • The browser decides only how to deliver it: toast (in_app), desktop and sound, per the user’s rules. Telegram is sent by the backend.
  • Toasts for events that have a notification kind come only from the notifications topic. The Phase 3 fill toast and fill sound (use-fill-toasts.ts, lib/sound.ts) are replaced by the fill notification, so one fill never shows two toasts.
  • The reject toast after a click stays: it answers the click. So order_rejected is off in-app by default (the contract’s default rules), and the notification is kept in the history and can go to Telegram. The ladder’s rejected chip (3 s at the level) also stays: it is the order’s state, not a notification.
  • Toasts for the user’s own actions that are not notification kinds stay local: Alert set, Alert deleted, Cancelled 7 orders, Settings saved, and so on.

Severity

Each kind has one severity, set by notify. Severity picks the toast stripe, duration and sound; nothing else. A failed fill is the one case where a kind’s severity varies: notify raises that fill to critical, and the browser follows the notification’s severity field, not the kind.

Copy per notification kind

Rules for notify:
  • Title: a fixed phrase per kind (or per alert kind), sentence case, at most 40 characters, no trailing full stop. It names the event.
  • Body: one line of facts, parts joined by ·, at most 120 characters, no trailing full stop, no advice. Figures come before names, and when the body is too long the market or game title is shortened with …, never a price, size, score or time.
  • Plain text only: no emoji, no symbols as icons, no Markdown. Glossary terms only.
  • Formats follow lib/format: 42¢, 42.5¢ on a 0.001 tick, shares without trailing zeros (25, 59.52), $1,234.56, scores with an en dash (88–84), durations as 30s, 12m, 23h.
Placeholders: Period labels. A bare period number from the sports feed is labelled for the sport, the same rule as the board’s score badge (periodLabel in frontend/src/components/trading/game-state.ts), matched on the league or sport name: A period that is not a bare number (HT, OT, S4) is shown as the feed sends it.

The table

Notes on the rows added in Phase 4:
  • Failed fill. Polymarket marks a trade failed when its settlement transaction does not go through; no shares change hands. The title uses Failed, the word the fills list shows for that status. It is sent once per fill, as critical.
  • order_resolved. Sent once for each order_unknown, when reconciliation finds the outcome. The title stays Order checked in every case, paired with Checking order; the last part of the body says how it ended. filled means the placement matched at once; the fill itself also arrives as a fill notification.
  • feed_restored and credentials_restored. Sent only after the matching problem was notified (a feed_down after 30 s, a credentials), so a short reconnect sends nothing. {down} is measured from the disconnect or the failure, not from the notification.
  • Startup. Sent to Telegram only, not a notification kind: no rule, no history row. It goes out on each start when Telegram is configured and counts against the 20-a-minute limit. {hash} is the full 64-character chain head, never shortened: it is the copy of the audit chain head kept outside the host (security finding P3-13), so the user can compare it across restarts. {seq} is the last row’s sequence number, without thousands separators. The failure reasons stay in the server log; the message carries no internal error text.
{reason} for credentials maps the CredentialStatus.reason codes: derive_failed → key derivation failed, auth_rejected → authentication refused, geoblocked → geoblocked, network → no response from the venue, wallet_mismatch → Session Key not a signer of this wallet, clock_skew → clock differs from the venue. reject-messages.ts has one entry that gives advice, session_key_expired: "the Session Key has expired; create a new one". Notification bodies use Session Key expired; the table entry should drop the advice too (FE-Platform).

In-app toasts

sonner, as in components.md. One toast per notification whose kind has in_app on.

Layout

  • 12 px padding, --popover, --shadow-md, the overlay radius. Minimum height 56 px; the body wraps to two lines and then ends in …, so its figures are not cut.
  • At most one action, a ghost text button at --control-h: Open when the notification has a target (below), none otherwise. The close [x] is an icon button (aria-label Close), shown on hover on desktop and always on touch.
  • Toasts are role="status"; critical ones are role="alert".

Position, stacking and duration

  • Durations by severity: info 4 s, warning 8 s, critical until closed. Hover pauses the timer (desktop), and timers pause while the tab is hidden.
  • Same event, one toast. A notification with the same kind and target (token_id, else game_id, else alert_id) as a toast still on screen replaces that toast’s text, restarts its timer and adds a count to the title: Order filled (3). A burst of fills on one outcome is one toast.
  • Critical toasts use the kind as their id, so there is at most one per kind; a new one replaces the old.
  • Notifications that arrive while the tab is hidden are shown when it becomes visible, newest first, within the same limits; the rest are in the history with the unread count.
  • Motion follows motion.md (toast row). Reduced motion: toasts appear and leave without sliding.

Targets

The Open action, a click on a desktop notification and a click on a history row go to the same place:

Desktop notifications

  • Shown with the Notification API through the same-origin service worker /sw.js (registration.showNotification), so they appear while the tab is in the background. The worker has no fetch handler, no cache and no push subscription.
  • Shown only when the kind’s desktop rule is on, the browser permission is granted, and no tab of the app is visible. While a tab is visible the toast covers it. Tabs share their visibility over a BroadcastChannel.
  • Nothing is delivered when no tab is open; Telegram covers that.
  • Options: title = the notification’s title; body = its body; tag = its id, so several tabs show it once; timestamp = created_at; icon = the app icon (192 px); silent: true (the sound channel decides sound); requireInteraction for critical; no action buttons, no image.
  • Text format: exactly the title and one body line from the copy table, plain text. The operating system adds the site name, so the title carries no app name.
  • Click: closes the notification, focuses an open app tab (or opens one) and goes to the target. Without a target it only focuses the app.

Sounds

  • One short tone per severity, synthesised with Web Audio (an oscillator and a gain envelope; no audio files). Each is a single note:
  • Envelope: 10 ms rise, then an exponential fall to silence by the end of the note.
  • Volume: peak gain = 0.25 × (sound_volume / 100)², so the slider feels even across its range; the default 70 gives about 0.12. At 0 nothing plays.
  • Plays when the kind’s sound rule is on, whether the tab is visible or not.
  • At most one sound per second: when several notifications arrive together, the highest severity among them plays once.
  • Only one tab plays sounds: the tab holding the Web Lock sesame-sound.
  • Browsers allow audio only after a user gesture, so the audio context unlocks on the first pointerdown or keydown. Before that, sounds are skipped; nothing asks the user to click.
  • Sound is not motion: prefers-reduced-motion does not mute it.

Telegram

  • Sent by the backend when the kind’s telegram rule is on and telegram_configured is true. Outbound only.
  • Message format: plain text, no parse_mode, two lines: the title, a line break, the body.
  • More than 20 messages in a minute: the rest of that minute are coalesced into one summary message with the copy in the table (12 more notifications / 8 Order filled · 4 Price alert).
  • On each start the server sends the startup message (copy table, Server started) with the audit chain head.
  • Setting up the bot and chat id: docs/runbooks/telegram.md.

Settings > Notifications

The Notifications section of settings.md. Backed by GET|PUT /settings/notifications. One row per notification kind, one column per channel.

Kind labels

test has no row: it has no rule.

Default rules

These are the defaults in the contract (NotificationSettings.rules in api/openapi.yaml); the contract is the source and notify implements it. notify seeds them on first start.
  • In-app is on for every kind except rejected orders, because the click already shows the reject.
  • Desktop is off everywhere, because turning it on is what asks for the browser permission.
  • Sound is on only for alerts, fills and combo fills.
  • Telegram is on for alerts, fills, combo fills, combos not filled, orders cancelled at start, feed disconnected, activity outside this app and credentials unavailable.
  • Combos not filled is on in-app although the slip shows the result, because the slip may be hidden or closed by the time execution ends.
  • Trading set to Safe is in-app only.
The sound volume defaults to 70. The test notification (Send test) has no rule and ignores the rules: it reaches every channel, whatever the table says.

Desktop (640 px form column)

  • Kinds sit under three small muted group labels, in this fixed order: Trading (alerts, fills, rejected orders, orders being checked, orders checked, orders cancelled at start, combo fills, combos not filled), Connection (feed disconnected and reconnected, credentials unavailable and available, activity outside this app) and Security (trading set to Safe, Session Key expiring, logins refused, cancel on shutdown failed). The All row stays above the groups.
  • The table sits on the section surface with dividers: no card, no zebra rows. Channel columns are 80 px, checkboxes centred on the column’s own centre (a 16 px box in a 32 px hit area). The kind column takes the rest; labels never truncate at this width.
  • The All row sits in the table header under the column labels, labelled All in the kind column (accessible names In-app: all, Desktop: all and so on). Its checkbox sets the whole column and shows mixed when the column is mixed; a disabled column disables it too, with the column’s tooltip.
  • Saving: every change saves on its own, with no Save button: the page PUTs the whole NotificationSettings (all rules and the volume) 400 ms after the last change. The checkbox changes at once; on failure the table returns to the saved values and the result line reads Not saved · {reason}. No success toast.
  • In-app column tooltip: Toast in this app. The history keeps every notification.

Channel states

Status lines are quiet: muted body-sm, shown only while a channel cannot deliver.

Browser permission flow

  1. The app never asks on load, at login or from a banner.
  2. The first time the user checks a Desktop box (or the Desktop header checkbox) while the permission is default, the click handler calls Notification.requestPermission(). The checkbox shows its pending state meanwhile.
  3. granted: the box stays checked, the rule saves, and /sw.js is registered if it is not yet.
  4. denied: the box returns to unchecked and the column switches to the blocked state. Nothing else is shown.
  5. Dismissed (still default): the box returns to unchecked with no message; the next check asks again.
  6. If the permission changes later (the user blocks the site), the column follows it on the next visit to the page (navigator.permissions change event where available).
  7. The service worker is registered after login when the permission is already granted and any Desktop rule is on; otherwise only at step 3.

Volume and test

  • Volume: a slider, 0 to 100 in steps of 5, 240 px on desktop, with its value in a 3-digit slot after it (70, Muted at 0). Releasing the slider plays the info tone at the new volume (a user gesture, so audio can start). It saves like the checkboxes.
  • Send test: a ghost text button (the section has no primary button). It calls POST /notifications/test, which sends one notification of kind test, Test notification / Sent from Settings. The test has no rule and ignores the rules: it reaches every channel. The browser shows the toast, plays the info tone and shows a desktop notification when the permission is granted (and no tab is visible); the backend sends it to Telegram when Telegram is configured. The button shows its loading state until the response.
    • 204: every channel was reached (Telegram left out when not configured). No extra message; the button is disabled for 10 s with the tooltip Sent · available again in 8s (the seconds count down).
    • 429 rate_limited: more than one test per 10 s. Result line Not sent · one test per 10 s.
    • 502 telegram_error: Telegram refused or failed the message; the browser channels were still reached. Result line Not sent · Telegram refused the message. The causes are in the Telegram runbook.
    • Network: Not sent · no response from server.
    • There is no 503 for the test: an unconfigured Telegram is skipped, not an error.

Mobile (390 px)

The Notifications page opens full screen from the settings list, with a back button.
  • Four 52 px channel columns (208 px) and the kind label in the rest (150 px), two lines at most; rows are 52 px. The headers are one line of label text (12 px) centred on each column: In-app, Desktop, Sound, Telegram, which fit 52 px. The drawing staggers them only to fit its character grid.
  • Every checkbox has a 44 × 44 hit area; the header checkboxes sit in a 44 px row of their own.
  • Status lines, saving, permission flow and test results are the same as desktop; the result line sits under Send test.