a73x

web/src/routes/+layout.svelte

Ref:   Size: 7.1 KiB   History

<script lang="ts">
	import favicon from '$lib/assets/favicon.svg';
	import { onMount } from 'svelte';
	import { fleet, fetchMe, connect, refresh, dismissError, startClock } from '$lib/fleet.svelte';
	let { children } = $props();

	onMount(async () => {
		startClock();
		try {
			// A 401 here redirects to /auth/login (handled in req); a throw means
			// we're on our way out, so render nothing further.
			await fetchMe();
		} catch {
			return;
		}
		refresh();
		connect();
	});

	async function signOut() {
		await fetch('/auth/logout', { method: 'POST' });
		window.location.href = '/';
	}

	// Dismissing the banner removes the focused button from the DOM, which
	// drops keyboard focus to <body>. Land it somewhere useful instead: the
	// fleet filter when present (overview), else the first main link/control.
	function dismissAndRefocus() {
		dismissError();
		// An open modal owns focus: dismissing the banner above it must not
		// send keyboard focus behind the overlay.
		const target =
			document.querySelector<HTMLElement>('.modal input, .modal select, .modal button') ??
			document.getElementById('fleet-filter') ??
			document.querySelector<HTMLElement>('main a, main button, main input');
		target?.focus();
	}
</script>

<svelte:head>
	<link rel="icon" href={favicon} />
	<title>eitri fleet</title>
</svelte:head>

