feat(onboarding): card redesign + add wish step routing to feedback hub

Onboarding wird zur 4-Step-Card im Workbench-Look und schließt mit einer
Freitext-Frage, die als @mana/feedback-Record landet.

UI-Redesign:
- Wraps die Screens in einer zentrierten Card mit ModuleShell-Chrome
  (paper texture, soft border, 1.25rem radius, dual shadow). Liest sich
  wie eine Workbench-Page statt eines flat Takeover-Screens.
- Header weg. Globaler Skip-Button sitzt unten links, Step-Dots zentriert
  unten — drei-Spalten-Grid hält Dots perfekt zentriert egal wie breit
  der Skip-Button ist.
- Per-Screen-Skip-Buttons aus name/ und templates/ entfernt — eine
  einzige Skip-Affordance reicht.

Wish-Step (neu, Step 4):
- /onboarding/wish: Freitext-Textarea (max 2000), Aktivierungstext
  ("Eine letzte Sache — was wünschst du dir von Mana?"). Submit postet
  fail-soft an feedbackService.createFeedback({ category:
  'onboarding-wish', isPublic: false }) — Server-Down blockiert das
  Onboarding nicht.
- onboarding-flow Store um pendingWish erweitert (Back-Nav-Preserve).
- Layout: 3 → 4 Step-Dots, Path-Mapping erweitert.
- markComplete + reset wandert von templates' Fertig-Handler in den
  wish-Screen; templates' Button heißt jetzt "Weiter" und routet zu
  /onboarding/wish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 21:52:52 +02:00
parent ba6274edbe
commit e5cd98936f
5 changed files with 412 additions and 146 deletions

View file

