Skip to content

Upgrade the client app UX: launch hub, first-run setup, Account/Configuration split, hint-only default - #125

Merged
alpha5611331 merged 36 commits into
mainfrom
feat/ux-upgrade
Sep 7, 2026
Merged

Upgrade the client app UX: launch hub, first-run setup, Account/Configuration split, hint-only default#125
alpha5611331 merged 36 commits into
mainfrom
feat/ux-upgrade

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #124. Supersedes #121, which GitHub closed when this branch was renamed.

The problem

Everything the app can do was reachable, and almost none of it was findable. / redirected into the control console; the console's capabilities lived in a 32px icon row; one "Settings" page answered two unrelated questions; and nothing anywhere told a new user what they had to set before their first interview. The two most expensive failures in this app — an empty profile and the wrong microphone — both present as silence rather than as errors, in the middle of a real call.

What changed

Twenty-seven commits, each buildable and testable on its own.

1. refactor(suggestions) — hint-only / full-sentence

"Professional mode" named neither of the two things it switched between, so the tooltip, the status badge and the settings row all had to explain the setting rather than label it. The modes now carry their own names: hint-only (a headline plus keyword bullets, read at a glance) and full-sentence (the answer written out as it would be spoken).

Hint-only becomes the default for a new install. Reading a paragraph of prose while an interviewer waits is the thing candidates cannot actually do, and the mode built for that should not be the one nobody finds.

Two things deliberately did not move with the rename:

  • The wire values stay normal / professional. They are the backend's contract (app/schemas/suggestion.py), it deploys separately, and renaming the TypeScript members without renaming the strings keeps the client readable without shipping the two together. Both mirrors say so.
  • An existing install keeps the mode it was on. The migration reads professionalMode one last time to seed hintOnlyMode, then scrubRetiredKey removes it — so the rename cannot silently change what current users see in the panel. test/config-store.test.mjs pins both halves.

The hotkey keeps Ctrl+Shift/Ctrl+Opt+F7; only its IPC channel, title and description change.

2. refactor(settings) — Account and Configuration, not "Settings"

The split follows a real difference, not a taxonomy:

Account Configuration
Answers Who am I How does the interview run
Holds email, full name, profile/CV, job context, password microphone (+ test), language, suggestion style, transcript panel
Shares every field is a component the wizard and the mock dialog reuse
Writes to the backend account, as one unit the local config, per control
Save button yes — a half-written profile is worse than an unedited one no — nothing can be half-applied

Configuration gains what the old Session tab never had: a microphone picker with a live level test. Choosing a device from a list said nothing about whether the machine was listening to it.

Each setting is now a component under components/custom/settings/, and the three account fields share use-account-form.ts. Both exist so the wizard renders the same controls rather than a second, drifting copy.

Also lifts the sticky header each routed page had its own copy of into page-header.tsx — including the location.key === 'default' fallback that a reload otherwise turns into a dead back button — and adopts it on Documentation and Buy Credits too.

3. feat(onboarding) — first-run setup

/onboarding asks once for the six things a first interview needs, one per step, in the order it needs them: profile, job context, language, microphone, suggestion style, transcript panel. Every step renders the same component the two settings pages use.

  • Only the profile step gates progress. The name and CV are what the control bar checks before it will start anything; letting the wizard past them would only move the failure later.
  • Skip is available throughout and records the same completion — nagging someone who has decided is worse than the state it would protect them from.
  • The account fields are written on the way out of each account step, and again on Skip. They are the only thing here the user typed, and a window closed on step four should not cost them a pasted CV.
  • Gated on onboardingCompleted, local rather than account-level because half of what the wizard sets — the microphone above all — is a property of the machine. An install that predates the wizard is migrated straight past it.

4. feat(home) — the launch hub

/ now names all five things a user comes here to do: start a mock interview, start the live assistant, Account, Configuration, Buy Credits.

Both buttons start a session — neither implements starting one. Live hands off to /main through router state, because /main's control panel owns the whole start sequence — the config and microphone checks, the headphone notice, the macOS permission gate, the save-history guard — and a second copy of that here is how the two would drift apart. Mock opens the same setup dialog the control bar uses, which collects the session's shape and hands the result to /mock-interview. In both cases the click ends in a running session, not on a console with a Start button waiting to be pressed again.

That handoff is deferred, not fired on arrival: checkCanStart reads the account config and the enumerated microphones, neither of which has resolved in the first frames after a route change, so starting immediately would greet the user with "could not load your saved configuration" for a config that was about to arrive.

5. feat(session) — stopping offers to save, then goes home

Stop only stopped. The transcript stayed in main-process memory with nothing on screen saying it was still there and nothing saying it was about to go, and the first question about saving it arrived at the start of the next session — a question about an interview the candidate had finished with an hour earlier, asked at the moment they are trying to begin a new one.

Stop now ends the session, asks whether to keep it, drops the buffers and returns home. stop is the first reason in save-history-dialog.tsx that is not a guard: the other four are asked before the destructive act and can be answered with "not now", which leaves the interview alone. This one is asked after, so it drops its Cancel and refuses Esc — an Esc that read as backing out would silently be the discard.

Deliberately not what the stop hotkey does. That one is the stealth-mode escape: it fires while the app is hidden mid-screen-share, where a modal dialog and a navigation to a dashboard are the opposite of what was asked for. It still stops, and the next Start still asks about what it left behind.

The control bar's Stop and the command palette's both route through one hook, so the two cannot drift.

6. feat(mock-interview) — the language is set where it is read

