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
+137
View File
@@ -0,0 +1,137 @@
import { useCallback, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
function scrollBehavior(): ScrollBehavior {
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return reduce ? "auto" : "smooth";
}
// Horizontal scroll-affordance: whether the scroller is clipped on the left / right, so the rail shows
// a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core
// (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the
// element itself so the paging buttons can scroll it.
function useHScroll(): {
ref: (node: HTMLElement | null) => void;
el: HTMLElement | null;
left: boolean;
right: boolean;
} {
const { ref, el, start, end } = useScrollEdges("x");
return { ref, el, left: start, right: end };
}
const FADE_PX = 28;
export function Carousel({
title,
onSeeAll,
seeAllLabel,
ariaLabel,
gap = "gap-3",
className,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
title?: ReactNode;
/** When set, a "see all" affordance appears in the header (needs `title`). */
onSeeAll?: () => void;
seeAllLabel?: string;
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
ariaLabel?: string;
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
const page = useCallback(
(dir: -1 | 1) => {
if (!el) return;
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
},
[el],
);
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
right ? FADE_PX : 0
}px), transparent)`;
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
return (
<div className={clsx("group/car relative", className)}>
{title != null && (
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
>
{children}
</div>
{right && (
<button
type="button"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div>
</div>
);
}