Development
Development
← Documentation · Previous: Upgrading
Layout
app/ Angular 22 workspace — standalone components, signals, zoneless
core/ model client, SSE reader, error mapping, token estimates,
story formatting, the prompt builder. Depends on nothing above
it, and on nothing of Angular's but DI and signals
store/ signal stores (one per document type), the four model requests
the chapters make, one API client for every call to our own
server, and the persistence layer they write through: the
session's documents, and the server
features/ chapters (page, message list, composer, scene sheet, chapters
list, close chapter, prompt preview), model (the connection and
the parameters, as the two tabs of one sheet), preferences,
story, updates, world. No feature imports another
chrome/ this app's own furniture: top bar, save indicator, the notices,
the reading voice, the character swatch — store-aware, and never
inside a feature
shared/ pieces with no story in them: editor field, controls, notice,
the phone-sheet gestures. Nothing here reaches a store
dialogs.ts what can be opened over the page, and the flows that chain two
or three sheets. At the root because it knows every feature
wire/ the one file the app and the server both read: the revision header,
the words of a refusal, the collections, the routes, the shapes of
the three answers that are not documents, and the version
comparison both sides make. Plain JavaScript with JSDoc types,
because the server runs from source; the app reaches it as `@wire`
server/ Express 5 — JSON documents on disk, and the built app in front of
them
app.js composition and nothing else: the order the middleware and the
three routers go in. Takes its store, so a test can hand it one
security.js the rules every request passes: the content security policy, the
Host check that answers DNS rebinding, the CORS allowance that is
off, and what counts as a body
*-router.js the routes, one file per subject: documents (list, read, write,
delete), sharing (the switch and the QR code), status (which
build this is, and whether a newer one exists)
static-app.js the built app, cached by name, with the single-page fallback
store.js the documents: one FIFO chain per file, atomic writes, and the
revision guard two writers meet. The two disk shapes — one file,
or a folder of them — are two objects at the top of it
bootstrap.js the start-up sequence itself, and the owner of the store: the
one thing both index.js and the desktop shell call, so there is
one order. index.js is the console around it; cli.js is what the
command line means, and where the built app is
share.js the second listener and the pairing lock in front of it, which is
how a phone on the same network reads the story
version.js the build stamp: which build this is, and which one wrote this
data folder last. zip.js and backup.js are the daily archive;
fs-atomic.js is the write-and-rename all three share; log.js is
the one line every one of them says things out loud through
electron/ the desktop shell. main.mjs is the wiring — the order things happen
in, and the state that outlives the function that made it — and it
starts the same server in-process, through that one call, and opens
one window at it. Everything decidable without a running Electron
is a module beside it with a test in tools/test/: profile.mjs
(where the writing is kept), window-state.mjs (what a saved size
may say), menu.mjs (the bar, as a template), shutdown.mjs (the
order a quit closes things in), fatal.mjs (how it says it could not
start), updates.mjs (whether it may ask GitHub). chromium.mjs and
window.mjs do need Electron and are not wiring either
tools/ dev.mjs (both halves at once), package.mjs (the runnable zip),
desktop.mjs (the window, and the installers), smoke.mjs (a fresh
install to walk by hand, and --check to prove one in CI),
screenshots.mjs (every picture in docs/),
icons.mjs (the raster icons), probe-providers.mjs (the CORS table),
check-docs.mjs (the links in docs/ survive becoming a website),
release-notes.mjs (docs/releases.md from CHANGELOG.md),
fetch-electron.mjs (Electron's binary, which npm ci does not fetch)
lib/ the part of a script worth a test, and script.mjs: the step, the
one-line stop, the child process, the JSON reader, the health
wait and the free port that every one of them used to carry its
own copy of
e2e/ Playwright specs + a fake OpenAI endpoint
docs/ these pages, and — served by GitHub Pages — the website
.github/ ci.yml: every check, on every push to main; release.yml: the same
checks and then the installers, on a tag; actions/verify: the
checks themselves, so the two workflows share one list
eslint.config.mjs at the root is the one lint configuration for all of it, and
.prettierrc the one formatting configuration. The layers above are rules in that
config rather than a convention: npm run lint fails on a core/ import that
reaches into the app, a store/ or shared/ import that reaches a component, a
shared/ file that reaches a store, and one feature importing another.
PLAN.md at the root is the plan of record: four steps, what each one had to do, and — more
usefully — why each decision went the way it did.
Scripts
npm start |
Both halves: persistence server on 4177, dev server on 4200 proxying /api to it. The proxy is why the API authorises no cross-origin request: nothing calls across. LAMPLIT_DEV_CORS=1 puts the localhost allowance back for a dev server run without it |
npm run server |
Just the server (and the built app, if there is one). The front end has no standalone mode: it reads its documents from the server or does not start |
npm run build |
Angular production build into app/dist |
npm run package |
The runnable zip — see Running it anywhere |
npm test |
Unit tests, all three of them: npm run test:app, npm run test:server and npm run test:tools run one on its own |
npm run lint |
ESLint over the whole tree: the type-aware rule sets on app/, the recommended ones on everything else. npm run lint:fix applies what can be applied |
npm run e2e |
Builds the app, then the full Playwright suite |
npm run e2e:quick |
Playwright without the build (skips the specs that need it) |
npm run smoke |
Packages, unzips the archive into an empty folder, and starts it — a genuinely fresh install to walk by hand. --check stops as soon as it answers /api/health and exits, which is how CI runs it |
npm run screenshots |
Regenerates every picture in docs/images |
npm run icons |
Regenerates favicon.ico and apple-touch-icon.png from app/public/favicon.svg |
npm run providers |
Asks every provider in the list whether it still lets a browser call it, and prints the table for Models and parameters. Not in CI: it makes a real request to every company on the list |
npm run electron |
Downloads Electron’s binary. Runs on postinstall, so normally you never call it — Electron 44 ships no install script of its own, and npm ci alone leaves you with the JavaScript and no executable |
npm run desktop |
Opens the Electron window against the repository — no packaging, so a change to the app needs npm run build and a reload |
npm run desktop:stage |
Stages the folder the installers wrap (build/desktop-stage), and stops |
npm run desktop:dist |
Stages, then builds installers for the OS you are on, into build/desktop |
npm run check:docs |
Every link in docs/ resolves, and will survive being turned into a website. Offline, and in CI |
npm run notes |
Regenerates docs/releases.md from CHANGELOG.md, the release notes page of the website. npm run check:notes fails if the two have drifted apart, and is in CI |
npm run format |
Prettier over everything. npm run format:check changes nothing and fails if it would have; that is the form CI runs |
Tests
Unit — npm test. Vitest for the app: the SSE reader, the request builder, error mapping,
token estimates, the story formatter, the prompt builder (block order, the scene verbatim, lore
scanning, budget trimming, chapter titles, the summary request), and the persistence layer (the
startup load, coalescing, sequence numbers, offline queueing, and refusing to start without a
server). node --test for the server: the document store’s write ordering and atomic writes, the
API, the zip writer, the daily backup, the build stamp (reading it, the dev fallback, and how an
upgrade is noticed), the command line (a port that is not one is a line, not a stack trace), and
the start-up sequence itself, on port 0 with the backup and the update check off. node --test
again for the scripts in tools/ and the desktop shell’s own modules, which have no other way of
being caught: reading the changelog’s top section and the page the website is made from, the
arguments and the flags of the desktop build, which of the shell’s modules the installers actually
package, where the profile is on a stick, what a saved window size may say, what the menu bar says,
the order a quit closes things in, how the shell reports a failure to start, whether it may ask
GitHub about an update, which packages the runnable zip has to carry, what the staged folder is
made of (the generated package.json, the two start scripts, the README, the line endings each of
them wants), and each trap a docs page can fall into on the way to becoming a website.
A script cannot be imported to be asked what it would do — it has its effects at the top of the
file — so the part of one worth a test lives in tools/lib/ and the script reads and writes the
files around it. That is the split to keep when a script grows a decision worth checking. The
desktop shell is the same rule under another name: main.mjs runs on import and cannot be asked
anything, so what it would decide lives in the modules beside it.
End to end — npm run e2e. Playwright drives the real app against
e2e/fake-openai-server.mjs, a deterministic stand-in for an OpenAI-compatible endpoint. Both
servers start automatically; no tokens are spent and no key is needed. The fake endpoint takes
instructions from the message text: !slow, !long, !error, !401, !prose, and !nolore,
which fails the lore request alone and streams the reply as usual. The model id a spec seeds into
the settings decides how well the endpoint speaks JSON: fake/no-json-schema refuses
response_format with a 400 and answers inside a ```json fence, the way an endpoint that has
never heard of schemas does; any other id takes the schema and answers with a bare object.
Two projects, because there are two layouts. chromium is a desktop window and everything under
e2e/specs/; phone is a Pixel 7 profile and e2e/specs/phone/, which is where the phone layout
is tested — a coarse pointer, no hover, and a 412px viewport. It is that pointer rather than the
width that decides whether Enter sends and whether a message’s actions are a rail or a menu, and
it cannot be had by making a desktop window narrow. See On your phone for what
the layout is.
Every spec runs against the real server serving the real production build, on its own port
with its own empty data folder — the server fixture in specs/fixtures.ts. There is no dev
server in the suite and no browser-storage mode to fall back on, so a spec seeds by writing JSON
into that folder, which is exactly what a person does when they copy a story onto a new machine.
Each test is isolated by construction: nothing carries over, because there is nowhere for it to
carry over in. That is why npm run e2e builds first; npm run e2e:quick skips the build, and
skips everything if there is nothing built.
The seeding itself is the app fixture beside it, written once: app.open() is a connected app
with one story, its opening chapter already on a scene, and app.seed() and app.visit() are the
two halves of that for the specs that put something else on disk in between. A spec that says
app.open({ scene: '' }) is asking for the state before anyone has written a scene, and a spec
that names a scene of its own is saying the words in it matter.
persistence.spec.ts— the disk as the story: documents written as the UI changes them, a reload coming back to what is on disk rather than to what was there before, a second browser seeing the same story, the server going away mid-chat and catching up, two tabs, deleting a story taking its files with it, and the app refusing to start when the documents cannot be read.journey.spec.ts— the whole app, once, in narrator mode, from nothing. Eleven stages in order, sharing one page, walked through the interface the way a person would: the connection sheet insisting, the story questions, the scene refusing whitespace, the first turn’s prompt in the right order, the story so far, lore staying out until the story mentions it, closing a chapter, chapter 2 carrying the summary and not the transcript, and an empty browser reading the lot back. It is the regression net for the shape of the app rather than for any one feature.
The human half of the same walk is npm run smoke plus the script in e2e/LIVE-TEST.md — every
prompt written out, a table per stage with the expected result beside it, and a worksheet for the
one thing a fake model cannot check: whether a real one tells a decent story, and what it costs.
On every push — CI. .github/workflows/ci.yml runs on each push to main and on each pull
request: npm ci, lint, the formatting check, the docs and release-notes checks, the unit tests,
the production build, the packaged folder started for real, and the end-to-end suite, on Ubuntu, in
about three minutes. The steps are
.github/actions/verify, and the release workflow runs the same action on both platforms before
it builds an installer, so a tag cannot pass a check that main would fail. The badge at the top
of the README is the last run on main.
How the screenshots are made
npm run screenshots (tools/screenshots.mjs) starts the persistence server on the production
build, drives a real browser through the app, and writes what the app actually draws into
docs/images. Nothing in these pages is a mock-up.
The model behind it is a small stand-in inside the script that answers with the demo story’s own prose, so the pictures are identical every run and no tokens are spent taking them. Each phase gets an empty data folder, because the server is the truth at startup.
Change the UI, re-run it, commit whatever moves.
The shape of the code
A few things are worth knowing before reading it:
- The whole prompt is rebuilt from the documents on every request. There is no conversation object and no accumulated state. Edit, regenerate and replay are not special paths — they all go through the same builder as a fresh send. See The prompt.
- One store slice = one JSON file. The stores are plain Angular services holding
signal()/computed()state, one per document type. No NgRx: the persistence model maps onto a hand-rolled signal store 1:1 with no ceremony. - One place a document lives. The server. The app reads every document once at startup into a
map that lasts the session; writes go to that map and to the server. The stores stay synchronous
and know about neither. It used to keep a
localStoragecopy as well, which bought a merge on every startup, a persisted write queue and a rule for which side wins — all of it gone, andPersistencesays why in its header. One key is left in the browser and it is not a document:lamplit-theme, whichpublic/boot-theme.jsreads before the stylesheet so that the first frame is the theme you chose rather than the one that arrives a fetch later. - No SDK, no HTTP client, no state library.
fetch, a hand-written SSE parser, andAbortControllerfor Stop. - Angular 22, zoneless. Signals throughout, the new control flow,
inject(), standalone components. Material is used for dialogs, menus, tabs, sliders, switches and tooltips; everything that has to look good — the chat itself, and every field in it — is hand-written. A field is a label above a box (li-field, orli-editor-fieldwhere the box holds a paragraph), and the frame round the box is one rule instyles.scssoverinput,textareaandselectalike. How tall a box is, is one of three names —li-rows-short,li-rows-medium,li-rows-tall— put on the box or on anything above it; every modal opens throughDialogsService, at one of four widths; and a Material button whose label can be longer than the room there is wearsli-truncates, which is what makes it end in an ellipsis rather than mid-word. - Every media query is a mixin in
app/src/breakpoints.scss. The five widths and the four things the reader’s machine answers are declared there, each with the question it asks; a component includes one and writes no threshold of its own. The two widths that TypeScript also has to know are published as custom properties and read back off<html>incore/layout.ts, so no number is written twice.
Conventions
- Prettier settings are in
.prettierrc;npm run formatbefore committing. npm run lintbefore committing too. Every ruleeslint.config.mjsturns off says why in a comment beside it; a rule that fights a deliberate choice of the codebase is not one the codebase wants, and a rule that is merely inconvenient stays on. Theapp/tsconfig hasnoUnusedLocals,noUnusedParametersandnoUncheckedIndexedAccesson;exactOptionalPropertyTypeswas tried and is not, and the file says why.- The repo is LF everywhere (
.gitattributes), including on Windows. - Comments explain why, not what. If a comment restates the line under it, delete one of them.
data/,backups/,build/anddist/are ignored, and should stay that way.