@ -1,8 +1,8 @@
/**
* Ephemeral state for the three-screen onboarding flow holds values
* Ephemeral state for the four-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).
* Screen 3 finish handler, the wish text for Screen 4's submit).
*
* Deliberately module-local and non-persistent:
* - The canonical source of truth is `authStore.user` (name) and the
@ -18,6 +18,7 @@
let pendingName = $state<string | null>(null);
let selectedTemplateIds = $state<string[]>([]);
let pendingWish = $state<string>('');
export const onboardingFlow = {
get pendingName() {
@ -26,14 +27,21 @@ export const onboardingFlow = {
get selectedTemplateIds() {
return selectedTemplateIds;
},
get pendingWish() {
return pendingWish;
},
setPendingName(value: string) {
pendingName = value.trim() || null;
},
setSelectedTemplateIds(ids: string[]) {
selectedTemplateIds = ids;
},
setPendingWish(value: string) {
pendingWish = value;
},
reset() {
pendingName = null;
selectedTemplateIds = [];
pendingWish = '';
},
};

View file

@ -1,16 +1,22 @@
<!--
Onboarding shell — wraps the three onboarding screens with a
centered layout, progress dots, and a skip-all affordance.
Onboarding shell — wraps the four onboarding screens (name, look,
templates, wish) in a centered card that mirrors the workbench
ModuleShell chrome (paper texture, soft border, rounded corners,
shadow). The card has no header bar; the global skip-all sits in the
footer next to centered progress dots so the body owns the full top
of the card.
Lives under (app)/ so the AuthGate in the parent layout keeps
unauthenticated users out. The parent layout hides its own chrome
(PillNav, bottom-stack) when the pathname starts with /onboarding,
so this shell renders into a clean full-viewport container.
(PillNav, bottom-stack, wallpaper) when the pathname starts with
/onboarding, so this shell renders into a clean full-viewport
container and supplies its own page background + centered card.
The skip-all button writes `onboardingCompletedAt = now()` via the
shared store and navigates home. Individual screens can also call
`onboardingStatus.markComplete()` themselves (templates/+page.svelte
does this as part of its finish handler).
shared store and navigates home. The wish screen (final step) also
calls `onboardingStatus.markComplete()` as part of its finish
handler — templates used to do this too but now just advances to the
wish screen.
-->
<script lang="ts">
import { goto } from '$app/navigation';
@ -24,6 +30,7 @@
// Any unknown /onboarding/* path falls back to step 0.
let currentStep = $derived.by(() => {
const path = $page.url.pathname;
if (path.startsWith('/onboarding/wish')) return 3;
if (path.startsWith('/onboarding/templates')) return 2;
if (path.startsWith('/onboarding/look')) return 1;
return 0; // /onboarding, /onboarding/name, or anything else
@ -40,33 +47,36 @@
</script>
<div class="onboarding-shell">
<header class="onboarding-header">
<div
class="progress-dots"
role="progressbar"
aria-valuemin={1}
aria-valuemax={3}
aria-valuenow={currentStep + 1}
>
{#each [0, 1, 2] as step (step)}
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
></span>
{/each}
</div>
<button
type="button"
class="skip-all"
onclick={handleSkipAll}
aria-label="Onboarding überspringen"
>
<X size={14} />
<span>Überspringen</span>
</button>
</header>
<div class="onboarding-card">
<main class="onboarding-body">
{@render children()}
</main>
<main class="onboarding-body">
{@render children()}
</main>
<footer class="onboarding-footer">
<button
type="button"
class="skip-all"
onclick={handleSkipAll}
aria-label="Onboarding überspringen"
>
<X size={14} weight="bold" />
<span>Überspringen</span>
</button>
<div
class="progress-dots"
role="progressbar"
aria-valuemin={1}
aria-valuemax={4}
aria-valuenow={currentStep + 1}
>
{#each [0, 1, 2, 3] as step (step)}
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
></span>
{/each}
</div>
<span class="footer-spacer" aria-hidden="true"></span>
</footer>
</div>
</div>
<style>
@ -75,16 +85,107 @@
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
overflow-y: auto;
}
/* Paper card — visual chrome mirrors ModuleShell.svelte so onboarding
reads as a workbench page. CSS vars come from applyThemeToDocument()
in @mana/shared-theme. */
.onboarding-card {
width: 100%;
max-width: 720px;
max-height: calc(100dvh - 2rem);
background-color: hsl(var(--color-card));
background-image: var(--paper-texture, none);
background-size: var(--paper-size, 240px 240px);
background-repeat: repeat;
background-blend-mode: var(--paper-blend-mode, multiply);
border: 2px solid hsl(0 0% 0% / 0.12);
border-radius: 1.25rem;
box-shadow:
0 8px 24px hsl(0 0% 0% / 0.12),
0 3px 8px hsl(0 0% 0% / 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeInUp 0.25s ease-out;
}
:global(.dark) .onboarding-card {
border-color: hsl(0 0% 0% / 0.28);
}
@media (prefers-contrast: more) {
.onboarding-card {
background-image: none;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.skip-all {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.skip-all:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
color: hsl(var(--color-foreground));
}
.onboarding-header {
.onboarding-body {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem 1.5rem 1.5rem;
overflow-y: auto;
}
.onboarding-footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
gap: 0.75rem;
padding: 0.875rem 0.875rem 1rem;
flex-shrink: 0;
}
.onboarding-footer .skip-all {
justify-self: start;
}
.onboarding-footer .progress-dots {
justify-self: center;
}
.footer-spacer {
display: block;
}
.progress-dots {
@ -108,33 +209,19 @@
background: hsl(var(--color-primary));
}
.skip-all {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.skip-all:hover {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.onboarding-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1.5rem 4rem;
overflow-y: auto;
@media (max-width: 540px) {
.onboarding-shell {
padding: 0.5rem;
}
.onboarding-card {
max-height: calc(100dvh - 1rem);
border-radius: 1rem;
}
.onboarding-body {
padding: 1.5rem 1rem 1.25rem;
}
.onboarding-footer {
padding: 0.75rem 0.75rem 0.875rem;
}
}
</style>

View file

@ -60,23 +60,6 @@
}
}
async function handleSkip() {
const fallback = (authStore.user?.email ?? '').split('@')[0] || 'du';
saving = true;
error = null;
try {
// Persist the fallback too so the user shows up as something
// other than "User 1234" in admin UIs — cheap, idempotent.
await saveName(fallback);
} catch (err) {
console.warn('[onboarding/name] skip-save failed:', err);
} finally {
saving = false;
}
onboardingFlow.setPendingName(fallback);
await goto('/onboarding/look');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && canSubmit) {
e.preventDefault();
@ -109,9 +92,6 @@
</div>
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleSkip} disabled={saving}>
Überspringen
</button>
<button
type="button"
class="btn-primary"
@ -173,28 +153,10 @@
.actions {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
}
.btn-ghost {
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:not(:disabled) {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.btn-primary {
display: inline-flex;
align-items: center;

View file

@ -1,13 +1,13 @@
<!--
Onboarding — Screen 3: Templates.
Multi-select of use-case templates. On Finish, the dedup'd union of
Multi-select of use-case templates. On Weiter, the dedup'd union of
picked templates' modules (capped at 8) is written to a fresh Home
scene via `workbenchScenesStore.createScene`, then the flow is
marked complete and we navigate home.
scene via `workbenchScenesStore.createScene`, then we advance to the
wish screen (Screen 4), which owns markComplete + reset + goto('/').
Skip path: no scene written (the hardcoded DEFAULT_HOME_APPS
fallback in workbench-scenes kicks in on first liveQuery), onboarding
still marked complete so the guard doesn't re-route.
Skip path (no selections): no scene written (the hardcoded
DEFAULT_HOME_APPS fallback in workbench-scenes kicks in on first
liveQuery), still advance to /onboarding/wish.
-->
<script lang="ts">
import { goto } from '$app/navigation';
@ -29,7 +29,6 @@
type OnboardingTemplateId,
} from '@mana/shared-branding';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
// Icon lookup — templates ship a phosphor name (string), the page
@ -54,8 +53,6 @@
let saving = $state(false);
let error = $state<string | null>(null);
let selectedCount = $derived(selected.size);
function toggle(id: OnboardingTemplateId) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
@ -64,13 +61,13 @@
onboardingFlow.setSelectedTemplateIds(Array.from(next));
}
async function finish({ skip = false }: { skip?: boolean } = {}) {
async function handleNext() {
if (saving) return;
saving = true;
error = null;
try {
if (!skip && selected.size > 0) {
if (selected.size > 0) {
// Preserve selection order. `selected` is a Set, but we inserted
// in order toggled — that matches the user's mental priority.
const modules = resolveModulesForTemplates(Array.from(selected));
@ -82,12 +79,10 @@
});
}
}
await onboardingStatus.markComplete();
onboardingFlow.reset();
await goto('/');
await goto('/onboarding/wish');
} catch (err) {
console.error('[onboarding/templates] finish failed:', err);
error = 'Konnte den Flow nicht abschließen. Versuch es noch mal.';
console.error('[onboarding/templates] save failed:', err);
error = 'Konnte die Auswahl nicht speichern. Versuch es noch mal.';
saving = false;
}
}
@ -144,25 +139,15 @@
<ArrowLeft size={16} weight="bold" />
<span>Zurück</span>
</button>
<div class="actions-right">
<button
type="button"
class="btn-ghost"
onclick={() => finish({ skip: true })}
disabled={saving}
>
Überspringen
</button>
<button
type="button"
class="btn-primary"
onclick={() => finish()}
disabled={saving || selectedCount === 0}
aria-label="Onboarding abschließen"
>
{saving ? 'Speichere…' : 'Fertig'}
</button>
</div>
<button
type="button"
class="btn-primary"
onclick={handleNext}
disabled={saving}
aria-label="Weiter zur Wunsch-Frage"
>
{saving ? 'Speichere…' : 'Weiter'}
</button>
</div>
</div>
@ -293,11 +278,6 @@
margin-top: 0.5rem;
}
.actions-right {
display: flex;
gap: 0.5rem;
}
.btn-ghost {
display: inline-flex;
align-items: center;

View file

@ -0,0 +1,229 @@
<!--
Onboarding — Screen 4: Wish.
Free-text "what do you want from Mana?" capture. Posts to the central
@mana/feedback hub as category='onboarding-wish', isPublic=false.
Submit is fail-soft: a network/server failure logs a warning and
still completes the flow — onboarding must never block on backend
latency.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Check } from '@mana/shared-icons';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
import { feedbackService } from '$lib/api/feedback';
const MAX_LEN = 2000;
let wish = $state(onboardingFlow.pendingWish ?? '');
let saving = $state(false);
let trimmed = $derived(wish.trim());
let charsLeft = $derived(MAX_LEN - wish.length);
async function handleFinish() {
if (saving) return;
saving = true;
// Fail-soft: a failed POST must not block onboarding completion.
if (trimmed.length > 0) {
onboardingFlow.setPendingWish(trimmed);
try {
await feedbackService.createFeedback({
feedbackText: trimmed,
category: 'onboarding-wish',
isPublic: false,
});
} catch (err) {
console.warn('[onboarding/wish] feedback submit failed:', err);
}
}
try {
await onboardingStatus.markComplete();
} catch (err) {
console.warn('[onboarding/wish] markComplete failed:', err);
}
onboardingFlow.reset();
await goto('/');
}
async function handleBack() {
onboardingFlow.setPendingWish(wish);
await goto('/onboarding/templates');
}
</script>
<div class="screen">
<div class="hero">
<h1>Eine letzte Sache</h1>
<p class="subtitle">
Was wünschst du dir von Mana? Wofür willst du's nutzen, was erhoffst du dir?
</p>
<p class="subtitle subtle">
Schreib einfach, wie's dir kommt — wir lesen jede Antwort und sie hilft uns, Mana für dich
besser zu machen.
</p>
</div>
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<textarea
bind:value={wish}
maxlength={MAX_LEN}
placeholder="Ich möchte Mana nutzen, um …"
rows="6"
autofocus
aria-label="Was du dir von Mana wünschst"
></textarea>
<div class="counter" class:warn={charsLeft < 100}>
{charsLeft} Zeichen übrig
</div>
</div>
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
<ArrowLeft size={16} weight="bold" />
<span>Zurück</span>
</button>
<button
type="button"
class="btn-primary"
onclick={handleFinish}
disabled={saving}
aria-label="Onboarding abschließen"
>
<span>{saving ? 'Speichere…' : 'Fertig'}</span>
<Check size={16} weight="bold" />
</button>
</div>
</div>
<style>
.screen {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.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;
}
.subtitle.subtle {
margin-top: 0.5rem;
font-size: 0.875rem;
opacity: 0.85;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field textarea {
width: 100%;
min-height: 8rem;
padding: 0.875rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
resize: vertical;
transition: border-color 0.15s ease;
}
.field textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
}
.counter {
align-self: flex-end;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.counter.warn {
color: hsl(var(--color-error, 0 84% 60%));
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
}
.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:not(:disabled) {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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,
opacity 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>