v0.62.0 — Notifications & Messages
One API for everything the app tells you, and a durable table underneath the part worth keeping. Not all Notifications are Messages, but every Message creates a Notification — a Notification is transient UI ("Map updated", a roll request), a Message is a durable record of something that happened to you (access granted, an invitation) that you can find again tomorrow.
Spec: 20-notifications-and-messages —
read § The central distinction
first, then
§ Build, extend, or adopt
for why notify()'s signature survives and only its renderer is replaced. Tasks are
requirements.md, one commit
each, in order.
Builds directly on v0.61.0: the Accept button writes the accepted column added there,
which is also why "new since last visit" needs no per-device timestamp.
The durable layer
- [x] 2a. A
messagestable.id,userId(recipient),kind,title,body,payload(JSON — the deep-link target and any action metadata),readAt,createdAt. Both editions —dbSchema.ts, the pg re-export,db:generate:pg, and amigrations-pg/file, which is what prod runs at boot.kindis an enum the code owns, not free text: the list UI renders per kind. (Done.migrations-pg/0018, both dialects indbSchema.ts, thedbSchema.pg.tsre-export, and — following v0.61.0's lesson, wherecharacter_permissionswas indbSchema.tsbut in none ofdb.ts's DDL and so was simply missing on sqlite — both ofdb.ts's sqlite copies,sqliteCreateTables()and thecreateTable()twin insqliteApplyColumnMigrations(). A test pins that it is two, not one. Three shape decisions worth naming.userIdis the recipient, not the actor: a Message is addressed to exactly one user and there is no broadcast row, because read state is per person and two people told the same thing need two of them. Unread is the absence ofreadAt, not a boolean beside it — one column cannot disagree with itself, and the list wants the timestamp regardless. Andpayloadis a blob rather than columns: every field in it is presentation, read only by the renderer for itskind, and none of it is ever queried.kindis a closed enum in shared (MESSAGE_KINDS,packages/shared/src/types/message.ts) carrying exactly the six interpersonal events 2c wires — character and campaign access granted / revoked, and the two invitations. The test asserts that set exactly, in both directions: a kind for a transient event ("map updated") showing up there is the regression, since the list renders per kind and an unrecognised one is a blank row. No index, deliberately. No table in this schema declares one, andchat_messagesis the same shape of problem — unbounded growth, always read by one column — so indexing this table alone would be an inconsistency rather than a policy. The migration says so in a comment, which is where the next person will look. One trap, cheap here and expensive later:db.ts's DDL lives inside a template literal, so a backtick in an explanatory comment ends the string. The comment there says so.) - [x] 2b. Message API.
GET /api/messagesreturns the caller's own, newest first, unread first — never another user's, under any parameter.POST /api/messages/:id/readplus a bulkreadfor the dwell rule in 5c. Server-sidecreateMessage(...)is the only way to write one and it emits the socket notification in the same call; principle 1 is enforced here or not at all. Test:createMessagewith a stubbed socket asserts both the row and the emit. (Done.utils/messages.tsandroutes/messages.ts, mounted under/apibehindrequireAuthWithImpersonation. Principle 1 is made structural three ways rather than asserted once. The emitter is a required parameter, so a call site cannot omit it and still typecheck. It is checked before the insert, so even an untyped caller cannot leave a row behind by passing nothing. And the emit is wrapped: if it throws, the row is deleted and the error rethrown, because a row and its notification are one act — a Message the recipient was never told about is worse than a failed write, since it is silent. There is also a source test that no module other thanutils/messages.tscontainsinsert(schema.messages); the behavioural tests provecreateMessagealways emits, and that one proves nothing else inserts.kindis validated againstMESSAGE_KINDSbefore the write, for the same reason the enum is closed: the list renders per kind, so an unrecognised one is a blank row, and rejecting it at the writer is what keeps that unreachable. Two API shapes worth naming. The list takes no user parameter at all — not a query string, not a body field — which is how "never another user's, under any parameter" is satisfied: there is no parameter to get wrong, and the recipient goes into theWHERErather than being checked once and then trusted. And the bulk read takes explicit ids, not a mark-everything flag: 5c's dwell rule marks what the page rendered, and a Message that arrives during those five seconds was never on screen. Marking read is idempotent and keeps the originalreadAt— re-reading is not an event, and a fresh timestamp would reorder the list under the user. There is no write endpoint: a client that could POST a Message could file one for somebody else, and 2c's server-side events are the only thing that needs to.) - [x] 2c. Wire the durable events. Campaign access granted / revoked, character access
granted / revoked, invitations — the interpersonal events, not the ambient ones.
Roll requests, map updates, "Copied" and save confirmations stay transient and call
notify()only. (Done, throughutils/messageEvents.tsso that each of the seven call sites is one call and the wording lives in one place. Wired: the character permission editor (PUT /characters/:id/permissions, both directions), direct character invites, character member removal, granting a character access request, ownership transfer, campaignadd-member, and campaign member removal. The rule that made the two "granted" kinds non-arbitrary:acceptedpicks the kind. A grant landing unaccepted is an*-invite— it carries the Accept action and the list shows it as "invited to access" — and one landing accepted is*-access-granted, with no button, because there is nothing to accept. Reading v0.61.0's column rather than deciding per call site is what keeps the Message and the list row from telling different stories. The granted access-request is where that bit: hard-codingaccepted: truewould read better and be a lie, since v0.61.0 lands that grant unaccepted like any other. Whether a request you made yourself should auto-accept is a v0.61.0 question, and mislabelling the Message is not how to answer it. Two call sites needed the state read before the write. The permission editor files only whenpriorGrantchanges from absent to present or the reverse — re-levelling an existing grant is an edit, not an event, and a PUT removing a grant that was never there is not news. And ownership transfer is the trap: it callsdeleteCharacterPermissionon the new owner, which looks like a revocation and is the exact opposite of one — their explicit row is redundant precisely because they now hold more than it granted. Revocations carry nohref. The deep link would 403, and offering a door that does not open is worse than offering none. Everything here is best-effort, and the asymmetry withcreateMessageis deliberate: a Message must never exist without its notification, but no Message at all is survivable — the grant has already been written by the time these run, and a notification failure must not turn a successful share into a 500. A missing socket is logged rather than swallowed. Three paths deliberately file nothing, each with a test pinning the absence. The campaign owner's association grant (v0.61.0 4a) — nobody invited the GM, and one Message per party member is noise, which is the same reasoning that lands itaccepted: true. Soft-deleting a character, which drops every permission row: it is undoable and it is not an interpersonal event. And every accept path, where the person being told is the person who just clicked. Email invites to an address with no account are also out — there is nouserIdto address a Message to.)
The notification system
- [x] 3a. Three flavours behind the existing API.
notify()gainsflavor: 'toast' | 'persistent' | 'takeover', defaulting from criticality so none of the ~25 existing call sites change.message()is the durable sibling: same options, persists via 2b, defaults topersistent. Intent / criticality / domain keep their current meanings exactly. (Done. The default is derived, not tabulated: a criticality whoseautoCloseis alreadyfalseWAS a stay-until-dismissed notification, so naming itpersistentrenames nothing and everything else is atoast. That is what makes principle 3 true by construction rather than by inspection — andtakeoveris unreachable from the derivation, because none of the existing calls asked to block the app. The flavour has the last word on how a notification ends, since that is what a flavour IS:persistentandtakeovernever auto-close whatever the criticality's timer said, and an explicittoastat a criticality that never closes falls back toloud's duration. Everything else about the criticality — border, icon, pulse, colours — is untouched; flavour says what shape, criticality says how loud. One visible behaviour change, and it is principle 5 rather than a side effect: every flavour now has a close button, which overrideslow— the one criticality that could leave a user with nothing to click. The criticality spec still saysfalse; the flavour resolution wins, and the test pins both halves so the override is legible rather than looking like a stale constant.message()displays a durable Message; it does not write one. That is a deliberate resolution of a contradiction in the spec, not a gap: §3a saysmessage()"persists via 2b", while §2b says server-sidecreateMessageis the only way to write a Message — and by construction there is no POST endpoint. Both cannot hold for a client-side writer, and 2b is the one carrying principle 1. So the durable writer is the server's (2b), the events are wired there (2c), and this is the client half: given a Message off themessage:newsocket or replayed from/notifications, show it — persistent by default, keyed on the Message's own id so the same one arriving twice replaces itself instead of stacking, with its own actions rendered andacceptas the primary. Presentation is per kind, because an invitation is asking you something and a revocation is telling you something. Two mechanics worth knowing.notify()now mints the notification id itself rather than taking the onenotifications.show()returns — an action button has to be able to dismiss the notification it lives inside, and the buttons are built before show() returns. And the buttons live incomponents/Notifications/NotifyActions.tsxrather than innotify.ts: Mantine'sButtonis polymorphic andcreateElementin a plain.tsfile cannot satisfy its overloads, which is also where 3b's renderer will go.) - [x] 3b. Our own renderer. Replaces
@mantine/notificationsas the presentation layer. A close button on every flavour, always; swipe-to-dismiss on toast and persistent, pointer and touch, dragging back the way it entered; optional actions rendered inside the notification. Respects the existing z-index constants andnotificationsZIndex.test.ts. The controller is tested as pure logic per the v0.35.0 async-feedback pattern —apps/webhas no jsdom. (Done, and the dependency is gone — nothing else needed it. Four pieces, split so that the parts worth testing are testable in node:notificationStore.ts(what is on screen and for how long — framework-free, becausenotify()is called from socket handlers and async utilities and must not need a hook),swipeDismiss.ts(the drag maths, pure),NotificationCard.tsx(a thin pointer-event shell over both) andNotificationRenderer.tsx(useSyncExternalStore, mounted where<Notifications />was). The swipe direction is derived from the placement, not fixed. "Back the way it came from" means a bottom-centre toast — which is what this app uses — goes back down, while a cornered one goes back sideways. Movement against that direction is clamped to zero rather than rubber-banded, the off-axis is ignored so a diagonal drag still reads as the swipe intended, and a drag never starts from a button, so an action press that wanders by a pixel is still a press (the same 5px slop v0.58.0 settled on). Two behaviours the old renderer did not have, both of which turned out to be bug fixes rather than features. The auto-close timer pauses on hover and on the start of a drag — a notification that closes out from under a cursor aiming at its button is the reason in-notification actions were awkward before. And re-showing an existing id replaces in place, keeping its position, which is what makesmessage()'s "key on the Message id" work when the same Message arrives over the socket and again from a refetch. One takeover at a time. Two stacked would be two questions with one set of buttons visible and the second answered blind; the rest wait, and nothing is dropped. A takeover does not swipe — it is asking something, and sliding it away would answer nothing — but it still has its close button, because principle 5 has no exceptions. The sweep this forced is the interesting part: six call sites were bypassingnotify()entirely and callingnotifications.show()with hand-builtStack/Group/Buttontrees — the four socket listeners plususeAsyncActionanduseAsyncFieldSave. All six are nownotify()calls withactions, which deleted roughly a hundred lines ofcreateElementand, along the way, several real defects: every "Dismiss" button that existed only because there was no guaranteed close button, and thenotifications.hide()at the TOP of two async handlers, which made the toast vanish before the work it announced had been sent. It also removed eleven pre-existing TypeScript errors, because Mantine's polymorphicButtonnever type-checked throughcreateElement; that problem now exists once, inNotifyActions.tsx, and is solved by JSX.notificationsZIndex.test.tsmoved with the code: the constant is now applied to two layers (the stack and the takeover overlay) and the test counts both — a takeover behind the chat panel is the same v0.20.0 bug that test was written for. A source test asserts nothing imports@mantine/notificationsany more and that onlynotify.tstalks to the store, which is what keeps the vocabulary mandatory.) - [x] 3c.
requestRoll()becomes the takeover flavour. Behaviour identical — a re-home, not a redesign, on a load-bearing play path. Leaves 24-chat-log-compactness's chat-vs-modal question exactly as open as it was. (Done. The prompt's content moved verbatim out of the<Modal>wrapper intoRollRequestPrompt.tsx— same rows, same Roll / Deny, same Roll All / Deny All, same GM badge, samerollOptions.labelline — andRollRequestModalbecame a driver that shows it as a takeover. It still rendersnulland still mounts in the same place inWebGLMap, so nothing above it changed. One stable notification id, deliberately: ten tokens asked for saves is ONE question with ten rows, which is what this prompt has always rendered. A per-request id would queue ten takeovers behind each other. It dismisses when the queue empties and when the map unmounts — a takeover left up with nothing behind it blocks the whole app. The one deliberate difference, and it is principle 5 rather than a redesign: the v0.29.0 modal setwithCloseButton,closeOnClickOutsideandcloseOnEscapeall false, which left its ownonClose={handleDenyAll}unreachable — the only way out was to answer. Every flavour now has a close button, so that handler is finally wired up, and closing denies rather than drops: the requester gets an answer instead of waiting forever on a prompt somebody closed. The alternative — a takeover with no close button — is the one exception principle 5 does not have. Thenotify()API grewonClosefor this, which is what lets closing a takeover MEAN something rather than just hide it.)
The inline sweep
- [x] 4a. Produce the list, then remove. Apply the rule from
§ The inline-notification sweep:
announcing a remote event → the notification system; describing the current view →
stays. Form validation, empty states and the v0.38.0 async-field save indicators
stay. The ad-hoc "you were invited" strips in the list pages go, replaced by 5d. The
list lands in the commit message so the judgement is reviewable.
(Done, and the list is short, which is the finding. The rule is a good rule: it
clears essentially everything on the first pass, and what survives it is one panel.
The full reviewed list is in the commit message; the decisions that are easy to undo
by accident have a test.
Removed:
PendingCharacterInvitationsPanelfrom the characters list page. It announced an event that happened elsewhere, and its own doc comment said it was there "so users notice incoming invites even if they don't open the bell menu" — which is precisely the gap the bell (5a) and the Message table (2c) close properly. It still renders on/notificationsuntil 5c replaces that page, so nothing became unreachable in the meantime. Kept, with the reason recorded next to each.InvitedToAccesson both list pages, because that is the whole distinction the rule turns on: an unaccepted grant is a ROW OF THE LIST you are looking at, not news about somewhere else — and it is what 5d builds on.PendingAccessRequestsPanel, which by the letter should go and does not:MESSAGE_KINDScovers grants, revocations and invitations but not requests, so removing it would delete the only surface that survives the owner being offline. It is named as a follow-up rather than swept. The mode banners (delete, impersonate, targeting, heal/harm, wall-drawing) and the AI status banner, which describe the state of the thing being looked at.InviteLanding, where "You now have access to X" is the result of what the user just did on that page rather than an announcement. And form validation, empty states and the v0.38.0 async-field indicators, per principle 4. Worth noting what 3b already swept without meaning to: the six call sites that bypassednotify()fornotifications.show()were the real "ad-hoc notification" problem in this codebase, and fixing the renderer is what surfaced them. By the time 4a ran, the inline layer had almost nothing left in it.)
The notifications UI
- [x] 5a. The bell returns to the global top nav. Icon button top right, immediately
left of the Clerk profile icon, with a badge showing the unread Message count. On a
new Message the bell vibrates three times — animation only, no sound — and the
badge colour pulses brighter for twice the duration of the ringing before settling.
Respects
prefers-reduced-motion. (Done, along with the live pipeline behind it:useMessages(the list, the count and optimistic mark-read) anduseMessageArrivals(themessage:newsubscription). Push-driven, not polled — principle 1 says every Message emits its notification, so the emit IS the refresh signal and a poll would only cover the case where that guarantee had already failed. It does refetch on every (re)connect, because a Message filed while the tab was disconnected had nobody in the room to emit to. The choreography lives inbellRing.tsas arithmetic rather than in the stylesheet as numbers:BADGE_PULSE_MS = BELL_RING_MS * 2is the requirement expressed as a relationship, the CSS reads every duration through a custom property, and a test asserts the stylesheet hard-codes no millisecond of its own — otherwise "twice as long" quietly stops being true the first time anyone changes the vibration count. The badge pulses brighter, not bigger: brightness is the one channel that cannot reflow the nav beside it. Two things this change had to settle. The profile menu's badge came off. It only existed because v0.48.0 retired the bell and the count needed somewhere to live; two badges an inch apart counting overlapping sets — pending invitations there, unread Messages here — is worse than either alone. The Notifications menu entry stays, since a second door to the page costs nothing. And this is a different bell fromNotificationsBellMenu, which is v0.42.0's pending-character-invitations dropdown and still serves SideNav; the new one counts the durable layer. Keeping them distinct is what lets 5c retire the old surface without touching the nav. The bell is also where principle 1's client half lands: an arriving Message rings it AND shows itself throughmessage()(3a), persistent and keyed on its own id.) - [x] 5b. Hover shows a brief list. Up to 3 Messages, one line each, plus an
"All notifications" button to
/notifications. (Done. A HoverCard, not a Tooltip: the panel has a button in it, and a tooltip that vanishes the moment you move toward what it is showing you is not a surface anyone can use. ThecloseDelayis what makes the diagonal trip from the bell to the button survivable. The selection takes the head of the list rather than re-sorting.GET /api/messagesalready returns unread-first then newest-first; sorting again here would be a second opinion about the ordering, and two opinions is how they diverge — the test asserts there is no.sortin the file. Unread is drawn at full strength and read is dimmed, the same way 5c's list will draw them, so the two surfaces cannot disagree about what "unread" looks like. Nothing here marks anything read, and both the list and the bell have a test saying so. §5c gives the dwell rule to the full page, where the user chose to look; brushing past the bell on the way to the avatar must not clear the badge. The "All notifications" button is always there, including when there is nothing to show — an empty popover with no way out is a dead end — and it names the overflow when there is one.) - [x] 5c.
/notificationsbecomes the full Message list. Replaces today's pending-character-invitations-only page. Sorted by timestamp, unread first with an active colour scheme, then a separator, then read ones subdued but still readable. Each row is a brief summary — up to two lines at 40rem — expandable for the rest. Clicking a Message marks it read; dwelling on the full page for 5 seconds marks every Message in that view read, and the brief hover list does not. Any actions on the Message are available here as well as in the original toast. (Done, andPendingCharacterInvitationsPanelis deleted — 4a took it off the characters list, this took it off the page, and nothing was left. Its accept flow survives in SideNav'sNotificationsBellMenu, which reads the same hook directly. The page splits the server's order rather than re-sorting it, the same one-opinion rule 5b follows. The separator only draws when there is something on both sides of it. Read rows are subdued by background and border, not by fading the text — §5c says "subdued but still readable", and a list you have already read is still a list you have to be able to read. The clamp is two lines, not a character count: a count that looks right at one width is wrong at every other, and the row is fluid up to the 40rem the requirement measures against. The dwell rule is a small pure module rather than an inlinesetTimeout, because the awkward part is not the five seconds — it is deciding when to restart. Restarting on every render means the timer never fires; never restarting means a Message that arrived four seconds after the user stopped looking gets marked read. It restarts only when the set of unread ids actually changed, and it targets only the unread, since re-stamping an already-read row is a request that can only be noise. One thing 2c had left underspecified and this had to fix: Accept has two different endpoints behind it. An invite is acharacter_invitesrow redeemed by its own id; a bare grant ischaracter_permissions.acceptedsettled by the character's id. The Message payload carried only the character, so Accept on an invite would have 404'd —payload.inviteIdis added (2c's direct-invite site fills it) andacceptEndpointForis the one pure function that chooses. It also answers for campaigns, and returns null rather than inventing a URL when a Message has no subject to accept. Actions on a row go through the sameuseMessageActionshandler the toast uses. A second copy of the accept logic is exactly how two surfaces come to disagree, which is what 5d needs not to happen.) - [x] 5d. "Invited to access" rows on the character and campaign lists. Unaccepted
grants (v0.61.0's
accepted) render in their own row — one row, scrollable, with a "show all" when it overflows — labelled invited to access, with an explicit Accept button rather than a card that loads the thing. The grant's notification carries the same Accept action; accepting in either place settles both, with no reload. (Done. v0.61.0 (5b) had already built the explicit-Accept half — this is the row geometry and the "settles both" half. v0.61.0 stacked the invitations vertically, which is fine for one and pushes the list you actually came for off the screen at five. It is one horizontal strip now, scrolling sideways, so the section costs the same height whether there is one invitation or twenty; "show all" unfolds it into a wrapped set, and only appears above three, because a "show all" that changes nothing is a button that lies. The cards carry a min AND a max width: without the floor they collapse to the shortest name, and without the ceiling one long name fills the strip and hides that there are others behind it — which is the one thing a scrolling row exists to show. The label moved from a badge on every card to the section heading, since repeating the same three words beside each name was noise once they sat side by side. "Accepting in either place settles both" turned out not to be free, and calling the same endpoint was never the hard part.useAcceptAccessinvalidated only the list it appeared in, so accepting from the LIST left the Message sitting on/notificationswith a live Accept button and the bell still counting it. Both directions invalidate both caches now. In the other direction the notification's Accept had to exist at all: the bell now passesuseMessageActionsintomessage(), so the toast, the/notificationsrow and the list strip are three surfaces over one handler — which is what stops them disagreeing rather than what makes them agree.)