Skip to content

v0.60.0 — Fixes & Polish

Roadmap System deferred. The twelve [ ] items under § Roadmap System below did not ship in v0.60.0 and are preserved here unchecked, as the record of what the release intended. The project itself now sits in the roadmap's Phase 2, and its spec (23-roadmap-system) still carries six open decisions.

The remaining small items: everything that is neither a live security hole nor a feature large enough to deserve its own release — plus the Roadmap System, which is mostly document work and needs to land before the Phase 1 series it exists to track. See § Sizing for whether that last part really belongs here.

Character Sheet

Spells Section

The row-geometry and layout work. The expando restructure and actionless-spell chat messages are not here — they are v0.64.0 (22-spell-chat-messages).

  • [x] There seems to be an unnecessarily wide minimum width for Spell buttons. I can't find an explicit min-width, could be an artifact of flex layout. (Confirmed: there is no min-width. ActionButtonRow's button carries flex: 1 (TraitListRow.tsx:99), so it absorbs all free space. This item and the next are one fix, not two.)
  • [x] When width is sufficient, the Spells Known and Spells Prepared subsections should be side by side. This may actually be implemented, but the previous issue makes it impossible to ever have sufficient width as the buttons keep expanding to fill all available width. (Done, as one commit. Two causes, not one. ActionButtonRow grew a grow prop — default true, so Attacks, Other Actions and the impersonated-token panel are untouched — and the trait rows pass grow={false}, which sizes a spell button to its label. The second cause is why "sufficient width" had never once occurred: the columns' 22rem basis was picked against the viewport, but the sheet is capped at CHARACTER_PAGE_MAX_WIDTH (40rem) with 4rem of padding inside that, so two 22rem columns could not fit at ANY window size. The basis is 17rem now — the widest whole-rem value that does fit — and a test asserts that relationship against the cap rather than the number, so raising one and not the other fails. Verified live on a 5e wizard: the two columns sit side by side at 280px each in a 608px section, and "Fireball" is an 88px button rather than a 560px one.)
  • [x] The order should be: spell button, ... button, (i). Make sure the ... button lines up neatly with the spell button, same height, 1px gap, no rounding on the edges bordering the gap. (ActionButtonRow already has the fused geometry — 1px transparent margin, squared inner corners, matched height — it is currently applied to ⋯ and deliberately not to (i). So this is a reorder plus a fused flag.) (Done, with one correction: the row cannot simply pass its whole control group as trailing, because fused clips whatever it is given — the (i), the drag handle and the trash button would have been fused too. Only the ⋯ goes inside the primitive now; the rest sit beside the pair. "Matched height" also turned out not to be already true here: Mantine sets min-height alongside height from --ai-size, so the size={32} menu kept setting the flex line's height and an xs (30px) spell button sat two pixels short inside it. The fused rule drops that floor, which fixes every xs fused pair rather than this one row. Verified live: button and ⋯ both 30px on the same baseline, a 1px seam between them, (i) separate.)
  • [x] Remove the redundant 'info' item inside the ⋯ menu. It pops up something similar to, but not identical to, the (i) popover. With it gone, ⋯ becomes purely an action-meta button, which is what v0.64.0's expando design depends on. (Done, scoped to the rows that have an (i) beside them — the one-action trait rows. Attacks, Other Actions and a power's expanded action rows keep their Info item: none of those rows renders an (i), so there it is not a duplicate route to the description, it is the only one. Removing it there would delete information rather than de-duplicate it. Also uncovered: with Info gone, "View action" is the menu's only item, and ActionMenu withholds that for a campaign-sourced action — which a trait row can have, since actions are matched out of the character's pool by name. Such a row would have fused itself to a menu that renders nothing, leaving a squared button corner and a 1px sliver of background; fused is now conditioned on the menu actually existing.)
  • [x] When dragging a Spell Known to a prepared slot, show a visual indicator when the dragged spell is in a valid prep slot drop zone, and persist the "ghost" of the dragged spell beyond the Spells Known container (currently it disappears, but dropping still works). (Cosmetic only — the drag itself works.) (Done. Three separate causes behind the two symptoms. The indicator had never rendered at all: the column's outline named --mantine-color-primary-filled, which is not a variable Mantine defines — the real name is --mantine-primary-color-filled — and an invalid var() drops the whole outline shorthand. It uses the project's own --vtt-color-* tokens now, with a test that resolves every token it names against all four skin files, because a merely wrong name fails silently. Second, the column asked useDroppable().isOver, which closestCenter makes false for any column that has rows in it — it resolves to the nearest ROW — so the section tracks the hovered column and tells each one. Third, the ghost: useSortable translates the row in place, so it stopped being drawn once it left its list; a DragOverlay now draws it at the pointer. Two highlight states, not one — every legal destination outlines faintly for the whole drag, and the one under the pointer outlines properly — and only a column a drop would really move the row to is highlighted, matching what onMoveTrait can express. Verified live by dragging Counterspell across: candidate outline on press, accent outline on arrival, ghost drawn outside the source column, and the drop prepared the spell.)

Help Documents

  • [x] Add an AI Policy link to the footer (next to Terms of Service and Privacy Policy) and the Help menu. The page lives at docs/help/legal/ai-policy.md and is declared in LEGAL_HELP_PAGES so the footer and the Help menu both pick it up from one entry. Unlike the Terms and Privacy placeholders it ships as a real, published document, carrying a version and a date so later revisions are legible. Content:
    • We do not bundle any genAI with this application, but we do allow users to supply their own API Keys for various AI enhancement purposes.
    • We offer a separate genAI module for further enhancements (included in the shared hosting environment, optional for self-hosted users). This module is not required to use the application, and is not bundled with the application.
    • We did not use genAI to create any images in our application, or any of our first-party modules. Artists were employed for the (somewhat limited) artwork we have.
    • If installed, the Scenes module will note that we purchased our assets from an artist.
    • When installed, the vtt-assets module will note that we purchased our assets from an artist.
    • We do use AI agents to assist our software engineering, including Anthropic's Claude Code, and Google's Gemini.
    • We use AI models in-browser for image processing, such as finding faces for tokens. These models do not generate anything; they analyze images and extract raw data, and they share nothing with any third party. Name the categories rather than the individual models, and link to ai-features.md for the specifics, so the policy does not need editing every time a model changes.
    • If you supply your own API key, your prompts and inputs go to that provider under that provider's terms and privacy policy, not ours. Link to the Privacy Policy.
    • We do not store your prompts or the providers' raw responses. We transform responses into native objects — Characters, Tokens, images — and keep only those. (Verified 2026-08-22: the genai module's server logs carry only provider/model/error strings, and the only persistence anywhere in it is the user's own map-mode prompt prefix in their browser's localStorage and cached model weights in IndexedDB. Nothing to remove.)
    • Not included: the support-request paragraph. There is no support-request system yet — v0.60.0 ships a mailto: link and the real thing is a Phase 2 project. Add that paragraph when the feature exists.
    • (Done. docs/help/legal/ai-policy.md, Version 1.0 — 23 August 2026, declared in LEGAL_HELP_PAGES so the footer and the Help menu both pick it up from the one entry, and linked from legal/index.md and the wiki index (the help link checker fails on an orphan). One wording change against the brief: the bullet "these models do not generate anything" is true of the models the platform runs in your browser — face detection, background removal, wall tracing, all onnxruntime-web — but not of the genAI module's in-browser language model, which generates monster statblocks. Saying it flatly would have made a published legal document inaccurate, so the policy separates the two: the application's local models analyse and never leave your machine, and generation comes from the optional module or from your own API key. Verified live: three links in the footer, the AI Policy one lands on /help/guide/legal/ai-policy, and the page renders on the help site.)
  • [x] Add a Support link to the Help menu — mailto:support@purplecat.games, which exists as of 2026-08-22. Help menu only, not the footer, which is getting crowded. A real support system is a Phase 2 project. (Done. SUPPORT_MAILTO sits beside LEGAL_HELP_PAGES in useOpenHelp.ts — same kind of declaration — but deliberately not inside that array, which is what would have put it in the footer too, since the footer renders the same list. Rendered as a real <a href="mailto:…"> rather than a click handler, so the address can be copied by someone who does not use a mail client. A test pins the placement rule in both directions. Not verified in the browser: the Chrome window went hidden partway through (innerWidth: 0, so the responsive nav renders nothing), and the item is a single menu entry with no logic. The AI Policy links above WERE verified live before that.)
    • Note: support@, dmca@ and privacy@ all exist. security@purplecat.games does not yet — the Terms of Service draft (§4) and the Privacy Policy draft (§10) both promise it. Create it or change those drafts before either is published. The only address currently surfaced to users is privacy@ in your-data.md.

