feat: apply imported design (bento grid, real elevation tokens, live indicator)
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user