> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications

# 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](alerts.md#notification-history).

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.

| Severity | Kinds | Toast stripe | Toast duration | Desktop notification |
| - | - | - | - | - |
| `info` | `alert`, `fill`, `feed_restored`, `credentials_restored`, `order_resolved`, `combo_filled`, `test` | none | 4 s | closes by itself |
| `warning` | `order_rejected`, `order_unknown`, `game_start_orders`, `trading_safe`, `session_key_expiring`, `combo_failed` | 3 px `--warning` | 8 s | closes by itself |
| `critical` | `feed_down`, `foreign_activity`, `credentials`, and a `fill` whose status became `failed` | 3 px `--destructive` | stays until closed | `requireInteraction: true` |

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:

| Placeholder | Value | Example |
| - | - | - |
| `{outcome}` | Outcome name; for `Yes`/`No` outcomes, `{market} · {outcome}` with the market title shortened to 40 characters | `Lakers`, `Fed decision December · Yes` |
| `{side}` | `Buy` or `Sell` | `Buy` |
| `{size}` | Shares of this fill or order | `25` |
| `{price}` | Limit or fill price | `42¢` |
| `{value}` | The value that met the alert: bid for above, ask for below, spread for spread alerts | `61¢` |
| `{threshold}` | The alert's `price` | `60¢` |
| `{game}` | Game title as on the board | `Lakers v Celtics` |
| `{league}` | League short name | `NBA` |
| `{score}` | Home–away score; an esports score as `Maps a–b · Bo3` (plus `Current map a–b` when non-zero, period `Map n`); otherwise the raw score | `88–84`, `Maps 0–1 · Bo3` |
| `{clock}` | Period label, a space, then the game clock; either part is left out when the feed has none. The period uses the sport's label, never the bare number the feed sends (below) | `Q3 4:12`, `P2 12:30`, `2H 67'`, `Set 2` |
| `{n}` | A count; the noun follows it in the singular for 1 | `3 orders`, `1 order` |
| `{reason}` | The fixed reject or state text from `frontend/src/lib/trading/reject-messages.ts`; `notify` keeps the same table | `price outside the band around the touch` |
| `{feed}` | `Polymarket market data` (`pm.market`), `Polymarket account updates` (`pm.user`), `Polymarket scores` (`pm.sports`) | `Polymarket market data` |
| `{left}` | Time until the Session Key expires | `23h`, `45m` |
| `{down}` | How long a feed was disconnected, or credentials unavailable, until it cleared: whole seconds under a minute, whole minutes under an hour, then whole hours | `45s`, `12m`, `3h` |
| `{venue}` | `Polymarket` (`Fake venue` on the fake venue) | `Polymarket` |
| `{note}` | The alert's note, shortened so the body stays within 120 characters | `take profit` |
| `{legs}` | A combo's leg outcomes joined with `, ` (Yes/No outcomes as in `{outcome}`), shortened with `…` so the body stays within 120 characters | `Lakers, Over 221.5, Fed decision December · Yes` |

**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:

| Sport | Label | Example for period `2` |
| - | - | - |
| Basketball, American football | `Q{n}` | `Q2` |
| Ice hockey | `P{n}` | `P2` |
| Football (soccer) | `{n}H` | `2H` |
| Baseball | `Inn {n}` | `Inn 2` |
| Tennis, volleyball | `Set {n}` | `Set 2` |
| Esports | `Map {n}` | `Map 2` |
| Any other sport | `P{n}` | `P2` |

A period that is not a bare number (`HT`, `OT`, `S4`) is shown as the feed sends it.

### The table

| Kind | Case | Title | Body | Example body |
| - | - | - | - | - |
| `alert` | price, above | `Price alert` | `{outcome} bid {value} · at or above {threshold}` | `Lakers bid 61¢ · at or above 60¢` |
| `alert` | price, below | `Price alert` | `{outcome} ask {value} · at or below {threshold}` | `Lakers ask 39¢ · at or below 40¢` |
| `alert` | spread, above | `Spread alert` | `{outcome} spread {value} · at or above {threshold}` | `Lakers spread 5¢ · at or above 4¢` |
| `alert` | spread, below | `Spread alert` | `{outcome} spread {value} · at or below {threshold}` | `Lakers spread 1¢ · at or below 1¢` |
| `alert` | score | `Score change` | `{score} · {clock} · {game}` | `88–84 · Q3 4:12 · Lakers v Celtics`, `2–1 · P2 12:30 · Rangers v Bruins` |
| `alert` | game start | `Game started` | `{game} · {league}` | `Lakers v Celtics · NBA` |
| `alert` | game end | `Game ended` | `Final {score} · {game}` | `Final 108–99 · Lakers v Celtics` |
| `alert` | any, with a note | as above | the body above, then ` · {note}` | `Lakers bid 61¢ · at or above 60¢ · take profit` |
| `fill` | matched, the order filled in full or no longer open | `Order filled` | `{outcome} · {side} {size} sh at {price}` | `Lakers · Buy 25 sh at 42¢` |
| `fill` | matched, the order still open and partly filled | `Partly filled · {filled}/{order size} sh` | as above | `Partly filled · 297/3,448 sh` |
| `fill` | matched before this start's fill mark (the first start's baseline, or a fill an earlier run saw) | none: not notified | | |
| `fill` | status became `failed` (critical) | `Fill failed` | `{outcome} · {side} {size} sh at {price} · not settled on chain` | `Lakers · Buy 25 sh at 42¢ · not settled on chain` |
| `order_rejected` | | `Order rejected` | `{outcome} · {side} {size} sh at {price} · {reason}` | `Lakers · Buy 25 sh at 42¢ · price outside the band around the touch` |
| `order_unknown` | | `Checking order` | `{outcome} · {side} {size} sh at {price} · no response from the venue` | `Lakers · Buy 25 sh at 42¢ · no response from the venue` |
| `order_resolved` | the order is open at the venue | `Order checked` | `{outcome} · {side} {size} sh at {price} · placed` | `Lakers · Buy 25 sh at 42¢ · placed` |
| `order_resolved` | the order matched | `Order checked` | `{outcome} · {side} {size} sh at {price} · filled` | `Lakers · Buy 25 sh at 42¢ · filled` |
| `order_resolved` | no order and no trade found | `Order checked` | `{outcome} · {side} {size} sh at {price} · not found at the venue` | `Lakers · Buy 25 sh at 42¢ · not found at the venue` |
| `combo_filled` | an accepted quote filled | `Combo filled` | `{side} {size} sh at {price} · {n} legs · {legs}` | `Buy 201.6 sh at 12.4¢ · 3 legs · Lakers, Over 221.5, Fed decision December · Yes` |
| `combo_failed` | refused after Accept (market maker declined, quote expired, risk or venue reject) | `Combo rejected` | `{side} {size} sh at {price} · {n} legs · {reason}` | `Buy 201.6 sh at 12.4¢ · 3 legs · market maker declined` |
| `combo_failed` | accepted, then execution failed | `Combo failed` | `{side} {size} sh at {price} · {n} legs · not settled on chain` | `Sell 201.6 sh at 30.2¢ · 3 legs · not settled on chain` |
| `game_start_orders` | | `Orders cancelled at start` | `{n} orders · {game}` | `3 orders · Lakers v Celtics` |
| `feed_down` | | `Feed disconnected` | `{feed} · no connection for 30s` | `Polymarket market data · no connection for 30s` |
| `feed_restored` | after a `feed_down` | `Feed reconnected` | `{feed} · disconnected for {down}` | `Polymarket market data · disconnected for 2m` |
| `foreign_activity` | | `Activity outside this app` | `Trading set to Safe` | `Trading set to Safe` |
| `trading_safe` | idle timeout | `Trading set to Safe` | `No trading action for {n} min` | `No trading action for 30 min` |
| `trading_safe` | the last login session expired | `Trading set to Safe` | `Login session expired` | `Login session expired` |
| `trading_safe` | any other reason except the user's own (Safe, Log out, Log out everywhere) | `Trading set to Safe` | `{reason}`, first letter capitalised | `Geoblocked`, `Session Key expired`, `Clock differs from the venue` |
| `session_key_expiring` | | `Session Key expiring` | `Expires in {left}` | `Expires in 23h` |
| `credentials` | | `Credentials unavailable` | `{venue} · {reason}` | `Polymarket · authentication refused` |
| `credentials_restored` | after a `credentials` | `Credentials available` | `{venue} · unavailable for {down}` | `Polymarket · unavailable for 4m` |
| `test` (`POST /notifications/test`) | | `Test notification` | `Sent from Settings` | `Sent from Settings` |
| Telegram summary | more than 20 in a minute | `{n} more notifications` | `{count} {title}` per title, most first | `8 Order filled · 4 Price alert` |
| Telegram startup | audit log verified | `Server started` | `Audit log verified · head {hash} at seq {seq}` | `Audit log verified · head 293abdeda8eceec84764e7b02c0549f87cc017bf971622a68649c9836af65d60 at seq 1284` |
| Telegram startup | audit log empty | `Server started` | `Audit log empty` | `Audit log empty` |
| Telegram startup | a row does not verify | `Server started` | `Audit log does not verify at seq {seq}` | `Audit log does not verify at seq 1285` |
| Telegram startup | the log could not be read | `Server started` | `Audit log not checked · read failed` | `Audit log not checked · read failed` |

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](../components.md#toast). One toast per notification whose
kind has `in_app` on.

### Layout

```
 ┌────────────────────────────────────────────┐
 │| Order filled                  [Open]  [x] │ 20 title line: body text, weight 600
 │| Lakers · Buy 25 sh at 42¢                 │ 18 body: body-sm, --muted-foreground, up to 2 lines
 └────────────────────────────────────────────┘
   360 wide on desktop; | = 3 px stripe for warning and critical only
