feat: apply imported design (bento grid, real elevation tokens, live indicator)
CI / Static checks (push) Successful in 33s
CI / Build and push image (push) Skipped

Imports the visual direction from a claude.ai/design session ("PulseNode
Dashboard.dc.html") and reconciles it with the app's existing choices
rather than a wholesale swap: kept the IBM Plex Sans/Mono + Space
Grotesk typography and the pulse-green accent concept from the earlier
pass, adopted the imported design's structural ideas - a real
tonal/elevation token system (derived divider/muted colors via
color-mix instead of hardcoded per-theme duplicates, actual box-shadow
elevation), a 12-column bento grid with per-widget-type column spans
instead of uniform equal-width cards, fading-edge section dividers,
and a shared WidgetCard/MetricBar/StatusTag vocabulary so every widget
type stops repeating its own card markup.

Icons come from @phosphor-icons/react's /ssr entrypoint (bundled at
build time) rather than the imported design's unpkg.com CDN script -
that script is fine for the standalone design-tool preview, but a
runtime third-party call would break the self-hosted-only principle
already established (fonts self-hosted via next/font, no external
requests). New app/icon.svg reuses the same nav badge mark as the
favicon, replacing the never-touched create-next-app default.

Two new pieces of real functionality prompted by the design's mockup
toast/live-indicator, not just decoration: a "live" WebSocket
connection-status indicator (lib/ws/client.ts now tracks and exposes
real connection state), and a toast that fires on actual config:update
and config:error events - the latter finally surfaces config validation
failures in the browser, previously visible only in server logs despite
being designed for exactly this back in M1.
This commit is contained in:
2026-08-17 17:21:18 +02:00
parent fa73750a28
commit e1e571754a
22 changed files with 486 additions and 192 deletions
+41 -24
View File
@@ -2,6 +2,7 @@
import { useState, type FormEvent } from "react";
import type { SearchEngine, Widget } from "@/lib/config/schema";
import { WidgetCard } from "@/components/widgets/WidgetCard";
type SearchWidget = Extract<Widget, { type: "search" }>;
@@ -11,6 +12,12 @@ const ENGINE_URLS: Record<string, string> = {
bing: "https://www.bing.com/search?q=",
};
const ENGINE_LABEL: Record<string, string> = {
duckduckgo: "DuckDuckGo",
google: "Google",
bing: "Bing",
};
export function SearchWidget({ widget }: { widget: SearchWidget }) {
const [engine, setEngine] = useState(widget.defaultEngine ?? widget.engines[0]);
const [query, setQuery] = useState("");
@@ -23,29 +30,39 @@ export function SearchWidget({ widget }: { widget: SearchWidget }) {
}
return (
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-2 sm:col-span-2"
>
{widget.engines.length > 1 && (
<select
value={engine}
onChange={(event) => setEngine(event.target.value as SearchEngine)}
className="rounded bg-transparent text-xs text-fg-muted outline-none"
>
{widget.engines.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-muted"
/>
</form>
<WidgetCard span={4}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col justify-center gap-2">
<div className="text-[10px] tracking-[0.1em] text-accent uppercase">Search</div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search the web…"
autoComplete="off"
className="min-h-9 w-full rounded-md border border-border bg-bg px-2.5 py-1.5 text-sm text-fg outline-none placeholder:text-fg-muted focus-visible:border-accent"
/>
{widget.engines.length > 1 && (
<div className="inline-flex self-start overflow-hidden rounded-md border border-border">
{widget.engines.map((option) => (
<label
key={option}
className={`cursor-pointer px-2.5 py-1 text-[12px] first:border-l-0 [&+label]:border-l [&+label]:border-border ${
option === engine ? "text-accent shadow-[inset_0_0_0_1px_var(--pn-accent)]" : "text-fg-muted"
}`}
>
<input
type="radio"
name="engine"
value={option}
checked={option === engine}
onChange={() => setEngine(option as SearchEngine)}
className="sr-only"
/>
{ENGINE_LABEL[option] ?? option}
</label>
))}
</div>
)}
</form>
</WidgetCard>
);
}