Skip to content

v0.119.0 — Playtest fixes

Merged 2026-10-08 (2ba276a5, tagged v0.119.0) — built on version/0.119.0.

Merged 2026-10-08 from version/0.119.0. The owner's playtest TODOs, one commit each. Also in it: system8 0.20.0 (trait buffs, generated-action briefs), Module API 1.43.0 (SystemModule.traitBuffs), and migration 0060_map_layers_visible (additive, runs at boot). No external module release.

  • [x] Token art from an image-search pick drew the broken-image X on the map.

    • Seen on Lord Primrose (Brave New World, 1.7 Harvest Time). The sheet portrait showed the picture; the map showed the X. The token's artwork held the search result's own URL, on a host that sends no CORS headers. A WebGL texture needs them, while the portrait's <img> does not.
    • Cause: the Add Token modal's create path uploaded the picture, but only into the token's legacy imageUrl mirror. Its "upload the original too" step only handled blob:/data: URLs, so the artwork kept the external URL. Since v0.70.0 the uploaded source and the original are the same picture for tokenized art. The edit-mode autosave and the standalone artwork editor already ingested external URLs. Only creation didn't.
    • Done: utils/artworkIngest.ts (artworkUrlNeedsIngest) holds the one rule: transient or external URLs need ingest, our storage doesn't. TokenModal's create path uses it. The active artwork takes the uploaded copy when that copy is its original, a different external original is uploaded too, and other slots are ingested like the autosave does.
    • Done: for rows already saved this way (66 live tokens in prod have an external URL on their active artwork; only hosts without CORS break), the map now falls back to the token's imageUrl mirror when the artwork's URL fails to load. resolveMapTokenImage supplies it as fallbackImageUrl, and TexturedMaterial swaps to it once. If the mirror fails too, the X stays.
      • Tests: artworkIngest.test.ts, mapTokenImage.test.ts (fallback cases), WebGLTokenLayer.artFallback.test.ts (wiring guards), DrawerComponents.test.ts (the create-path ingest).
  • [x] debounce keyboard event changes to ability scores; holding up/down causes race conditions

    • Cause: the steps were already debounced (v0.108.0), but Mantine's NumberInput runs its own Up/Down handler after ours and ignores preventDefault. Every press was therefore our debounced step AND Mantine's increment. The increment landed in the typed-total draft, which writes outside the step debounce, so a held key raced two writers. DraftNumberInput's stepOnArrows rows (the numeric attribute tiles) had the same double step and also wrote once per key repeat.
    • Done: withKeyboardEvents={false} on the ability tile's input and on DraftNumberInput when it steps itself. A stepOnArrows step now goes into the draft, so a held key is one write once the repeats stop. Live check (Kaelan, local): 10 held ArrowUp presses gave +10 and one PATCH; 10 ArrowDown presses restored it in one.
  • [x] buffs from powers (e.g. “Large” power) aren’t working in s8 (cs or map)

    • [x] make character > set Strength to 16 (start) +4 (prof) = 20 > add “Large” power which has a +8 Strength buff > expected: 28 str. observed: 20 str
    • Cause: System 8 had no buff integration at all. Its pages declare their buffs in frontmatter (the system8 repo's buff lexicon: effects: [str +8, con +4, dex -2, …]), but nothing read them. A power entry stores no buffs, and the platform only applies a stored power buff while the power is enabled. S8 also pre-totalled its attributes without the modifiers, so even a hand-written Strength buff never reached the sheet or the map.
    • Done, platform (Module API 1.43.0): SystemModule.traitBuffs(character) returns the buffs a system's own rules give a character's powers and feats. They hold while the trait is held, with no switch. collectModifiers takes them as traitBuffs, and the sheet's projection (useModuleProjection) and the linked-token fan-out (characterFanOut) pass them through traitBuffsOf(module, character). evaluateBuffExpression is now shared, so expression values (+(2 * @rank)) work. The Module API docs are updated.
    • Done, system8 0.20.0:
      • scripts/generate-trait-buffs.mjs compiles each power and feat page's PASSIVE buffs (no trigger, on the bearer, not a choice option) into src/generated/traitBuffs.ts: 61 pages, 74 buffs, about 8 KB. It's compiled in because the sheet derives in the browser, which can't read data/docs/. Ingest runs it, and a test fails if the table is stale.
      • traitBuffs.ts parses the lexicon's attribute forms (+N, -N, +N%, =, >=, <=, numbers or (expressions)) and condition +x, and drops what the platform doesn't evaluate yet (resist, immune, rolls). It looks a trait up by its page, or by its slug when that's unambiguous, gates each buff on minRank, and declares the buff vocabulary.
      • buffedCharacter.ts (dnd5e's pattern): getAttributes, getDerivedStats and projectToken derive from the buffed scores, so ability checks, Parry, attacks and HP follow. The rows keep the stored components, a stored minimum stays a floor applied after the buffs, and the breakdown tints the number.
    • Live check (local copy of prod): Kaelan (Large + Huge) shows STR 62 = 38 + 8 + 16 on the sheet. Harvest Golem's map token re-projected to STR 38 / CON 34 / DEX 8. Note: Harvest Golem's Strength carries a hand-added Nort +8 component, presumably a workaround for this bug, which now double-counts until it's removed. Existing linked tokens pick the buffs up at their character's next write (adding the power is one).
  • [x] chat briefs (when using threads) are overly simplistic from generated actions

    • [x] if an action chooses a target, rolls a DC, requests a save, and rolls/applies damage, the brief should be something like ‘I used ${powerName}, DC ${dc}, damage ${damage}, and ${targetName} got a ${targetDc} on their save’. Currently it’ll say something like ‘Fireball’ for an S8 power action.
    • [x] S8 Attacks work better (e.g. ‘Longbow: 20 damage’ is better, but should be ‘Longbow hits a 23 for 20 damage’).
    • [x] S8 ability score rolls just say the ability score alias. Should be something like ‘Strength check: ${dieRoll} + ${mod} = ${result}’
    • Cause: no generated action called api.chat.brief, so a thread's lead bubble fell back to the action's name plus a summary derived from the steps (fallbackBrief).
    • Done, power actions (derivePowerActions): the shared stream preamble gains a guarded setBrief. The generated code rebuilds the brief as each result lands: "I used Fireball, DC 18, 12 fire damage, and Goblin got a 9 on their save". Several targets read "…Goblin got an 8, Orc got an 11, Imp declined to roll on their saves". The power roll is named "DC" when the power has a save (in S8 the roll IS the DC) and is just the roll otherwise. DERIVATION_VERSION 6 → 7, so every stored power action is stale and the existing true-up re-derives it.
    • Done, system8 0.20.0 (projectToken): ability checks "Strength check: 18 + 4 = 22", skills and defensive reactions the same way ("Parry: 15 + 4 = 19"). The die is the total less the known modifier, so the roll keeps its dice row in chat. Attacks read "Longbow hits a 23 for 20 damage"; a spellcasting implement names the tier, and says so when the tier prompt is cancelled.
    • Tests run the generated code with a fake api (powerActionBrief.test.ts, actionBriefs.test.ts). Not seen live in chat.
    • Follow-up (playtest): an ability-check brief draws its die roll as the roll bubble's d20, not as a bare number. A new chat marker, [DIE_FACE:20:6] (shared utils/dieFace.ts), is drawn by MessageContent with the same DieIndicator the roll uses, inline in the text: a picture of the face, with no roll menu. The Transcript and exports read it as the number. System8's check briefs (ability checks, skills, defensive reactions) write it. Live check (local, rendering only): "Strength check: ⬢6 + 0 = 6".
  • [x] mini-me doesn’t have any data for new tokens (maybe for non characters?)

    • [x] example: new map, imported characters, they all look fine in terms of Mini-Me. Then used Generate Monster (AI option), the actual monster is perfectly fine, but the Mini-me has no actions in any category for that token. The Full Character Sheet shows all actions and they work fine. This was S8
    • Cause: Mini-Me fills each of the system's menus from the token's actions by category (ability, attack, power). The AI monster generator wrote its ability checks as ability check and its attacks with no category at all. Seen on Corn Golem 1: Slam and Burst of Kernels uncategorised, Str–Cha as ability check, so every menu was empty. The full sheet groups by whatever category is there, so it looked fine.
    • Done, at creation: every monster flow (AI and template Generate Monster, Insert Monster, tokenFromMonster) writes ability checks as ability, and the AI generator's attacks as attack. Monster features that were filed under ability (now the Ability Scores menu) carry no category.
    • Done, in Mini-Me, for tokens already on maps: ability check reads as ability. Actions with no category, or one the platform doesn't know, get a trailing Other actions menu (miniMeMenusFor), so nothing on a token is unreachable. A known category the system shows no menu for stays out, which keeps S8's deliberate lack of a Reactions menu.
    • Live check (local): impersonating Corn Golem 1, Ability Scores lists STR–CHA and Other actions lists Slam and Burst of Kernels.
  • [x] token rendered as X when valid tokenizer art is preset

    • Done: the first item above (Lord Primrose: an external, CORS-less artwork URL).
  • [x] no End Turn button when not impersonating current token

    • [x] Without mini-initiative, it’s a bit harder for the GM to control flow. When Initiative is active, a primary action button to ‘End Turn’ should appear above the Timeline, along its right edge
    • Done: components/Timeline/TimelineEndTurnButton.tsx, mounted by the map UI. It shows while initiative is active, to the effective GM (the owner, not viewing as a player), on the desktop. It stands 8px above the strip (aboveBottomChrome, since the strip publishes its height) and clears the right sidebar like the strip's +. It hides while the GM impersonates the acting token, because Mini-Me's End Turn is right there. Players still end their own turns through Mini-Me: this one advances the whole table.
    • Live check (local, initiative running on 1.7 Harvest Time): bottom at 982px over the strip's top at 990px, 16px in from the chat sidebar.
  • [x] hover UI obscured by Timeline

    • [x] position of the hover UI should be bottom: ${topCoordinateOfTimeline}, currently is bottom: ${topCoordinateOfGlobalFooter} so it gets overlapped by the timeline
    • Done: the hovered token's portrait panel (HoverTokenPortrait) is anchored at aboveBottomChrome(), the footer plus whatever bottom chrome is up. The Timeline strip publishes its height there, so the panel stands on the strip, and on the footer when the strip is collapsed or absent. It was bottom: 0, under both.
    • Live check (local): Lord Primrose's portrait's bottom at 990px = the strip's top.
  • [x] Mini-Me powers dialog needs grouping controls

    • [x] In System 8, power actions can be grouped by parent power, or by type (e.g. Assault, Defense, Healing). They are actually grouped in Mini-Me’s power actions menu, but the controls to change grouping aren’t currently implemented there.
    • Done: a By power / By type / List control at the top of Mini-Me's Powers menu, the sheet's three modes (powerGroupingModes). By type follows the sheet's rule (groupPowerPoolByType): an action's own subcategories win, else its parent power's type, found through the action ids each power carries. An action of several types appears under each, and one with none goes under Uncategorized, last. The choice is per viewer and remembered (stores/miniMePrefsStore.ts, localStorage).
    • Live check (local, Kazimir): the control switches the menu between the modes. Content note: 29 S8 power pages give type: class rather than a category, so a class power's actions group under "class", the same as the sheet's Group by Category. Fixing that is a content change in system8, not a platform one.
  • [x] hide grid!

    • [x] On a data level, the grid layer for any given map should have a ‘visible’ boolean (default yes).
    • [x] In the Edit Grid and Edit Map dialogs, show a toggle for Show Grid.
    • [x] In the Edit Layers modal, show an eyeball icon for each layer that shows or hides the entire layer for all users (default: all layers shown). This can hide the grid layer (or any other layer).
    • Done, data: map_layers.visible (boolean, default true) on every layer. Migration 0060_map_layers_visible (generated, journalled, additive) runs at boot; the sqlite mirror is in db.ts. The layers PATCH takes visible even on a protected layer, since the Grid is protected and hiding it is the point. It refuses visible: false on VBL / FOW (409): hiding that would lift the fog off the map for every player.
    • Done, map: a hidden layer isn't drawn (return null in place, so the layers above keep their depth), and a hidden Foundation hides the map's base image. A hidden Grid still draws while it's being adjusted, since there would be nothing to line up otherwise.
    • Done, controls: an eye on every row of Edit Layers (disabled on VBL / FOW, with the reason in its tooltip). Show Grid in the Adjust Grid panel applies at once, apart from its Save / Cancel, which are about geometry. In Edit Map it's form state, written on Save, in edit mode only, since a new map has no Grid row yet. Both go through useMapLayers().setGridVisible.
    • Live check (local, 1.7 Harvest Time): hiding the Grid through the route removed the WebGL grid (the map's art has its own painted grid, which stays). Other open pages updated live. VBL / FOW answered 409.
  • [x] Chat Settings: move these to a new section in the Settings UI.

    • Done: Settings gains a Chat category (avatar, display name, chat colour, voice-to-text, the dice roller's own settings), saving as it changes like the rest of the page. They are the account's settings, not a campaign's; the campaign in context only files an uploaded avatar.
    • The separate Chat Settings tab is retired from the right side of the play bar. A layout saved with it open shows Settings on Chat. Everything that opened chat settings now opens Settings → Chat (utils/openChatSettings.ts): on the desktop map that's the Settings tab, and anywhere else (the phone's chat gear, which used to open a modal) the Settings page at /settings?section=chat. That includes the transcript's "turn on voice input" button. SettingsPage takes a section, which the Settings tab passes through.
    • Live check (local): no Chat Settings tab; Settings → Chat shows the form.
  • [x] Chat Settings: all inputs that auto-save should have status indicators (loading, save succeeded, save failed). Inputs in the Character Sheet, for instance, have this feature, but none of those in Chat Settings seem to.

    • [x] Also, the Display Name in Chat Settings should have a manual submit button and a discard button, similar to the name and slug inputs in Character Sheet.
    • Cause: the form was one debounced write of everything that differed, with a single "Saved" line beneath it. That line named no field, and a failed save showed only an alert at the top.
    • Done: each input saves on its own through the shared async-field lifecycle (useAsyncFieldSave), the one the sheet's inputs use: saving, saved, failed with a retry, each patch naming only its own setting. Display Name is the sheet's AsyncTextInput, with a Save / Discard pair while it differs from what's saved; Enter or blur commits, Escape discards. Chat Text Color saves once changes stop (600 ms), whether dragged or typed. Voice-to-Text and Microphone save on the toggle or the pick. Avatar shows the upload, then its save, beside its heading. The microphone dropdown portals onto the theme's TOP layer (no call-site zIndex, per dropdownLayer.guard), so it clears the Settings sidebar or a floating window. The whole-form autosave (chatSettingsAutosave.ts) and the modal's Save button are gone.
    • Live check (local): typing in Display Name brings up Save / Discard, and Discard restores the saved name without writing anything.
  • [x] Roll Initiative: the Timeline handles initiative but has no controls for it. Add a “Roll Initiative” button, same treatment as the + button in Timeline, but to the left of the + button, with the ‘swords’ tabler icon.

    • [x] When Initiative is active, this becomes an End Initiative button.
    • Done: a GM-only control in TimelineTrack, the + 's full-height treatment one control width to its left. It's IconSwords (Roll initiative), and while initiative runs a red IconSwordOff (End initiative). Rolling and ending were two callbacks inside ChatLog; they're now hooks/useInitiativeControls.ts (combatants only, one roll per token, the chat announcements, the Combat Log's round 1 and first turn), shared by the chat panel's buttons and this one.
    • Live check (local): End initiative ended it and the button became Roll; Roll put 7 combatants in and posted "Combat begins · Round 1".
  • [x] Right-side tabs: swap the icons for Initiative Tracker (list-numbers) and Combat Log (swords).

    • Done: the Initiative Tracker tab is now swords (the same as the Timeline's Roll Initiative) and the Combat Log is the numbered list. The brackets are read as the icons each tab had, which is what v0.113.0 gave them. Live check (local): the tabs draw swords and list-numbers respectively.
  • [x] Mini-Me: in the … menu next to the x button, add ‘Edit this character’ which loads the route to edit the character on a standalone page in a new tab.

    • Done: in MiniMeTokenMenu, for a linked token. It's a real link (characterHref, target="_blank", rel="noopener noreferrer"), so middle-click and copy-link work too. Live check (local, Kazimir): the item links /characters/kaz in a new tab.
  • [x] Mini-Me: change the … icon within the portrait to the tabler icon ‘photo’. Two … icons is confusing.

    • Done: MiniMeArtworkMenu's trigger is IconPhoto; the token menu keeps the …. Live check (local): the portrait draws photo.
  • [x] Campaign Context

    • [x] It’s not clear which campaign is in context at any given time.
    • [x] When navigating to a character page, navigating back to a campaign context page (like /play) is unintuitive; users often have to manually reselect the campaign
    • [x] Top bar:
      • [x] Add a fixed top bar to all routes that aren’t /play
      • [x] same visual treatment as PlayTopBar, but different contents:
        • [x] Leftmost: breadcrumbs, with current Campaign Name (when in context) which links to that campaign page, then a down caret which spawns a menu to switch to any other campaign; then the current Character name (when in context), same treatment
        • [x] Rightmost: Clerk profile icon and settings gear icon (already present on these pages, just put them in the fixed bar)
        • Done: Layout/ContextTopBar.tsx, on every desktop page off the map, right of the rail. It has the play bar's look: 48px, body background, bottom border (PlayTopBar itself was retired in v0.97.0; the play bar is its successor). The phone keeps its bottom bar. The campaign crumb links to the campaign's page and its caret lists every campaign; the character crumb links to the sheet and its caret lists the campaign's characters, or yours with no campaign. The campaign in context (contextTopBarModel.topBarContext) is the route's, or on a bare sheet the character's home campaign, the one that sheet reads its rules from. Elsewhere (library, systems) there is none and the bar says nothing. The account corner (gear + avatar) is the bar's right end instead of floating fixed in the corner. Live check (local): /campaign/brave-new-world/characters/kaz shows "Brave New World ▾ › Kazimir ▾"; /characters/kaz shows the same, read off Kazimir's home; /library shows none.
      • [x] in PlayTopBar, no changes
      • [x] in the ‘nub’ in /play routes:
        • [x] Change the collapsed label, currently ${mapName} ${connectedPlayerCount to ${campaignName} ${mapName} ${connectedPlayerCount}
          • [x] Use a subtler color for the campaign name to visually separate it from the map name
        • [x] In the expanded view, above the map selector, show a header with the campaign name, and a small button to “switch campaign” which just jumps to /campaigns route
        • Done (MapNub): collapsed, it's the campaign dimmed, then the map in bold, then the count; the campaign truncates first. Expanded, a header names the campaign above the map selector (now labelled just "Maps"), with a small Switch button that goes to /campaigns. The button reads "Switch" with "Switch campaign" as its tooltip and accessible name, because at the popover's 320px the full words squeezed the campaign's name to "Brave New Wo…". Live check (local): "Brave New World · 1.7 - Harvest Time · 1"; Switch went to /campaigns.
      • [x] Links to character detail pages within a campaign context (e.g. /play/:campaignId/:mapId or /campaigns/:id) should point to /campaigns/:id/characters/:id to keep the campaign context.
        • Done: the campaign-scoped sheet is a page again. It's the app's existing singular form, /campaign/:c/characters/:ch (plus /sheet and /wiki); there's no /campaigns/:id/… family. v0.90.0 had made it redirect to the bare /characters/:ch ("characters do not vary per campaign"), and this reverses that so the campaign stays in context. The sheet reads that campaign's rules, and the id→slug URL rewrite now covers both segments. characterHref takes an optional campaign, and useCampaignCharacterHref binds the route's. It's used wherever a character is linked from inside a campaign: the map's party, target and character panels, a token's sheet and actions, Mini-Me's Edit this character, the campaign's character list, character-to-character navigation on the sheet, and the character wiki link. Links outside a campaign (the home page's characters, a party page) stay bare.
        • [x] This will allow the campaign breadcrumb to populate on the character page
        • [x] Clicking ‘play’ on any /campaigns/:id/… route will remember the campaign id (and should also automatically restore the most recent map id)
          • Done: each campaign's last map is remembered per device (purpleCat:lastMapByCampaign, 7 days, beside the existing last route). Play on a campaign's page, or on a sheet in its context, resumes that campaign's map, or its page when it has none. Live check (local): from /campaign/brave-new-world/characters/kaz, Play went to /play/brave-new-world/1-7-harvest-time.