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{'}'} damagewith 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.streamfor their async flows: a save loop streams a live tally into a named slot as answers arrive instead of going silent behindPromise.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 ontypeof api, so the same generated code degrades to today's behaviour on any platform without documents — and module tests, which bind noapi, 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 }andstepComponent: 'proficiency', with nothing capping the steppers because the per-ability ceiling of level + 3 and the total budget are bothvalidate()'s to report. What was wrong is thatmodules.hosted.jsonstill pinned0.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 system80.7.0→0.10.0and dnd5e0.11.0→0.14.0, and broughtscripts/setup/lib.mjs's catalog back in line with the file its docstring claims it mirrors (system8 was stuck at0.4.1there, 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,
resolveSheetBackgroundreturns 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 insideWebGLTokenLayer— so an artless token was present on the map and absent everywhere else that draws it. It isutils/tokenPlaceholderArt.tsnow, 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
viewBoxand nowidth/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.TextureLoaderreported 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 toutils/mapTokenImage.ts(pure, node-testable, out of a 3,800-line component): token-art slot → portrait slot → the first artwork with an image → legacyimageUrl. 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'shandleInsert) swappedopenTokenModalEditWithSearchforsetImpersonatedToken— which auto-opens the token's own sheet, where art, stats and actions are all editable. The 100mssetTimeouteach 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.
openTokenModalEditandopenTokenModalEditWithSearchare gone from the store, and itsmodenarrowed 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 —AdvancedTokenSettingsplus the sameArtworkTabthe 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 wherelayerId !== "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, incrossLayerPick.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.