From e8ecf2fd7a30bf556aed5c2bf9df2e650d80c438 Mon Sep 17 00:00:00 2001 From: Thibaut Lapierre Date: Wed, 2 Sep 2026 13:38:37 +0200 Subject: [PATCH 1/2] web: staged power button; Summary becomes the fact-card grid Power gets a first-class toolbar button that stages the runStrategy change and says so (toast + staged badge) - burying the most basic verb inside Edit Settings was counterintuitive. Summary reorganizes into six cards (Guest/Hardware/Placement/GitOps/Issues/Capacity), each with its next action; tiles fold into the cards they duplicated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B2CJp2mhGv61yHsQQqstGP --- web/e2e/ui/shell.spec.ts | 12 +- web/src/lib/components/VMDetail.svelte | 46 ++++- web/src/lib/components/VMSummary.svelte | 263 +++++++++++++----------- 3 files changed, 195 insertions(+), 126 deletions(-) diff --git a/web/e2e/ui/shell.spec.ts b/web/e2e/ui/shell.spec.ts index 94ad274..fc99f57 100644 --- a/web/e2e/ui/shell.spec.ts +++ b/web/e2e/ui/shell.spec.ts @@ -42,12 +42,16 @@ test('base: VM detail opens with live facts and tabs', async ({ page }) => { await expect(page.getByRole('link', { name: 'Snapshots', exact: true })).toBeVisible(); await expect(page.getByRole('link', { name: 'Console', exact: true })).toBeVisible(); - // The flat toolbar: imperative verbs promoted, power deliberately absent - // (declarative here), delete demoted into the Actions menu. + // The flat toolbar: power first (declarative - the button STAGES a change), + // then the imperative verbs; delete demoted into the Actions menu. + await expect(page.getByRole('button', { name: 'Power off', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Restart', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Migrate', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: /Power (on|off)/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Migrate', exact: true }).first()).toBeVisible(); await expect(page.getByRole('button', { name: 'Delete VM' })).toHaveCount(0); + await page.getByRole('button', { name: 'Power off', exact: true }).click(); + // The stage lands as a toast + staged badge, never an immediate power change. + await expect(page.getByText(/Power Off staged for web-1/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Staged' }).first()).toBeVisible(); await page.getByRole('button', { name: 'Actions' }).click(); await expect(page.getByRole('button', { name: 'Delete VM' })).toBeVisible(); await page.keyboard.press('Escape'); diff --git a/web/src/lib/components/VMDetail.svelte b/web/src/lib/components/VMDetail.svelte index b100575..69e1efc 100644 --- a/web/src/lib/components/VMDetail.svelte +++ b/web/src/lib/components/VMDetail.svelte @@ -24,6 +24,7 @@ Play, RotateCw, Server, + Square, } from 'lucide-svelte'; import { api, Unauthorized, type Change, type DraftItem, type Network, type VM } from '$lib/api'; import { adoptVM, manifestURL, runRuntimeAction, vmActions, type VMAction } from '$lib/actions'; @@ -126,12 +127,12 @@ // Actions menu. Power is deliberately absent - it is a declarative // (staged, PR-gated) runStrategy change here, and a flat button would read // as immediate. Pause is the instant containment verb instead. - const TOOLBAR: { id: VMAction['id']; icon: typeof Monitor; label: string }[] = [ - { id: 'console', icon: Monitor, label: 'Console' }, - { id: 'migrate', icon: ArrowRightLeft, label: 'Migrate' }, + const TOOLBAR: { id: VMAction['id']; icon: typeof Monitor; label: string; sep?: boolean }[] = [ { id: 'restart', icon: RotateCw, label: 'Restart' }, { id: 'pause', icon: Pause, label: 'Pause' }, { id: 'unpause', icon: Play, label: 'Unpause' }, + { id: 'console', icon: Monitor, label: 'Console', sep: true }, + { id: 'migrate', icon: ArrowRightLeft, label: 'Migrate' }, ]; const toolbar = $derived.by(() => { const v = vm; @@ -143,6 +144,33 @@ }); const PROMOTED: VMAction['id'][] = ['console', 'migrate', 'restart', 'pause', 'unpause', 'edit']; + // Power is declarative here (a staged runStrategy change), but it still + // deserves a first-class button: hiding it inside Edit Settings made the + // most basic verb the hardest to find. The button stages and says so. + let powerBusy = $state(false); + async function stagePower() { + const target = vm; + if (!target?.sourceFile || powerBusy) return; + const to = target.power === 'On' ? 'Off' : 'On'; + powerBusy = true; + try { + await api.stageEdit(target.namespace, target.name, { + sourceFile: target.sourceFile, + power: to, + }); + onstaged?.(); + ui.showToast(`Power ${to} staged for ${target.name} — applies when the PR merges.`, { + kind: 'success', + action: { label: 'Review & propose', run: () => ui.openChanges() }, + }); + } catch (e) { + if (e instanceof Unauthorized) return; + ui.showToast(friendlyError(e), { kind: 'error' }); + } finally { + powerBusy = false; + } + } + function loadDrift(ns: string, name: string) { // Drop a stale response if the selection moved while it was in flight - // VM A's drift must never render under VM B. @@ -310,8 +338,19 @@
+ {#each toolbar as t (t.id)} {@const Icon = t.icon} + {#if t.sep}{/if} - - {Math.round(usage.cpu.used)}% used + {/if} + {/snippet} +
+ + + {#if stagedChanges.has('Power')} + + {:else}{vm.power}{/if} + + + +
+ {#if vm.ips?.length} + {#each vm.ips as ip (ip)}
{ip}
{/each} + {:else}{vm.guestIP || '—'}{/if}
+
+ +
+ + + + {#snippet action()} + {#if onedit && vm.sourceFile} + {/if} - - + {/if} + {/snippet} +
+ + {#if vm.nodeName} + {vm.nodeName} - {:else}—{/if} -
- {#if usage && usage.memory.total} -
—{/if} + + + g.name).join(', ') ?? ''} + /> + + + + + + + {#snippet action()} + {#if stagedItem} + - - {fmtUsage('bytes', usage.memory.used)} used -
- {:else if vm.memoryActual && vm.memory && vm.memoryActual !== vm.memory} -
{vm.memoryActual} live
{/if} - -
-
- Disks -
-
{vm.disks?.length ?? 0}
-
-
-
- Status -
-
{statusText}
- {#if duration(vm.startedAt)}
- up {duration(vm.startedAt)} -
{/if} + {/snippet} +
+
-
-
- -
- - onconsole()} /> - +
+ + + 1 ? 's' : ''} differ — see below` + : 'Identical'} + /> +
+ -
- - - {#snippet action()} - {#if vm.phase === 'Running'} - + + {#if vmIssues.length === 0} +

+ No standing problems. +

+ {:else} +
    + {#each vmIssues as i (i.label)} +
  • + + {i.label}{i.detail ? ` — ${i.detail}` : ''} +
  • + {/each} +
{/if} - {/snippet} -
- - - {#if stagedChanges.has('Power')} - - {:else}{vm.power}{/if} - - - -
- {#if vm.ips?.length} - {#each vm.ips as ip (ip)}
{ip}
{/each} - {:else}{vm.guestIP || '—'}{/if} -
-
-
-
+
- - - {#snippet action()} - {#if onedit && vm.sourceFile} - - {/if} - {/snippet} -
- - - - -
-
+ +
+ onconsole()} /> {#if !vm.sourceFile} From 1947f327d6384a63890476d509aba413e4fd346b Mon Sep 17 00:00:00 2001 From: Thibaut Lapierre Date: Wed, 2 Sep 2026 13:38:37 +0200 Subject: [PATCH 2/2] web: dark theme carries color in accent and status, not the ground Surfaces drop from slate chroma 0.04 to near-neutral 0.012-0.016 (navy ground drowned the accent), hairlines a step more visible, selection solid instead of alpha, badge tints lifted off the invisible -950s. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B2CJp2mhGv61yHsQQqstGP --- web/src/app.css | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 1fc601f..77a4e98 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -101,17 +101,20 @@ :root[data-theme='dark'] { color-scheme: dark; - /* surfaces */ - --color-canvas: #0b1120; /* between slate-900 and slate-950 */ - --color-panel: oklch(20.8% 0.042 265.755); /* slate-900 */ - --color-inset: oklch(23.9% 0.041 262.5); /* a step above panel */ - --color-inset-strong: oklch(27.9% 0.041 260.031); /* slate-800 */ - --color-bar: oklch(12.9% 0.042 264.695); /* slate-950: the header stays darkest */ - - /* hairlines */ - --color-line: oklch(27.9% 0.041 260.031); /* slate-800 */ - --color-line-strong: oklch(37.2% 0.044 257.287); /* slate-700 */ - --color-line-soft: oklch(24.5% 0.041 262); + /* surfaces: near-neutral darks (chroma <=0.016) - at slate's 0.04 every + surface reads navy and drowns the accent; color belongs to accent and + status, not the ground. Panel sits a clear step above canvas so cards + separate without shadows. */ + --color-canvas: oklch(14.5% 0.012 262); + --color-panel: oklch(20% 0.014 262); + --color-inset: oklch(23.5% 0.015 262); + --color-inset-strong: oklch(27.5% 0.016 262); + --color-bar: oklch(12.5% 0.014 262); /* the header stays darkest */ + + /* hairlines: same near-neutral family, one step more visible than before */ + --color-line: oklch(29% 0.018 262); + --color-line-strong: oklch(38% 0.02 260); + --color-line-soft: oklch(25% 0.016 262); /* ink */ --color-ink: oklch(96.8% 0.007 247.896); /* slate-100 */ @@ -128,9 +131,10 @@ --color-accent-hover: oklch(62.3% 0.214 259.815); /* blue-500 */ --color-accent-ink: oklch(70.7% 0.165 254.624); /* blue-400 */ - /* selection */ - --color-select: oklch(37.9% 0.146 265.522 / 0.45); /* blue-800 @ 45% */ - --color-select-soft: oklch(37.9% 0.146 265.522 / 0.25); + /* selection: solid tokens (alpha over varying grounds was unpredictable), + bright enough to read as the one blue plane on the neutral darks */ + --color-select: oklch(32% 0.085 262); + --color-select-soft: oklch(25.5% 0.05 262); /* accent tint for informational badges */ --color-accent-soft: oklch(28.2% 0.091 267.935); /* blue-950 */ @@ -139,9 +143,10 @@ --color-ok: oklch(76.5% 0.177 163.223); /* emerald-400 */ --color-warn: oklch(82.8% 0.189 84.429); /* amber-400 */ --color-danger: oklch(63.7% 0.237 25.331); /* red-500 */ - --color-ok-soft: oklch(26.6% 0.065 152.934); /* green-950 */ - --color-warn-soft: oklch(27.9% 0.077 45.635); /* amber-950 */ - --color-danger-soft: oklch(25.8% 0.092 26.042); /* red-950 */ + /* badge tints one step above the -950s, which vanished on the panels */ + --color-ok-soft: oklch(31% 0.07 153); + --color-warn-soft: oklch(32% 0.08 60); + --color-danger-soft: oklch(30% 0.09 26); --color-ok-ink: oklch(87.1% 0.15 154.449); /* green-300 */ --color-warn-ink: oklch(87.9% 0.169 91.605); /* amber-300 */ --color-danger-ink: oklch(80.8% 0.114 19.571); /* red-300 */