diff --git a/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx b/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx index 312d08850..26b4f54fb 100644 --- a/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx +++ b/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx @@ -56,7 +56,71 @@ { label: "loudnorm", value: "LoudNorm" }, ]; - function Row({ label, help, control = 320, first = false, children }) { + // Level-2 explainer copy for the field-level progressive-disclosure pattern (ersatztv#734). + // Mirrors web/src/screens/FFmpegProfilesScreen.tsx's FIELD_HELP record; see + // docs/spa-conventions.md §15 for the contract this shape is mirroring. + const FIELD_HELP = { + threadCount: + "Caps the worker threads FFmpeg uses per transcode. 0 lets FFmpeg decide, which is usually right; a low fixed value keeps one channel from starving the others on a busy host, at the cost of falling behind realtime on heavy content.", + scalingBehavior: + "Decides what happens when the source aspect ratio does not match the preferred resolution. Scale and Pad keeps the whole picture and adds bars; Crop fills the frame and cuts whatever overflows; Stretch fills it by distorting the image, which is why it is rarely what you want.", + videoBitrate: + "Target output bitrate. Too low and the encoder throws away detail on motion; too high and clients on slow links buffer. Buffer size is the companion setting — it bounds how far the encoder may deviate from this target.", + videoBufferSize: + "How much bitrate deviation the encoder may bank before it has to correct. Roughly 2x the bitrate is the usual starting point. Very small values force a near-constant bitrate and hurt quality on scene changes.", + hardwareAcceleration: + "Offloads decode and encode to the GPU. The list only offers what this FFmpeg build supports, so an unsupported kind never appears here. What nothing checks when you save is whether the device itself is present and passed through to the container — that is the mismatch that fails at playback time.", + normalizeLoudnessMode: + "Levels volume across content from different sources. Off leaves each item at its own level, so volume jumps between them; loudnorm retargets everything to one integrated loudness, which evens that out at the cost of an extra filter in the graph and a less faithful dynamic range.", + }; + + // Mockup of the shared `FieldHelp` primitive (web/src/components/fieldHelp.tsx). The prototype + // reproduces the two opening signals that read in a static review (tap-pins and hover); the shipped + // component additionally handles keyboard focus, Escape, outside-press dismissal, the hover + // bridge, and portalling the panel out of the card's clip. + function FieldHelp({ label, detail }) { + const [pinned, setPinned] = React.useState(false); + const [hovered, setHovered] = React.useState(false); + const open = pinned || hovered; + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {open && ( + + {detail} + + )} + + ); + } + + function Row({ label, help, detail, control = 320, first = false, children }) { return (
-
{label}
+
+ {label} + {detail && } +
{help &&
{help}
}
{children}
@@ -177,7 +244,7 @@ - + @@ -191,7 +258,7 @@ {normalizeVideo && ( - + - + - + - + - + ` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](records/spa/datetime-local-input.md) | | `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](records/spa/deco-templates-table.md) | | `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](records/spa/download-sample-gate.md) | +| `spa.field-progressive-disclosure` | A consequential settings field explains itself through one shared `FieldHelp` icon trigger beside the field name — never the label itself, never a widened `Tooltip` — with the paragraph declared `as const` in the screen's own `FIELD_HELP` record and the panel portalled to `document.body`. | 2026-08-26 | [link](records/spa/field-progressive-disclosure.md) | | `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](records/spa/legacy-redirect-matcher.md) | | `spa.library-pickers-resolve-by-search` | A picker over a media-library table (Episode/Song/Image/Movie/MusicVideo/TelevisionShow/TelevisionSeason/Artist/OtherVideo/RemoteStream) resolves its options by SEARCH — a debounced `SearchPicker` calling `searchLibraryPickerOptions`, which issues at most ONE `getLibraryBrowseItems` request per settled query, bounded to `LIBRARY_PICKER_RESULTS` (25) rows — CLAMPED inside the helper, not merely defaulted — and gated on `LIBRARY_PICKER_MIN_QUERY` (2) characters. It list-loads NOTHING on mount or on a type switch, so there is no truncation to surface and no truncation hint. The typed text is COMPILED (`titleContainsQuery` → `title:**`), never forwarded raw. The current selection renders from the OWNING RECORD, not from the result set (`selectedName` on a rerun collection / playlist item; a single by-id detail read — `getShow`/`getSeason`/`getArtist` — for a filler preset, which stores only the id), and an edit draft is INITIALIZED ONCE from the detail read — never seeded from the list row, never reconciled against a late response — with the form withheld until it lands, the editor failing CLOSED when the response carries no USABLE concurrency token — absent, empty and whitespace-only ETags are ONE case, normalized in one place, so a PUT without `If-Match` is unreachable, and a deadline plus a route back so a hung request cannot strand it. An id NEVER travels without its namespace: search results are cached against `(source, query)` and list-backed options carry the type they were loaded for, so no id from one type can be offered under another; and every id entering editor state — search result, list-backed option, or a selection restored from a detail read — passes ONE shared `isSelectionId` (int32) predicate at that boundary, an unbindable id being treated as ABSENT rather than coerced. Conflicts are detected at SAVE time via `If-Match` -> 412 -> Reload, and Reload simply drops the draft back to null and re-runs the same initialize-once load, so the form is unmounted while the replacement is in flight; an asynchronously-resolved name is keyed to the id it was resolved for and never overwrites a label naming a different id. The typeahead implements the full ARIA combobox keyboard contract, because it replaces a natively keyboard-operable `); + const trigger = renderHelp(); + const wrap = trigger.parentElement as HTMLElement; + const elsewhere = screen.getByTestId('elsewhere'); + elsewhere.focus(); + + fireEvent.mouseEnter(wrap); + expect(screen.getByRole('note')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.getByRole('note')).toBeInTheDocument(); + expect(document.activeElement).toBe(elsewhere); + }); + + it('closes a pinned panel on Escape and returns focus to the trigger', () => { + const trigger = renderHelp(); + + fireEvent.click(trigger); + expect(screen.getByRole('note')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('note')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('closes a pinned panel on an outside pointer press but not an inside one', () => { + const trigger = renderHelp({ docsHref: 'https://example.invalid/docs' }); + + fireEvent.click(trigger); + fireEvent.pointerDown(screen.getByRole('link', { name: /Learn more/ })); + expect(screen.getByRole('note')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('renders the level-3 docs affordance only when a target is supplied', () => { + const trigger = renderHelp(); + fireEvent.click(trigger); + expect(screen.queryByRole('link')).toBeNull(); + cleanup(); + + const withDocs = renderHelp({ docsHref: 'https://example.invalid/docs', docsLabel: 'Full reference' }); + fireEvent.click(withDocs); + + const link = screen.getByRole('link', { name: /Full reference/ }); + expect(link).toHaveAttribute('href', 'https://example.invalid/docs'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer'); + }); +}); diff --git a/web/src/components/fieldHelp.tsx b/web/src/components/fieldHelp.tsx new file mode 100644 index 000000000..6f170ba20 --- /dev/null +++ b/web/src/components/fieldHelp.tsx @@ -0,0 +1,242 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode +} from 'react'; +import { createPortal } from 'react-dom'; +import { ExternalLink, Info } from 'lucide-react'; + +/** + * Field-level progressive disclosure (#734). + * + * Level 1 (the field name plus an optional one-sentence summary) is rendered by the calling + * screen's own row/label markup — see `.ctv-settings-row-help`. This component is level 2: a + * consistently-shaped trigger that opens a one-short-paragraph explainer, with an optional + * level-3 deep link to external docs. + * + * Why not `Tooltip` (components/feedback.tsx): that primitive is a single-line label for a + * control whose purpose is not otherwise stated — it is `white-space: nowrap`, has + * `pointer-events: none`, opens on hover/focus only, and cannot contain a link. All four are + * wrong for a paragraph with a click-through, and touch users never get it at all. The two + * coexist deliberately; do not widen `Tooltip` into this role. + * + * Why the panel is PORTALLED to `document.body` rather than positioned inside the wrapper: + * `.ctv-card` sets `overflow: hidden`, which clips a positioned descendant no matter its + * `z-index`. An in-flow panel on the last row of a card was measured showing 12px of a 92px + * paragraph. A portal escapes every ancestor's clip, so the panel's readability stops depending + * on which row of which card adopted it. + */ +export interface FieldHelpProps { + /** The field name this explains. Used to build the trigger's accessible name. */ + label: string; + /** One short paragraph. Longer treatments belong behind `docsHref`, not here. */ + detail: ReactNode; + /** Optional level-3 deep link. Renders a "Learn more" link inside the panel. */ + docsHref?: string; + docsLabel?: string; +} + +/** Offset between trigger and panel. */ +const GAP = 7; +/** Mirrors `max-width` on `.ctv-field-help-panel`; used only to keep the panel inside the viewport. */ +const PANEL_MAX_WIDTH = 320; +const EDGE = 8; + +interface PanelPosition { + left: number; + top?: number; + bottom?: number; + placement: 'top' | 'bottom'; +} + +export function FieldHelp({ label, detail, docsHref, docsLabel = 'Learn more' }: FieldHelpProps) { + const panelId = useId(); + const triggerRef = useRef(null); + const panelRef = useRef(null); + + // Four independent reasons the panel can be showing, none derived from another. `pinned` is the + // tap/click state, the only one a touch user can produce, and the ONLY one under which the + // panel's own interactive content (a `docsHref` link) is meant to be reachable — see the hover + // note in spa-conventions.md §15. Trigger-hover and panel-hover are tracked separately because + // the portal means the wrapper's mouseleave cannot stand in for "the pointer is still in this + // widget"; panel-hover keeps an already-entered panel open, it does not make the gap crossable. + const [pinned, setPinned] = useState(false); + const [hoveredTrigger, setHoveredTrigger] = useState(false); + const [hoveredPanel, setHoveredPanel] = useState(false); + const [focused, setFocused] = useState(false); + const open = pinned || hoveredTrigger || hoveredPanel || focused; + + // Set immediately before the programmatic re-focus that follows a dismiss, so the trigger's own + // focus handler does not read that focus as "the user tabbed here" and reopen what was just closed. + const suppressFocusOpen = useRef(false); + + const [position, setPosition] = useState(null); + + const dismiss = useCallback(() => { + setPinned(false); + setHoveredTrigger(false); + setHoveredPanel(false); + setFocused(false); + }, []); + + // Position the portalled panel against the trigger's viewport rect, flipping above when the panel + // would run off the bottom and there is room above. Runs in a layout effect so the flip lands + // before paint; re-runs on scroll/resize because a fixed panel does not travel with the page. + useLayoutEffect(() => { + // No reset on close: the panel is unmounted, and because this is a LAYOUT effect the next open + // recomputes before paint, so a stale position from the previous open is never painted. + if (!open) { + return; + } + + const update = () => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const rect = trigger.getBoundingClientRect(); + const height = panelRef.current?.offsetHeight ?? 0; + const spaceBelow = window.innerHeight - rect.bottom - GAP; + const spaceAbove = rect.top - GAP; + const flip = height > 0 && spaceBelow < height && spaceAbove > spaceBelow; + // Clamp against the panel's MEASURED width once it exists: using the max-width constant for a + // panel narrower than it would shove the panel left of its own trigger near the right edge. + const width = panelRef.current?.offsetWidth || PANEL_MAX_WIDTH; + const left = Math.max(EDGE, Math.min(rect.left, window.innerWidth - width - EDGE)); + + setPosition( + flip + ? { left, bottom: window.innerHeight - rect.top + GAP, placement: 'top' } + : { left, top: rect.bottom + GAP, placement: 'bottom' } + ); + }; + + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + // `detail` participates because changing the copy changes the panel height, and therefore the flip. + }, [detail, docsHref, open]); + + // Escape and outside-pointer dismissal. Deliberately NOT armed for a hover-only panel: a + // document-level Escape handler that fires whenever a pointer happens to rest on an info icon + // would steal focus from whatever the user was actually trying to dismiss. + const dismissible = pinned || focused; + useEffect(() => { + if (!dismissible) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + dismiss(); + if (document.activeElement !== triggerRef.current) { + suppressFocusOpen.current = true; + triggerRef.current?.focus(); + } + }; + const onPointerDown = (event: Event) => { + const target = event.target; + if ( + target instanceof Node && + (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) + ) { + return; + } + dismiss(); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('pointerdown', onPointerDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('pointerdown', onPointerDown); + }; + }, [dismiss, dismissible]); + + const panel = open ? ( + setHoveredPanel(true)} + onMouseLeave={() => setHoveredPanel(false)} + > + {detail} + {docsHref && ( + setPinned(false)} + > + {docsLabel} + + )} + + ) : null; + + return ( + setHoveredTrigger(true)} + onMouseLeave={() => setHoveredTrigger(false)} + > + + {panel && createPortal(panel, document.body)} + + ); +} diff --git a/web/src/components/index.ts b/web/src/components/index.ts index ca8a6d0aa..fbf63f38b 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -3,6 +3,7 @@ import './components.css'; export * from './bugPreview'; export * from './dataDisplay'; export * from './feedback'; +export * from './fieldHelp'; export * from './forms'; export * from './navigation'; export * from './overlay'; diff --git a/web/src/screens/FFmpegProfilesScreen.tsx b/web/src/screens/FFmpegProfilesScreen.tsx index ba095bbcd..5dfb3e796 100644 --- a/web/src/screens/FFmpegProfilesScreen.tsx +++ b/web/src/screens/FFmpegProfilesScreen.tsx @@ -3,7 +3,18 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react'; import { navigateToPath } from '../routing'; import { usePrimaryAction } from '../primaryAction'; -import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components'; +import { + Badge, + Button, + Card, + Checkbox, + ConfirmDialog, + FieldHelp, + IconButton, + Input, + Select, + Spinner +} from '../components'; import { createFFmpegProfile, deleteFFmpegProfile, @@ -25,6 +36,33 @@ const BASE_PATH = '/app/ffmpeg-profiles'; // form must not offer one it would silently override (ersatztv#529) const MINIMUM_QSV_EXTRA_HARDWARE_FRAMES = 64; +// Level-2 explainer copy for the progressive-disclosure pattern (#734). Colocated with the field +// definitions it describes — see docs/spa-conventions.md §15. One short paragraph each; anything +// longer belongs behind a `docsHref` once the external docs exist. Level 1 (the one-sentence +// summary) stays on the prop at the call site. +const FIELD_HELP = { + threadCount: + 'Caps the worker threads FFmpeg uses per transcode. 0 lets FFmpeg decide, which is usually right; a low fixed value keeps one channel from starving the others on a busy host, at the cost of falling behind realtime on heavy content.', + scalingBehavior: + 'Decides what happens when the source aspect ratio does not match the preferred resolution. Scale and Pad keeps the whole picture and adds bars; Crop fills the frame and cuts whatever overflows; Stretch fills it by distorting the image, which is why it is rarely what you want.', + videoBitrate: + 'Target output bitrate. Too low and the encoder throws away detail on motion; too high and clients on slow links buffer. Buffer size is the companion setting — it bounds how far the encoder may deviate from this target.', + videoBufferSize: + 'How much bitrate deviation the encoder may bank before it has to correct. Roughly 2x the bitrate is the usual starting point. Very small values force a near-constant bitrate and hurt quality on scene changes.', + hardwareAcceleration: + 'Offloads decode and encode to the GPU. The list only offers what this FFmpeg build supports, so an unsupported kind never appears here. What nothing checks when you save is whether the device itself is present and passed through to the container — that is the mismatch that fails at playback time.', + qsvExtraHardwareFrames: + `Extra surfaces in the QSV upload pool. A pool of 0 is measured to fail outright — the channel serves nothing at all — while values between 1 and ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES - 1} are untested rather than known-bad. Rather than trust them, the server raises anything smaller to ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} as it saves.`, + qsvPreferNativeDecoder: + 'Splits the pipeline: VA-API decodes, QSV encodes. The VA-API decoder tolerates imperfect streams that the QSV decoder rejects outright, and it is required for Dolby Vision, so this is the recommended setting on Intel.', + normalizeLoudnessMode: + 'Levels volume across content from different sources. Off leaves each item at its own level, so volume jumps between them; loudnorm retargets everything to one integrated loudness, which evens that out at the cost of an extra filter in the graph and a less faithful dynamic range.', + targetLoudness: + 'The integrated loudness loudnorm aims for, in LUFS. Broadcast practice sits near -23; streaming services sit nearer -16. Louder targets leave less headroom and are more likely to clip peaks.' + // `as const` rather than `Record`: under an index signature `FIELD_HELP.typo` + // types as `string`, so a mistyped key renders a trigger with an empty panel and no build error. +} as const; + // `Complete<…>` so the draft must name every request member: the edit path PUTs this whole // object to a full-replace endpoint, where an unset member is written as its default rather // than left alone. `qsvPreferNativeDecoder` is optional in the schema and both draft builders @@ -287,20 +325,29 @@ function draftFromProfile(profile: FFmpegProfile): Draft { function Row({ children, control = 320, + detail, + docsHref, first = false, help, label }: { children: ReactNode; control?: number; + /** Level-2 explainer paragraph; renders the shared FieldHelp trigger beside the label (#734). */ + detail?: string; + docsHref?: string; first?: boolean; + /** Level-1 one-sentence summary. */ help?: string; label: string; }) { return (
-
{label}
+
+ {label} + {detail && } +
{help &&
{help}
}
{children}
@@ -676,7 +723,7 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } | value={draft.name ?? ''} /> - + set({ threadCount })} value={draft.threadCount} /> @@ -694,7 +741,7 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } | {draft.normalizeVideo && ( <> - + set({ hardwareAcceleration: event.target.value as HardwareAccelerationKind })} options={hwaccelKinds} @@ -790,7 +837,12 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } | )} {draft.hardwareAcceleration === 'Qsv' ? ( - + {/* below this the QSV upload pool has too little headroom and transcoding fails on any unthrottled read; the server floors it anyway (ersatztv#529) */} set({ audioSampleRate })} unit="kHz" value={draft.audioSampleRate} /> - +