the marketplace nav, in the house section style

taken from fofos-admin/web/NavPanel.tsx, which is the pattern from here on.
three changes, each doing a job rather than being a preference:

SEPARATION. `border-b border-border` on every row, replacing gapped pills. a
gapped list leaves the boundary between one item and the next to guesswork; a
ruled edge says where a row ends.

THE LABEL IS NO LONGER MUTED. it was `text-duck-dark/70` — every row whispering,
with nothing for the eye to land on. the muting moves down to the second line,
where a count genuinely is secondary.

THE COUNT MOVES OUT OF THE RIGHT-HAND COLUMN. it was a number at the far edge at
60% opacity, which is where the eye goes last. as the row's second line — "3
plugins" — it is read WITH the label rather than hunted for, and it gives every
row the same two-line shape the fofos panel has.

active is `bg-muted`, a filled row, rather than a tinted label. colour on the text
was carrying both "this is a link" and "this one is selected", which is why
nothing looked selected.

the footer moved to the same border and the same `muted-foreground` token rather
than the duck palette, so it sits under the rows instead of looking like another
kind of thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-28 23:50:37 +01:00
co-authored by Claude Opus 5
parent cac58ea044
commit e2d03394e1
@@ -32,14 +32,31 @@ const TAG_ICONS: Record<string, string> = {
Fun: 'Gamepad2',
};
const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left';
/*
The house section-nav row, taken from `fofos-admin/web/NavPanel.tsx`.
Three things carry the look, and each is doing a job:
`border-b border-border` — the SEPARATION. Gapped pills read as a floating list where the boundary
between one item and the next is guesswork; a ruled edge says where a row ends.
The label is NOT muted. It was `text-duck-dark/70`, so every row whispered and the eye had nothing to
land on. The muting moves to the line BELOW it, where a count is genuinely secondary.
Active is `bg-muted` — a filled row rather than a tinted label. Colour on the text was carrying both
"this is a link" and "this one is selected", which is why nothing looked selected.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
export const TagNav = () => {
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins();
const [params] = useSearchParams();
const active = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
// Counted over everything the grid can show — installed and available together — so a number here
// always matches what clicking it produces.
@@ -65,61 +82,58 @@ export const TagNav = () => {
return { search: `?${next}` };
};
/** One row: icon, a label that is actually legible, and what it holds underneath. */
const row = (
key: string,
to: ReturnType<typeof link>,
Icon: typeof Package,
label: string,
n: number,
on: boolean,
) => (
<Link key={key} to={to} className={rowClass(on)}>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{label}</span>
{/* The count moves here from the right-hand column. It was a number floating at the edge at 60%
opacity, which is where the eye goes last; as the row's second line it is read WITH the label
rather than hunted for. */}
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{n} plugin{n === 1 ? '' : 's'}
</span>
</span>
</Link>
);
return (
<nav className="flex h-full flex-col overflow-auto">
<div className="flex flex-col gap-0.5 p-2">
<Link
to={link(null)}
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Package className="size-4 shrink-0" />
<span className="flex-1">All</span>
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
</Link>
{ordered.map(([tag, n]) => {
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
return (
<Link
key={tag}
to={link(tag)}
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1 truncate">{tag}</span>
<span className="text-xs tabular-nums opacity-60">{n}</span>
</Link>
);
})}
<div className="flex flex-col">
{row('__all', link(null), Package, 'All', all.length, !active)}
{ordered.map(([tag, n]) =>
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
)}
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
and that is worth being able to SEE rather than discovering by its absence. */}
{untagged > 0 && (
<Link
to={link('__untagged')}
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<Puzzle className="size-4 shrink-0" />
<span className="flex-1 truncate">Untagged</span>
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
</Link>
)}
{untagged > 0 && row('__untagged', link('__untagged'), Puzzle, 'Untagged', untagged, active === '__untagged')}
</div>
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
{/* Below the rows, and separated by the same border they use — these are facts about the store
rather than filters you can click, so they must not look like another section. */}
<div className="mt-auto flex flex-col gap-1 border-t border-border p-3 text-xs">
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
{plugins.length > 0 && (
<button
onClick={() => updateAll()}
disabled={updatingAll}
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
className="rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 disabled:opacity-50"
>
{updatingAll ? 'Updating…' : 'Update all'}
</button>
)}
{broken.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
<div className="flex items-start gap-2 px-2 py-1.5 text-busy-ink">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
@@ -127,7 +141,7 @@ export const TagNav = () => {
</div>
)}
{orphans.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
<div className="flex items-start gap-2 px-2 py-1.5 text-muted-foreground">
<Database className="mt-0.5 size-3.5 shrink-0" />
<span>
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins