clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / docs / app.md
app.md 40.8K rendered
1# The application23**Status: built.** This document4specifies the hypermedia application: the URL surface, the session record, the5partial and event envelopes, the region and template system, the state island,6and the contract between the handler and whatever host it is mounted in. §117is the standing list of what is named rather than built, so nobody mistakes a8placeholder for a decision.910The implementation is `internal/app`, its host seam is11`internal/app/hostenv`, and the two hosts are the desktop shell at the module12root (`main.go`) and `atlas serve` (`cmd/atlas/serve.go`). Where this document13and the code disagree, take it as a defect in one of them and say so.1415---1617## 1. One handler, three hosts1819The application is **one pure `http.Handler`**. It touches no filesystem,20builds no path, opens no dialog, and reads no environment. Everything21OS-shaped arrives through three small interfaces:2223```go24type Hostenv interface {25 Volumes() VolumeStore26 Sessions() SessionStore27 PickFile(ctx context.Context) (io.ReadCloser, string, error)28}29```3031That is the whole host contract. It exists so the same handler can be mounted32by three different hosts:3334| Host | Status | What it supplies |35|---|---|---|36| **Wails webview** | built | The desktop window; `PickFile` is the native dialog; the library is the application's data directory. `main.go` + `redirects.go` + `hostenv/wailshost`. |37| **`atlas serve`** | built | Plain HTTP, no window. `PickFile` refuses with `ErrNotAvailable`. The dev loop, CI, and the browser suite (`tests/e2e`). |38| **WASM service worker** | not scheduled | Go compiled to `js/wasm`; the stores back onto OPFS. Nothing is built for it; the discipline is what keeps it reachable. |3940The rule is enforced mechanically: `tools/depcheck`'s `hostenv` analyzer41fails any import of `os`, `os/exec`, `path/filepath`, `syscall`, or a window42toolkit from `internal/app` outside `internal/app/hostenv`. The OS43implementations live in `internal/app/hostenv/oshost`, so a host that is not44an operating system links none of them. The two host entries — `cmd/atlas` and45the desktop shell at the module root — are where the machine is *supposed* to46be reached, and are exempt by sitting outside the lane the rule is written47about; depcheck's scope carries the root package for the rest of its rules.4849### 1.1 `VolumeStore`5051```go52Volumes() []Volume // the serving build of each volume53Rescan() ([]string, error) // → the volumes whose serving build moved54Install(name string, content io.Reader) (Installed, error)55Location() string // a label: where the library lives56```5758The registry model is **scan at launch, rescan on import — never watch** (issue59#5 §2, decision 15). A file dropped into the library from outside appears at60the next launch. The fold that decides which build of a volume serves is61`format/bundle`'s and is pure — newest capture, then policy revision, then62stamp, then locator — so a host only ever has to produce descriptors.6364`Location()` is a label, not a path the handler takes apart: it is what the65catalog reports as `bundlesDir` and what the empty-library page shows.6667### 1.2 `SessionStore`6869```go70Load(name string) ([]byte, error) // hostenv.ErrNoSession when absent71Save(name string, data []byte) error72Delete(name string) error // a record that is not there is already gone73Names() ([]string, error)74```7576Bytes under a name. What the bytes mean is §3 of this document, which is what77lets a file, an OPFS handle, and a test's map all be the same store. Names are78held to `hostenv.ValidName` — lowercase, digits, `-`, `_`, `.`, no separators —79before they reach any store.8081`Delete` is the whole of the blunt reset (§4.3): the arrangement a volume opens82with is synthesized from the world itself, so forgetting the record *is* the83way back to it. Deleting a record the store does not hold is success, not84`ErrNoSession` — the caller wants the record gone, and a record that was never85there is gone.8687### 1.3 `PickFile`8889Returns the chosen file as a stream, the name it was chosen under, and an90error. Three answers are distinct and must stay so: a file, `ErrNoSelection`91(the reader cancelled), and `ErrNotAvailable` (this host has no picker). An92import that cannot happen and an import nobody wanted are different things to93say — and one of the two things to say is nothing. A host with no picker is94refused out loud, because a reader who pressed the button is owed a reason; a95reader who closed their own dialog is told nothing at all, and the row that96was standing by is taken away again (§4.1).9798### 1.4 The desktop shell99100The shell at the module root is host wiring and nothing else (issue #5 §3.4).101It resolves two directories, opens an `oshost` over them with a native picker102attached, and hands the handler to `wails.Run` as the asset server:103104```go105host, _ := oshost.New(oshost.Options{106 BundlesDir: library, SessionsDir: sessions, Pick: window.Pick,107})108wails.Run(&options.App{109 AssetServer: &assetserver.Options{Handler: followRedirects(handler)},110 OnStartup: window.Opened,111})112```113114Five things about it are worth knowing.115116**Where the library is.** `$ATLAS_DATA_DIR`, else `os.UserConfigDir()` +117`dev.felinestatemachine.atlas` — the identifier existing libraries already118live under, so a reader's library is found where it always was. `bundles/` under it119holds the volumes and `sessions/` the session records. `$ATLAS_BUNDLES_DIR`120moves the library alone, which is what a development run points at a freshly121composed `dist/bundles`. The headless host reads the same two variables122(`cmd/atlas/serve.go`).123124**The included Earth volume.** The shell embeds the committed bundle at125`included/` (`//go:embed included/*.atlas`) and installs it into the library126before the host is constructed, so the first scan already serves Earth and a127first launch opens onto a world instead of the empty-library card. The install128goes through `format/bundle`'s own path — validated, versioned, staged,129idempotent — so a second launch is a no-op and another Earth build in the130library stays beside it for the fold to order; a built-in that cannot be131installed is a startup error, never a silent absence. After installation the132embedded copy is done with: everything reads the installed file, exactly as if133the reader had imported it134([decision 19](decisions/0019-an-earth-volume-is-included.md);135`included/README.md` carries the provenance and the recipe). The headless host136installs nothing: `atlas serve` uses exactly the registry the operator gives137it.138139**Where the seam is.** `//go:embed static` — the root `static/` directory,140holding the seam's built bundle as `app.js`. `make static` puts the same bytes141in `dist/static`, which is what a `-static` mount is pointed at; the shell142embeds its copy, so the shipped application is one file with no sidecars. A143build that skipped `make static` embeds only that directory's README:144`/static/app.js` answers `404`, `<atlas-viewport>` renders nothing, and145everything else works. The stylesheet system is not there — it is146`internal/app/assets`, embedded by the application itself and served from147`/assets`, so deleting the seam costs a page one script tag, not its chrome.148149**Redirects are followed by the host.** A Wails page is served over a custom150URL scheme, and a scheme task has no way to express a redirect: WebKit hands151the `302` to the page as if it were a document, and the reader is looking at152the two words Go writes in a redirect body. `redirects.go` walks the153application's own doorways instead — GET only, `Location` values naming a path154only, five hops at most, streaming answers passed straight through — so the155handler goes on redirecting and `atlas serve` goes on serving those redirects156to clients that follow them157([decision 17](decisions/0017-the-host-follows-redirects.md)).158159**No Wails runtime JavaScript in the page.** Nothing is bound, no bindings160JSON is generated, and the page hears that the library moved over the161application's own SSE stream (§5). Wails' asset server injects its runtime into162a `200 text/html` answer at a path ending in `/`; this application answers `/`163with a redirect whenever it has a library at all, so the only page that can164ever carry the injection is the empty-library doorway, which uses none of it.165Every explorer page is at `/v/…` and is served exactly as the headless host166serves it.167168The build is a plain `go build -tags "desktop,production"`, not `wails build`:169the Wails CLI's job is scaffolding and driving a Vite frontend, and this tree170serves its own pages — which is also why there is no `wails.json`. `make171desktop` is the macOS recipe, and `.github/workflows/release.yml` carries all172three platforms.173174---175176## 2. Routes177178### 2.1 The data plane179180```181GET /data/catalog.json no-store182GET /data/v/{slug}/{stamp12}/{worlds|tiles|icons}/... private, max-age=31536000, immutable183```184185The seam reads this plane, so its shape is contract rather than the186application's to reshape; the browser suite serves it over the committed187corpus.188189**The catalog** is composed at the moment it is asked for and never cached:190191```json192{"volumes":[{"slug":"…","title":"…","stamp":"<64 hex>","base":"/data/v/<slug>/<stamp12>",193 "tileGrid":{…},"worlds":[…]}],194 "bundlesDir":"…"}195```196197Volumes are listed **by title**. `worlds` is the manifest's world list198verbatim. `base` carries the twelve-hex short stamp, which is the whole cache199story: the URL names exactly one build, so everything under it is immutable,200and the moment a newer build takes the slug over the old URL is a 404 — which201is the client's cue to refetch the catalog.202203**Content** is served out of the archive with a `Content-Length`, a204`Content-Type` from a fixed extension table (`.json .text .bin .jpg .png205.webp .svg`), and nothing else. Everything not admitted by that table, not206under `worlds/`, `tiles/` or `icons/`, or not carrying the serving stamp, is a207plain `404`.208209**Byte ranges are not served.** Tiles are stored uncompressed so that they210could be, but a `Range` request is answered `200` with the whole body and no211`Accept-Ranges`. Changing that is a deliberate act, not a drive-by:212`TestContentDoesNotServeRanges` (`internal/app`) is what keeps it from213happening by accident.214215### 2.2 The app plane216217| Route | Answers |218|---|---|219| `GET /` | 302 to the last volume's world; the library card when nothing is installed. |220| `GET /open?volume=&world=` | 302 to the explorer page those two name. The doorway the topbar's two selects go through. |221| `GET /v/{volume}/{world}` | The whole explorer page, server rendered, in its remembered state. Also records that this is where the reader is. |222| `GET /fragments/detail/{id}?volume=` | One feature's card. |223| `POST /session/{concern}` | The partial set for the regions that concern touches (§4). |224| `POST /session/reset` | Deletes this volume's record and answers `204` with `HX-Redirect` back into the volume (§4.3). The one concern whose answer is a page. |225| `POST /bundles/import` | One progress row, streamed through its states (§4.1); picks, installs, rescans, announces. |226| `GET /events?volume=` | The SSE stream (§5). |227| `GET /assets/{app.css,htmx.js}` | The application's own chrome, out of the binary. |228| `GET /static/{path...}` | Whatever static tree the host mounted; `404` when it mounted none. |229230Every world lives at a real URL: it can be bookmarked, reloaded, and linked231to. `/` is a doorway, not a second name for the explorer.232233A `GET` of an explorer page writes the session's `world` and the last-volume234pointer. That is deliberate: arriving at a URL is a choice whether it was235clicked, typed, or restored by the browser, and the session follows the236address bar rather than the other way round. Arriving on a *different* world237also clears what belonged to the old one — the selection, the highlights, the238search — and re-derives the arrangement (§3.1).239240**`/open` exists because a `<select>` cannot build a path out of its own241value.** The volume and the world are both in the address, so the two crumbs242name what they want and the server redirects to it; after the redirect the243reader is at the real URL, which is the only place the explorer lives. It is244one route rather than client-side glue, which is the same trade the rest of245this document makes.246247**`/assets` is a different mount from `/static`, and the difference is the248deletability principle in the URL space.** `/static` is whatever tree a host249handed over — the seam's built bundle, which `make static` puts where a250`-static` mount wants it ([render-seam.md](render-seam.md) §9) — and answers251`404` when a host handed over nothing. `/assets` is the part of the page that is the252application's own: the stylesheet system and the vendored hypermedia runtime,253compiled into the binary (`internal/app/assets`). A build with no seam serves254a complete, styled, interactive page.255256Both assets are vendored rather than linked. The offline invariant is not only257about bundles: an atlas opens on a machine with no network, forever, and a258page that fetches its runtime from a CDN is a page that does not.259260---261262## 3. The session record263264**The state-placement rule (issue #5 §4.1):** discrete application state lives265on the server and in URLs; continuous interaction state lives in the seam. The266camera is the single, deliberate exception — it reports upward once per settle,267so a volume reopens where the reader left it.268269One record per volume, named `volume.<slug>.json`, plus `app.json` holding the270last volume. `SessionSchema` versions the shape; a record written under a271schema this build does not know is passed over rather than half-read, which272costs a reader their layout once and never corrupts it.273274```json275{276 "schema": 2,277 "volume": "bend-or",278 "stamp": "3610a0f10798",279 "world": "2026-08-02",280 "lens": "Basemap",281 "hidden": ["1496244488"],282 "collapsed": ["zones"],283 "expanded": ["1951802496", "253393030"],284 "highlighted": ["277390785"],285 "arranged": true,286 "labels": {"39191589": "quiet"},287 "search": "mill",288 "dock": {"open": true, "dismissed": false, "section": "counts"},289 "detail": {"open": true},290 "grid": {"system": "geohash", "cell": "9q5c", "subgrid": 2},291 "sidebar": {"collapsed": false},292 "overview": {"docked": false},293 "selected": "277390785",294 "cameras": {"2026-08-02": {"x": 4096, "y": -4096, "zoom": 1.34, "rotation": 0, "at": "2026-08-02T09:12:44Z"}},295 "updatedAt": "2026-08-02T09:12:44Z"296}297```298299Notes that are contract, not style:300301- `hidden`, `collapsed`, `expanded` and `highlighted` are **sorted sets**, so a302 record is stable to diff and two paths to the same state produce the same303 bytes. They are stored as strings — collection and feature ids ride the DOM304 as strings — and the island writes them back as the numbers the payload305 declares (§6).306- `lens` is the lens's **name**, not its index. A name outlives a build's307 ordering; the island's `lens` key is the ordinal (§6).308- `focused` is the ground the reader last *went to* from a list, and it is not309 `selected`: closing the card puts the selection down and leaves the feature310 index still marking where the reader has been. It is set only by a row that311 says `focus=1` — a feature index row, a panel row, a link inside a card —312 because a pick off the canvas is a reader who was already there, and it is313 cleared when the ground itself is rebuilt: a world opened, a split sheet's314 layer swapped, the volume come back to from another one.315- `sidebar` and `overview` are spelled as the thing a reader *does* to them —316 collapsed, docked — so the zero value is the ordinary page.317- `dock.dismissed` is the reader having folded the panel by hand. Until they318 have, the panel comes out on its own the first time it has something to say;319 after that it stays where they put it.320- `stamp` is the serving build the record was last written against. A record321 behind the serving build is still read — slugs outlive builds — and the322 difference is what a stamp-move refresh is about.323- `cameras` is keyed by world slug, because a camera belongs to a ground. The324 server stores it and hands it back. It never reasons about it.325326**Schema 2** is the templates wave. It gained `expanded`, `highlighted`,327`arranged` and `overview`, renamed `sidebar.open` to `sidebar.collapsed`, and328lost `solo`: isolating turned out to be a move on the hide set rather than a329state of its own, and the chip is derived from what is hidden so that it is330right however that set was reached — including by switching rows off one at a331time.332333### 3.1 The arrangement a world opens with334335Three of the sets have non-empty defaults that the *world* supplies, not the336reader:337338| Set | Default | Why |339|---|---|---|340| `hidden` | every collection the payload marks `visible: false` | the producer's own curation |341| `collapsed` | `["zones"]` | shape collections are a navigation aid, not the primary filter surface, so their section folds and pin groups stay above the fold |342| `expanded` | every ungrouped shape collection | their feature indexes are there the moment the section is opened, and in the DOM for anything reaching for a feature without unfolding first |343344`arranged` is what tells a fresh record from an arranged one. Without it an345empty hide set — a reader who asked to see everything — is the same bytes as a346record nobody has touched, and the next request would put the curation back.347348A record can also go away entirely. `POST /session/reset` (§4.3), which ⌘R349presses, deletes it; the next load reads nothing and builds the table above out350of the world again. It is the one operation on the record that is not a move on351a field, and that is the point: a record that has gone wrong is not something a352reader should have to diagnose a field at a time.353354---355356## 4. Regions, templates, and the partial envelope357358### 4.1 The regions359360Eleven regions, one template file each (`internal/app/templates/<region>.tmpl`),361mirroring the one-file-per-region stylesheet system in362`internal/app/assets/css`. A region's template renders that region's own363container, so a first paint and a swap produce the same bytes.364365| Region | Element | Swap | What it is |366|---|---|---|---|367| `shell` | `#atlas-shell` | `outerMorph` | the whole page frame |368| `topbar` | `#atlas-topbar` | `innerMorph` | volume, world, lens, import |369| `legend` | `#atlas-legend` | `outerMorph` | search field, toolbar, tree, footer count |370| `dock` | `#atlas-dock` | `outerMorph` | count, flag, shortlist, and the card inside it |371| `detail` | `#atlas-detail` | `outerMorph` | one feature's card |372| `grid-navigator` | `#atlas-grid-navigator` | `outerMorph` | cell system, held cell, subgrid |373| `overview` | `#atlas-overview` | `outerMorph` | the corner locator's chrome |374| `viewport` | `#atlas-viewport-state` | `outerMorph` | the inert state node the seam reads |375| `island` | `#atlas-session-island` | `outerHTML` | the session record the seam reads, as JSON |376| `empty-state` | `#atlas-shell` | `innerMorph` | the library card |377| `import` | `#atlas-import` | `outerMorph` | the import that is happening, as one row |378379**Nearly every region is an *outer* morph**, and that is the reading that380matches what the templates render: the element is morphed onto itself, which381keeps the first-paint bytes and the swap bytes identical and is the only swap382that can set an attribute on the region's own container. Three states are383spelled exactly that way — the sidebar's collapsed class on the shell, the384navigator's `hidden`, the detail card's `hidden`. The exceptions each have a385reason: the topbar's container carries nothing a swap moves; the island is a386`<script>` node with nothing inside worth preserving, so it is replaced rather387than morphed, because morphing one leaves its text where it was.388`internal/app/partials.go` is the table this one mirrors.389390**An import is one row, not a log.** The response is a stream — a hundred391megabytes takes long enough that silence reads as failure — but it is a stream392of *states of the same row*: the handler renders the whole region again, and393flushes it, for every state a run reaches, and the morph carries one row from394"choosing a bundle" to "installed" in place. The row's element id carries the395run's number, so a second import is a different element rather than the first396one's leftovers. Three things follow, and they are the point of the397arrangement: a cancelled picker is **silent** (its last render holds no row at398all, and the section renders itself `hidden`), a run that ended well marks its399last row and the stylesheet fades it out on a delay, and a refusal stays until400the next import replaces it — it is the only account of what went wrong, and401nothing else on the page will say it. An append swap, which is what this was,402made every state of every run a line of its own that outlived the run, the403import, and everything short of a full page render.404405**The legend is one region because it is one answer.** Every filtering move406changes the rows, the isolate chip and the footer count together; three407regions would be three ways for them to disagree.408409**The card is nested inside the dock and is still its own region.** That is410where it belongs on screen, and `select` moves the card and the list together,411so a dock render carries the card with it and the two cannot disagree about412what is open.413414`viewport.tmpl` defines two templates. `viewport` is the partial — the state415node alone, because that node is what an interaction moves. `viewport-surface`416is the shell's alone and is never swapped.417418### 4.2 The envelope419420Every `/session/*` POST answers with an `<hx-partial>` set covering **exactly**421the regions that interaction touches — never a page-wide refresh standing in422for knowing what moved.423424```html425<hx-partial hx-target="#atlas-legend" hx-swap="innerMorph">…</hx-partial>426<hx-partial hx-target="#atlas-dock" hx-swap="innerMorph">…</hx-partial>427```428429The target and swap are spelled `hx-target` and `hx-swap`: htmx 4 reads an430`<hx-partial>` through the same attribute vocabulary it reads everything else431through, and an unprefixed `target` is silently no target at all. The region432names and the element ids either side of it are the same as they ever were.433434Morph swaps are what let scroll position, focus, and open `<details>` survive a435re-render. **The seam's surfaces are morph-skipped**: `<atlas-viewport>`, the436map pane and the overview's canvas holder carry `hx-morph-skip-children`, htmx4374's own spelling of "touch my attributes, never my internals". Tearing down a438WebGL context mid-gesture is not a re-render.439440### 4.3 The concern table441442One route per concern, each declaring the regions its answer covers. Reading443the regions column downward is the fastest way to see what the application444thinks is coupled to what.445446| `POST /session/…` | Fields | Regions |447|---|---|---|448| `world` | `world` | topbar, legend, dock, overview, viewport |449| `lens` | `lens` (the lens's name) | topbar, legend, dock, overview, viewport |450| `collections` | `collection`+`visible`, or `section`, or `all`=`show`\|`hide`, or `hidden` (repeated) | legend, dock, viewport |451| `sections` | `section`+`open`, or `all`=`fold`\|`unfold` | legend |452| `expand` | `collection`+`open`, or `all`=`fold`\|`unfold` | legend |453| `labels` | `collection` + `flip`, or `collection`+`policy` (empty clears) | legend, viewport |454| `solo` | `collection` or `section`; neither means show everything | legend, dock, viewport |455| `search` | `q` | legend, dock, viewport |456| `highlight` | `feature` (+ optional `on`, or `only`=`1`), or `all`=`clear` | legend, dock, viewport |457| `dock` | `open`, `byHand`, `section` | dock |458| `select` | `feature` (empty closes), `focus` | legend, dock, detail, viewport |459| `grid` | `system`, `cell`, `subgrid` | grid-navigator, dock, viewport |460| `overview` | `docked` | overview |461| `sidebar` | `open` | shell |462| `view` | `world`, `x`, `y`, `zoom`, `rotation` | — answers `204` |463| `reset` | — | — answers `204` and `HX-Redirect` back into the volume |464465Every request carries `volume`, declared once on the shell as466`hx-vals:inherited`. A volume that is not installed is a `404`; a malformed467slug or a missing required field is a `400`.468469`view` is the camera report: the seam's one debounced upward whisper, answered470with `204 No Content`, because swapping anything in response to a settling471camera would fight the reader's own hand. The other upward flow is the pick:472the seam resolves a canvas hit and submits the *identity* through an ordinary473`POST /session/select`.474475**`reset` is the one concern that answers with a page**, and it earns the476exception by deleting the record every other answer is rendered from. It is the477blunt reset behind ⌘R (§4.5): the volume's `volume.<slug>.json` goes whole —478what is hidden, folded, unfolded, highlighted and labelled, the search, the479panel, the card, the grid, the index, the corner locator, every world's camera480— and the next load reads no record at all, so §3.1's arrangement is481synthesized fresh. Nothing here writes defaults; forgetting *is* the reset.482483The last-volume pointer is deliberately **not** touched: the reader is not484leaving the volume, they are standing in it with nothing remembered, so485`app.json` stays and the answer sends them back to `/v/{volume}/{world}` — the486world read out of the record before it goes, the volume's first world when the487record named none, and `/` when the volume has no world to name. It is spelled488as the `HX-Redirect` header htmx acts on itself, because a `303` would be489followed by `fetch` and swapped into a page that asked for no swap, which is a490reset that appears to do nothing at all.491492**`labels` is spelled as a flip, not a destination.** The policy turns over,493and if the other word is what the producer curated anyway the override has494nothing left to say and is dropped rather than stored. That is what keeps a495ladder turned over and back from leaving overrides behind it — and it is why496the route needs the world, not just the id.497498### 4.4 Template rules499500- **One file per region**, named for the region.501- **No `hx-on`.** Every interaction is an `hx-*` attribute naming a route. The502 only glue on the page is the seam's boot module.503- **Explicit inheritance.** `hx-vals:inherited` and `hx-swap:inherited` are504 declared once, on the shell, and nothing else inherits.505- **Templates render; they do not decide.** Every display decision runs in Go506 first (§7). A template that needs an `if` about what a collection *means* is507 a decision in the wrong place.508- **`hx-*` lives in templates only.** The routes, the region names, the partial509 envelope and the viewport state node are all framework-neutral, so replacing510 HTMX would be an afternoon in one directory rather than an architecture511 event.512513### 4.5 The keyboard514515Every shortcut that moves discrete state is a route rather than a handler: an516`hx-trigger` naming the key and the element it is heard on, declared once in517`shell.tmpl` — the one region a swap never replaces from underneath — and518nothing else. There is no `hx-on` on the page and no listener behind it.519520| Key | Route | Does |521|---|---|---|522| `⌘G` | `grid` | cycles which cell system divides the map — **above the editable guard** |523| `⌘R` | `reset` | throws this volume's remembered state away and reloads it fresh — **above the editable guard** |524| `⌘⌥B` | `dock` | folds the panel beside the map, and unfolds it; two spans, each asking the fold button which way it is pointing |525| `⌘B` | `sidebar` | puts the index away |526| `G` | `grid` | opens the grid and closes it |527| `Space` | `grid` | divides the chosen cell, or stops dividing it; also from inside the address field |528| `Escape` (on `#map`) | `select` | closes the card |529| `Escape` (on the window) | `grid` | telescopes the grid out one level, then out of the grid |530531Every filter says three things, and the page was broken for want of all three:532**whose keystroke it is** (`kbd-editable`, so a reader typing "g" into the533search field does not put a grid up); **that the key is answered** (`prevent`, which htmx runs only when the534filter passed, so a shortcut swallows the keystroke it acts on and nothing535else); and **that the key was pressed rather than held** (`!event.repeat`, so an536autorepeating key is not sixty writes a second down a route that writes a file).537538Two shortcuts stand **above** the guard. ⌘G539because switching cell systems is most wanted from inside the token field; ⌘R540because a reader whose page is misbehaving reaches for reload from wherever the541cursor happens to be. ⌘R is also the one place `prevent` takes something away542and hands it straight back: the browser's own reload is suppressed, and the543route answers with the same page fetched again over a record that is now gone.544545The other half of the keyboard — holding `Z` for labels, `⌘K` to focus the546search, flipping panes, stepping the zoom, swallowing the webview's context547menu — is **not here and must not be**: none of it moves discrete state, and548one of them cannot be a request at all ([render-seam.md](render-seam.md)).549550---551552## 5. The events stream553554`GET /events?volume={slug}` is Server-Sent Events. The connection opens with a555`: atlas` comment, so a client knows it is live rather than merely accepted.556Two event names, and no more:557558```559event: catalog560data: <hx-partial hx-target="#atlas-topbar" hx-swap="innerMorph">…</hx-partial>561562event: refresh563data: <hx-partial hx-target="#atlas-shell" hx-swap="innerMorph" hx-get="/v/tunic/world"></hx-partial>564```565566- **`catalog`** — the library's composition moved. Carries the regions that567 list it. Sent to every connection.568- **`refresh`** — the one directive. The volume this connection is watching now569 serves a different build, every URL under its old stamp is gone, and the page570 has to be fetched whole rather than patched. Sent **only** to connections571 watching that volume.572573The page takes the plainer road of re-fetching its own URL on either event: a574page that re-reads where it is cannot end up half-patched, and both events mean575the library moved under it. The bodies stay in the contract for a client that576wants the cheaper path.577578There is no event for "a file appeared in the library directory", because579nothing watches it. An import is what triggers a rescan, and the rescan is what580produces these events.581582---583584## 6. The state island585586The explorer page carries the session it was rendered from as an inert JSON587script node:588589```html590<script type="application/json" id="atlas-session-island">{…}</script>591```592593It exists for one reason: the application's account of the arrangement has to594be diffable against the seam's, key for key, without either half guessing at595the other. **The key names below are the contract** (`internal/app/island.go`596is their home). Where the arrangement is stored is this application's597business; what the island says about it is not.598599```json600{"last": "bend-or",601 "entry": {"volume": "bend-or", "world": "2026-08-02", "lens": 0,602 "center": [4096, -4096], "zoom": 1.34,603 "hidden": [], "collapsed": ["zones"],604 "expanded": [39191589, 50985093, …],605 "labels": ["39191589=quiet"],606 "overviewDocked": false, "dockFolded": true, "dockDismissed": false}}607```608609Shape notes: ids are emitted as the numbers a payload declares them as but610sorted as the strings they ride the DOM as;611`labels` is the override ledger, one `"<collection>=<policy>"` per entry,612sorted; `lens` is the index; `center` is rounded to whole world units and613`zoom` to three decimals, so two accounts of one camera are diffable without a614normalizer in between.615616`tests/island` is the suite. It drives session POST sequences through the617application's own HTTP surface, over the committed corpus — the legend, solo,618search, label-policy, lens, grid and overview concerns — and holds the island619to its contract key for key, in both directions: a key the contract promises620and the island lacks is a hole, and a key the island invents is an invention.621622### 6.1 What is seam-side, and why623624Two of the twelve keys **cannot be produced server-side**, and are not forced625to be:626627- **`center`** and **`zoom`** are the chart's camera. They are arrived at by628 fitting a raster to a window and depend on the viewport's size, the lens's629 own depth, and the fit the seam computes. No amount of session state produces630 them. The server's only honest relationship with them is the one issue #5631 §4.1 describes: the seam reports a settled camera upward, debounced, at632 `POST /session/view`, and the server stores it and hands it back. Until the633 seam has reported one they are `null`. The island suite posts a camera and634 then checks the round trip, so what is proved about these two is the echo,635 not the origin.636637**The grid cull is not one of them, and was for a while.** A held cell narrows638what stands exactly the way a highlight does, and the count above the panel has639to be the count the map is drawing — so the server has to answer "is this640feature inside the held cell" without asking the seam, which the third upward641flow §5 of [render-seam.md](render-seam.md) forbids would be the only other642way. `internal/app/cells` is the smallest piece of one system's arithmetic that643answers it: the recursive halving and nothing else, gated against the shared644cell vectors (`analysis/testdata/cells/`). The cost — a second copy of arithmetic645`analysis/cellsystems` also holds — is written down in that package's own646comment, and the file is the first thing a Go twin of the analysis lane would647delete.648649One thing still belongs to a lane, and is recorded here rather than papered650over:651652- **The footer's "in view" half.** The footer's full sentence is653 "N of M features in view", and N is the count inside the camera's extent —654 seam-side by construction. The server renders M; refining it to the full655 sentence is the seam's, once there is a camera to ask.656657---658659## 7. Display logic660661All of it runs in Go, once, before a template is handed anything (issue #5662§4.5). It lives in four files beside the handler, and reads the conventions663only through `format/semconv` — an `atlas.*` string literal outside the664registry is a depcheck failure, not a review comment.665666| File | What it decides |667|---|---|668| `world.go` | the payload model: collections in order, points unpacked from `ATLASLOC`, shape rings projected onto the world square, the parent chain, containment |669| `legend.go` | the tree — sections, rows, counts, the label ladder, the feature index order, isolating, the solo chip, the opening arrangement |670| `filter.go` | what stands: the hide set, the search, the AND-across/OR-within highlight filter, the shard, the counts and the words above them |671| `view.go` | the card, the dock, the grid and overview chrome, the viewport state node |672673Three rules worth stating because they are easy to get subtly wrong:6746751. **Highlighting reads AND across collections, OR within one.** Two districts676 highlighted widens the question; a district and a subwatershed narrows it to677 the ground they share. The corpus city is the test bed: nine shape678 collections, and highlighting one boundary plus one waterbody takes the679 drawn count from 213 to 148, because none of the 65 annotated places stands680 in both. Two answers are exempt from the cull — the feature the reader has681 open, and one they are searching for by name — because both were asked for682 rather than merely drawn.6832. **A search narrows points and never the ground.** Searching for a place has684 never taken the ground out from under it, so a shape answers the search in685 the list and not on the canvas.6863. **Containment is boundary-inclusive with a pixel of grace.** A pin dropped687 on a zone's border was put there to mean the zone, and exact point-in-polygon688 arithmetic would flip it out over the width of the line it stands on.689690**One zone, exclusively, is a fourth way to ask.** Isolating a collection or a691section answers the different and heavier question of putting every other692collection away; `only`=`1` names one feature and makes the highlight set693exactly that feature (`session.go`, `applyHighlight`). It is a move on the694set rather than a state of its own — the same shape as isolating, and derived695the same way, so a zone that is alone *by any route* wears the pressed control696and pressing it gives every highlight back. The ride-along holds: an697exclusive highlight still brings its own collection out of hiding, because698asking to look at a piece of ground and keeping it put away cannot both be699meant.700701The label ladder's one convention rule lives in the registry rather than in a702viewer: `semconv.LabelPolicy` gives a point collection curated as703`atlas.render.as = text` a speaking default, because floating names are labels704a producer pinned on rather than a different kind of thing to draw.705706### 7.1 The stylesheet system707708`internal/app/assets/css` is a token-first, one-file-per-region system,709carried as an asset (issue #5 §9). Everything this application's markup asks710of it beyond the carried files lives in one file, `chrome.css`: the region711containers carry ids of their own, the seam's custom elements need somewhere712to stand, and the import row — including the delayed fade that takes it away713when a run ended well, which is an animation and not a script. The detail card714is an outerMorph region (§4.1) and renders its own `hidden`, so715`pin-detail.css` works verbatim. The visual identity is neutral dark chrome,716palette as accents.717718---719720## 8. The development loop721722```sh723atlas dev # 127.0.0.1:7433, chrome read from the working copy724atlas dev -bundles DIR -root . # against another library725atlas dev -seam-watch # and the seam's bundler beside it726```727728`atlas dev` is `atlas serve` with two additions and no third. Templates and729stylesheets are read from the working copy instead of the binary and re-parsed730the moment a file is written, so a template edit is one refresh away rather731than one rebuild away; writes are coalesced over 60 ms, because editors write a732file three times per save. A tree that will not parse is reported and the last733set that parsed keeps serving — an unbalanced `{{if}}` halfway through an edit734is a normal thing to type, and taking the page down over it would make the loop735worse than a rebuild.736737**The watching lives in `cmd/`, and nowhere else.** `internal/app` is one pure738`http.Handler` that touches no filesystem; what it exposes is739`templates.Reload(fs.FS)` and `assets.Reload(fs.FS)`, and building an `fs.FS`740out of a directory is the command's business. That is the hostenv rule741surviving having a development loop.742743`-seam-watch` runs `npm run watch` in `render/` beside the server, streaming744the bundler's output into the same event stream. A tree with no745`render/package.json` — the deletability principle exercised — is told so at746`info` and the server carries on, because a missing seam is a served page with747one script tag fewer, not a failed run.748749---750751## 9. Logging752753`internal/logging`, per [`logging.md`](logging.md). The application speaks754`op=` values `scan`, `serve`, `install`, `session`, `events`, `catalog`,755`render`, and uses `volume`, `world`, `stamp` and `path` from the shared756vocabulary. `atlas serve` and `atlas dev` print their address to **stdout** — a757script starts the host and reads where it landed — and everything else to the758stream on stderr.759760---761762## 10. The pragmatism clause763764Issue #5 §4.3: HTMX ownership is a means, not a loyalty test. If an interaction765proves laggy or awkward as a round trip, it is re-classified by editing the766issue's interaction inventory — never patched with ad-hoc client code. Three767things are worth watching, recorded here so the edit has evidence behind it768rather than a feeling:7697701. **Search-as-you-type.** Debounced 150 ms on the client attribute and771 answered with three region partials. On the city (213 features) and the772 games (up to 1,400) the round trip is comfortable; on a volume an order of773 magnitude larger the legend partial is the expensive half, because it774 re-renders every row to move one count. If it ever bites, the fix inside775 this architecture is a narrower region — a `legend-count` region — not a776 client-side filter.7772. **The feature index.** A shape row's index is rendered whether or not the778 row is unfolded, which is what779 keeps a feature reachable by name without unfolding first. It is also the780 largest thing in the legend partial.7813. **The grid navigator's text field.** Typing a cell address round-trips per782 keystroke like the search. It is a short string and a cheap region, but it783 is the interaction most likely to feel like typing through a straw, and it784 is the first candidate for the hybrid bucket if it does.785786None of the three has been re-classified. They are listed because the clause is787worth nothing without a habit of writing down what it would apply to.788789---790791## 11. What is still named rather than built792793Named here so a stub is never mistaken for a decision — and so that a wave794which lands moves out of the list rather than sitting in it as folklore.795796- **The WASM service-worker host.** §1's third row. Nothing is built for it;797 the hostenv discipline is what keeps it reachable.798- **The footer's "in view" half.** §6.1.799800The seam's own list of what it has not proven is `docs/render-seam.md` §10, and801it is the one to read for anything below the `<atlas-viewport>` boundary.