clone

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

feat/atlas-next-horizon
atlas / docs / app.md
app.md 43.5K source

The application

Status: built. This document specifies the hypermedia application: the URL surface, the session record, the partial and event envelopes, the region and template system, the state island, and the contract between the handler and whatever host it is mounted in. §11 is the standing list of what is named rather than built, so nobody mistakes a placeholder for a decision.

The implementation is internal/app, its host seam is internal/app/hostenv, and the two hosts are the desktop shell at the module root (main.go) and atlas serve (cmd/atlas/serve.go). Where this document and the code disagree, take it as a defect in one of them and say so.


1. One handler, three hosts

The application is one pure http.Handler. It touches no filesystem, builds no path, opens no dialog, and reads no environment. Everything OS-shaped arrives through three small interfaces:

type Hostenv interface {
    Volumes()  VolumeStore
    Sessions() SessionStore
    PickFile(ctx context.Context) (io.ReadCloser, string, error)
}

That is the whole host contract. It exists so the same handler can be mounted by three different hosts:

Host Status What it supplies
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.
atlas serve built Plain HTTP, no window. PickFile refuses with ErrNotAvailable. The dev loop, CI, and the browser suite (tests/e2e).
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.

Native authoring is a separate, optional capability rather than a fourth filesystem method on Hostenv. The desktop supplies minting.Minter; the headless and future WASM hosts omit it. When it is absent the creator and its routes are absent together. When it is present, the application still sees only a compact request, a bounded preview, build events, and the installed volume identity—never a path, cache, or network client.

The rule is enforced mechanically: tools/depcheck's hostenv analyzer fails any import of os, os/exec, path/filepath, syscall, or a window toolkit from internal/app outside internal/app/hostenv. The OS implementations live in internal/app/hostenv/oshost, so a host that is not an operating system links none of them. The two host entries — cmd/atlas and the desktop shell at the module root — are where the machine is supposed to be reached, and are exempt by sitting outside the lane the rule is written about; depcheck's scope carries the root package for the rest of its rules.

1.1 VolumeStore

Volumes() []Volume                              // the serving build of each volume
Rescan() ([]string, error)                      // → the volumes whose serving build moved
Install(name string, content io.Reader) (Installed, error)
Location() string                               // a label: where the library lives

