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
numericAttributesand 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.dnd5e0.9.0 carries the SRD's per-species speeds;system50.4.0 compiles its 218 species speeds out of thespeed: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.classOptionsLabelis 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.classOptionKindsis 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 asCharacterClassLevel.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] Add support for Fighting Style and other class-based choices.
- [x] Spells Known: hidden for classes who know all spells on their list, such as Paladins.
appliesTowas 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 declareappliesTo: 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 declaringrenderMode: 'level-scaffolded'+slotSchedule.resolveGrantLevelsrenders empty slots today (that is how Feats do it), so this is a module-side declaration. What blocks it:- The counts differ by EDITION, not just by class. 2014's bundled class tables carry
spellsKnownfor Bard / Ranger / Sorcerer / Warlock and nothing for the prepared casters. The 2024 tables carry nospellsKnownat all — every caster has a fixedpreparedSpellscolumn instead, which is the 2024 revision's whole change here. SopreparationBudgets' "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. - "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.)
- The counts differ by EDITION, not just by class. 2014's bundled class tables carry
Global Breadcrumb / Nav Top Bar
- [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.mapandcharacterare 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.catexport 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:aiConfigStorepersists users' third-party API keys in plaintextlocalStorageunder a comment claiming they are "never sent to server" (they are, asx-ai-keyheaders);purgeExpiredDeletedItemsruns 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/helpis now a real site:apps/helprenders it with @kenjura/ursa (pnpm build:help→apps/help/dist),pnpm devserves 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/:pagedeep-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 aspaflag, because rewriting every unknown path to/index.htmlwith a 200 is right for a single-page app and wrong for a wiki, where a mistyped URL must actually be missing.ci.ymlbuilds 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, soimmutablewould 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 parallelpnpm devhangs 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 onhelpWikiPageFromPath, and a dependency-free link checker asapps/help'stest— 29 pages, every internal link resolving, no orphans — which now runs as part ofpnpm 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.tfdeclared three frontends:help, plusvttandcharacterleft from an era when this state was the original dev environment.characteris an app that no longer exists in the repo.vttwas worse: through this stack'senvironmentit resolved topurple-cat-vtt-dev-…, the bucketinfra/envs/nonprod'smodule.devnow 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 firststate rm-ing would instead have destroyed dev's bucket. Both are gone from the config, and rootinfra/now owns exactly one frontend, the Help wiki.map.purplecat.gamesis documented as deliberately unmanaged — its bucket and distribution were created outside Terraform, and re-declaring them needsimportfirst, 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 ownmonstersgroup compiles empty, so there is nothing of its own to serve yet. The twosystemId === …chains that gated the picker and the synthesised saving throws collapse into oneFIVE_E_MONSTER_SYSTEMSlist first, so the new system costs one string instead of two branches — CC-1 leakage inInsertMonster.tsxratchets 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.borderWidthand 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, andshowFactionRingremains its only off switch. The rule moved into a purefactionRing()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 sharedcharacterCardArtorder (portrait slot → display art, which reaches the token slot → legacy), with a token'simageUrlmapped onto thetokenImageUrlthe 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'suseSectionScrollSpyandscrollSectionIntoView, 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/:tabtook 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 intoHelp/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 thedocs/helpwiki through an iframe, athelp.purplecat.gamesin prod/alpha/dev andursa servelocally — already howHELP_BASE_URLresolved, 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 indocs/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.cssnow carries the palette, typography and radii frompurple-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.