Skip to content

v0.63.0 — Sharing Buttons

Two icons, two meanings, and a global Share button that says something true wherever it is standing. share-2 is the verb — "share this item" — and every sharing button in the app is one, so every one of them changes. The plain share icon becomes the noun, "here is who has access", and this release creates its first correct use by redefining the global top-bar button from an action into a status display.

Spec: 21-sharing-buttons — read § The icon split first, then § Why the global button is confusing today. Tasks are requirements.md, one commit each, in order.

Builds on v0.61.0: the read / control / edit model is what the character sharing UI here writes, and there was nothing coherent to write before it existed.

The icon vocabulary

  • [x] 2a. Every sharing action becomes share-2. Sweep every IconShare usage; all current ones are actions, so all of them change. The plain share icon is reserved for status displays and is used by exactly one thing after 4a — the global top-bar button. A short comment at the import site records the split, so the next person adding a sharing button picks correctly without reading the spec. (Done. Six verb sites moved to IconShare2: the sheet's share split-button and its two menu items in ui-components, PlayTopBar, CharacterNav, CharacterList, MapList and CharacterPageMenu. Each carries the vocabulary comment at its import. Two of the current usages turned out not to be actions, which is the one place this departs from the spec's letter to keep its principle. CampaignList's "Shared" badge and CampaignSelectorModal's "Shared With Me" heading are labels — nothing happens when you look at them — so they are already the noun and they keep it. Converting them would have violated principle 1 ("a button that DOES sharing and a panel that SHOWS sharing must not look alike") in the exact direction the principle forbids. The criterion that mattered — no BUTTON wears the plain icon — holds, and is what the guard asserts.* The guard is a ratcheting sweep (shareIconVocabulary.guard.test.ts) rather than a per-file assertion, modelled on the v0.39.0 z-index literal guard: it walks apps/web/src and packages/ui-components/src and fails on any plain IconShare outside a named three-entry allowlist. A new sharing control cannot quietly reach for the noun, because doing so lands you in a failure message naming the icon you wanted. One trap worth naming: the guard has to strip comments before scanning, because the explanatory comment 2a asks for at every import site NAMES both icons — so the first version of it fired on the very documentation it exists to require.)

Character sheet Share button

  • [x] 3a. Share button on the character sheet. /play and /character routes, same treatment on both, immediately left of the ⋯ button. Visible only to the character's owner or a user with edit — control does not confer the right to re-share. Opens the per-user access UI from v0.61.0 §6a: read / control / edit per campaign member, not a single "access" toggle. (Done. SheetShareButton + CharacterAccessModal, one component in both hosts — CharacterPage's headerActions and the Character Context's — so the two routes cannot drift. The modal is a thin host around v0.61.0's PermissionsPanel, unchanged: a second permissions editor is the two-editors-two-vocabularies problem v0.61.0 spent a release deleting. The server had to move first. control not conferring re-share is a client rule; edit conferring it was not a rule anywhere — PUT /api/characters/:id/permissions allowed the owner and the campaign GM and nobody else, so a button shown to an edit holder would have 403'd the moment they used it. Both the PUT's authz and the GET's canEdit now read resolveCharacterAccess, and the button is gated on that same canEdit rather than re-deriving "may I share" from the player list — one field, one answer, so the button and the Selects inside the modal cannot disagree.* The GM stays, and that is the one place this reads the spec rather than transcribing it. §3a's words are "the owner or a user with edit", and a GM's resolved level is control — so the literal reading silently takes permission editing away from the campaign owner, one release after v0.61.0 built the whole editor around their floor. Nothing in the spec asks for that removal, so the change is additive: edit gains the right, nobody loses it, and a plain player at control still does not have it.* Two placement details. On /play the button is not gated on impersonation — sharing is about the character, and gating it there would hide it on the plain sheet — so it renders beside <TokenContextActions>'s ⋯ whether or not a token is being impersonated. And on /characters/:id, which can carry no campaign at all, it falls back to the character's own first _campaigns entry, the same fallback characterPath uses; with neither, the modal says why the list is empty instead of showing an empty list. The Character Context keeps its inline PermissionsPanel at the bottom of the sheet as well. It is not redundant with the modal: only the inline one carries syncTokenId, which clears a legacy token-allow row on revoke, and the modal has no token.)
  • [x] 3b. The campaign-access note, branched by role. The modal states that sharing requires the recipient to have campaign access already. The campaign owner gets a hotlink to /campaigns/:id that opens the campaign Share modal on arrival — nav state read once and cleared, not a query string that reopens it forever. Everyone else gets a note that campaign access must be requested from the campaign owner. (Done. The note sits under the level editor, not above it: the reader who needs it is the one who has just failed to find somebody in the list, and that is where they are looking. The intent travels as React Router location state, keyed by one exported constant (utils/campaignShareIntent.ts) so the writer and the reader cannot drift. The requirement rules out a query string and the reason generalises: a URL survives a bookmark, a copy, a back button and a reload, and every one of those would reopen the modal. Location state survives none of them — except a reload, which the History API does persist, so it is still cleared explicitly with a replace. Reading it without clearing reaches the same "reopens on every visit" failure from the other direction. The hotlink opens the campaign page's existing Share button modal — principle 4, the invite flow is reused unchanged, not replaced. That modal is InviteUsersModal despite the button saying "Share", and both are gated on campaign ownership, which is the same branch this note takes. Requesting campaign access on the recipient's behalf is not offered anywhere, and a test pins its absence rather than trusting that nobody adds it: it needs a request/approve flow that does not exist.)