The registry model is scan at launch, rescan on import — never watch (issue #5 §2, decision 15). A file dropped into the library from outside appears at the next launch. The fold that decides which build of a volume serves is format/vnext's and is pure — newest capture, then policy revision, then stamp, then locator — so a host only ever has to produce descriptors.

Location() is a label, not a path the handler takes apart: it is what the catalog reports as bundlesDir and what the empty-library page shows.

1.2 SessionStore

Load(name string) ([]byte, error)   // hostenv.ErrNoSession when absent
Save(name string, data []byte) error
Delete(name string) error           // a record that is not there is already gone
Names() ([]string, error)

Bytes under a name. What the bytes mean is §3 of this document, which is what lets a file, an OPFS handle, and a test's map all be the same store. Names are held to hostenv.ValidName — lowercase, digits, -, _, ., no separators — before they reach any store.

Delete is the whole of the blunt reset (§4.3): the arrangement a volume opens with is synthesized from the world itself, so forgetting the record is the way back to it. Deleting a record the store does not hold is success, not ErrNoSession — the caller wants the record gone, and a record that was never there is gone.

1.3 PickFile

Returns the chosen file as a stream, the name it was chosen under, and an error. Three answers are distinct and must stay so: a file, ErrNoSelection (the reader cancelled), and ErrNotAvailable (this host has no picker). An import that cannot happen and an import nobody wanted are different things to say — and one of the two things to say is nothing. A host with no picker is refused out loud, because a reader who pressed the button is owed a reason; a reader who closed their own dialog is told nothing at all, and the row that was standing by is taken away again (§4.1).

1.4 The desktop shell

The shell at the module root is host wiring and nothing else (issue #5 §3.4). It resolves two directories, opens an oshost over them with a native picker attached, and hands the handler to wails.Run as the asset server:

host, _ := oshost.New(oshost.Options{
    BundlesDir: library, SessionsDir: sessions, Pick: window.Pick,
})
wails.Run(&options.App{
    AssetServer: &assetserver.Options{Handler: followRedirects(handler)},
    OnStartup:   window.Opened,
})

Five things about it are worth knowing.

Where the library is. $ATLAS_DATA_DIR, else os.UserConfigDir() + dev.felinestatemachine.atlas — the identifier existing libraries already live under, so a reader's library is found where it always was. bundles/ under it holds the volumes and sessions/ the session records. $ATLAS_BUNDLES_DIR moves the library alone, which is what a development run points at a freshly composed dist/bundles. The headless host reads the same two variables (cmd/atlas/serve.go).

The desktop also owns recipes/ and cache/ beneath that data directory. Earth authoring writes exactly one portable .atlas-project manifest per minted volume into recipes/, shares captured evidence through cache/, and publishes the validated result into bundles/. Those paths never cross into the handler. Workbench remains an optional advanced manifest/receipt inspector; it is not required for the first-run creator.

The included Earth volume. The shell embeds the committed bundle at included/ (//go:embed included/*.atlas) and installs it into the library before the host is constructed, so the first scan already serves Earth and a first launch opens onto a world instead of the empty-library card. The install goes through format/vnext's own path — validated, versioned, staged, idempotent — so a second launch is a no-op and another Earth build in the library stays beside it for the fold to order; a built-in that cannot be installed is a startup error, never a silent absence. After installation the embedded copy is done with: everything reads the installed file, exactly as if the reader had imported it (decision 19; included/README.md carries the provenance and the recipe). The headless host installs nothing: atlas serve uses exactly the registry the operator gives it.

Where the seam is. //go:embed static — the root static/ directory, holding the seam's built bundle as app.js. make static puts the same bytes in dist/static, which is what a -static mount is pointed at; the shell embeds its copy, so the shipped application is one file with no sidecars. A build that skipped make static embeds only that directory's README: /static/app.js answers 404, <atlas-viewport> renders nothing, and everything else works. The stylesheet system is not there — it is internal/app/assets, embedded by the application itself and served from /assets, so deleting the seam costs a page one script tag, not its chrome.

Redirects are followed by the host. A Wails page is served over a custom URL scheme, and a scheme task has no way to express a redirect: WebKit hands the 302 to the page as if it were a document, and the reader is looking at the two words Go writes in a redirect body. redirects.go walks the application's own doorways instead — GET only, Location values naming a path only, five hops at most, streaming answers passed straight through — so the handler goes on redirecting and atlas serve goes on serving those redirects to clients that follow them (decision 17).

No Wails runtime JavaScript in the page. Nothing is bound, no bindings JSON is generated, and the page hears that the library moved over the application's own SSE stream (§5). Wails' asset server injects its runtime into a 200 text/html answer at a path ending in /; this application answers / with a redirect whenever it has a library at all, so the only page that can ever carry the injection is the empty-library doorway, which uses none of it. Every explorer page is at /v/… and is served exactly as the headless host serves it.

The build is a plain go build -tags "desktop,production", not wails build: the Wails CLI's job is scaffolding and driving a Vite frontend, and this tree serves its own pages — which is also why there is no wails.json. make desktop is the macOS recipe, and .github/workflows/release.yml carries all three platforms.


2. Routes

2.1 The data plane

GET /data/catalog.json                                  no-store
GET /data/v/{slug}/{stamp12}/{worlds|tiles|icons}/...   private, max-age=31536000, immutable

The seam reads this plane, so its shape is contract rather than the application's to reshape; the browser suite serves it over the committed corpus.

The catalog is composed at the moment it is asked for and never cached:

{"volumes":[{"slug":"…","title":"…","stamp":"<64 hex>","base":"/data/v/<slug>/<stamp12>",
             "tileGrid":{…},"worlds":[…]}],
 "bundlesDir":"…"}

Volumes are listed by title. worlds is the manifest's world list verbatim. base carries the twelve-hex short stamp, which is the whole cache story: the URL names exactly one build, so everything under it is immutable, and the moment a newer build takes the slug over the old URL is a 404 — which is the client's cue to refetch the catalog.

Content is served out of the archive with a Content-Length, a Content-Type from a fixed extension table (.json .text .bin .jpg .png .webp .svg), and nothing else. Everything not admitted by that table, not under worlds/, tiles/ or icons/, or not carrying the serving stamp, is a plain 404.

Byte ranges are not served. Tiles are stored uncompressed so that they could be, but a Range request is answered 200 with the whole body and no Accept-Ranges. Changing that is a deliberate act, not a drive-by: TestContentDoesNotServeRanges (internal/app) is what keeps it from happening by accident.

2.2 The app plane

Route Answers
GET / 302 to the last volume's world; the library card when nothing is installed.
GET /open?volume=&world= 302 to the explorer page those two name. The doorway the topbar's two selects go through.
GET /v/{volume}/{world} The whole explorer page, server rendered, in its remembered state. Also records that this is where the reader is.
GET /fragments/detail/{id}?volume= One feature's card.
POST /session/{concern} The partial set for the regions that concern touches (§4).
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.
POST /bundles/import One progress row, streamed through its states (§4.1); picks, installs, rescans, announces.
POST /mint/preview The exact bounded plan for the creator's current WGS84 zone, sources, detail and presentation. Desktop capability only.
POST /mint Persists the app-managed manifest, captures/builds/validates/installs, rescans, then redirects into the minted volume. Desktop capability only.
GET /events?volume= The SSE stream (§5).
GET /assets/{app.css,htmx.js} The application's own chrome, out of the binary.
GET /static/{path...} Whatever static tree the host mounted; 404 when it mounted none.

Every world lives at a real URL: it can be bookmarked, reloaded, and linked to. / is a doorway, not a second name for the explorer.

2.3 Earth is the authoring surface

The included Earth volume offers Create Atlas only when two facts are true: the world declares a spherical surface and the host supplied the native minter. The drawer is a complete server-rendered form. Its title, WGS84 bounds, detail, public source pack, presentation choices, cost preview, and submit all work without the render seam. The seam progressively enhances that same form with two camera facts: Use current view and a rectangular Draw zone gesture over the existing map. It does not own a recipe, fetch a source, or compile anything.

Minting follows the ordinary authoring path:

Earth camera / drawn zone
          ↓
app-managed single manifest
          ↓
plan → capture → observe → assemble → native compile → validate → install
          ↓
rescan library → open the new Atlas

The zone itself may be any finite, non-antimeridian Web Mercator rectangle; it is not fenced to today's source coverage. The default pack is USGS Topo plus TIGER roads, water, and county context and is labelled as U.S. coverage, so an out-of-coverage layer may simply be empty. Planning enforces the same pixel, request, evidence, feature, geometry and raster refusal budgets as the CLI. A failed build leaves its recipe and cache evidence available for diagnosis but never installs a partial Atlas.

A GET of an explorer page writes the session's world and the last-volume pointer. That is deliberate: arriving at a URL is a choice whether it was clicked, typed, or restored by the browser, and the session follows the address bar rather than the other way round. Arriving on a different world also clears what belonged to the old one — the selection, the highlights, the search — and re-derives the arrangement (§3.1).

/open exists because a <select> cannot build a path out of its own value. The volume and the world are both in the address, so the two crumbs name what they want and the server redirects to it; after the redirect the reader is at the real URL, which is the only place the explorer lives. It is one route rather than client-side glue, which is the same trade the rest of this document makes.

/assets is a different mount from /static, and the difference is the deletability principle in the URL space. /static is whatever tree a host handed over — the seam's built bundle, which make static puts where a -static mount wants it (render-seam.md §9) — and answers 404 when a host handed over nothing. /assets is the part of the page that is the application's own: the stylesheet system and the vendored hypermedia runtime, compiled into the binary (internal/app/assets). A build with no seam serves a complete, styled, interactive page.

Both assets are vendored rather than linked. The offline invariant is not only about bundles: an atlas opens on a machine with no network, forever, and a page that fetches its runtime from a CDN is a page that does not.


3. The session record

The state-placement rule (issue #5 §4.1): discrete application state lives on the server and in URLs; continuous interaction state lives in the seam. The camera is the single, deliberate exception — it reports upward once per settle, so a volume reopens where the reader left it.

One record per volume, named volume.<slug>.json, plus app.json holding the last volume. SessionSchema versions the shape; a record written under a schema this build does not know is passed over rather than half-read, which costs a reader their layout once and never corrupts it.

{
  "schema": 2,
  "volume": "bend-or",
  "stamp": "3610a0f10798",
  "world": "2026-08-02",
  "lens": "Basemap",
  "hidden":      ["1496244488"],
  "collapsed":   ["zones"],
  "expanded":    ["1951802496", "253393030"],
  "highlighted": ["277390785"],
  "arranged":    true,
  "labels":      {"39191589": "quiet"},
  "search":      "mill",
  "dock":     {"open": true, "dismissed": false, "section": "counts"},
  "detail":   {"open": true},
  "grid":     {"system": "geohash", "cell": "9q5c", "subgrid": 2},
  "sidebar":  {"collapsed": false},
  "overview": {"docked": false},
  "selected": "277390785",
  "cameras":  {"2026-08-02": {"x": 4096, "y": -4096, "zoom": 1.34, "rotation": 0, "at": "2026-08-02T09:12:44Z"}},
  "updatedAt": "2026-08-02T09:12:44Z"
}

Notes that are contract, not style:

  • hidden, collapsed, expanded and highlighted are sorted sets, so a record is stable to diff and two paths to the same state produce the same bytes. They are stored as strings — collection and feature ids ride the DOM as strings — and the island writes them back as the numbers the payload declares (§6).
  • lens is the lens's name, not its index. A name outlives a build's ordering; the island's lens key is the ordinal (§6).
  • focused is the ground the reader last went to from a list, and it is not selected: closing the card puts the selection down and leaves the feature index still marking where the reader has been. It is set only by a row that says focus=1 — a feature index row, a panel row, a link inside a card — because a pick off the canvas is a reader who was already there, and it is cleared when the ground itself is rebuilt: a world opened, a split sheet's layer swapped, the volume come back to from another one.
  • sidebar and overview are spelled as the thing a reader does to them — collapsed, docked — so the zero value is the ordinary page.
  • dock.dismissed is the reader having folded the panel by hand. Until they have, the panel comes out on its own the first time it has something to say; after that it stays where they put it.
  • stamp is the serving build the record was last written against. A record behind the serving build is still read — slugs outlive builds — and the difference is what a stamp-move refresh is about.
  • cameras is keyed by world slug, because a camera belongs to a ground. The server stores it and hands it back. It never reasons about it.

Schema 2 is the templates wave. It gained expanded, highlighted, arranged and overview, renamed sidebar.open to sidebar.collapsed, and lost solo: isolating turned out to be a move on the hide set rather than a state of its own, and the chip is derived from what is hidden so that it is right however that set was reached — including by switching rows off one at a time.

3.1 The arrangement a world opens with

Three of the sets have non-empty defaults that the world supplies, not the reader:

Set Default Why
hidden every collection the payload marks visible: false the producer's own curation
collapsed ["zones"] shape collections are a navigation aid, not the primary filter surface, so their section folds and pin groups stay above the fold
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

arranged is what tells a fresh record from an arranged one. Without it an empty hide set — a reader who asked to see everything — is the same bytes as a record nobody has touched, and the next request would put the curation back.

A record can also go away entirely. POST /session/reset (§4.3), which ⌘R presses, deletes it; the next load reads nothing and builds the table above out of the world again. It is the one operation on the record that is not a move on a field, and that is the point: a record that has gone wrong is not something a reader should have to diagnose a field at a time.


4. Regions, templates, and the partial envelope

4.1 The regions

Twelve regions, one template file each (internal/app/templates/<region>.tmpl), mirroring the one-file-per-region stylesheet system in internal/app/assets/css. A region's template renders that region's own container, so a first paint and a swap produce the same bytes.

Region Element Swap What it is
shell #atlas-shell outerMorph the whole page frame
topbar #atlas-topbar innerMorph volume, world, lens, import
legend #atlas-legend outerMorph search field, toolbar, tree, footer count
dock #atlas-dock outerMorph count, flag, shortlist, and the card inside it
detail #atlas-detail outerMorph one feature's card
grid-navigator #atlas-grid-navigator outerMorph cell system, held cell, subgrid
overview #atlas-overview outerMorph the corner locator's chrome
viewport #atlas-viewport-state outerMorph the inert state node the seam reads
island #atlas-session-island outerHTML the session record the seam reads, as JSON
empty-state #atlas-shell innerMorph the library card
import #atlas-import outerMorph the import that is happening, as one row
mint #atlas-mint / #atlas-mint-preview ordinary form / outerHTML Earth zone, source and presentation authoring plus its bounded preview

Nearly every region is an outer morph, and that is the reading that matches what the templates render: the element is morphed onto itself, which keeps the first-paint bytes and the swap bytes identical and is the only swap that can set an attribute on the region's own container. Three states are spelled exactly that way — the sidebar's collapsed class on the shell, the navigator's hidden, the detail card's hidden. The exceptions each have a reason: the topbar's container carries nothing a swap moves; the island is a <script> node with nothing inside worth preserving, so it is replaced rather than morphed, because morphing one leaves its text where it was. internal/app/partials.go is the table this one mirrors.

An import is one row, not a log. The response is a stream — a hundred megabytes takes long enough that silence reads as failure — but it is a stream of states of the same row: the handler renders the whole region again, and flushes it, for every state a run reaches, and the morph carries one row from "choosing a bundle" to "installed" in place. The row's element id carries the run's number, so a second import is a different element rather than the first one's leftovers. Three things follow, and they are the point of the arrangement: a cancelled picker is silent (its last render holds no row at all, and the section renders itself hidden), a run that ended well marks its last row and the stylesheet fades it out on a delay, and a refusal stays until the next import replaces it — it is the only account of what went wrong, and nothing else on the page will say it. An append swap, which is what this was, made every state of every run a line of its own that outlived the run, the import, and everything short of a full page render.

The legend is one region because it is one answer. Every filtering move changes the rows, the isolate chip and the footer count together; three regions would be three ways for them to disagree.

The card is nested inside the dock and is still its own region. That is where it belongs on screen, and select moves the card and the list together, so a dock render carries the card with it and the two cannot disagree about what is open.

viewport.tmpl defines two templates. viewport is the partial — the state node alone, because that node is what an interaction moves. viewport-surface is the shell's alone and is never swapped.

4.2 The envelope

Every /session/* POST answers with an <hx-partial> set covering exactly the regions that interaction touches — never a page-wide refresh standing in for knowing what moved.

<hx-partial hx-target="#atlas-legend" hx-swap="innerMorph">…</hx-partial>
<hx-partial hx-target="#atlas-dock"   hx-swap="innerMorph">…</hx-partial>

The target and swap are spelled hx-target and hx-swap: htmx 4 reads an <hx-partial> through the same attribute vocabulary it reads everything else through, and an unprefixed target is silently no target at all. The region names and the element ids either side of it are the same as they ever were.

Morph swaps are what let scroll position, focus, and open <details> survive a re-render. The seam's surfaces are morph-skipped: <atlas-viewport>, the map pane and the overview's canvas holder carry hx-morph-skip-children, htmx 4's own spelling of "touch my attributes, never my internals". Tearing down a WebGL context mid-gesture is not a re-render.

4.3 The concern table

One route per concern, each declaring the regions its answer covers. Reading the regions column downward is the fastest way to see what the application thinks is coupled to what.

POST /session/… Fields Regions
world world topbar, legend, dock, overview, viewport
lens lens (the lens's name) topbar, legend, dock, overview, viewport
collections collection+visible, or section, or all=show|hide, or hidden (repeated) legend, dock, viewport
sections section+open, or all=fold|unfold legend
expand collection+open, or all=fold|unfold legend
labels collection + flip, or collection+policy (empty clears) legend, viewport
solo collection or section; neither means show everything legend, dock, viewport
search q legend, dock, viewport
highlight feature (+ optional on, or only=1), or all=clear legend, dock, viewport
dock open, byHand, section dock
select feature (empty closes), focus legend, dock, detail, viewport
grid system, cell, subgrid grid-navigator, dock, viewport
overview docked overview
sidebar open shell
view world, x, y, zoom, rotation — answers 204
reset — — answers 204 and HX-Redirect back into the volume

Every request carries volume, declared once on the shell as hx-vals:inherited. A volume that is not installed is a 404; a malformed slug or a missing required field is a 400.

view is the camera report: the seam's one debounced upward whisper, answered with 204 No Content, because swapping anything in response to a settling camera would fight the reader's own hand. The other upward flow is the pick: the seam resolves a canvas hit and submits the identity through an ordinary POST /session/select.

reset is the one concern that answers with a page, and it earns the exception by deleting the record every other answer is rendered from. It is the blunt reset behind ⌘R (§4.5): the volume's volume.<slug>.json goes whole — what is hidden, folded, unfolded, highlighted and labelled, the search, the panel, the card, the grid, the index, the corner locator, every world's camera — and the next load reads no record at all, so §3.1's arrangement is synthesized fresh. Nothing here writes defaults; forgetting is the reset.

The last-volume pointer is deliberately not touched: the reader is not leaving the volume, they are standing in it with nothing remembered, so app.json stays and the answer sends them back to /v/{volume}/{world} — the world read out of the record before it goes, the volume's first world when the record named none, and / when the volume has no world to name. It is spelled as the HX-Redirect header htmx acts on itself, because a 303 would be followed by fetch and swapped into a page that asked for no swap, which is a reset that appears to do nothing at all.

labels is spelled as a flip, not a destination. The policy turns over, and if the other word is what the producer curated anyway the override has nothing left to say and is dropped rather than stored. That is what keeps a ladder turned over and back from leaving overrides behind it — and it is why the route needs the world, not just the id.

4.4 Template rules

  • One file per region, named for the region.
  • No hx-on. Every interaction is an hx-* attribute naming a route. The only glue on the page is the seam's boot module.
  • Explicit inheritance. hx-vals:inherited and hx-swap:inherited are declared once, on the shell, and nothing else inherits.
  • Templates render; they do not decide. Every display decision runs in Go first (§7). A template that needs an if about what a collection means is a decision in the wrong place.
  • hx-* lives in templates only. The routes, the region names, the partial envelope and the viewport state node are all framework-neutral, so replacing HTMX would be an afternoon in one directory rather than an architecture event.

4.5 The keyboard

Every shortcut that moves discrete state is a route rather than a handler: an hx-trigger naming the key and the element it is heard on, declared once in shell.tmpl — the one region a swap never replaces from underneath — and nothing else. There is no hx-on on the page and no listener behind it.

Key Route Does
⌘G grid cycles which cell system divides the map — above the editable guard
⌘R reset throws this volume's remembered state away and reloads it fresh — above the editable guard
⌘⌥B dock folds the panel beside the map, and unfolds it; two spans, each asking the fold button which way it is pointing
⌘B sidebar puts the index away
G grid opens the grid and closes it
Space grid divides the chosen cell, or stops dividing it; also from inside the address field
Escape (on #map) select closes the card
Escape (on the window) grid telescopes the grid out one level, then out of the grid

Every filter says three things, and the page was broken for want of all three: whose keystroke it is (kbd-editable, so a reader typing "g" into the search field does not put a grid up); that the key is answered (prevent, which htmx runs only when the filter passed, so a shortcut swallows the keystroke it acts on and nothing else); and that the key was pressed rather than held (!event.repeat, so an autorepeating key is not sixty writes a second down a route that writes a file).

Two shortcuts stand above the guard. ⌘G because switching cell systems is most wanted from inside the token field; ⌘R because a reader whose page is misbehaving reaches for reload from wherever the cursor happens to be. ⌘R is also the one place prevent takes something away and hands it straight back: the browser's own reload is suppressed, and the route answers with the same page fetched again over a record that is now gone.

The other half of the keyboard — holding Z for labels, ⌘K to focus the search, flipping panes, stepping the zoom, swallowing the webview's context menu — is not here and must not be: none of it moves discrete state, and one of them cannot be a request at all (render-seam.md).


5. The events stream

GET /events?volume={slug} is Server-Sent Events. The connection opens with a : atlas comment, so a client knows it is live rather than merely accepted. Two event names, and no more:

event: catalog
data: <hx-partial hx-target="#atlas-topbar" hx-swap="innerMorph">…</hx-partial>

event: refresh
data: <hx-partial hx-target="#atlas-shell" hx-swap="innerMorph" hx-get="/v/tunic/world"></hx-partial>
  • catalog — the library's composition moved. Carries the regions that list it. Sent to every connection.
  • refresh — the one directive. The volume this connection is watching now serves a different build, every URL under its old stamp is gone, and the page has to be fetched whole rather than patched. Sent only to connections watching that volume.

The page takes the plainer road of re-fetching its own URL on either event: a page that re-reads where it is cannot end up half-patched, and both events mean the library moved under it. The bodies stay in the contract for a client that wants the cheaper path.

There is no event for "a file appeared in the library directory", because nothing watches it. An import is what triggers a rescan, and the rescan is what produces these events.


6. The state island

The explorer page carries the session it was rendered from as an inert JSON script node:

<script type="application/json" id="atlas-session-island">{…}</script>

It exists for one reason: the application's account of the arrangement has to be diffable against the seam's, key for key, without either half guessing at the other. The key names below are the contract (internal/app/island.go is their home). Where the arrangement is stored is this application's business; what the island says about it is not.

{"last": "bend-or",
 "entry": {"volume": "bend-or", "world": "2026-08-02", "lens": 0,
           "center": [4096, -4096], "zoom": 1.34,
           "hidden": [], "collapsed": ["zones"],
           "expanded": [39191589, 50985093, …],
           "labels": ["39191589=quiet"],
           "overviewDocked": false, "dockFolded": true, "dockDismissed": false}}

Shape notes: ids are emitted as the numbers a payload declares them as but sorted as the strings they ride the DOM as; labels is the override ledger, one "<collection>=<policy>" per entry, sorted; lens is the index; center is rounded to whole world units and zoom to three decimals, so two accounts of one camera are diffable without a normalizer in between.

tests/island is the suite. It drives session POST sequences through the application's own HTTP surface, over the committed corpus — the legend, solo, search, label-policy, lens, grid and overview concerns — and holds the island to its contract key for key, in both directions: a key the contract promises and the island lacks is a hole, and a key the island invents is an invention.

6.1 What is seam-side, and why

Two of the twelve keys cannot be produced server-side, and are not forced to be:

  • center and zoom are the chart's camera. They are arrived at by fitting a raster to a window and depend on the viewport's size, the lens's own depth, and the fit the seam computes. No amount of session state produces them. The server's only honest relationship with them is the one issue #5 §4.1 describes: the seam reports a settled camera upward, debounced, at POST /session/view, and the server stores it and hands it back. Until the seam has reported one they are null. The island suite posts a camera and then checks the round trip, so what is proved about these two is the echo, not the origin.

The grid cull is not one of them, and was for a while. A held cell narrows what stands exactly the way a highlight does, and the count above the panel has to be the count the map is drawing — so the server has to answer "is this feature inside the held cell" without asking the seam, which the third upward flow §5 of render-seam.md forbids would be the only other way. internal/app/cells is the smallest piece of one system's arithmetic that answers it: the recursive halving and nothing else, gated against the shared cell vectors (analysis/testdata/cells/). The cost — a second copy of arithmetic analysis/cellsystems also holds — is written down in that package's own comment, and the file is the first thing a Go twin of the analysis lane would delete.

One thing still belongs to a lane, and is recorded here rather than papered over:

  • The footer's "in view" half. The footer's full sentence is "N of M features in view", and N is the count inside the camera's extent — seam-side by construction. The server renders M; refining it to the full sentence is the seam's, once there is a camera to ask.

7. Display logic

All of it runs in Go, once, before a template is handed anything (issue #5 §4.5). It lives in four files beside the handler, and reads the conventions only through format/semconv — an atlas.* string literal outside the registry is a depcheck failure, not a review comment.

File What it decides
world.go + vnext_runtime.go the semantic model: Presentation applied to FeatureSets, durable string identities, direct coordinate-space geometry, typed properties, relationships, provenance, the parent chain and containment
legend.go the tree — sections, rows, counts, the label ladder, the feature index order, isolating, the solo chip, the opening arrangement
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
view.go the card, the dock, the grid and overview chrome, the viewport state node

Three rules worth stating because they are easy to get subtly wrong:

  1. Highlighting reads AND across collections, OR within one. Two districts highlighted widens the question; a district and a subwatershed narrows it to the ground they share. The corpus city is the test bed: nine shape collections, and highlighting one boundary plus one waterbody takes the drawn count from 213 to 148, because none of the 65 annotated places stands in both. Two answers are exempt from the cull — the feature the reader has open, and one they are searching for by name — because both were asked for rather than merely drawn.
  2. A search narrows points and never the ground. Searching for a place has never taken the ground out from under it, so a shape answers the search in the list and not on the canvas.
  3. Containment is boundary-inclusive with a pixel of grace. A pin dropped on a zone's border was put there to mean the zone, and exact point-in-polygon arithmetic would flip it out over the width of the line it stands on.

One zone, exclusively, is a fourth way to ask. Isolating a collection or a section answers the different and heavier question of putting every other collection away; only=1 names one feature and makes the highlight set exactly that feature (session.go, applyHighlight). It is a move on the set rather than a state of its own — the same shape as isolating, and derived the same way, so a zone that is alone by any route wears the pressed control and pressing it gives every highlight back. The ride-along holds: an exclusive highlight still brings its own collection out of hiding, because asking to look at a piece of ground and keeping it put away cannot both be meant.

The label ladder's one convention rule lives in the registry rather than in a viewer: semconv.LabelPolicy gives a point collection curated as atlas.render.as = text a speaking default, because floating names are labels a producer pinned on rather than a different kind of thing to draw.

7.1 The stylesheet system

internal/app/assets/css is a token-first, one-file-per-region system, carried as an asset (issue #5 §9). Everything this application's markup asks of it beyond the carried files lives in one file, chrome.css: the region containers carry ids of their own, the seam's custom elements need somewhere to stand, and the import row — including the delayed fade that takes it away when a run ended well, which is an animation and not a script. The detail card is an outerMorph region (§4.1) and renders its own hidden, so pin-detail.css works verbatim. The visual identity is neutral dark chrome, palette as accents.


8. The development loop

atlas dev                       # 127.0.0.1:7433, chrome read from the working copy
atlas dev -bundles DIR -root .  # against another library
atlas dev -seam-watch           # and the seam's bundler beside it

atlas dev is atlas serve with two additions and no third. Templates and stylesheets are read from the working copy instead of the binary and re-parsed the moment a file is written, so a template edit is one refresh away rather than one rebuild away; writes are coalesced over 60 ms, because editors write a file three times per save. A tree that will not parse is reported and the last set that parsed keeps serving — an unbalanced {{if}} halfway through an edit is a normal thing to type, and taking the page down over it would make the loop worse than a rebuild.

The watching lives in cmd/, and nowhere else. internal/app is one pure http.Handler that touches no filesystem; what it exposes is templates.Reload(fs.FS) and assets.Reload(fs.FS), and building an fs.FS out of a directory is the command's business. That is the hostenv rule surviving having a development loop.

-seam-watch runs npm run watch in render/ beside the server, streaming the bundler's output into the same event stream. A tree with no render/package.json — the deletability principle exercised — is told so at info and the server carries on, because a missing seam is a served page with one script tag fewer, not a failed run.


9. Logging

internal/logging, per logging.md. The application speaks op= values scan, serve, install, session, events, catalog, render, and uses volume, world, stamp and path from the shared vocabulary. atlas serve and atlas dev print their address to stdout — a script starts the host and reads where it landed — and everything else to the stream on stderr.


10. The pragmatism clause

Issue #5 §4.3: HTMX ownership is a means, not a loyalty test. If an interaction proves laggy or awkward as a round trip, it is re-classified by editing the issue's interaction inventory — never patched with ad-hoc client code. Three things are worth watching, recorded here so the edit has evidence behind it rather than a feeling:

  1. Search-as-you-type. Debounced 150 ms on the client attribute and answered with three region partials. On the city (213 features) and the games (up to 1,400) the round trip is comfortable; on a volume an order of magnitude larger the legend partial is the expensive half, because it re-renders every row to move one count. If it ever bites, the fix inside this architecture is a narrower region — a legend-count region — not a client-side filter.
  2. The feature index. A shape row's index is rendered whether or not the row is unfolded, which is what keeps a feature reachable by name without unfolding first. It is also the largest thing in the legend partial.
  3. The grid navigator's text field. Typing a cell address round-trips per keystroke like the search. It is a short string and a cheap region, but it is the interaction most likely to feel like typing through a straw, and it is the first candidate for the hybrid bucket if it does.

None of the three has been re-classified. They are listed because the clause is worth nothing without a habit of writing down what it would apply to.


11. What is still named rather than built

Named here so a stub is never mistaken for a decision — and so that a wave which lands moves out of the list rather than sitting in it as folklore.

  • The WASM service-worker host. §1's third row. Nothing is built for it; the hostenv discipline is what keeps it reachable.
  • The footer's "in view" half. §6.1.

The seam's own list of what it has not proven is docs/render-seam.md §10, and it is the one to read for anything below the <atlas-viewport> boundary.