learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: deck creation tutorial

+830 -37
+1 -1
docs/todo.md
··· 44 44 - [x] Onboarding flow with persona selection (Learner/Creator/Curator) 45 45 - [x] Empty states with helpful prompts for new users 46 46 - [x] Help center/FAQ section (with beta development notice) 47 - - [ ] Tutorial/walkthrough for first deck creation 47 + - [x] Tutorial/walkthrough for first deck creation 48 48 49 49 **SEO & Meta:** 50 50
+4 -4
web/src/App.tsx
··· 1 1 import { AppLayout } from "$components/layout/AppLayout"; 2 2 import { OnboardingDialog } from "$components/OnboardingDialog"; 3 3 import type { Persona } from "$lib/model"; 4 - import { authStore, preferencesStore } from "$lib/store"; 4 + import { authStore, prefStore } from "$lib/store"; 5 5 import About from "$pages/About"; 6 6 import DeckNew from "$pages/DeckNew"; 7 7 import DeckView from "$pages/DeckView"; ··· 26 26 27 27 onMount(async () => { 28 28 if (authStore.isAuthenticated()) { 29 - await preferencesStore.fetchPreferences(); 29 + await prefStore.fetchPrefs(); 30 30 } 31 31 }); 32 32 33 33 createEffect(() => { 34 - if (preferencesStore.needsOnboarding()) { 34 + if (prefStore.needsOnboarding()) { 35 35 setShowOnboarding(true); 36 36 } 37 37 }); 38 38 39 39 const handleOnboardingComplete = (_persona: Persona) => { 40 40 setShowOnboarding(false); 41 - preferencesStore.fetchPreferences(); 41 + prefStore.fetchPrefs(); 42 42 }; 43 43 44 44 return (
+19 -1
web/src/components/DeckEditor.tsx
··· 2 2 import { api } from "$lib/api"; 3 3 import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/model"; 4 4 import { toast } from "$lib/toast"; 5 + import { useTutorialTarget } from "$lib/TutorialProvider"; 5 6 import { Button } from "$ui/Button"; 6 7 import { createSignal, For, Show } from "solid-js"; 7 8 import { Motion } from "solid-motionone"; ··· 18 19 19 20 const [cards, setCards] = createSignal<Card[]>([]); 20 21 const [showCardEditor, setShowCardEditor] = createSignal(false); 22 + 23 + const registerTutorialTarget = (id: string) => { 24 + try { 25 + return useTutorialTarget(id); 26 + } catch { 27 + return () => {}; 28 + } 29 + }; 21 30 22 31 const handleSubmit = async (e: Event) => { 23 32 e.preventDefault(); ··· 80 89 <div> 81 90 <label for="title" class="block text-sm font-medium text-gray-400 mb-1">Title</label> 82 91 <input 92 + ref={registerTutorialTarget("title")} 83 93 id="title" 84 94 type="text" 85 95 value={title()} ··· 91 101 <div> 92 102 <label for="description" class="block text-sm font-medium text-gray-400 mb-1">Description</label> 93 103 <textarea 104 + ref={registerTutorialTarget("description")} 94 105 id="description" 95 106 value={description()} 96 107 onInput={(e) => setDescription(e.target.value)} ··· 100 111 <div> 101 112 <label for="tags" class="block text-sm font-medium text-gray-400 mb-1">Tags (comma separated)</label> 102 113 <input 114 + ref={registerTutorialTarget("tags")} 103 115 id="tags" 104 116 type="text" 105 117 value={tags()} ··· 111 123 <div> 112 124 <label for="visibility" class="block text-sm font-medium text-gray-400 mb-1">Visibility</label> 113 125 <select 126 + ref={registerTutorialTarget("visibility")} 114 127 id="visibility" 115 128 value={visibilityType()} 116 129 onChange={(e) => setVisibilityType(e.target.value as Visibility["type"])} ··· 187 200 <Show 188 201 when={showCardEditor()} 189 202 fallback={ 190 - <Button type="button" variant="secondary" onClick={() => setShowCardEditor(true)} class="w-full"> 203 + <Button 204 + ref={registerTutorialTarget("add-card")} 205 + type="button" 206 + variant="secondary" 207 + onClick={() => setShowCardEditor(true)} 208 + class="w-full"> 191 209 Add Card 192 210 </Button> 193 211 }>
+191
web/src/components/TutorialOverlay.tsx
··· 1 + import { useTutorial } from "$lib/TutorialProvider"; 2 + import { Button } from "$ui/Button"; 3 + import type { Component } from "solid-js"; 4 + import { createEffect, createMemo, createSignal, Index, onCleanup, Show } from "solid-js"; 5 + import { Motion, Presence } from "solid-motionone"; 6 + 7 + type Position = { top: number; left: number; width: number; height: number }; 8 + 9 + const getTooltipPosition = (target: Position, placement: "top" | "bottom" | "left" | "right") => { 10 + const padding = 12; 11 + const tooltipWidth = 320; 12 + 13 + switch (placement) { 14 + case "top": 15 + return { 16 + top: target.top - padding - 8, 17 + left: target.left + target.width / 2 - tooltipWidth / 2, 18 + transform: "translateY(-100%)", 19 + }; 20 + case "bottom": 21 + return { top: target.top + target.height + padding, left: target.left + target.width / 2 - tooltipWidth / 2 }; 22 + case "left": 23 + return { 24 + top: target.top + target.height / 2, 25 + left: target.left - padding - tooltipWidth, 26 + transform: "translateY(-50%)", 27 + }; 28 + case "right": 29 + return { 30 + top: target.top + target.height / 2, 31 + left: target.left + target.width + padding, 32 + transform: "translateY(-50%)", 33 + }; 34 + default: 35 + return { top: target.top + target.height + padding, left: target.left }; 36 + } 37 + }; 38 + 39 + export const TutorialOverlay: Component = () => { 40 + const tutorial = useTutorial(); 41 + const [targetPos, setTargetPos] = createSignal<Position | null>(null); 42 + 43 + const currentTarget = createMemo(() => { 44 + const step = tutorial.currentStep(); 45 + if (!step) return null; 46 + return tutorial.targets().get(step.id) ?? null; 47 + }); 48 + 49 + createEffect(() => { 50 + if (!tutorial.active()) return; 51 + 52 + const element = currentTarget(); 53 + if (!element) { 54 + setTargetPos(null); 55 + return; 56 + } 57 + 58 + const updatePosition = () => { 59 + const rect = element.getBoundingClientRect(); 60 + setTargetPos({ 61 + top: rect.top + window.scrollY, 62 + left: rect.left + window.scrollX, 63 + width: rect.width, 64 + height: rect.height, 65 + }); 66 + }; 67 + 68 + updatePosition(); 69 + window.addEventListener("resize", updatePosition); 70 + window.addEventListener("scroll", updatePosition); 71 + onCleanup(() => { 72 + window.removeEventListener("resize", updatePosition); 73 + window.removeEventListener("scroll", updatePosition); 74 + }); 75 + }); 76 + 77 + createEffect(() => { 78 + if (!tutorial.active()) return; 79 + const element = currentTarget(); 80 + if (element && typeof element.scrollIntoView === "function") { 81 + element.scrollIntoView({ behavior: "smooth", block: "center" }); 82 + } 83 + }); 84 + 85 + return ( 86 + <Presence> 87 + <Show when={tutorial.active()}> 88 + <Motion.div 89 + initial={{ opacity: 0 }} 90 + animate={{ opacity: 1 }} 91 + exit={{ opacity: 0 }} 92 + transition={{ duration: 0.2 }} 93 + class="fixed inset-0 z-50 pointer-events-none"> 94 + <Show when={targetPos()}> 95 + {(pos) => ( 96 + <> 97 + <svg 98 + class="absolute inset-0 w-full h-full pointer-events-auto" 99 + style={{ height: `${document.documentElement.scrollHeight}px` }}> 100 + <defs> 101 + <mask id="spotlight-mask"> 102 + <rect width="100%" height="100%" fill="white" /> 103 + <rect 104 + x={pos().left - 8} 105 + y={pos().top - 8} 106 + width={pos().width + 16} 107 + height={pos().height + 16} 108 + rx="8" 109 + fill="black" /> 110 + </mask> 111 + </defs> 112 + <rect 113 + width="100%" 114 + height="100%" 115 + fill="rgba(0,0,0,0.75)" 116 + mask="url(#spotlight-mask)" 117 + onClick={() => tutorial.skipTutorial()} /> 118 + </svg> 119 + 120 + <div 121 + class="absolute border-2 border-[#0F62FE] rounded-lg pointer-events-none" 122 + style={{ 123 + top: `${pos().top - 8}px`, 124 + left: `${pos().left - 8}px`, 125 + width: `${pos().width + 16}px`, 126 + height: `${pos().height + 16}px`, 127 + "box-shadow": "0 0 0 4px rgba(15, 98, 254, 0.3)", 128 + }} /> 129 + 130 + <Motion.div 131 + initial={{ opacity: 0, scale: 0.95 }} 132 + animate={{ opacity: 1, scale: 1 }} 133 + transition={{ duration: 0.2 }} 134 + class="absolute w-80 bg-[#262626] border border-[#393939] rounded-lg shadow-xl p-4 pointer-events-auto" 135 + style={{ 136 + top: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).top}px`, 137 + left: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).left}px`, 138 + transform: getTooltipPosition(pos(), tutorial.currentStep()!.placement).transform, 139 + }}> 140 + <div class="h-1 bg-[#393939] rounded-full mb-4 overflow-hidden"> 141 + <div 142 + class="h-full bg-[#0F62FE] transition-all duration-300" 143 + style={{ width: `${tutorial.progress()}%` }} /> 144 + </div> 145 + 146 + <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">{tutorial.currentStep()?.title}</h3> 147 + <p class="text-sm text-[#C6C6C6] mb-4">{tutorial.currentStep()?.desc}</p> 148 + 149 + <div class="flex items-center justify-between"> 150 + <button 151 + onClick={() => tutorial.skipTutorial()} 152 + class="text-sm text-[#8D8D8D] hover:text-[#C6C6C6] transition-colors"> 153 + Skip tutorial 154 + </button> 155 + <div class="flex gap-2"> 156 + <Show when={!tutorial.isFirstStep()}> 157 + <Button 158 + variant="secondary" 159 + size="sm" 160 + onClick={() => tutorial.prevStep()}> 161 + Back 162 + </Button> 163 + </Show> 164 + <Button size="sm" onClick={() => tutorial.nextStep()}> 165 + {tutorial.isLastStep() ? "Finish" : "Next"} 166 + </Button> 167 + </div> 168 + </div> 169 + 170 + <div class="flex justify-center gap-1.5 mt-4"> 171 + <Index each={tutorial.steps()}> 172 + {(_, i) => ( 173 + <div 174 + class={`w-2 h-2 rounded-full transition-colors ${ 175 + i === tutorial.currentStepIndex() ? "bg-[#0F62FE]" : "bg-[#525252]" 176 + }`} /> 177 + )} 178 + </Index> 179 + </div> 180 + <p class="text-xs text-[#525252] text-center mt-3">Use ← → arrow keys or Esc to skip</p> 181 + </Motion.div> 182 + </> 183 + )} 184 + </Show> 185 + </Motion.div> 186 + </Show> 187 + </Presence> 188 + ); 189 + }; 190 + 191 + export default TutorialOverlay;
+112
web/src/components/tests/TutorialOverlay.test.tsx
··· 1 + import { TutorialProvider, useTutorial } from "$lib/TutorialProvider"; 2 + import { cleanup, render, screen } from "@solidjs/testing-library"; 3 + import { createEffect } from "solid-js"; 4 + import { afterEach, describe, expect, it, vi } from "vitest"; 5 + import { TutorialOverlay } from "../TutorialOverlay"; 6 + 7 + vi.mock("$lib/api", () => ({ api: { updatePreferences: vi.fn().mockResolvedValue({ ok: true }) } })); 8 + 9 + vi.mock( 10 + "$lib/store", 11 + () => ({ prefStore: { prefs: vi.fn(() => ({ tutorial_deck_completed: false })), fetchPrefs: vi.fn() } }), 12 + ); 13 + 14 + const TutorialTestHarness = (props: { onTutorial?: (t: ReturnType<typeof useTutorial>) => void }) => { 15 + const tutorial = useTutorial(); 16 + 17 + createEffect(() => { 18 + props.onTutorial?.(tutorial); 19 + }); 20 + 21 + return ( 22 + <> 23 + <div 24 + ref={(el) => tutorial.registerTarget("title", el)} 25 + style={{ position: "absolute", top: "100px", left: "100px", width: "200px", height: "50px" }}> 26 + Target Element 27 + </div> 28 + <TutorialOverlay /> 29 + </> 30 + ); 31 + }; 32 + 33 + describe("TutorialOverlay", () => { 34 + afterEach(cleanup); 35 + 36 + it("does not render when tutorial is inactive", () => { 37 + render(() => ( 38 + <TutorialProvider> 39 + <TutorialTestHarness /> 40 + </TutorialProvider> 41 + )); 42 + expect(screen.queryByText("Name Your Deck")).not.toBeInTheDocument(); 43 + }); 44 + 45 + it("renders when tutorial is active and target exists", () => { 46 + let tutorialRef: ReturnType<typeof useTutorial> | undefined; 47 + 48 + render(() => ( 49 + <TutorialProvider> 50 + <TutorialTestHarness 51 + onTutorial={(t) => { 52 + tutorialRef = t; 53 + }} /> 54 + </TutorialProvider> 55 + )); 56 + 57 + tutorialRef!.startTutorial(); 58 + 59 + expect(screen.getByText("Name Your Deck")).toBeInTheDocument(); 60 + }); 61 + 62 + it("shows skip button when active", () => { 63 + let tutorialRef: ReturnType<typeof useTutorial> | undefined; 64 + 65 + render(() => ( 66 + <TutorialProvider> 67 + <TutorialTestHarness 68 + onTutorial={(t) => { 69 + tutorialRef = t; 70 + }} /> 71 + </TutorialProvider> 72 + )); 73 + 74 + tutorialRef!.startTutorial(); 75 + 76 + expect(screen.getByText("Skip tutorial")).toBeInTheDocument(); 77 + }); 78 + 79 + it("shows Next button when not on last step", () => { 80 + let tutorialRef: ReturnType<typeof useTutorial> | undefined; 81 + 82 + render(() => ( 83 + <TutorialProvider> 84 + <TutorialTestHarness 85 + onTutorial={(t) => { 86 + tutorialRef = t; 87 + }} /> 88 + </TutorialProvider> 89 + )); 90 + 91 + tutorialRef!.startTutorial(); 92 + 93 + expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument(); 94 + }); 95 + 96 + it("shows keyboard hint", () => { 97 + let tutorialRef: ReturnType<typeof useTutorial> | undefined; 98 + 99 + render(() => ( 100 + <TutorialProvider> 101 + <TutorialTestHarness 102 + onTutorial={(t) => { 103 + tutorialRef = t; 104 + }} /> 105 + </TutorialProvider> 106 + )); 107 + 108 + tutorialRef!.startTutorial(); 109 + 110 + expect(screen.getByText(/arrow keys/i)).toBeInTheDocument(); 111 + }); 112 + });
+178
web/src/lib/TutorialProvider.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { prefStore } from "$lib/store"; 3 + import type { Accessor, JSX } from "solid-js"; 4 + import { createContext, createEffect, createSignal, onCleanup, useContext } from "solid-js"; 5 + 6 + type TutorialPlacement = "top" | "bottom" | "left" | "right"; 7 + 8 + export type TutorialStep = { id: string; title: string; desc: string; placement: TutorialPlacement }; 9 + 10 + export const DECK_CREATION_STEPS: TutorialStep[] = [{ 11 + id: "title", 12 + title: "Name Your Deck", 13 + desc: "Start by giving your deck a memorable title. This helps you find it later!", 14 + placement: "bottom", 15 + }, { 16 + id: "description", 17 + title: "Add a Description", 18 + desc: "Describe what this deck covers. Great for sharing with others!", 19 + placement: "bottom", 20 + }, { 21 + id: "tags", 22 + title: "Add Tags", 23 + desc: "Tags help categorize your deck. Use topics like 'spanish', 'vocabulary', 'history'.", 24 + placement: "bottom", 25 + }, { 26 + id: "visibility", 27 + title: "Set Visibility", 28 + desc: "Keep it Private for yourself, or make it Public to share with the community.", 29 + placement: "bottom", 30 + }, { 31 + id: "add-card", 32 + title: "Add Your First Card", 33 + desc: "Click here to create a flashcard. Add a question on the front and answer on the back!", 34 + placement: "top", 35 + }]; 36 + 37 + type TutorialContextValue = { 38 + active: Accessor<boolean>; 39 + currentStep: Accessor<TutorialStep | undefined>; 40 + currentStepIndex: Accessor<number>; 41 + steps: Accessor<TutorialStep[]>; 42 + targets: Accessor<Map<string, HTMLElement>>; 43 + isFirstStep: Accessor<boolean>; 44 + isLastStep: Accessor<boolean>; 45 + progress: Accessor<number>; 46 + shouldShowTutorial: () => boolean; 47 + startTutorial: () => void; 48 + nextStep: () => void; 49 + prevStep: () => void; 50 + skipTutorial: () => void; 51 + completeTutorial: () => Promise<void>; 52 + registerTarget: (id: string, element: HTMLElement) => void; 53 + unregisterTarget: (id: string) => void; 54 + }; 55 + 56 + const TutorialContext = createContext<TutorialContextValue>(); 57 + 58 + export function TutorialProvider(props: { children: JSX.Element; steps?: TutorialStep[] }) { 59 + const [active, setActive] = createSignal(false); 60 + const [currentStepIndex, setCurrentStepIndex] = createSignal(0); 61 + const [targets, setTargets] = createSignal<Map<string, HTMLElement>>(new Map()); 62 + const steps = () => props.steps ?? DECK_CREATION_STEPS; 63 + 64 + const shouldShowTutorial = () => { 65 + const prefs = prefStore.prefs(); 66 + return prefs !== null && !prefs.tutorial_deck_completed; 67 + }; 68 + 69 + const registerTarget = (id: string, element: HTMLElement) => { 70 + setTargets((prev) => { 71 + const next = new Map(prev); 72 + next.set(id, element); 73 + return next; 74 + }); 75 + }; 76 + 77 + const unregisterTarget = (id: string) => { 78 + setTargets((prev) => { 79 + const next = new Map(prev); 80 + next.delete(id); 81 + return next; 82 + }); 83 + }; 84 + 85 + const startTutorial = () => { 86 + setCurrentStepIndex(0); 87 + setActive(true); 88 + }; 89 + 90 + const nextStep = () => { 91 + if (currentStepIndex() < steps().length - 1) { 92 + setCurrentStepIndex(currentStepIndex() + 1); 93 + } else { 94 + completeTutorial(); 95 + } 96 + }; 97 + 98 + const prevStep = () => { 99 + if (currentStepIndex() > 0) { 100 + setCurrentStepIndex(currentStepIndex() - 1); 101 + } 102 + }; 103 + 104 + const skipTutorial = () => completeTutorial(); 105 + 106 + const completeTutorial = async () => { 107 + setActive(false); 108 + try { 109 + await api.updatePreferences({ tutorial_deck_completed: true }); 110 + prefStore.fetchPrefs(); 111 + } catch (e) { 112 + console.error("Failed to mark tutorial complete:", e); 113 + } 114 + }; 115 + 116 + const currentStep = () => steps()[currentStepIndex()]; 117 + const isFirstStep = () => currentStepIndex() === 0; 118 + const isLastStep = () => currentStepIndex() === steps().length - 1; 119 + const progress = () => ((currentStepIndex() + 1) / steps().length) * 100; 120 + 121 + // Keyboard navigation 122 + createEffect(() => { 123 + if (!active()) return; 124 + 125 + const handleKeyDown = (e: KeyboardEvent) => { 126 + if (e.key === "Escape") skipTutorial(); 127 + if (e.key === "ArrowRight" || e.key === "Enter") nextStep(); 128 + if (e.key === "ArrowLeft") prevStep(); 129 + }; 130 + 131 + window.addEventListener("keydown", handleKeyDown); 132 + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); 133 + }); 134 + 135 + const value: TutorialContextValue = { 136 + active, 137 + currentStep, 138 + currentStepIndex, 139 + steps, 140 + targets, 141 + isFirstStep, 142 + isLastStep, 143 + progress, 144 + shouldShowTutorial, 145 + startTutorial, 146 + nextStep, 147 + prevStep, 148 + skipTutorial, 149 + completeTutorial, 150 + registerTarget, 151 + unregisterTarget, 152 + }; 153 + 154 + return <TutorialContext.Provider value={value}>{props.children}</TutorialContext.Provider>; 155 + } 156 + 157 + export function useTutorial() { 158 + const context = useContext(TutorialContext); 159 + if (!context) { 160 + throw new Error("useTutorial must be used within a TutorialProvider"); 161 + } 162 + return context; 163 + } 164 + 165 + /** 166 + * Register an element as a tutorial target. Use as a ref directive. 167 + */ 168 + export function useTutorialTarget(stepId: string) { 169 + const context = useContext(TutorialContext); 170 + 171 + return (element: HTMLElement) => { 172 + if (!context) return; 173 + context.registerTarget(stepId, element); 174 + onCleanup(() => context.unregisterTarget(stepId)); 175 + }; 176 + } 177 + 178 + export type { TutorialContextValue };
+10 -10
web/src/lib/store.ts
··· 41 41 42 42 export const authStore = createRoot(createAuthStore); 43 43 44 - function createPreferencesStore() { 45 - const [preferences, setPreferences] = createSignal<UserPreferences | null>(null); 44 + function createPrefStore() { 45 + const [prefs, setPrefs] = createSignal<UserPreferences | null>(null); 46 46 const [loading, setLoading] = createSignal(false); 47 47 48 - const fetchPreferences = async () => { 48 + const fetchPrefs = async () => { 49 49 if (!authStore.isAuthenticated()) return; 50 50 setLoading(true); 51 51 try { 52 52 const res = await api.getPreferences(); 53 53 if (res.ok) { 54 - setPreferences(await res.json()); 54 + setPrefs(await res.json()); 55 55 } 56 56 } catch (e) { 57 57 console.error("Failed to fetch preferences:", e); ··· 64 64 try { 65 65 const res = await api.updatePreferences(updates); 66 66 if (res.ok) { 67 - setPreferences(await res.json()); 67 + setPrefs(await res.json()); 68 68 } 69 69 } catch (e) { 70 70 console.error("Failed to update preferences:", e); ··· 72 72 }; 73 73 74 74 const needsOnboarding = () => { 75 - const prefs = preferences(); 76 - return prefs !== null && prefs.onboarding_completed_at === null; 75 + const preferences = prefs(); 76 + return preferences !== null && preferences.onboarding_completed_at === null; 77 77 }; 78 78 79 - const persona = () => preferences()?.persona ?? null; 79 + const persona = () => prefs()?.persona ?? null; 80 80 81 - return { preferences, loading, fetchPreferences, updatePreferences, needsOnboarding, persona }; 81 + return { prefs, loading, fetchPrefs, updatePreferences, needsOnboarding, persona }; 82 82 } 83 83 84 - export const preferencesStore = createRoot(createPreferencesStore); 84 + export const prefStore = createRoot(createPrefStore);
+175
web/src/lib/tests/TutorialProvider.test.tsx
··· 1 + import { render } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + 4 + vi.mock("$lib/api", () => ({ api: { updatePreferences: vi.fn().mockResolvedValue({ ok: true }) } })); 5 + 6 + vi.mock( 7 + "$lib/store", 8 + () => ({ prefStore: { prefs: vi.fn(() => ({ tutorial_deck_completed: false })), fetchPrefs: vi.fn() } }), 9 + ); 10 + 11 + import { DECK_CREATION_STEPS, TutorialProvider, useTutorial } from "../TutorialProvider"; 12 + 13 + describe("DECK_CREATION_STEPS", () => { 14 + it("has predefined deck creation steps", () => { 15 + expect(DECK_CREATION_STEPS.length).toBeGreaterThan(0); 16 + expect(DECK_CREATION_STEPS[0]).toHaveProperty("id"); 17 + expect(DECK_CREATION_STEPS[0]).toHaveProperty("title"); 18 + expect(DECK_CREATION_STEPS[0]).toHaveProperty("desc"); 19 + }); 20 + }); 21 + 22 + describe("useTutorial", () => { 23 + it("throws error when used outside provider", () => { 24 + expect(() => { 25 + const TestComponent = () => { 26 + useTutorial(); 27 + return null; 28 + }; 29 + render(() => <TestComponent />); 30 + }).toThrow("useTutorial must be used within a TutorialProvider"); 31 + }); 32 + }); 33 + 34 + describe("TutorialProvider", () => { 35 + it("provides tutorial context to children", () => { 36 + let tutorial: ReturnType<typeof useTutorial> | undefined; 37 + 38 + const Consumer = () => { 39 + tutorial = useTutorial(); 40 + return null; 41 + }; 42 + 43 + render(() => ( 44 + <TutorialProvider> 45 + <Consumer /> 46 + </TutorialProvider> 47 + )); 48 + 49 + expect(tutorial).toBeDefined(); 50 + expect(tutorial!.active()).toBe(false); 51 + expect(tutorial!.currentStepIndex()).toBe(0); 52 + }); 53 + 54 + it("starts tutorial when startTutorial is called", () => { 55 + let tutorial: ReturnType<typeof useTutorial> | undefined; 56 + 57 + const Consumer = () => { 58 + tutorial = useTutorial(); 59 + return null; 60 + }; 61 + 62 + render(() => ( 63 + <TutorialProvider> 64 + <Consumer /> 65 + </TutorialProvider> 66 + )); 67 + 68 + tutorial!.startTutorial(); 69 + expect(tutorial!.active()).toBe(true); 70 + expect(tutorial!.currentStepIndex()).toBe(0); 71 + }); 72 + 73 + it("advances to next step", () => { 74 + let tutorial: ReturnType<typeof useTutorial> | undefined; 75 + 76 + const Consumer = () => { 77 + tutorial = useTutorial(); 78 + return null; 79 + }; 80 + 81 + render(() => ( 82 + <TutorialProvider> 83 + <Consumer /> 84 + </TutorialProvider> 85 + )); 86 + 87 + tutorial!.startTutorial(); 88 + tutorial!.nextStep(); 89 + expect(tutorial!.currentStepIndex()).toBe(1); 90 + }); 91 + 92 + it("goes back to previous step", () => { 93 + let tutorial: ReturnType<typeof useTutorial> | undefined; 94 + 95 + const Consumer = () => { 96 + tutorial = useTutorial(); 97 + return null; 98 + }; 99 + 100 + render(() => ( 101 + <TutorialProvider> 102 + <Consumer /> 103 + </TutorialProvider> 104 + )); 105 + 106 + tutorial!.startTutorial(); 107 + tutorial!.nextStep(); 108 + tutorial!.nextStep(); 109 + tutorial!.prevStep(); 110 + expect(tutorial!.currentStepIndex()).toBe(1); 111 + }); 112 + 113 + it("does not go below step 0", () => { 114 + let tutorial: ReturnType<typeof useTutorial> | undefined; 115 + 116 + const Consumer = () => { 117 + tutorial = useTutorial(); 118 + return null; 119 + }; 120 + 121 + render(() => ( 122 + <TutorialProvider> 123 + <Consumer /> 124 + </TutorialProvider> 125 + )); 126 + 127 + tutorial!.startTutorial(); 128 + tutorial!.prevStep(); 129 + expect(tutorial!.currentStepIndex()).toBe(0); 130 + }); 131 + 132 + it("calculates progress correctly", () => { 133 + let tutorial: ReturnType<typeof useTutorial> | undefined; 134 + 135 + const Consumer = () => { 136 + tutorial = useTutorial(); 137 + return null; 138 + }; 139 + 140 + render(() => ( 141 + <TutorialProvider> 142 + <Consumer /> 143 + </TutorialProvider> 144 + )); 145 + 146 + tutorial!.startTutorial(); 147 + const stepCount = tutorial!.steps().length; 148 + expect(tutorial!.progress()).toBe((1 / stepCount) * 100); 149 + 150 + tutorial!.nextStep(); 151 + expect(tutorial!.progress()).toBe((2 / stepCount) * 100); 152 + }); 153 + 154 + it("registers and unregisters targets", () => { 155 + let tutorial: ReturnType<typeof useTutorial> | undefined; 156 + const mockElement = document.createElement("div"); 157 + 158 + const Consumer = () => { 159 + tutorial = useTutorial(); 160 + return null; 161 + }; 162 + 163 + render(() => ( 164 + <TutorialProvider> 165 + <Consumer /> 166 + </TutorialProvider> 167 + )); 168 + 169 + tutorial!.registerTarget("test", mockElement); 170 + expect(tutorial!.targets().get("test")).toBe(mockElement); 171 + 172 + tutorial!.unregisterTarget("test"); 173 + expect(tutorial!.targets().has("test")).toBe(false); 174 + }); 175 + });
+58 -19
web/src/pages/DeckNew.tsx
··· 1 1 import { DeckEditor } from "$components/DeckEditor"; 2 + import { TutorialOverlay } from "$components/TutorialOverlay"; 2 3 import { api } from "$lib/api"; 3 4 import type { CreateDeckPayload } from "$lib/model"; 5 + import { prefStore } from "$lib/store"; 4 6 import { toast } from "$lib/toast"; 7 + import { TutorialProvider, useTutorial } from "$lib/TutorialProvider"; 8 + import { Button } from "$ui/Button"; 5 9 import { useNavigate } from "@solidjs/router"; 6 10 import type { Component } from "solid-js"; 11 + import { createEffect, onMount, Show } from "solid-js"; 7 12 import { Motion } from "solid-motionone"; 8 13 9 - const DeckNew: Component = () => { 14 + const DeckNewContent: Component = () => { 10 15 const navigate = useNavigate(); 16 + const tutorial = useTutorial(); 17 + 18 + onMount(async () => { 19 + if (!prefStore.prefs()) { 20 + await prefStore.fetchPrefs(); 21 + } 22 + }); 23 + 24 + createEffect(() => { 25 + if (tutorial.shouldShowTutorial() && !tutorial.active()) { 26 + setTimeout(() => tutorial.startTutorial(), 500); 27 + } 28 + }); 11 29 12 30 const handleSave = async (data: CreateDeckPayload) => { 13 31 try { 14 32 const res = await api.createDeck(data); 15 33 if (res.ok) { 16 34 const deck = await res.json(); 35 + if (!prefStore.prefs()?.tutorial_deck_completed) { 36 + await api.updatePreferences({ tutorial_deck_completed: true }); 37 + prefStore.fetchPrefs(); 38 + } 17 39 toast.success("Deck created successfully"); 18 40 navigate(`/decks/${deck.id}`); 19 41 } else { ··· 27 49 }; 28 50 29 51 return ( 30 - <Motion.div 31 - initial={{ opacity: 0 }} 32 - animate={{ opacity: 1 }} 33 - transition={{ duration: 0.3 }} 34 - class="max-w-3xl mx-auto"> 52 + <> 35 53 <Motion.div 36 - initial={{ opacity: 0, y: -10 }} 37 - animate={{ opacity: 1, y: 0 }} 38 - transition={{ duration: 0.4 }} 39 - class="mb-8"> 40 - <h1 class="text-4xl text-[#F4F4F4] mb-2 tracking-tight">Create New Deck</h1> 41 - <p class="text-[#C6C6C6] font-light">Start a new collection of flashcards.</p> 42 - </Motion.div> 43 - <Motion.div 44 - initial={{ opacity: 0, y: 20 }} 45 - animate={{ opacity: 1, y: 0 }} 46 - transition={{ duration: 0.4, delay: 0.1 }}> 47 - <DeckEditor onSave={handleSave} /> 54 + initial={{ opacity: 0 }} 55 + animate={{ opacity: 1 }} 56 + transition={{ duration: 0.3 }} 57 + class="max-w-3xl mx-auto"> 58 + <Motion.div 59 + initial={{ opacity: 0, y: -10 }} 60 + animate={{ opacity: 1, y: 0 }} 61 + transition={{ duration: 0.4 }} 62 + class="mb-8 flex justify-between items-start"> 63 + <div> 64 + <h1 class="text-4xl text-[#F4F4F4] mb-2 tracking-tight">Create New Deck</h1> 65 + <p class="text-[#C6C6C6] font-light">Start a new collection of flashcards.</p> 66 + </div> 67 + <Show when={prefStore.prefs()?.tutorial_deck_completed}> 68 + <Button variant="secondary" size="sm" onClick={() => tutorial.startTutorial()}> 69 + <span class="i-bi-question-circle mr-1.5" /> Show Tutorial 70 + </Button> 71 + </Show> 72 + </Motion.div> 73 + <Motion.div 74 + initial={{ opacity: 0, y: 20 }} 75 + animate={{ opacity: 1, y: 0 }} 76 + transition={{ duration: 0.4, delay: 0.1 }}> 77 + <DeckEditor onSave={handleSave} /> 78 + </Motion.div> 48 79 </Motion.div> 49 - </Motion.div> 80 + 81 + <TutorialOverlay /> 82 + </> 50 83 ); 51 84 }; 85 + 86 + const DeckNew: Component = () => ( 87 + <TutorialProvider> 88 + <DeckNewContent /> 89 + </TutorialProvider> 90 + ); 52 91 53 92 export default DeckNew;
+2 -2
web/src/pages/Home.tsx
··· 4 4 import { Tag } from "$components/ui/Tag"; 5 5 import { api } from "$lib/api"; 6 6 import type { Deck, Persona } from "$lib/model"; 7 - import { preferencesStore } from "$lib/store"; 7 + import { prefStore } from "$lib/store"; 8 8 import { Button } from "$ui/Button"; 9 9 import { A } from "@solidjs/router"; 10 10 import type { Component, JSX } from "solid-js"; ··· 145 145 }); 146 146 147 147 const currentTip = createMemo(() => { 148 - const persona = preferencesStore.persona(); 148 + const persona = prefStore.persona(); 149 149 return persona ? personaTips[persona] : defaultTip; 150 150 }); 151 151
+80
web/src/pages/tests/DeckNew.test.tsx
··· 1 + import { cleanup, render, screen } from "@solidjs/testing-library"; 2 + import { JSX } from "solid-js"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import DeckNew from "../DeckNew"; 5 + 6 + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); 7 + 8 + vi.mock("$lib/api", () => ({ api: { createDeck: vi.fn(), updatePreferences: vi.fn() } })); 9 + 10 + vi.mock( 11 + "$lib/store", 12 + () => ({ prefStore: { prefs: vi.fn(() => ({ tutorial_deck_completed: true })), fetchPrefs: vi.fn() } }), 13 + ); 14 + 15 + vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); 16 + 17 + vi.mock( 18 + "@solidjs/router", 19 + () => ({ 20 + useNavigate: () => mockNavigate, 21 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 22 + }), 23 + ); 24 + 25 + vi.mock( 26 + "$components/DeckEditor", 27 + () => ({ 28 + DeckEditor: (props: { onSave: (data: unknown) => void }) => ( 29 + <div data-testid="deck-editor"> 30 + <button 31 + onClick={() => 32 + props.onSave({ title: "Test", description: "", tags: [], visibility: { type: "Private" }, cards: [] })}> 33 + Save 34 + </button> 35 + </div> 36 + ), 37 + }), 38 + ); 39 + 40 + vi.mock("$components/TutorialOverlay", () => ({ TutorialOverlay: () => <div data-testid="tutorial-overlay" /> })); 41 + 42 + vi.mock( 43 + "$lib/TutorialProvider", 44 + () => ({ 45 + TutorialProvider: (props: { children: JSX.Element }) => <>{props.children}</>, 46 + useTutorial: () => ({ active: () => false, shouldShowTutorial: () => false, startTutorial: vi.fn() }), 47 + }), 48 + ); 49 + 50 + describe("DeckNew", () => { 51 + afterEach(() => { 52 + cleanup(); 53 + vi.clearAllMocks(); 54 + }); 55 + 56 + it("renders page title", () => { 57 + render(() => <DeckNew />); 58 + expect(screen.getByText("Create New Deck")).toBeInTheDocument(); 59 + }); 60 + 61 + it("renders page description", () => { 62 + render(() => <DeckNew />); 63 + expect(screen.getByText("Start a new collection of flashcards.")).toBeInTheDocument(); 64 + }); 65 + 66 + it("renders DeckEditor component", () => { 67 + render(() => <DeckNew />); 68 + expect(screen.getByTestId("deck-editor")).toBeInTheDocument(); 69 + }); 70 + 71 + it("renders TutorialOverlay component", () => { 72 + render(() => <DeckNew />); 73 + expect(screen.getByTestId("tutorial-overlay")).toBeInTheDocument(); 74 + }); 75 + 76 + it("shows tutorial button when tutorial already completed", () => { 77 + render(() => <DeckNew />); 78 + expect(screen.getByText(/Show Tutorial/i)).toBeInTheDocument(); 79 + }); 80 + });