Visuelize
Runs the screens in the boardroom. You control what every screen shows from a phone or laptop; changes land on the room displays in well under a second, with no page reload and no one touching the room machine.
The four surfaces
| Surface | Address | Who opens it |
|---|---|---|
| Room display | /s/<screen>?token=… | Opened once on each room machine and left alone |
| Control panel | /admin | Whoever runs the meeting — an account with manage rights |
| Organisation console | /console | Owners and admins — people, teams, sessions, PINs, devices |
| Join | /join | Anyone invited — session number and join password, no account |
| Presenter remote | /remote | A presenter's phone — presenter PIN or a presenter account |
| Screen share | /share | Someone off-site sharing their screen — share PIN |
| Logs | /logs | What every machine in the room reported — manage rights |
A room display never logs in. It authenticates with its own screen token, which is baked into the link, and that token only ever grants read access to meeting content. That is what lets you open a screen with zero typing at the start of a day.
Setting up a meeting
- Settings — meeting title, subtitle, date line, venue, logo, theme, accent colour.
- Presentations — upload PDF / PPTX / PPT / ODP / images. PPTX is converted on the server; check the thumbnails that appear.
- Photos — make a gallery, upload photos, set seconds per photo.
- Agenda — add items with speaker and start time.
- Music — upload tracks, build the playlist, choose which screens play sound.
- Screens & Links — open each screen link on its room machine and click Start Display once.
Running the meeting
Live Control has one card per screen. Pick a mode and the screen changes immediately. Two people with the panel open never drift apart — the server is the single source of truth and pushes every change to both.
The header buttons drive every screen at once: All → Agenda, All → Welcome, All → Black.
Every screen mode
| Mode | Shows | Card controls |
|---|---|---|
| Welcome | Title, logo, date, venue, optional clock, animated backdrop | — |
| Screensaver | The same copy, larger, over a full-screen animation | Animation picker |
| Agenda | Full agenda; current item highlighted, done items struck through | — |
| Presentation | A deck | First / Prev / Next / Last, jump to slide |
| Photos | A gallery cycling on its own | Gallery picker |
| Video | An MP4 full-screen with its own sound; room music mutes while it plays | Video picker, Play/Pause, Restart, Seek, Loop |
| Interval | Countdown or count-up clock, message, now-playing strip | Direction, timer presets or a typed length in whole minutes, stopwatch, red threshold, heading, message |
| Live Share | A remote presenter's screen | Presenter picker |
| Message | One big headline and sub-line | Headline, sub-line |
| Black | Nothing at all | — |
Mark an agenda item Now and every agenda screen highlights it instantly.
Locked room screens
A room display locks its keyboard and mouse the moment someone clicks Start Display. Every key, click, right-click, scroll and touch gesture is swallowed, so a keyboard left on the boardroom table — or a curious hand on the room PC — cannot change what the room sees. The control panel and the presenter remote stay the only ways in.
Presentations are the exception. While a screen is showing a deck, whoever is standing at that machine can walk the slides:
| Keys | Does |
|---|---|
→ ↓ Space PageDown Enter | Next slide |
← ↑ PageUp Backspace Shift+Space | Previous slide |
Home / End | First / last slide |
Those moves go through the server exactly like a tap on the remote, so a second screen on the same deck follows along and the operator's panel always shows where the room really is. In any other mode the keys do nothing at all.
Ctrl,
Alt or Cmd still reaches the browser — blocking
Ctrl+R would strand a wedged screen with no way back.
Esc and F11 still leave full screen, because the browser
acts on them before the page ever sees the key; the display asks for full screen
back and files a warning in the log when it cannot.
&unlock=1 to a
display URL to open it with the lock off — for setup and debugging on the room
machine. The screen logs a warning so an unlocked display is never a mystery.
Presentations
Decks are converted to flat per-slide JPEGs on upload. The display page never needs a PDF renderer, so a slide can never fail to paint in the middle of a meeting. Conversion runs one deck at a time.
Slides render at 400 DPI (~5333 px wide for 16:9) at JPEG quality 92 — native 4K with no upscaling, so text stays crisp on a projector. Higher fidelity means larger files (~4–8 MB per slide) and slower conversion; a big deck can take a while. Change an existing deck's resolution by re-uploading it (Replace file) — slides are only rendered at upload time.
Accepted: .pdf .pptx .ppt .odp .pps .ppsx .key, and single images (.jpg .png .webp).
If a converted deck looks wrong, in order of effort:
- Replace file on the deck — upload the PDF version. Same deck, so screen assignments and agenda links stay pointed at it.
- Replace slide — hover a thumbnail and upload a PNG of just that slide.
- Upload slide images directly as their own deck.
Tags, search & archive
Presentations, Videos and Photos each carry a filter bar: a search box over names and tags, the tags actually in use as chips, and an Archived switch. The three tabs keep their own filter, and none of it is saved to the server — it is a view, not a setting, so two operators never fight over it.
Tags
Add tags on any deck, video, gallery or single photo. Comma-separated, up to 12, lower-cased and de-duplicated on save; the dialog offers every tag already in use so the same idea does not end up spelled three ways. A tag on a card is a button — clicking it filters the tab down to that tag.
Archive
Archive takes an item out of the library list and out of the screen pickers, on the panel and on the presenter remote. Nothing is deleted: the file, its slides and its tags all stay. Archived (n) switches the tab to the archive, where Restore puts an item back.
Rename sits next to them on every kind: deck (title and speaker), video, gallery, and a photo's caption via the ✎ button on its thumbnail.
Photos
A gallery cycles on its own once a screen is in Photos mode. Each photo is stored full-size plus a thumbnail. Set seconds-per-photo per gallery; drag to reorder.
Agenda
Items carry a title, speaker, start time, duration and optional notes, and can be linked to a deck. Marking an item Now highlights it on every agenda screen and strikes through everything above it. Reset progress clears the run without deleting the agenda.
Sub-items
An entry can carry sub-items — the small lines under it, like the list of DDGs under
"Issues & Activities". Expand an agenda row in the panel and type one per line;
add a speaker after a pipe: DDG Planning | Dr. Perera. On the display they
lay out in two columns under the item, just like the printed agenda.
Each agenda screen decides how much to show, from its Live Control card:
| Sub-items | Shows |
|---|---|
| Under the current item only (default) | They appear as the meeting reaches that item, so the board is not a wall of text beforehand |
| Show under every item | All sub-items, always |
| Hide sub-items | Titles only |
Discreet backdrop
An agenda screen can run a faint animated backdrop behind the list — the same CSS scenes as the welcome screen, held at low strength behind a heavy veil so the agenda never loses contrast. Off by default; set it on the screen's Live Control card. WebGL scenes are intentionally not offered here — too heavy to run behind live text.
Videos
Upload an MP4 (also MOV, WebM, M4V, OGV) in the Videos tab, then set any
screen to Video mode and pick it. It plays full-screen with
object-fit: contain and its own audio; room music
auto-mutes while a screen is on Video, the same way it does for Presentation.
Operator controls
| Control | What it does |
|---|---|
| Video picker | Which uploaded file this screen plays |
| Play / Pause | Autoplays when first put on a screen; pause and resume from the card |
| Restart | Jump back to the start and play |
| Seek | Slider scrubs to any point |
| Loop | Repeat the file when it ends |
Autoplay with sound relies on the one-time Start Display click on each screen — the same gesture music needs. A screen opened but never clicked plays the video silently. Uploads are capped at 400 MB per file.
Background music
Music is one playlist for the whole room, not a track per screen. The server owns the queue, the playhead and the clock; a screen only decides whether it makes noise. That is what stops two screens with speakers playing different bars of different songs.
The Music tab
| Control | What it does |
|---|---|
| Transport | Play / Pause, previous, next, Mute, volume, loop playlist |
| Playlist | Ordered queue — reorder with ↑ ↓, play any track, remove |
| Plays on | A checkbox per screen by name, plus All screens — which screens are equipped for sound |
| Mute (per screen) | Silence one screen on the spot, independent of the sound set and the room-wide mute — on its Live Control card, or beside its name in the Music tab |
| Auto-mute | Modes where music drops out by itself. Presentation and Live Share by default |
| Library | Everything uploaded — add to playlist, preview, delete |
Uploads land in the playlist automatically and show a progress bar with the real
transfer percentage. Accepted: .mp3 .m4a .aac
.ogg .oga .wav .flac, plus .mp4 .m4v
(their audio track plays), up to 400 MB.
Track length is read on upload with ffprobe.
Presenters get the transport on /remote — Play/Pause, next, and Mute.
Muting the room is something you do mid-sentence. The playlist itself stays admin-only.
How the sync works
- Every music frame carries
startedAtand the server clock, so a display that joins mid-track seeks to where the room already is instead of restarting the song. - With two or more tracks queued, looping is disabled on the audio element. Whichever screen's track ends reports it; the server advances once — duplicate reports inside 2 seconds are ignored — and broadcasts the next track to everyone together.
- A single track with loop on simply repeats in the browser, no round trip.
- Measured drift between two screens: about half a second.
Screensaver
The welcome copy at rest — same words, much larger, over a full-strength animated backdrop, with a very slow breathing scale so a screen left on it for an hour never burns in and never looks frozen from across the room. Meant for the half hour before people arrive and for coffee breaks.
Set the meeting-wide default in Settings → Screensaver animation. Any screen can override it: put the screen in Screensaver mode and its card shows an Animation picker. One room gets stars while the lobby gets the globe.
Lightweight backdrops CSS
Pure CSS on six layers — compositor-only, effectively free to run all day. These are also the only ones offered for the Welcome screen, where the title has to stay the brightest thing on the wall. All are drawn in a single hue from the meeting accent.
| Name | Look |
|---|---|
| Aurora | Soft accent haze, drifting over about a minute |
| Silk | Broad diagonal sheens, the quietest of the set |
| Constellation | A fine drifting star field |
| Rays | A slow rotating light fan, anchored above the title |
| Grid | A shallow perspective floor |
| Still | No animation at all |
Animated scenes WebGL
Fourteen scenes from Vanta.js, coloured from the live theme: Waves, Fog, Clouds, Clouds II, Globe, Net, Birds, Halo, Rings, Cells, Dots, Ripple, Topology and Trunk. Screensaver only.
- Vendored, not CDN-loaded — three.js, p5.js and the effects are served from
/vendor, so a display with no internet still comes up. - Loaded on demand — a screen running a CSS backdrop downloads none of it.
- One instance, always destroyed — leaving Screensaver tears the GL context down instead of leaving a render loop running behind the next scene.
Keeping the text readable
An animated scene puts arbitrary colour directly behind the meeting name, so the copy sits on its own frosted plate drawn from the theme surface, with a halo under the glyphs themselves. Settings → Screensaver text panel controls it:
| Setting | Behaviour |
|---|---|
| Auto (default) | Plate appears over WebGL scenes only — the CSS backdrops are quiet enough without it |
| Always on | Plate on every backdrop |
| Never | Text straight on the backdrop |
Separately, accent-coloured type — the clock, mainly — is contrast-fitted at runtime. An accent is picked to look good as a block of colour, which is a different job from being legible as text: pure blue on near-black is about 2:1, unreadable from the back of a room. The hue is walked toward the page's ink until it clears 4.5:1, so blocks and rules keep the exact colour you picked while the type stays readable.
Appearance & themes
| Theme | Background |
|---|---|
| Midnight | Near-black blue — the default |
| Oxford Blue | Deep navy |
| Slate | Neutral dark grey |
| Paper | Pure white |
| Mist | Cool near-white |
| Ivory | Warm white |
The theme applies everywhere — displays, control panel, remote, share and the sign-in page. Backdrop intensity is theme-aware: light themes use a paler wash and a multiply blend so white stays white instead of going muddy.
The accent can be any colour. Text that sits on the accent picks itself — white on a dark accent, near-black on a light one — from the accent's relative luminance, so a navy or pure-blue accent never ends up with dark text on a dark button.
The rest of the panel is grouped by the screen each setting lands on — Theme, then Welcome screen, Agenda screen and Screensaver, each asking the same two questions: which backdrop, and which clock.
Shared folder
Shared Folder lists every original file the meeting holds — the PPTX a deck was built from, the MP4 behind a video, the track behind a playlist entry — plus anything else you upload straight to it, whatever the type. Uploading to Presentations, Videos or Music files the original here automatically; nothing extra to do.
Downloads run through a signed-in route, not the open /media path the room
displays read from, so board papers are not sitting on a guessable URL. Deleting an
original is done from its own library item — the folder refuses it, because a deck whose
source file has vanished cannot be handed out.
Sending a file to a room machine
Send to screen… makes that display's browser save the file to its own Downloads folder. Nothing appears on the screen — the room sees no download bar — and the panel shows which screens confirmed. Send to all does every connected screen at once.
★ Keep on screens is the fast path: every screen pulls a copy into its own cache immediately and again whenever it reconnects, so a later push is instant instead of a download over whatever the room's link happens to be. Star the papers before the meeting starts.
--download-directory if the display is launched kiosk-style. Worth doing once,
on every screen machine, before you rely on this in a meeting.
Access & sessions
Organisations, teams and sessions
One deployment serves many organisations. An organisation owns its content library and its people. Inside it, a team owns the physical screens — a display bolted to a wall belongs to a room, not to a meeting. A session is one meeting: it has a nine-digit number, a join password, a presenter PIN and a share PIN, and while it is live it claims the team's screens. Nothing crosses an organisation boundary.
Manage all of it at /console: people and their roles, teams, sessions and their credentials, and the devices you are signed in on.
Three ways in
| Who | How | Gets |
|---|---|---|
| Account | Email + password, a magic link, or single sign-on. A second factor on top, if set | Whatever the organisation and team roles say — up to full control |
| Guest | Session number + join password at /join. No account, nothing to set up | The session lobby. The presenter PIN or share PIN promotes them on the spot |
| Display | Its own screen token, baked into the link | Read-only access to what it is told to show. Never a login |
Capabilities, not job titles, decide what a request may do: view, present, share, manage. A guest holding the presenter PIN and a signed-in presenter both hold present, so they take exactly the same path through the code.
PINs cascade
Set a presenter PIN or a share PIN on the organisation and every team and session inherits it. Set one on a team and it wins for that team's sessions. Set one on the session and it wins there. The console shows which level each value came from, so it is never a guess.
Login sessions
- Sessions last 7 days and slide — every request pushes the expiry forward, so a panel left open all day never expires under you. Only a genuinely idle session ages out.
- A guest session lasts 12 hours, and dies the moment its meeting ends — immediately, not at the next request.
- Sessions are stored in the database, so a restart or crash no longer signs anyone out.
- Every device you are signed in on is listed in the console with its address and last-seen time, and can be revoked one at a time.
- Eight bad attempts against one email locks that pair for five minutes; a much looser ceiling applies per IP, so one busy office cannot lock out a whole organisation.
- Screen links carry their own token; Rotate token on a screen invalidates the old link immediately.
Where the secrets live
| Secret | Stored | Why |
|---|---|---|
| Account password | scrypt hash, unreadable | It is the person's, and it is reused elsewhere |
| Join password, presenter PIN, share PIN | Readable in the database | An organiser has to read it back to put it in an invitation. Per-session and rotated freely |
| Screen token | Readable, shown only to a caller who can manage | It is the link — it has to be copyable |
| Emailed links (magic, invite, reset, verify) | SHA-256 hash, single use, short-lived | A stolen database must not yield working links |
| Two-step secret | Readable in the database | The server has to generate the same codes the phone does. Recovery codes, which it does not, are hashed |
Magic links, invitations, verification and password resets go out over SMTP, configured under
an smtp key in data/config.json. With no SMTP configured every
flow still works — the link is written to the log and shown to an owner in the console,
which is what makes this testable on a box that cannot send mail.
Two-step verification
Optional per person, under Account in the console: scan the QR code with any authenticator app, type one code back to prove it works, and keep the ten recovery codes. They are shown once and never again.
Every route into the account then asks for it — a magic link and a password reset included. Otherwise switching it on would make an account weaker for anyone whose mailbox is the thing that got breached. An owner can require it of a whole organisation under People → Security; that takes effect at once, and anyone without a second factor keeps their session but holds no rights until they enrol.
Single sign-on
Any OpenID Connect provider — Google, Microsoft Entra, Okta — configured under an
oidc key in data/config.json. The sign-in page grows a
Continue with… button on its own once one is set up.
An organisation can claim an email domain, and anyone arriving from that domain joins it as a member automatically. Only ever as a member: an email domain is evidence of employment, not of authority. An address the provider has not verified is refused outright, and an account with a second factor still has to give a code afterwards.
Setting one up
-
Register the application with the provider and give it this redirect URI,
exactly —
<name>is whatever you call the provider in the next step:
Google calls it an Authorized redirect URI on a Web application OAuth client; Entra calls it a Web redirect URI; Okta calls it a Sign-in redirect URI. The host must matchhttps://your-host/auth/oidc/<name>/callbackpublicUrlindata/config.json. -
Add the provider to
data/config.json. Nothing is provider-specific beyond these four values — everything else is read from the issuer's own discovery document:
Issuers for the other two: Entra is{ "publicUrl": "https://your-host", "oidc": { "google": { "label": "Google", "issuer": "https://accounts.google.com", "clientId": "xxxx.apps.googleusercontent.com", "clientSecret": "xxxx" } } }https://login.microsoftonline.com/<tenant-id>/v2.0, Okta ishttps://<your-org>.okta.com. Add"scope"only if you need more thanopenid email profile. Several providers can sit side by side; each key becomes its own button. - Restart the service.
/loginnow shows the button. -
Claim your email domain so first-time arrivals land somewhere real:
Console → People → Security → Single sign-on. That block only appears
once a provider is configured. Enter
acme.com, not@acme.com, and pick the role such arrivals get. One domain belongs to one organisation; a superadmin can also set it per org in the panel.
sso-no-org and needs an invitation. That is deliberate: a provider
button is not an open door.
People link and unlink their own provider accounts under Console → Sign-in. When
a sign-in bounces back, the reason is in the URL (sso-unverified-email,
sso-no-org, sso-expired) and the detail is in
Logs under sso.*.
The old shared password
The single admin password and presenter PIN in data/config.json still work, scoped
to the original organisation. The PIN gets view, present and share; the password adds manage.
Retire them once you have a real account: run node tools/create-owner.js <email>
on the server, follow the one-time link it prints, then delete adminHash from
config.json.
Sharing from outside the building
A presenter on the room's own network connects straight to the screens. A presenter
anywhere else goes through a relay (TURN) running on this server, because two
home or mobile networks cannot normally reach each other. If a share shows a black screen and
then Connection failed, that is the relay path failing — check Logs
for rtc.failed, which lists what each side managed to offer.
Locked out completely? Delete data/config.json and restart — a fresh
password is printed once to the log. Nothing else is lost.
Operations
systemctl status directors-meeting # is it running
systemctl restart directors-meeting # restart
journalctl -u directors-meeting -f # live logs
tail -f /var/log/directors-meeting.log # app output
curl -s localhost:4300/healthz # liveness + screen count
The service restarts on crash and starts on boot. It is pinned to the nvm-managed Node 24 — better-sqlite3 is a native module compiled against it.
Restarts & uptime
A deploy or a crash-restart is invisible in the room. What makes that true:
- On
SIGTERMthe server tells every socket it is going, drops them itself, and exits — a stop takes milliseconds instead of waiting on live WebSockets. - systemd waits 500 ms and starts it again, with the restart rate limiter disabled so it can never give up mid-meeting.
- Clients that hear the goodbye reconnect immediately; a display then retries every 250 ms for the first few seconds before backing off, with jitter so a wall of screens does not reconnect in lockstep.
- Caddy holds HTTP requests for up to 5 seconds and retries, so nothing in the room sees a 502.
- The display keeps painting whatever it was showing the whole time, and the "reconnecting" badge only appears if the outage lasts more than 2.5 seconds.
Measured: backend unreachable for about 350 ms; the display's socket is live again in under 2 seconds; the room screen never blanks and never asks for that Start Display click again.
Deploying behind Caddy
sudo cp deploy/dir-meeting.caddy /etc/caddy/Caddyfile.d/
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
/etc/caddy/Caddyfile already imports Caddyfile.d/*.caddy, so the main file needs no edit.
Backup & restore
tar czf dm-backup-$(date +%F).tgz data/ media/ uploads/
data/app.db holds all structure and state — screens, decks, agenda,
playlist, settings, sessions. media/ holds rendered slides, photos and
audio. uploads/ holds the original files as sent. Restore by unpacking
those three over a stopped service.
To wipe demo or old content while keeping screens, links, settings and credentials: ./deploy/reset-content.sh
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| No sound on a screen | Nobody clicked Start Display on it. Reload the screen link and click once. |
| Sound on one screen only | Music tab → Plays on. That screen is unticked. |
| Music stops during a presentation | Working as configured — Auto-mute includes Presentation. Untick it in the Music tab. |
| Music paused and nobody paused it | Check the transport: someone may have hit Mute or Pause from /remote. |
| Upload seems to do nothing | Watch the progress bar; on failure it turns red and shows the server's reason. Very large files simply take time. |
| Converted deck looks wrong | Replace file with a PDF, or replace the single bad slide with a PNG. |
| Screen shows the wrong meeting name | Settings are meeting-wide and push instantly; if one screen is stale, its socket dropped — it reconnects on its own within seconds. |
| Screen link says "Wrong or missing screen token" | The token was rotated. Copy the current link from Screens & Links. |
| Live share never connects | If the presenter is off-site, the LAN-only default blocks them — set DM_LAN_ONLY=0 or a TURN relay. See Live screen share. Fall back to HDMI. |
| Signed out unexpectedly | Should no longer happen across restarts. If it does, the browser dropped the cookie — sign in again; sessions last 7 days. |
| WebGL screensaver stutters | The room machine is too weak for that scene. Switch to a CSS backdrop. |
API reference
Everything under /api needs at least a presenter session; admin-only routes are marked. Displays use /api/public with their screen token.
| Route | Does |
|---|---|
GET /api/snapshot | Everything the panel renders from |
POST /api/screens/:id/state | Point a screen at something |
POST /api/screens/:id/nav | first / prev / next / last / goto |
GET POST /api/music | Read the bus; patch playing, muted, volume, index (queue, loop, screens, auto-mute are admin) |
POST /api/music/skip | Advance by delta |
POST /api/music/queue admin | add, removeAt, or from/to to reorder |
POST /api/audio admin | Upload a track (multipart) |
GET /api/tags | Every tag in use, with how many things carry it |
PATCH /api/decks/:id admin | title, speaker, tags (array), archived (0/1) |
PATCH /api/videos/:id admin | title, tags, archived |
PATCH /api/galleries/:id admin | name, interval_ms, tags, archived |
PATCH /api/photos/:id admin | caption, tags |
POST /api/settings admin | Meeting branding, theme, backdrops |
GET /api/public/music | The bus, for a display |
POST /api/public/music/ended | A display reporting its track ran out |
GET /healthz | Liveness — touches the DB, so a process with a broken database reports unhealthy |
GET /theme | Palette only, unauthenticated, for the sign-in page |
The WebSocket at /ws carries state, settings, music, snapshot, presence, data, bye, and the WebRTC handshake. It also accepts one frame from a display — { t:'nav', action }, the arrow keys of a locked screen. It moves only the screen that socket authenticated as, and only inside that screen's current deck.
File layout
server.js Express + WebSocket wiring, page routes, boot
lib/db.js Baseline schema and scoped settings helpers
lib/migrate.js Migration runner; backs the database up first
lib/migrations/ Numbered schema changes - the only way to alter the schema
lib/store.js All data access; the one definition of screen state
lib/hub.js WebSocket fan-out + WebRTC relay; authenticates at upgrade
lib/auth.js Sessions, scrypt password, login throttle
lib/authz.js can(actor, capability, scope) - the one access decision
lib/tenancy.js Orgs, teams, sessions, the PIN cascade
lib/users.js Accounts, invitations, single-use emailed links
lib/totp.js Two-step verification and recovery codes
lib/oidc.js OpenID Connect single sign-on
lib/mailer.js SMTP, with a log/console fallback when unconfigured
lib/convert.js PPTX/PDF -> per-slide JPEG pipeline
routes/api.js Control API (session required)
routes/auth.js Sign up, sign in, magic links, reset, device list
routes/join.js Guest entry: session number, password, PIN promotion
routes/console.js Orgs, teams, people, sessions, credentials
routes/public.js Read-only endpoints for displays (screen token)
tools/create-owner.js Promote an email to owner and print a set-password link
public/display.html Room screen
public/admin.html Control panel
public/console.html Organisation console
public/join.html Session number + password
public/session.html Guest lobby
public/remote.html Presenter phone remote
public/share.html Remote screen sharer
public/docs.html This page
public/js/theme.js Shared theme + accent-ink application
public/vendor/ three.js, p5.js, Vanta effects (vendored, not CDN)
data/backups/ Automatic database copy taken before each migration
deploy/ Caddy site file and a copy of the systemd unit