Skip to content

v0.35.0 - Preparing for UI Overhaul

More Misc

  • [x] When a sticky notification pops up in the /play view, it persists when leaving the /play route. This is probably the right behavior for general sticky notifications, but it would be nice if notifications could be flagged as route-specific (initiative turn start condition notifications should be) so they auto-close when leaving the route. (Scoped the cited case — the initiative turn-start conditions notice (useTurnStartConditionsNotice, which lives in the /play UI). It now hides its sticky notice on hook unmount (i.e. leaving /play) and when combat ends / there's no active turn, so it doesn't follow the user to other routes or outlive the encounter. General sticky notifications (e.g. "token move failed to save" errors) still persist by design. A fully-general "route-specific" flag for arbitrary notifications can follow if another case needs it. Contract test added.)
  • [x] While dragging a large image directly onto the Background layer does use the LOD flow correctly, uploading a large image in the Source Artwork Editor incorrectly turns the large image (e.g. 4k x 4k) into a small, low-res, compressed image more suitable for a token. High-res images on the Background layer, regardless of how they become token art, must be kept at full resolution (with mild webp lossy compression), along with 2 levels of LOD. They should not be processed as non-Background tokens. (Note: this is like the 5th time this error has recurred. Please ensure the fix is completely final this time.) (Root cause: SourceImageEditorDrawer's upload ImageDropZone hardcoded category="token", and ImageDropZone pre-compresses the file in the browser to that category's cap (token = 512px) before it's ever uploaded — so a 4k Background image was downscaled to 512px client-side and the full-res original never reached the server's skip-resize + LOD path. (The drag-onto-map flow was fine — it routes through TokenModal with a layer-derived mapObject category and uploads the original.) Fix: the drawer now derives the category from its layerId — a defined non-token map layer (background/objects) → mapObject (4096px cap, kept full-res + 2 LOD server-side); absent layerId (character/monster artwork — the drawer is reused there) stays token, so portraits are unchanged. The other drawer sources (URL/Library/Search/genai) already pass hosted URLs (no client compression), so only the file-upload path needed it. Contract test added.)

