feat(audit): Batch 6-7 — design tokens + type/color roles, full migration
Landed complete design-token foundation (Batch 6) and type/color standardization (Batch 7): - New src/renderer/src/components/ui/tokens.ts: SPACE, RADIUS, ELEVATION, Z, ICON, MOTION, SCRIM, META_SEP scales - New src/renderer/src/components/ui/text.ts: useTextStyles hook + type-role convention (Subtitle2/Text/Body1/Caption1) - Migrated scattered literals in 17 renderer files to named tokens - Adopted unified muted color and row-title component across 7 list views - All tile glyphs snapped to ICON tiers (no literal px remain) - base.css reduced-motion gate + prefers-color-scheme alignment - Verified tile-glyph visual deltas (≤2px) for watched pass; UI3/UI12 implementation confirmed, awaiting visual check - CODE-AUDIT.md reconciled: Batch 6 complete (12 items checked, UI3+UI12 flagged); Batch 7 complete (6 items checked) Verified: typecheck (node+web) + 268 tests + eslint + production build green; touched files prettier-clean. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { Onboarding } from './components/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { LiveRegion } from './components/LiveRegion'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { SPACE } from './components/ui/tokens'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useNav } from './store/nav'
|
||||
import { useDownloads } from './store/downloads'
|
||||
@@ -40,7 +41,8 @@ const useStyles = makeStyles({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflowY: 'auto',
|
||||
padding: '24px 28px'
|
||||
// Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else.
|
||||
padding: SPACE.page
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -38,3 +38,20 @@ body {
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/*
|
||||
* One motion policy (UI26): honor the OS "reduce motion" preference everywhere.
|
||||
* The app's transitions are subtle (see MOTION in components/ui/tokens.ts), but a
|
||||
* user who asks for less motion gets none — this near-instant override covers any
|
||||
* current or future transition/animation without each component opting in.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string
|
||||
@@ -16,21 +17,21 @@ const useStyles = makeStyles({
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
zIndex: Z.overlay,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: '14vh',
|
||||
backgroundColor: 'rgba(0,0,0,0.32)'
|
||||
backgroundColor: SCRIM
|
||||
},
|
||||
panel: {
|
||||
width: 'min(560px, 92vw)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
...shorthands.borderRadius(RADIUS.card),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
boxShadow: tokens.shadow28,
|
||||
boxShadow: ELEVATION.overlay,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
input: {
|
||||
|
||||
@@ -30,6 +30,8 @@ import { QUALITY_OPTIONS } from '../qualityOptions'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { META_SEP } from './ui/tokens'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
import { useDownloadBarStyles } from './downloadBar/styles'
|
||||
import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar'
|
||||
@@ -37,6 +39,7 @@ import { PlaylistPanel } from './downloadBar/PlaylistPanel'
|
||||
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useDownloadBarStyles()
|
||||
const text = useTextStyles()
|
||||
const bar = useDownloadBar()
|
||||
const {
|
||||
url,
|
||||
@@ -201,9 +204,9 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.previewBody}>
|
||||
<Text className={styles.previewTitle}>{info.title}</Text>
|
||||
<Caption1 className={styles.previewMeta}>
|
||||
{[info.channel, info.durationLabel].filter(Boolean).join(' • ')}
|
||||
<Text className={text.title}>{info.title}</Text>
|
||||
<Caption1 className={text.muted}>
|
||||
{[info.channel, info.durationLabel].filter(Boolean).join(META_SEP)}
|
||||
</Caption1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
{kind !== 'video' && (
|
||||
<Field label="Audio format" hint="For audio-only downloads.">
|
||||
<Select
|
||||
aria-label="Audio format"
|
||||
value={value.audioFormat}
|
||||
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||
@@ -125,7 +124,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
hint="The file format used when video and audio are combined into one file."
|
||||
>
|
||||
<Select
|
||||
aria-label="Video container"
|
||||
value={value.videoContainer}
|
||||
options={VIDEO_CONTAINERS.map((c) => ({
|
||||
value: c,
|
||||
@@ -141,7 +139,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
hint="A preference when several formats match -- not a strict filter."
|
||||
>
|
||||
<Select
|
||||
aria-label="Preferred video codec"
|
||||
value={value.preferredVideoCodec}
|
||||
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
||||
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
||||
@@ -165,7 +162,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
||||
label={value.embedSubtitles ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.embedSubtitles && (
|
||||
@@ -194,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
<Switch
|
||||
checked={value.sponsorBlock}
|
||||
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
|
||||
label={value.sponsorBlock ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.sponsorBlock && (
|
||||
<div className={styles.subGroup}>
|
||||
<Field label="Action">
|
||||
<Select
|
||||
aria-label="SponsorBlock action"
|
||||
value={value.sponsorBlockMode}
|
||||
options={[
|
||||
{ value: 'remove', label: 'Remove segments (cut from file)' },
|
||||
@@ -240,7 +234,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
<Switch
|
||||
checked={value.embedChapters}
|
||||
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
||||
label={value.embedChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -250,14 +243,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
<Switch
|
||||
checked={value.splitChapters}
|
||||
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||
label={value.splitChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||
<Switch
|
||||
checked={value.embedMetadata}
|
||||
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
|
||||
label={value.embedMetadata ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -286,7 +277,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
<Switch
|
||||
checked={value.embedThumbnail}
|
||||
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
|
||||
label={value.embedThumbnail ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.embedThumbnail && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||
|
||||
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
|
||||
// measurement/key cache on every render (PERF5).
|
||||
@@ -30,7 +31,8 @@ const useStyles = makeStyles({
|
||||
// growing to thousands of rows.
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
// One section gap on every screen (UI2).
|
||||
gap: SPACE.section,
|
||||
height: '100%'
|
||||
},
|
||||
queueHeader: {
|
||||
@@ -114,8 +116,8 @@ export function DownloadsView(): React.JSX.Element {
|
||||
<Caption1 className={styles.summaryText}>
|
||||
{summary.downloading} downloading
|
||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
||||
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
||||
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
|
||||
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
@@ -143,7 +145,7 @@ export function DownloadsView(): React.JSX.Element {
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<ArrowDownloadRegular fontSize={40} />
|
||||
<ArrowDownloadRegular fontSize={ICON.hero} />
|
||||
<Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { Z } from './ui/tokens'
|
||||
|
||||
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
|
||||
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
|
||||
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
bubble: {
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
zIndex: Z.tooltip,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
|
||||
alignEnd: { right: 0 }
|
||||
})
|
||||
|
||||
interface HintProps {
|
||||
// `align` only shifts a top/bottom bubble left/right; it does nothing for a
|
||||
// left/right placement. The union makes that explicit — passing `align` with
|
||||
// `placement="left"|"right"` is a type error now, instead of being silently
|
||||
// ignored (L129).
|
||||
type HintProps = {
|
||||
label: string
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
align?: 'start' | 'end'
|
||||
children: React.ReactNode
|
||||
}
|
||||
} & (
|
||||
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
|
||||
| { placement: 'left' | 'right'; align?: never }
|
||||
)
|
||||
|
||||
export function Hint({
|
||||
label,
|
||||
|
||||
@@ -33,13 +33,15 @@ import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||
import { THUMB_SM } from '../thumbSizes'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
gap: SPACE.section
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
@@ -92,15 +94,6 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
title: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
meta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
@@ -126,7 +119,8 @@ const useStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: tokens.borderRadiusCircular,
|
||||
fontSize: '26px'
|
||||
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
|
||||
fontSize: `${ICON.section}px`
|
||||
},
|
||||
emptyHint: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
@@ -148,6 +142,7 @@ export function HistoryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
const isDark = useResolvedDark()
|
||||
const tc = thumbColors[isDark ? 'dark' : 'light']
|
||||
const entries = useHistory((s) => s.entries)
|
||||
@@ -406,11 +401,11 @@ export function HistoryView(): React.JSX.Element {
|
||||
iconSize={22}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<Text className={styles.title}>{h.title}</Text>
|
||||
<Caption1 className={styles.meta}>
|
||||
<Text className={text.title}>{h.title}</Text>
|
||||
<Caption1 className={text.muted}>
|
||||
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
|
||||
.filter(Boolean)
|
||||
.join(' • ')}
|
||||
.join(META_SEP)}
|
||||
</Caption1>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -43,6 +43,8 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
||||
import { THUMB_XS } from '../thumbSizes'
|
||||
|
||||
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
||||
@@ -73,8 +75,7 @@ const STATUS_PRIORITY: Record<DownloadStatus, number> = {
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
suggestion: {
|
||||
@@ -157,7 +158,8 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
// Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||
fontSize: `${ICON.control}px`
|
||||
},
|
||||
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
||||
@@ -170,7 +172,6 @@ const useStyles = makeStyles({
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
srcSub: { color: tokens.colorNeutralForeground3 },
|
||||
detail: {
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
padding: '12px 14px',
|
||||
@@ -214,7 +215,8 @@ const useStyles = makeStyles({
|
||||
rowThumb: {
|
||||
width: `${THUMB_XS.w}px`,
|
||||
height: `${THUMB_XS.h}px`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusSmall)
|
||||
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||
...shorthands.borderRadius(RADIUS.control)
|
||||
},
|
||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
rowTitle: {
|
||||
@@ -222,8 +224,7 @@ const useStyles = makeStyles({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 }
|
||||
}
|
||||
})
|
||||
|
||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||
@@ -250,6 +251,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
const sources = useSources((s) => s.sources)
|
||||
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||
@@ -477,8 +479,8 @@ export function LibraryView(): React.JSX.Element {
|
||||
>
|
||||
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<AppsListRegular />
|
||||
<span className={styles.groupTitle}>{row.title}</span>
|
||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
||||
<Text className={styles.groupTitle}>{row.title}</Text>
|
||||
<Caption1 className={text.muted}>{row.items.length}</Caption1>
|
||||
</button>
|
||||
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
||||
{allOn ? 'None' : 'All'}
|
||||
@@ -512,10 +514,10 @@ export function LibraryView(): React.JSX.Element {
|
||||
iconSize={16}
|
||||
/>
|
||||
<div className={styles.rowMain}>
|
||||
<span className={styles.rowTitle}>
|
||||
<Text className={styles.rowTitle}>
|
||||
{it.playlistIndex}. {it.title}
|
||||
</span>
|
||||
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
||||
</Text>
|
||||
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
|
||||
</div>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
@@ -579,14 +581,14 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="secondary"
|
||||
appearance="subtle"
|
||||
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||
onClick={onCheckNew}
|
||||
disabled={syncing || watchedCount === 0}
|
||||
>
|
||||
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||
</Button>
|
||||
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
|
||||
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
||||
<div className={styles.toolbarSpacer} />
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Auto-download new</Caption1>
|
||||
@@ -619,7 +621,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
{sources.length === 0 && !indexing.active ? (
|
||||
<div className={styles.empty}>
|
||||
<LibraryRegular fontSize={40} />
|
||||
<LibraryRegular fontSize={ICON.hero} />
|
||||
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -640,9 +642,9 @@ export function LibraryView(): React.JSX.Element {
|
||||
label="Select all"
|
||||
disabled={actionableItems.length === 0}
|
||||
/>
|
||||
<Caption1 className={styles.srcSub}>
|
||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
||||
{relTime(src.lastIndexedAt)}
|
||||
<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>
|
||||
@@ -703,7 +705,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
</Button>
|
||||
{confirmRemoveId === src.id ? (
|
||||
<>
|
||||
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1>
|
||||
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
@@ -735,7 +737,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
||||
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
|
||||
|
||||
{flatRows.length > VIRTUALIZE_AT ? (
|
||||
// Big source: a fixed-height, internally-scrolling virtualized
|
||||
@@ -781,6 +783,7 @@ function SourceCard({
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<button
|
||||
@@ -796,16 +799,20 @@ function SourceCard({
|
||||
</div>
|
||||
<div className={styles.srcMeta}>
|
||||
<span className={styles.srcTitleRow}>
|
||||
<span className={styles.srcTitle}>{source.title}</span>
|
||||
<Text className={styles.srcTitle}>{source.title}</Text>
|
||||
{source.watched && (
|
||||
<span className={styles.watchBadge}>
|
||||
<AlertRegular fontSize={11} /> Watching
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption1 className={styles.srcSub}>
|
||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
|
||||
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
|
||||
<Caption1 className={text.muted}>
|
||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
||||
{META_SEP}
|
||||
{source.itemCount} videos
|
||||
{source.channel && source.channel !== source.title
|
||||
? `${META_SEP}${source.channel}`
|
||||
: ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
VideoClipRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { SPACE, ICON } from './ui/tokens'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -30,13 +31,14 @@ const useStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflowY: 'auto',
|
||||
padding: '24px'
|
||||
padding: SPACE.page
|
||||
},
|
||||
card: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
padding: '32px',
|
||||
gap: SPACE.roomy,
|
||||
// The welcome hero card (UI3 hero tier).
|
||||
padding: SPACE.hero,
|
||||
maxWidth: '460px',
|
||||
width: '100%',
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||
@@ -56,11 +58,12 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '26px'
|
||||
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||
fontSize: `${ICON.section}px`
|
||||
},
|
||||
folderNote: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
marginBottom: '8px'
|
||||
marginBottom: SPACE.tight
|
||||
},
|
||||
folderRow: {
|
||||
display: 'flex',
|
||||
@@ -72,10 +75,10 @@ const useStyles = makeStyles({
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
folderRowGap: {
|
||||
marginTop: '8px'
|
||||
marginTop: SPACE.tight
|
||||
},
|
||||
folderIcon: {
|
||||
fontSize: '18px',
|
||||
fontSize: `${ICON.control}px`,
|
||||
flexShrink: 0,
|
||||
color: tokens.colorCompoundBrandForeground1
|
||||
},
|
||||
@@ -106,9 +109,9 @@ const useStyles = makeStyles({
|
||||
gap: '10px'
|
||||
},
|
||||
tipIcon: {
|
||||
fontSize: '18px',
|
||||
fontSize: `${ICON.control}px`,
|
||||
flexShrink: 0,
|
||||
marginTop: '2px',
|
||||
marginTop: SPACE.hairline,
|
||||
color: tokens.colorCompoundBrandForeground1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,15 +34,18 @@ import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
||||
import { THUMB_MD } from '../thumbSizes'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
gap: '14px',
|
||||
padding: '14px',
|
||||
// List-item card: card padding + list-item radius (UI3/UI7).
|
||||
padding: SPACE.section,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
...shorthands.borderRadius(RADIUS.surface),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
thumb: {
|
||||
@@ -52,7 +55,8 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||
...shorthands.borderRadius(RADIUS.control)
|
||||
},
|
||||
body: {
|
||||
flexGrow: 1,
|
||||
@@ -66,20 +70,11 @@ const useStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
title: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
meta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
progressRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginTop: '4px'
|
||||
marginTop: SPACE.xtight
|
||||
},
|
||||
progressBar: {
|
||||
flexGrow: 1
|
||||
@@ -94,7 +89,7 @@ const useStyles = makeStyles({
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
gap: SPACE.xtight,
|
||||
flexShrink: 0,
|
||||
// Icon-only row actions get a >=40px hit target for touch/pen (W16); the
|
||||
// glyph stays its normal size, the subtle button just carries more padding.
|
||||
@@ -116,6 +111,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
// One shallow-compared selection instead of 10 separate subscriptions per row
|
||||
// (L163). The actions are stable, so this never re-renders from the store.
|
||||
const {
|
||||
@@ -186,26 +182,29 @@ export const QueueItem = memo(function QueueItem({
|
||||
<div className={styles.titleRow}>
|
||||
{item.status === 'completed' && (
|
||||
<CheckmarkCircleFilled
|
||||
fontSize={16}
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<ErrorCircleFilled
|
||||
fontSize={16}
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={styles.title}>{item.title}</Text>
|
||||
<Text className={text.title}>{item.title}</Text>
|
||||
<StatusChip status={item.status} />
|
||||
{item.incognito && (
|
||||
<Hint label="Private -- not saved to history" placement="top" align="start">
|
||||
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
||||
<EyeOffRegular
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorNeutralForeground3 }}
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
|
||||
<Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
|
||||
|
||||
{item.status === 'downloading' && (
|
||||
<div className={styles.progressRow}>
|
||||
@@ -224,8 +223,8 @@ export const QueueItem = memo(function QueueItem({
|
||||
) : (
|
||||
<>
|
||||
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
|
||||
{speedLabel ? ` • ${speedLabel}` : ''}
|
||||
{etaLabel ? ` • ${etaLabel} left` : ''}
|
||||
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
|
||||
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
|
||||
</>
|
||||
)}
|
||||
</Caption1>
|
||||
@@ -247,7 +246,10 @@ export const QueueItem = memo(function QueueItem({
|
||||
thickness="large"
|
||||
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||
/>
|
||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||
<Caption1 className={styles.stats}>
|
||||
Paused{META_SEP}
|
||||
{pct(item.progress)}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import {
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands,
|
||||
useFieldControlProps_unstable
|
||||
} from '@fluentui/react-components'
|
||||
|
||||
// A native <select> styled to match Fluent inputs. We use this instead of
|
||||
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
|
||||
@@ -69,8 +75,16 @@ export function Select({
|
||||
disabled
|
||||
}: SelectProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
// Integrate with a wrapping Fluent <Field> (L133): pick up the id + label/hint
|
||||
// association so the Field's visible label names the select natively (via
|
||||
// `htmlFor`), instead of every call site repeating the name in an `aria-label`.
|
||||
// Outside a Field this returns the props unchanged, so standalone selects keep
|
||||
// their own `aria-label`. `supportsLabelFor` = native <label for>, not aria-labelledby.
|
||||
const fieldProps = useFieldControlProps_unstable({}, { supportsLabelFor: true })
|
||||
return (
|
||||
<select
|
||||
{...fieldProps}
|
||||
aria-label={ariaLabel}
|
||||
className={mergeClasses(
|
||||
styles.select,
|
||||
size === 'large' && styles.large,
|
||||
@@ -79,7 +93,6 @@ export function Select({
|
||||
)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((o) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Input, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { SearchRegular } from '@fluentui/react-icons'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { SPACE } from './ui/tokens'
|
||||
import { DownloadsCard } from './settings/DownloadsCard'
|
||||
import { AppearanceCard } from './settings/AppearanceCard'
|
||||
import { PostProcessingCard } from './settings/PostProcessingCard'
|
||||
@@ -18,7 +19,7 @@ const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px'
|
||||
gap: SPACE.section
|
||||
}
|
||||
})
|
||||
|
||||
@@ -77,7 +78,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{noResults && (
|
||||
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
|
||||
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
|
||||
No settings match "{search}".
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { IconButton } from './ui/IconButton'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { MOTION, ICON } from './ui/tokens'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
@@ -30,7 +32,8 @@ const useStyles = makeStyles({
|
||||
padding: '16px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
transition: 'width 0.15s ease'
|
||||
// The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
|
||||
transition: `width ${MOTION.duration} ${MOTION.curve}`
|
||||
},
|
||||
rootCollapsed: {
|
||||
width: '60px',
|
||||
@@ -64,7 +67,8 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||
fontSize: `${ICON.control}px`
|
||||
},
|
||||
brandText: {
|
||||
display: 'flex',
|
||||
@@ -77,9 +81,6 @@ const useStyles = makeStyles({
|
||||
lineHeight: tokens.lineHeightBase400,
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
caption: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -117,7 +118,7 @@ const useStyles = makeStyles({
|
||||
}
|
||||
},
|
||||
navIcon: {
|
||||
fontSize: '18px',
|
||||
fontSize: `${ICON.control}px`,
|
||||
flexShrink: 0,
|
||||
display: 'flex'
|
||||
},
|
||||
@@ -169,6 +170,7 @@ export function Sidebar({
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
|
||||
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||
@@ -178,11 +180,11 @@ export function Sidebar({
|
||||
}
|
||||
const themeIcon =
|
||||
theme === 'system' ? (
|
||||
<DesktopRegular fontSize={18} />
|
||||
<DesktopRegular fontSize={ICON.control} />
|
||||
) : isDark ? (
|
||||
<WeatherMoonRegular fontSize={18} />
|
||||
<WeatherMoonRegular fontSize={ICON.control} />
|
||||
) : (
|
||||
<WeatherSunnyRegular fontSize={18} />
|
||||
<WeatherSunnyRegular fontSize={ICON.control} />
|
||||
)
|
||||
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
||||
|
||||
@@ -209,7 +211,7 @@ export function Sidebar({
|
||||
{/* Stable tagline so the caption never flips from text to a version
|
||||
string once it loads (L66); the version shows on hover and in
|
||||
Settings → About. */}
|
||||
<Caption1 className={styles.caption} title={version ? `Version ${version}` : undefined}>
|
||||
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
|
||||
Video downloader
|
||||
</Caption1>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icon
|
||||
import { useSettings } from '../store/settings'
|
||||
import { newId } from '../id'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { SPACE } from './ui/tokens'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
@@ -29,7 +30,7 @@ const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, k
|
||||
const MAX_LOG_LINES = 2000
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
|
||||
gate: {
|
||||
padding: '12px 14px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
@@ -180,7 +181,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
{running ? (
|
||||
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { type PlaylistInfo } from '@shared/ipc'
|
||||
import { type MediaKind } from '../../store/downloads'
|
||||
import { Hint } from '../Hint'
|
||||
import { IconButton } from '../ui/IconButton'
|
||||
import { useTextStyles } from '../ui/text'
|
||||
import { META_SEP } from '../ui/tokens'
|
||||
import { useDownloadBarStyles } from './styles'
|
||||
|
||||
interface PlaylistPanelProps {
|
||||
@@ -28,15 +30,16 @@ export function PlaylistPanel({
|
||||
onToggleItemKind
|
||||
}: PlaylistPanelProps): React.JSX.Element {
|
||||
const styles = useDownloadBarStyles()
|
||||
const text = useTextStyles()
|
||||
return (
|
||||
<div className={styles.plPanel}>
|
||||
<div className={styles.plHeader}>
|
||||
<AppsListRegular />
|
||||
<Caption1 className={styles.plHeaderText}>
|
||||
{playlist.title}
|
||||
{playlist.uploader ? ` • ${playlist.uploader}` : ''}
|
||||
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
|
||||
</Caption1>
|
||||
<Caption1 className={styles.previewMeta}>
|
||||
<Caption1 className={text.muted}>
|
||||
{selected.size} of {playlist.count} selected
|
||||
</Caption1>
|
||||
<Button size="small" appearance="subtle" onClick={onToggleAll}>
|
||||
@@ -62,8 +65,8 @@ export function PlaylistPanel({
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={styles.plItemMeta}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
|
||||
<Caption1 className={text.muted}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
|
||||
</Caption1>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { THUMB_LG } from '../../thumbSizes'
|
||||
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
|
||||
|
||||
export const useDownloadBarStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
gap: SPACE.cozy,
|
||||
// Page-level card: card padding + card radius, and the app's one "raised"
|
||||
// elevation — the download bar floats above the queue (UI3/UI7/L100).
|
||||
padding: SPACE.section,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
boxShadow: tokens.shadow4
|
||||
...shorthands.borderRadius(RADIUS.card),
|
||||
boxShadow: ELEVATION.raised
|
||||
},
|
||||
// Highlight while a link / .url file is dragged over the card.
|
||||
rootDragging: {
|
||||
@@ -73,7 +76,8 @@ export const useDownloadBarStyles = makeStyles({
|
||||
justifyContent: 'center',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium)
|
||||
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||
...shorthands.borderRadius(RADIUS.control)
|
||||
},
|
||||
previewBody: {
|
||||
flexGrow: 1,
|
||||
@@ -83,15 +87,6 @@ export const useDownloadBarStyles = makeStyles({
|
||||
justifyContent: 'center',
|
||||
gap: '2px'
|
||||
},
|
||||
previewTitle: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
previewMeta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
statusRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -170,9 +165,6 @@ export const useDownloadBarStyles = makeStyles({
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
plItemMeta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
// --- duplicate warning ---
|
||||
dupRow: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -108,7 +108,6 @@ export function AboutCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={autoUpdateYtdlp}
|
||||
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
|
||||
label={autoUpdateYtdlp ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -142,10 +141,13 @@ export function AboutCard(): React.JSX.Element {
|
||||
</Caption1>
|
||||
)}
|
||||
<div className={styles.folderRow}>
|
||||
<Button onClick={checkVersion} disabled={checking}>
|
||||
<Button
|
||||
icon={checking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||
onClick={checkVersion}
|
||||
disabled={checking}
|
||||
>
|
||||
Check yt-dlp version
|
||||
</Button>
|
||||
{checking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
@@ -153,7 +155,6 @@ export function AboutCard(): React.JSX.Element {
|
||||
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
|
||||
>
|
||||
<Select
|
||||
aria-label="Update channel"
|
||||
value={ytdlpChannel}
|
||||
options={UPDATE_CHANNEL_OPTIONS}
|
||||
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function AppearanceCard(): React.JSX.Element {
|
||||
|
||||
<Field label="Theme">
|
||||
<Select
|
||||
aria-label="Theme"
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={(v) => update({ theme: v as ThemeMode })}
|
||||
|
||||
@@ -77,7 +77,6 @@ export function CookiesCard(): React.JSX.Element {
|
||||
|
||||
<Field label="Cookie source">
|
||||
<Select
|
||||
aria-label="Cookie source"
|
||||
value={cookieSource}
|
||||
options={COOKIE_SOURCE_OPTIONS}
|
||||
onChange={(v) => update({ cookieSource: v as CookieSource })}
|
||||
@@ -90,7 +89,6 @@ export function CookiesCard(): React.JSX.Element {
|
||||
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
|
||||
>
|
||||
<Select
|
||||
aria-label="Browser"
|
||||
value={cookiesBrowser}
|
||||
options={COOKIE_BROWSER_OPTIONS}
|
||||
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
|
||||
|
||||
@@ -32,14 +32,12 @@ export function CustomCommandsCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={customCommandEnabled}
|
||||
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
|
||||
label={customCommandEnabled ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{customCommandEnabled && (
|
||||
<Field label="Default template">
|
||||
<Select
|
||||
aria-label="Default template"
|
||||
value={defaultTemplateId ?? 'none'}
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
|
||||
@@ -105,12 +105,7 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
</Field>
|
||||
|
||||
<Field label="Default format">
|
||||
<Select
|
||||
aria-label="Default format"
|
||||
value={formatValue}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={onFormatSelect}
|
||||
/>
|
||||
<Select value={formatValue} options={FORMAT_OPTIONS} onChange={onFormatSelect} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
@@ -139,7 +134,6 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={clipboardWatch}
|
||||
onChange={(_, d) => update({ clipboardWatch: d.checked })}
|
||||
label={clipboardWatch ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -150,7 +144,6 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={notifyOnComplete}
|
||||
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
|
||||
label={notifyOnComplete ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -161,7 +154,6 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={minimizeToTray}
|
||||
onChange={(_, d) => update({ minimizeToTray: d.checked })}
|
||||
label={minimizeToTray ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -172,7 +164,6 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={launchAtStartup}
|
||||
onChange={(_, d) => update({ launchAtStartup: d.checked })}
|
||||
label={launchAtStartup ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
@@ -36,7 +36,6 @@ export function FilenamesCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={restrictFilenames}
|
||||
onChange={(_, d) => update({ restrictFilenames: d.checked })}
|
||||
label={restrictFilenames ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -47,7 +46,6 @@ export function FilenamesCard(): React.JSX.Element {
|
||||
<Switch
|
||||
checked={downloadArchive}
|
||||
onChange={(_, d) => update({ downloadArchive: d.checked })}
|
||||
label={downloadArchive ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
@@ -50,11 +50,7 @@ export function NetworkCard(): React.JSX.Element {
|
||||
label="Use aria2c downloader"
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
|
||||
>
|
||||
<Switch
|
||||
checked={useAria2c}
|
||||
onChange={(_, d) => update({ useAria2c: d.checked })}
|
||||
label={useAria2c ? 'On' : 'Off'}
|
||||
/>
|
||||
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { SPACE, RADIUS, ICON } from '../ui/tokens'
|
||||
|
||||
// Shared styles for the Settings cards. Each card renders a single <Card> so it
|
||||
// stays one direct child of SettingsView's root -- the search filter toggles
|
||||
@@ -7,9 +8,10 @@ export const useSettingsStyles = makeStyles({
|
||||
card: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '20px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||
// Page-level card: card padding + card radius (UI3/UI7).
|
||||
gap: SPACE.section,
|
||||
padding: SPACE.section,
|
||||
...shorthands.borderRadius(RADIUS.card)
|
||||
},
|
||||
notesBox: {
|
||||
maxHeight: '180px',
|
||||
@@ -28,7 +30,8 @@ export const useSettingsStyles = makeStyles({
|
||||
gap: '10px'
|
||||
},
|
||||
sectionIcon: {
|
||||
fontSize: '20px',
|
||||
// Section-header glyph, next to each card's <Subtitle2> title (UI12 section tier).
|
||||
fontSize: `${ICON.section}px`,
|
||||
color: tokens.colorCompoundBrandForeground1
|
||||
},
|
||||
folderRow: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
import { ICON } from './tokens'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
btn: {
|
||||
@@ -23,8 +24,8 @@ const useStyles = makeStyles({
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
},
|
||||
md: { width: '32px', height: '32px', fontSize: '18px' },
|
||||
sm: { width: '28px', height: '28px', fontSize: '16px' }
|
||||
md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
|
||||
sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
|
||||
})
|
||||
|
||||
type IconButtonProps = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
import { ICON } from './tokens'
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T
|
||||
@@ -63,7 +64,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
icon: {
|
||||
display: 'flex',
|
||||
fontSize: '16px',
|
||||
fontSize: `${ICON.inline}px`,
|
||||
flexShrink: 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||
|
||||
/**
|
||||
* Shared text-role styles + the app's type-role convention (Batch 7).
|
||||
*
|
||||
* The renderer had drifted on typography: the one muted color was re-declared under
|
||||
* eight class names (L110), row titles were rendered as three different components
|
||||
* (L114), and `Caption1`/`Body1`/`Text` were applied by feel (L123/L124). The
|
||||
* convention below makes the roles explicit; this module supplies the two styles
|
||||
* that back it.
|
||||
*
|
||||
* Type roles — reach for the Fluent type component, add `muted` for secondary color:
|
||||
* - `<Subtitle2>` — screen titles and settings-section headers (via `ScreenHeader`
|
||||
* and each settings card's header).
|
||||
* - `<Text>` — the one row/item title component (add `title` for the semibold,
|
||||
* single-line-with-ellipsis treatment). Previously split across `<Text>`, a bare
|
||||
* `<span>`, and `<Caption1>` (L114).
|
||||
* - `<Body1>` — body copy: empty-state messages, onboarding prose, gated notices.
|
||||
* Not for titles or metadata (L124).
|
||||
* - `<Caption1>` — small secondary text: metadata, hints, counts, timestamps. One
|
||||
* role doing several closely-related jobs, all "small secondary text" (L123).
|
||||
*/
|
||||
export const useTextStyles = makeStyles({
|
||||
/**
|
||||
* The single muted / secondary-text color (L110) — was re-declared as
|
||||
* hint/sub/meta/srcSub/stats/count/emptyHint/previewMeta. Merge onto a `<Caption1>`
|
||||
* or `<Text>`, or use alone where the element sets no other style.
|
||||
*/
|
||||
muted: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
|
||||
title: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { tokens } from '@fluentui/react-components'
|
||||
|
||||
/**
|
||||
* AeroFetch design tokens — the app's own semantic scales, layered on Fluent's.
|
||||
*
|
||||
* Batch 6 of the audit (UI2–UI7, UI12, UI26, L100, L101, L121, L125, L160, L164)
|
||||
* found the same visual decisions expressed as ad-hoc literals scattered across
|
||||
* components: four section gaps, seven card paddings, two floating shadows, two
|
||||
* hardcoded `zIndex: 1000`s, a raw `rgba()` scrim, nine inline icon sizes, and
|
||||
* three metadata separators. This module is the single home for those values so
|
||||
* every screen reads as one product and a change happens in one place.
|
||||
*
|
||||
* Where Fluent already defines a scale (spacing, radius, shadow, motion, the
|
||||
* overlay scrim) these alias Fluent's tokens, so the app stays pixel-aligned with
|
||||
* Fluent's own controls. Where Fluent has no token (z-index, icon px, the
|
||||
* metadata separator) the value lives here.
|
||||
*
|
||||
* Griffel evaluates `makeStyles` at runtime in this project (no build plugin — see
|
||||
* `thumbSizes` already imported into `downloadBar/styles.ts`), so these imported
|
||||
* constants work directly inside `makeStyles` blocks and in inline `style={{}}`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Spacing ramp (px). Gaps, padding, and margins all draw from this one scale; the
|
||||
* comments record the role each step plays so call sites stay self-documenting.
|
||||
* Values match Fluent's spacing ramp (XXS 2 · XS 4 · S 8 · MNudge 10 · M 12 · L 16
|
||||
* · XL 20 · XXL 24 · XXXL 32).
|
||||
*/
|
||||
export const SPACE = {
|
||||
/** 2px — optical hairline nudge (e.g. aligning an icon to a text baseline) (L160). */
|
||||
hairline: '2px',
|
||||
/** 4px — icon-button clusters (UI5); tight margins between stacked lines (L160). */
|
||||
xtight: '4px',
|
||||
/** 8px — labelled-control rows (UI5); the default inline gap. */
|
||||
tight: '8px',
|
||||
/** 10px — control inner padding; small tiles. */
|
||||
snug: '10px',
|
||||
/** 12px — list-row padding; the gap between rows inside a card. */
|
||||
cozy: '12px',
|
||||
/** 16px — the one gap between screen sections (UI2) and the card padding tier (UI3). */
|
||||
section: '16px',
|
||||
/** 20px — a larger block gap. */
|
||||
roomy: '20px',
|
||||
/** 24px — page content padding (UI4); the hero padding tier's floor. */
|
||||
page: '24px',
|
||||
/** 32px — the onboarding / welcome hero card padding (UI3 hero tier). */
|
||||
hero: '32px'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Corner-radius tiers by surface (UI6/UI7). Aliases Fluent's radius tokens, which
|
||||
* `friendlyRadii` (theme.ts) tunes to Medium 10 · Large 12 · XLarge 16.
|
||||
*
|
||||
* - `control` (10) — inputs, thumbnails, small tiles, popover rows.
|
||||
* - `surface` (12) — list-item cards and inline panels.
|
||||
* - `card` (16) — page-level cards (the download bar, settings cards, onboarding).
|
||||
*/
|
||||
export const RADIUS = {
|
||||
control: tokens.borderRadiusMedium,
|
||||
surface: tokens.borderRadiusLarge,
|
||||
card: tokens.borderRadiusXLarge
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Elevation scale (L100). Cards are flat by default (border only — the app's
|
||||
* resting surface); only genuinely floating surfaces cast a shadow.
|
||||
*
|
||||
* - `flat` — the default card: no shadow, a 1px neutral-stroke border.
|
||||
* - `raised` (shadow4) — the download bar, which floats above the queue.
|
||||
* - `overlay` (shadow28) — the command palette and any future popover.
|
||||
*/
|
||||
export const ELEVATION = {
|
||||
flat: 'none',
|
||||
raised: tokens.shadow4,
|
||||
overlay: tokens.shadow28
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Z-index scale (L101). Replaces the two hardcoded `zIndex: 1000`s so stacking is
|
||||
* ordered on purpose: tooltips sit above full-screen overlays.
|
||||
*/
|
||||
export const Z = {
|
||||
/** full-screen scrims and the command palette. */
|
||||
overlay: 1000,
|
||||
/** hint bubbles — above overlays so a tooltip is never clipped by one. */
|
||||
tooltip: 1100
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Theme-aware scrim for full-screen overlays (L121). Fluent's overlay token
|
||||
* replaces the renderer's one raw `rgba(0,0,0,0.32)` — it's the semantic dialog
|
||||
* scrim and darkens a touch more, so the palette separates cleanly from a dark UI.
|
||||
*/
|
||||
export const SCRIM = tokens.colorBackgroundOverlay
|
||||
|
||||
/**
|
||||
* Icon-size scale in px (UI12/L125): one small set instead of the nine scattered
|
||||
* `fontSize` literals. Applied as a numeric `fontSize={ICON.x}` on standalone
|
||||
* glyphs, or `` `${ICON.x}px` `` on the rare makeStyles container.
|
||||
*
|
||||
* - `inline` (16) — glyphs sitting inline with text (row actions, small badges).
|
||||
* - `control` (20) — glyphs inside buttons, nav items, toggles, and small leading
|
||||
* accent icons (e.g. the onboarding folder/tip rows).
|
||||
* - `section` (24) — section/card-header glyphs (e.g. each settings card's title).
|
||||
* - `hero` (40) — the focal icon in an empty state.
|
||||
*
|
||||
* Snapped to the nearest tier rather than a literal px even when centered in a
|
||||
* fixed tile (UI11 — no literal px font sizes): the brand/source tiles
|
||||
* (Sidebar/Onboarding `mark`, Library `srcIcon`, History `emptyBadge`) take
|
||||
* `control` or `section`. Genuinely off this scale: the tiny `watchBadge` glyph
|
||||
* (matched to its badge-caption text size) and the thumbnail placeholder icons
|
||||
* (which track `thumbSizes`).
|
||||
*/
|
||||
export const ICON = {
|
||||
inline: 16,
|
||||
control: 20,
|
||||
section: 24,
|
||||
hero: 40
|
||||
} as const
|
||||
|
||||
/**
|
||||
* One motion policy (UI26). The app keeps a single, subtle transition timing
|
||||
* (matching the sidebar's former `0.15s ease`) and gates *all* transitions and
|
||||
* animations behind `prefers-reduced-motion` globally in base.css — so motion is
|
||||
* consistent where it exists and fully off for users who ask for less.
|
||||
*/
|
||||
export const MOTION = {
|
||||
duration: tokens.durationFast,
|
||||
curve: tokens.curveEasyEase
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The single metadata separator (L164). Joins independent fields on a meta line
|
||||
* (e.g. `Channel • 12:04`, `1.2 GB/s • 3s left`). Previously three styles were in
|
||||
* use (`' • '`, `' · '`, `' • '`). Note this is distinct from the *intra-value*
|
||||
* separator inside a compound quality label ("720p · mp4 · 184 MB"), which is a
|
||||
* data format parsed elsewhere and intentionally left alone.
|
||||
*/
|
||||
export const META_SEP = ' • '
|
||||
Reference in New Issue
Block a user