Skip to content

Developing

github-actions[bot] edited this page Sep 29, 2026 · 19 revisions

Developing Tippani, and forking it

Two things shape everything else, so they come before the setup commands:

  • The whole app ships as one static Go binary with the frontend embedded. There is no Node at runtime, no separate API process, no reverse proxy required. web/dist/ is a committed build artefact embedded by web/embed.go, which is unusual and deliberate — it means go build alone produces something that runs.
  • CPU frugality is a requirement, not a preference. The target is a NAS already running a hundred other things. No pollers, no timers, no scheduler: nothing runs unless a person or the app's own lookup started it, and nothing wakes on a timer. If a change needs something to wake up on its own, that is a design discussion before it is a patch.

Which document answers what

Seven documents, seven questions. Each fact lives in exactly one of them and the others link to it by name, because a summary is a copy that drifts more quietly than it fails.

Document The one question it answers
README.md Should I run this, and how do I run it?
Developing.md (this file) I want to change the code — where does it go, and how do I know it worked?
docs/wiki/Design-decisions.md Why is it built this way, what was rejected, and what did I get wrong?
docs/plans/*.md How will one specific unbuilt feature work? (Folded into Design-decisions.md and deleted once it ships — or once it is dropped.)
docs/ui-glossary.html What is this bit of the interface called?
docs/roadmap.html What is coming next? (generated — never hand-edited)
How-this-was-written.md How was this written, and what does that mean for trusting it?
docs/wiki/Troubleshooting.md The app logged a TIP-* code at me — what now?

This file says where and how. PLAN says why and why-not: if you are about to write a sentence explaining a design choice, it belongs there. How-this-was-written.md states what verification exists, as a claim about the repository — so How-this-was-written.md carries the counts and this file carries the commands, and neither should carry the other.

Contents

What I will and will not merge

This section is first because it is the one that can save you a weekend.

Open an issue before writing anything beyond a fix, and read the roadmap before you file — in particular Considered and set aside, which lists what has been refused deliberately and why. A request from that list is still welcome; it just needs the argument rather than the vote.

Refused on sight, with the reasoning on the roadmap or in docs/wiki/Design-decisions.md:

  • A new always-on dependency. The Go side has three direct modules and the frontend has three runtime npm packages. A fourth needs a reason that survives being written down.
  • Anything that wakes on a timer — a ticker, a poller, a cron, a scheduler, a pool of workers. Cleanup and scheduling happen on a read, and a job runs because somebody started it. This is the frugality budget, and it is the constraint most features have to be redesigned around rather than argued out of.
  • Anything that phones home by default. Every outbound call in this app is one a person asked for, or one a screen they opened makes for what it is about to draw, and every one is a line in the log.
  • A config file. Everything is an environment variable, on purpose.
  • A frontend state library. fetch and useState, and a refetch signal passed down from App.jsx. This is a small app and it should keep reading like one.
  • Server-side OCR or speech, serving book files, and social features. All four have entries under Considered and set aside.

And one that catches people out: some of what looks like a bug is a decision, written up in docs/wiki/Design-decisions.md. The backup archive is keyed on your own credentials rather than a built-in key, so a lost password really does lose the archive. The first highlight colour cannot be named, because it is also what an import writes when the source gave no colour. Read the decision before you fix the symptom.

Getting it running

You need Go — the version on the go line in go.mod — and, only if you are changing the frontend, Node, at whatever major version the frontend job in .github/workflows/ci.yml installs. Both are stated in one place each on purpose; a version number repeated in a document is a version number that goes stale.

git clone https://github.com/aaronified/tippani
cd tippani
make run                    # go run ./cmd/tippani serve  ->  http://localhost:8080

That works from a fresh clone with no frontend build, because web/dist/ is committed. The first account you create becomes the admin.

make build                  # static binary -> bin/tippani
make frontend               # npm install + vite build -> web/dist
make test                   # go test ./...
make run                    # run from source
make clean                  # bin/ and node_modules

make build sets CGO_ENABLED=0 and stamps the version into internal/buildinfo.Version via ldflags. Pass your own with make build VERSION=v1.2.3; it defaults to dev, and a dev build reporting itself as dev in Settings is correct rather than a bug.

Nothing is required to run — no API keys, no accounts, no outbound calls. Metadata lookups are opt-in, and TMDB and TheTVDB do nothing at all without a key of your own.

Configuration is entirely environment variables, and README's ## Configuration is the table — it is not repeated here. One line it cannot give you, because it is a development concern: the binary defaults to binding 127.0.0.1:8080, and the Docker image overrides that to 0.0.0.0:8080. So local and container defaults differ, and a test that assumes either one is wrong half the time.

Where things live

The rule this map is written to, so that editing it keeps the altitude: describe a directory; name a file only when that file is the single place some rule is enforced. Where a directory holds interchangeable siblings — ten importers, thirty migrations, forty test files — the pattern and the registration point are the useful facts, and listing the siblings only guarantees the list is wrong on the eleventh.

scripts/doc-map-check.mjs keeps this honest: it fails CI if a path named here has stopped existing, if a new package, script or workflow has appeared that this document never mentions, or if the table under Maintainer: CI and ci.yml's jobs disagree. Run it yourself with node scripts/doc-map-check.mjs.

The shape of a request

browser ──▶ web/dist (embedded SPA)          ← everything not under /api
        └─▶ /api/* ──▶ internal/httpapi/server.go
                        ├── middleware: logging → gzip → security headers → CSRF
                        ├── *_handlers.go   the route group for this noun
                        ├── internal/jobs   the queue, and the log of every job and request
                        └── internal/store  the only thing that opens SQLite
                                └── internal/search   FTS5 MATCH, escaped
                                    internal/importer parse an uploaded file
                                    internal/metadata the only outbound HTTP

cmd/ — the process

Path What it is
cmd/tippani/main.go The entry point and the subcommand table: serve, user add|passwd|del, notify daily, healthcheck, version. Reads every TIPPANI_* variable, opens and migrates the database, wires the log and the job queue to it, and serves until a signal, then shuts down in the order its shutdown sets out. Where a new CLI verb goes.
cmd/tippani/tls.go Optional native HTTPS from a PEM pair, hot-reloaded when the files change so external renewal tooling needs no restart.

internal/ — the packages

Package What it owns
internal/httpapi/ Every HTTP route, the middleware chain, and the request/response shapes. The largest package by far — see its own table below.
internal/store/ The SQLite connections — the library's pool and the log's own synchronous=NORMAL connection — the pragmas, the migrations, the dedupe rules, and the one lock every swap of the database files takes. The only package that opens the database.
internal/search/ Building safe FTS5 MATCH expressions, and the typo-correction pass.
internal/importer/ One parser per source format, producing the package's shared intermediate shapes. Touches no database.
internal/metadata/ Every outbound call to a metadata provider: Google Books, Open Library, TMDB, TheTVDB, Wikidata, Amazon, the two picture searches (image_search.go), plus the SSRF-guarded image fetcher. It used to be described as every outbound call in the app and was one short — internal/updater/ asks GitHub for the latest release.
internal/auth/ Password hashing, cookie sessions, bearer device tokens, and the login rate limiter.
internal/outbound/ The one answer to "may this request leave the machine?". A http.RoundTripper that refuses everything while TIPPANI_OFFLINE is set, wrapped around the four real clients — the shared provider client, the SSRF-guarded image fetcher, the GitHub release check and Pushover's — and telling one observer of every call that leaves, refusals included. The OIDC client carries the observer without the gate. Redact is the one list of what a URL may not show in a log. A test names every &http.Client{} in the tree so a fifth cannot appear ungated.
internal/jobs/ The routines a reader starts, run on the server, and the logs. runner.go is the queue — one job at a time across the server, the rest waiting in the order started; logbook.go is the one writer of the system log and every job's lines, onto the store's log connection; clean.go is the door every line passes, which strips control characters and secrets; recorder.go is how a line finds its job, including the job a request becomes when it looks outward. The kinds a person can start are registered from internal/httpapi/jobs_kinds.go. Two environment variables here are test seams, not settings, and both are ignored unless TIPPANI_OFFLINE is set: TIPPANI_JOBS_HOLD=1 keeps the queue from starting any job, so a test can see one waiting on a server whose jobs otherwise end in milliseconds (TIPPANI_JOBS_HOLD=running starts the first and holds it running until it is stopped, so a test can see one run), and TIPPANI_LOG_HOLD=1 keeps the log from writing until shutdown, so the serve test can see shutdown's order.
internal/olog/ Operational logging, the registry of stable TIP-* operator codes, and the sink every line is also handed to, which is how the system log is kept.
internal/updater/ The in-app self-update: the GitHub release check, and the Docker Engine calls that pull and recreate.
internal/changelog/ The release history, parsed. It reads the root package's embedded CHANGELOG.md (changelog.go at the repo root, package tippani), because //go:embed cannot reach outside the embedding package's directory. It held a copy of the file and a drift test to keep the two alike until 3.0.2, when the root became a package and both went.
internal/buildinfo/ The running build's identity — version from ldflags, plus the repo and image the update check queries. Three constants a fork overrides.
internal/i18n/ The locale FILE FORMAT, and en.txt/bn.txt — the canonical copy of every user-facing string, for both sides of the app. The frontend imports these same bytes with Vite's ?raw, so there is one copy and no drift test — the shape internal/changelog has had since 3.0.2, when its copy of CHANGELOG.md went. Holds the parser and the reader for <data>/Locales; it deliberately holds no resolver, because nothing the server prints is translated yet.

internal/httpapi/ — the convention, then the files that carry rules

Flat, and by a wide margin the largest package — which is why there is no per-file map of it here and should not be one. The convention instead: *_handlers.go is a route group named for its noun; a bare noun is a shared shape or a helper. A new endpoint joins the nearest existing group, and only earns a file of its own when it is a new noun.

Seven of them are not route groups but rules, and these are the ones worth knowing:

File The rule it holds
server.go The route table and the middleware chain. Where auth and CSRF wrap the mux, and where every shared handler helper lives. Start here.
quote.go The shared shape of a quote across all three kinds, plus the colour vocabulary and its validators. Change it and every export, search result and import path changes with it.
import_staging.go The pending queue. No import writes to the library directly — everything lands here and is approved out.
backup_crypto.go The AES-256-GCM archive envelope, and the rule that its key comes from a secret the operator knows rather than one stored beside it.
capture_fields.go What an offline capture must set on create — noted_at and source — so a queued phone capture keeps its real date.
locformula.go The location arithmetic over free-text locators (p.142, 610-612, 42%, 01:02:03) that bulk staging edits use.
cleanup.go The stray-mark rules, as pure functions over a string — and the rule that this reports and never fixes. Every rule has a false positive that is somebody's real writing, so there is no companion POST and no "fix all".

The route groups themselves, so you can find the noun you want:

Group Endpoints for
auth_handlers.go Login, logout, signup and first-run, password change, /auth/me and preferences.
annotation_handlers.go · dialogue_handlers.go · utterance_handlers.go The three kinds of quote: book highlight, screen line, and standalone.
book_handlers.go · movie_handlers.go The two kinds of work. Films and shows share one table, split by media_type.
people_handlers.go · portrait_handlers.go Credited people — bios, external links, and pinning a person to a stable external id so a re-fetch cannot drift to a namesake.
search_handler.go The FTS5 search across all five content tables, with facets and the zero-hit fuzzy pass.
review_handlers.go The spaced-repetition engine: both decks, the grading path, the interval ladder.
names.go A record's name and every other spelling of it, as ONE field whose first line prints — so promoting an alias is a line move rather than a two-box dance that can fail halfway. Whole-list in one transaction; 0063's seq is what makes the order the reader's.
review_questions.go The one place the deck repertoire rules live: which question types a deck may ask, and the three that stop a reader configuring it into something that can ask them nothing. Mirrored client-side in web/frontend/src/quiz.js, which a test keeps in step by reading this file.
shelf.go · read_history_handlers.go Shelf status, the legal transitions, and the read log.
stats_handlers.go Everything the Stats page draws.
export_handlers.go · export_quotes.go Markdown export per work and for the whole library, and the standalone-quote type:.
import_handlers.go · import_queue.go · import_quotes.go · import_movies.go · import_staged_bulk.go · import_dupes.go Upload, stage, bulk-edit and de-duplicate. An upload is a queued job (import_queue.go: the spool it waits in, and the job that stages it), and so is an approval.
metadata_handlers.go · metadata_library.go · metadata_bulk.go · lookup_handlers.go · reverify_handlers.go Source keys, the coverage console, bulk correction, one-off lookups, and the preview-then-apply re-verify flow.
cast.go · cast_handlers.go A work's cast: the row every screen reads, and the six fields 0063 added to it — two about the CREDIT (its note and the language of that performance) and four about the character in THIS work (part, first appearance, age, and the spellings this work uses). All six take the optional-pointer contract, because five screens save this row and none has a box for every field.
whos_in_it.go Everything behind one carousel tile: the work's own page, every character linked to it, and everybody it credits in any role — plus, per character, how many quotes of theirs this work holds and in how many distinct places. One request, because a chooser opens on a press.
field_offers.go What every supplier says about every field, whether or not it differs from what is stored. A different question from the re-verify diff, and the reason it cannot be answered by it: a field's tag names a supplier BECAUSE that supplier wrote the value, so the diff for it is empty by construction.
image_search_handlers.go The picture strip behind all three pickers — a cover, a poster, a portrait. A different question from a catalogue lookup: these suppliers search for PICTURES, and none of them is required.
covers_handler.go · avatar_handlers.go · sticker_handlers.go The three image kinds, all under <DataDir>/MediaCover.
taxonomy_handlers.go Tags and genres, and the starter vocabulary seeded per account.
seed_stickers.go · assets/stickers/ The five starter seals, embedded as SVG and copied into each account's own cover store — plus the one-shot backfill that hands them to accounts older than the feature.
backup_handlers.go · backup_recovery.go · safety_copy.go Archive create, download and in-process restore; the per-instance recovery key; the safety copy a restore or a reset takes first, and its one download.
jobs_handlers.go · jobs_kinds.go · jobkinds.go · logs_handlers.go The jobs API — start, list, watch, stop, rerun, export — and the kinds a person may start with the params each accepts; which kind a request is when it looks outward, by route; and the admin's system log.
admin_handlers.go · maintenance_handlers.go · update_handlers.go · update_progress.go User management, FTS rebuild and factory reset, and the self-updater — which writes down which step it reached as it goes, because the apply outlasts the reply and the page cannot otherwise see what happened.
pairing_handlers.go · capabilities_handler.go · share_handlers.go Phone pairing by QR, the client version handshake, and one-shot share-image downloads.
cleanup_handlers.go The one-pass sweep behind Settings → Stray marks: every quote read once, capped, with what the rules in cleanup.go found. Read-only.
bulk_handlers.go · paging.go · gzip.go Bulk tagging, shared LIMIT/OFFSET, and response compression.

internal/store/

File What it is
store.go Opens both pools with this project's pragmas and pool settings: the library's four connections at synchronous=FULL, and the log's one at NORMAL, which only LogWrite writes through.
migrate.go The migration runner. Applies embedded migrations/*.sql newer than the recorded schema version, one transaction each, and refuses to open a database from a newer build.
migrations/ Numbered, embedded, append-only SQL. NNNN_what_it_does.sql. Never edit one that has shipped.
onetime.go The registry for one-time upgrade passes — the third kind of change, neither a schema migration nor a boot repair. Each pass registers itself from its own file's init(), runs once per database, and records itself in one_time_passes.
onetime_<version>_<what>.go One such pass, named for the release it first ships in. Retiring it is a file deletion: nothing else names it.
hash.go The dedupe rules for all three quote kinds, and the text normalisation — punctuation folding, case, whitespace — that defines what "the same words" means.
repair.go quick_check on boot, per-index FTS rebuild, recovery-from-content, and the factory reset.
backup.go · swap.go · journal.go The VACUUM INTO snapshot; Swap, the one way the database files are replaced, which holds the swap lock across the move and reopens both pools on every exit; and the job history an archive leaves out (StripJournal) and a restore carries over (CarryJournal), marked so that nothing runs a carried job again (CarriedJob).
settings.go The key-value settings table, which is where in-app metadata keys live.

internal/search/

File What it is
fts.go The only sanctioned way user input reaches an FTS5 MATCH. The highest-value grep target in the repository.
correct.go Bounded typo correction against the indexed vocabulary, for the zero-hit pass.
levenshtein.go Bounded edit distance with early abandon, plus a prefix mode for typeahead.

internal/importer/, internal/metadata/, internal/auth/, and the small ones

internal/importer/ holds one parser per source format, all the same shape — Tippani and Readest markdown, catalogue markdown, standalone-quote markdown, Kindle My Clippings.txt, Bookcision, a saved Amazon notebook page, Goodreads, Hardcover, IMDb. To add one: write the parser, register it in importer.go, put a fixture in testdata/, and copy the nearest neighbour. movie_markdown.go additionally owns the three-way routing that decides which parser an uploaded file reaches.

internal/metadata/ is the same pattern for outbound calls: metadata.go holds the shared HTTP plumbing — descriptive User-Agent, timeout, body-size limits — and each provider is a file beside it. covers.go is the SSRF-guarded image fetcher and is worth reading before you add anything that downloads a URL. credits.go splits a joined credit ("Gaiman & Pratchett") at read time, never by rewriting what was stored.

internal/auth/ is two files: auth.go (bcrypt, cookie sessions, device tokens) and ratelimit.go (an in-memory token bucket keyed ip|username).

internal/olog/codes.go is the registry of TIP-<SUBSYS>-<NNN> codes, and a test keeps it in lockstep with docs/wiki/Troubleshooting.md — add a code and you add a row.

web/ — the frontend

Path What it is
web/embed.go //go:embed all:dist. The one line that makes the binary self-contained.
web/dist/ The built SPA. A committed build artefact — a pull request's head must carry it rebuilt; CI checks.
web/frontend/index.html The SPA shell, carrying an absolute og:image.
web/frontend/public/ The manifest, the icon set, and the two SVG marks. Copied verbatim into dist/.
web/frontend/vite.config.js Builds into ../dist and proxies /api during development.
web/frontend/package.json Dependencies, and the five scripts everything else calls.

web/frontend/src/ is flat too, and the naming carries the distinction: TitleCase *.jsx are routed screens, lowercase modules are shared. The screens are Home, Library, Movies, Quotes, SearchPage, AddSurface, ImportPage, StagingPage, ChecksPage, TagsPage, MetadataPage, StatsPage, Settings, Account, WorkDetails, CoverPicker, ReverifyReview — each is what its name says, and none of them needs a row here.

The shared modules do:

File What it is
main.jsx Boot. Applies theme, colours and label density before the first paint, so a phone never shows one frame of the wrong thing.
App.jsx The auth gate and the app shell — top bar, phone drawer, bottom bar — and the only file that knows the full screen list. Two props go almost everywhere: onAdd opens the + Add surface aimed at the current page, and dataNonce is the refetch signal.
type.js The type scale. Ten integer steps and one factor per role, written onto <html> as finished integer pixels — --type-ui-13 is "13px at 100%, whatever the interface dial says". Every font-size in the app is one of these tokens, and typescale.test.js fails on a hardcoded one.
history.js The session history. Whether the in-app Back arrow can delegate to the browser's, decided from a tpDepth carried in history.state. Pure of React, so it is testable without mounting App — which nothing does.
routes.js The URL contract. Pure functions, no React, so they are testable without rendering. Holds four hand-maintained nav lists of the same tab keys; routes.test.js asserts they agree, because a tab once got added to three of them.
api.js The whole server surface in one small file: URL prefixing, the four request helpers, cover URLs, and the demo flag.
ui.jsx The shared component library — every card, control, icon, overlay and hook the screens are assembled from. Large, and imported by everything.
works.jsx What books and films share: the shelf vocabulary, work cards, hero headers, grouping.
WorkDetail.jsx The one work page — a book, a film, a show and a game. side picks the endpoint family; media_type on the loaded row picks the kind; every difference is a row in workKinds.js. Not WorkDetails.jsx, which is the editable Details PANEL this screen opens.
workKinds.js What a book, a film, a show and a game differ BY, as one table — routes, nouns, shelf words, facts, credits, board copy, locators, speaker. Locale keys rather than words, so nothing resolves at module load. A fifth media type is a row here; something that cannot be a value in it is a genuine difference of medium. Imports i18n.js and nothing else.
people.jsx Credit splitting, the name→metadata cache, portraits, and the person modal.
characterRows.jsx The row vocabulary the character and person screens are assembled from — nine kinds, presentation only, resolving no locale key of its own. What makes the design pack's five sheets five SCOPES of one object rather than five screens: a defect in a row kind would otherwise be a defect on five screens.
sectionRail.jsx The rail a sectioned screen is navigated by, drawn by Settings and Metadata both — tabs across a desk, a field on a phone, because five tabs on a 390px screen show two and a half. Resolves no locale key of its own: the two screens' sections have nothing to say to each other.
prefRow.jsx One row, one preference — the other half of the pair recordRow.jsx began. A label, a sub-line only where it carries something the label does not, an info dot only where there is a paragraph, a mark where the reader has moved off the default, and a control the CALLER passes in. It draws no control itself, so it never becomes a registry of every input the app has.
recordRow.jsx One row, for every list in the app. A mark, a name, a sub-line, chips, a count and the verbs — the grammar the v3 pack draws six metadata consoles in, built here so Library, Catalogue, Quotes and the rest take the same row rather than a fourth copy of it. Holds the rules that keep a list readable: the name never truncates, a row says a thing once, and a count of PROBLEMS is a door while a count of records is not.
fieldOffers.jsx The door on a field's provenance mark: one field, and what each supplier is offering for it, side by side. Picking one rewrites that field alone and records whose answer it was.
savedThemes.js A look you can come back to, and a file you can hand somebody. Four per profile, each holding the six fields that travel together — both grounds, the accent, the material set, its tiles and its dials. Not the light/dark mode: that is about the room you are in rather than the look.
glassLens.js True glass — the per-surface displacement field, built from primitives that survive inside a backdrop-filter and applied as a bare url(). Off unless the reader asks, never over prefers-reduced-motion, and the app is complete without it: with the lens off every pane is still the stylesheet's own glass.
theme.js The two aesthetics × light/dark, the accent, label density, and the six nameable colour categories, written onto <html> as data attributes and custom properties.
i18n.js Every user-facing string, by key. t('some.key') and nothing else — no English literal at a call site and no fallback argument. Holds the parser (agreeing with Go's, over one shared fixture), the §8 fallback chain, coverage, and the pseudo-locale. Shaped like theme.js: frozen tables, one applier, pure readers — plus one subscription, because GET /locales lands after the first paint.
locale.jsx The one control that changes the language, used twice: the first-run screen and a Settings row. Applies the choice itself; the caller supplies the save.
help.jsx The per-screen copy registry behind every ?. A test asserts every reachable screen has an entry.
tour.jsx The first-launch guided tour, replayable from Settings.
share.jsx · quoteImage.js The share sheet, and rendering a quote to PNG on a 2D canvas in the current styling.
stickers.jsx · flow.jsx The sticker library, and the layer that flows quote text around a dragged sticker while keeping it real selectable DOM.
undo.jsx One delete-with-Undo helper, so the seven screens that delete something cannot each forget the offer.
update.js Waiting for the box to come back after an in-app update: poll the server's own record of the apply until the version changes or it says it stopped. Bounded three ways, because a fetch with no timeout is what left the page stuck.
actions.jsx The one list of what can be done to a quote, per kind, and where each action sits. Read by the card row, the ⋯ overflow and the bulk bar, so they cannot offer different sets.
selection.jsx · SelectionBar.jsx Which cards are picked, and the sticky bar that acts on them. The hook drops ids that leave the visible list, so the count it reports is a count it can act on.
greetings.js · epigraphs.js The two pools of bundled copy — Home’s greeting and the login screen’s epigraph — each with a rule about what may go in it.
secret.js Password and passphrase rules, plus the backup header layout — parsed by fixed byte offset against a Go-defined struct, so the two must change together.
greetings.js The date line and greeting on Home, from the device's own clock and zone.
index.css The whole stylesheet: tokens, the paper/film material system, every component recipe the JSX names by class, and the mobile layout.
textures/ Six grayscale WebP tiles that are live (paper, wood, metal, glass, fabric, rubber), plus ten PNG tiles that are not: a pack landed ahead of the UI overhaul, referenced by nothing and therefore bundled into nothing. textures/README.md says which is which, what the sd figure beside each tile is for, and why rubber.webp is now a different image under the same name.
demo/install.js The demo shim. Replaces window.fetch with a router over in-memory fixtures so the Pages build runs with no backend. It must mirror real handler response shapes; when it drifts, the demo renders nonsense rather than failing.

The test tree

Path What it is
internal/**/*_test.go The Go suite, beside the code it tests. Real handlers, real SQLite, no mocks.
internal/httpapi/crud_test.go Holds newTestServer(t) — the harness almost every handler test starts from.
internal/importer/testdata/ Fixture files, one per format. Real exports, trimmed.
web/frontend/test/pure/ Value-in, value-out tests. Node environment, no DOM, fast.
web/frontend/test/dom/ Component tests. jsdom, and only where a component is genuinely under test.
web/frontend/test/rules/ Lint, not tests. The files here read the SOURCE TEXT and assert how it is spelled — never truncate a name, spacing is a constant, no emoji glyphs, the typescale. Out of npm test and into npm run lint:rules, because the app can be entirely broken and every one of them still passes. Deleted one at a time as a journey covers its ground.
web/frontend/test/journeys/ A real browser against a real server. See "The journeys are the tier that presses buttons" below.
web/frontend/vitest.config.js Defines pure, dom and rules, pins TZ=UTC, and exports TIPPANI_SRC for the tests that read a source file rather than import it. vitest.journeys.config.js is separate so the fast suite never pays for the journeys' go build and seed.
web/frontend/test/setup-pure.js One shim: window.matchMedia, because theme.js calls it at module scope.
web/frontend/test/setup-dom.js Everything jsdom lacks or answers uselessly, and the per-test reset.
web/frontend/test/locale-file.js Reads internal/i18n/*.txt through the app's own parser, so the copy budgets (help-budget, infodot-copy) measure the shipped strings instead of source literals.
web/frontend/test/token-scan.js Scans src/ for every locale key the tree reaches — literal t() arguments, keys held in tables, keys built from a stem. Shared by locale-complete (code ↔ English) and token-coverage (token set × every language), because two extractions over one tree drift silently.
web/frontend/test/screens.js The list of screens App can route to, shared by the mount smoke test and the pseudo-locale gate.

scripts/ — plain Node, no dependencies

File What it does
roadmap-data.mjs Renders docs/data/*.json into the marked regions of docs/roadmap.html. Backs up first, and refuses to write a page that fails verification.
roadmap-tracker.mjs Reads the issue tracker through gh into docs/data/tracker.json, so the renderer needs no network. --audit writes nothing and fails if the page and the tracker disagree.
web/frontend/scripts/glossary-build.mjs Generates docs/ui-glossary.html: entries from scripts/glossary/catalogue.js and from the glossary declarations beside the components, constants from src/tokens.js, theme data captured from theme.js's own applyTheme, and the built stylesheet inlined so samples are styled by the rules the app ships. Lives under web/frontend/ rather than in this directory because it renders the real components through Vite and needs that package's node_modules. --check verifies it.
changelog-entry.mjs Adds one entry to the newest release's section of CHANGELOG.md. Exists because three hand-edits in one afternoon damaged CHANGELOG.md the same way — an offset computed rather than found (index('### Fixed') + len('### Fixed\n\n'), and this file has no blank line after a heading), which lands one character inside the bullet below and produces -- **New entry while stripping the - off the entry underneath. Both notes then vanish from the app's Changelog screen, because changelog.go matches "- " only. It finds the position instead of computing it, and re-parses the result before writing — refusing if the entry does not come back out as it went in, or if any existing entry changed. internal/changelog's TestEveryBulletSurvivesTheParse is the other half, and is what caught the third one.
iso6393-data.mjs Writes web/frontend/src/iso6393.data.js (the ISO 639-3 registry, loaded lazily by the language search) and iso6393.pairs.js (639-1 → 639-3) from the iso-639-3 package pinned in web/frontend's devDependencies — the one script here that reads that package's node_modules, because the registry is data, not a runtime dependency. --check fails when the committed files differ from what the pinned package produces.
site-links.mjs Walks an assembled _site/ and fails on any local href or src in its pages' markup, or CSS url() in its pages or its stylesheets, that does not resolve inside the site. CI's roadmap job also runs it on scripts/testdata/site-links/: one site that must pass, and two that must each fail with exit 1 naming their broken link, one per place a url() is read (a page's <style>, a stylesheet).
seed-issues.mjs Backfills a GitHub issue per roadmap item that predates the automation.
doc-map-check.mjs Checks this document against the tree: every path it names must exist, every package, script and workflow must be named somewhere in it, and the CI table must have a row for every job in ci.yml and no other.
claude-kit-setup.sh Puts claude-kit back in a fresh cloud container, for the environment's setup script: the marketplace and the plugin, the digest's three off-thresholds in user settings, the kit's commit guard and the exclude line for visual-verify's working screenshots, and npm ci for the two packages the test and capture skills drive. Bash because it is a run of shell commands — the claude CLI, git, npm — with one inline Python edit for the JSON. Ends with the kit's own audit, kit_guard.py --tracked, so a kit file tracked in the clone counts as a failed step. Needs aaronified/claude-kit picked when the session that builds the environment's cache starts, since later sessions start from that snapshot and skip the script. A session whose guard does not run can run it itself, after an add_repo of the kit if the plugin cache has no copy of the guard, to add the hook and the exclude line to its own clone — every cloud session is a fresh VM, so that covers only it. CLAUDE.md's claude-kit section says how a session checks both. Idempotent. Always exits 0, because a setup script that exits non-zero stops the session from starting; a step that failed, or that it could not verify, is printed as it happens and counted on the last line. It writes the kit's hook where there is none and rewrites one that is exactly the kit's; any other pre-commit hook is left alone and reported on every run with the line to add, because whether a hook runs the guard cannot be read from its text.
claude-kit-setup-check.sh Runs claude-kit-setup.sh in a sandbox — its own HOME, a real git init, the installed kit's own kit_guard.py, stubs only for claude and npm that log their arguments — through the cases its history broke on, asserts the last line of each run, drives the written hook through real git commits (a kit file refused, an ordinary one let through), and exits 1 if any case fails. By hand after changing the setup script; not in CI, which cannot fetch the private kit the guard comes from.
sandbox-probe.sh The journeys job's check that Chrome starts with its sandbox on, and on which one: it tries the namespace sandbox, then the setuid helper, and fails the job when the first start finds no sandbox and the helper does not start Chrome either. A first start that fails for another reason is a warning, and the journeys decide. test/pure/sandbox-probe.test.js runs it against a stub Chrome, one case per outcome.
wiki-check.mjs Checks docs/wiki/ before it is published: every internal link resolves, the navigation names only pages that exist, and no page is published without something linking to it.
wiki-publish.mjs Writes docs/wiki/ out for the wiki, which wiki.yml runs in place of a copy. On the wiki a page link written with its .md extension is served as raw text and a ../ path into the repository is a 404, so a page link loses the extension, a link to the landing page, the roadmap or the glossary goes to the Pages site, and any other repository path goes to github.com at the ref being published.
dist-inputs.mjs Records every path web/dist is built from, with its hash, into web/dist-inputs.json. Run by npm run build, so the record cannot be forgotten. --check verifies it. The paths outside web/frontend/ are derived from the imports that escape it, not listed by hand.
screenshots/typescale.mjs Turns every type dial to 200% and the root font size to 24px, then fails when a screen clips something it did not clip at rest. A DIFFERENCE rather than a threshold: parts of this app clip on purpose, so a check that failed on all clipping would fail on the design. Reuses capture.mjs's screen roster and session helper rather than restating them. scripts/screenshots/typescale-baseline.json records what each screen still clips, and may fall but never rise. Run it with make typescale.
screenshots/frame-scroll.mjs Measures the work detail in Firefox. At 1440×900 and 1440×520 it fails if the locked page clips, if a column cannot scroll, or if a column with room below it wears no edge fade. Across eight widths from 1179 down to 780 it also fails if the title's lines do not all start in the same place — a float cutting into a name leaves it complete, unclipped and in two pieces, which every other guard in the repo passes. It exists because jsdom has no layout — scrollHeight there is a constant 0 — so the whole vitest suite is blind to a screen whose height chain is broken, and one shipped. The short window is part of the check: the fixture's books carry three quotes, which fit whatever the frame does, so at one size a broken stream and a working one report the same number. The stylesheet half of the same guard is test/rules/screen-scroll-chain.test.js. Run it with make frame-scroll.
screenshots/metadata-layout.mjs Measures Metadata and Settings on a desk at 1440×900 and fails when a measurement disagrees with the owner's asks: every section's tab row at the same height, the selected underline overhanging its content equally on both sides (counted tab or not), a character's portrait the same box as a person's, every People and Characters row drawn in two lines of ink, and no masonry card overlapping another or floating more than a gap under the card above it. Each check was mutation-verified. It is a probe rather than a journey because every claim is a position or a size, which no journey verb states. Run it with make metadata-layout, via screenshots/run-metadata-layout.sh.
screenshots/proto.mjs Renders a design-pack prototype offline. The prototype files in docs/design/prototypes/ are the contract for what a screen looks like, and they pull React, ReactDOM and Babel from unpkg — blocked here, so for a long time the prototypes could not be looked at in this environment at all and "it only resembles the prototype" was an unanswerable claim. It does not edit them to fix that: support.js's cdnScriptFor consults window.__resources[url] first and, where it finds a string, uses it as the src without the integrity attribute — which is exactly what a local substitute needs, since an SRI hash pins the bytes to unpkg's copy. evaluateOnNewDocument sets that map before any page script runs. node proto.mjs character-popup writes a full-page shot of all five artboards.
screenshots/appshot.mjs The other half of that pair: opens one of the app's own panels and shoots the panel alone, at the same theme, for holding against an artboard. It unfolds every <details> first — the app folds its per-work editors behind one and the prototype has no such block, so a folded sheet against an unfolded artboard compares two different things. It also prints the sheet's section order as text, because a missing or reordered section is easier to read in a list than in a picture. node appshot.mjs /movies/2 "Rick Blaine" char-film.
screenshots/panel-depth.mjs Opens a film page, its Details panel and a character from the cast strip inside it, then asks two things only a browser can answer. That history counted both panels and the one on top draws a back crumb: a panel opened from inside a panel that leaves nothing on screen is the history.go(-n) plus requestAnimationFrame(push) race, and one whose recorded depth disagrees with the stack is what makes the ✕ stop working. And that the back crumb clips, is marked with an ellipsis, and stays clear of the title beside it, on every head that draws a crumb, at 390, with a parent name forced far longer than the key — because a fixture's short names measure the fixture. Against the stylesheet as it shipped it fails on both heads. It lives here rather than in vitest because jsdom lays nothing out and dispatches popstate on a schedule that does not lose to a frame callback; test/dom/panel-opens-panel.test.jsx and test/pure/crumb-stays-in-its-slot.test.js pin the contracts a stylesheet and a stack can state, and this reads the rectangles. Its door was broken for a release and exited 1 on every run while three places called it the guard for the race: it pressed "a film page, a cast chip", and the cast moved into Details when that screen was built to the pack. Verify the embed by asset hash before trusting a run either way — a stale one, and an unseeded fixture, have each produced a false reading here. Run it with make panel-depth.
screenshots/glyph-align.mjs Measures every glyph that sits beside text, on 24 addresses at 390 and 1280, against the text's own painted rect — and measures the glyph's INK (getBBox() mapped through the viewBox), not its box. That distinction is the instrument: this app's fill glyphs are Phosphor icons with viewBoxes cropped off centre, so a box centred perfectly can still have its drawing sitting three pixels high, and a first cut that measured element boxes reported every count in the app as fine while disagreeing with the owner — who was looking at the ink. It found the systematic cause of "the chevrons are slightly up compared to the version numbers… an alignment problem i see app wide": an svg's baseline is its BOTTOM EDGE, so a glyph in an align-items: baseline row hangs its whole body above the line. Ratchets against glyph-align-baseline.json, which carries a reason per remaining site; a new site fails rather than being appended. Three filters earn their place and each was added after a wrong reading: the glyph and the text must belong to the same control (a toolbar row otherwise measures one button's glyph against another's label and reports eleven pixels of nothing), the two rects must overlap vertically (a glyph LEADING a block is not an alignment question), and a label this app has clipped away is not text on the screen. Run it with make glyph-align.
screenshots/hero-control.mjs Measures the heart beside a work's title against the title's own optical centre (a Range over the text nodes, not the border box) at 390px, for a one-line title AND a wrapped one. Fails past 2px, and fails again if the title's row is taller than its own line box. The pair is the whole check: the heart is a 44px tap target and a one-line title's box is about 25px, so aligning the two boxes at the top spends the difference below the line — half under the glyph, all of it under the row — while a two-line title hides both because the title is then the taller of the two. That asymmetry is what the report "headers with two rows look fine, one row doesn't" was. It replaces a jsdom test that asserted the CSS declarations as strings — deleted, because that shape passes on a rename and fails on an equivalent implementation, and the observable here is a distance. Run it with make hero-control.
screenshots/ratchet.mjs The arithmetic behind "may fall and never rise", taken out of the fifty minutes that feed it. It lived inside controls.mjs, after a browser walk of thirty surfaces, so the only way to ask whether the rule was right was to spend an hour producing an input for it — and it went wrong twice unnoticed: once when the ceiling belonged to a different library from the run, once when there was no ceiling at all and a missing one is not a failure. It judges the two opposite ways a ratchet stops working. A count that rose is the easy half. A ceiling the app has left behind is the other: the thing got better, the number stayed, and the gate now has room in it, so the next regression that size passes unseen — 139 controls of room sat there for as long as the ceiling was another library's. Both fail; an unrecorded ceiling is loud and does not, because failing there is how a ratchet gets deleted rather than filled in. web/frontend/test/rules/controls-ratchet.test.js imports this module by absolute URL and asks it both directions in a millisecond.
screenshots/clipverdict.mjs Whether a box is cut off in a way the type dial broke — the one predicate typescale.mjs's whole gate rests on, taken out of the page.evaluate string it lived in. Asking whether it was right cost a build, a restore and thirteen screens, so it was wrong for half an hour with nothing failing: if (CLAMPED(cs)) continue skipped a clamped element ENTIRELY, and the argument for exempting a clamp is that it holds N LINES at every type size — which says nothing about width, so a clamped box cut off sideways stopped being counted. The exemption is on the vertical check alone now. Two older exemptions are why it takes both overflow values: an overflow: auto box that outruns its size is a scroller wearing a fade, and an overflow: visible one spills rather than clips. typescale.mjs stringifies this into its probe rather than copying it, and web/frontend/test/rules/clip-verdict.test.js asserts that it still does — a copy is what let the two disagree.
screenshots/dragverdict.mjs The verdict on a drag, taken out of the browser run that feeds it — ratchet.mjs's argument applied to sheet-drag.mjs. The judgement was a chain of else ifs inside a puppeteer run, and one of its arms was unreachable: travel dereferenced the last reading ABOVE the live.length < 6 arm that exists for there being none, so the one case the guard was written for threw a TypeError out of the probe — losing every later case in the run, and reading as a broken harness rather than an unmeasurable sheet. Producing that input meant a build, a restore, a film page and a sheet that then failed to render, so the question went unasked. It takes plain numbers and answers with a string; the ORDER of its arms is a judgement too (a sheet that held still fails the travel check as well, and deserves the first sentence). It also judges the drag's frame timing, which every other check leaves out: a drag can pass every mechanical test while dropping every third frame, so judgeFrames reads requestAnimationFrame intervals and reports the median, the p95, the worst and how many were over two frames' worth. That half REPORTS rather than ratchets — headless against a software compositor, a count of long frames is a fact about the machine too — and fails only on a single frame over 250ms, which is a blocked main thread and not a busy one. web/frontend/test/rules/drag-verdict.test.js asks the whole module eighteen ways in a millisecond.
screenshots/pickfilm.mjs Which film a probe opens, decided without a browser. run-panel-depth.sh passed --movie-id 2 — a fact about the SEEDED fixture, since seed-cast.mjs --movie-id 2 is what puts a cast on it — and against a restored archive the same flag asks for a page that need not be a film: the probe sat thirty seconds on waitForSelector('.tp-btn') and died with a message about a button. The replacement then ended return String(list[0]) under a comment promising null, handing back a film with no cast — the same failure one step later. Two lookups are injected (films(), castCount(id)) so both wrong answers are a millisecond to ask about; wantCast is passed IN, because sheet-drag.mjs reaches its sheet through any film and panel-depth.mjs opens a cast face. See web/frontend/test/rules/pick-film.test.js.
screenshots/backup-env.sh Where the owner's archive is, read from one gitignored file, and every way that reading failed silently. Four names and only those four (TIPPANI_BACKUP, _PASSWORD, _USER, _PASS), because a stray line in that file may not set anything else in a shell about to run a browser as root. It dropped its last line when the file had no trailing newline (read returns false on a final line without one, having read it), ignored an export prefix and a leading indent, and kept the quotes on a quoted path and the carriage return on a CRLF file. Every one of those fails towards seeding, which prints the same first line as a machine that has no archive — so nothing looked wrong. web/frontend/test/rules/harness-archive.test.js runs it in a real shell against a file written each of those ways.
screenshots/scratch-server.sh The cleanup every harness in that directory shares, and it was written in seven places and fixed in one. Each of them boots a Tippani against a mktemp -d and tore it down with trap … EXIT, which fires when the shell RETURNS — a run stopped with a TERM never gets there, so the server keeps its port and the data dir stays. run-with-backup.sh restores somebody's real library into one, so when nine such directories were found on disk its trap was widened to INT, TERM and HUP and given a sweep of what a SIGKILL leaves; the other six were not touched, and the reasoning applied to all of them. It bit as something else: a leaked server kept 127.0.0.1:8128, the next make controls could not bind, healthchecked the port anyway, and seeded, logged into and measured the DEAD RUN's library while reporting "account already holds 22 book(s)". So there are three functions and the third is the one that matters: scratch_sweep removes a mktemp holding a tippani.db that nothing is serving (and declines, loudly, where there is no fuser to ask), scratch_trap covers all four signals a shell can be sent, and scratch_require_free refuses to run at all when something is already answering on the port — a leaked process does not announce itself as a leaked process.
screenshots/controls.mjs Presses every control on every screen and asks two things of each: did anything at all change — a dialog, a panel, the route, focus, the scroll position, the surface's own text — and if not, did the control SAY it was disabled. A control answering no to both is a lie to the reader whatever the reason, and the reason is never visible from the outside. It also counts a menu's rows and flags one that opens empty, and at 390px it checks the 44px touch floor. Written as a PROPERTY rather than a regression: it knows about no particular defect, which is why its first run found the ⋯ opening an empty card on six of twelve screens — every screen rendered, the button opened, and the defect was the ABSENCE of rows in a box one line tall. Three of its own failure modes fail rather than pass quietly: a surface that drew almost nothing did not render, a control that moved between enumeration and the press was not tested, and the run exits non-zero on any of its five lists. Run it with make controls, which does both widths.

Third-party marks are recorded in docs/wiki/Provider-marks.md: twelve suppliers' logos, vendored into web/frontend/src/providerMarks.js as data: URIs and painted as CSS masks. It names each mark's origin, its licence, the two deliberate substitutions, and why Amazon's is the letterform alone.

.github/

File What it does
workflows/ci.yml The push and PR gate. What each of its jobs runs is under Maintainer: CI.
workflows/roadmap-bugs.yml On every issue event, rebuilds the tracker snapshot, re-renders the roadmap, and commits if anything moved, then dispatches pages.yml when the page itself moved, because its own push starts no workflow.
workflows/pages.yml Builds the demo and assembles the published site around it.
workflows/wiki.yml Writes docs/wiki/*.md to this repository's GitHub wiki through wiki-publish.mjs, with links rewritten for the wiki and the text otherwise unchanged. The repository is the source; an edit made in the wiki is overwritten by the next run.
workflows/release.yml Cuts the GitHub Release on a v* tag from that version's changelog section.
workflows/docker-publish.yml Builds the multi-arch image and pushes to GHCR. Decides which image tags may move.
ISSUE_TEMPLATE/ The two issue forms and the no-blank-issues config that feed the roadmap pipeline.

docs/ and the root

Path What it is
docs/wiki/Design-decisions.md The decision log — every design decision, its reasoning, and the reversals.
docs/plans/*.md One file per designed-but-unbuilt feature, and nothing else — see its README. A shipped plan is folded into docs/wiki/Design-decisions.md, with a pass on what it got wrong, and deleted here; the directory is a list of what is coming, never an archive. The first three (the bin, context menus and multiselect, search facets) retired at 1.14.2, three more at 1.15.0, and speaker discovery at 1.16.0. Three more retired the other way in 1.16.0 — half shipped, the rest dropped with their roadmap sections — which is the directory’s second exit and is recorded in its README.
docs/roadmap.html · docs/roadmap.backup.html The published roadmap, and its last known-good copy. Generated regions — do not hand-edit between the markers.
docs/ui-glossary.html Every part of the interface, named and rendered live in all four theme combinations.
docs/landing.html The published site's front page. Carries absolute canonical and social URLs.
docs/wiki/Troubleshooting.md One row per TIP-* code.
docs/data/ The roadmap's four JSON files. tracker.json is generated; bugs.json and features.json are hand-written prose; issue-map.json maps a section slug to its issue.
docs/img/ The README screenshots.
Makefile · Dockerfile · docker-compose.yml Build, image, and the shipped self-hosting default.
deploy/ A systemd unit and a Caddy example, for running the binary without Docker.
.gitattributes Normalises every text file to LF. Read its comments before overriding core.eol on Windows — see When your own build fails.

Committed that you might not expect: web/dist/, docs/roadmap.html, docs/ui-glossary.html. Ignored: bin/, node_modules/, data/, _site/.

Rules the code enforces that are easy to break

Each of these is stated as an absence — the thing that must never appear — because that is the part you cannot infer by reading the code around it.

  • Per-user isolation is a security property, not a filter. Every query is scoped by user_id, and a row belonging to someone else answers 404, never 403 — a 403 would confirm the row exists. New handler: scope it, and add the test that proves a second user gets 404.
  • User input never reaches an FTS5 MATCH un-escaped. internal/search/fts.go is the only place that builds one. A quote mark in a search box should find quotes, not raise an error.
  • An import never writes to the library. Everything goes through internal/httpapi/import_staging.go and is approved out of the queue. Staging is what makes an importer safe enough to offer at all.
  • A shipped migration is never edited. Forward-only, append-only, one transaction each. Someone is already running it.
  • A read-only transaction says so. See Database changes — the _txlock=immediate consequence is that an unmarked transaction takes the write lock and serialises against real writers for nothing.
  • Nothing wakes on a timer, and nothing runs unless a person or the app's own lookup started it. There is no ticker, no poller, no scheduler and no pool. Besides the listener and shutdown's own bounded waits, two goroutines outlive the call that starts them, both in internal/jobs — the queue's worker and the log's writer — and each exits when it has nothing to do; a third is a design conversation.
  • Every outbound HTTP call goes through internal/outbound, and nothing outside internal/store/ opens the database — the log's own connection included.
  • The demo shim mirrors real response shapes. When web/frontend/src/demo/install.js drifts from a handler, the published demo does not fail — it renders something wrong, quietly, which is worse.

How the frontend and the backend meet

The API is mounted under /api so that the entire root path space belongs to client-side routes. Anything not matching /api is served from the embedded SPA, with unknown paths falling back to index.html for the router to resolve. That is why adding a top-level URL is a frontend change and not a server one.

Every response from api.js resolves to { ok, status, data } — including failures, so a caller reads ok rather than catching. Server errors are {"error": "message"}, and the message is written to be shown to a person.

There are two credentials and they are not interchangeable. A browser carries a cookie session and must therefore pass CSRF; a phone carries an Authorization: Bearer <device token> and bypasses CSRF because it was never subject to it. A request presenting a bearer header that is present but unusable fails closed rather than falling through to the cookie.

In a Go test, newTestServer(t) in internal/httpapi/crud_test.go gives you a real server over a real temporary database; sign in through it the way a browser would rather than constructing a session by hand, because the middleware chain is part of what you are testing.

Common tasks

Add an endpoint. Write the handler in the *_handlers.go group that owns its noun → register the route in server.go → scope every query by user_id → add a test in the matching *_test.go, including the second-user 404 → if the frontend calls it, add the helper to api.js.

Add an import format. Write the parser in internal/importer/, returning the shared shapes from importer.go → register it there → add a fixture under internal/importer/testdata/ → add the routing in movie_markdown.go if it is a markdown variant → add the upload branch in import_handlers.go → give it a Source* slug and a signature in detect.go, and an entry in importSources, importProbeOrder and importProbes in import_auto.go, because the one drop target is dispatched from those three tables and a format missing from them can only be reached by its own route → add its how-to to SOURCES in importSources.js (which the import screen and the import help section both read, so the list cannot drift from the parsers) and its slug to READ_AS in ImportPage.jsx (the as slugs are the importer's constants, not the hyphenated route names).

Add a migration. Create internal/store/migrations/NNNN_what_it_does.sql, the next number → it is embedded automatically → never edit it again → if it adds a column an existing write path should populate, find every write site, because a column that exists but is never written is worse than one that is absent.

Add a user preference. It goes in the users.preferences JSON blob, not a new column → read and write it through the /auth/me handlers in auth_handlers.go → apply it in theme.js if it affects appearance → add the control to Settings.jsx → add its help entry in help.jsx.

Add a metadata provider. New file in internal/metadata/, using the shared client in metadata.go → map its response into the existing candidate/details shapes rather than inventing new ones → fetch images only through covers.go → add its key to the settings table via settings.go and its card to the Metadata sources section of Settings.jsx.

Add a CLI subcommand. The table in cmd/tippani/main.go is the whole of it.

Add a screen. New TitleCase *.jsx in web/frontend/src/ → add the tab to routes.js and to all four nav lists → add the case in App.jsx → add its help.jsx entry → routes.test.js will fail if the nav lists disagree.

Tests

go test ./...               # everything, Go side — the local bar
go test -race -timeout 0 ./...   # what the nightly sweeps; over an hour, needs a C toolchain
go test ./internal/store/ -run TestConcurrent -count=5    # one thing, repeatedly
go vet ./...

cd web/frontend && npm test          # the frontend suite (Vitest)
npx vitest --root web/frontend run test/dom/icons.test.jsx    # one file

(cd scripts/screenshots && npm ci)    # once, for npm run journeys: they launch their browser through it
cd web/frontend && npm run journeys  # the journeys: a real browser against a real server

The journeys are the tier that presses buttons. Each file gets its own Tippani — its own binary, data directory, port and browser — signs in through the real login form, and then does everything by pressing what is on the screen. A journey may know the address it opens, what is on the screen, what a person can do to it, and what the app shows or keeps afterwards; it may not know a function name, a module path, a CSS class, a JSON field name, a Go type, or the text of any source file. Exceptions are declared in the file's own header. They are slow enough to have their own config (vitest.journeys.config.js) so npm test never pays for them, and their fixture is seeded once and copied per file.

The Go tests run against real HTTP handlers and a real SQLite database — there are no mocks, and a test that needs one is usually a design smell. -count=5 is worth reaching for on anything concurrent; a race that shows up one run in four is still a race.

The frontend suite runs on Vitest in three projects plus a fourth config: pure for value-in, value-out logic in the node environment, dom for components under jsdom, which is paid for only where a component is genuinely under test, rules — the files that read the source text and assert how it is spelled — and vitest.journeys.config.js, which is a separate config rather than a project because it needs a globalSetup that builds the binary and seeds a library, and a timeout an order of magnitude longer than the others. The binary embeds the SPA the sources build: when web/dist is behind them, as it is between two pushes, the globalSetup builds the SPA into the run's own directory and hands it to go build as an overlay, and says so on its first line. web/dist itself is never written. Only pure and dom are what npm test runs; rules runs as npm run lint:rules and the journeys as npm run journeys, each its own CI step. rules is out of npm test because the app can be entirely broken and every one of those files still passes — a suite let a feature ship 100% dead exactly that way, which is what the journeys exist to end. TZ is pinned to UTC because several places call toLocaleDateString with an undefined locale and would otherwise pass here and fail on a runner set to anything else. Its dependencies are devDependencies only — the three runtime npm packages are a claim How-this-was-written.md makes, and it has to stay true.

Two things to know before adding to it. The setup files exist because jsdom's silence is worse than its absence: getBoundingClientRect returns all zeros, so Masonry packs everything into column 0 and Tooltip never opens — wrong, with nothing thrown. And a passing new test is not evidence yet: several written for this suite were later found to assert nothing when the code was deliberately broken under them. Break your fix on purpose and watch the test go red before you trust it.

-race needs CGO_ENABLED=1 and a C compiler, which the rest of the build deliberately does without (CGO_ENABLED=0, pure-Go SQLite) — so on a machine with no gcc it is a thing you read in a CI log rather than run. Plain go test ./... is the local bar; if you are changing anything that writes concurrently, open a pull request and read its race job before you call it done.

It runs in two halves, and the reason is worth knowing before you move it back. The whole suite under -race took 29 minutes at 1.7.4 and no longer finishes in an hour, because pure-Go SQLite means the detector instruments the entire database engine rather than only this repo's code. On an idle developer machine on 2026-09-01, internal/httpapi alone did not finish in 55 minutes raced, and the nightly on 2.2.9 ended with that package alone at its 60-minute timeout while every other package passed. So the five locking tests (conflict_pool_test.go, write_lock_test.go), and three from health_test.go that hold the pool full, run raced on every push to main and every pull request, which is the coverage those files were written for. The job takes about two minutes on a runner with its eight tests (2m01s at 3.0.1). The full sweep runs nightly at 03:00 UTC, or by hand: one job per package (race-nightly), and internal/httpapi split ten ways by test name (race-nightly-httpapi). Sharding by package does not buy that package any time: -timeout has always applied to each package's test binary on its own. The ten-way split is what does, because each shard is its own binary run with its own hour. If you add a test that races, name it in the race job's filter or it will not be raced until the following morning.

That job asserts each named test actually ran. A -run filter that matches nothing still exits 0, and ok (0 tests) reads exactly like ok — a false green that has already cost this repo an afternoon.

The bar for a change: go vet clean, gofmt -l . empty, go test ./... green, and a test that would have failed before your fix. That last one is the one that matters. There is a worked example in the repo — the concurrent-write 500 had a written-up cause and a written-up fix, and both were wrong. What settled it was making the test fail on purpose and reading the error code.

Database changes

Migrations live in internal/store/migrations/, are embedded, numbered NNNN_what_it_does.sql, and append-only. Each runs in its own transaction.

  • Add a new file. Never edit a migration that has shipped — someone is already running it.
  • SQLite via modernc.org/sqlite: pure Go, so CGO_ENABLED=0 works and FTS5 is compiled in with no build tag.

Three kinds of change, and which one you have

A schema migration is not the only way a database moves, and picking the wrong one is how a change either runs when it should not or never runs at all.

Kind Where Runs
Schema migration migrations/NNNN_what.sql once, in version order, forward-only
Boot repair a Backfill* in store every start, unguarded, so it heals a row a later edit left stale
One-time pass onetime_<version>_<what>.go once per database, recorded in one_time_passes

The third is for a change that has to happen once on a database that ALREADY EXISTED, because a release changed what something means — "every instance upgrading to 2.2.0 needs telling that the default film source moved". Running it twice would be wrong, and running it on a fresh install would state something untrue, which is why OneTimeEnv.FreshInstall exists and why every such pass has to check it.

Write one as its own file, named for the release it first ships in, registering itself from init():

func init() {
    RegisterOneTimePass(OneTimePass{
        Version: "2.2.0",
        Name:    "2.2.0-tvdb-default-notice", // the primary key; never changed
        Why:     "one line, logged when it runs",
        Run:     func(tx *sql.Tx, env OneTimeEnv) error { ... },
    })
}

Retiring one is a file deletion, and that is the whole reason for the registry: nothing else in the tree names the pass, so removing it cannot break startup for anybody. Delete it once no supported instance can still be upgrading from before its release — the version in the filename is what makes that answerable from ls. Check first whether anything the pass wrote is still read; if so (as with store.SettingFilmSourceNotice), move that constant to a permanent home rather than deleting it with the pass.

A pass that fails is logged and skipped, not returned — an error out of Migrate() means the app does not start, and a one-time pass is not worth that. It stays unrecorded, so the next start tries again.

  • The connection is opened WAL, synchronous=FULL, busy_timeout=5000, foreign_keys=ON, and _txlock=immediate.

That last pragma is load-bearing and worth understanding before you touch store.go. Almost every write here reads before it writes. Under SQLite's default DEFERRED locking that makes BEGIN take a read lock which the first INSERT must upgrade — and SQLite refuses to wait on that upgrade, because two transactions both holding read locks and both wanting to write would deadlock. It fails the loser instantly, so busy_timeout is never consulted. IMMEDIATE takes the write lock up front, where there is nothing to upgrade.

The consequence for you: a transaction that only reads should say so.

tx, err := s.Store.DB.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})

Otherwise it takes the write lock and serialises against real writers for nothing. TestReadersOverlapAWriter fails if reads ever start queueing behind writes.

Frontend build

cd web/frontend
npm install
npm run dev                 # Vite dev server, proxying /api to 127.0.0.1:8080
npm run build               # -> ../../web/dist  (commit this)
npm run build:demo          # -> _site, the read-only demo with a fetch shim

Run make run in one terminal and npm run dev in another. Point the proxy elsewhere with TIPPANI_DEV_API.

web/dist/ is committed because the Go binary embeds it, so a frontend change needs the rebuilt dist beside the source by the time it is pushed: in the same commit, or, as the maintainer's sessions do (CLAUDE.md), in one rebuild commit ending the push. If you change the frontend and do not rebuild, the binary keeps serving the old UI and nothing will tell you — which is why CI runs git diff --exit-code -- web/dist after building.

What counts as a frontend change is wider than web/frontend/. src/i18n.js imports internal/i18n/en.txt and bn.txt with Vite's ?raw — every user-facing string in the SPA comes from those two files — so editing a locale file changes the bundle, and nothing about editing a .txt inside a Go package suggests you have just changed the frontend. That is how the first commit after v2.1.3 reached main with a stale dist, and turned the whole push red for a reason its author had no way to predict. The full input set is written to web/dist-inputs.json by npm run build (scripts/dist-inputs.mjs) and checked by TestDistWasBuiltFromTheseInputs, so go test ./... now fails on a stale dist in your working tree — no Node, no build, and before there is a commit to push.

The demo build (VITE_DEMO=1) swaps in the dummy-data fetch shim and disables writes. It is what GitHub Pages publishes.

For what a piece of the interface is called before you rename it, docs/ui-glossary.html is the canonical list. It is not repeated here.

Conventions

Commit messages are Conventional Commits: fix:, feat:, docs:, refactor:, test:, chore:. The subject says what changed; the body says why, and why the obvious alternative was not chosen. Look at git log — the bodies are long on purpose, because the reasoning is the part that cannot be recovered from the diff later. One commit per fix and per feature; minor fixes may be clubbed.

Comments explain why, not what. The codebase is written that way, and a patch that only restates its own code reads as a different author.

Every UI label is five words or fewer. Longer copy goes behind an info dot. This is a house rule rather than a coincidence: a bubble that needs a paragraph is an info dot.

Every document in this repo speaks in the first person — "I", not "we" and not "the maintainer".

Documents that go stale with a change, and are expected in the same pull request: CHANGELOG.md for anything user-visible, docs/ui-glossary.html for anything the interface is named by, How-this-was-written.md if you change how the repo is checked, and docs/wiki/Design-decisions.md if you depart from the design — recorded as a departure, with the reasoning, rather than silently.

Sending a pull request

  1. Open an issue first for anything beyond a fix. See What I will and will not merge.
  2. Branch off main. Keep it to one concern.
  3. Write the commit message body. The why, and the alternative you rejected.
  4. go vet ./... and go test ./... must pass, and gofmt -l . list nothing, npm test if you touched the frontend, and the pull request's head must carry the rebuilt web/dist/ (CI checks it). The maintainer's own sessions commit features without it and add one rebuild commit before each push; see CLAUDE.md.
  5. Update the docs that go stale — the list is in Conventions.

When your own build fails

Five failures that are self-inflicted rather than real, in the order they catch people:

  • git diff --exit-code -- web/dist fails and the diff is whitespace. Line endings. .gitattributes normalises every text file to LF, and overriding core.eol or core.autocrlf locally defeats it. Read the comments in that file before changing anything there.
  • TestDistWasBuiltFromTheseInputs fails. The committed web/dist/ predates a file it is built from, and the failure names the file. It is often internal/i18n/en.txt or bn.txt, which the SPA imports — that is a frontend change even though it does not look like one. Run make frontend and commit web/dist/ and web/dist-inputs.json with it.
  • A Go test passes suspiciously fast. Check the -run filter matched something. A filter that matches nothing exits 0 and prints ok.
  • npm run glossary:check fails. docs/ui-glossary.html is generated. Run make frontend (it inlines the built stylesheet, which every build renames), then make glossary. Do not hand-edit the page: an entry is changed in web/frontend/scripts/glossary/catalogue.js, a sample by adding a glossary declaration beside its component in ui.jsx, and a constant in src/tokens.js.
  • node scripts/roadmap-data.mjs --check fails. The page has drifted from docs/data/*.json. Run the script without --check. Never edit the page between the ROADMAP:* markers.
  • The app logged a TIP-* code. docs/wiki/Troubleshooting.md has a row per code.

Maintainer: CI

.github/workflows/ci.yml runs on pushes to main and on pull requests, plus a 03:00 UTC schedule, and by hand (workflow_dispatch: gh workflow run ci.yml). Its jobs:

Job What it runs
go go vet, gofmt -l . (it fails when the list is not empty), the full Go suite — which includes the check that web/dist is not stale — and a smoke test that boots the server and health-checks it.
race The five locking tests, and three #40 tests that hold the connection pool full, under -race, on every push to main, every pull request and every run by hand. Asserts each named test actually ran.
race-nightly Every package but internal/httpapi under -race, on the schedule and on a run by hand, one job per package so a race or a timeout in one does not hide another.
race-nightly-httpapi internal/httpapi under -race, on the schedule and on a run by hand, split ten ways by test name because the package does not fit an hour raced. Each shard lists the tests from the race binary and fails unless every test it was dealt ran.
journeys npm run journeys in the runner's Google Chrome, with its sandbox on. A failing journey uploads what the reader saw and what the server said.
frontend npm test, npm run lint:rules, npm run build, git diff --exit-code -- web/dist web/dist-inputs.json, npm run glossary:check and iso6393-data.mjs --check. It checks out the whole history, which the citation guard in lint:rules reads.
roadmap roadmap-data.mjs --check, doc-map-check.mjs, site-links.mjs on its three fixture sites under scripts/testdata/site-links/, and wiki-check.mjs. (The glossary check moved into the frontend job, which is where a fresh web/dist and node_modules exist.)

The other five workflows are described in .github/ above.

Maintainer: the roadmap pipeline

Worth understanding even if you never touch it, because it explains why editing docs/roadmap.html between the marker comments does not stick.

The roadmap is a single self-contained HTML file with no script in it, so anything dynamic has to be baked in at commit time rather than fetched at view time. Six marked regions are generated; everything outside them is hand-written prose.

flowchart LR
  A[Issue filed<br/>via a form] --> B[Workflow recovers<br/>bug / enhancement]
  B --> C{Maintainer adds<br/>accepted or considered}
  C -- no --> D[Nowhere.<br/>Filing publishes nothing]
  C -- yes --> E[roadmap-tracker.mjs<br/>reads the labels]
  E --> F[roadmap-data.mjs<br/>renders the regions]
  F --> G[Commit + Pages deploy]
Loading

The rule in one sentence: the labels decide what is on the page, and the repo only decides how it reads.

File Owner Contains
docs/data/tracker.json Generated The tracker's state. Never edit it
docs/data/bugs.json You Per-issue prose overrides for bugs
docs/data/features.json You Per-issue prose overrides for requests
docs/data/issue-map.json Tooling Section slug to issue number
node scripts/roadmap-tracker.mjs   # read the tracker (needs gh, and auth)
node scripts/roadmap-tracker.mjs --audit  # does the page still match the tracker?
node scripts/roadmap-data.mjs      # render the page
node scripts/roadmap-data.mjs --check     # CI: fail if the page is stale
node scripts/roadmap-data.mjs --restore   # put docs/roadmap.backup.html back
node scripts/seed-issues.mjs       # file an issue per existing roadmap item (dry run)

Taking something off the roadmap

Culling a section and closing its issue are one job, not two. Every item on the page carries an issue number, and the page's own promise is that closing the issue is the only bookkeeping there is — so an item removed from the page with its issue left open turns that promise into a lie, in the one direction nobody notices. It has happened four times: §§1–3 and §18 came off the page across 1.15.3 and 1.16.0 and their issues sat open afterwards.

So, in the same pass:

node scripts/roadmap-tracker.mjs --audit          # lists what is out of step, both ways
gh issue comment <n> --body-file <what happened>  # what shipped, or what was dropped and why
gh issue close <n> --reason completed             # or --reason "not planned"

--audit reports two kinds of drift and exits non-zero on either: an orphan is open, labelled for the page, and no longer on it; a ghost is closed and still listed. roadmap-data.mjs --check cannot catch either — it validates the generated regions against docs/data/*.json and never reads the hand-written backlog, which is where every culled section lived.

It is not in CI, deliberately. It needs a live tracker read, so it would put a contributor's pull request at the mercy of what is happening on the issue tracker, and go red for maintainer bookkeeping that has nothing to do with their diff. It is a step in the cull, not a gate on the tree.

The comment is the part that cannot be automated. A closed issue with no explanation reads as a shrug to whoever subscribed to it, and --reason "not planned" on something half delivered reads as a refusal — so the comment says what shipped, what was dropped, and whether a request would reopen it.

Three things keep it from going wrong: every write keeps the previous page in docs/roadmap.backup.html; a render that loses a marker, unbalances <details> or shrinks the page implausibly is refused rather than published; and CI fails if the committed page has drifted from its data files.

Issue text is escaped before it goes anywhere near the page, fenced code blocks are dropped rather than rendered, and only paragraphs, list items and code spans are emitted. An issue cannot inject markup. That is also why the accepted gate exists — escaping stops markup, and does nothing about a report that is simply wrong.

One rule for editing the page by hand: a section's § number is its position and moves whenever the order does. Cite the issue number, never the §. Anchors are content slugs (#mobile-pwa) for the same reason, and issue-map.json is keyed on them.

Maintainer: cutting a release

Releases are tag-driven. There is no version constant to bump: it is stamped from the tag.

  1. In CHANGELOG.md, rename ## [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD. Match the house style of the entries above it: the user-visible symptom first, then the reasoning, then what was rejected.

  2. Commit as chore(release): X.Y.Z. The binary embeds CHANGELOG.md itself (changelog.go at the root), so what Settings → Changelog shows is this file, and there is no copy to refresh.

  3. Tag and push — the tag by name, never --tags:

    git tag vX.Y.Z
    git push origin main
    git push origin vX.Y.Z

    --tags pushes every tag in the local repository, and --follow-tags pushes every annotated tag reachable from the commit — which is all of them. Either will quietly publish a tag made weeks ago and never pushed, firing its whole release pipeline alongside the one I meant. That is not hypothetical: on 2026-08-09 an orphaned v1.3.0 went up beside v1.7.2, built more slowly, finished second, and took :latest with it. Naming the tag pushes exactly one thing.

  4. Watch it land: gh run list --limit 5, and check the release page and the GHCR tags.

release.yml cuts the GitHub Release using that version's changelog section as the notes, and docker-publish.yml builds and pushes the GHCR image on the same tag. Both are also runnable by hand against an existing tag to backfill a missed release:

gh workflow run release.yml --ref vX.Y.Z

docker-publish.yml decides which image tags may move: X.Y.Z is always published, but latest and X.Y are claimed only by the highest-ranked tag, computed from the tag list rather than from build order. And the binary refuses to open a database whose schema version is above the newest migration it carries — so a downgrade stops with both numbers in the message instead of starting up blind to every table added since.

Version numbers follow Semantic Versioning. A migration that changes existing data, or anything that alters an export format, deserves a minor bump and a paragraph in the changelog saying what to expect — people are self-hosting this, and an upgrade that surprises them is worse than one that waits a week.

Appendix: forking it as your own

Nothing here assumes you are me, but a handful of strings do. In rough order of how much they matter:

  1. Module path. go.mod declares module tippani, and every internal import is tippani/internal/.... Renaming it is a find-and-replace across the tree:

    grep -rl 'tippani/internal' --include='*.go' . | xargs sed -i 's#tippani/internal#yourname/internal#g'
    sed -i 's#^module tippani#module yourname#' go.mod

    Leaving it alone is entirely fine and costs nothing.

  2. Update check and image. internal/buildinfo/buildinfo.go defaults to aaronified/tippani and ghcr.io/aaronified/tippani. You do not need to edit the code: set TIPPANI_REPO and TIPPANI_IMAGE. Do set them, or your fork's in-app updater will offer people my releases.

  3. Published docs base. DOCS_BASE in web/frontend/src/Settings.jsx points at https://aaronified.github.io/tippani/. The roadmap and UI glossary are not embedded in the binary, so a self-hosted instance links out to a published copy.

  4. The published site's own URLs. docs/landing.html carries an absolute canonical, og:url and og:image, and web/frontend/index.html carries an absolute og:image — all four must be absolute or they are ignored, so none of them can be made relative. The base in .github/workflows/pages.yml builds robots.txt and sitemap.xml from the same value. Point them at your fork's Pages URL, or your social previews advertise mine.

  5. Roadmap tooling. REPO in scripts/roadmap-data.mjs, and the GITHUB_REPOSITORY fallback in scripts/roadmap-tracker.mjs and scripts/seed-issues.mjs. In Actions the environment supplies it; locally the fallback is used.

  6. Issue forms and links. .github/ISSUE_TEMPLATE/*.yml and the URLs in docs/roadmap.html and README.md.

  7. Metadata user agent. internal/metadata/metadata.go identifies itself to the metadata providers. Change it — it is how they contact you about your traffic, not mine.

  8. docker-compose.yml references the image name.

To get the roadmap automation working on a fork you also need the labels it depends on, because a label that does not exist is silently not applied:

gh label create roadmap    --color B4482D --description "Planned work, written up on the roadmap"
gh label create considered --color BE8A4E --description "Being considered, not committed"
gh label create accepted   --color 3E8E5A --description "Accepted; appears on the roadmap"

bug, enhancement, duplicate and wontfix already exist in a new GitHub repository.

Then enable Pages (Settings → Pages → Source: GitHub Actions). pages.yml cannot do it for you: configure-pages' enablement needs a token other than GITHUB_TOKEN. roadmap-bugs.yml asks for the write access it needs in its own permissions: block, so the repository's default workflow permissions can stay read-only.

Clone this wiki locally