Skip to content

v0.73.0 — Chat Log Compactness

One button press could produce twenty-five chat messages. A spell with thirteen targets posted a cast line, a save prompt per target, a save result per target, a damage card per target and a handful of rulings between them — each one a separate frame, arriving out of order, scrolling the interesting part off the top before anyone had read it. The log was a transcript of the machinery rather than of the fight.

This release makes an invocation one message that changes. The script, the damage approvers and the roll answerers all write keyed, commutative patches into a single [ACTION_DOC:…] document, so what a reader sees is the current state of the action rather than the history of how it was computed. 28 tasks across 7 phases, plus a sweep of the surfaces that render chat, and a matching pass over the three modules that generate the actions.

Spec: 24-chat-log-compactness.

Chat

  • [x] Chat Log Compactness — all 28 tasks across 7 phases, plus the surface sweep. An invocation now posts ONE [ACTION_DOC:…] message and mutates it while the script runs: every writer (the script, damage approvers, roll answerers) writes keyed, commutative step patches; api.chat.stream(value, { variable }) gives authors named slots that update in place; pending asks render animated and resolve from the target owner's client; overdue asks fall to the GM; abandoned docs close by ruling; the collapsing renderer turns runs of saves and damage cards into tables with approve-all and breakdown tooltips under a derived headline; resolved damage cards collapse to ${'{'}name{'}'} took ${'{'}damage{'}'} damage with the full application on hover; and a transient, never-persisted socket channel shows "Kyrin is casting Fireball at 13 targets…" live as the reticle moves. The one button press that produced 21–25 messages now produces one, which ends smaller than any message it replaced. Main article
  • [x] Module authoring catch-up — the dnd5e, system5, and system8 modules' generated actions (spell saves, monster abilities, the S8 implement prompt) adopt api.chat.stream for their async flows: a save loop streams a live tally into a named slot as answers arrive instead of going silent behind Promise.all, and the big HTML summary blocks step aside on doc platforms where the ask table, damage cards, and derived headline already say it better. Guarded on typeof api, so the same generated code degrades to today's behaviour on any platform without documents — and module tests, which bind no api, keep exercising exactly the legacy path they always did. Platform side: characterToToken.ts's hardcoded S8 implement branch gets the same treatment as its module twin.

