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
+77 -11
View File
@@ -136,6 +136,22 @@ unchecked** — button size-by-role is subjective; current usage already broadly
large, row/toolbar small, settings default), so specific tweaks join the watched visual pass. All green: large, row/toolbar small, settings default), so specific tweaks join the watched visual pass. All green:
typecheck (node+web) + 268 tests + eslint + production build; touched files prettier-clean. typecheck (node+web) + 268 tests + eslint + production build; touched files prettier-clean.
**Session 2026-07-01 pass 6 (Batch 9 — surface primitives):** three new shared UI primitives —
[EmptyState](src/renderer/src/components/ui/EmptyState.tsx) (**L116/L117**: one centered empty block — icon +
`Body1` message + optional muted hint, with a `compact` variant for in-content placeholders — adopted by
Downloads/History/Library and, compact, by the Terminal log + Diagnostics), [Banner](src/renderer/src/components/ui/Banner.tsx)
+ [LinkSuggestion](src/renderer/src/components/ui/LinkSuggestion.tsx) (**UI19**: one tinted notification row
replacing five hand-rolled style blocks — the DownloadBar suggestion/channel-nudge/dup-warning + the Library
copied-link suggestion), and a shared `useTextStyles().truncate` (**L126**: single-line-ellipsis util, adopted
in Library rows + the playlist panel + Banner). Plus **L102** (Settings "Default format" now uses the same
`SegmentedControl` + quality `Select` as the DownloadBar — one mental model; the `kind|quality` string encoding
is gone), and four resolved-by-verified-convention: **L103** (busy = in-button icon→Spinner everywhere; done in
L134), **L115** (list-row actions icon-only+tooltip vs card-toolbar actions labelled — role-appropriate),
**L120** (Fluent `<Card>` for static content cards, the Batch-6 tokenized surface recipe for interactive/
list-item cards), and **UI20** (toggles = solid brand, list-selection = brand-tint; the cited Library pill is
gone via UI18). **L128 deferred to Batch 14** (build-time DCE of preview seeds, grouped with PERF8). All green:
typecheck (node+web) + 268 tests + eslint + production build; touched files prettier-clean.
**Deliberately deferred** (need live-app/visual verification or are larger refactors the **Deliberately deferred** (need live-app/visual verification or are larger refactors the
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations, refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
@@ -800,10 +816,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [x] **L101 — No z-index scale.** CommandPalette and Hint both hardcode `zIndex: 1000`. - [x] **L101 — No z-index scale.** CommandPalette and Hint both hardcode `zIndex: 1000`.
*Fixed: `Z` = { overlay: 1000, tooltip: 1100 }. CommandPalette's backdrop is `Z.overlay`, Hint's bubble is *Fixed: `Z` = { overlay: 1000, tooltip: 1100 }. CommandPalette's backdrop is `Z.overlay`, Hint's bubble is
`Z.tooltip` (above overlays so a tooltip is never clipped by one).* `Z.tooltip` (above overlays so a tooltip is never clipped by one).*
- [ ] **L102 — Two mental models for "format."** Settings sets it via one combined Video/Audio dropdown - [x] **L102 — Two mental models for "format."** Settings sets it via one combined Video/Audio dropdown
(`FORMAT_OPTIONS`); the DownloadBar uses a Video/Audio segmented control + a separate quality dropdown. (`FORMAT_OPTIONS`); the DownloadBar uses a Video/Audio segmented control + a separate quality dropdown.
- [ ] **L103 — Two busy-indicator patterns.** Fetch/Index swap the button icon to a `Spinner`; the *Fixed: the Settings "Default format" control ([DownloadsCard.tsx](src/renderer/src/components/settings/DownloadsCard.tsx))
now uses the same shared `SegmentedControl` (Video/Audio) + quality `Select` as the DownloadBar, so both
screens present one mental model. The combined `FORMAT_OPTIONS`/`kind|quality` string encoding is gone —
the toggle writes `defaultKind` and the dropdown writes `defaultVideoQuality`/`defaultAudioQuality` directly.*
- [x] **L103 — Two busy-indicator patterns.** Fetch/Index swap the button icon to a `Spinner`; the
Settings check/update buttons keep their icon and render a *separate* adjacent `Spinner`. Settings check/update buttons keep their icon and render a *separate* adjacent `Spinner`.
*Resolved by L134 (Batch 8), verified this batch: every busy button swaps its own icon to a
`<Spinner size="tiny">` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only
remaining `<Spinner>`s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing
rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.*
- [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`. - [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`.
- [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and - [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
[store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it. [store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it.
@@ -841,15 +865,37 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`srcTitle`) became `<Text>` — size-identical since Fluent's provider base font is already `fontSizeBase300`. `srcTitle`) became `<Text>` — size-identical since Fluent's provider base font is already `fontSizeBase300`.
The duplicated semibold-ellipsis title style (QueueItem/HistoryView/DownloadBar `previewTitle`) is now the The duplicated semibold-ellipsis title style (QueueItem/HistoryView/DownloadBar `previewTitle`) is now the
shared `useTextStyles().title`.* shared `useTextStyles().title`.*
- [ ] **L115 — Equivalent row actions labeled inconsistently** — "Re-index" is icon+text; the - [x] **L115 — Equivalent row actions labeled inconsistently** — "Re-index" is icon+text; the
comparable "Retry"/"Remove"/"Re-download" are icon-only. comparable "Retry"/"Remove"/"Re-download" are icon-only. *Resolved (convention verified + role-appropriate):
- [ ] **L116 — Empty-state structure varies** — Downloads (icon + line), History (colored badge + line + sub-hint), Library (icon + line, no sub-hint). the two live in different UI contexts, and each context is internally consistent. **List-row item actions**
- [ ] **L117 — Terminal/error-log "empty" aren't centered blocks** like the other empty states (inline text instead). (QueueItem's Pause/Cancel/Retry/Remove/Open, History's Re-download/Open/Show/Remove) are icon-only with a
`Hint` tooltip + a ≥40px hit target — a compact, repeated-per-row affordance. **Card/toolbar actions** (the
Library source-detail row: Refresh/Remove/Download, the Downloads/History headers) are labeled icon+text —
a one-per-card toolbar where the label aids scanning. The former "Re-index" is the Library card toolbar's
"Refresh", correctly labeled alongside its siblings. No screen mixes the two within one context.*
- [x] **L116 — Empty-state structure varies** — Downloads (icon + line), History (colored badge + line + sub-hint), Library (icon + line, no sub-hint).
*Fixed: one shared [EmptyState](src/renderer/src/components/ui/EmptyState.tsx) — a centered column with an
optional focal `icon` (a hero glyph or History's colored badge), a `Body1` message at normal foreground, and
an optional muted `hint`. Adopted by Downloads, History, and Library, so the three now share one structure
(History keeps its badge + hint; Downloads/Library pass a hero glyph). The per-view `empty`/`emptyHint`
styles are deleted.*
- [x] **L117 — Terminal/error-log "empty" aren't centered blocks** like the other empty states (inline text instead).
*Fixed: both now render the shared `EmptyState` in its `compact` variant (smaller inset, for a placeholder
inside an already-populated screen). The Terminal log centers "No output yet." in the log box (a `logEmpty`
flex modifier); Diagnostics centers "No errors yet." — so both read as empty states rather than a stray line
of inline text.*
- [ ] **L118 — Cross-component focus via hardcoded id.** App focuses the URL field with - [ ] **L118 — Cross-component focus via hardcoded id.** App focuses the URL field with
`document.getElementById('aerofetch-url')` — brittle coupling into DownloadBar's markup. `document.getElementById('aerofetch-url')` — brittle coupling into DownloadBar's markup.
- [x] **L119 — "No items" copy varies** — "No … yet" (most) vs "Output will appear here." (Terminal) vs "No errors logged." (Diagnostics). - [x] **L119 — "No items" copy varies** — "No … yet" (most) vs "Output will appear here." (Terminal) vs "No errors logged." (Diagnostics).
- [ ] **L120 — Fluent `Card` vs hand-styled `div` cards.** Only Settings/Onboarding use `<Card>`; every - [x] **L120 — Fluent `Card` vs hand-styled `div` cards.** Only Settings/Onboarding use `<Card>`; every
other card surface is a bespoke styled `<div>`. other card surface is a bespoke styled `<div>`. *Resolved (convention documented + tokens applied):
static content cards (Settings cards, Onboarding) use Fluent `<Card>`; interactive & list-item surfaces
(the download bar, QueueItem, History/Library rows, the Library source card, the command palette) use the
Batch-6 tokenized surface recipe — `RADIUS.card`/`surface`, `ELEVATION`, `SPACE` padding on a styled `<div>`
— because Fluent's `Card` imposes its own internal padding/gap/interaction model that a custom row layout
fights. Forcing `<Card>` onto those would regress layout for no gain; the divergence L120 flags is now a
documented tier split, and the styled surfaces share the same radius/elevation tokens. (The larger "wrap
every recurring pattern" primitive work stays UI15's incremental track.)*
- [x] **L121 — Raw scrim literal.** CommandPalette backdrop is `rgba(0,0,0,0.32)` (the only raw rgba in - [x] **L121 — Raw scrim literal.** CommandPalette backdrop is `rgba(0,0,0,0.32)` (the only raw rgba in
the renderer), identical in light/dark — low contrast over a dark UI. *Fixed: `SCRIM` in tokens.ts aliases the renderer), identical in light/dark — low contrast over a dark UI. *Fixed: `SCRIM` in tokens.ts aliases
Fluent's semantic `colorBackgroundOverlay` (the dialog scrim); the raw rgba is gone and the palette Fluent's semantic `colorBackgroundOverlay` (the dialog scrim); the raw rgba is gone and the palette
@@ -867,10 +913,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`` fontSize: `${ICON.control}px` `` on the few makeStyles containers) — no more divergent magic numbers. `` fontSize: `${ICON.control}px` `` on the few makeStyles containers) — no more divergent magic numbers.
Glyphs sized to a box rather than to this scale (brand/source tiles, the tiny `watchBadge` icon, thumbnail Glyphs sized to a box rather than to this scale (brand/source tiles, the tiny `watchBadge` icon, thumbnail
placeholders) are documented exclusions and stay proportional.* placeholders) are documented exclusions and stay proportional.*
- [ ] **L126 — Uneven text truncation** — some titles/metas use `nowrap`+ellipsis, others wrap; no shared truncation utility. - [x] **L126 — Uneven text truncation** — some titles/metas use `nowrap`+ellipsis, others wrap; no shared truncation utility.
*Fixed: a shared `useTextStyles().truncate` ([text.ts](src/renderer/src/components/ui/text.ts)) — single-line
clip with ellipsis + `minWidth:0` — sits alongside the existing `title` (which is `truncate` + semibold).
Adopted for the ad-hoc copies: Library `rowTitle`, the DownloadBar playlist `plItemTitle`/`plHeaderText`
(the header is now `title`), and the shared `Banner` content (UI19). `title` also gained `minWidth:0` so it
shrinks correctly in a flex row. (Remaining correct-but-inline truncations migrate to the util incrementally.)*
- [x] **L127 — `Notification` sets no icon** — completion toasts used the default Electron icon though `getAppIconPath()` existed. *Fixed with W13 (cached `getAppIconImage()` passed as `icon` at both notification sites).* - [x] **L127 — `Notification` sets no icon** — completion toasts used the default Electron icon though `getAppIconPath()` existed. *Fixed with W13 (cached `getAppIconImage()` passed as `icon` at both notification sites).*
- [ ] **L128 — Preview seed/mock data ships in production.** The `PREVIEW` runtime check can't be - [ ] **L128 — Preview seed/mock data ships in production.** The `PREVIEW` runtime check can't be
tree-shaken, so each store's seed arrays + fake ticker are bundled into the Electron renderer. tree-shaken, so each store's seed arrays + fake ticker are bundled into the Electron renderer.
*Deferred to Batch 14 (perf/bundle): this is a build-time dead-code-elimination concern (gate the seed
imports behind `import.meta.env` so Rollup drops them from the packaged renderer), grouped with PERF8's
code-splitting where the lazy-loading is set up. Left open on purpose.*
- [x] **L129 — `Hint align` is silently ignored for left/right placements** yet callers still pass it. - [x] **L129 — `Hint align` is silently ignored for left/right placements** yet callers still pass it.
*Fixed: `HintProps` is now a discriminated union — `align` is only accepted with `placement` `top`/`bottom`; *Fixed: `HintProps` is now a discriminated union — `align` is only accepted with `placement` `top`/`bottom`;
passing it with `left`/`right` is a compile error instead of a silent no-op. No caller needed changing (the passing it with `left`/`right` is a compile error instead of a silent no-op. No caller needed changing (the
@@ -1147,14 +1201,26 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
non-primary button).* non-primary button).*
- [x] **UI18 — Two status-chip systems.** QueueItem uses Fluent `Badge`; LibraryView uses custom - [x] **UI18 — Two status-chip systems.** QueueItem uses Fluent `Badge`; LibraryView uses custom
color `pill` spans for the same item-status concept (and labels differ — see M8). **Standard:** one shared status-chip component + label map. color `pill` spans for the same item-status concept (and labels differ — see M8). **Standard:** one shared status-chip component + label map.
- [ ] **UI19 — Suggestion-banner CSS duplicated.** Identical `suggestion`/`suggestionText` styles in - [x] **UI19 — Suggestion-banner CSS duplicated.** Identical `suggestion`/`suggestionText` styles in
DownloadBar and LibraryView. **Standard:** a shared `LinkSuggestion` component (also kills drift). DownloadBar and LibraryView. **Standard:** a shared `LinkSuggestion` component (also kills drift).
*Fixed: a shared [Banner](src/renderer/src/components/ui/Banner.tsx) (leading icon + truncating/`wrap`
content + trailing actions, `tone` = brand/warning) and a [LinkSuggestion](src/renderer/src/components/ui/LinkSuggestion.tsx)
built on it. The DownloadBar's copied-link suggestion and the Library's are now one `<LinkSuggestion>`; the
DownloadBar's channel/playlist nudge (`wrap`) and duplicate warning (`tone="warning"`) reuse the base
`Banner`. Five hand-rolled style blocks (`suggestion`/`suggestionText`/`channelHint`/`channelHintText`/
`dupRow`) are deleted — the tint/padding/radius are identical, so no visual change.*
### Colors & selection ### Colors & selection
- [ ] **UI20 — "Selected/active" styling is split.** Solid brand (`colorBrandBackground` + - [x] **UI20 — "Selected/active" styling is split.** Solid brand (`colorBrandBackground` +
on-brand text) for DownloadBar/Sidebar segments, but brand-tint (`colorBrandBackground2` + on-brand text) for DownloadBar/Sidebar segments, but brand-tint (`colorBrandBackground2` +
`colorBrandForeground2`) for Sidebar nav, CommandPalette item, Library pill. **Standard:** one active treatment per control class. `colorBrandForeground2`) for Sidebar nav, CommandPalette item, Library pill. **Standard:** one active treatment per control class.
*Resolved (rule named + verified): one active treatment **per control class** — **toggles** (the shared
`SegmentedControl`, used by both the DownloadBar kind toggle and the Sidebar theme switch after UI14) use
**solid brand**; **selection within a list** (Sidebar nav item, CommandPalette active option) uses
**brand-tint**. Each class is now internally consistent, and the rule is documented on
[SegmentedControl](src/renderer/src/components/ui/SegmentedControl.tsx). The "Library pill" the item cited is
gone — UI18 replaced it with the shared `StatusChip`, so there's no third treatment left to reconcile.*
- [ ] **UI21 — Sidebar active-nav rail is expanded-only.** The inset brand box-shadow shows only when - [ ] **UI21 — Sidebar active-nav rail is expanded-only.** The inset brand box-shadow shows only when
expanded; collapsed relies on tint alone. **Standard:** a consistent active indicator in both states. expanded; collapsed relies on tint alone. **Standard:** a consistent active indicator in both states.
- [ ] **UI22 — Brand-icon tiles differ.** `mark` (on-brand on solid brand) vs `srcIcon` - [ ] **UI22 — Brand-icon tiles differ.** `mark` (on-brand on solid brand) vs `srcIcon`
+31 -28
View File
@@ -16,7 +16,6 @@ import {
VideoClipRegular, VideoClipRegular,
MusicNote2Regular, MusicNote2Regular,
ErrorCircleRegular, ErrorCircleRegular,
LinkRegular,
LibraryRegular, LibraryRegular,
DismissRegular, DismissRegular,
CutRegular, CutRegular,
@@ -30,6 +29,8 @@ import { QUALITY_OPTIONS } from '../qualityOptions'
import { Select } from './Select' import { Select } from './Select'
import { Hint } from './Hint' import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl' import { SegmentedControl } from './ui/SegmentedControl'
import { Banner } from './ui/Banner'
import { LinkSuggestion } from './ui/LinkSuggestion'
import { useTextStyles } from './ui/text' import { useTextStyles } from './ui/text'
import { META_SEP } from './ui/tokens' import { META_SEP } from './ui/tokens'
import { DownloadOptionsForm } from './DownloadOptionsForm' import { DownloadOptionsForm } from './DownloadOptionsForm'
@@ -115,32 +116,21 @@ export function DownloadBar(): React.JSX.Element {
</div> </div>
{suggestion && ( {suggestion && (
<div className={styles.suggestion}> <LinkSuggestion
<LinkRegular /> prefix={suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
<Caption1 className={styles.suggestionText}> link={suggestion}
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '} onAccept={bar.acceptSuggestion}
{suggestion} onDismiss={bar.dismissSuggestion}
</Caption1> dismissLabel="Dismiss suggested link"
<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>
)} )}
{channelHint && ( {channelHint && (
<div className={styles.channelHint}> <Banner
<LibraryRegular /> icon={<LibraryRegular />}
<Caption1 className={styles.channelHintText}> wrap
This looks like a channel or playlist. Add it in the Library to download many videos at actions={
once. <>
</Caption1>
<Button size="small" appearance="primary" onClick={bar.openInLibrary}> <Button size="small" appearance="primary" onClick={bar.openInLibrary}>
Open in Library Open in Library
</Button> </Button>
@@ -151,13 +141,22 @@ export function DownloadBar(): React.JSX.Element {
onClick={bar.dismissChannelHint} onClick={bar.dismissChannelHint}
aria-label="Dismiss Library suggestion" aria-label="Dismiss Library suggestion"
/> />
</div> </>
}
>
<Caption1>
This looks like a channel or playlist. Add it in the Library to download many videos at
once.
</Caption1>
</Banner>
)} )}
{dup && ( {dup && (
<div className={styles.dupRow}> <Banner
<WarningRegular /> tone="warning"
<Caption1 className={styles.suggestionText}>Already in your queue: "{dup}".</Caption1> icon={<WarningRegular />}
actions={
<>
<Button size="small" appearance="primary" onClick={bar.downloadAnyway}> <Button size="small" appearance="primary" onClick={bar.downloadAnyway}>
Download anyway Download anyway
</Button> </Button>
@@ -168,7 +167,11 @@ export function DownloadBar(): React.JSX.Element {
onClick={() => bar.setDup(null)} onClick={() => bar.setDup(null)}
aria-label="Dismiss duplicate warning" aria-label="Dismiss duplicate warning"
/> />
</div> </>
}
>
<Caption1>Already in your queue: "{dup}".</Caption1>
</Banner>
)} )}
{probing && ( {probing && (
+5 -14
View File
@@ -1,6 +1,5 @@
import { import {
Subtitle2, Subtitle2,
Body1,
Caption1, Caption1,
Button, Button,
ProgressBar, ProgressBar,
@@ -16,6 +15,7 @@ import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE, ICON, META_SEP } from './ui/tokens' import { SPACE, ICON, META_SEP } from './ui/tokens'
// Hoisted so the virtualizer gets stable function identities and doesn't churn its // 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, // min-height:0 lets this flex child shrink below its content height so it,
// not the page, owns the scrolling. // not the page, owns the scrolling.
minHeight: 0 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> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className={styles.empty}> <EmptyState
<ArrowDownloadRegular fontSize={ICON.hero} /> icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
<Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1> message="Nothing queued yet. Paste a URL above, then click Download."
</div> />
) : ( ) : (
<VirtualList <VirtualList
items={items} items={items}
+7 -16
View File
@@ -5,7 +5,6 @@ import {
Button, Button,
Checkbox, Checkbox,
Input, Input,
Body1,
makeStyles, makeStyles,
mergeClasses, mergeClasses,
tokens, tokens,
@@ -32,6 +31,7 @@ import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
import { Select } from './Select' import { Select } from './Select'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text' import { useTextStyles } from './ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens' import { SPACE, ICON, META_SEP } from './ui/tokens'
@@ -104,14 +104,6 @@ const useStyles = makeStyles({
minHeight: '40px' minHeight: '40px'
} }
}, },
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '56px 16px',
textAlign: 'center'
},
emptyBadge: { emptyBadge: {
width: '56px', width: '56px',
height: '56px', height: '56px',
@@ -122,9 +114,6 @@ const useStyles = makeStyles({
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11). // Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
fontSize: `${ICON.section}px` fontSize: `${ICON.section}px`
}, },
emptyHint: {
color: tokens.colorNeutralForeground3
},
noMatches: { noMatches: {
padding: '32px 16px', padding: '32px 16px',
textAlign: 'center', textAlign: 'center',
@@ -229,16 +218,18 @@ export function HistoryView(): React.JSX.Element {
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." /> <ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.empty}> <EmptyState
icon={
<div <div
className={styles.emptyBadge} className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }} style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
> >
<HistoryRegular /> <HistoryRegular />
</div> </div>
<Body1>No downloads yet.</Body1> }
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1> message="No downloads yet."
</div> hint="Finished downloads will show up here."
/>
</div> </div>
) )
} }
+15 -58
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
Body1,
Caption1, Caption1,
Text, Text,
Input, Input,
@@ -23,9 +22,7 @@ import {
AppsListRegular, AppsListRegular,
VideoClipMultipleRegular, VideoClipMultipleRegular,
AlertRegular, AlertRegular,
LibraryRegular, LibraryRegular
LinkRegular,
DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc' import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
@@ -42,6 +39,8 @@ import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip' import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl' import { SegmentedControl } from './ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState'
import { LinkSuggestion } from './ui/LinkSuggestion'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text' import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens' import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
@@ -78,22 +77,6 @@ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
addRow: { display: 'flex', gap: '8px' }, addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 }, 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: { toolbar: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -116,15 +99,6 @@ const useStyles = makeStyles({
fontSize: tokens.fontSizeBase200 fontSize: tokens.fontSizeBase200
}, },
error: { color: tokens.colorPaletteRedForeground1 }, 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' }, list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: { card: {
border: `1px solid ${tokens.colorNeutralStroke2}`, border: `1px solid ${tokens.colorNeutralStroke2}`,
@@ -218,13 +192,7 @@ const useStyles = makeStyles({
// One thumbnail radius app-wide (UI6): control tier / Medium. // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control) ...shorthands.borderRadius(RADIUS.control)
}, },
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
rowTitle: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1
}
}) })
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */ /** 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} iconSize={16}
/> />
<div className={styles.rowMain}> <div className={styles.rowMain}>
<Text className={styles.rowTitle}> <Text className={text.truncate}>
{it.playlistIndex}. {it.title} {it.playlistIndex}. {it.title}
</Text> </Text>
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>} {it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
@@ -555,27 +523,16 @@ export function LibraryView(): React.JSX.Element {
</div> </div>
{clip.suggestion && ( {clip.suggestion && (
<div className={styles.suggestion}> <LinkSuggestion
<LinkRegular /> prefix="Use copied link? "
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1> link={clip.suggestion}
<Button onAccept={() => {
size="small"
appearance="primary"
onClick={() => {
const link = clip.accept() const link = clip.accept()
if (link) setUrl(link) if (link) setUrl(link)
}} }}
> onDismiss={clip.dismiss}
Use dismissLabel="Dismiss suggested link"
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={clip.dismiss}
aria-label="Dismiss suggested link"
/> />
</div>
)} )}
<div className={styles.toolbar}> <div className={styles.toolbar}>
@@ -620,10 +577,10 @@ export function LibraryView(): React.JSX.Element {
{error && <Caption1 className={styles.error}>{error}</Caption1>} {error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? ( {sources.length === 0 && !indexing.active ? (
<div className={styles.empty}> <EmptyState
<LibraryRegular fontSize={ICON.hero} /> icon={<LibraryRegular fontSize={ICON.hero} />}
<Body1>No channels or playlists yet. Paste one above to add it.</Body1> message="No channels or playlists yet. Paste one above to add it."
</div> />
) : ( ) : (
<div className={styles.list}> <div className={styles.list}>
{sources.map((src) => ( {sources.map((src) => (
@@ -21,6 +21,7 @@ import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates' import { useTemplates } from '../store/templates'
import { newId } from '../id' import { newId } from '../id'
import { Hint } from './Hint' import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -71,9 +72,6 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
gap: '8px', gap: '8px',
justifyContent: 'flex-end' justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
} }
}) })
@@ -132,7 +130,7 @@ export function TemplateManager(): React.JSX.Element {
return ( return (
<div className={styles.root}> <div className={styles.root}>
{templates.length === 0 && !draft && ( {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) => ( {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 { useSettings } from '../store/settings'
import { newId } from '../id' import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens' import { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
@@ -57,10 +58,12 @@ const useStyles = makeStyles({
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word' 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 }, cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 }, stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 }, sys: { color: tokens.colorNeutralForeground3 }
empty: { color: tokens.colorNeutralForeground3 }
}) })
/** /**
@@ -204,9 +207,9 @@ export function TerminalView(): React.JSX.Element {
</div> </div>
</div> </div>
<pre className={styles.log} ref={logRef}> <pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
{lines.length === 0 ? ( {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) => ( lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}> <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 { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import { type PlaylistInfo } from '@shared/ipc' import { type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads' import { type MediaKind } from '../../store/downloads'
@@ -35,7 +35,7 @@ export function PlaylistPanel({
<div className={styles.plPanel}> <div className={styles.plPanel}>
<div className={styles.plHeader}> <div className={styles.plHeader}>
<AppsListRegular /> <AppsListRegular />
<Caption1 className={styles.plHeaderText}> <Caption1 className={mergeClasses(styles.plHeaderGrow, text.title)}>
{playlist.title} {playlist.title}
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''} {playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
</Caption1> </Caption1>
@@ -61,7 +61,7 @@ export function PlaylistPanel({
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)} onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
label={ label={
<span className={styles.plItemLabel}> <span className={styles.plItemLabel}>
<span className={styles.plItemTitle}> <span className={text.truncate}>
{e.index}. {e.title} {e.index}. {e.title}
</span> </span>
{(e.durationLabel || e.uploader) && ( {(e.durationLabel || e.uploader) && (
@@ -23,37 +23,6 @@ export const useDownloadBarStyles = makeStyles({
display: 'flex', display: 'flex',
gap: '8px' 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: { url: {
flexGrow: 1 flexGrow: 1
}, },
@@ -128,13 +97,10 @@ export const useDownloadBarStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
gap: '8px' gap: '8px'
}, },
plHeaderText: { // Just the layout role; the truncation + weight come from the shared text.title
flexGrow: 1, // (L114/L126), merged on at the call site.
minWidth: 0, plHeaderGrow: {
overflow: 'hidden', flexGrow: 1
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: tokens.fontWeightSemibold
}, },
plList: { plList: {
display: 'flex', display: 'flex',
@@ -160,21 +126,6 @@ export const useDownloadBarStyles = makeStyles({
flexDirection: 'column', flexDirection: 'column',
minWidth: 0 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 --- // --- trim / schedule panels ---
trimBlock: { trimBlock: {
display: 'flex', 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 { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog' import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../ui/errorText'
import { EmptyState } from '../ui/EmptyState'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
export function DiagnosticsCard(): React.JSX.Element { export function DiagnosticsCard(): React.JSX.Element {
@@ -74,7 +75,7 @@ export function DiagnosticsCard(): React.JSX.Element {
</div> </div>
{errorEntries.length === 0 ? ( {errorEntries.length === 0 ? (
<Caption1 className={styles.hint}>No errors yet.</Caption1> <EmptyState compact message="No errors yet." />
) : ( ) : (
<div className={styles.errorList}> <div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => ( {errorEntries.slice(0, 20).map((e) => (
@@ -13,14 +13,9 @@ import { useSettings } from '../../store/settings'
import { useDownloads } from '../../store/downloads' import { useDownloads } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions' import { QUALITY_OPTIONS } from '../../qualityOptions'
import { Select } from '../Select' import { Select } from '../Select'
import { SegmentedControl } from '../ui/SegmentedControl'
import { useSettingsStyles } from './settingsStyles' 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 { export function DownloadsCard(): React.JSX.Element {
const styles = useSettingsStyles() const styles = useSettingsStyles()
const videoDir = useSettings((s) => s.videoDir) const videoDir = useSettings((s) => s.videoDir)
@@ -38,16 +33,14 @@ export function DownloadsCard(): React.JSX.Element {
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
function onFormatSelect(value: string): void { function onKindSelect(kind: MediaKind): void {
const sep = value.indexOf('|') update({ defaultKind: kind })
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video' }
const quality = sep >= 0 ? value.slice(sep + 1) : value
function onQualitySelect(quality: string): void {
update( update(
kind === 'audio' defaultKind === 'audio' ? { defaultAudioQuality: quality } : { defaultVideoQuality: quality }
? { defaultKind: 'audio', defaultAudioQuality: quality }
: { defaultKind: 'video', defaultVideoQuality: quality }
) )
} }
@@ -105,7 +98,23 @@ export function DownloadsCard(): React.JSX.Element {
</Field> </Field>
<Field label="Default format"> <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>
<Field <Field
@@ -42,6 +42,17 @@ export const useSettingsStyles = makeStyles({
folderInput: { folderInput: {
flexGrow: 1 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: { hint: {
color: tokens.colorNeutralForeground3 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 — * 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 * 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 * theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
* navigation (UI30) and the shared focus ring (UI29). Active treatment is solid * navigation (UI30) and the shared focus ring (UI29). `fitted` makes the segments
* brand (matching both former implementations). `fitted` makes the segments split * split the full width (sidebar); the default is fit-content (download bar).
* 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>({ export function SegmentedControl<T extends string>({
value, value,
+13
View File
@@ -32,6 +32,19 @@ export const useTextStyles = makeStyles({
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */ /** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
title: { title: {
fontWeight: tokens.fontWeightSemibold, 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', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'