The dialog whose entire job is configuring a session showed the language as a read-only row and told the user to change it on another screen — the one setting there that could not be set where it was being read. It is now the same LanguageField the configuration page and the wizard use, editing the same stored setting, because there is only one: a mock session reads the language the live assistant does.

showVoice is new on that field and only the mock passes it. The live assistant never speaks, so whether a language has a Deepgram voice is a fact about mock interviews alone — it marks the voiceless languages in the list itself, where it is needed while choosing.

7. refactor(control-bar)/main only stops

Starting is a home-screen decision now, so the control bar's split Start button was a third way to do the same thing — with its own dropdown, and its own stored memory of which mode you last used, to answer a question the home screen asks outright. /main carries Stop and nothing else. Idle is a transient state on that route rather than its resting one (a start cancelled at the headphone notice or the permission gate, or the route opened directly), and the primary slot holds a way back home for it, because an idle console with an empty primary position is a dead end.

Three things follow from the removal rather than being separate changes:

  • The mock setup dialog moves to the home screen, its only caller now. "Practise again" on the report and the palette's mock start hand off there instead of to /main, and the mock route's other two exits follow them home.
  • lastSessionMode is removed and scrubbed from disk. It existed solely to decide what the split button did by default; leaving a key the app still writes and never reads is how config rots.
  • /main's router-state handoff narrows to the live start, the only thing that still arrives that way.

8. feat(home) — Sign out

Reachable only from the titlebar menu and the command palette before — a strange place for the one action that ends everything else the screen offers. Refused while a session is running, with the reason on the control.

9. refactor(onboarding) + fix(onboarding) — the flag moves to the account, and the wizard becomes answerable

