feat(onboarding): M4 — Screen 3 (Templates) + finish handler

- packages/shared-branding/onboarding-templates.ts:
  * 7 templates: Alltag / Arbeit / Health / Sport / Lernen / Entdecken
    / Erinnern — each with a phosphor icon name, German name/desc and
    an ordered moduleIds list
  * resolveModulesForTemplates() — deduplicates the union of selected
    templates' modules (priority-ordered) and caps at 8 (2×4 grid)
- packages/shared-branding/onboarding-templates.spec.ts: 10 tests
  covering order preservation, dedup-across-templates, cap honouring,
  unknown-id tolerance
- /onboarding/templates/+page.svelte:
  * Multi-select grid of 7 tiles (checkmark + primary border when on)
  * Finish handler: runs resolveModulesForTemplates → creates a new
    "Zuhause" scene with those apps → onboardingStatus.markComplete()
    → navigates to /
  * Skip still marks complete (no scene — user lands on DEFAULT_HOME_APPS)
  * Prefills selection from onboardingFlow store so back-nav is stable

With this, the 3-screen flow runs end-to-end for a new user:
signup → /onboarding/name → /look → /templates → / with a curated
home scene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 23:03:00 +02:00
parent d1ac8a6ea9
commit 1198d01263
4 changed files with 579 additions and 0 deletions

View file

