* 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>
295 lines
9.2 KiB
TypeScript
295 lines
9.2 KiB
TypeScript
'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" />
|
|
}
|