Merge Batch 17 — layout & virtualization (L161, L104)

This commit is contained in:
2026-07-02 11:43:36 -04:00
9 changed files with 755 additions and 642 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "ui", "--", "--port", "4321", "--strictPort"],
"port": 4321
}
]
}
+18 -12
View File
@@ -992,13 +992,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`<Spinner size="tiny">` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only `<Spinner size="tiny">` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only
remaining `<Spinner>`s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing remaining `<Spinner>`s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing
rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.* rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.*
- [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`. - [x] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`.
*Deferred (bounded; needs the L161 layout rework + live verification): History is capped at 500 rows (L141), *Fixed (Batch 17, with L161): HistoryView renders through the shared `VirtualList` inside the new `fill`
so the un-virtualized render is bounded — not a real perf problem. Virtualizing it requires giving the list a screen shell — the list owns an internal scroll region (flex + 160px floor, DownloadsView's contract). The
fixed-height internal scroll container (the same `height:100%`/`overflow` rework L161 flags as needing container-level Ctrl+A moved onto a wrapper around the virtualized scroller (key events bubble up from the
live verification at multiple window sizes) *and* re-homing its container-level Ctrl+A and per-row Delete rows), and per-row Delete stays on each row so it works wherever the row mounts. Verified live in an
keyboard handling onto the virtualized scroller. That's a layout+interaction change best done watched, so it Electron probe against the vite preview at 1100×720 and 800×520: rows render via `[data-index]` virtual
rides with L161 rather than shipping unattended for a list that's already capped.* wrappers, the list scrolls internally, and `<main>`/body never scroll (probe metrics + screenshots).*
- [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and - [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
[store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it. [store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it.
*Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local *Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local
@@ -1301,12 +1301,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE` 8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE`
(`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is (`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is
still L5.)* still L5.)*
- [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set - [x] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set
`height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can `height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can
double-scroll or misalign the virtualized list's viewport. *Deferred (layout, needs live verification): it double-scroll or misalign the virtualized list's viewport. *Fixed (Batch 17): `<main>` no longer scrolls or
works today (the virtualized queue scrolls correctly), and reworking the `<main>` scroll/height contract is pads — it's a plain flex slot, and each screen owns its scrolling through two shared shells in
a layout change best validated with the app open at several window sizes — a watched pass, not an unattended [ui/Screen.tsx](src/renderer/src/components/ui/Screen.tsx): `page` (document screens — Library, Settings,
edit.* empty History: the shell scrolls) and `fill` (internally-scrolling screens — Downloads, Terminal, History:
the screen's list/log is the scroller, with the shell scrolling only as the short-window fallback). The
`height:100%` couplings are gone (`flexGrow:1; minHeight:0` inside the shell instead). The rework surfaced a
real pre-existing edge: on a very short window the flexed list collapsed to 0px — now floored at 160px with
the shell absorbing the overflow. Verified with an Electron probe at 1100×720 and 800×520 per screen:
exactly one active scroller per screen in the normal case, `<main>`/body never scroll, floors hold
(probe metrics + screenshots).*
- [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store - [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store
subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. *Deferred — same as subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. *Deferred — same as
PERF6: virtualization/the 500-cap bounds the mounted count, and a prop conflicts with PERF5's hoisted PERF6: virtualization/the 500-cap bounds the mounted count, and a prop conflicts with PERF5's hoisted
+7 -4
View File
@@ -7,7 +7,6 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion' import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster' import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useNav } from './store/nav' import { useNav } from './store/nav'
import { useDownloads } from './store/downloads' import { useDownloads } from './store/downloads'
@@ -55,9 +54,13 @@ const useStyles = makeStyles({
content: { content: {
flexGrow: 1, flexGrow: 1,
minWidth: 0, minWidth: 0,
overflowY: 'auto', // L161: <main> no longer scrolls or pads — each screen owns its scroll region
// Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else. // via the shared `page`/`fill` shells (ui/Screen.tsx), so a page scroll and an
padding: SPACE.page // internal list scroll can never stack. Padding (SPACE.page, UI4) lives on the
// shells.
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
} }
}) })
+76 -67
View File
@@ -26,14 +26,15 @@ const renderQueueRow = (item: DownloadItem): React.JSX.Element => <QueueItem ite
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
// Fill the scroll area so the queue list below can flex to the remaining // Fill the `fill` shell (L161) so the queue list below can flex to the
// height and scroll internally (it's virtualized), instead of the whole page // remaining height and scroll internally (it's virtualized), instead of the
// growing to thousands of rows. // whole page growing to thousands of rows.
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
// One section gap on every screen (UI2). // One section gap on every screen (UI2).
gap: SPACE.section, gap: SPACE.section,
height: '100%' flexGrow: 1,
minHeight: 0
}, },
queueHeader: { queueHeader: {
display: 'flex', display: 'flex',
@@ -63,9 +64,10 @@ const useStyles = makeStyles({
}, },
listScroll: { listScroll: {
flexGrow: 1, flexGrow: 1,
// min-height:0 lets this flex child shrink below its content height so it, // The floor keeps the virtualized queue visible on a very short window (the
// not the page, owns the scrolling. // `fill` shell scrolls the overflow); above it, the list shrinks below its
minHeight: 0 // content height so it, not the page, owns the scrolling.
minHeight: '160px'
} }
}) })
@@ -95,70 +97,77 @@ export function DownloadsView(): React.JSX.Element {
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.fill}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." /> <div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
<DownloadBar /> <DownloadBar />
{summary.active && ( {summary.active && (
<div className={styles.summary}> <div className={styles.summary}>
<ProgressBar <ProgressBar
className={styles.summaryBar} className={styles.summaryBar}
value={summary.progress} value={summary.progress}
thickness="large" thickness="large"
aria-label="Overall download progress" aria-label="Overall download progress"
/>
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
<div className={styles.queueHeader}>
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={retryAll}
>
Retry all failed ({summary.failed})
</Button>
)}
{cancelable > 0 && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelAll}
>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
</div>
{items.length === 0 ? (
<EmptyState
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
message="Nothing queued yet. Paste a URL above, then click Download."
/> />
<Caption1 className={styles.summaryText}> ) : (
{summary.downloading} downloading <VirtualList
{summary.queued ? `, ${summary.queued} queued` : ''} items={items}
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''} className={styles.listScroll}
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''} gap={10}
</Caption1> overscan={6}
</div> estimateSize={estimateRowSize}
)} getKey={getItemKey}
renderItem={renderQueueRow}
<div className={styles.queueHeader}> />
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2> )}
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={retryAll}
>
Retry all failed ({summary.failed})
</Button>
)}
{cancelable > 0 && (
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
</div> </div>
{items.length === 0 ? (
<EmptyState
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
message="Nothing queued yet. Paste a URL above, then click Download."
/>
) : (
<VirtualList
items={items}
className={styles.listScroll}
gap={10}
overscan={6}
estimateSize={estimateRowSize}
getKey={getItemKey}
renderItem={renderQueueRow}
/>
)}
</div> </div>
) )
} }
+247 -215
View File
@@ -30,6 +30,7 @@ import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
import { Select } from './Select' import { Select } from './Select'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from './ui/EmptyState'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
@@ -43,6 +44,21 @@ const useStyles = makeStyles({
flexDirection: 'column', flexDirection: 'column',
gap: SPACE.section gap: SPACE.section
}, },
// Fills the `fill` shell (L161) so the virtualized list below owns the scroll.
fill: {
flexGrow: 1,
minHeight: 0
},
listScroll: {
flexGrow: 1,
// The floor keeps the virtualized list visible on a very short window (the
// `fill` shell scrolls the overflow); above it, the list shrinks below its
// content height so it, not the page, owns the scrolling (same contract as
// DownloadsView).
minHeight: '160px',
display: 'flex',
flexDirection: 'column'
},
header: { header: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -65,11 +81,6 @@ const useStyles = makeStyles({
spacer: { spacer: {
flexGrow: 1 flexGrow: 1
}, },
list: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
row: { row: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -127,6 +138,11 @@ const KIND_FILTER_OPTIONS = [
{ value: 'audio', label: 'Audio' } { value: 'audio', label: 'Audio' }
] ]
// Hoisted so the virtualizer gets stable function identities (PERF5). The row
// renderer itself stays a component-scoped closure — it needs select-mode state.
const estimateRowSize = (): number => 76
const getEntryKey = (h: HistoryEntry): string => h.id
export function HistoryView(): React.JSX.Element { export function HistoryView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles() const screen = useScreenStyles()
@@ -214,230 +230,246 @@ export function HistoryView(): React.JSX.Element {
exitSelectMode() exitSelectMode()
} }
// One history row; rendered through the virtualized list (L104). The per-row
// Delete key handling stays on the row itself, so it works wherever the row is
// mounted in the window.
function renderRow(h: HistoryEntry): React.JSX.Element {
return (
<div
className={mergeClasses(styles.row, focus.focusRing)}
tabIndex={0}
onKeyDown={(e) => {
// Delete removes the focused row (not when a child button is focused).
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
remove(h.id)
}
}}
>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
kind={h.kind}
iconSize={22}
/>
<div className={styles.body}>
<Text className={text.title}>{h.title}</Text>
<Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(META_SEP)}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
}
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.page}>
<ScreenHeader title="History" description="Files you've finished downloading." /> <div className={mergeClasses(styles.root, screen.width)}>
<EmptyState <ScreenHeader title="History" description="Files you've finished downloading." />
icon={ <EmptyState
<div icon={
className={styles.emptyBadge} <div
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }} className={styles.emptyBadge}
> style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
<HistoryRegular /> >
</div> <HistoryRegular />
} </div>
message="No downloads yet." }
hint="Finished downloads will show up here." message="No downloads yet."
/> hint="Finished downloads will show up here."
/>
</div>
</div> </div>
) )
} }
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.fill}>
<ScreenHeader title="History" description="Files you've finished downloading." /> <div className={mergeClasses(styles.root, styles.fill, screen.width)}>
<div className={styles.header}> <ScreenHeader title="History" description="Files you've finished downloading." />
{selectMode ? ( <div className={styles.header}>
<> {selectMode ? (
<Caption1 className={styles.count}>{selected.size} selected</Caption1> <>
<Button size="small" appearance="subtle" onClick={toggleAll}> <Caption1 className={styles.count}>{selected.size} selected</Caption1>
{allFilteredSelected ? 'Select none' : 'Select all'} <Button size="small" appearance="subtle" onClick={toggleAll}>
</Button> {allFilteredSelected ? 'Select none' : 'Select all'}
<div className={styles.spacer} /> </Button>
{confirmDelete ? ( <div className={styles.spacer} />
<> {confirmDelete ? (
<Caption1 className={styles.count}> <>
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}? <Caption1 className={styles.count}>
</Caption1> Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
<Button </Caption1>
size="small" <Button
appearance="primary" size="small"
icon={<DeleteRegular />} appearance="primary"
onClick={deleteSelected} icon={<DeleteRegular />}
> onClick={deleteSelected}
Delete >
</Button> Delete
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}> </Button>
Cancel <Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
</Button> Cancel
</> </Button>
) : ( </>
<> ) : (
<Button <>
size="small" <Button
appearance="primary" size="small"
icon={<DeleteRegular />} appearance="primary"
onClick={() => setConfirmDelete(true)} icon={<DeleteRegular />}
disabled={selected.size === 0} onClick={() => setConfirmDelete(true)}
> disabled={selected.size === 0}
Delete selected >
</Button> Delete selected
<Button </Button>
size="small" <Button
appearance="subtle" size="small"
icon={<DismissRegular />} appearance="subtle"
onClick={exitSelectMode} icon={<DismissRegular />}
> onClick={exitSelectMode}
Cancel >
</Button> Cancel
</> </Button>
)} </>
</> )}
) : ( </>
<> ) : (
<Caption1 className={styles.count}> <>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'} <Caption1 className={styles.count}>
</Caption1> {entries.length} {entries.length === 1 ? 'download' : 'downloads'}
<Input </Caption1>
className={styles.search} <Input
input={{ 'aria-label': 'Search history' }} className={styles.search}
size="small" input={{ 'aria-label': 'Search history' }}
value={query} size="small"
onChange={(_, d) => setQuery(d.value)} value={query}
placeholder="Search title, channel, URL…" onChange={(_, d) => setQuery(d.value)}
contentBefore={<SearchRegular />} placeholder="Search title, channel, URL…"
/> contentBefore={<SearchRegular />}
<Select />
className={styles.kindFilter} <Select
aria-label="Filter by type" className={styles.kindFilter}
value={kindFilter} aria-label="Filter by type"
options={KIND_FILTER_OPTIONS} value={kindFilter}
onChange={(v) => setKindFilter(v as 'all' | MediaKind)} options={KIND_FILTER_OPTIONS}
/> onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
<div className={styles.spacer} /> />
<Button <div className={styles.spacer} />
size="small"
appearance="subtle"
icon={<CheckmarkSquareRegular />}
onClick={() => setSelectMode(true)}
>
Select
</Button>
{confirmClear ? (
<>
<Caption1 className={styles.count}>
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
clear()
setConfirmClear(false)
}}
>
Clear all
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
) : (
<Button <Button
size="small" size="small"
appearance="subtle" appearance="subtle"
icon={<DeleteRegular />} icon={<CheckmarkSquareRegular />}
onClick={() => setConfirmClear(true)} onClick={() => setSelectMode(true)}
> >
Clear history Select
</Button> </Button>
)} {confirmClear ? (
</> <>
<Caption1 className={styles.count}>
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
clear()
setConfirmClear(false)
}}
>
Clear all
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmClear(true)}
>
Clear history
</Button>
)}
</>
)}
</div>
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div
className={styles.listScroll}
onKeyDown={(e) => {
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
// select mode if needed). Scoped to the list, so it never hijacks
// Ctrl+A in the search field, which lives in the header above. Lives
// on this wrapper (key events bubble up from the rows) because the
// virtualized scroller below renders rows through a render prop (L104).
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault()
setSelectMode(true)
setSelected(new Set(filtered.map((x) => x.id)))
}
}}
>
<VirtualList
items={filtered}
className={styles.listScroll}
gap={8}
overscan={6}
estimateSize={estimateRowSize}
getKey={getEntryKey}
renderItem={renderRow}
/>
</div>
)} )}
</div> </div>
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div
className={styles.list}
onKeyDown={(e) => {
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
// select mode if needed). Scoped to the list, so it never hijacks
// Ctrl+A in the search field, which lives in the header above.
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault()
setSelectMode(true)
setSelected(new Set(filtered.map((x) => x.id)))
}
}}
>
{filtered.map((h: HistoryEntry) => {
return (
<div
key={h.id}
className={mergeClasses(styles.row, focus.focusRing)}
tabIndex={0}
onKeyDown={(e) => {
// Delete removes the focused row (not when a child button is focused).
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
remove(h.id)
}
}}
>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
kind={h.kind}
iconSize={22}
/>
<div className={styles.body}>
<Text className={text.title}>{h.title}</Text>
<Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(META_SEP)}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
)}
</div> </div>
) )
} }
+242 -238
View File
@@ -505,266 +505,270 @@ export function LibraryView(): React.JSX.Element {
} }
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.page}>
<ScreenHeader <div className={mergeClasses(styles.root, screen.width)}>
title="Library" <ScreenHeader
description="Add a channel or playlist once, then download its videos into organized folders." title="Library"
/> description="Add a channel or playlist once, then download its videos into organized folders."
<div className={styles.addRow}>
<Input
className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL to add it…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
/> />
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Add
</Button>
</div>
{clip.suggestion && ( <div className={styles.addRow}>
<LinkSuggestion <Input
prefix="Use copied link? " className={styles.addInput}
link={clip.suggestion} input={{ 'aria-label': 'Channel or playlist URL' }}
onAccept={() => { value={url}
const link = clip.accept() onChange={(_, d) => setUrl(d.value)}
if (link) setUrl(link) onKeyDown={(e) => e.key === 'Enter' && onIndex()}
}} placeholder="Paste a channel or playlist URL to add it…"
onDismiss={clip.dismiss} size="large"
dismissLabel="Dismiss suggested link" contentBefore={<LibraryRegular />}
/> disabled={indexing.active}
)}
<div className={styles.toolbar}>
<Hint
label={
watchedCount === 0
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
: 'Check your watched sources for new uploads'
}
placement="top"
align="start"
>
<Button
size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
</Hint>
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
<Switch
checked={autoDownloadNew}
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
aria-label="Auto-download new uploads"
/> />
</span>
<span className={styles.switchRow}>
<Caption1>Daily sync</Caption1>
<Switch
checked={scheduled}
onChange={(_, d) => toggleScheduled(d.checked)}
aria-label="Daily background sync"
/>
{scheduled && (
<Input
type="time"
size="small"
value={syncTime}
onChange={(_, d) => onSyncTimeChange(d.value)}
onBlur={onSyncTimeCommit}
aria-label="Daily sync time"
/>
)}
</span>
</div>
{indexing.active && (
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
{/* B2: a big channel's index walk can take minutes — let the user abort it. */}
<Button <Button
size="small" size="large"
appearance="subtle" appearance="primary"
icon={<DismissRegular />} icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={cancelIndexing} onClick={onIndex}
disabled={!url.trim() || indexing.active}
> >
Cancel Add
</Button> </Button>
</div> </div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? ( {clip.suggestion && (
<EmptyState <LinkSuggestion
icon={<LibraryRegular fontSize={ICON.hero} />} prefix="Use copied link? "
message="No channels or playlists yet. Paste one above to add it." link={clip.suggestion}
/> onAccept={() => {
) : ( const link = clip.accept()
<div className={styles.list}> if (link) setUrl(link)
{sources.map((src) => ( }}
<SourceCard onDismiss={clip.dismiss}
key={src.id} dismissLabel="Dismiss suggested link"
styles={styles} />
source={src} )}
expanded={selectedSourceId === src.id}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)} <div className={styles.toolbar}>
<Hint
label={
watchedCount === 0
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
: 'Check your watched sources for new uploads'
}
placement="top"
align="start"
>
<Button
size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
> >
<div className={styles.detail}> Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
<div className={styles.actionRow}> </Button>
<Checkbox </Hint>
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false} {syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
onChange={(_, d) => toggleAll(!!d.checked)} <div className={styles.toolbarSpacer} />
label="Select all" <span className={styles.switchRow}>
disabled={actionableItems.length === 0} <Caption1>Auto-download new</Caption1>
/> <Switch
<Caption1 className={text.muted}> checked={autoDownloadNew}
{items.length} videos{META_SEP} onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)} aria-label="Auto-download new uploads"
</Caption1> />
<div className={styles.actionSpacer} /> </span>
<SegmentedControl<MediaKind> <span className={styles.switchRow}>
value={enqueueKind} <Caption1>Daily sync</Caption1>
options={[ <Switch
{ value: 'video', label: 'Video' }, checked={scheduled}
{ value: 'audio', label: 'Audio' } onChange={(_, d) => toggleScheduled(d.checked)}
]} aria-label="Daily background sync"
onChange={setEnqueueKind} />
ariaLabel="Download as video or audio" {scheduled && (
/> <Input
{selected.size > 0 ? ( type="time"
<> size="small"
<Button value={syncTime}
size="small" onChange={(_, d) => onSyncTimeChange(d.value)}
appearance="subtle" onBlur={onSyncTimeCommit}
onClick={() => setSelected(new Set())} aria-label="Daily sync time"
> />
Clear )}
</Button> </span>
</div>
{indexing.active && (
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total
? ` (${indexing.current}/${indexing.total})`
: ''}
</Text>
{/* B2: a big channel's index walk can take minutes — let the user abort it. */}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelIndexing}
>
Cancel
</Button>
</div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? (
<EmptyState
icon={<LibraryRegular fontSize={ICON.hero} />}
message="No channels or playlists yet. Paste one above to add it."
/>
) : (
<div className={styles.list}>
{sources.map((src) => (
<SourceCard
key={src.id}
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Checkbox
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
onChange={(_, d) => toggleAll(!!d.checked)}
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={text.muted}>
{items.length} videos{META_SEP}
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1>
<div className={styles.actionSpacer} />
<SegmentedControl<MediaKind>
value={enqueueKind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={setEnqueueKind}
ariaLabel="Download as video or audio"
/>
{selected.size > 0 ? (
<>
<Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
disabled={selectedActionable.length === 0}
>
Download {selectedActionable.length} selected
</Button>
</>
) : (
<Button <Button
size="small" size="small"
appearance="primary" appearance="primary"
icon={<ArrowDownloadRegular />} icon={<ArrowDownloadRegular />}
onClick={downloadSelected} onClick={downloadPending}
disabled={selectedActionable.length === 0} disabled={pendingItems.length === 0}
> >
Download {selectedActionable.length} selected Download {pendingItems.length} pending
</Button> </Button>
</> )}
) : ( <span className={styles.switchRow}>
<Button <Caption1>Watch</Caption1>
size="small" <Switch
appearance="primary" checked={!!src.watched}
icon={<ArrowDownloadRegular />} onChange={(_, d) => setWatched(src.id, !!d.checked)}
onClick={downloadPending} aria-label={`Watch ${src.title} for new uploads`}
disabled={pendingItems.length === 0} />
> </span>
Download {pendingItems.length} pending
</Button>
)}
<span className={styles.switchRow}>
<Caption1>Watch</Caption1>
<Switch
checked={!!src.watched}
onChange={(_, d) => setWatched(src.id, !!d.checked)}
aria-label={`Watch ${src.title} for new uploads`}
/>
</span>
<Button
size="small"
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Refresh
</Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button <Button
size="small" size="small"
appearance="subtle" appearance="subtle"
icon={<DeleteRegular />} icon={<ArrowSyncRegular />}
onClick={() => setConfirmRemoveId(src.id)} onClick={() => reindexSource(src.id)}
disabled={indexing.active}
> >
Remove Refresh
</Button> </Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmRemoveId(src.id)}
>
Remove
</Button>
)}
</div>
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
// panel so 1000s of rows stay light. A definite height (not
// max-height) gives the virtualizer a viewport to measure.
<VirtualList
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
) : (
// Small source: render inline so the card grows naturally.
<div className={styles.plainList}>
{flatRows.map((row) => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)} )}
</div> </div>
</SourceCard>
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>} ))}
</div>
{flatRows.length > VIRTUALIZE_AT ? ( )}
// Big source: a fixed-height, internally-scrolling virtualized </div>
// panel so 1000s of rows stay light. A definite height (not
// max-height) gives the virtualizer a viewport to measure.
<VirtualList
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
) : (
// Small source: render inline so the card grows naturally.
<div className={styles.plainList}>
{flatRows.map((row) => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
</SourceCard>
))}
</div>
)}
</div> </div>
) )
} }
+33 -28
View File
@@ -66,36 +66,41 @@ export function SettingsView(): React.JSX.Element {
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean) const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.page}>
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." /> <div className={mergeClasses(styles.root, screen.width)}>
<div> <ScreenHeader
<Input title="Settings"
input={{ 'aria-label': 'Search settings' }} description="Folders, formats, network, cookies, and more."
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
contentBefore={<SearchRegular />}
style={{ width: '100%' }}
/> />
{noResults && ( <div>
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}> <Input
No settings match &quot;{search}&quot;. input={{ 'aria-label': 'Search settings' }}
</Caption1> value={search}
)} onChange={(_, d) => setSearch(d.value)}
</div> placeholder="Search settings…"
contentBefore={<SearchRegular />}
{CARDS.map((Card, i) => ( style={{ width: '100%' }}
<div />
// Static array, never reordered/filtered (cards only hide) — index key is stable. {noResults && (
key={i} <Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
ref={(el) => { No settings match &quot;{search}&quot;.
cardRefs.current[i] = el </Caption1>
}} )}
style={hidden[i] ? { display: 'none' } : undefined}
>
<Card />
</div> </div>
))}
{CARDS.map((Card, i) => (
<div
// Static array, never reordered/filtered (cards only hide) — index key is stable.
key={i}
ref={(el) => {
cardRefs.current[i] = el
}}
style={hidden[i] ? { display: 'none' } : undefined}
>
<Card />
</div>
))}
</div>
</div> </div>
) )
} }
+86 -76
View File
@@ -17,7 +17,8 @@ import { SPACE } from './ui/tokens'
import { useTerminalRun, type LineKind } from './useTerminalRun' import { useTerminalRun, type LineKind } from './useTerminalRun'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' }, // Fills the `fill` shell (L161); the log <pre> below is the screen's scroller.
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, flexGrow: 1, minHeight: 0 },
gate: { gate: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -86,87 +87,96 @@ export function TerminalView(): React.JSX.Element {
: undefined : undefined
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={screen.fill}>
<ScreenHeader <div className={mergeClasses(styles.root, screen.width)}>
title="Terminal" <ScreenHeader
description={ title="Terminal"
<> description={
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} <>
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically. Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
</> <code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
} </>
/> }
/>
{!customCommandEnabled && ( {!customCommandEnabled && (
<div className={styles.gate}> <div className={styles.gate}>
<Body1> <Body1>
The terminal runs the bundled yt-dlp with your own arguments part of custom commands, The terminal runs the bundled yt-dlp with your own arguments part of custom
which can pass arbitrary yt-dlp flags. Turn it on to use the terminal. commands, which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
</Body1> </Body1>
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
>
Enable custom commands
</Button>
</div>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button <Button
appearance="primary" appearance="primary"
icon={<PlayRegular />} size="small"
onClick={run} onClick={() => updateSettings({ customCommandEnabled: true })}
disabled={!customCommandEnabled || !args.trim()}
> >
Run Enable custom commands
</Button> </Button>
)} </div>
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={clearLines}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
{lines.length === 0 ? (
<EmptyState compact message="No output yet." hint="Run yt-dlp above to see its output." />
) : (
lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}>
{l.text}
</div>
))
)} )}
</pre>
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={clearLines}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre
className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)}
ref={logRef}
>
{lines.length === 0 ? (
<EmptyState
compact
message="No output yet."
hint="Run yt-dlp above to see its output."
/>
) : (
lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
</div> </div>
) )
} }
+35 -2
View File
@@ -1,7 +1,9 @@
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components' import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
import { SPACE } from './tokens'
/** /**
* One shared content max-width for every screen (UI1). * One shared content max-width for every screen (UI1), plus the screen scroll
* contract (L161).
* *
* Settings used to be a 640px column while the list screens (Downloads, Library, * Settings used to be a 640px column while the list screens (Downloads, Library,
* History, Terminal) were full-width, so on a wide window Settings read as an odd * History, Terminal) were full-width, so on a wide window Settings read as an odd
@@ -10,7 +12,19 @@ import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-compone
* is already under the cap, so the list screens are visually unchanged — only the * is already under the cap, so the list screens are visually unchanged — only the
* wide-window upper bound is now shared. * wide-window upper bound is now shared.
* *
* Merge `screen.width` onto each screen's existing root with `mergeClasses`. * Scroll contract (L161): App's `<main>` is a non-scrolling flex column — each
* screen owns its scrolling through one of two shells, so a page scroll and an
* internal list scroll can never stack:
*
* - `page` — document screens (Library, Settings, empty History): the shell
* scrolls; content is natural height.
* - `fill` — internally-scrolling screens (Downloads, Terminal, History): the
* shell fills the viewport and hides overflow; the screen's own list/log
* region (VirtualList, the terminal <pre>) is the only scroller.
*
* Usage: shell div outside, `mergeClasses(styles.root, screen.width)` inside.
* Inside a `fill` shell the inner root uses `flexGrow: 1; minHeight: 0` (not
* `height: 100%`, which is what coupled screens to `<main>`'s padding before).
*/ */
export const useScreenStyles = makeStyles({ export const useScreenStyles = makeStyles({
width: { width: {
@@ -18,6 +32,25 @@ export const useScreenStyles = makeStyles({
maxWidth: '1200px', maxWidth: '1200px',
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto' marginRight: 'auto'
},
page: {
flexGrow: 1,
minHeight: 0,
overflowY: 'auto',
padding: SPACE.page
},
fill: {
flexGrow: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
// Normally content fits and only the screen's internal list/log scrolls; on a
// very short window the fixed-height rows above the list (header, download
// bar) plus the list's min-height floor overflow, and the shell scrolls as
// the fallback instead of the floor collapsing to 0px.
overflowY: 'auto',
overflowX: 'hidden',
padding: SPACE.page
} }
}) })