Skip to content

v0.57.0 - More 5e Improvements, and Early SaaS Maturity

5e Character Sheet

Scope: 5e 2014, 5e 2024, and System5.

  • [x] AC and Speed are missing from the Combat attributes section. Both are declared as numericAttributes and always have been, but each module only carried them through — emitting a row when the record already had one. Nothing creates them, so a freshly-built character's Combat group held HP alone: nothing to read, and no field to type an AC into either. Both are now derived when absent (AC = 10 + Dex, the unarmoured default; Speed = the species' walking speed, defaulting to 30), exactly as HP already was, with a stored value still winning. dnd5e 0.9.0 carries the SRD's per-species speeds; system5 0.4.0 compiles its 218 species speeds out of the speed: frontmatter its docs already carry.
  • [x] Change the Subclass section to "Class Options". The heading came from subclassKind.label, because the subclass was all the section held. It is about to hold whatever else a class chooses, and one field cannot be both the section's heading and the subclass ROW's label. CharacterSheetSchema.classOptionsLabel is the heading, falling back to the row label for a module that declares none.
    • [x] Add support for Fighting Style and other class-based choices. CharacterSheetSchema.classOptionKinds is the general declaration: per kind, which classes offer it and from what level, how many the class has by now, and the options to choose from — all module answers, so the platform compares a level to a level and renders as many pickers as it is told to. Stored as CharacterClassLevel.options[kindId], a LIST, because a class can hold several of one kind. The catalogue is handed over rather than browsed: a closed half-page list has no content group, and inventing one to hold it would be a route nothing else uses. Both 5e editions and System 5 declare Fighting Style; System 5 compiles its six classes' lists out of the class docs' prose.
  • [x] Spells Known: hidden for classes who know all spells on their list, such as Paladins. appliesTo was only ever asked of a CS-1.3d view, on the reasoning that a subtype owning a collection is a place to put something and so earns its heading even while empty. A prepared caster is the counter-case: they know the whole list, the place they add a spell is Spells Prepared, and an empty "Spells Known" says "you know no spells" — the opposite of the rule. A collection subtype may now decline a character too; both modules declare appliesTo: knowsSpells, which is per character, so a Sorcerer 3 / Paladin 2 keeps it.
  • [x] Spells Known and Spells Prepared: show empty slots. For instance, if my character has 2 spells known and only one is populated, show an empty slot (similar to feats).
    • For those whose spells known or prepared are level-specific, group slots (filled or otherwise) by level. For those others (e.g. warlock), just show a flat list of slots.
    • Resolved in v0.58.0, where the two decisions this skip named were answered (per-edition, per-class budgets — see the merged Spells section above) and resolveSlotCount (CS-1.3g) was built to scaffold a capacity that is a table count rather than a level schedule.
    • SKIPPED — needs two decisions. The platform side is already built and needs nothing: a subtype declaring renderMode: 'level-scaffolded' + slotSchedule.resolveGrantLevels renders empty slots today (that is how Feats do it), so this is a module-side declaration. What blocks it:
      1. The counts differ by EDITION, not just by class. 2014's bundled class tables carry spellsKnown for Bard / Ranger / Sorcerer / Warlock and nothing for the prepared casters. The 2024 tables carry no spellsKnown at all — every caster has a fixed preparedSpells column instead, which is the 2024 revision's whole change here. So preparationBudgets' "ability modifier + level" formula is 2024-wrong for every class, and fixing the empty slots means deciding whether to replace that formula with the 2024 table (a real rules change, visible in the existing preparation validation) or to scaffold 2024 off the formula and knowingly show the wrong number of slots.
      2. "Level-specific" does not describe any 5e class. Neither spells known nor spells prepared is tracked per spell level in either edition — a Sorcerer knows N spells of any level they can cast — so the flat list is what every class would get and the grouped variant never fires. Which class did you have in mind? (If the intent was "when the section is grouped by Level, put the empty slots somewhere sensible", that is a different and smaller change: the grouped render path returns before the slot list today, so empty slots are invisible in any non-default grouping.)
  • [x] When a breadcrumb has another entry to its right, its first option returns to that entry's page. sectionHomePath(ctx, section) answers it, on two conditions: the section names a thing with its own page, and that page is not the one you are already on — an entry that navigates nowhere reads as broken rather than as absent. The Campaign section shows it first, above New Campaign, labelled with the campaign's own name. map and character are declared and answer null on every route today, because a map id is only ever in scope on the map's own page and a character id only on the sheet.

Early SaaS Maturity

As the VTT enters alpha testing with non-Purple Cat Games users, it needs to be elevated to a certain level of maturity, including Terms of Service, a Privacy Policy, SLAs, etc.

