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

+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"
/>
);
}