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
+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"