diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb177f5..29c53783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.106.2 — 2026-10-14 + +### Fixed + +- Pictures: a thumbnail, poster or channel picture that fails to load is retried, and meanwhile the card shows its empty frame instead of a broken-image mark. + ## 0.106.1 — 2026-10-14 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 7aa7203b..b9353518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory Not repeated here, because a lint rule refuses the wrong one and its message names the right one: buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.", }, + // A picture that failed to load left the browser's broken-image mark on a card: a bare + // sends a Referer Google's image CDNs refuse, and has no retry and no fallback. + { + selector: "JSXOpeningElement[name.name='img']", + message: + "No bare : use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.", + }, // Keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT // scale with the Settings --font-scale slider while its rem neighbours do; use // `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and diff --git a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx index 7fc5a128..b0ee918e 100644 --- a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx +++ b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx @@ -99,10 +99,10 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => { }); // The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the -// next waits, a visible shows the browser's broken-image glyph (seen live). It stays -// invisible until a source has actually loaded. +// next waits, a visible showed the browser's broken-image glyph (seen live). While a retry +// waits there is no at all — the bar's own ground — and the retry asks past the cache. describe("ChannelPage — the banner", () => { - it("is invisible until it loads, and again while a failed attempt is retried", async () => { + it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); onTestFinished(() => { vi.useRealTimers(); @@ -134,16 +134,14 @@ describe("ChannelPage — the banner", () => { , ); const banner = () => - container.querySelector('img[src^="https://yt3.example/banner"]')!; + container.querySelector('img[src^="https://yt3.example/banner"]'); await waitFor(() => expect(banner()).toBeTruthy()); - expect(banner().classList.contains("opacity-0")).toBe(true); - fireEvent.error(banner()); + expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer"); + fireEvent.error(banner()!); + expect(banner()).toBeNull(); await act(async () => { - await vi.advanceTimersByTimeAsync(600); + await vi.advanceTimersByTimeAsync(1000); }); - expect(banner().getAttribute("src")).toContain("?r=1"); - expect(banner().classList.contains("opacity-0")).toBe(true); - fireEvent.load(banner()); - expect(banner().classList.contains("opacity-0")).toBe(false); + expect(banner()!.getAttribute("src")).toContain("?r=1"); }); }); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index fa0279ca..9a61acca 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -47,6 +47,7 @@ import { IconButton } from "./ui/IconButton"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; import { useTags } from "../lib/useTags"; +import RemoteImage from "./ui/RemoteImage"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -94,17 +95,6 @@ export default function ChannelPage({ // is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row. const foldable = useRef(null); const folded = useScrollFold(foldable); - // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our - // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none - // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, - // so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall - // back to the blank bar if it keeps failing (a genuinely dead URL — rare). - const MAX_BANNER_RETRIES = 3; - const [bannerAttempt, setBannerAttempt] = useState(0); - // The banner source that has actually loaded. Until then the is invisible: an attempt that - // fails shows the browser's broken-image glyph for as long as the retry waits (seen live). - const [bannerLoaded, setBannerLoaded] = useState(null); - // A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and // the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the // scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is @@ -113,13 +103,6 @@ export default function ChannelPage({ intervalMs: channelDetailPollMs, }); const ch = detail.data; - // A fresh channel/banner gets the retries back, in the render that changes it: as an effect the - // new banner was committed with the previous one's exhausted attempt count still in place. - const [bannerFor, setBannerFor] = useState(ch?.banner_url); - if (bannerFor !== ch?.banner_url) { - setBannerFor(ch?.banner_url); - setBannerAttempt(0); - } // The poll above is also the ONLY signal that the running backfill has written videos, so it // drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on // the PATCH response, before the background job has stored anything). @@ -348,9 +331,7 @@ export default function ChannelPage({ // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the // tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile. - const bannerSrc = ch?.banner_url - ? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}` - : null; + const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null; return ( <> @@ -359,44 +340,21 @@ export default function ChannelPage({
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
- {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( - // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a - // low default size, so request a crisp wide version (=w1707) and object-cover the - // desktop "safe area" — the centre 2560×423 (~6:1) band. -
- per attempt; the ?r= cache-buster on a retry bypasses the - // browser's negative cache (googleusercontent accepts an extra query param). - key={bannerAttempt} - src={bannerSrc} - referrerPolicy="no-referrer" - alt="" - className={`w-full h-full object-cover object-center ${ - bannerLoaded === bannerSrc ? "" : "opacity-0" - }`} - onLoad={() => setBannerLoaded(bannerSrc)} - onError={() => { - if (bannerAttempt <= MAX_BANNER_RETRIES) { - window.setTimeout( - () => setBannerAttempt((a) => a + 1), - 500 * (bannerAttempt + 1), - ); - } - }} - /> -
- ) : ( - // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the - // overlapping avatar + the Back button have the same room and don't collide (a short bar - // let the -mt-8 avatar ride up into the Back button). -
+ - )} +
diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index a37f6a3f..92c44434 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -23,6 +23,7 @@ import Modal from "./ui/Modal"; import { MoreButton } from "./ui/MoreButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useContextMenu } from "./ui/useContextMenu"; +import RemoteImage from "./ui/RemoteImage"; // The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time, // in the shared dialog shell. What a file shows is the server's call (`asset.preview`, @@ -187,19 +188,17 @@ function Stage({ title: string; }) { const { t } = useTranslation(); - const [failed, setFailed] = useState(false); if (kind === "image") - return failed ? ( - - {t("common.loadError")} - - ) : ( + return (
- {title} setFailed(true)} className="max-h-full max-w-full rounded-md object-contain" + retries={0} + fallback={{t("common.loadError")}} />
); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 3f86a066..44a53f2b 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -72,6 +72,7 @@ import { mediaPictureBox, mediaTitleClass, } from "./ui/mediaCardStyles"; +import RemoteImage from "./ui/RemoteImage"; // The Home surface — "Mit nézzek most?". // @@ -450,7 +451,7 @@ function ShelfTile({ className={MEDIA_PICTURE} > {card.thumb ? ( - + ) : null} {/* The Play only when there is something to play: the click plays the channel's newest unwatched video, the playlist's first. */} @@ -574,7 +575,7 @@ function Hero({ aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`} > {card.thumb ? ( - + ) : null} {card.duration_seconds != null && ( // It steps aside while the hover row covers its corner, as a card's length does. diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx index 64052c2c..b10c3ec2 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/components/IssueComposer.tsx @@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle"; import { Textarea, Input } from "./ui/Input"; import { Checkbox } from "./ui/NativeControls"; import { SURFACE } from "./ui/surfaces"; +import RemoteImage from "./ui/RemoteImage"; // The issue composer: 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, @@ -680,10 +681,11 @@ function Attachments({ key={shot.id} className="flex items-center gap-3 rounded-lg border border-border px-3 py-2" > - {t("recorder.composer.screenshot",} />
{t("recorder.composer.screenshot", { n: i + 1 })} diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index 031df4af..e22319ea 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -10,6 +10,7 @@ import { useMediaLogRef } from "../lib/mediaLog"; import { useMediaSession } from "../lib/mediaSession"; import { useMediaKeys } from "../lib/mediaKeys"; import { keptPosition, useResume } from "../lib/resume"; +import RemoteImage from "./ui/RemoteImage"; // In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin // YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain @@ -94,7 +95,7 @@ export default function LibraryPlayer({ {isAudio ? (
{poster ? ( -
{items[0]?.thumbnail_url && ( - + )}
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 6013690f..ec8079a7 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -29,6 +29,7 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { MoreButton } from "./ui/MoreButton"; +import RemoteImage from "./ui/RemoteImage"; type PlSort = { key: "custom" | "name" | "count" | "duration" | "recent"; @@ -298,7 +299,11 @@ export default function PlaylistsRail({
{pl.cover_thumbnail && ( - + )}
{pl.cover_thumbnail && ( - import("./PlexPlayer")); @@ -672,18 +673,17 @@ export default function PlexBrowse() { title={t("plex.info.viewPerson", { name: p.name })} className="group/person flex min-w-0 items-center gap-2.5 rounded-lg" > - {p.photo ? ( - - ) : ( -
- -
- )} + + +
+ } + />
{p.name} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 3256ea2f..934b7efd 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { toggleWatchState } from "../lib/plexWatch"; import { vocab } from "../lib/vocab"; +import RemoteImage from "./ui/RemoteImage"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -403,10 +404,16 @@ export default function PlexInfo({ >
{/* Poster */} - + } />
diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index 3e042133..3cf06368 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState } from "./ui/QueryState"; import { cardImage } from "../lib/plexImage"; +import RemoteImage from "./ui/RemoteImage"; // A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and // the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and @@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
    {results.map((c) => (
  • - - +
); } @@ -201,7 +207,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
- {block.items.slice(0, 3).map((ep, i) => ( - - 0 && [0, 1].map((i) => posters[i] ? ( -
{next && ( - ; open: boolean }; @@ -252,7 +253,7 @@ function Thumb({ className={MEDIA_PICTURE} > {video.thumbnail_url ? ( - void; }) { - const [failed, setFailed] = useState(false); - if (failed) { - return ( -
-
- {label} -
-
- ); - } return ( onOpen?.(src, label)} aria-label={label} className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`} > - {label} setFailed(true)} className="w-full h-full object-cover object-top" + fallback={} /> @@ -254,6 +245,15 @@ function Preview({ ); } +// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark. +function ShotLabel({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + // Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport // (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape. function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) { @@ -320,7 +320,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose className="flex max-h-full max-w-full flex-col items-center gap-3" onClick={(e) => e.stopPropagation()} > - {label}(null); - if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null); - // The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same - // way, so the row falls to the glyph below instead of the browser's broken-image mark. - const [failedRemote, setFailedRemote] = useState(null); - if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null); - // An image file's own picture failed (gone since, or not an image after all): the glyph instead. - const [failedPicture, setFailedPicture] = useState(null); - const candidate = failedPrimary && failedPrimary === primary ? remote : primary; - const src = candidate && candidate === failedRemote ? null : candidate; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. An image is its own picture (its sandboxed preview); the rest show - // a neutral file glyph instead of an (always-empty) thumbnail. + // a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer + // (gone since, or not an image after all), not a hiccup: the glyph at once, no retries. if (job.asset?.media_kind === "file") { - const picture = imageThumbOf(job); + const glyph =
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the same text height and the row of circles sits on one baseline regardless of name length. */} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 532bacea..e4cc3da8 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -16,6 +16,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { Chip } from "./ui/Chip"; +import RemoteImage from "./ui/RemoteImage"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -263,10 +264,13 @@ export function DetailHero({ className={`group/poster relative ${posterSizeClass} shrink-0 self-start`} title={posterTitle} > - + } /> {showPlayOverlay && (
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index e03d7092..fd9b4269 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -62,6 +62,7 @@ import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; import { vocab } from "../lib/vocab"; import { formatDate, formatDecimal } from "../lib/format"; +import RemoteImage from "./ui/RemoteImage"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, @@ -292,13 +293,16 @@ export function PlexPersonView({
- {d.photo ? ( - - ) : ( -
- -
- )} + + +
+ } + />
{d.name} diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx index 712283b8..43366d8e 100644 --- a/frontend/src/components/ui/Avatar.tsx +++ b/frontend/src/components/ui/Avatar.tsx @@ -1,15 +1,9 @@ -import { useState } from "react"; +import RemoteImage from "./RemoteImage"; -// Avatar image with a graceful fallback. -// -// Uses referrerPolicy="no-referrer" because Google's avatar CDNs -// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests -// from our origin — yt3 refuses even a single load, not only a burst (measured -// 2026-09-26) — and the browser shows its broken-image icon. Dropping the -// referrer makes those CDNs serve the images. The channel banner, from the same -// CDN, does the same (ChannelPage). -// If a load still fails we render a neutral placeholder (optionally an initial) -// instead of the broken-image icon. +// Avatar image with a graceful fallback: a neutral disc (optionally the name's initial) while there is +// no picture. The loading itself — no Referer for Google's avatar CDNs, the retry past the browser's +// negative cache, the failure remembered by URL so a recycled feed slot never paints one channel's +// initial over the next one's picture — is ui/RemoteImage's. export default function Avatar({ src, alt = "", @@ -21,43 +15,21 @@ export default function Avatar({ fallback?: string; // a name/title; its first letter is shown when there's no image className?: string; }) { - // The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes. - // That distinction is visible: this component is recycled across items in the virtualized feed, so - // one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image - // for a frame — React committed the render with the new `src` and the stale `failed`, and only the - // post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset. - // - // Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429 - // TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src` - // swaps — the player's channel avatar stepping through a queue — would - // show the initial for a throttled channel forever, never re-requesting even after the CDN - // relented. Written in render because it is convergent (the write falsifies its own condition) and - // because an effect would leave one committed frame in which the memory is stale, which is the - // frame this whole component is about. - const [failedSrc, setFailedSrc] = useState(null); - if (failedSrc !== null && failedSrc !== src) setFailedSrc(null); - const failed = !!src && failedSrc === src; - - if (src && !failed) { - return ( - {alt} setFailedSrc(src)} - className={className} - /> - ); - } - const initial = fallback?.trim()?.[0]?.toUpperCase() ?? ""; return ( -
- {initial} -
+ + {initial} +
+ } + /> ); } diff --git a/frontend/src/components/ui/PageMenu.dom.test.tsx b/frontend/src/components/ui/PageMenu.dom.test.tsx index 6140b4ca..b96375df 100644 --- a/frontend/src/components/ui/PageMenu.dom.test.tsx +++ b/frontend/src/components/ui/PageMenu.dom.test.tsx @@ -71,6 +71,7 @@ function Page({ withMenu = true }: { withMenu?: boolean }) { >
+ {/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 86f00cc6..b92cf515 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `