fix(ui): the platform layer speaks platform words; boxes scale with the text
- PriorityStepper labelled itself with channels.row.* and told every caller it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine / sidebar.match on the feed sidebar. The generic strings moved to common.*, and what a priority MEANS arrives as the stepper's `hint` prop. uiI18n.contract.test.ts pins components/ui/ to the platform namespaces — the import boundary could never see an i18n key. - 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …) became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the default text size, and now they grow with the Settings slider instead of clipping or re-wrapping their own labels. An eslint rule refuses the px form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts; measured in the browser that every new step is generated at its old px. - chipLane's import-strip regex ran past a side-effect import into the next statement's `from`, hiding a lane between them; it now stops at the import's own `;`.
This commit is contained in:
@@ -63,6 +63,12 @@ const jsxCommentText = {
|
|||||||
};
|
};
|
||||||
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
|
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
|
||||||
|
|
||||||
|
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
|
||||||
|
const BOX_PX =
|
||||||
|
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[\\d+px\\]/";
|
||||||
|
const BOX_PX_MESSAGE =
|
||||||
|
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||||
{
|
{
|
||||||
@@ -107,7 +113,7 @@ export default tseslint.config(
|
|||||||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale
|
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale
|
||||||
// with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs`
|
// with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs`
|
||||||
// or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks;
|
// or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks;
|
||||||
// rem arbitraries (text-[0.75rem]) don't match (no "px"). Font-size `text-` only — not w-/h-/p-.
|
// rem arbitraries (text-[0.75rem]) don't match (no "px").
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@@ -120,6 +126,11 @@ export default tseslint.config(
|
|||||||
message:
|
message:
|
||||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||||||
},
|
},
|
||||||
|
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
||||||
|
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
||||||
|
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||||||
|
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
|
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
</span>
|
</span>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
className="glass w-[480px] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ export default function ChannelPage({
|
|||||||
onPriority={(d) => bumpPriority(channelId, d)}
|
onPriority={(d) => bumpPriority(channelId, d)}
|
||||||
onSet={(v) => setPriority(channelId, v)}
|
onSet={(v) => setPriority(channelId, v)}
|
||||||
size="md"
|
size="md"
|
||||||
|
hint={t("channels.row.priorityHint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function TagEditorMenu({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
|
<div ref={fade.ref} style={fade.style} className="max-h-60 overflow-y-auto no-scrollbar">
|
||||||
{userTags.map((tg) => {
|
{userTags.map((tg) => {
|
||||||
const on = attached.has(tg.id);
|
const on = attached.has(tg.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export default function GlassTuner() {
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
style={{ pointerEvents: "auto" }}
|
style={{ pointerEvents: "auto" }}
|
||||||
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden"
|
className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||||
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
|
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
|||||||
|
|
||||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||||
// drift from it.
|
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||||
|
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
||||||
const YT_SLOT = 200;
|
const YT_SLOT = 200;
|
||||||
|
|
||||||
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
||||||
@@ -182,7 +183,7 @@ function Rail({
|
|||||||
{cards.map((c, i) => (
|
{cards.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${c.source}:${c.kind}:${c.id}`}
|
key={`${c.source}:${c.kind}:${c.id}`}
|
||||||
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
||||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||||
>
|
>
|
||||||
{c.video ? (
|
{c.video ? (
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ export default function PlaylistsRail({
|
|||||||
// ~12px the moment you start renaming, and every row below it moves under the
|
// ~12px the moment you start renaming, and every row below it moves under the
|
||||||
// pointer.
|
// pointer.
|
||||||
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
||||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
||||||
{pl.cover_thumbnail && (
|
{pl.cover_thumbnail && (
|
||||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||||
)}
|
)}
|
||||||
@@ -357,7 +357,7 @@ export default function PlaylistsRail({
|
|||||||
: "border-transparent hover:bg-card/60"
|
: "border-transparent hover:bg-card/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
||||||
{pl.cover_thumbnail && (
|
{pl.cover_thumbnail && (
|
||||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export default function PlexBrowse() {
|
|||||||
|
|
||||||
if (sub.view.kind === "discover") {
|
if (sub.view.kind === "discover") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<div className="p-4 max-w-400 mx-auto">
|
||||||
<PlexDiscover
|
<PlexDiscover
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onOpenCard={onCard}
|
onOpenCard={onCard}
|
||||||
@@ -484,25 +484,25 @@ export default function PlexBrowse() {
|
|||||||
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
||||||
if (sub.view.kind === "playlists") {
|
if (sub.view.kind === "playlists") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<div className="p-4 max-w-400 mx-auto">
|
||||||
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (sub.view.kind === "collections") {
|
if (sub.view.kind === "collections") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<div className="p-4 max-w-400 mx-auto">
|
||||||
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<div className="p-4 max-w-400 mx-auto">
|
||||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||||
while only the poster grid scrolls. */}
|
while only the poster grid scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto">
|
<div className="px-4 pt-2 pb-1 max-w-400 mx-auto">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
||||||
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
||||||
@@ -557,7 +557,7 @@ export default function PlexBrowse() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium truncate max-w-[150px] group-hover/person:text-accent">
|
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
|
||||||
{p.name}
|
{p.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xs text-muted">
|
<div className="text-2xs text-muted">
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function PlexCollectionView({
|
|||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-media mx-auto max-w-[1200px] p-4">
|
<div className="glass-media mx-auto max-w-300 p-4">
|
||||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||||
|
|
||||||
{q.isError && !q.data ? (
|
{q.isError && !q.data ? (
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function CardRail({
|
|||||||
keyboardNav
|
keyboardNav
|
||||||
>
|
>
|
||||||
{cards.map((c, i) => (
|
{cards.map((c, i) => (
|
||||||
<div key={c.id} className="w-[150px] shrink-0">
|
<div key={c.id} className="w-37.5 shrink-0">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ export default function PlexPlaylistView({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
<div className="mx-auto w-[90%] max-w-275 p-4">
|
||||||
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t("plex.backToLibrary")}
|
{t("plex.backToLibrary")}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function UpNextCard({
|
|||||||
onClick={onAdvance}
|
onClick={onAdvance}
|
||||||
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
|
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
|
||||||
>
|
>
|
||||||
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
|
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
|
||||||
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||||
<circle
|
<circle
|
||||||
cx="12"
|
cx="12"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function VideoInfoCard({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={t("card.info")}
|
ariaLabel={t("card.info")}
|
||||||
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
|
className="glass w-85 max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
|
||||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
|||||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
||||||
{StateIcon && (
|
{StateIcon && (
|
||||||
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
||||||
<StateIcon className="w-[18px] h-[18px]" />
|
<StateIcon className="w-4.5 h-4.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{t(titleKey)}
|
{t(titleKey)}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
priority={c.priority}
|
priority={c.priority}
|
||||||
onPriority={(d) => bumpPriority(c.id, d)}
|
onPriority={(d) => bumpPriority(c.id, d)}
|
||||||
onSet={(v) => setPriority(c.id, v)}
|
onSet={(v) => setPriority(c.id, v)}
|
||||||
|
hint={t("channels.row.priorityHint")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export function DownloadRow({
|
|||||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||||
them on the title's line; the text column shrinks instead. */}
|
them on the title's line; the text column shrinks instead. */}
|
||||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-[18px]">
|
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-4.5">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function playlistColumns({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onPlay(v)}
|
onClick={() => onPlay(v)}
|
||||||
aria-label={`${t("card.play")} — ${v.title}`}
|
aria-label={`${t("card.play")} — ${v.title}`}
|
||||||
className="relative w-[62px] h-[35px] rounded-sm overflow-hidden bg-surface shrink-0 group"
|
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group"
|
||||||
>
|
>
|
||||||
{v.thumbnail_url && (
|
{v.thumbnail_url && (
|
||||||
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function PlexInfoView({
|
|||||||
const lang = plexLang();
|
const lang = plexLang();
|
||||||
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||||
return (
|
return (
|
||||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
<div className="w-[90%] max-w-400 mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<Button variant="glass" size="xs" onClick={onBack}>
|
<Button variant="glass" size="xs" onClick={onBack}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -168,7 +168,7 @@ export function PlexPersonView({
|
|||||||
const d = q.data;
|
const d = q.data;
|
||||||
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||||
return (
|
return (
|
||||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
<div className="w-[90%] max-w-400 mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<Button variant="glass" size="xs" onClick={onBack}>
|
<Button variant="glass" size="xs" onClick={onBack}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -246,7 +246,7 @@ export function PlexPersonView({
|
|||||||
keyboardNav
|
keyboardNav
|
||||||
>
|
>
|
||||||
{d.known_for.map((c, i) => (
|
{d.known_for.map((c, i) => (
|
||||||
<div key={c.id} className="w-[150px] shrink-0">
|
<div key={c.id} className="w-37.5 shrink-0">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
@@ -360,7 +360,7 @@ export function PlexShowView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||||
|
|
||||||
{q.isError && !q.data ? (
|
{q.isError && !q.data ? (
|
||||||
@@ -615,7 +615,7 @@ export function PlexSeasonView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||||
|
|
||||||
{q.isError && !q.data ? (
|
{q.isError && !q.data ? (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("Combobox", () => {
|
|||||||
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||||
fireEvent.focus(screen.getByRole("combobox"));
|
fireEvent.focus(screen.getByRole("combobox"));
|
||||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
||||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
expect(screen.getByText("common.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
|
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
|
||||||
|
|||||||
@@ -238,9 +238,7 @@ export default function Combobox({
|
|||||||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||||
{loading && filtered.length === 0 ? (
|
{loading && filtered.length === 0 ? (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted">
|
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
|
||||||
{t("plex.loading", { defaultValue: "Loading…" })}
|
|
||||||
</p>
|
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted">
|
<p className="px-2 py-1.5 text-xs text-muted">
|
||||||
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
||||||
@@ -285,7 +283,7 @@ export default function Combobox({
|
|||||||
})}
|
})}
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||||
{t("plex.filter.moreRefine", {
|
{t("common.moreRefine", {
|
||||||
count: hiddenCount,
|
count: hiddenCount,
|
||||||
defaultValue: "{{count}} more — refine with search",
|
defaultValue: "{{count}} more — refine with search",
|
||||||
})}
|
})}
|
||||||
@@ -299,7 +297,7 @@ export default function Combobox({
|
|||||||
open in the combobox's focus model. */}
|
open in the combobox's focus model. */}
|
||||||
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||||||
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||||||
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
|
<span className="text-2xs text-muted">{t("common.matchMode")}</span>
|
||||||
<div className="ml-auto flex gap-1">
|
<div className="ml-auto flex gap-1">
|
||||||
{(["or", "and"] as const).map((m) => (
|
{(["or", "and"] as const).map((m) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ afterEach(() => {
|
|||||||
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
||||||
});
|
});
|
||||||
|
|
||||||
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
|
const raise = () => screen.getByRole("button", { name: "common.priority.raise" });
|
||||||
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
const lower = () => screen.getByRole("button", { name: "common.priority.lower" });
|
||||||
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
|
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
|
||||||
|
|
||||||
describe("PriorityStepper", () => {
|
describe("PriorityStepper", () => {
|
||||||
it("shows the current priority and both step buttons", () => {
|
it("shows the current priority and both step buttons", () => {
|
||||||
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
|
render(
|
||||||
|
<PriorityStepper hint="what it orders" priority={12} onPriority={vi.fn()} onSet={vi.fn()} />,
|
||||||
|
);
|
||||||
expect(valueBtn().textContent).toBe("12");
|
expect(valueBtn().textContent).toBe("12");
|
||||||
expect(raise()).toBeTruthy();
|
expect(raise()).toBeTruthy();
|
||||||
expect(lower()).toBeTruthy();
|
expect(lower()).toBeTruthy();
|
||||||
@@ -31,14 +33,23 @@ describe("PriorityStepper", () => {
|
|||||||
|
|
||||||
it("tapping + / − nudges by ±1", () => {
|
it("tapping + / − nudges by ±1", () => {
|
||||||
const onPriority = vi.fn();
|
const onPriority = vi.fn();
|
||||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
render(
|
||||||
|
<PriorityStepper
|
||||||
|
hint="what it orders"
|
||||||
|
priority={0}
|
||||||
|
onPriority={onPriority}
|
||||||
|
onSet={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.click(raise());
|
fireEvent.click(raise());
|
||||||
fireEvent.click(lower());
|
fireEvent.click(lower());
|
||||||
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
|
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking the value opens an inline editor seeded with the current value", () => {
|
it("clicking the value opens an inline editor seeded with the current value", () => {
|
||||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
|
render(
|
||||||
|
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={vi.fn()} />,
|
||||||
|
);
|
||||||
fireEvent.click(valueBtn());
|
fireEvent.click(valueBtn());
|
||||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
expect(input.value).toBe("7");
|
expect(input.value).toBe("7");
|
||||||
@@ -46,7 +57,9 @@ describe("PriorityStepper", () => {
|
|||||||
|
|
||||||
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
|
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
|
||||||
const onSet = vi.fn();
|
const onSet = vi.fn();
|
||||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
render(
|
||||||
|
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
|
||||||
|
);
|
||||||
fireEvent.click(valueBtn());
|
fireEvent.click(valueBtn());
|
||||||
const input = screen.getByRole("spinbutton");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "-3" } });
|
fireEvent.change(input, { target: { value: "-3" } });
|
||||||
@@ -56,7 +69,9 @@ describe("PriorityStepper", () => {
|
|||||||
|
|
||||||
it("Escape cancels the edit without committing", () => {
|
it("Escape cancels the edit without committing", () => {
|
||||||
const onSet = vi.fn();
|
const onSet = vi.fn();
|
||||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
render(
|
||||||
|
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
|
||||||
|
);
|
||||||
fireEvent.click(valueBtn());
|
fireEvent.click(valueBtn());
|
||||||
const input = screen.getByRole("spinbutton");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "99" } });
|
fireEvent.change(input, { target: { value: "99" } });
|
||||||
@@ -68,7 +83,9 @@ describe("PriorityStepper", () => {
|
|||||||
|
|
||||||
it("committing an unchanged value does not call onSet", () => {
|
it("committing an unchanged value does not call onSet", () => {
|
||||||
const onSet = vi.fn();
|
const onSet = vi.fn();
|
||||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
render(
|
||||||
|
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
|
||||||
|
);
|
||||||
fireEvent.click(valueBtn());
|
fireEvent.click(valueBtn());
|
||||||
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
|
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
|
||||||
expect(onSet).not.toHaveBeenCalled();
|
expect(onSet).not.toHaveBeenCalled();
|
||||||
@@ -76,7 +93,15 @@ describe("PriorityStepper", () => {
|
|||||||
|
|
||||||
it("renders the same interactive contract at size=md", () => {
|
it("renders the same interactive contract at size=md", () => {
|
||||||
const onPriority = vi.fn();
|
const onPriority = vi.fn();
|
||||||
render(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
|
render(
|
||||||
|
<PriorityStepper
|
||||||
|
hint="what it orders"
|
||||||
|
priority={3}
|
||||||
|
onPriority={onPriority}
|
||||||
|
onSet={vi.fn()}
|
||||||
|
size="md"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(valueBtn().textContent).toBe("3");
|
expect(valueBtn().textContent).toBe("3");
|
||||||
fireEvent.click(raise());
|
fireEvent.click(raise());
|
||||||
expect(onPriority).toHaveBeenCalledWith(1);
|
expect(onPriority).toHaveBeenCalledWith(1);
|
||||||
@@ -85,7 +110,14 @@ describe("PriorityStepper", () => {
|
|||||||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onPriority = vi.fn();
|
const onPriority = vi.fn();
|
||||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
render(
|
||||||
|
<PriorityStepper
|
||||||
|
hint="what it orders"
|
||||||
|
priority={0}
|
||||||
|
onPriority={onPriority}
|
||||||
|
onSet={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.pointerDown(raise());
|
fireEvent.pointerDown(raise());
|
||||||
vi.advanceTimersByTime(349);
|
vi.advanceTimersByTime(349);
|
||||||
@@ -109,7 +141,14 @@ describe("PriorityStepper", () => {
|
|||||||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onPriority = vi.fn();
|
const onPriority = vi.fn();
|
||||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
render(
|
||||||
|
<PriorityStepper
|
||||||
|
hint="what it orders"
|
||||||
|
priority={0}
|
||||||
|
onPriority={onPriority}
|
||||||
|
onSet={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.pointerDown(lower());
|
fireEvent.pointerDown(lower());
|
||||||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||||||
@@ -122,7 +161,12 @@ describe("PriorityStepper", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onPriority = vi.fn();
|
const onPriority = vi.fn();
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
|
<PriorityStepper
|
||||||
|
hint="what it orders"
|
||||||
|
priority={0}
|
||||||
|
onPriority={onPriority}
|
||||||
|
onSet={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.pointerDown(raise());
|
fireEvent.pointerDown(raise());
|
||||||
|
|||||||
@@ -65,11 +65,15 @@ export function PriorityStepper({
|
|||||||
onPriority,
|
onPriority,
|
||||||
onSet,
|
onSet,
|
||||||
size = "sm",
|
size = "sm",
|
||||||
|
hint,
|
||||||
}: {
|
}: {
|
||||||
priority: number;
|
priority: number;
|
||||||
onPriority: (delta: number) => void;
|
onPriority: (delta: number) => void;
|
||||||
onSet: (value: number) => void;
|
onSet: (value: number) => void;
|
||||||
size?: Size;
|
size?: Size;
|
||||||
|
/** What the number MEANS where it is used (the tooltip, and the value's accessible name). The
|
||||||
|
* platform layer does not know — a channel's priority orders the feed, another's would not. */
|
||||||
|
hint: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -129,16 +133,16 @@ export function PriorityStepper({
|
|||||||
? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}`
|
? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}`
|
||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
<Tooltip hint={hint}>
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={t("channels.row.priorityHint")}
|
aria-label={hint}
|
||||||
className={`inline-flex items-center cursor-help ${framed}`}
|
className={`inline-flex items-center cursor-help ${framed}`}
|
||||||
>
|
>
|
||||||
<PriorityStepButton
|
<PriorityStepButton
|
||||||
delta={-1}
|
delta={-1}
|
||||||
Icon={Minus}
|
Icon={Minus}
|
||||||
label={t("channels.row.lowerPriority")}
|
label={t("common.priority.lower")}
|
||||||
sizeCls={cls}
|
sizeCls={cls}
|
||||||
onHoldStart={start}
|
onHoldStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
@@ -151,8 +155,8 @@ export function PriorityStepper({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={beginEdit}
|
onClick={beginEdit}
|
||||||
aria-label={t("channels.row.editPriority")}
|
aria-label={t("common.priority.edit")}
|
||||||
title={t("channels.row.editPriority")}
|
title={t("common.priority.edit")}
|
||||||
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||||
>
|
>
|
||||||
{priority}
|
{priority}
|
||||||
@@ -169,7 +173,7 @@ export function PriorityStepper({
|
|||||||
if (e.key === "Enter") commitEdit();
|
if (e.key === "Enter") commitEdit();
|
||||||
else if (e.key === "Escape") setEditing(false);
|
else if (e.key === "Escape") setEditing(false);
|
||||||
}}
|
}}
|
||||||
aria-label={t("channels.row.priorityHint")}
|
aria-label={hint}
|
||||||
className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -177,7 +181,7 @@ export function PriorityStepper({
|
|||||||
<PriorityStepButton
|
<PriorityStepButton
|
||||||
delta={1}
|
delta={1}
|
||||||
Icon={Plus}
|
Icon={Plus}
|
||||||
label={t("channels.row.raisePriority")}
|
label={t("common.priority.raise")}
|
||||||
sizeCls={cls}
|
sizeCls={cls}
|
||||||
onHoldStart={start}
|
onHoldStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ A component belongs here **iff its props and imports name no domain concept**. I
|
|||||||
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
|
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
|
||||||
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the
|
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the
|
||||||
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
||||||
supply it.
|
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
||||||
|
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
||||||
|
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
||||||
|
`hint`).
|
||||||
|
|
||||||
The direction is one-way from below, too: `lib/` may not import from `components/` — this directory
|
The direction is one-way from below, too: `lib/` may not import from `components/` — this directory
|
||||||
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
|
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
|
||||||
@@ -54,10 +57,9 @@ somewhere:
|
|||||||
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
||||||
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
||||||
— `VirtualGrid`'s `minCol` / `colMaxWidth` and `gridColumns`' `GRID_GAP` — which are compared
|
— `VirtualGrid`'s `minCol` / `colMaxWidth` and `gridColumns`' `GRID_GAP` — which are compared
|
||||||
against a container's own pixel width rather than sized against text. Two px box values are NOT
|
against a container's own pixel width rather than sized against text. A px-frozen BOX utility
|
||||||
deliberate and are worth converting when touched: `Tooltip`'s `max-w-[240px]` (it does not widen
|
(`w-[Npx]`, `max-w-[Npx]`, …) is an eslint error too, since the 2026-09 sweep: use the spacing
|
||||||
with the slider while its text does, so the tooltip re-wraps at large text sizes) and `form.tsx`'s
|
scale (`w-37.5` is 150px at the default size and grows with the slider).
|
||||||
`left-[18px]` toggle knob.
|
|
||||||
- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon
|
- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon
|
||||||
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
|
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
|
||||||
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
|
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function Tooltip({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
passthrough
|
passthrough
|
||||||
className="glass w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
className="glass w-max max-w-60 px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
||||||
>
|
>
|
||||||
{hint}
|
{hint}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function Switch({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||||
checked ? "left-[18px]" : "left-0.5"
|
checked ? "left-4.5" : "left-0.5"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// The platform layer (`components/ui/`) may not import a feature component or a domain API slice —
|
||||||
|
// eslint's `no-restricted-imports` enforces that. It cannot see an i18n KEY, though, and two
|
||||||
|
// primitives spoke domain vocabulary through one: `PriorityStepper` labelled itself with
|
||||||
|
// `channels.row.*` (and told every caller that it orders the Feed), `Combobox` said `plex.loading`
|
||||||
|
// and `plex.filter.moreRefine` on the feed sidebar. A primitive's strings live in a platform
|
||||||
|
// namespace; what a value MEANS where it is used arrives as a prop.
|
||||||
|
const PLATFORM_NAMESPACES = new Set([
|
||||||
|
"common",
|
||||||
|
"errors",
|
||||||
|
"datatable",
|
||||||
|
"keys",
|
||||||
|
"palette",
|
||||||
|
"sidebar",
|
||||||
|
"notifications",
|
||||||
|
"time",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const files = import.meta.glob("./**/*.{ts,tsx}", {
|
||||||
|
query: "?raw",
|
||||||
|
import: "default",
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
||||||
|
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
||||||
|
|
||||||
|
describe("components/ui/ speaks only platform i18n namespaces", () => {
|
||||||
|
it("names no feature namespace", () => {
|
||||||
|
const sources = Object.entries(files).filter(([p]) => !/\.test\.tsx?$/.test(p));
|
||||||
|
expect(sources.length).toBeGreaterThan(20); // the glob matched the layer
|
||||||
|
const offenders = sources.flatMap(([p, src]) =>
|
||||||
|
[...String(src).matchAll(KEY)]
|
||||||
|
.map((m) => m[1]!)
|
||||||
|
.filter((ns) => !PLATFORM_NAMESPACES.has(ns))
|
||||||
|
.map((ns) => `${p}: ${ns}.*`),
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees a feature namespace when one is used", () => {
|
||||||
|
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,9 +108,6 @@
|
|||||||
"editTags": "Edit tags",
|
"editTags": "Edit tags",
|
||||||
"filterFeedByTag": "Filter the Feed by “{{name}}”",
|
"filterFeedByTag": "Filter the Feed by “{{name}}”",
|
||||||
"priorityHint": "Your ranking for this channel. Sort the Feed by “Channel priority” to bring higher-priority channels to the top.",
|
"priorityHint": "Your ranking for this channel. Sort the Feed by “Channel priority” to bring higher-priority channels to the top.",
|
||||||
"raisePriority": "Raise priority",
|
|
||||||
"lowerPriority": "Lower priority",
|
|
||||||
"editPriority": "Click to type a priority (negatives allowed)",
|
|
||||||
"fullHint": "Full history fetched.",
|
"fullHint": "Full history fetched.",
|
||||||
"fullySynced": "fully synced",
|
"fullySynced": "fully synced",
|
||||||
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
||||||
|
|||||||
@@ -27,5 +27,12 @@
|
|||||||
"scrollRight": "Scroll right",
|
"scrollRight": "Scroll right",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"noResults": "No matches"
|
"noResults": "No matches",
|
||||||
|
"priority": {
|
||||||
|
"raise": "Raise priority",
|
||||||
|
"lower": "Lower priority",
|
||||||
|
"edit": "Click to type a priority (negatives allowed)"
|
||||||
|
},
|
||||||
|
"moreRefine": "{{count}} more — refine with search",
|
||||||
|
"matchMode": "Match"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,7 @@
|
|||||||
"remux": "Direct Stream",
|
"remux": "Direct Stream",
|
||||||
"transcode_hw": "Transcode · HW",
|
"transcode_hw": "Transcode · HW",
|
||||||
"transcode_sw": "Transcode · SW"
|
"transcode_sw": "Transcode · SW"
|
||||||
},
|
}
|
||||||
"moreRefine": "{{count}} more — refine with search"
|
|
||||||
},
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"any": "Any",
|
"any": "Any",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"tagModeTooltip": "Match any vs all selected tags",
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
"match": "Match",
|
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
|
|||||||
@@ -108,9 +108,6 @@
|
|||||||
"editTags": "Címkék szerkesztése",
|
"editTags": "Címkék szerkesztése",
|
||||||
"filterFeedByTag": "Feed szűrése: „{{name}}”",
|
"filterFeedByTag": "Feed szűrése: „{{name}}”",
|
||||||
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
|
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
|
||||||
"raisePriority": "Prioritás növelése",
|
|
||||||
"lowerPriority": "Prioritás csökkentése",
|
|
||||||
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
|
|
||||||
"fullHint": "Teljes előzmény letöltve.",
|
"fullHint": "Teljes előzmény letöltve.",
|
||||||
"fullySynced": "teljesen szinkronban",
|
"fullySynced": "teljesen szinkronban",
|
||||||
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
||||||
|
|||||||
@@ -27,5 +27,12 @@
|
|||||||
"scrollRight": "Görgetés jobbra",
|
"scrollRight": "Görgetés jobbra",
|
||||||
"search": "Keresés",
|
"search": "Keresés",
|
||||||
"remove": "Eltávolítás",
|
"remove": "Eltávolítás",
|
||||||
"noResults": "Nincs találat"
|
"noResults": "Nincs találat",
|
||||||
|
"priority": {
|
||||||
|
"raise": "Prioritás növelése",
|
||||||
|
"lower": "Prioritás csökkentése",
|
||||||
|
"edit": "Kattints a prioritás beírásához (negatív is lehet)"
|
||||||
|
},
|
||||||
|
"moreRefine": "még {{count}} — finomíts kereséssel",
|
||||||
|
"matchMode": "Egyezés"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,7 @@
|
|||||||
"remux": "Közvetlen streamelés",
|
"remux": "Közvetlen streamelés",
|
||||||
"transcode_hw": "Átkódolás · HW",
|
"transcode_hw": "Átkódolás · HW",
|
||||||
"transcode_sw": "Átkódolás · SW"
|
"transcode_sw": "Átkódolás · SW"
|
||||||
},
|
}
|
||||||
"moreRefine": "még {{count}} — finomíts kereséssel"
|
|
||||||
},
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"any": "Bármelyik",
|
"any": "Bármelyik",
|
||||||
"all": "Összes",
|
"all": "Összes",
|
||||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
"match": "Egyezés",
|
|
||||||
"custom": "Egyéni",
|
"custom": "Egyéni",
|
||||||
"from": "Ettől",
|
"from": "Ettől",
|
||||||
"to": "Eddig",
|
"to": "Eddig",
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ function count(haystack: string, needle: string): number {
|
|||||||
* lane in a file that already had a compliant one still passed, which is the exact hole this test
|
* lane in a file that already had a compliant one still passed, which is the exact hole this test
|
||||||
* was rewritten to close (E10 review round 2, caught by mutation). */
|
* was rewritten to close (E10 review round 2, caught by mutation). */
|
||||||
function laneCount(src: string): number {
|
function laneCount(src: string): number {
|
||||||
return count(src.replace(/^\s*import[\s\S]*?from\s+"[^"]*";$/gm, ""), "chipLaneClass");
|
// `[^;]` rather than `[\s\S]`: an import is stripped up to ITS OWN `from "…";`. The lazy any-char
|
||||||
|
// form ran past a side-effect import (`import "./x.css";` has no `from`) into the next statement's
|
||||||
|
// `from`, swallowing whatever lay between — a lane there would be uncounted.
|
||||||
|
return count(src.replace(/^\s*import\s[^;]*?\sfrom\s+"[^"]*";$/gm, ""), "chipLaneClass");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */
|
/** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */
|
||||||
@@ -55,6 +58,15 @@ function withMirror(): Array<[string, string]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("chip lane containment contract", () => {
|
describe("chip lane containment contract", () => {
|
||||||
|
it("strips imports without swallowing what follows a side-effect import", () => {
|
||||||
|
const src = [
|
||||||
|
'import "./x.css";',
|
||||||
|
"const lane = chipLaneClass;",
|
||||||
|
'import { chipLaneClass } from "./chipLane";',
|
||||||
|
].join("\n");
|
||||||
|
expect(laneCount(src)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps all three halves of the lane class — offsetParent, clip, and room for the focus ring", () => {
|
it("keeps all three halves of the lane class — offsetParent, clip, and room for the focus ring", () => {
|
||||||
expect(chipLaneClass.split(/\s+/)).toEqual(
|
expect(chipLaneClass.split(/\s+/)).toEqual(
|
||||||
expect.arrayContaining(["relative", "overflow-clip", "[overflow-clip-margin:0.25rem]"]),
|
expect.arrayContaining(["relative", "overflow-clip", "[overflow-clip-margin:0.25rem]"]),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { ESLint } from "eslint";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// The px-frozen BOX rule in eslint.config.js (beside the older `text-[Npx]` one). A `no-restricted-
|
||||||
|
// syntax` selector that matches nothing looks exactly like a clean codebase, so pin that it fires.
|
||||||
|
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||||
|
|
||||||
|
async function refused(source: string): Promise<number> {
|
||||||
|
const eslint = new ESLint({ cwd: FRONTEND });
|
||||||
|
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
|
||||||
|
return (result?.messages ?? []).filter((m) => m.message.startsWith("px-frozen box size")).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a px-frozen box utility", () => {
|
||||||
|
it("is refused in a string and in a template literal", async () => {
|
||||||
|
expect(await refused('export const a = "group w-[150px] shrink-0";\n')).toBe(1);
|
||||||
|
expect(await refused("export const b = `x ${1} max-w-[240px]`;\n")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => {
|
||||||
|
expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0);
|
||||||
|
expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user