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 againstNotification,
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
notifyactor writes every notification, with its finaltitleandbody, and publishes it on thenotificationstopic. The browser showstitleandbodyas sent and never rebuilds them; Telegram sends the same two strings. So the copy below is implemented once, innotify(BE-Core). - The browser decides only how to deliver it: toast (
in_app),desktopandsound, per the user’s rules. Telegram is sent by the backend. - Toasts for events that have a notification kind come only from the
notificationstopic. The Phase 3 fill toast and fill sound (use-fill-toasts.ts,lib/sound.ts) are replaced by thefillnotification, so one fill never shows two toasts. - The reject toast after a click stays: it answers the click. So
order_rejectedis 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 bynotify. 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 fornotify:
- 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 as30s,12m,23h.
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
failedwhen its settlement transaction does not go through; no shares change hands. The title usesFailed, the word the fills list shows for that status. It is sent once per fill, as critical. order_resolved. Sent once for eachorder_unknown, when reconciliation finds the outcome. The title staysOrder checkedin every case, paired withChecking order; the last part of the body says how it ended.filledmeans the placement matched at once; the fill itself also arrives as afillnotification.feed_restoredandcredentials_restored. Sent only after the matching problem was notified (afeed_downafter 30 s, acredentials), 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 hasin_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:Openwhen the notification has a target (below), none otherwise. The close[x]is an icon button (aria-labelClose), shown on hover on desktop and always on touch. - Toasts are
role="status"; critical ones arerole="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, elsegame_id, elsealert_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
TheOpen 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
desktoprule is on, the browser permission isgranted, and no tab of the app is visible. While a tab is visible the toast covers it. Tabs share their visibility over aBroadcastChannel. - Nothing is delivered when no tab is open; Telegram covers that.
- Options:
title= the notification’stitle;body= itsbody;tag= itsid, so several tabs show it once;timestamp=created_at;icon= the app icon (192 px);silent: true(the sound channel decides sound);requireInteractionfor 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
soundrule 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-motiondoes not mute it.
Telegram
- Sent by the backend when the kind’s
telegramrule is on andtelegram_configuredis 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 byGET|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
Allin the kind column (accessible namesIn-app: all,Desktop: alland 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 wholeNotificationSettings(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 readsNot 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
- The app never asks on load, at login or from a banner.
- The first time the user checks a Desktop box (or the Desktop header checkbox) while the
permission is
default, the click handler callsNotification.requestPermission(). The checkbox shows its pending state meanwhile. granted: the box stays checked, the rule saves, and/sw.jsis registered if it is not yet.denied: the box returns to unchecked and the column switches to the blocked state. Nothing else is shown.- Dismissed (still
default): the box returns to unchecked with no message; the next check asks again. - If the permission changes later (the user blocks the site), the column follows it on the
next visit to the page (
navigator.permissionschange event where available). - The service worker is registered after login when the permission is already
grantedand 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,Mutedat 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 kindtest,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 lineNot sent · one test per 10 s. - 502
telegram_error: Telegram refused or failed the message; the browser channels were still reached. Result lineNot 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.
- 204: every channel was reached (Telegram left out when not configured). No extra
message; the button is disabled for 10 s with the tooltip
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.