```

* 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

| | Desktop | Mobile |
| - | - | - |
| Position | Bottom right, 16 px above the bottom dock | Bottom, above the stake bar and tab bar, 8 px side margins |
| Visible at once | 3, newest in front; older ones collapse behind and expand on hover | 1, the newest; the others wait behind it |
| Swipe | none | Swipe sideways to close |

* 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](../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:

| Notification has | Opens |
| - | - |
| `market_id` | `/markets/{market_id}`, with `outcome={token_id}` when set |
| `game_id` only | `/live`, scrolled to that game with its row cursor on it |
| `alert_id` only | `/alerts`, with that alert's row selected |
| none | nothing (`feed_down`, `feed_restored`, `foreign_activity`, `trading_safe`, `session_key_expiring`, `credentials`, `credentials_restored`, `test`: no `Open`) |

## 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:

| Severity | Wave | Pitch | Length |
| - | - | - | - |
| info | sine | 880 Hz | 90 ms |
| warning | triangle | 660 Hz | 150 ms |
| critical | triangle | 440 Hz | 240 ms |

* 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.

```
Order filled
Lakers · Buy 25 sh at 42¢
```

* 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](../../runbooks/telegram.md).

## Settings > Notifications

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

### Kind labels

| Kind | Label | Tooltip (desktop) |
| - | - | - |
| `alert` | Alerts | Your price, spread and game alerts |
| `fill` | Fills | Each match of your orders |
| `order_rejected` | Rejected orders | Orders refused by risk or the venue |
| `order_unknown` | Orders being checked | Placements with no response, while they are reconciled |
| `game_start_orders` | Orders cancelled at start | Open orders the venue cancels when a game starts |
| `feed_down` | Feed disconnected | A venue feed with no connection for 30s |
| `foreign_activity` | Activity outside this app | Trading on the account from elsewhere; trading goes to Safe |
| `trading_safe` | Trading set to Safe | Armed ended for a reason other than you |
| `session_key_expiring` | Session Key expiring | 24 h before the Session Key expires |
| `credentials` | Credentials unavailable | Venue credentials lost |
| `feed_restored` | Feed reconnected | A disconnected feed connected again |
| `credentials_restored` | Credentials available | Venue credentials work again |
| `order_resolved` | Orders checked | How a placement with no response ended |
| `combo_filled` | Combo fills | Each accepted combo quote that filled |
| `combo_failed` | Combos not filled | Accepted combo quotes that were rejected or failed |

`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.

