feat(onboarding): M3 — Screen 2 (Look — theme mode + variant)

- onboarding-flow.svelte.ts: tiny ephemeral store that bridges
  freshly-typed values between screens (needed because authStore.user
  is JWT-derived and won't reflect PATCH /me/profile until next token
  mint — Screen 2's greeting would otherwise show the stale empty name)
- name screen now writes into the flow store on submit and on skip
- /onboarding/look/+page.svelte:
  * "Hi {name}, wähle deinen Look" greeting — falls back to JWT name,
    email local-part, or "dir"
  * Hell/Dunkel/System mode toggle
  * 8 theme variants (lume/nature/stone/ocean/sunset/midnight/rose/
    lavender), live preview with gradient, instant-apply on click
  * Back button to Screen 1, Next to /onboarding/templates

No server write here — `theme.setVariant` / `theme.setMode` already
sync via userSettings into mana-auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 22:58:47 +02:00
parent 5aecf8b90d
commit d1ac8a6ea9
3 changed files with 380 additions and 3 deletions

View file

@ -0,0 +1,39 @@
/**
* Ephemeral state for the three-screen onboarding flow holds values
* a later screen needs from an earlier screen (the freshly-typed name
* for Screen 2's greeting, the multi-selected template ids for the
* Screen 3 finish handler).
*
* Deliberately module-local and non-persistent:
* - The canonical source of truth is `authStore.user` (name) and the
* scene Dexie table (apps). This store only bridges screens inside
* a single session.
* - We can't mutate `authStore.user.name` after PATCH /me/profile
* because `user` is minted from the JWT; the in-memory value only
* catches up on the next token refresh. This store lets Screen 2
* greet the user immediately with what they just typed.
* - Reset on flow completion so a `markComplete → reset → revisit`
* from settings starts fresh.
*/
let pendingName = $state<string | null>(null);
let selectedTemplateIds = $state<string[]>([]);
export const onboardingFlow = {
get pendingName() {
return pendingName;
},
get selectedTemplateIds() {
return selectedTemplateIds;
},
setPendingName(value: string) {
pendingName = value.trim() || null;
},
setSelectedTemplateIds(ids: string[]) {
selectedTemplateIds = ids;
},
reset() {
pendingName = null;
selectedTemplateIds = [];
},
};

View file

@ -0,0 +1,334 @@
<!--
Onboarding — Screen 2: Look.
Theme-Mode (Hell/Dunkel/System) + Theme-Variant (8 colour schemes)
picker. Writes directly to the reactive `theme` store on click, so
the user sees the change live; userSettings mirrors the change to
mana-auth via the shared-theme hook. No "Save" button needed — the
"Weiter" CTA just advances.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Sun, Moon, Desktop, Check, ArrowRight, ArrowLeft } from '@mana/shared-icons';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
type ThemeVariant,
type ThemeMode,
} from '@mana/shared-theme';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
const modes: { id: ThemeMode; label: string; icon: typeof Sun }[] = [
{ id: 'light', label: 'Hell', icon: Sun },
{ id: 'dark', label: 'Dunkel', icon: Moon },
{ id: 'system', label: 'System', icon: Desktop },
];
// Greeting chain: the name the user just typed → the JWT's name
// (returning user) → email local-part → a generic placeholder.
// authStore.user.name doesn't refresh after PATCH until the next
// token mint, which is why onboardingFlow.pendingName comes first.
let displayName = $derived(
onboardingFlow.pendingName ||
authStore.user?.name ||
(authStore.user?.email ?? '').split('@')[0] ||
'dir'
);
let allVariants: ThemeVariant[] = [...DEFAULT_THEME_VARIANTS, ...EXTENDED_THEME_VARIANTS];
/** Reflect the active mode in the preview so the tile gradient
* matches what the rest of the app will render. */
function paletteFor(variant: ThemeVariant) {
const def = THEME_DEFINITIONS[variant];
return theme.effectiveMode === 'dark' ? def.dark : def.light;
}
function gradientCss(variant: ThemeVariant): string {
const c = paletteFor(variant);
return `linear-gradient(135deg, hsl(${c.primary}) 0%, hsl(${c.secondary}) 100%)`;
}
async function handleNext() {
await goto('/onboarding/templates');
}
async function handleBack() {
await goto('/onboarding/name');
}
</script>
<div class="screen">
<div class="hero">
<h1>Hi {displayName}, wähle deinen Look</h1>
<p class="subtitle">
Das Theme gilt sofort und für alle Module. Du kannst es jederzeit in Einstellungen wechseln.
</p>
</div>
<section class="mode-section">
<div class="section-label">Modus</div>
<div class="mode-row" role="tablist">
{#each modes as m (m.id)}
{@const isActive = theme.mode === m.id}
<button
type="button"
role="tab"
aria-selected={isActive}
class="mode-btn"
class:active={isActive}
onclick={() => theme.setMode(m.id)}
>
<m.icon size={16} weight={isActive ? 'fill' : 'regular'} />
<span>{m.label}</span>
</button>
{/each}
</div>
</section>
<section class="themes-section">
<div class="section-label">Theme</div>
<div class="themes-grid">
{#each allVariants as variant (variant)}
{@const def = THEME_DEFINITIONS[variant]}
{@const isActive = theme.variant === variant}
<button
type="button"
class="theme-card"
class:active={isActive}
style:background={gradientCss(variant)}
onclick={() => theme.setVariant(variant)}
aria-label={def.label}
aria-pressed={isActive}
>
{#if isActive}
<span class="active-check">
<Check size={12} weight="bold" />
</span>
{/if}
<span class="theme-label">{def.label}</span>
</button>
{/each}
</div>
</section>
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleBack} aria-label="Zurück zum Namen">
<ArrowLeft size={16} weight="bold" />
<span>Zurück</span>
</button>
<button
type="button"
class="btn-primary"
onclick={handleNext}
aria-label="Weiter zu den Templates"
>
<span>Weiter</span>
<ArrowRight size={16} weight="bold" />
</button>
</div>
</div>
<style>
.screen {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
letter-spacing: -0.02em;
}
.subtitle {
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
margin: 0;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.625rem;
}
/* ── Mode selector ─────────────────────────────────────────── */
.mode-row {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.625rem;
}
.mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.625rem;
border: none;
background: transparent;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
box-shadow 0.15s;
}
.mode-btn:hover {
color: hsl(var(--color-foreground));
}
.mode-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.12);
}
.mode-btn.active:hover {
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
/* ── Theme cards ───────────────────────────────────────────── */
.themes-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
@media (max-width: 540px) {
.themes-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.theme-card {
position: relative;
aspect-ratio: 4 / 3;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
cursor: pointer;
overflow: hidden;
padding: 0;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.15s;
}
.theme-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.12);
}
.theme-card.active {
border-color: hsl(var(--color-primary));
box-shadow:
0 0 0 2px hsl(var(--color-primary) / 0.35),
0 4px 12px hsl(0 0% 0% / 0.12);
}
.theme-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 50%, hsl(0 0% 0% / 0.35) 100%);
pointer-events: none;
}
.active-check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: hsl(0 0% 100%);
color: hsl(var(--color-primary));
box-shadow: 0 1px 4px hsl(0 0% 0% / 0.2);
}
.theme-label {
position: absolute;
left: 0.625rem;
bottom: 0.5rem;
z-index: 1;
font-size: 0.8125rem;
font-weight: 700;
color: hsl(0 0% 100%);
text-shadow: 0 1px 3px hsl(0 0% 0% / 0.5);
letter-spacing: -0.01em;
}
/* ── Actions ─────────────────────────────────────────────── */
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.9375rem;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.btn-ghost:hover {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
font-size: 0.9375rem;
font-weight: 600;
border-radius: 0.75rem;
cursor: pointer;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
}
</style>

View file

@ -8,6 +8,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
import { ArrowRight } from '@mana/shared-icons';
function getAuthUrl(): string {
@ -19,9 +20,10 @@
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
// Prefill: existing name (returning user revisiting) → email local-part
// → empty. Trimmed so whitespace-only values don't count as "filled".
let name = $state((authStore.user?.name ?? '').trim());
// Prefill: last value entered in this flow (back-navigation) → existing
// `user.name` (returning user revisiting) → empty. Trimmed so
// whitespace-only values don't count as "filled".
let name = $state((onboardingFlow.pendingName ?? authStore.user?.name ?? '').trim());
let saving = $state(false);
let error = $state<string | null>(null);
@ -48,6 +50,7 @@
error = null;
try {
await saveName(trimmed);
onboardingFlow.setPendingName(trimmed);
await goto('/onboarding/look');
} catch (err) {
console.error('[onboarding/name] save failed:', err);
@ -70,6 +73,7 @@
} finally {
saving = false;
}
onboardingFlow.setPendingName(fallback);
await goto('/onboarding/look');
}