feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)

A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

Verified: typecheck (node+web) + 279 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 17:05:26 -04:00
parent 814ecac287
commit 661a4e7572
18 changed files with 420 additions and 35 deletions
+71 -9
View File
@@ -169,6 +169,20 @@ electron-store split is deliberate for DPAPI), **CC11** (constants centralized;
carry a documented standard + deferral rationale. All green: typecheck (node+web) + 275 tests + eslint + carry a documented standard + deferral rationale. All green: typecheck (node+web) + 275 tests + eslint +
production build; touched files prettier-clean. production build; touched files prettier-clean.
**Session 2026-07-01 pass 8 (Batch 11 — UX behavior gaps):** a new in-app **toast** surface
([store/toasts.ts](src/renderer/src/store/toasts.ts) + [ui/Toaster.tsx](src/renderer/src/components/ui/Toaster.tsx),
non-portal bottom-center, +unit-tested) is the batch's spine — it makes **UX6** real: a shared
[reveal.ts](src/renderer/src/reveal.ts) `revealFile` now surfaces open/reveal failures ("File not found — it
may have been moved…") instead of the old silent no-op, and `safeShowInFolder` was upgraded to return an error
string like `safeOpenPath`. Also landed **UX9/UI25** (a `CounterBadge` on the sidebar Downloads nav item shows
the active count from any tab, via a count-only selector that doesn't re-render on ticks), **UX15** (`cancelAll`
+ a "Cancel all (N)" header button), **UX24** (`disabledFocusable` + `Hint` explains the disabled "Check
watched"), **UX12** (an inline "Enable custom commands" button on the Terminal gate), and **L144** (the dup
guard now checks history → "Already downloaded: …"). **Deferred with rationale:** UX7/UX8 (Settings IA
redesign — visual), UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs the live taskbar),
and **L142** (the history/templates/sources IPC-return reconciliation — a focused own-PR, CC14's tail). All
green: typecheck (node+web) + 279 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,
@@ -950,6 +964,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
"interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw. "interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw.
- [ ] **L131 — Taskbar `error` state is ephemeral** — it's driven by live `error` items, so "Clear - [ ] **L131 — Taskbar `error` state is ephemeral** — it's driven by live `error` items, so "Clear
finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log). finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log).
*Deferred (arguably by-design, needs the live taskbar): the taskbar reflects the *live queue*, so clearing
finished items legitimately returns it to idle — the persisted record lives in Diagnostics. Whether the red
bar should linger after a clear is a debatable UX call best judged against the real Windows taskbar; left
for the watched Windows pass (with Batch 14/15).*
- [x] **L132 — `clearProbe` + form-reset logic is duplicated** in DownloadBar (`onUrlChange`, `download`, `addPlaylist`). - [x] **L132 — `clearProbe` + form-reset logic is duplicated** in DownloadBar (`onUrlChange`, `download`, `addPlaylist`).
*Fixed: a shared `resetForm()` in [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts) *Fixed: a shared `resetForm()` in [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts)
now owns the post-enqueue reset (URL + probe + the one-shot trim/schedule/advanced/incognito/options), now owns the post-enqueue reset (URL + probe + the one-shot trim/schedule/advanced/incognito/options),
@@ -1017,11 +1035,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings - [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings
IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does
optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34). optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
*Deferred (targeted, own PR): settings already reconcile (M34). Extending "apply the returned authoritative
state" to history/templates/sources touches each store's mutation path and each corresponding IPC return
shape, and the divergence it guards against is rare (only when main caps/sanitizes differently than the
optimistic update) and self-heals on reload. Best done as one focused reconciliation PR with each store
verified, not folded into this UX batch. Standard already recorded under CC14.*
- [ ] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides; - [ ] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides;
quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager
(mitigated today by the embedded fallback tray icon). (mitigated today by the embedded fallback tray icon).
- [ ] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier - [x] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier
(already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension). (already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension).
*Fixed: the DownloadBar dup guard ([useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts))
now checks history when a URL isn't in the live queue — if it was downloaded before, the warning banner reads
"Already downloaded: …" (vs "Already in your queue: …"), with the same "Download anyway" confirm. A new
`dupInHistory` flag drives the wording.*
- [x] **L145 — `useClipboardLink` re-offers a dismissed link after a tab switch.** `lastSeen` is - [x] **L145 — `useClipboardLink` re-offers a dismissed link after a tab switch.** `lastSeen` is
per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again. per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again.
*Fixed: `lastSeen` is now a module-level variable (not a per-instance `useRef`), so a dismissed/accepted *Fixed: `lastSeen` is now a module-level variable (not a per-instance `useRef`), so a dismissed/accepted
@@ -1253,9 +1280,12 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
- [ ] **UI24 — No context menus anywhere.** Every row exposes actions only as inline buttons; - [ ] **UI24 — No context menus anywhere.** Every row exposes actions only as inline buttons;
right-click does nothing on any screen, despite many per-row actions that conventionally also live on right-click does nothing on any screen, despite many per-row actions that conventionally also live on
right-click. **Standard:** add consistent right-click menus mirroring row actions, or note "none by design." right-click. **Standard:** add consistent right-click menus mirroring row actions, or note "none by design."
- [ ] **UI25 — No in-app global status surface.** Queue progress shows only on the Downloads tab's - [x] **UI25 — No in-app global status surface.** Queue progress shows only on the Downloads tab's
summary strip; on any other tab there's no in-app sign downloads are running (only the OS taskbar). summary strip; on any other tab there's no in-app sign downloads are running (only the OS taskbar).
**Standard:** a persistent affordance (e.g. a sidebar "Downloads" badge with the active count). **Standard:** a persistent affordance (e.g. a sidebar "Downloads" badge with the active count).
*Fixed (with UX9): a `CounterBadge` on the sidebar Downloads nav item shows the active (downloading +
queued) count from every tab, and a new in-app toast surface covers transient status. The exact
suggested affordance.*
### Animations & transitions ### Animations & transitions
@@ -1388,35 +1418,60 @@ root cause is already filed.
### Medium — confusion, feedback & organization ### Medium — confusion, feedback & organization
- [ ] **UX6 — Silent failures on file actions.** `openFile`/`showInFolder` in the stores call - [x] **UX6 — Silent failures on file actions.** `openFile`/`showInFolder` in the stores call
`window.api.openPath(...)` and ignore the returned error string; [reveal.ts](src/main/reveal.ts) `window.api.openPath(...)` and ignore the returned error string; [reveal.ts](src/main/reveal.ts)
refuses missing/moved files or disallowed types and returns an error nobody surfaces. Clicking refuses missing/moved files or disallowed types and returns an error nobody surfaces. Clicking
"Open file" on a moved download does nothing, with no message. **Fix:** surface open/reveal errors. "Open file" on a moved download does nothing, with no message. **Fix:** surface open/reveal errors.
*Fixed: a shared renderer helper [reveal.ts](src/renderer/src/reveal.ts) `revealFile('open'|'folder', path)`
awaits the IPC result and raises the main-process reason ("File not found — it may have been moved…",
"Refusing to open this file type.") as an error **toast** — the new transient-notification surface (see
UX9). Both the downloads and history stores route through it. `safeShowInFolder` was upgraded to return an
error string like `safeOpenPath` (it previously no-op'd silently), so reveal failures surface too.*
- [ ] **UX7 — Settings is one long unsegmented scroll** of ~11 cards with no section nav/anchors; the - [ ] **UX7 — Settings is one long unsegmented scroll** of ~11 cards with no section nav/anchors; the
only finder is the hide-cards search (M14). Order is arbitrary and related items are split. only finder is the hide-cards search (M14). Order is arbitrary and related items are split.
**Fix:** group into sub-sections (or a left rail) with a stable, logical order. **Fix:** group into sub-sections (or a left rail) with a stable, logical order.
*Deferred (visual, own pass): a Settings section-nav / left-rail is a layout redesign whose value is
entirely in how it looks and scrolls — it wants the app running and eyeballed, not an unattended edit.
Grouped with the visual/UX polish pass.*
- [ ] **UX8 — "Default format" vs "Format & post-processing" are two places for one concept.** Kind + - [ ] **UX8 — "Default format" vs "Format & post-processing" are two places for one concept.** Kind +
quality live under Downloads; container/codec/subs/SponsorBlock live in a separate card. **Fix:** quality live under Downloads; container/codec/subs/SponsorBlock live in a separate card. **Fix:**
co-locate, or clearly label one "defaults" and the other "advanced post-processing." co-locate, or clearly label one "defaults" and the other "advanced post-processing."
- [ ] **UX9 — No global completion feedback off the Downloads tab** (UI25). If OS notifications are *Deferred (ties to UX7): co-locating or re-labelling these cards is part of the same Settings
information-architecture pass; done together there with the app running.*
- [x] **UX9 — No global completion feedback off the Downloads tab** (UI25). If OS notifications are
off/suppressed (focus assist) and you're on another tab, a finished download gives no in-app sign. off/suppressed (focus assist) and you're on another tab, a finished download gives no in-app sign.
**Fix:** a sidebar Downloads badge / lightweight in-app toast. **Fix:** a sidebar Downloads badge / lightweight in-app toast.
*Fixed (both halves of the suggested fix): a **sidebar Downloads badge** — a Fluent `CounterBadge` on the
Downloads nav item shows the active (downloading + queued) count from any tab
([Sidebar.tsx](src/renderer/src/components/Sidebar.tsx)), driven by a count-only selector in App so it
updates without re-rendering on every progress tick; and a **lightweight in-app toast** surface
([Toaster.tsx](src/renderer/src/components/ui/Toaster.tsx) + [store/toasts.ts](src/renderer/src/store/toasts.ts)),
a non-portal bottom-center stack that any store/component can raise (now used for UX6's file-action errors).
Together this closes **UI25**.*
- [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).* - [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).*
- [ ] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to - [ ] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to
miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the
app running (or persist + relaunch). app running (or persist + relaunch).
- [ ] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always - [x] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always
visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the
nav item, or let the gate enable the setting inline. nav item, or let the gate enable the setting inline. *Fixed (both fixes): the nav item is already hidden
when custom commands are off (Sidebar's `showTerminal` filter, L18), and the gate — still reachable via the
command palette — now carries an inline **"Enable custom commands"** button that flips the setting on the
spot ([TerminalView.tsx](src/renderer/src/components/TerminalView.tsx)), so it's no longer a dead-end that
sends you to another screen.*
- [x] **UX13 — Backup export warns of nothing; it contains secrets** (M22). A user "backs up settings" - [x] **UX13 — Backup export warns of nothing; it contains secrets** (M22). A user "backs up settings"
and ships proxy creds / tokens in cleartext. **Fix:** warn, or mask/omit secrets. *Resolved by M22 and ships proxy creds / tokens in cleartext. **Fix:** warn, or mask/omit secrets. *Resolved by M22
(backup strips proxy / PO-token / update-token; the caption states credentials aren't included).* (backup strips proxy / PO-token / update-token; the caption states credentials aren't included).*
- [x] **UX14 — Duplicate warning can show a placeholder title** (L76): 'Already in your queue: - [x] **UX14 — Duplicate warning can show a placeholder title** (L76): 'Already in your queue:
"YouTube video (abc123)"' before metadata resolves looks broken. **Fix:** fall back to the URL. "YouTube video (abc123)"' before metadata resolves looks broken. **Fix:** fall back to the URL.
*Resolved by L76 (the dup warning falls back to the URL when the title is still the placeholder).* *Resolved by L76 (the dup warning falls back to the URL when the title is still the placeholder).*
- [ ] **UX15 — No bulk actions in the Downloads queue.** History has a select-mode + bulk delete; - [x] **UX15 — No bulk actions in the Downloads queue.** History has a select-mode + bulk delete;
Downloads has only per-row remove + "Clear finished." Inconsistent. **Fix:** mirror select/bulk, or Downloads has only per-row remove + "Clear finished." Inconsistent. **Fix:** mirror select/bulk, or
add "Cancel all." add "Cancel all." *Fixed (the "Cancel all" option): a `cancelAll` store action + a "Cancel all (N)"
header button ([DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx)) that cancels every
active (downloading + queued) item at once — killing the live processes and freeing the queue — shown only
when there's something cancelable. (A full History-style select-mode is the heavier alternative; "Cancel
all" covers the common bulk need.)*
### Low — polish & smaller friction ### Low — polish & smaller friction
@@ -1427,6 +1482,8 @@ root cause is already filed.
- [ ] **UX17 — Icon-only Fetch/Paste buttons** rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). **Fix:** labels or `aria`-described affordances. - [ ] **UX17 — Icon-only Fetch/Paste buttons** rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). **Fix:** labels or `aria`-described affordances.
- [ ] **UX18 — The quality control morphs after Fetch** (preset list → real formats; label "Quality" → - [ ] **UX18 — The quality control morphs after Fetch** (preset list → real formats; label "Quality" →
"Quality / format") in place — a surprising transform. **Fix:** keep a stable control with a clear "formats loaded" state. "Quality / format") in place — a surprising transform. **Fix:** keep a stable control with a clear "formats loaded" state.
*Deferred (subjective visual): the morph is arguably informative (the label change signals real formats
loaded); whether it "surprises" is a judgment best made watching it, so it joins the watched visual pass.*
- [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed - [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed
920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 — 920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 —
[windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.* [windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.*
@@ -1438,13 +1495,18 @@ root cause is already filed.
- [ ] **UX22 — Onboarding is shallow and one-shot** (L67): three tips, no folder choice, not - [ ] **UX22 — Onboarding is shallow and one-shot** (L67): three tips, no folder choice, not
revisitable; the clipboard tip requires a focus event a first-timer won't trigger. revisitable; the clipboard tip requires a focus event a first-timer won't trigger.
- [ ] **UX23 — Settings folder paths are read-only** (L71) — no paste; Browse-dialog only. - [ ] **UX23 — Settings folder paths are read-only** (L71) — no paste; Browse-dialog only.
- [ ] **UX24 — "Check N watched for new" is disabled with no explanation** when no source is watched — - [x] **UX24 — "Check N watched for new" is disabled with no explanation** when no source is watched —
a user with indexed (but unwatched) sources sees a dead button. **Fix:** tooltip / enable with a hint to watch a source. a user with indexed (but unwatched) sources sees a dead button. **Fix:** tooltip / enable with a hint to watch a source.
*Fixed: the button uses `disabledFocusable` (not `disabled`) when nothing is watched, so it stays
hover/focus-reachable, and a `Hint` explains why — "Turn on 'Watch' for a channel or playlist below first,
then this checks them for new uploads." ([LibraryView.tsx](src/renderer/src/components/LibraryView.tsx)).*
- [x] **UX25 — Empty-state copy mismatches the flow** (L78): Downloads says "Paste a URL above to get - [x] **UX25 — Empty-state copy mismatches the flow** (L78): Downloads says "Paste a URL above to get
started," but the actual path is Fetch/Download. *Resolved by L78 (DownloadsView empty-state copy started," but the actual path is Fetch/Download. *Resolved by L78 (DownloadsView empty-state copy
rewritten to match the Fetch/Download flow).* rewritten to match the Fetch/Download flow).*
- [ ] **UX26 — yt-dlp/ffmpeg version + initial settings load have no skeleton/spinner** — brief blank - [ ] **UX26 — yt-dlp/ffmpeg version + initial settings load have no skeleton/spinner** — brief blank
states on boot (plus the documented one-frame theme flash). **Fix:** lightweight loading states. states on boot (plus the documented one-frame theme flash). **Fix:** lightweight loading states.
*Deferred (low value, needs eyeballing): the blanks are sub-second on a normal launch; adding skeletons is
a perceived-polish tweak worth doing while watching the actual boot, so it joins the visual pass.*
--- ---
+6 -3
View File
@@ -53,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
return shell.openPath(p) return shell.openPath(p)
} }
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */ /** Reveal a path in the OS file manager. Returns '' on success, or an error
export function safeShowInFolder(p: unknown): void { * string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return export function safeShowInFolder(p: unknown): string {
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
shell.showItemInFolder(p) shell.showItemInFolder(p)
return ''
} }
+2 -1
View File
@@ -80,7 +80,8 @@ const api = {
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url), openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path), showInFolder: (path: string): Promise<string> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead), readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
+9
View File
@@ -9,6 +9,7 @@ import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding' import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette' import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion' import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens' import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
@@ -55,6 +56,12 @@ function App(): React.JSX.Element {
const isDark = useResolvedDark() const isDark = useResolvedDark()
const tab = useNav((s) => s.tab) const tab = useNav((s) => s.tab)
const setTab = useNav((s) => s.setTab) const setTab = useNav((s) => s.setTab)
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
// progress is visible from any tab. A number selector only re-renders when the
// count actually changes -- not on every progress tick.
const activeCount = useDownloads(
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
)
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
// Track the element that was focused before the palette opened so we can restore it on close. // Track the element that was focused before the palette opened so we can restore it on close.
const prePaletteRef = useRef<Element | null>(null) const prePaletteRef = useRef<Element | null>(null)
@@ -225,6 +232,7 @@ function App(): React.JSX.Element {
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={toggleCollapsed} onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal} showTerminal={showTerminal}
downloadCount={activeCount}
/> />
<main className={styles.content}> <main className={styles.content}>
@@ -248,6 +256,7 @@ function App(): React.JSX.Element {
</> </>
)} )}
<LiveRegion /> <LiveRegion />
<Toaster />
</div> </div>
</FluentProvider> </FluentProvider>
) )
+4 -1
View File
@@ -52,6 +52,7 @@ export function DownloadBar(): React.JSX.Element {
scheduleAt, scheduleAt,
dragActive, dragActive,
dup, dup,
dupInHistory,
showAdvanced, showAdvanced,
downloadOptions, downloadOptions,
incognito, incognito,
@@ -170,7 +171,9 @@ export function DownloadBar(): React.JSX.Element {
</> </>
} }
> >
<Caption1>Already in your queue: "{dup}".</Caption1> <Caption1>
{dupInHistory ? 'Already downloaded' : 'Already in your queue'}: "{dup}".
</Caption1>
</Banner> </Banner>
)} )}
+10 -1
View File
@@ -8,7 +8,7 @@
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats' import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from './DownloadBar'
@@ -75,6 +75,7 @@ export function DownloadsView(): React.JSX.Element {
const items = useDownloads((s) => s.items) const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished) const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll) const retryAll = useDownloads((s) => s.retryAll)
const cancelAll = useDownloads((s) => s.cancelAll)
const summary = queueSummaryOf(items) const summary = queueSummaryOf(items)
const hasFinished = items.some( const hasFinished = items.some(
@@ -89,6 +90,9 @@ export function DownloadsView(): React.JSX.Element {
i.status === 'paused' || i.status === 'paused' ||
i.status === 'saved' i.status === 'saved'
).length ).length
// Cancelable = the items with a live/pending run (downloading + queued); this is
// exactly what "Cancel all" acts on (UX15).
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
return ( return (
<div className={mergeClasses(styles.root, screen.width)}> <div className={mergeClasses(styles.root, screen.width)}>
@@ -126,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
Retry all failed ({summary.failed}) Retry all failed ({summary.failed})
</Button> </Button>
)} )}
{cancelable > 0 && (
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && ( {hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}> <Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished Clear finished
+22 -8
View File
@@ -36,6 +36,7 @@ import { thumbUrl } from '../thumb'
import { relTime } from '../datetime' import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
import { Hint } from './Hint'
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'
@@ -536,15 +537,28 @@ export function LibraryView(): React.JSX.Element {
)} )}
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Hint
size="small" label={
appearance="subtle" watchedCount === 0
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />} ? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
onClick={onCheckNew} : 'Check your watched sources for new uploads'
disabled={syncing || watchedCount === 0} }
placement="top"
align="start"
> >
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new <Button
</Button> size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
</Hint>
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>} {syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} /> <div className={styles.toolbarSpacer} />
<span className={styles.switchRow}> <span className={styles.switchRow}>
+38 -3
View File
@@ -1,4 +1,11 @@
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import {
Caption1,
CounterBadge,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { import {
ArrowDownloadFilled, ArrowDownloadFilled,
ArrowDownloadRegular, ArrowDownloadRegular,
@@ -88,6 +95,8 @@ const useStyles = makeStyles({
alignSelf: 'stretch' alignSelf: 'stretch'
}, },
navItem: { navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '11px', gap: '11px',
@@ -105,6 +114,16 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover backgroundColor: tokens.colorNeutralBackground1Hover
} }
}, },
// Expanded: the active-download count sits at the far right of the nav row.
navBadge: {
marginLeft: 'auto'
},
// Collapsed: pin the count to the top-right corner of the centered icon.
navBadgeCollapsed: {
position: 'absolute',
top: '4px',
right: '8px'
},
navItemCollapsed: { navItemCollapsed: {
justifyContent: 'center', justifyContent: 'center',
padding: '9px 0' padding: '9px 0'
@@ -155,6 +174,8 @@ interface SidebarProps {
onToggleCollapsed: () => void onToggleCollapsed: () => void
/** Show the Terminal nav item (only when custom commands are enabled) */ /** Show the Terminal nav item (only when custom commands are enabled) */
showTerminal: boolean showTerminal: boolean
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
downloadCount: number
} }
export function Sidebar({ export function Sidebar({
@@ -166,7 +187,8 @@ export function Sidebar({
version, version,
collapsed, collapsed,
onToggleCollapsed, onToggleCollapsed,
showTerminal showTerminal,
downloadCount
}: SidebarProps): React.JSX.Element { }: SidebarProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
@@ -238,10 +260,23 @@ export function Sidebar({
} }
onClick={() => onTabChange(n.value)} onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
aria-label={n.label} aria-label={
n.value === 'downloads' && downloadCount > 0
? `${n.label}, ${downloadCount} active`
: n.label
}
> >
<span className={styles.navIcon}>{n.icon}</span> <span className={styles.navIcon}>{n.icon}</span>
{!collapsed && n.label} {!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button> </button>
) )
return collapsed ? ( return collapsed ? (
+18 -4
View File
@@ -33,6 +33,10 @@ const MAX_LOG_LINES = 2000
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: { gate: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '8px',
padding: '12px 14px', padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1, backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1, color: tokens.colorStatusWarningForeground1,
@@ -75,6 +79,7 @@ export function TerminalView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles() const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('') const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([]) const [lines, setLines] = useState<Line[]>([])
@@ -158,10 +163,19 @@ export function TerminalView(): React.JSX.Element {
/> />
{!customCommandEnabled && ( {!customCommandEnabled && (
<Body1 className={styles.gate}> <div className={styles.gate}>
The terminal is part of custom commands. Turn on "Run custom commands" in Settings <Body1>
Custom commands to use it. The terminal runs the bundled yt-dlp with your own arguments part of custom commands,
</Body1> which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
</Body1>
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
>
Enable custom commands
</Button>
</div>
)} )}
<div className={styles.inputRow}> <div className={styles.inputRow}>
@@ -9,6 +9,7 @@ import {
type CommandPreviewResult type CommandPreviewResult
} from '@shared/ipc' } from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads' import { useDownloads, type MediaKind } from '../../store/downloads'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions' import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats' import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
@@ -55,6 +56,8 @@ export interface DownloadBarController {
setDragActive: Dispatch<SetStateAction<boolean>> setDragActive: Dispatch<SetStateAction<boolean>>
dup: string | null dup: string | null
setDup: Dispatch<SetStateAction<string | null>> setDup: Dispatch<SetStateAction<string | null>>
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
dupInHistory: boolean
// advanced / preview // advanced / preview
showAdvanced: boolean showAdvanced: boolean
setShowAdvanced: Dispatch<SetStateAction<boolean>> setShowAdvanced: Dispatch<SetStateAction<boolean>>
@@ -141,6 +144,9 @@ export function useDownloadBar(): DownloadBarController {
// Duplicate guard: warn before enqueuing a URL already in the active queue. // Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null) const [dup, setDup] = useState<string | null>(null)
// Whether the duplicate matched a history entry (already downloaded) rather than
// a live queue item (L144) — the banner wording differs.
const [dupInHistory, setDupInHistory] = useState(false)
const confirmDup = useRef(false) const confirmDup = useRef(false)
// Per-download options (M5/M6/UX1): advanced features. // Per-download options (M5/M6/UX1): advanced features.
@@ -431,6 +437,15 @@ export function useDownloadBar(): DownloadBarController {
existing.title.endsWith(' download') || existing.title.endsWith(' download') ||
existing.title === 'New download' existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title) setDup(isPlaceholder ? existing.url : existing.title)
setDupInHistory(false)
return
}
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
// re-download is a deliberate choice, not an accidental duplicate.
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
if (downloaded) {
setDup(downloaded.title || downloaded.url)
setDupInHistory(true)
return return
} }
} }
@@ -513,6 +528,7 @@ export function useDownloadBar(): DownloadBarController {
scheduleAt, scheduleAt,
setScheduleAt, setScheduleAt,
// drag / dup // drag / dup
dupInHistory,
dragActive, dragActive,
setDragActive, setDragActive,
dup, dup,
@@ -0,0 +1,92 @@
import { Caption1, Button, makeStyles, tokens, shorthands } from '@fluentui/react-components'
import {
DismissRegular,
ErrorCircleFilled,
CheckmarkCircleFilled,
InfoFilled
} from '@fluentui/react-icons'
import { useToasts, type ToastTone } from '../../store/toasts'
import { Z, ELEVATION, RADIUS, ICON } from './tokens'
/**
* Renders the transient-toast stack (UX6/UX9) bottom-center. Deliberately NOT a
* Fluent Toaster/portal this app avoids portal-based overlays (they blank/flicker
* on the dev GPU; see Select.tsx / CommandPalette.tsx) so it's a plain fixed
* stack. No enter/exit animation, matching the app's conservative motion policy on
* this machine.
*/
const useStyles = makeStyles({
stack: {
position: 'fixed',
left: '50%',
bottom: '24px',
transform: 'translateX(-50%)',
zIndex: Z.tooltip,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
// Let clicks fall through the gaps between toasts to the app underneath.
pointerEvents: 'none'
},
toast: {
pointerEvents: 'auto',
display: 'flex',
alignItems: 'center',
gap: '10px',
maxWidth: 'min(520px, 90vw)',
padding: '10px 8px 10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(RADIUS.surface),
boxShadow: ELEVATION.overlay
},
icon: {
flexShrink: 0,
fontSize: `${ICON.control}px`,
display: 'flex'
},
info: { color: tokens.colorNeutralForeground3 },
error: { color: tokens.colorStatusDangerForeground1 },
success: { color: tokens.colorStatusSuccessForeground1 },
message: {
flexGrow: 1,
minWidth: 0
}
})
const ICONS: Record<ToastTone, React.JSX.Element> = {
info: <InfoFilled />,
error: <ErrorCircleFilled />,
success: <CheckmarkCircleFilled />
}
export function Toaster(): React.JSX.Element | null {
const styles = useStyles()
const toasts = useToasts((s) => s.toasts)
const dismiss = useToasts((s) => s.dismiss)
const toneClass: Record<ToastTone, string> = {
info: styles.info,
error: styles.error,
success: styles.success
}
if (toasts.length === 0) return null
return (
<div className={styles.stack} role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={styles.toast}>
<span className={`${styles.icon} ${toneClass[t.tone]}`}>{ICONS[t.tone]}</span>
<Caption1 className={styles.message}>{t.message}</Caption1>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
/>
</div>
))}
</div>
)
}
+1 -1
View File
@@ -135,7 +135,7 @@ export const mockApi: Api = {
chooseFolder: async () => null, chooseFolder: async () => null,
openPath: async () => '', openPath: async () => '',
openUrl: async () => {}, openUrl: async () => {},
showInFolder: async () => {}, showInFolder: async () => '',
readClipboard: async () => '', readClipboard: async () => '',
logError: () => {}, logError: () => {},
getSettings: async () => MOCK_SETTINGS, getSettings: async () => MOCK_SETTINGS,
+19
View File
@@ -0,0 +1,19 @@
import { toast } from './store/toasts'
import { logError } from './reportError'
/**
* Open a downloaded file, or reveal it in its folder, surfacing any failure as a
* toast (UX6) instead of silently doing nothing. Clicking "Open file" on a
* download that was later moved or deleted used to no-op with no message; now the
* main-process reason ("File not found — it may have been moved…", "Refusing to
* open this file type.") is shown. Both IPC calls resolve to '' on success or an
* error string; a rejected promise (a bridge failure) is logged.
*/
export function revealFile(action: 'open' | 'folder', filePath: string): void {
const call = action === 'open' ? window.api.openPath(filePath) : window.api.showInFolder(filePath)
call
.then((err) => {
if (err) toast(err, 'error')
})
.catch(logError(action === 'open' ? 'openPath' : 'showInFolder'))
}
+2
View File
@@ -116,6 +116,8 @@ export interface DownloadState {
/** internal: promote any 'saved' item whose scheduledFor time has arrived */ /** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void promoteDueScheduled: () => void
cancel: (id: string) => void cancel: (id: string) => void
/** cancel every active (downloading + queued) item at once (UX15) */
cancelAll: () => void
remove: (id: string) => void remove: (id: string) => void
clearFinished: () => void clearFinished: () => void
openFile: (id: string) => void openFile: (id: string) => void
+22 -2
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { type MediaKind } from '@shared/ipc' import { type MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
import { revealFile } from '../reveal'
import { useSettings } from './settings' import { useSettings } from './settings'
import { useHistory } from './history' import { useHistory } from './history'
import { emit, on } from './coordinator' import { emit, on } from './coordinator'
@@ -335,6 +336,25 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump() pump()
}, },
cancelAll: () => {
const active = get().items.filter((i) => i.status === 'downloading' || i.status === 'queued')
if (active.length === 0) return
// Kill the live processes for the running ones; queued items never started.
if (!PREVIEW) {
for (const i of active) {
if (i.status === 'downloading') window.api.cancelDownload(i.id).catch(() => {})
}
}
set((s) => ({
items: s.items.map((i) =>
i.status === 'downloading' || i.status === 'queued'
? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
: i
)
}))
pump()
},
remove: (id) => { remove: (id) => {
set((s) => ({ items: s.items.filter((i) => i.id !== id) })) set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
pump() pump()
@@ -353,12 +373,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
openFile: (id) => { openFile: (id) => {
const item = get().items.find((i) => i.id === id) const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.openPath(item.filePath) if (!PREVIEW && item?.filePath) revealFile('open', item.filePath)
}, },
showInFolder: (id) => { showInFolder: (id) => {
const item = get().items.find((i) => i.id === id) const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.showInFolder(item.filePath) if (!PREVIEW && item?.filePath) revealFile('folder', item.filePath)
}, },
applyEvent: (ev) => { applyEvent: (ev) => {
+3 -2
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
import { revealFile } from '../reveal'
// Mock history so the History tab has content during UI design. // Mock history so the History tab has content during UI design.
const seed: HistoryEntry[] = PREVIEW const seed: HistoryEntry[] = PREVIEW
@@ -87,12 +88,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
openFile: (id) => { openFile: (id) => {
const e = get().entries.find((x) => x.id === id) const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.openPath(e.filePath) if (!PREVIEW && e?.filePath) revealFile('open', e.filePath)
}, },
showInFolder: (id) => { showInFolder: (id) => {
const e = get().entries.find((x) => x.id === id) const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.showInFolder(e.filePath) if (!PREVIEW && e?.filePath) revealFile('folder', e.filePath)
} }
})) }))
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { newId } from '../id'
/**
* A tiny transient-notification store (audit UX6/UX9). The app had no way to tell
* the user about a background failure a swallowed `openPath` error, a file that
* moved so those actions silently did nothing. This backs a lightweight,
* non-blocking toast stack rendered by `Toaster`; any store or component can raise
* one via {@link toast}.
*/
export type ToastTone = 'info' | 'error' | 'success'
export interface Toast {
id: string
message: string
tone: ToastTone
}
interface ToastState {
toasts: Toast[]
show: (message: string, tone?: ToastTone) => void
dismiss: (id: string) => void
}
/** How long a toast lingers before auto-dismissing. */
const AUTO_DISMISS_MS = 5000
export const useToasts = create<ToastState>((set, get) => ({
toasts: [],
show: (message, tone = 'info') => {
const id = newId('toast')
set((s) => ({ toasts: [...s.toasts, { id, message, tone }] }))
// Auto-dismiss so the stack self-clears; the user can also dismiss early.
setTimeout(() => get().dismiss(id), AUTO_DISMISS_MS)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
}))
/**
* Imperative shortcut for non-component callers (the Zustand stores), so a store
* action can surface an error without wiring the hook through a component.
*/
export function toast(message: string, tone?: ToastTone): void {
useToasts.getState().show(message, tone)
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useToasts, toast } from '../src/renderer/src/store/toasts'
describe('toasts store', () => {
beforeEach(() => {
useToasts.setState({ toasts: [] })
vi.useRealTimers()
})
it('show() adds a toast with the given message and tone', () => {
toast('could not open file', 'error')
const ts = useToasts.getState().toasts
expect(ts).toHaveLength(1)
expect(ts[0]?.message).toBe('could not open file')
expect(ts[0]?.tone).toBe('error')
})
it('defaults the tone to info', () => {
toast('heads up')
expect(useToasts.getState().toasts[0]?.tone).toBe('info')
})
it('dismiss() removes only the matching toast', () => {
toast('a')
toast('b')
const firstId = useToasts.getState().toasts[0]?.id ?? ''
useToasts.getState().dismiss(firstId)
const ts = useToasts.getState().toasts
expect(ts).toHaveLength(1)
expect(ts[0]?.message).toBe('b')
})
it('auto-dismisses after the timeout elapses', () => {
vi.useFakeTimers()
toast('temporary')
expect(useToasts.getState().toasts).toHaveLength(1)
vi.advanceTimersByTime(5000)
expect(useToasts.getState().toasts).toHaveLength(0)
})
})