The completion flag started as a local config key, which cannot answer the question it was asked: a user who set up on their laptop was walked through it again on their desktop, and a second account on a shared machine never saw it at all. It lives on the account now (backend#62, merged), and the local key is scrubbed rather than merely ignored.

Then a UX pass on the wizard itself, which had six things wrong with it:

  • A disabled Continue said nothing. It now names what is missing, and the "account unreachable" case gets a Retry rather than an instruction to restart the app.
  • Typing while the account loaded lost what was typed. The fetch runs to thirty seconds and the fields render immediately. Fields are disabled until it lands, and a late response no longer overwrites edited fields.
  • Enter did nothing. The step is a form; Enter advances from any single-line field.
  • Nothing said where you were. The screen names itself, the progress line names the step, completed steps read differently from the current one, and the heading takes focus on every change.
  • The titlebar offered three ways out that all looked broken. Hidden during the compulsory run; left alone for a voluntary one.
  • Skipping looked final. Configuration offers Run setup, so the button says "Skip for now" and finishing confirms itself.

And the wizard ran twice, every time. Its write to the account resolves over one IPC message and the app state carrying the result arrives over another, with nothing ordering the two — so home re-rendered on the old value the instant the wizard navigated to it and sent the user straight back in. A session-local dismissal now says "done now" while the account flag says "done"; the gate reads both.

10. fix(onboarding) — interface size joins Configuration and setup

Zoom was a control-bar button and a hotkey, which is no use to the person who needs it: this window is small on purpose so it does not cover the call, and a candidate squinting at a suggestion mid-question is not going to go hunting for a zoom control. zoom:set-factor is new — an invoke rather than a send, unlike the three beside it, so the field's readout cannot drift from the window when a value is clamped.

11. feat(mock-interview) — the question is written out as it is spoken

A whole paragraph appearing at once, followed a second or two later by a voice starting to read it, does not read as someone asking a question. The reveal is timed against the first audio chunk actually sounding rather than against the Speaking state — that state begins before the first sentence has been synthesised, so timing against it would have put the words up during the silence and made the gap worse.

Presentation only: the backend returns the question whole. It paces at roughly twice speech so the last word lands before the sentence ends, gives up waiting for audio after 2.5s so a failed synthesis cannot leave an empty row, shows everything at once under prefers-reduced-motion, and keeps the full text in the DOM for assistive technology throughout.

12. A review pass over everything since v1.7.1

The last pass reviewed this branch against the release rather than against itself, and found five
defects — four of them cross-account, on a machine two people share.

  • Sign-out left the interview in memory. It cleared the token and the account config and
    nothing else, so the transcript, the suggestions and the whole mock session survived it — and
    the close guard reads hasHistory / hasMockContent straight off that state. A second user
    signing in and closing the app was offered "Save this interview before closing?" and would
    have exported the first user's questions and spoken answers to a file.
  • Sign-out was only guarded against a live session. A mock session deliberately leaves
    runningState on Idle, so the check waved it through and the candidate could sign out from
    under an interviewer that was mid-question.
  • Sign-out did not ask. It destroys an interview that is not on disk unless it was exported —
    the definition the save-history guard already applies to Clear, Start, Stop and closing the app.
  • The setup dismissal was cleared from the index route, which only sees the sign-outs that
    happen while it is mounted. Sign out from Configuration and the next account to sign in was
    never offered setup at all — the exact hole the reset existed to close, open on every route but
    one.
  • Home and Account reported who was remembered, not who was signed in. ConfigStore.email
    is a credential the login form writes only under "remember me", so a user who declined it — on
    a shared machine, where declining is the reasonable choice — was shown "Not signed in" on the
    home screen of an app they were signed into. The account's own email is carried in app state
    now, and cleared with the rest on sign-out.

Two more from the same pass, both introduced by this branch:

  • Leaving a mock interview mid-session ended it silently, and this branch added five new
    one-click ways to leave (titlebar Home and the two settings pages, the same in the palette, and
    the palette's two Start actions). Guarded with useBlocker rather than a check on each, because
    that list grows.
  • The home screen's live start could fire before /main's config had loaded, opening the ASR
    socket with an empty session token. A hand-pressed Start was always well clear of that.

test/account.test.mjs gains ten assertions covering the pull-then-clear cycle for all three
account fields, and the absent-flag rule.

13–27. Docs and earlier review fixes

ux-conventions.md now points at the two settings pages with the rule for choosing between them, and gains one convention the work argues for: a setting the app cannot work without is not finished when it appears on a settings page — it goes in the wizard too, rendering the same component. CLAUDE.md, SPEC.md, the in-app Documentation page and the README follow.

The remaining commits are fixes found reviewing the flow end to end — see "What review caught" below.

Judgment calls

  • The wire enum was not renamed. Renaming 'professional' to 'hint_only' would have required the backend to deploy in lockstep with the client for a cosmetic gain. The TypeScript members are HintOnly / FullSentence; the strings are the contract.
  • Existing users keep full-sentence mode. The new default applies to new installs only. A rename that quietly changed what long-time users see in the panel mid-interview would be a worse bug than the one it fixed.
  • onboardingCompleted is local, not account-scoped. The cost is a second account signing in on a machine where the first has already been through the wizard: it does not run again, and that user reaches Start with an empty profile. They are not stranded — the control bar names the missing field and sends them to Account — and scoping the flag to an account trades this for a worse failure: re-running the wizard for the same person on every new machine. Documented where the flag is declared.
  • Configuration keeps a keyboard-shortcuts entry, beyond the four settings the brief names. The cheat-sheet was previously the Settings page's Shortcuts tab; dropping it entirely would have removed a surface rather than moved it.
  • The mock setup dialog stays. Home's mock button opens it rather than starting with remembered defaults: seniority, length and difficulty are the whole shape of the session, and a mock that silently reuses last week's settings is worse than one extra press.
  • /main keeps no Start at all, not even a disabled one. The idle slot holds a Home button instead: a disabled Start would imply the screen could start something, and an empty slot would strand anyone who cancelled out of the headphone notice.
  • The onboarding flag is on the account, and there is no backfill. Existing accounts read as false and are offered the wizard once, prefilled and skippable. A backfill would spare them that screen and cost a migration plus a startup query on every boot — and this release changes the default suggestion mode and adds a microphone test, which makes showing them once the better half of that trade.
  • Stopping clears the session. The save prompt is therefore the genuine last chance, and the console is empty when you return — rather than holding a finished interview that only the next Start would ask about.
  • "Full name", not "username". It is the string the prompts address the candidate by, and it is what the rest of the app already calls it. The sign-in identity (email) is shown above it, so the page carries both.

What review caught

Five holes, each fixed in its own commit rather than folded back:

  1. Signing in redirected to /main — the one route the first-run gate does not cover. The users the wizard exists for were the users who would never have seen it. Sign-in now lands on home.
  2. Finishing the wizard navigated home whether or not the completion flag was written. Home gates on that flag, so a failed write bounced the user straight back into the wizard with no explanation — a loop, not a degraded state.
  3. Skip discarded a CV that had just been pasted in, and the deferred live start could fire twice under StrictMode (the effect runs twice on mount with no render in between, so the recomputed condition was not what stopped the second run — the ref is).
  4. The palette's second Start was swallowed. The arrival intent was consumed once per mount, which was right while every request arrived with a route change and wrong the moment one could arrive without one — firing Start from the palette while already on /main is the same mount. Guarded per history entry now.
  5. Home offered "Start mock interview" during a live session, and routed the user to a screen whose only content was the main process refusing it. The card now names the reason and leaves the tab order; the live card becomes the way back to the interview it would otherwise offer to start again.
  6. An older backend would have locked the app. A deployment without onboarding_completed omits it, and the client read that as false — sending every user of that deployment into a wizard whose only two exits write through an endpoint it does not have either. Absent counts as done now: guessing wrong that way costs a screen nobody saw, the other way locks the app.
  7. A slow account pull could revert a just-finished setup, putting the wizard back in front of someone who had completed it. The write bumps the generation now, the same way saving a profile does.
  8. A voluntary Run setup was dressed as a first run — "Skip for now" as the only way out of a page the user chose to open, with the titlebar's navigation taken away.

test/mock-session-scroll.test.mjs caught the mock route's redirect target when it moved, which is what that assertion is there for.

Test plan

  • pnpm lint clean across the project
  • tsc -b (renderer) and tsc -p tsconfig.electron.json (main) both clean
  • pnpm test:main — all checks pass, including new config-store assertions for the rename migration, the key scrub, and the onboarding migration
  • pnpm run build succeeds end to end
  • Manual walkthrough in the packaged app: fresh install → sign up → sign in → wizard (all six steps, Back, Skip, mic test with a real device) → home → both launch buttons → Account save and Change Password → Configuration (mic test, language switch, style switch, transcript toggle) → Buy Credits → Ctrl+Shift+F7 and the control-bar toggle mid-session → Stop (save prompt in both formats, and discard) landing back on home with an empty console → the stop hotkey in stealth mode, confirming it does not prompt or navigate → a mock interview, confirming the question is written out in step with the voice and that a text-only language reveals immediately → Run setup from Configuration, confirming it reads as a page rather than a first run → an upgrade over an existing install, confirming the previous suggestion style survives and the wizard does not appear

alpha5611331 and others added 16 commits September 4, 2026 13:28
Nothing in the repo currently says where a new setting or action
should live, which is how core functionality ended up split across
a 32px toolbar, disconnected menu items, and hotkeys with zero
on-screen affordance. Establishes placement tiers, the control bar's
space budget, and a mandatory design-principles checklist so future
additions stay findable.
/ used to just redirect straight into /main with no page of its own,
dropping users into the dense control console with zero orientation.
Renders a real landing page instead: a primary Start Interview action,
an account/credits summary, and Settings/Documentation entry points -
the T1/T2 discovery surface the UX conventions doc calls for, applied
to the first thing a user sees.

Logged-out users still redirect to /auth/login unchanged.
…only

status-panel.tsx's "Show Hotkeys" control only ever revealed the list
on hover - nothing visible without pointing at it first, the exact
antipattern the rest of the control bar has. Converts it to a proper
dialog opened by click or a new '?' shortcut (local to this window,
not a registered global hotkey - it only needs focus, not stealth-mode
reach).

Extracts the hotkey list itself into hotkey-cheatsheet.tsx so
status-panel.tsx and documentation-dialog.tsx render the same data
instead of two independently-drifting copies.
The centerpiece fix for capabilities that were T1 (occasional) or
T3 (hotkey-only) with nothing to point at: a Cmd/Ctrl+K palette
listing navigation, session toggles, and app actions (Settings,
Documentation, Keyboard Shortcuts, theme, sign out), plus a visible
titlebar button so the entry point isn't itself hotkey-only.

The shortcut is a renderer-local keydown listener, not a registered
global hotkey - Cmd/Ctrl+K is common enough elsewhere that grabbing
it system-wide via Electron's globalShortcut would hijack it in
every other running app. Inert during stealth mode for the same
reason the control bar is: popping a dialog over a screen share
defeats the point of stealth.

Only wires actions that already have a real, renderer-callable
handler (Zustand-store-backed toggles, existing IPC calls) - hotkeys
with no renderer entry point (window placement/move/resize, capture)
stay reference-only via the Keyboard Shortcuts entry rather than
faking an action that doesn't exist.

Adds cmdk + a shadcn command.tsx wrapper (new dependency).
Configuration, Change password, and Buy Credits were three separate,
unrelated-looking rows in the titlebar menu, plus a fourth settings
surface (control-bar toggles) with no relationship to any of them.
Turns the settings dialog into a tabbed hub instead:

- Account: existing profile fields, plus a Change Password action
  (folds change-password-dialog.tsx in as a nested dialog rather
  than a separate menu item)
- Session: professional mode and transcript panel defaults
- Shortcuts: the same hotkey-cheatsheet.tsx list used elsewhere
- Billing: credits balance and a Buy Credits action

Titlebar menu trims to one guessable "Settings" entry instead of
three - the underlying actions haven't gone anywhere, just moved
somewhere a person only has to learn once.
…buttons

Capture Screenshot, Clear Captures, and Generate Suggestion were only
reachable via their hotkeys (Ctrl+Shift+F9/F10/F11) - the service
methods behind them (suggestion-action.service.ts) were fully
implemented, but nothing on screen pointed at them: no button, no
IPC handler for the renderer to call them through at all.

Adds the three missing ipcMain handlers (thin wrappers around the
existing service methods - no new business logic), exposes them via
preload, and adds icon buttons to tools-group.tsx next to the other
session tools. Window placement/move/resize/zoom/scroll stay
hotkey+palette+cheat-sheet only, correctly: those are directional,
not single-shot actions, and don't map to a one-click button the way
capture/clear/trigger do.
Both existed only as forward references when the doc was first
written; points at the real files now that command-palette.tsx and
hotkey-cheatsheet.tsx exist, and states directly that directional
hotkeys (window placement/move/resize/zoom/scroll) are correctly
hotkey+cheat-sheet-only rather than a gap the palette should fill.
Both were modals stacked on top of whatever route the user opened
them from. Converts them to routed pages (/settings, /documentation)
following the pattern pages/payment/index.tsx already established -
sticky header with a back button, content below - since they're
destinations a user goes to do a batch of related things, not
transient in-context actions.

Removes configuration-dialog.tsx, documentation-dialog.tsx, and the
use-configuration-dialog.tsx context (openConfigurationDialog is now
just navigate('/settings')); updates every call site (titlebar menu,
command palette, home page, control panel's start-checks). The
hotkey cheat-sheet stays a dialog - it's a quick glance meant not to
lose your place mid-session, not a destination.

docs/ux-conventions.md gets a new "dialog vs. page" rule so the next
addition doesn't have to guess which one it should be.
Resolves conflicts between the mock-interview feature (merged via main)
and this branch's settings/documentation dialog-to-page refactor:

- control-panel/index.tsx and titlebar.tsx: keep both branches' additions
  (command palette, mock-interview setup) and drop the dead
  useConfigurationDialog/config-for-llmModel usages main's side no longer
  needs.
- use-mock-interview-setup-form.ts (new on main) called the deleted
  useConfigurationDialog hook; switched it to navigate('/settings') to
  match this branch's dialog-to-page refactor.
- test/rtl-rendering.test.mjs still pointed at the removed
  documentation-dialog.tsx; repointed at pages/documentation/index.tsx.

Verified with tsc -b and pnpm test:main.
…entence

"Professional mode" named neither of the two things it switches between, so
the control bar's tooltip, the status badge and the settings row all had to
explain the setting rather than label it. The two modes now carry their own
names: hint-only (a headline plus keyword bullets, read at a glance) and
full-sentence (the answer written out as it would be spoken).

Hint-only becomes the default for a new install. Reading a paragraph of prose
while an interviewer waits is the thing candidates cannot actually do, and the
mode built for that should not be the one nobody finds.

An existing install keeps the mode it was already on. `professionalMode` is
read one last time by the migration to seed `hintOnlyMode`, then scrubbed, so
the rename cannot silently change what current users see in the panel.

`SuggestionMode`'s wire values stay `normal`/`professional`: they are the
backend's contract, it deploys separately, and renaming the members without
renaming the strings keeps the client readable without shipping the two
together. Both mirrors say so.

The hotkey keeps Ctrl+Shift/Ctrl+Opt+F7; only its IPC channel, title and
description change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One tabbed Settings screen answered two unrelated questions - "change
something about my account" and "change how the interview runs" - and made the
user pick the right tab before they could tell which one they were in. They are
now two destinations named after what they hold, which is what lets the home
page offer them as two buttons instead of one word that could mean either.

The split follows a real difference, not just a taxonomy. Account has a Save
button because it writes three fields to the backend as one unit and a
half-written profile is a worse state than an unedited one. Configuration has
none, because every control on it persists as it is changed and several are
meant to be changed mid-interview.

Configuration also gains what the old Session tab never had: a microphone
picker with a live test. Choosing a device from a list said nothing about
whether the machine was listening to it, which is the failure this app survives
worst - it presents as silence, ten minutes into a real call, not as an error.

Each setting is now a component under `components/custom/settings/`, and the
three account fields share `use-account-form.ts`. Both exist so the first-run
wizard can render the same controls rather than a second, drifting copy.

Also lifts the sticky header every routed page had its own copy of into
`page-header.tsx`, including the `location.key === 'default'` back-button
fallback that a reload otherwise turns into a dead button, and adopts it on
Documentation and Buy Credits as well. The Shortcuts tab becomes a button to
the cheat-sheet dialog it always rendered inline; Billing becomes the Buy
Credits page it already linked to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new account landed on a screen that assumed the six things it needed had
already been set, and said nothing about any of them. The first sign that the
profile was empty was Start refusing to run; the first sign the wrong
microphone was selected was silence during a real interview.

`/onboarding` asks for them once, one per step, in the order a first interview
needs them: profile, job context, language, microphone, suggestion style,
transcript panel. Every step renders the same component the account and
configuration pages use, so the wizard cannot teach a control that then looks
different everywhere else.

Only the profile step gates progress - the name and CV are what the control
bar checks before it will start anything, so letting the wizard past them would
only move the failure later. Skip is available throughout and records the same
completion, because nagging someone who has decided is worse than the state it
would protect them from.

The account fields are written on the way out of the context step rather than
at the end: they are the only thing here the user typed, and a window closed
halfway through should not cost them a pasted CV.

Gated on `onboardingCompleted`, which is local rather than account-level
because half of what the wizard sets - the microphone above all - is a
property of the machine, not the account. An install that predates the wizard
is migrated straight to completed: those users have already configured the app
the long way round.

The gate reads the config the previous commit moved into `MainFrame`, so it
sees a real value rather than the undefined a route-local fetch would leave it
with for the first frames - which would flash the wizard at every signed-in
user on launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The home page listed one way to start and sent everything else to a single
"Settings" button. It now names all five things a user comes here to do: start
a mock interview, start the live assistant, open their account, open their
configuration, or buy credits.

Neither launch button starts a session on this page. Live hands off to `/main`
through router state, because `/main`'s control panel owns the whole start
sequence - the config and microphone checks, the headphone notice, the macOS
permission gate and the save-history guard - and a second copy of that here is
how the two would drift apart. Mock opens the same setup dialog the control bar
uses and hands the result to `/mock-interview`, exactly as the control bar
does.

That handoff is deferred rather than fired on arrival: `checkCanStart` reads
the account config and the enumerated microphones, neither of which has
resolved in the first frames after a route change, so starting immediately
would greet the user with "could not load your saved configuration" for a
config that was about to arrive. If those never resolve, nothing happens and
the user is looking at the Start button, which is the honest outcome.

The greeting prefers the name the user actually gave over the local part of
their email address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ux-conventions.md` was written against a single Settings page and now points
at the two that replaced it, with the rule for deciding between them: Account
is who the user is, Configuration is how the interview runs, and the Save
button follows from that rather than the other way round.

Adds one convention the last two commits argue for. A setting the app cannot
work without - the profile it writes answers from, the microphone it listens
through - is not finished when it appears on a settings page, because a user
expected to discover it on their own will discover it during their first real
interview. It goes in the wizard too, as a step rendering the same component.

`CLAUDE.md` and `SPEC.md` follow the mode rename, and record the two things
that deliberately did not move with it: the wire values, which are the
backend's contract, and the migration that carries an existing user's choice
onto the renamed key before the old one is scrubbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… failed finish

Three holes found reviewing the flow end to end.

Signing in redirected to `/main`, which is the one route the first-run gate
does not cover - so the users the wizard exists for were the users who never
saw it. It now lands on home, which is also the more honest destination for
someone who has just signed in: `/main` assumes everything is already
configured.

Finishing the wizard navigated home whether or not the completion flag was
written. Home gates on that flag, so a failed write bounced the user straight
back into the wizard with no explanation - a loop, not a degraded state. A
failed write now keeps them on the step and says why.

The deferred live-assistant start could fire twice: StrictMode runs the effect
twice on mount with no render in between, so the recomputed condition was not
what stopped the second run. The ref is now re-checked inside the effect.

Also documents the shared-machine cost of a local completion flag where the
flag is declared, and adds two sections to the in-app documentation - what the
two suggestion styles are, and which of the two settings pages holds what.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Skip abandoned everything, including the CV that had just been pasted into
step one - the single most expensive thing to re-enter in the whole app, and
gone with no warning. It now saves the account fields first when they are
complete, and warns rather than blocking if that write fails: refusing to let
someone leave is the one thing Skip must never do.

The same fields are also written on the way out of the profile step, not only
the context step, so a window closed on step four does not cost them either.
Going back and forward costs one idempotent write; the alternative cost a CV.

README follows the two settings surfaces and the two suggestion styles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

alpha5611331 and others added 4 commits September 6, 2026 15:51
…e work

"Start Interview" named one action for what the home page now correctly
presents as two. The palette offers the same two, in the same words, and
routes them the same way - through `/main`'s router state, because the control
panel there owns the whole start sequence and the palette is reachable from
routes where none of it is mounted. `/main` itself stays in "Go to" as the
interview console, which is what it is.

That exposed a real bug in the handoff the previous commit added. The arrival
intent was consumed once per mount, which was correct while every request
arrived with a route change and wrong the moment one could arrive without one:
firing Start from the palette while already on `/main` is the same mount, so
the second request was silently swallowed. Guarded per history entry instead -
each navigate makes a new key, the replace clears the state so Back finds
nothing to re-trigger, and the key still stops StrictMode's double effect from
acting twice before that replace lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live assistant and a mock interview are mutually exclusive - both want the
microphone and an ASR socket, and the main process refuses the second one. The
control bar has always expressed that by offering the mock start only while
idle; home did not, so during a live session it still offered "Start mock
interview" and routed the user to a screen whose only content was the refusal.

The mock card now names the reason and leaves the tab order, and the live card
becomes the way back to the interview it would otherwise offer to start again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stop only stopped. The transcript stayed in main-process memory with nothing on
screen saying it was still there and nothing saying it was about to go, and the
first question about saving it arrived at the *start* of the next session - a
question about an interview the candidate had finished with an hour earlier,
asked at the moment they are trying to begin a new one.

Stop now ends the session, asks whether to keep it, drops the buffers and
returns to home. Asking while it is still the thing they were just doing is the
only moment the question has an obvious answer, and going home is the only
honest destination once the console behind the dialog is empty.

`stop` is the first reason in `save-history-dialog.tsx` that is not a guard.
The other four are asked before the destructive act and can be answered with
"not now", which leaves the interview alone; this one is asked after, so it
drops its Cancel and refuses Esc - an Esc that read as backing out would
silently be the discard.

Deliberately not what the stop hotkey does. That one is the stealth-mode
escape: it fires while the app is hidden mid-screen-share, where a modal dialog
and a navigation to a dashboard are the opposite of what was asked for. It
still stops, and the next Start still asks about what it left behind.

The control bar's Stop and the command palette's both route through the one
hook, so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog whose entire job is configuring a session showed the language as a
read-only row and told the user to go and change it on another screen - the one
setting there that could not be set where it was being read.

It is now the same `LanguageField` the configuration page and the first-run
wizard use, editing the same stored setting, because there is only one: a mock
session reads the language the live assistant does. Sharing the component
rather than adding a second picker is what keeps that true.

`showVoice` is new on that field and only the mock passes it. The live
assistant never speaks, so whether a language has a Deepgram voice is a fact
about mock interviews alone - it marks the voiceless languages in the list
itself, where it is needed while choosing, and keeps the existing explanation of
what happens when one is picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starting is a home-screen decision now - both kinds of session are named and
described there, and both launch buttons hand off to the surface that runs
them. The control bar's split Start button was a third way to do the same
thing, with its own dropdown and its own stored memory of which mode you last
used, all to answer a question the home screen now asks outright.

So `/main` carries Stop and nothing else. Idle is a transient state on that
route rather than its resting one - the start was cancelled at the headphone
notice or the permission gate, or the route was opened directly - and the
primary slot holds a way back to the home screen for it, because an idle
console with an empty primary position is a dead end.

Three things follow from the removal rather than being separate changes:

- The mock setup dialog moves off the control bar to the home screen, which is
  now its only caller. "Practise again" on the report and the palette's mock
  start hand off there instead of to `/main`, and the mock route's other two
  exits follow them home.
- `lastSessionMode` existed solely to decide what the split button did by
  default. Nothing reads it any more, so it is removed and scrubbed from disk
  rather than left as a key the app still writes and never consults.
- `/main`'s router-state handoff narrows to the live start, which is the only
  thing that still arrives that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331 and others added 6 commits September 6, 2026 17:39
Signing out was reachable only from the titlebar menu and the command palette -
a strange place for the one action that ends everything else the screen offers,
and one a user looking for it has no reason to open. It sits with Documentation
at the foot of the home screen now, held to the right so it does not read as
another thing to go and do.

Refused while a session is running, for the same reason the titlebar menu
refuses it and with the reason on the control: signing out tears down the
credentials the running assistant is streaming on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s Start

Comment-only. Six docstrings and inline notes still named a split Start button
on the control bar, a mock setup dialog opened from it, and a Start the user
would be left looking at on an idle console - none of which exist now. Two of
them carried real reasoning worth keeping accurate rather than deleting: why
the mock form is split out of its dialog, and why `clearAll` can guard on
`isActive()` rather than checking harder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was a local config key, which cannot answer the question it was asked. Two
cases it got wrong, in opposite directions: a user who set up on their laptop
was walked through the wizard again on their desktop, and a second account
signing in on a machine the first had already set up never saw it at all - and
reached Start with an empty profile.

The flag is a property of the account, so it lives there
(`PATCH /api/users/me/onboarding`, PowerInterviewAI/backend#62). The local key
is scrubbed rather than merely ignored: two answers to a one-answer question is
how the wizard ends up deciding by whichever was read last.

The gate waits for `interviewConfigLoaded` as well as the flag. Until the
account has actually been read this session the flag is a default rather than
an answer - acting on it sooner would flash the wizard at every signed-in user
on launch, and show it in full to anyone whose pull failed. For the same reason
the write is mirrored into app state only after the backend confirms it: an
optimistic write would let a failed save look like a finished setup until the
next launch put the wizard back.

Signing out clears it along with the rest of the account, or the next user on
the machine would inherit the previous one's answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six things that made a first-run screen worse than no first-run screen.

**A disabled Continue said nothing.** The commonest way a wizard strands
someone: they can see they cannot go on and cannot see what to do about it. It
now names what is missing - the name, the profile, or an account it could not
reach - and the unreachable case gets a Retry rather than an instruction to
restart the app.

**Typing while the account loaded lost what was typed.** The fetch runs to
thirty seconds, the fields render immediately, and filling in a form that looks
empty is the obvious thing to do. Both halves are closed: the fields are
disabled until the account lands, and the response no longer overwrites fields
the user has already edited.

**Enter did nothing.** The step is a form now, so Enter advances from any
single-line field. The two long fields are textareas and keep Enter for
newlines.

**Nothing said where you were or where you were going.** The screen names
itself, the progress line names the current step rather than only counting it,
and completed steps are distinguishable from the current one. The heading takes
focus on every step change - this is the one navigation in the app where a
button press replaces the whole page, so a keyboard or screen-reader user was
told nothing about it.

**The titlebar offered three ways to leave that all looked broken.** Home
bounced straight back, and Account and Configuration are what the wizard is in
the middle of collecting. They are hidden while it is open; Skip is the way out
and it is on the screen, where it can say what skipping costs.

**Skipping looked final.** It is not - Configuration now offers *Run setup*, and
`/onboarding` renders regardless of the flag - so the button says "Skip for
now", and finishing says "You're all set" rather than the screen simply
vanishing with nothing to confirm it took.

Also stops the footer jumping between a step with two textareas and a step with
one checkbox, and stops the microphone step telling a user with no device
plugged in to reopen a screen that already updates itself when one appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… flag

Two holes found re-reading the gate.

A deployment that predates `onboarding_completed` omits it, and the client read
that as false - so every user of that deployment would be sent to the wizard
with no way out, because Finish and Skip both write through an endpoint that
deployment does not have either. Absent now counts as done. Guessing wrong in
that direction locks the app; guessing wrong in the other costs a screen nobody
saw.

And a pull that started before Finish could land after it, reverting the flag
and putting the wizard back in front of someone who had just completed it. The
write bumps the generation now, the same way saving a profile does, so the
stale read is discarded rather than applied. The flag is also read out of the
account response before the legacy-migration branch, which returns early - a
user migrating a pre-sync CV was getting the wizard regardless of what their
account said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The wizard ran twice, every time.** Its write to the account resolves over one
IPC message and the app state carrying the result arrives over another, with
nothing ordering the two - so home re-rendered on the old value the instant the
wizard navigated to it, decided setup was still pending, and sent the user
straight back in. A session-local dismissal now says "done *now*" while the
account flag says "done"; the gate reads both, and it is cleared on sign-out so
the next account is not handed a dismissal that was never theirs.

That also gives Skip a way out that does not depend on the network. Skip means
"let me out", so it leaves whether or not the durable write landed, warning that
setup may be offered again. Finish still stays put on a failure - the user has
just answered six questions and should not be asked all six again.

**Zoom joins Configuration and the wizard.** It was a control-bar button and a
hotkey, which is no use to the person who needs it: this window is small on
purpose so it does not cover the call, and a candidate squinting at a
suggestion mid-question is not going to go hunting for a zoom control. Asked
during setup, while there is time to answer it.

`zoom:set-factor` is new and is an invoke rather than a send, unlike the three
beside it, so the field's readout cannot drift from the window when a value is
clamped. The field otherwise reads the same `zoom:level-changed` broadcast the
control bar does, so the two and the hotkeys cannot disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331 and others added 9 commits September 6, 2026 20:12
…s it

A whole paragraph appearing at once, followed a second or two later by a voice
starting to read it, does not read as someone asking a question. It reads as a
page loading and then a recording playing - which is the wrong impression for
the one screen in the app that is pretending to be an interview.

The question is now written out word by word, and the reveal is timed against
the *sound* rather than the state change. `Speaking` begins before there is any
audio, because the first sentence still has to be synthesised, so timing the
reveal against the state would have put the words up during that silence and
made the gap worse rather than better. `mockTtsService` announces the moment its
first chunk actually starts playing, and the reveal starts there.

Presentation only: the backend returns a question whole, the text is complete in
state throughout, and only how much of it is on screen changes. It paces at
roughly twice speech so the last word is up well before the sentence ends - at
speech pace the candidate could never read ahead, which is worse than not
animating at all - and a long question speeds up rather than taking
proportionally longer.

Three ways it declines to get in the way: `prefers-reduced-motion` shows the
whole question at once, the wait for audio gives up after 2.5s so a slow or
failed synthesis cannot leave an empty row, and the full text is always in the
DOM for assistive technology with only the unrevealed part hidden from sight.
Once it is the candidate's turn the whole question stays on screen to be read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eveal

Three things the last four commits changed that the docs still described the old
way: setup now has seven steps rather than six, Configuration holds interface
size, and the gate reads a session-local dismissal alongside the account flag -
which is worth writing down where it is, because it looks redundant with the
flag until you know the two IPC messages are unordered.

Adds a short section on how a mock question reaches the screen, since "the
backend streams it" is the obvious wrong conclusion to draw from watching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st run

Configuration's *Run setup* lands on the same screen as a first launch, and the
screen did not know the difference. It called itself first-time setup, offered
"Skip for now" as the only way out of something the user had chosen to open,
and the titlebar hid Home, Account and Configuration - so a user who opened the
guide to change one setting had to press Skip to leave the app's own settings
page.

The compulsory run keeps all of that, because there it is honest: setup is not
finished, Home does bounce straight back, and Skip does have a cost worth
naming. A voluntary run is an ordinary page - "Setup guide", "Close", and the
navigation left where it was.

Also adds `currentQuestion.hasAudio` to the mock transcript's turn memo, which
now reads it. `speechFailed` moves it for the question already on screen, and it
currently rides along on a state change in the same broadcast - which is exactly
why leaving it out would stay invisible until the day it did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects the mock-interview feature introduced, found reviewing everything
since v1.7.1.

**The next user on a shared machine inherited the previous one's interview.**
Sign-out cleared the session token and the account config and nothing else, so
the transcript, the suggestions and the whole mock session stayed in
main-process memory - and the close guard reads `hasHistory` /`hasMockContent`
off exactly that state. A second user signing in and closing the app was offered
"Save this interview before closing?" and would have exported the first user's
questions and spoken answers to a file. Sign-out now drops all of it, including
a mock session still running, which `clearAll` will not touch by default and
which this caller opts into.

**Sign-out was only guarded against a live session.** A mock session
deliberately leaves `runningState` on Idle - it hides no window surfaces and
holds no ASR socket - so the existing check waved it through, and the candidate
could sign out from under an interviewer that was mid-question, tearing down the
token its next request needs and leaving the microphone gate to whatever
happened to release it. Both sign-out controls now treat a mock session as a
running one, as does the Account entry beside them.

And sign-out asks first. It destroys an interview that has not been written to
disk unless it was exported, which is the definition the save-history guard
already applies to Clear, Start, Stop and closing the app - it was simply the
one path that did it silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The session-local dismissal was cleared from the index route, which only sees
the sign-outs that happen while it is mounted. Sign out from Configuration - or
have a token expire on any other screen - and it stayed set, so the next account
to sign in during that run of the app was never offered setup at all. Exactly
the hole the reset existed to close, left open on every route but one.

Watched from `MainFrame` now, which is mounted for every route and so sees every
sign-out, including the ones main declares itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving `/mock-interview` mid-session ends it outright, scoring whatever was
answered and dropping the rest. That was defensible when the route had one way
out. It is not now: this branch added Home and the two settings pages to the
titlebar menu, the same entries to the command palette, and the palette's own
two Start actions - so a live interview became a single click from over, with
nothing asked and nothing said.

Guarded with `useBlocker` rather than a check on each of those, because that
list is exactly the kind that grows without anyone remembering it exists. This
catches the next one too.

Signing out is deliberately not blocked: `isLoggedIn` going false is main saying
the session is over, usually an expired token, and there is nothing to stay for.
The report screen's own two exits are unaffected - a finished session is not an
active one - and closing the app stays with the window-close guard, which is
what covers Alt+F4, the taskbar button and Cmd+Q.

Closes the follow-up the module docstring has been carrying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Home and the account page both reported the signed-in user from
`ConfigStore.email`. That is not an identity, it is a credential: the login form
writes it only when "remember me" is ticked, and writes an empty string when it
is not. So a user who declined "remember me" - a reasonable thing to do on a
shared machine, which is exactly where it matters - was shown "Not signed in" on
the home screen of an app they were signed into, and "Loading..." forever on
their own account page.

The account's own email is now carried in app state beside the rest of what the
account read establishes, and everything that displays who is signed in reads
that. It is cleared with the rest on sign-out, for the same reason the profile
and the onboarding flag are: on a shared machine the next user would otherwise
be shown the previous one's address.

`test/account.test.mjs` pins that pull-then-clear cycle for all three fields,
and pins that an absent `onboarding_completed` counts as done - the one that
would lock every user of an older deployment into a wizard with no way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`startAssistant` reads the session token, the microphone and the language
straight off the config store, which is undefined until `config:get` resolves. A
hand-pressed Start was always well clear of that. A start that fires on arrival
- which is what the home screen's launch button now does - is not, and the
failure it lands on is an ASR socket opened with an empty token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are the kind of rule that reads as an arbitrary restriction to whoever
meets it next and gets removed: why sign-out clears main-process state, why it
is refused during a mock session that leaves `runningState` on Idle, and why
leaving the mock route asks before it does what it has always done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331
alpha5611331 merged commit cebcb1c into main Sep 7, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the feat/ux-upgrade branch September 7, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The client app is hard to find your way around

1 participant