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 / internal / app / templates / shell.tmpl
shell.tmpl 17.8K
1{{/*2 shell — the whole page.34 The region containers below are the contract three things agree on: the5 partial envelope swaps into them by id (internal/app/partials.go), the6 carried stylesheet system files itself under their classes, and the seam7 reads the viewport's state node out of them. Each region's own template8 renders its own container, so a swap and a first paint produce the same9 bytes.1011 Inheritance is declared once, here, and explicitly (issue #5 §4.3): every12 interaction on this page is about the same volume, so hx-vals says so at the13 top rather than every control repeating it, and nothing inherits by accident14 because nothing inherits without ":inherited" spelled out.15*/}}16{{define "shell"}}<!doctype html>17<html lang="en">18<head>19<meta charset="utf-8">20<meta name="viewport" content="width=device-width, initial-scale=1">21<meta name="color-scheme" content="dark">22<link rel="icon" href="data:,">23<title>{{.Title}} — Atlas</title>24<link rel="stylesheet" href="/assets/app.css">25<script src="/assets/htmx.js" defer></script>26</head>27<body class="atlas">28{{template "shell-region" .}}29{{template "import" .}}30{{/*31 The seam. It is loaded from /static, which is whatever tree the host mounted32 and answers 404 when it mounted none. An undefined <atlas-viewport> renders33 nothing and breaks nothing, which is the deletability principle costing this34 page one script tag (issue #5 §5.5).35*/}}36<script type="module" src="/static/app.js"></script>37</body>38</html>39{{end}}4041{{/*42 shell-region — the page frame as a region.4344 The document above is what a first paint answers with; this is what a swap45 answers with, and they are the same bytes because the document renders this.46 It matters that they are separable: the sidebar's collapsed state is a class47 on this element, and a swap can only set an attribute on a container it48 renders *itself* -- so the shell is an outer morph over this, and the49 document is never a partial.50*/}}51{{/*52 kbd-editable — the editable-target guard, spelled once and pasted into every53 keyboard filter that needs it (see the shortcuts block below).5455 It is the reference implementation's isEditableTarget written as an56 expression, and `event.target instanceof Element` is doing real work at both57 ends. A select counts as editable: the reference says so, and a select58 answers the space bar itself. And a synthetic key dispatched straight at the59 window -- which is how a headless driver presses one -- has the window as60 its target, which is not an Element, so it passes as a keystroke nobody was61 typing.6263 It carries no `]` and no comma on purpose: htmx reads a trigger's name up to64 the first `]`, and splits a trigger attribute on commas outside brackets, so65 either character would cut the filter in half.66*/}}67{{define "kbd-editable"}}!(event.target instanceof Element&&(/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable)){{end}}6869{{define "shell-region"}}<div id="atlas-shell" class="app-shell{{if .Session.Sidebar.Collapsed}} sidebar-collapsed{{end}}"70 hx-vals:inherited='{"volume":"{{with .Volume}}{{.Slug}}{{end}}"}'71 hx-swap:inherited="none">72 <header id="atlas-topbar" class="topbar">{{template "topbar" .}}</header>73 {{template "legend" .}}74 <main class="map-panel">75 {{template "viewport-surface" .}}76 {{template "viewport" .}}77 <div class="zoom-controls" aria-label="Map controls">78 <button type="button" id="zoom-in" aria-label="Zoom in">+</button>79 <button type="button" id="zoom-out" aria-label="Zoom out">−</button>80 </div>81 {{template "grid-navigator" .}}82 <div class="map-corner">83 {{template "overview" .}}84 <div class="map-hint" id="map-hint">85 <span>Drag to pan</span><i></i>86 <span>Scroll to zoom</span><i></i>87 <span id="labels-hint">Z · hold for labels</span><i></i>88 <span id="grid-hint">{{if .Grid.Enabled}}G · {{.Grid.Name}}{{else}}G · grid off{{end}}</span>89 </div>90 </div>91 </main>92 {{template "dock" .}}93 {{template "island" .}}94 {{/*95 The keyboard, as hypermedia.9697 Every shortcut below moves discrete state, so every one of them is a route98 rather than a handler: an hx-trigger naming the key and the element the99 key is heard on, and nothing else. There is no hx-on here and no listener100 in the page. What is *not* here is the other half of the keyboard --101 holding Z, focusing a field, flipping panes, stepping the zoom, swallowing102 the webview's own menu -- because none of it moves discrete state and all103 of it belongs to the seam (issue #5 §4.1, render/keys.ts).104105 They listen on the window rather than on the body, because that is where106 a keystroke with no focused control is heard: an event dispatched at the107 window never travels down to the body, so a shortcut listening there would108 hear a reader's own typing and nothing else.109110 They are declared here rather than beside the controls they double for111 because a shortcut has to survive the region that would have carried it112 being swapped, and the shell is the one region a swap never replaces from113 underneath.114115 THREE THINGS EVERY FILTER BELOW SAYS, and the page was broken for want of116 all three.117118 It says whose keystroke it is. A shortcut heard on the window hears a119 reader typing into the search field too, so "g" put a grid up mid-word and120 the space bar in a hash flipped the subgrid; every filter but ⌘G carries121 "kbd-editable", which is the reference implementation's isEditableTarget122 written as an expression. ⌘G is deliberately above it, exactly as the123 reference has it: switching cell systems is most wanted from inside the124 token field.125126 It says the key is answered. `prevent` is htmx's own preventDefault, and127 htmx runs it only when the filter passed -- so a shortcut suppresses the128 keystroke it acts on and nothing else, which is what stops macOS sounding129 the rejection tone at every press. ⌘R is the one place `prevent` takes130 something away and hands it straight back: the browser's own reload is131 swallowed, and the answer to the post is the same page fetched again --132 over a record that has just been thrown away.133134 And it says the key was pressed rather than held. `!event.repeat` keeps an135 autorepeating key from posting sixty times a second down a route that136 writes a file.137138 The filters read the live page where the state they need is not this139 template's to know -- whether the grid is up, whether the panel is folded.140 A value rendered into hx-vals here would be right once and stale for the141 rest of the session, because those regions swap and the shell does not.142 */}}143 <div hidden id="atlas-shortcuts">144 {{/*145 ⌘G cycles which cell system divides the map -- above the guard, so it146 answers from inside the token field.147 */}}148 <span hx-post="/session/grid" hx-vals:append='{"system":"cycle"}'149 hx-trigger="keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='g'&&!event.repeat] from:window prevent"></span>150 {{/*151 ⌘R throws this volume's remembered state away and comes straight back to152 it: the refresh key doing what a reader already believes refreshing does,153 and the one way out of a record that has gone wrong -- five highlights154 culled at every pin, a filter nobody can find the row for -- without155 knowing which field is at fault.156157 ABOVE THE GUARD, exactly where the reference had it: a reader whose page158 is misbehaving reaches for reload from wherever the cursor is, including159 a field they were typing in, and a reload that answers everywhere but160 there is a reload that has been half taken away.161162 The answer is a redirect back into the volume (resetSession,163 internal/app/session.go) rather than a partial set: the record every164 region is rendered from is gone, so there is nothing to patch and the165 round trip is the refresh `prevent` just swallowed.166 */}}167 <span hx-post="/session/reset"168 hx-trigger="keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='r'&&!event.repeat] from:window prevent"></span>169 {{/*170 ⌘⌥B folds the panel beside the map, the shortcut editors give their171 secondary sidebar. BY PHYSICAL KEY: Option rewrites event.key on a Mac172 (⌥B types "∫"), so event.code is the one name the chord keeps173 everywhere -- and it is declared before ⌘B, whose filter refuses altKey,174 so the two chords can never both answer.175176 TWO SPANS, not one, and that is the interesting part. The panel's own177 fold button knows which way it is going because the dock re-renders with178 it; a shortcut in the shell does not, because folding the dock swaps the179 dock alone. So the direction is not rendered at all: each span asks the180 button on screen which way it is pointing, and only the one that agrees181 answers. Nothing here can go stale, because nothing here is remembered.182 */}}183 <span hx-post="/session/dock" hx-vals:append='{"open":"1","byHand":"1"}'184 hx-trigger="keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&{{template "kbd-editable"}}&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='false'] from:window prevent"></span>185 <span hx-post="/session/dock" hx-vals:append='{"open":"0","byHand":"1"}'186 hx-trigger="keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&{{template "kbd-editable"}}&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='true'] from:window prevent"></span>187 {{/*188 ⌘B puts the index away. This one *can* render its direction, because the189 sidebar's collapsed state is a class on the shell and the shell is what190 a sidebar swap re-renders -- so the value below is rewritten with it.191 */}}192 <span hx-post="/session/sidebar"193 hx-vals:append='{"open":"{{if .Session.Sidebar.Collapsed}}1{{else}}0{{end}}"}'194 hx-trigger="keydown[(metaKey||ctrlKey)&&!altKey&&key.toLowerCase()=='b'&&!event.repeat&&{{template "kbd-editable"}}] from:window prevent"></span>195 {{/*196 G opens the grid and closes it. The reference hands the keyboard on as197 well -- the field that takes a hash is focused on the way in, the map on198 the way out, so "gm6" arrives at m6 -- and that half is focus rather199 than state, so it belongs to the seam and is noted as owed there200 (render/keys.ts).201 */}}202 <span hx-post="/session/grid" hx-vals:append='{"system":"toggle"}'203 hx-trigger="keydown[key.toLowerCase()=='g'&&!metaKey&&!ctrlKey&&!altKey&&!event.repeat&&{{template "kbd-editable"}}] from:window prevent"></span>204 {{/*205 Space divides the chosen cell, or stops dividing it -- only while there206 is a grid to divide, and never when the toggle itself is focused, which207 answers its own space bar and would otherwise be pressed twice.208 */}}209 <span hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'210 hx-trigger="keydown[key==' '&&!event.repeat&&{{template "kbd-editable"}}&&document.querySelector('#atlas-grid-navigator')?.hidden===false&&event.target!==document.querySelector('#subgrid-toggle')] from:window prevent"></span>211 {{/*212 And the same key from inside the field, which the guard above shuts out213 by design: no hash will ever spell a space, and clearing the mesh is214 what is most wanted while typing one. It is heard on the field rather215 than the window, so it needs no guard of its own.216 */}}217 <span hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'218 hx-trigger="keydown[key==' '&&!event.repeat] from:#grid-input prevent"></span>219 {{/*220 ESCAPE MEANS TWO THINGS AT ONCE, and both of them happen.221222 Heard on the map it closes the card; heard on the window it telescopes223 the grid out one level, and out of the grid altogether once there is no224 address left. A press with the map focused is heard by both -- the first225 at its target, the second as it bubbles -- which is exactly what the226 reference did with one handler and two function calls.227228 Two handlers means two requests, and hx-sync is what keeps that from229 being a race: both queue on the shell, so the card closes and *then* the230 grid ascends, and neither answer is computed from a record the other one231 has already moved on from.232233 The third meaning is the field's, and it is a stopPropagation in the234 seam rather than a route: the first Escape in the grid input only hands235 the keyboard back to the map, and the second one gets here.236 */}}237 <span hx-post="/session/select" hx-vals:append='{"feature":""}'238 hx-sync="#atlas-shell:queue all"239 hx-trigger="keydown[key=='Escape'&&!event.repeat&&{{template "kbd-editable"}}] from:#map prevent"></span>240 <span hx-post="/session/grid" hx-vals:append='{"ascend":"1"}'241 hx-sync="#atlas-shell:queue all"242 hx-trigger="keydown[key=='Escape'&&!event.repeat&&{{template "kbd-editable"}}&&document.querySelector('#atlas-grid-navigator')?.hidden===false] from:window prevent"></span>243 </div>244 {{/*245 The camera's way home.246247 The seam reports a settled camera so a volume reopens where the reader248 left it (issue #5 §4.1). It is the one upward flow that is not a reader's249 move, and it is *this page's* post rather than the seam's own: the250 application renders the form and the fields, the seam fills them in and251 raises the event, and htmx does the rest. That is the same arrangement the252 labels hint and the corner locator already stand in -- chrome the253 application renders for the seam to draw into (docs/render-seam.md §2) --254 and it is what lets the answer land. A report the seam posted for itself255 would throw the answer away, and the answer is the state island carrying256 the camera that was just written.257 */}}258 <form hidden id="atlas-camera" hx-post="/session/view"259 hx-trigger="atlas:camera from:window">260 <input type="hidden" name="world" id="atlas-camera-world"261 value="{{with .Volume}}{{.World.Slug}}{{end}}">262 <input type="hidden" name="x" id="atlas-camera-x">263 <input type="hidden" name="y" id="atlas-camera-y">264 <input type="hidden" name="zoom" id="atlas-camera-zoom">265 <input type="hidden" name="rotation" id="atlas-camera-rotation" value="0">266 </form>267 {{/*268 The pick's way in.269270 The panes resolve a hit themselves -- a pixel is theirs and an identity is271 this application's (docs/render-seam.md §5) -- and this is the half that272 was missing: the seam handed an identity up and nothing was standing there273 to take it, so a click on the canvas moved nothing at all.274275 It is the camera's arrangement exactly, and for the same reason. The276 application renders the form and the field it wants filled, the seam fills277 it in and raises the event, htmx posts it and applies the answer -- and the278 answer is the selection the legend marks and the dock and the card draw,279 which are the page's regions, not the seam's. A request the seam made for280 itself would throw all of that away.281282 ONE FIELD, and the two absences are the point. The volume is not here283 because it is inherited from the shell above like every other interaction's284 on this page, and `focus` is not here because a pick off the canvas is not285 a jump: a row reached for from a list marks the ground it opened in the286 index, and a reader who clicked the ground was already standing on it287 (applySelect, internal/app/session.go).288 */}}289 <form hidden id="atlas-pick" hx-post="/session/select"290 hx-trigger="atlas:pick from:window">291 <input type="hidden" name="feature" id="atlas-pick-feature">292 </form>293 {{/*294 The cell's way in, which is the pick's way in for the other thing a295 surface can be pointed at.296297 A reader telescopes through the grid three ways and they are one298 interaction: typing an address into the navigator's field, clicking a cell299 the chart drew, and pressing a point on the sphere -- which names the cell300 one level below the one being held. The field is a control the application301 already renders and posts for itself; the two surfaces are pixels, and a302 pixel is the seam's to resolve (docs/render-seam.md §5). So they arrive303 here, at the same route the field posts to and with the same field name,304 and the server normalizes and validates the address once for all three.305306 IT POSTS `cell` AND NOTHING ELSE. Which system is dividing the map is307 already in the session -- the address was resolved *by* that system, and a308 seam telling the server which one it used would be the seam holding an309 opinion about state it does not own. The volume is inherited from the310 shell, as everything on this page is.311 */}}312 <form hidden id="atlas-grid-pick" hx-post="/session/grid"313 hx-trigger="atlas:grid-pick from:window">314 <input type="hidden" name="cell" id="atlas-grid-pick-cell">315 </form>316 {{/*317 Live updates. The stream carries ready-to-swap partials and two event318 names (docs/app.md §5); the page takes the plainer road of re-fetching319 itself on either, because both events mean the library moved under it and320 a page that re-reads its own URL cannot end up half-patched. The bodies321 stay in the contract for a client that wants the cheaper path.322323 These stand inside #atlas-shell for the same reason the island does: the324 address they re-fetch is the address of the volume they were rendered325 for, and a pair left outside the shell goes on naming the volume the page326 first opened on. A reconcile would then take the reader somewhere else,327 which is exactly what an unchanged catalog must never do.328 */}}329 {{with .Volume}}<div hidden id="atlas-events" hx-sse:connect="/events?volume={{.Slug}}" hx-trigger="load"></div>330 <div hidden hx-get="/v/{{.Slug}}/{{.World.Slug}}"331 hx-trigger="catalog from:#atlas-events, refresh from:#atlas-events"332 hx-target="#atlas-shell" hx-select="#atlas-shell" hx-swap="outerMorph"></div>{{end}}333</div>334{{end}}