diff --git a/DECISIONS.md b/DECISIONS.md index 35bf258a..e0f2452d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -353,3 +353,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-24] BankID company picker in journey onboarding: searchable list (name + form/role + orgnr, "finns redan" section, filter with single-match Enter) is the standard at ANY engagement count; chips variant cut (founder call on concept c82c9358). Picking a company still runs zero Lens lookups: CompanyRoles prefill only, F-skatt/moms/rakenskapsar asked as questions per the plan's lookupRan rule. [2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry. [2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists). +[2026-07-24] Journey reducer lives in lib/onboarding-journey/, not components/onboarding/journey/ as the plan sketched: pure logic belongs in the vitest scope (lib/ + app/api/), and the plan's reducer test matrix requires it. Components stay copy-agnostic; i18n keys land with their consumer in PR C. diff --git a/app/sandbox/journey/page.tsx b/app/sandbox/journey/page.tsx new file mode 100644 index 00000000..51dc104c --- /dev/null +++ b/app/sandbox/journey/page.tsx @@ -0,0 +1,205 @@ +'use client' + +/** + * Internal demo route for the journey onboarding primitives (PR B). + * Auth-free via the /sandbox middleware exemption; view logged out. + * + * A gallery, not the product flow: it exercises JourneyOrb (all states, + * travel, monogram morph), JourneyTrack (answers, jump-back), Question + + * ChipRow (fly-to-orb ghost), YearBand, JourneyDatePicker and + * AddressFields, with demo data only. NO TIC calls are made from this + * page: the lookup budget is untouched. Swedish-only by design (internal + * surface); the real flow (PR C) is fully i18n'd. + */ + +import { useRef, useState } from 'react' +import JourneyOrb, { type OrbState } from '@/components/onboarding/journey/JourneyOrb' +import JourneyTrack from '@/components/onboarding/journey/JourneyTrack' +import Question from '@/components/onboarding/journey/Question' +import ChipRow from '@/components/onboarding/journey/ChipRow' +import YearBand from '@/components/onboarding/journey/YearBand' +import JourneyDatePicker from '@/components/onboarding/journey/JourneyDatePicker' +import AddressFields from '@/components/onboarding/journey/AddressFields' +import '@/components/onboarding/journey/journey.css' + +const ORB_STATES: OrbState[] = ['listening', 'searching', 'thinking', 'working', 'shaping', 'check'] +const STATION_FRACS = [0.07, 0.285, 0.5, 0.715, 0.93] + +type Panel = 'chips' | 'band' | 'date' | 'address' + +export default function JourneySandboxPage() { + const bandRef = useRef(null) + const [orbState, setOrbState] = useState('listening') + const [active, setActive] = useState(1) + const [morph, setMorph] = useState(null) + const [panel, setPanel] = useState('chips') + const [bandSpan, setBandSpan] = useState<[number, number] | null>([0, 11]) + const [bandLabel, setBandLabel] = useState('1 januari – 31 december') + const [pickedDate, setPickedDate] = useState(null) + const [address, setAddress] = useState(null) + + const answers: (string | null)[] = [ + 'Nordvik Bygg & Konsult AB', + active > 1 ? 'Kalenderår' : null, + active > 2 ? 'Kvartalsvis' : null, + active > 3 ? 'Faktureringsmetoden' : null, + null, + ] + + return ( +
+
+
+ /sandbox/journey · primitivgalleri · inga TIC-anrop + + {ORB_STATES.map((s) => ( + + ))} + + +
+ +
+
+ setActive(i)} + orbLabel={orbState} + > + + +
+ +
+
Nordvik Bygg & Konsult AB
+
+ Aktiebolag + · Malmö + · F-skatt + · Momsregistrerat +
+
+ +
+ {panel === 'chips' && ( + + { + if (k === 'other') setPanel('band') + else if (k === 'first') setPanel('date') + else setActive((a) => Math.min(a + 1, 4)) + }} + flyTargetRef={bandRef} + flyTargetFrac={STATION_FRACS[active]} + /> + + )} + + {panel === 'band' && ( + + +
+ {['jan', 'feb', 'mar', 'apr', 'maj', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec'].map((n, i) => ( + + ))} +
+
+ )} + + {panel === 'date' && ( + + { + setPickedDate(d) + setPanel('address') + }} + /> + {pickedDate ?

Valt: {pickedDate}

: null} +
+ )} + + {panel === 'address' && ( + + + Tryck Enter när du är klar + + } + skipLabel="Lägg till senare" + onSubmit={(v) => { + setAddress(v.city ?? v.addressLine1 ?? '(hoppade över)') + setPanel('chips') + }} + /> + {address ?

Sparat: {address}

: null} +
+ )} +
+ + +
+ ) +} diff --git a/components/onboarding/journey/AddressFields.tsx b/components/onboarding/journey/AddressFields.tsx new file mode 100644 index 00000000..dc1e0417 --- /dev/null +++ b/components/onboarding/journey/AddressFields.tsx @@ -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(null) + const cityRef = useRef(null) + + function submit() { + onSubmit({ + addressLine1: street.trim() || undefined, + postalCode: zip.trim() || undefined, + city: city.trim() || undefined, + }) + } + + return ( +
+
+
+ setStreet(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && zipRef.current?.focus()} + /> +
+
+
+ setZip(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && cityRef.current?.focus()} + /> +
+
+ setCity(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> +
+
+
+

{enterHint}

+
+ +
+
+ ) +} diff --git a/components/onboarding/journey/ChipRow.tsx b/components/onboarding/journey/ChipRow.tsx new file mode 100644 index 00000000..7fa9ab78 --- /dev/null +++ b/components/onboarding/journey/ChipRow.tsx @@ -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 { + key: K + label: string + /** Small secondary line inside the chip (e.g. "12 månader"). */ + rec?: string +} + +interface ChipRowProps { + options: ChipOption[] + onPick: (key: K) => void + /** The band element the ghost flies toward. */ + flyTargetRef?: RefObject + /** 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({ + options, + onPick, + flyTargetRef, + flyTargetFrac = 0, + disabled, +}: ChipRowProps) { + const [picked, setPicked] = useState(null) + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} diff --git a/components/onboarding/journey/JourneyDatePicker.tsx b/components/onboarding/journey/JourneyDatePicker.tsx new file mode 100644 index 00000000..5cbf59ca --- /dev/null +++ b/components/onboarding/journey/JourneyDatePicker.tsx @@ -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(years[years.length - 1]) + const [m, setM] = useState(0) + const [d, setD] = useState(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 ( +
+
+ {years.map((yy) => ( + + ))} +
+
+ {MONTHS_SHORT.map((name, i) => ( + + ))} +
+ {m ? ( +
+ {Array.from({ length: lastDay(y, m) }, (_, i) => i + 1).map((day) => ( + + ))} +
+ ) : null} +
{label ? : null}
+
+ ) +} diff --git a/components/onboarding/journey/JourneyOrb.tsx b/components/onboarding/journey/JourneyOrb.tsx new file mode 100644 index 00000000..cedb6b83 --- /dev/null +++ b/components/onboarding/journey/JourneyOrb.tsx @@ -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(null) + const stateRef = useRef(state) + const targetRef = useRef(targetX) + const morphRef = useRef(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