v0.58.0
General
- [x] Restore the footer to
position: fixedat the bottom of the viewport, visible on every route. It was a flow element at the end of the shell and was skipped entirely on map routes, so the one place you spend most of a session never showed it and everywhere else you had to scroll a whole page to reach it. Fixed, it reserves no space of its own, so its height is published once —FOOTER_HEIGHTfor TS,--vtt-footer-heightfor CSS, held together by a contract test — and everything that used to sit atbottom: 0subtracts it: the left nav, both context sidebars, the mobile context bar and its overlay, and the map's own bottom token bars and End Turn button. Page content reserves it as padding on.mainArea.Z_INDEX.FOOTERputs it above the map chrome and the mobile bar (so "always visible" survives a stray fixed element) and below the modals, which are entitled to cover it. - [x] The footer carries direct links to the Terms of Service and the Privacy Policy — the same
destinations the Help menu points at, not a second route to the same prose. "Same destination"
is structural rather than a matching pair of strings: the pages are declared once in
LEGAL_HELP_PAGES, and both the menu and the footer open them throughuseOpenHelp, the hook the Help menu's own logic moved into. So on /play they land in the wide left sidebar (staying on the map you are reading about) and everywhere else they navigate to/help/guide/legal/..., exactly as the menu items do. - [x] The Action API help now has a page per function.
docs/help/api/action-api.mdwas a tour — enough to recogniseroll()andapplyDamage(), not enough to write against them — and the only place a full signature existed was the editor's autocomplete strings. All 18 functions now have their own reference page underdocs/help/api/action-api/, each with the signature, every parameter and its default, the return value and whatnull/undefinedmean there, failure modes, several worked examples, and cross-links. The tour page keeps its shape and links into them, and gains the two things it never covered: theareavalue and thecharacteraccessor. Three behaviours documented here disagree with what the code does — see the notes ondraw()(manual placement is in grid coordinates but drawn as pixels),prompt()(Booleanrejectsfalse), andaddAction()(never persists on its own).
Map
- [x] Trackpads become first-class: two-finger scroll pans the map, pinch zooms. A trackpad
never surfaces Touch events or finger counts in the browser — the
wheelstream is its only multi-finger signal, and the map was spending all of it on zoom.handleWheelnow splits it: ctrl/cmd-modified events (how every engine encodes a pinch) zoom continuously viaMath.exp, and plain wheel events pan on a trackpad but keep zooming on a mouse wheel. The telling-apart lives inutils/wheelGestures.ts— per-event shape heuristics (deltaMode, horizontal component, fractional vs full-notch deltas) plus a sticky per-stream tracker that defaults to 'wheel' so the first scroll on an unidentified device zooms, as every VTT user expects. Because a free-spinning hi-res mouse wheel is genuinely indistinguishable from a slow trackpad scroll, Settings gains a per-DEVICE (localStorage,scrollPrefsStore) three-way override: Auto detect / Scroll zooms / Scroll pans. Left-click drag is thereby freed on trackpads for what it already was on mice — selection — with no right-click-drag or three-finger-drag contortions required to pan. - [x] A press on a token is no longer instantly a drag: 5px of screen-space slop first. On
tap-to-click trackpads the OS holds the virtual button through the follow-up movement, so
"tap the token, move toward the Character Context" used to pick the token up. Pointer-down
now only ARMS a pending drag; it promotes to a real one when the pointer clears
DRAG_SLOP_PX(screen px — a world-space slop would vanish when zoomed in), and a release inside the slop resolves as the click/double-click it always was. Two side benefits:tokenDrag:startbroadcasts at promotion rather than at press, so plain clicks stop flashing a drag preview on everyone else's screen, and Escape now abandons a press or an in-flight drag outright — mesh snaps home, remote previews drop — for the times the OS starts a drag anyway. - [x] Wheel and pinch zoom anchor at the cursor. The old zoom scaled about the view center,
so the map "ran away" from whatever you were pointing at — tolerable on a notched mouse
wheel, plainly broken under a trackpad pinch. The world point under the pointer is resolved
through the CURRENT projection before the zoom changes, then
anchoredCameraPosition(unit-tested invariant:(world − camera) × zoomis constant across the change) repositions the camera so that point stays under the pointer. Keyboard Q/E zoom keeps its center anchor — there is no cursor to honour. - [x] Option/Alt + left-drag pans the camera, everywhere and in every mode. The deliberate hold-to-pan affordance for pointers where right-drag is an awkward contortion — trackpads again. Space, the classic pan key (Figma, Photoshop), is taken twice over on this map: held Space broadcasts your pointer to the table, and Space mid-drag anchors a movement waypoint — so Option carries it. The branch sits ahead of the targeting/heal-harm/impersonate intercepts (those modes previously had NO pan at all: right-click exits them), and a pointer-down carrying altKey never arms a token drag, so the gesture pans even when it starts over a token.
- [x] Experiment: a hover names the token; a CLICK opens its UI — tried, and ROLLED BACK. The
experiment shipped mid-release (hover renders the name/faction pill alone; a click expands
the action bar, vision badges and portrait; clicking the same token again impersonates), and
the playtest verdict was to keep the old model. Reverted within the same release, so it never
ships: on desktop a hover shows the ENTIRE hover UI again, and a single click impersonates —
v0.35.0's rule, unchanged. Mobile keeps v0.34.0's tap behaviour, which the experiment had
generalised and the revert restores.
uiStore.expandedHoverTokenIdand the projector's effect-fallback left with it.
Character Sheet/Context
Portrait Section
- [x] Zoom and Download join the edit button, inset into the portrait, sharing one style
constant with it — two of three buttons matching "the same treatment" is worse than none of
them doing. Neither inherits the edit button's read-only gate: that exists because a
surface offering an editor which saves nothing is worse than none, and neither of these
writes anything.
- [x] Zoom opens
ArtworkLightbox: the image contain-fitted to the viewport, wheel and pinch to zoom, drag to pan, a caret each side when the character has more than one source art, and Escape / the corner button / a click outside to close. It shows the UNPROCESSED source rather than the portrait's crop — seeing the picture the sheet is cropping is the point. A bare backdrop rather than a Mantine Modal, because "clicking outside the image closes it" means the backdrop IS the dismiss target and a Modal would put a panel between them. The pan surface has to fill that backdrop (or a drag would end the moment the pointer left the picture), so the image stops its own click and the surface swallows only the click that ends a drag. Pan is bounded by the overflow the zoom created, so a flung drag cannot leave an empty rectangle with no way back (utils/lightboxTransform). - [x] Download saves
originalUrl— the unprocessed upload, not the optimised webp and not the background-removed cutout, which is what "full res, as stored in the bucket" names. It fetches the bytes rather than using<a download>: that attribute is honoured only same-origin, and in production the app and the images are different origins, where a plain link NAVIGATES to the image instead of saving it. The filename comes from the artwork, then the URL, then the character's name — never the bare id, and never the pipeline's genericimage.webp.
- [x] Zoom opens
Character List page
- [x] Character cards use the designated portrait. The resolver was not the culprit —
characterCardArtasks the portrait slot first and always has. The list ENDPOINT withheld the answer: its stub columns carriedartworksbut neitherportraitArtworkIdnortokenArtworkId, so the first question was unanswerable and every card fell through togetCharacterDisplayArt, which returns the first entry. Two short text columns join the stub select and the client'sCharacterStub; they do not reopen the heavy-JSON cost that list exists to avoid. - [x] Character card art is anchored top center. A portrait is overwhelmingly taller than the
square it is cropped into, so the CSS default of centre took the face — the one part a card
exists to show — and left a midriff. The sheet's own portrait learned this in the v0.55.0
playtest; the cards are the same picture in a smaller box.
CardImagegained anobjectPositionthat still defaults tocenter, so map thumbnails are untouched, and the value lives besidecharacterCardArtasCHARACTER_CARD_OBJECT_POSITION— "which picture" and "framed how" belong together, and a new card surface has one thing to reach for.
Edit Campaign
- [x] The campaign's artwork is a tab of its own, and the two "Upload / or paste a URL" fields
at the bottom of Basic Info are gone. They had nowhere to put a list of what the campaign
holds, no way to reuse an image, and no framing at all — whatever you uploaded was centred and
cover-cropped, and changing your mind meant uploading again.
- [x] A source art strip across the top, each thumbnail draggable onto either slot below and
carrying a Replace and a Delete. The
+opens the character sheet's own Artwork modal —ArtworkModalShell,SourceArtEditor,SourceOptionPane, all three unchanged, since none of them knows or cares what owns the artwork it produces — so the campaign gets the same five sources and the same half-width slide without a second picker existing. Replace is that modal with the artwork prefilled. A file dropped anywhere on the strip, or an image pasted with the tab open, skips the chooser and uploads directly. - [x] Card and Background previews side by side: wheel to zoom, drag to pan, pinch on touch, a nine-cell position control, and — on the Card — Native (the default), Square, 4:3, 3:4, 16:9 and 9:16. Dropping a thumbnail on a preview replaces its image and resets the framing; adding source art fills whichever slots are still empty.
- [x] The panning and the position control are the same value, which is what makes "manual
panning overrides this" fall out rather than need reconciling: clicking a cell writes the
keyword form (
top center), a drag writes percentages, and the cell simply stops looking pressed. It is a CSS position because the same value has to frame the image in the editor, on the campaign card, and behind a whole page, and a value that means what CSS means is the only version of that which cannot drift — the three surfaces render through onecampaignImageLayerStyle. A campaign nobody has framed keeps its old fixed-height banner exactly as it was; campaigns predating the tab show their two legacy URLs as source art, derived for display and persisted on the first edit. - [x] The Background's crop is locked, and locked to the viewport rather than to the card's shape. The requirement said both — "the aspect ratio currently used by campaign card images" and "the entire viewport minus fixed elements" — and only one of them is the box the image actually fills, so any other shape would preview a crop the page never applies.
- [x] A source art strip across the top, each thumbnail draggable onto either slot below and
carrying a Replace and a Delete. The
Playtest fixes
- [x] The top bar vanished on any campaign page with a framed background. This release's
campaign Artwork tab paints the Background slot behind the whole page as a
position: fixed; z-index: 0layer — and a positioned element at z-index 0 paints over non-positioned flow content, which is exactly what the new GlobalTopNav was: a bar in normal flow with no stacking position. The (positioned) left nav stayed visible, which is why only the top strip disappeared. The bar now takesposition: relativeandZ_INDEX.TOP_BAR— the layer the token table has declared for it all along; the new GlobalTopNav simply never took it. Verified live on /campaign/:id with the S5 background. - [x] The map palette drops the layer NAME on a phone. The row of tools already overflows a
~700px viewport, and a word is the widest thing in it that is not a control. The icon and the
chevron stay, so the control is the same size to hit and still recognisable, and the name is
not lost — the tooltip and the
aria-labelboth carry it. Gated on the 768px query the rest of the map chrome calls mobile, notuseScreenSize, whosesmallis 1024: at 1024 this row has room to spare, and the threshold here is the row running out of width. - [x] The zoomed-artwork viewer was under the chat log. The character sheet renders inside
.sidebarPanel, which isposition: fixed; z-index: 600; overflow: hidden— a stacking context AND a clip. On /play the sheet is the LEFT sidebar and the chat is the RIGHT one: two siblings at the same z-index, later wins, and no z-index on anything INSIDE the left one can beat it.Z_INDEX.MODAL_OVERLAYonly means what it says at the root, so the backdrop is portalled todocument.body— which is what a MantineModalwould have given for free, and what its deliberate absence cost. - [x] Delete Character reported failure for a delete that had happened.
softDeleteCharacterfound the character's tokens withjson_extract(metadata, '$.characterId')— a SQLite function. Postgres has none, so on every deployment that matters this threw AFTER the character's own UPDATE had committed: 500 on the first press, 400 "already deleted" on the retry, character gone either way. It now queriestokens.character_id, which is what P1 added the column for — indexed, dialect-agnostic, dual-written on every token write and backfilled at boot in both dialects — and clears BOTH halves of that dual-write when it unlinks. Thecleanup-dangling-character-tokensscript had the same bug and the same fix. Two existing tests assertedjson_extractwas PRESENT, so they held a Postgres-fatal query in place while passing; they now assert the opposite. - [x] Map thumbnails showed a map with no tokens, and never changed. Two independent bugs, live
at once.
OrthographicCamera's frustum is in CAMERA space — the view matrix subtracts the camera position first — and the capture passed WORLD coordinates as the frustum and put the camera at the centre of that same region, applying the offset twice. Tokens are always at positive world coordinates, so it never framed them; it only looked plausible because the 200×200 foundation means there is always texture to see. The arithmetic moved toutils/mapSnapshotFraming, where a test asks the question the old code got wrong: does the framing, composed with the camera position the way three.js does it, actually CONTAIN the tokens. Separately, the upload reuses one storage path per map (stableKey, so a capture every 15 seconds does not leave a file behind each time) while images are servedCache-Control: max-age=31536000, immutable. Correct for every upload whose URL changes with its content; exactly wrong for this one — the card kept showing whatever the browser saw first. The path stays stable and the stored URL now carries a version.
Status Site
A microsite, status.purplecat.games, showing the status of the various environments.
-
Environments: prod (default), alpha, dev, help, www
-
Features:
- Current status
- Graph showing status over time, configurable (e.g. last 24 hours, last 7 days, last 30 days)
- Graph showing number of visits to this site over that same time (a good indicator of how many users are affected by an outage)
- Stream of BlueSky messages from our status account, with a link to the full feed
- Report Outage button
- Graph of number of reports from the Outage button
-
[x] Specced, not built — docs/committed/13-status-site, broken into nine atomic tasks (S-1…S-9). The one decision that shapes everything else is answered: the status site must survive the outage it reports, which rules out a page in
apps/web, a read from the platform's Postgres, a checker on the platform's ECS cluster, and a Report Outage endpoint onapi.purplecat.games— users press that button precisely when that host is not answering. What survives, cheaply: a scheduled GitHub Action as the probe (already this repo's deploy path, a wholly separate provider from what it measures, probing from outside our network), history as a committed JSON file rather than a database, and a static page. Three findings the ask could not have known:www.purplecat.gameshas no Terraform, no DNS record and no bucket, so there is nothing to check; prod is TWO independently-failing things (a CloudFront SPA and an ECS API, and a staleindex.htmlis an outage/healthreports as fine); and there is no analytics anywhere in the platform, so "visits" means visits to the STATUS SITE — the reading that also keeps working while the platform is dark. Five things are yours: a BlueSky handle, the host, the Report Outage backend, and the questions in §6/§7.
Playtest Issues
Open decisions: docs/shipped/14-playtest-issues/decisions.md — the questions where two readings produce materially different work, each with a recommendation. One of them (the per-edition spells-known rule) blocks three separate items and is the same question v0.57.0 skipped.
Character Sheet UX
Note: "in 5e" refers to 5e 2014, 2024, and system5 unless otherwise specified. If 5e is mentioned and System 8 isn't, assume System 8 is unchanged.
- [x] A lock button on every section, unlocking build mode for that section alone. The sheet had
one editing switch and it unlocked everything — the wrong grain for "let me fix my AC", which
should not put every ability score, feat and spell into an editable state where a stray click
is a silent edit to something the user was not looking at. The visible half is easy; the half
that matters is that "for that section only" has to REACH the section, and every one of its
children took
editModestraight from the sheet-widebuildMode— a lock that rendered but left the fields read-only would have looked exactly like a working one. All twelve now resolve throughsectionEditMode(section.id). Not persisted: an unlocked section is a state you are in while doing something, not a preference, and it clears whenever the global toggle moves so leaving Build Mode really does re-lock the sheet. Under global Build Mode each lock renders OPEN and disabled — reading "locked" over an editable section would be a lie, and being clickable would raise the question of which switch wins. - [x] The Build toggle is that same lock/unlock icon, at sheet scope. Two different glyphs for one idea at two grains would read as two different features — and the lock says what the resting state IS (closed, safe from a stray click) where the pencil only hinted at what the other state does. Behaviour is unchanged: it still unlocks the whole sheet, and it still names the transition rather than the state in its tooltip.
- [x] A grip on every section, dragging to reorder, saved per user and per character.
@dnd-kit, which the campaign editor already uses — so no new dependency, the same feel as the app's other sortable lists, and keyboard dragging for free. The grip carries the listeners, not the section: a sheet full of interactive fields cannot also be a drag surface, or every attempt to select a number becomes a drag. The preference goes inuseCharacterUIPrefs, whose localStorage-keyed-by-character-id is already exactly "per-user, per-character". The interesting part isutils/sheetSectionOrder, because the set of sections is NOT stable — it changes with a module update, a token linking, a character's first spell, an attribute group emptying out. So the stored list is a preference matched against whatever exists now: ids that no longer resolve are ignored rather than leaving a hole, and a section the arrangement has never heard of is kept, at the BOTTOM. Guessing it into the middle from the band table would move sections the user placed by hand; the end moves nothing. Two sections have no grip for honest reasons and both still take part as drop targets: the portrait has no header to hang one on, and a sheet with no character id has nothing to key the preference to. Identity, which leads with its own heading and so rendered no header at all, gets a bare grip row — otherwise it was quietly the one section nobody could reorder. - [x] One Spells section: the slots, then Known and Prepared side by side. Three headings for
one subject — what this character can cast today — each a scroll from the next. The merge is
the MODULE's to declare, because only the rules know that "known" and "prepared" are two views
of one collection: a channel says
mergeSubtypesand the platform lays its subtypes out as columns, and sayshostedPoolsand the slot tiles render at the head of that section instead of as sections of their own.TraitGroupSectionnames no system, subtype or pool; a module that declares neither field keeps one section per subtype, as every module did before. Spells moved to a channel of their own (spells) in bothdnd5eandsystem5— the merge is per channel and Feats must not be dragged into it. A channel id is not storage, so a spell still lives incharacter.powersand nothing about where it is kept moved with it. Empty slots needed the number nobody had:resolveSlotCount(CS-1.3g) lets a subtype compute a CAPACITY rather than a level schedule, because the ninth row of a Bard 6's list was not granted at level 9 — it is the ninth row of a table read at class level 6. So this is also where the per-edition spells-known rule landed:spellBudgetTables.tsis generated fromdata/<edition>/classes.jsonand committed, since every consumer of it runs in the BROWSER wherenode:fsdoes not exist, andspellBudgets.tsreads it per class — 2014's known-caster column, 2024'spreparedSpellscolumn for every caster, and 2014's "ability modifier + level" formula only where that edition leaves the question to prose.nullis not zero: a 2014 Cleric has no spells-known count at all, and scaffolding zero rows would assert a limit the rules do not have. Dragging a spell into Prepared works because the drag context lives in the merged section rather than in each column — two sibling contexts cannot see each other's droppables — and the columns' sortable ids are namespaced, since Prepared is a view of the same spells Known lists and bare slugs would collide. A drop across moves VIEW MEMBERSHIP: preparing a spell, and dragging it back un-prepares without un-learning it. Two columns at1 1 22rem, so they become one below ~46rem — every phone, and the narrow play sidebar at any viewport width. - [x] The six combat numbers are one section, in the stated order. HP, AC and Speed were
attributes of the Combat group; Proficiency Bonus, Initiative and Passive Perception were a
derived-stat PANEL — a second heading directly beneath the first, holding three more numbers
about the same subject. The three are projected as attributes now, carrying their own
attributeGroup(CS-15.3), because the module derived them and so the module knows where they belong; the campaign's property definitions still say the ORDER within the group, which is where the fixtures now read HP 1, AC 2, Speed 3, Proficiency Bonus 4, Initiative 5, Passive Perception 6. Passive Perception had no definition at all and gained one. They are derived exactly as AC and Speed are, which means a STORED value wins: an item that changes initiative, or a class that scales proficiency differently, is typed in and never recomputed over. Two things had to give way. The Combat panel now computes nothing for a character, so the platform drops a derived panel whosecomputereturns nothing — the same "absent rather than empty" rule the token side already followed, and asked only where there IS a character, since "don't know" is not "none". The panel stays declared because an impersonated token has no character to project attributes from and reads its stat block throughcomputeForToken. And the row width: six tiles across is one long row of small numbers, where the ask draws two rows of three.attributeGroupLayouts(CS-1.2b) lets a module name a group's column count; the platform keeps the tiles, the wrapping and its six-wide default, so Ability Scores still reads as one row and System 8 is untouched. - [x] A spell with one action IS that action. The row was the spell's NAME as a hyperlink with
an expando beside it, so reaching the only thing the row could do — cast the spell — took a
click on a caret and then a click on the button it revealed. The button is the row now, and
the description did not lose its route: it moved to an
(i)beside it, which is also where a multi-action row's description lives. A spell with SEVERAL actions is a label at the samesize="xs"as the buttons it hides, so the line height does not jump between the two shapes, invariant="default"rather than the action buttons' violet — it does not cast anything, and reading as a quieter version of what it reveals is the point. The caret is on the label itself. Chosen by how many actions a trait carries, never by what the trait is: a feat with no actions is the same plain row with the same hyperlink it always was, and a System 8 power with two actions gets the label-and-expando for the same reason a spell does. - [x] The system declares the default order; the user still overrides it. No band table could
express what was asked: Class Abilities has to land BETWEEN two trait sections that share a
band, and Skills below both of them. So
defaultSectionOrder(CS-1.15) lets a module name the order it wants, and the platform applies it before the user's saved arrangement — which is still applied on top and still wins, per character. A module names its OWN ids —combat,spells,feat,Saves— matched against a section'ssourceIdas well as its id, so nothing has to know that an attribute group renders asattributes-combator a subtype astraits-spells-power. The named sections move as one block, placed where the earliest of them sat, and everything unnamed keeps its natural order around it: that is what keeps the portrait on top without a module naming it, and what "(remaining sections not mentioned)" means in practice — Background, Rest, Inventory, Ledger, Notes and the token blocks keep their relative order, after the ones that were named.- [x] System 8: Powers before Feats. Declared rather than achieved by swapping the two subtype declarations: that order is also what the pickers and the catalogue read, and "which section comes first" should not be said by moving data for a reason none of those callers can see.
- [x] 5e and System 5: Identity, Ability Scores, Saves, Combat, Attacks, Spells, Class Abilities, Feats, Skills, then the rest.
- [x] Permissions is a section of the character sheet, not only of a token. It was built and it
worked —
PermissionsPanelover the allow-row table, one toggle per player in the campaign, the GM and the owner locked on. It was declared as a TOKEN section, so it appeared only where a token was linked, which is exactly the reported symptom the wrong way round: the owner reading the plain character sheet saw nothing, while a player impersonating the token saw the list. A character with no token now gets the section in its own right. Not doubled where a token IS linked — that block already carries one, and it already renders the CHARACTER's permissions (they are the same rows; the server keeps the token in step). Absent outside a campaign, where the list of "everyone else in this campaign" is empty by construction. Anyone with view access sees the list; the toggles stay disabled for anyone who is not the owner or the GM, which the panel already decided for itself.
Class Abilities
- [x] Built. The section that held only the CHOICES — the subclass picker, the class-option
rows — now also lists what each class GIVES: its automatic abilities in a left column and the
chosen subclass's in a right one headed "Subclass:
", each name a hyperlink opening a modal with the full description as it appears in the class prose. The seam is CS-1.16: a module declares resolveClassAbilities(character)— per class at that class's OWN level (decisions B1: a Fighter 6 / Wizard 1 lists six levels of one and one of the other), everything up to the current level, subclass features included once chosen — and serves the prose throughgetDetail('class-ability', slug). The platform renders columns, links and the modal, and names no system. The lists are compiled, the prose is not. Each module's generator lifts names and levels out of its class data into a committed table (browser-safe, like the spell budgets); the descriptions stay in the bundled bodies and are cut out lazily at their own headings — dnd5e's###### Rage/###### Level 1: Rage, System 5's### Rage (3rd level). dnd5e's subclass prose lives inside the class body (slugbarbarian--frenzy); System 5's subclasses are first-class records, so its slugs say which record owns the prose (c:/s:prefixes). Exclusions per B1: declared choices (ASI, Fighting Style), subclass hooks (Primal Path, "Barbarian Subclass", "Path Feature"), scaling "improvements" rows, and System 5's (Optional) variant rows. The generator prints every feature with no matching body heading — two known SRD gaps (2014's Aura of Courage and Feral Senses), which the modal renders as "no further detail" rather than an empty pane. The filter menu sits in the section header left of the lock button: one section per class (default) / combine all classes — combined qualifies each name with its class, unless there is only one; sorting alphabetical (default) / by level; grouping by category (default) / none. Category grouping renders only where categories exist. The category pass is written and not run:scripts/categorize-class-abilities.mjsin each module calls Gemini's REST API directly (no new dependency;GEMINI_API_KEY), resumes an interrupted run, and writes a categories file the table generator folds in on its next run — 136 distinct abilities for dnd5e 2014, ~1,300 for System 5. Until it runs, every list renders ungrouped, which is the honest default.
Token Image Resolution
- [x] Resolution and LOD are no longer category privileges — which is why three fixes never
held. Every previous fix patched one upload ROUTE, and the rules stayed per-category:
category
tokenwas capped (512px client, resized server-side) and denied LODs on both sides, and the category was chosen from the LAYER at upload time. Drag onto Background gotmapObjectand behaved; upload, Google and genAI gottokenand lost resolution permanently — layer and sizing mode are decisions made and remade after upload, but the cap was applied before it. Nowtokenkeeps full resolution exactly asmap/mapObject/artworkdo (client cap 4096 like every map-bound category; server resize off), and LOD generation is decided by the thresholds alone — >250K source pixels earns LOD1, >2M earns LOD2 — withtokenandartworkadmitted to the LOD categories on client and server both. A 512px avatar costs nothing extra; a 4000px painting gets its variants whichever door it came through. One selection function, asking about the IMAGE.getLODUrlran for every token already, but gated on the entity's world size — a heuristic to dodge 404s for the variants tokens never had. Uploads record their processed dimensions onto the artwork now, and the selector uses them: an image known to be under the threshold never requests a variant, one between the thresholds clamps to LOD1, and a high-res painting sized to one grid square gets its LOD — which is exactly when it wants one. Legacy artwork recorded no dimensions and keeps the old heuristic, where "wrong" means full resolution: what those images always got. "Render Mode" is "Sizing Mode" in the token modal and Advanced Token Settings — the switch no longer touches resolution or LOD, so what it still decides is sizing: square-and-grid-snapped versus free size and aspect, pixel-size versus grid-size initial footprint. The stored field keeps itsrenderModename; renaming storage would strand every existing token. Per decisions C3, switching an existing token's mode re-processes nothing silently — the art a token has is the art it keeps until re-uploaded.
Action API
- [x]
rollSilent(expression).roll(expr, false)has always done exactly this and nobody could find it: the second argument is a positional boolean whose meaning lives only in the docs, and rolling without narrating is the common case in a spell action, not the exotic one. One expression in, one number out —rollDetailed(expr).total, so a caller who wants a number does not have to reach into a field and remember what it is called. In scope in the Test panel and in campaign functions as well as in actions, since all three compile their own parameter list and a function missing from one of them is a function that works everywhere but where you were testing it. - [x] The editor's completions describe the API that exists. Four things in scope were offered
by nothing:
requestRoll(the one function a saving throw needs),draw,areaanddamageTypes— plus the token's two derived artwork indices. Several of the entries that were there described something else:roll()was typed as taking one argument and shown being awaited, though it is synchronous and takes the whole decoration object;getDistanceToandgetLineOfSightToclaimed to need a token when an id has always done;applyDamagenever mentioned that a negative amount heals. Correcting the wording is the smaller half. The drift has a shape — someone adds a function to the sandbox and nobody adds it to the completions — so the lists are now read out of the SOURCE they describe: the sandbox's own parameter list, the token API's own return statement, the proxy's own traps. A completion for something that does not exist fails, and so does a function in scope that nothing offers. Two more checks come free: the synchronous rollers must not be shown withawaitand the asynchronous ones must, and every function must have a page in the help site's reference section. - [x] The Action Editor can widen to hold the documentation beside the code. A toggle in the
modal takes it from
lgto95vwand puts the help site's Action API page in the space that buys — an iframe of the same wiki the Help sidebar embeds, not a second copy of the reference pasted into the app, because there is one Action API document and a copy is a thing to forget to update. Both columns are1 1 34rem, so the editor keeps the width it had and the docs take only what widening added. Rendered only while open: an iframe that is merely hidden still fetches the page, and this modal is opened far more often to rename an action than to read aboutrequestRoll. The choice is remembered per browser, because it describes how you are working — up for an afternoon while writing your first action — rather than what you are doing right now.
Spell Chat Messages
- [x] Every 5e and System 5 spell action announces itself, platform-assembled. System 8 always
had "I cast Fireball, DC 17…"; the other systems didn't, or did per-spell. Per decisions C4
the announcement is the PLATFORM's, composed from metadata the action DECLARES —
IAction.spellcarries the save DC the generated code was built to roll against and the damage dice it declares — so one implementation covers both editions and homebrew, and regenerating every bundled action is not the price of a consistent chat log. The frame follows the template:I cast X.— DC when there is one — declared damage when there is any — Target(s) from targeting mode — and a collapsed<details>expandable with the spell description. Declared values, deliberately not rolled ones: the rolls and the damage cards still arrive in the same frame from the action's own code, exactly as the requirement lists ("other effects of the action may create other chat messages"). Emitted FIRST in the action's marker frame, so everything else reads as its consequences. Keyed on the declared metadata rather than oncategory: 'power', which is what keeps System 8 — whose action code writes its own announcement — from double-reporting; its actions declare nothing and get no frame. Both 5e module generators (spec path and legacy parser path) attach the metadata, and the zod action schemas on the token and character routes declare the field so a save cannot strip it. Target names were already reachable in the Action API (targets[].name), so no API version bump was needed.
Misc
- [x] Sharing a campaign says so, over the socket. The invite worked and announced itself only
by email — best-effort, and configured in production alone — so a player sitting in the app saw
a campaign appear in their list with no idea when, or from whom. The server emits
campaignInvite:createdinto the invited user's OWN room (the campaign room is the people already in it, and the one person who needs to hear this is by definition not among them yet), carrying the campaign's name and the inviter's, and a listener beside the other three shows a toast with a View campaign button. Best-effort in the same sense the email is: the membership row is already written, and a socket that is missing or throws must not turn a successful invite into a 500. - [x] The granted-access notification impersonates; the sheet is the fallback. Being granted a
character is something that happens while you are standing on a map wanting to PLAY them, so
the primary button now takes control of their token and View character moves beside it. The
token is looked up at CLICK time rather than when the toast appears — a map's tokens arrive
asynchronously and the grant usually lands first — and with no map or no token of theirs on
it, the button opens the sheet rather than doing nothing.
- [x] The hard reload was one ref. A denied attempt marked the token id as already-refetched, and that mark was cleared only when impersonation went to NOTHING. A player who had a token of their own was reverted to it instead, so the mark survived — and their next attempt at the same token, made after the owner granted access, was denied straight from the stale cached set without the server being asked at all. Nothing but a reload cleared it. The attempt is now tracked separately from the refetch mark, so every fresh attempt asks the server again while one attempt still cannot refetch in a loop.
- [x] The grid is one CSS pixel at every zoom. Its lines are meshes, and their thickness was two
WORLD units — a fixed size on the map rather than on the screen, so at 8× those two units were
a sixteen-pixel band of grey and the grid stopped reading as lines. Zoomed out it was the same
mistake pointing the other way: under a pixel, aliased into an intermittent dotted mess.
An orthographic camera's frustum height, over its zoom, over the viewport's height in pixels,
is the world size of one pixel — so the thickness is that. CSS pixels rather than device
pixels (decisions.md C5): half a line on a retina display is not what 1px means to anyone
looking at it. Recomputed in
useFramerather than from a stored zoom, because the camera is mutated directly during a pan or a pinch and no store hears every step; guarded by a 2% band so four hundred meshes rebuild when the zoom changes and not once a frame. - [x] Moving a group of tokens is one call that either moves all of them or none. It was one
PATCH per token, fired in parallel — five chances to fail, and four succeeding is the reported
bug exactly: the formation looks right until you reload, and nothing said otherwise.
POST /api/tokens/bulk-movetakes the DRAGGED token's destination and the ids moving with it, reads where that token currently is, and applies the one delta it derives — so the other positions are the server's arithmetic rather than the client's, and a stale base cannot put one token somewhere nobody dropped it (which is the v0.33.0 formation jump in a different costume). Atomic by being ONE statement —UPDATE … SET x = x + dx WHERE id IN (…)— rather than by a hand-rolled transaction: drizzle'stransaction()is not portable between the two drivers (better-sqlite3's takes a synchronous callback), so the hand-rolled version would be atomic on Postgres and quietly not on SQLite, which is the worse failure because it is the one nobody sees in development. Mount groups are expanded by the endpoint and the delta applied once per token, so a rider whose mount was dragged still comes along and nothing moves twice. The frontend presumes success; on failure the optimistic write is rolled back — leaving every token where the server says it is, which is where they were — and the notification offers Try again and Reload app. - Impersonated Token Context
- [x] A token's name is a field in Build mode. It was read-only in both modes on the reasoning
that a token's name belongs to the token modal, and a second write path to one field is the
class of bug the one-sheet refactor removed. For an impersonated token this section IS its
identity block, and Build mode is exactly the state in which a player expects a name to
become a field — and it stays ONE write path,
updateToken, which is what the modal calls too. A blank name is refused as a half-typed one rather than saved, the same rule the character's own name follows, and the faction stays read-only: that one has its control on the map, so a second here would be a different field's second path.
- [x] A token's name is a field in Build mode. It was read-only in both modes on the reasoning
that a token's name belongs to the token modal, and a second write path to one field is the
class of bug the one-sheet refactor removed. For an impersonated token this section IS its
identity block, and Build mode is exactly the state in which a player expects a name to
become a field — and it stays ONE write path,
- No way to invite to a campaign from /play
- [x] The Share icon is back in the global top bar. Phase 11 removed it as a duplicate of the
Connectivity popup's Invite Players button — true, and beside the point: nobody looks for
"invite someone" behind a connection-status dot, and on /play that popup was the only route
to it. Both open the same
InviteUsersModal, so this is a second door rather than a second flow, and it appears only with a campaign in scope — there is nothing to share otherwise.
- [x] The Share icon is back in the global top bar. Phase 11 removed it as a duplicate of the
Connectivity popup's Invite Players button — true, and beside the point: nobody looks for
"invite someone" behind a connection-status dot, and on /play that popup was the only route
to it. Both open the same
- [x] Spells known, cantrips and prepared spells are all validated against the class table.
Nothing validated spells KNOWN at all, because there was no per-edition budget to validate
against — so a Bard could learn thirty spells in silence.
spells-known-countandcantrips-known-countjoinprepared-count, all three counted PER CLASS fromspellBudgets(see the merged Spells section above, where that layer landed). Under budget is INFO and over budget is a WARNING, never an error: knowing fewer spells than you may is a character halfway through levelling up, and knowing more is either a mistake or a subclass, a feat or an item that this module genuinely cannot see.prepared-countgained the under-budget half it never had, and reads 2024'spreparedSpellscolumn where 2014 used a formula that was wrong for every 2024 class. - [x] Shade was the multiclass corner of exactly that rule. A Bard 6 / Cleric 1 could take any
number of spells because no single class's rule applied and so no rule ran: the Bard half is
validated against the BARD table at BARD level (nine at 6), and the Cleric half contributes no
spells-known budget at all — which is the rule rather than an omission, since a Cleric prepares
from its whole list. A test builds her exact shape and runs it through the module's own
validate(), because a rule existing is not the same as the sheet running it. - [x] The prepared-spells picker offers whatever the class draws from. It was declared and never
read:
view.sourcesayscollectionorcatalog, and the picker asked the provider for the whole catalogue either way. "Limited to spells known" is right for a Wizard and wrong for a Cleric, and that difference is the rule rather than an inconsistency — a Wizard prepares from its spellbook, a Cleric knows its whole class list and prepares a handful of it. So a spellbook caster now picks fromcharacter.powersand a prepared caster from the catalogue, with a character who is both getting the catalogue: the more open of the two, since a picker that could not reach the Cleric list would make the section useless for that half of them. The prepared picker is a SPELL picker while it is at it — the level slider, the school sort and the wording all applied topoweralone — and it asks the catalogue forpowerrecords, because a view stores nothing under its own id. - [x] Any targeted spell enters targeting mode, damage or not. The platform was never the gate —
it enters targeting for any action with
target.enabled. The gate was the module'sspecActionRefusal: a no-roll action with no damage, no healing and no zone was refused OUTRIGHT, so a buff like Enhance Ability produced no action at all, and no action means no targeting. The refusal is narrowed to the untargeted case: an action that PICKS a creature now builds as a targeted effect —buildEffectCodeenters targeting like any other spell and announces who was affected and what the effect is, from the spec's own summary. Announcing without applying is the same honest half the zone builder settled on for saves: the effect itself stays inunmodelledand in the chat line, where the GM rules on it. A self-only no-roll action is still refused — for Fly-on-yourself there is nothing a button can do that casting the spell did not already do. The authoring validator makes the same exception, since the two disagreeing is how a spell gets declined in a diff nobody reads. - [x] Enhance Ability has all six choices. The authoring model wrote one action (Bear's
Endurance, the only one with dice) and filed "Choice of which of the six effects is bestowed"
under
unmodelled— and the other five, being no-roll effects, would have been refused by the translator anyway until the item above admitted them. The DRAFT (the pipeline's source of truth) now carries six hand-authored actions, one per effect, re-promoted through the normal review gate; the shipped diff touches Enhance Ability alone. On the sheet the spell is the multi-action row — a label that expands to six buttons — and the user selects by clicking one, which enters targeting mode. Bear's Endurance keeps its 2d6 temporary hit points; the other five announce their effect on the chosen target. - [x] "You have no tokens on this map" no longer lies to a player with a granted token. The
banner counted tokens where
owner === userId, and a Token Ownership allow makes you the owner of nothing — so the one player the permission feature exists for was the one told to go ask the GM. It counts the server's impersonatable set now (owner OR GM OR token-allow OR linked-character-allow), which is the same set the permission guard enforces, so the banner and the guard cannot disagree about who has a token here. It also waits for that set to LOAD before saying anything — an empty set that has not arrived is "don't know", and the flash over a granted token was half the report — and the widened list feeds player auto-impersonate too, so a player with only a granted token picks it up on arrival like an owner would. - [x] An attack added by any writer reaches every linked token. The branching logic the report
suspected was the WRITE PATH, not the viewer: only the whole-character PATCH ran the P4
fan-out, and attacks are written through the name-keyed collection routes
(
POST /characters/:id/attacks), which updated the character row and told its linked tokens nothing. Character Context renders the TOKEN's stored actions, so the play sidebar showed the stale list to everyone — writer included — while the standalone sheet, which projects the character fresh on the client, showed the truth. The collection routes now run the same fan-out the PATCH handler does, after the response and after power derivation (so a newly-picked spell's projection carries its real actions rather than a generic roll), and the DELETE route gained the hook it never had — a removed attack that kept its button on the map is the same bug pointing the other way. Applies to all five collections, so a feat, skill or spell added by an editor reaches the map the same way. - [x] An attack's
⋯menu says "View action", and the button works. Three fixes in one item. The dead button was not a z-index problem: the menu delegated to the host'sonEditAction, and Character Context mounts its ActionEditor only while a token is impersonated — so the click set state nothing was listening to. The section mounts its own viewer now (the pattern TraitSection already used for a power's derived actions), so it works on every surface the section renders on. Renamed and read-only for the same reason as each other: an attack's action is DERIVED, rebuilt on every projection, so "Edit" promised a save that would be overwritten within minutes —readOnlyis the honest form, and the label says what the modal does. "Edit attack" (the authored thing the action derives from) was already gated on Build mode — Play meaning not Build, per decisions C5 — and the gate is now pinned by a test. - [x] Token art follows the character again — two bugs were stacked. The dedicated artwork
routes (
POST/PATCH/DELETE /characters/:id/artwork) never ran the P4 fan-out at all, so adding and deleting source art told the linked tokens nothing. And the fan-out's artwork half declined any token that already HAD art — a guard meant to protect a look someone chose, which in practice blocked everything, because creation flows snapshot the character's art into the token and so every linked token has art from birth. That is why it "was working" and broke: the guard arrived with the R-b amendment, after the propagation had been tested. A linked token's artwork now follows its character outright, which is what P4's one-way-projection model said all along; idempotence moved from the guard into an equality check, so a character save that touches no art writes no tokens. The trade-off is stated in the fan-out's header: art given to a LINKED token through the token modal is a divergence the next character write overwrites — a linked token's look is edited on the character, and a token that should keep its own look is a token to unlink. A character with no art still gives none; the fan-out never blanks a token. A test pins the report's hardest gesture: a re-tokenization changes settings inside an entry that keeps its id, which no id-based guard could see.