| Kind | In-app | Desktop | Sound | Telegram |
| - | - | - | - | - |
| Alerts | on | off | on | on |
| Fills | on | off | on | on |
| Rejected orders | off | off | off | off |
| Orders being checked | on | off | off | off |
| Orders cancelled at start | on | off | off | on |
| Feed disconnected | on | off | off | on |
| Activity outside this app | on | off | off | on |
| Trading set to Safe | on | off | off | off |
| Session Key expiring | on | off | off | off |
| Credentials unavailable | on | off | off | on |
| Feed reconnected | on | off | off | off |
| Credentials available | on | off | off | off |
| Orders checked | on | off | off | off |
| Combo fills | on | off | on | on |
| Combos not filled | on | off | off | on |

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)

```
 Notifications                                                          24 section title
 Desktop    Blocked in this browser                                     20 status line (only when not usable)
 Telegram   Not configured · Telegram runbook [external-link]           20 status line (only when not configured)

 Kind                          In-app   Desktop   Sound   Telegram      32 column headers
 All                             [-]      [ ]      [-]      [-]         32 All row: one checkbox per column
 ─────────────────────────────────────────────────────────────────
 Trading                                                                group label, 12 px muted
 Alerts                          [*]      [ ]      [*]      [*]         36
 Fills                           [*]      [ ]      [*]      [*]         36
 Rejected orders                 [ ]      [ ]      [ ]      [ ]         36
 Orders being checked            [*]      [ ]      [ ]      [ ]         36
 Orders checked                  [*]      [ ]      [ ]      [ ]         36
 Orders cancelled at start       [*]      [ ]      [ ]      [*]         36
 Combo fills, Combos not filled  ...
 Connection
 Feed disconnected, Feed reconnected, Credentials unavailable, Credentials available,
 Activity outside this app
 Security
 Trading set to Safe, Session Key expiring, Logins refused for new devices,
 Cancel on shutdown failed
 ─────────────────────────────────────────────────────────────────
 Volume  [──────────o────────]  70                         [Send test]  32 one row
 Not sent · Telegram refused the message                                16 result line (reserved)
```