@ -0,0 +1,351 @@
<!--
Onboarding — Screen 3: Templates.
Multi-select of use-case templates. On Finish, 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.
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.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
House,
Briefcase,
Heart,
Barbell,
GraduationCap,
Compass,
Camera,
Check,
ArrowLeft,
} from '@mana/shared-icons';
import type { Component } from 'svelte';
import {
ONBOARDING_TEMPLATES,
resolveModulesForTemplates,
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
// resolves it to the actual component. Keeps templates data-only
// so they can be consumed by memoro/mobile later without pulling
// in a web-only icon binding.
const ICONS: Record<string, Component> = {
House,
Briefcase,
Heart,
Barbell,
GraduationCap,
Compass,
Camera,
};
// Prefill from the flow store so a back-nav preserves what the user
// already picked. Start empty on first visit.
let selected = $state<Set<OnboardingTemplateId>>(
new Set(onboardingFlow.selectedTemplateIds as OnboardingTemplateId[])
);
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);
else next.add(id);
selected = next;
onboardingFlow.setSelectedTemplateIds(Array.from(next));
}
async function finish({ skip = false }: { skip?: boolean } = {}) {
if (saving) return;
saving = true;
error = null;
try {
if (!skip && 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));
if (modules.length > 0) {
await workbenchScenesStore.createScene({
name: 'Zuhause',
seedApps: modules.map((appId) => ({ appId })),
setActive: true,
});
}
}
await onboardingStatus.markComplete();
onboardingFlow.reset();
await goto('/');
} catch (err) {
console.error('[onboarding/templates] finish failed:', err);
error = 'Konnte den Flow nicht abschließen. Versuch es noch mal.';
saving = false;
}
}
async function handleBack() {
await goto('/onboarding/look');
}
</script>
<div class="screen">
<div class="hero">
<h1>Wofür willst du Mana nutzen?</h1>
<p class="subtitle">
Wähl aus, was zu dir passt. Wir stellen dir dazu passende Module auf deinen Startbildschirm —
weitere kannst du jederzeit hinzufügen.
</p>
</div>
<div class="grid">
{#each ONBOARDING_TEMPLATES as tpl (tpl.id)}
{@const Icon = ICONS[tpl.iconName]}
{@const isSelected = selected.has(tpl.id)}
<button
type="button"
class="tile"
class:selected={isSelected}
onclick={() => toggle(tpl.id)}
aria-pressed={isSelected}
>
<div class="tile-icon">
{#if Icon}
<Icon size={24} weight={isSelected ? 'fill' : 'regular'} />
{/if}
</div>
<div class="tile-body">
<div class="tile-name">{tpl.name}</div>
<div class="tile-desc">{tpl.shortDescription}</div>
</div>
{#if isSelected}
<div class="tile-check">
<Check size={12} weight="bold" />
</div>
{/if}
</button>
{/each}
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
<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>
</div>
</div>
<style>
.screen {
width: 100%;
max-width: 680px;
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;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
}
@media (max-width: 500px) {
.grid {
grid-template-columns: 1fr;
}
}
.tile {
position: relative;
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface, var(--color-background)));
border-radius: 0.875rem;
cursor: pointer;
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease,
background 0.15s ease;
color: inherit;
}
.tile:hover {
transform: translateY(-1px);
border-color: hsl(var(--color-primary) / 0.4);
}
.tile.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.25);
}
.tile-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.tile.selected .tile-icon {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.tile-body {
flex: 1;
min-width: 0;
}
.tile-name {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 0.125rem;
}
.tile-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.35;
}
.tile-check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
box-shadow: 0 1px 4px hsl(0 0% 0% / 0.2);
}
.error {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-error, 0 84% 60%));
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.actions-right {
display: flex;
gap: 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: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 {
padding: 0.75rem 1.5rem;
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.5;
cursor: not-allowed;
}
</style>

View file

@ -84,3 +84,11 @@ export {
type SpaceModuleId,
type SpaceMetadata,
} from './spaces';
// Onboarding templates (see docs/plans/onboarding-flow.md)
export {
ONBOARDING_TEMPLATES,
resolveModulesForTemplates,
type OnboardingTemplate,
type OnboardingTemplateId,
} from './onboarding-templates';

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import {
ONBOARDING_TEMPLATES,
resolveModulesForTemplates,
type OnboardingTemplateId,
} from './onboarding-templates';
describe('ONBOARDING_TEMPLATES', () => {
it('has exactly 7 templates in the expected order', () => {
expect(ONBOARDING_TEMPLATES.map((t) => t.id)).toEqual([
'alltag',
'arbeit',
'health',
'sport',
'lernen',
'entdecken',
'erinnern',
]);
});
it('every template has a name, description, icon, and at least 3 modules', () => {
for (const tpl of ONBOARDING_TEMPLATES) {
expect(tpl.name.length).toBeGreaterThan(0);
expect(tpl.shortDescription.length).toBeGreaterThan(0);
expect(tpl.iconName.length).toBeGreaterThan(0);
expect(tpl.moduleIds.length).toBeGreaterThanOrEqual(3);
}
});
it('no template references duplicate module ids within itself', () => {
for (const tpl of ONBOARDING_TEMPLATES) {
expect(new Set(tpl.moduleIds).size).toBe(tpl.moduleIds.length);
}
});
});
describe('resolveModulesForTemplates', () => {
it('returns empty for empty selection', () => {
expect(resolveModulesForTemplates([])).toEqual([]);
});
it('returns a single templates modules verbatim (under cap)', () => {
expect(resolveModulesForTemplates(['alltag'])).toEqual([
'todo',
'calendar',
'notes',
'contacts',
]);
});
it('dedupes across templates in priority order', () => {
// Alltag first: todo/calendar/notes/contacts
// Arbeit second: todo/calendar/mail/chat/times/notes — only mail/chat/times are new
const result = resolveModulesForTemplates(['alltag', 'arbeit']);
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']);
});
it('preserves selection order (arbeit-first vs alltag-first)', () => {
const a = resolveModulesForTemplates(['arbeit', 'alltag']);
const b = resolveModulesForTemplates(['alltag', 'arbeit']);
// Different first module confirms priority is the selection order,
// not a fixed template order.
expect(a[0]).toBe('todo'); // both start with todo, but arbeit's "mail" outranks alltag's "contacts"
expect(a.indexOf('mail')).toBeLessThan(a.indexOf('contacts'));
expect(b.indexOf('contacts')).toBeLessThan(b.indexOf('mail'));
});
it('honours the cap and drops overflow modules (default cap = 8)', () => {
// All 7 templates picked — union pre-dedup is ~27 modules.
const allIds: OnboardingTemplateId[] = [
'alltag',
'arbeit',
'health',
'sport',
'lernen',
'entdecken',
'erinnern',
];
const result = resolveModulesForTemplates(allIds);
expect(result.length).toBe(8);
// Alltag's four modules must all make it — they're in the first template.
expect(result.slice(0, 4)).toEqual(['todo', 'calendar', 'notes', 'contacts']);
});
it('respects a custom cap', () => {
const result = resolveModulesForTemplates(['alltag', 'health'], 5);
expect(result.length).toBe(5);
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'habits']);
});
it('silently ignores unknown template ids', () => {
const result = resolveModulesForTemplates([
'alltag',
'nonexistent' as OnboardingTemplateId,
'arbeit',
]);
// Same as 'alltag' + 'arbeit' without the unknown one.
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']);
});
});

