From 0c143bed870cd96744120f654619c70ecb68f1e4 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 12 May 2026 19:05:53 +0100 Subject: [PATCH] feat: welcome modal, demo navbar pill, segmented Settings, reset app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end UX pass for first-run + demo-mode flows, plus a Settings reshuffle that lets all of this breathe. Welcome modal (replaces FirstRunModal): - Two-step: Welcome (BarChart3 icon + 3 bold-led bullets) → Folder (option cards for Default vs Choose-another). 400px wide, fixed 392px shell height so the modal never resizes between steps. - "Start with demo data" link bottom-left, "Choose folder" → "Get started" primary on the right. - Default card surfaces the Recommended badge when the path is detected, or "Not found on this Mac" disabled state when it's not. - Clicking the custom card opens the native picker directly. Demo-mode navbar pill (new): - Orange split-pill that lives in the navbar's centre slot whenever demoMode is on. Left half is the persistent "Demo mode" label (routes to Settings). Right half is a contextual action chip — "Set folder" opens the picker, "Exit" flips demo off in one click. - Centered via MainHeader switching to a grid-cols-[1fr_auto_1fr] layout; left/right slots balance around it. Settings reorganised: - Segmented tabs at the top (General / Data / About). Range pill hides on /settings so the tab strip owns the chrome. - General: Recordings folder, Appearance, Transcripts, Demo data. - Data: Indexing, Filler-phrase dictionary. - About: tagline+disclaimer combined, version table, updater. Reset app moved to its own card so the action sits separately. - Drop the auto-hide-sidebar toggle row (behaviour stays default-on in config; the row was UI overkill). Reset app (new): - IPC channel writes config.json back to defaults. - Renderer's resetApp flips a transient welcomeForceShow flag so the welcome modal re-appears even when SuperWhisper is installed at the default location (which would otherwise be auto-adopted on hydrate). - Two-step confirm with accent-orange "Confirm reset" (not red — recordings on disk are untouched). Plumbing: - main/cache.ts demo fallback: when no folder is configured, serve the synthetic dataset so the welcome modal renders against populated screens instead of a blank shell. - main/config.ts resolveRecordingsPath: a picked SuperWhisper parent folder auto-promotes to its `recordings/` subdirectory if the parent itself isn't a valid recordings dir. - Settings → Recordings folder choose() and WelcomeModal done() set the path FIRST, then disable demo — the reverse order leaves the renderer briefly in (!path && !demoMode) which fires the welcome trigger and flashes the modal. - App.tsx hydrate-on-no-path (instead of clearData) so the demo fallback reaches the renderer behind the welcome. - LoadingOverlay gated on configValid so the welcome doesn't peek through a "Loading recordings…" curtain on fresh installs. Cosmetic: - New accent-orange token (light + dark) for the demo pill and reset confirm. Strong orange that doesn't compete with chart greys or accent-blue identity cues. - Reusable SegmentedTabs primitive (same lifted-pill aesthetic as RangePill). Keyboard focus uses a subtle bg tint, not a ring, so tabbing through doesn't leave a stuck-selection look. - middleTruncate util for paths that need to keep their leaf visible (e.g. ~/Library/.../recordings). - OptionCard detailMono prop: mono font only when displaying actual paths, sans-serif for prose ("Pick a folder anywhere on your Mac"). --- src/main/cache.ts | 36 +- src/main/config.ts | 31 ++ src/main/ipc.ts | 28 +- src/preload/api.ts | 7 +- src/renderer/src/App.tsx | 29 +- src/renderer/src/components/FirstRunModal.tsx | 77 ---- src/renderer/src/components/WelcomeModal.tsx | 401 ++++++++++++++++++ .../src/components/layout/DemoModeBadge.tsx | 88 ++++ .../src/components/layout/MainHeader.tsx | 47 +- .../src/components/layout/RootLayout.tsx | 25 +- .../src/components/ui/SegmentedTabs.tsx | 74 ++++ src/renderer/src/lib/format.ts | 17 + src/renderer/src/screens/Settings.tsx | 167 ++++++-- src/renderer/src/state/configStore.ts | 31 +- src/renderer/src/styles/tokens.css | 11 + 15 files changed, 902 insertions(+), 167 deletions(-) delete mode 100644 src/renderer/src/components/FirstRunModal.tsx create mode 100644 src/renderer/src/components/WelcomeModal.tsx create mode 100644 src/renderer/src/components/layout/DemoModeBadge.tsx create mode 100644 src/renderer/src/components/ui/SegmentedTabs.tsx diff --git a/src/main/cache.ts b/src/main/cache.ts index 7a283aa..1a848f3 100644 --- a/src/main/cache.ts +++ b/src/main/cache.ts @@ -51,27 +51,39 @@ function clear(): void { function rescan(): HydratePayload { const config = getConfig() - // Demo mode short-circuits the disk read. The synthetic dataset is - // reproducible, so callers can toggle it on and off safely without - // losing or mixing real-data state. - if (config.demoMode) { + // Demo data is served in two cases: + // 1. `config.demoMode` is on (user opted in via Settings). + // 2. No folder is configured yet (first-launch fallback so the + // welcome modal renders against a populated app, not a blank + // shell — picking a folder swaps in real data). + // Either case short-circuits the disk read. The synthetic dataset is + // deterministic, so callers can toggle it on and off without losing + // or mixing real-data state. We track the *persisted* demoMode flag + // in `lastDemoMode` (not the fact we're rendering demo) so the + // hydrate() change-detection stays correct: when the user later + // picks a folder OR toggles demo on, the path/flag mismatch triggers + // a fresh rescan. + const usingDemoFallback = !config.superwhisperPath + if (config.demoMode || usingDemoFallback) { const t0 = Date.now() recordings = buildDemoRecordings(new Date(), config.fillerWords) aggregates = computeAll(recordings, new Date()) indexedAt = new Date().toISOString() - lastScannedPath = null - lastDemoMode = true + lastScannedPath = config.superwhisperPath + lastDemoMode = config.demoMode scanErrors = 0 scanSkipped = 0 - console.log(`[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms`) + const reason = config.demoMode ? 'demo mode' : 'no folder configured' + console.log( + `[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms (${reason})` + ) return buildPayload(null) } - const path = config.superwhisperPath - if (!path) { - clear() - return buildPayload('No recordings folder configured.') - } + // `superwhisperPath` is guaranteed non-null here: the demo fallback + // branch above catches the null case. Narrow via assertion so the + // `scan(path, …)` call below doesn't trip the type checker. + const path = config.superwhisperPath as string if (!isPathValid(path)) { clear() return buildPayload(`Path not found: ${path}`) diff --git a/src/main/config.ts b/src/main/config.ts index d5e04e4..c7bd8d1 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -88,6 +88,22 @@ export function setConfig(patch: Partial): Config { return merged } +/** + * Wipe the persisted config back to defaults. Used by the "Reset app" + * affordance in Settings so the welcome flow can be re-tested without + * hand-editing `~/Library/Application Support/me.cyzr.superwhisper- + * analytics/config.json`. Writes the defaults explicitly so the + * file's mtime updates and the renderer's next `config:status` call + * sees the cleared state. + */ +export function resetConfig(): Config { + const fresh = defaultConfig() + const file = configFilePath() + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(fresh, null, 2), 'utf-8') + return fresh +} + /** * Probe known SuperWhisper recordings paths. Returns the first that * exists, or `null` if neither does (user must pick manually). @@ -99,6 +115,21 @@ export function defaultPath(): string | null { return null } +/** + * Resolve a picked path to the actual recordings directory. The user + * might select the SuperWhisper parent folder (e.g. `.../com.super + * duper.superwhisper`) rather than its `recordings/` subdirectory. + * If the picked path isn't itself a valid recordings folder but has a + * `recordings/` child that is, promote to the child — saves the user + * having to re-navigate through the picker. + */ +export function resolveRecordingsPath(picked: string): string { + if (isPathValid(picked)) return picked + const sub = join(picked, 'recordings') + if (isPathValid(sub)) return sub + return picked +} + /** * Cheap validity check: directory exists and at least one of its first * five children contains a `meta.json`. Avoids walking all 11k entries diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8b9c6ac..c7c8f9c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,14 @@ import { BrowserWindow, dialog, ipcMain, shell } from 'electron' import type { HydratePayload } from '@shared/types' -import { defaultPath, getConfig, isPathInsideHome, isPathValid, setConfig } from './config' +import { + defaultPath, + getConfig, + isPathInsideHome, + isPathValid, + resetConfig, + resolveRecordingsPath, + setConfig +} from './config' import { hydrate, reindex, setFillerWords } from './cache' import { checkForUpdatesManually, getUpdaterStatus, type UpdaterStatus } from './updater' import { disableWatch, enableWatch } from './watcher' @@ -46,7 +54,12 @@ export function registerIpcHandlers(): void { ipcMain.handle('config:setPath', (_, path: unknown): ConfigStatus => { // Accept string or null; ignore anything else. if (path !== null && !validString(path)) return buildStatus() - setConfig({ superwhisperPath: path }) + // Auto-promote a SuperWhisper parent-folder pick to its `recordings/` + // child if the parent itself isn't a valid recordings dir. Saves + // users having to re-navigate via the picker when they grabbed the + // SuperWhisper container by mistake. + const resolved = path === null ? null : resolveRecordingsPath(path) + setConfig({ superwhisperPath: resolved }) syncWatcher() return buildStatus() }) @@ -82,6 +95,17 @@ export function registerIpcHandlers(): void { return buildStatus() }) + // Reset everything — wipes config.json back to defaults so the + // welcome flow shows again on next hydrate. Used by the "Reset app" + // affordance in Settings → About. + ipcMain.handle('config:reset', (): ConfigStatus => { + resetConfig() + // Watch is keyed off path, which is now null — kill any active + // watcher so we don't leak a handle pointing at the old folder. + disableWatch() + return buildStatus() + }) + ipcMain.handle('dialog:pickFolder', async (event): Promise => { const win = BrowserWindow.fromWebContents(event.sender) const opts = { diff --git a/src/preload/api.ts b/src/preload/api.ts index b4d52ce..9b197a7 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -103,7 +103,12 @@ export const api = { * (≤900px) hide the sidebar automatically; when off the user controls * it entirely via Cmd-B / the navbar icon. */ setAutoHideSidebar: (enabled: boolean): Promise => - ipcRenderer.invoke('config:setAutoHideSidebar', enabled) + ipcRenderer.invoke('config:setAutoHideSidebar', enabled), + /** Wipe the persisted config back to defaults — clears the saved + * folder, demo flag, custom filler dictionary, etc. The renderer + * follows up by triggering a fresh hydrate so the dataStore picks + * up the cleared state. Used by Settings → About → Reset app. */ + reset: (): Promise => ipcRenderer.invoke('config:reset') }, data: { hydrate: (): Promise => ipcRenderer.invoke('data:hydrate'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c5fee72..ba5cddb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -43,18 +43,21 @@ function App(): React.JSX.Element { void hydrateConfig() }, [hydrateConfig]) - // Once config is hydrated, drive the data store off the path's - // validity. Valid path → hydrate (cache rescans transparently if the - // path has changed). Invalid path → clear data (so stale aggregates - // from a previous folder don't linger after the user breaks the - // path). + // Once config is hydrated, ask main for data. Main decides what to + // serve: + // • configured + valid path → real recordings + // • demo mode on → synthetic dataset + // • no folder configured → demo fallback (so screens look + // populated behind the welcome modal on a fresh install) + // clearData() is intentionally not called for the "no folder" path + // any more — the demo fallback covers that case and avoids a brief + // empty-screen flash before the modal animates in. useEffect(() => { if (!configHydrated) return - if (configValid) { - void hydrateData() - } else { - clearData() - } + void hydrateData() + // `clearData` is referenced here purely so React's lint rule for + // exhaustive deps stays happy when other branches re-introduce it. + void clearData }, [configHydrated, configValid, hydrateData, clearData]) // Subscribe to fs.watch invalidation pushes from main — when the user @@ -77,7 +80,11 @@ function App(): React.JSX.Element { return ( <> - {dataLoading && } + {/* LoadingOverlay only covers the screen for real-data scans + (configured + valid path). On a fresh install we serve demo + data behind the welcome modal — no need for a loading curtain + there too. */} + {configValid && dataLoading && } ) } diff --git a/src/renderer/src/components/FirstRunModal.tsx b/src/renderer/src/components/FirstRunModal.tsx deleted file mode 100644 index 50dd9e9..0000000 --- a/src/renderer/src/components/FirstRunModal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useConfigStore } from '@renderer/state/configStore' -import { FolderSearch } from 'lucide-react' -import { useState } from 'react' - -/** - * Blocking first-run modal. Surfaces the moment we discover there's no - * valid SuperWhisper recordings folder configured. - * - * Single action — open the native folder picker. Until the user points - * us at a folder that looks right (`isValid === true` per the cheap - * meta.json probe in `main/config.ts`) the rest of the app stays - * inaccessible behind the backdrop. - */ -export function FirstRunModal(): React.JSX.Element { - const setPath = useConfigStore((s) => s.setPath) - const [picking, setPicking] = useState(false) - const [error, setError] = useState(null) - - async function pick(): Promise { - setPicking(true) - setError(null) - try { - const chosen = await window.api.dialog.pickFolder() - if (!chosen) return - await setPath(chosen) - const stillInvalid = !useConfigStore.getState().isValid - if (stillInvalid) { - setError( - "That folder doesn't look like a SuperWhisper recordings folder. Pick the directory that contains your recording subfolders." - ) - } - } finally { - setPicking(false) - } - } - - return ( -
-
-
- -
-

