| 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 | |