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
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
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,
@@ -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
`<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.*
- [ ] **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.
- [ ] **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
(`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
@@ -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>`).
- [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear;
only the manual "Check" button shows a spinner.
- [ ] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as
`hint`/`sub`/`meta`/`srcSub`/`stats`/`count`/`emptyHint`/`previewMeta` across components.
- [ ] **L111 — Two helper-text mechanisms** — Fluent `Field` `hint` vs manual `<Caption1 className={hint}>`; SettingsView uses both.
- [ ] **L112 — `Switch` labeling inconsistent.** Settings/DownloadOptionsForm show "On"/"Off" text;
Library switches use `aria-label` only (no visible state text).
- [x] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as
`hint`/`sub`/`meta`/`srcSub`/`stats`/`count`/`emptyHint`/`previewMeta` across components. *Fixed: one
`useTextStyles().muted` in [ui/text.ts](src/renderer/src/components/ui/text.ts). Adopted for the pure-color
aliases in the list views (QueueItem `meta`, HistoryView `meta`, LibraryView `sub`/`srcSub`/`rowMeta`,
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
`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
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
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).
@@ -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).
- [ ] **L120 — Fluent `Card` vs hand-styled `div` cards.** Only Settings/Onboarding use `<Card>`; every
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
the renderer), identical in light/dark — low contrast over a dark UI.
- [x] **L121 — Raw scrim literal.** CommandPalette backdrop is `rgba(0,0,0,0.32)` (the only raw rgba in
the renderer), identical in light/dark — low contrast over a dark UI. *Fixed: `SCRIM` in tokens.ts aliases
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.
- [ ] **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.
- [ ] **L125 — Icon sizing has two syntaxes** — px strings (`fontSize: '20px'`) on icon *containers* vs numeric `fontSize={28}` on inline icons.
- [x] **L123 — `Caption1` is overloaded** for hints, metadata, counts, and timestamps — one size doing four jobs.
*Resolved: the [ui/text.ts](src/renderer/src/components/ui/text.ts) header defines `Caption1`'s role as "small
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.
- [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
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",
"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
@@ -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
`onUrlChange`. Also fixes a latent inconsistency — `addPlaylist` previously left the trim/schedule/advanced
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).
- [ ] **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.
- [ ] **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.
*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:*
@@ -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.
- [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.
- [ ] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView
8px) instead of style classes (extends L5).
- [x] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView
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
`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.
@@ -903,10 +1009,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*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
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
`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).
@@ -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
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.
- [ ] **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.
*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) /
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.
- [ ] **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.
- [ ] **UI5 — Action-cluster gaps split 4px vs 8px.** Icon-button rows use 4px (QueueItem/History/
*Implemented (SPACE scale defined; the card tier unified to 16 — QueueItem 14→16, Settings cards 20→16;
DownloadBar already 16; Onboarding → `hero`) but **left unchecked for a watched visual pass**: 20→16 tightens
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.)*
*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
- [ ] **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.)*
- [ ] **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.
*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)
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.
- [x] **UI10 — Only Library has a screen subtitle/description.** Others jump straight to content.
**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'`.
**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
- [ ] **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).
- [ ] **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).
*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
@@ -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).
- [ ] **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).
- [ ] **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.
*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
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
@@ -1035,9 +1176,13 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
### 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
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
+3 -1
View File
@@ -10,6 +10,7 @@ import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
@@ -40,7 +41,8 @@ const useStyles = makeStyles({
flexGrow: 1,
minWidth: 0,
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 {
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 { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
export interface PaletteAction {
id: string
@@ -16,21 +17,21 @@ const useStyles = makeStyles({
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
zIndex: Z.overlay,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
backgroundColor: SCRIM
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
...shorthands.borderRadius(RADIUS.card),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
boxShadow: ELEVATION.overlay,
overflow: 'hidden'
},
input: {
+6 -3
View File
@@ -30,6 +30,8 @@ import { QUALITY_OPTIONS } from '../qualityOptions'
import { Select } from './Select'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { useTextStyles } from './ui/text'
import { META_SEP } from './ui/tokens'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { useDownloadBarStyles } from './downloadBar/styles'
import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar'
@@ -37,6 +39,7 @@ import { PlaylistPanel } from './downloadBar/PlaylistPanel'
export function DownloadBar(): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
const bar = useDownloadBar()
const {
url,
@@ -201,9 +204,9 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
<div className={styles.previewBody}>
<Text className={styles.previewTitle}>{info.title}</Text>
<Caption1 className={styles.previewMeta}>
{[info.channel, info.durationLabel].filter(Boolean).join(' • ')}
<Text className={text.title}>{info.title}</Text>
<Caption1 className={text.muted}>
{[info.channel, info.durationLabel].filter(Boolean).join(META_SEP)}
</Caption1>
</div>
</div>
@@ -112,7 +112,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
{kind !== 'video' && (
<Field label="Audio format" hint="For audio-only downloads.">
<Select
aria-label="Audio format"
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
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."
>
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((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."
>
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
@@ -165,7 +162,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/>
</Field>
{value.embedSubtitles && (
@@ -194,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch
checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/>
</Field>
{value.sponsorBlock && (
<div className={styles.subGroup}>
<Field label="Action">
<Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode}
options={[
{ value: 'remove', label: 'Remove segments (cut from file)' },
@@ -240,7 +234,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch
checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field
@@ -250,14 +243,12 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/>
</Field>
<Field
@@ -286,7 +277,6 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
<Switch
checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/>
</Field>
{value.embedThumbnail && (
@@ -16,6 +16,7 @@ import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
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
// measurement/key cache on every render (PERF5).
@@ -30,7 +31,8 @@ const useStyles = makeStyles({
// growing to thousands of rows.
display: 'flex',
flexDirection: 'column',
gap: '20px',
// One section gap on every screen (UI2).
gap: SPACE.section,
height: '100%'
},
queueHeader: {
@@ -114,8 +116,8 @@ export function DownloadsView(): React.JSX.Element {
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''}
{summary.etaLabel ? `~${summary.etaLabel} left` : ''}
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
@@ -143,7 +145,7 @@ export function DownloadsView(): React.JSX.Element {
{items.length === 0 ? (
<div className={styles.empty}>
<ArrowDownloadRegular fontSize={40} />
<ArrowDownloadRegular fontSize={ICON.hero} />
<Body1>Nothing queued yet. Paste a URL above, then click Download.</Body1>
</div>
) : (
+11 -5
View File
@@ -1,4 +1,5 @@
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
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
},
bubble: {
position: 'absolute',
zIndex: 1000,
zIndex: Z.tooltip,
visibility: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap',
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
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
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode
}
} & (
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
| { placement: 'left' | 'right'; align?: never }
)
export function Hint({
label,
+9 -14
View File
@@ -33,13 +33,15 @@ import { Hint } from './Hint'
import { Select } from './Select'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens'
import { THUMB_SM } from '../thumbSizes'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
gap: SPACE.section
},
header: {
display: 'flex',
@@ -92,15 +94,6 @@ const useStyles = makeStyles({
display: 'flex',
flexDirection: 'column'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
gap: '4px',
@@ -126,7 +119,8 @@ const useStyles = makeStyles({
alignItems: 'center',
justifyContent: 'center',
borderRadius: tokens.borderRadiusCircular,
fontSize: '26px'
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
fontSize: `${ICON.section}px`
},
emptyHint: {
color: tokens.colorNeutralForeground3
@@ -148,6 +142,7 @@ export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
@@ -406,11 +401,11 @@ export function HistoryView(): React.JSX.Element {
iconSize={22}
/>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
<Text className={text.title}>{h.title}</Text>
<Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
.join(META_SEP)}
</Caption1>
</div>
<div className={styles.actions}>
+31 -24
View File
@@ -43,6 +43,8 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_XS } from '../thumbSizes'
/** 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({
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
sub: { color: tokens.colorNeutralForeground3 },
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 },
suggestion: {
@@ -157,7 +158,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: '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 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
@@ -170,7 +172,6 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
srcSub: { color: tokens.colorNeutralForeground3 },
detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px',
@@ -214,7 +215,8 @@ const useStyles = makeStyles({
rowThumb: {
width: `${THUMB_XS.w}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 },
rowTitle: {
@@ -222,8 +224,7 @@ const useStyles = makeStyles({
overflow: 'hidden',
textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1
},
rowMeta: { color: tokens.colorNeutralForeground3 }
}
})
/** 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 screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
@@ -477,8 +479,8 @@ export function LibraryView(): React.JSX.Element {
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Text className={styles.groupTitle}>{row.title}</Text>
<Caption1 className={text.muted}>{row.items.length}</Caption1>
</button>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'}
@@ -512,10 +514,10 @@ export function LibraryView(): React.JSX.Element {
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
<Text className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</Text>
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
</div>
<StatusChip status={status} />
</div>
@@ -579,14 +581,14 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.toolbar}>
<Button
size="small"
appearance="secondary"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
disabled={syncing || watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
@@ -619,7 +621,7 @@ export function LibraryView(): React.JSX.Element {
{sources.length === 0 && !indexing.active ? (
<div className={styles.empty}>
<LibraryRegular fontSize={40} />
<LibraryRegular fontSize={ICON.hero} />
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
</div>
) : (
@@ -640,9 +642,9 @@ export function LibraryView(): React.JSX.Element {
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
<Caption1 className={text.muted}>
{items.length} videos{META_SEP}
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1>
<div className={styles.actionSpacer} />
<SegmentedControl<MediaKind>
@@ -703,7 +705,7 @@ export function LibraryView(): React.JSX.Element {
</Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1>
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
@@ -735,7 +737,7 @@ export function LibraryView(): React.JSX.Element {
)}
</div>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
@@ -781,6 +783,7 @@ function SourceCard({
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return (
<div className={styles.card}>
<button
@@ -796,16 +799,20 @@ function SourceCard({
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={styles.srcSub}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
+12 -9
View File
@@ -21,6 +21,7 @@ import {
VideoClipRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
const useStyles = makeStyles({
root: {
@@ -30,13 +31,14 @@ const useStyles = makeStyles({
alignItems: 'center',
justifyContent: 'center',
overflowY: 'auto',
padding: '24px'
padding: SPACE.page
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
padding: '32px',
gap: SPACE.roomy,
// The welcome hero card (UI3 hero tier).
padding: SPACE.hero,
maxWidth: '460px',
width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
@@ -56,11 +58,12 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '26px'
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.section}px`
},
folderNote: {
color: tokens.colorNeutralForeground3,
marginBottom: '8px'
marginBottom: SPACE.tight
},
folderRow: {
display: 'flex',
@@ -72,10 +75,10 @@ const useStyles = makeStyles({
border: `1px solid ${tokens.colorNeutralStroke2}`
},
folderRowGap: {
marginTop: '8px'
marginTop: SPACE.tight
},
folderIcon: {
fontSize: '18px',
fontSize: `${ICON.control}px`,
flexShrink: 0,
color: tokens.colorCompoundBrandForeground1
},
@@ -106,9 +109,9 @@ const useStyles = makeStyles({
gap: '10px'
},
tipIcon: {
fontSize: '18px',
fontSize: `${ICON.control}px`,
flexShrink: 0,
marginTop: '2px',
marginTop: SPACE.hairline,
color: tokens.colorCompoundBrandForeground1
}
})
+24 -22
View File
@@ -34,15 +34,18 @@ import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_MD } from '../thumbSizes'
const useStyles = makeStyles({
root: {
display: 'flex',
gap: '14px',
padding: '14px',
// List-item card: card padding + list-item radius (UI3/UI7).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
...shorthands.borderRadius(RADIUS.surface),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
@@ -52,7 +55,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusLarge)
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
body: {
flexGrow: 1,
@@ -66,20 +70,11 @@ const useStyles = makeStyles({
alignItems: 'center',
gap: '8px'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
progressRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '4px'
marginTop: SPACE.xtight
},
progressBar: {
flexGrow: 1
@@ -94,7 +89,7 @@ const useStyles = makeStyles({
actions: {
display: 'flex',
alignItems: 'center',
gap: '4px',
gap: SPACE.xtight,
flexShrink: 0,
// 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.
@@ -116,6 +111,7 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const text = useTextStyles()
// One shallow-compared selection instead of 10 separate subscriptions per row
// (L163). The actions are stable, so this never re-renders from the store.
const {
@@ -186,26 +182,29 @@ export const QueueItem = memo(function QueueItem({
<div className={styles.titleRow}>
{item.status === 'completed' && (
<CheckmarkCircleFilled
fontSize={16}
fontSize={ICON.inline}
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
/>
)}
{item.status === 'error' && (
<ErrorCircleFilled
fontSize={16}
fontSize={ICON.inline}
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
/>
)}
<Text className={styles.title}>{item.title}</Text>
<Text className={text.title}>{item.title}</Text>
<StatusChip status={item.status} />
{item.incognito && (
<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>
)}
</div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
<Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
{item.status === 'downloading' && (
<div className={styles.progressRow}>
@@ -224,8 +223,8 @@ export const QueueItem = memo(function QueueItem({
) : (
<>
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
{speedLabel ? `${speedLabel}` : ''}
{etaLabel ? `${etaLabel} left` : ''}
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
</>
)}
</Caption1>
@@ -247,7 +246,10 @@ export const QueueItem = memo(function QueueItem({
thickness="large"
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>
)}
+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
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
@@ -69,8 +75,16 @@ export function Select({
disabled
}: SelectProps): React.JSX.Element {
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 (
<select
{...fieldProps}
aria-label={ariaLabel}
className={mergeClasses(
styles.select,
size === 'large' && styles.large,
@@ -79,7 +93,6 @@ export function Select({
)}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
disabled={disabled}
>
{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 { SearchRegular } from '@fluentui/react-icons'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { SPACE } from './ui/tokens'
import { DownloadsCard } from './settings/DownloadsCard'
import { AppearanceCard } from './settings/AppearanceCard'
import { PostProcessingCard } from './settings/PostProcessingCard'
@@ -18,7 +19,7 @@ const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px'
gap: SPACE.section
}
})
@@ -77,7 +78,7 @@ export function SettingsView(): React.JSX.Element {
style={{ width: '100%' }}
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
No settings match &quot;{search}&quot;.
</Caption1>
)}
+12 -10
View File
@@ -17,6 +17,8 @@ import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { MOTION, ICON } from './ui/tokens'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
@@ -30,7 +32,8 @@ const useStyles = makeStyles({
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
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: {
width: '60px',
@@ -64,7 +67,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
},
brandText: {
display: 'flex',
@@ -77,9 +81,6 @@ const useStyles = makeStyles({
lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1
},
caption: {
color: tokens.colorNeutralForeground3
},
nav: {
display: 'flex',
flexDirection: 'column',
@@ -117,7 +118,7 @@ const useStyles = makeStyles({
}
},
navIcon: {
fontSize: '18px',
fontSize: `${ICON.control}px`,
flexShrink: 0,
display: 'flex'
},
@@ -169,6 +170,7 @@ export function Sidebar({
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const text = useTextStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
@@ -178,11 +180,11 @@ export function Sidebar({
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={18} />
<DesktopRegular fontSize={ICON.control} />
) : isDark ? (
<WeatherMoonRegular fontSize={18} />
<WeatherMoonRegular fontSize={ICON.control} />
) : (
<WeatherSunnyRegular fontSize={18} />
<WeatherSunnyRegular fontSize={ICON.control} />
)
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
string once it loads (L66); the version shows on hover and in
Settings About. */}
<Caption1 className={styles.caption} title={version ? `Version ${version}` : undefined}>
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
Video downloader
</Caption1>
</div>
+3 -2
View File
@@ -13,6 +13,7 @@ import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icon
import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
@@ -29,7 +30,7 @@ const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, k
const MAX_LOG_LINES = 2000
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: {
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
@@ -180,7 +181,7 @@ export function TerminalView(): React.JSX.Element {
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
@@ -4,6 +4,8 @@ import { type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads'
import { Hint } from '../Hint'
import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens'
import { useDownloadBarStyles } from './styles'
interface PlaylistPanelProps {
@@ -28,15 +30,16 @@ export function PlaylistPanel({
onToggleItemKind
}: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
return (
<div className={styles.plPanel}>
<div className={styles.plHeader}>
<AppsListRegular />
<Caption1 className={styles.plHeaderText}>
{playlist.title}
{playlist.uploader ? `${playlist.uploader}` : ''}
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
</Caption1>
<Caption1 className={styles.previewMeta}>
<Caption1 className={text.muted}>
{selected.size} of {playlist.count} selected
</Caption1>
<Button size="small" appearance="subtle" onClick={onToggleAll}>
@@ -62,8 +65,8 @@ export function PlaylistPanel({
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
<Caption1 className={text.muted}>
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
</Caption1>
)}
</span>
@@ -1,15 +1,18 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../thumbSizes'
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
export const useDownloadBarStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
gap: SPACE.cozy,
// 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,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
...shorthands.borderRadius(RADIUS.card),
boxShadow: ELEVATION.raised
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
@@ -73,7 +76,8 @@ export const useDownloadBarStyles = makeStyles({
justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1,
...shorthands.borderRadius(tokens.borderRadiusMedium)
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
previewBody: {
flexGrow: 1,
@@ -83,15 +87,6 @@ export const useDownloadBarStyles = makeStyles({
justifyContent: 'center',
gap: '2px'
},
previewTitle: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
previewMeta: {
color: tokens.colorNeutralForeground3
},
statusRow: {
display: 'flex',
alignItems: 'center',
@@ -170,9 +165,6 @@ export const useDownloadBarStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
// --- duplicate warning ---
dupRow: {
display: 'flex',
@@ -108,7 +108,6 @@ export function AboutCard(): React.JSX.Element {
<Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
@@ -142,10 +141,13 @@ export function AboutCard(): React.JSX.Element {
</Caption1>
)}
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
<Button
icon={checking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={checkVersion}
disabled={checking}
>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<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."
>
<Select
aria-label="Update channel"
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
@@ -29,7 +29,6 @@ export function AppearanceCard(): React.JSX.Element {
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
@@ -77,7 +77,6 @@ export function CookiesCard(): React.JSX.Element {
<Field label="Cookie source">
<Select
aria-label="Cookie source"
value={cookieSource}
options={COOKIE_SOURCE_OPTIONS}
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."
>
<Select
aria-label="Browser"
value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
@@ -32,14 +32,12 @@ export function CustomCommandsCard(): React.JSX.Element {
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
label={customCommandEnabled ? 'On' : 'Off'}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
aria-label="Default template"
value={defaultTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
@@ -105,12 +105,7 @@ export function DownloadsCard(): React.JSX.Element {
</Field>
<Field label="Default format">
<Select
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
<Select value={formatValue} options={FORMAT_OPTIONS} onChange={onFormatSelect} />
</Field>
<Field
@@ -139,7 +134,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
label={clipboardWatch ? 'On' : 'Off'}
/>
</Field>
@@ -150,7 +144,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch
checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
@@ -161,7 +154,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
@@ -172,7 +164,6 @@ export function DownloadsCard(): React.JSX.Element {
<Switch
checked={launchAtStartup}
onChange={(_, d) => update({ launchAtStartup: d.checked })}
label={launchAtStartup ? 'On' : 'Off'}
/>
</Field>
</Card>
@@ -36,7 +36,6 @@ export function FilenamesCard(): React.JSX.Element {
<Switch
checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })}
label={restrictFilenames ? 'On' : 'Off'}
/>
</Field>
@@ -47,7 +46,6 @@ export function FilenamesCard(): React.JSX.Element {
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
label={downloadArchive ? 'On' : 'Off'}
/>
</Field>
</Card>
@@ -50,11 +50,7 @@ export function NetworkCard(): React.JSX.Element {
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."
>
<Switch
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
</Field>
<Field
@@ -1,4 +1,5 @@
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
// stays one direct child of SettingsView's root -- the search filter toggles
@@ -7,9 +8,10 @@ export const useSettingsStyles = makeStyles({
card: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
// Page-level card: card padding + card radius (UI3/UI7).
gap: SPACE.section,
padding: SPACE.section,
...shorthands.borderRadius(RADIUS.card)
},
notesBox: {
maxHeight: '180px',
@@ -28,7 +30,8 @@ export const useSettingsStyles = makeStyles({
gap: '10px'
},
sectionIcon: {
fontSize: '20px',
// Section-header glyph, next to each card's <Subtitle2> title (UI12 section tier).
fontSize: `${ICON.section}px`,
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
@@ -1,6 +1,7 @@
import { forwardRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
const useStyles = makeStyles({
btn: {
@@ -23,8 +24,8 @@ const useStyles = makeStyles({
backgroundColor: 'transparent'
}
},
md: { width: '32px', height: '32px', fontSize: '18px' },
sm: { width: '28px', height: '28px', fontSize: '16px' }
md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
})
type IconButtonProps = {
@@ -1,6 +1,7 @@
import { useRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
export interface SegmentOption<T extends string> {
value: T
@@ -63,7 +64,7 @@ const useStyles = makeStyles({
},
icon: {
display: 'flex',
fontSize: '16px',
fontSize: `${ICON.inline}px`,
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 = ' • '