a73x

6ee34b4e

feat(web): the console speaks the site's language

a73x   2026-08-07 15:12

Commit message
feat(web): the console speaks the site's language

The console is derived from eitri.sh and softened for an app: the site's
monospace and its square, unfilled, unrounded boxes, but rules where the site
draws a full grid. The vocabulary is named once in the layout — ink, paper,
hairline, faint, wash, and one green and one red that only ever qualify a
glyph or a hover — so every route draws from one palette instead of a pile of
literals, and the whole console follows the reader's light or dark scheme.

The tables keep their shape under real data. Each one sits in its own
horizontally scrolling box at its natural width, so a long CIDR or a host
name is scrolled to rather than squeezed, wrapped, or quietly elided. One ink
rule under the head and a hairline between rows carry the eye across nine
columns, with a wash under the pointer to hold the row. Numbers right-align
on tabular figures, and the capacity column splits into vCPU, mem and disk
under one spanning "Used / total" heading — a host that has not reported its
capacity reads "0/?" in a column instead of dissolving into a run-on line. A
key/value table has no head, so its keys are labels: faint, unemphasised, and
ruled like any other row.

A status light is a glyph: filled and green for online, hollow and faint for
offline, with the label beside it in ink so the state still reads without the
colour. Load leads with the number and carries a short meter beside it for
the shape of the fleet at a glance; past 1.0 the number sets in bold, and a
resource bar past 90% turns, because that is a wall you can hit. Every action
is a real but quiet button — a hairline box that firms to ink under the
pointer, and to red when what it does cannot be undone. What a page must not
let you miss keeps an ink border of its own: the create-VM dialog, the
one-time token, the callout on a VM being deleted. Only the serial console
stays dark, because it is a terminal.

