Every surface of Relay for Chrome, and every state it can be in.
The first-party Chrome extension shipped as Aware only: a toolbar count, desktop notifications for what needs you, and a read-only popup. This page is the design record of that build. Each state is drawn as HTML from the code that renders it, with the real strings, so it can be checked against the product line by line.
Everything on a mock is quoted from relay-app extension/src/ or from the platform's notification templates. Names, emails and Codes are fictional. Theme (top right) flips every mock between light and dark; the popup follows the OS theme the same way.
One job of four, and the smallest permission set that does it.
The PRD maps Mobile v3's four jobs onto the browser. The MVP is the first job only. “From a MVP, just get notifications moving. We can deal with interactions later.” (EC, 2026-09-22). No new server surface was needed and none was changed.
Badge with the unified count, desktop notifications for p0/p1, a popup with the latest ten.
Approve / deny, snooze, mark read, from the notification or the popup.
Right-click Send to Relay; save a tab to a Stream or the library.
Package an AI chat, paste a Code into a composer, Codes on any page.
Permissions, pinned by a test so they cannot creep:
The one host permission is what lets the extension's requests carry the app's own session cookie. That is the whole sign-in: the extension is whoever is signed in to app.relayctx.com in that Chrome profile. It stores no credential and never refreshes the session.
A number, a colour and a tooltip. Seven states.
Every minute an alarm asks the app for the count. The number is unread notifications plus open nudges, the same number the app's bell shows. Above 99 it reads 99+. The colour is amber when anything p0 is waiting, teal otherwise, and grey when the extension can't vouch for the number. The tooltip is the explanation.
No badge. Tooltip Relay
. Badge text is empty when unread + nudges is 0.
Teal, Relay
. 3 unread + 1 open nudge = 4. Nothing p0.
Amber, the app's p0 “must action” colour. Any p0 in the count turns the whole badge amber: a request to open your relay, a connection request, a relay expiring within 2h.
Anything over 99 reads 99+. Chrome fits about four characters in a badge; the cap keeps it legible. Amber if any of them is p0.
Badge cleared, tone muted. Also what an expired session and a 403 (a legal or consent gate the app has to show) look like. Every notification on screen is taken down.
Network failure. The last number is kept and turns grey: it may be stale, so it stops claiming urgency. Retries on the next tick.
A 5xx or maintenance page. Same treatment as offline: number kept, grey, nothing else changes.
#d97706 · any p0
normal · the light accent teal, both themes
muted · grey #71717a · signed out, offline, error
Badge colours are fixed in background.ts because the toolbar has no theme; they are the extension's values, not brand tokens. The badge text colour is not set by the extension, so Chrome picks it for contrast; white is drawn here. The icon is public/icons/icon-*.png: the Open Signal mark on a dark tile in every theme.
Plain text in the operating system's frame.
When the count moves, the extension fetches the latest 20 unread items and toasts the ones it has never surfaced before, and only p0 and p1; p2 and p3 stay on the badge. Oldest first, at most three a minute, then one summary. The OS draws the frame, so these are drawn as a neutral card: only the icon, the title and the message are Relay's. Titles clip at 80 characters, messages at 180, and markup in a title shows as literal text.
transfer.sentTitle and body from the payload. Times out like any notification. Click → /transfer/K7QX2M4TPR.
claim.requestRaised at high priority with requireInteraction: it stays on screen until clicked or dismissed. Click → /transfer/<Code>, where the request is decided.
The title and body are the platform's template, quoted as shipped. They still say “claim” in copy, which _vocab/_VOCAB.md retires; that is relay-platform's string to change, not the extension's.
When the payload carries no title, the event type picks one; when it carries no body, context title or Code, the message is Open Relay to see it.
claim.request* → Someone asked to open your relay
relay.received* → A relay arrived for you
contains connection → Connection request
anything else → Something in Relay needs you
Five new p0/p1 items in one tick: the three oldest toast, the rest fold into N more waiting in Relay
. The summary click opens /inbox. Collapsed here to titles.
Platform frames differ. macOS, Windows and ChromeOS each draw their own card, order and timeout; Do Not Disturb applies. The MVP has no notification buttons, so nothing here is affected by macOS folding buttons under Options; that matters only for P2 Act (§6).
360 pixels, read-only, the latest ten.
Opening the popup nudges a fresh tick so the badge agrees with what it shows. The list is the latest ten items, read or unread: the dot encodes priority, not read state (amber p0, teal p1, grey p2/p3). The header names the signed-in account, because the extension follows whichever account the app is on. Every string is set with textContent, since titles carry other people's words. No web fonts: the popup loads nothing remote, so it renders in the system UI face, as drawn.
- Claim request for your relayMaya Chen wants to claim ‘Q3 pricing review’4m
- Priya Nair sent you a relay‘Onboarding copy pass’ is ready for you to receive18m
- Relay expiring in 2h‘Vendor shortlist’ expires very soon1h
- Session getting heavy‘Pricing research’ — package it before context drops3h
- New Stream contributionDana Ruiz contributed to ‘Launch plan’1d
- You were mentionedDana Ruiz mentioned you in ‘Launch plan’2d
- Relay receivedTheo Park received ‘Board prep notes’Sep 14
Rows are buttons in the build; each opens the item where the same row lands in the app (§4). Title and body clip to one line each. Ages: now, 5m, 3h, 2d, then a date after a week. Clock skew reads now, never negative. The second row is shown hovered. A p0 row is announced as Needs you: <title>
.
Nothing needs you right now.
No items at all. One line, and the way into the app.
Sign in to Relay in this browser and notifications start here automatically — no separate sign-in.
No account in the header. The button opens the app at /, where the app's own sign-in runs; the extension picks the session up on its next tick.
You look offline. Relay will catch up when you reconnect.
The list request failed at the network. The account line stays empty (it is fetched only after the list succeeds); the button is unchanged.
Relay isn’t answering right now. It will retry in a minute.
Any status other than 200, 401 or 403. Same shape as offline.
Loading…
The static first paint from popup.html, held for the one request it takes. The main region is aria-live="polite", so whatever replaces it is announced.
Exactly where the same row lands in the app.
Notification rows carry no URL, so routing.ts mirrors the web app's notifAction() from two fields: cta_action and the Code. It never dead-ends: anything unrecognised opens /inbox, which holds every notification. A click reuses an open app tab (navigating it and bringing its window forward) or opens one, and takes the clicked notification down.
cta_action | With a Code | Without a Code | Typical event |
|---|---|---|---|
approve_claim · claim_relay · confirm_recipient | /transfer/<Code> | /requests | a request to open your relay; a relay sent to you; confirm the recipient |
view_relay, or no action but a Code present | /transfer/<Code> | /inbox | relay received, updated, expiring |
view_pulse | /stream | Stream contribution, mention, weight (the wire still says pulse) | |
review_connection · view_connection · review_org_connection | /network | connection requests, reminders, accepted | |
view_requests | /requests | the request digest | |
| anything else | /inbox | view_session, view_profile_links, view_org, claim_series, dismiss … | |
| “N more waiting in Relay” summary | /inbox | the flood fold (§2) | |
| Open Relay / Sign in to Relay (popup) | / | the popup's one button | |
All destinations are on https://app.relayctx.com; the popup refuses to open anything outside it. Codes are URL-encoded and no length is assumed (legacy six-character Codes and ten-character Codes route the same). Worth a look later: claim_series and view_session fall through to /inbox, which is safe but less direct than the app could be.
Four rules you only notice when they're missing.
The first look badges; it never floods.
The first check after sign-in, after signing back in following a gap, or after an account switch marks everything present as seen and toasts nothing. The badge still shows the full count.
If that first list fails, it stays a first run, so the retry can't flood either.
The app's bell already told you.
When an app.relayctx.com tab is the focused tab of the focused window, no notifications are raised. Those items still count as seen, so they don't pop up later when you leave the tab.
A notification leaves when its item does.
Read or act on an item in the app, or anywhere else, and its desktop notification is taken down on the next tick. Signing out takes every one down; switching accounts takes the old account's down.
No separate sign-in.
The extension is whoever is signed in to the app in this Chrome profile. Switch accounts in the app and it switches; sign out and it signs out. It never refreshes the session, so an expired one reads as signed out until the app is opened again.
Also true, and visible: the same item never notifies twice (E4); when nothing moved, the list isn't fetched at all, one small request a minute (E6); p2 and p3 only ever reach the badge (E3); open nudges count, matching the bell (E20).
Twenty-two cases, each a named test.
From PRD §5a; each E-row is a test in extension/src/tick.test.ts. The right-hand column is what a person sees, which is the part this page records.
| # | Case | What the person sees |
|---|---|---|
| E1 | First check after sign-in | Badge with the backlog; no notifications |
| E2 | New p0 / p1 | One notification; click opens the item |
| E3 | New p2 / p3 | Badge only |
| E4 | Same item across checks | Never notified twice |
| E5 | Flood | Three notifications, oldest first, then N more waiting in Relay → Inbox |
| E6 | Nothing moved | Nothing changes |
| E7 | Signed out / session expired | Badge cleared and grey, notifications taken down, tooltip says how to fix it |
| E8 | 403 legal or consent gate | Same as signed out; the app shows the gate |
| E9 | Signed back in after a gap | What arrived meanwhile is badged, not toasted |
| E10 | Account switched in the app | Old account's notifications gone; new account starts clean |
| E11 | Two accounts with identical counts | The switch is still noticed |
| E12 | Account flips between two requests | That list is dropped; nothing from the wrong account appears |
| E13 | Offline | Last number kept, grey; tooltip says offline |
| E14 | 5xx | Nothing changes; tooltip says it's having trouble |
| E15 | List fails during a first run | Still no flood on the retry |
| E16 | List fails later | Caught up on the next tick |
| E17 | Malformed response | Treated as zero; nothing breaks |
| E18 | Item read or actioned in the app | Its notification is taken down |
| E19 | App is the focused tab | No notifications; they don't replay later |
| E20 | Open nudges | Counted in the badge, same as the bell |
| E21 | Missing title or body; long text | Fallback title (§2); clipped at 80 / 180 |
| E22 | Markup in a title | Shown as literal text, in the notification and the popup |
Not yet verified at build time: the live session cookie against production (PRD D6). The first unpacked load checks it: sign in to the app, then open the popup.
Not built. Sketches, so the MVP's shapes leave room for them.
Nothing in this section exists in the extension. Screenshots and store art must not show any of it (the marketing kit's rule). Each sketch reuses a shape the MVP already has, which is the point of drawing it now.
Decide from where you're told.
Approve or deny a request, snooze, mark read, from the notification or the popup, on endpoints that already exist (claim-approval/{id}/approve|deny, notifications/read, notifications/snooze). Target: notification to decision in under 15 seconds. The popup is the dependable surface, because on macOS Chrome can fold notification buttons under Options.
Two buttons, the OS limit. On macOS these may sit behind Options, so the notification can't be the only place to decide. The copy is a proposal and drops “claim”.
- Maya Chen asked to open ‘Q3 pricing review’Code K7QX2M4TPRApproveDenySnooze4m
- Priya Nair sent you a relay‘Onboarding copy pass’ is ready18m
Actions inline on p0 rows only; everything else keeps the MVP row. Org-membership approvals route separately and would need adding.
Everything Relay does around AI chats that have no MCP.
Both need content scripts, so both are gated on PRD D4: optional host permissions requested per site at first use, never at install. Carry is the strategic one: an extension reaches every AI chat in the browser, regardless of vendor.
Selection, page or link into Relay; the current tab into a Stream or the library (the second-brain Pipe).
Package this chat seals the conversation as a relay; Paste Code relays one in and loads it into the composer.
A Code found on any page (mail, chat, docs) gets a hover card and a one-click receive; the omnibox takes relay <Code>.
Also ahead, outside the four jobs: P1b Web Push (RCTX-1431) replaces the one-minute poll and brings quiet hours and per-event push preferences to the extension (PRD D1); a cross-profile badge and an account switcher; the extension as a second device for sign-in approval. Each needs its own ruling.