Misc

  • [x] Restore System 8 character sheet ability score wizard. Nothing needed building. The wizard, the array, the trades and the proficiency-only steppers all shipped in the System 8 module as v0.8.0 ("the array is the generator, and +/- move proficiency"): generationMethods: ['standard-array'], arrayTrades: { max: 2, cost: 2, gain: 2, raiseCap: 16 } and stepComponent: 'proficiency', with nothing capping the steppers because the per-ability ceiling of level + 3 and the total budget are both validate()'s to report. What was wrong is that modules.hosted.json still pinned 0.7.0 — the version before all of it — so prod and alpha loaded a module that declared no generation method at all and the platform correctly offered no wizard. Bumped system8 0.7.0 → 0.10.0 and dnd5e 0.11.0 → 0.14.0, and brought scripts/setup/lib.mjs's catalog back in line with the file its docstring claims it mirrors (system8 was stuck at 0.4.1 there, handing every fresh operator the same missing wizard). Its test now reads the versions from the catalog instead of hardcoding them, so a routine bump stops failing an assertion that is only about order and shape.
  • [x] In the character sheet (platform level), allow users to customize their character's 4 colors that comprise the background of the character sheet. The four corners of design.md §9's gradient were always derived — a stored palette, else node-vibrant on the portrait — which is a good default and a bad ceiling: a dark portrait washes the whole sheet grey, and a character's colours are not always their picture's. Options (Section 10) gains a 2×2 grid laid out where the colours land — the top-left input paints the top-left corner — with the pins stored per corner and sparse: an unpinned corner still follows the portrait, so changing the art still moves everything nobody chose. Storing four resolved hexes instead would have frozen the whole background the first time someone nudged one corner. Each input shows what is currently painted (pinned colour, else the portrait's) so editing starts from what is on screen; clearing one unpins it; "Reset to portrait" clears them all, and clearing the last pin removes the metadata key so "uncustomised" has exactly one representation. With nothing pinned, resolveSheetBackground returns the derived gradient object itself — the default path is unchanged, not merely equivalent, which its test asserts by identity.

Tokens

  • [x] Every flow that creates a token with no artwork (New Character, New Monster, Generate Monster, Insert Monster without picking art) should show placeholder art — a grey disc with a ? — rather than an empty frame. The disc already existed, but only inside WebGLTokenLayer — so an artless token was present on the map and absent everywhere else that draws it. It is utils/tokenPlaceholderArt.ts now, and the Token Sheet (where Generate / Insert Monster land once they impersonate) and the Floating Token Window fall back to it. Render-time, not stored: nothing is written to the token, so the placeholder can never be tokenized, retouched, or left behind as a stale artwork — it simply stops being reached the moment real art arrives, and existing artless tokens are fixed too. Surfaces that already say something useful for an artless token keep saying it: the token bar's own ? panel, and the Party / Target panels' initial-letter avatars, which carry more information than a disc would.
  • [x] Why the map never showed the disc either (found in playtest). The placeholder has existed since January and had never once rendered on the map, because its SVG declared only a viewBox and no width/height. An <img> rasterizes that happily — Chrome defaults it to 150×150, so every DOM surface showed it and nothing looked wrong in a browser — but WebGL uploads such an image as an empty texture: THREE.TextureLoader reported a 150×150 texture that drew zero pixels, leaving a bare faction ring with the map showing through. Measured both ways in a browser against the real markup: 0 non-transparent pixels without the attributes, ~3,000 with them. The map's broken-image fallback (the red ✕) had the identical defect and has therefore never displayed either; both are fixed, and the placeholder's test pins the attributes with the reason, since no DOM-based check can catch this.
  • [x] A token whose art sits in the portrait slot now draws it. The map asked only getPrimaryTokenArt, while the token sheet, hover card and Party / Target panels all fall back to the portrait — so art added through the artwork editor without being assigned to the token slot appeared on every surface except the one the token is played on. Resolution moved to utils/mapTokenImage.ts (pure, node-testable, out of a 3,800-line component): token-art slot → portrait slot → the first artwork with an image → legacy imageUrl. The backing disc colour comes from that same artwork rather than being looked up separately, which is how a token could end up wearing one artwork's colour under another's picture.
  • [x] Flows that end in the Edit Token modal should skip it: once Generate Monster / Insert Monster has stats, create the token with placeholder art and impersonate it. Art is the Edit Token Artwork button's job from there. All four call sites (handleCreateToken, handleCreateAiToken, handleCreateCharacterToken, and Insert Monster's handleInsert) swapped openTokenModalEditWithSearch for setImpersonatedToken — which auto-opens the token's own sheet, where art, stats and actions are all editable. The 100ms setTimeout each one used to "let the modal state settle" went with it. The generator's second button, "Customize Token", is Insert & Impersonate now: it was named after the modal it opened, and that is not what it does. "Insert into Map" is unchanged — a monster dropped on the map without stepping into it is still a thing GMs want.
  • [x] Sunset the Edit Token modal's edit paths — nothing left in the app opens a token for editing in a modal. openTokenModalEdit and openTokenModalEditWithSearch are gone from the store, and its mode narrowed from "edit" | "new" to "new", so the retirement is structural rather than a convention. The two paths v0.36.0 could not close were the ones with nowhere to go: objects- and background-layer tokens have no context to impersonate into, which is exactly why the modal survived. They get a Token Settings drawer instead — AdvancedTokenSettings plus the same ArtworkTab the Edit Token Artwork modal renders, both persisting on change, so there is no Save button and no draft to lose by closing it. A drawer and not a modal because editing a map object is something you do while looking at the map. The map context menu's "Edit" (still offered only where layerId !== "tokens") and the background bar's "Edit" open it; the shared token "…" menu's Edit Token item — restored temporarily in v0.39.1 because the inline art editor "doesn't yet meet requirements" — is gone, that reason having expired when the standalone artwork editor became the same component. The modal keeps its creation half: blank tokens, dropped images, library items and the PDF character-sheet import still open it, and still have no other home.
  • [x] The "On other layers" pick menu opens on double-click, not on a single click. The click that opens it is also the deselect gesture, so every deselect over a spot another layer happened to have something under answered with a popup — the rare reach-through was taxing the common gesture. isCrossLayerDoubleClick (pure, in crossLayerPick.ts) bounds the pair by both time and distance: a bare timestamp would read two quick deselects at opposite ends of the map as one gesture. The pair resets when it fires, so a third click starts a new one rather than reopening the menu.