Main article: Early SaaS Maturity

  • [x] Every document the requirements ask for is written, from the source rather than from a template: terms-of-service, privacy-policy, frontend-monitoring, smoke-testing, user-control-of-data, and a 29-page help wiki in docs/help/. Every legal clause carries a > Basis: note naming the code that makes it true, and each draft ends in the decisions only a human can make — the two that block the others being how ToS acceptance is captured at all (users land straight in the app behind Clerk, so there is no signup funnel to hang a checkbox on) and registering a DMCA agent, without which the takedown section is decorative. The per-user-bucket question is answered NO. The premise does not hold — a bucket in our cloud account is ours however it is named — so it moves no legal control; §512(c) safe harbour already applies with one shared bucket, and the storage layout is campaign-scoped, so "one bucket per user" has no coherent answer for a token uploaded into someone else's campaign. Provider-managed encryption at rest we already have; client-side E2E would break transcoding, CDN delivery, background removal, AI parsing and .cat export while not reducing the DMCA duty it reaches for. What the idea actually wanted is the already-committed private image storage. Three findings are recorded where they matter rather than fixed here: aiConfigStore persists users' third-party API keys in plaintext localStorage under a comment claiming they are "never sent to server" (they are, as x-ai-key headers); purgeExpiredDeletedItems runs only at server start, so a long-lived container never purges the 30-day trash, and it deletes rows but never the stored objects; and uploads record no uploader, which independently blocks "delete everything I uploaded" and any repeat-infringer policy. (Docs only — no code.)
  • [x] The Help wiki is wired into the app, the build, and the infrastructure. docs/help is now a real site: apps/help renders it with @kenjura/ursa (pnpm build:help → apps/help/dist), pnpm dev serves it live on :5175 beside the API and the app, and Help gains a Guide tab — an iframe over the built site, the same shape as the System sidebar over the System 8 wiki — which is now the default landing tab for /help. /help/guide/:page deep-links into it (/help/guide/api/action-api), which is what a welcome notification will need. The global Help command now lands there rather than on About — About is a version and copyright panel, and someone searching "Help" wants help — and the Help menu lists Guide above it. Terraform grows a third frontend, help.purplecat.games, sharing the existing bucket/OAC/ CloudFront/Route 53 pattern; the one behavioural difference is a spa flag, because rewriting every unknown path to /index.html with a 200 is right for a single-page app and wrong for a wiki, where a mistyped URL must actually be missing. ci.yml builds and deploys it with the app on main, deliberately not with the SPA's cache headers: Vite content-hashes its filenames and ursa does not, so immutable would freeze the help site in readers' browsers for a year while the deploy reported success. Ports: 5175, not ursa's default 8080 — 8080 is this repo's own Adminer port and where any other ursa wiki on the machine already serves. Two ursa fixes were needed, written and released as part of this work (@kenjura/ursa, now 0.88.0 on npm): 0.87.5, because a docroot under a dot-directory built an empty site while reporting success — hidden-folder patterns were tested against the absolute path, so a git worktree under .claude/worktrees/ marked its own contents hidden; and 0.88.0, adding --strict-port, because ursa's interactive port-fallback prompt inside a parallel pnpm dev hangs the whole dev command. The dependency is ^0.88.0; both are prerequisites, so do not float it below that. Tests: 14 on the app wiring, 12 on helpWikiPageFromPath, and a dependency-free link checker as apps/help's test — 29 pages, every internal link resolving, no orphans — which now runs as part of pnpm test.
  • [x] The prod Terraform stack now describes only what it owns. Auditing it before the help-site apply turned up a live hazard rather than mere clutter. infra/frontends.tf declared three frontends: help, plus vtt and character left from an era when this state was the original dev environment. character is an app that no longer exists in the repo. vtt was worse: through this stack's environment it resolved to purple-cat-vtt-dev-…, the bucket infra/envs/nonprod's module.dev now creates and owns as dev.purplecat.games' frontend — two states claiming one live bucket. An untargeted apply would have tried to rename it to the prod name (colliding with the real, live prod bucket); deleting the config without first state rm-ing would instead have destroyed dev's bucket. Both are gone from the config, and root infra/ now owns exactly one frontend, the Help wiki. map.purplecat.games is documented as deliberately unmanaged — its bucket and distribution were created outside Terraform, and re-declaring them needs import first, since CloudFront will not duplicate an alias. The environment map is now written down in both stacks' headers so the next reader does not have to reconstruct it from state files.

Impersonated Token Context

Goal: build this UI anew in each module, to look and feel as much like the Character Context as possible. Where the Character Context uses projected values, this uses token values, but otherwise mirrors the structure and behavior of the Character Context. (No need for validation/automation for now). Ideally we can reuse many components, assuming the initial implementation followed something of a "smart components / dumb components" model. If not, we'll just duplicate as needed.

