refactor(e10): move the platform components into components/ui/ and enforce the boundary

The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
This commit is contained in:
2026-09-05 16:58:29 +02:00
parent 31fa554b87
commit 5bfcf5d83a
138 changed files with 262 additions and 189 deletions
+65
View File
@@ -0,0 +1,65 @@
import { type LucideIcon } from "lucide-react";
import { useAccountPersistedState } from "../../lib/storage";
import CountBadge from "./CountBadge";
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
// to localStorage so a reload keeps the user where they were.
export interface TabDef {
id: string;
label: string;
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
testId?: string; // optional data-testid, to preserve existing e2e hooks
}
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
* carry into another in the same browser) — re-exported under its original name for call sites. */
export const usePersistedTab = useAccountPersistedState;
export default function Tabs({
tabs,
active,
onChange,
orientation = "horizontal",
className,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
// "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
orientation?: "horizontal" | "vertical";
// Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
// header, or the settings rail adds a width + right border. Omit for the orientation default.
className?: string;
}) {
const vertical = orientation === "vertical";
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
return (
<div className={wrapCls}>
{tabs.map((tb) => {
const on = tb.id === active;
const Icon = tb.icon;
return (
<button
key={tb.id}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
vertical
? "flex w-full justify-start gap-2 py-2 text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
);
})}
</div>
);
}