v0.107.0 — Playtesting fixes
Shipped 2026-09-22 — built on version/0.107.0; the merge hash and tag are
in the changelog. Followed by the hotfixes
v0.107.1 (the dnd5e bump) and v0.107.2.
The bugs and small asks a playtest fills a notebook with, taken as a release of their own so the System 8 sheet does not carry them. Most are one fix each; four are a subject with a shape — slug uniqueness, the dropdown-over-modal layering, concentration as a timed effect, and performance monitoring — and each says what it needs below. The roadmap page is Playtesting fixes.
Layering (the one that keeps coming back)
- [x] A floating context window (
FLOATING_WINDOW, 400) sits under an open sidebar (SIDEBAR, 600), so undocking anything while a sidebar is open reads as nothing happening — found in v0.106.0's playtest with the character sheet and reproduced with the Character context, so it is the band order rather than any one context. Decide what a floating window is above (it is a window the user put there, so: above the sidebars it came from, below modals) and move the band, with the z-index guard's generated variables following.- Done:
FLOATING_WINDOW650 /FLOATING_WINDOW_RAISED660 — aboveSIDEBAR, below the mobile chrome, footer, context menus, modals and dropdowns; pinned inzIndex.test.ts.
- Done:
- [x] In the UI that pops up when clicking the 'nub' in /play mode, the dropdown to choose map doesn't work (probably a z-index issue)
- [x] 'on' field in new TimedEffect modal dropdown doesn't work (z-index)
- [x] we covered this many releases ago…all dropdowns should be on a layer above all modals…this should never happen
- Done: both themes open every Combobox (Select, MultiSelect, Autocomplete, TagsInput) and input picker Popover on
TOP; the ~130 call sites that passed their own band (the nub's 1000 inside a 1300 popover, the modal's 2100 inside 3000) pass none now, anddropdownLayer.guard.test.tsfails the build if one does.
- Done: both themes open every Combobox (Select, MultiSelect, Autocomplete, TagsInput) and input picker Popover on
- [x] we covered this many releases ago…all dropdowns should be on a layer above all modals…this should never happen
The rest, as the notebook has them
- [x] Mobile - when not impersonating a token, can’t find main map (it’s the permissions bug: Me tab doesn’t show the correct characters, i.e. the ones with play or edit or owner access; in this case, it showed nobody)
- Done:
/tokens/impersonatable(the list the Me tab's chooser shows) read only the legacy permission tables, so a token or character shared with a custom role — a grant with no legacy row — could be played but was never listed. It now asks each token's grant cascade too, counting only grants on that token or its character (a campaign member's blanketplayon every token stays out of "your characters"). Built-in roles through the share UI already worked and are now tested (impersonatable.grants.test.ts). The Me tab header switcher and the desktop rail still list only tokens you placed; widening them is a separate decision (a GM's rail would fill with every player's token).
- Done:
- [x] When two tokens (large?) overlap, their faction rings cut through each other—the token on top should entirely obscure the one behind
- Done: tokens were
zIndex × 0.01apart in depth while a token's own overlays reach +0.35 (the faction ring at +0.17), so a lower token's ring was nearer the camera than the next token's art and drew over it. Each token now gets its own depth slot, by rank in the layer's zIndex sort, and its whole overlay stack is squashed into that slot by scaling the group along z (invisible under the top-down orthographic camera). Layers keep their own bands (background 0.60–0.64, objects 0.80–0.88 under drawings at 0.9, tokens 1.0–1.8).utils/tokenStacking.ts. Not verified in a live render — the browser tab could not be brought to the foreground.
- Done: tokens were
- [x] show the usernames (including player-abcd fallbacks) in admin/users
- Done: a Username column; someone with no username shows the
Player-2abcfallback in italics, with a tooltip saying that is what everyone else sees.
- Done: a Username column; someone with no username shows the
- [x] In the UI that pops up when clicking the ‘nub’ in /play mode, the dropdown to choose map doesn’t work (probably a z-index issue) — duplicate of the Layering item above
- [x] create map foo: its slug is foo. create second map named foo: its slug is also named foo, no warning about slug or name collision.
- Done: the second "foo" in one campaign was already
foo-2(reproduced locally; in a different campaignfoois correct, slugs are per campaign) — what was missing was saying so. A write that moved a name-derived slug now answersslugAdjusted: { wanted, slug }and the client shows an "Address adjusted" notice. Copying a map carried the source's slug and 500'd on the unique index when the target had it (including copying into the same campaign); it now derives its own. An imported map archive had no slug; it gets one.
- Done: the second "foo" in one campaign was already
- [x] exported a character someone else owned, then imported. apparently the permissions are part of the export, and I don’t own this new one. export/import shouldn’t preserve owner…the owner is whoever made it (in this case, the importing user, me)
- Done: every import path already creates the row as the importer; what rode along was the source's version group. A
.catexport carriedversionGroupId/isActiveVersion, andPOST /api/charactershonoured a body that named a group — so the import joined the other owner's group, and the list (one character per group) showed their active version instead of yours. The export now omits both (and the trash bookkeeping), and create always starts a character in its own group. The round trip is a route test that fails on the old code at all three steps. The exact client path in the playtest is not identified — the in-app importers parse only build fields — so both ends are closed.
- Done: every import path already creates the row as the importer; what rode along was the source's version group. A
- [x] in the share modal, the username in ‘add by username’ is confusing LastPass into thinking it’s a password field. please add necessary attributes to prevent this
- Done:
utils/notALoginField.ts—autoComplete="off"plus each manager's own opt-out (data-lpignore,data-1p-ignore,data-bwignore,data-form-type="other"), spread onto the share modal's "Add by username" and the new-conversation username picker, which had the same shape.
- Done:
- [x] when a character is renamed, the slug should be renamed (with collision avoidance); when this is committed, the history API should update the current url if it's using a slug
- Done: a rename moves a slug that followed the old name — itself, or itself with a collision suffix, or none at all — to the new name's, unique among the owner's characters (
Ada Lovelace→ada-lovelace-2when taken, with the "Address adjusted" notice). A slug the owner typed stays theirs; a PATCH that setsslugitself decides it. The old slug's tenure is released into history, so an old link says where the character went. The URL: the updated slug reaches the slug store, anduseCanonicalUrlreplaces the slug segment in place (historyreplace, no new entry).
- Done: a rename moves a slug that followed the old name — itself, or itself with a collision suffix, or none at all — to the new name's, unique among the owner's characters (
- [x] slug uniqueness is not being enforced for many mutations. Make it a hard constraint in the database, and add guards in the API and UI to avoid slug collisions. Default is to auto-rewrite the slug to be unique (e.g. foo -> foo2) then call attention to it, rather than blocking edits.
- Done. The database already had the constraint: unique campaign slugs, map slugs unique per campaign, and character slugs unique per owner, in both dialects.
slugBackfill.test.tsnow pins those indexes. The leaks were rows that stood outside it. Six write paths left the slug null or skipped the check: character YAML import, save-version, map archive import, map copy, a legacy trash restore whose old slug had been taken, and the name-derived paths. Each now derives a unique slug (deriveSlug). A boot backfill gives every existing slug-less campaign, map and character one, idempotently. This matters most on Postgres: its baseline indexes areNULLS NOT DISTINCT, so the second slug-less row in a scope failed its insert. When a name-derived slug has to move, it is auto-rewritten (foo→foo-2) and the client shows an "Address adjusted" notice. A slug someone types into a slug field still gets v0.91.0's alternatives and takeover prompt rather than a silent rewrite, because they asked for that address by name.
- Done. The database already had the constraint: unique campaign slugs, map slugs unique per campaign, and character slugs unique per owner, in both dialects.
- [x] feature to be specced out: pop up reactions when you get targeted. (For actions designated a reaction)
- Specced: Reactions — a
triggeron an action from a closed lexicon (targeted,hit,damaged,leaves-reach,casts,ally-targeted; targeting is not the only one), prompts to whoever controls the token, spent per round, feats surfaced by the module the way powers are. Six decisions (RX-D1…D6) with recommendations; the one that shapes it is whether a triggering action waits (Shield needs it to). - Need a 'trigger' field with some standard lexicon
- Feats and non-powers that allow reactions need to surface them
- Is targeting the only trigger?
- Specced: Reactions — a
- [x] performance monitoring: time to implement. Multiple users complaining of crashing Chrome tabs. Plan A: use something like Sentry. Plan B: implement our own logging to identify systemic issues.
- Done, both plans: Sentry (
@sentry/react, inert withoutVITE_SENTRY_DSN, environment from the hostname, 10% production traces, no replay, ids only —src/monitoring/) and our own unclean-exit trail, because an out-of-memory tab crash runs no JavaScript and no in-page reporter can see it: a heartbeat record with memory samples, removed on a cleanpagehide, reported by the next page load as likely-crash or likely-discard. Source maps upload once CI hasSENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT. See Frontend monitoring.
- Done, both plans: Sentry (
- [x] Magic Arrow spell (for example) in System 8 has failing Actions due to the damage defined as "half the missile's base damage"
- Done, all three: (1) document text never breaks the code —
metaNotesand the wiki link are JSON-encoded, and an expression that is not JavaScript is never written into code: the action says it in chat, "apply by hand"; (2) a lexicon for damage based on a base —base, "the missile's base damage", with half / double / twice / triple / a third of / a quarter of — normalised to code overbase(normalizeDamageExpression,packages/shared/src/utils/powerDamage.ts), andbaseusable in code expressions too; (3) the writer rolls the character's missile attack (pickMissileAttack: the first ranged attack by type, name or range) at their rank forbase, then applies the effect's share — or, with no ranged attack, says so instead of inventing a number. Every Flame/Frost/Shock/… Arrow compiles now; a test compiles each generated case.DERIVATION_VERSION2 → 3, so every stored power action re-derives on its next true-up and the broken arrows already on characters are replaced. - 1: escape breaking characters like the apostrophe
- 2: come up with a standard lexicon for damage figures based on some "base" (in this case, you attack with an arrow, inflict the normal damage that arrow would do, then the power's effect is based on that amount of damage)
- 3: handle these cases in the action writer
- Done, all three: (1) document text never breaks the code —
- [x] ‘on’ field in new TimedEffect modal dropdown doesn’t work (z-index) — duplicate of the Layering item above
- [x] add context menu item to add TimedEffect to token under mouse cursor
- Done: "Add timed effect…" on a token's right-click menu opens the timeline (the strip, or the phone's Timeline drawer) and its effect dialogue with that token already chosen — through a small request store, since the timeline is the one component that owns the dialogue.
- [x] "nub" enhancements: (the current map / connected players indicator in the top center of the /play map):
- Underneath the current map indicator / quick map change selector, are two buttons, occupying equal width and the same row:
- Edit Map: opens the edit map sidebar for the current map
- New Map: opens the Create New Map UI in the sidebar
- Remove "edit current" button from the bottom of the UI
- Done: Edit Map and New Map share one row under the switcher; New Map opens the create form as a sidebar page (
new-map, the form split out of the modal asNewMapForm) that closes itself once the map exists. "Edit Current Map" is gone from the bottom; Invite Players stays there.
- Underneath the current map indicator / quick map change selector, are two buttons, occupying equal width and the same row:
- [x] Currently, when a token is selected/impersonated, the keyboard arrow keys move that token by 1 square. This should continue, when the cursor is inside the map area. If it's inside a sidebar, they should instead scroll that sidebar.
- Done: one passive pointer listener remembers what the pointer is over. The sidebars, the Timeline strip and floating context windows are marked
data-arrow-keys="scroll"; with the pointer inside one, an arrow key scrolls the nearest scrollable element under the pointer (or does nothing when nothing scrolls) instead of moving the token. Over the map — or before the pointer has moved at all — the token moves as before.utils/arrowKeyScope.ts.
- Done: one passive pointer listener remembers what the pointer is over. The sidebars, the Timeline strip and floating context windows are marked
- [x] Bug: used Generate Monster (with AI)
- On the map, the token appeared with art, as expected, although the background wasn't being clipped, indicating a tokenization issue
- In the Edit Artwork modal, the tokenizer and portrait previews were empty, seeming to indicate the default image source wasn't applied to either
- The image source preview was the 'no image' default, despite there obviously being art
- Dragging the image source to the tokenizer or portrait failed to set the token/portrait art, instead bugging out and deleting the image source
- Expected behavior: when creating a generated monster, pressing Insert Into Map:
- The generated art becomes the first source art
- Said source art is visible in the Edit Artwork Modal (preview thumbnail)
- The source art is auto applied to the tokenizer, which automatically runs face detection to set pan/zoom. Tokenizer clips to a circle.
- The source art is auto applies to the portrait art.
- All of this is accurately represented in the Edit Artwork Modal.
- Done. Cause: the AI image arrives as a
data:URL (Gemini, gpt-image) or a provider URL that expires (DALL·E, Grok), and nothing uploaded it; the token route stripsdata:out ofartworks[]but not the legacyimageUrl, so the token was stored with an artwork that had no picture and a legacy image that did — the map drew the legacy image unclipped (resolveMapTokenImagefalls back to it untokenized), and the modal's source, tokenizer and portrait were all empty. Every Generate Monster tab (AI, algorithmic, System 8) now runs its art through Generate Encounter's ingestion before the token exists: uploaded to our storage, face-detected and tokenized (mapAppearance: tokenized, clipped), and — new optionsharePortrait— the one source art fills both the token and the portrait slot. Best-effort at each step; a failure still inserts. Not reproduced: dragging the source onto a slot deleting it — no drop path in the Phase 1d modal removes an artwork; with the source now carrying its image the drag assigns as designed, but if it recurs it needs a fresh report (which editor, which slot).
- [x] Add 'concentration' duration handling to TimedEffect. When the duration of a power/spell is explicitly 'concentration', add a TimedEffect tied to the caster. Add a special UI in the bottom center of the map, when impersonating a character who is concentrating, that shows "Concentration on ${powerName}", with a button to End Concentration.
- Extend the Action API to be able to set concentration status for a target.
- Extend the model of Conditions so they can have a function attached to them. When the token is mutated, and that condition is active, call the function (token => void).
- Extend the fixtures for 5e/S8 conditions. Conditions that end concentration such as Dying or Unconscious will have a function that automatically ends concentration.
- Done, in three commits. Model:
timed_effects.concentration(pg 0048); a concentration effect is on its caster, one at a time (a new one ends the last);POST /api/maps/:mapId/concentration/:tokenId/endfor whoever may play the token. Action API:api.timeline.concentrate({ name, duration?, tokenId? })andapi.timeline.endConcentration(tokenId?); derived System 8 powers whose duration says concentration concentrate their caster (a self-range one's effect IS the concentration). UI: "Concentration on …" with End Concentration, bottom centre of the map while the impersonated token concentrates. Conditions with a function:conditions.on_token_change(pg 0049) —(token) => void, run in the server sandbox after every token PATCH and when a condition is added, withtoken.endConcentration(); the platform defaults and both modules' fixtures give it to the conditions that end concentration. Module side, committed on local branches and not yet published: dnd5e 0.18.0 (version/dnd5e-0.18.0— Incapacitated, Paralyzed, Petrified, Stunned, Unconscious, Dying, Dead; its compiled concentration spells callconcentrate; fixture v11) and system8 0.18.0 (version/system8-0.18.0— Stunned, Paralyzed, Frozen, Dying, Dead; fixture v10). Both add an entry at the top of vtt-modules' shared CHANGELOG, so the second to merge resolves that one conflict.
- [x] S8 spell "Color Pool" didn’t draw an area. Duration effects with an area should use the Action API functions to create a drawing of that area. Additionally:
- Make it possible to tie a TimedEffect to a drawing
- Add a link[] array to TimedEffect to link any number of drawings or tokens to it.
- For automatic ending of the effect, still rely on the function.
- In cases like this, automatically create a TimedEffect tied to the drawing. Example:
- Blink the Fairy casts Color Pool on x=10, y=12, circle 2.
- The Action draws a circle 2 at the location.
- A TimedEffect is created (duration: concentration). It is linked to the drawing, and to Blink.
- If Blink ends concentration, the drawing and the TimedEffect will both end.
- When mousing over a token or drawing that is linked to a TimedEffect, show the TimedEffect hover/popover on the drawing/token.
- Done. Why nothing was drawn: a derived power drew its area only for a counted duration, and Color Pool is concentration. Now every lasting area is drawn (counted, concentration, encounter, sustained…), and the drawing's id is kept. Links:
timed_effects.links(pg 0050) —[{ type: 'drawing' | 'token', id }], through the route,api.timeline.createEffectandconcentrate. A concentration power's area is tied to the caster's concentration effect; any other lasting area gets an effect of its own tied to the drawing. Ending an effect — expiry, End Concentration, a condition, the timeline's End or Remove — deletes its drawings on the server (same map only), and clients refetch drawings on the effect's announcement; the old per-browser round timer is only a fallback now. Hover: a drawing's tooltip and a token's hover pill show the live effects tied to it (a token: also those it bears) — name, colour, concentration, time left. The example runs as written: Blink casts Color Pool → circle drawn → concentration effect on Blink tied to it → End Concentration removes both. Not done here: the 5e module's area spells still draw with a round timer and are not yet tied to an effect.
- Make it possible to tie a TimedEffect to a drawing
- [x] If a user with Edit/Owner for a token uses Heal/Harm mode (either pressing H or using the heart button in the hover UI), they should auto-apply the heal or harm.
- Done: decided per target token — the campaign owner, or anyone whose level on that token is
edit(what Owner and Edit resolve to) — and for heal as well as harm (heal used to always go to the GM). The card still appears, already approved; the HP written is the executor's own result, floored at 0 for harm and capped at max for a heal, and the server's auto-Dying removes Dying when a heal crosses 0. Anyone else still sends the GM an approval card.
- Done: decided per target token — the campaign owner, or anyone whose level on that token is
- [x] In current map, it says 5 connected but only shows 3 (maybe because of all the username renaming?)
- Done: not the renaming — presence is per connection, so one person in two tabs (or a reconnect the server has not reaped yet) is two entries. The panel's list always de-duplicated by user and the nub's count did not.
useConnectedUsersnow returns one entry per person (latest connection wins), so the count and the list are the same number.
- Done: not the renaming — presence is per connection, so one person in two tabs (or a reconnect the server has not reaped yet) is two entries. The panel's list always de-duplicated by user and the nub's count did not.
- [x] TimedEffect: add a metadata object, key-value pairs, shown verbatim to the user in the popup. Example use case: most duration effects have a DC of some sort. This could be set in metadata, so users can mouse over and see that an area duration effect has a DC.
- Done:
timed_effects.metadata(pg 0051), up to twenty key-value pairs kept as written (numbers and booleans as the text they read as). Through the route,api.timeline.createEffect/concentrate, and the effect dialogue's new Details field (oneKey: valueper line). Shown on the Timeline's card, in a drawing's hover tooltip and in a token's hover pill. A derived save-ends effect fills it in itself:Save: STR ends,DC: <the power roll>.
- Done:
- [x] system defect (not platform; to be moved): magic mushrooms has a save to end, but no duration, so there’s no TimedEffect
- Checked against the bundled document (
elementalist/magic-mushrooms:save: Wis ends, noduration): not a content defect. Since v0.104.2 a save that ends is itself the duration, and the generator today places one save-ends effect per target (Wis, against the power roll, asked on the bearer's turn). The playtest's copy was derived before that; this release'sDERIVATION_VERSIONbumps (2 → 6) re-derive every stored power action on its next true-up. It needs a target, by design: cast at nobody, a ranged save-ends power has no one to put the effect on.
- Checked against the bundled document (