feat: phase 3 renderers & ergonomics — index/console, ⌘K palette, icon suggest

- Index renderer (editorial): serif masthead + section heads, numbered
  full-width rows, live stat strip (time/services/up/down/widgets)
- Console renderer (terminal): $ dash status banner, bordered stat panels,
  ## section heads, › prompt rows, [ up ]/[ down ] tags
- Renderer contract gains index prop for row/section numbering
- Settings: 2x2 renderer radio grid with per-renderer descriptions
- ⌘K/Ctrl+K// palette: actions + service endpoints, arrow-key nav,
  zero-dep implementation on the existing Dialog primitive
- Icon picker: dashboard-icons slug suggestion chip + live preview
- Fonts: Instrument Serif, JetBrains Mono (fontsource)
- Fix: global :focus-visible rule was unlayered and silently overrode all
  focus-visible: utilities since phase 1 — moved into @layer base

Verified: typecheck/lint/build clean, dnd + collapse + popover in both new
renderers, palette filter + execute, icon suggest resolves real CDN assets,
360px + light theme, docker image 43.9 MB serving board + widget types.

Generated with [Devin](https://devin.ai)

Authored By: TDvorak <[email protected]>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 20:29:22 +02:00
1 parent 8afc7df53b
commit 55fb57456e
16 files changed
+803 -51

No files matched your search

@@ -0,0 +1,164 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
import { useBoard } from "@/board/store";
import { hostOf, IconImg } from "@/board/primitives";
import { rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { Item } from "@/types";
// ⌘K palette — flat filtered list over actions + service endpoints.
// No cmdk dep: an input, a list, and an active-index is the whole mechanic.
interface Command {
id: string;
group: "Actions" | "Services";
icon?: Item;
glyph?: typeof LayoutGrid;
label: string;
hint?: string | undefined;
run(): void;
}
export function CommandPalette({
open,
onOpenChange,
onAddService,
onAddWidget,
onSettings,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
onAddService(): void;
onAddWidget(): void;
onSettings(): void;
}) {
const board = useBoard();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
{
id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon,
label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`,
run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"),
},
...rendererList.map((r): Command => ({
id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid,
label: `Renderer: ${rendererLabels[r]}`,
hint: board.renderer === r ? "active" : undefined,
run: () => void board.setSetting("renderer", r),
})),
{
id: "export", group: "Actions", glyph: Download, label: "Export board JSON",
run: () => { window.open("/api/export", "_blank", "noopener"); },
},
];
for (const sec of board.sections) {
for (const it of sec.items) {
if (it.kind === "widget") continue;
for (const u of it.urls) {
cmds.push({
id: `svc-${u.id}`, group: "Services", icon: it,
label: u.label ? `${it.name} — ${u.label}` : it.name,
hint: hostOf(u.url),
run: () => window.open(u.url, "_blank", "noopener"),
});
}
}
}
return cmds;
}, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings]);
const q = query.trim().toLowerCase();
const filtered = q
? commands.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(q))
: commands;
const clamped = Math.min(active, Math.max(0, filtered.length - 1));
useEffect(() => {
if (open) { setQuery(""); setActive(0); }
}, [open]);
useEffect(() => {
listRef.current?.querySelector(`[data-idx="${clamped}"]`)?.scrollIntoView({ block: "nearest" });
}, [clamped]);
function pick(i: number) {
const c = filtered[i];
if (!c) return;
onOpenChange(false);
c.run();
}
const groups: { name: string; items: { c: Command; i: number }[] }[] = [];
filtered.forEach((c, i) => {
const g = groups.find((g) => g.name === c.group);
if (g) g.items.push({ c, i });
else groups.push({ name: c.group, items: [{ c, i }] });
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-4">
<input
autoFocus
value={query}
onChange={(e) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={(e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive(clamped + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); }
else if (e.key === "Enter") { e.preventDefault(); pick(clamped); }
}}
placeholder="Search services and actions…"
className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none"
/>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
</div>
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
) : groups.map((g) => (
<div key={g.name}>
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{g.name}
</div>
{g.items.map(({ c, i }) => (
<button
key={c.id}
type="button"
data-idx={i}
onMouseEnter={() => setActive(i)}
onClick={() => pick(i)}
className={cn(
"flex w-full items-center gap-2.5 rounded-[7px] px-2.5 py-2 text-left text-[13px]",
i === clamped && "bg-surface-hover",
)}
>
{c.icon ? (
<IconImg item={c.icon} size={22} />
) : c.glyph ? (
<span className="flex size-[22px] items-center justify-center text-text-faint">
<c.glyph size={13} />
</span>
) : null}
<span className="min-w-0 truncate">{c.label}</span>
{c.hint ? <span className="ml-auto truncate font-mono text-[10.5px] text-text-faint">{c.hint}</span> : null}
{i === clamped ? <ArrowRight size={12} className="shrink-0 text-text-faint" /> : null}
</button>
))}
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+35 -1
View File
@@ -10,6 +10,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// dashboard-icons CDN serves png/<slug>.png — slugify the service name for
// the one-click suggestion under the icon field.
const DI_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png";
const diSlug = (name: string) =>
name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
// One dialog for both add and edit: item present = edit, absent = add.
export function ServiceDialog({
open,
@@ -164,7 +170,8 @@ export function ServiceDialog({
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-icon">Icon URL</Label>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<IconPreview url={icon} />
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
<Upload size={13} />
@@ -174,6 +181,15 @@ export function ServiceDialog({
onChange={() => setErr("")}
/>
</div>
{!icon && diSlug(name) ? (
<button
type="button"
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
>
suggest: {diSlug(name)}.png
</button>
) : null}
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
</div>
@@ -197,3 +213,21 @@ export function ServiceDialog({
</Dialog>
);
}
// Small live preview for the icon URL — hides itself on load failure so a
// dead URL reads as "no preview", not a broken image.
function IconPreview({ url }: { url: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [url]);
if (!url || failed) {
return <span className="size-[30px] shrink-0 rounded-[7px] border border-dashed border-border" />;
}
return (
<img
src={url}
alt=""
onError={() => setFailed(true)}
className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1"
/>
);
}
+22 -11
View File
@@ -1,7 +1,8 @@
import { useRef, useState } from "react";
import { Download, Upload } from "lucide-react";
import { useBoard } from "@/board/store";
import { rendererLabels, rendererList } from "@/renderers";
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
@@ -46,16 +47,26 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Renderer</Label>
<Select value={board.renderer} onValueChange={(v) => {
if (v === "bento" || v === "cards") void board.setSetting("renderer", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{rendererList.map((r) => (
<SelectItem key={r} value={r}>{rendererLabels[r]}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
{rendererList.map((r) => (
<button
key={r}
type="button"
role="radio"
aria-checked={board.renderer === r}
onClick={() => void board.setSetting("renderer", r)}
className={cn(
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
board.renderer === r
? "border-border-strong bg-surface-hover"
: "border-border hover:border-border-strong hover:bg-surface-hover",
)}
>
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
+9 -5
View File
@@ -8,7 +8,9 @@ const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
function DialogContent({
className, children, showCloseButton = true, ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
@@ -22,10 +24,12 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);