feat(audit): Batch 9 — surface primitives (empty-state, banner, truncation)
Shared UI primitives to end the "two ways to do X" drift on surfaces: - EmptyState (ui/EmptyState.tsx): one centered empty block — optional focal icon/badge + Body1 message + optional muted hint, with a `compact` variant for in-content placeholders. Adopted by Downloads, History, Library (L116), and — compact — the Terminal log, Diagnostics, and TemplateManager (L117). - Banner (ui/Banner.tsx) + LinkSuggestion (ui/LinkSuggestion.tsx): one tinted notification row (icon + truncating/wrap content + actions, tone brand/warning) replacing five hand-rolled style blocks — the DownloadBar copied-link suggestion / channel nudge / duplicate warning and the Library copied-link suggestion (UI19). - useTextStyles().truncate: shared single-line-ellipsis utility, adopted in Library rows + the playlist panel + Banner; `title` now also sets minWidth:0 so it shrinks in a flex row (L126). - L102: Settings "Default format" now uses the same SegmentedControl (Video/ Audio) + quality Select as the DownloadBar — one mental model; the combined `kind|quality` string encoding is gone. Resolved by verified convention (documented in code / CODE-AUDIT.md): - L103 busy = in-button icon→Spinner everywhere (done in L134; no adjacent- spinner pattern survives). - L115 list-row actions icon-only+tooltip vs card-toolbar actions labelled. - L120 Fluent <Card> for static content cards; tokenized surface recipe for interactive/list-item cards. - UI20 toggles = solid brand, list-selection = brand-tint (rule noted on SegmentedControl); the cited Library pill is gone via UI18. L128 (build-time DCE of preview seeds) deferred to Batch 14 with PERF8. Verified: typecheck (node+web) + 268 tests + eslint + production build green; touched files prettier-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
VideoClipRegular,
|
||||
MusicNote2Regular,
|
||||
ErrorCircleRegular,
|
||||
LinkRegular,
|
||||
LibraryRegular,
|
||||
DismissRegular,
|
||||
CutRegular,
|
||||
@@ -30,6 +29,8 @@ import { QUALITY_OPTIONS } from '../qualityOptions'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { Banner } from './ui/Banner'
|
||||
import { LinkSuggestion } from './ui/LinkSuggestion'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { META_SEP } from './ui/tokens'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
@@ -115,60 +116,62 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{suggestion && (
|
||||
<div className={styles.suggestion}>
|
||||
<LinkRegular />
|
||||
<Caption1 className={styles.suggestionText}>
|
||||
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
|
||||
{suggestion}
|
||||
</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={bar.acceptSuggestion}>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={bar.dismissSuggestion}
|
||||
aria-label="Dismiss suggested link"
|
||||
/>
|
||||
</div>
|
||||
<LinkSuggestion
|
||||
prefix={suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
|
||||
link={suggestion}
|
||||
onAccept={bar.acceptSuggestion}
|
||||
onDismiss={bar.dismissSuggestion}
|
||||
dismissLabel="Dismiss suggested link"
|
||||
/>
|
||||
)}
|
||||
|
||||
{channelHint && (
|
||||
<div className={styles.channelHint}>
|
||||
<LibraryRegular />
|
||||
<Caption1 className={styles.channelHintText}>
|
||||
<Banner
|
||||
icon={<LibraryRegular />}
|
||||
wrap
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
|
||||
Open in Library
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={bar.dismissChannelHint}
|
||||
aria-label="Dismiss Library suggestion"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Caption1>
|
||||
This looks like a channel or playlist. Add it in the Library to download many videos at
|
||||
once.
|
||||
</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
|
||||
Open in Library
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={bar.dismissChannelHint}
|
||||
aria-label="Dismiss Library suggestion"
|
||||
/>
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{dup && (
|
||||
<div className={styles.dupRow}>
|
||||
<WarningRegular />
|
||||
<Caption1 className={styles.suggestionText}>Already in your queue: "{dup}".</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={bar.downloadAnyway}>
|
||||
Download anyway
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => bar.setDup(null)}
|
||||
aria-label="Dismiss duplicate warning"
|
||||
/>
|
||||
</div>
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<WarningRegular />}
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" appearance="primary" onClick={bar.downloadAnyway}>
|
||||
Download anyway
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => bar.setDup(null)}
|
||||
aria-label="Dismiss duplicate warning"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Caption1>Already in your queue: "{dup}".</Caption1>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{probing && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
@@ -16,6 +15,7 @@ import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { EmptyState } from './ui/EmptyState'
|
||||
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||
|
||||
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
|
||||
@@ -66,15 +66,6 @@ const useStyles = makeStyles({
|
||||
// min-height:0 lets this flex child shrink below its content height so it,
|
||||
// not the page, owns the scrolling.
|
||||
minHeight: 0
|
||||
},
|
||||
empty: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '56px 16px',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -144,10 +135,10 @@ export function DownloadsView(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<ArrowDownloadRegular fontSize={ICON.hero} />
|
||||
<Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
|
||||
message="Nothing queued yet. Paste a URL above, then click Download."
|
||||
/>
|
||||
) : (
|
||||
<VirtualList
|
||||
items={items}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Body1,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
@@ -32,6 +31,7 @@ import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { EmptyState } from './ui/EmptyState'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||
@@ -104,14 +104,6 @@ const useStyles = makeStyles({
|
||||
minHeight: '40px'
|
||||
}
|
||||
},
|
||||
empty: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '56px 16px',
|
||||
textAlign: 'center'
|
||||
},
|
||||
emptyBadge: {
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
@@ -122,9 +114,6 @@ const useStyles = makeStyles({
|
||||
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
|
||||
fontSize: `${ICON.section}px`
|
||||
},
|
||||
emptyHint: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
noMatches: {
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
@@ -229,16 +218,18 @@ export function HistoryView(): React.JSX.Element {
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||
<div className={styles.empty}>
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
</div>
|
||||
<Body1>No downloads yet.</Body1>
|
||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
</div>
|
||||
}
|
||||
message="No downloads yet."
|
||||
hint="Finished downloads will show up here."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Body1,
|
||||
Caption1,
|
||||
Text,
|
||||
Input,
|
||||
@@ -23,9 +22,7 @@ import {
|
||||
AppsListRegular,
|
||||
VideoClipMultipleRegular,
|
||||
AlertRegular,
|
||||
LibraryRegular,
|
||||
LinkRegular,
|
||||
DismissRegular
|
||||
LibraryRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
||||
import { isPreview as PREVIEW } from '../isPreview'
|
||||
@@ -42,6 +39,8 @@ import { VirtualList } from './VirtualList'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { EmptyState } from './ui/EmptyState'
|
||||
import { LinkSuggestion } from './ui/LinkSuggestion'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
||||
@@ -78,22 +77,6 @@ const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
suggestion: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
suggestionText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -116,15 +99,6 @@ const useStyles = makeStyles({
|
||||
fontSize: tokens.fontSizeBase200
|
||||
},
|
||||
error: { color: tokens.colorPaletteRedForeground1 },
|
||||
empty: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '56px 16px',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: 'center'
|
||||
},
|
||||
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
||||
card: {
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
@@ -218,13 +192,7 @@ const useStyles = makeStyles({
|
||||
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||
...shorthands.borderRadius(RADIUS.control)
|
||||
},
|
||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
rowTitle: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: tokens.colorNeutralForeground1
|
||||
}
|
||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
|
||||
})
|
||||
|
||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||
@@ -514,7 +482,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
iconSize={16}
|
||||
/>
|
||||
<div className={styles.rowMain}>
|
||||
<Text className={styles.rowTitle}>
|
||||
<Text className={text.truncate}>
|
||||
{it.playlistIndex}. {it.title}
|
||||
</Text>
|
||||
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
|
||||
@@ -555,27 +523,16 @@ export function LibraryView(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{clip.suggestion && (
|
||||
<div className={styles.suggestion}>
|
||||
<LinkRegular />
|
||||
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
const link = clip.accept()
|
||||
if (link) setUrl(link)
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={clip.dismiss}
|
||||
aria-label="Dismiss suggested link"
|
||||
/>
|
||||
</div>
|
||||
<LinkSuggestion
|
||||
prefix="Use copied link? "
|
||||
link={clip.suggestion}
|
||||
onAccept={() => {
|
||||
const link = clip.accept()
|
||||
if (link) setUrl(link)
|
||||
}}
|
||||
onDismiss={clip.dismiss}
|
||||
dismissLabel="Dismiss suggested link"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
@@ -620,10 +577,10 @@ export function LibraryView(): React.JSX.Element {
|
||||
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
||||
|
||||
{sources.length === 0 && !indexing.active ? (
|
||||
<div className={styles.empty}>
|
||||
<LibraryRegular fontSize={ICON.hero} />
|
||||
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
|
||||
</div>
|
||||
<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) => (
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { CommandTemplate } from '@shared/ipc'
|
||||
import { useTemplates } from '../store/templates'
|
||||
import { newId } from '../id'
|
||||
import { Hint } from './Hint'
|
||||
import { EmptyState } from './ui/EmptyState'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -71,9 +72,6 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
empty: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
}
|
||||
})
|
||||
|
||||
@@ -132,7 +130,7 @@ export function TemplateManager(): React.JSX.Element {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{templates.length === 0 && !draft && (
|
||||
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1>
|
||||
<EmptyState compact message="No custom command templates yet." />
|
||||
)}
|
||||
|
||||
{templates.map((t) => (
|
||||
|
||||
@@ -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 { EmptyState } from './ui/EmptyState'
|
||||
import { SPACE } from './ui/tokens'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
@@ -57,10 +58,12 @@ const useStyles = makeStyles({
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
// Center the "no output yet" placeholder in the log box, so it reads as an
|
||||
// empty state rather than a stray line of text at the top (L117).
|
||||
logEmpty: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
||||
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
||||
stderr: { color: tokens.colorPaletteRedForeground1 },
|
||||
sys: { color: tokens.colorNeutralForeground3 },
|
||||
empty: { color: tokens.colorNeutralForeground3 }
|
||||
sys: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -204,9 +207,9 @@ export function TerminalView(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className={styles.log} ref={logRef}>
|
||||
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
|
||||
{lines.length === 0 ? (
|
||||
<span className={styles.empty}>No output yet.</span>
|
||||
<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)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Checkbox, Caption1 } from '@fluentui/react-components'
|
||||
import { Button, Checkbox, Caption1, mergeClasses } from '@fluentui/react-components'
|
||||
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
|
||||
import { type PlaylistInfo } from '@shared/ipc'
|
||||
import { type MediaKind } from '../../store/downloads'
|
||||
@@ -35,7 +35,7 @@ export function PlaylistPanel({
|
||||
<div className={styles.plPanel}>
|
||||
<div className={styles.plHeader}>
|
||||
<AppsListRegular />
|
||||
<Caption1 className={styles.plHeaderText}>
|
||||
<Caption1 className={mergeClasses(styles.plHeaderGrow, text.title)}>
|
||||
{playlist.title}
|
||||
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
|
||||
</Caption1>
|
||||
@@ -61,7 +61,7 @@ export function PlaylistPanel({
|
||||
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={styles.plItemTitle}>
|
||||
<span className={text.truncate}>
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
|
||||
@@ -23,37 +23,6 @@ export const useDownloadBarStyles = makeStyles({
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
},
|
||||
suggestion: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
suggestionText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
// The channel/playlist nudge toward the Library (UX3): same brand-tinted banner
|
||||
// as the link suggestion, but its message wraps instead of truncating.
|
||||
channelHint: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
channelHintText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
url: {
|
||||
flexGrow: 1
|
||||
},
|
||||
@@ -128,13 +97,10 @@ export const useDownloadBarStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
plHeaderText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: tokens.fontWeightSemibold
|
||||
// Just the layout role; the truncation + weight come from the shared text.title
|
||||
// (L114/L126), merged on at the call site.
|
||||
plHeaderGrow: {
|
||||
flexGrow: 1
|
||||
},
|
||||
plList: {
|
||||
display: 'flex',
|
||||
@@ -160,21 +126,6 @@ export const useDownloadBarStyles = makeStyles({
|
||||
flexDirection: 'column',
|
||||
minWidth: 0
|
||||
},
|
||||
plItemTitle: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
// --- duplicate warning ---
|
||||
dupRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
// --- trim / schedule panels ---
|
||||
trimBlock: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-compone
|
||||
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useErrorLog } from '../../store/errorlog'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { EmptyState } from '../ui/EmptyState'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function DiagnosticsCard(): React.JSX.Element {
|
||||
@@ -74,7 +75,7 @@ export function DiagnosticsCard(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{errorEntries.length === 0 ? (
|
||||
<Caption1 className={styles.hint}>No errors yet.</Caption1>
|
||||
<EmptyState compact message="No errors yet." />
|
||||
) : (
|
||||
<div className={styles.errorList}>
|
||||
{errorEntries.slice(0, 20).map((e) => (
|
||||
|
||||
@@ -13,14 +13,9 @@ import { useSettings } from '../../store/settings'
|
||||
import { useDownloads } from '../../store/downloads'
|
||||
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||
import { Select } from '../Select'
|
||||
import { SegmentedControl } from '../ui/SegmentedControl'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
// Combined "Type -- Quality" options for the default-format dropdown.
|
||||
const FORMAT_OPTIONS = [
|
||||
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
|
||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
|
||||
]
|
||||
|
||||
export function DownloadsCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const videoDir = useSettings((s) => s.videoDir)
|
||||
@@ -38,16 +33,14 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||
const formatValue = `${defaultKind}|${formatQuality}`
|
||||
|
||||
function onFormatSelect(value: string): void {
|
||||
const sep = value.indexOf('|')
|
||||
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video'
|
||||
const quality = sep >= 0 ? value.slice(sep + 1) : value
|
||||
function onKindSelect(kind: MediaKind): void {
|
||||
update({ defaultKind: kind })
|
||||
}
|
||||
|
||||
function onQualitySelect(quality: string): void {
|
||||
update(
|
||||
kind === 'audio'
|
||||
? { defaultKind: 'audio', defaultAudioQuality: quality }
|
||||
: { defaultKind: 'video', defaultVideoQuality: quality }
|
||||
defaultKind === 'audio' ? { defaultAudioQuality: quality } : { defaultVideoQuality: quality }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +98,23 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
</Field>
|
||||
|
||||
<Field label="Default format">
|
||||
<Select value={formatValue} options={FORMAT_OPTIONS} onChange={onFormatSelect} />
|
||||
<div className={styles.formatRow}>
|
||||
<SegmentedControl<MediaKind>
|
||||
value={defaultKind}
|
||||
options={[
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]}
|
||||
onChange={onKindSelect}
|
||||
ariaLabel="Default download type"
|
||||
/>
|
||||
<Select
|
||||
className={styles.formatQuality}
|
||||
value={formatQuality}
|
||||
options={QUALITY_OPTIONS[defaultKind].map((q) => ({ value: q, label: q }))}
|
||||
onChange={onQualitySelect}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -42,6 +42,17 @@ export const useSettingsStyles = makeStyles({
|
||||
folderInput: {
|
||||
flexGrow: 1
|
||||
},
|
||||
// The "Default format" control: a Video/Audio segmented toggle beside a quality
|
||||
// dropdown, mirroring the DownloadBar so both read as one mental model (L102).
|
||||
formatRow: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
formatQuality: {
|
||||
minWidth: '200px'
|
||||
},
|
||||
hint: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { RADIUS } from './tokens'
|
||||
import { useTextStyles } from './text'
|
||||
|
||||
/**
|
||||
* One inline notification bar (UI19). The DownloadBar's copied-link suggestion,
|
||||
* its channel/playlist nudge, its duplicate warning, and the Library's copied-link
|
||||
* suggestion had each hand-rolled the same tinted row (leading icon + flex-grow
|
||||
* text + trailing actions). This is that row, once: a leading `icon`, `children`
|
||||
* content that truncates by default (`wrap` to let it flow onto multiple lines),
|
||||
* and a trailing `actions` slot.
|
||||
*
|
||||
* `tone` picks the semantic tint — `brand` for suggestions/nudges, `warning` for
|
||||
* the duplicate caution — matching the former per-component colors exactly.
|
||||
*/
|
||||
export type BannerTone = 'brand' | 'warning'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
...shorthands.borderRadius(RADIUS.surface)
|
||||
},
|
||||
brand: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
}
|
||||
})
|
||||
|
||||
export function Banner({
|
||||
icon,
|
||||
tone = 'brand',
|
||||
wrap = false,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
tone?: BannerTone
|
||||
/** Let the content wrap onto multiple lines instead of truncating (the channel nudge). */
|
||||
wrap?: boolean
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const text = useTextStyles()
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, styles[tone])}>
|
||||
{icon}
|
||||
<div className={mergeClasses(styles.content, !wrap && text.truncate)}>{children}</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Body1, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { SPACE } from './tokens'
|
||||
|
||||
/**
|
||||
* One shared empty-state block (L116/L117). The screens had drifted into three
|
||||
* shapes — Downloads (icon + line), History (colored badge + line + sub-hint),
|
||||
* Library (icon + line, no sub-hint) — and the Terminal / Diagnostics "empty"
|
||||
* text wasn't a centered block at all (inline text). This centers them all on one
|
||||
* structure: an optional focal `icon` (a hero glyph or a colored badge), a `Body1`
|
||||
* message at normal foreground, and an optional muted `hint` line.
|
||||
*
|
||||
* `compact` swaps the tall screen-level padding (56px) for a smaller inset (32px),
|
||||
* for placeholders that sit inside an already-populated screen (the Terminal log,
|
||||
* the Diagnostics list) rather than filling an empty one.
|
||||
*/
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: SPACE.tight,
|
||||
padding: '56px 16px',
|
||||
// The container is muted, so a hero glyph and the hint line read as secondary;
|
||||
// the message overrides back to the normal foreground so it stays legible.
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: 'center'
|
||||
},
|
||||
compact: {
|
||||
padding: '32px 16px'
|
||||
},
|
||||
message: {
|
||||
color: tokens.colorNeutralForeground1
|
||||
}
|
||||
})
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
message,
|
||||
hint,
|
||||
compact = false,
|
||||
className
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
message: React.ReactNode
|
||||
hint?: React.ReactNode
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, compact && styles.compact, className)}>
|
||||
{icon}
|
||||
<Body1 className={styles.message}>{message}</Body1>
|
||||
{hint && <Caption1>{hint}</Caption1>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Caption1 } from '@fluentui/react-components'
|
||||
import { LinkRegular, DismissRegular } from '@fluentui/react-icons'
|
||||
import { Banner } from './Banner'
|
||||
|
||||
/**
|
||||
* The "use this copied/received link?" banner (UI19), shared by the DownloadBar
|
||||
* and the Library add-source field, which had identical markup + CSS. Renders the
|
||||
* link (with an optional lead-in like "Link received: ") and Use / Dismiss actions
|
||||
* on the shared {@link Banner}.
|
||||
*/
|
||||
export function LinkSuggestion({
|
||||
prefix,
|
||||
link,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
dismissLabel
|
||||
}: {
|
||||
/** Short lead-in before the link, e.g. "Use copied link? " or "Link received: ". */
|
||||
prefix: string
|
||||
link: string
|
||||
onAccept: () => void
|
||||
onDismiss: () => void
|
||||
/** Accessible name for the dismiss button — say what's being dismissed (L153). */
|
||||
dismissLabel: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Banner
|
||||
icon={<LinkRegular />}
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" appearance="primary" onClick={onAccept}>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={onDismiss}
|
||||
aria-label={dismissLabel}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Caption1>
|
||||
{prefix}
|
||||
{link}
|
||||
</Caption1>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
@@ -73,9 +73,14 @@ const useStyles = makeStyles({
|
||||
* One shared segmented control (UI14) replacing the two hand-rolled versions —
|
||||
* the DownloadBar's Video/Audio kind toggle and the Sidebar's Light/Dark/Auto
|
||||
* theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
|
||||
* navigation (UI30) and the shared focus ring (UI29). Active treatment is solid
|
||||
* brand (matching both former implementations). `fitted` makes the segments split
|
||||
* the full width (sidebar); the default is fit-content (download bar).
|
||||
* navigation (UI30) and the shared focus ring (UI29). `fitted` makes the segments
|
||||
* split the full width (sidebar); the default is fit-content (download bar).
|
||||
*
|
||||
* Active treatment is solid brand — the app's one rule for *toggles* (a compact
|
||||
* set where the chosen option should read as pressed). This is deliberately
|
||||
* distinct from the brand-*tint* used for *selection within a list* (the Sidebar
|
||||
* nav item, the CommandPalette active option): one active treatment per control
|
||||
* class (UI20).
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
|
||||
@@ -32,6 +32,19 @@ export const useTextStyles = makeStyles({
|
||||
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
|
||||
title: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
/**
|
||||
* The single-line truncation utility (L126): clip overflowing text to one line
|
||||
* with an ellipsis. This is `title` without the semibold weight — for metadata
|
||||
* and non-title text that should truncate rather than wrap. Needs `minWidth: 0`
|
||||
* so it can shrink inside a flex row.
|
||||
*/
|
||||
truncate: {
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
|
||||
Reference in New Issue
Block a user