SWIFTPOINT//HUB
Support

Project structure — a file-by-file map

This file is the map of the repository: what every directory and file is for, what depends on it, in what order server.js wires the pieces together and why that

This file is the map of the repository: what every directory and file is for, what depends on it, in what order server.js wires the pieces together and why that order matters, and where a new feature belongs. Everything here was read from the source on 2026-09-30; counts in the tables are the real ones.

The top level

swiftpoint-hub/
  server.js          Express app, entry point ("main": "server.js")
  package.json       9 runtime dependencies, no devDependencies, no build step
  .env / .env.example  configuration; .env is git-ignored
  routes/            HTTP: one router per surface
  lib/               plain data and rendering, no Express and no session
  public/            12 static HTML pages, js/ (27 files), css/ (15 files), oled/
  docs/              the guides, as markdown, plus the recorded walkthroughs
  test/              47 test files on node:test, plus two DOM helpers
  scripts/           one generator: sync-header.js
  data/              the SQLite file (git-ignored)

Other things in the root that are inputs rather than code: z3.md (the Z3 research note the guides were written from), z3-profile-viewer.html and assets/ (the reference the checker was built against), seo-review.json (a one-off audit record), google6a47ec6ec477047a.html (the Search Console verification token), and swiftpoint-hub-old/ (a previous version of the app, kept but not loaded).

There is no build step and no bundler. npm install, npm start. npm test runs check:header first and then node --test. The whole dependency list is hand-picked and the code comments say so repeatedly — see lib/spcfParser.js and lib/session-store.js, which each explain why a package was not added.

The runtime dependencies

Package Version Why it is here
express ^4.19.2 The HTTP layer and the router.
better-sqlite3 ^11.3.0 The database. Synchronous, which is why the data modules are synchronous too. WAL and foreign_keys are set at open (lib/db.js).
express-session ^1.18.0 Sessions, with a custom store rather than the default memory store.
markdown-it ^15.0.2 Both markdown engines are built on it.
markdown-it-container ^4.0.0 The ::: blocks.
highlight.js ^11.12.0 Server-side syntax highlighting for fenced code.
multer ^2.0.0 The one multipart endpoint: the profile upload. 2 MB cap, memory storage.
dotenv ^16.4.5 Loads .env at the top of server.js.
@napi-rs/canvas ^1.0.5 Server-side PNG rendering: the OpenGraph cards, the per-guide cards and /profiles/:id/share.png.

routes/ — HTTP and permissions

Nothing in routes/ reads process.env for anything except the few base-URL constants, and none of them talks to the filesystem except where it has to. Each router is mounted in server.js and exports either the router or, for two of them, a router plus a small set of helpers.

File Mount point What it does Depends on
routes/auth.js /api/auth Discord OAuth2 login and callback, logout, /me, and the three guards requireUser, requireModerator, requireAdmin plus safeReturnTo. lib/db, lib/admin, lib/notify, lib/paths
routes/profiles.js /api/profiles Search, sort, filter, tag and game lists, stats, one profile, download, vote, upload, edit, replace the file, comments. Re-exports groupsForRow from lib/profile-groups. lib/db, lib/spcfParser, lib/profile-groups, lib/notify, lib/settings, lib/games, routes/auth
routes/notifications.js /api/notifications The list, the unread count, the SSE stream, and marking read. lib/db, lib/events
routes/reports.js /api/reports Filing a report (any signed-in user) and the report queue (admin). lib/db, lib/notify, routes/auth
routes/admin.js /api/admin Overview statistics, settings, staff CRUD, profile transfer, and mounts docs-write.js as a sub-router. lib/db, lib/settings, lib/admin, lib/notify, lib/analytics, lib/docs/write, routes/docs-write
routes/users.js /api/users One public member profile and the bio editor. lib/db, routes/auth
routes/forum.js /api/forum Categories, threads, posts, edit/delete, pin/lock/move, ticket status, the ticket queue, preview, subscriptions, search, and the updates endpoints. lib/db, lib/forum, lib/updates, lib/markdown, lib/notify, routes/auth
routes/records.js /api/records The recordings CRUD, publish and unpublish. Every route is requireAdmin. lib/records, routes/auth
routes/docs.js BASE_PATH (root) The guides site: /docs, /docs/<slug>, guide images, /docs/og/<slug>.png, .rec.json files, and the JSON endpoints /api/docs-tree, /api/guides, /api/docs-graph, /api/docs/<slug>/raw, /api/docs/<slug>/discord. lib/docs (the engine), lib/docs-og, lib/records, lib/analytics, lib/header
routes/docs-write.js inside /api/admin The guides editor API: list, create folder, create document, save, rename, delete, and preview. Every route individually requireAdmin. lib/docs/write, lib/docs/index, routes/auth
routes/seo.js BASE_PATH (root) Every HTML page, the OpenGraph images, robots.txt, sitemap.xml, the four UMD module routes (/js/spcfParser.js, which concatenates lib/nesting.js ahead of itself, plus /js/nesting.js, /js/spcfWriter.js and /js/step-diff.js), and serveShell. lib/db, lib/analytics, lib/share-image, lib/og-card, lib/header, lib/footer, lib/paths, lib/legal, lib/forum, lib/updates, lib/docs

