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:
2026-07-01 16:37:32 -04:00
parent bd7ba8726e
commit 3d4e574916
17 changed files with 407 additions and 238 deletions
+49 -46
View File
@@ -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 && (
+5 -14
View File
@@ -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}
+13 -22
View File
@@ -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>
)
}
+19 -62
View File
@@ -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) => (
+7 -4
View File
@@ -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
},
+63
View File
@@ -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,
+13
View File
@@ -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'