<header>
	<a href="/" class="brand">eitri</a>
	<span class="fleet-label">fleet</span>
	<span class="spacer"></span>
	{#if fleet.me}
		<span class="dot {fleet.connected ? 'on' : 'off'}"></span>
		<span class="conn">{fleet.connected ? 'live' : 'disconnected'}</span>
		<span class="email">{fleet.me.email}</span>
		<a href="/settings">settings</a>
		<button type="button" class="ghost" onclick={signOut}>Sign out</button>
	{/if}
</header>

{#if fleet.error}
	<div class="error" role="alert">
		<span>{fleet.error}</span>
		<button type="button" class="dismiss" onclick={dismissAndRefocus} aria-label="dismiss error">✕</button>
	</div>
{/if}

<main>
	{#if fleet.me}
		{@render children()}
	{:else}
		<p class="hint">Signing in…</p>
	{/if}
</main>

<style>
	/* The console is derived from the site (site/style.css) and softened for an
	   app: the same monospace and the same square unfilled boxes, but rules
	   instead of a full cell grid. The vocabulary is named once here — ink,
	   paper, hairline, faint, wash — so the rest of the console inherits it
	   instead of carrying literals. */
	:global(:root) {
		color-scheme: light dark;
		--ink: CanvasText;
		--paper: Canvas;
		--hairline: color-mix(in srgb, CanvasText 25%, transparent);
		--faint: color-mix(in srgb, CanvasText 55%, transparent);
		--wash: color-mix(in srgb, CanvasText 6%, transparent);
		--ok: #3a9e4f;
		--bad: #c2503a;
	}
	:global(*) {
		margin: 0;
		padding: 0;
		box-sizing: border-box;
	}
	:global(body) {
		font-family: monospace;
		line-height: 1.5;
		color: var(--ink);
		background: var(--paper);
	}
	:global(a) {
		color: inherit;
	}
	:global(h1) {
		margin-bottom: 0.5em;
	}
	:global(h2) {
		margin: 1.5em 0 0.5em;
	}
	:global(h3) {
		margin: 1em 0 0.5em;
	}
	:global(p, ul, ol) {
		margin-bottom: 1em;
	}
	:global(ul, ol) {
		padding-left: 2em;
	}
	:global(li) {
		margin-bottom: 0.25em;
	}
	:global(code) {
		word-break: break-all;
	}
	/* One strong rule under the head, a hairline between rows, and a wash under
	   the pointer: enough structure to track a row across nine columns without
	   boxing every cell. */
	:global(table) {
		border-collapse: collapse;
		margin: 0.5em 0 1.5em;
	}
	:global(th) {
		border-bottom: 1px solid var(--ink);
		padding: 0.35em 0.9em 0.35em 0;
		text-align: left;
		font-weight: bold;
		white-space: nowrap;
	}
	:global(td) {
		border-bottom: 1px solid var(--hairline);
		padding: 0.45em 0.9em 0.45em 0;
		white-space: nowrap;
	}
	:global(tr:hover td) {
		background: var(--wash);
	}
	/* A key/value table has no head, so its keys are labels rather than
	   headings: faint, unemphasised, and ruled like any other row. */
	:global(.kv th) {
		border-bottom: 1px solid var(--hairline);
		color: var(--faint);
		font-weight: normal;
		vertical-align: top;
		width: 20ch;
	}
	:global(td.num, th.num) {
		text-align: right;
		font-variant-numeric: tabular-nums;
	}
	/* A status light is a glyph, not a swatch: filled and green for on, hollow
	   and faint for off. Only the glyph takes the colour — the label beside it
	   stays ink, so the state reads without it. */
	:global(.dot) {
		display: inline-block;
		margin-right: 0.25em;
	}
	:global(.dot.on) {
		color: var(--ok);
	}
	:global(.dot.off) {
		color: var(--faint);
	}
	:global(.dot.on::before) {
		content: '●';
	}
	:global(.dot.off::before) {
		content: '○';
	}
	:global(.hint) {
		color: var(--faint);
	}
	/* The two-column split the VM, host and settings pages share: facts down the
	   wider left, what-you-can-do down the right, stated once here so the three
	   pages cannot drift apart. Below the breakpoint there is no grid at all and
	   the blocks stack in source order. A column is not a page—each table inside
	   it keeps its natural width and scrolls sideways in its own .scroll box, so
	   a fingerprint or a CPU model is scrolled to rather than squeezed and the
	   page itself never scrolls. The overflow-x stays inside the query on
	   purpose: an always-on one makes each box a formatting context, which stops
	   margins collapsing and changes the stacked spacing narrower than this. */
	@media (min-width: 1100px) {
		:global(.columns) {
			display: grid;
			grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
			column-gap: 3em;
			align-items: start;
		}
		:global(.scroll) {
			overflow-x: auto;
		}
		:global(.scroll table) {
			min-width: 100%;
		}
	}
	header {
		display: flex;
		align-items: center;
		gap: 0.75em;
		padding: 1em;
		border-bottom: 1px solid var(--hairline);
	}
	.brand {
		font-weight: bold;
		text-decoration: none;
	}
	.fleet-label,
	.conn,
	.email {
		color: var(--faint);
	}
	.spacer {
		flex: 1;
	}
	/* Controls are real but quiet: a hairline box that firms up to ink when you
	   reach for it, and to red when what it does cannot be undone. */
	:global(input, select, textarea) {
		font: inherit;
		color: inherit;
		background: none;
		border: 1px solid var(--hairline);
		border-radius: 0;
		padding: 0.05em 0.6em;
		box-sizing: border-box;
	}
	:global(input:focus, select:focus, textarea:focus) {
		border-color: var(--ink);
	}
	:global(button) {
		font: inherit;
		color: inherit;
		background: none;
		border: 1px solid var(--hairline);
		border-radius: 0;
		padding: 0.05em 0.6em;
		cursor: pointer;
	}
	:global(button:hover) {
		border-color: var(--ink);
	}
	:global(button.danger:hover) {
		border-color: var(--bad);
		color: var(--bad);
	}
	/* A tick box is the one control the browser draws better than we can. */
	:global(input[type='checkbox'], input[type='radio']) {
		border: 0;
		padding: 0;
	}
	/* Wide enough for a full fleet table; the site's 72ch measure is for prose,
	   and it is the language that carries over, not the measure. */
	main {
		padding: 1em;
		max-width: 170ch;
		margin: 0 auto;
	}
	.error {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 0.75em;
		padding: 0.5em 1em;
		border-bottom: 1px solid var(--hairline);
		color: var(--bad);
	}
</style>