The global Share button becomes route-aware

  • [x] 4a. /campaigns/:id — campaign access status. Who has access to this campaign, revocable by the owner, plus an Invite User button opening the existing invite UI unchanged. Keeps the plain share icon — this is the status display 2a reserves it for. (Done. CampaignAccessModal + useCampaignMembers, opened by the global button through topNavShareSubject(ctx) — a pure function over the existing top-nav route context, which is 4c's "not a new path-matching helper" satisfied by construction and unit-testable without a router. The v0.58.0 affordance survives the redefinition rather than being traded for it. That release added this button because /play had no route to "invite someone", and the literal reading of 4a — a status display — removes it. /play carries a campaignId, so it gets the panel, and the panel carries Invite User opening the same InviteUsersModal the Connectivity popup does. One click further in, and better on arrival: it also says who is already there, which is usually what someone reaching for a share control wanted to know.* GET /api/campaigns/:id/members returned raw rows — user ids, no names — so it gains displayName per member, ownerDisplayName, and canRevoke. All three are additive; nothing on the client read this endpoint before. canRevoke comes from the server rather than being re-derived from useCampaigns()'s isOwner, for the same reason 3a's button reads canEdit: a second derivation of a rule the DELETE enforces is a button that 403s. Names resolve through a new utils/userDisplayNames.ts — one Clerk call for the whole roster. The character permission editor was doing await getUser(id) inside a loop, so a ten-player campaign made ten sequential round trips before a single row rendered; it now uses the same helper. The fallback chain ends at the raw user id deliberately, which is the opposite of resolveActorName's choice next door — there the name is an optional clause in a sentence and a raw id reads worse than the omission; here the name IS the row and a blank one is unusable. Two shape decisions. The owner is a locked row, not an omission: leaving them out makes the list read as "everyone here can be removed", which is only true because the one person who cannot is missing. And Invite User replaces the panel rather than stacking on it — two modals over a top-bar popover is three layers deep for one decision. The trap, and it is v0.62.0's from the other side: the DELETE keys off the membership row id, not the user id. CampaignMember.id is the row, CampaignMember.userId is the person, and they are both strings.)
  • [x] 4b. /characters/:id — character access status. With or without a /campaign/:id prefix. Shows current read / control / edit for campaign users, editable by anyone with edit and read-only for everyone else who can see it. (Done — and it is 3a's modal, not a second one. What differs between the two doors is who may open them, not what is inside: the sheet's share-2 button is an action and appears only for someone who may re-share; this is a status display and appears for anyone who can see the character. "Editable by anyone with edit, read-only for everyone else" then needs no second code path at all — PermissionsPanel already disables its Selects on the server's canEdit, so the read-only variant is the same component with a different answer from the same field. A test pins that the modal never gates its own content, because the moment it did, the two doors would start needing separate maintenance. On a campaign-scoped sheet both ids are in scope and the button picks the character. That is the whole complaint being fixed: "share this campaign" on a character sheet is what a reasonable person reads as "share this character". The campaign is not lost — its own page has the same button, pointed at itself.* The bare route needed the model to learn about it. /characters/:id is real — it stays put when the character belongs to no campaign — and topNavContextFromPath classified it as other with nothing in scope, which made "whether or not the route is prefixed" unsatisfiable. It now names the character, and keeps area: 'other' deliberately: area decides which CAMPAIGN-relative sections render, and claiming characterSheet would hand the breadcrumb a campaign label and the Map/Character section a home on a route that has no campaign. Sub-pages and actions — parties, factions, import, undelete, new — are excluded, with a test walking all five.)*
  • [x] 4c. Hidden everywhere else. No shareable subject → not rendered, not greyed out. Driven off the existing top-nav route context rather than a new path-matching helper. (Already true when 4a and 4b landed — topNavShareSubject returns null and the JSX is a conditional render, with no disabled prop anywhere near the control. So what this commit adds is the part that keeps it true: globalShareButton.matrix.test.ts. The matrix is exhaustive over TopNavArea, not a list of paths, and the difference matters. The function keys off the ids in scope rather than the area, which is the right shape — and it means a future area that starts carrying a campaignId would silently grow a button nobody decided to give it. A Record<TopNavArea, …> makes adding an area without deciding its answer a compile error rather than a surprise in the bar. §6's own six-route walk runs beside it, verbatim.* Two source ratchets go with it: the share block contains no disabled, and GlobalTopNavRight reads no location.pathname — the decision comes from ctx or it is not this feature. One route deliberately left alone: /chat/:campaignId is classified other with no campaign in scope, so the button is absent there. It is arguably a campaign route, but the fix is in topNavContextFromPath's classification, which the breadcrumb reads too — a change to make on its own merits rather than as a side effect of a share button.)