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:
@@ -137,7 +137,7 @@ describe("Combobox", () => {
|
||||
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
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", () => {
|
||||
|
||||
@@ -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). */}
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{loading && filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{t("plex.loading", { defaultValue: "Loading…" })}
|
||||
</p>
|
||||
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
||||
@@ -285,7 +283,7 @@ export default function Combobox({
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||
{t("plex.filter.moreRefine", {
|
||||
{t("common.moreRefine", {
|
||||
count: hiddenCount,
|
||||
defaultValue: "{{count}} more — refine with search",
|
||||
})}
|
||||
@@ -299,7 +297,7 @@ export default function Combobox({
|
||||
open in the combobox's focus model. */}
|
||||
{matchMode != null && onMatchModeChange && selected.length >= 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">
|
||||
{(["or", "and"] as const).map((m) => (
|
||||
<button
|
||||
|
||||
@@ -17,13 +17,15 @@ afterEach(() => {
|
||||
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 lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
||||
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
|
||||
const raise = () => screen.getByRole("button", { name: "common.priority.raise" });
|
||||
const lower = () => screen.getByRole("button", { name: "common.priority.lower" });
|
||||
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
|
||||
|
||||
describe("PriorityStepper", () => {
|
||||
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(raise()).toBeTruthy();
|
||||
expect(lower()).toBeTruthy();
|
||||
@@ -31,14 +33,23 @@ describe("PriorityStepper", () => {
|
||||
|
||||
it("tapping + / − nudges by ±1", () => {
|
||||
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(lower());
|
||||
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
|
||||
});
|
||||
|
||||
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());
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
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)", () => {
|
||||
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());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "-3" } });
|
||||
@@ -56,7 +69,9 @@ describe("PriorityStepper", () => {
|
||||
|
||||
it("Escape cancels the edit without committing", () => {
|
||||
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());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "99" } });
|
||||
@@ -68,7 +83,9 @@ describe("PriorityStepper", () => {
|
||||
|
||||
it("committing an unchanged value does not call onSet", () => {
|
||||
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.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
|
||||
expect(onSet).not.toHaveBeenCalled();
|
||||
@@ -76,7 +93,15 @@ describe("PriorityStepper", () => {
|
||||
|
||||
it("renders the same interactive contract at size=md", () => {
|
||||
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");
|
||||
fireEvent.click(raise());
|
||||
expect(onPriority).toHaveBeenCalledWith(1);
|
||||
@@ -85,7 +110,14 @@ describe("PriorityStepper", () => {
|
||||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||||
vi.useFakeTimers();
|
||||
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());
|
||||
vi.advanceTimersByTime(349);
|
||||
@@ -109,7 +141,14 @@ describe("PriorityStepper", () => {
|
||||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||||
vi.useFakeTimers();
|
||||
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());
|
||||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||||
@@ -122,7 +161,12 @@ describe("PriorityStepper", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
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());
|
||||
|
||||
@@ -65,11 +65,15 @@ export function PriorityStepper({
|
||||
onPriority,
|
||||
onSet,
|
||||
size = "sm",
|
||||
hint,
|
||||
}: {
|
||||
priority: number;
|
||||
onPriority: (delta: number) => void;
|
||||
onSet: (value: number) => void;
|
||||
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 [editing, setEditing] = useState(false);
|
||||
@@ -129,16 +133,16 @@ export function PriorityStepper({
|
||||
? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}`
|
||||
: "";
|
||||
return (
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<Tooltip hint={hint}>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("channels.row.priorityHint")}
|
||||
aria-label={hint}
|
||||
className={`inline-flex items-center cursor-help ${framed}`}
|
||||
>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={Minus}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
label={t("common.priority.lower")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
@@ -151,8 +155,8 @@ export function PriorityStepper({
|
||||
>
|
||||
<button
|
||||
onClick={beginEdit}
|
||||
aria-label={t("channels.row.editPriority")}
|
||||
title={t("channels.row.editPriority")}
|
||||
aria-label={t("common.priority.edit")}
|
||||
title={t("common.priority.edit")}
|
||||
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{priority}
|
||||
@@ -169,7 +173,7 @@ export function PriorityStepper({
|
||||
if (e.key === "Enter") commitEdit();
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
@@ -177,7 +181,7 @@ export function PriorityStepper({
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={Plus}
|
||||
label={t("channels.row.raisePriority")}
|
||||
label={t("common.priority.raise")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
against a container's own pixel width rather than sized against text. Two px box values are NOT
|
||||
deliberate and are worth converting when touched: `Tooltip`'s `max-w-[240px]` (it does not widen
|
||||
with the slider while its text does, so the tooltip re-wraps at large text sizes) and `form.tsx`'s
|
||||
`left-[18px]` toggle knob.
|
||||
against a container's own pixel width rather than sized against text. A px-frozen BOX utility
|
||||
(`w-[Npx]`, `max-w-[Npx]`, …) is an eslint error too, since the 2026-09 sweep: use the spacing
|
||||
scale (`w-37.5` is 150px at the default size and grows with the slider).
|
||||
- **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
|
||||
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
|
||||
popover={pop}
|
||||
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}
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Switch({
|
||||
>
|
||||
<span
|
||||
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>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user