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 / 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 {{template "mint" .}}78 <div class="zoom-controls" aria-label="Map controls">79 <button type="button" id="zoom-in" aria-label="Zoom in">+</button>80 <button type="button" id="zoom-out" aria-label="Zoom out">−</button>81 </div>82 {{template "grid-navigator" .}}83 <div class="map-corner">84 {{template "overview" .}}85 <div class="map-hint" id="map-hint">86 <span>Drag to pan</span><i></i>87 <span>Scroll to zoom</span><i></i>88 <span id="labels-hint">Z · hold for labels</span><i></i>89 <span id="grid-hint">{{if .Grid.Enabled}}G · {{.Grid.Name}}{{else}}G · grid off{{end}}</span>90 </div>91 </div>92 </main>93 {{template "dock" .}}94 {{template "island" .}}95 {{/*96 The keyboard, as hypermedia.9798 Every shortcut below moves discrete state, so every one of them is a route99 rather than a handler: an hx-trigger naming the key and the element the100 key is heard on, and nothing else. There is no hx-on here and no listener101 in the page. What is *not* here is the other half of the keyboard --102 holding Z, focusing a field, flipping panes, stepping the zoom, swallowing103 the webview's own menu -- because none of it moves discrete state and all104 of it belongs to the seam (issue #5 §4.1, render/keys.ts).105106 They listen on the window rather than on the body, because that is where107 a keystroke with no focused control is heard: an event dispatched at the108 window never travels down to the body, so a shortcut listening there would109 hear a reader's own typing and nothing else.110111 They are declared here rather than beside the controls they double for112 because a shortcut has to survive the region that would have carried it113 being swapped, and the shell is the one region a swap never replaces from114 underneath.115116 THREE THINGS EVERY FILTER BELOW SAYS, and the page was broken for want of117 all three.118119 It says whose keystroke it is. A shortcut heard on the window hears a120 reader typing into the search field too, so "g" put a grid up mid-word and121 the space bar in a hash flipped the subgrid; every filter but ⌘G carries122 "kbd-editable", which is the reference implementation's isEditableTarget123 written as an expression. ⌘G is deliberately above it, exactly as the124 reference has it: switching cell systems is most wanted from inside the125 token field.126127 It says the key is answered. `prevent` is htmx's own preventDefault, and128 htmx runs it only when the filter passed -- so a shortcut suppresses the129 keystroke it acts on and nothing else, which is what stops macOS sounding130 the rejection tone at every press. ⌘R is the one place `prevent` takes131 something away and hands it straight back: the browser's own reload is132 swallowed, and the answer to the post is the same page fetched again --133 over a record that has just been thrown away.134135 And it says the key was pressed rather than held. `!event.repeat` keeps an136 autorepeating key from posting sixty times a second down a route that137 writes a file.138139 The filters read the live page where the state they need is not this140 template's to know -- whether the grid is up, whether the panel is folded.141 A value rendered into hx-vals here would be right once and stale for the142 rest of the session, because those regions swap and the shell does not.143 */}}144 <div hidden id="atlas-shortcuts">145 {{/*146 ⌘G cycles which cell system divides the map -- above the guard, so it147 answers from inside the token field.148 */}}149 <span hx-post="/session/grid" hx-vals:append='{"system":"cycle"}'150 hx-trigger="keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='g'&&!event.repeat] from:window prevent"></span>151 {{/*152 ⌘R throws this volume's remembered state away and comes straight back to153 it: the refresh key doing what a reader already believes refreshing does,154 and the one way out of a record that has gone wrong -- five highlights155 culled at every pin, a filter nobody can find the row for -- without156 knowing which field is at fault.157158 ABOVE THE GUARD, exactly where the reference had it: a reader whose page159 is misbehaving reaches for reload from wherever the cursor is, including160 a field they were typing in, and a reload that answers everywhere but161 there is a reload that has been half taken away.162163 The answer is a redirect back into the volume (resetSession,164 internal/app/session.go) rather than a partial set: the record every165 region is rendered from is gone, so there is nothing to patch and the166 round trip is the refresh `prevent` just swallowed.167 */}}168 <span hx-post="/session/reset"169 hx-trigger="keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='r'&&!event.repeat] from:window prevent"></span>170 {{/*171 ⌘⌥B folds the panel beside the map, the shortcut editors give their172 secondary sidebar. BY PHYSICAL KEY: Option rewrites event.key on a Mac173 (⌥B types "∫"), so event.code is the one name the chord keeps174 everywhere -- and it is declared before ⌘B, whose filter refuses altKey,175 so the two chords can never both answer.176177 TWO SPANS, not one, and that is the interesting part. The panel's own178 fold button knows which way it is going because the dock re-renders with179 it; a shortcut in the shell does not, because folding the dock swaps the180 dock alone. So the direction is not rendered at all: each span asks the181 button on screen which way it is pointing, and only the one that agrees182 answers. Nothing here can go stale, because nothing here is remembered.183 */}}184 <span hx-post="/session/dock" hx-vals:append='{"open":"1","byHand":"1"}'185 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>186 <span hx-post="/session/dock" hx-vals:append='{"open":"0","byHand":"1"}'187 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>188 {{/*189 ⌘B puts the index away. This one *can* render its direction, because the190 sidebar's collapsed state is a class on the shell and the shell is what191 a sidebar swap re-renders -- so the value below is rewritten with it.192 */}}193 <span hx-post="/session/sidebar"194 hx-vals:append='{"open":"{{if .Session.Sidebar.Collapsed}}1{{else}}0{{end}}"}'195 hx-trigger="keydown[(metaKey||ctrlKey)&&!altKey&&key.toLowerCase()=='b'&&!event.repeat&&{{template "kbd-editable"}}] from:window prevent"></span>196 {{/*197 G opens the grid and closes it. The reference hands the keyboard on as198 well -- the field that takes a hash is focused on the way in, the map on199 the way out, so "gm6" arrives at m6 -- and that half is focus rather200 than state, so it belongs to the seam and is noted as owed there201 (render/keys.ts).202 */}}203 <span hx-post="/session/grid" hx-vals:append='{"system":"toggle"}'204 hx-trigger="keydown[key.toLowerCase()=='g'&&!metaKey&&!ctrlKey&&!altKey&&!event.repeat&&{{template "kbd-editable"}}] from:window prevent"></span>205 {{/*206 Space divides the chosen cell, or stops dividing it -- only while there207 is a grid to divide, and never when the toggle itself is focused, which208 answers its own space bar and would otherwise be pressed twice.209 */}}210 <span hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'211 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>212 {{/*213 And the same key from inside the field, which the guard above shuts out214 by design: no hash will ever spell a space, and clearing the mesh is215 what is most wanted while typing one. It is heard on the field rather216 than the window, so it needs no guard of its own.217 */}}218 <span hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'219 hx-trigger="keydown[key==' '&&!event.repeat] from:#grid-input prevent"></span>220 {{/*221 ESCAPE MEANS TWO THINGS AT ONCE, and both of them happen.222223 Heard on the map it closes the card; heard on the window it telescopes224 the grid out one level, and out of the grid altogether once there is no225 address left. A press with the map focused is heard by both -- the first226 at its target, the second as it bubbles -- which is exactly what the227 reference did with one handler and two function calls.228229 Two handlers means two requests, and hx-sync is what keeps that from230 being a race: both queue on the shell, so the card closes and *then* the231 grid ascends, and neither answer is computed from a record the other one232 has already moved on from.233234 The third meaning is the field's, and it is a stopPropagation in the235 seam rather than a route: the first Escape in the grid input only hands236 the keyboard back to the map, and the second one gets here.237 */}}238 <span hx-post="/session/select" hx-vals:append='{"feature":""}'239 hx-sync="#atlas-shell:queue all"240 hx-trigger="keydown[key=='Escape'&&!event.repeat&&{{template "kbd-editable"}}] from:#map prevent"></span>241 <span hx-post="/session/grid" hx-vals:append='{"ascend":"1"}'242 hx-sync="#atlas-shell:queue all"243 hx-trigger="keydown[key=='Escape'&&!event.repeat&&{{template "kbd-editable"}}&&document.querySelector('#atlas-grid-navigator')?.hidden===false] from:window prevent"></span>244 </div>245 {{/*246 The camera's way home.247248 The seam reports a settled camera so a volume reopens where the reader249 left it (issue #5 §4.1). It is the one upward flow that is not a reader's250 move, and it is *this page's* post rather than the seam's own: the251 application renders the form and the fields, the seam fills them in and252 raises the event, and htmx does the rest. That is the same arrangement the253 labels hint and the corner locator already stand in -- chrome the254 application renders for the seam to draw into (docs/render-seam.md §2) --255 and it is what lets the answer land. A report the seam posted for itself256 would throw the answer away, and the answer is the state island carrying257 the camera that was just written.258 */}}259 <form hidden id="atlas-camera" hx-post="/session/view"260 hx-trigger="atlas:camera from:window">261 <input type="hidden" name="world" id="atlas-camera-world"262 value="{{with .Volume}}{{.World.Slug}}{{end}}">263 <input type="hidden" name="x" id="atlas-camera-x">264 <input type="hidden" name="y" id="atlas-camera-y">265 <input type="hidden" name="zoom" id="atlas-camera-zoom">266 <input type="hidden" name="rotation" id="atlas-camera-rotation" value="0">267 </form>268 {{/*269 The pick's way in.270271 The panes resolve a hit themselves -- a pixel is theirs and an identity is272 this application's (docs/render-seam.md §5) -- and this is the half that273 was missing: the seam handed an identity up and nothing was standing there274 to take it, so a click on the canvas moved nothing at all.275276 It is the camera's arrangement exactly, and for the same reason. The277 application renders the form and the field it wants filled, the seam fills278 it in and raises the event, htmx posts it and applies the answer -- and the279 answer is the selection the legend marks and the dock and the card draw,280 which are the page's regions, not the seam's. A request the seam made for281 itself would throw all of that away.282283 ONE FIELD, and the two absences are the point. The volume is not here284 because it is inherited from the shell above like every other interaction's285 on this page, and `focus` is not here because a pick off the canvas is not286 a jump: a row reached for from a list marks the ground it opened in the287 index, and a reader who clicked the ground was already standing on it288 (applySelect, internal/app/session.go).289 */}}290 <form hidden id="atlas-pick" hx-post="/session/select"291 hx-trigger="atlas:pick from:window">292 <input type="hidden" name="feature" id="atlas-pick-feature">293 </form>294 {{/*295 The cell's way in, which is the pick's way in for the other thing a296 surface can be pointed at.297298 A reader telescopes through the grid three ways and they are one299 interaction: typing an address into the navigator's field, clicking a cell300 the chart drew, and pressing a point on the sphere -- which names the cell301 one level below the one being held. The field is a control the application302 already renders and posts for itself; the two surfaces are pixels, and a303 pixel is the seam's to resolve (docs/render-seam.md §5). So they arrive304 here, at the same route the field posts to and with the same field name,305 and the server normalizes and validates the address once for all three.306307 IT POSTS `cell` AND NOTHING ELSE. Which system is dividing the map is308 already in the session -- the address was resolved *by* that system, and a309 seam telling the server which one it used would be the seam holding an310 opinion about state it does not own. The volume is inherited from the311 shell, as everything on this page is.312 */}}313 <form hidden id="atlas-grid-pick" hx-post="/session/grid"314 hx-trigger="atlas:grid-pick from:window">315 <input type="hidden" name="cell" id="atlas-grid-pick-cell">316 </form>317 {{/*318 Live updates. The stream carries ready-to-swap partials and two event319 names (docs/app.md §5); the page takes the plainer road of re-fetching320 itself on either, because both events mean the library moved under it and321 a page that re-reads its own URL cannot end up half-patched. The bodies322 stay in the contract for a client that wants the cheaper path.323324 These stand inside #atlas-shell for the same reason the island does: the325 address they re-fetch is the address of the volume they were rendered326 for, and a pair left outside the shell goes on naming the volume the page327 first opened on. A reconcile would then take the reader somewhere else,328 which is exactly what an unchanged catalog must never do.329 */}}330 {{with .Volume}}<div hidden id="atlas-events" hx-sse:connect="/events?volume={{.Slug}}&amp;stamp={{.Stamp}}" hx-trigger="load"></div>331 <div hidden hx-get="/v/{{.Slug}}/{{.World.Slug}}"332 hx-trigger="catalog from:#atlas-events, refresh from:#atlas-events"333 hx-target="#atlas-shell" hx-select="#atlas-shell" hx-swap="outerMorph"></div>{{end}}334</div>335{{end}}