Contents

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

SurfaceAddressWho opens it
Room display/s/<screen>?token=…Opened once on each room machine and left alone
Control panel/adminWhoever runs the meeting — an account with manage rights
Organisation console/consoleOwners and admins — people, teams, sessions, PINs, devices
Join/joinAnyone invited — session number and join password, no account
Presenter remote/remoteA presenter's phone — presenter PIN or a presenter account
Screen share/shareSomeone off-site sharing their screen — share PIN
Logs/logsWhat 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

  1. Settings — meeting title, subtitle, date line, venue, logo, theme, accent colour.
  2. Presentations — upload PDF / PPTX / PPT / ODP / images. PPTX is converted on the server; check the thumbnails that appear.
  3. Photos — make a gallery, upload photos, set seconds per photo.
  4. Agenda — add items with speaker and start time.
  5. Music — upload tracks, build the playlist, choose which screens play sound.
  6. Screens & Links — open each screen link on its room machine and click Start Display once.
Sound needs that one click. Browsers refuse to autoplay audio until a page has been clicked. A screen that was opened but never clicked will show slides perfectly and stay silent all day. Click Start Display during setup, not five minutes before the meeting.

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

ModeShowsCard controls
WelcomeTitle, logo, date, venue, optional clock, animated backdrop
ScreensaverThe same copy, larger, over a full-screen animationAnimation picker
AgendaFull agenda; current item highlighted, done items struck through
PresentationA deckFirst / Prev / Next / Last, jump to slide
PhotosA gallery cycling on its ownGallery picker
VideoAn MP4 full-screen with its own sound; room music mutes while it playsVideo picker, Play/Pause, Restart, Seek, Loop
IntervalCountdown or count-up clock, message, now-playing stripDirection, timer presets or a typed length in whole minutes, stopwatch, red threshold, heading, message
Live ShareA remote presenter's screenPresenter picker
MessageOne big headline and sub-lineHeadline, sub-line
BlackNothing 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:

KeysDoes
Space PageDown EnterNext slide
PageUp Backspace Shift+SpacePrevious slide
Home / EndFirst / 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.

Deliberately not blocked. Anything held with 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.
Driving a screen by hand. Add &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).

Upload limits. Every upload — decks, photos, music — is capped at 400 MB per file (60 files per request); the Caddy proxy allows a 500 MB body, so 400 MB is the effective ceiling. A 256 MB track uploads fine; it just takes time over the network.
Send presenters to PDF. PPTX conversion is good, not perfect — font substitution is the usual culprit when a deck looks off. Carlito and Caladea are installed as metric-identical stand-ins for Calibri and Cambria, which covers most decks.

If a converted deck looks wrong, in order of effort:

  1. Replace file on the deck — upload the PDF version. Same deck, so screen assignments and agenda links stay pointed at it.
  2. Replace slide — hover a thumbnail and upload a PNG of just that slide.
  3. 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.

A live screen is never pulled out from under you. If a screen is already showing something you archive, it keeps showing it and it stays in that screen's dropdown, marked — archived. Everywhere else it is gone.

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-itemsShows
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 itemAll sub-items, always
Hide sub-itemsTitles 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.

This is how you show a deck's animations. Slides are flattened to images (no animation). To keep builds, transitions and embedded video, export the deck to MP4 in PowerPoint (File → Export → Create Video) and upload that here — it plays exactly as authored. Live Share is the other route (the presenter runs the real PowerPoint); Video is the unattended, file-based one.

Operator controls

ControlWhat it does
Video pickerWhich uploaded file this screen plays
Play / PauseAutoplays when first put on a screen; pause and resume from the card
RestartJump back to the start and play
SeekSlider scrubs to any point
LoopRepeat 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.

Controls target one screen. The same video on two screens plays independently and can drift out of sync — for a single controlled playback, put it on one screen.

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

ControlWhat it does
TransportPlay / Pause, previous, next, Mute, volume, loop playlist
PlaylistOrdered queue — reorder with ↑ ↓, play any track, remove
Plays onA 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-muteModes where music drops out by itself. Presentation and Live Share by default
LibraryEverything 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

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.

NameLook
AuroraSoft accent haze, drifting over about a minute
SilkBroad diagonal sheens, the quietest of the set
ConstellationA fine drifting star field
RaysA slow rotating light fan, anchored above the title
GridA shallow perspective floor
StillNo 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.

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:

SettingBehaviour
Auto (default)Plate appears over WebGL scenes only — the CSS backdrops are quiet enough without it
Always onPlate on every backdrop
NeverText 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.

These are real WebGL. On a weak room machine they cost noticeably more than the CSS backdrops — if a screen is also driving a 4K panel, prefer Constellation or Silk.

Appearance & themes

ThemeBackground
MidnightNear-black blue — the default
Oxford BlueDeep navy
SlateNeutral dark grey
PaperPure white
MistCool near-white
IvoryWarm 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.

Defaults here, overrides in Live Control. Every backdrop on this page is the meeting-wide default. A single screen can differ — its card in Live Control has the same picker, with Meeting default (Settings) at the top. Leave a screen on that and it follows this page for the rest of the meeting; pick anything else, including Still — no animation, and that screen keeps its own choice.

Live screen share