Misc

  • [x] Set default from/to snap settings for drawings based on system.
    • System 8: from grid center, to grid line — snapMode: 'center', endpointSnapMode: 'lines'
    • 5e: from grid corner, to grid line — snapMode: 'corner', endpointSnapMode: 'lines'
    • System 5: from grid corner, to grid line — snapMode: 'corner', endpointSnapMode: 'lines'
    • No system / unknown: 'none' / 'none' (today's behaviour)
    • Mechanism: a system-level and campaign-level property, initialized by fixtures, with the campaign overriding the system. Surface it in the Basic Info section for now.
    • No persistence: every new drawing starts from the resolved default. The system or campaign changes that default; the user's in-session change is not remembered across reloads. (Note: no drawing tool setting persists today — colour, width, opacity, snap and square mode all reset on reload. That is a separate gap, not addressed here.)
    • The wall tool keeps its forced corner snap, which overrides all of the above. Walls have to close for vision geometry to work.
    • (Done. Stored as ONE json column, drawing_snap_settings, on both systems and campaigns — the pair is a single decision, so "unset" is the whole object being null and a row holding an origin but no endpoint is not a state this setting has. A column rather than a properties key because it has a system-level twin, which is baseCurrency's shape, not targetingSettings'. The campaign overrides the system wholesale, never field by field. Applied by an effect when the map view learns its campaign, NOT per drawing: the toolbar's toggles have to keep working for the rest of a session, which is the reading of "no persistence" that leaves them useful. Both Basic Info surfaces get the same component — a switch (the pair or nothing, matching the type) plus two Selects, which turn on pre-filled with whatever was being inherited so taking control changes nothing by itself. Module side committed but not published: dnd5e 0.12.0 and system8 0.9.0 in vtt-modules, system5 0.7.0, each with its fixture version bumped (the seeder skips a system whose stored version is current, so without a bump the field never reaches an existing database). They cannot be published before this release merges — they use ISystem.drawingSnapSettings, which arrives here, and module CI compiles against platform main. That inverts the usual publish-modules-first order for this item alone.)

Roadmap System

Spec: 23-roadmap-system. Mostly document work, but tasks 1–3 are real code. Six decisions open in §9. This roughly doubles the release; confirmed as belonging here anyway, because v0.61.0 begins the Phase 1 series this exists to track and migrating a moving target is worse.

The shape: the repo stays the database (typed frontmatter, validated in CI, readable by Claude and reviewable in a diff), and help.purplecat.games becomes the UI — it is live, it already builds from docs/, and it needs no new hosting. Votes, when they exist, live in a separate store and join by id; no off-the-shelf tool gets to own the roadmap.

  • [ ] Schema + validator. Frontmatter carrying id, status (idea → draft → ready → committed → shipped → dropped), release, visibility, depends_on, effort, reviewed_at, touches, risks. Invalid frontmatter fails the build. status: committed requires a release number even if it slips.
  • [ ] Staleness check. git log <reviewed_at>..HEAD -- <touches> per spec. Non-empty means the code moved underneath the doc, and the commit list is the evidence. Badges the dashboard; does not fail the build. No AI agent required — the specs already carry the manual version of both fields.
  • [ ] Dependency + ordering check. Every depends_on resolves, no cycles, and release order is topologically consistent with it. This one does fail the build. Seed it with the real case: Sharing Buttons depends on Character Permissions.
  • [ ] Generate the dashboard. ursa renders pages, it does not query them, so a pre-build step emits docs/help/roadmap/index.md (public) and an internal twin. Committed, not gitignored, with a regenerate-and-diff guard.
  • [ ] Move the changelog to docs/help/changelog/. 109 files. This is what fixes the long-broken Changelog tab: its "Released" links currently point at files the help site does not contain, so every one of them is dead.
  • [ ] Move committed and shipped scope to roadmap pages, triaged for visibility first.
  • [ ] One page per idea. docs/ideas/ becomes individual pages; the multi-idea dumps get split. Repoint HelpTabs.tsx's Roadmap tab, which currently imports docs/temp_to_be_analyzed/ROADMAP.md?raw.
  • [ ] Relocate docs/legacy-migrations/ to packages/server-core/legacy-migrations/. Keep it — 11 test files read those SQL files as source-of-truth contracts, so it is fixture data, not documentation, and it should never have been under docs/.
  • [ ] Give architecture and entity specs a permanent home. They are a third category — neither roadmap nor changelog — and leaving that undefined is how system.md ended up in temp_to_be_analyzed/ while still being cited. All four architecture/ trees collapse into docs/internal/architecture/, branching into api/ and data/. The consumer-facing docs/help/api/ (22 pages, public) stays exactly where it is; the 9 contract specs are a different document for a different reader, and each side links the other so drift has somewhere to be noticed.
  • [ ] data-index.md — every table, one row. There are 47 tables in dbSchema.ts and no entity documentation at all; the only data-model doc in the repo covers one project's slice. Modelled on api-index.md: purpose, owning route module, lifecycle flags, and either a spec link or an explicit "no spec yet". The index is the deliverable — it turns an invisible gap into a countable one.
  • [ ] Three or four exemplar entity specs (maps, tokens, drawings — chosen for not changing in v0.61.0) to set the template: columns, ownership, lifecycle, relationships, routes, and the invariants a schema dump can never give you. Each lists dbSchema.ts in touches, so the staleness check flags it automatically the next time the schema moves. The other ~32 specs are deliberately not in this release — v0.61.0 rewrites part of the entity model, so writing them now means writing them twice.
  • [ ] Hard-gate docs/temp_to_be_analyzed/ — 38 files, use it or lose it, one pass. ROADMAP.md and architecture/system.md have live consumers and must be handled before anything is deleted. Disposition list goes in the commit message.

Public by exception. docs/help/ is a world-readable S3 + CloudFront site with no auth. visibility therefore defaults to internal, and internal pages live under docs/internal/, which ursa never reads — the v0.59.0 security section describes three live unauthorised routes in exploitable detail, and a filter that could be misconfigured is not good enough. docs/internal/ is the one directory that survives the cleanup.

Modules

  • [x] genai: set output_format: 'webp' on gpt-image-1. The OpenAI adapter sends only { model, prompt, n }, so we take PNG by default for no reason — and gpt-image-1 supports output_format plus output_compression. One line, shrinks the payload roughly tenfold on the current server path, and needs none of v0.65.0's architecture. Gemini has no equivalent — generateContent returns PNG inlineData with no output-format parameter, which is why the rest of that problem waits for v0.65.0. Ships as a genai patch release. (Done as genai 0.5.4, committed but not published. Not one line, in the end: output_compression: 90 goes with it, the pair is gated on the model id because the legacy dall-e-* models 400 on those fields rather than ignoring them, and — the part that would have shipped a bug — both response paths hardcoded data:image/png, which the platform's artwork pipeline trusts. Asking for WebP and labelling it PNG is worse than not asking, so the label follows the request; two existing tests pinned the old pairing and are updated. /images/edits (Extend This Image) gets the same treatment, being the same models returning the same payload.)