v0.99.0 — Chat scope
Shipped 2026-09-20 — merged to main as 552591d2, tagged v0.99.0.
Built on version/0.99.0, seventeen commits. The ninth of the
releases the near-term roadmap sequences from the
August 2026 heuristic evaluation; the roadmap page is
chat-scope. The help is
Chat, Voice, and the Combat Log → Channels.
Chat had one log per campaign. It has channels now: Campaign (that log), Map, private messages, Personal, the owner's Secret channel with per-message reveal, and custom channels — behind an iMessage-style conversation list in the sidebar, the phone's Chat tab and a full page. Membership is a grant (v0.95.0), a channel is a socket room (v0.90.0's Q.14), and every sender posts into the channel the user is looking at. Decisions CH-D1 (a PM is one conversation across campaigns) and CH-D2 (a reveal cannot be undone) at their recommendations.
The model
- Virtual built-ins (C.1) — the one deviation from the page. A Campaign
channel's name, membership and lifetime are its campaign's, so a row for
it would carry nothing and could be forgotten by a code path that makes a
campaign. Its id names the parent instead —
campaign:<id>,map:<id>,secret:<campaignId>,personal:<userId>— and nothing is stored; thechannelstable (pg 0037, sqlite boot DDL) holds only PMs (pm:+ the members sorted, one conversation per set of people) and custom channels.chat_messagesgainschannel_id(null = the Campaign channel),revealed_fromandrevealed_to; the migration points every existing row atcampaign:<its campaign>.shared/types/channel.tsis the one reader of an id's shape. - Membership through grants (C.2) —
channelis a grant entity type with a cascade loader by kind: Campaign → its campaign, Map → its map (so readers read, players post —playis the posting verb; a Viewer reads and cannot post), Secret → no parent and the campaign's owner as its Owner, Personal → its user, a PM → no parent, its members equal, a custom channel → its creator as Owner and its grants. No parent is what keeps a PM private from the GM. - Routes (C.3) —
GET /api/channels?campaignId=&mapId=synthesises the built-ins the actor may read and joins the stored ones they hold a grant on, each withcanWrite, a preview and an unread count;POSTopens a PM by username (idempotent) or makes a custom channel;GET /:id/messagesis the log;POST /:id/messages/:messageId/reveal;DELETE /:idfor a custom channel.POST /api/chat-messagestakeschannelIdand needsplayon it; every read, patch and delete checks the channel after the campaign. - Rooms (C.4) —
channel:<id>, joined throughmayJoinChannel(the channel'sread, through the resolver);chatMessage:*anddice:rollgo to the channel's room and nowhere else;joinCampaignalso joins the Campaign channel's.
The UI
- The conversation list (C.5) —
chatChannelStoreholds the current channel per campaign (the Campaign channel by default, named with no fetch),useChannelsthe list, joined to every room it names and kept live;useChatMessages(campaignId, channelId?)is per channel, and every send posts into the channel current at call time. AChannelBarabove every log opens the list in the same panel — This campaign / This map / Direct messages / Personal, previews (chatPreviewText: a dice roll reduced to its total), unread badges, Mute — with New conversation and New channel; the phone's View sheet lists the channels above the four views. - The route (C.6) —
/chat/:campaignId[/:channelId]is the full page (the list as a column beside the log on a wide screen; the URL follows the channel),/play/…?channel=<id>opens the panel on one, and the rail lists Chat under a campaign. /w(C.7) —/w alice hello,/w @alice @bob hello,/w alice,bob hello; usernames only; the PM opened and made current, the text sent into it.UsernamePickerfor the modals, through the permissions search.- Custom channels (C.8) — New channel, and Members… hosting the one
PermissionsEditoron thechannelentity. - The Secret channel (C.9) — Reveal on every row (the bubble and the action-doc frame), Revealed on the original, Revealed from the Secret channel on the copy; secret dice; the other channels' cached logs kept live so the copy is there when the owner switches back.
The rest of the app
- Actions and rolls know their channel (C.10) by construction, and a
requestRollcarries the channel it was asked in so the answer goes there; a guard holds that no sender names a channel of its own but that answer and/w. - Notifications (C.11) — a
chat-messageMessage for every other member of a PM or a custom channel, with Open; the bell neither toasts nor rings one for the channel being viewed or a muted one, and the log marks the viewed one read. A channel's unread count is its unread Messages. - Combat log and transcript per channel (C.12) — type filters over the channel's log, free.
Guards and verification
Fifteen route-harness tests for the API (the third user refused a PM's log,
row, post and delete — and so is the GM), a real three-socket test that a
PM's pushes reach its members and nobody else, eight loader tests on the
in-memory cascade, and on the client the store, the grouping, /w, the
senders guard and the contract tests. Verified live in Chrome: the list
with four groups and real previews, a message into the map's channel and
not the campaign's, /w to one and to two people, a custom channel with
its Members editor, a roll in Secret revealed into the campaign log, the
full page's URL following the channel, ?channel= on /play.
Decisions
CH-D1 and CH-D2 at their recommendations. Made here: the built-ins are
virtual (above); the full page is /chat/:id/:channelId, not the roadmap's
/campaigns/:id/chat/…, because /chat/:id has been the campaign chat's
route since v0.42.0 with slug resolution and href helpers; posting is the
play verb, so a campaign Viewer reads the chat and cannot post — what
"viewer" has meant since v0.96.0; and a PM has no Owner and no parent, so
nobody manages it and nobody above it sees in.