Status: planned — not yet built. This document describes an intended design, not current code. The schema tables, the components (
ProgRevGate,ProgressIndicator,AchievementToast), and therecordAction()helper described below do not exist insrc/yet. It is kept as a design-of-record to emulate, and is excluded from the chatbot corpus so the assistant does not assert it as live.
Implementation blueprint for the Progressive Revelation system. ProgRev progressively reveals content and features as users demonstrate readiness, reducing cognitive overload while guiding them through the application.
Foundation: See ../foundation/progressive-revelation.md for philosophy and anti-goals.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. hooks.server.ts ──► Load guest_id cookie, verify guest exists │
│ │ │
│ ▼ │
│ 2. hooks.server.ts ──► Load ProgRev state from DB → event.locals.progrev │
│ │ │
│ ▼ │
│ 3. +layout.server.ts ──► Pass progrev to page data │
│ │ │
│ ▼ │
│ 4. +layout.svelte ──► Sync to localStorage cache, provide context │
│ │ │
│ ▼ │
│ 5. Components ──► Consume via useProgRev(), gate with <ProgRevGate> │
│ │ │
│ ▼ │
│ 6. Form Actions ──► recordAction() → DB update → invalidateAll() │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Principles:
- Server-authoritative: PostgreSQL is canonical; client cannot lie about progress
- Write-through cache: localStorage caches server state; never written by client actions
- Guest-first: Create guest on first meaningful action, not page load
- Atomic merge: Guest → user data transfer in single transaction
Research Validation
Research validates the proposed architecture:
| Assumption | Validation | Source |
|---|---|---|
| Progressive disclosure reduces cognitive load | Verified - NN/g 1995, reconfirmed 2025 | Nielsen Norman Group |
| Server-authoritative required for feature gating | "Client-side only opens you to abuse cases" | Statsig Documentation |
| Session IDs must regenerate on privilege change | OWASP standard (anonymous → authenticated) | OWASP Cheat Sheet |
| Forced tutorials underperform progressive | 72% abandon apps with too many onboarding steps | UserGuiding 2024 |
| Average onboarding completion: ~19% | Median: 10.1% across 188 companies | Userpilot 2024 |
| Gamification increases completion by 50% | Progress indicators, badges, checklists | Intercom Research |
Anti-patterns to avoid (verified):
- Dark patterns found on >10% of popular sites (Princeton/UChicago 2019)
- Epic Games paid $245M settlement for deceptive payment patterns
- Function-oriented onboarding "feels like an information dump"
Database Schema
Tables Overview
| Table | Purpose | Ownership |
|---|---|---|
guest |
Anonymous visitor identity | Top-level |
progrev_progress |
FTUX phases, actions, milestones | Guest XOR User |
onboarding_progress |
Post-signup journey | User only |
achievement_definition |
Achievement catalog | Reference |
user_achievement |
Earned achievements | User only |
bookmark |
Saved content | Guest XOR User |
seen_content |
Viewed content for muting | Guest XOR User |
Schema Definition
// src/lib/server/db/schema/progrev.ts
import {
pgTable, text, timestamp, integer, boolean, pgEnum,
index, unique, check
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { user } from './_better-auth';
// ============================================================================
// ENUMS
// ============================================================================
export const ftuxPhaseEnum = pgEnum('ftux_phase', [
'landing', // Phase 1: Initial view
'exploration', // Phase 2: After first CTA click
'commitment' // Phase 3: Ready for signup
]);
export const achievementTierEnum = pgEnum('achievement_tier', [
'bronze', 'silver', 'gold', 'platinum'
]);
// ============================================================================
// GUEST (Anonymous Visitor Identity)
// ============================================================================
export const guest = pgTable('guest', {
id: text('id').primaryKey(), // gst_xxxxxxxxxxxx
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
// Merge tracking (audit trail)
mergedToUserId: text('merged_to_user_id').references(() => user.id, { onDelete: 'set null' }),
mergedAt: timestamp('merged_at', { withTimezone: true }),
}, (table) => [
index('guest_last_seen_at_idx').on(table.lastSeenAt),
index('guest_merged_to_user_id_idx').on(table.mergedToUserId),
]);
// ============================================================================
// PROGREV PROGRESS (FTUX State - Guest or User)
// ============================================================================
export const progrevProgress = pgTable('progrev_progress', {
id: text('id').primaryKey(), // prg_xxxxxxxxxxxx
// Dual ownership: exactly one must be non-null
guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
// FTUX state
ftuxPhase: ftuxPhaseEnum('ftux_phase').notNull().default('landing'),
// TEXT[] for actions - simple, fast, no JSON parsing
completedActions: text('completed_actions').array().notNull().default(sql`'{}'::text[]`),
milestones: text('milestones').array().notNull().default(sql`'{}'::text[]`),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('progrev_progress_guest_id_idx').on(table.guestId),
index('progrev_progress_user_id_idx').on(table.userId),
// Exactly one owner
check('progrev_progress_owner_check', sql`
(guest_id IS NOT NULL AND user_id IS NULL) OR
(guest_id IS NULL AND user_id IS NOT NULL)
`),
// One progress record per owner
unique('progrev_progress_guest_unique').on(table.guestId),
unique('progrev_progress_user_unique').on(table.userId),
]);
// ============================================================================
// ONBOARDING PROGRESS (Post-Signup - User Only)
// ============================================================================
export const onboardingProgress = pgTable('onboarding_progress', {
userId: text('user_id')
.primaryKey()
.references(() => user.id, { onDelete: 'cascade' }),
currentStage: integer('current_stage').notNull().default(1),
completedStages: integer('completed_stages').array().notNull().default(sql`'{}'::int[]`),
skipped: boolean('skipped').notNull().default(false),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
// ============================================================================
// ACHIEVEMENTS
// ============================================================================
export const achievementDefinition = pgTable('achievement_definition', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(), // 'PIONEER', 'NAVIGATOR', 'TRAILBLAZER'
name: text('name').notNull(),
description: text('description').notNull(),
tier: achievementTierEnum('tier').notNull().default('bronze'),
iconUrl: text('icon_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('achievement_definition_key_idx').on(table.key),
]);
export const userAchievement = pgTable('user_achievement', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
achievementId: text('achievement_id').notNull()
.references(() => achievementDefinition.id, { onDelete: 'cascade' }),
earnedAt: timestamp('earned_at', { withTimezone: true }).notNull().defaultNow(),
context: text('context'), // JSON string for metadata
}, (table) => [
index('user_achievement_user_id_idx').on(table.userId),
unique('user_achievement_user_achievement_unique').on(table.userId, table.achievementId),
]);
// ============================================================================
// CONTENT TRACKING (Guest or User)
// ============================================================================
export const bookmark = pgTable('bookmark', {
id: text('id').primaryKey(),
guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
contentKey: text('content_key').notNull(),
label: text('label'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('bookmark_guest_id_idx').on(table.guestId),
index('bookmark_user_id_idx').on(table.userId),
check('bookmark_owner_check', sql`
(guest_id IS NOT NULL AND user_id IS NULL) OR
(guest_id IS NULL AND user_id IS NOT NULL)
`),
unique('bookmark_guest_content_unique').on(table.guestId, table.contentKey),
unique('bookmark_user_content_unique').on(table.userId, table.contentKey),
]);
export const seenContent = pgTable('seen_content', {
id: text('id').primaryKey(),
guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
contentKey: text('content_key').notNull(),
seenAt: timestamp('seen_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('seen_content_guest_id_idx').on(table.guestId),
index('seen_content_user_id_idx').on(table.userId),
check('seen_content_owner_check', sql`
(guest_id IS NOT NULL AND user_id IS NULL) OR
(guest_id IS NULL AND user_id IS NOT NULL)
`),
unique('seen_content_guest_content_unique').on(table.guestId, table.contentKey),
unique('seen_content_user_content_unique').on(table.userId, table.contentKey),
]);
ID Generation
// src/lib/server/db/id.ts
import { nanoid } from 'nanoid';
export const createId = {
guest: () => `gst_${nanoid(12)}`,
progrevProgress: () => `prg_${nanoid(12)}`,
achievement: () => `ach_${nanoid(8)}`,
userAchievement: () => `uac_${nanoid(12)}`,
bookmark: () => `bmk_${nanoid(12)}`,
seenContent: () => `scn_${nanoid(12)}`,
};
SvelteKit Implementation
Type Definitions
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: User | null;
session: Session | null;
guest: Guest | null;
progrev: ProgRevState;
}
interface PageData {
progrev: ProgRevState;
}
}
}
export interface Guest {
id: string;
createdAt: Date;
lastSeenAt: Date;
mergedToUserId: string | null;
}
export type FtuxPhase = 'landing' | 'exploration' | 'commitment';
export interface ProgRevState {
identity: 'anonymous' | 'guest' | 'authenticated';
guestId?: string;
userId?: string;
ftuxPhase: FtuxPhase;
completedActions: string[];
milestones: string[];
onboardingStage: number | null;
onboardingSkipped: boolean;
achievements: string[];
}
export {};
Server Hooks
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { guest, progrevProgress } from '$lib/server/db/schema/progrev';
import { eq, and, isNull } from 'drizzle-orm';
const GUEST_COOKIE = 'guest_id';
const GUEST_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
/**
* Load guest identity from cookie.
* Does NOT create guest - that happens on first meaningful action.
*/
const loadGuestIdentity: Handle = async ({ event, resolve }) => {
const guestId = event.cookies.get(GUEST_COOKIE);
if (guestId) {
const [guestRecord] = await db
.select()
.from(guest)
.where(and(
eq(guest.id, guestId),
isNull(guest.mergedToUserId)
))
.limit(1);
if (guestRecord) {
event.locals.guest = guestRecord;
// Touch lastSeenAt (fire-and-forget)
db.update(guest)
.set({ lastSeenAt: new Date() })
.where(eq(guest.id, guestId))
.execute()
.catch(console.error);
} else {
// Guest merged or deleted
event.cookies.delete(GUEST_COOKIE, { path: '/' });
}
}
return resolve(event);
};
/**
* Load ProgRev state from database.
*/
const loadProgRevState: Handle = async ({ event, resolve }) => {
const user = event.locals.user;
const guestRecord = event.locals.guest;
// Default state for anonymous users
let state: ProgRevState = {
identity: 'anonymous',
ftuxPhase: 'landing',
completedActions: [],
milestones: [],
onboardingStage: null,
onboardingSkipped: false,
achievements: []
};
try {
if (user) {
// Authenticated user
const [progress] = await db
.select()
.from(progrevProgress)
.where(eq(progrevProgress.userId, user.id))
.limit(1);
state = {
identity: 'authenticated',
userId: user.id,
ftuxPhase: progress?.ftuxPhase ?? 'commitment', // Auth users skip FTUX
completedActions: progress?.completedActions ?? [],
milestones: progress?.milestones ?? [],
onboardingStage: progress?.onboardingStage ?? 1,
onboardingSkipped: false,
achievements: [] // Load separately if needed
};
} else if (guestRecord) {
// Guest user
const [progress] = await db
.select()
.from(progrevProgress)
.where(eq(progrevProgress.guestId, guestRecord.id))
.limit(1);
if (progress) {
state = {
identity: 'guest',
guestId: guestRecord.id,
ftuxPhase: progress.ftuxPhase,
completedActions: progress.completedActions,
milestones: progress.milestones,
onboardingStage: null,
onboardingSkipped: false,
achievements: []
};
}
}
} catch (err) {
console.error('Failed to load ProgRev state:', err);
}
event.locals.progrev = state;
return resolve(event);
};
export const handle = sequence(
// betterAuthHandle, // Better Auth (populates event.locals.user)
loadGuestIdentity,
loadProgRevState,
// rateLimitHandle, // Rate limiting
);
Root Layout
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
session: locals.session,
progrev: locals.progrev
};
};
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setContext, onMount } from 'svelte';
import { page } from '$app/state';
import type { ProgRevState } from '$lib/types';
let { children } = $props();
// Cache management
let cachedProgRev = $state<ProgRevState | null>(null);
// Write-through: server state → localStorage
$effect(() => {
const serverProgRev = page.data.progrev;
if (serverProgRev) {
localStorage.setItem('progrev_cache', JSON.stringify(serverProgRev));
cachedProgRev = serverProgRev;
}
});
// Read cache on mount (prevent flash)
onMount(() => {
const cached = localStorage.getItem('progrev_cache');
if (cached) {
try {
cachedProgRev = JSON.parse(cached);
} catch {}
}
});
// Server data wins when available
const progrev = $derived(page.data.progrev ?? cachedProgRev);
// Provide context
setContext('progrev', {
get state() { return progrev; }
});
</script>
{@render children()}
Action Recording
// src/lib/server/progrev/actions.ts
import { db, createId } from '$lib/server/db';
import { guest, progrevProgress } from '$lib/server/db/schema/progrev';
import { eq, and } from 'drizzle-orm';
import type { RequestEvent } from '@sveltejs/kit';
import type { FtuxPhase } from '$lib/types';
const GUEST_COOKIE = 'guest_id';
const GUEST_MAX_AGE = 60 * 60 * 24 * 90;
/**
* Creates a guest on first meaningful action.
*/
export async function createGuest(event: RequestEvent): Promise<string> {
const id = createId.guest();
await db.insert(guest).values({
id,
createdAt: new Date(),
lastSeenAt: new Date()
});
await db.insert(progrevProgress).values({
id: createId.progrevProgress(),
guestId: id,
ftuxPhase: 'landing',
completedActions: [],
milestones: []
});
event.cookies.set(GUEST_COOKIE, id, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: GUEST_MAX_AGE
});
return id;
}
/**
* Records a meaningful action.
* Creates guest if anonymous.
*/
export async function recordAction(
event: RequestEvent,
actionId: string,
options: {
advancePhase?: FtuxPhase;
addMilestones?: string[];
} = {}
): Promise<void> {
const user = event.locals.user;
let guestRecord = event.locals.guest;
// Create guest for anonymous users
if (!user && !guestRecord) {
const guestId = await createGuest(event);
guestRecord = { id: guestId, createdAt: new Date(), lastSeenAt: new Date(), mergedToUserId: null };
event.locals.guest = guestRecord;
}
const ownerId = user?.id ?? guestRecord!.id;
const ownerField = user ? 'userId' : 'guestId';
// Get existing progress
const [existing] = await db
.select()
.from(progrevProgress)
.where(eq(progrevProgress[ownerField], ownerId))
.limit(1);
if (!existing) {
// Create progress record
await db.insert(progrevProgress).values({
id: createId.progrevProgress(),
[ownerField]: ownerId,
ftuxPhase: options.advancePhase ?? 'landing',
completedActions: [actionId],
milestones: options.addMilestones ?? []
});
} else {
// Update existing
const updates: Partial<typeof progrevProgress.$inferInsert> = {
completedActions: [...new Set([...existing.completedActions, actionId])],
updatedAt: new Date()
};
if (options.advancePhase) {
updates.ftuxPhase = options.advancePhase;
}
if (options.addMilestones) {
updates.milestones = [...new Set([...existing.milestones, ...options.addMilestones])];
}
await db
.update(progrevProgress)
.set(updates)
.where(eq(progrevProgress[ownerField], ownerId));
}
}
Guest-to-User Merge
// src/lib/server/progrev/merge.ts
import { db, createId } from '$lib/server/db';
import {
guest, progrevProgress, bookmark, seenContent,
userAchievement, achievementDefinition, onboardingProgress
} from '$lib/server/db/schema/progrev';
import { eq, and, isNull } from 'drizzle-orm';
interface MergeResult {
success: boolean;
achievementsGranted: string[];
bookmarksMerged: number;
seenContentMerged: number;
}
/**
* Merges guest data into user account.
* Called after successful signup.
*
* Conflict resolution:
* - FTUX phase: take higher (further along)
* - Actions/milestones: union of sets
* - Bookmarks: union (skip duplicates)
*/
export async function mergeGuestToUser(
guestId: string,
userId: string
): Promise<MergeResult> {
return db.transaction(async (tx) => {
const result: MergeResult = {
success: false,
achievementsGranted: [],
bookmarksMerged: 0,
seenContentMerged: 0,
};
// 1. Verify guest exists and not merged
const [guestRecord] = await tx
.select()
.from(guest)
.where(and(eq(guest.id, guestId), isNull(guest.mergedToUserId)))
.limit(1);
if (!guestRecord) return result;
// 2. Get guest progress
const [guestProgress] = await tx
.select()
.from(progrevProgress)
.where(eq(progrevProgress.guestId, guestId))
.limit(1);
// 3. Get or create user progress
const [userProgress] = await tx
.select()
.from(progrevProgress)
.where(eq(progrevProgress.userId, userId))
.limit(1);
if (!userProgress && guestProgress) {
// Transfer guest progress to user
await tx.update(progrevProgress)
.set({
guestId: null,
userId: userId,
ftuxPhase: 'commitment', // Auth users complete FTUX
updatedAt: new Date(),
})
.where(eq(progrevProgress.guestId, guestId));
} else if (userProgress && guestProgress) {
// Merge progress (take higher phase, union of actions)
const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };
const higherPhase = phaseOrder[guestProgress.ftuxPhase] > phaseOrder[userProgress.ftuxPhase]
? guestProgress.ftuxPhase
: 'commitment';
await tx.update(progrevProgress)
.set({
ftuxPhase: higherPhase,
completedActions: [...new Set([
...userProgress.completedActions,
...guestProgress.completedActions
])],
milestones: [...new Set([
...userProgress.milestones,
...guestProgress.milestones
])],
updatedAt: new Date(),
})
.where(eq(progrevProgress.userId, userId));
// Delete guest progress
await tx.delete(progrevProgress)
.where(eq(progrevProgress.guestId, guestId));
}
// 4. Transfer bookmarks (skip duplicates)
const guestBookmarks = await tx
.select()
.from(bookmark)
.where(eq(bookmark.guestId, guestId));
for (const bm of guestBookmarks) {
const [exists] = await tx
.select()
.from(bookmark)
.where(and(
eq(bookmark.userId, userId),
eq(bookmark.contentKey, bm.contentKey)
))
.limit(1);
if (!exists) {
await tx.update(bookmark)
.set({ guestId: null, userId: userId })
.where(eq(bookmark.id, bm.id));
result.bookmarksMerged++;
} else {
await tx.delete(bookmark).where(eq(bookmark.id, bm.id));
}
}
// 5. Transfer seen content (skip duplicates)
const guestSeen = await tx
.select()
.from(seenContent)
.where(eq(seenContent.guestId, guestId));
for (const sc of guestSeen) {
const [exists] = await tx
.select()
.from(seenContent)
.where(and(
eq(seenContent.userId, userId),
eq(seenContent.contentKey, sc.contentKey)
))
.limit(1);
if (!exists) {
await tx.update(seenContent)
.set({ guestId: null, userId: userId })
.where(eq(seenContent.id, sc.id));
result.seenContentMerged++;
} else {
await tx.delete(seenContent).where(eq(seenContent.id, sc.id));
}
}
// 6. Mark guest as merged (keep for audit)
await tx.update(guest)
.set({ mergedToUserId: userId, mergedAt: new Date() })
.where(eq(guest.id, guestId));
// 7. Grant PIONEER achievement
const [pioneer] = await tx
.select()
.from(achievementDefinition)
.where(eq(achievementDefinition.key, 'PIONEER'))
.limit(1);
if (pioneer) {
await tx.insert(userAchievement)
.values({
id: createId.userAchievement(),
userId,
achievementId: pioneer.id,
context: JSON.stringify({ trigger: 'signup', fromGuest: guestId }),
})
.onConflictDoNothing();
result.achievementsGranted.push('PIONEER');
}
// 8. Create onboarding progress
await tx.insert(onboardingProgress)
.values({ userId })
.onConflictDoNothing();
result.success = true;
return result;
});
}
UI Components
ProgRevGate
Conditionally renders content based on ProgRev state.
<!-- src/lib/progrev/ProgRevGate.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import type { FtuxPhase, ProgRevState } from '$lib/types';
interface Props {
/** Required FTUX phase */
phase?: FtuxPhase;
/** Required identity level */
identity?: 'guest' | 'authenticated';
/** Required action completion */
requiresAction?: string;
/** Content when unlocked */
children: Snippet;
/** Content when locked */
fallback?: Snippet;
}
let { phase, identity, requiresAction, children, fallback }: Props = $props();
const progrev = getContext<{ state: ProgRevState }>('progrev');
const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };
const isUnlocked = $derived(() => {
const state = progrev.state;
if (!state) return false;
// Check identity
if (identity === 'authenticated' && state.identity !== 'authenticated') return false;
if (identity === 'guest' && state.identity === 'anonymous') return false;
// Check phase
if (phase && phaseOrder[state.ftuxPhase] < phaseOrder[phase]) return false;
// Check action
if (requiresAction && !state.completedActions.includes(requiresAction)) return false;
return true;
});
</script>
{#if isUnlocked()}
{@render children()}
{:else if fallback}
{@render fallback()}
{/if}
Usage:
<ProgRevGate phase="exploration">
<ContentSection />
{#snippet fallback()}
<LockedPreview onclick={handleCTA} />
{/snippet}
</ProgRevGate>
ProgressIndicator
Ambient progress display for sidebar.
<!-- src/lib/progrev/ProgressIndicator.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
import type { ProgRevState } from '$lib/types';
interface Props {
density?: 'compact' | 'default' | 'detailed';
orientation?: 'vertical' | 'horizontal';
}
let { density = 'default', orientation = 'vertical' }: Props = $props();
const progrev = getContext<{ state: ProgRevState }>('progrev');
const stages = [
{ id: 'landing', label: 'Welcome', phase: 'landing' },
{ id: 'exploration', label: 'Explore', phase: 'exploration' },
{ id: 'commitment', label: 'Ready', phase: 'commitment' }
] as const;
const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };
function getStageState(phase: string) {
const current = progrev.state?.ftuxPhase ?? 'landing';
const currentNum = phaseOrder[current];
const stageNum = phaseOrder[phase];
if (stageNum < currentNum) return 'completed';
if (stageNum === currentNum) return 'current';
return 'upcoming';
}
</script>
<nav class="progress-indicator {orientation}" aria-label="Progress">
<ol class="stage-list">
{#each stages as stage}
{@const state = getStageState(stage.phase)}
<li class="stage {state}">
<div class="stage-dot">
{#if state === 'completed'}
<span class="i-mdi-check"></span>
{/if}
</div>
{#if density !== 'compact'}
<span class="stage-label">{stage.label}</span>
{/if}
</li>
{/each}
</ol>
</nav>
<style>
.progress-indicator { --completed: hsl(142 71% 45%); --current: hsl(221 83% 53%); }
.stage-list { list-style: none; padding: 0; margin: 0; display: flex; }
.vertical .stage-list { flex-direction: column; gap: 1.5rem; }
.horizontal .stage-list { justify-content: space-around; }
.stage-dot {
w-8 h-8 rounded-full flex items-center justify-center;
transition: all 200ms;
}
.completed .stage-dot { background: var(--completed); color: white; }
.current .stage-dot { background: var(--current); color: white; box-shadow: 0 0 0 4px hsl(221 83% 53% / 0.15); }
.upcoming .stage-dot { background: hsl(0 0% 80%); }
.completed .stage-label { opacity: 0.6; }
.upcoming .stage-label { opacity: 0.4; }
</style>
AchievementToast
Celebratory notification for milestones.
<!-- src/lib/progrev/AchievementToast.svelte -->
<script lang="ts">
interface Props {
achievement: 'PIONEER' | 'NAVIGATOR' | 'TRAILBLAZER';
duration?: number;
onDismiss?: () => void;
}
let { achievement, duration = 5000, onDismiss }: Props = $props();
let visible = $state(true);
const config = {
PIONEER: { icon: 'i-mdi-rocket-launch', title: 'Pioneer', desc: "You're in. Welcome to the frontier." },
NAVIGATOR: { icon: 'i-mdi-compass', title: 'Navigator', desc: 'You charted the course.' },
TRAILBLAZER: { icon: 'i-mdi-terrain', title: 'Trailblazer', desc: 'You forge your own path.' }
};
const { icon, title, desc } = config[achievement];
$effect(() => {
if (duration > 0) {
const timeout = setTimeout(() => {
visible = false;
onDismiss?.();
}, duration);
return () => clearTimeout(timeout);
}
});
</script>
{#if visible}
<div class="achievement-toast" role="status" aria-live="polite">
<span class={icon}></span>
<div>
<strong>{title}</strong>
<p>{desc}</p>
</div>
<button onclick={() => { visible = false; onDismiss?.(); }} aria-label="Dismiss">
<span class="i-mdi-close"></span>
</button>
</div>
{/if}
<style>
.achievement-toast {
position: fixed; top: 1rem; right: 1rem; z-index: 1000;
display: flex; align-items: center; gap: 1rem;
background: white; border: 1px solid hsl(0 0% 90%);
border-radius: 0.5rem; padding: 1rem; max-width: 24rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
animation: slide-in 300ms ease-out;
}
@keyframes slide-in {
from { transform: translateX(calc(100% + 1rem)); opacity: 0; }
}
@media (max-width: 640px) {
.achievement-toast { left: 1rem; right: 1rem; max-width: none; }
}
</style>
Cleanup Job
// src/lib/server/jobs/guest-cleanup.ts
import { db } from '$lib/server/db';
import { guest } from '$lib/server/db/schema/progrev';
import { lt, and, isNull } from 'drizzle-orm';
const STALE_DAYS = 30;
export async function cleanupStaleGuests(): Promise<{ deleted: number }> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - STALE_DAYS);
const result = await db
.delete(guest)
.where(and(
lt(guest.lastSeenAt, cutoff),
isNull(guest.mergedToUserId)
))
.returning({ id: guest.id });
return { deleted: result.length };
}
// src/routes/api/cron/guest-cleanup/+server.ts
import { json, error } from '@sveltejs/kit';
import { timingSafeEqual } from 'crypto';
import { CRON_SECRET } from '$env/static/private';
import { cleanupStaleGuests } from '$lib/server/jobs/guest-cleanup';
export async function GET({ request }) {
const auth = request.headers.get('authorization');
const expected = `Bearer ${CRON_SECRET}`;
if (!auth || auth.length !== expected.length ||
!timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
error(401, 'Unauthorized');
}
const result = await cleanupStaleGuests();
return json({ success: true, ...result });
}
Vercel cron:
{
"crons": [{
"path": "/api/cron/guest-cleanup",
"schedule": "0 2 * * *"
}]
}
File Structure
src/
├── app.d.ts # ProgRev types in App namespace
├── hooks.server.ts # Guest + ProgRev loading
├── routes/
│ ├── +layout.server.ts # Pass progrev to page data
│ ├── +layout.svelte # Cache management, context
│ └── api/cron/guest-cleanup/ # Daily cleanup endpoint
├── lib/
│ ├── types.ts # FtuxPhase, ProgRevState
│ ├── progrev/
│ │ ├── ProgRevGate.svelte # Conditional rendering
│ │ ├── ProgressIndicator.svelte # Progress display
│ │ ├── AchievementToast.svelte # Celebration notifications
│ │ └── context.svelte.ts # useProgRev() helper
│ └── server/
│ ├── db/schema/progrev.ts # Database schema
│ ├── progrev/
│ │ ├── actions.ts # recordAction(), createGuest()
│ │ └── merge.ts # mergeGuestToUser()
│ └── jobs/guest-cleanup.ts # Stale guest cleanup
Implementation Checklist
Phase 1: Database
- Create schema in
src/lib/server/db/schema/progrev.ts - Add ID generators to
id.ts - Generate and run migration
- Seed achievement definitions
Phase 2: Server Infrastructure
- Add hooks for guest identity loading
- Add hooks for ProgRev state loading
- Implement
recordAction()helper - Implement
mergeGuestToUser()transaction - Create cleanup cron endpoint
Phase 3: Client Components
-
ProgRevGate.svelte- Conditional rendering -
ProgressIndicator.svelte- Progress display -
AchievementToast.svelte- Celebrations - Context provider in root layout
- localStorage cache sync
Phase 4: Integration
- Landing page FTUX flow
- Sign-up flow with merge
- Onboarding flow (optional)
- Sidebar with ProgRev state
Related Documentation
- ../foundation/progressive-revelation.md - Philosophy and anti-goals
- auth.md - Better Auth integration
- state.md - Svelte 5 runes patterns
- forms.md - Superforms for actions
- db/relational.md - Schema conventions