v0.118.0 — Library actions; modules in the platform
Merged 2026-10-05 (ac26eae2, GitHub PR #79) — built on version/0.118.0; the tag is in
the changelog. No module release: dnd5e, system8, scenes and system5 are built from
this repository from now on.
The first CI run on main failed: lint-and-test's web build ran out of the default Node heap,
so nothing deployed. v0.118.1 (the changelog) fixes that and files the release's leftovers.
Two parts:
- Library actions. Delete (soft by default, with a permanent option) and Reclassify on each item, tag editing in place, a Bulk Edit mode on the list pages, and mass delete in a new /admin/library.
- The first-party modules move into this repo. dnd5e, system8, scenes and system5 move in under
modules/, and every environment builds them from source. This retires the cross-repo release order (publish the module, bump the pin, then deploy the platform). The plan is from the 2026-09-07 multi-repo review.
Decisions (2026-10-05):
- vtt-assets stays out. It is ~586 MB of textures with no code coupling, so it stays a published, pinned package in vtt-modules, like genai.
- Publishing is retired for in-repo modules. Their versions still bump in
package.jsonand CHANGELOG, but there are no<module>/v*tags and no registry. - Committed content ships. A module's
data/is what every environment builds. Refreshing it is an explicit authoring step against a sibling checkout of the content repo, andcontent-source.jsonrecords the exact commit. CI never clones system8 or system5. - A permanent Library delete removes the row and its tags, but never the image files. Tokens and maps placed from the Library may share those URLs.
Library
- New actions available for library items:
- Reclassify: change classification (e.g. token->texture, object-token)
- Delete: delete from library (soft delete by default, option to insta-perma-delete)
- Add/Modify/Remove Tag: what it says on the tin. Supports existing and custom tags.
- Implementations:
- [x] Each library item should have a delete button
- Done: soft delete underneath.
library_itemshasdeleted_at/deleted_by(migrations-pg0059, journalled; the sqlite and pg live upgrades; every CREATE TABLE copy).- Every reader skips binned rows through one helper,
notDeletedLibraryItem()inutils/libraryLive.ts: the list,/query,GET/PATCH /:id(404), both texture lists, map generations, the ingest dedupe (a binned file can be imported again) and the quota (a binned item does not count).libraryLive.guard.test.tsholds every.from(schema.libraryItems)in server-core to it or to a three-entry allow-list. DELETE /api/library/:idstamps the row;?permanent=trueremoves the row and its tags, live or binned.POST /api/library/:id/restoreundoes it and re-checks the owner's quota. Same write rule as before: server-scope items are admin-only.- Undelete lists them (
GET /api/soft-deleted?entityType=libraryItem) and restores and purges them through the Library's endpoints. The daily purge takes them after 30 days withpermanentlyDeleteLibraryItem, the same function a permanent delete uses. Neither touches the image files. - On each card, a trash icon, hidden where the server would refuse. It opens
LibraryDeleteModal(built onConfirmDestructive, which gainedconfirmDisabled): a soft delete happens at once with Undo in the toast; "Hard delete (cannot undo)" turns the confirm into a 5-second countdown that any click or key press cancels (utils/cancellableCountdown.ts,useCancellableCountdown, withuseLockoutready for /admin/library). - "Show Deleted" in the Library header opens
/library/undelete.
- Every reader skips binned rows through one helper,
- Done: soft delete underneath.
- [x] Each library item should have a reclassify button
- Done: a category icon on each card, beside Delete and hidden where the server would refuse (the same
canModifyLibraryItem). It opens a menu, "Move to", of the other three classifications in tab order; picking one PATCHes the item'stype, which the server stamps as the user's confirmed choice, so the item moves to that tab. The toast says what it became ("Goblin" is now a token, under Tokens) and carries Undo, which puts each item back to what it was. A failure goes throughshowError, saying how many changed first.- The pure half is in
utils/libraryItemActions.ts(LIBRARY_CLASSIFICATIONS,reclassifyChoices,libraryReclassifyCopy,reclassifyLibraryItems, which remembers each item's old type and throws aReclassifyErrorcarrying the ones done). The toast and Undo are inuseLibraryReclassify. Both take many items, so Bulk Edit reuses them. Tags are left as they are, including a previous classification's namespaced ones.
- The pure half is in
- Done: a category icon on each card, beside Delete and hidden where the server would refuse (the same
- [x] Each tag should have an x button that appears on hover to delete it
- Done: the card shows every tag, not the first two, after the classification badge (
LibraryTags). Where the viewer may change the item (canModifyLibraryItem), each tag carries an × that appears on hover or keyboard focus and removes it, case-insensitively (removeTaginutils/libraryTagEdit.ts). It saves through the Library's PATCH, which stamps the tags as the user's, and a failed save goes throughshowError.
- Done: the card shows every tag, not the first two, after the classification badge (
- [x] To the right of the tag list, a + button allows adding new tags (custom or existing, using a typeahead)
- Done: a + after the tags (where the viewer may change the item) opens
LibraryTagChooser, a small popover on the TOP layer with a typeahead (its dropdown takes TOP from the theme). It offers the classification's vocabulary (vocabularyFor: global tags and the type's own), then the tags in use on the loaded page (tagsInUse), each once and without the item's own, filtered by what is typed (tagSuggestions). Enter takes whatever is typed as a custom tag unless a suggestion is highlighted; Escape or a click away closes.addTagignores a blank or a tag already there in any case. There is still no server endpoint listing every tag, so "existing" means the vocabulary and this page.
- Done: a + after the tags (where the viewer may change the item) opens
- [x] Each tag should have a pencil button that appears on hover to replace it with a different tag (same tag chooser UI as 'new tag' feature)
- Done: a pencil beside each tag's ×, on hover or keyboard focus (and kept showing while its chooser is open), opens the same
LibraryTagChooserstarting from the tag. What is picked or typed takes the old tag's place (replaceTag): the same tag in another case is a respelling, and a tag the item already has elsewhere moves into that place so nothing is there twice. A blank changes nothing.
- Done: a pencil beside each tag's ×, on hover or keyboard focus (and kept showing while its chooser is open), opens the same
- [x] Library list pages should have a Bulk Edit option, with sub-options Delete and Reclassify
- Done: a Bulk Edit menu in the Library header (Delete, Reclassify; not in the review list), Done to leave it; a new tab or the review list ends it. A bar below the header names the mode, counts the selection, offers Select all / Select none, and holds the call to action: Delete All or Reclassify All, with the count, disabled until something is picked.
- Cards lose Add to Map, Retouch, Reclassify, Delete and tag editing. The whole card is the hit zone for a top-right checkbox (
role="checkbox", Space or Enter toggles it), and only items the viewer may change can be picked; others are dimmed. The selection keeps to the items on the page, so a deleted or reclassified item drops out (utils/libraryBulkEdit.ts). - Delete All opens the same
LibraryDeleteModalas a card: soft at once with Undo, or "Hard delete (cannot undo)" behind the 5-second countdown that any click or key cancels. Reclassify All opensLibraryReclassifyModal, which offers the other classifications, thenuseLibraryReclassify's toast with Undo.
- Cards lose Add to Map, Retouch, Reclassify, Delete and tag editing. The whole card is the hit zone for a top-right checkbox (
- In this mode, each library item loses its usual action buttons, and the whole card is the hit zone for a top-right checkbox
- The list page CTA becomes Delete All or Reclassify All
- Both have a confirmation modal.
- In delete mode, there is an option to "hard delete (cannot undo)". When enabled, skip soft delete. This changes the behavior of the primary submit button: instead of instantly soft-deleting all selected items, it enters a 5 second process where any click or key at all will cancel the delete, and the UI clearly shows the countdown and what will happen.
- Done: a Bulk Edit menu in the Library header (Delete, Reclassify; not in the review list), Done to leave it; a new tab or the review list ends it. A bar below the header names the mode, counts the selection, offers Select all / Select none, and holds the call to action: Delete All or Reclassify All, with the count, disabled until something is picked.
- [x] Each library item should have a delete button
- [x] In /admin/library, add buttons to mass-delete either an entire classification (e.g. all Tokens) or all Library items. The confirmation modal has a 3 second lockout before submission is possible, and submission has the same 5 second countdown as the user-facing delete.
- Done:
/admin/library(AdminLibrary, in the Admin column after Data) shows a row per classification with its live count across every user and scope, a "Delete all Tokens"-style button on each, and "Delete everything"; it also notes how many items wait in the bin.AdminLibraryDeleteModal(built onConfirmDestructive) locks its submit for 3 seconds ("Wait 3…",useLockout). Any submit, soft or hard, then runs the 5-second countdown that any click or key cancels. "Hard delete (cannot undo)" is off by default.- Server:
GET /api/admin/data/library-summary(live counts per classification, plus the binned count) andPOST /api/admin/data/library/mass-delete { scope, permanent }, bothrequireAdmin. Soft stamps every live row in scope with the admin asdeleted_by, so each owner finds theirs in Undelete. Permanent removes every row in scope, live or binned, and their tags in chunks (deleteLibraryItemTagsFor), never the image files. A missing or unknown scope deletes nothing, and only a literaltrueis permanent. Tested against sqlite inadminLibraryMassDelete.test.ts.
- Server:
- Notes for the build:
- Library items gain
deleted_at/deleted_by(migration). Every reader filters them: list,GET/PATCH/DELETE /:id, textures, map generations, the ingest dedupe and the quota. - Soft-deleted items show up in Undelete, and the 30-day purge removes them.
- Server-scope items stay admin-only to delete or edit.
- The mass delete offers the same "hard delete" option, soft by default.
- Library items gain
- Done:
Modules in the platform
-
[x] M.1 — The modules move in:
- dnd5e, system8 and scenes come from vtt-modules
main(8fddf86), system5 fromsystem5/module(745138e), all under a rootmodules/folder. - Their
link:paths topackages/*becomeworkspace:*, andpnpm-workspace.yamlgainsmodules/*. - dddice-roller (a bare package.json) and vtt-modules' stale
modules/system5(docs and scripts only) are left behind. - The type check that CI and
pnpm verifyrun builds the modules, and the Docker context ignores their source. - Done:
- The files are copies of the tracked files at those commits. Their history stays in the old repos (the version merge is squashed anyway).
build:packagesbuilds./modules/*too, andbuild:modulesbuilds them alone.- The modules' vitest moves to the platform's 4.1. A second vitest left jest-dom's matchers on the wrong
expectand failed eight dnd5e sheet tests. - system5's "regenerated from docs that still exist" check reads a sibling
../system5checkout and skips without one. The never-empty half stays unconditional. .dockerignorekeepsmodules/out of the image context. The dnd5e scrape and SRD-PDF ignores moved into.gitignore.
- dnd5e, system8 and scenes come from vtt-modules
-
[x] M.2 — The modules join the gates:
- They move to the platform's vitest and get
test:coveragescripts, and the coverage-script parity guard learnsmodules/. - They get
lintscripts held to the same rules as everything else. - Tests that read module source through sibling paths, and skipped when the sibling was missing, now read it in the repo and always run.
- Done:
-
Each module has
lint,lint:allandtest:coverage, and the parity guard'sWORKSPACE_GLOBSincludesmodules. -
Coverage ratchets, floored at what each module measured on the way in:
module lines functions branches statements dnd5e 88 81 74 86 system8 70 75 59 71 scenes 74 72 68 70 system5 77 77 66 76 -
The modules' 15 lint errors were all unused imports and locals, and are removed. Two of them were a dead
attrModcomputed per attack in both 5eprojectTokens. -
A 30s
testTimeout, as the platform packages use. Two system8 tests that dynamic-import the module timed out under coverage at full workspace load. -
spellCastMessage.test.ts(dnd5e and system5) andFeatPowerModalFilters.test.ts(system8's router) readmodules/and no longer skip.
-
- They move to the platform's vitest and get
-
[x] M.3 — Content: the committed
data/is what ships.- system8's
ingestand system5'sbuild:contentbecome authoring steps that read a sibling../system8/../system5checkout. - Each bundle records the commit it was built from, instead of a floating
main. pnpm dev:system8,pnpm setup's module catalog andmodules.local.example.jsonpoint atmodules/.- Done:
- system8's
content-source.jsonis gone. It only told the retired publish job which ref to clone.data/provenance.jsonalready records the exact commit, and a test now holds it to a full SHA. - The ingest defaults to
<platform>/../system8/output. Its ref and channel come from what that checkout is on: stable onmain, beta otherwise, aspnpm dev:system8already stamped them. - system5's
build:contentreads<platform>/../system5(or--repo) and writesdata/provenance.json: the repo, version, branch, commit, and whether the docs had uncommitted edits. There is no clock, so an unchanged source still rebuilds byte-identical. Rebuilt from system5745138e, the data matched what was committed. pnpm dev:system8falls back tomodules/system8whenmodules.local.jsonhas no entry. The setup catalog loses dddice-roller, gains dnd5e and system5, and its versions catch up with the hosted pins.- Fixed on the way: the root
.gitignore'sdata/(meant for user uploads) had kept every module's content out of M.1. The data is now in M.1, and!modules/*/data/re-includes it.moduleContentTracked.guard.test.tsfails if any path in a module'sfilesother thandisthas nothing committed behind it.
- system8's
- system8's
-
[x] M.4 — Every environment builds first-party modules from source:
- One script builds and packs each in-repo module and writes the effective manifest.
modules.hosted.jsonlists the in-repo modules by directory and pins only genai and vtt-assets. - Prod (
ci.yml), alpha and dev all use it. Dev's sibling checkouts, itsvtt_modules_ref/system5_refinputs andmodules.dev.jsongo. - The Dockerfile and the install scripts take the tarballs, and Admin → Hosted modules reads the in-repo versions.
- Done:
modules.hosted.jsonnames each in-repo module bydir(modules/dnd5e,system8,scenes,system5) and pins only genai and vtt-assets byversion; each entry has exactly one of the two.modules.dev.jsonis gone: dev, alpha and prod read the same file.pnpm modules:bundle(scripts/bundle-modules.mjs) checks eachdirentry (the package name matches,dist/exists), packs it withpnpm packintomodule-tarballs/(pnpm rewritesworkspace:*; npm would not), and writesmodule-tarballs/modules.hosted.json, where every entry has a version. In-repo entries also carrysourceandcommit.--stamp dev.<run>packs asX.Y.Z-dev.<run>and puts the package.json back afterwards. The planning and resolution are pure and tested (bundle-modules.test.ts).- Every workflow runs it after install and
build:packagesand copies the manifest overmodules.hosted.jsonbefore anything reads it:- ci.yml: lint-and-test, deploy-frontends, and build-docker, which now installs and builds the workspace before the snapshot.
- deploy-alpha.yml: both jobs. A release tag older than the bundler skips this and installs from the registry as it always did.
- deploy-dev.yml: the bundle-modules job is checkout, install, build,
modules:bundle --stamp, and upload. The sibling checkouts of vtt-modules and system5, thevtt_modules_ref/system5_refinputs, the inline packing script andMODULES_TOKEN_*are gone.
dev-modules/becamemodule-tarballs/: the DockerfileCOPY,.gitignore,.gitkeep, the install scripts' defaultMODULES_TARBALL_DIR, and.gcloudignore's whitelist. Both install scripts refuse an unresolved manifest (an in-repodirwith no version), naming the step that was missed.- Local dev with no
modules.local.jsonloads the in-repo modules frommodules/: the server'sconfig.tsand the Vite plugin read adirentry as a path.modules.local.jsonstill wins when present. - Admin → Hosted modules declares an in-repo module's version from its own package.json; an image's resolved manifest carries the packed one.
pnpm setupwrites{package, dir}for in-repo modules (isInRepoModule). install-hosted-modules.test.tsis rewritten for the new shape: dir XOR version, exact versions, dirs that exist with matching names, the genai floor, the Dockerfile'smodule-tarballs/, and nomodules.dev.json. It runs now: step 0 (re-landed from the never-merged v0.84.0) madescripts/a workspace (@purple-cat/scripts), andorphanTestFiles.guard.test.tsfails on any test no workspace runs.- Verified locally:
pnpm build:packages && pnpm modules:bundle --stamp dev.42packed four tarballs (dist and data, noworkspace:) and a six-entry manifest; both install scripts installed all four from them; the unresolved committed manifest is refused. Not verified: a real CI run of any workflow, anddocker build(no Docker on this machine). The first push of this branch runs Deploy Dev, which exercises the dev path end to end. - Fixed on the way: scenes'
@types/nodewas ^20 against the workspace's 26, and on a fresh install pnpm could hoist the 20, giving server-core 25 type errors. It is ^26.1.2 now (its own commit, before the Library work).
- One script builds and packs each in-repo module and writes the effective manifest.
-
[x] M.5 — Publishing retired; the old homes cleaned up:
- CLAUDE.md's module rules (typecheck-as-ci, wait-for-publish) and the module docs describe the new flow.
- vtt-modules is cut down to vtt-assets; system5 loses
module/and its publish workflow. Both are local branches in those repos, for the owner to push. - The
MODULES_TOKEN_*secrets are no longer used; removing them is left to the owner. - Done:
- CLAUDE.md: the in-repo modules ship with the platform. They are built, linted and tested by the gate, a change is an ordinary commit with a version bump and a CHANGELOG line, and there are no per-module tags. The typecheck-as-CI and wait-for-publish rules now cover only the external modules (genai, vtt-assets), with their pin bumped in
modules.hosted.json. - The four modules' package.json:
"private": true;publishConfigandprepublishOnlygone (pnpm packpacks a private package all the same). There was noplatformRefleft to drop. (Not so: dnd5e'spackage.jsonkept"platformRef": "v0.107.0". v0.118.1 removed it.) - Docs:
module-template.mdgains "A first-party module" (workspace package, listed bydir, built from source, nothing published). The System 8 and dnd5e references inmonster-api.md,character-sheet-api.mdandcharacter-sheet-renderer.mdpoint atmodules/in this repo. The dddice-roller example links to vtt-modules as it stood at8fddf86. system5's README describesbuild:contentas an authoring step and local development frommodules/system5. The map generator proposal notes thatmodules/*is now in the workspace. Historical changelog and roadmap prose is left as written. - Sibling repos, as local branches for the owner to push (neither is pushed):
- vtt-modules
version/0.3.0(341822a, from main8fddf86): the moved modules, dddice-roller andscripts/typecheck-as-ci.shremoved;publish.ymlpublishes vtt-assets only, with no platform checkout,platformRef, system8 ingest orcontent_ref; README and CLAUDE.md describe a vtt-assets repository; version 0.3.0; a# Repository — 2026-10-05entry; the lockfile follows. Made in a separate worktree (../vtt-modules-0.3.0) so the../vtt-modulescheckout, whichmodules.local.jsonmay point into, stays on main. - system5
version/module-moved(57b189c, from main745138e):module/andpublish-module.ymlremoved, with a# Repository — 2026-10-05entry. The docs stay; they are the module's content source.
- vtt-modules
- The
MODULES_TOKEN_*secrets are no longer read by any workflow (M.4); deleting them is the owner's.
- CLAUDE.md: the in-repo modules ship with the platform. They are built, linted and tested by the gate, a change is an ordinary commit with a version bump and a CHANGELOG line, and there are no per-module tags. The typecheck-as-CI and wait-for-publish rules now cover only the external modules (genai, vtt-assets), with their pin bumped in