- Find your recordings -

-

- Choose the folder where SuperWhisper stores your recordings. This is usually - - ~/Library/Application Support/com.superduper.superwhisper/recordings - - but you can point us anywhere. -

- {error && ( -

- {error} -

- )} - -
-
- ) -} diff --git a/src/renderer/src/components/WelcomeModal.tsx b/src/renderer/src/components/WelcomeModal.tsx new file mode 100644 index 0000000..1a6314c --- /dev/null +++ b/src/renderer/src/components/WelcomeModal.tsx @@ -0,0 +1,401 @@ +import { cn } from '@renderer/lib/cn' +import { middleTruncate } from '@renderer/lib/format' +import { useConfigStore } from '@renderer/state/configStore' +import { Activity, BarChart3, BookOpen, ChevronRight, Clock, FolderSearch } from 'lucide-react' +import { useState } from 'react' + +/** + * Two-step welcome flow. Shown when the renderer has hydrated and the + * configured recordings folder is missing or invalid AND the user has + * not opted into demo mode. Demo data renders behind the modal so the + * underlying screens look populated (see main/cache.ts demo fallback). + * + * Step 1 — Welcome. Compact onboarding: BarChart3 icon, heading, + * subtitle, three icon-square bullets. Footer: "Start with demo + * data" link bottom-left, "Choose folder" solid primary button + * bottom-right. + * + * Step 2 — Folder. Two stacked option cards: "Default location" + * (with Recommended badge when detected) and "Choose another + * folder". Clicking the custom card opens the native picker. + * Footer: "← Back" / "Done". + * + * Width 400px. Both step bodies share min-h-[280px] so the modal + * shell stays the same height across steps. Modal can't be dismissed + * by clicking the backdrop — only by completing a flow or choosing + * demo. Colour discipline: blue is reserved for the icon square + + * the small Recommended badge (identity / status). Primary actions + * use solid foreground; selected option card uses foreground tint. + */ + +type Step = 'welcome' | 'folder' +type FolderChoice = 'default' | 'custom' + +// Each bullet is rendered as **** — . +const BULLETS: Array<{ lead: string; detail: string; icon: typeof Clock }> = [ + { + icon: Clock, + lead: 'See how often you record', + detail: 'when, where in your week, and how it has changed over time.' + }, + { + icon: Activity, + lead: 'Spot trends in your usage', + detail: 'speaking pace, filler-word rate, vocabulary growth.' + }, + { + icon: BookOpen, + lead: 'Browse every transcript', + detail: 'with audio playback and a clickable segment view.' + } +] + +/** Solid foreground primary — black in light mode, near-white in dark. + * Reserves the strongest visual weight on the modal for the primary + * forward-action without using blue (which is reserved for the icon + * square + Recommended badge — identity / micro-status). */ +const PRIMARY_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-[8px] bg-foreground px-3 text-[11.5px] font-medium text-background transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50' + +const LINK_BUTTON = + 'text-[11.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50' + +/** Fixed modal shell height. Both step bodies render inside a + * `flex-1` div, so the actual shell never jumps as the user moves + * between steps — regardless of how much content each step has. */ +const SHELL_HEIGHT = 392 + +export function WelcomeModal(): React.JSX.Element { + const setPath = useConfigStore((s) => s.setPath) + const setDemoMode = useConfigStore((s) => s.setDemoMode) + const defaultPath = useConfigStore((s) => s.defaultPath) + const dismissWelcomeForce = useConfigStore((s) => s.dismissWelcomeForce) + + const [step, setStep] = useState('welcome') + // Selection model for step 2. When no defaultPath was detected we + // auto-select 'custom' so the user is one click away from the picker; + // the disabled default card stays visible for context ("we looked + // here, didn't find anything"). + const [choice, setChoice] = useState(defaultPath ? 'default' : 'custom') + const [customPath, setCustomPath] = useState(null) + const [picking, setPicking] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const resolvedPath = choice === 'default' ? defaultPath : customPath + + async function pickCustom(): Promise { + setPicking(true) + setError(null) + try { + const chosen = await window.api.dialog.pickFolder() + if (chosen) { + setCustomPath(chosen) + setChoice('custom') + } + } finally { + setPicking(false) + } + } + + async function done(): Promise { + if (!resolvedPath) return + setBusy(true) + setError(null) + try { + // Real folder wins over any previously-set demo flag — if the + // user has been browsing with demo on and is now wiring real + // data, flip demo off so the path-driven scan takes over. + // Order matters: persist the path first, then disable demo, so + // the renderer never sees the (!demoMode && !path) in-between + // state that would briefly fire the welcome trigger. + await setPath(resolvedPath) + if (useConfigStore.getState().demoMode) await setDemoMode(false) + const stillInvalid = !useConfigStore.getState().isValid + if (stillInvalid) { + setError( + "That folder doesn't look like a SuperWhisper recordings folder. Pick the directory that contains your recording subfolders." + ) + } else { + // Path stuck. Clear the reset-triggered force flag so the + // modal disappears even when configValid would have hidden + // it anyway. + dismissWelcomeForce() + } + } finally { + setBusy(false) + } + } + + async function startWithDemo(): Promise { + setBusy(true) + try { + await setDemoMode(true) + dismissWelcomeForce() + } finally { + setBusy(false) + } + } + + return ( +
+
+ {step === 'welcome' ? ( + <> + +
void startWithDemo()} + disabled={busy} + className={LINK_BUTTON} + > + Start with demo data + + } + right={ + + } + /> + + ) : ( + <> + { + if (defaultPath) setChoice('default') + }} + onSelectCustom={() => void pickCustom()} + /> +
{ + setError(null) + setStep('welcome') + }} + disabled={busy} + className={LINK_BUTTON} + > + ← Back + + } + right={ + + } + /> + + )} +
+
+ ) +} + +function WelcomeStep(): React.JSX.Element { + return ( +
+
+ +
+

+ Welcome to SuperWhisper Analytics +

+

+ An unofficial local app for browsing your SuperWhisper recording history. +

+
    + {BULLETS.map((b, i) => ( +
  • + + + + + {b.lead} + — {b.detail} + +
  • + ))} +
+
+ ) +} + +function FolderStep({ + defaultPath, + customPath, + choice, + picking, + error, + onSelectDefault, + onSelectCustom +}: { + defaultPath: string | null + customPath: string | null + choice: FolderChoice + picking: boolean + error: string | null + onSelectDefault: () => void + onSelectCustom: () => void +}): React.JSX.Element { + const defaultMissing = !defaultPath + return ( +
+
+ +
+

+ Where are your recordings? +

+

+ Use the default unless your recordings are in a custom location. +

+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} +
+ ) +} + +function OptionCard({ + selected, + disabled, + onClick, + title, + badge, + detail, + detailTone = 'muted', + detailMono = false +}: { + selected: boolean + disabled?: boolean + onClick: () => void + title: string + badge?: string + detail: string + detailTone?: 'muted' | 'warn' + detailMono?: boolean +}): React.JSX.Element { + return ( + + ) +} + +function Footer({ + left, + right +}: { + left: React.ReactNode + right: React.ReactNode +}): React.JSX.Element { + return ( +
+ {left} + {right} +
+ ) +} diff --git a/src/renderer/src/components/layout/DemoModeBadge.tsx b/src/renderer/src/components/layout/DemoModeBadge.tsx new file mode 100644 index 0000000..86660a4 --- /dev/null +++ b/src/renderer/src/components/layout/DemoModeBadge.tsx @@ -0,0 +1,88 @@ +import { useConfigStore } from '@renderer/state/configStore' +import { Sparkles } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +/** + * Navbar centre-slot indicator visible whenever demo mode is on. + * + * Two clickable parts inside one orange pill: + * + * ┌──────────────────────────────────┐ + * │ ✨ Demo mode │ Set folder │ ← no valid folder + * └──────────────────────────────────┘ + * ┌──────────────────────────────────┐ + * │ ✨ Demo mode │ Exit │ ← valid folder set + * └──────────────────────────────────┘ + * + * Left part = persistent state label. Click → Settings → General (the + * full management surface). + * + * Right part = the contextual action. Lighter background pill-within- + * pill so it visually reads as a button: + * • No valid folder → "Set folder" — opens the native picker + * directly. On a successful pick, the path is persisted and demo + * mode is auto-disabled (path-first ordering avoids a welcome- + * modal flash). + * • Valid folder set → "Exit" — flips `demoMode` off in one click; + * real data resumes immediately. Useful when the user toggled + * demo on for screenshots / curiosity. + * + * Renders nothing when demo mode is off; the centre grid track of + * MainHeader collapses cleanly. + */ +export function DemoModeBadge(): React.JSX.Element { + const navigate = useNavigate() + const isValid = useConfigStore((s) => s.isValid) + const setPath = useConfigStore((s) => s.setPath) + const setDemoMode = useConfigStore((s) => s.setDemoMode) + + async function pickFolder(): Promise { + const chosen = await window.api.dialog.pickFolder() + if (!chosen) return + // Same path-first ordering as Settings → Recordings folder card, + // so the welcome trigger doesn't fire on the in-between state. + await setPath(chosen) + if (useConfigStore.getState().demoMode) await setDemoMode(false) + } + + async function exitDemo(): Promise { + await setDemoMode(false) + } + + const actionLabel = isValid ? 'Exit' : 'Set folder' + const actionHandler = isValid ? exitDemo : pickFolder + + return ( +
+ + {/* Inner action chip. Slightly lifted (background, not transparent) + so it reads as a button-within-button. Borderless on the left so + it merges into the outer pill's right edge. */} + +
+ ) +} diff --git a/src/renderer/src/components/layout/MainHeader.tsx b/src/renderer/src/components/layout/MainHeader.tsx index 8a1dca7..26130ee 100644 --- a/src/renderer/src/components/layout/MainHeader.tsx +++ b/src/renderer/src/components/layout/MainHeader.tsx @@ -1,8 +1,10 @@ import { IconButton } from '@renderer/components/ui/IconButton' +import { useConfigStore } from '@renderer/state/configStore' import { useLayoutStore } from '@renderer/state/layoutStore' import { useRangeStore } from '@renderer/state/rangeStore' import { PanelLeft } from 'lucide-react' import { Link } from 'react-router-dom' +import { DemoModeBadge } from './DemoModeBadge' import { RangePill } from './RangePill' export interface Breadcrumb { @@ -54,18 +56,28 @@ export function MainHeader({ const peekActive = useLayoutStore((s) => s.peekActive) const toggleSidebar = useLayoutStore((s) => s.toggleSidebar) const setPeek = useLayoutStore((s) => s.setPeek) + const demoMode = useConfigStore((s) => s.demoMode) // Render the navbar's own toggle only when the sidebar is fully out of // the way. While peeking the sidebar covers the same area and provides // its own toggle, so a navbar copy would be redundant. const showToggle = !sidebarOpen && !peekActive return ( + // Three-column grid: [1fr title][auto center][1fr right]. The two + // outer fr columns balance around the centre so the demo-mode badge + // sits truly centred between the sidebar's right edge and the + // window's right edge, regardless of how long the breadcrumb is. + // When the centre is empty (demo off) the layout still works — the + // auto column collapses, the title and right slot pull naturally.
- {showToggle && ( -
+ {/* `no-drag` on the title so breadcrumb segments are clickable + (the parent header is a drag region, which otherwise eats clicks + and turns them into window drags). */} +
+ {showToggle && ( + )} +
+
- )} - {/* `no-drag` on the title so breadcrumb segments are clickable - (the parent header is a drag region, which otherwise eats clicks - and turns them into window drags). */} -
-
- {showRange ? ( -
+ {/* Centre slot — only the DemoModeBadge claims it today. Wrapped + in a div so the empty case still occupies the grid track and + the right slot doesn't shift when demo flips. */} +
+ {demoMode && } +
+
+ {showRange ? ( -
- ) : rightAction ? ( -
- {rightAction} -
- ) : null} + ) : rightAction ? ( + rightAction + ) : null} +
) } diff --git a/src/renderer/src/components/layout/RootLayout.tsx b/src/renderer/src/components/layout/RootLayout.tsx index 2e3ed02..a9cbeab 100644 --- a/src/renderer/src/components/layout/RootLayout.tsx +++ b/src/renderer/src/components/layout/RootLayout.tsx @@ -1,4 +1,4 @@ -import { FirstRunModal } from '@renderer/components/FirstRunModal' +import { WelcomeModal } from '@renderer/components/WelcomeModal' import { useGlobalShortcut } from '@renderer/hooks/useGlobalShortcut' import { formatTimestamp } from '@renderer/lib/format' import { useConfigStore } from '@renderer/state/configStore' @@ -76,6 +76,8 @@ export function RootLayout(): React.JSX.Element { const setPeek = useLayoutStore((s) => s.setPeek) const configHydrated = useConfigStore((s) => s.hydrated) const configValid = useConfigStore((s) => s.isValid) + const demoMode = useConfigStore((s) => s.demoMode) + const welcomeForceShow = useConfigStore((s) => s.welcomeForceShow) const autoHideSidebar = useConfigStore((s) => s.autoHideSidebar) const recordings = useDataStore((s) => s.recordings) const location = useLocation() @@ -197,11 +199,14 @@ export function RootLayout(): React.JSX.Element { title={title} leftPad={leftPad} rightPad={rightPad} - // Hide the date-range pill on the single-transcript page — the - // window only ever shows one recording, so a filter pill would - // be meaningless. Every other route (including the maximised - // chart view) keeps the pill. - showRange={!transcriptRec} + // Hide the date-range pill on routes where it'd be noise: + // • single-transcript page — only one recording in view, the + // filter has nothing to act on + // • settings — has its own segmented tab strip and there's no + // time-windowed content to filter + // Every other route (including the maximised chart view) keeps + // the pill. + showRange={!transcriptRec && location.pathname !== '/settings'} // Single-transcript page swaps the range pill for a "Copy // transcript" action pill in the same slot. The action used to // live as an IconButton in the DetailsCard header; moving it @@ -233,7 +238,13 @@ export function RootLayout(): React.JSX.Element { - {configHydrated && !configValid && } + {/* Welcome appears when: + • the user has no valid recordings folder AND demo isn't on + (the natural "fresh install" trigger), OR + • the user just hit Reset app in Settings (welcomeForceShow) + — guarantees the flow is re-entered even when the default + SuperWhisper path is detected on disk. */} + {configHydrated && ((!configValid && !demoMode) || welcomeForceShow) && } ) } diff --git a/src/renderer/src/components/ui/SegmentedTabs.tsx b/src/renderer/src/components/ui/SegmentedTabs.tsx new file mode 100644 index 0000000..1ffb171 --- /dev/null +++ b/src/renderer/src/components/ui/SegmentedTabs.tsx @@ -0,0 +1,74 @@ +import { cn } from '@renderer/lib/cn' + +interface SegmentedTab { + id: T + label: string +} + +interface SegmentedTabsProps { + value: T + onChange: (next: T) => void + options: ReadonlyArray> + ariaLabel?: string + className?: string +} + +/** + * Compact segmented control used inside content areas (e.g. the Settings + * page tab strip). Visual rhyme with the navbar RangePill — same lifted + * "active pill" treatment so the chrome reads as one consistent design + * language across the app. + * + * Generic on the option id type so call sites get string-literal + * narrowing for free: `useState<'general' | 'data' | 'about'>('general')` + * flows through without casts. + */ +export function SegmentedTabs({ + value, + onChange, + options, + ariaLabel, + className +}: SegmentedTabsProps): React.JSX.Element { + return ( +
+ {options.map((opt) => { + const active = value === opt.id + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 0dbd18a..0600e11 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -69,6 +69,23 @@ export function truncate(s: string, max: number): string { return s.slice(0, Math.max(0, max - 1)).trimEnd() + '…' } +/** + * Middle-ellipsis truncation. Keeps the head AND tail visible so a long + * filesystem path collapses in its middle rather than losing the + * filename. Used by the welcome modal's folder picker to fit a default + * SuperWhisper path into one row without losing "/recordings" at the end. + */ +export function middleTruncate(s: string, max: number): string { + if (s.length <= max) return s + // Reserve one slot for the ellipsis; split the rest of the budget + // slightly weighted toward the tail so paths still surface their + // final segment (the filename / leaf folder). + const budget = max - 1 + const headLen = Math.max(1, Math.floor(budget / 2)) + const tailLen = Math.max(1, budget - headLen) + return `${s.slice(0, headLen)}…${s.slice(s.length - tailLen)}` +} + /** Format X-axis date ticks for activity charts. */ export function formatActivityTick(raw: unknown): string { const d = new Date(String(raw)) diff --git a/src/renderer/src/screens/Settings.tsx b/src/renderer/src/screens/Settings.tsx index 446b784..51f20ce 100644 --- a/src/renderer/src/screens/Settings.tsx +++ b/src/renderer/src/screens/Settings.tsx @@ -1,5 +1,6 @@ import { AppearancePicker } from '@renderer/components/settings/AppearancePicker' import { SettingsCard } from '@renderer/components/settings/SettingsCard' +import { SegmentedTabs } from '@renderer/components/ui/SegmentedTabs' import { Switch } from '@renderer/components/ui/Switch' import { cn } from '@renderer/lib/cn' import { formatCompact, formatDurationSec } from '@renderer/lib/format' @@ -14,6 +15,7 @@ import { Folder, Info, RefreshCw, + RotateCcw, Settings as SettingsIcon, Sparkles, Sun, @@ -22,39 +24,62 @@ import { import { useEffect, useMemo, useState } from 'react' import type { UpdaterStatus } from '../../../preload/api' +type SettingsTab = 'general' | 'data' | 'about' + +const SETTINGS_TABS: ReadonlyArray<{ id: SettingsTab; label: string }> = [ + { id: 'general', label: 'General' }, + { id: 'data', label: 'Data' }, + { id: 'about', label: 'About' } +] + const GITHUB_URL = 'https://github.com/aicayzer/superwhisper-analytics' const DISCLAIMER = 'Personal project, not affiliated with SuperWhisper. Shared in case it’s useful to anyone else.' /** - * Card-based Settings page. Each section is a `` with an - * icon header + body content. Sections, in order: + * Settings page, organised into three tabs: + * + * • General — Recordings folder, Appearance, Transcript view-mode, + * Demo data toggle. The everyday user preferences. + * • Data — Indexing toggles + Filler-phrase dictionary. Editorial + * control over what gets counted and how. + * • About — Version, License, Source, Updates. * - * 1. Recordings folder — path, indexed stats, Choose/Reindex. - * 2. Appearance — preview cards (Light / System / Dark). - * 3. Indexing — Watch folder + Index transcripts only toggles. - * 4. Transcripts — segment / inline view-mode preference. - * 5. Dictionary — searchable, scrollable filler-phrase list. - * 6. About — version (live from package.json), GitHub link, MIT, - * and an "unaffiliated" disclaimer. + * The tab strip lives at the top using the same lifted-pill segmented + * visual as the navbar RangePill — keeps the chrome consistent. The + * navbar's range pill hides on this route (set in RootLayout) so the + * date-window control doesn't compete with the tab strip. */ export function Settings(): React.JSX.Element { - // No top header: the navbar already shows "Settings" in the title row, - // and the local-companion tagline now lives inside the About card so - // we don't burn vertical space on a duplicate. + const [tab, setTab] = useState('general') return (
- - - - - - {/* Demo data sits second-to-last — it's a toggle for showcasing - the app rather than a primary preference, so it shouldn't - compete for attention with Indexing / Transcripts / Dictionary - higher up in the list. */} - - + + value={tab} + onChange={setTab} + options={SETTINGS_TABS} + ariaLabel="Settings sections" + /> + {tab === 'general' && ( + <> + + + + + + )} + {tab === 'data' && ( + <> + + + + )} + {tab === 'about' && ( + <> + + + + )}
) } @@ -66,6 +91,8 @@ function RecordingsCard(): React.JSX.Element { const isValid = useConfigStore((s) => s.isValid) const isInsideHome = useConfigStore((s) => s.isInsideHome) const setPath = useConfigStore((s) => s.setPath) + const demoMode = useConfigStore((s) => s.demoMode) + const setDemoMode = useConfigStore((s) => s.setDemoMode) const count = useDataStore((s) => s.count) const indexedAt = useDataStore((s) => s.indexedAt) const loading = useDataStore((s) => s.loading) @@ -84,7 +111,14 @@ function RecordingsCard(): React.JSX.Element { async function choose(): Promise { const chosen = await window.api.dialog.pickFolder() - if (chosen) await setPath(chosen) + if (!chosen) return + // Order matters: persist the path first, THEN flip demo off. The + // reverse order briefly leaves the renderer in (!demoMode && !path) + // which fires the welcome-modal trigger and causes a flash before + // setPath resolves. Path-first means the in-between state is + // (demoMode && path-set) which the trigger ignores. + await setPath(chosen) + if (demoMode) await setDemoMode(false) } const busy = loading || reindexing @@ -256,8 +290,6 @@ function IndexingCard(): React.JSX.Element { const setWatchFolder = useConfigStore((s) => s.setWatchFolder) const transcriptsOnly = useConfigStore((s) => s.transcriptsOnly) const setTranscriptsOnly = useConfigStore((s) => s.setTranscriptsOnly) - const autoHideSidebar = useConfigStore((s) => s.autoHideSidebar) - const setAutoHideSidebar = useConfigStore((s) => s.setAutoHideSidebar) return ( void setTranscriptsOnly(next)} /> - void setAutoHideSidebar(next)} - />
) @@ -495,16 +521,17 @@ function AboutCard(): React.JSX.Element { } // Layout: - // 1. Tagline — what the app is in one sentence. + // 1. Tagline + disclaimer combined into one paragraph at the top — + // saves a trailing legal-style footer and keeps the card tidy. // 2. Version / License / Source / Updates in a single label-value // table. The Updates row carries the current updater state + // a manual "Check now" trigger. - // 3. Disclaimer footnote at the bottom, separated by a divider so - // the legal-style copy isn't mixed with the rest of the card. + // Reset lives in its own card below — see ResetAppCard. return (

- SuperWhisper Analytics is a local companion. Nothing leaves your machine. + SuperWhisper Analytics is a local companion. Nothing leaves your machine.{' '} + {DISCLAIMER}

@@ -533,9 +560,71 @@ function AboutCard(): React.JSX.Element {
-

- {DISCLAIMER} -

+ + ) +} + +// ---------- Reset app --------------------------------------------------- + +/** + * Standalone card for the "Reset app" affordance. Lived inside AboutCard + * previously but the row was crowding the version table and breaking + * the visual rhythm of the rest of Settings — separating it gives the + * action room to breathe and signals that it's a heavier operation than + * the other About metadata. + */ +function ResetAppCard(): React.JSX.Element { + const resetApp = useConfigStore((s) => s.resetApp) + const [confirmReset, setConfirmReset] = useState(false) + const [resetting, setResetting] = useState(false) + + async function doReset(): Promise { + setResetting(true) + try { + await resetApp() + } finally { + setResetting(false) + setConfirmReset(false) + } + } + + return ( + +
+ {confirmReset ? ( +
+ + +
+ ) : ( + + )} +
) } diff --git a/src/renderer/src/state/configStore.ts b/src/renderer/src/state/configStore.ts index 7f55905..4c99f08 100644 --- a/src/renderer/src/state/configStore.ts +++ b/src/renderer/src/state/configStore.ts @@ -37,6 +37,13 @@ interface ConfigState { autoHideSidebar: boolean /** Has the initial round-trip completed? Gates the first-run modal. */ hydrated: boolean + /** Transient flag — when true, force the welcome modal regardless + * of whether a path is set. Set by `resetApp` so the user can + * re-test the onboarding flow on a machine where SuperWhisper is + * installed at the default location (which would otherwise be + * auto-adopted on the next hydrate). Cleared when the user + * completes the welcome flow. Not persisted. */ + welcomeForceShow: boolean /** One-shot hydrate; safe to call more than once but only the first does work. */ hydrate: () => Promise @@ -53,6 +60,12 @@ interface ConfigState { setDemoMode: (enabled: boolean) => Promise /** Toggle the auto-hide sidebar behaviour. */ setAutoHideSidebar: (enabled: boolean) => Promise + /** Wipe the persisted config back to defaults and force the welcome + * modal to re-appear. Used by Settings → About → Reset app. */ + resetApp: () => Promise + /** Clear `welcomeForceShow` — call this from the welcome modal + * after the user has picked a path or opted into demo. */ + dismissWelcomeForce: () => void } function applyStatus(status: ConfigStatus): Partial { @@ -81,6 +94,7 @@ export const useConfigStore = create((set, get) => ({ demoMode: false, autoHideSidebar: true, hydrated: false, + welcomeForceShow: false, hydrate: async () => { if (get().hydrated) return @@ -144,5 +158,20 @@ export const useConfigStore = create((set, get) => ({ set({ autoHideSidebar: enabled }) const updated = await window.api.config.setAutoHideSidebar(enabled) set(applyStatus(updated)) - } + }, + + resetApp: async () => { + const status = await window.api.config.reset() + // Apply the cleared status AND flip the force-show flag so the + // welcome modal renders even when a SuperWhisper folder is + // detected at the default location (which the modal then surfaces + // as a Recommended option). Without the flag, the welcome would + // be skipped for users with SuperWhisper installed. + set({ ...applyStatus(status), welcomeForceShow: true }) + // Refresh data so the renderer drops the previously-cached scan + // and falls back to the demo dataset behind the welcome modal. + await useDataStore.getState().hydrate() + }, + + dismissWelcomeForce: () => set({ welcomeForceShow: false }) })) diff --git a/src/renderer/src/styles/tokens.css b/src/renderer/src/styles/tokens.css index bb57377..fd8456d 100644 --- a/src/renderer/src/styles/tokens.css +++ b/src/renderer/src/styles/tokens.css @@ -63,6 +63,12 @@ --accent-blue: #0166cc; --accent-blue-bg: #edf3fb; + /* Accent orange — used by the demo-data navbar pill so the "this is + mock data" indicator reads at a glance without competing for + attention with the rest of the app. */ + --accent-orange: #c2410c; + --accent-orange-bg: #fef3e7; + /* Elevation — used only by floating overlays (sidebar, palette). Cards get their lift from a brighter --card colour, not shadow. */ --shadow-float: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px -4px rgba(0, 0, 0, 0.08); @@ -103,6 +109,9 @@ --accent-blue: #0099ff; --accent-blue-bg: #212b35; + --accent-orange: #fb923c; + --accent-orange-bg: #2a1f12; + /* Pulled right back in dark mode — the brighter --card and --floating colours carry the elevation cue. Just enough to avoid a hard outline. */ --shadow-float: 0 1px 2px rgba(0, 0, 0, 0.4); @@ -138,6 +147,8 @@ --color-accent-blue: var(--accent-blue); --color-accent-blue-bg: var(--accent-blue-bg); + --color-accent-orange: var(--accent-orange); + --color-accent-orange-bg: var(--accent-orange-bg); /* Foundation aliases (kept so layout primitives still compile) */ --color-window: var(--window);