Scope: dnd5e and system5 modules. System 8 will generate Character objects for its monsters.

  • [x] Decomposed into docs/committed/11-impersonated-token-context/todo.md — 11 TODOs (CS-15.1 … CS-15.11). One finding worth knowing before reading it: the Impersonated Token Context already renders the SAME component as the Character Context (task 1e.1 replaced <TokenSheet> with <CharacterSheet character={null} token={…}>), so the smart/dumb split the goal hopes for exists and nothing needs duplicating. What is not module-owned is the DATA — token attributes come from campaign property definitions rather than from the module — which is why the two contexts diverge in feel. The decomposition treats that as the work, and names the one question it could not answer alone.

Misc

  • [x] Enable 'select monster from system' for system5. System 5 campaigns now browse the bundled 5e SRD bestiary, the same borrowing System 8 has had since v0.20.0 — and a smaller stretch here, since System 5 is an OGL d20 system whose monsters are 5e monsters and whose token metadata keys (ac, hp, str…) are already the ones the converter writes. No module change: System 5's own monsters group compiles empty, so there is nothing of its own to serve yet. The two systemId === … chains that gated the picker and the synthesised saving throws collapse into one FIVE_E_MONSTER_SYSTEMS list first, so the new system costs one string instead of two branches — CC-1 leakage in InsertMonster.tsx ratchets 12 → 3.
  • [x] Faction outlines came back. Reported in playtest as "did that code get deleted?" — it had not; it was switched off by a side effect. The faction ring and the token's ART border shared one number: the ring took its thickness from imageTransform.borderWidth and vanished when that was 0. v0.56.0's "default border thickness is zero" made every creation path write exactly that — a change aimed at the art border, which the faction ring had made redundant — so every token created since had no outline, while legacy tokens (which store no width at all) kept theirs. They are two features and are now separate: the ring owns its geometry, draws OUTSIDE the art so an art border underneath stays visible, and showFactionRing remains its only off switch. The rule moved into a pure factionRing() so it is tested rather than asserted about.
  • [x] The impersonated token's portrait was always blank. Reported in playtest: an empty portrait box over a token whose picture was on the map, in its hover card and in its own artwork editor. The sheet's portrait memo returned early on !character, so a token never resolved one — which predates every CS-15 change; the sheet has read the portrait from the character alone since N1 first taught it to render a token. It now resolves from the token when there is no character, through the same shared characterCardArt order (portrait slot → display art, which reaches the token slot → legacy), with a token's imageUrl mapped onto the tokenImageUrl the resolver expects. A character still wins where there is one, since a linked token is a projection of it.
  • [x] A table of contents beside the standalone character sheet. Three columns where there is room — contents, sheet, validation — two where there is not, and the sheet alone below that. Which asides fit is now ONE decision (resolveSheetColumns) rather than a nested pair: they compete for the same space, and a nested layout has already spent the width by the time the question is asked. When exactly one fits and both are available, a per-user preference decides, defaulting to the contents; the control that changes it rides whichever aside is showing, so it is reachable from the layout it changes. The TOC reuses the sticky header's useSectionScrollSpy and scrollSectionIntoView, so the crumb and the highlight cannot disagree about where the reader is. Opt-in, and only this page opts in: the Character Context is already a navigation surface and the sheet modal is transient.
  • [x] Help opens in a sidebar on /play. Navigating to /help/:tab took the user off the map they wanted help WITH. Help now joins the set of pages that open in the wide left sidebar there — the pattern Manage Systems, Settings and the Admin pages already follow — and navigates everywhere else, because the sidebar only exists on /play. The tabs moved into Help/HelpTabs, controlled rather than routed, so both hosts render the same content and the sidebar can switch tabs without touching the map's URL. Local content (About, Roadmap, Changelog, Modules) renders directly; the Guide is the docs/help wiki through an iframe, at help.purplecat.games in prod/alpha/dev and ursa serve locally — already how HELP_BASE_URL resolved, unchanged.
  • [x] Terms of Service and Privacy Policy in the Help menu. Guide pages rather than tabs: they have to stand alone on the public help site anyway, since someone may need to read the Terms without an account. docs/help/legal/ holds a placeholder for each for now — the drafts in docs/committed/12-early-saas-maturity/ still carry ‹…› blanks and cross-reference internal planning documents that are not published, so promoting them is an editorial pass over two legal documents rather than a rename.
  • [x] The help wiki wears the marketing site's clothes. docs/help/style.css now carries the palette, typography and radii from purple-cat-games/website — violet scale, Space Grotesk over Inter, the light and dark surface pairs — so moving from purplecat.games to help.purplecat.games does not feel like changing product. ursa exposes five theme variables and this maps ours onto them, which re-skins its whole chrome without touching its markup; the hardcoded cyan it uses for "you are here" becomes the brand violet. The link fix this file was originally written for survives intact, with every ratio re-measured against the new backgrounds.