From b7250343a9e1ec4224346be365cbbb5aae38ef31 Mon Sep 17 00:00:00 2001 From: kleinbem Date: Wed, 11 Nov 2026 02:39:59 +0000 Subject: [PATCH 1/2] fix(ui): palette keyboard access, visible focus, theme tokens, docs The palette's Enter handler hijacked Enter on focused result links, focus was neither contained nor restored, and the dialog and input had no names. focus:outline-hidden suppressed web-config's :focus-visible ring on cards and host buttons. *-brand, bg-bg-surface and text-text-primary had no theme variables, so they emitted no CSS. README lists orin-nano, AGENTS.md says the catalogue is hand-maintained and why TS 6 and 7 sit side by side. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 +++- README.md | 2 +- src/app.css | 10 +++++ src/lib/components/CommandPalette.svelte | 47 ++++++++++++++++++++---- src/lib/components/HostBar.svelte | 2 +- src/lib/components/ServiceCard.svelte | 16 ++++---- 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f559cbc..2f62f63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ It replaces legacy heavy dashboards (like Homepage) with a fast, modern static S ## Key Capabilities - **Fleet Design System**: Uses `@kleinbem/web-config` themes (dark/light, system preference, zero-FOUC). -- **Fleet Inventory Integration**: Reads services dynamically from `inventory.nix` data. +- **Service catalogue**: hand-maintained `FLEET_SERVICES` in `src/lib/data/services.ts` and `FLEET_HOSTS` in `src/lib/data/hosts.ts` (not generated from `inventory.nix`). - **Network Reachability Indicators**: Highlights Public (Cloudflare Tunnel), Mesh-Only (NetBird), and LAN endpoints. - **Gatus Live Health**: Queries live service statuses from `status.kleinbem.dev`. - **Command Palette (`⌘K`)**: Instant fuzzy search across services, hosts, and tools. @@ -24,3 +24,9 @@ just check # Run svelte-check and TypeScript checks just build # Build static production assets just preview # Preview production build ``` + +## TypeScript + +`typescript` is aliased to `@typescript/typescript6` and TypeScript 7 sits beside it as +`@typescript/native`: svelte-check and the Svelte tooling need the TS 6 JavaScript API, which the +native TS 7 compiler does not ship. Same setup in every fleet Svelte repo. diff --git a/README.md b/README.md index e7720f0..2addcac 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Foyer serves as the front door to [`home.kleinbem.dev`](https://home.kleinbem.de - **Network Reachability Awareness**: Clear visual badges for **Public** (Cloudflare Tunnel), **Mesh-Only** (NetBird), and **LAN-Only** services. - **Real-Time Health & Uptime**: Direct live status integration with the fleet's [Gatus](https://status.kleinbem.dev) instance. - **Command Palette (`⌘K` / `/`)**: Fast keyboard-driven fuzzy search across all services, host nodes, and quick links. -- **Host Node Filtering**: Filter services dynamically by node (`core-pi`, `nixos-nvme`, `mac-mini`, `nasbook`, `bpi-r4`). +- **Host Node Filtering**: Filter services dynamically by node (`core-pi`, `nixos-nvme`, `mac-mini`, `nasbook`, `orin-nano`, `bpi-r4`). - **Live Weather & Telemetry**: Built-in weather widget (Open-Meteo) and system clocks. ## 🛠️ Development diff --git a/src/app.css b/src/app.css index 38b70ad..75a5100 100644 --- a/src/app.css +++ b/src/app.css @@ -3,6 +3,16 @@ @import "@kleinbem/web-config/base.css"; @import "@kleinbem/web-config/badges.css"; +/* App-level names for web-config's tokens; without these the *-brand, bg-bg-surface and + text-text-primary utilities generate no CSS. */ +@theme inline { + --color-bg-surface: var(--color-bg); + --color-bg-elevated: var(--color-surface); + --color-text-primary: var(--color-heading); + --color-border-subtle: var(--color-line); + --color-brand: var(--color-accent); +} + @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *, .dark, .dark *)); /* Glassmorphism utility for modern card and navbar styling */ diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 8379896..16a7013 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -12,6 +12,7 @@ let query = $state(''); let selectedIndex = $state(0); let inputEl = $state(null); + let dialogEl = $state(null); let copyNotice = $state(null); let filtered = $derived.by(() => { @@ -64,14 +65,34 @@ }); $effect(() => { - if (open) { - query = ''; - selectedIndex = 0; - copyNotice = null; - setTimeout(() => inputEl?.focus(), 50); - } + if (!open) return; + const previous = document.activeElement as HTMLElement | null; + query = ''; + selectedIndex = 0; + copyNotice = null; + setTimeout(() => inputEl?.focus(), 50); + return () => previous?.focus?.(); }); + // Keep Tab inside the open dialog. + function trapTab(e: KeyboardEvent) { + if (!dialogEl) return; + const focusable = Array.from( + dialogEl.querySelectorAll('a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])') + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || !dialogEl.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (active === last || !dialogEl.contains(active))) { + e.preventDefault(); + first.focus(); + } + } + $effect(() => { // Reset selected index when query changes query; @@ -103,7 +124,12 @@ e.preventDefault(); open = true; } - if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') { + if ( + e.key === '/' && + !(e.metaKey || e.ctrlKey || e.altKey) && + !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName ?? '') && + !(document.activeElement as HTMLElement | null)?.isContentEditable + ) { e.preventDefault(); open = true; } @@ -113,6 +139,8 @@ if (e.key === 'Escape') { e.preventDefault(); open = false; + } else if (e.key === 'Tab') { + trapTab(e); } else if ((e.metaKey || e.ctrlKey) && e.key === 'c') { // Copy highlighted URL if input text isn't selected if ( @@ -131,6 +159,8 @@ e.preventDefault(); selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(1, filtered.length); } else if (e.key === 'Enter') { + // A focused result link or button activates itself natively. + if (e.target instanceof HTMLAnchorElement || e.target instanceof HTMLButtonElement) return; e.preventDefault(); if (query.trim().startsWith('>')) { webSearch(query.trim().slice(1)); @@ -152,9 +182,11 @@ {#if open}