lib/ — data and rendering

The rule the modules follow: no Express, no session, plain data in and out. lib/forum.js and lib/updates.js say so in their own headers, and the tests drive them directly instead of over HTTP.

File What it is for Depends on Used by
lib/db.js Opens the SQLite file (creating the directory), sets journal_mode = WAL and foreign_keys = ON, and runs the whole schema: users, profiles, comments, notifications, reports, pageviews, votes, settings, forum_categories, forum_threads, forum_posts, forum_ticket_events, forum_subscriptions, updates, staff, recordings. better-sqlite3 Almost everything.
lib/session-store.js A four-method express-session Store over the same SQLite file. Sweeps expired rows on construction and every 100 writes. lib/db server.js
lib/admin.js The single place the staff allowlists are read. .env is the stronger claim; the staff table is the weaker one. Exposes isAdminDiscordId, isModeratorDiscordId, listStaff, addStaff, setStaffRole, removeStaff, knownPeople, reload. lib/db (lazily) routes/auth, routes/admin, lib/notify, routes/seo
lib/paths.js BASE_PATH and GALLERY_PATH (/profiles/hub), plus galleryPath(). — routes/seo, lib/header, lib/footer, routes/auth
lib/header.js The site header, generated once: row 1 with the nav, row 2 the per-page tool strip. Exports headerHtml, NAV, START, END, SUPPORT_URL. lib/paths scripts/sync-header.js, routes/docs.js, routes/seo.js
lib/footer.js The shared footer, the link list and the disclaimer note. lib/paths scripts/sync-header.js, routes/seo.js
lib/legal.js /privacy and /imprint, generated from the implementation so the claims are checkable. lib/db routes/seo.js
lib/analytics.js First-party pageview counter. trackPageView(ip, path) HMAC-hashes the IP; trafficOverview() returns 1 h / 24 h / 7 d / 30 d windows. Prunes rows older than 30 days every 200 inserts. crypto, lib/db routes/seo, routes/docs, routes/admin
lib/events.js In-process pub/sub for the SSE notification stream: subscribe, unsubscribe, publish, keyed by user id. — lib/notify, routes/notifications
lib/notify.js Writes notification rows and publishes them. notifyAdmins, notifyStaff, notifyUser, notifyMany, isAdminUser. lib/db, lib/events, lib/admin routes/profiles, routes/forum, routes/admin, routes/reports, routes/auth
lib/spcfParser.js UMD. Reads a .spcf and produces the display model: groups, sub-groups, device settings from the profileChanged system event, button mappings, tilt mappings, the input-nesting tree (nesting, keyed by raw ActionMappings index) and OLED frames. Enforces MAX_GROUPS 100, MAX_MAPPINGS 1000, MAX_NESTING 20. Also exports describeTrigger, extractProfileSettings, buttonLabel and indexInList — the last is the only sanctioned raw-index → display-list bridge. Buffer.from(value, 'base64') and lib/nesting routes/profiles, routes/seo, lib/share-image, lib/profile-groups, public/js/check.js and public/js/editor.js via /js/spcfParser.js
lib/nesting.js UMD, zero dependencies. The nesting core: buildTreeFromValues, walk, flatten, validate, pathToRoot, isSingleButtonStack, the mutation helpers insertChild / removeNode / moveNode, and MAX_NEST_DEPTH = 20. Shape-agnostic (a count plus a parent-index callback), so the parser and the Builder share it. Zero dependencies is a hard requirement: routes/seo.js serves the file verbatim to the browser. Defensive by contract — a dangling or self-referential link makes that node a root, a cycle is detached, and every index is visited exactly once. — lib/spcfParser, public/js/editor.js, test/nesting-tree.test.js
lib/profile-groups.js groupsForRow(row) — the groups a stored profiles row is rendered from. parsed_summary is a cache of JSON.stringify(parseSpcf(file).groups) and all 48 rows predate the nesting shape, so a cached copy without a nesting block is re-parsed from raw_json. Never throws: an unparseable body degrades to the row as stored. lib/spcfParser routes/profiles.js (re-exported as router.groupsForRow), lib/share-image.js
lib/spcfWriter.js UMD. The other direction: builds the raw .NET DataContract JSON nodes the Control Panel writes, including the $type class-name catalogue. applyNesting(values) regenerates the recursive Predecessor copies from PredecessorId; it is verified byte-identical on all 60 groups of all 48 stored profiles. — public/js/editor.js via /js/spcfWriter.js, and the tests
lib/step-diff.js UMD. Semantic diff between two .spcf documents, phrased in the guides' own vocabulary. Caps at CHANGES_PER_STEP = 2. public/js/export-helpers.js, lib/spcfParser.js public/js/recorder.js via /js/step-diff.js
lib/markdown.js The forum markdown engine. html: false, a URL scheme allowlist, rel="noopener nofollow ugc" on external links, a fr-* class prefix, and the ::: blocks: callouts, changelog entries, spoiler, steps, video. CHANGELOG (added/changed/fixed/removed/security) is exported and imported by the docs engine. markdown-it, markdown-it-container, highlight.js lib/forum.js, lib/updates.js, routes/forum.js
lib/docs/index.js The guides engine. Front matter (title, description, tags, order), wikilinks, relative links and images, Obsidian callouts rewritten into :::, tabs, steps, video, spoiler, changelog blocks, heading anchors, a TOC, outgoing/incoming link sets, graphData(), apiTree(), tagsStats(), toDiscord(). Owns DOCS_ROOT = docs and siteUrl(). fs, markdown-it, markdown-it-container, highlight.js, lib/records, lib/markdown (for CHANGELOG) routes/docs.js, routes/docs-write.js, routes/seo.js
lib/docs/write.js Writing guides to the repository. absFor() is the one path guard and every operation goes through it. Front matter is preserved, writes are temp-file + rename, and folder deletion asks first (409 with a file count). Caps: name 60, title 140, description 300, body 200 000, tags 12. fs, path, lib/docs/index.js routes/docs-write.js
lib/docs-og.js The 1200×630 per-guide OpenGraph card. @napi-rs/canvas, lib/docs routes/docs.js
lib/og-card.js The site's own social cards: homeCard, galleryCard, toolCard, userCard, and WIDTH/HEIGHT. @napi-rs/canvas routes/seo.js
lib/share-image.js Server-side render of /profiles/:id/share.png, reusing the same UMD modules the browser uses, so the served image and the in-page Share download are identical. Reads its groups through lib/profile-groups.js, so a stored parsed_summary from before the nesting rework still renders a tree. @napi-rs/canvas, public/js/export-helpers.js, public/js/share-render.js, lib/profile-groups, lib/db routes/seo.js
lib/forum.js The forum data layer: categories, threads, posts, subscriptions, the ticket statuses and the trail. STATUSES is the canonical list. lib/db, lib/markdown, lib/records routes/forum.js, routes/seo.js
lib/updates.js The updates data layer. KINDS (website, mouse) and ACCENTS (lime, blue, teal, violet, orange, critical). lib/db, lib/markdown routes/forum.js, routes/seo.js
lib/games.js The canonical game taxonomy: canonicalGame(), and migrate(), which rewrites stored spellings at boot. lib/db routes/profiles.js, server.js
lib/records.js The recordings data layer. Validates a .rec.json payload, keeps the document in the recordings table, and maps recordings/<slug>.rec.json to a slug. MAX_BYTES 50 MB because the screenshots are data URLs. fs, path, lib/db routes/records.js, routes/docs.js, lib/docs/index.js, lib/forum.js
lib/settings.js Two functions over the settings table: getSetting(key, fallback) and setSetting(key, value). lib/db routes/profiles.js, routes/admin.js