Bugs

  • [x] When the token hover UI is rendered on the left, its position fails to include the width of the top-level nav, so the portrait and properties are clipped on the left edge by that amount. (HoverTokenPortrait is position: fixed (viewport coords), so the popped-left left: leftOffset of 0 (no left panel) sat under the always-reserved desktop nav rail. leftOffset now adds SIDENAV_COLLAPSED_WIDTH (the reserved rail width) + the left context-panel width, so the portrait clears the nav. This also corrects the pane-midpoint used by shouldPopPortraitLeft. Contract test added.)
  • [x] Request Roll (in the token hover UI) is failing. Investigation notes:
    • Selecting characters and a monster shows only the monster's rolls
    • Selecting only the characters STILL shows the monster's rolls
    • Seems like the request roll button in hover UI sometimes shows the impersonated token's rolls, not the hovered target's rolls
    • During later investigation, consistent behavior is:
      • No impersonated token: Request Roll always empty
      • Impersonated token: Request Roll always shows impersonated token's rolls, not hovered target's rolls
    • Correct behavior:
      • Hovering over a single token: show's that token's actions
      • Select multiple tokens: shows the union of selected tokens' actions
      • Whether an impersonated token is present or not shouldn't change the displayed actions, as they are the target's actions not the impersonated token's actions
    • Fixed (v0.35.0): both TokenHoverUI (single hover) and MultiSelectActionBar (multi-select) built the Request Roll menu from impersonatedActions instead of the target token(s)' own actions — so it showed the impersonator's rolls, or nothing when no one was impersonating. Single hover now lists the hovered token's own roll-capable actions; multi-select lists the de-duped union of the selected tokens' rolls and asks each token to roll its OWN same-named expression (so heterogeneous modifiers stay correct). The Target buttons still use the impersonated token's actions (you target others with your actions). Source-contract tests added in both components.
  • [ ] No way to change maxHP in edit token modal. Changing HP only changes current. Properties of type "range" should have both inputs. (Skipped v0.35.0 — the Edit Token modal is being removed (see UI Overhaul → Modals), so this is moot there; for the record, the modal's Properties tab already renders both current (slider + pencil) and Min/Max inputs for range props. Revisit only if max-HP editing turns out to be missing on the replacement surface — the Token Context / Impersonated Token Context.)
  • [x] GenAI artwork "AI Generated" picker shows no Gemini models in prod (only OpenAI), even though all Gemini models are enabled in Settings and Gemini works in other AI contexts like Generate Monster. Root cause + fix (v0.35.0): the picker fetches GET /api/ai/image-models — the platform's single source of truth for image models — through the authFetch the platform hands the genai module, but that request sent no x-ai-provider / x-ai-key headers. The server's resolveImageProviderKey then only resolved providers that have a server env var (system-level): OpenAI is system-level in prod, but Gemini is user-level (its key lives only in the browser's AI config), so Gemini was filtered out of the list. It looked fine locally because the dev box also has a GEMINI_API_KEY env var, and fine in Generate Monster because that path (/api/ai/models/:provider) already forwards the user key. Verified live in prod via the Chrome extension: replaying /api/ai/image-models with the Gemini header returned all six Imagen / Nano-Banana models, confirming the server already supports it. Fix: new useImageGenAuthFetch hook wraps authFetch to inject the user's image-gen provider key (getImageGenAIHeaders / pure pickImageGenProviderHeaders, mirroring the server's IMAGE_GEN_PROVIDERS) onto /api/ai/... requests only — never onto uploads, asset fetches, or the genai module's own /api/modules/genai/* generate calls (generation resolves its own key independently). Applied to all three artwork surfaces that feed a genai source/extender: SourceImageEditorDrawer (the reported path), TokenArtworkEditor (which previously passed no authFetch at all — a separate latent break), and BackgroundTokenBar. Unit + source-contract tests added. NOTE: a single request can only unlock ONE user-level provider (the x-ai-* headers are singular); the 2+ user-level providers case is tracked in CHANGELOG → Next Version: v0.48.0 → "Multi-provider image-gen keys".

Misc

  • [x] When "New Character" is clicked from a /campaigns/:id route, pre-populate the new character's campaign property with the current campaign. (Added a defaultCampaignId prop to NewCharacterModal: when set, the Campaigns multiselect pre-selects that campaign each time the modal opens — re-applied via an effect keyed on the primitive id so it survives create/cancel resets but won't clobber an in-session deselection. MapList (the /campaign/:id route) passes the current campaignId; the /characters and /characters/:id callers omit it, so they still open with no campaign pre-selected. Functionally MapList already defaulted the link, but the campaign is now visible/selected in the modal. RTL tests: pre-selected → onCreate gets ['c2'] untouched; omitted → undefined.)
  • [x] In /play, move notifications to the bottom center of the map, as they're hard to notice in their current position. (ActionNotificationRenderer no longer anchors each bubble to the originating click point — it now stacks every notification in one fixed, bottom-centered, click-through column (left: 50% + translateX(-50%), mirroring the multi-select action bar), lifted clear of the bottom token/action bars (bottom: 80, mobile 128 via useScreenSize). Newest sits at the bottom; older ones push up. Store API unchanged — the now-vestigial x/y coords still flow through but are ignored for layout. Source-contract test added. Follow-up (live test): the visible top-right toasts were the SEPARATE @mantine/notifications system (turn/condition reminders, errors, etc.) — moved its global provider in App.tsx from position="top-right" to "bottom-center", so both notification systems now sit bottom-center.)
  • Character Context
    • [x] In the Character Context, remove the backgrounds of the ... and zoom button in the fixed header, and extend the tab bar's underline all the way to the right, underneath said buttons (TokenContextActions gained a flat prop — the tab-strip "..." and focus/zoom-to-token buttons now render with variant="subtle" and no dark circle (matching the adjacent filter button); the portrait-overlay usages keep the filled dark circle for legibility over artwork. The actions box moved INSIDE Tabs.List (pushed right with marginLeft: auto), so the tab underline now spans the full width underneath them. viewBuildTabs + TokenContextActions contract tests updated.)
    • [x] In the Character Context, move the Filter button from the fixed header to the upper right of the Actions section (ActionsPrefsControl (filter/group/sort) moved out of the sticky breadcrumb's right-aligned slot into the Actions section header — PlayModeView gained an actionsPrefsControl slot prop, rendered upper-right in a space-between Group next to the "Actions" title; CharacterContext passes the control there instead of via the breadcrumb. The breadcrumb's now-dead actionsExtras prop + slot (and unused ReactNode import) were removed. So the filter sits with the actions it filters, visible whenever the Actions section is. Contract tests updated across breadcrumb/CharacterContext/PlayModeView.)
    • [x] In the Character Context, remove the invisible spacer that's indenting Powers to the right, but not Feats (Every power row used to lead with a column — an expand caret for action-powers, or an invisible <Box w={22} /> spacer for the rest — pushing all power names ~30px right of the feat names. Removed that leading column so power names sit flush-left, aligned with feats; the expand caret (action-powers only) moved to the right end of the row. inlinePowerActions contract test updated.)
    • [x] Remove the various pills/badges next to Feats and Power (Removed the feat-type badge and the power Rank N / school badges from the Feats and Powers list rows in PlayModeView — each row is now just the (dotted-underline) name. The power metadata badges still render inside each derived action's (i) info popover when a power is expanded, so the info is still reachable on demand. New PlayModeView.listBadges contract test.)
    • [x] When equipment is clicked, show the detail page from the wiki in a modal for that equipment, in a similar manner to feats and powers in this same UI (EquipmentSection gained an optional onShowDetail callback; PlayModeView passes one that opens the shared feat/power wiki detail modal keyed off the item's sourceUrl (the wiki link saved at import). Items with no sourceUrl fall back to the description, exactly like a feat/power with no body. Made the modal's lazy fetch robust to both absolute-path links (feats/powers) and full-URL sourceUrls. EquipmentSection's local properties modal stays as a fallback for callers that don't pass onShowDetail. New EquipmentSection.wikiDetail contract test.)
  • [x] Import Character creates ability score actions in the form "Str", "Dex", etc. Generate Monster and Insert Monster create ability score actions in the form "Strength Check", etc. Change the latter to match the former. This is helpful for requesting rolls from multiple tokens. (Added a shared abilityAbbreviations.ts (single source of truth for "Strength"→"Str"); Import Character now uses it, and all four monster flows — Insert Monster, Generate Monster algorithmic + AI, and Generate Encounter — name their ability-check actions with the short alias. Chat messages still read the full name. Source-contract tests updated.)
  • [x] Change the behavior of clicking on a token from marking the token as Selected (current) to impersonating the token (new). (A plain single-click on a token now impersonates it instead of setting it as the selected/currentToken. Scoped to the token layer in both renderers: WebGLTokenLayer (live WebGL map) — the non-shift click path calls setImpersonatedToken (deselecting first if it was currentToken), while background/foreground scenery objects keep select-to-edit; shift-click multi-select is unchanged; the double-click handler is unchanged but now resolves to "open Edit Token" since the first click already impersonates. TokenLayer (Konva WebGL-crash fallback) does the same via impersonateEntity(). Heal/Harm mode was added to the token pointer-down special-mode bail list so a heal/harm click can't also impersonate its target. Vision/VBL already preferred the impersonated token, so click-to-impersonate also focuses vision. Source-contract tests updated/added: WebGLTokenLayer.shiftClick, WebGLTokenLayer.clickImpersonate. Behavior change to flag: plain-click no longer selects, so arrow-key nudge and the Delete key — which act on currentToken/selectedTokens — now need a shift-click (or Party/Initiative selection); drag-to-move is unaffected.)
  • [x] Hide the AI Optimizer in the character sheet (Gated the CharacterOptimization panel in CharacterDetail behind a SHOW_AI_OPTIMIZER = false flag — component + /api/ai/optimize-character endpoint stay intact for the future validation sidebar, but it no longer renders. AIConfig source-contract test updated.)
  • [x] Add fear conditions (frightened, shaken, panicked) to System 8 fixtures (Added in the vtt-modules sibling repo — modules/system8/src/fixtures.ts gains the three conditions with themed badge SVGs; fixture seed version bumped 3→4 so deployments re-seed, package bumped 0.4.1→0.4.2, module rebuilt. Live in dev (modules.local.json path link) after a re-seed. Production deploy pending (user step, needs remote/publish): tag system8/v0.4.2 to publish, then bump modules.hosted.json system8 0.4.1→0.4.2.)

Token Hover UI

  • [x] Token Hover UI: move the properties and notes to the portrait in the lower right of the map (lower left and lower right of the portrait, respectively), with 0.5rem margin from the corners, and slightly more transparent background than current. Other hover UI elements remain as is. (The showOnHover properties and GM notes pills moved off the token (they used to flank it via TokenInfoOverlay) onto HoverTokenPortrait's lower corners — properties bottom-left, notes bottom-right, each 0.5rem from the corner, in a slightly-more-transparent pill (0.92→0.78). TokenInfoOverlay now carries only the name/faction pill above the token; name/faction + the action buttons are unchanged. The portrait container now renders even for art-less tokens (so they don't lose hover info) — the <img> is gated on a resolved url. isGM is threaded into the portrait for the GM-only notes. Contract tests moved/added across TokenHoverUI/TokenInfoOverlay/HoverTokenPortrait/TokenNotes. Smoke-test interactively (no DOM render tests in apps/web). Note: the portrait is desktop-only, so on mobile properties/notes ride with task #11's mobile hover rework.)
  • [x] Token Hover Portrait: if the token being hovered over is positioned in the right side of the visible map pane, pop up the portrait on the left so as not to overlap the token (New pure shouldPopPortraitLeft(tokenScreenX, paneLeft, paneRight) helper: when the live-hovered token's screen x is past the midpoint of the visible map pane (between the open sidebars), HoverTokenPortrait anchors to the lower-left (left: leftOffset, justifyContent: flex-start) instead of the lower-right, so it doesn't cover the token. Only the hovered token flips — a persisted/selected token (no live screen x) keeps the right anchor. Unit tests for the helper + a source contract for the anchor flip.)
  • [x] On mobile, tapping a token should show the hover UI, and tapping it again, or tapping off the token, should close it. Opening a different panel like Me or Chat should close it. (Implemented after the user confirmed selection is obsolete (a tap no longer selects) and a mobile tap currently does nothing — so the canvas touchend handler owns mobile taps cleanly (no race). In the default-mode touchend branch, a plain tap on a token projects its screen rect (mirroring WebGLTokenLayer's pointerover projection) and calls setHoveredToken(token, pos, halfSize) to show the hover UI via the existing render path; tapping the same token again, or empty space, calls setHoveredToken(null). A UI.tsx effect clears the hover when a side panel (Me/Chat/etc.) opens on mobile. Source-contract tests (WebGLMap.mobileTapHover). Note: the info portrait (properties/notes/conditions) is still desktop-only, so mobile shows the name pill + action buttons; surfacing the portrait info on mobile is a separate follow-up. Follow-up (live test): tapping an action button closed the hover without firing it — the desktop "hover bridge" onPointerLeave → setHoverUiPinned(false) cleared the hover on touch (the pointer lifts the instant a button is tapped, racing/beating its onClick). Added a sticky prop to TokenHoverUI (passed on mobile) that skips the bridge pointer handlers; the hover is managed by the tap handlers + each action's finishAndUnpin instead, so tapped buttons now fire. Smoke-test on a touch device.)

Async Actions

Spec: style guide (Full Style Guide → Async Actions). Tasks: docs/committed/todo/design/async-actions.md.

  • [x] Auto-persisting Mantine form fields show loading / success / failed states (visual reference). (Built the reusable system: pure save-state controller (utils/asyncFieldSave.ts, 12 fake-timer unit tests), useAsyncFieldSave hook, FieldSaveIndicator, data-async-status CSS, failure-toast policy, and AsyncTextInput/AsyncNumberInput/AsyncTextarea/AsyncSelect wrappers — these ARE auto-persisting fields that show the states. Follow-up (live test): the wrappers were built but not yet adopted, so HP/AC showed no states — now adopted on the token Properties tab's number-type (e.g. AC) and string-type property inputs (AsyncNumberInput/AsyncTextInput), which commit on blur and await patchToken so the loading→success states reflect the real save. (HP is usually a range type edited via the slider/modal — a different control — so its async treatment + the broader rollout still land with the Character Sheet Overhaul.))
  • [x] Async buttons show loading / success / failed states (in-button spinner + label swap; success fades, failure persists with tooltip + toast). (Built utils/asyncAction.ts (pure state machine, 6 fake-timer tests), useAsyncAction, and AsyncButton (label swap) + AsyncActionIcon (icon swap, required tooltip in every state). Reference adoption: NewMapModal's Create button — failures now surface (it threw away errors before). Broader Save/Generate/Delete rollout follows the same pattern.)
  • [x] List/grid pages show animated skeleton cards while loading (same size/shape as real cards, no layout shift). (Built CardSkeleton (mirrors a card's footprint, prefers-reduced-motion → static) + CardSkeletonGrid (N skeletons in EntityList's grid via shared ENTITY_CARD_GRID_* constants, so no drift). Rolled out to /characters and /campaigns, replacing their ad-hoc loading states.)

Random

  • [x] Token Hover UI property list should also show current conditions (The hover property pill (now on the portrait's lower-left, per the Token Hover UI tasks) appends a Conditions: line listing the token's active condition names — resolved from token.conditions (ids) against useConditions(campaignId). The pill now renders for properties OR conditions, and conditions count toward the "is there anything to show" gate so an art-less, property-less token with conditions still shows them. Contract test added.)
  • [x] Add a search button to the palette to find a token by its name. Show potential matches in a list under the search button, and when clicked, move the camera to that token and ping it. WASD are already bound, so determine a good hotkey that activates this feature and focuses the text input (maybe / for ?). (New IconSearch button in ToolPalette opens a popover with a text input + a live list of name matches (filterTokensByName, token-layer only, case-insensitive, capped at 12). Picking a match (click or Enter on the first) calls uiStore.focusToken — centers the camera and fires the existing rainbow ping. Hotkey: / opens + focuses the input (it was a duplicate Debug-Mode binding alongside ?; ? keeps Debug Mode), driven by a paletteStore.tokenSearchOpen flag so the keyboard hook can open the palette-local popover. Tokens flow from UI.tsx (useTokens). Unit tests for the filter + palette/keyboard source contracts. Smoke-test interactively.)

Character Context Improvements

  • [x] Remove Max HP button (why is that there?) (Removed the v0.34.0 interim MaxHpControl ("Max HP: N" popover) from the Character Context — deleted the component + its test and dropped the now-unused updateToken prop from PlayModeView (CharacterContext keeps updateToken for its own action edits). Proper Current/Max HP editing is specced in the Character Sheet Overhaul roadmap and will be rebuilt there.)