web/src/lib/Console.svelte
Old New
@@ -146,11 +146,9 @@
146 146
147 <style> 147 <style>
148 .console { 148 .console {
149 background: #15171c; 149 border: 1px solid var(--hairline);
150 border: 1px solid #2a2e37; 150 padding: 0.6em 0.8em;
151 border-radius: 6px; 151 margin-top: 0.8em;
152 padding: 0.6rem 0.8rem;
153 margin-top: 0.8rem;
154 } 152 }
155 .console header { 153 .console header {
156 display: flex; 154 display: flex;
@@ -159,29 +157,30 @@
159 } 157 }
160 .console h3 { 158 .console h3 {
161 margin: 0; 159 margin: 0;
162 font-size: 13px; 160 font-size: inherit;
163 } 161 }
162 /* The terminal itself stays a dark surface—it is a terminal—but its frame
163 is the console's own hairline. */
164 .term { 164 .term {
165 min-height: 320px; 165 min-height: 320px;
166 background: #000; 166 background: #000;
167 padding: 4px; 167 padding: 4px;
168 border-radius: 4px; 168 margin-top: 0.5em;
169 margin-top: 0.5rem;
170 /* xterm renders a fixed 80-col canvas (~640px): scroll it inside the 169 /* xterm renders a fixed 80-col canvas (~640px): scroll it inside the
171 card instead of overflowing narrow viewports. */ 170 panel instead of overflowing narrow viewports. */
172 overflow-x: auto; 171 overflow-x: auto;
173 } 172 }
174 .term.hidden { 173 .term.hidden {
175 display: none; 174 display: none;
176 } 175 }
177 .err { 176 .err {
178 color: #ffb4b4; 177 color: var(--bad);
179 margin: 0.4rem 0 0; 178 margin: 0.4em 0 0;
180 } 179 }
181 /* color comes from the global .hint rule (+layout.svelte); this one just 180 /* colour comes from the global .hint rule (+layout.svelte); this one just
182 wants a smaller size and top margin. */ 181 wants a smaller size and top margin. */
183 .hint { 182 .hint {
184 font-size: 0.85em; 183 font-size: 0.85em;
185 margin: 0.4rem 0 0; 184 margin: 0.4em 0 0;
186 } 185 }
187 </style> 186 </style>
web/src/lib/ResourceBar.svelte
Old New
@@ -27,43 +27,38 @@
27 27
28 <style> 28 <style>
29 .bar { 29 .bar {
30 margin: 0.35rem 0; 30 margin: 0.35em 0;
31 } 31 }
32 .head { 32 .head {
33 display: flex; 33 display: flex;
34 justify-content: space-between; 34 justify-content: space-between;
35 gap: 1em;
35 margin-bottom: 2px; 36 margin-bottom: 2px;
36 } 37 }
37 .label { 38 .label {
38 color: #8b919c; 39 color: var(--faint);
39 } 40 }
40 .nums { 41 .nums {
41 color: #d6d9df; 42 font-variant-numeric: tabular-nums;
42 }
43 .free {
44 color: #6b7280;
45 } 43 }
44 .free,
46 .dim { 45 .dim {
47 color: #6b7280; 46 color: var(--faint);
48 } 47 }
48 /* The same meter the fleet table uses, stretched to the panel: a wash track
49 with an ink fill. The numbers above it are the reading; the bar is the
50 glance. Past 90% the fill turns, because that is a wall you can hit. */
49 .track { 51 .track {
50 height: 8px; 52 height: 3px;
51 background: #20242c; 53 background: var(--wash);
52 border-radius: 4px;
53 overflow: hidden; 54 overflow: hidden;
54 } 55 }
55 .fill { 56 .fill {
56 height: 100%; 57 height: 100%;
57 border-radius: 4px; 58 background: var(--ink);
58 transition: width 0.3s; 59 transition: width 0.3s;
59 } 60 }
60 .fill.ok {
61 background: #34d399;
62 }
63 .fill.warm {
64 background: #f5b945;
65 }
66 .fill.hot { 61 .fill.hot {
67 background: #f87171; 62 background: var(--bad);
68 } 63 }
69 </style> 64 </style>
web/src/routes/+layout.svelte
Old New
@@ -71,126 +71,181 @@
71 </main> 71 </main>
72 72
73 <style> 73 <style>
74 :global(body) { 74 /* The console is derived from the site (site/style.css) and softened for an
75 app: the same monospace and the same square unfilled boxes, but rules
76 instead of a full cell grid. The vocabulary is named once here — ink,
77 paper, hairline, faint, wash — so the rest of the console inherits it
78 instead of carrying literals. */
79 :global(:root) {
80 color-scheme: light dark;
81 --ink: CanvasText;
82 --paper: Canvas;
83 --hairline: color-mix(in srgb, CanvasText 25%, transparent);
84 --faint: color-mix(in srgb, CanvasText 55%, transparent);
85 --wash: color-mix(in srgb, CanvasText 6%, transparent);
86 --ok: #3a9e4f;
87 --bad: #c2503a;
88 }
89 :global(*) {
75 margin: 0; 90 margin: 0;
76 font-family: ui-monospace, SFMono-Regular, Menlo, monospace; 91 padding: 0;
77 background: #0c0d10; 92 box-sizing: border-box;
78 color: #d6d9df; 93 }
79 font-size: 13px; 94 :global(body) {
95 font-family: monospace;
96 line-height: 1.5;
97 color: var(--ink);
98 background: var(--paper);
80 } 99 }
81 :global(a) { 100 :global(a) {
82 color: #6cb6ff; 101 color: inherit;
83 text-decoration: none; 102 }
103 :global(h1) {
104 margin-bottom: 0.5em;
84 } 105 }
85 :global(a:hover) { 106 :global(h2) {
86 text-decoration: underline; 107 margin: 1.5em 0 0.5em;
87 } 108 }
109 :global(h3) {
110 margin: 1em 0 0.5em;
111 }
112 :global(p, ul, ol) {
113 margin-bottom: 1em;
114 }
115 :global(ul, ol) {
116 padding-left: 2em;
117 }
118 :global(li) {
119 margin-bottom: 0.25em;
120 }
121 :global(code) {
122 word-break: break-all;
123 }
124 /* One strong rule under the head, a hairline between rows, and a wash under
125 the pointer: enough structure to track a row across nine columns without
126 boxing every cell. */
88 :global(table) { 127 :global(table) {
89 border-collapse: collapse; 128 border-collapse: collapse;
90 width: 100%; 129 margin: 0.5em 0 1.5em;
91 margin: 0.5rem 0 1.5rem;
92 } 130 }
93 :global(th, td) { 131 :global(th) {
132 border-bottom: 1px solid var(--ink);
133 padding: 0.35em 0.9em 0.35em 0;
94 text-align: left; 134 text-align: left;
95 padding: 0.4rem 0.6rem; 135 font-weight: bold;
96 border-bottom: 1px solid #20242c;
97 white-space: nowrap; 136 white-space: nowrap;
98 } 137 }
99 :global(th) { 138 :global(td) {
100 color: #8b919c; 139 border-bottom: 1px solid var(--hairline);
101 font-weight: 600; 140 padding: 0.45em 0.9em 0.45em 0;
102 border-bottom: 1px solid #2a2e37; 141 white-space: nowrap;
103 } 142 }
143 :global(tr:hover td) {
144 background: var(--wash);
145 }
146 /* A key/value table has no head, so its keys are labels rather than
147 headings: faint, unemphasised, and ruled like any other row. */
148 :global(.kv th) {
149 border-bottom: 1px solid var(--hairline);
150 color: var(--faint);
151 font-weight: normal;
152 vertical-align: top;
153 width: 20ch;
154 }
155 :global(td.num, th.num) {
156 text-align: right;
157 font-variant-numeric: tabular-nums;
158 }
159 /* A status light is a glyph, not a swatch: filled and green for on, hollow
160 and faint for off. Only the glyph takes the colour — the label beside it
161 stays ink, so the state reads without it. */
104 :global(.dot) { 162 :global(.dot) {
105 width: 8px;
106 height: 8px;
107 border-radius: 50%;
108 display: inline-block; 163 display: inline-block;
164 margin-right: 0.25em;
109 } 165 }
110 :global(.dot.on) { 166 :global(.dot.on) {
111 background: #34d399; 167 color: var(--ok);
112 } 168 }
113 :global(.dot.off) { 169 :global(.dot.off) {
114 background: #f87171; 170 color: var(--faint);
171 }
172 :global(.dot.on::before) {
173 content: '●';
174 }
175 :global(.dot.off::before) {
176 content: '○';
115 } 177 }
116 :global(.hint) { 178 :global(.hint) {
117 color: #6b7280; 179 color: var(--faint);
118 } 180 }
119 header { 181 header {
120 display: flex; 182 display: flex;
121 align-items: center; 183 align-items: center;
122 gap: 0.6rem; 184 gap: 0.75em;
123 padding: 0.6rem 1rem; 185 padding: 1em;
124 background: #15171c; 186 border-bottom: 1px solid var(--hairline);
125 border-bottom: 1px solid #23262d;
126 } 187 }
127 .brand { 188 .brand {
128 font-weight: 700; 189 font-weight: bold;
129 color: #fff; 190 text-decoration: none;
130 font-size: 15px;
131 } 191 }
132 .fleet-label { 192 .fleet-label,
133 color: #6b7280; 193 .conn,
194 .email {
195 color: var(--faint);
134 } 196 }
135 .spacer { 197 .spacer {
136 flex: 1; 198 flex: 1;
137 } 199 }
138 .conn { 200 /* Controls are real but quiet: a hairline box that firms up to ink when you
139 color: #9aa0aa; 201 reach for it, and to red when what it does cannot be undone. */
140 margin-right: 0.5rem; 202 :global(input, select, textarea) {
141 } 203 font: inherit;
142 .email { 204 color: inherit;
143 color: #9aa0aa; 205 background: none;
144 } 206 border: 1px solid var(--hairline);
145 :global(input, button, select, textarea) { 207 border-radius: 0;
146 background: #0c0d10; 208 padding: 0.05em 0.6em;
147 color: #d6d9df;
148 border: 1px solid #2a2e37;
149 border-radius: 4px;
150 padding: 0.3rem 0.5rem;
151 font-family: inherit;
152 font-size: 12px;
153 box-sizing: border-box; 209 box-sizing: border-box;
154 } 210 }
211 :global(input:focus, select:focus, textarea:focus) {
212 border-color: var(--ink);
213 }
155 :global(button) { 214 :global(button) {
215 font: inherit;
216 color: inherit;
217 background: none;
218 border: 1px solid var(--hairline);
219 border-radius: 0;
220 padding: 0.05em 0.6em;
156 cursor: pointer; 221 cursor: pointer;
157 background: #1f6feb;
158 border-color: #1f6feb;
159 color: #fff;
160 } 222 }
161 :global(button:hover) { 223 :global(button:hover) {
162 background: #2b7bf5; 224 border-color: var(--ink);
163 } 225 }
164 :global(button.ghost) { 226 :global(button.danger:hover) {
165 background: transparent; 227 border-color: var(--bad);
166 border-color: #2a2e37; 228 color: var(--bad);
167 color: #d6d9df;
168 } 229 }
169 :global(button.danger) { 230 /* A tick box is the one control the browser draws better than we can. */
170 background: #b9333f; 231 :global(input[type='checkbox'], input[type='radio']) {
171 border-color: #b9333f; 232 border: 0;
233 padding: 0;
172 } 234 }
235 /* Wide enough for a full fleet table; the site's 72ch measure is for prose,
236 and it is the language that carries over, not the measure. */
173 main { 237 main {
174 padding: 1rem; 238 padding: 1em;
175 max-width: 1100px; 239 max-width: 170ch;
240 margin: 0 auto;
176 } 241 }
177 .error { 242 .error {
178 background: #3a1518;
179 color: #ffb4b4;
180 padding: 0.5rem 1rem;
181 border-bottom: 1px solid #5a2025;
182 display: flex; 243 display: flex;
183 align-items: center; 244 align-items: center;
184 justify-content: space-between; 245 justify-content: space-between;
185 gap: 0.6rem; 246 gap: 0.75em;
186 } 247 padding: 0.5em 1em;
187 .error .dismiss { 248 border-bottom: 1px solid var(--hairline);
188 background: transparent; 249 color: var(--bad);
189 border: 1px solid #5a2025;
190 color: #ffb4b4;
191 padding: 0.1rem 0.45rem;
192 }
193 .error .dismiss:hover {
194 background: #5a2025;
195 } 250 }
196 </style> 251 </style>
web/src/routes/+page.svelte
Old New
@@ -210,59 +210,70 @@
210 {/if} 210 {/if}
211 211
212 {#if fleet.hosts.length > 0 && shownHosts.length > 0} 212 {#if fleet.hosts.length > 0 && shownHosts.length > 0}
213 <table> 213 <div class="scroll">
214 <thead> 214 <table>
215 <tr><th>Name</th><th>Status</th><th>OS</th><th>Version</th><th>Load</th><th>VMs</th><th>Guest network</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr> 215 <thead>
216 </thead>
217 <tbody>
218 {#each shownHosts as h (h.id)}
219 {@const load = loadRatio(h)}
220 <tr> 216 <tr>
221 <td><a href="/hosts/{h.id}">{h.name}</a></td> 217 <th rowspan="2">Name</th>
222 <td> 218 <th rowspan="2">Status</th>
223 <span class="dot {h.online ? 'on' : 'off'}"></span> 219 <th rowspan="2">OS</th>
224 {h.status}{h.online ? '' : ' · offline'} 220 <th rowspan="2">Version</th>
225 </td> 221 <th rowspan="2" class="num">Load</th>
226 <td title={osTitle(h)}>{osLabel(h)}</td> 222 <th rowspan="2" class="num">VMs</th>
227 <td> 223 <th rowspan="2">Guest network</th>
228 {h.agent_version || '—'} 224 <th colspan="3" class="group">Used / total</th>
229 {#if h.agent_update_available} 225 <th rowspan="2"></th>
230 <button class="ghost upgrade" onclick={() => upgrade(h)}>↑ {fleet.latest_version}</button>
231 {/if}
232 </td>
233 <td>
234 {#if load !== null}
235 <span class="loadcell" title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU">
236 <span
237 class="loadbar"
238 class:calm={load <= 0.7}
239 class:warm={load > 0.7 && load <= 1}
240 class:hot={load > 1}
241 ><span style="width:{Math.min(load, 1) * 100}%"></span></span>
242 {load.toFixed(2)}
243 </span>
244 {:else}
245 <span class="hint">—</span>
246 {/if}
247 </td>
248 <td>{vmCountByHost.get(h.id) ?? 0}</td>
249 <td>{h.bridge_cidr || '—'}</td>
250 <td>
251 {h.allocated.vcpus}/{h.capacity.vcpus || '?'}c ·
252 {h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}MB ·
253 {h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}GB
254 </td>
255 <td>
256 {#if h.status !== 'decommissioning'}
257 <button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button>
258 {:else}
259 <span class="hint">draining…</span>
260 {/if}
261 </td>
262 </tr> 226 </tr>
263 {/each} 227 <tr><th class="num">vCPU</th><th class="num">mem MB</th><th class="num">disk GB</th></tr>
264 </tbody> 228 </thead>
265 </table> 229 <tbody>
230 {#each shownHosts as h (h.id)}
231 {@const load = loadRatio(h)}
232 <tr>
233 <td><a href="/hosts/{h.id}">{h.name}</a></td>
234 <td>
235 <span class="dot {h.online ? 'on' : 'off'}"></span>
236 {h.status}{h.online ? '' : ' · offline'}
237 </td>
238 <td title={osTitle(h)}>{osLabel(h)}</td>
239 <td>
240 {h.agent_version || '—'}
241 {#if h.agent_update_available}
242 <button class="ghost upgrade" onclick={() => upgrade(h)}>↑ {fleet.latest_version}</button>
243 {/if}
244 </td>
245 <td class="num">
246 {#if load !== null}
247 <span
248 class="load"
249 class:over={load > 1}
250 title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU"
251 >
252 {load.toFixed(2)}<span class="meter"
253 ><i style="width:{Math.min(load, 1) * 100}%"></i></span
254 >
255 </span>
256 {:else}
257 <span class="hint">—</span>
258 {/if}
259 </td>
260 <td class="num">{vmCountByHost.get(h.id) ?? 0}</td>
261 <td>{h.bridge_cidr || '—'}</td>
262 <td class="num">{h.allocated.vcpus}/{h.capacity.vcpus || '?'}</td>
263 <td class="num">{h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}</td>
264 <td class="num">{h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}</td>
265 <td>
266 {#if h.status !== 'decommissioning'}
267 <button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button>
268 {:else}
269 <span class="hint">draining…</span>
270 {/if}
271 </td>
272 </tr>
273 {/each}
274 </tbody>
275 </table>
276 </div>
266 {/if} 277 {/if}
267 </section> 278 </section>
268 279
@@ -277,38 +288,40 @@
277 {:else if shownVMs.length === 0} 288 {:else if shownVMs.length === 0}
278 <p class="hint">No VMs match “{filter}”.</p> 289 <p class="hint">No VMs match “{filter}”.</p>
279 {:else} 290 {:else}
280 <table> 291 <div class="scroll">
281 <thead> 292 <table>
282 <tr><th>Name</th><th>Host</th><th>Allocation</th><th>Status</th><th>Power</th><th></th></tr> 293 <thead>
283 </thead> 294 <tr><th>Name</th><th>Host</th><th class="num">Allocation</th><th>Status</th><th>Power</th><th></th></tr>
284 <tbody> 295 </thead>
285 {#each shownVMs as v (v.id)} 296 <tbody>
286 <tr> 297 {#each shownVMs as v (v.id)}
287 <td><a href="/vms/{v.id}">{v.name}</a></td> 298 <tr>
288 <td>{hostName(v.host_id)}</td> 299 <td><a href="/vms/{v.id}">{v.name}</a></td>
289 <td>{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td> 300 <td>{hostName(v.host_id)}</td>
290 <td> 301 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
291 {#if v.deleted}<span class="teardown">deleting—undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error 302 <td>
292 ? ` · ${v.last_error}` 303 {#if v.deleted}<span class="teardown">deleting—undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error
293 : ''} 304 ? ` · ${v.last_error}`
294 </td> 305 : ''}
295 <td>{vmPower(v)}</td> 306 </td>
296 <td class="actions"> 307 <td>{vmPower(v)}</td>
297 {#if v.deleted} 308 <td class="actions">
298 <button class="restore" onclick={() => restore(v.id)}>Undo delete</button> 309 {#if v.deleted}
299 {:else} 310 <button class="restore" onclick={() => restore(v.id)}>Undo delete</button>
300 {#if vmIsRunning(v)}
301 <button class="ghost" onclick={() => power(v.id, 'stopped')}>Stop</button>
302 {:else} 311 {:else}
303 <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button> 312 {#if vmIsRunning(v)}
313 <button class="danger" onclick={() => power(v.id, 'stopped')}>Stop</button>
314 {:else}
315 <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button>
316 {/if}
317 <button class="danger" onclick={() => remove(v.id)}>Delete</button>
304 {/if} 318 {/if}
305 <button class="danger" onclick={() => remove(v.id)}>Delete</button> 319 </td>
306 {/if} 320 </tr>
307 </td> 321 {/each}
308 </tr> 322 </tbody>
309 {/each} 323 </table>
310 </tbody> 324 </div>
311 </table>
312 {/if} 325 {/if}
313 </section> 326 </section>
314 327
@@ -362,118 +375,115 @@
362 .row { 375 .row {
363 display: flex; 376 display: flex;
364 align-items: center; 377 align-items: center;
365 gap: 0.8rem; 378 gap: 0.75em;
366 } 379 }
367 .row.end { 380 .row.end {
368 justify-content: flex-end; 381 justify-content: flex-end;
369 margin-top: 0.5rem; 382 margin-top: 0.5em;
370 } 383 }
371 h2 { 384 h2 {
372 font-size: 14px; 385 font-size: inherit;
373 margin: 1rem 0 0; 386 margin: 1.5em 0 0.5em;
374 } 387 }
375 /* base .dot rule and .on/.off colors are global (+layout.svelte); this 388 h3 {
376 page's dot just wants a bit more breathing room before the text. */ 389 font-size: inherit;
377 .dot { 390 }
378 margin-right: 3px; 391 /* Every table keeps its natural width and scrolls sideways inside its own
379 } 392 box. Nothing is squeezed and nothing is truncated: a CIDR or a host name
380 .actions { 393 that does not fit is scrolled to, never elided into a half-truth. */
381 display: flex; 394 .scroll {
382 gap: 0.3rem; 395 overflow-x: auto;
396 }
397 .scroll table {
398 min-width: 100%;
399 }
400 /* The heading that spans the three capacity columns. Numbers align on their
401 digits (td.num/th.num, global) so capacity reads down the column instead
402 of drifting with the width of each value. */
403 .group {
404 text-align: center;
405 }
406 /* Normalized-load pressure: the number is the reading, and the meter beside
407 it is the same value at a glance. A host past 1.0 sets in bold. */
408 .load {
409 display: inline-block;
410 font-variant-numeric: tabular-nums;
383 } 411 }
384 /* Normalized-load pressure gauge: the number is the source of truth, the 412 .load.over {
385 bar + colour are a supplementary at-a-glance signal (calm/warm/hot). */ 413 font-weight: bold;
386 .loadcell {
387 display: inline-flex;
388 align-items: center;
389 gap: 0.4rem;
390 white-space: nowrap;
391 } 414 }
392 .loadbar { 415 .meter {
393 display: inline-block; 416 display: inline-block;
394 width: 34px; 417 width: 6ch;
395 height: 6px; 418 height: 3px;
396 border-radius: 3px; 419 background: var(--wash);
397 background: #2a2e37; 420 vertical-align: middle;
398 overflow: hidden; 421 margin-left: 0.6ch;
399 } 422 }
400 .loadbar > span { 423 .meter > i {
401 display: block; 424 display: block;
402 height: 100%; 425 height: 100%;
403 border-radius: 3px; 426 background: var(--ink);
404 }
405 .loadbar.calm > span {
406 background: #8fe3a0;
407 } 427 }
408 .loadbar.warm > span { 428 .upgrade {
409 background: #e3c98f; 429 margin-left: 0.5em;
410 } 430 }
411 .loadbar.hot > span { 431 .actions {
412 background: #e38f8f; 432 display: flex;
433 gap: 0.5em;
413 } 434 }
414 .enroll { 435 .enroll {
415 background: #15171c; 436 border: 1px solid var(--hairline);
416 border: 1px solid #2a2e37; 437 padding: 1em;
417 border-radius: 4px; 438 margin: 0.5em 0;
418 padding: 0.5rem; 439 overflow-x: auto;
419 margin: 0.5rem 0;
420 }
421 .update-banner {
422 background: #15171c;
423 border: 1px solid #2a2e37;
424 border-radius: 4px;
425 padding: 0.5rem 0.8rem;
426 margin: 0.8rem 0 0;
427 color: #9aa0aa;
428 }
429 .upgrade {
430 margin-left: 0.4rem;
431 padding: 0.1rem 0.45rem;
432 font-size: 12px;
433 } 440 }
434 .enroll code { 441 .enroll code {
435 display: block; 442 display: block;
436 margin-top: 0.3rem;
437 color: #8fe3a0;
438 word-break: break-all; 443 word-break: break-all;
439 } 444 }
445 .update-banner {
446 display: inline-block;
447 border: 1px solid var(--hairline);
448 padding: 0.25em 0.75em;
449 margin: 1em 0 0;
450 }
440 .modal { 451 .modal {
441 position: fixed; 452 position: fixed;
442 inset: 0; 453 inset: 0;
443 background: rgba(0, 0, 0, 0.6); 454 background: rgba(0, 0, 0, 0.5);
444 display: flex; 455 display: flex;
445 align-items: center; 456 align-items: center;
446 justify-content: center; 457 justify-content: center;
447 } 458 }
448 .card { 459 .card {
449 background: #15171c; 460 background: var(--paper);
450 border: 1px solid #2a2e37; 461 color: var(--ink);
451 border-radius: 8px; 462 border: 1px solid var(--ink);
452 padding: 1.2rem; 463 padding: 1em;
453 width: 420px; 464 width: 52ch;
454 max-width: 92vw; 465 max-width: 92vw;
455 display: flex; 466 display: flex;
456 flex-direction: column; 467 flex-direction: column;
457 gap: 0.5rem; 468 gap: 0.5em;
458 } 469 }
459 .card h3 { 470 .card h3 {
460 margin: 0 0 0.3rem; 471 margin: 0 0 0.25em;
461 } 472 }
462 label { 473 label {
463 display: flex; 474 display: flex;
464 flex-direction: column; 475 flex-direction: column;
465 gap: 0.2rem; 476 gap: 0.1em;
466 color: #9aa0aa;
467 } 477 }
468 label.checkbox { 478 label.checkbox {
469 flex-direction: row; 479 flex-direction: row;
470 align-items: center; 480 align-items: center;
471 gap: 0.4rem; 481 gap: 0.5em;
472 } 482 }
473 .grid { 483 .grid {
474 display: grid; 484 display: grid;
475 grid-template-columns: 1fr 1fr 1fr; 485 grid-template-columns: 1fr 1fr 1fr;
476 gap: 0.5rem; 486 gap: 0.5em;
477 } 487 }
478 /* Let grid columns shrink to their 1fr share; number inputs have an 488 /* Let grid columns shrink to their 1fr share; number inputs have an
479 intrinsic min-width that otherwise pushes the third column past the card. */ 489 intrinsic min-width that otherwise pushes the third column past the card. */
@@ -485,24 +495,28 @@
485 .card textarea { 495 .card textarea {
486 width: 100%; 496 width: 100%;
487 } 497 }
498 /* A tick box keeps its own size; only the fields fill the card. */
499 .card input[type='checkbox'] {
500 width: auto;
501 }
488 .filter { 502 .filter {
489 margin-top: 0.8rem; 503 margin-top: 1em;
490 width: 260px; 504 width: 28ch;
491 } 505 }
492 .teardown { 506 .teardown {
493 color: #f0b429; 507 font-weight: bold;
494 font-weight: 600;
495 } 508 }
496 .onboard { 509 .onboard {
497 background: #15171c; 510 border: 1px solid var(--hairline);
498 border: 1px solid #2a2e37; 511 padding: 1em;
499 border-radius: 8px; 512 margin: 0.5em 0;
500 padding: 1.2rem; 513 max-width: 72ch;
501 margin: 0.5rem 0;
502 max-width: 620px;
503 } 514 }
504 .onboard h3 { 515 .onboard h3 {
505 margin: 0 0 0.4rem; 516 margin: 0 0 0.5em;
517 }
518 .onboard p {
519 margin-bottom: 0.5em;
506 } 520 }
507 .onboard .enroll { 521 .onboard .enroll {
508 margin-bottom: 0; 522 margin-bottom: 0;
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -135,28 +135,17 @@
135 135
136 <style> 136 <style>
137 h2 { 137 h2 {
138 font-size: 14px; 138 font-size: inherit;
139 } 139 }
140 .resources { 140 .resources {
141 max-width: 460px; 141 max-width: 60ch;
142 } 142 }
143 .note { 143 .note {
144 color: #6b7280; 144 color: var(--faint);
145 max-width: 600px; 145 max-width: 72ch;
146 margin: 0.3rem 0 1rem; 146 margin: 0.3em 0 1em;
147 }
148 .kv th {
149 color: #8b919c;
150 width: 140px;
151 } 147 }
152 .upgrade { 148 .upgrade {
153 margin-left: 0.4rem; 149 margin-left: 0.5em;
154 padding: 0.1rem 0.45rem;
155 font-size: 12px;
156 }
157 /* base .dot rule and .on/.off colors are global (+layout.svelte); this
158 page's dot just wants a bit more breathing room before the text. */
159 .dot {
160 margin-right: 4px;
161 } 150 }
162 </style> 151 </style>
web/src/routes/settings/+page.svelte
Old New
@@ -129,7 +129,7 @@
129 <h2>Identity</h2> 129 <h2>Identity</h2>
130 </div> 130 </div>
131 {#if fleet.me} 131 {#if fleet.me}
132 <table> 132 <table class="kv">
133 <tbody> 133 <tbody>
134 <tr><th>Email</th><td>{fleet.me.email}</td></tr> 134 <tr><th>Email</th><td>{fleet.me.email}</td></tr>
135 <tr><th>Tenant</th><td>{fleet.me.tenant}</td></tr> 135 <tr><th>Tenant</th><td>{fleet.me.tenant}</td></tr>
@@ -244,87 +244,82 @@
244 .row { 244 .row {
245 display: flex; 245 display: flex;
246 align-items: center; 246 align-items: center;
247 gap: 0.8rem; 247 gap: 0.75em;
248 } 248 }
249 h2 { 249 h2 {
250 font-size: 14px; 250 font-size: inherit;
251 margin: 1rem 0 0; 251 margin: 1.5em 0 0.5em;
252 } 252 }
253 .actions { 253 .actions {
254 display: flex; 254 display: flex;
255 gap: 0.3rem; 255 gap: 0.5em;
256 } 256 }
257 .enroll { 257 .enroll {
258 background: #15171c; 258 border: 1px solid var(--hairline);
259 border: 1px solid #2a2e37; 259 padding: 1em;
260 border-radius: 4px; 260 margin: 0.5em 0;
261 padding: 0.5rem; 261 overflow-x: auto;
262 margin: 0.5rem 0;
263 } 262 }
264 .enroll code { 263 .enroll code {
265 display: block; 264 display: block;
266 margin-top: 0.3rem;
267 color: #8fe3a0;
268 word-break: break-all; 265 word-break: break-all;
269 } 266 }
270 .pat-form { 267 .pat-form {
271 display: flex; 268 display: flex;
272 gap: 0.5rem; 269 gap: 0.5em;
273 align-items: flex-end; 270 align-items: flex-end;
274 flex-wrap: wrap; 271 flex-wrap: wrap;
275 margin-top: 0.5rem; 272 margin-top: 0.5em;
276 } 273 }
277 .pat-form label { 274 .pat-form label {
278 display: flex; 275 display: flex;
279 flex-direction: column; 276 flex-direction: column;
280 gap: 0.2rem; 277 gap: 0.1em;
281 color: #9aa0aa; 278 color: var(--faint);
282 } 279 }
280 /* The one secret the console ever shows, and it shows it once: ink border,
281 like the modal, so it does not read as another panel. */
283 .secret-box { 282 .secret-box {
284 background: #15171c; 283 border: 1px solid var(--ink);
285 border: 1px solid #2a5a35; 284 padding: 0.8em;
286 border-radius: 6px; 285 margin: 0.6em 0;
287 padding: 0.8rem; 286 max-width: 72ch;
288 margin: 0.6rem 0;
289 } 287 }
290 .secret-head { 288 .secret-head {
291 display: flex; 289 display: flex;
292 align-items: center; 290 align-items: center;
293 justify-content: space-between; 291 justify-content: space-between;
294 gap: 0.6rem; 292 gap: 0.75em;
295 } 293 }
296 .secret-box .warn { 294 .secret-box .warn {
297 color: #f0b429; 295 color: var(--bad);
298 margin: 0.4rem 0; 296 margin: 0.4em 0;
299 } 297 }
300 .secret-value { 298 .secret-value {
301 display: flex; 299 display: flex;
302 align-items: center; 300 align-items: center;
303 gap: 0.5rem; 301 gap: 0.5em;
304 } 302 }
305 .secret-value code { 303 .secret-value code {
306 flex: 1; 304 flex: 1;
307 background: #0c0d10; 305 border: 1px solid var(--hairline);
308 border: 1px solid #2a2e37; 306 padding: 0.05em 0.6em;
309 border-radius: 4px;
310 padding: 0.3rem 0.5rem;
311 color: #8fe3a0;
312 word-break: break-all; 307 word-break: break-all;
313 } 308 }
314 .ca-add { 309 .ca-add {
315 display: flex; 310 display: flex;
316 gap: 0.5rem; 311 gap: 0.5em;
317 align-items: flex-end; 312 align-items: flex-end;
318 flex-wrap: wrap; 313 flex-wrap: wrap;
319 margin-top: 0.5rem; 314 margin-top: 0.5em;
320 } 315 }
321 .ca-add label { 316 .ca-add label {
322 flex: 1; 317 flex: 1;
323 min-width: 200px; 318 min-width: 25ch;
324 display: flex; 319 display: flex;
325 flex-direction: column; 320 flex-direction: column;
326 gap: 0.2rem; 321 gap: 0.1em;
327 color: #9aa0aa; 322 color: var(--faint);
328 } 323 }
329 .ca-add input { 324 .ca-add input {
330 width: 100%; 325 width: 100%;
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -258,7 +258,7 @@
258 {:else} 258 {:else}
259 <span class="hint">host address not yet known · :{e.host_port}</span> 259 <span class="hint">host address not yet known · :{e.host_port}</span>
260 {/if} 260 {/if}
261 <span class="state {exposureState(e)}">● {exposureState(e)}</span> 261 <span class="state {exposureState(e)}">●</span> {exposureState(e)}
262 {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if} 262 {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if}
263 </td> 263 </td>
264 <td> 264 <td>
@@ -306,11 +306,9 @@
306 306
307 <style> 307 <style>
308 h2 { 308 h2 {
309 font-size: 14px; 309 font-size: inherit;
310 } 310 }
311 .kv th { 311 .kv th {
312 color: #8b919c;
313 width: 140px;
314 vertical-align: top; 312 vertical-align: top;
315 } 313 }
316 .wrap { 314 .wrap {
@@ -318,83 +316,69 @@
318 white-space: normal; 316 white-space: normal;
319 } 317 }
320 .err { 318 .err {
321 color: #ffb4b4; 319 color: var(--bad);
322 } 320 }
323 .actions { 321 .actions {
324 display: flex; 322 display: flex;
325 gap: 0.4rem; 323 gap: 0.5em;
326 margin-top: 0.8rem; 324 margin-top: 0.8em;
327 } 325 }
326 /* A VM on its way out is worth a box of its own, so the undo is not one
327 button among several. Ink border, like the modal: this one is loud. */
328 .teardown-callout { 328 .teardown-callout {
329 background: #2a2213; 329 border: 1px solid var(--ink);
330 border: 1px solid #7a5c12; 330 padding: 0.7em 0.9em;
331 border-radius: 6px; 331 margin-top: 0.9em;
332 padding: 0.7rem 0.9rem; 332 max-width: 72ch;
333 margin-top: 0.9rem;
334 } 333 }
335 .teardown-callout h3 { 334 .teardown-callout h3 {
336 margin: 0 0 0.3rem; 335 margin: 0 0 0.3em;
337 font-size: 13px; 336 font-size: inherit;
338 color: #f0b429;
339 } 337 }
340 .teardown-callout p { 338 .teardown-callout p {
341 margin: 0 0 0.6rem; 339 margin: 0 0 0.6em;
342 color: #d6c48a;
343 }
344 :global(button.restore) {
345 background: #2f9e44;
346 border-color: #2f9e44;
347 font-weight: 600;
348 }
349 :global(button.restore:hover) {
350 background: #37b24d;
351 } 340 }
352 .exposures { 341 .exposures {
353 margin-top: 1.2rem; 342 margin-top: 1.2em;
354 } 343 }
355 .exposures h3 { 344 .exposures h3 {
356 margin: 0 0 0.2rem; 345 margin: 0 0 0.2em;
357 font-size: 13px; 346 font-size: inherit;
358 } 347 }
359 .exposures .kv th { 348 .exposures .kv th {
360 color: #8b919c; 349 width: 20ch;
361 width: 140px;
362 font-weight: 400;
363 white-space: nowrap;
364 } 350 }
351 /* The glyph takes the state's colour; the word beside it stays ink, so the
352 state reads without it. */
365 .state { 353 .state {
366 margin-left: 0.5rem; 354 margin-left: 0.5em;
367 font-size: 12px;
368 } 355 }
369 .state.active { 356 .state.active {
370 color: #51cf66; 357 color: var(--ok);
371 } 358 }
372 .state.failed { 359 .state.failed {
373 color: #ffb4b4; 360 color: var(--bad);
374 } 361 }
375 .state.pending, 362 .state.pending,
376 .state.deleting { 363 .state.deleting {
377 color: #8b919c; 364 color: var(--faint);
378 } 365 }
379 .expose-form { 366 .expose-form {
380 display: flex; 367 display: flex;
381 gap: 0.4rem; 368 gap: 0.5em;
382 margin-top: 0.5rem; 369 margin-top: 0.5em;
383 } 370 }
384 .expose-form input { 371 .expose-form input {
385 width: 10rem; 372 width: 24ch;
386 } 373 }
387 .timeline { 374 .timeline {
388 margin-top: 1.2rem; 375 margin-top: 1.2em;
389 } 376 }
390 .timeline h3 { 377 .timeline h3 {
391 margin: 0 0 0.2rem; 378 margin: 0 0 0.2em;
392 font-size: 13px; 379 font-size: inherit;
393 } 380 }
394 .timeline .kv th { 381 .timeline .kv th {
395 color: #8b919c; 382 width: 30ch;
396 width: 220px;
397 font-weight: 400;
398 white-space: nowrap;
399 } 383 }
400 </style> 384 </style>