Skip to content

v0.64.0 — Spell Chat Messages (round 2)

v0.58.0 gave every spell action an announcement. This release covers the two things it left behind: spells with no action at all, which announce nothing because there is nothing to announce from, and the row itself — what a spell looks like when it is not a single clickable action.

Spec: 22-spell-chat-messages — read § What already ships first, then § Actionless spells get an action for why the fix is a module-written minimal action rather than a platform-side fallback. Tasks are requirements.md, one commit each, in order.

Depends on v0.60.0, which removed the redundant 'info' item from the ⋯ menu. That is what lets ⋯ mean exactly one thing here — action meta — and therefore what lets it disappear from a row that has no single action to be meta about.

Actionless spells

  • [x] 2a. Resolve the voice inconsistency first. v0.58.0 ships I cast X.; the spec's agreed text for the new action is (characterName) casts (spellName). Shipping both puts two voices in one log, for two spells that differ only in whether anyone wrote an action for them. Decision: option (b) — third person everywhere, so v0.58.0's template moves with it in this same release. Both paths use the same template constant. (Done. spellCastMessage takes the caster's name and composes the line through one exported castLine(caster, spell); every existing announcement's first line changes with it, and nothing below that line moves — DC, Damage, Target(s) and the description expando are byte-identical. "Both paths use the same template constant" turned out to be free, not a thing to arrange. Under option (b) the module's minimal action (2b) writes no sentence at all: it declares spell metadata and no mechanics, and the platform frame assembles its whole line. So there is genuinely one template rather than two kept in step, and a test asserts the negative — nothing in the frame says I cast any more.* The name is invocation.tokenName, the same field the combat log records, so a cast cannot be attributed to one name in chat and another in the log. It is escaped like every other interpolation — a token name is user-supplied text — and an unnamed token casts as Someone rather than leaving the sentence to open with a space.)
  • [x] 2b. The module generates a minimal action for spells that have none. dnd5e and System 5. Existing spells with actions are untouched. The generated action declares no damage, no save and no targeting, so v0.58.0's platform-side frame renders it as a bare cast line and its only effect is the announcement. Generated at the same point as every other spell action — spec path and legacy parser path both. Ships as a module minor on both. (Done. dnd5e 0.13.0 and system5 0.8.0, plus the platform-side half of the contract here: the requirement's own test — a spell with no mechanical metadata yields exactly one action, and executing it emits one chat message and nothing else — and a source-text check that both modules still generate that shape, since nothing in this repo would otherwise notice their half moving. What was actually being replaced was not "nothing". The spec describes these spells as announcing nothing, and on the sheet that was true. On a token it was not: omitting the action left the platform's generic 1d20 + spellcasting mod "Power Roll" standing in, which the modules' own headers defended as better than a button that does nothing. Rolling a d20 for Prestidigitation is noise dressed as a mechanic, so the generic roll is gone for spells — deliberately, and both headers now say so.* The gate that matters is "is this power a spell at all". The CS-6b seam hands the derivation EVERY power a character has, not only its spells, and the old code passed non-spells over by accident — they simply failed to parse. Generating a cast line at that same point would have produced "Aria casts Second Wind". Refusal is now keyed on having an SRD spell record or an authored spec, and that is tested directly in both modules.* An authored refusal still stands as a refusal. A spec that yields no actions was read by a person who decided nothing here rolls; 2b does not overturn that, it answers a different question — "nothing rolls" is not "nothing happened". Two smaller traps. The action's body is return; rather than empty, because empty code is the executor's marker for a ?actionDetail=stub action and would buy an on-demand fetch on every cast. And the test that asserts the module writes no wording has to strip comments first — the generated body's own comment explains the arrangement by naming the sentence it declines to write. That is v0.63.0's share-icon guard trap exactly, hit again for the same reason.)
  • [x] 2c. The spell name in chat opens a description modal. Standard modal, the same content as the sheet's (i). The description travels in the message rather than being fetched per-reader: a link that only works for the caster is worse than no link. Applies to v0.58.0's existing announcements too — they already carry the text in a collapsed <details>; this promotes it to a modal from the name. (Done. The description rides in a data- attribute on the name, and ChatLog's existing handleHtmlClick — already there to intercept wiki links — turns it into a standard modal. Both ends read one exported constant for the attribute name: the writer is spellCastMessage and the reader is a closest() selector in a different component, with no type between them to catch a drifting literal. The <details> is replaced, not joined. "Promoted" could have meant "and also", and two affordances for one paragraph in a single line is precisely the redundancy v0.60.0 deleted from the ⋯ menu — the thing this release's row work depends on having been deleted. Re-creating it one line lower would have undone the reason ⋯ can now mean one thing.* The modal renders through ActionDescription, the sheet's own render site, which is what makes "the same content as the (i)" literally true rather than approximately so — and it is a real fix as well as a principle: spell bodies are Markdown, and the <details> printed them escaped in a pre-wrap div, so readers saw # Fireball and **bold**. A spell with no description keeps a plain-text name. A link that opens an empty modal is the dead end this release removes, not one to relocate. The one new injection surface is the attribute itself, and it is closed by the same escapeHtml the rest of the frame uses — a quote would otherwise end the attribute and turn the remainder into markup. Verified end to end in jsdom rather than by inspection: the exact text, newlines and all, survives innerHTML → getAttribute unchanged, and an embedded <b> arrives as characters rather than an element.)

The row

  • [x] 3a. Multi-action spells become label + expando. Template: (spellName) (expando) (i). No ⋯ — there is no single action for it to be about, and today the row shows the first action's menu, which is a lie about which of several it belongs to. The unexpanded label stays button-shaped and non-clickable so the row still aligns with its single-action neighbours. (Done. Most of the shape was already there — v0.60.0 built the label + expando and gave it the one-action row's height and rhythm — so what was actually missing was the ⋯'s absence. actionMenu is now built for actions.length === 1 rather than > 0, which is the whole of it: the lie was concrete, not theoretical, because "View action" on a row labelled Sunbeam opened whichever of Sunbeam's three actions happened to be first. Stated structurally, not as a condition. The row shapes had TWO control groups — one for the fused case and one, identical but for a leading {actionMenu}, for the shapes with no violet button to fuse to. Once ⋯ exists only where there is a single action, and a single action always has that button, nothing can reach the second group, so the two collapsed into one. There is now nowhere left for a stray ⋯ to be rendered, which is a stronger guarantee than a correct condition at each site.* (i) stays on the row, as the spec says it may: the spell has one description in the module however many actions we derived from it.)
  • [x] 3b. Single-action spells and sub-actions keep the button shape. Template: (spellName)(⋯)(i), with v0.60.0's fused geometry. Unchanged behaviour: clicking casts. Locked down so 3a's removal cannot drift into the rows that should keep it. (Done. The single-action row needed nothing but pinning — v0.60.0 built it — and it is pinned, because the failure mode of 3a is removing ⋯ one row too far. The sub-action rows did need a change, and it is the one place this reads the template rather than the sentence. "Unchanged behaviour" describes the click; the template describes the controls, and a sub-action had no (i) — its description was an Info item inside ⋯. Giving it the (i) and dropping that item is what makes (spellName)(⋯)(i) true of it, and it finishes a job v0.60.0 started: that release removed the menu's Info item wherever an (i) already stood beside it, and left these rows alone for the stated reason that nothing else showed their description. Adding the (i) removes the reason. Leaving both would have rebuilt the two-similar-but-not-identical-popups confusion one level further in — inside the expando, on rows this release's ⋯-means-one-thing premise reaches.* Its (i) opens the ACTION's description where the row's opens the TRAIT's, which is what makes it a different control rather than a second copy of the one above it. One inherited trap re-armed by the change: with Info gone, "View action" is the menu's only item and ActionMenu withholds it for a campaign action — so the menu can render nothing, and the row was fusing to it unconditionally. fused is now conditioned on the menu existing, exactly as v0.60.0 had to do for the row above.)