Skip to content

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.json and 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, and content-source.json records 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_items has deleted_at / deleted_by (migrations-pg 0059, journalled; the sqlite and pg live upgrades; every CREATE TABLE copy).
          • Every reader skips binned rows through one helper, notDeletedLibraryItem() in utils/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.ts holds every .from(schema.libraryItems) in server-core to it or to a three-entry allow-list.
          • DELETE /api/library/:id stamps the row; ?permanent=true removes the row and its tags, live or binned. POST /api/library/:id/restore undoes 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 with permanentlyDeleteLibraryItem, 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 on ConfirmDestructive, which gained confirmDisabled): 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, with useLockout ready for /admin/library).
          • "Show Deleted" in the Library header opens /library/undelete.
      • [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's type, 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 through showError, 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 a ReclassifyError carrying the ones done). The toast and Undo are in useLibraryReclassify. Both take many items, so Bulk Edit reuses them. Tags are left as they are, including a previous classification's namespaced ones.
      • [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 (removeTag in utils/libraryTagEdit.ts). It saves through the Library's PATCH, which stamps the tags as the user's, and a failed save goes through showError.
      • [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. addTag ignores 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.
      • [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 LibraryTagChooser starting 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.
      • [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 LibraryDeleteModal as 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 opens LibraryReclassifyModal, which offers the other classifications, then useLibraryReclassify's toast with Undo.
        • 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.
  • [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 on ConfirmDestructive) 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) and POST /api/admin/data/library/mass-delete { scope, permanent }, both requireAdmin. Soft stamps every live row in scope with the admin as deleted_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 literal true is permanent. Tested against sqlite in adminLibraryMassDelete.test.ts.
    • 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.

Modules in the platform

  • [x] M.1 — The modules move in:

    • dnd5e, system8 and scenes come from vtt-modules main (8fddf86), system5 from system5/module (745138e), all under a root modules/ folder.
    • Their link: paths to packages/* become workspace:*, and pnpm-workspace.yaml gains modules/*.
    • 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 verify run 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:packages builds ./modules/* too, and build:modules builds them alone.
      • The modules' vitest moves to the platform's 4.1. A second vitest left jest-dom's matchers on the wrong expect and failed eight dnd5e sheet tests.
      • system5's "regenerated from docs that still exist" check reads a sibling ../system5 checkout and skips without one. The never-empty half stays unconditional.
      • .dockerignore keeps modules/ out of the image context. The dnd5e scrape and SRD-PDF ignores moved into .gitignore.
  • [x] M.2 — The modules join the gates:

    • They move to the platform's vitest and get test:coverage scripts, and the coverage-script parity guard learns modules/.
    • They get lint scripts 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:all and test:coverage, and the parity guard's WORKSPACE_GLOBS includes modules.

      • 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 attrMod computed per attack in both 5e projectTokens.

      • 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) and FeatPowerModalFilters.test.ts (system8's router) read modules/ and no longer skip.

  • [x] M.3 — Content: the committed data/ is what ships.

    • system8's ingest and system5's build:content become authoring steps that read a sibling ../system8 / ../system5 checkout.
    • Each bundle records the commit it was built from, instead of a floating main.
    • pnpm dev:system8, pnpm setup's module catalog and modules.local.example.json point at modules/.
    • Done:
      • system8's content-source.json is gone. It only told the retired publish job which ref to clone. data/provenance.json already 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 on main, beta otherwise, as pnpm dev:system8 already stamped them.
      • system5's build:content reads <platform>/../system5 (or --repo) and writes data/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 system5 745138e, the data matched what was committed.
      • pnpm dev:system8 falls back to modules/system8 when modules.local.json has 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's data/ (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.ts fails if any path in a module's files other than dist has nothing committed behind it.
  • [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.json lists 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, its vtt_modules_ref / system5_ref inputs and modules.dev.json go.
    • The Dockerfile and the install scripts take the tarballs, and Admin → Hosted modules reads the in-repo versions.
    • Done:
      • modules.hosted.json names each in-repo module by dir (modules/dnd5e, system8, scenes, system5) and pins only genai and vtt-assets by version; each entry has exactly one of the two. modules.dev.json is gone: dev, alpha and prod read the same file.
      • pnpm modules:bundle (scripts/bundle-modules.mjs) checks each dir entry (the package name matches, dist/ exists), packs it with pnpm pack into module-tarballs/ (pnpm rewrites workspace:*; npm would not), and writes module-tarballs/modules.hosted.json, where every entry has a version. In-repo entries also carry source and commit. --stamp dev.<run> packs as X.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:packages and copies the manifest over modules.hosted.json before 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, the vtt_modules_ref / system5_ref inputs, the inline packing script and MODULES_TOKEN_* are gone.
      • dev-modules/ became module-tarballs/: the Dockerfile COPY, .gitignore, .gitkeep, the install scripts' default MODULES_TARBALL_DIR, and .gcloudignore's whitelist. Both install scripts refuse an unresolved manifest (an in-repo dir with no version), naming the step that was missed.
      • Local dev with no modules.local.json loads the in-repo modules from modules/: the server's config.ts and the Vite plugin read a dir entry as a path. modules.local.json still 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 setup writes {package, dir} for in-repo modules (isInRepoModule).
      • install-hosted-modules.test.ts is rewritten for the new shape: dir XOR version, exact versions, dirs that exist with matching names, the genai floor, the Dockerfile's module-tarballs/, and no modules.dev.json. It runs now: step 0 (re-landed from the never-merged v0.84.0) made scripts/ a workspace (@purple-cat/scripts), and orphanTestFiles.guard.test.ts fails on any test no workspace runs.
      • Verified locally: pnpm build:packages && pnpm modules:bundle --stamp dev.42 packed four tarballs (dist and data, no workspace:) 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, and docker 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/node was ^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).
  • [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; publishConfig and prepublishOnly gone (pnpm pack packs a private package all the same). There was no platformRef left to drop. (Not so: dnd5e's package.json kept "platformRef": "v0.107.0". v0.118.1 removed it.)
      • Docs: module-template.md gains "A first-party module" (workspace package, listed by dir, built from source, nothing published). The System 8 and dnd5e references in monster-api.md, character-sheet-api.md and character-sheet-renderer.md point at modules/ in this repo. The dddice-roller example links to vtt-modules as it stood at 8fddf86. system5's README describes build:content as an authoring step and local development from modules/system5. The map generator proposal notes that modules/* 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 main 8fddf86): the moved modules, dddice-roller and scripts/typecheck-as-ci.sh removed; publish.yml publishes vtt-assets only, with no platform checkout, platformRef, system8 ingest or content_ref; README and CLAUDE.md describe a vtt-assets repository; version 0.3.0; a # Repository — 2026-10-05 entry; the lockfile follows. Made in a separate worktree (../vtt-modules-0.3.0) so the ../vtt-modules checkout, which modules.local.json may point into, stays on main.
        • system5 version/module-moved (57b189c, from main 745138e): module/ and publish-module.yml removed, with a # Repository — 2026-10-05 entry. The docs stay; they are the module's content source.
      • The MODULES_TOKEN_* secrets are no longer read by any workflow (M.4); deleting them is the owner's.