View file

@ -0,0 +1,120 @@
/**
* Onboarding templates starter-packs a user picks on Screen 3 of the
* first-login flow (/onboarding/templates). Each template is a named
* use-case with an ordered list of module IDs. The flow's finish
* handler deduplicates the union of picked templates' modules (keeping
* the first occurrence in priority order), caps the total at 8, and
* writes the result as the user's Home scene.
*
* All module IDs are verified against
* apps/mana/apps/web/src/lib/app-registry/apps.ts if a template
* references a module that was removed, catch it in the test below
* before the user sees an empty tile.
*
* See docs/plans/onboarding-flow.md for rationale (why these templates,
* why multi-select, why a cap).
*/
export type OnboardingTemplateId =
| 'alltag'
| 'arbeit'
| 'health'
| 'sport'
| 'lernen'
| 'entdecken'
| 'erinnern';
export type OnboardingTemplate = {
id: OnboardingTemplateId;
/** German name rendered on the tile. */
name: string;
/** One-line description under the tile name. */
shortDescription: string;
/** Phosphor icon name — resolved by the consuming page. */
iconName: 'House' | 'Briefcase' | 'Heart' | 'Barbell' | 'GraduationCap' | 'Compass' | 'Camera';
/**
* Module IDs in priority order (first = most important). Dedup
* across templates keeps the earliest occurrence; cap = 8.
*/
moduleIds: string[];
};
export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [
{
id: 'alltag',
name: 'Alltag',
shortDescription: 'Aufgaben, Termine, Notizen, Kontakte',
iconName: 'House',
moduleIds: ['todo', 'calendar', 'notes', 'contacts'],
},
{
id: 'arbeit',
name: 'Arbeit',
shortDescription: 'Produktivität für den Job',
iconName: 'Briefcase',
moduleIds: ['todo', 'calendar', 'mail', 'chat', 'times', 'notes'],
},
{
id: 'health',
name: 'Health',
shortDescription: 'Gesundheit, Stimmung, Ernährung, Zyklus',
iconName: 'Heart',
moduleIds: ['habits', 'body', 'mood', 'food', 'period'],
},
{
id: 'sport',
name: 'Sport',
shortDescription: 'Training, Ziele, Körper, Ernährung',
iconName: 'Barbell',
moduleIds: ['habits', 'body', 'food', 'goals', 'stretch'],
},
{
id: 'lernen',
name: 'Lernen',
shortDescription: 'Skills, Quizzes, Notizen, Kontext',
iconName: 'GraduationCap',
moduleIds: ['skilltree', 'quiz', 'notes', 'library', 'kontext'],
},
{
id: 'entdecken',
name: 'Entdecken',
shortDescription: 'Orte, Fotos, Musik, Wetter',
iconName: 'Compass',
moduleIds: ['places', 'citycorners', 'photos', 'music', 'wetter'],
},
{
id: 'erinnern',
name: 'Erinnern',
shortDescription: 'Memoro, Journal, Fotos, Zitate',
iconName: 'Camera',
moduleIds: ['memoro', 'journal', 'photos', 'moodlit', 'quotes'],
},
];
/**
* Pick modules for the user's Home scene based on a multi-selection of
* templates. Iterates templates in the order provided, collecting their
* modules while skipping dups and stopping at the cap.
*
* Exported so the onboarding page and its test can share the same
* logic the page only has to worry about UI state.
*/
export function resolveModulesForTemplates(
selectedIds: readonly OnboardingTemplateId[],
cap = 8
): string[] {
const byId = new Map(ONBOARDING_TEMPLATES.map((t) => [t.id, t]));
const seen = new Set<string>();
const out: string[] = [];
for (const id of selectedIds) {
const tpl = byId.get(id);
if (!tpl) continue;
for (const moduleId of tpl.moduleIds) {
if (seen.has(moduleId)) continue;
seen.add(moduleId);
out.push(moduleId);
if (out.length >= cap) return out;
}
}
return out;
}