public/ — the front end

Twelve HTML files, each of which is a shell: the server rewrites the title, the description, the canonical and og:* tags, and the client script fills in the data. None of them contains an inline script.

Page file Served at Rendered by Stylesheets Client script
index.html / routes/seo.js tokens, base, hdr, ui, home-beta theme.js, chrome.js, hero.js
gallery.html /profiles/hub routes/seo.js + gallery app.js, auth.js, card.js
profile-beta.html /profiles/:id routes/seo.js + profile-beta profile-beta.js, share-render.js, export.js
user.html /users/:id routes/seo.js + user user.js
forum.html /forum routes/seo.js + forum forum-common.js, forum.js
thread.html /forum/t/:id routes/seo.js + forum forum-common.js, thread.js, steps-player.js
updates.html /updates routes/seo.js + updates forum-common.js, updates.js
check.html /check routes/seo.js + check check.js, export-helpers.js
editor.html /editor routes/seo.js (adds data-can-record) + editor spcfParser.js, spcfWriter.js, step-diff.js, editor.js, recorder.js (admin), steps-player.js
oled.html /oled routes/seo.js + oled/oled-editor oled/oled-editor.js, oled/spcf-inject.js
records.html /records routes/seo.js (admin only) + records records.js, steps-player.js
admin.html /admin routes/seo.js (admin only) + admin admin.js, admin-guides.js, markdown-editor.js

