feat(onboarding): journey PR B — reducer, orb, track, question primitives (behind /sandbox demo) (#1145)

* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)

First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.

- Move deriveFirstYearDefaults + parseStartMonthDay out of
  WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
  them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
  ar, first year short/extended, EF calendar-year rule, period names,
  BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
  Lens-backed /lookup, typed outcomes (found / not_found / disabled /
  error / aborted), never throws. Fixes the 403/404 conflation: the
  dispatcher's 404 ("Extension not found") and feature-flag 503
  (EXTENSION_DISABLED) now degrade silently instead of rendering as
  "company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)

Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.

Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.

34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)

Ports the founder-approved concept (artifact c82c9358) to React:

- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
  and the monogram finale (glyph sampled live from --font-display). Own
  component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
  reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
  keyboard-accessible jump-back buttons; answers mirrored to an aria-live
  region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
  ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
  JourneyDatePicker (year -> month by name -> day), AddressFields
  (Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
  incl. the no-scroll composition (100dvh + optical-centering balance
  spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
  demo data only: this page makes ZERO TIC calls.

i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log journey reducer location decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>

The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 13:32:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f9ef8913ae
commit b771c1f923
13 changed files with 2273 additions and 0 deletions
@@ -0,0 +1,80 @@
'use client'
import { useRef, useState, type ReactNode } from 'react'
/**
* Three-part address entry (street / postal code / city) with Enter
* chaining field to field and submitting from the last one. Skippable:
* the address is optional in onboarding, exactly like the wizard.
*/
interface AddressFieldsProps {
placeholders: { street: string; postalCode: string; city: string }
enterHint: ReactNode
skipLabel: string
onSubmit: (v: { addressLine1?: string; postalCode?: string; city?: string }) => void
}
export default function AddressFields({ placeholders, enterHint, skipLabel, onSubmit }: AddressFieldsProps) {
const [street, setStreet] = useState('')
const [zip, setZip] = useState('')
const [city, setCity] = useState('')
const zipRef = useRef<HTMLInputElement | null>(null)
const cityRef = useRef<HTMLInputElement | null>(null)
function submit() {
onSubmit({
addressLine1: street.trim() || undefined,
postalCode: zip.trim() || undefined,
city: city.trim() || undefined,
})
}
return (
<div>
<div className="jny-addr">
<div className="jny-biginput">
<input
value={street}
placeholder={placeholders.street}
aria-label={placeholders.street}
autoComplete="off"
autoFocus
onChange={(e) => setStreet(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && zipRef.current?.focus()}
/>
</div>
<div className="jny-addr-row">
<div className="jny-biginput jny-addr-zip">
<input
ref={zipRef}
value={zip}
placeholder={placeholders.postalCode}
aria-label={placeholders.postalCode}
inputMode="numeric"
autoComplete="off"
onChange={(e) => setZip(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && cityRef.current?.focus()}
/>
</div>
<div className="jny-biginput jny-addr-city">
<input
ref={cityRef}
value={city}
placeholder={placeholders.city}
aria-label={placeholders.city}
autoComplete="off"
onChange={(e) => setCity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
</div>
</div>
</div>
<p className="jny-enterhint">{enterHint}</p>
<div className="jny-qactions">
<button type="button" className="jny-btn-quiet" onClick={() => onSubmit({})}>
{skipLabel}
</button>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { useState, type RefObject } from 'react'
/**
* Pill-chip answers. On pick, a ghost of the chip flies into the orb
* (the active station's spot on the band) before the flow moves on.
* Skipped under prefers-reduced-motion.
*/
export interface ChipOption<K extends string = string> {
key: K
label: string
/** Small secondary line inside the chip (e.g. "12 månader"). */
rec?: string
}
interface ChipRowProps<K extends string> {
options: ChipOption<K>[]
onPick: (key: K) => void
/** The band element the ghost flies toward. */
flyTargetRef?: RefObject<HTMLElement | null>
/** Horizontal fraction (0-1) of the band where the orb currently sits. */
flyTargetFrac?: number
disabled?: boolean
}
export function flyToBand(el: HTMLElement, band: HTMLElement | null, frac: number) {
if (!band) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const r = el.getBoundingClientRect()
const c = band.getBoundingClientRect()
const tx = c.left + frac * c.width - (r.left + r.width / 2)
const ty = c.top + 28 - (r.top + r.height / 2)
const ghost = el.cloneNode(true) as HTMLElement
ghost.style.cssText = `position:fixed; left:${r.left}px; top:${r.top}px; width:${r.width}px; margin:0; z-index:99; pointer-events:none; border:1px solid hsl(var(--border)); border-radius:99px; padding:10px 18px; background:hsl(var(--background)); font-size:13.5px; transition: transform 520ms cubic-bezier(0.5, 0, 0.2, 1), opacity 520ms var(--ease-out);`
document.body.appendChild(ghost)
requestAnimationFrame(() => {
ghost.style.transform = `translate(${tx}px,${ty}px) scale(0.12)`
ghost.style.opacity = '0'
})
setTimeout(() => ghost.remove(), 560)
}
export default function ChipRow<K extends string>({
options,
onPick,
flyTargetRef,
flyTargetFrac = 0,
disabled,
}: ChipRowProps<K>) {
const [picked, setPicked] = useState<K | null>(null)
return (
<div className="jny-chips">
{options.map((opt) => (
<button
key={opt.key}
type="button"
className={`jny-pick${picked === opt.key ? ' is-sel' : ''}`}
disabled={disabled}
onClick={(e) => {
if (disabled) return
setPicked(opt.key)
flyToBand(e.currentTarget, flyTargetRef?.current ?? null, flyTargetFrac)
onPick(opt.key)
}}
>
{opt.label}
{opt.rec ? <span className="jny-rec">{opt.rec}</span> : null}
</button>
))}
</div>
)
}
@@ -0,0 +1,89 @@
'use client'
import { useState } from 'react'
import { InkText } from './ink'
/**
* Tap-only date picker: year → month BY NAME → day. No dd/mm ambiguity.
* Fires onPick("YYYY-MM-DD") shortly after the day is tapped (immediately
* under reduced motion).
*/
const MONTHS_SV = [
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
]
const MONTHS_SHORT = ['jan', 'feb', 'mar', 'apr', 'maj', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec']
function lastDay(y: number, m: number) {
return new Date(y, m, 0).getDate()
}
interface JourneyDatePickerProps {
years: number[]
onPick: (date: string) => void
}
export default function JourneyDatePicker({ years, onPick }: JourneyDatePickerProps) {
const [y, setY] = useState<number>(years[years.length - 1])
const [m, setM] = useState<number>(0)
const [d, setD] = useState<number>(0)
function pickDay(day: number) {
setD(day)
const iso = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
window.setTimeout(() => onPick(iso), reduced ? 0 : 650)
}
const label = m && d ? `${d} ${MONTHS_SV[m - 1]} ${y}` : m ? `${MONTHS_SV[m - 1]} ${y}` : ''
return (
<div>
<div className="jny-mchips" style={{ marginTop: 10 }}>
{years.map((yy) => (
<button
key={yy}
type="button"
className={`jny-mchip${yy === y ? ' is-sel' : ''}`}
onClick={() => {
setY(yy)
setD(0)
}}
>
{yy}
</button>
))}
</div>
<div className="jny-mchips">
{MONTHS_SHORT.map((name, i) => (
<button
key={name}
type="button"
className={`jny-mchip${i + 1 === m ? ' is-sel' : ''}`}
onClick={() => {
setM(i + 1)
setD(0)
}}
>
{name}
</button>
))}
</div>
{m ? (
<div className="jny-mchips jny-dp-days">
{Array.from({ length: lastDay(y, m) }, (_, i) => i + 1).map((day) => (
<button
key={day}
type="button"
className={`jny-mchip${day === d ? ' is-sel' : ''}`}
onClick={() => pickDay(day)}
>
{day}
</button>
))}
</div>
) : null}
<div className="jny-dp-label">{label ? <InkText text={label} step={40} /> : null}</div>
</div>
)
}
@@ -0,0 +1,294 @@
'use client'
import { useEffect, useRef } from 'react'
/**
* The journey's particle orb, ported from the founder-approved concept.
* A 320-particle sphere rides the journey band: it stretches into a comet
* while traveling, morphs into a checkmark when the company is created,
* and can re-morph into the company's serif initial (the monogram finale).
*
* Deliberately its own canvas component (NOT the thinking-orbs package:
* that lacks the comet and the shape morphs; decision logged in the plan).
*
* - rAF pauses while the tab is hidden.
* - Reduced motion renders static frames on state changes (jump, no glide).
*/
export type OrbState = 'listening' | 'searching' | 'thinking' | 'working' | 'shaping' | 'check'
interface JourneyOrbProps {
/** Animation mode; drives speed, wobble and the check morph. */
state: OrbState
/** Horizontal target as a fraction (0-1) of the band width. */
targetX: number
/** When set during `check`, the particles re-morph into this character. */
morphChar?: string | null
/** Band height in px (canvas height). */
height?: number
}
const N = 320
const SIZE = 56
const TN = 66
function buildSphere() {
const pts: { x: number; y: number; z: number; ph: number; lag: number }[] = []
for (let i = 0; i < N; i++) {
const y = 1 - (i / (N - 1)) * 2
const r = Math.sqrt(1 - y * y)
const th = i * 2.39996323
pts.push({ x: Math.cos(th) * r, y, z: Math.sin(th) * r, ph: Math.random() * 6.283, lag: Math.random() })
}
return pts
}
function buildCheck(): [number, number][] {
const chk: [number, number][] = []
const A = [-0.75, 0.1]
const B = [-0.15, 0.65]
const C = [0.85, -0.6]
const lenAB = Math.hypot(B[0] - A[0], B[1] - A[1])
const lenBC = Math.hypot(C[0] - B[0], C[1] - B[1])
const cut = lenAB / (lenAB + lenBC)
for (let j = 0; j < TN; j++) {
const t = j / (TN - 1)
if (t < cut) {
const f = t / cut
chk.push([A[0] + (B[0] - A[0]) * f, A[1] + (B[1] - A[1]) * f])
} else {
const f = (t - cut) / (1 - cut)
chk.push([B[0] + (C[0] - B[0]) * f, B[1] + (C[1] - B[1]) * f])
}
}
return chk
}
/** Sample a serif glyph into particle targets, normalized to roughly [-1, 1]. */
function glyphTargets(ch: string, fontFamily: string): [number, number][] | null {
const gc = document.createElement('canvas')
gc.width = gc.height = 120
const g = gc.getContext('2d')
if (!g) return null
g.fillStyle = '#000'
g.font = `100px ${fontFamily}`
g.textAlign = 'center'
g.textBaseline = 'middle'
g.fillText(ch, 60, 68)
const data = g.getImageData(0, 0, 120, 120).data
let pts: [number, number][] = []
for (let y = 2; y < 118; y += 3) {
for (let x = 2; x < 118; x += 3) {
if (data[(y * 120 + x) * 4 + 3] > 120) pts.push([(x - 60) / 52, (y - 60) / 52])
}
}
if (pts.length > 150) {
const step = pts.length / 150
const out: [number, number][] = []
for (let k = 0; k < 150; k++) out.push(pts[Math.floor(k * step)])
pts = out
}
return pts.length ? pts : null
}
export default function JourneyOrb({ state, targetX, morphChar, height = 112 }: JourneyOrbProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<OrbState>(state)
const targetRef = useRef(targetX)
const morphRef = useRef<string | null>(morphChar ?? null)
const kickRef = useRef<() => void>(() => {})
useEffect(() => {
stateRef.current = state
targetRef.current = targetX
morphRef.current = morphChar ?? null
kickRef.current()
}, [state, targetX, morphChar])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const CY = height / 4
let W = 0
const pts = buildSphere()
const CHK = buildCheck()
let shapeTarget: [number, number][] = CHK
let SN = TN
const shapeCur: [number, number][] = CHK.map((p) => [p[0], p[1]])
let appliedMorph: string | null = null
function setShape(next: [number, number][]) {
const n = Math.min(next.length, N)
while (shapeCur.length < n) {
const base = shapeCur[shapeCur.length % TN]
shapeCur.push([base[0], base[1]])
}
shapeTarget = next
SN = n
if (reduced) {
for (let q = 0; q < SN; q++) {
shapeCur[q][0] = next[q][0]
shapeCur[q][1] = next[q][1]
}
}
}
function resize() {
const parent = canvas!.parentElement
W = parent ? parent.clientWidth : 0
canvas!.width = W * dpr
canvas!.height = height * dpr
canvas!.style.width = `${W}px`
canvas!.style.height = `${height}px`
}
resize()
let t = 0
let last = 0
let shapeMix = 0
let cx = targetRef.current
let vel = 0
let raf = 0
let running = false
function speed(s: OrbState) {
return s === 'working' ? 2.6 : s === 'searching' ? 1.4 : s === 'thinking' ? 0.9 : s === 'check' ? 0.2 : 0.35
}
function frame(now: number) {
const s = stateRef.current
const dt = Math.min((now - last) / 1000, 0.05)
last = now
t += dt * speed(s)
// Shape bookkeeping: default check; monogram only while checked.
const wantMorph = s === 'check' ? morphRef.current : null
if (wantMorph !== appliedMorph) {
appliedMorph = wantMorph
if (wantMorph) {
const fontFamily =
getComputedStyle(document.documentElement).getPropertyValue('--font-display') ||
'Georgia, serif'
const glyph = glyphTargets(wantMorph, fontFamily)
if (glyph) setShape(glyph)
} else {
setShape(CHK)
}
}
const prev = cx
cx += (targetRef.current - cx) * Math.min(dt * 4.2, 1)
if (reduced) cx = targetRef.current
vel = ((cx - prev) * W) / Math.max(dt, 0.001)
const wantShape = s === 'shaping' || s === 'check' ? 1 : 0
shapeMix += (wantShape - shapeMix) * Math.min(dt * 4, 1)
if (reduced) shapeMix = wantShape
if (shapeMix > 0.01) {
const sk = Math.min(dt * 5, 1)
for (let q = 0; q < SN; q++) {
shapeCur[q][0] += (shapeTarget[q][0] - shapeCur[q][0]) * sk
shapeCur[q][1] += (shapeTarget[q][1] - shapeCur[q][1]) * sk
}
}
const col = getComputedStyle(canvas!).color
ctx!.clearRect(0, 0, canvas!.width, canvas!.height)
ctx!.fillStyle = col
const R = (SIZE / 2 - 3) * dpr
const cxp = cx * W * dpr
const cyp = CY * dpr
const cosT = Math.cos(t)
const sinT = Math.sin(t)
const stretch = reduced ? 0 : Math.min(Math.abs(vel) / 900, 1.6)
const dir = vel >= 0 ? 1 : -1
for (let i = 0; i < N; i++) {
const p = pts[i]
let px = p.x
let py = p.y
let pz = p.z
if (s === 'thinking') {
const band = Math.sin(py * 6.5 + t * 2.4) * 0.45
const c2 = Math.cos(band)
const s2 = Math.sin(band)
const nx = px * c2 - pz * s2
pz = px * s2 + pz * c2
px = nx
}
if (s === 'working') {
px += Math.sin(t * 3.1 + p.ph) * 0.34
py += Math.cos(t * 2.7 + p.ph * 1.7) * 0.34
pz += Math.sin(t * 2.2 + p.ph * 2.3) * 0.3
}
const rx = px * cosT + pz * sinT
const rz = -px * sinT + pz * cosT
const depth = (rz + 1) / 2
const lagPx = stretch * R * (0.35 + p.lag * 1.5) * (0.4 + 0.6 * depth) * dir
const squash = 1 / (1 + stretch * 0.25)
let sx = cxp + rx * R * 0.92 * (1 + stretch * 0.08) - lagPx
let sy = cyp + py * R * 0.92 * squash
if (shapeMix > 0.01 && i < SN) {
const tp = shapeCur[i]
sx = sx + (cxp + tp[0] * R * 1.05 - sx) * shapeMix
sy = sy + (cyp + tp[1] * R * 1.05 - sy) * shapeMix
}
const a = shapeMix > 0.5 && i >= SN ? 1 - shapeMix : 0.2 + 0.75 * depth
if (a <= 0.02) continue
ctx!.globalAlpha = a
const rad = (0.55 + 0.75 * depth) * dpr
ctx!.beginPath()
ctx!.arc(sx, sy, rad, 0, 6.2832)
ctx!.fill()
}
ctx!.globalAlpha = 1
if (!reduced && running && !document.hidden) {
raf = requestAnimationFrame(frame)
} else {
running = false
}
}
function start() {
if (reduced) {
frame(last + 16)
return
}
if (running || document.hidden) return
running = true
raf = requestAnimationFrame((now) => {
last = now
raf = requestAnimationFrame(frame)
})
}
kickRef.current = start
function onVisibility() {
if (!document.hidden) start()
}
function onResize() {
resize()
if (reduced) frame(last + 16)
}
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('resize', onResize)
start()
return () => {
running = false
cancelAnimationFrame(raf)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('resize', onResize)
kickRef.current = () => {}
}
// The loop reads live values via refs; height is the only real dependency.
}, [height])
return <canvas ref={canvasRef} className="jny-canvas" aria-hidden="true" />
}
@@ -0,0 +1,75 @@
'use client'
import type { ReactNode } from 'react'
import { InkText } from './ink'
/**
* The five journey stations (Företaget → Klart): dots, uppercase labels and
* the inked-in answers beneath completed stations. No connecting lines.
* Completed stations are jump-back buttons (plan: rewind to the station's
* first question). Labels/answers hide under 640px; answers are mirrored
* into a visually hidden aria-live region.
*/
export interface TrackStation {
label: string
answer?: string | null
}
interface JourneyTrackProps {
stations: TrackStation[]
/** Index of the active station (0-4). Earlier stations render as done. */
active: number
onJump?: (station: number) => void
/** The orb canvas (positioned absolutely inside the band). */
children?: ReactNode
/** Screen-reader description of what the orb is doing. */
orbLabel?: string
}
const LEFTS = ['7.0%', '28.5%', '50.0%', '71.5%', '93.0%']
export default function JourneyTrack({ stations, active, onJump, children, orbLabel }: JourneyTrackProps) {
return (
<div className="jny-journey" aria-label="Steg i onboardingen">
{stations.map((st, i) => {
const done = i < active
return (
<div
key={st.label}
className={`jny-pt${done ? ' is-done' : ''}${i === active ? ' is-active' : ''}`}
style={{ left: LEFTS[i] ?? LEFTS[LEFTS.length - 1] }}
role={done && onJump ? 'button' : undefined}
tabIndex={done && onJump ? 0 : -1}
title={done && onJump ? 'Ändra' : undefined}
onClick={done && onJump ? () => onJump(i) : undefined}
onKeyDown={
done && onJump
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onJump(i)
}
}
: undefined
}
>
<span className="jny-dot" />
<span className="jny-lbl">{st.label}</span>
<span className="jny-ans">
{st.answer ? <InkText text={st.answer} step={40} /> : null}
</span>
</div>
)
})}
{children}
<span className="sr-only" aria-live="polite">
{orbLabel}
{stations
.filter((s) => s.answer)
.map((s) => `${s.label}: ${s.answer}`)
.join('. ')}
</span>
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useEffect, useId, useState, type ReactNode } from 'react'
import { InkText } from './ink'
/**
* Question scaffold: serif inked title, optional sub line, optional "?"
* help popover (Esc closes), optional one-line attention text (.jny-attn),
* and the answer surface as children. One question on screen at a time.
*/
interface QuestionProps {
title: string
sub?: ReactNode
info?: ReactNode
attn?: ReactNode
children?: ReactNode
}
export default function Question({ title, sub, info, attn, children }: QuestionProps) {
const [infoOpen, setInfoOpen] = useState(false)
const infoId = useId()
// A new question closes the previous question's popover (render-time
// derived-state reset, the lint-sanctioned pattern).
const [lastTitle, setLastTitle] = useState(title)
if (title !== lastTitle) {
setLastTitle(title)
setInfoOpen(false)
}
useEffect(() => {
if (!infoOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setInfoOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [infoOpen])
return (
<div className="jny-qstep" key={title}>
<h1 className="jny-qtitle">
<InkText text={title} />
</h1>
{(sub || info) && (
<p className="jny-qsub">
{sub}
{info ? (
<button
type="button"
className={`jny-qhelp${infoOpen ? ' is-on' : ''}`}
aria-label="Mer information"
aria-expanded={infoOpen}
aria-controls={infoId}
onClick={() => setInfoOpen((v) => !v)}
>
?
</button>
) : null}
</p>
)}
{info && infoOpen ? (
<p className="jny-qinfo" id={infoId}>
{info}
</p>
) : null}
{attn ? <p className="jny-attn">{attn}</p> : null}
{children}
</div>
)
}
@@ -0,0 +1,56 @@
'use client'
import { InkText } from './ink'
/**
* The fiscal-year band: 24 or 36 month cells with a springy highlight span,
* a serif label ("1 januari – 31 december") and a small note line.
* Pure display; the parent drives span/label/note from hover or selection.
*/
const MONTH_LETTERS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']
interface YearBandProps {
cells?: 24 | 36
year0: number
/** Inclusive month-cell indices to highlight, or null for none. */
span?: [number, number] | null
label?: string
note?: string
}
export default function YearBand({ cells = 24, year0, span, label, note }: YearBandProps) {
const years = Array.from({ length: cells / 12 }, (_, i) => year0 + i)
return (
<div className="jny-yband">
<div className="jny-yb-grid">
<div
className={`jny-yb-span${span ? ' is-on' : ''}`}
style={
span
? {
left: `${(span[0] / cells) * 100}%`,
width: `${((span[1] - span[0] + 1) / cells) * 100}%`,
}
: undefined
}
/>
<div className="jny-yb-cells" style={{ gridTemplateColumns: `repeat(${cells}, 1fr)` }}>
{Array.from({ length: cells }, (_, i) => (
<span key={i} className={span && i >= span[0] && i <= span[1] ? 'is-in' : undefined}>
{MONTH_LETTERS[i % 12]}
</span>
))}
</div>
</div>
<div className="jny-yb-years" style={{ gridTemplateColumns: `repeat(${cells}, 1fr)` }}>
{years.map((y) => (
<span key={y} style={{ gridColumn: 'span 12' }}>
{y}
</span>
))}
</div>
<div className="jny-yb-label">{label ? <InkText text={label} step={40} /> : null}</div>
<div className="jny-yb-note">{note}</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
'use client'
/** Word-by-word ink-dry text: each word blurs in with a slight stagger.
* Keyed by the text so a changed string re-inks. */
export function InkText({ text, step = 70 }: { text: string; step?: number }) {
return (
<span key={text}>
{text.split(' ').map((w, i) => (
<span key={`${i}-${w}`} className="jny-ink" style={{ animationDelay: `${i * step}ms` }}>
{w}
</span>
)).reduce<React.ReactNode[]>((acc, node, i) => (i === 0 ? [node] : [...acc, ' ', node]), [])}
</span>
)
}
+261
View File
@@ -0,0 +1,261 @@
/* Journey onboarding styles, ported from the founder-approved concept
(artifact c82c9358). Namespaced under .jny; uses the app's tokens
(--font-display, --ease-out, --ease-spring, color tokens).
Motion here deliberately deviates from the dashboard motion rules:
the concept was signed off with springs, ink and the particle orb. */
/* ── app-shell composition: the page never scrolls ───────────────── */
.jny {
height: 100vh;
height: 100dvh;
min-height: 540px;
overflow: hidden;
display: flex;
flex-direction: column;
text-align: center;
}
.jny-center {
flex: 1;
min-height: 0;
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 1vh 24px 0;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
/* optical centering: mirrors the top block below the question and
collapses first when a tall step needs the room */
.jny-balance { flex: 0 20 180px; min-height: 0; }
.jny-backrow { flex: none; margin: 8px 0 12px; min-height: 20px; }
/* ── journey band ────────────────────────────────────────────────── */
.jny-journey { flex: none; position: relative; width: min(680px, 94vw); margin: 6px auto 0; height: 112px; }
.jny-pt { position: absolute; top: 23px; transform: translateX(-50%); text-align: center; width: 150px; }
.jny-dot { display: block; width: 9px; height: 9px; margin: 0 auto; border-radius: 99px; border: 1px solid hsl(var(--border)); background: hsl(var(--background)); transition: background-color 300ms var(--ease-out), border-color 300ms var(--ease-out); }
.jny-pt.is-done .jny-dot { background: hsl(var(--success)); border-color: hsl(var(--success)); animation: jnyDotPop 460ms var(--ease-spring); }
.jny-pt.is-active .jny-dot { opacity: 0; }
.jny-lbl { display: block; margin-top: 28px; font-size: 10.5px; color: hsl(var(--muted-foreground)); letter-spacing: 0.05em; text-transform: uppercase; transition: color 300ms var(--ease-out); }
.jny-pt.is-done .jny-lbl, .jny-pt.is-active .jny-lbl { color: hsl(var(--foreground)); }
.jny-pt.is-done { cursor: pointer; }
.jny-pt.is-done:hover .jny-lbl { text-decoration: underline dotted; text-underline-offset: 3px; }
.jny-pt:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 4px; border-radius: 8px; }
.jny-ans { display: block; margin-top: 3px; font-size: 11.5px; color: hsl(var(--muted-foreground)); line-height: 1.45; }
.jny-canvas { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
@keyframes jnyDotPop { 0% { transform: scale(1); } 45% { transform: scale(1.8); } 100% { transform: scale(1); } }
@media (max-width: 640px) { .jny-lbl, .jny-ans { display: none; } }
/* ── fact line (lookup results ink in under the band) ────────────── */
.jny-facts { flex: none; margin: 18px 0 0; min-height: 48px; max-width: 540px; transition: opacity 500ms var(--ease-out); }
.jny-facts.is-gone { opacity: 0; pointer-events: none; min-height: 0; height: 0; margin: 0; overflow: hidden; }
.jny-factname { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; min-height: 26px; }
.jny-factmeta { font-size: 12.5px; color: hsl(var(--muted-foreground)); margin-top: 5px; line-height: 1.7; }
.jny-factmeta .jny-f { opacity: 0; transition: opacity 600ms var(--ease-out); }
.jny-factmeta .jny-f.is-on { opacity: 1; }
.jny-factmeta .jny-f.is-warn { color: hsl(var(--destructive)); }
/* ── ink typography ──────────────────────────────────────────────── */
.jny-ink { display: inline-block; animation: jnyInkDry 620ms var(--ease-out) both; }
@keyframes jnyInkDry {
from { opacity: 0; filter: blur(7px); transform: translateY(5px); }
55% { opacity: 1; }
to { opacity: 1; filter: blur(0); transform: none; }
}
/* ── question area ───────────────────────────────────────────────── */
.jny-qarea {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 10px 0;
}
.jny-qstep { max-height: 100%; overflow-y: auto; overscroll-behavior: contain; animation: jnyQIn 380ms var(--ease-out); }
@keyframes jnyQIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.jny-qtitle {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(24px, 3.4vw, 32px);
line-height: 1.25;
letter-spacing: -0.01em;
margin: 0 0 8px;
text-wrap: balance;
}
.jny-qsub { font-size: 13px; color: hsl(var(--muted-foreground)); margin: 0 0 26px; }
.jny-attn { font-size: 12.5px; color: hsl(var(--warning)); margin: -14px 0 20px; }
.jny-qhelp {
display: inline-flex; align-items: center; justify-content: center;
width: 17px; height: 17px; border-radius: 99px;
border: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
font-size: 11px; line-height: 1;
margin-left: 7px; vertical-align: -3px;
background: none; cursor: pointer;
transition: background-color 150ms var(--ease-out), color 150ms var(--ease-out), border-color 150ms var(--ease-out);
}
.jny-qhelp:hover, .jny-qhelp.is-on { color: hsl(var(--foreground)); border-color: hsl(var(--foreground) / 0.35); background: hsl(var(--secondary) / 0.5); }
.jny-qinfo { font-size: 12.5px; color: hsl(var(--muted-foreground)); max-width: 52ch; margin: -14px auto 26px; line-height: 1.65; animation: jnyQIn 300ms var(--ease-out); }
/* ── inputs ──────────────────────────────────────────────────────── */
.jny-biginput { position: relative; border-bottom: 1px solid hsl(var(--border)); padding: 8px 0 12px; max-width: 360px; margin: 0 auto; transition: border-color 200ms var(--ease-out); }
.jny-biginput:focus-within { border-color: hsl(var(--foreground) / 0.55); }
.jny-biginput input {
width: 100%; border: 0; outline: 0; background: none;
font: inherit; color: inherit;
font-size: 26px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums;
text-align: center;
}
.jny-biginput input::placeholder { color: hsl(var(--muted-foreground) / 0.45); }
.jny-biginput.is-err { animation: jnyShake 340ms var(--ease-out); }
@keyframes jnyShake { 0%, 100% { transform: none; } 20%, 60% { transform: translateX(-5px); } 40%, 80% { transform: translateX(5px); } }
.jny-enterhint { font-size: 11.5px; color: hsl(var(--muted-foreground)); margin-top: 10px; }
.jny-enterhint b { border: 1px solid hsl(var(--border)); border-radius: 5px; padding: 1px 6px; font-weight: 400; }
.jny-addr { max-width: 380px; margin: 26px auto 0; }
.jny-addr .jny-biginput { max-width: none; margin: 0; }
.jny-addr input { font-size: 19px; letter-spacing: 0.01em; text-align: center; }
.jny-addr-row { display: flex; gap: 26px; margin-top: 4px; }
.jny-addr-zip { flex: 0 0 130px; }
.jny-addr-city { flex: 1; }
/* ── chips ───────────────────────────────────────────────────────── */
.jny-chips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; max-width: 520px; margin: 0 auto; }
.jny-pick {
border: 1px solid hsl(var(--border));
border-radius: 99px;
padding: 10px 18px;
font-size: 13.5px;
font-family: inherit;
color: inherit;
background: hsl(var(--background));
cursor: pointer;
transition: background-color 150ms var(--ease-out), transform 180ms var(--ease-out);
}
.jny-pick:hover { background: hsl(var(--secondary) / 0.6); transform: translateY(-1px); }
.jny-pick:active { transform: translateY(0) scale(0.96); }
.jny-pick.is-sel { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
.jny-pick .jny-rec { font-size: 10.5px; color: hsl(var(--muted-foreground)); display: block; }
.jny-pick.is-sel .jny-rec { color: hsl(var(--primary-foreground) / 0.7); }
.jny-qactions { display: flex; gap: 18px; align-items: center; justify-content: center; margin-top: 30px; }
.jny-btn {
display: inline-flex; align-items: center; gap: 7px;
border-radius: 99px; padding: 8px 22px; border: 0;
font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer;
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
transition: background-color 150ms var(--ease-out);
}
.jny-btn:hover { background: hsl(var(--primary) / 0.85); }
.jny-btn-quiet { border: 0; background: none; cursor: pointer; font-family: inherit; color: hsl(var(--muted-foreground)); font-size: 12.5px; }
.jny-btn-quiet:hover { color: hsl(var(--foreground)); }
/* ── fiscal-year band ────────────────────────────────────────────── */
.jny-yband { max-width: 500px; margin: 4px auto 0; }
.jny-yb-grid { position: relative; padding: 4px 0 0; }
.jny-yb-cells { position: relative; display: grid; z-index: 1; }
.jny-yb-cells span { font-size: 10px; color: hsl(var(--muted-foreground) / 0.65); line-height: 28px; font-variant-numeric: tabular-nums; transition: color 350ms var(--ease-out); }
.jny-yb-cells span.is-in { color: hsl(var(--foreground)); font-weight: 500; }
.jny-yb-span {
position: absolute; top: 5px; height: 26px; z-index: 0; opacity: 0;
border-radius: 99px;
background: hsl(var(--secondary) / 0.8);
border: 1px solid hsl(var(--border));
transition: left 480ms var(--ease-spring), width 480ms var(--ease-spring), opacity 250ms var(--ease-out);
}
.jny-yb-span.is-on { opacity: 1; }
.jny-yb-years { display: grid; margin-top: 3px; }
.jny-yb-years span { font-size: 10px; color: hsl(var(--muted-foreground) / 0.55); text-align: left; padding-left: 3px; }
.jny-yb-label { font-family: var(--font-display); font-size: 17px; margin-top: 14px; min-height: 25px; }
.jny-yb-note { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 5px; min-height: 18px; }
.jny-mchips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 460px; margin: 18px auto 0; }
.jny-mchip {
border: 1px solid hsl(var(--border)); border-radius: 99px;
padding: 5px 12px; font-size: 12px;
font-family: inherit; color: inherit; cursor: pointer;
background: hsl(var(--background));
transition: background-color 150ms var(--ease-out), transform 150ms var(--ease-out);
}
.jny-mchip:hover { background: hsl(var(--secondary) / 0.6); transform: translateY(-1px); }
.jny-mchip:active { transform: translateY(0) scale(0.96); }
.jny-mchip.is-sel { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
.jny-dp-days { max-width: 420px; gap: 6px; }
.jny-dp-days .jny-mchip { min-width: 32px; padding: 4px 0; font-variant-numeric: tabular-nums; }
.jny-dp-label { font-family: var(--font-display); font-size: 17px; margin-top: 16px; min-height: 25px; }
/* ── company picker rows (BankID roster) ─────────────────────────── */
.jny-rowlist {
max-width: 470px; margin: 18px auto 0; text-align: left;
max-height: 42vh; overflow-y: auto; overscroll-behavior: contain; padding: 4px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
}
.jny-rowsec { font-size: 10.5px; letter-spacing: 0.05em; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin: 22px 14px 4px; }
.jny-rowpick { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; width: 100%; padding: 11px 14px; border: 0; border-radius: 12px; text-align: left; font-family: inherit; color: inherit; background: none; cursor: pointer; transition: background-color 150ms var(--ease-out); }
.jny-rowpick:hover { background: hsl(var(--secondary) / 0.55); }
.jny-rowpick:active { transform: scale(0.99); }
.jny-rowpick .jny-rleft { display: flex; flex-direction: column; min-width: 0; }
.jny-rowpick .jny-rname { font-size: 14px; }
.jny-rowpick .jny-rmeta { font-size: 11.5px; color: hsl(var(--muted-foreground)); margin-top: 1px; }
.jny-rowpick .jny-rorg { font-size: 11.5px; color: hsl(var(--muted-foreground) / 0.75); font-variant-numeric: tabular-nums; white-space: nowrap; }
.jny-rowpick.is-existing .jny-rname { color: hsl(var(--muted-foreground)); }
.jny-rownote { font-size: 12px; color: hsl(var(--muted-foreground)); padding: 14px; }
.jny-filter { max-width: 340px; }
.jny-filter input { font-size: 17px; letter-spacing: 0.01em; }
/* ── done scene: profile card + notes ────────────────────────────── */
.jny-card {
margin: 22px auto 0; max-width: 380px; text-align: left;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 14px;
padding: 16px 20px;
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.05), 0 16px 40px -24px hsl(0 0% 0% / 0.25);
}
.jny-card-eyebrow { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: hsl(var(--muted-foreground)); }
.jny-card-name { font-family: var(--font-display); font-size: 17px; margin-top: 2px; }
.jny-card dl { margin: 12px 0 0; }
.jny-card-row {
display: flex; justify-content: space-between; gap: 18px;
padding: 7px 0; border-top: 1px dashed hsl(var(--border) / 0.8);
font-size: 12.5px;
opacity: 0; transform: translateY(4px);
transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out);
}
.jny-card-row.is-on { opacity: 1; transform: none; }
.jny-card-row dt { color: hsl(var(--muted-foreground)); margin: 0; }
.jny-card-row dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.jny-notes { margin: 20px auto 0; max-width: 430px; font-size: 12.5px; color: hsl(var(--muted-foreground)); line-height: 2; }
.jny-notes div { opacity: 0; transform: translateY(4px); transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out); }
.jny-notes div.is-on { opacity: 1; transform: none; }
/* ── dawn: progress as atmosphere (composes with OnboardingBackdrop) ─ */
.jny-dawn {
position: fixed; left: 0; right: 0; bottom: 0; height: 55vh;
pointer-events: none; z-index: 0;
background: radial-gradient(120% 95% at 50% 100%, hsl(36 60% 62% / 0.26), transparent 66%);
opacity: var(--jny-dawn, 0);
transition: opacity 1100ms var(--ease-out);
}
.dark .jny-dawn { background: radial-gradient(120% 95% at 50% 100%, hsl(30 45% 42% / 0.26), transparent 66%); }
/* ── compression for short windows: shrink, never scroll ─────────── */
@media (max-height: 780px) {
.jny-facts { margin-top: 10px; min-height: 36px; }
.jny-qtitle { font-size: clamp(20px, 3vw, 26px); }
.jny-qsub { margin-bottom: 16px; }
.jny-qactions { margin-top: 20px; }
.jny-card { margin-top: 14px; padding: 12px 18px; }
.jny-card-row { padding: 5px 0; }
.jny-notes { margin-top: 12px; line-height: 1.8; }
.jny-rowlist { max-height: 36vh; margin-top: 12px; }
.jny-mchips { margin-top: 12px; }
.jny-balance { flex-basis: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.jny *, .jny *::before, .jny *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}