* 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 `PUT`s 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

| Channel | State | Treatment |
| - | - | - |
| Desktop | permission `default` (not asked) | Checkboxes enabled. No status line |
| Desktop | `granted` | Normal. No status line |
| Desktop | `denied` | Column disabled, tooltip `Desktop notifications blocked in this browser`. Status line `Blocked in this browser`. Saved values are kept, since another browser may allow them |
| Desktop | Not supported (no Notification API or service worker, e.g. iOS Safari outside a home-screen app) | Column disabled. Status line `Not supported in this browser` |
| Telegram | `telegram_configured: false` | Column disabled with its saved values, tooltip `Telegram not configured` on its header and boxes. Status line `Not configured · Telegram runbook [external-link]`, a link to [`docs/runbooks/telegram.md`](../../runbooks/telegram.md) in the repository, opened through `lib/url` in a new tab |
| Telegram | configured | Normal. No status line |
| Sound | `sound_volume` 0 | Column stays enabled; the volume row shows `Muted` after the slider |

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](../../runbooks/telegram.md#5-when-the-test-says-not-sent--telegram-refused-the-message).
  * 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.

```
 ┌──────────────────────────────────────┐
 │ Notifications                        │ 44 page title
 │ Telegram  Not configured · Runbook   │ 20 status line
 ├──────────────────────────────────────┤
 │               In-app       Sound     │ 32 channel headers on two staggered
 │                    Desktop  Telegram │    lines only in this drawing
 │ Alerts          [*]    [ ]   [*]  [*]│ 52 rows, 44 px hit areas
 │ Fills           [*]    [ ]   [*]  [*]│ 52
 │ Orders          [*]    [ ]   [ ]  [*]│ 52 labels wrap to two lines
 │ cancelled at start                   │
 ├──────────────────────────────────────┤
 │ Volume  [──────o──────]  70 Send test│ 44 one row, ghost Send test
 └──────────────────────────────────────┘
```

* 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`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.