public/js/ — 27 classic scripts, all IIFEs or UMD modules, none of them a module system:

File What it is for
theme.js The theme switch. A separate file loaded without defer so data-theme is set before first paint.
chrome.js Header behaviour: theme, mobile drawer, buildFooter() as a fallback for any page not yet synced.
auth.js The one place the client asks about the session. Exports SPHAuth; four page scripts used to each carry their own copy.
app.js The gallery: load, search, sort, filter, cards, upload.
card.js The parts of a profile card both the gallery and the dashboard need: game colour, DPI range, big numbers, avatar fallback.
profile-beta.js The profile detail page: section nav with scroll-spy, layer switcher, live binding filter, votes, comments, reports.
user.js The member page.
forum-common.js Fetch wrapper, HTML escaper, toasts, modals, "3 minutes ago" — shared by the three forum-family pages.
forum.js The forum index.
thread.js One thread: posts, composer, ticket status buttons, trail, pin/lock/move/delete.
updates.js The updates list and its kind filter.
markdown-editor.js The one markdown editor: new thread, reply, post edit, updates form. Toolbar, preview, localStorage draft.
notifications.js The unread badge and the toast feed. Also polls the unread count every 30 s.
check.js The client-side .spcf viewer. Parses with the shared parser; "the file never leaves the device".
editor.js The Profile Builder. The largest file in the repository. Three panels, the mouse diagram, the binding composer, the layers rail, the localStorage draft.
recorder.js The walkthrough recorder. Hangs off window.EDHooks; gated on document.body.dataset.canRecord.
records.js The recordings admin page: list, edit, delete, publish, embed.
steps-player.js The ::: steps player. Self-contained: it fetches the .rec.json behind the box it is in. Used by the guides and the forum.
export-helpers.js UMD. Pure helpers for describing profile data, shared by Node (share images) and the browser.
export.js UMD. The clean Markdown listing of a profile's bindings, for the download and the clipboard.
share-render.js UMD. The canvas renderer for the share image, identical in Node and the browser.
hero.js The front page's 64×32 OLED marquee preview and the scroll reveal. Both decorative.
docs.js The guides page: tree collapse, search, outline scroll-spy, lightbox, mobile drawer, keyboard shortcuts. The page itself is server-rendered.
docs-graph.js The notes map.
video-player.js The chapter chips and ranges for a ::: video block.
admin.js The admin panel: statistics, settings, staff, profiles, reports.
admin-guides.js The guides editor inside the panel. Writes files; keeps no copy.

public/oled/ — the OLED editor lives in its own directory with its own stylesheet:

File What it is for
oled/css/oled-editor.css The full-viewport app shell for the pixel editor.
oled/js/oled-editor.js The editor itself: 64×32, 1-bit, frames, layers, keyframes, onion skin, text, symbols, undo, and every export.
oled/js/spcf-inject.js Takes one of your own .spcf files, attaches the current animation as a chain of oledFlash actions, and hands the file back. Entirely client-side.

public/css/ — 15 stylesheets, layered in a fixed order, and test/ui-css-coverage.test.js fails if a page uses a class that a stylesheet it does not load does not style:

