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 everyIconShareusage; all current ones are actions, so all of them change. The plainshareicon 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 toIconShare2: the sheet's share split-button and its two menu items inui-components,PlayTopBar,CharacterNav,CharacterList,MapListandCharacterPageMenu. 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 andCampaignSelectorModal'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 walksapps/web/srcandpackages/ui-components/srcand fails on any plainIconShareoutside 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.
/playand/characterroutes, same treatment on both, immediately left of the⋯button. Visible only to the character's owner or a user withedit—controldoes 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'sheaderActionsand the Character Context's — so the two routes cannot drift. The modal is a thin host around v0.61.0'sPermissionsPanel, 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.controlnot conferring re-share is a client rule;editconferring it was not a rule anywhere —PUT /api/characters/:id/permissionsallowed the owner and the campaign GM and nobody else, so a button shown to aneditholder would have 403'd the moment they used it. Both the PUT's authz and the GET'scanEditnow readresolveCharacterAccess, and the button is gated on that samecanEditrather 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 withedit", and a GM's resolved level iscontrol— 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:editgains the right, nobody loses it, and a plain player atcontrolstill does not have it.* Two placement details. On/playthe 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_campaignsentry, the same fallbackcharacterPathuses; with neither, the modal says why the list is empty instead of showing an empty list. The Character Context keeps its inlinePermissionsPanelat the bottom of the sheet as well. It is not redundant with the modal: only the inline one carriessyncTokenId, 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/:idthat 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 areplace. 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 isInviteUsersModaldespite 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 plainshareicon — this is the status display 2a reserves it for. (Done.CampaignAccessModal+useCampaignMembers, opened by the global button throughtopNavShareSubject(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/playhad no route to "invite someone", and the literal reading of 4a — a status display — removes it./playcarries acampaignId, so it gets the panel, and the panel carries Invite User opening the sameInviteUsersModalthe 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/membersreturned raw rows — user ids, no names — so it gainsdisplayNameper member,ownerDisplayName, andcanRevoke. All three are additive; nothing on the client read this endpoint before.canRevokecomes from the server rather than being re-derived fromuseCampaigns()'sisOwner, for the same reason 3a's button readscanEdit: a second derivation of a rule the DELETE enforces is a button that 403s. Names resolve through a newutils/userDisplayNames.ts— one Clerk call for the whole roster. The character permission editor was doingawait 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 ofresolveActorName'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.idis the row,CampaignMember.userIdis the person, and they are both strings.) - [x] 4b.
/characters/:id— character access status. With or without a/campaign/:idprefix. Shows current read / control / edit for campaign users, editable by anyone witheditand 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'sshare-2button 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 withedit, read-only for everyone else" then needs no second code path at all —PermissionsPanelalready disables its Selects on the server'scanEdit, 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/:idis real — it stays put when the character belongs to no campaign — andtopNavContextFromPathclassified it asotherwith nothing in scope, which made "whether or not the route is prefixed" unsatisfiable. It now names the character, and keepsarea: 'other'deliberately:areadecides which CAMPAIGN-relative sections render, and claimingcharacterSheetwould 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 —
topNavShareSubjectreturns null and the JSX is a conditional render, with nodisabledprop anywhere near the control. So what this commit adds is the part that keeps it true:globalShareButton.matrix.test.ts. The matrix is exhaustive overTopNavArea, 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 acampaignIdwould silently grow a button nobody decided to give it. ARecord<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 nodisabled, andGlobalTopNavRightreads nolocation.pathname— the decision comes fromctxor it is not this feature. One route deliberately left alone:/chat/:campaignIdis classifiedotherwith no campaign in scope, so the button is absent there. It is arguably a campaign route, but the fix is intopNavContextFromPath'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.)