feat(onboarding): the branch question becomes its own journey step (#1615)

Founder feedback from a real signup: the done screen stacked the welcome,
the 8-row company profile card AND the branch question, pushing the
question below the fold, and the tiny favicons-in-ellipses provider chips
looked bad.

The done screen now ends in a revealed Fortsatt action; a new 'source'
step at the existing KLART station (same station grammar as momsyn/moms
under MOMSEN) shows only "Var fanns bokforingen innan?" with redesigned
provider tiles: a 2-column grid of generously sized choices, each with
the real logo on a small white bordered mark (the LogoMark grammar from
NewUserChecklist), SIE-fil and new-business as equal-weight text tiles,
flat hover, no lift. Everything fits one viewport.

Behavior preserved exactly: mode='first' only (reducer-guarded
DONE_CONTINUE), the single-choice latch, fire-and-forget keepalive PATCH
/api/onboarding/state, captureBranch analytics, branchDestination
routing, and the quiet skip escape.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-14 12:25:23 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent fb3feaea12
commit 0938646693
7 changed files with 150 additions and 63 deletions
+1
View File
@@ -984,5 +984,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages.
[2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust.
[2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule.
[2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded).
[2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency.
[2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time.
@@ -180,12 +180,36 @@ export default function OnboardingJourney({
if (initialOrgNumber) submitOrg(initialOrgNumber)
}, [initialOrgNumber, submitOrg])
// One choice only: rapid clicks on different chips must not race two
// PATCHes (last-write-wins could persist the wrong path after navigation).
const onBranch = useCallback(
(choice: BranchChoice) => {
if (branchChosenRef.current) return
branchChosenRef.current = true
const dest = branchDestination(choice)
if (dest.path) {
// Fire-and-forget: the checklist path is a nicety, routing is
// the point. A lost PATCH just leaves the Hem checklist
// unpathed; it must never block or delay the navigation.
fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dest.path }),
keepalive: true,
}).catch(() => logError('branch path persist failed', { choice }))
}
captureBranch(choice)
router.push(dest.href)
},
[router],
)
// A short thinking beat between questions.
const prevStep = useRef(state.step)
useEffect(() => {
if (prevStep.current === state.step) return
prevStep.current = state.step
if (state.step === 'done' || state.submitting) return
if (state.step === 'done' || state.step === 'source' || state.submitting) return
setThinking(true)
const timer = window.setTimeout(() => setThinking(false), 420)
return () => window.clearTimeout(timer)
@@ -257,7 +281,7 @@ export default function OnboardingJourney({
/* ── derived display ──────────────────────────────────────────── */
const orbState: OrbState = state.step === 'done'
const orbState: OrbState = state.step === 'done' || state.step === 'source'
? 'check'
: state.submitting
? narration === null
@@ -662,29 +686,12 @@ export default function OnboardingJourney({
momsAnswer={momsAnswer}
methodAnswer={methodAnswer}
onOpen={() => router.push('/')}
onBranch={(choice) => {
// One choice only: rapid clicks on different chips must not
// race two PATCHes (last-write-wins could persist the wrong
// path after navigation).
if (branchChosenRef.current) return
branchChosenRef.current = true
const dest = branchDestination(choice)
if (dest.path) {
// Fire-and-forget: the checklist path is a nicety, routing is
// the point. A lost PATCH just leaves the Hem checklist
// unpathed; it must never block or delay the navigation.
fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dest.path }),
keepalive: true,
}).catch(() => logError('branch path persist failed', { choice }))
}
captureBranch(choice)
router.push(dest.href)
}}
onContinue={() => dispatch({ type: 'DONE_CONTINUE' })}
/>
)
case 'source':
return <SourceStep t={t} onBranch={onBranch} />
}
}
@@ -762,7 +769,10 @@ export default function OnboardingJourney({
<div className="jny-balance" aria-hidden="true" />
<div className="jny-backrow">
{state.history.length > 0 && state.step !== 'done' && !state.submitting ? (
{state.history.length > 0 &&
state.step !== 'done' &&
state.step !== 'source' &&
!state.submitting ? (
<button type="button" className="jny-btn-quiet" onClick={() => dispatch({ type: 'BACK' })}>
&lsaquo; {t('back')}
</button>
@@ -1007,7 +1017,7 @@ function DoneStep({
momsAnswer,
methodAnswer,
onOpen,
onBranch,
onContinue,
}: {
t: TFn
state: JourneyState
@@ -1016,7 +1026,7 @@ function DoneStep({
momsAnswer: string | null
methodAnswer: string | null
onOpen: () => void
onBranch: (choice: BranchChoice) => void
onContinue: () => void
}) {
const s = state.settings
const shortName = (s.company_name ?? '').split(' ')[0] || ''
@@ -1068,36 +1078,10 @@ function DoneStep({
) : null}
{mode === 'first' ? (
<Reveal delay={notes.length > 0 ? 1400 + notes.length * 260 : 1200}>
<div className="jny-done-branch">
<h2 className="jny-done-q">
<InkText text={t('journey_done_source_title')} />
</h2>
<p className="jny-done-sub">{t('journey_done_source_sub')}</p>
<div className="jny-chips">
{BRANCH_PROVIDERS.map((p) => (
<button
key={p.id}
type="button"
className="jny-pick"
onClick={() => onBranch(p.id)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.logo} alt="" />
{p.name}
</button>
))}
<button type="button" className="jny-pick" onClick={() => onBranch('sie')}>
{t('journey_done_source_sie')}
</button>
<button type="button" className="jny-pick" onClick={() => onBranch('fresh')}>
{t('journey_done_source_fresh')}
</button>
</div>
<div className="jny-qactions">
<button type="button" className="jny-btn-quiet" onClick={() => onBranch('skip')}>
{t('journey_done_source_skip')}
</button>
</div>
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onContinue}>
{t('journey_done_continue')}
</button>
</div>
</Reveal>
) : (
@@ -1111,7 +1095,43 @@ function DoneStep({
)
}
/** Delayed mount so the branch question enters (with the standard .jny-qstep
/**
* The branch question as its own step, sharing the Klart station with the
* welcome screen (same station grammar as momsyn/moms under Momsen).
* Providers and the SIE file share one grid of generously sized tiles; the
* provider tiles carry the real logo on a small white mark (the LogoMark
* grammar from NewUserChecklist), the text answers stay equal-weight tiles.
*/
function SourceStep({ t, onBranch }: { t: TFn; onBranch: (choice: BranchChoice) => void }) {
return (
<Question title={t('journey_done_source_title')} sub={t('journey_done_source_sub')}>
<div className="jny-srcgrid">
{BRANCH_PROVIDERS.map((p) => (
<button key={p.id} type="button" className="jny-srcpick" onClick={() => onBranch(p.id)}>
<span className="jny-srcmark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.logo} alt="" />
</span>
{p.name}
</button>
))}
<button type="button" className="jny-srcpick is-text" onClick={() => onBranch('sie')}>
{t('journey_done_source_sie')}
</button>
<button type="button" className="jny-srcpick is-text is-span" onClick={() => onBranch('fresh')}>
{t('journey_done_source_fresh')}
</button>
</div>
<div className="jny-qactions">
<button type="button" className="jny-btn-quiet" onClick={() => onBranch('skip')}>
{t('journey_done_source_skip')}
</button>
</div>
</Question>
)
}
/** Delayed mount so the continue action enters (with the standard .jny-qstep
* rise) only after the profile card and notes have finished settling. */
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
const [on, setOn] = useState(false)
+24 -6
View File
@@ -270,11 +270,29 @@
.jny *, .jny *::before, .jny *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ── done-screen branch question ─────────────────────────────────── */
.jny-done-branch { margin-top: 34px; }
.jny-done-q { font-family: var(--font-display); font-size: 20px; font-weight: 400; margin: 0 0 4px; letter-spacing: -0.01em; }
.jny-done-sub { font-size: 12.5px; color: hsl(var(--muted-foreground)); margin: 0 0 18px; }
.jny-pick img { width: 16px; height: 16px; object-fit: contain; border-radius: 4px; background: #fff; vertical-align: -3px; margin-right: 8px; }
/* ── source step: where were the books before ────────────────────── */
/* One grid, equal-weight tiles. Providers carry a real logo on a small
white mark (the LogoMark grammar); SIE and new-business are text tiles
in the same shape. Hover is a flat background shift, no lift. */
.jny-srcgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; max-width: 440px; margin: 0 auto; }
.jny-srcpick {
display: flex; align-items: center; gap: 12px;
min-height: 52px; padding: 10px 16px;
border: 1px solid hsl(var(--border)); border-radius: 8px;
background: hsl(var(--background));
font-family: inherit; font-size: 13.5px; color: inherit; text-align: left;
cursor: pointer;
transition: background-color 150ms var(--ease-out);
}
.jny-srcpick:hover { background: hsl(var(--secondary) / 0.6); }
.jny-srcpick.is-text { justify-content: center; text-align: center; }
.jny-srcpick.is-span { grid-column: 1 / -1; }
.jny-srcmark {
flex: none; display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; overflow: hidden;
border: 1px solid hsl(var(--border)); border-radius: 4px; background: #fff;
}
.jny-srcmark img { width: 18px; height: 18px; object-fit: contain; }
@media (max-height: 780px) {
.jny-done-branch { margin-top: 22px; }
.jny-srcpick { min-height: 46px; padding: 8px 14px; }
}
@@ -461,6 +461,41 @@ describe('journeyReducer: server errors', () => {
})
})
describe('journeyReducer: branch question step (source)', () => {
function atDone(mode: 'first' | 'add' = 'first'): JourneyState {
return run(
initJourney({ mode }),
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
{ type: 'FY_CALENDAR_CONFIRMED' },
{ type: 'MOMS_PERIOD_PICKED', period: 'quarterly' },
{ type: 'METHOD_PICKED', method: 'accrual' },
{ type: 'SUBMIT_SUCCEEDED' },
)
}
it('continue moves the done screen to the source step, still on the Klart station', () => {
const s = journeyReducer(atDone(), { type: 'DONE_CONTINUE' })
expect(s.step).toBe('source')
expect(stationOfStep(s.step)).toBe(4)
})
it("mode='add' never reaches the source step", () => {
const s = atDone('add')
expect(journeyReducer(s, { type: 'DONE_CONTINUE' })).toBe(s)
})
it('is ignored anywhere but the done screen', () => {
const s = manualAbAtFy()
expect(journeyReducer(s, { type: 'DONE_CONTINUE' })).toBe(s)
})
it('Back from the source step restores the done screen', () => {
const s = run(atDone(), { type: 'DONE_CONTINUE' }, { type: 'BACK' })
expect(s.step).toBe('done')
})
})
describe('journeyReducer: robustness', () => {
it('ignores a LOOKUP_RESULT when no lookup is pending', () => {
const s = initJourney()
+11
View File
@@ -40,6 +40,7 @@ export type JourneyStep =
| 'moms'
| 'method'
| 'done'
| 'source'
export type JourneyStation = 0 | 1 | 2 | 3 | 4
@@ -59,6 +60,7 @@ const STATION_OF: Record<JourneyStep, JourneyStation> = {
moms: 2,
method: 3,
done: 4,
source: 4,
}
export function stationOfStep(step: JourneyStep): JourneyStation {
@@ -128,6 +130,7 @@ export type JourneyAction =
| { type: 'METHOD_PICKED'; method: 'accrual' | 'cash' }
| { type: 'SUBMIT_SUCCEEDED' }
| { type: 'SUBMIT_FAILED'; code: 'org_number_invalid' | 'period_invalid' | 'generic' }
| { type: 'DONE_CONTINUE' }
| { type: 'BACK' }
| { type: 'STATION_JUMP'; station: 0 | 1 | 2 | 3 }
@@ -475,6 +478,14 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
case 'SUBMIT_SUCCEEDED':
return go(stay(state, { submitting: false }), 'done')
case 'DONE_CONTINUE': {
// Welcome screen → the branch question ("Var fanns bokföringen
// innan?") as its own step. First-company flow only: mode='add'
// ends on the done screen with "Öppna appen".
if (state.step !== 'done' || state.mode !== 'first') return state
return go(state, 'source')
}
case 'SUBMIT_FAILED': {
const cleared = stay(state, { submitting: false })
if (action.code === 'org_number_invalid') {
+1
View File
@@ -1623,6 +1623,7 @@
"journey_note_vat_watch": "We watch the SEK 120,000 threshold and let you know in good time.",
"journey_note_ceased": "The company is marked as deregistered at Bolagsverket.",
"journey_open_app": "Open Accounted",
"journey_done_continue": "Continue",
"journey_done_source_title": "Where were the books before?",
"journey_done_source_sub": "Your history comes along, down to the last öre.",
"journey_done_source_sie": "SIE file",
+1
View File
@@ -1623,6 +1623,7 @@
"journey_note_vat_watch": "Vi bevakar 120 000-gränsen och säger till i god tid.",
"journey_note_ceased": "Företaget är markerat som avregistrerat hos Bolagsverket.",
"journey_open_app": "Öppna Accounted",
"journey_done_continue": "Fortsätt",
"journey_done_source_title": "Var fanns bokföringen innan?",
"journey_done_source_sub": "Historiken följer med hit, ner till sista öret.",
"journey_done_source_sie": "SIE-fil",