File Layer What it owns
tokens.css 1 Design tokens, single source of truth, loaded first on every page.
base.css 2 Reset, element defaults, layout primitives, accessibility defaults.
hdr.css 3 The shared site chrome: header rows, nav, mobile drawer. Nothing else may restyle it.
ui.css 4 The component kit: buttons, fields, cards, chips, tabs, tables, empty states, toasts.
gallery.css page The gallery layout.
home-beta.css page The front page.
profile-beta.css page The profile detail layout: identity sidebar, section nav.
forum.css page The forum index, the thread page, and .st-<status> colours.
updates.css page The updates list, and .k-<kind> colours.
check.css page The checker.
editor.css page The Profile Builder shell and workspace.
records.css page The recordings page.
admin.css page The admin panel.
user.css page The member page.
docs.css page The guides viewer: explorer, article, outline rail, and the d-* block classes.
oled/css/oled-editor.css page The OLED editor (loaded from its own directory).

docs/, test/ and scripts/

docs/ is the guides tree the site serves, and it is the whole of it — there is no second level in between any more. Four things live in it:

Path What it is Served at
docs/profile-builder/ Eleven markdown files: README.md plus the ten Player guides /docs/profile-builder/<slug>
docs/testing.md One guide at the root, with the new-profile recording in it /docs/testing
docs/recordings/ Every .rec.json there is, and nothing else /docs/recordings/<slug>.rec.json
docs/about/ The twenty-five files you are reading now /docs/about/<slug> — closed, see below

There is no docs/wiki/; the tree used to be nested inside it and the URLs never were, so the folder only ever added a level that had to be argued about. recordings/ is at the root rather than inside a guide's folder because a recording is a shared asset, not something a guide owns: two guides may embed the same walkthrough, and it must not live in either of them. See Walkthrough recorder.

docs/about/ is part of the served tree — it is inside DOCS_ROOT, it renders, and it has addresses — but a doc_visibility row closes the folder to staff, so a public reader gets the same "no such page" answer as for a file that is not there. See 15 Auth and roles. Everything in docs/ is owned by the repository, edited by hand or from the admin panel, and rendered by lib/docs/index.js.

test/ is 47 files on node:test plus two helpers (test/helpers/editor-dom.mjs, a stand-in DOM good enough to boot the markdown editor, and test/helpers/md-dom.js, the equivalent for the markdown blocks). They fall into: the HTTP surfaces (forum, docs-api, docs-write, auth-roundtrip, records, staff, admin-access), the pure modules (game-taxonomy, step-diff, blocks, session-store, spcf-format, profile-format), the front end against a fake DOM (editor-boot, editor-draft, editor-first-run, editor-flow, editor-preview, dom-contract, script-order, ui-shape, video-player, docs-player), the chrome and the CSS (header, footer, mobile-header, ui-css-coverage, gallery-links, front-page, forum-pages, forum-render, docs-tags, docs-empty-folders, notes-map, legal-pages, ui-language), and the cross-cutting invariants (http-headers, limits, robots, social-card, profile-seo, public-api-privacy, step-titles, auth, docs-slug, docs-steps). 22 Testing covers what each group protects.

scripts/sync-header.js is the only generator. npm run sync:header writes the header row 1 and the footer into the twelve static pages; npm run check:header fails if any page is stale, and pretest runs it. The tool strip (.tbar) is authored in each page and left alone.

The mount order in server.js, and why it matters

1  express.json({ limit: '64mb' })
2  express-session        cookie `swiftpoint.sid`, 12 h, SQLite store
3  security headers       CSP, nosniff, X-Frame-Options, Referrer-Policy, HSTS
4  cache-control          by file extension, unless a route already set one
--  lib/games.migrate()   runs here, before the first request
5  /api/auth
6  /api/profiles
7  /api/notifications
8  /api/reports
9  /api/admin             also mounts routes/docs-write.js → /api/admin/docs/…
10 /api/users
11 /api/forum
12 /api/records
13 routes/docs.js         /docs and everything under it
14 routes/seo.js          every HTML page, the OG images, robots, sitemap
15 express.static(public)
16 error handler          LIMIT_FILE_SIZE → 413, entity.too.large → 413

