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:
@@ -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).",
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user