carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1{{define "top"}}<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>{{.Title}} · {{.Brand}}</title>
7<style>
8 :root {
9 --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
10 --accent: #5b3e96; --accent-strong: #4c3380;
11 --accent-soft: rgba(91, 62, 150, 0.09);
12 --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; --good: #2f7a4f;
13 --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
14 --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
15 }
16 @media (prefers-color-scheme: dark) {
17 :root {
18 --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
19 --accent: #b394f2; --accent-strong: #c9b2f7;
20 --accent-soft: rgba(179, 148, 242, 0.12);
21 --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f;
22 }
23 }
24 [hidden] { display: none !important; }
25 * { margin: 0; padding: 0; box-sizing: border-box; }
26 body {
27 background: var(--bg); color: var(--ink);
28 font: 400 1rem/1.6 var(--sans);
29 -webkit-font-smoothing: antialiased; padding: 0 1.25rem;
30 }
31 a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
32 code {
33 font-family: var(--mono);
34 font-size: 0.88em; background: var(--accent-soft);
35 padding: 0.1em 0.35em; border-radius: 0.25rem;
36 }
37 pre {
38 background: var(--accent-soft); border-radius: 0.5rem;
39 padding: 0.9rem 1.1rem; margin: 0 0 1rem; max-width: 40rem;
40 overflow-x: auto; font-family: var(--mono); font-size: 0.85rem; line-height: 1.6;
41 }
42 pre code { background: none; padding: 0; }
43 /* A tag's changelog and a release's notes are prose, not code — long lines
44 should wrap inside the box rather than force the horizontal scrollbar
45 every other <pre> on this page gets for a shell command. */
46 .changelog { white-space: pre-wrap; }
47 select, input[type=text], input[type=search], input[type=email] {
48 font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
49 border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
50 }
51 td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
52 /* A form living inside a sentence (the bake toggle, a rung's fast-track):
53 it must not break the line it is part of. */
54 form.inline { display: inline; margin: 0; }
55 form.inline button { vertical-align: baseline; }
56 /* A list's filter row (the Running tab): its control and its button on one
57 line, wrapping onto the next when the screen is too narrow for both. */
58 form.listctl { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 0.6rem; }
59 /* Interface icons: inline SVG from internal/console/icons.go — Lucide,
60 vendored, because this page fetches nothing from anywhere. They carry no
61 width/height of their own, so they size to the text beside them and
62 inherit its colour. */
63 .icon { width: 1em; height: 1em; flex: none; vertical-align: -0.125em; }
64 header {
65 max-width: 64rem; margin: 0 auto; padding: 1.4rem 0;
66 display: flex; align-items: baseline; gap: 1rem;
67 border-bottom: 1px solid var(--rule);
68 }
69 header .brand a { font-weight: 700; color: var(--ink); text-decoration: none; }
70 header nav {
71 margin-left: auto; display: flex; align-items: baseline; gap: 1rem;
72 font-size: 0.9rem;
73 }
74 header .addr { color: var(--muted); }
75 /* The account switcher sits where the wordmark used to: a <details>
76 disclosure, so it is a real button to a screen reader, opens on
77 Enter/Space, and needs no JavaScript to work at all. The inline script
78 at the foot only adds Escape and click-away, which are enhancements. */
79 header .switcher {
80 position: relative;
81 }
82 header .switcher > summary {
83 display: inline-flex; align-items: baseline; gap: 0.4rem;
84 list-style: none; cursor: pointer;
85 padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; border-radius: 0.5rem;
86 }
87 header .switcher > summary::-webkit-details-marker { display: none; }
88 header .switcher > summary:hover { background: var(--accent-soft); }
89 /* Names are free text up to 64 characters, which unbounded would shove the
90 nav off the header — truncate in the trigger, wrap in the menu. */
91 header .switcher .current-name {
92 font-weight: 700; min-width: 0; max-width: 14rem;
93 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
94 }
95 /* The caret is an icon, not a text character: centred against a
96 baseline-aligned row, muted, and flipped while the menu is open. */
97 header .switcher > summary > .icon { color: var(--muted); align-self: center; width: 0.9em; }
98 header .switcher[open] > summary > .icon { transform: rotate(180deg); }
99 header .switcher-menu {
100 position: absolute; z-index: 20; top: calc(100% + 0.6rem); left: -0.5rem;
101 /* max-content, not shrink-to-fit: the menu's containing block is the
102 narrow trigger, so shrink-to-fit pinned it at min-width and wrapped
103 any name longer than that mid-word. */
104 width: max-content; min-width: 13rem; max-width: 20rem;
105 background: var(--bg); border: 1px solid var(--rule); border-radius: 0.6rem;
106 padding: 0.35rem; font-size: 0.9rem;
107 box-shadow: 0 0.4rem 1.5rem rgba(0, 0, 0, 0.14);
108 }
109 /* Someone in dozens of accounts gets a list that scrolls inside the menu
110 instead of running off the bottom of the screen. */
111 header .switcher-menu ul {
112 list-style: none; margin: 0; padding: 0;
113 max-height: min(22rem, 60vh); overflow-y: auto; overscroll-behavior: contain;
114 }
115 /* The filter box only renders past a handful of accounts, and stays
116 hidden until the script at the foot shows it: with JS off it could not
117 filter anything. */
118 header .switcher-find { display: block; width: 100%; margin: 0 0 0.35rem; }
119 header .switcher-none { padding: 0.35rem 0.55rem; color: var(--muted); }
120 header .switcher-menu a, header .switcher-menu .current {
121 display: flex; align-items: baseline; gap: 0.5rem;
122 padding: 0.35rem 0.55rem; border-radius: 0.4rem;
123 color: var(--ink); text-decoration: none; overflow-wrap: anywhere;
124 }
125 header .switcher-menu a:hover, header .switcher-menu a:focus { background: var(--accent-soft); }
126 header .switcher-menu .current { font-weight: 600; background: var(--accent-soft); }
127 /* The mark column reserves its width on every row so the names stay in one
128 column: a check on the account you are in, hidden on the ones you are
129 not, a plus on "New account". */
130 header .switcher-menu .mark {
131 color: var(--accent); width: 0.9rem; flex: none;
132 display: flex; align-self: center;
133 }
134 header .switcher-menu a .mark { visibility: hidden; }
135 header .switcher-menu .new a .mark { visibility: visible; }
136 header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; }
137 header .switcher-menu .new a { color: var(--accent); }
138 main { max-width: 64rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
139 h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
140 h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
141 h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
142 /* Settings: an input-and-button row, and the deletion section's heading
143 in the ink that says be careful. */
144 .settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; }
145 .settings-layout {
146 display: grid; grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
147 gap: 2.5rem; align-items: start;
148 }
149 .settings-nav { display: flex; flex-direction: column; gap: 0.2rem; }
150 .settings-nav a {
151 display: block; padding: 0.45rem 0.65rem; border-radius: 0.35rem;
152 color: var(--muted); text-decoration: none;
153 }
154 .settings-nav a:hover { color: var(--ink); background: var(--accent-soft); }
155 .settings-nav a[aria-current="page"] {
156 color: var(--ink); background: var(--accent-soft); font-weight: 600;
157 }
158 .settings-content { min-width: 0; }
159 .settings-content > h2:first-child { margin-top: 0; }
160 .danger-head { color: var(--bad); }
161 p { max-width: 40rem; margin: 0 0 1rem; }
162 .lede { font-size: 1.15rem; color: var(--muted); }
163 .small { font-size: 0.9rem; color: var(--muted); }
164 /* Present to a screen reader, absent to the eye — for labels the visual
165 design carries in placement or placeholder text alone. */
166 .visually-hidden {
167 position: absolute; width: 1px; height: 1px; overflow: hidden;
168 clip-path: inset(50%); white-space: nowrap;
169 }
170 .error { color: var(--bad); }
171 .banner {
172 background: var(--accent-soft); border-radius: 0.5rem;
173 padding: 0.6rem 0.9rem; margin: 0 0 1.5rem; max-width: 40rem;
174 }
175 .tablewrap { overflow-x: auto; }
176 .instances .instance-address { white-space: nowrap; }
177 .instances td:nth-child(2) { white-space: nowrap; }
178 .instance-open { display: inline-flex; padding: .25rem; }
179 .instance-group th { text-align: left; padding-top: 1rem; background: var(--accent-soft); }
180 .instance-group .small { font-weight: normal; }
181 .small a[aria-current="true"] { color: var(--ink); font-weight: 700; text-decoration-thickness: 3px; }
182 .instance-facts { display: grid; grid-template-columns: minmax(8rem, auto) 1fr; gap: .5rem 1.5rem; margin: 1rem 0; }
183 .instance-facts dt { color: var(--muted); }
184 .instance-facts dd { margin: 0; overflow-wrap: anywhere; }
185 .instance-facts .cellspark { max-width: 24rem; }
186 /* The Logs tab (logsview.go): the pane reads as the terminal it mirrors —
187 the same line `carlos logs` prints, monospaced, one line per record, no
188 wrapping. A table wrapped every message across three rows and put the
189 eye back at the left margin for each one, which is precisely what a log
190 reader is scanning past.
191
192 Ground is the shell-command <pre>'s own fill rather than a terminal's
193 black: this page is a light document in light mode, and a black
194 rectangle down the middle of it would read as a foreign object, not as
195 the console's own output. The pane is a scrolled region in BOTH axes —
196 sideways because lines do not wrap, and down because 200 lines must not
197 turn the page itself into the scrollbar. */
198 #logs-filter { display: flex; flex-wrap: wrap; align-items: end; gap: 0.65rem; margin-bottom: 1rem; }
199 #logs-filter label { display: flex; flex-direction: column; gap: 0.25rem; max-width: 100%; }
200 #logs-filter select, #logs-filter input { max-width: 100%; margin: 0; }
201 #logs-filter select[name="host"] { max-width: 28rem; }
202 .term {
203 background: var(--accent-soft); border-radius: 0.5rem;
204 padding: 0.8rem 0 0.8rem 1rem; margin: 0 0 1rem;
205 min-height: 16rem; max-height: 70vh; overflow: auto;
206 font-family: var(--mono); font-size: 0.8rem; line-height: 1.55;
207 }
208 /* white-space:pre belongs to the LINE, not the pane: on the pane it would
209 render the newlines BETWEEN these divs as blank lines. width:max-content
210 with min-width:100% makes each line as wide as its own text, so the
211 pane's horizontal scroll reaches the end of the longest one — and the
212 right-hand gutter is the LINE's padding, not the pane's, because a
213 scrolled-away pane padding leaves the longest line hard against the
214 edge. */
215 .tl { white-space: pre; width: max-content; min-width: 100%; padding-right: 1rem; }
216 /* Time, host and trailing k=v fields are the line's furniture; the message
217 is what the reader came for, so it alone gets full-strength ink. */
218 .tl-t, .tl-h, .tl-f { color: var(--muted); }
219 .tl-s { color: var(--accent); }
220 /* The CLI colours a level rather than printing its name (logs.go's
221 printLogRecords); the pane says it the same way, in the same two tones
222 the rest of this console uses for warn and bad. */
223 .tl-warn .tl-m { color: var(--warn, #a6790f); }
224 .tl-error .tl-m { color: var(--bad); font-weight: 600; }
225 table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; }
226 .tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; }
227 .tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; }
228 /* A tile that is also a link (the Overview dashboard): the tile itself is
229 the affordance, so no underline and no accent ink — hover carries it. */
230 a.tile { color: var(--ink); text-decoration: none; }
231 a.tile:hover { outline: 1px solid var(--accent); }
232 /* The dashboard's six statistics as an even grid — flex would let the
233 last row's stragglers stretch full-width. */
234 .stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
235 .tile h2 {
236 margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase;
237 letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
238 }
239 .tile-value { font-size: 1.6rem; font-weight: 600; margin: 0; }
240 .tile-detail { color: var(--muted); font-size: 0.8rem; margin: 0.25rem 0 0; }
241 /* .chip-stale is bordered-only, no fill — a "trust this less" flag, not
242 an outage marker, matching internal/status/assets/status.css. */
243 .chip {
244 display: inline-block; font-family: var(--mono); font-size: 0.78rem; font-weight: 500;
245 padding: 0.15rem 0.55rem; border-radius: 999px; margin: 0.15rem 0.3rem 0.15rem 0;
246 }
247 .chip-stale { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
248 /* A canary is bordered — a fact about a route, not a warning. A canary on
249 the app's primary host is FILLED, because an unreviewed build in front of
250 every user is the one thing on this page a reader must not skim past. */
251 .chip-canary { border: 1px solid var(--accent); color: var(--accent); }
252 .chip-prod { background: var(--bad); color: var(--bg); }
253 /* An expired canary (spec §5) keeps serving — this is "trust this less",
254 the same bordered-only warning as .chip-stale, never a filled outage
255 marker like .chip-prod. */
256 .chip-expired { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
257 /* A route's bound environment — a fact, not a warning, so it is bordered
258 like .chip-canary but in a neutral tone: it sits on every route, not
259 just the ones worth a second look. */
260 .chip-env { border: 1px solid var(--rule); color: var(--muted); }
261 /* Health verdict chips. "attention" is the one filled variant here — the
262 row a reader must not skim past — in the warn tone, not .chip-prod's
263 red: a silent instance is a problem, not the unreviewed-build outage.
264 ok/notdeployed/unknown are bordered facts. */
265 .chip-health-ok { border: 1px solid var(--good); color: var(--good); }
266 .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
267 .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
268 .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
269 /* /fleet/economics/accounts (Task 14): FreeTierPos chips. "inside" (all
270 usage covered by the free tier, zero revenue) is the pure-cost-sink
271 case worth a second look, so it gets the same bordered warn tone as
272 chip-stale/chip-health-unknown — a fact to watch, not yet an outage.
273 "partial" (some usage billable, some free-tier-covered — the ordinary
274 lifecycle state) is a neutral accent border, chip-canary's tone.
275 "paying" (nothing left free) is chip-health-ok's bordered good tone.
276 "pooled" (Task 5: a try/pool/"" tier account, priced at zero revenue by
277 design, drawing from the fleet pool instead) is a STATUS, not a
278 warning — chip-env's muted neutral tone, the same posture as an
279 ordinary route fact nobody needs to act on. */
280 .chip-freetier-inside { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
281 .chip-freetier-partial { border: 1px solid var(--accent); color: var(--accent); }
282 .chip-freetier-paying { border: 1px solid var(--good); color: var(--good); }
283 .chip-freetier-pooled { border: 1px solid var(--rule); color: var(--muted); }
284 /* /fleet/economics/accounts (Task 6): fleet-pool TIER chips (store.go's
285 validAccountTiers) — the operator's ASSIGNMENT, distinct from the
286 freetier chips above (a PRICED POSITION). "unassigned" (never set) and
287 "try"/"pool" (both draw from the shared pool, same as an unassigned
288 account) share chip-freetier-pooled's muted, nothing-to-act-on tone.
289 "hosted"/"fleet"/"dedicated" are individually priced, each its own
290 bordered tone so the three read apart at a glance without implying a
291 ranking between them. */
292 .chip-tier-unassigned { border: 1px solid var(--rule); color: var(--muted); }
293 .chip-tier-try { border: 1px solid var(--rule); color: var(--muted); }
294 .chip-tier-pool { border: 1px solid var(--rule); color: var(--muted); }
295 .chip-tier-hosted { border: 1px solid var(--good); color: var(--good); }
296 .chip-tier-fleet { border: 1px solid var(--accent); color: var(--accent); }
297 .chip-tier-dedicated { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
298 .chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
299 .chip-up { background: var(--accent-soft); color: #2f7a4f; }
300 .chip-down { background: var(--accent-soft); color: var(--bad); }
301 .chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; }
302 /* /fleet/economics (Task 13): a box's utilization as a filled bar, CSS
303 width only — the initial render needs no JS, and the inline knob script
304 updates .bar-fill's width style in place on recompute. */
305 .bar { display: inline-block; width: 6rem; height: 0.5rem; border-radius: 999px; background: var(--accent-soft); vertical-align: middle; overflow: hidden; }
306 .bar-fill { display: block; height: 100%; background: var(--accent); }
307 /* The knob drawer: one row per rate-card number, label then control,
308 narrower than .settings-row's button-row spacing since these are all
309 text/number inputs with no submit button riding along on the same line. */
310 .knob-row { display: flex; gap: 0.6rem; align-items: center; margin: 0 0 0.5rem; flex-wrap: wrap; }
311 .knob-row label { min-width: 12rem; color: var(--muted); font-size: 0.9rem; }
312 .knob-row input[type=number], .knob-row input[type=text] { width: 8rem; }
313 .knob-row input[type=range] { width: 12rem; }
314 /* The app flow diagram (flow.go): the release path drawn as the pipeline it
315 is, with the addresses each channel feeds hanging under it.
316
317 The one claim the drawing makes beyond its own words is the ARROW: solid
318 and accented where a version really did travel that step, dashed and grey
319 where it did not. That is what lets "how far has my build got" be seen
320 rather than worked out by comparing three version strings, so the two
321 states have to stay visually unmistakable — and distinguishable by more
322 than colour, hence solid vs dashed. */
323 .flow { margin: 0 0 1.75rem; }
324 .flow-line {
325 --flow-y: 0.52rem;
326 display: flex; flex-wrap: wrap; gap: 1.1rem 2.6rem;
327 list-style: none; margin: 0; padding: 0;
328 }
329 .flow-stage { position: relative; flex: 1 1 10rem; min-width: 9rem; }
330 .flow-stage + .flow-stage::before, .flow-stage + .flow-stage::after {
331 content: ""; position: absolute; pointer-events: none;
332 }
333 .flow-stage + .flow-stage::before {
334 left: -2.05rem; top: var(--flow-y); width: 1.3rem; border-top: 1px dashed var(--rule);
335 }
336 .flow-stage + .flow-stage::after {
337 left: -0.82rem; top: var(--flow-y); transform: translateY(-50%);
338 border: 0.28rem solid transparent; border-right: 0; border-left-color: var(--rule);
339 }
340 .flow-stage.is-carried::before { border-top-style: solid; border-top-color: var(--accent); }
341 .flow-stage.is-carried::after { border-left-color: var(--accent); }
342 .flow-label {
343 margin: 0; font-family: var(--mono); font-size: 0.74rem; text-transform: uppercase;
344 letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
345 }
346 .flow-version { margin: 0.3rem 0 0; font-size: 0.95rem; }
347 /* An empty box states its emptiness in words, so it needs no fill of its
348 own — the missing version chip is the signal. */
349 .flow-stage:not(.is-held) .flow-version { color: var(--muted); }
350 .flow-note { margin: 0.25rem 0 0; font-size: 0.78rem; color: var(--muted); }
351 .flow-addrs {
352 list-style: none; margin: 0.6rem 0 0; padding: 0 0 0 0.7rem;
353 border-left: 1px solid var(--rule); display: grid; gap: 0.35rem;
354 }
355 .flow-addr { font-size: 0.82rem; line-height: 1.35; }
356 .flow-addr a { display: block; overflow-wrap: anywhere; }
357 .flow-health { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--muted); }
358 .flow-health .icon { width: 0.9em; height: 0.9em; flex: none; }
359 .flow-tone-ok .flow-health { color: var(--good); }
360 .flow-tone-wait .flow-health { color: var(--warn, #a6790f); }
361 .flow-tone-bad .flow-health { color: var(--bad); font-weight: 600; }
362 .flow-band { margin: 1.4rem 0 0.7rem; font-size: 0.9rem; color: var(--muted); }
363 .flow-caption { margin: 1.1rem 0 0; }
364 /* Narrow screens: the same path, turned down the page. The connector turns
365 with it — an arrow still pointing the way the version travels. */
366 @media (max-width: 46rem) {
367 .settings-layout { display: block; }
368 .settings-nav {
369 flex-direction: row; overflow-x: auto; margin: 0 0 1.5rem;
370 padding-bottom: 0.25rem;
371 }
372 .settings-nav a { white-space: nowrap; }
373 .flow-line { display: block; }
374 .flow-stage + .flow-stage { margin-top: 1.6rem; }
375 .flow-stage + .flow-stage::before {
376 left: 0.55rem; top: -1.2rem; width: 0; height: 0.85rem;
377 border-top: 0; border-left: 1px dashed var(--rule);
378 }
379 .flow-stage + .flow-stage::after {
380 left: 0.55rem; top: -0.34rem; transform: translateX(-50%);
381 border: 0.28rem solid transparent; border-bottom: 0; border-top-color: var(--rule);
382 border-left-color: transparent;
383 }
384 .flow-stage.is-carried::before { border-left-color: var(--accent); }
385 .flow-stage.is-carried::after { border-top-color: var(--accent); border-left-color: transparent; }
386 }
387 /* The release ladder: four rungs drawn top-down in the order the spec
388 draws them (canary first, stable at the foot), with the promotion
389 between two of them rendered as the step it is. list-style is off
390 because the rung's own name is its marker. */
391 .ladder { list-style: none; margin: 0 0 1rem; padding: 0; max-width: 40rem; }
392 .rung {
393 border: 1px solid var(--rule); border-radius: 0.6rem; padding: 0.8rem 1rem;
394 }
395 .rung + .rung { margin-top: 0.4rem; }
396 .rung-name {
397 font-family: var(--mono);
398 font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
399 font-weight: 600; color: var(--muted); margin: 0 0 0.3rem;
400 }
401 .rung p { margin: 0 0 0.2rem; }
402 .rung .gate { margin: 0.5rem 0 0; }
403 .rung .routes { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; font-size: 0.95rem; }
404 .rung .routes li { padding: 0.1rem 0; }
405 /* The step to the next rung is the last band of the rung it leaves: ruled
406 off, full width, so the arrow reads as "and then down to here". */
407 .step {
408 display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
409 margin: 0.7rem -1rem -0.8rem; padding: 0.5rem 1rem;
410 border-top: 1px solid var(--rule);
411 }
412 .step form { display: flex; align-items: baseline; gap: 0.5rem; margin: 0; }
413 .step > .icon { color: var(--muted); align-self: center; }
414 th {
415 font-family: var(--mono);
416 font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
417 color: var(--muted); text-align: left; font-weight: 600;
418 }
419 th, td { padding: 0.5rem 1rem 0.5rem 0; border-bottom: 1px solid var(--rule); }
420 form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; }
421 form.signin input[type=text], form.signin input[type=email] { padding: 0.55rem 0.8rem; min-width: 16rem; }
422 form.signin label { flex-basis: 100%; font-weight: 600; }
423 button, .btn {
424 font-size: 0.95rem; font-weight: 600; background: var(--accent);
425 color: var(--bg); border: 0; border-radius: 0.4rem;
426 padding: 0.6rem 1.2rem; cursor: pointer;
427 text-decoration: none; display: inline-block;
428 }
429 button.linkish {
430 background: none; color: var(--accent); font-weight: 400; padding: 0;
431 border-radius: 0; text-decoration: underline; font-size: 0.9rem; cursor: pointer;
432 }
433 button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
434 button.linkish.danger { color: var(--bad); }
435 /* Actions that change what serves are BUTTONS, not links — small enough to
436 sit inside a table row or a ladder rung, unmistakably a button all the
437 same. Danger is filled red: rollbacks and retirements should not dress
438 like promotions. */
439 .btn-sm, button.btn-sm {
440 font-size: 0.85rem; padding: 0.35rem 0.8rem; border-radius: 0.4rem;
441 }
442 button.btn-danger, .btn-danger { background: var(--bad); color: var(--bg); }
443 button.btn-danger:hover, .btn-danger:hover { background: var(--bad); filter: brightness(0.9); }
444 /* The Instances table's in-cell readings: number first, then the 48h
445 shape beside it, low-contrast so the number stays the headline. */
446 td .cell-value { display: inline-block; min-width: 3.5rem; font-weight: 600; }
447 .cellspark { width: 6rem; height: 1.1rem; vertical-align: -0.2rem; color: var(--muted); }
448 .cellspark polyline { stroke-width: 2.5; }
449 /* The Overview's recent-activity strip. */
450 .feed { list-style: none; margin: 0 0 0.5rem; padding: 0; max-width: 40rem; }
451 .feed li { padding: 0.25rem 0; border-bottom: 1px solid var(--rule); }
452 .feed li:last-child { border-bottom: 0; }
453 /* The confirm interstitial: one card saying exactly what is about to
454 happen, one button that does it, one link that does not. */
455 .confirm-card {
456 border: 1px solid var(--rule); border-radius: 0.6rem;
457 padding: 1.25rem 1.4rem; max-width: 40rem; margin: 0 0 1rem;
458 }
459 .confirm-card .actions { display: flex; gap: 0.9rem; align-items: center; margin-top: 1rem; }
460 .confirm-card .confirm-note { margin: 0.8rem 0 0; }
461 /* /fleet/architecture: the diagram and its box cards. Colours come from
462 the same tokens as the rest of the console so it themes light and dark
463 with no second palette to keep in sync, and the SVG scrolls inside its
464 own wrapper rather than making the page scroll sideways — the drawing
465 has a minimum readable width the viewport may not have. */
466 /* Sized to main's 64rem column, and no wider: a band is at most four
467 nodes across (archLayer), 1018 units, which fits inside it. The wrapper
468 used to be sized to the viewport instead, because the single-picture
469 page drew an estate 1918 units wide and the column showed the DNS and
470 the IPs and hid every box; one picture per region is what made that
471 unnecessary, and overflow-x only has work to do on a window narrower
472 than the column. */
473 .archwrap { overflow-x: auto; margin: 0.2rem 0 1.6rem; }
474 /* The band heading. A region code is a machine string an operator reads
475 dozens of times a day, so it is set in the mono face and quietly — the
476 picture under it is the content, and a loud heading every 400px down a
477 six-region page reads as six separate documents. */
478 h4.archband {
479 border-top: 1px solid var(--rule); color: var(--muted);
480 font: 600 0.8rem var(--mono); letter-spacing: 0.02em;
481 margin: 1.5rem 0 0; padding-top: 0.5rem;
482 }
483 /* No width rule: the SVG carries its own width/height (RenderArchSVG), so
484 it renders 1:1 and .archwrap scrolls when the viewport is narrower.
485 Sizing it to the container instead would scale a 1918-unit-wide drawing
486 into a ~1024px column and take the 13px labels to ~7px. */
487 .archsvg { display: block; }
488 .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); }
489 .archsvg .archnode-box rect { stroke: var(--accent); }
490 .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); }
491 .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); }
492 /* The edges carry the whole meaning of the diagram — which name reaches
493 which box — so they are stroked off --muted, not --rule. --rule is the
494 1px table-hairline token (#eae7f0 light, #2c2836 dark): correct for a
495 node's outline sitting on a filled body, invisible for a line that has
496 to be followed across the page. */
497 .archsvg .archedge { fill: none; stroke: var(--muted); stroke-width: 1.5; }
498 /* An alarm rides the node it watches (archlayout.go): a healthy one is a
499 quiet mark, a firing one takes the bad colour so it reads at a glance
500 rather than only in the words of its hover title. */
501 .archsvg .archalarm { fill: var(--good); }
502 .archsvg .archalarm-firing { fill: var(--bad); }
503 /* 64rem, not 44rem: the resource tables below run the full width of the
504 column, and a 44rem card left the column edge stepping in and out down
505 the page (discussion #9). */
506 .archbox {
507 border: 1px solid var(--rule); border-radius: 0.6rem;
508 padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 64rem;
509 }
510 .archbox h3 { margin: 0 0 0.3rem; }
511 .archbox p { margin: 0 0 0.2rem; }
512 /* The box card's fact line: every fact on it is independently absent (a
513 BYO box has no instance type, an unpriced box has no size and no
514 price), so the separator is drawn between surviving neighbours rather
515 than written after each fact — no dangling middots. */
516 .archbox .fact + .fact::before { content: " · "; color: var(--muted); }
517 footer {
518 max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem;
519 border-top: 1px solid var(--rule);
520 font-size: 0.85rem; color: var(--muted);
521 }
522 /* Page anatomy: every page opens with a mono eyebrow naming the context,
523 an h1, and one human sentence saying what the page is for. */
524 .pagehead { margin: 0 0 1.75rem; }
525 .eyebrow {
526 font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.08em;
527 text-transform: uppercase; color: var(--muted); margin: 0 0 0.3rem;
528 }
529 .pagehead h1 { margin: 0 0 0.35rem; }
530 .pagehead .lede { margin: 0; font-size: 1.05rem; }
531 /* Progressive-reveal sections: a page leads with the summary strip, and
532 the deeper tables/ladder sit behind an opt-in disclosure. */
533 .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; }
534 /* App tab bar (Heroku-style), and the account nav's active marker.
535 aria-current carries the state; the accent underline draws it. */
536 .tabs {
537 display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0 0 2rem;
538 border-bottom: 1px solid var(--rule);
539 font-family: var(--mono); font-size: 0.85rem;
540 }
541 .tabs a {
542 color: var(--muted); text-decoration: none;
543 padding: 0 0 0.55rem; border-bottom: 2px solid transparent; margin-bottom: -1px;
544 }
545 .tabs a:hover { color: var(--ink); }
546 .tabs a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
547 header nav a[aria-current] { color: var(--ink); font-weight: 600; text-decoration: none; }
548 header nav a.addr { color: var(--muted); text-decoration: none; }
549 header nav a.addr:hover { text-decoration: underline; }
550</style>
551</head>
552<body>
553<header>
554 {{if .Account}}
555 <details class="switcher" id="switcher">
556 <summary>
557 <span class="small">Account</span>
558 <span class="current-name">{{.Account.Name}}</span>
559 {{icon "chevron-down"}}
560 </summary>
561 <div class="switcher-menu">
562 {{if gt (len .Accounts) 7}}<input type="search" class="switcher-find" placeholder="Find an account" aria-label="Find an account" autocomplete="off" spellcheck="false" hidden>
563 <p class="switcher-none" hidden>No account matches that.</p>{{end}}
564 <ul>
565 {{range .Accounts}}<li>{{if eq .Sqid $.Account.Sqid}}
566 <span class="current" aria-current="true"><span class="mark">{{icon "check"}}</span>{{.Name}}</span>
567 {{else}}
568 <a href="/a/{{.Seg}}/"><span class="mark">{{icon "check"}}</span>{{.Name}}</a>
569 {{end}}</li>
570 {{end}}
571 </ul>
572 <hr>
573 <div class="new"><a href="/accounts/new"><span class="mark">{{icon "plus"}}</span>New account</a></div>
574 </div>
575 </details>
576 {{else}}
577 <div class="brand"><a href="/">{{.Brand}}</a></div>
578 {{end}}
579 {{if .Address}}<nav>
580 {{if .Account}}
581 <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a>
582 <a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a>
583 <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a>
584 <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a>
585 {{end}}
586 <a class="addr" href="/settings" title="Your settings">{{.Address}}</a>
587 <form method="post" action="/auth/signout">
588 <input type="hidden" name="csrf" value="{{.CSRF}}">
589 <button class="linkish" type="submit">Sign out</button>
590 </form>
591 </nav>{{end}}
592</header>
593<main>
594{{end}}
595
596{{define "bottom"}}
597</main>
598<footer>🤖 Console text written by an LLM, per <a href="https://11factor.org/#x">factor X</a>.</footer>
599<script>
600// Enhancement only: <details> already opens, closes and focuses on its own.
601// This adds the two behaviours a menu is expected to have and the element
602// does not — Escape closes it (returning focus to the trigger), and a click
603// anywhere else closes it. With JS off, the dropdown is unchanged.
604//
605// For a long list it also adds the filter box: typing narrows the rows by
606// name, Enter opens the first row left, and the arrow keys walk the rows.
607(function () {
608 var d = document.getElementById("switcher");
609 if (!d) return;
610 document.addEventListener("keydown", function (e) {
611 if (e.key === "Escape" && d.open) { d.open = false; d.firstElementChild.focus(); }
612 });
613 document.addEventListener("click", function (e) {
614 if (d.open && !d.contains(e.target)) d.open = false;
615 });
616
617 var find = d.querySelector(".switcher-find");
618 var none = d.querySelector(".switcher-none");
619 var list = d.querySelector(".switcher-menu ul");
620 if (!list) return;
621 var rows = Array.prototype.slice.call(list.children);
622 if (find) find.hidden = false;
623
624 function shown() {
625 return rows.filter(function (li) { return !li.hidden; })
626 .map(function (li) { return li.querySelector("a"); })
627 .filter(Boolean);
628 }
629 function filter() {
630 var q = find.value.trim().toLowerCase(), any = false;
631 rows.forEach(function (li) {
632 li.hidden = q !== "" && li.textContent.toLowerCase().indexOf(q) < 0;
633 if (!li.hidden) any = true;
634 });
635 none.hidden = any;
636 list.hidden = !any;
637 }
638 if (find) {
639 find.addEventListener("input", filter);
640 find.addEventListener("keydown", function (e) {
641 var links = shown();
642 if (e.key === "Enter" && links.length) { e.preventDefault(); links[0].click(); }
643 if (e.key === "ArrowDown" && links.length) { e.preventDefault(); links[0].focus(); }
644 });
645 }
646 list.addEventListener("keydown", function (e) {
647 if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
648 var links = shown(), i = links.indexOf(document.activeElement);
649 if (i < 0) return;
650 e.preventDefault();
651 if (e.key === "ArrowDown") { if (i + 1 < links.length) links[i + 1].focus(); }
652 else if (i > 0) links[i - 1].focus();
653 else if (find) find.focus();
654 });
655 d.addEventListener("toggle", function () {
656 if (!d.open) {
657 if (find && find.value) { find.value = ""; filter(); }
658 return;
659 }
660 // Open on the account you are in, wherever it sits in a long list.
661 var cur = list.querySelector("[aria-current]");
662 if (cur) list.scrollTop = cur.parentNode.offsetTop - list.offsetTop - list.clientHeight / 2;
663 // Type straight away with a keyboard; a phone does not get its
664 // on-screen keyboard thrown up just for opening the menu.
665 if (find && window.matchMedia("(pointer: fine)").matches) find.focus();
666 });
667})();
668</script>
669</body>
670</html>
671{{end}}
672