From 2ef19ead71a778cfd8fc61e356a43851c17f028d Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 2 Jul 2026 11:10:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit):=20Batch=2016=20=E2=80=94=20store?= =?UTF-8?q?=20discipline=20(L142=20reconciliation,=20L93=20view-model=20ho?= =?UTF-8?q?oks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L142 (+CC14): keyed latest-wins reconciler (lib/reconcile.ts, unit-tested) applies main's authoritative IPC returns in the history/templates/sources stores, extending the M34 settings pattern; loadSources shares the key so a slow list read can't clobber a newer mutation result. L93 (+CC13): settings cards, TerminalView, and LibraryView now reach IPC only through colocated view-model hooks (useAboutCard/useBackupCard/ useCookiesCard/useSoftwareUpdateCard/useNetworkCard/useTerminalRun) or store actions (openHighContrastSettings, scheduled-sync on sources) — no window.api left in components. Co-Authored-By: Claude Fable 5 --- CODE-AUDIT.md | 58 ++++++---- src/renderer/src/components/LibraryView.tsx | 27 ++--- src/renderer/src/components/TerminalView.tsx | 75 +------------ .../src/components/settings/AboutCard.tsx | 78 +++----------- .../components/settings/AppearanceCard.tsx | 8 +- .../src/components/settings/BackupCard.tsx | 43 +------- .../src/components/settings/CookiesCard.tsx | 45 +------- .../src/components/settings/NetworkCard.tsx | 25 +---- .../settings/SoftwareUpdateCard.tsx | 83 +++----------- .../src/components/settings/useAboutCard.ts | 93 ++++++++++++++++ .../src/components/settings/useBackupCard.ts | 57 ++++++++++ .../src/components/settings/useCookiesCard.ts | 55 ++++++++++ .../src/components/settings/useNetworkCard.ts | 36 +++++++ .../settings/useSoftwareUpdateCard.ts | 102 ++++++++++++++++++ src/renderer/src/components/useTerminalRun.ts | 89 +++++++++++++++ src/renderer/src/lib/reconcile.ts | 29 +++++ src/renderer/src/store/history.ts | 38 ++++++- src/renderer/src/store/settings.ts | 22 +++- src/renderer/src/store/sources.ts | 83 ++++++++++++-- src/renderer/src/store/templates.ts | 35 +++++- test/reconcile.test.ts | 101 +++++++++++++++++ 21 files changed, 816 insertions(+), 366 deletions(-) create mode 100644 src/renderer/src/components/settings/useAboutCard.ts create mode 100644 src/renderer/src/components/settings/useBackupCard.ts create mode 100644 src/renderer/src/components/settings/useCookiesCard.ts create mode 100644 src/renderer/src/components/settings/useNetworkCard.ts create mode 100644 src/renderer/src/components/settings/useSoftwareUpdateCard.ts create mode 100644 src/renderer/src/components/useTerminalRun.ts create mode 100644 src/renderer/src/lib/reconcile.ts create mode 100644 test/reconcile.test.ts diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index abb11a1..b20d7cc 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -935,12 +935,22 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n ([preload/index.ts](src/preload/index.ts)); the renderer callers ([store/sources.ts](src/renderer/src/store/sources.ts)) and the preview mock ([mockApi.ts](src/renderer/src/mockApi.ts)) updated to suit. The `Api` type derives from the preload, so the rename is enforced end-to-end by the compiler.* -- [ ] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for +- [x] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed - data-access in one component. *Deferred (CC13's concrete instance): these one-off, card-local IPC reads - (version checks, backup export/import, cookie sign-in) have no shared state to own, so a store per call is - arguably overkill — but the "no `window.api` in components" convention argues for thin hooks. Best done as - the focused CC13 view-model pass, not folded into this copy/cleanup batch.* + data-access in one component. *Fixed (Batch 16, the CC13 view-model pass): each card's IPC + state machine + moved into a colocated view-model hook on the `useDownloadBar` pattern — + [useAboutCard](src/renderer/src/components/settings/useAboutCard.ts) / + [useBackupCard](src/renderer/src/components/settings/useBackupCard.ts) (reloads the settings/templates + stores via new store `reload()` actions after a restore) / + [useCookiesCard](src/renderer/src/components/settings/useCookiesCard.ts) / + [useSoftwareUpdateCard](src/renderer/src/components/settings/useSoftwareUpdateCard.ts) / + [useNetworkCard](src/renderer/src/components/settings/useNetworkCard.ts) (PO-token mint); AppearanceCard's + one-liner became a settings-store action (`openHighContrastSettings`). The sweep also cleared the two + non-settings components: TerminalView's run/stream/stop → + [useTerminalRun](src/renderer/src/components/useTerminalRun.ts), and LibraryView's scheduled-sync IPC → + sources-store state/actions (`scheduledSyncEnabled`/`loadScheduledSync`/`setScheduledSync`/ + `commitSyncTime`). No `window.api` remains in any component (`useDownloadBar` is itself the view-model + layer, per CC13).* - [x] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx) (no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. *Fixed: a 1-entry items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands @@ -1191,14 +1201,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n *Fixed in [store/history.ts](src/renderer/src/store/history.ts): `add` now `.slice(0, MAX_ENTRIES)` (500, the same cap as main's `history.ts`) and de-dupes by `url` as well as `id`, so the optimistic in-memory list matches what a reload would show (also aligns with main's M35 url de-dup).* -- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings +- [x] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34). - *Deferred (targeted, own PR): settings already reconcile (M34). Extending "apply the returned authoritative - state" to history/templates/sources touches each store's mutation path and each corresponding IPC return - shape, and the divergence it guards against is rare (only when main caps/sanitizes differently than the - optimistic update) and self-heals on reload. Best done as one focused reconciliation PR with each store - verified, not folded into this UX batch. Standard already recorded under CC14.* + *Fixed (Batch 16, the focused reconciliation PR): a keyed latest-wins reconciler + ([lib/reconcile.ts](src/renderer/src/lib/reconcile.ts), unit-tested in + [test/reconcile.test.ts](test/reconcile.test.ts)) applies main's authoritative return on every mutation — + history add/remove/removeMany/clear, template save/remove, and sources setWatched/removeSource/ + setMediaItemDownloaded (keyed per source so overlapping completions don't suppress each other) — extending + the M34 settings pattern to all four stores. The seq guard drops a stale response when a newer call was + issued on the same collection, so an overlapped pair can't transiently roll back the newer optimistic + update; `loadSources` shares the mutations' key so a slow list read can't clobber a newer authoritative + result either.* - [x] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides; quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager (mitigated today by the embedded fallback tray icon). @@ -2001,25 +2015,23 @@ split — but that style is unenforced and several "do the same thing two ways" exactly the kind of sweeping move that wants its own reviewed PR, not an unattended batch. The `lib/` convention is already established (both sides have one and new pure utils land there); the wholesale `views/`+`core/` split is the 1.x task. Standard recorded.* -- [ ] **CC13 — View/state boundary (the project's "MVVM").** It's React+Zustand, but the container/ +- [x] **CC13 — View/state boundary (the project's "MVVM").** It's React+Zustand, but the container/ presentational split is inconsistent: orchestration lives in stores for downloads/sources yet inside the component for DownloadBar, and SettingsView calls `window.api` directly (L93, UX1). **Standard:** stores/ hooks are the view-models (own orchestration + all IPC); components stay presentational; no `window.api` in components. - *Partly closed / deferred: the substantive split largely holds — downloads/sources orchestration lives in - stores, and DownloadBar's was extracted into the `useDownloadBar` hook (H1), a view-model in all but name. - The concrete remaining leak is SettingsView calling `window.api` directly, filed as **L93** (Batch 13); - the broader "no `window.api` in any component" sweep rides with it. No new work needed here beyond L93.* -- [ ] **CC14 — State ownership is unprincipled.** State lives in Zustand stores, component `useState`, + *Closed with L93 (Batch 16): the settings cards, TerminalView, and LibraryView now reach IPC only through + colocated view-model hooks or store actions; `grep window.api src/renderer/src/components` matches only + hooks (`use*.ts`). The standard holds codebase-wide: stores/hooks own orchestration + IPC, components render.* +- [x] **CC14 — State ownership is unprincipled.** State lives in Zustand stores, component `useState`, `localStorage`, the main `electron-store` (mirrored into a renderer store), and module-level vars (`idCounter`, `notifiedBackground`, `active`). Persisted state is optimistic-only and never reconciled (M34/L142); two stores form a cycle (C2). **Standard:** main owns persisted state; renderer stores mirror it and **apply the authoritative value the IPC call returns**; UI-only state in stores; break store cycles via a coordinator. - *Largely closed / deferred: main owns persisted state and the renderer mirrors it; the store cycle is gone - (C2, via the coordinator event bus); settings now **apply the authoritative IPC return** (M34); and - `localStorage` was folded into the settings store (M19). The one remaining "optimistic-only, never - reconciled" case is the other IPC mutations (**L142**, Batch 11). The module-level vars called out - (`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped singletons, not misplaced UI - state. Closes with L142.* + *Closed with L142 (Batch 16): every persisted-state mutation now applies main's authoritative return + (history/templates/sources via the keyed reconciler; settings via M34). Main owns persisted state, the + renderer mirrors + reconciles, the store cycle is gone (C2), `localStorage` is folded in (M19). The + module-level vars called out (`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped + singletons, not misplaced UI state.* ### Recommended single style (apply project-wide) diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 8ed38ac..0e01535 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -26,12 +26,10 @@ import { DismissRegular } from '@fluentui/react-icons' import type { MediaItem, Source, MediaKind } from '@shared/ipc' -import { isPreview as PREVIEW } from '../isPreview' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' import { useNav } from '../store/nav' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' -import { logError } from '../reportError' import { useDownloads, type DownloadStatus } from '../store/downloads' import { thumbUrl } from '../thumb' import { relTime } from '../datetime' @@ -235,6 +233,10 @@ export function LibraryView(): React.JSX.Element { const setWatched = useSources((s) => s.setWatched) const syncWatched = useSources((s) => s.syncWatched) const syncing = useSources((s) => s.syncing) + const scheduled = useSources((s) => s.scheduledSyncEnabled) + const loadScheduledSync = useSources((s) => s.loadScheduledSync) + const setScheduledSync = useSources((s) => s.setScheduledSync) + const commitSyncTime = useSources((s) => s.commitSyncTime) const downloadItems = useDownloads((s) => s.items) const autoDownloadNew = useSettings((s) => s.autoDownloadNew) const syncTime = useSettings((s) => s.syncTime) @@ -273,18 +275,13 @@ export function LibraryView(): React.JSX.Element { // Which playlist groups are expanded. Empty = all collapsed (the default), so // an expanded source shows just its group headers until one is opened. const [expandedGroups, setExpandedGroups] = useState>(new Set()) - const [scheduled, setScheduled] = useState(false) const watchedCount = sources.filter((s) => s.watched).length // Load the current scheduled-sync (Task Scheduler) state once. useEffect(() => { - if (PREVIEW) return - window.api - .getScheduledSync() - .then((s) => setScheduled(s.enabled)) - .catch(logError('getScheduledSync')) - }, []) + loadScheduledSync() + }, [loadScheduledSync]) async function onCheckNew(): Promise { setSyncNote(null) @@ -293,11 +290,8 @@ export function LibraryView(): React.JSX.Element { } async function toggleScheduled(next: boolean): Promise { - setScheduled(next) // optimistic - if (PREVIEW) return - const res = await window.api.setScheduledSync(next, syncTime) - setScheduled(res.enabled) - if (res.error) setError(res.error) + const err = await setScheduledSync(next) + if (err) setError(err) } // The native time input emits a complete 'HH:MM' or '' (cleared/partial) — only @@ -310,9 +304,8 @@ export function LibraryView(): React.JSX.Element { // Re-register the task with the new time once editing settles (blur), so typing // through the segments doesn't spawn schtasks per keystroke (L51). async function onSyncTimeCommit(): Promise { - if (!scheduled || PREVIEW) return - const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime) - if (res.error) setError(res.error) + const err = await commitSyncTime() + if (err) setError(err) } // Reset the selection (and any batch note) whenever the expanded source changes. diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 87f0f7f..9693ef0 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { Button, Textarea, @@ -11,24 +11,10 @@ import { } from '@fluentui/react-components' import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons' import { useSettings } from '../store/settings' -import { newId } from '../id' import { ScreenHeader, useScreenStyles } from './ui/Screen' import { EmptyState } from './ui/EmptyState' import { SPACE } from './ui/tokens' - -type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' -interface Line { - id: number - text: string - kind: LineKind -} -let lineSeq = 0 -const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind }) - -// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of -// lines in seconds. Cap the visible log to prevent unbounded React state growth -// and keep the
 scrollable (H6); older lines are dropped from the front.
-const MAX_LOG_LINES = 2000
+import { useTerminalRun, type LineKind } from './useTerminalRun'
 
 const useStyles = makeStyles({
   root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
@@ -81,66 +67,15 @@ export function TerminalView(): React.JSX.Element {
   const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
   const updateSettings = useSettings((s) => s.update)
 
-  const [args, setArgs] = useState('')
-  const [lines, setLines] = useState([])
-  const [running, setRunning] = useState(false)
-  const runId = useRef(null)
+  // View-model hook owns the run/stream/stop orchestration + IPC (L93/CC13).
+  const { args, setArgs, lines, running, run, stop, clearLines } = useTerminalRun()
   const logRef = useRef(null)
 
-  // Stream terminal output for the current run into the log.
-  useEffect(
-    () =>
-      window.api.onTerminalOutput((ev) => {
-        if (ev.id !== runId.current) return
-        if (ev.type === 'output') {
-          setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
-        } else if (ev.type === 'error') {
-          setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
-          setRunning(false)
-          runId.current = null
-        } else {
-          setLines((ls) =>
-            [...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
-          )
-          setRunning(false)
-          runId.current = null
-        }
-      }),
-    []
-  )
-
   // Keep the log pinned to the latest output.
   useEffect(() => {
     if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
   }, [lines])
 
-  function run(): void {
-    const a = args.trim()
-    if (!a || running) return
-    const id = newId('t')
-    runId.current = id
-    setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
-    setRunning(true)
-    window.api
-      .runTerminal(id, a)
-      .then((res) => {
-        if (!res.ok) {
-          setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
-          setRunning(false)
-          runId.current = null
-        }
-      })
-      .catch((e: unknown) => {
-        setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
-        setRunning(false)
-        runId.current = null
-      })
-  }
-
-  function stop(): void {
-    if (runId.current) window.api.cancelTerminal(runId.current)
-  }
-
   const lineClass = (kind: LineKind): string | undefined =>
     kind === 'cmd'
       ? styles.cmd
@@ -214,7 +149,7 @@ export function TerminalView(): React.JSX.Element {
           
         
diff --git a/src/renderer/src/components/settings/BackupCard.tsx b/src/renderer/src/components/settings/BackupCard.tsx
index 7207c5d..9f45e41 100644
--- a/src/renderer/src/components/settings/BackupCard.tsx
+++ b/src/renderer/src/components/settings/BackupCard.tsx
@@ -1,51 +1,16 @@
-import { useState } from 'react'
 import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
 import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
-import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
-import { useSettings } from '../../store/settings'
-import { useTemplates } from '../../store/templates'
 import { useErrorTextStyles } from '../ui/errorText'
 import { useSettingsStyles } from './settingsStyles'
+import { useBackupCard } from './useBackupCard'
 
 export function BackupCard(): React.JSX.Element {
   const styles = useSettingsStyles()
   const errText = useErrorTextStyles()
 
-  const [exporting, setExporting] = useState(false)
-  const [exportResult, setExportResult] = useState(null)
-  const [importing, setImporting] = useState(false)
-  const [importResult, setImportResult] = useState(null)
-
-  async function exportBackup(): Promise {
-    setExporting(true)
-    setExportResult(null)
-    try {
-      setExportResult(await window.api.exportBackup())
-    } catch (e) {
-      setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
-    } finally {
-      setExporting(false)
-    }
-  }
-
-  async function importBackup(): Promise {
-    setImporting(true)
-    setImportResult(null)
-    try {
-      const result = await window.api.importBackup()
-      setImportResult(result)
-      if (result.ok) {
-        // Settings + templates changed underneath the stores -- reload both.
-        const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
-        useSettings.setState({ ...s, loaded: true })
-        useTemplates.setState({ templates: t })
-      }
-    } catch (e) {
-      setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
-    } finally {
-      setImporting(false)
-    }
-  }
+  // View-model hook owns the export/import state + IPC (L93/CC13).
+  const { exporting, exportResult, importing, importResult, exportBackup, importBackup } =
+    useBackupCard()
 
   return (
     
diff --git a/src/renderer/src/components/settings/CookiesCard.tsx b/src/renderer/src/components/settings/CookiesCard.tsx
index fc99fe8..fa4daec 100644
--- a/src/renderer/src/components/settings/CookiesCard.tsx
+++ b/src/renderer/src/components/settings/CookiesCard.tsx
@@ -1,16 +1,11 @@
-import { useEffect, useState } from 'react'
 import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
 import { CookiesRegular } from '@fluentui/react-icons'
-import {
-  COOKIE_BROWSERS,
-  type CookieSource,
-  type CookieBrowser,
-  type CookiesStatus
-} from '@shared/ipc'
+import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc'
 import { useSettings } from '../../store/settings'
 import { Select } from '../Select'
 import { useErrorTextStyles } from '../ui/errorText'
 import { useSettingsStyles } from './settingsStyles'
+import { useCookiesCard } from './useCookiesCard'
 
 const COOKIE_SOURCE_OPTIONS = [
   { value: 'none', label: 'None' },
@@ -30,39 +25,9 @@ export function CookiesCard(): React.JSX.Element {
   const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
   const update = useSettings((s) => s.update)
 
-  const [cookiesStatus, setCookiesStatus] = useState(null)
-  const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
-  const [signingIn, setSigningIn] = useState(false)
-  const [loginError, setLoginError] = useState(null)
-
-  useEffect(() => {
-    window.api.cookiesStatus().then(setCookiesStatus)
-  }, [])
-
-  async function signIn(): Promise {
-    setSigningIn(true)
-    setLoginError(null)
-    try {
-      const result = await window.api.cookiesLogin(loginUrl)
-      if (result.ok && result.cookieCount === 0) {
-        // Window closed without capturing anything -- don't imply success (L50).
-        setLoginError('No cookies were captured -- did you sign in before closing the window?')
-      } else if (result.ok) {
-        setCookiesStatus(await window.api.cookiesStatus())
-      } else {
-        setLoginError(result.error ?? 'Sign-in failed.')
-      }
-    } catch (e) {
-      setLoginError(e instanceof Error ? e.message : String(e))
-    } finally {
-      setSigningIn(false)
-    }
-  }
-
-  async function clearSavedCookies(): Promise {
-    await window.api.cookiesClear()
-    setCookiesStatus({ exists: false })
-  }
+  // View-model hook owns the cookies status + sign-in orchestration (L93/CC13).
+  const { cookiesStatus, loginUrl, setLoginUrl, signingIn, loginError, signIn, clearSavedCookies } =
+    useCookiesCard()
 
   return (
     
diff --git a/src/renderer/src/components/settings/NetworkCard.tsx b/src/renderer/src/components/settings/NetworkCard.tsx
index 643e13f..62eec0d 100644
--- a/src/renderer/src/components/settings/NetworkCard.tsx
+++ b/src/renderer/src/components/settings/NetworkCard.tsx
@@ -1,4 +1,3 @@
-import { useState } from 'react'
 import {
   Field,
   Input,
@@ -12,6 +11,7 @@ import {
 import { GlobeRegular } from '@fluentui/react-icons'
 import { useSettings } from '../../store/settings'
 import { useSettingsStyles } from './settingsStyles'
+import { useNetworkCard } from './useNetworkCard'
 
 export function NetworkCard(): React.JSX.Element {
   const styles = useSettingsStyles()
@@ -23,8 +23,8 @@ export function NetworkCard(): React.JSX.Element {
   const youtubePoToken = useSettings((s) => s.youtubePoToken)
   const update = useSettings((s) => s.update)
 
-  const [mintingPot, setMintingPot] = useState(false)
-  const [potHint, setPotHint] = useState(null)
+  // View-model hook owns the PO-token minting state + IPC (L93/CC13).
+  const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
 
   return (
     
@@ -114,29 +114,14 @@ export function NetworkCard(): React.JSX.Element {
             placeholder="Optional -- paste a token"
             onChange={(_, d) => {
               update({ youtubePoToken: d.value })
-              setPotHint(null)
+              clearPotHint()
             }}
           />
           
diff --git a/src/renderer/src/components/settings/SoftwareUpdateCard.tsx b/src/renderer/src/components/settings/SoftwareUpdateCard.tsx
index 2292aa2..9cccb85 100644
--- a/src/renderer/src/components/settings/SoftwareUpdateCard.tsx
+++ b/src/renderer/src/components/settings/SoftwareUpdateCard.tsx
@@ -1,4 +1,3 @@
-import { useEffect, useRef, useState } from 'react'
 import {
   Field,
   Input,
@@ -17,11 +16,10 @@ import {
   OpenRegular,
   DismissRegular
 } from '@fluentui/react-icons'
-import { type AppUpdateInfo } from '@shared/ipc'
 import { useSettings } from '../../store/settings'
 import { useErrorTextStyles } from '../ui/errorText'
-import { logError } from '../../reportError'
 import { useSettingsStyles } from './settingsStyles'
+import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
 
 export function SoftwareUpdateCard(): React.JSX.Element {
   const styles = useSettingsStyles()
@@ -29,67 +27,19 @@ export function SoftwareUpdateCard(): React.JSX.Element {
   const updateToken = useSettings((s) => s.updateToken)
   const update = useSettings((s) => s.update)
 
-  const [appVersion, setAppVersion] = useState('')
-  const [appUpd, setAppUpd] = useState(null)
-  const [appChecking, setAppChecking] = useState(false)
-  const [appDownloading, setAppDownloading] = useState(false)
-  const [appFraction, setAppFraction] = useState(undefined)
-  const [appUpdError, setAppUpdError] = useState(null)
-  // Set when the user cancels the download, so the awaited result (which comes back
-  // not-ok) is treated as canceled rather than surfaced as an error (B6).
-  const canceledRef = useRef(false)
-
-  useEffect(() => {
-    window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
-    return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
-  }, [])
-
-  async function checkAppUpdate(): Promise {
-    setAppChecking(true)
-    setAppUpd(null)
-    setAppUpdError(null)
-    try {
-      const info = await window.api.checkForAppUpdate()
-      // Funnel a failed check into the single error slot rather than storing a
-      // second not-ok AppUpdateInfo, so only one error message can ever render.
-      if (info.ok) setAppUpd(info)
-      else setAppUpdError(info.error ?? 'Update check failed.')
-    } catch (e) {
-      setAppUpdError(e instanceof Error ? e.message : String(e))
-    } finally {
-      setAppChecking(false)
-    }
-  }
-
-  async function installAppUpdate(): Promise {
-    if (!appUpd?.downloadUrl) return
-    canceledRef.current = false
-    setAppDownloading(true)
-    setAppFraction(undefined)
-    setAppUpdError(null)
-    try {
-      const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
-      // The user hit Cancel: main aborted the download and cleaned up the partial —
-      // don't surface its not-ok result as an error or try to install (B6).
-      if (canceledRef.current) return
-      if (!dl.ok || !dl.filePath) {
-        setAppUpdError(dl.error ?? 'Download failed.')
-        return
-      }
-      const run = await window.api.runAppUpdate(dl.filePath)
-      // On success the app quits as the installer launches; only errors return here.
-      if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
-    } catch (e) {
-      if (!canceledRef.current) setAppUpdError(e instanceof Error ? e.message : String(e))
-    } finally {
-      setAppDownloading(false)
-    }
-  }
-
-  function cancelDownload(): void {
-    canceledRef.current = true
-    window.api.cancelAppUpdate?.().catch(logError('cancelAppUpdate'))
-  }
+  // View-model hook owns the check → download → install orchestration (L93/CC13).
+  const {
+    appVersion,
+    appUpd,
+    appChecking,
+    appDownloading,
+    appFraction,
+    appUpdError,
+    checkAppUpdate,
+    installAppUpdate,
+    cancelDownload,
+    openReleasePage
+  } = useSoftwareUpdateCard()
 
   return (
     
@@ -144,10 +94,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
               {appDownloading ? 'Downloading…' : 'Update now'}
             
             {appUpd.htmlUrl && (
-              
             )}
diff --git a/src/renderer/src/components/settings/useAboutCard.ts b/src/renderer/src/components/settings/useAboutCard.ts
new file mode 100644
index 0000000..ac8c9c3
--- /dev/null
+++ b/src/renderer/src/components/settings/useAboutCard.ts
@@ -0,0 +1,93 @@
+import { useEffect, useState } from 'react'
+import type { YtdlpVersionResult, YtdlpUpdateResult, FfmpegVersionResult } from '@shared/ipc'
+import { useSettings } from '../../store/settings'
+import { logError } from '../../reportError'
+
+export interface AboutCardController {
+  checking: boolean
+  version: YtdlpVersionResult | null
+  ffmpeg: FfmpegVersionResult | null
+  updating: boolean
+  updateResult: YtdlpUpdateResult | null
+  checkVersion: () => Promise
+  runUpdate: () => Promise
+  refreshFfmpeg: () => void
+}
+
+/**
+ * View-model for the About card (L93/CC13): owns the yt-dlp/ffmpeg version state
+ * and the update orchestration so the card stays presentational — no `window.api`
+ * in the component (the `useDownloadBar` pattern).
+ */
+export function useAboutCard(): AboutCardController {
+  const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
+
+  const [checking, setChecking] = useState(false)
+  const [version, setVersion] = useState(null)
+  const [ffmpeg, setFfmpeg] = useState(null)
+  const [updating, setUpdating] = useState(false)
+  const [updateResult, setUpdateResult] = useState(null)
+
+  useEffect(() => {
+    // Show the current yt-dlp version without a manual click, and reflect a
+    // background auto-update (which may run on launch) live in this panel.
+    window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
+    // ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
+    window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
+    return window.api.onYtdlpAutoUpdateStatus((s) => {
+      if (s.phase === 'checking') {
+        setChecking(true)
+        return
+      }
+      setChecking(false)
+      if (s.version) setVersion({ ok: true, version: s.version })
+      if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
+      if (s.phase === 'updated') {
+        setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
+      } else if (s.phase === 'error' && s.error) {
+        setUpdateResult({ ok: false, error: s.error })
+      }
+    })
+  }, [])
+
+  async function checkVersion(): Promise {
+    setChecking(true)
+    setVersion(null)
+    try {
+      setVersion(await window.api.getYtdlpVersion())
+    } catch (e) {
+      setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
+    } finally {
+      setChecking(false)
+    }
+  }
+
+  async function runUpdate(): Promise {
+    setUpdating(true)
+    setUpdateResult(null)
+    try {
+      const result = await window.api.updateYtdlp(ytdlpChannel)
+      setUpdateResult(result)
+      if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
+    } catch (e) {
+      setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
+    } finally {
+      setUpdating(false)
+    }
+  }
+
+  function refreshFfmpeg(): void {
+    window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
+  }
+
+  return {
+    checking,
+    version,
+    ffmpeg,
+    updating,
+    updateResult,
+    checkVersion,
+    runUpdate,
+    refreshFfmpeg
+  }
+}
diff --git a/src/renderer/src/components/settings/useBackupCard.ts b/src/renderer/src/components/settings/useBackupCard.ts
new file mode 100644
index 0000000..f36cd59
--- /dev/null
+++ b/src/renderer/src/components/settings/useBackupCard.ts
@@ -0,0 +1,57 @@
+import { useState } from 'react'
+import type { BackupExportResult, BackupImportResult } from '@shared/ipc'
+import { useSettings } from '../../store/settings'
+import { useTemplates } from '../../store/templates'
+
+export interface BackupCardController {
+  exporting: boolean
+  exportResult: BackupExportResult | null
+  importing: boolean
+  importResult: BackupImportResult | null
+  exportBackup: () => Promise
+  importBackup: () => Promise
+}
+
+/**
+ * View-model for the Backup & restore card (L93/CC13): owns the export/import
+ * orchestration — including reloading the settings/templates stores after a
+ * restore changes both underneath — so the card stays presentational.
+ */
+export function useBackupCard(): BackupCardController {
+  const [exporting, setExporting] = useState(false)
+  const [exportResult, setExportResult] = useState(null)
+  const [importing, setImporting] = useState(false)
+  const [importResult, setImportResult] = useState(null)
+
+  async function exportBackup(): Promise {
+    setExporting(true)
+    setExportResult(null)
+    try {
+      setExportResult(await window.api.exportBackup())
+    } catch (e) {
+      setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
+    } finally {
+      setExporting(false)
+    }
+  }
+
+  async function importBackup(): Promise {
+    setImporting(true)
+    setImportResult(null)
+    try {
+      const result = await window.api.importBackup()
+      setImportResult(result)
+      if (result.ok) {
+        // Settings + templates changed underneath the stores -- reload both.
+        useSettings.getState().reload()
+        useTemplates.getState().reload()
+      }
+    } catch (e) {
+      setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
+    } finally {
+      setImporting(false)
+    }
+  }
+
+  return { exporting, exportResult, importing, importResult, exportBackup, importBackup }
+}
diff --git a/src/renderer/src/components/settings/useCookiesCard.ts b/src/renderer/src/components/settings/useCookiesCard.ts
new file mode 100644
index 0000000..fbf0fdb
--- /dev/null
+++ b/src/renderer/src/components/settings/useCookiesCard.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+import type { CookiesStatus } from '@shared/ipc'
+import { logError } from '../../reportError'
+
+export interface CookiesCardController {
+  cookiesStatus: CookiesStatus | null
+  loginUrl: string
+  setLoginUrl: (url: string) => void
+  signingIn: boolean
+  loginError: string | null
+  signIn: () => Promise
+  clearSavedCookies: () => Promise
+}
+
+/**
+ * View-model for the Cookies card (L93/CC13): owns the saved-cookies status and
+ * the sign-in-window orchestration so the card stays presentational.
+ */
+export function useCookiesCard(): CookiesCardController {
+  const [cookiesStatus, setCookiesStatus] = useState(null)
+  const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
+  const [signingIn, setSigningIn] = useState(false)
+  const [loginError, setLoginError] = useState(null)
+
+  useEffect(() => {
+    window.api.cookiesStatus().then(setCookiesStatus).catch(logError('cookiesStatus'))
+  }, [])
+
+  async function signIn(): Promise {
+    setSigningIn(true)
+    setLoginError(null)
+    try {
+      const result = await window.api.cookiesLogin(loginUrl)
+      if (result.ok && result.cookieCount === 0) {
+        // Window closed without capturing anything -- don't imply success (L50).
+        setLoginError('No cookies were captured -- did you sign in before closing the window?')
+      } else if (result.ok) {
+        setCookiesStatus(await window.api.cookiesStatus())
+      } else {
+        setLoginError(result.error ?? 'Sign-in failed.')
+      }
+    } catch (e) {
+      setLoginError(e instanceof Error ? e.message : String(e))
+    } finally {
+      setSigningIn(false)
+    }
+  }
+
+  async function clearSavedCookies(): Promise {
+    await window.api.cookiesClear()
+    setCookiesStatus({ exists: false })
+  }
+
+  return { cookiesStatus, loginUrl, setLoginUrl, signingIn, loginError, signIn, clearSavedCookies }
+}
diff --git a/src/renderer/src/components/settings/useNetworkCard.ts b/src/renderer/src/components/settings/useNetworkCard.ts
new file mode 100644
index 0000000..ae98225
--- /dev/null
+++ b/src/renderer/src/components/settings/useNetworkCard.ts
@@ -0,0 +1,36 @@
+import { useState } from 'react'
+
+export interface NetworkCardController {
+  mintingPot: boolean
+  potHint: string | null
+  clearPotHint: () => void
+  mintPoToken: () => Promise
+}
+
+/**
+ * View-model for the Network card's PO-token minting (L93/CC13): owns the fetch
+ * state and hint copy so the card stays presentational.
+ */
+export function useNetworkCard(): NetworkCardController {
+  const [mintingPot, setMintingPot] = useState(false)
+  const [potHint, setPotHint] = useState(null)
+
+  async function mintPoToken(): Promise {
+    setMintingPot(true)
+    setPotHint(null)
+    try {
+      const token = await window.api.mintPoToken()
+      if (token) {
+        setPotHint('Token saved.')
+      } else {
+        setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
+      }
+    } catch {
+      setPotHint('Failed to open YouTube window.')
+    } finally {
+      setMintingPot(false)
+    }
+  }
+
+  return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken }
+}
diff --git a/src/renderer/src/components/settings/useSoftwareUpdateCard.ts b/src/renderer/src/components/settings/useSoftwareUpdateCard.ts
new file mode 100644
index 0000000..d466de3
--- /dev/null
+++ b/src/renderer/src/components/settings/useSoftwareUpdateCard.ts
@@ -0,0 +1,102 @@
+import { useEffect, useRef, useState } from 'react'
+import type { AppUpdateInfo } from '@shared/ipc'
+import { logError } from '../../reportError'
+
+export interface SoftwareUpdateCardController {
+  appVersion: string
+  appUpd: AppUpdateInfo | null
+  appChecking: boolean
+  appDownloading: boolean
+  appFraction: number | undefined
+  appUpdError: string | null
+  checkAppUpdate: () => Promise
+  installAppUpdate: () => Promise
+  cancelDownload: () => void
+  openReleasePage: () => void
+}
+
+/**
+ * View-model for the Software update card (L93/CC13): owns the check → download
+ * → run-installer orchestration (including the cancel race, B6) so the card
+ * stays presentational.
+ */
+export function useSoftwareUpdateCard(): SoftwareUpdateCardController {
+  const [appVersion, setAppVersion] = useState('')
+  const [appUpd, setAppUpd] = useState(null)
+  const [appChecking, setAppChecking] = useState(false)
+  const [appDownloading, setAppDownloading] = useState(false)
+  const [appFraction, setAppFraction] = useState(undefined)
+  const [appUpdError, setAppUpdError] = useState(null)
+  // Set when the user cancels the download, so the awaited result (which comes back
+  // not-ok) is treated as canceled rather than surfaced as an error (B6).
+  const canceledRef = useRef(false)
+
+  useEffect(() => {
+    window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
+    return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
+  }, [])
+
+  async function checkAppUpdate(): Promise {
+    setAppChecking(true)
+    setAppUpd(null)
+    setAppUpdError(null)
+    try {
+      const info = await window.api.checkForAppUpdate()
+      // Funnel a failed check into the single error slot rather than storing a
+      // second not-ok AppUpdateInfo, so only one error message can ever render.
+      if (info.ok) setAppUpd(info)
+      else setAppUpdError(info.error ?? 'Update check failed.')
+    } catch (e) {
+      setAppUpdError(e instanceof Error ? e.message : String(e))
+    } finally {
+      setAppChecking(false)
+    }
+  }
+
+  async function installAppUpdate(): Promise {
+    if (!appUpd?.downloadUrl) return
+    canceledRef.current = false
+    setAppDownloading(true)
+    setAppFraction(undefined)
+    setAppUpdError(null)
+    try {
+      const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
+      // The user hit Cancel: main aborted the download and cleaned up the partial —
+      // don't surface its not-ok result as an error or try to install (B6).
+      if (canceledRef.current) return
+      if (!dl.ok || !dl.filePath) {
+        setAppUpdError(dl.error ?? 'Download failed.')
+        return
+      }
+      const run = await window.api.runAppUpdate(dl.filePath)
+      // On success the app quits as the installer launches; only errors return here.
+      if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
+    } catch (e) {
+      if (!canceledRef.current) setAppUpdError(e instanceof Error ? e.message : String(e))
+    } finally {
+      setAppDownloading(false)
+    }
+  }
+
+  function cancelDownload(): void {
+    canceledRef.current = true
+    window.api.cancelAppUpdate?.().catch(logError('cancelAppUpdate'))
+  }
+
+  function openReleasePage(): void {
+    void window.api?.openUrl?.(appUpd?.htmlUrl ?? '')
+  }
+
+  return {
+    appVersion,
+    appUpd,
+    appChecking,
+    appDownloading,
+    appFraction,
+    appUpdError,
+    checkAppUpdate,
+    installAppUpdate,
+    cancelDownload,
+    openReleasePage
+  }
+}
diff --git a/src/renderer/src/components/useTerminalRun.ts b/src/renderer/src/components/useTerminalRun.ts
new file mode 100644
index 0000000..fc8ee89
--- /dev/null
+++ b/src/renderer/src/components/useTerminalRun.ts
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from 'react'
+import { newId } from '../id'
+
+export type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
+export interface Line {
+  id: number
+  text: string
+  kind: LineKind
+}
+let lineSeq = 0
+const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
+
+// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
+// lines in seconds. Cap the visible log to prevent unbounded React state growth
+// and keep the 
 scrollable (H6); older lines are dropped from the front.
+const MAX_LOG_LINES = 2000
+
+export interface TerminalRunController {
+  args: string
+  setArgs: (args: string) => void
+  lines: Line[]
+  running: boolean
+  run: () => void
+  stop: () => void
+  clearLines: () => void
+}
+
+/**
+ * View-model for the built-in yt-dlp terminal (L93/CC13): owns the run/stream/
+ * stop orchestration and the capped output log so TerminalView stays
+ * presentational — no `window.api` in the component.
+ */
+export function useTerminalRun(): TerminalRunController {
+  const [args, setArgs] = useState('')
+  const [lines, setLines] = useState([])
+  const [running, setRunning] = useState(false)
+  const runId = useRef(null)
+
+  // Stream terminal output for the current run into the log.
+  useEffect(
+    () =>
+      window.api.onTerminalOutput((ev) => {
+        if (ev.id !== runId.current) return
+        if (ev.type === 'output') {
+          setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
+        } else if (ev.type === 'error') {
+          setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
+          setRunning(false)
+          runId.current = null
+        } else {
+          setLines((ls) =>
+            [...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
+          )
+          setRunning(false)
+          runId.current = null
+        }
+      }),
+    []
+  )
+
+  function run(): void {
+    const a = args.trim()
+    if (!a || running) return
+    const id = newId('t')
+    runId.current = id
+    setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
+    setRunning(true)
+    window.api
+      .runTerminal(id, a)
+      .then((res) => {
+        if (!res.ok) {
+          setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
+          setRunning(false)
+          runId.current = null
+        }
+      })
+      .catch((e: unknown) => {
+        setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
+        setRunning(false)
+        runId.current = null
+      })
+  }
+
+  function stop(): void {
+    if (runId.current) window.api.cancelTerminal(runId.current)
+  }
+
+  return { args, setArgs, lines, running, run, stop, clearLines: () => setLines([]) }
+}
diff --git a/src/renderer/src/lib/reconcile.ts b/src/renderer/src/lib/reconcile.ts
new file mode 100644
index 0000000..1e67bf3
--- /dev/null
+++ b/src/renderer/src/lib/reconcile.ts
@@ -0,0 +1,29 @@
+/**
+ * Latest-wins reconciliation for optimistic store mutations (L142).
+ *
+ * The main-process mutation handlers (history/templates/sources) return the
+ * authoritative persisted state — validated, capped, sanitized — but the stores
+ * update optimistically first, so applying every response verbatim can briefly
+ * roll back a newer optimistic update when calls overlap (add A, add B, response
+ * A arrives without B). Each mutation therefore registers under a key; a
+ * response is applied only if no newer call on that key has been issued since.
+ * Responses for one key resolve in invoke order (main handles them
+ * sequentially), so the applied response is always the newest state.
+ */
+export type Reconcile = (
+  key: string,
+  call: Promise,
+  apply: (value: T) => void,
+  onError: (e: unknown) => void
+) => void
+
+export function createReconciler(): Reconcile {
+  const latest = new Map()
+  return (key, call, apply, onError) => {
+    const seq = (latest.get(key) ?? 0) + 1
+    latest.set(key, seq)
+    call.then((value) => {
+      if (latest.get(key) === seq) apply(value)
+    }, onError)
+  }
+}
diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts
index 7bdb74d..dc33914 100644
--- a/src/renderer/src/store/history.ts
+++ b/src/renderer/src/store/history.ts
@@ -3,6 +3,7 @@ import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
 import { isPreview as PREVIEW } from '../isPreview'
 import { logError } from '../reportError'
 import { revealFile } from '../reveal'
+import { createReconciler } from '../lib/reconcile'
 
 // Mock history so the History tab has content during UI design. Gated on
 // `import.meta.env.DEV` too so Rollup drops it from the production build (L128).
@@ -59,6 +60,11 @@ interface HistoryState {
   showInFolder: (id: string) => void
 }
 
+// Every mutation applies main's authoritative return (validated/capped/de-duped)
+// through a latest-wins reconciler, so the optimistic list can't silently diverge
+// from what a reload would show (L142, the M34 pattern extended off settings).
+const reconcile = createReconciler()
+
 export const useHistory = create((set, get) => ({
   entries: seed,
 
@@ -69,23 +75,47 @@ export const useHistory = create((set, get) => ({
         HISTORY_MAX_ENTRIES
       )
     }))
-    if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory'))
+    if (!PREVIEW)
+      reconcile(
+        'entries',
+        window.api.addHistory(entry),
+        (entries) => set({ entries }),
+        logError('addHistory')
+      )
   },
 
   remove: (id) => {
     set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
-    if (!PREVIEW) window.api.removeHistory(id).catch(logError('removeHistory'))
+    if (!PREVIEW)
+      reconcile(
+        'entries',
+        window.api.removeHistory(id),
+        (entries) => set({ entries }),
+        logError('removeHistory')
+      )
   },
 
   removeMany: (ids) => {
     const remove = new Set(ids)
     set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
-    if (!PREVIEW) window.api.removeManyHistory(ids).catch(logError('removeManyHistory'))
+    if (!PREVIEW)
+      reconcile(
+        'entries',
+        window.api.removeManyHistory(ids),
+        (entries) => set({ entries }),
+        logError('removeManyHistory')
+      )
   },
 
   clear: () => {
     set({ entries: [] })
-    if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory'))
+    if (!PREVIEW)
+      reconcile(
+        'entries',
+        window.api.clearHistory(),
+        (entries) => set({ entries }),
+        logError('clearHistory')
+      )
   },
 
   openFile: (id) => {
diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts
index 6455e7f..19b926e 100644
--- a/src/renderer/src/store/settings.ts
+++ b/src/renderer/src/store/settings.ts
@@ -22,10 +22,14 @@ interface SettingsState extends Settings {
   /** true once persisted settings have loaded (always true in preview) */
   loaded: boolean
   update: (partial: Partial) => void
+  /** re-fetch persisted settings from main (a backup restore changes them underneath) */
+  reload: () => void
   /** open the OS folder picker and store the result as the video or audio folder */
   chooseDir: (target: 'videoDir' | 'audioDir') => void
   /** clear a per-kind folder override, restoring its Documents\… default */
   clearDir: (target: 'videoDir' | 'audioDir') => void
+  /** open the Windows High Contrast settings page (AppearanceCard) */
+  openHighContrastSettings: () => void
 }
 
 export const useSettings = create((set, get) => ({
@@ -50,6 +54,14 @@ export const useSettings = create((set, get) => ({
       .catch(logError('setSettings'))
   },
 
+  reload: () => {
+    if (PREVIEW) return
+    window.api
+      .getSettings()
+      .then((s) => set({ ...s, loaded: true }))
+      .catch(logError('getSettings'))
+  },
+
   chooseDir: (target) => {
     if (PREVIEW) return
     // Seed the OS picker with the folder this target currently points at (W5).
@@ -61,10 +73,12 @@ export const useSettings = create((set, get) => ({
       .catch(logError('chooseFolder'))
   },
 
-  clearDir: (target) => get().update({ [target]: '' })
+  clearDir: (target) => get().update({ [target]: '' }),
+
+  openHighContrastSettings: () => {
+    if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
+  }
 }))
 
 // Load persisted settings on startup.
-if (!PREVIEW) {
-  window.api.getSettings().then((s) => useSettings.setState({ ...s, loaded: true }))
-}
+if (!PREVIEW) useSettings.getState().reload()
diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts
index fa09521..fc49048 100644
--- a/src/renderer/src/store/sources.ts
+++ b/src/renderer/src/store/sources.ts
@@ -4,6 +4,7 @@ import { isPreview as PREVIEW } from '../isPreview'
 import { useSettings } from './settings'
 import { emit, on } from './coordinator'
 import { logError } from '../reportError'
+import { createReconciler } from '../lib/reconcile'
 
 // --- Preview seed data ------------------------------------------------------
 
@@ -105,12 +106,25 @@ interface SourcesState {
    * videos. Resolves with how many new videos were found.
    */
   syncWatched: () => Promise
+  /** whether the OS-scheduled daily sync task is registered (Phase J) */
+  scheduledSyncEnabled: boolean
+  /** load the Task Scheduler state (Library view mount) */
+  loadScheduledSync: () => void
+  /** register/unregister the daily sync task; resolves with an error message or null */
+  setScheduledSync: (enabled: boolean) => Promise
+  /** re-register the task after the sync time changed; error message or null (L51) */
+  commitSyncTime: () => Promise
 }
 
 // Set by cancelIndexing() so the awaiting index/reindex call knows the user
 // aborted and can resolve as canceled (no error UI) rather than as a failure (B2).
 let indexCanceledRequested = false
 
+// Mutations apply main's authoritative return via a latest-wins reconciler so the
+// optimistic state can't diverge from disk until reload (L142). The source list
+// and each source's item list are independent keys.
+const reconcile = createReconciler()
+
 export const useSources = create((set, get) => ({
   sources: seedSources,
   itemsBySource: seedItemsBySource,
@@ -120,10 +134,14 @@ export const useSources = create((set, get) => ({
 
   loadSources: () => {
     if (PREVIEW) return
-    window.api
-      .listSources()
-      .then((sources) => set({ sources }))
-      .catch(logError('listSources'))
+    // Shares the mutations' 'sources' key so a slow list response can't clobber
+    // a newer setWatched/removeSource authoritative result (L142).
+    reconcile(
+      'sources',
+      window.api.listSources(),
+      (sources) => set({ sources }),
+      logError('listSources')
+    )
   },
 
   selectSource: (id) => {
@@ -205,7 +223,13 @@ export const useSources = create((set, get) => ({
     // stops its videos from finishing into now-orphaned folders (L157). Routed via
     // the event bus (C2) so sources doesn't import downloads.
     emit('sourceRemoved', id)
-    if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
+    if (!PREVIEW)
+      reconcile(
+        'sources',
+        window.api.removeSource(id),
+        (sources) => set({ sources }),
+        logError('removeSource')
+      )
   },
 
   enqueueItems: (sourceId, items, kindOverride) => {
@@ -240,6 +264,11 @@ export const useSources = create((set, get) => ({
   },
 
   markDownloaded: (itemId, filePath) => {
+    // Which loaded source holds this item — also the reconcile key, so overlapping
+    // completions in different sources never suppress each other's response.
+    const owner = Object.entries(get().itemsBySource).find(([, list]) =>
+      list.some((m) => m.id === itemId)
+    )?.[0]
     set((s) => {
       const next: Record = {}
       let changed = false
@@ -256,12 +285,28 @@ export const useSources = create((set, get) => ({
       return changed ? { itemsBySource: next } : {}
     })
     if (!PREVIEW)
-      window.api.setMediaItemDownloaded(itemId, filePath).catch(logError('setMediaItemDownloaded'))
+      reconcile(
+        `items:${owner ?? itemId}`,
+        window.api.setMediaItemDownloaded(itemId, filePath),
+        (items) => {
+          // Main returns the updated list for the item's source, or [] when the id
+          // is unknown there (e.g. a queue item without a persisted MediaItem).
+          const sid = items[0]?.sourceId
+          if (sid) set((s) => ({ itemsBySource: { ...s.itemsBySource, [sid]: items } }))
+        },
+        logError('setMediaItemDownloaded')
+      )
   },
 
   setWatched: (id, watched) => {
     set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
-    if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched'))
+    if (!PREVIEW)
+      reconcile(
+        'sources',
+        window.api.setSourceWatched(id, watched),
+        (sources) => set({ sources }),
+        logError('setSourceWatched')
+      )
   },
 
   syncWatched: async () => {
@@ -295,6 +340,30 @@ export const useSources = create((set, get) => ({
     } finally {
       set({ syncing: false })
     }
+  },
+
+  scheduledSyncEnabled: false,
+
+  loadScheduledSync: () => {
+    if (PREVIEW) return
+    window.api
+      .getScheduledSync()
+      .then((s) => set({ scheduledSyncEnabled: s.enabled }))
+      .catch(logError('getScheduledSync'))
+  },
+
+  setScheduledSync: async (enabled) => {
+    set({ scheduledSyncEnabled: enabled }) // optimistic
+    if (PREVIEW) return null
+    const res = await window.api.setScheduledSync(enabled, useSettings.getState().syncTime)
+    set({ scheduledSyncEnabled: res.enabled })
+    return res.error ?? null
+  },
+
+  commitSyncTime: async () => {
+    if (!get().scheduledSyncEnabled || PREVIEW) return null
+    const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime)
+    return res.error ?? null
   }
 }))
 
diff --git a/src/renderer/src/store/templates.ts b/src/renderer/src/store/templates.ts
index f94b018..720f018 100644
--- a/src/renderer/src/store/templates.ts
+++ b/src/renderer/src/store/templates.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand'
 import type { CommandTemplate } from '@shared/ipc'
 import { isPreview as PREVIEW } from '../isPreview'
 import { logError } from '../reportError'
+import { createReconciler } from '../lib/reconcile'
 
 // Sample templates so the Settings tab has content during UI design.
 const seed: CommandTemplate[] = PREVIEW
@@ -16,23 +17,47 @@ interface TemplatesState {
   /** add a new template or update an existing one (matched by id) */
   save: (template: CommandTemplate) => void
   remove: (id: string) => void
+  /** re-fetch persisted templates from main (a backup restore replaces them underneath) */
+  reload: () => void
 }
 
+// Mutations apply main's authoritative return (sanitized names/args, capped list)
+// via a latest-wins reconciler so the optimistic list matches disk (L142).
+const reconcile = createReconciler()
+
 export const useTemplates = create((set) => ({
   templates: seed,
 
   save: (template) => {
     set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
-    if (!PREVIEW) window.api.saveTemplate(template).catch(logError('saveTemplate'))
+    if (!PREVIEW)
+      reconcile(
+        'templates',
+        window.api.saveTemplate(template),
+        (templates) => set({ templates }),
+        logError('saveTemplate')
+      )
   },
 
   remove: (id) => {
     set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
-    if (!PREVIEW) window.api.removeTemplate(id).catch(logError('removeTemplate'))
+    if (!PREVIEW)
+      reconcile(
+        'templates',
+        window.api.removeTemplate(id),
+        (templates) => set({ templates }),
+        logError('removeTemplate')
+      )
+  },
+
+  reload: () => {
+    if (PREVIEW) return
+    window.api
+      .listTemplates()
+      .then((templates) => set({ templates }))
+      .catch(logError('listTemplates'))
   }
 }))
 
 // Load persisted templates on startup.
-if (!PREVIEW) {
-  window.api.listTemplates().then((templates) => useTemplates.setState({ templates }))
-}
+if (!PREVIEW) useTemplates.getState().reload()
diff --git a/test/reconcile.test.ts b/test/reconcile.test.ts
new file mode 100644
index 0000000..60b3a00
--- /dev/null
+++ b/test/reconcile.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi } from 'vitest'
+import { createReconciler } from '../src/renderer/src/lib/reconcile'
+
+/** A promise whose resolution the test controls. */
+function deferred(): {
+  promise: Promise
+  resolve: (v: T) => void
+  reject: (e: unknown) => void
+} {
+  let resolve!: (v: T) => void
+  let reject!: (e: unknown) => void
+  const promise = new Promise((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+const flush = () => new Promise((r) => setTimeout(r, 0))
+
+describe('createReconciler (L142)', () => {
+  it('applies the response of a single call', async () => {
+    const reconcile = createReconciler()
+    const apply = vi.fn()
+    const d = deferred()
+    reconcile('k', d.promise, apply, vi.fn())
+    d.resolve(['a'])
+    await flush()
+    expect(apply).toHaveBeenCalledWith(['a'])
+  })
+
+  it('drops a stale response when a newer call was issued on the same key', async () => {
+    const reconcile = createReconciler()
+    const applied: string[][] = []
+    const apply = (v: string[]) => applied.push(v)
+    const first = deferred()
+    const second = deferred()
+    reconcile('k', first.promise, apply, vi.fn())
+    reconcile('k', second.promise, apply, vi.fn())
+    // Even resolving in order, only the newest call's response is applied —
+    // the first would transiently roll back the second's optimistic update.
+    first.resolve(['a'])
+    second.resolve(['a', 'b'])
+    await flush()
+    expect(applied).toEqual([['a', 'b']])
+  })
+
+  it('drops an out-of-order late response entirely', async () => {
+    const reconcile = createReconciler()
+    const applied: string[][] = []
+    const first = deferred()
+    const second = deferred()
+    reconcile('k', first.promise, (v) => applied.push(v), vi.fn())
+    reconcile('k', second.promise, (v) => applied.push(v), vi.fn())
+    second.resolve(['a', 'b'])
+    await flush()
+    first.resolve(['a']) // resolves after the newer response was applied
+    await flush()
+    expect(applied).toEqual([['a', 'b']])
+  })
+
+  it('keys are independent — a call on one key never suppresses another', async () => {
+    const reconcile = createReconciler()
+    const applied: Record = {}
+    const a = deferred()
+    const b = deferred()
+    reconcile('a', a.promise, (v) => (applied.a = v), vi.fn())
+    reconcile('b', b.promise, (v) => (applied.b = v), vi.fn())
+    a.resolve([1])
+    b.resolve([2])
+    await flush()
+    expect(applied).toEqual({ a: [1], b: [2] })
+  })
+
+  it('routes rejection to onError and never to apply', async () => {
+    const reconcile = createReconciler()
+    const apply = vi.fn()
+    const onError = vi.fn()
+    const d = deferred()
+    reconcile('k', d.promise, apply, onError)
+    d.reject(new Error('ipc failed'))
+    await flush()
+    expect(apply).not.toHaveBeenCalled()
+    expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'ipc failed' }))
+  })
+
+  it('a rejected older call does not block applying the newer response', async () => {
+    const reconcile = createReconciler()
+    const applied: string[][] = []
+    const onError = vi.fn()
+    const first = deferred()
+    const second = deferred()
+    reconcile('k', first.promise, (v) => applied.push(v), onError)
+    reconcile('k', second.promise, (v) => applied.push(v), onError)
+    first.reject(new Error('boom'))
+    second.resolve(['b'])
+    await flush()
+    expect(applied).toEqual([['b']])
+    expect(onError).toHaveBeenCalledTimes(1)
+  })
+})