feat(ui): the six native selects become the house controls

Owner's pick from the S7 mockups (2026-09-19): a short fixed set is a SegmentedToggle — the live
search's result count, the table's rows per page, a download profile's mode (on its own row, so the
three worded choices fit a phone) and audio format — and a longer set or the user's own list is a
FilterSelect: the profile's quality (seven heights) and the download dialog's profile.

A lint rule refuses a native <select> from now on (the canonical "FE control" row). The px-frozen
size rules also caught up with a fraction: `text-[9.5px]` had slipped past the integer-only pattern
in the Glass tuner, which is rem now.
This commit is contained in:
2026-09-19 17:53:22 +02:00
parent 17a26894b0
commit 0ef25a8037
9 changed files with 171 additions and 84 deletions
+12 -3
View File
@@ -162,8 +162,9 @@ const localRules = {
};
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
// `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the integer-only pattern.
const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[\\d+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].";
@@ -217,13 +218,21 @@ export default tseslint.config(
// rem arbitraries (text-[0.75rem]) don't match (no "px").
"no-restricted-syntax": [
"error",
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
// 2026-09 debt burn replaced them, and this keeps the next one out.
{
selector: "Literal[value=/text-\\[\\d+px\\]/]",
selector: "JSXOpeningElement[name.name='select']",
message:
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
},
{
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
message:
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
},
{
selector: "TemplateElement[value.raw=/text-\\[\\d+px\\]/]",
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
message:
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
},
+12 -12
View File
@@ -13,6 +13,7 @@ import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
import FilterSelect from "./ui/FilterSelect";
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
// the Download Center. Enqueues via the API and points the user at the Download Center.
@@ -65,18 +66,17 @@ export default function DownloadDialog({
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.profile")}</label>
<select
value={chosen ?? ""}
onChange={(e) => setProfileId(Number(e.target.value))}
className={inputCls}
>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.dialog.profile")}
</span>
{/* The user's own list, of any length: a menu, not a segmented bar (owner's pick, 2026-09-19). */}
<FilterSelect
label={t("downloads.dialog.profile")}
hideLabel
selected={chosen == null ? [] : [String(chosen)]}
onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
/>
<button
type="button"
onClick={() => setManage(true)}
+18 -15
View File
@@ -51,6 +51,7 @@ import ItemCount from "./ui/ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import SegmentedToggle from "./ui/SegmentedToggle";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -95,6 +96,9 @@ function matchesView(status: VideoStatus, show: string): boolean {
}
}
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
export default function Feed({
filters,
setFilters,
@@ -441,21 +445,20 @@ export default function Feed({
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span>
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
{t("feed.yt.show")}
<select
value={ytCount}
onChange={(e) => setYtCount(Number(e.target.value))}
title={t("feed.yt.showHint")}
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
>
{[20, 40, 60, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
pick, 2026-09-19). The caption is the group's name; the hint rides on the row. */}
<div
className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0"
title={t("feed.yt.showHint")}
>
<span aria-hidden="true">{t("feed.yt.show")}</span>
<SegmentedToggle
ariaLabel={t("feed.yt.show")}
value={String(ytCount)}
onChange={(v) => setYtCount(Number(v))}
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
/>
</div>
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
costs nothing and works the same on both search sources. */}
<SortControl
+1 -1
View File
@@ -404,7 +404,7 @@ export default function GlassTuner() {
{t("settings.tuner.reset")}
</button>
</div>
<pre className="text-[9.5px] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
<pre className="text-[0.59375rem] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
{cssOut}
</pre>
</div>
+45 -37
View File
@@ -8,6 +8,8 @@ import { useConfirm } from "./confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
@@ -99,8 +101,6 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
};
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
return (
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
@@ -162,51 +162,59 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.mode")}
</label>
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
<option value="av">{t("downloads.profiles.modeAv")}</option>
<option value="v">{t("downloads.profiles.modeV")}</option>
<option value="a">{t("downloads.profiles.modeA")}</option>
</select>
</div>
{/* Mode on its own row: three worded choices need the width a half column lacks on a
phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.mode")}
</span>
<SegmentedToggle
ariaLabel={t("downloads.profiles.mode")}
size="md"
fullWidth
value={spec.mode}
onChange={(mode) => patch({ mode })}
options={[
{ value: "av", label: t("downloads.profiles.modeAv") },
{ value: "v", label: t("downloads.profiles.modeV") },
{ value: "a", label: t("downloads.profiles.modeA") },
]}
/>
</div>
<div>
{spec.mode !== "a" ? (
<div>
<label className="block text-sm font-medium mb-1">
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.quality")}
</label>
<select
value={spec.max_height ?? ""}
onChange={(e) =>
patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })
}
className={inputCls}
>
{HEIGHTS.map((h) => (
<option key={h ?? "best"} value={h ?? ""}>
{h ? `${h}p` : t("downloads.profiles.best")}
</option>
))}
</select>
</span>
<FilterSelect
label={t("downloads.profiles.quality")}
hideLabel
selected={[String(spec.max_height ?? "")]}
onChange={([v]) => v !== undefined && patch({ max_height: v ? Number(v) : null })}
options={HEIGHTS.map((h) => ({
value: String(h ?? ""),
label: h ? `${h}p` : t("downloads.profiles.best"),
}))}
/>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.audioFormat")}
</label>
</span>
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
unreliably as a bare file, and the module's purpose is in-browser playback. */}
<select
<SegmentedToggle
ariaLabel={t("downloads.profiles.audioFormat")}
size="md"
value={spec.audio_format ?? "m4a"}
onChange={set("audio_format")}
className={inputCls}
>
<option value="m4a">M4A</option>
<option value="mp3">MP3</option>
</select>
onChange={(audio_format) => patch({ audio_format })}
options={[
{ value: "m4a", label: "M4A" },
{ value: "mp3", label: "MP3" },
]}
/>
</div>
)}
{/* Container/codec are no longer user-selectable: every download is clamped to the
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
import Pager from "./Pager";
afterEach(cleanup);
// Rows per page is a short fixed set, so it is the house's segmented control (debt burn 2026-09),
// not a native <select>: one named group, the current size pressed, "All" as size 0.
describe("Pager · rows per page", () => {
it("offers each size and All as one named group, and reports the pick", () => {
const onSizeChange = vi.fn();
render(
<Pager
page={0}
size={25}
total={120}
pageSizeOptions={[10, 25, 50, 100]}
onPageChange={() => {}}
onSizeChange={onSizeChange}
sizeLabel="Rows per page"
/>,
);
const group = screen.getByRole("group", { name: "Rows per page" });
expect(group.querySelector('[aria-pressed="true"]')?.textContent).toBe("25");
fireEvent.click(screen.getByRole("button", { name: "50" }));
fireEvent.click(screen.getByRole("button", { name: "datatable.all" }));
expect(onSizeChange.mock.calls).toEqual([[50], [0]]);
});
});
+15 -16
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import { FocusSafeButton } from "./FocusSafeButton";
import SegmentedToggle from "./SegmentedToggle";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -93,24 +94,22 @@ export default function Pager({
</div>
)}
{total > 0 && (
<label className="flex items-center gap-2 text-muted">
{sizeLabel}
<select
value={size}
onChange={(e) => {
onSizeChange(Number(e.target.value));
<div className="flex items-center gap-2 text-muted">
<span aria-hidden="true">{sizeLabel}</span>
{/* A handful of fixed sizes plus "All": segmented, the house control for a short fixed set. */}
<SegmentedToggle
ariaLabel={sizeLabel}
value={String(size)}
onChange={(v) => {
onSizeChange(Number(v));
scrollListToTop();
}}
className="bg-surface border border-border rounded-md px-1.5 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
<option value={0}>{t("datatable.all")}</option>
</select>
</label>
options={[
...pageSizeOptions.map((n) => ({ value: String(n), label: String(n) })),
{ value: "0", label: t("datatable.all") },
]}
/>
</div>
)}
</div>
);
@@ -0,0 +1,22 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// The canonical "FE control" row (CLAUDE.md) as a lint rule: no native <select>. Six had drifted in
// before the rule existed. A selector that matches nothing looks exactly like a clean codebase, so
// this pins 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("No native <select>")).length;
}
describe("a native select", () => {
it("is refused; the house controls are not", async () => {
expect(await refused("export const A = () => <select />;\n")).toBe(1);
expect(await refused("export const B = () => <SegmentedToggle />;\n")).toBe(0);
});
});
+12
View File
@@ -21,6 +21,18 @@ describe("a px-frozen box utility", () => {
expect(await refused("export const b = `x ${1} " + px("max-w", 240) + "`;\n")).toBe(1);
});
// A fractional size is as frozen as a whole one: `text-[9.5px]` sat in the Glass tuner past an
// integer-only pattern (debt burn 2026-09).
it("is refused with a fractional size, for a box and for a font", async () => {
const px = (u: string, n: number) => `${u}-[${n}px]`;
expect(await refused(`export const e = "${px("w", 12.5)}";\n`)).toBe(1);
const eslint = new ESLint({ cwd: FRONTEND });
const [font] = await eslint.lintText(`export const f = "${px("text", 9.5)}";\n`, {
filePath: "src/components/__probe.tsx",
});
expect(font?.messages.some((m) => m.message.startsWith("px-frozen font size"))).toBe(true);
});
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);