Four reasons the order is load-bearing:

  • The API mounts come before the page mounts on purpose. /api/* never falls through to a page. The 64 MB JSON limit is set first so a recording POST with per-step screenshot data URLs is parsed at all, and it is large for that reason and no other.
  • /profiles/hub is registered above /profiles/:id in routes/seo.js, so the word "hub" is not read as a profile id. lib/paths.js holds the constant because the redirect for a signed-in non-admin, the sitemap, the header, the footer and the client scripts all have to agree on it.
  • routes/docs.js registers its specific patterns before the generic one: /docs/og/<slug>.png is matched before the image matcher, and the .rec.json route is registered before the catch-all /docs/(.+) — otherwise a recording would be resolved as a guide slug. The serveShell behaviour has to be in place before express.static so a generated page is never shadowed by a same-named file, and /oled.html redirects to /oled for the same reason.
  • /api/admin is one surface with one gate, and docs-write.js is mounted inside it rather than separately. test/admin-access.test.js reads routes/admin.js and asserts that every admin route is behind requireAdmin; a sub-router the check cannot see is a sub-router nobody is checking. Each route in docs-write.js therefore carries its own requireAdmin — it writes files, and one of them deletes them.

The boot-time lib/games.migrate() sits between the API mounts and the page mounts so the canonical spellings are in place before any request can read them. It is idempotent and logs Canonicalised N of M game names when it changed something.

Where a new feature goes

If you are adding… Put it in And do not forget
An HTTP endpoint routes/<surface>.js The guard: requireUser, requireModerator or requireAdmin, from routes/auth.js.
A query, a rule or a renderer lib/<concern>.js Keep it free of Express and the session so the tests can drive it directly.
A new table or column lib/db.js CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS, so an existing database picks it up on boot.
A page public/<name>.html A route in routes/seo.js that serves it, plus npm run sync:header if it needs the shared header or footer.
A style public/css/<page>.css And check the page actually loads it — ui-css-coverage.test.js enforces that.
A guide docs/<folder>/<slug>.md Front matter with at least title and description; order decides the reading order, tags make it filterable.
A recording docs/recordings/<slug>.rec.json Published from /records, not written by hand. Every guide's ::: steps resolves into that one directory.
A test test/<thing>.test.js node --test picks it up with no registration.

npm run sync:header after editing a static page is not optional: the header and the footer are generated, and npm test fails when a page has drifted.

Adding a change without breaking things

  • Run npm test before and after. pretest runs check:header, so a page whose header is stale fails before your test even starts.
  • Do not add an inline script or an onclick=. script-src 'self' means it will not run, and the failure is silence. test/script-order.test.js exists because a defer race once made a whole admin panel look like an auth failure.
  • Do not read the allowlists anywhere but lib/admin.js. test/admin-access.test.js fails if a second module parses ADMIN_DISCORD_IDS itself.
  • Do not change the .spcf writer's output shape casually. test/profile-format.test.js asserts that every profile the hub actually stores still round-trips through the parser, because a field that stops being written is invisible in a diff and fatal in a download.
  • Do not widen a limit in one place only. test/limits.test.js exists because the editor's counter, the server's cap and the preview endpoint's cap were three numbers that had to agree.
  • Restart the service after deploying. A running process only loads code at start, so a new route answers Cannot GET … until sudo systemctl restart swiftpoint-hub.service. This is the single most confusing thing for a new contributor, and it is not a proxy problem.

The three extension points the project README calls out

These are the places where one value is deliberately declared in more than one file, and where adding to them means editing all of them:

  1. A new ticket status. Add it to STATUSES in lib/forum.js, to STATUSES in public/js/thread.js (so the buttons appear), and give it a .st-<value> rule in public/css/forum.css (so it has a colour). test/forum-pages.test.js fails when one of the three is missing.

    lib/forum.js        STATUSES = [ { value, label, hint }, … ]   STATUS_SHORT = { … }
    public/js/thread.js STATUSES
    public/css/forum.css .st-open .st-in_progress .st-on_hold .st-closed
    
  2. A new update kind. Add it to KINDS in lib/updates.js and give it a .k-<value> rule in public/css/updates.css. The existing two are website and mouse; the split is the feature, so a third kind has to be as distinct as those two are.

  3. A new staff member. The allowlist is only ever read in lib/admin.js. An .env entry is the stronger claim: a staff table row cannot demote or remove it, and the last admin cannot be removed or demoted at all — guardNotLastAdmin() returns the refusal and test/staff.test.js covers it.

Next: 03 Pages and routes.