A presenter opens /share, picks a window or screen, and any display put into Live Share mode shows it. The connection is direct WebRTC between the sharer and each screen — the server only relays the handshake and never touches the video. The same share can go to several screens at once from one capture. This is the way to show a deck with its animations: the presenter runs the real PowerPoint, so every build and transition plays natively — nothing is flattened to images.

Capture runs up to 1080p/30 (4K/60 ceiling) with the encoder tuned for sharp text.

The Live Shares tab lists everyone who has the share page open, whether or not they have pressed Start sharing yet, and which screens they are on. Put one up with Show on… exactly as you would a deck — no need to set a screen to Live Share first. Assigning someone before they press Start is fine and is the smooth way to do it: the screen queues its request and the picture appears the moment they begin. End share stops their capture and clears every screen showing them, not just one.

By default all share traffic stays on the LAN. With the same-WiFi default (DM_LAN_ONLY=1) there is no STUN/TURN, so media never leaves the local network — lowest latency, no internet dependency, but a genuinely off-site presenter will not connect. For off-site, set DM_LAN_ONLY=0 (adds public STUN) or configure a TURN relay with DM_TURN_URL / DM_TURN_USER / DM_TURN_PASS. Test with the actual presenter on their actual network before the meeting. Fallback: run the call on the room PC and switch HDMI input.

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.

Two settings on each room machine decide whether a push is silent. Chrome saves without asking only when “Ask where to save each file” is off; leave it on and a save dialog pops up on the projector. Set the destination with --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

WhoHowGets
AccountEmail + password, a magic link, or single sign-on. A second factor on top, if setWhatever the organisation and team roles say — up to full control
GuestSession number + join password at /join. No account, nothing to set upThe session lobby. The presenter PIN or share PIN promotes them on the spot
DisplayIts own screen token, baked into the linkRead-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

Where the secrets live

SecretStoredWhy
Account passwordscrypt hash, unreadableIt is the person's, and it is reused elsewhere
Join password, presenter PIN, share PINReadable in the databaseAn organiser has to read it back to put it in an invitation. Per-session and rotated freely
Screen tokenReadable, shown only to a caller who can manageIt is the link — it has to be copyable
Emailed links (magic, invite, reset, verify)SHA-256 hash, single use, short-livedA stolen database must not yield working links
Two-step secretReadable in the databaseThe server has to generate the same codes the phone does. Recovery codes, which it does not, are hashed

Email

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

  1. Register the application with the provider and give it this redirect URI, exactly — <name> is whatever you call the provider in the next step:
    https://your-host/auth/oidc/<name>/callback
    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 match publicUrl in data/config.json.
  2. 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:
    {
      "publicUrl": "https://your-host",
      "oidc": {
        "google": {
          "label": "Google",
          "issuer": "https://accounts.google.com",
          "clientId": "xxxx.apps.googleusercontent.com",
          "clientSecret": "xxxx"
        }
      }
    }
    Issuers for the other two: Entra is https://login.microsoftonline.com/<tenant-id>/v2.0, Okta is https://<your-org>.okta.com. Add "scope" only if you need more than openid email profile. Several providers can sit side by side; each key becomes its own button.
  3. Restart the service. /login now shows the button.
  4. 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.
Without a claimed domain, SSO still works for people who already have an account here — the first sign-in links the two by verified email address. Everyone else is refused with 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:

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

SymptomCause and fix
No sound on a screenNobody clicked Start Display on it. Reload the screen link and click once.
Sound on one screen onlyMusic tab → Plays on. That screen is unticked.
Music stops during a presentationWorking as configured — Auto-mute includes Presentation. Untick it in the Music tab.
Music paused and nobody paused itCheck the transport: someone may have hit Mute or Pause from /remote.
Upload seems to do nothingWatch the progress bar; on failure it turns red and shows the server's reason. Very large files simply take time.
Converted deck looks wrongReplace file with a PDF, or replace the single bad slide with a PNG.
Screen shows the wrong meeting nameSettings 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 connectsIf 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 unexpectedlyShould no longer happen across restarts. If it does, the browser dropped the cookie — sign in again; sessions last 7 days.
WebGL screensaver stuttersThe 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.

RouteDoes
GET /api/snapshotEverything the panel renders from
POST /api/screens/:id/statePoint a screen at something
POST /api/screens/:id/navfirst / prev / next / last / goto
GET POST /api/musicRead the bus; patch playing, muted, volume, index (queue, loop, screens, auto-mute are admin)
POST /api/music/skipAdvance by delta
POST /api/music/queue adminadd, removeAt, or from/to to reorder
POST /api/audio adminUpload a track (multipart)
GET /api/tagsEvery tag in use, with how many things carry it
PATCH /api/decks/:id admintitle, speaker, tags (array), archived (0/1)
PATCH /api/videos/:id admintitle, tags, archived
PATCH /api/galleries/:id adminname, interval_ms, tags, archived
PATCH /api/photos/:id admincaption, tags
POST /api/settings adminMeeting branding, theme, backdrops
GET /api/public/musicThe bus, for a display
POST /api/public/music/endedA display reporting its track ran out
GET /healthzLiveness — touches the DB, so a process with a broken database reports unhealthy
GET /themePalette 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