feat(audit): Batch 6-7 — design tokens + type/color roles, full migration

Landed complete design-token foundation (Batch 6) and type/color standardization (Batch 7):
- New src/renderer/src/components/ui/tokens.ts: SPACE, RADIUS, ELEVATION, Z, ICON, MOTION, SCRIM, META_SEP scales
- New src/renderer/src/components/ui/text.ts: useTextStyles hook + type-role convention (Subtitle2/Text/Body1/Caption1)
- Migrated scattered literals in 17 renderer files to named tokens
- Adopted unified muted color and row-title component across 7 list views
- All tile glyphs snapped to ICON tiers (no literal px remain)
- base.css reduced-motion gate + prefers-color-scheme alignment
- Verified tile-glyph visual deltas (≤2px) for watched pass; UI3/UI12 implementation confirmed, awaiting visual check
- CODE-AUDIT.md reconciled: Batch 6 complete (12 items checked, UI3+UI12 flagged); Batch 7 complete (6 items checked)

Verified: typecheck (node+web) + 268 tests + eslint + production build green; touched files prettier-clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:24:00 -04:00
parent e6b4f343e0
commit bd7ba8726e
30 changed files with 547 additions and 198 deletions
+177 -32
View File
@@ -79,6 +79,63 @@ tails (SIMP5 line-buffer + SIMP16 net.request), then Phases 48 (renderer toke
UX/Windows, copy/a11y). Per the run's policy, subjective-visual and live-verify-only items are implemented UX/Windows, copy/a11y). Per the run's policy, subjective-visual and live-verify-only items are implemented
but left **unchecked** with a flagged list for a watched pass. but left **unchecked** with a flagged list for a watched pass.
**Session 2026-07-01 pass 3 (Batch 6 — the renderer design-token foundation):** landed the shared
design-scale module [components/ui/tokens.ts](src/renderer/src/components/ui/tokens.ts) — one home for
**SPACE** (the spacing ramp), **RADIUS** (control/surface/card tiers over Fluent's radii), **ELEVATION**
(flat/raised/overlay), **Z** (overlay/tooltip), **ICON** (16/20/24/40), **MOTION**, **SCRIM**, and
**META_SEP** — then migrated the scattered literals across 17 renderer files. Structurally-verified items
checked off: **UI2** (one 16px section gap on every screen via the view roots), **UI4** (symmetric page
padding — the lone 28px horizontal is gone), **UI5** (the intentional 4px icon-group / 8px control-row split
is now named + documented), **UI6/UI7** (one thumbnail radius = Medium; page-card XLarge / list-item Large /
control Medium tiers), **UI26** (a single motion timing + a global `prefers-reduced-motion` gate in
[base.css](src/renderer/src/assets/base.css)), **L100** (elevation scale — only the download bar (raised) and
command palette (overlay) cast a shadow; every other card stays flat/border-only), **L101** (a z-index scale
replaces the two hardcoded `1000`s), **L121** (the renderer's one raw `rgba(0,0,0,0.32)` scrim → Fluent's
theme-aware `colorBackgroundOverlay`), **L125** (icon sizes single-source from ICON), **L160** (inline margins
tokenized — values unchanged), and **L164** (one metadata separator ` • `; the intra-value ` · ` inside
compound quality labels is deliberately untouched). **UI3 and UI12 are implemented but left unchecked** for a
watched visual pass — they shift a few pixels: card padding 14/20→16 (QueueItem, Settings cards), and the
former-18px chrome glyphs (sidebar nav + theme toggles, IconButton md) → 20 plus the private badge 14→16. All
green: typecheck (node+web) + 268 tests + eslint + production build; the touched files are prettier-clean (the
9 remaining prettier warnings are all pre-existing, untouched files).
**Session 2026-07-01 pass 4 (Batch 7 — type & color roles):** added
[components/ui/text.ts](src/renderer/src/components/ui/text.ts) — a `useTextStyles` hook with the single
`muted` secondary-text color and the shared `title` treatment, plus a header documenting the app's type-role
convention (Subtitle2 = titles/section headers · Text = row titles · Body1 = body copy · Caption1 = small
secondary text). Landed: **L110** (the one muted color, was eight aliases hint/sub/meta/srcSub/…/previewMeta —
adopted in the list views: QueueItem, HistoryView, LibraryView, Sidebar, DownloadBar, PlaylistPanel;
color-identical, so no visual change; the combined/shared-across-cards aliases — `stats`/`count`/`emptyHint`,
settings `hint`, CommandPalette, inline — migrate incrementally), **L114** (one row-title component: the
duplicated semibold-ellipsis title style is now `text.title`, and Library's three `<span>` titles became
`<Text>` — size-identical since Fluent's base font is already `fontSizeBase300`), **L123/L124** (the
Caption1/Body1/Text roles are now written down in text.ts and followed), **UI13** (verified: `Filled` is used
only on the two brand marks, `Regular` everywhere else — the convention already held), and **UI11** (no literal
px font sizes remain — the last tile/badge glyphs snap to `ICON` tiers: Sidebar mark 20, Onboarding mark 24,
Library srcIcon 20, History emptyBadge 24; the `fontSizeBase*` token usages already satisfied the standard).
The four tile-glyph deltas (≤2px) join UI12's watched visual pass. All green: typecheck (node+web) + 268 tests
+ eslint + production build; touched files prettier-clean (9 pre-existing warnings untouched).
**Session 2026-07-01 pass 5 (Batch 8 — form primitives):** **L112** (removed the redundant dynamic
`On`/`Off` label from all 15 `<Field>`-wrapped `<Switch>`es in DownloadOptionsForm + the settings cards — the
Field already names each switch, and the toggle's position + `aria-checked` already convey state, matching
Library's switches), **L133** (made the custom [Select](src/renderer/src/components/Select.tsx) `<Field>`-aware
via `useFieldControlProps_unstable({ supportsLabelFor: true })`, so a Field's visible label names the native
`<select>` through `htmlFor` and its hint associates via `aria-describedby`; dropped the now-duplicate
`aria-label` from every Field-wrapped select (9 in total) — the two standalone selects, download-bar quality
and history filter, keep theirs), **L129** (the
`Hint` `align` prop is now a discriminated union — allowed only with `top`/`bottom` placement, a type error
with `left`/`right` instead of being silently ignored), **L134** (one busy convention — `disabled` +
icon→`Spinner`; converted AboutCard's version-check button off its adjacent-spinner layout so it matches
Fetch/Index/update), **UI17** (the two `appearance="secondary"` buttons — Library "Check for new", Terminal
"Stop" — are now `subtle`, the app's standard non-primary appearance), **L111** (convention clarified &
verified: Fluent `Field hint` for control-associated help, `<Caption1 muted>` only for standalone card
notes/status — control help already uses `Field hint` uniformly), and **L135** (verified role-appropriate:
`Textarea` for the multiline trim ranges "one per line", `Input` for single-line fields). **UI16 left
unchecked** — button size-by-role is subjective; current usage already broadly follows it (screen-primary
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.
**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,
@@ -735,9 +792,14 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [x] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no - [x] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no
`<label for>`/`aria-label` (placeholder only) — see M28. *Resolved by M28: the URL input already carries `<label for>`/`aria-label` (placeholder only) — see M28. *Resolved by M28: the URL input already carries
`aria-label="Video or playlist URL"`, so it has an accessible name — the placeholder isn't the only affordance.* `aria-label="Video or playlist URL"`, so it has an accessible name — the placeholder isn't the only affordance.*
- [ ] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float; - [x] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float;
every other card (Settings, QueueItem, History, Library, Templates) is flat (border-only). Define an elevation scale. every other card (Settings, QueueItem, History, Library, Templates) is flat (border-only). Define an elevation scale.
- [ ] **L101 — No z-index scale.** CommandPalette and Hint both hardcode `zIndex: 1000`. *Fixed: `ELEVATION` in [tokens.ts](src/renderer/src/components/ui/tokens.ts) names the scale — `flat`
(border-only, the resting card), `raised` (shadow4, the floating download bar), `overlay` (shadow28, the
command palette). The two floating surfaces use the tokens; flat cards are documented as the default.*
- [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
`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 - [ ] **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 - [ ] **L103 — Two busy-indicator patterns.** Fetch/Index swap the button icon to a `Spinner`; the
@@ -753,16 +815,32 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [x] **L108 — Main window sets no explicit `title`** (relies on the document `<title>`). - [x] **L108 — Main window sets no explicit `title`** (relies on the document `<title>`).
- [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear; - [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear;
only the manual "Check" button shows a spinner. only the manual "Check" button shows a spinner.
- [ ] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as - [x] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as
`hint`/`sub`/`meta`/`srcSub`/`stats`/`count`/`emptyHint`/`previewMeta` across components. `hint`/`sub`/`meta`/`srcSub`/`stats`/`count`/`emptyHint`/`previewMeta` across components. *Fixed: one
- [ ] **L111 — Two helper-text mechanisms** — Fluent `Field` `hint` vs manual `<Caption1 className={hint}>`; SettingsView uses both. `useTextStyles().muted` in [ui/text.ts](src/renderer/src/components/ui/text.ts). Adopted for the pure-color
- [ ] **L112 — `Switch` labeling inconsistent.** Settings/DownloadOptionsForm show "On"/"Off" text; aliases in the list views (QueueItem `meta`, HistoryView `meta`, LibraryView `sub`/`srcSub`/`rowMeta`,
Library switches use `aria-label` only (no visible state text). Sidebar `caption`, DownloadBar/PlaylistPanel `previewMeta`/`plItemMeta`) — the color is identical, so no
visual change. The combined classes (`stats`/`count`/`emptyHint`), the shared-across-cards settings `hint`,
CommandPalette, Onboarding, and the inline styles migrate incrementally (same pattern M12 used for errorText).*
- [x] **L111 — Two helper-text mechanisms** — Fluent `Field` `hint` vs manual `<Caption1 className={hint}>`; SettingsView uses both.
*Resolved (convention clarified + verified): the two aren't interchangeable — Fluent `Field hint` is for help
associated with a specific control (announced via `aria-describedby`), and `<Caption1 muted>` is for
standalone card intros and status lines ("Saved to…", "No errors yet"). Control help already uses `Field
hint` uniformly, so the split is role-appropriate rather than an inconsistency.*
- [x] **L112 — `Switch` labeling inconsistent.** Settings/DownloadOptionsForm show "On"/"Off" text;
Library switches use `aria-label` only (no visible state text). *Fixed: dropped the dynamic
`label={x ? 'On' : 'Off'}` from all 15 `<Field>`-wrapped switches (DownloadOptionsForm ×6 + settings cards
×9). The Field already names each switch, and the toggle position + `aria-checked` convey state — so no
switch shows redundant On/Off text now, matching Library's inline switches.*
- [x] **L113 — Tooltip ≠ aria-label.** The Fetch button's Hint says "Fetch formats / playlist" but its - [x] **L113 — Tooltip ≠ aria-label.** The Fetch button's Hint says "Fetch formats / playlist" but its
`aria-label` says "Fetch formats or playlist" (DownloadBar). *Resolved by UX2: the Fetch button's Hint `aria-label` says "Fetch formats or playlist" (DownloadBar). *Resolved by UX2: the Fetch button's Hint
tooltip and `aria-label` are now the same string ("Preview available formats (optional)"), so what a tooltip and `aria-label` are now the same string ("Preview available formats (optional)"), so what a
sighted user sees on hover and what a screen reader announces match.* sighted user sees on hover and what a screen reader announces match.*
- [ ] **L114 — Row titles use different components**`Text` (QueueItem/History/Templates) vs `span` (Library) vs `Caption1` elsewhere. - [x] **L114 — Row titles use different components**`Text` (QueueItem/History/Templates) vs `span` (Library) vs `Caption1` elsewhere.
*Fixed: `<Text>` is the one row-title component. Library's three `<span>` titles (`groupTitle`, `rowTitle`,
`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
shared `useTextStyles().title`.*
- [ ] **L115 — Equivalent row actions labeled inconsistently** — "Re-index" is icon+text; the - [ ] **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.
- [ ] **L116 — Empty-state structure varies** — Downloads (icon + line), History (colored badge + line + sub-hint), Library (icon + line, no sub-hint). - [ ] **L116 — Empty-state structure varies** — Downloads (icon + line), History (colored badge + line + sub-hint), Library (icon + line, no sub-hint).
@@ -772,17 +850,31 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [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 - [ ] **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>`.
- [ ] **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. 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
separates more cleanly from a dark UI.*
- [x] **L122 — "Checking…" indicated twice** — the Settings update button changes its label to "Checking…" *and* a separate Spinner renders beside it. - [x] **L122 — "Checking…" indicated twice** — the Settings update button changes its label to "Checking…" *and* a separate Spinner renders beside it.
- [ ] **L123 — `Caption1` is overloaded** for hints, metadata, counts, and timestamps — one size doing four jobs. - [x] **L123 — `Caption1` is overloaded** for hints, metadata, counts, and timestamps — one size doing four jobs.
- [ ] **L124 — `Body1` vs `Text` role overlap**`Body1` appears only in empty states/onboarding/gate; `Text` carries titles; the split isn't principled. *Resolved: the [ui/text.ts](src/renderer/src/components/ui/text.ts) header defines `Caption1`'s role as "small
- [ ] **L125 — Icon sizing has two syntaxes** — px strings (`fontSize: '20px'`) on icon *containers* vs numeric `fontSize={28}` on inline icons. secondary text" — hints/metadata/counts/timestamps are all that one role, now stated on purpose rather than by
accident (paired with the shared `muted` color for L110).*
- [x] **L124 — `Body1` vs `Text` role overlap**`Body1` appears only in empty states/onboarding/gate; `Text` carries titles; the split isn't principled.
*Resolved: the type-role convention in text.ts makes the split explicit — `Body1` = body copy (empty states,
onboarding prose, gated notices); `Text` = row/item titles. Current usage already follows it.*
- [x] **L125 — Icon sizing has two syntaxes** — px strings (`fontSize: '20px'`) on icon *containers* vs numeric `fontSize={28}` on inline icons.
*Fixed: both syntaxes now single-source from `ICON` in tokens.ts (`fontSize={ICON.control}` on inline icons,
`` 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
placeholders) are documented exclusions and stay proportional.*
- [ ] **L126 — Uneven text truncation** — some titles/metas use `nowrap`+ellipsis, others wrap; no shared truncation utility. - [ ] **L126 — Uneven text truncation** — some titles/metas use `nowrap`+ellipsis, others wrap; no shared truncation utility.
- [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.
- [ ] **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`;
passing it with `left`/`right` is a compile error instead of a silent no-op. No caller needed changing (the
left/right hints already omit `align`).*
- [ ] **L130 — Error-string style is mixed** — internal errors are lowercase/no-period ("timed out", - [ ] **L130 — Error-string style is mixed** — internal errors are lowercase/no-period ("timed out",
"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
@@ -793,12 +885,24 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
called by both `download()` and `addPlaylist()`. `clearProbe()` stays the lighter probe-only reset for called by both `download()` and `addPlaylist()`. `clearProbe()` stays the lighter probe-only reset for
`onUrlChange`. Also fixes a latent inconsistency — `addPlaylist` previously left the trim/schedule/advanced `onUrlChange`. Also fixes a latent inconsistency — `addPlaylist` previously left the trim/schedule/advanced
panels open after enqueuing.* panels open after enqueuing.*
- [ ] **L133 — `Field` controls mix `aria-label` + visible `label`.** Many `Field label="X"` wrap a - [x] **L133 — `Field` controls mix `aria-label` + visible `label`.** Many `Field label="X"` wrap a
`Select aria-label="X"`, so AT announces the name twice (extends M20 across DownloadOptionsForm). `Select aria-label="X"`, so AT announces the name twice (extends M20 across DownloadOptionsForm).
- [ ] **L134 — Inconsistent disabled affordance for busy buttons.** Some stay enabled-looking with a *Fixed: the custom [Select](src/renderer/src/components/Select.tsx) is now `<Field>`-aware
(`useFieldControlProps_unstable`, `supportsLabelFor: true`), so a Field's visible label names the native
`<select>` via `htmlFor` and its hint associates via `aria-describedby`. Removed the now-duplicate
`aria-label` from every Field-wrapped select (DownloadOptionsForm ×4, AboutCard, AppearanceCard, CookiesCard
×2, DownloadsCard, CustomCommandsCard). The two standalone selects — the download-bar quality picker and the
history type filter — keep their `aria-label`, since there's no Field to name them.*
- [x] **L134 — Inconsistent disabled affordance for busy buttons.** Some stay enabled-looking with a
spinner icon (Fetch/Index) while others are `disabled` with adjacent text (Settings) — no single "busy" convention. spinner icon (Fetch/Index) while others are `disabled` with adjacent text (Settings) — no single "busy" convention.
- [ ] **L135 — `Textarea` vs `Input` for similar fields.** Trim uses a `Textarea`; the visually-similar *Fixed: one busy convention — `disabled` while working + the button's own icon swaps to a `<Spinner
size="tiny">`. Fetch, Index, Check-for-new and the yt-dlp update button already did this; converted
AboutCard's version-check button off its separate adjacent spinner to match.*
- [x] **L135 — `Textarea` vs `Input` for similar fields.** Trim uses a `Textarea`; the visually-similar
single-line fields use `Input` — fine, but the trim/schedule panels and the template form mix both with no shared field style. single-line fields use `Input` — fine, but the trim/schedule panels and the template form mix both with no shared field style.
*Resolved: verified role-appropriate — the trim field is genuinely multiline (time ranges "one per line",
`resize="vertical"`), so `Textarea` is correct there and `Input` for the single-line fields. Kept as-is; the
shared field styling is covered by the Fluent `<Field>` wrapper they all use.*
*Round 6 (2026-06-29) — behavioral/logic edge cases:* *Round 6 (2026-06-29) — behavioral/logic edge cases:*
@@ -889,8 +993,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag. crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag.
- [x] **L159 — Dead fallback branch.** `copyErrorReport` falls back to "No errors logged." but its button - [x] **L159 — Dead fallback branch.** `copyErrorReport` falls back to "No errors logged." but its button
is disabled when there are no entries, so the fallback is unreachable. is disabled when there are no entries, so the fallback is unreachable.
- [ ] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView - [x] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView
8px) instead of style classes (extends L5). 8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE`
(`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is
still L5.)*
- [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set - [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set
`height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can `height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can
double-scroll or misalign the virtualized list's viewport. double-scroll or misalign the virtualized list's viewport.
@@ -903,10 +1009,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*Round 8 (2026-06-29) — formatting micro-inconsistencies:* *Round 8 (2026-06-29) — formatting micro-inconsistencies:*
- [ ] **L164 — Metadata separator glyph/spacing is inconsistent.** Meta lines join with `' • '` - [x] **L164 — Metadata separator glyph/spacing is inconsistent.** Meta lines join with `' • '`
(2-space bullet) in QueueItem/History/DownloadBar/DownloadsView, with `' · '` (1-space midd·dot) in (2-space bullet) in QueueItem/History/DownloadBar/DownloadsView, with `' · '` (1-space midd·dot) in
LibraryView, and with `' • '` (1-space bullet) in QueueItem's "Paused • 42%". Three styles for one role. LibraryView, and with `' • '` (1-space bullet) in QueueItem's "Paused • 42%". Three styles for one role.
**Standard:** one separator constant (glyph + spacing). **Standard:** one separator constant (glyph + spacing). *Fixed: one `META_SEP = ' • '` in tokens.ts, adopted
at every meta-line join (QueueItem/History/DownloadBar/DownloadsView/Library/PlaylistPanel). The `' · '`
inside compound quality labels ("720p · mp4 · 184 MB") is a data format parsed by HistoryView and is
intentionally left as-is.*
- [x] **L165 — The two byte/speed formatters round differently.** `download.ts fmtBytes` uses - [x] **L165 — The two byte/speed formatters round differently.** `download.ts fmtBytes` uses
`toFixed(v >= 100 ? 0 : 1)` ("512 MB") while `queueStats formatSpeed` uses `toFixed(1)` ("512.0 MB/s") — `toFixed(v >= 100 ? 0 : 1)` ("512 MB") while `queueStats formatSpeed` uses `toFixed(1)` ("512.0 MB/s") —
visibly different precision for the same magnitudes (compounds the duplicate-formatter issue H2/M9). visibly different precision for the same magnitudes (compounds the duplicate-formatter issue H2/M9).
@@ -955,22 +1064,37 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
but Downloads/Library/History/Terminal are full-width; Onboarding card is 460px, CommandPalette but Downloads/Library/History/Terminal are full-width; Onboarding card is 460px, CommandPalette
560px. On a wide window Settings is a narrow column while siblings stretch edge-to-edge. 560px. On a wide window Settings is a narrow column while siblings stretch edge-to-edge.
**Standard:** one reading-width token (e.g. 720px) applied by a shared `Screen` wrapper, or commit to full-width everywhere. **Standard:** one reading-width token (e.g. 720px) applied by a shared `Screen` wrapper, or commit to full-width everywhere.
- [ ] **UI2 — Per-screen vertical rhythm differs.** Root section gap is 16 (Settings/Terminal), 20 - [x] **UI2 — Per-screen vertical rhythm differs.** Root section gap is 16 (Settings/Terminal), 20
(Downloads), 18 (Library), 12 (History). **Standard:** a single section-gap (16px) via a shared screen wrapper. (Downloads), 18 (Library), 12 (History). **Standard:** a single section-gap (16px) via a shared screen wrapper.
*Fixed: all five view roots now use `SPACE.section` (16) — Downloads 20→16, Library 18→16, History 12→16;
Settings/Terminal were already 16 and are now tokenized.*
- [ ] **UI3 — Card padding has no scale.** 14 (QueueItem) / 16 (DownloadBar) / 20 (Settings card) / - [ ] **UI3 — Card padding has no scale.** 14 (QueueItem) / 16 (DownloadBar) / 20 (Settings card) /
32 (Onboarding) / `10px 12px` (History row) / `12px 14px` (Library head) / `8px 10px` (template row). 32 (Onboarding) / `10px 12px` (History row) / `12px 14px` (Library head) / `8px 10px` (template row).
**Standard:** a padding scale (e.g. control 810, list-row 12, card 16, hero 24) keyed to surface tier. **Standard:** a padding scale (e.g. control 810, list-row 12, card 16, hero 24) keyed to surface tier.
- [ ] **UI4 — Asymmetric app content padding.** `padding: '24px 28px'` ([App.tsx](src/renderer/src/App.tsx)) — *Implemented (SPACE scale defined; the card tier unified to 16 — QueueItem 14→16, Settings cards 20→16;
28px horizontal appears nowhere else. **Standard:** symmetric or scale-based padding. DownloadBar already 16; Onboarding → `hero`) but **left unchecked for a watched visual pass**: 20→16 tightens
- [ ] **UI5 — Action-cluster gaps split 4px vs 8px.** Icon-button rows use 4px (QueueItem/History/ the 11 settings cards and 14→16 loosens queue rows. The smaller list-row/control paddings (History/Library
head/template) are not yet migrated. Eyeball, then check off or retune the card value.*
- [x] **UI4 — Asymmetric app content padding.** `padding: '24px 28px'` ([App.tsx](src/renderer/src/App.tsx)) —
28px horizontal appears nowhere else. **Standard:** symmetric or scale-based padding. *Fixed: the `<main>`
content padding is now the symmetric `SPACE.page` (24px).*
- [x] **UI5 — Action-cluster gaps split 4px vs 8px.** Icon-button rows use 4px (QueueItem/History/
TemplateManager) but DownloadBar/Library action rows use 8px. **Standard:** 4px for icon-button groups, 8px for labelled-control rows. *(Good baseline already: every empty-state uses `56px 16px` — keep it.)* TemplateManager) but DownloadBar/Library action rows use 8px. **Standard:** 4px for icon-button groups, 8px for labelled-control rows. *(Good baseline already: every empty-state uses `56px 16px` — keep it.)*
*Resolved: the existing split already matched the standard; it's now named in `SPACE` (`xtight` = 4 for
icon-button clusters, `tight` = 8 for control rows) and adopted where touched, so the rule is explicit
rather than incidental.*
### Corner radius ### Corner radius
- [ ] **UI6 — Thumbnail radius varies for the same element.** QueueItem `Large`, History `Medium`, - [x] **UI6 — Thumbnail radius varies for the same element.** QueueItem `Large`, History `Medium`,
Library rowThumb `Small`, DownloadBar previewThumb `Medium`. **Standard:** one thumbnail radius (Medium/10px). *(Distinct from L9, which is thumbnail pixel dimensions.)* Library rowThumb `Small`, DownloadBar previewThumb `Medium`. **Standard:** one thumbnail radius (Medium/10px). *(Distinct from L9, which is thumbnail pixel dimensions.)*
- [ ] **UI7 — Card radius (XLarge vs Large) has no stated rule.** Top-level cards use XLarge (16), *Fixed: every thumbnail uses `RADIUS.control` (Medium) — QueueItem Large→Medium and Library rowThumb
Small→Medium; History/DownloadBar were already Medium and are now tokenized.*
- [x] **UI7 — Card radius (XLarge vs Large) has no stated rule.** Top-level cards use XLarge (16),
list rows Large (12), but it's applied by feel. **Standard:** document surface tiers — page-card XLarge, list-item Large, control Medium — and apply uniformly. list rows Large (12), but it's applied by feel. **Standard:** document surface tiers — page-card XLarge, list-item Large, control Medium — and apply uniformly.
*Fixed: `RADIUS` documents the tiers (`card` XLarge / `surface` Large / `control` Medium) and they're
applied on the surfaces touched this batch (page cards → `card`, the QueueItem list-item → `surface`,
thumbnails/controls → `control`); remaining correct-but-untokenized `borderRadius*` calls migrate incrementally.*
- [x] **UI8 — Circular shapes mix token and literal.** `borderRadiusCircular` (Library pill/watchBadge) - [x] **UI8 — Circular shapes mix token and literal.** `borderRadiusCircular` (Library pill/watchBadge)
vs literal `'50%'` (SettingsView swatch, History emptyBadge). **Standard:** always the token. vs literal `'50%'` (SettingsView swatch, History emptyBadge). **Standard:** always the token.
@@ -981,16 +1105,28 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
(just a count). **Standard:** one page-title style on every screen. (just a count). **Standard:** one page-title style on every screen.
- [x] **UI10 — Only Library has a screen subtitle/description.** Others jump straight to content. - [x] **UI10 — Only Library has a screen subtitle/description.** Others jump straight to content.
**Standard:** a consistent header block (title + optional one-line description). **Standard:** a consistent header block (title + optional one-line description).
- [ ] **UI11 — Semantic type ramp vs ad-hoc px.** Sidebar `brandName`/`navItem` and Library - [x] **UI11 — Semantic type ramp vs ad-hoc px.** Sidebar `brandName`/`navItem` and Library
`watchBadge`/`pill` set raw `fontSizeBaseXXX`; `sectionIcon`/`mark` use literal `'20px'`/`'26px'`. `watchBadge`/`pill` set raw `fontSizeBaseXXX`; `sectionIcon`/`mark` use literal `'20px'`/`'26px'`.
**Standard:** Fluent type components/tokens; no literal px font sizes. **Standard:** Fluent type components/tokens; no literal px font sizes. *Fixed: no literal px font sizes
remain — the last tile/badge glyphs snap to `ICON` tiers (Sidebar mark 20, Onboarding mark 24, Library
srcIcon 20, History emptyBadge 24). The `fontSizeBase*` usages already met the standard (they're tokens, on
interactive `<button>`/pill elements that can't be Fluent type components). The four ≤2px tile-glyph deltas
join UI12's watched visual pass.*
### Icons ### Icons
- [ ] **UI12 — No icon-size scale.** Inline `fontSize` of 11/14/16/18/20/22/26/28/40 is scattered - [ ] **UI12 — No icon-size scale.** Inline `fontSize` of 11/14/16/18/20/22/26/28/40 is scattered
across components. **Standard:** a small set (16 inline / 20 control / 24 section / 40 empty-state). across components. **Standard:** a small set (16 inline / 20 control / 24 section / 40 empty-state).
- [ ] **UI13 — Regular vs Filled weight mixed for one concept.** `ArrowDownloadRegular` (nav, buttons) *Implemented (`ICON` = 16/20/24/40, all four tiers in use; standalone chrome glyphs migrated) but **left
unchecked for a watched visual pass** because a few glyphs shift: the former-18px chrome icons (sidebar nav +
theme toggles, IconButton md) → 20 (control), the settings card-header icons 20→24 (section), and the private
badge 14→16. Box-proportional glyphs (brand/source tiles, `watchBadge` 11px, thumbnail placeholders 22/28)
are documented exclusions and unchanged. Eyeball the 18→20 / 20→24 / 14→16 deltas.*
- [x] **UI13 — Regular vs Filled weight mixed for one concept.** `ArrowDownloadRegular` (nav, buttons)
vs `ArrowDownloadFilled` (brand mark). **Standard:** one weight per concept (Filled only for brand/emphasis). vs `ArrowDownloadFilled` (brand mark). **Standard:** one weight per concept (Filled only for brand/emphasis).
*Verified: `ArrowDownloadFilled` appears only on the two brand marks (Sidebar, Onboarding); every other
download arrow (nav, buttons, `contentBefore`) is `ArrowDownloadRegular`. The convention already held — the
only Filled glyphs elsewhere are the status Checkmark/Error circles, which are deliberate state emphasis.*
### Buttons & controls ### Buttons & controls
@@ -1002,8 +1138,13 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
are bespoke. **Standard:** wrap recurring patterns so hover/focus/disabled are uniform (ties to L5/M15). are bespoke. **Standard:** wrap recurring patterns so hover/focus/disabled are uniform (ties to L5/M15).
- [ ] **UI16 — Button size hierarchy isn't applied uniformly.** DownloadBar `large`; Library "Index" - [ ] **UI16 — Button size hierarchy isn't applied uniformly.** DownloadBar `large`; Library "Index"
`large` but its toolbar `small`; History all `small`; Settings mostly default. **Standard:** size-by-role (screen primary = large; secondary = medium; row actions = small). `large` but its toolbar `small`; History all `small`; Settings mostly default. **Standard:** size-by-role (screen primary = large; secondary = medium; row actions = small).
- [ ] **UI17 — `appearance="secondary"` used in only two spots** (Library "Check for new", Terminal *Convention documented (screen-primary large · row/toolbar small · settings default/medium); current usage
already broadly follows it. **Left unchecked** — the specific per-screen size tweaks are a subjective visual
call, so they join the watched visual pass (with UI3/UI12).*
- [x] **UI17 — `appearance="secondary"` used in only two spots** (Library "Check for new", Terminal
"Stop") while every other non-primary button is `subtle`. **Standard:** pick subtle *or* secondary as the standard non-primary appearance. "Stop") while every other non-primary button is `subtle`. **Standard:** pick subtle *or* secondary as the standard non-primary appearance.
*Fixed: both switched to `subtle` (the app's standard non-primary appearance — used by every other
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 - [ ] **UI19 — Suggestion-banner CSS duplicated.** Identical `suggestion`/`suggestionText` styles in
@@ -1035,9 +1176,13 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
### Animations & transitions ### Animations & transitions
- [ ] **UI26 — Lone animation.** Only the sidebar width transitions (`0.15s ease`); tab switches, - [x] **UI26 — Lone animation.** Only the sidebar width transitions (`0.15s ease`); tab switches,
panel expand/collapse, banners, and Library card expansion are all instant. **Standard:** one motion panel expand/collapse, banners, and Library card expansion are all instant. **Standard:** one motion
policy — either add subtle, consistent transitions (gated on `prefers-reduced-motion`) or drop the sidebar one. policy — either add subtle, consistent transitions (gated on `prefers-reduced-motion`) or drop the sidebar one.
*Fixed (policy established): the sidebar transition now uses the shared `MOTION` timing (durationFast +
curveEasyEase), and a global `@media (prefers-reduced-motion: reduce)` rule in base.css disables every
transition/animation for users who ask for less. New transitions adopt `MOTION`; none were added
speculatively (compositing overlays flicker on the dev GPU — see the gpu-flicker note).*
### Hover, focus, disabled, keyboard & accessibility ### Hover, focus, disabled, keyboard & accessibility
+3 -1
View File
@@ -10,6 +10,7 @@ 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 { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useNav } from './store/nav' import { useNav } from './store/nav'
import { useDownloads } from './store/downloads' import { useDownloads } from './store/downloads'
@@ -40,7 +41,8 @@ const useStyles = makeStyles({
flexGrow: 1, flexGrow: 1,
minWidth: 0, minWidth: 0,
overflowY: 'auto', overflowY: 'auto',
padding: '24px 28px' // Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else.
padding: SPACE.page
} }
}) })
+17
View File
@@ -38,3 +38,20 @@ body {
#root { #root {
height: 100vh; height: 100vh;
} }
/*
* One motion policy (UI26): honor the OS "reduce motion" preference everywhere.
* The app's transitions are subtle (see MOTION in components/ui/tokens.ts), but a
* user who asks for less motion gets none — this near-instant override covers any
* current or future transition/animation without each component opting in.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
export interface PaletteAction { export interface PaletteAction {
id: string id: string
@@ -16,21 +17,21 @@ const useStyles = makeStyles({
backdrop: { backdrop: {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
zIndex: 1000, zIndex: Z.overlay,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'flex-start', alignItems: 'flex-start',
paddingTop: '14vh', paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)' backgroundColor: SCRIM
}, },
panel: { panel: {
width: 'min(560px, 92vw)', width: 'min(560px, 92vw)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge), ...shorthands.borderRadius(RADIUS.card),
border: `1px solid ${tokens.colorNeutralStroke2}`, border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28, boxShadow: ELEVATION.overlay,
overflow: 'hidden' overflow: 'hidden'
}, },
input: { input: {
+6 -3
View File
@@ -30,6 +30,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 { useTextStyles } from './ui/text'
import { META_SEP } from './ui/tokens'
import { DownloadOptionsForm } from './DownloadOptionsForm' import { DownloadOptionsForm } from './DownloadOptionsForm'
import { useDownloadBarStyles } from './downloadBar/styles' import { useDownloadBarStyles } from './downloadBar/styles'
import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar' import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar'
@@ -37,6 +39,7 @@ import { PlaylistPanel } from './downloadBar/PlaylistPanel'
export function DownloadBar(): React.JSX.Element { export function DownloadBar(): React.JSX.Element {
const styles = useDownloadBarStyles() const styles = useDownloadBarStyles()
const text = useTextStyles()
const bar = useDownloadBar() const bar = useDownloadBar()
const { const {
url, url,
@@ -201,9 +204,9 @@ export function DownloadBar(): React.JSX.Element {
</div> </div>
)} )}
<div className={styles.previewBody}> <div className={styles.previewBody}>
<Text className={styles.previewTitle}>{info.title}</Text> <Text className={text.title}>{info.title}</Text>
<Caption1 className={styles.previewMeta}> <Caption1 className={text.muted}>
{[info.channel, info.durationLabel].filter(Boolean).join(' • ')} {[info.channel, info.durationLabel].filter(Boolean).join(META_SEP)}
</Caption1> </Caption1>
</div> </div>
</div> </div>
@@ -112,7 +112,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
{kind !== 'video' && ( {kind !== 'video' && (
<Field label="Audio format" hint="For audio-only downloads."> <Field label="Audio format" hint="For audio-only downloads.">
<Select <Select
aria-label="Audio format"
value={value.audioFormat} value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))} options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)} onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
@@ -125,7 +124,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
hint="The file format used when video and audio are combined into one file." hint="The file format used when video and audio are combined into one file."
> >
<Select <Select
aria-label="Video container"
value={value.videoContainer} value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ options={VIDEO_CONTAINERS.map((c) => ({
value: c, value: c,
@@ -141,7 +139,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
hint="A preference when several formats match -- not a strict filter." hint="A preference when several formats match -- not a strict filter."
> >
<Select <Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec} value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))} options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)} onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
@@ -165,7 +162,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch <Switch
checked={value.embedSubtitles} checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)} onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.embedSubtitles && ( {value.embedSubtitles && (
@@ -194,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch <Switch
checked={value.sponsorBlock} checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)} onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.sponsorBlock && ( {value.sponsorBlock && (
<div className={styles.subGroup}> <div className={styles.subGroup}>
<Field label="Action"> <Field label="Action">
<Select <Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode} value={value.sponsorBlockMode}
options={[ options={[
{ value: 'remove', label: 'Remove segments (cut from file)' }, { value: 'remove', label: 'Remove segments (cut from file)' },
@@ -240,7 +234,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch <Switch
checked={value.embedChapters} checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)} onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field <Field
@@ -250,14 +243,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch <Switch
checked={value.splitChapters} checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)} onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags."> <Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch <Switch
checked={value.embedMetadata} checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)} onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field <Field
@@ -286,7 +277,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch <Switch
checked={value.embedThumbnail} checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)} onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.embedThumbnail && ( {value.embedThumbnail && (
@@ -16,6 +16,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 { 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
// measurement/key cache on every render (PERF5). // measurement/key cache on every render (PERF5).
@@ -30,7 +31,8 @@ const useStyles = makeStyles({
// growing to thousands of rows. // growing to thousands of rows.
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '20px', // One section gap on every screen (UI2).
gap: SPACE.section,
height: '100%' height: '100%'
}, },
queueHeader: { queueHeader: {
@@ -114,8 +116,8 @@ export function DownloadsView(): React.JSX.Element {
<Caption1 className={styles.summaryText}> <Caption1 className={styles.summaryText}>
{summary.downloading} downloading {summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''} {summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''} {summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
{summary.etaLabel ? `~${summary.etaLabel} left` : ''} {summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
</Caption1> </Caption1>
</div> </div>
)} )}
@@ -143,7 +145,7 @@ export function DownloadsView(): React.JSX.Element {
{items.length === 0 ? ( {items.length === 0 ? (
<div className={styles.empty}> <div className={styles.empty}>
<ArrowDownloadRegular fontSize={40} /> <ArrowDownloadRegular fontSize={ICON.hero} />
<Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1> <Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1>
</div> </div>
) : ( ) : (
+11 -5
View File
@@ -1,4 +1,5 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { Z } from './ui/tokens'
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a // An instant, theme-styled tooltip implemented entirely in CSS — shown via a
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child // `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
}, },
bubble: { bubble: {
position: 'absolute', position: 'absolute',
zIndex: 1000, zIndex: Z.tooltip,
visibility: 'hidden', visibility: 'hidden',
pointerEvents: 'none', pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
alignEnd: { right: 0 } alignEnd: { right: 0 }
}) })
interface HintProps { // `align` only shifts a top/bottom bubble left/right; it does nothing for a
// left/right placement. The union makes that explicit — passing `align` with
// `placement="left"|"right"` is a type error now, instead of being silently
// ignored (L129).
type HintProps = {
label: string label: string
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode children: React.ReactNode
} } & (
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
| { placement: 'left' | 'right'; align?: never }
)
export function Hint({ export function Hint({
label, label,
+9 -14
View File
@@ -33,13 +33,15 @@ 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 { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens'
import { THUMB_SM } from '../thumbSizes' import { THUMB_SM } from '../thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px' gap: SPACE.section
}, },
header: { header: {
display: 'flex', display: 'flex',
@@ -92,15 +94,6 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}, },
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: { actions: {
display: 'flex', display: 'flex',
gap: '4px', gap: '4px',
@@ -126,7 +119,8 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: tokens.borderRadiusCircular, borderRadius: tokens.borderRadiusCircular,
fontSize: '26px' // Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
fontSize: `${ICON.section}px`
}, },
emptyHint: { emptyHint: {
color: tokens.colorNeutralForeground3 color: tokens.colorNeutralForeground3
@@ -148,6 +142,7 @@ export function HistoryView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles() const screen = useScreenStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
const text = useTextStyles()
const isDark = useResolvedDark() const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light'] const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries) const entries = useHistory((s) => s.entries)
@@ -406,11 +401,11 @@ export function HistoryView(): React.JSX.Element {
iconSize={22} iconSize={22}
/> />
<div className={styles.body}> <div className={styles.body}>
<Text className={styles.title}>{h.title}</Text> <Text className={text.title}>{h.title}</Text>
<Caption1 className={styles.meta}> <Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)] {[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean) .filter(Boolean)
.join(' • ')} .join(META_SEP)}
</Caption1> </Caption1>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
+31 -24
View File
@@ -43,6 +43,8 @@ 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 { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_XS } from '../thumbSizes' import { THUMB_XS } from '../thumbSizes'
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */ /** Per-item status shown in the library: a live queue status, or pending/downloaded. */
@@ -73,8 +75,7 @@ const STATUS_PRIORITY: Record<DownloadStatus, number> = {
} }
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
sub: { color: tokens.colorNeutralForeground3 },
addRow: { display: 'flex', gap: '8px' }, addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 }, addInput: { flexGrow: 1 },
suggestion: { suggestion: {
@@ -157,7 +158,8 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '18px' // Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
}, },
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }, srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
@@ -170,7 +172,6 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorBrandBackground2, backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2 color: tokens.colorBrandForeground2
}, },
srcSub: { color: tokens.colorNeutralForeground3 },
detail: { detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`, borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px', padding: '12px 14px',
@@ -214,7 +215,8 @@ const useStyles = makeStyles({
rowThumb: { rowThumb: {
width: `${THUMB_XS.w}px`, width: `${THUMB_XS.w}px`,
height: `${THUMB_XS.h}px`, height: `${THUMB_XS.h}px`,
...shorthands.borderRadius(tokens.borderRadiusSmall) // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
}, },
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
rowTitle: { rowTitle: {
@@ -222,8 +224,7 @@ const useStyles = makeStyles({
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1 color: tokens.colorNeutralForeground1
}, }
rowMeta: { color: tokens.colorNeutralForeground3 }
}) })
/** 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. */
@@ -250,6 +251,7 @@ export function LibraryView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles() const screen = useScreenStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
const text = useTextStyles()
const sources = useSources((s) => s.sources) const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource) const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId) const selectedSourceId = useSources((s) => s.selectedSourceId)
@@ -477,8 +479,8 @@ export function LibraryView(): React.JSX.Element {
> >
{open ? <ChevronDownRegular /> : <ChevronRightRegular />} {open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular /> <AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span> <Text className={styles.groupTitle}>{row.title}</Text>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1> <Caption1 className={text.muted}>{row.items.length}</Caption1>
</button> </button>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}> <Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'} {allOn ? 'None' : 'All'}
@@ -512,10 +514,10 @@ export function LibraryView(): React.JSX.Element {
iconSize={16} iconSize={16}
/> />
<div className={styles.rowMain}> <div className={styles.rowMain}>
<span className={styles.rowTitle}> <Text className={styles.rowTitle}>
{it.playlistIndex}. {it.title} {it.playlistIndex}. {it.title}
</span> </Text>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>} {it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
</div> </div>
<StatusChip status={status} /> <StatusChip status={status} />
</div> </div>
@@ -579,14 +581,14 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Button
size="small" size="small"
appearance="secondary" appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />} icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew} onClick={onCheckNew}
disabled={syncing || watchedCount === 0} disabled={syncing || watchedCount === 0}
> >
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button> </Button>
{syncNote && <Caption1 className={styles.sub}>{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}>
<Caption1>Auto-download new</Caption1> <Caption1>Auto-download new</Caption1>
@@ -619,7 +621,7 @@ export function LibraryView(): React.JSX.Element {
{sources.length === 0 && !indexing.active ? ( {sources.length === 0 && !indexing.active ? (
<div className={styles.empty}> <div className={styles.empty}>
<LibraryRegular fontSize={40} /> <LibraryRegular fontSize={ICON.hero} />
<Body1>No channels or playlists yet. Paste one above to add it.</Body1> <Body1>No channels or playlists yet. Paste one above to add it.</Body1>
</div> </div>
) : ( ) : (
@@ -640,9 +642,9 @@ export function LibraryView(): React.JSX.Element {
label="Select all" label="Select all"
disabled={actionableItems.length === 0} disabled={actionableItems.length === 0}
/> />
<Caption1 className={styles.srcSub}> <Caption1 className={text.muted}>
{items.length} videos · {pendingItems.length} pending · indexed{' '} {items.length} videos{META_SEP}
{relTime(src.lastIndexedAt)} {pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1> </Caption1>
<div className={styles.actionSpacer} /> <div className={styles.actionSpacer} />
<SegmentedControl<MediaKind> <SegmentedControl<MediaKind>
@@ -703,7 +705,7 @@ export function LibraryView(): React.JSX.Element {
</Button> </Button>
{confirmRemoveId === src.id ? ( {confirmRemoveId === src.id ? (
<> <>
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1> <Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button <Button
size="small" size="small"
appearance="primary" appearance="primary"
@@ -735,7 +737,7 @@ export function LibraryView(): React.JSX.Element {
)} )}
</div> </div>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>} {batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? ( {flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized // Big source: a fixed-height, internally-scrolling virtualized
@@ -781,6 +783,7 @@ function SourceCard({
children: React.ReactNode children: React.ReactNode
}): React.JSX.Element { }): React.JSX.Element {
const focus = useFocusStyles() const focus = useFocusStyles()
const text = useTextStyles()
return ( return (
<div className={styles.card}> <div className={styles.card}>
<button <button
@@ -796,16 +799,20 @@ function SourceCard({
</div> </div>
<div className={styles.srcMeta}> <div className={styles.srcMeta}>
<span className={styles.srcTitleRow}> <span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span> <Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && ( {source.watched && (
<span className={styles.watchBadge}> <span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching <AlertRegular fontSize={11} /> Watching
</span> </span>
)} )}
</span> </span>
<Caption1 className={styles.srcSub}> <Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos {source.kind === 'channel' ? 'Channel' : 'Playlist'}
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''} {META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1> </Caption1>
</div> </div>
</button> </button>
+12 -9
View File
@@ -21,6 +21,7 @@ import {
VideoClipRegular VideoClipRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -30,13 +31,14 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflowY: 'auto', overflowY: 'auto',
padding: '24px' padding: SPACE.page
}, },
card: { card: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '20px', gap: SPACE.roomy,
padding: '32px', // The welcome hero card (UI3 hero tier).
padding: SPACE.hero,
maxWidth: '460px', maxWidth: '460px',
width: '100%', width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge) ...shorthands.borderRadius(tokens.borderRadiusXLarge)
@@ -56,11 +58,12 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '26px' // Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.section}px`
}, },
folderNote: { folderNote: {
color: tokens.colorNeutralForeground3, color: tokens.colorNeutralForeground3,
marginBottom: '8px' marginBottom: SPACE.tight
}, },
folderRow: { folderRow: {
display: 'flex', display: 'flex',
@@ -72,10 +75,10 @@ const useStyles = makeStyles({
border: `1px solid ${tokens.colorNeutralStroke2}` border: `1px solid ${tokens.colorNeutralStroke2}`
}, },
folderRowGap: { folderRowGap: {
marginTop: '8px' marginTop: SPACE.tight
}, },
folderIcon: { folderIcon: {
fontSize: '18px', fontSize: `${ICON.control}px`,
flexShrink: 0, flexShrink: 0,
color: tokens.colorCompoundBrandForeground1 color: tokens.colorCompoundBrandForeground1
}, },
@@ -106,9 +109,9 @@ const useStyles = makeStyles({
gap: '10px' gap: '10px'
}, },
tipIcon: { tipIcon: {
fontSize: '18px', fontSize: `${ICON.control}px`,
flexShrink: 0, flexShrink: 0,
marginTop: '2px', marginTop: SPACE.hairline,
color: tokens.colorCompoundBrandForeground1 color: tokens.colorCompoundBrandForeground1
} }
}) })
+24 -22
View File
@@ -34,15 +34,18 @@ import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
import { StatusChip } from './ui/StatusChip' import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_MD } from '../thumbSizes' import { THUMB_MD } from '../thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
gap: '14px', gap: '14px',
padding: '14px', // List-item card: card padding + list-item radius (UI3/UI7).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge), ...shorthands.borderRadius(RADIUS.surface),
border: `1px solid ${tokens.colorNeutralStroke2}` border: `1px solid ${tokens.colorNeutralStroke2}`
}, },
thumb: { thumb: {
@@ -52,7 +55,8 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusLarge) // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
}, },
body: { body: {
flexGrow: 1, flexGrow: 1,
@@ -66,20 +70,11 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
gap: '8px' gap: '8px'
}, },
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
progressRow: { progressRow: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
marginTop: '4px' marginTop: SPACE.xtight
}, },
progressBar: { progressBar: {
flexGrow: 1 flexGrow: 1
@@ -94,7 +89,7 @@ const useStyles = makeStyles({
actions: { actions: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: SPACE.xtight,
flexShrink: 0, flexShrink: 0,
// Icon-only row actions get a >=40px hit target for touch/pen (W16); the // Icon-only row actions get a >=40px hit target for touch/pen (W16); the
// glyph stays its normal size, the subtle button just carries more padding. // glyph stays its normal size, the subtle button just carries more padding.
@@ -116,6 +111,7 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
const text = useTextStyles()
// One shallow-compared selection instead of 10 separate subscriptions per row // One shallow-compared selection instead of 10 separate subscriptions per row
// (L163). The actions are stable, so this never re-renders from the store. // (L163). The actions are stable, so this never re-renders from the store.
const { const {
@@ -186,26 +182,29 @@ export const QueueItem = memo(function QueueItem({
<div className={styles.titleRow}> <div className={styles.titleRow}>
{item.status === 'completed' && ( {item.status === 'completed' && (
<CheckmarkCircleFilled <CheckmarkCircleFilled
fontSize={16} fontSize={ICON.inline}
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }} style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
/> />
)} )}
{item.status === 'error' && ( {item.status === 'error' && (
<ErrorCircleFilled <ErrorCircleFilled
fontSize={16} fontSize={ICON.inline}
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }} style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
/> />
)} )}
<Text className={styles.title}>{item.title}</Text> <Text className={text.title}>{item.title}</Text>
<StatusChip status={item.status} /> <StatusChip status={item.status} />
{item.incognito && ( {item.incognito && (
<Hint label="Private -- not saved to history" placement="top" align="start"> <Hint label="Private -- not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} /> <EyeOffRegular
fontSize={ICON.inline}
style={{ color: tokens.colorNeutralForeground3 }}
/>
</Hint> </Hint>
)} )}
</div> </div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1> <Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
{item.status === 'downloading' && ( {item.status === 'downloading' && (
<div className={styles.progressRow}> <div className={styles.progressRow}>
@@ -224,8 +223,8 @@ export const QueueItem = memo(function QueueItem({
) : ( ) : (
<> <>
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)} {item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
{speedLabel ? `${speedLabel}` : ''} {speedLabel ? `${META_SEP}${speedLabel}` : ''}
{etaLabel ? `${etaLabel} left` : ''} {etaLabel ? `${META_SEP}${etaLabel} left` : ''}
</> </>
)} )}
</Caption1> </Caption1>
@@ -247,7 +246,10 @@ export const QueueItem = memo(function QueueItem({
thickness="large" thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`} aria-label={`Download progress for ${item.title ?? item.url}`}
/> />
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1> <Caption1 className={styles.stats}>
Paused{META_SEP}
{pct(item.progress)}
</Caption1>
</div> </div>
)} )}
+15 -2
View File
@@ -1,4 +1,10 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import {
makeStyles,
mergeClasses,
tokens,
shorthands,
useFieldControlProps_unstable
} from '@fluentui/react-components'
// A native <select> styled to match Fluent inputs. We use this instead of // A native <select> styled to match Fluent inputs. We use this instead of
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited // Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
@@ -69,8 +75,16 @@ export function Select({
disabled disabled
}: SelectProps): React.JSX.Element { }: SelectProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
// Integrate with a wrapping Fluent <Field> (L133): pick up the id + label/hint
// association so the Field's visible label names the select natively (via
// `htmlFor`), instead of every call site repeating the name in an `aria-label`.
// Outside a Field this returns the props unchanged, so standalone selects keep
// their own `aria-label`. `supportsLabelFor` = native <label for>, not aria-labelledby.
const fieldProps = useFieldControlProps_unstable({}, { supportsLabelFor: true })
return ( return (
<select <select
{...fieldProps}
aria-label={ariaLabel}
className={mergeClasses( className={mergeClasses(
styles.select, styles.select,
size === 'large' && styles.large, size === 'large' && styles.large,
@@ -79,7 +93,6 @@ export function Select({
)} )}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
disabled={disabled} disabled={disabled}
> >
{options.map((o) => ( {options.map((o) => (
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Input, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components' import { Input, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { SearchRegular } from '@fluentui/react-icons' import { SearchRegular } from '@fluentui/react-icons'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { SPACE } from './ui/tokens'
import { DownloadsCard } from './settings/DownloadsCard' import { DownloadsCard } from './settings/DownloadsCard'
import { AppearanceCard } from './settings/AppearanceCard' import { AppearanceCard } from './settings/AppearanceCard'
import { PostProcessingCard } from './settings/PostProcessingCard' import { PostProcessingCard } from './settings/PostProcessingCard'
@@ -18,7 +19,7 @@ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '16px' gap: SPACE.section
} }
}) })
@@ -77,7 +78,7 @@ export function SettingsView(): React.JSX.Element {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
{noResults && ( {noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}> <Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
No settings match &quot;{search}&quot;. No settings match &quot;{search}&quot;.
</Caption1> </Caption1>
)} )}
+12 -10
View File
@@ -17,6 +17,8 @@ import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl' import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton' import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { MOTION, ICON } from './ui/tokens'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings' export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
@@ -30,7 +32,8 @@ const useStyles = makeStyles({
padding: '16px 12px', padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`, borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
transition: 'width 0.15s ease' // The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
transition: `width ${MOTION.duration} ${MOTION.curve}`
}, },
rootCollapsed: { rootCollapsed: {
width: '60px', width: '60px',
@@ -64,7 +67,8 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '20px' // Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
}, },
brandText: { brandText: {
display: 'flex', display: 'flex',
@@ -77,9 +81,6 @@ const useStyles = makeStyles({
lineHeight: tokens.lineHeightBase400, lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1 color: tokens.colorNeutralForeground1
}, },
caption: {
color: tokens.colorNeutralForeground3
},
nav: { nav: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -117,7 +118,7 @@ const useStyles = makeStyles({
} }
}, },
navIcon: { navIcon: {
fontSize: '18px', fontSize: `${ICON.control}px`,
flexShrink: 0, flexShrink: 0,
display: 'flex' display: 'flex'
}, },
@@ -169,6 +170,7 @@ export function Sidebar({
}: SidebarProps): React.JSX.Element { }: SidebarProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
const text = useTextStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto. // Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system'] const order: ThemeMode[] = ['light', 'dark', 'system']
@@ -178,11 +180,11 @@ export function Sidebar({
} }
const themeIcon = const themeIcon =
theme === 'system' ? ( theme === 'system' ? (
<DesktopRegular fontSize={18} /> <DesktopRegular fontSize={ICON.control} />
) : isDark ? ( ) : isDark ? (
<WeatherMoonRegular fontSize={18} /> <WeatherMoonRegular fontSize={ICON.control} />
) : ( ) : (
<WeatherSunnyRegular fontSize={18} /> <WeatherSunnyRegular fontSize={ICON.control} />
) )
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light' const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
@@ -209,7 +211,7 @@ export function Sidebar({
{/* Stable tagline so the caption never flips from text to a version {/* Stable tagline so the caption never flips from text to a version
string once it loads (L66); the version shows on hover and in string once it loads (L66); the version shows on hover and in
Settings → About. */} Settings → About. */}
<Caption1 className={styles.caption} title={version ? `Version ${version}` : undefined}> <Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
Video downloader Video downloader
</Caption1> </Caption1>
</div> </div>
+3 -2
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 { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line { interface Line {
@@ -29,7 +30,7 @@ const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, k
const MAX_LOG_LINES = 2000 const MAX_LOG_LINES = 2000
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: { gate: {
padding: '12px 14px', padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1, backgroundColor: tokens.colorStatusWarningBackground1,
@@ -180,7 +181,7 @@ export function TerminalView(): React.JSX.Element {
</div> </div>
<div className={styles.buttons}> <div className={styles.buttons}>
{running ? ( {running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}> <Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop Stop
</Button> </Button>
) : ( ) : (
@@ -4,6 +4,8 @@ import { type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads' import { type MediaKind } from '../../store/downloads'
import { Hint } from '../Hint' import { Hint } from '../Hint'
import { IconButton } from '../ui/IconButton' import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens'
import { useDownloadBarStyles } from './styles' import { useDownloadBarStyles } from './styles'
interface PlaylistPanelProps { interface PlaylistPanelProps {
@@ -28,15 +30,16 @@ export function PlaylistPanel({
onToggleItemKind onToggleItemKind
}: PlaylistPanelProps): React.JSX.Element { }: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles() const styles = useDownloadBarStyles()
const text = useTextStyles()
return ( return (
<div className={styles.plPanel}> <div className={styles.plPanel}>
<div className={styles.plHeader}> <div className={styles.plHeader}>
<AppsListRegular /> <AppsListRegular />
<Caption1 className={styles.plHeaderText}> <Caption1 className={styles.plHeaderText}>
{playlist.title} {playlist.title}
{playlist.uploader ? `${playlist.uploader}` : ''} {playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
</Caption1> </Caption1>
<Caption1 className={styles.previewMeta}> <Caption1 className={text.muted}>
{selected.size} of {playlist.count} selected {selected.size} of {playlist.count} selected
</Caption1> </Caption1>
<Button size="small" appearance="subtle" onClick={onToggleAll}> <Button size="small" appearance="subtle" onClick={onToggleAll}>
@@ -62,8 +65,8 @@ export function PlaylistPanel({
{e.index}. {e.title} {e.index}. {e.title}
</span> </span>
{(e.durationLabel || e.uploader) && ( {(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}> <Caption1 className={text.muted}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} {[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
</Caption1> </Caption1>
)} )}
</span> </span>
@@ -1,15 +1,18 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../thumbSizes' import { THUMB_LG } from '../../thumbSizes'
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
export const useDownloadBarStyles = makeStyles({ export const useDownloadBarStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px', gap: SPACE.cozy,
padding: '16px', // Page-level card: card padding + card radius, and the app's one "raised"
// elevation — the download bar floats above the queue (UI3/UI7/L100).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge), ...shorthands.borderRadius(RADIUS.card),
boxShadow: tokens.shadow4 boxShadow: ELEVATION.raised
}, },
// Highlight while a link / .url file is dragged over the card. // Highlight while a link / .url file is dragged over the card.
rootDragging: { rootDragging: {
@@ -73,7 +76,8 @@ export const useDownloadBarStyles = makeStyles({
justifyContent: 'center', justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2, backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1, color: tokens.colorBrandForeground1,
...shorthands.borderRadius(tokens.borderRadiusMedium) // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
}, },
previewBody: { previewBody: {
flexGrow: 1, flexGrow: 1,
@@ -83,15 +87,6 @@ export const useDownloadBarStyles = makeStyles({
justifyContent: 'center', justifyContent: 'center',
gap: '2px' gap: '2px'
}, },
previewTitle: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
previewMeta: {
color: tokens.colorNeutralForeground3
},
statusRow: { statusRow: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -170,9 +165,6 @@ export const useDownloadBarStyles = makeStyles({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}, },
plItemMeta: {
color: tokens.colorNeutralForeground3
},
// --- duplicate warning --- // --- duplicate warning ---
dupRow: { dupRow: {
display: 'flex', display: 'flex',
@@ -108,7 +108,6 @@ export function AboutCard(): React.JSX.Element {
<Switch <Switch
checked={autoUpdateYtdlp} checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })} onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/> />
</Field> </Field>
@@ -142,10 +141,13 @@ export function AboutCard(): React.JSX.Element {
</Caption1> </Caption1>
)} )}
<div className={styles.folderRow}> <div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}> <Button
icon={checking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={checkVersion}
disabled={checking}
>
Check yt-dlp version Check yt-dlp version
</Button> </Button>
{checking && <Spinner size="tiny" />}
</div> </div>
<Field <Field
@@ -153,7 +155,6 @@ export function AboutCard(): React.JSX.Element {
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest." hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
> >
<Select <Select
aria-label="Update channel"
value={ytdlpChannel} value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS} options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })} onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
@@ -29,7 +29,6 @@ export function AppearanceCard(): React.JSX.Element {
<Field label="Theme"> <Field label="Theme">
<Select <Select
aria-label="Theme"
value={theme} value={theme}
options={THEME_MODE_OPTIONS} options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })} onChange={(v) => update({ theme: v as ThemeMode })}
@@ -77,7 +77,6 @@ export function CookiesCard(): React.JSX.Element {
<Field label="Cookie source"> <Field label="Cookie source">
<Select <Select
aria-label="Cookie source"
value={cookieSource} value={cookieSource}
options={COOKIE_SOURCE_OPTIONS} options={COOKIE_SOURCE_OPTIONS}
onChange={(v) => update({ cookieSource: v as CookieSource })} onChange={(v) => update({ cookieSource: v as CookieSource })}
@@ -90,7 +89,6 @@ export function CookiesCard(): React.JSX.Element {
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them." hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
> >
<Select <Select
aria-label="Browser"
value={cookiesBrowser} value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS} options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })} onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
@@ -32,14 +32,12 @@ export function CustomCommandsCard(): React.JSX.Element {
<Switch <Switch
checked={customCommandEnabled} checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })} onChange={(_, d) => update({ customCommandEnabled: d.checked })}
label={customCommandEnabled ? 'On' : 'Off'}
/> />
</Field> </Field>
{customCommandEnabled && ( {customCommandEnabled && (
<Field label="Default template"> <Field label="Default template">
<Select <Select
aria-label="Default template"
value={defaultTemplateId ?? 'none'} value={defaultTemplateId ?? 'none'}
options={[ options={[
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
@@ -105,12 +105,7 @@ export function DownloadsCard(): React.JSX.Element {
</Field> </Field>
<Field label="Default format"> <Field label="Default format">
<Select <Select value={formatValue} options={FORMAT_OPTIONS} onChange={onFormatSelect} />
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
</Field> </Field>
<Field <Field
@@ -139,7 +134,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch <Switch
checked={clipboardWatch} checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })} onChange={(_, d) => update({ clipboardWatch: d.checked })}
label={clipboardWatch ? 'On' : 'Off'}
/> />
</Field> </Field>
@@ -150,7 +144,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch <Switch
checked={notifyOnComplete} checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })} onChange={(_, d) => update({ notifyOnComplete: d.checked })}
label={notifyOnComplete ? 'On' : 'Off'}
/> />
</Field> </Field>
@@ -161,7 +154,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch <Switch
checked={minimizeToTray} checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })} onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/> />
</Field> </Field>
@@ -172,7 +164,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch <Switch
checked={launchAtStartup} checked={launchAtStartup}
onChange={(_, d) => update({ launchAtStartup: d.checked })} onChange={(_, d) => update({ launchAtStartup: d.checked })}
label={launchAtStartup ? 'On' : 'Off'}
/> />
</Field> </Field>
</Card> </Card>
@@ -36,7 +36,6 @@ export function FilenamesCard(): React.JSX.Element {
<Switch <Switch
checked={restrictFilenames} checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })} onChange={(_, d) => update({ restrictFilenames: d.checked })}
label={restrictFilenames ? 'On' : 'Off'}
/> />
</Field> </Field>
@@ -47,7 +46,6 @@ export function FilenamesCard(): React.JSX.Element {
<Switch <Switch
checked={downloadArchive} checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })} onChange={(_, d) => update({ downloadArchive: d.checked })}
label={downloadArchive ? 'On' : 'Off'}
/> />
</Field> </Field>
</Card> </Card>
@@ -50,11 +50,7 @@ export function NetworkCard(): React.JSX.Element {
label="Use aria2c downloader" label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available." hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
> >
<Switch <Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
</Field> </Field>
<Field <Field
@@ -1,4 +1,5 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { SPACE, RADIUS, ICON } from '../ui/tokens'
// Shared styles for the Settings cards. Each card renders a single <Card> so it // Shared styles for the Settings cards. Each card renders a single <Card> so it
// stays one direct child of SettingsView's root -- the search filter toggles // stays one direct child of SettingsView's root -- the search filter toggles
@@ -7,9 +8,10 @@ export const useSettingsStyles = makeStyles({
card: { card: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '16px', // Page-level card: card padding + card radius (UI3/UI7).
padding: '20px', gap: SPACE.section,
...shorthands.borderRadius(tokens.borderRadiusXLarge) padding: SPACE.section,
...shorthands.borderRadius(RADIUS.card)
}, },
notesBox: { notesBox: {
maxHeight: '180px', maxHeight: '180px',
@@ -28,7 +30,8 @@ export const useSettingsStyles = makeStyles({
gap: '10px' gap: '10px'
}, },
sectionIcon: { sectionIcon: {
fontSize: '20px', // Section-header glyph, next to each card's <Subtitle2> title (UI12 section tier).
fontSize: `${ICON.section}px`,
color: tokens.colorCompoundBrandForeground1 color: tokens.colorCompoundBrandForeground1
}, },
folderRow: { folderRow: {
@@ -1,6 +1,7 @@
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing' import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
const useStyles = makeStyles({ const useStyles = makeStyles({
btn: { btn: {
@@ -23,8 +24,8 @@ const useStyles = makeStyles({
backgroundColor: 'transparent' backgroundColor: 'transparent'
} }
}, },
md: { width: '32px', height: '32px', fontSize: '18px' }, md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
sm: { width: '28px', height: '28px', fontSize: '16px' } sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
}) })
type IconButtonProps = { type IconButtonProps = {
@@ -1,6 +1,7 @@
import { useRef } from 'react' import { useRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing' import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
export interface SegmentOption<T extends string> { export interface SegmentOption<T extends string> {
value: T value: T
@@ -63,7 +64,7 @@ const useStyles = makeStyles({
}, },
icon: { icon: {
display: 'flex', display: 'flex',
fontSize: '16px', fontSize: `${ICON.inline}px`,
flexShrink: 0 flexShrink: 0
} }
}) })
+39
View File
@@ -0,0 +1,39 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* Shared text-role styles + the app's type-role convention (Batch 7).
*
* The renderer had drifted on typography: the one muted color was re-declared under
* eight class names (L110), row titles were rendered as three different components
* (L114), and `Caption1`/`Body1`/`Text` were applied by feel (L123/L124). The
* convention below makes the roles explicit; this module supplies the two styles
* that back it.
*
* Type roles — reach for the Fluent type component, add `muted` for secondary color:
* - `<Subtitle2>` — screen titles and settings-section headers (via `ScreenHeader`
* and each settings card's header).
* - `<Text>` — the one row/item title component (add `title` for the semibold,
* single-line-with-ellipsis treatment). Previously split across `<Text>`, a bare
* `<span>`, and `<Caption1>` (L114).
* - `<Body1>` — body copy: empty-state messages, onboarding prose, gated notices.
* Not for titles or metadata (L124).
* - `<Caption1>` — small secondary text: metadata, hints, counts, timestamps. One
* role doing several closely-related jobs, all "small secondary text" (L123).
*/
export const useTextStyles = makeStyles({
/**
* The single muted / secondary-text color (L110) — was re-declared as
* hint/sub/meta/srcSub/stats/count/emptyHint/previewMeta. Merge onto a `<Caption1>`
* or `<Text>`, or use alone where the element sets no other style.
*/
muted: {
color: tokens.colorNeutralForeground3
},
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
+139
View File
@@ -0,0 +1,139 @@
import { tokens } from '@fluentui/react-components'
/**
* AeroFetch design tokens — the app's own semantic scales, layered on Fluent's.
*
* Batch 6 of the audit (UI2UI7, UI12, UI26, L100, L101, L121, L125, L160, L164)
* found the same visual decisions expressed as ad-hoc literals scattered across
* components: four section gaps, seven card paddings, two floating shadows, two
* hardcoded `zIndex: 1000`s, a raw `rgba()` scrim, nine inline icon sizes, and
* three metadata separators. This module is the single home for those values so
* every screen reads as one product and a change happens in one place.
*
* Where Fluent already defines a scale (spacing, radius, shadow, motion, the
* overlay scrim) these alias Fluent's tokens, so the app stays pixel-aligned with
* Fluent's own controls. Where Fluent has no token (z-index, icon px, the
* metadata separator) the value lives here.
*
* Griffel evaluates `makeStyles` at runtime in this project (no build plugin — see
* `thumbSizes` already imported into `downloadBar/styles.ts`), so these imported
* constants work directly inside `makeStyles` blocks and in inline `style={{}}`.
*/
/**
* Spacing ramp (px). Gaps, padding, and margins all draw from this one scale; the
* comments record the role each step plays so call sites stay self-documenting.
* Values match Fluent's spacing ramp (XXS 2 · XS 4 · S 8 · MNudge 10 · M 12 · L 16
* · XL 20 · XXL 24 · XXXL 32).
*/
export const SPACE = {
/** 2px — optical hairline nudge (e.g. aligning an icon to a text baseline) (L160). */
hairline: '2px',
/** 4px — icon-button clusters (UI5); tight margins between stacked lines (L160). */
xtight: '4px',
/** 8px — labelled-control rows (UI5); the default inline gap. */
tight: '8px',
/** 10px — control inner padding; small tiles. */
snug: '10px',
/** 12px — list-row padding; the gap between rows inside a card. */
cozy: '12px',
/** 16px — the one gap between screen sections (UI2) and the card padding tier (UI3). */
section: '16px',
/** 20px — a larger block gap. */
roomy: '20px',
/** 24px — page content padding (UI4); the hero padding tier's floor. */
page: '24px',
/** 32px — the onboarding / welcome hero card padding (UI3 hero tier). */
hero: '32px'
} as const
/**
* Corner-radius tiers by surface (UI6/UI7). Aliases Fluent's radius tokens, which
* `friendlyRadii` (theme.ts) tunes to Medium 10 · Large 12 · XLarge 16.
*
* - `control` (10) — inputs, thumbnails, small tiles, popover rows.
* - `surface` (12) — list-item cards and inline panels.
* - `card` (16) — page-level cards (the download bar, settings cards, onboarding).
*/
export const RADIUS = {
control: tokens.borderRadiusMedium,
surface: tokens.borderRadiusLarge,
card: tokens.borderRadiusXLarge
} as const
/**
* Elevation scale (L100). Cards are flat by default (border only — the app's
* resting surface); only genuinely floating surfaces cast a shadow.
*
* - `flat` — the default card: no shadow, a 1px neutral-stroke border.
* - `raised` (shadow4) — the download bar, which floats above the queue.
* - `overlay` (shadow28) — the command palette and any future popover.
*/
export const ELEVATION = {
flat: 'none',
raised: tokens.shadow4,
overlay: tokens.shadow28
} as const
/**
* Z-index scale (L101). Replaces the two hardcoded `zIndex: 1000`s so stacking is
* ordered on purpose: tooltips sit above full-screen overlays.
*/
export const Z = {
/** full-screen scrims and the command palette. */
overlay: 1000,
/** hint bubbles — above overlays so a tooltip is never clipped by one. */
tooltip: 1100
} as const
/**
* Theme-aware scrim for full-screen overlays (L121). Fluent's overlay token
* replaces the renderer's one raw `rgba(0,0,0,0.32)` — it's the semantic dialog
* scrim and darkens a touch more, so the palette separates cleanly from a dark UI.
*/
export const SCRIM = tokens.colorBackgroundOverlay
/**
* Icon-size scale in px (UI12/L125): one small set instead of the nine scattered
* `fontSize` literals. Applied as a numeric `fontSize={ICON.x}` on standalone
* glyphs, or `` `${ICON.x}px` `` on the rare makeStyles container.
*
* - `inline` (16) — glyphs sitting inline with text (row actions, small badges).
* - `control` (20) — glyphs inside buttons, nav items, toggles, and small leading
* accent icons (e.g. the onboarding folder/tip rows).
* - `section` (24) — section/card-header glyphs (e.g. each settings card's title).
* - `hero` (40) — the focal icon in an empty state.
*
* Snapped to the nearest tier rather than a literal px even when centered in a
* fixed tile (UI11 — no literal px font sizes): the brand/source tiles
* (Sidebar/Onboarding `mark`, Library `srcIcon`, History `emptyBadge`) take
* `control` or `section`. Genuinely off this scale: the tiny `watchBadge` glyph
* (matched to its badge-caption text size) and the thumbnail placeholder icons
* (which track `thumbSizes`).
*/
export const ICON = {
inline: 16,
control: 20,
section: 24,
hero: 40
} as const
/**
* One motion policy (UI26). The app keeps a single, subtle transition timing
* (matching the sidebar's former `0.15s ease`) and gates *all* transitions and
* animations behind `prefers-reduced-motion` globally in base.css — so motion is
* consistent where it exists and fully off for users who ask for less.
*/
export const MOTION = {
duration: tokens.durationFast,
curve: tokens.curveEasyEase
} as const
/**
* The single metadata separator (L164). Joins independent fields on a meta line
* (e.g. `Channel • 12:04`, `1.2 GB/s • 3s left`). Previously three styles were in
* use (`' • '`, `' · '`, `' • '`). Note this is distinct from the *intra-value*
* separator inside a compound quality label ("720p · mp4 · 184 MB"), which is a
* data format parsed elsewhere and intentionally left alone.
*/
export const META_SEP = ' • '