feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)

- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
This commit is contained in:
2026-09-24 23:48:22 +02:00
parent 81936e9f56
commit dac9d89f91
63 changed files with 923 additions and 416 deletions
+1
View File
@@ -96,6 +96,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
+22 -8
View File
@@ -111,19 +111,31 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only: a text
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB). A text
button on a picture is a pill (the card's Resume in the accent, From the start dark translucent, C5), and
the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`,
≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height (32); a toolbar search
is a pill at the `sm` height (28). Text is 1rem on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form
(it lines up with the inputs).
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole
labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser.
- **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
choice"), r8 and full width in a form (it lines up with the inputs). Open is the accent edge and the
chevron turned over.
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
`CheckRow`, `Slider`, `ColorInput`); the whole labelled row is the 44px target. Keyboard, screen reader and
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
passes).
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
[WCAG 2.4.3]
Also in force:
@@ -295,6 +307,7 @@ Wherever there is text, the user can select it and copy it, and no click action
| Date | Decision | Chosen | Over | Why |
| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
@@ -321,5 +334,6 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+40 -2
View File
@@ -100,8 +100,9 @@ const BUTTON_PRIMITIVES = new Set([
"form.tsx", // Switch
"PriorityStepper.tsx",
"SortControl.tsx",
"FilterSelect.tsx", // the dropdown trigger (A5, S3c)
"Combobox.tsx", // the field's own chips and chevron (A4, S3c)
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's own chips and chevron (A4)
]);
const buttonPrimitive = {
meta: {
@@ -124,6 +125,40 @@ const buttonPrimitive = {
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
"NativeControls.tsx",
"PriorityStepper.tsx", // the number box laid over the stepper's own face
]);
const inputPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name !== "input" && name !== "textarea") return;
const type = node.attributes.find(
(a) => a.type === "JSXAttribute" && a.name.name === "type",
);
const v = type?.value?.type === "Literal" ? type.value.value : null;
if (name === "input" && (v === "file" || v === "hidden")) return;
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -200,6 +235,7 @@ const localRules = {
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"input-primitive": inputPrimitive,
"literal-a11y-text": literalA11yText,
},
};
@@ -255,6 +291,7 @@ export default tseslint.config(
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/input-primitive": "error",
"local/literal-a11y-text": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -418,6 +455,7 @@ export default tseslint.config(
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
},
},
prettier,
+4 -2
View File
@@ -10,6 +10,7 @@ import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -154,7 +155,9 @@ export default function AddToPlaylist({
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
@@ -168,7 +171,6 @@ export default function AddToPlaylist({
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
</div>
+10 -3
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -34,6 +35,8 @@ import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
@@ -245,15 +248,19 @@ export default function AdminLogs() {
className="overflow-hidden rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
+5 -4
View File
@@ -44,6 +44,7 @@ import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -687,12 +688,12 @@ function AdminInvites() {
}}
className="flex items-center gap-2 mt-3"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
type="submit"
@@ -861,12 +862,12 @@ function AdminDemo() {
}}
className="flex items-center gap-2"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
+4 -2
View File
@@ -10,6 +10,7 @@ import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -246,7 +247,9 @@ function TagEditorMenu({
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -262,7 +265,6 @@ function TagEditorMenu({
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<IconButton
variant="outline"
+3 -2
View File
@@ -13,6 +13,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
@@ -210,7 +211,8 @@ export default function ChatThread({
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<textarea
<Textarea
className="flex-1 resize-none max-h-28"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -221,7 +223,6 @@ export default function ChatThread({
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<IconButton
label={t("messages.send")}
+8 -10
View File
@@ -18,6 +18,8 @@ import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -283,22 +285,18 @@ export default function ConfigPanel() {
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
<CheckRow
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
className="text-sm"
>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
</CheckRow>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
@@ -393,7 +391,8 @@ function Field({
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : (
<input
<Input
className="w-52"
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
@@ -402,7 +401,6 @@ function Field({
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
)}
{showReset && !secretDisabled && (
+13 -17
View File
@@ -1,12 +1,13 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { CalendarDays, Check } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
@@ -93,23 +94,18 @@ export default function DateFilterSelect({
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
const dateInputCls =
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
return (
<>
<Pressable
<SelectTrigger
context="form"
open={open}
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</Pressable>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
@@ -143,7 +139,8 @@ export default function DateFilterSelect({
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
<Input
size="sm"
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
@@ -157,12 +154,12 @@ export default function DateFilterSelect({
year: undefined,
})
}
className={dateInputCls}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
<Input
size="sm"
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
@@ -176,7 +173,6 @@ export default function DateFilterSelect({
year: undefined,
})
}
className={dateInputCls}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
+3 -3
View File
@@ -26,9 +26,9 @@ import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
// The debug recorder (E16): Start → reproduce → Stop.
//
@@ -308,14 +308,14 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<input
<Input
className="w-full"
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
+3 -3
View File
@@ -37,13 +37,13 @@ import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
export default function DownloadCenter() {
const me = useMe();
@@ -189,13 +189,13 @@ export default function DownloadCenter() {
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
<Input
className="w-full"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<IconButton
label={t("downloads.page.paste")}
+3 -3
View File
@@ -12,8 +12,8 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
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";
import { Input } from "./ui/Input";
// 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.
@@ -82,11 +82,11 @@ export default function DownloadDialog({
</Button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<input
<Input
className="w-full mb-5"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.dialog.namePlaceholder")}
className={`${inputCls} mb-5`}
/>
<div className="flex justify-end gap-2">
+10 -9
View File
@@ -25,7 +25,8 @@ import { IconButton } from "./ui/IconButton";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
@@ -472,28 +473,28 @@ function DraftFields({
<label htmlFor="issue-title" className="text-sm text-muted">
{t("recorder.composer.fieldTitle")}
</label>
<input
<Input
className="w-full"
id="issue-title"
value={form.title}
readOnly={readOnly}
maxLength={200}
placeholder={t("recorder.composer.titlePlaceholder")}
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-description" className="text-sm text-muted">
{t("recorder.composer.description")}
</label>
<textarea
<Textarea
className="w-full resize-y"
id="issue-description"
value={form.description}
readOnly={readOnly}
rows={4}
placeholder={t("recorder.composer.descriptionPlaceholder")}
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
className={`${inputCls} resize-y`}
/>
</div>
<fieldset className="flex flex-col gap-1.5 min-w-0">
@@ -507,13 +508,14 @@ function DraftFields({
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
{i + 1}.
</span>
<input
<Input
className="flex-1"
size="sm"
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
value={step.text}
readOnly={readOnly}
placeholder={t("recorder.composer.stepPlaceholder")}
onChange={(e) => setStep(i, { text: e.target.value })}
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
/>
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
{t(`recorder.composer.stepKind.${step.kind}`)}
@@ -764,8 +766,7 @@ function ReviewList({
off ? "opacity-50 line-through" : ""
}`}
>
<input
type="checkbox"
<Checkbox
checked={!off}
disabled={readOnly}
aria-label={t("recorder.composer.keepRow", {
+7 -7
View File
@@ -6,7 +6,7 @@ import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
import { Input } from "./ui/Input";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
@@ -102,13 +102,13 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<input
<Input
className="w-full"
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
className={inputCls}
/>
)}
{err && (
@@ -145,21 +145,21 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
</p>
)}
<input
<Input
className="w-full"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
className={inputCls}
/>
{!configured && (
<input
<Input
className="w-full"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
className={inputCls}
/>
)}
{err && (
+3 -3
View File
@@ -15,11 +15,11 @@ import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
type View =
| "list"
@@ -235,11 +235,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
</IconButton>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
<Input
className="w-full"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
className={inputCls}
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
+10 -3
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
@@ -16,6 +17,8 @@ import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
@@ -123,15 +126,19 @@ export default function MyLogViewer() {
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[10rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[10rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
<InnerInput
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("settings.myLog.search")}
aria-label={t("settings.myLog.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
@@ -24,6 +24,7 @@ import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
@@ -201,12 +202,12 @@ export default function PersonalViewsModal() {
</DndContext>
</div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<input
<Input
className="flex-1"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<IconButton
label={t("views.save")}
@@ -224,12 +225,12 @@ export default function PersonalViewsModal() {
{selected ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
<input
<Input
className="flex-1 font-medium"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void onRename()}
onBlur={() => void onRename()}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<IconButton
variant="outline"
+21 -11
View File
@@ -68,6 +68,9 @@ import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableC
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { useReturnFocus } from "./ui/useReturnFocus";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -170,6 +173,7 @@ export default function Playlists() {
const confirm = useConfirm();
const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
@@ -869,23 +873,31 @@ export default function Playlists() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<input
<Input
className="flex-1 text-lg font-semibold"
size="heading"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
if (e.key === "Enter") {
renameFocus.arm();
saveRename();
}
if (e.key === "Escape") {
renameFocus.arm();
setRenaming(false);
}
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<IconButton
ref={renameFocus.target}
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
@@ -974,15 +986,13 @@ export default function Playlists() {
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
<CheckRow
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
onChange={setGroupBy}
className="text-xs text-muted"
>
{t("playlists.groupByChannel")}
</label>
</CheckRow>
{viewActive && (
<Button
variant="link-muted"
+7 -4
View File
@@ -25,6 +25,7 @@ import { usePlaylistActions } from "./usePlaylistActions";
import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
@@ -276,12 +277,13 @@ export default function PlaylistsRail({
className="flex items-center gap-1.5"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
disabled={!canWrite}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
</form>
</div>
@@ -322,7 +324,9 @@ export default function PlaylistsRail({
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<input
<Input
className="flex-1"
size="sm"
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
@@ -339,7 +343,6 @@ export default function PlaylistsRail({
}
}}
aria-label={t("playlists.rename")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
) : (
@@ -10,6 +10,7 @@ import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -144,12 +145,12 @@ export default function PlexCollectionEditor({
<Modal title={t("plex.collEditor.title", { title: item.title })} onClose={onClose}>
{/* Create new */}
<div className="mb-3 flex gap-2">
<input
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.collEditor.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
variant="glass"
@@ -165,11 +166,11 @@ export default function PlexCollectionEditor({
{/* Filter */}
{editable.length > 6 && (
<input
<Input
className="mb-2 w-full"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("plex.collEditor.search")}
className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
)}
+17 -6
View File
@@ -23,6 +23,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -90,6 +92,7 @@ export default function PlexCollectionView({
const [expanded, setExpanded] = useState(false);
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const [name, setName] = useState("");
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
@@ -268,17 +271,24 @@ export default function PlexCollectionView({
customizeMenu={null}
>
{renaming ? (
<input
<Input
className="w-full text-2xl font-semibold"
size="heading"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={doRename}
onKeyDown={(e) => {
if (e.key === "Enter") doRename();
if (e.key === "Escape") setRenaming(false);
if (e.key === "Enter") {
renameFocus.arm();
doRename();
}
if (e.key === "Escape") {
renameFocus.arm();
setRenaming(false);
}
}}
aria-label={t("plex.collection.rename")}
className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<div className="flex items-center gap-2">
@@ -345,6 +355,7 @@ export default function PlexCollectionView({
{canEdit && (
<>
<ActionBtn
ref={renameFocus.target}
onClick={() => {
setName(d.title);
setRenaming(true);
@@ -470,13 +481,13 @@ function AddItemPanel({
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted" />
<input
<Input
className="flex-1"
autoFocus
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder={t("plex.collection.addSearch")}
aria-label={t("plex.collection.addSearch")}
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
+9 -27
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls";
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
import { Pressable } from "./ui/Pressable";
import { ColorInput, Checkbox } from "./ui/NativeControls";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -160,36 +161,24 @@ export function PlexPlayerSettings(props: {
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
<input
type="color"
value={prefs.subColor}
onChange={(e) => patchPrefs({ subColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
<ColorInput value={prefs.subColor} onChange={(v) => patchPrefs({ subColor: v })} />
</div>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-2xs text-white/70">
<input
type="checkbox"
<Checkbox
checked={prefs.subBg === "transparent"}
onChange={(e) =>
onChange={(checked) =>
patchPrefs({
subBg: e.target.checked ? "transparent" : "#000000",
subBg: checked ? "transparent" : "#000000",
})
}
className="accent-accent"
/>
{t("plex.player.subBgNone")}
</label>
{prefs.subBg !== "transparent" && (
<input
type="color"
value={prefs.subBg}
onChange={(e) => patchPrefs({ subBg: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
<ColorInput value={prefs.subBg} onChange={(v) => patchPrefs({ subBg: v })} />
)}
</div>
</div>
@@ -207,11 +196,9 @@ export function PlexPlayerSettings(props: {
<span className="text-[0.75rem] text-white/80">
{t("plex.player.subShadowColor")}
</span>
<input
type="color"
<ColorInput
value={prefs.subShadowColor}
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
onChange={(v) => patchPrefs({ subShadowColor: v })}
/>
</div>
)}
@@ -241,12 +228,7 @@ export function PlexPlayerSettings(props: {
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
<input
type="color"
value={prefs.clockColor}
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
<ColorInput value={prefs.clockColor} onChange={(v) => patchPrefs({ clockColor: v })} />
</div>
<Toggle
label={t("plex.player.clockDate")}
+3 -2
View File
@@ -9,6 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -112,12 +113,12 @@ export default function PlexPlaylistAdd({
return (
<Modal title={t("plex.playlistAdd.title", { title: target.title })} onClose={onClose}>
<div className="mb-3 flex gap-2">
<input
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.playlistAdd.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
variant="glass"
+19 -3
View File
@@ -41,6 +41,8 @@ import {
useSortableA11y,
useSortableItem,
} from "./ui/sortableA11y";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -374,6 +376,7 @@ export default function PlexPlaylistView({
const qc = useQueryClient();
const confirm = useConfirm();
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const [name, setName] = useState("");
const [layout, setLayout] = useState<Layout>(() =>
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
@@ -525,13 +528,25 @@ export default function PlexPlaylistView({
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
<input
<Input
className="w-full text-lg font-bold"
size="heading"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm();
rename();
}
// Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") {
renameFocus.arm();
setRenaming(false);
}
}}
onBlur={rename}
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
aria-label={t("plex.playlist.rename")}
/>
) : (
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
@@ -551,6 +566,7 @@ export default function PlexPlaylistView({
label={t("plex.playlist.layoutLabel")}
/>
<IconButton
ref={renameFocus.target}
variant="glass"
size="md"
onClick={() => {
+4 -2
View File
@@ -21,6 +21,7 @@ import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -519,7 +520,9 @@ function NumberInput({
onChange: (v: number | null) => void;
}) {
return (
<input
<Input
className="w-20 tabular-nums"
size="sm"
type="number"
inputMode="numeric"
value={value ?? ""}
@@ -528,7 +531,6 @@ function NumberInput({
const n = e.target.value === "" ? null : Number(e.target.value);
onChange(n != null && Number.isFinite(n) ? n : null);
}}
className="w-20 rounded-lg border border-border bg-surface px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
);
}
+6 -6
View File
@@ -6,11 +6,12 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./ui/Modal";
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";
import { IconButton } from "./ui/IconButton";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
@@ -36,10 +37,9 @@ function Check({
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<CheckRow checked={checked} onChange={onChange} className="text-sm">
{label}
</label>
</CheckRow>
);
}
@@ -152,11 +152,11 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="rounded-xl border border-border p-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
<input
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.profiles.namePlaceholder")}
className={inputCls}
/>
</div>
{/* Mode on its own row: three worded choices need the width a half column lacks on a
+9 -10
View File
@@ -28,6 +28,7 @@ import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -181,7 +182,10 @@ function JobRow({
</Tooltip>
{editing ? (
<span className="inline-flex items-center gap-1">
<input
<Input
className="w-16"
size="xs"
invalid={!valid}
type="number"
min={1}
max={1440}
@@ -194,11 +198,6 @@ function JobRow({
if (e.key === "Enter") save();
if (e.key === "Escape") close();
}}
className={`w-16 bg-surface border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 ${
valid
? "border-border focus:ring-accent/40"
: "border-danger focus:ring-danger/40"
}`}
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<IconButton onClick={save} disabled={saving || !valid} label={t("common.save")}>
@@ -306,7 +305,10 @@ function MaintenanceSettings({
</span>
</Tooltip>
<span className="flex-1" />
<input
<Input
className="w-28 tabular-nums"
size="sm"
invalid={!valid}
type="number"
min={m.min}
max={m.max}
@@ -320,9 +322,6 @@ function MaintenanceSettings({
aria-label={t("scheduler.maintenance.batchLabel")}
aria-invalid={!valid}
aria-errormessage={valid ? undefined : rangeId}
className={`w-28 bg-surface border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 ${
valid ? "border-border focus:ring-accent/40" : "border-danger focus:ring-danger/40"
}`}
/>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving || !valid}>
{t("common.save")}
+3 -2
View File
@@ -11,6 +11,7 @@ import { useSearchFocusKey } from "../lib/keymap";
import { PaletteKeycap } from "./PaletteLauncher";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { InnerInput } from "./ui/Input";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
@@ -73,7 +74,8 @@ export default function SearchBar({
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<input
<InnerInput
className="text-sm"
{...inputProps}
ref={inputRef}
value={value}
@@ -90,7 +92,6 @@ export default function SearchBar({
else if (e.key === "Escape") e.currentTarget.blur();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/>
{value && (
<IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
+16 -19
View File
@@ -14,7 +14,7 @@ import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
import { Section, SettingRow, Switch } from "./ui/form";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./confirmContext";
@@ -25,6 +25,8 @@ import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
import { Input, Textarea } from "./ui/Input";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
@@ -186,15 +188,13 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<span>{t("settings.appearance.backgroundFade")}</span>
<span className="tabular-nums">{theme.bgFade}%</span>
</div>
<input
type="range"
<Slider
min={0}
max={100}
step={1}
value={theme.bgFade}
onChange={(e) => setTheme({ ...theme, bgFade: Number(e.target.value) })}
onChange={(v) => setTheme({ ...theme, bgFade: v })}
aria-label={t("settings.appearance.backgroundFade")}
className="w-full accent-accent"
/>
</div>
)}
@@ -230,15 +230,13 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</Section>
<Section title={t("settings.appearance.textSize")}>
<input
type="range"
<Slider
min={0.9}
max={1.3}
step={0.02}
value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
onChange={(v) => setTheme({ ...theme, fontScale: v })}
aria-label={t("settings.appearance.textSize")}
className="w-full accent-accent"
/>
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
</Section>
@@ -282,15 +280,14 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
type="range"
<Slider
className="mt-1"
min={2000}
max={15000}
step={1000}
value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })}
onChange={(v) => update({ durationMs: v })}
aria-label={t("settings.notifications.dismissAfter")}
className="w-full accent-accent mt-1"
/>
</div>
)}
@@ -481,22 +478,22 @@ function SignInMethods({ me }: { me: Me }) {
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
{me.has_password && (
<input
<Input
className="w-full"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder={t("settings.account.password.current")}
autoComplete="current-password"
className={inputCls}
/>
)}
<input
<Input
className="w-full"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder={t("settings.account.password.new")}
autoComplete="new-password"
className={inputCls}
/>
{err && (
<p role="alert" className="text-xs text-danger">
@@ -731,12 +728,12 @@ export function DownloadAuth() {
<div className="mt-2 space-y-2">
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
fetched or prefilled. */}
<textarea
<Textarea
className="w-full font-mono resize-y"
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={5}
placeholder={t("settings.downloadAuth.placeholder")}
className={`${inputCls} font-mono text-xs resize-y`}
data-testid={`cookie-input-${h.host_key}`}
spellCheck={false}
autoComplete="off"
+21 -21
View File
@@ -5,7 +5,7 @@ import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
import { Input } from "./ui/Input";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
@@ -165,38 +165,38 @@ export default function SetupWizard() {
{step === "admin" && (
<Section title={t("setup.admin.title")} desc={t("setup.admin.desc")}>
<input
<Input
className="w-full"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("setup.admin.email")}
autoComplete="username"
className={inputCls}
/>
<input
<Input
className="w-full"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("setup.admin.password", { n: PASSWORD_MIN })}
autoComplete="new-password"
className={inputCls}
/>
</Section>
)}
{step === "google" && (
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
<input
<Input
className="w-full"
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={t("setup.google.clientId")}
className={inputCls}
/>
<input
<Input
className="w-full"
value={gsecret}
onChange={(e) => setGsecret(e.target.value)}
placeholder={t("setup.google.clientSecret")}
className={inputCls}
/>
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
</Section>
@@ -204,46 +204,46 @@ export default function SetupWizard() {
{step === "smtp" && (
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
<input
<Input
className="w-full"
value={smtp.host}
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
placeholder={t("setup.smtp.host")}
className={inputCls}
/>
<div className="flex gap-2">
<input
<Input
className="w-24"
value={smtp.port}
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
placeholder={t("setup.smtp.port")}
className={`${inputCls} w-24`}
/>
<input
<Input
className="w-full"
value={smtp.user}
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
placeholder={t("setup.smtp.user")}
className={inputCls}
/>
</div>
<input
<Input
className="w-full"
value={smtp.from}
onChange={(e) => setSmtp({ ...smtp, from: e.target.value })}
placeholder={t("setup.smtp.from")}
className={inputCls}
/>
<input
<Input
className="w-full"
type="password"
value={smtp.password}
onChange={(e) => setSmtp({ ...smtp, password: e.target.value })}
placeholder={t("setup.smtp.password")}
className={inputCls}
/>
{smtp.host.trim() && (
<div className="flex items-center gap-2 pt-1">
<input
<Input
className="w-full"
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
placeholder={t("setup.smtp.testTo")}
className={inputCls}
/>
<Button
variant="glass"
+14 -22
View File
@@ -10,10 +10,11 @@ import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input, InnerInput } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -56,7 +57,8 @@ function UserShare({ job }: { job: DownloadJob }) {
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
<div className="relative">
<input
<Input
className="w-full"
value={q}
onChange={(e) => {
setQ(e.target.value);
@@ -66,7 +68,6 @@ function UserShare({ job }: { job: DownloadJob }) {
// Close on blur, but after a tick so a click on a dropdown row still registers.
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.share.pickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
@@ -144,11 +145,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<input
readOnly
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
@@ -207,14 +204,15 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
</span>
))}
<div className="flex-1" />
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={link.allow_download} onChange={() => toggle.mutate()} />
<CheckRow checked={link.allow_download} onChange={() => toggle.mutate()}>
{t("downloads.share.allowDownload")}
</label>
</CheckRow>
</div>
{editPass && (
<div className="flex items-center gap-2">
<input
<Input
className="flex-1"
size="sm"
type="password"
autoComplete="new-password"
value={newPass}
@@ -225,7 +223,6 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
placeholder={t(
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
)}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
size="xs"
@@ -327,14 +324,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
{creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={allowDownload}
onChange={(e) => setAllowDownload(e.target.checked)}
/>
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
{t("downloads.share.allowDownload")}
</label>
</CheckRow>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted">{t("downloads.share.expiry")}:</span>
{EXPIRY_DAYS.map((d) => (
@@ -351,12 +343,12 @@ function LinkShare({ job }: { job: DownloadJob }) {
</Button>
))}
</div>
<input
<Input
className="w-full"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("downloads.share.password")}
className={inputCls}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2">
+5 -4
View File
@@ -14,6 +14,7 @@ import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -51,12 +52,12 @@ function TagRow({
});
return (
<div className="flex items-center gap-2">
<input
<Input
className="flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<span
ref={pop.refs.setReference}
@@ -207,11 +208,11 @@ export default function TagManager({
className="flex items-center gap-2 mt-2 pt-3 border-t border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
type="submit"
+3 -3
View File
@@ -13,8 +13,8 @@ import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { inputCls } from "./ui/form";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -179,7 +179,8 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.nameLabel")}
</label>
<input
<Input
className="w-full mb-5"
id="upload-display-name"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -190,7 +191,6 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
// third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's
// own DISPLAY_TEXT_MAX.
maxLength={DISPLAY_NAME_MAX}
className={`${inputCls} mb-5`}
/>
{busy && (
+9 -9
View File
@@ -8,12 +8,12 @@ import Modal from "./ui/Modal";
import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { failureReason, notifyActionError } from "../lib/actionErrors";
import { markReported } from "../lib/api/core";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -38,8 +38,6 @@ function parseTc(v: string): number | null {
type Seg = { id: number; start: number; end: number; keep: boolean };
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const numCls =
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40";
const MIN_SEG = 0.1;
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
@@ -583,7 +581,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div className="flex-1" />
<label className="flex items-center gap-1 text-muted">
{t("editor.in")}
<input
<Input
className="w-24 tabular-nums"
size="sm"
defaultValue={tc(sel.start)}
key={`s-${sel.id}-${sel.start}`}
disabled={selIdx === 0}
@@ -591,12 +591,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const v = parseTc(e.target.value);
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
}}
className={numCls}
/>
</label>
<label className="flex items-center gap-1 text-muted">
{t("editor.out")}
<input
<Input
className="w-24 tabular-nums"
size="sm"
defaultValue={tc(sel.end)}
key={`e-${sel.id}-${sel.end}`}
disabled={selIdx === segments.length - 1}
@@ -604,7 +605,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const v = parseTc(e.target.value);
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
}}
className={numCls}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
@@ -672,11 +672,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div>
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
<input
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultName}
className={inputCls}
/>
</div>
+7 -11
View File
@@ -1,9 +1,9 @@
import { useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import { Check, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { SelectTrigger } from "./ui/SelectTrigger";
export interface ViewOption<T extends string> {
id: T;
@@ -60,23 +60,19 @@ export default function ViewSwitcher<T extends string>({
return (
<>
<Pressable
<SelectTrigger
context="toolbar"
open={open}
icon={<CurrentIcon className="w-4 h-4 shrink-0" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className={`shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/>
</Pressable>
</SelectTrigger>
<PopoverSurface
popover={pop}
ariaLabel={label}
+3 -2
View File
@@ -6,6 +6,7 @@ import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -268,7 +269,8 @@ export default function WatchPage() {
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
</div>
<input
<Input
className="w-full"
type="password"
value={password}
autoFocus
@@ -276,7 +278,6 @@ export default function WatchPage() {
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
{error && (
+7 -7
View File
@@ -31,12 +31,12 @@ import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
import { MODE_URL, type Mode } from "./welcomeMode";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -660,18 +660,19 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
<>
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
{mode !== "reset" && (
<input
<Input
className="w-full"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("welcome.auth.email")}
className={inputCls}
/>
)}
{mode !== "forgot" && mode !== "resend" && (
<input
<Input
className="w-full"
type="password"
autoComplete={mode === "signin" ? "current-password" : "new-password"}
required
@@ -682,7 +683,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
? t("welcome.auth.newPassword", { min: PASSWORD_MIN })
: t("welcome.auth.password")
}
className={inputCls}
/>
)}
{error && (
@@ -769,12 +769,12 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
<p className="text-2xs text-muted">{t("welcome.auth.demoHint")}</p>
<div className="flex gap-2">
<input
<Input
className="w-full"
type="email"
value={demoEmail}
onChange={(e) => setDemoEmail(e.target.value)}
placeholder={t("welcome.auth.demoEmail")}
className={inputCls}
/>
<Button type="submit" variant="outline" size="lg" disabled={demoBusy}>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
+15 -15
View File
@@ -7,12 +7,13 @@ import Modal from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api";
import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
@@ -80,41 +81,40 @@ function QuotaModal({
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<input
<Input
className="w-full"
type="number"
value={state.gb}
onChange={(e) => upd({ gb: Number(e.target.value) })}
className={inputCls}
disabled={state.unlimited}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<input
<Input
className="w-full"
type="number"
value={state.jobs}
onChange={(e) => upd({ jobs: Number(e.target.value) })}
className={inputCls}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<input
<Input
className="w-full"
type="number"
value={state.conc}
onChange={(e) => upd({ conc: Number(e.target.value) })}
className={inputCls}
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
<CheckRow
checked={state.unlimited}
onChange={(e) => upd({ unlimited: e.target.checked })}
/>
onChange={(checked) => upd({ unlimited: checked })}
className="text-sm"
>
{t("downloads.admin.unlimited")}
</label>
</CheckRow>
<div className="flex justify-between pt-1">
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
{t("downloads.admin.reset")}
@@ -153,7 +153,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
}, [q, list]);
return (
<div className="relative w-full sm:w-72">
<input
<Input
className="w-full"
value={q}
onChange={(e) => {
setQ(e.target.value);
@@ -162,7 +163,6 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
+8 -12
View File
@@ -5,10 +5,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys } from "../lib/downloadFailure";
import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
// editing and the failure-detail window.
@@ -50,28 +50,24 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<input
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputCls}
autoFocus
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<input
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputCls}
/>
<Input className="w-full" value={channel} onChange={(e) => setChannel(e.target.value)} />
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<input
<Input
className="w-full"
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
className={inputCls}
/>
</label>
<div>
@@ -79,11 +75,11 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<input
<Input
className="w-full"
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
className={inputCls}
/>
<IconBtn
onClick={() => removeLink(i)}
+9 -6
View File
@@ -14,6 +14,7 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -114,13 +115,13 @@ function NewCollectionPanel({
<X />
</IconButton>
</div>
<input
<Input
className="w-full"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("plex.collection.namePlaceholder")}
aria-label={t("plex.collection.newCollection")}
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{dupHint ? (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
@@ -129,13 +130,13 @@ function NewCollectionPanel({
)}
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted" />
<input
<Input
className="flex-1"
value={term}
onChange={(e) => setTerm(e.target.value)}
disabled={!nameOk}
placeholder={t("plex.collection.addSearch")}
aria-label={t("plex.collection.addSearch")}
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
</div>
<PlexLibraryPicker
@@ -179,13 +180,15 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
return (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
<Input
className="w-full max-w-sm"
size="sm"
pill
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
aria-label={t("plex.filter.collectionSearch")}
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{isAdmin && !creating && (
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
+4 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode, type Ref } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -170,6 +170,7 @@ export function BackBtn({ onBack, label }: { onBack: () => void; label: string }
}
export function ActionBtn({
ref,
onClick,
icon: Icon,
label,
@@ -183,9 +184,11 @@ export function ActionBtn({
sub?: string;
primary?: boolean;
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
}) {
return (
<Button
ref={ref}
variant={primary ? "primary" : "glass"}
onClick={onClick}
disabled={disabled}
+3 -15
View File
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton";
import { Slider as Range, Checkbox } from "./ui/NativeControls";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
@@ -93,15 +94,7 @@ export function Slider({
)}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-accent"
/>
<Range min={min} max={max} step={step} value={value} onChange={(v) => onChange(v)} />
</div>
);
}
@@ -117,12 +110,7 @@ export function Toggle({
return (
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
<Checkbox checked={checked} onChange={onChange} />
</label>
);
}
+4 -2
View File
@@ -50,15 +50,17 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
// Shape (docs/DESIGN.md A2): round because it floats over the content, 3rem (48) on mouse and touch —
// a lone corner button at a toolbar rung gets lost.
return createPortal(
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<ArrowUp className="size-5" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</Pressable>,
document.body,
+9 -3
View File
@@ -1,9 +1,12 @@
import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -176,7 +179,10 @@ export default function Combobox({
setOpenAnd(true);
}
}}
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
className={clsx(
fieldClass({ size: "free" }),
"flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm",
)}
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
@@ -196,7 +202,8 @@ export default function Combobox({
</button>
</span>
))}
<input
<InnerInput
className="min-w-8 px-1 py-0.5"
ref={setInputRef}
{...pop.getReferenceProps({
onChange: (e) => {
@@ -226,7 +233,6 @@ export default function Combobox({
// made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping
// the chevron to a second line while the (empty) input filled the first. A small floor keeps the
// chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left.
className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
@@ -5,6 +6,8 @@ import Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
import { Pressable } from "./Pressable";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -191,9 +194,11 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2">
<div
className={clsx(fieldClass({ size: "free" }), "flex h-10 items-center gap-2 px-3 text-sm")}
>
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
<InnerInput
autoFocus
value={query}
onChange={(e) => {
@@ -210,7 +215,6 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
aria-expanded={flat.length > 0}
aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
/>
</div>
+8 -10
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -89,22 +90,19 @@ export default function FilterSelect({
return (
<>
<button
type="button"
<SelectTrigger
context="toolbar"
open={open}
label={hideLabel ? undefined : label}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
// Carry the current value in the accessible name (not just the group label), matching
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
aria-label={`${label}: ${summary}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
{!hideLabel && <span className="text-muted">{label}:</span>}
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// E28 S3c: the field primitives (docs/DESIGN.md A4-A6): one frame per rung, the native controls handing
// back values, and the dropdown trigger by context.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Input, Textarea } from "./Input";
import { CheckRow, Slider } from "./NativeControls";
import { SelectTrigger } from "./SelectTrigger";
afterEach(cleanup);
describe("Input", () => {
it("wears the A4 frame at the chosen rung and says when it is invalid", () => {
render(
<>
<Input aria-label="name" />
<Input aria-label="search" size="sm" pill invalid />
</>,
);
const name = screen.getByRole("textbox", { name: "name" });
expect(name.className).toContain("edge-strong");
expect(name.className).toContain("h-8");
expect(name.className).toContain("rounded-lg");
expect(name.getAttribute("aria-invalid")).toBeNull();
const search = screen.getByRole("textbox", { name: "search" });
expect(search.className).toContain("h-7");
expect(search.className).toContain("rounded-full");
expect(search.getAttribute("aria-invalid")).toBe("true");
});
it("lets a caller lay it out but not re-paint its edge", () => {
render(<Textarea aria-label="body" className="w-full" />);
const body = screen.getByRole("textbox", { name: "body" });
expect(body.className).toContain("w-full");
expect(body.className).toContain("edge-strong");
});
});
describe("the native controls", () => {
it("hand back a boolean and a number, not the event", () => {
const onCheck = vi.fn();
const onSlide = vi.fn();
render(
<>
<CheckRow checked={false} onChange={onCheck}>
Only unwatched
</CheckRow>
<Slider aria-label="size" min={0} max={10} value={3} onChange={onSlide} />
</>,
);
// The label is the target: a click on its text toggles the box.
fireEvent.click(screen.getByText("Only unwatched"));
expect(onCheck).toHaveBeenCalledWith(true);
fireEvent.change(screen.getByRole("slider", { name: "size" }), { target: { value: "7" } });
expect(onSlide).toHaveBeenCalledWith(7);
});
});
describe("SelectTrigger", () => {
it("is a pill in a toolbar, r8 and full width in a form, and shows open", () => {
render(
<>
<SelectTrigger context="toolbar" open={false} label="Sort">
Newest
</SelectTrigger>
<SelectTrigger context="form" open>
MP4
</SelectTrigger>
</>,
);
const toolbar = screen.getByRole("button", { name: /Newest/ });
const form = screen.getByRole("button", { name: "MP4" });
expect(toolbar.textContent).toBe("Sort:Newest");
expect(toolbar.className).toContain("rounded-full");
expect(toolbar.className).toContain("border-border");
expect(form.className).toContain("rounded-lg");
expect(form.className).toContain("w-full");
expect(form.className).toContain("border-accent");
});
});
+79
View File
@@ -0,0 +1,79 @@
import clsx from "clsx";
import type { InputHTMLAttributes, Ref, TextareaHTMLAttributes } from "react";
import { fieldClass, type FieldSize } from "./fieldStyles";
// The text-field primitives (docs/DESIGN.md A4, E28 S3c). Every text, number, password, e-mail, search
// and date box is an `Input`; a multi-line one a `Textarea`; a box whose frame also holds an icon,
// chips or a clear button is a wrapper painted with `fieldClass` around an `InnerInput`. A raw
// `<input>` or `<textarea>` anywhere else is a lint error (`local/input-primitive`), so the
// frame, the focus edge and the coarse-pointer 1rem text cannot drift apart again.
//
// `className` lays the field out (width, margin, font weight); its box and edge are the rung's.
type Common = {
/** The rung (`FieldSize`): `md` 32 in a form (default), `sm` 28 in a toolbar, a popover or an inline rename, `xs` 24 in a row, `heading` for a title rename. */
size?: FieldSize;
/** A toolbar search (A4): a pill instead of r8. */
pill?: boolean;
/** Marks the field invalid (the danger edge) and says so to a screen reader. */
invalid?: boolean;
};
export function Input({
size,
pill,
invalid,
className,
ref,
...rest
}: Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & Common & { ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
aria-invalid={invalid || undefined}
className={clsx(fieldClass({ size, pill }), className)}
{...rest}
/>
);
}
export function Textarea({
invalid,
className,
ref,
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement> & {
invalid?: boolean;
ref?: Ref<HTMLTextAreaElement>;
}) {
return (
<textarea
ref={ref}
aria-invalid={invalid || undefined}
className={clsx(
fieldClass({ size: "free" }),
"px-3 py-1.5 text-sm pointer-coarse:text-base",
className,
)}
{...rest}
/>
);
}
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width. */
export function InnerInput({
className,
ref,
...rest
}: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
className={clsx(
"min-w-0 flex-1 bg-transparent text-fg outline-hidden placeholder:text-muted",
className,
)}
{...rest}
/>
);
}
@@ -0,0 +1,98 @@
import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
// A6 (docs/DESIGN.md, E28 S3c): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the accent — keyboard, screen reader and forced-colours support come free, and a drawn copy
// would have to rebuild all three. What the browser does not give is the 44 touch target: the labelled
// row (`CheckRow`) or the slider's own box grows to it on a coarse pointer. Like `Input`, a raw
// `<input>` for any of them anywhere else is a lint error.
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`. */
export function Checkbox({
onChange,
className,
ref,
...rest
}: Native & { onChange?: (checked: boolean) => void; ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
type="checkbox"
onChange={(e) => onChange?.(e.target.checked)}
className={clsx(
"size-4 shrink-0 cursor-pointer accent-accent disabled:cursor-not-allowed",
className,
)}
{...rest}
/>
);
}
/** A checkbox and its label as one row, the whole row the target (44 on touch). */
export function CheckRow({
checked,
onChange,
disabled,
className,
children,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
/** Lays the row out (text size, colour, margin); the target geometry is the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<label
className={clsx(
"inline-flex cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
<Checkbox checked={checked} onChange={onChange} disabled={disabled} />
{children}
</label>
);
}
/** A range slider; `onChange` hands back the number. */
export function Slider({
onChange,
className,
...rest
}: Native & { onChange: (value: number) => void }) {
return (
<input
type="range"
onChange={(e) => onChange(Number(e.target.value))}
className={clsx(
"w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
className,
)}
{...rest}
/>
);
}
/** The browser's colour well, a small swatch. */
export function ColorInput({
onChange,
className,
...rest
}: Native & { onChange: (value: string) => void }) {
return (
<input
type="color"
onChange={(e) => onChange(e.target.value)}
className={clsx(
"h-6 w-10 cursor-pointer rounded-sm bg-transparent pointer-coarse:h-(--touch-target) pointer-coarse:w-14",
className,
)}
{...rest}
/>
);
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import SegmentedToggle from "./SegmentedToggle";
import { Button } from "./Button";
import { Input } from "./Input";
// 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 +
@@ -77,7 +78,9 @@ export default function Pager({
</Button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
<Input
className="w-14 text-center tabular-nums"
size="xs"
type="number"
min={1}
max={totalPages}
@@ -86,7 +89,6 @@ export default function Pager({
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")}
className="w-14 bg-surface border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
@@ -22,6 +22,20 @@ const lower = () => screen.getByRole("button", { name: "common.priority.lower" }
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
describe("PriorityStepper", () => {
it("hands the focus back to the value after Enter or Escape, not to <body>", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={3} onPriority={vi.fn()} onSet={onSet} />,
);
for (const key of ["Escape", "Enter"]) {
fireEvent.click(valueBtn());
const field = screen.getByRole("spinbutton");
field.focus();
fireEvent.keyDown(field, { key });
expect(document.activeElement).toBe(valueBtn());
}
});
it("shows the current priority and both step buttons", () => {
render(
<PriorityStepper hint="what it orders" priority={12} onPriority={vi.fn()} onSet={vi.fn()} />,
+10 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Minus, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import Tooltip from "./Tooltip";
import { useReturnFocus } from "./useReturnFocus";
type Size = "sm" | "md";
@@ -114,6 +115,7 @@ export function PriorityStepper({
onPriority(delta);
};
const editFocus = useReturnFocus(editing);
const commitEdit = () => {
const parsed = parseInt(draft, 10);
if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed);
@@ -154,6 +156,7 @@ export function PriorityStepper({
} ${cls.value}`}
>
<button
ref={editFocus.target}
onClick={beginEdit}
aria-label={t("common.priority.edit")}
title={t("common.priority.edit")}
@@ -170,8 +173,13 @@ export function PriorityStepper({
onChange={(e) => setDraft(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
else if (e.key === "Escape") setEditing(false);
if (e.key === "Enter") {
editFocus.arm();
commitEdit();
} else if (e.key === "Escape") {
editFocus.arm();
setEditing(false);
}
}}
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"
+2 -2
View File
@@ -37,9 +37,9 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -0,0 +1,50 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
// The dropdown trigger (docs/DESIGN.md A5, E28 S3c): the one button that opens a list of choices —
// `SortControl`, `FilterSelect`, the view switcher, the date filter. By context: a pill at the toolbar's
// 28 (in a toolbar the pill means "a filter or a choice"), or r8 at the form's 32, full width, lining up
// with the inputs around it. Open is the accent edge and the chevron turned over. The popover wiring
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
export const SelectTrigger = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement> & {
context: "toolbar" | "form";
open: boolean;
/** The muted "Sort:" prefix before the value. */
label?: ReactNode;
/** A leading glyph (the view's icon, a calendar). */
icon?: ReactNode;
/** Between the value and the chevron (the sort direction arrow). */
trailing?: ReactNode;
}
>(function SelectTrigger(
{ context, open, label, icon, trailing, className, children, ...rest },
ref,
) {
return (
<FocusSafeButton
ref={ref}
type="button"
className={clsx(
"glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target",
context === "toolbar" ? "h-7 rounded-full px-3" : "h-8 w-full rounded-lg px-2.5",
open ? "border-accent" : "border-border",
className,
)}
{...rest}
>
{icon}
{label != null && <span className="text-muted">{label}:</span>}
<span className={clsx("text-fg", context === "form" && "flex-1 truncate")}>{children}</span>
{trailing}
<ChevronDown
aria-hidden="true"
className={clsx("size-3.5 shrink-0 text-muted transition-transform", open && "rotate-180")}
/>
</FocusSafeButton>
);
});
+13 -13
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
import { ArrowDown, ArrowUp, Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -64,8 +65,10 @@ export default function SortControl({
return (
<>
<button
type="button"
<SelectTrigger
context="toolbar"
open={open}
label={label}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is
@@ -74,21 +77,18 @@ export default function SortControl({
aria-label={`${label}: ${summary}${
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
{showArrow &&
trailing={
showArrow &&
active &&
(active.dir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
))}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
))
}
>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
+41
View File
@@ -0,0 +1,41 @@
import clsx from "clsx";
// The A4 field frame (docs/DESIGN.md): one bordered box for every text field, whether the frame is the
// <input> itself (`Input`, `Textarea`) or a wrapper that holds an icon, chips or a clear button beside
// a bare `InnerInput` (a log search, the Combobox). `focus-within` covers both: an input is within
// itself. Its own file so `Input.tsx` exports components only (react-refresh).
export type FieldSize =
| "md" // 32 — a form field, lines up with a `md` Button
| "sm" // 28 — a toolbar search, a field in a popover or an inline rename, lines up with the toolbar row
| "xs" // 24 — inside a table row or a pager
| "heading" // an inline rename of a page title: the field takes the heading's own type (the caller's)
| "free"; // the caller lays the box out (a multi-line field, a chip field that wraps)
// On a coarse pointer the text is 1rem, so a tablet browser does not zoom the page on focus (not on a
// heading, whose type is already larger).
const SIZES: Record<FieldSize, string> = {
md: "h-8 px-3 text-sm pointer-coarse:text-base",
sm: "h-7 px-2.5 text-sm pointer-coarse:text-base",
xs: "h-6 px-2 text-xs pointer-coarse:text-base",
heading: "px-2 py-0.5",
free: "",
};
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
// is the danger edge, focused or not. On a coarse pointer the box grows to the 44 touch target.
const BASE =
"min-w-0 border edge-strong bg-transparent text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger has-aria-invalid:border-danger has-aria-invalid:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 has-disabled:cursor-not-allowed has-disabled:opacity-50 " +
"pointer-coarse:min-h-(--touch-target)";
export function fieldClass({
size = "md",
pill = false,
}: { size?: FieldSize; pill?: boolean } = {}) {
// A4: a toolbar search is a pill; every other field r8.
return clsx(BASE, SIZES[size], pill ? "rounded-full" : "rounded-lg");
}
-9
View File
@@ -5,15 +5,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** The ONE text/select/number input recipe (full-width, surface bg, rounded-lg, 2px accent focus
* RING). R10 S3 unified every input onto this: the old settings-family border-focus style
* (`bg-card rounded-xl focus:border-accent`) and its three copy-pasted local `inputCls` constants
* (KeyGate/SettingsPanel/SetupWizard) are gone. Size variants (a small pager/table-filter/chat
* input) keep their own width/radius/padding but adopt the same bg-surface + ring-focus look
* inline — reach for this const for a default full-size field, match its look for a smaller one. */
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({
+4 -2
View File
@@ -11,6 +11,7 @@ import { useStickyTop } from "./stickyTop";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { Input } from "./Input";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
@@ -95,12 +96,13 @@ function FilterFunnel<T>({
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
<Input
className="w-full"
size="sm"
ref={inputRef}
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
)}
{f.kind === "select" && (
@@ -0,0 +1,21 @@
import { useCallback, useEffect, useRef } from "react";
// An in-place editor (a title rename, a number field over a stepper) swaps its trigger for a field
// and back. Leaving it by Enter or Escape unmounts the focused field, and the browser drops the focus
// on <body>: the next Tab starts again at the top of the page (WCAG 2.4.3). This hands the focus back
// to the trigger once the editor has CLOSED — after the commit, so the trigger is mounted and visible
// again whether it stayed in the tree or was swapped out. `arm()` it from the keys only: leaving by a
// click elsewhere means the user already put the focus where they wanted it.
export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open: boolean) {
const target = useRef<T | null>(null);
const armed = useRef(false);
useEffect(() => {
if (open || !armed.current) return;
armed.current = false;
target.current?.focus();
}, [open]);
const arm = useCallback(() => {
armed.current = true;
}, []);
return { target, arm };
}
+4 -4
View File
@@ -4,6 +4,7 @@ import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -59,14 +60,13 @@ export default function DesignLab() {
</Chip>
<label className="flex items-center gap-2 text-xs text-muted">
text {look.fontScale.toFixed(2)}
<input
type="range"
<Slider
className="w-24"
min={0.9}
max={1.2}
step={0.02}
value={look.fontScale}
onChange={(e) => set({ fontScale: Number(e.target.value) })}
className="w-24 accent-accent"
onChange={(v) => set({ fontScale: v })}
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
+42 -47
View File
@@ -12,9 +12,14 @@ import {
Trash2,
} from "lucide-react";
import { Contrast, Reference, Sample } from "./labKit";
import { H, LAYER, STRONG } from "./labTokens";
import { LAYER, STRONG } from "./labTokens";
import { Button } from "../components/ui/Button";
import { IconButton } from "../components/ui/IconButton";
import { Checkbox, Slider } from "../components/ui/NativeControls";
import { Input, InnerInput } from "../components/ui/Input";
import { fieldClass } from "../components/ui/fieldStyles";
import { SelectTrigger } from "../components/ui/SelectTrigger";
import { Switch } from "../components/ui/form";
// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended), rendered with the
// batch B decisions that refine them: B1's compact ladder and B3's state layers. Only the chosen
@@ -84,15 +89,9 @@ function TouchRow() {
function Trigger({ label, value }: { label: string; value: string }) {
return (
<span
className={clsx(
"glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 text-sm",
H.bar,
)}
>
<span className="text-muted">{label}:</span> {value}
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
<SelectTrigger context="toolbar" open={false} label={label}>
{value}
</SelectTrigger>
);
}
@@ -101,24 +100,16 @@ function NativeSet() {
return (
<>
<label className={row}>
<input type="checkbox" defaultChecked className="h-4.5 w-4.5 accent-accent" />
<Checkbox defaultChecked />
Only unwatched
</label>
<label className={clsx(row, "justify-between")}>
<div className={clsx(row, "justify-between")}>
Hide Shorts
<span className="relative h-5 w-9 rounded-full bg-accent">
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-accent-fg" />
</span>
</label>
<Switch checked onChange={() => {}} label="Hide Shorts" />
</div>
<label className="flex flex-col gap-1 px-2 text-sm">
Text size
<input
type="range"
min={90}
max={120}
defaultValue={102}
className="w-full accent-accent"
/>
<Slider min={90} max={120} defaultValue={102} onChange={() => {}} />
</label>
</>
);
@@ -136,14 +127,13 @@ export function BatchA() {
<Sample label="Toolbar · sm, with a search, a trigger and an icon button, all 28">
<div className="flex flex-wrap items-center gap-2">
<label
className={clsx("flex w-40 items-center gap-2 rounded-full border px-3", H.bar)}
style={{ borderColor: STRONG }}
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex w-40 items-center gap-2",
)}
>
<Search className="h-4 w-4 text-muted" />
<input
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
placeholder="Search"
/>
<Search className="h-4 w-4 shrink-0 text-muted" />
<InnerInput placeholder="Search" aria-label="Search" />
</label>
<Trigger label="Sort" value="Newest" />
<Btn tone="glass" size="sm" icon={<RefreshCw className={ico} />}>
@@ -228,16 +218,27 @@ export function BatchA() {
>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Playlist name</span>
<input
className={clsx(
"rounded-lg border bg-transparent px-3 text-sm outline-none focus:ring-2 focus:ring-accent",
H.field,
)}
style={{ borderColor: STRONG }}
defaultValue="Evening mix"
/>
<span className="text-xs text-danger">A playlist with this name already exists.</span>
<Input defaultValue="Evening mix" className="w-full" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Playlist name (invalid)</span>
<Input
invalid
defaultValue="Favourites"
className="w-full"
aria-describedby="lab-a4-err"
/>
<span id="lab-a4-err" className="text-xs text-danger">
A playlist with this name already exists.
</span>
</label>
<Sample label="Rungs · md 32, sm 28 (pill: a toolbar search), xs 24">
<div className="flex flex-wrap items-center gap-2">
<Input placeholder="md" className="w-24" />
<Input size="sm" pill placeholder="sm pill" className="w-24" />
<Input size="xs" placeholder="xs" className="w-16" />
</div>
</Sample>
<Contrast fg={STRONG} min={3} label="Edge vs page" />
</Reference>
<Reference
@@ -252,15 +253,9 @@ export function BatchA() {
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Download format</span>
<span
className={clsx(
"glass-card flex items-center justify-between rounded-lg border px-3 text-sm",
H.field,
)}
>
<SelectTrigger context="form" open={false}>
MP4 · 1080p
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
</SelectTrigger>
</label>
</Reference>
<Reference
@@ -0,0 +1,43 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// `local/input-primitive` (eslint.config.js): every text field is ui/Input, Textarea or InnerInput and
// every checkbox / slider / colour well a NativeControls primitive (docs/DESIGN.md A4, A6; E28 S3c).
// ~100 hand-styled boxes were swept; this pins that the rule keeping a new one out fires, and where it
// stands down.
const RULE = "local/input-primitive";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a hand-rolled field", () => {
it("is refused anywhere outside the field primitives, text box or native control", async () => {
const text = 'export const A = () => <input className="border px-2" />;\n';
expect(await hits(text)).toBe(1);
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
// …and in a ui/ composite: the pager's page box is an Input too.
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
});
it("passes in a field primitive, for a file picker, and for the primitives themselves", async () => {
for (const file of ["Input.tsx", "NativeControls.tsx"])
expect(await hits("export const E = () => <input />;\n", `src/components/ui/${file}`)).toBe(
0,
);
expect(await hits('export const F = () => <input type="file" hidden />;\n')).toBe(0);
expect(
await hits(
'import { Input } from "./ui/Input";\nexport const G = () => <Input className="w-full" />;\n',
),
).toBe(0);
});
});