diff --git a/docs/superpowers/plans/2026-06-14-quickstart-guide.md b/docs/superpowers/plans/2026-06-14-quickstart-guide.md new file mode 100644 index 0000000..f9c78a0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-quickstart-guide.md @@ -0,0 +1,656 @@ +# First-login Quickstart Guide Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a one-time, 5-slide welcome modal to brand-new users on their first Dashboard visit, pointing them to Providers, Templates, the Restaurant Landing template, and the build/deploy flow. + +**Architecture:** A DB flag `profiles.has_seen_quickstart` gates the modal (existing users backfilled to `true`). DashboardPage fetches the flag, renders a new `QuickstartGuide` component when it is `false`, and flips it to `true` on dismiss. Pure slide config + show-once predicate live in a testable `src/lib/quickstart.ts` module. A small deep-link addition to TemplatesPage lets the Restaurant slide open that template's preview directly. + +**Tech Stack:** React 19 + TypeScript, React Router v7, Supabase (Postgres + RLS), CSS Modules, Vitest (node env). + +--- + +## File Structure + +- Create: `supabase/migrations/20260614010000_add_quickstart_flag.sql` — add + backfill the flag. +- Create: `src/lib/quickstart.ts` — slide config + `shouldShowQuickstart` predicate (pure, testable). +- Create: `src/lib/__tests__/quickstart.test.ts` — unit tests for the above. +- Create: `src/components/QuickstartGuide/QuickstartGuide.tsx` — the modal component. +- Create: `src/components/QuickstartGuide/QuickstartGuide.module.css` — modal styles. +- Modify: `src/pages/DashboardPage.tsx` — fetch flag, render guide, persist dismissal. +- Modify: `src/pages/TemplatesPage.tsx` — auto-open a template from `location.state.openTemplateId`. + +--- + +## Task 1: Database migration for the show-once flag + +**Files:** +- Create: `supabase/migrations/20260614010000_add_quickstart_flag.sql` + +- [ ] **Step 1: Write the migration** + +Create `supabase/migrations/20260614010000_add_quickstart_flag.sql`: + +```sql +-- First-login quickstart guide: track whether a user has seen it. +alter table public.profiles + add column if not exists has_seen_quickstart boolean not null default false; + +-- Existing users have already used the app — don't re-onboard them. +-- New signups created after this migration get the default `false`. +update public.profiles set has_seen_quickstart = true; +``` + +- [ ] **Step 2: Verify it is valid SQL by inspection** + +The existing `profiles_update_own` RLS policy (in +`20260603000000_profiles_and_collaboration.sql`) already permits a user to update +their own row, so no new policy is required. Confirm no other migration adds a +`has_seen_quickstart` column (it does not). + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260614010000_add_quickstart_flag.sql +git commit -m "feat(db): add has_seen_quickstart flag to profiles" +``` + +> **Note for the implementer:** This migration must be applied to the live +> database with `supabase db push` (or via the dashboard) before the feature +> works end-to-end. Applying it is a deploy step, not part of the code commit. + +--- + +## Task 2: Pure quickstart logic and slide config + +**Files:** +- Create: `src/lib/quickstart.ts` +- Test: `src/lib/__tests__/quickstart.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/__tests__/quickstart.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { QUICKSTART_SLIDES, shouldShowQuickstart } from '../quickstart' + +describe('shouldShowQuickstart', () => { + it('shows only when the flag is explicitly false', () => { + expect(shouldShowQuickstart(false)).toBe(true) + }) + it('does not show when already seen', () => { + expect(shouldShowQuickstart(true)).toBe(false) + }) + it('does not show while unknown/loading (null or undefined)', () => { + expect(shouldShowQuickstart(null)).toBe(false) + expect(shouldShowQuickstart(undefined)).toBe(false) + }) +}) + +describe('QUICKSTART_SLIDES', () => { + it('starts with an advance action and ends with a finish action', () => { + expect(QUICKSTART_SLIDES[0].action.type).toBe('advance') + expect(QUICKSTART_SLIDES[QUICKSTART_SLIDES.length - 1].action.type).toBe('finish') + }) + it('routes the Providers slide to /providers', () => { + const slide = QUICKSTART_SLIDES.find((s) => s.id === 'providers') + expect(slide?.action).toEqual({ type: 'navigate', label: 'Go to Providers', to: '/providers' }) + }) + it('deep-links the Restaurant slide to the restaurant-landing template', () => { + const slide = QUICKSTART_SLIDES.find((s) => s.id === 'restaurant') + expect(slide?.action).toMatchObject({ + type: 'navigate', + to: '/templates', + state: { openTemplateId: 'restaurant-landing' }, + }) + }) + it('has unique slide ids', () => { + const ids = QUICKSTART_SLIDES.map((s) => s.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/__tests__/quickstart.test.ts` +Expected: FAIL — cannot resolve module `../quickstart`. + +- [ ] **Step 3: Write the implementation** + +Create `src/lib/quickstart.ts`: + +```ts +/** Action taken when a quickstart slide's primary button is pressed. */ +export type QuickstartAction = + | { type: 'advance'; label: string } + | { type: 'finish'; label: string } + | { type: 'navigate'; label: string; to: string; state?: Record } + +export interface QuickstartSlide { + id: string + heading: string + body: string + action: QuickstartAction +} + +/** + * Slides shown to a brand-new user on their first dashboard visit. + * `navigate` actions close the guide (persisting the flag) and route the user. + */ +export const QUICKSTART_SLIDES: QuickstartSlide[] = [ + { + id: 'welcome', + heading: 'Welcome to OpenThorn', + body: 'OpenThorn builds complete websites from a single prompt — using your own AI provider key.', + action: { type: 'advance', label: 'Next' }, + }, + { + id: 'providers', + heading: 'Connect a provider', + body: 'Your API key stays yours (BYOK). Add it under Providers in the sidebar to start generating.', + action: { type: 'navigate', label: 'Go to Providers', to: '/providers' }, + }, + { + id: 'templates', + heading: 'Browse Templates', + body: 'Prefer a head start? Production-ready starting points live under Templates.', + action: { type: 'navigate', label: 'Open Templates', to: '/templates' }, + }, + { + id: 'restaurant', + heading: 'Try the Restaurant Landing template', + body: 'Open Templates, click a card to preview it, then “Use this template” to customize it with AI.', + action: { + type: 'navigate', + label: 'Open Restaurant template', + to: '/templates', + state: { openTemplateId: 'restaurant-landing' }, + }, + }, + { + id: 'build', + heading: 'Build & deploy', + body: 'Describe your idea in the prompt box on the dashboard, then deploy your site when it’s ready.', + action: { type: 'finish', label: 'Get started' }, + }, +] + +/** Show the guide only when the persisted flag is explicitly false. */ +export function shouldShowQuickstart(hasSeen: boolean | null | undefined): boolean { + return hasSeen === false +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/__tests__/quickstart.test.ts` +Expected: PASS (9 assertions across the two describe blocks). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/quickstart.ts src/lib/__tests__/quickstart.test.ts +git commit -m "feat: add quickstart slide config and show-once predicate" +``` + +--- + +## Task 3: QuickstartGuide component + +**Files:** +- Create: `src/components/QuickstartGuide/QuickstartGuide.tsx` +- Create: `src/components/QuickstartGuide/QuickstartGuide.module.css` + +- [ ] **Step 1: Write the component** + +Create `src/components/QuickstartGuide/QuickstartGuide.tsx`: + +```tsx +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { QUICKSTART_SLIDES } from '../../lib/quickstart' +import styles from './QuickstartGuide.module.css' + +interface QuickstartGuideProps { + firstName: string + /** Called whenever the guide is dismissed (finish, navigate, or close). The + * parent persists the has_seen_quickstart flag here. */ + onClose: () => void +} + +export default function QuickstartGuide({ firstName, onClose }: QuickstartGuideProps) { + const navigate = useNavigate() + const [step, setStep] = useState(0) + const slide = QUICKSTART_SLIDES[step] + const isFirst = step === 0 + const total = QUICKSTART_SLIDES.length + + const handleAction = useCallback(() => { + const action = slide.action + if (action.type === 'advance') { + setStep((s) => Math.min(s + 1, total - 1)) + return + } + // Both 'finish' and 'navigate' dismiss the guide. + onClose() + if (action.type === 'navigate') { + navigate(action.to, action.state ? { state: action.state } : undefined) + } + }, [slide, onClose, navigate, total]) + + // Escape closes the guide. + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [onClose]) + + const heading = slide.id === 'welcome' + ? `Welcome to OpenThorn, ${firstName}` + : slide.heading + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + role="dialog" + aria-modal="true" + aria-label="Quickstart guide" + > +
+ + + Getting started · {step + 1}/{total} +

{heading}

+

{slide.body}

+ + + +
+ {!isFirst && ( + + )} + +
+ + +
+
+ ) +} +``` + +- [ ] **Step 2: Write the styles** + +Create `src/components/QuickstartGuide/QuickstartGuide.module.css`. Use design +tokens from `src/index.css` (`--color-bg`, `--color-text`, `--color-accent`); do +not hardcode brand hex values: + +```css +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 300; + display: grid; + place-items: center; + padding: 1.5rem; + animation: pageFade 0.2s ease both; +} + +.modal { + position: relative; + width: 100%; + max-width: 460px; + background: var(--color-bg); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 2.25rem 2rem 1.5rem; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); + text-align: center; + animation: pageRise 0.25s ease both; +} + +.close { + position: absolute; + top: 1rem; + right: 1rem; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 8px; + color: var(--color-text); + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; +} +.close:hover { opacity: 1; background: rgba(255, 255, 255, 0.08); } + +.eyebrow { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-accent); + margin-bottom: 0.85rem; +} + +.heading { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.75rem; + color: var(--color-text); +} + +.body { + font-size: 0.95rem; + line-height: 1.65; + color: var(--color-text); + opacity: 0.75; + margin: 0 auto 1.5rem; + max-width: 36ch; +} + +.dots { + display: flex; + justify-content: center; + gap: 0.4rem; + margin-bottom: 1.5rem; +} +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transition: background 0.2s, transform 0.2s; +} +.dotActive { background: var(--color-accent); transform: scale(1.25); } + +.actions { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +.primary { + flex: 1; + max-width: 280px; + padding: 0.8rem 1.5rem; + background: var(--color-accent); + color: #fff; + border-radius: 12px; + font-weight: 600; + font-size: 0.95rem; + transition: opacity 0.15s, transform 0.15s; +} +.primary:hover { opacity: 0.9; transform: translateY(-1px); } + +.back { + padding: 0.8rem 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + color: var(--color-text); + font-weight: 500; + font-size: 0.95rem; + transition: border-color 0.15s; +} +.back:hover { border-color: var(--color-accent); } + +.skip { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--color-text); + opacity: 0.5; + transition: opacity 0.15s; +} +.skip:hover { opacity: 0.85; } +``` + +> **Note:** `pageFade` and `pageRise` keyframes are defined globally in +> `src/index.css` (per CLAUDE.md). If a lint/build error reports them missing, +> replace the `animation` lines with a local `@keyframes` fallback, but they +> should resolve globally. + +- [ ] **Step 3: Verify it compiles** + +Run: `npm run build` +Expected: `tsc -b && vite build` completes with no errors referencing +`QuickstartGuide`. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/QuickstartGuide/QuickstartGuide.tsx src/components/QuickstartGuide/QuickstartGuide.module.css +git commit -m "feat: add QuickstartGuide modal component" +``` + +--- + +## Task 4: Wire the guide into DashboardPage + +**Files:** +- Modify: `src/pages/DashboardPage.tsx` + +- [ ] **Step 1: Add the import** + +Near the other component imports (after the `PromptInput` import around +`src/pages/DashboardPage.tsx:9`), add: + +```tsx +import QuickstartGuide from '../components/QuickstartGuide/QuickstartGuide' +import { shouldShowQuickstart } from '../lib/quickstart' +``` + +- [ ] **Step 2: Add state for the guide** + +After the `const [sidebarOpen, setSidebarOpen] = useState(false)` line +(`src/pages/DashboardPage.tsx:110`), add: + +```tsx + const [showQuickstart, setShowQuickstart] = useState(false) +``` + +- [ ] **Step 3: Fetch the flag on mount** + +Add a new effect immediately after the provider-status effect (which ends near +`src/pages/DashboardPage.tsx:286`). Insert: + +```tsx + // First-login quickstart guide — show once per account. + useEffect(() => { + if (!user) return + let cancelled = false + supabase + .from('profiles') + .select('has_seen_quickstart') + .eq('id', user.id) + .single() + .then(({ data, error }) => { + if (error) { + logError('DashboardQuickstartFlag', error) + return + } + if (!cancelled && shouldShowQuickstart(data?.has_seen_quickstart)) { + setShowQuickstart(true) + } + }) + return () => { cancelled = true } + }, [user]) +``` + +- [ ] **Step 4: Add the dismiss handler** + +Add this `useCallback` alongside the other handlers (e.g. after +`handleExampleClick` near `src/pages/DashboardPage.tsx:507`): + +```tsx + const handleQuickstartClose = useCallback(async () => { + setShowQuickstart(false) + if (!user) return + const { error } = await supabase + .from('profiles') + .update({ has_seen_quickstart: true }) + .eq('id', user.id) + if (error) logError('DashboardQuickstartDismiss', error) + }, [user]) +``` + +- [ ] **Step 5: Render the guide** + +Inside the top-level `<>...` return, render the guide as a sibling of the +other modals — directly after the opening `<>` is fine, but to match the +existing "modals live after `` root" pattern, add it right before the +Publish modal block (`{publishingProject && (` near +`src/pages/DashboardPage.tsx:1033`): + +```tsx + {showQuickstart && ( + + )} + +``` + +(`firstName` is already computed at `src/pages/DashboardPage.tsx:124`.) + +- [ ] **Step 6: Verify build and lint** + +Run: `npm run build && npm run lint` +Expected: no errors. (`useCallback` and `useEffect` are already imported at +`src/pages/DashboardPage.tsx:1`.) + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/DashboardPage.tsx +git commit -m "feat: show quickstart guide to first-login users on dashboard" +``` + +--- + +## Task 5: TemplatesPage deep-link to a template + +**Files:** +- Modify: `src/pages/TemplatesPage.tsx` + +- [ ] **Step 1: Read the route state** + +`useNavigate` is already imported. Add `useLocation` to the existing +`react-router-dom` import at `src/pages/TemplatesPage.tsx:2`: + +```tsx +import { useNavigate, useLocation } from 'react-router-dom' +``` + +Then, inside the component after `const navigate = useNavigate()` +(`src/pages/TemplatesPage.tsx:32`), add: + +```tsx + const location = useLocation() +``` + +- [ ] **Step 2: Auto-open the requested template** + +Add a new effect after the existing "Build live previews" effect (which ends near +`src/pages/TemplatesPage.tsx:55`): + +```tsx + // Deep-link: open a specific template's preview when navigated here with state + // (e.g. from the first-login quickstart guide). + useEffect(() => { + const openId = (location.state as { openTemplateId?: string } | null)?.openTemplateId + if (!openId) return + const match = templates.find((t) => t.id === openId) + if (match) { + setSelected(match) + setDeviceMode('desktop') + } + }, [location.state, templates]) +``` + +- [ ] **Step 3: Verify build** + +Run: `npm run build` +Expected: compiles cleanly. The Restaurant template (`id: 'restaurant-landing'`, +confirmed in `src/lib/templates.ts:1515`) will auto-open its preview overlay when +the quickstart "Open Restaurant template" button routes here. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/TemplatesPage.tsx +git commit -m "feat: deep-link templates page to auto-open a template preview" +``` + +--- + +## Task 6: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `npm run test` +Expected: all tests pass, including the new `quickstart.test.ts`. + +- [ ] **Step 2: Lint and build** + +Run: `npm run lint && npm run build` +Expected: no errors. + +- [ ] **Step 3: Manual smoke test (requires migration applied)** + +After `supabase db push` against a dev project: +1. Create a fresh account (or set `has_seen_quickstart = false` on your profile + row via SQL). +2. Land on `/dashboard` → the guide appears starting at "Welcome to OpenThorn, + ". +3. Step through with Next/Back; dots track position. +4. Press "Go to Providers" → guide closes, routes to `/providers`. +5. Reload `/dashboard` → guide does NOT reappear (flag persisted). +6. Reset the flag to `false`, reload, advance to the Restaurant slide, press + "Open Restaurant template" → lands on `/templates` with the Restaurant + preview overlay open. + +- [ ] **Step 4: Final commit (if any cleanup)** + +```bash +git add -A +git commit -m "chore: quickstart guide verification cleanup" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** show-once DB flag (Task 1), read/dismiss flow (Task 4), + component + 5 slides with action buttons (Tasks 2–3), Providers/Templates/ + Restaurant deep-link CTAs (Tasks 2 & 5), backfill of existing users (Task 1), + out-of-scope items omitted. All spec sections mapped. +- **Type consistency:** `QuickstartSlide.action` discriminated union is defined + in Task 2 and consumed unchanged in Task 3; `shouldShowQuickstart` signature + matches its use in Task 4; `openTemplateId` state key is identical in the slide + config (Task 2) and the TemplatesPage reader (Task 5). +- **No placeholders:** every code step contains complete, copy-ready content. diff --git a/docs/superpowers/specs/2026-06-14-quickstart-guide-design.md b/docs/superpowers/specs/2026-06-14-quickstart-guide-design.md new file mode 100644 index 0000000..7513544 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-quickstart-guide-design.md @@ -0,0 +1,98 @@ +# First-login Quickstart Guide — Design + +**Date:** 2026-06-14 +**Status:** Approved + +## Overview + +A one-time welcome modal that appears the first time a user lands on the +Dashboard after signing up. It walks through a short sequence of slides covering +what OpenThorn is, where to connect a provider, where Templates live, how to open +the Restaurant Landing template, and the core build/deploy flow. Each feature +slide carries an **action button** that closes the modal and navigates the user +to the relevant place. The guide is shown once per account, tracked via a flag on +the `profiles` table. + +The existing inline "Launch checklist" card on the Dashboard +(`DashboardPage.tsx`) stays as the ongoing reference; the quickstart guide +complements it rather than replacing it. + +## Show-once mechanism (database) + +New migration `supabase/migrations/_add_quickstart_flag.sql`: + +```sql +alter table public.profiles + add column if not exists has_seen_quickstart boolean not null default false; + +-- Existing users have already used the app — don't re-onboard them. +-- Only accounts created after this migration get the default `false`. +update public.profiles set has_seen_quickstart = true; +``` + +- New signups receive `has_seen_quickstart = false` from the column default via + the existing `handle_new_user` trigger. Only these users see the guide. +- The existing `profiles_update_own` RLS policy already allows a user to update + their own row, so the client can flip the flag itself. No policy changes. + +### Read / dismiss flow (DashboardPage) + +- On mount, once `user` is available, query + `select has_seen_quickstart from profiles where id = user.id`. +- If the value is `false`, open the quickstart modal. +- On dismiss (finishing the last slide, closing, or pressing any action button), + optimistically hide the modal in local state and run + `update profiles set has_seen_quickstart = true where id = user.id`. +- Failures to persist are logged via the existing `logError` helper and do not + block the UI (worst case: the guide could reappear on next load). + +## Component + +New `src/components/QuickstartGuide/QuickstartGuide.tsx` with a co-located +`QuickstartGuide.module.css`, following the existing modal patterns in the +codebase (backdrop + centered card, like the Publish-to-Community modal in +`DashboardPage.tsx` and the Templates preview overlay): click-outside / Escape to +close, design-token colors from `src/index.css`. + +**Props:** +- `firstName: string` +- `onClose: () => void` — called for both "finish" and "close"; the parent flips + the DB flag here. + +The component owns its own slide state (`step`, prev/next handlers, progress +dots). It receives a `navigate`-style callback (or imports `useNavigate`) so +action buttons can route; each action button calls `onClose` (which persists the +flag) before navigating, so the guide never reappears. + +**Slides** (each: a small inline SVG illustration, heading, one or two lines of +copy, and an action button): + +1. **Welcome, {firstName}** — OpenThorn builds complete websites from a prompt + using your own AI provider key. → *Next* +2. **Connect a provider** — Your key stays yours (BYOK). Find it under + **Providers** in the sidebar. → *Go to Providers* — `navigate('/providers')` +3. **Browse Templates** — Production-ready starting points live under + **Templates**. → *Open Templates* — `navigate('/templates')` +4. **Try the Restaurant Landing template** — Open Templates, click a card to + preview it, then "Use this template." → *Open Restaurant template* — + `navigate('/templates', { state: { openTemplateId: 'restaurant-landing' } })` +5. **Build & deploy** — Describe your idea in the prompt box and deploy when + ready. → *Get started* (closes the guide) + +The guide is rendered by `DashboardPage` as a sibling of the existing modals, +gated on the fetched `has_seen_quickstart` flag being `false`. + +## Supporting change: TemplatesPage deep-link + +`TemplatesPage` reads `location.state.openTemplateId` on mount and, if present and +matching a known template, auto-opens that template's preview overlay +(`setSelected(...)`). This lets slide 4's action button land the user directly on +the Restaurant Landing preview. The Restaurant template id is `restaurant-landing` +(verified in `src/lib/templates.ts`). + +## Out of scope (YAGNI) + +- No guided spotlight/coachmark tour over real UI elements. +- No re-show mechanism or "view guide again" entry point. +- No admin toggle or per-tenant configuration. +- No change to the existing inline Launch checklist. diff --git a/package-lock.json b/package-lock.json index 7f9be49..494da46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.106.2", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "driver.js": "^1.4.0", "esbuild-wasm": "^0.28.0", "framer-motion": "^12.12.1", "hash-wasm": "^4.12.0", @@ -2918,6 +2919,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", diff --git a/package.json b/package.json index 3cb4115..7b1f768 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@supabase/supabase-js": "^2.106.2", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "driver.js": "^1.4.0", "esbuild-wasm": "^0.28.0", "framer-motion": "^12.12.1", "hash-wasm": "^4.12.0", diff --git a/src/components/DashboardSidebar/DashboardSidebar.tsx b/src/components/DashboardSidebar/DashboardSidebar.tsx index 18827ab..91ac52b 100644 --- a/src/components/DashboardSidebar/DashboardSidebar.tsx +++ b/src/components/DashboardSidebar/DashboardSidebar.tsx @@ -32,6 +32,7 @@ interface DashboardSidebarProps { interface NavItem { label: string icon: ReactNode + tourId?: string } const iconSize = 20 @@ -48,6 +49,7 @@ const mainNavItems: NavItem[] = [ }, { label: 'Templates', + tourId: 'templates', icon: ( @@ -70,6 +72,7 @@ const mainNavItems: NavItem[] = [ }, { label: 'Providers', + tourId: 'providers', icon: ( @@ -193,6 +196,7 @@ export default function DashboardSidebar({ projects = [], activeFilter = 'all', className={`${styles.navItem} ${isSub ? styles.navItemSub : ''} ${isActive ? styles.navItemActive : ''}`} onClick={() => handleNavClick(item.label)} type="button" + data-tour={item.tourId} > {item.icon} {item.label} diff --git a/src/index.css b/src/index.css index cab7dc5..657b0cc 100644 --- a/src/index.css +++ b/src/index.css @@ -123,6 +123,56 @@ input, textarea { font-family: inherit; font-size: inherit; color: inherit; } border-radius: 4px; } +/* ===== driver.js spotlight tour — dark on-brand theme (popoverClass: openthorn-tour) ===== */ +.driver-popover.openthorn-tour { + background: var(--color-surface-raised); + color: var(--color-text); + border: 1px solid var(--color-border-visible); + border-radius: 14px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55); + max-width: 320px; +} +.driver-popover.openthorn-tour .driver-popover-title { + font-size: 1rem; + font-weight: 700; + color: var(--color-text); +} +.driver-popover.openthorn-tour .driver-popover-description { + font-size: 0.875rem; + line-height: 1.6; + color: var(--color-text-secondary); +} +.driver-popover.openthorn-tour .driver-popover-progress-text { + color: var(--color-text-muted); + font-size: 0.75rem; +} +.driver-popover.openthorn-tour .driver-popover-navigation-btns button { + background: var(--color-accent); + color: #fff; + text-shadow: none; + border: none; + border-radius: 8px; + padding: 0.45rem 0.9rem; + font-weight: 600; +} +.driver-popover.openthorn-tour .driver-popover-navigation-btns button:hover { + background: var(--color-accent-glow); +} +.driver-popover.openthorn-tour .driver-popover-prev-btn { + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-visible); +} +.driver-popover.openthorn-tour .driver-popover-prev-btn:hover { + background: rgba(255, 255, 255, 0.06); +} +.driver-popover.openthorn-tour .driver-popover-close-btn { + color: var(--color-text-muted); +} +.driver-popover.openthorn-tour .driver-popover-arrow { + border-color: var(--color-surface-raised); +} + /* ===== Shared entrance keyframes (global — referenced from page modules) ===== */ @keyframes pageRise { from { opacity: 0; transform: translateY(18px); } diff --git a/src/lib/__tests__/quickstart.test.ts b/src/lib/__tests__/quickstart.test.ts new file mode 100644 index 0000000..5b28ea2 --- /dev/null +++ b/src/lib/__tests__/quickstart.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { DASHBOARD_TOUR_STEPS, shouldShowQuickstart } from '../quickstart' + +describe('shouldShowQuickstart', () => { + it('shows only when the flag is explicitly false', () => { + expect(shouldShowQuickstart(false)).toBe(true) + }) + it('does not show when already seen', () => { + expect(shouldShowQuickstart(true)).toBe(false) + }) + it('does not show while unknown/loading (null or undefined)', () => { + expect(shouldShowQuickstart(null)).toBe(false) + expect(shouldShowQuickstart(undefined)).toBe(false) + }) +}) + +describe('DASHBOARD_TOUR_STEPS', () => { + it('targets the providers, templates, and prompt anchors', () => { + expect(DASHBOARD_TOUR_STEPS.map((s) => s.element)).toEqual([ + '[data-tour="providers"]', + '[data-tour="templates"]', + '[data-tour="prompt"]', + ]) + }) + it('gives every step a title and description', () => { + for (const step of DASHBOARD_TOUR_STEPS) { + expect(step.title.length).toBeGreaterThan(0) + expect(step.description.length).toBeGreaterThan(0) + } + }) +}) diff --git a/src/lib/dashboard-tour.ts b/src/lib/dashboard-tour.ts new file mode 100644 index 0000000..8905710 --- /dev/null +++ b/src/lib/dashboard-tour.ts @@ -0,0 +1,44 @@ +import { driver } from 'driver.js' +import 'driver.js/dist/driver.css' +import { DASHBOARD_TOUR_STEPS } from './quickstart' + +/** + * Runs the first-login dashboard spotlight tour. Steps whose target element is + * not currently in the DOM (e.g. sidebar hidden on small screens) are skipped. + * `onComplete` fires once when the tour finishes or is dismissed — the caller + * uses it to persist the "seen" flag. + */ +export function startDashboardTour(onComplete: () => void): void { + const steps = DASHBOARD_TOUR_STEPS + .filter((s) => document.querySelector(s.element)) + .map((s) => ({ + element: s.element, + popover: { title: s.title, description: s.description }, + })) + + if (steps.length === 0) { + onComplete() + return + } + + let finished = false + const finishOnce = () => { + if (finished) return + finished = true + onComplete() + } + + const tour = driver({ + showProgress: true, + allowClose: true, + overlayColor: 'rgba(7, 7, 15, 0.7)', + popoverClass: 'openthorn-tour', + nextBtnText: 'Next', + prevBtnText: 'Back', + doneBtnText: 'Got it', + steps, + onDestroyed: finishOnce, + }) + + tour.drive() +} diff --git a/src/lib/quickstart.ts b/src/lib/quickstart.ts new file mode 100644 index 0000000..5ad772a --- /dev/null +++ b/src/lib/quickstart.ts @@ -0,0 +1,36 @@ +/** + * Steps for the first-login dashboard spotlight tour (driver.js). Each step + * points at a real element via a CSS selector and shows a small anchored popover. + */ +export interface DashboardTourStep { + /** CSS selector for the element to spotlight. */ + element: string + title: string + description: string +} + +export const DASHBOARD_TOUR_STEPS: DashboardTourStep[] = [ + { + element: '[data-tour="providers"]', + title: 'Connect a provider', + description: + 'Add your own AI provider key here — OpenAI, Anthropic, Gemini and more. Your key stays yours, and you only pay your provider’s raw rates.', + }, + { + element: '[data-tour="templates"]', + title: 'Start from a template', + description: + 'Browse ready-made templates. Open one to preview it, then “Use this template” to customize it with AI — try the Restaurant Landing template.', + }, + { + element: '[data-tour="prompt"]', + title: 'Or describe your idea', + description: + 'Tell OpenThorn what you want to build right here, and the agent generates your whole site live in the browser.', + }, +] + +/** Show the tour only when the persisted flag is explicitly false. */ +export function shouldShowQuickstart(hasSeen: boolean | null | undefined): boolean { + return hasSeen === false +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index a04f7c9..c77666e 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -7,6 +7,8 @@ import { usePageTitle } from '../lib/usePageTitle' import type { AgentThinkingLevel } from '../lib/agent-thinking' import DashboardSidebar, { type ProjectFilter, type SidebarNotification } from '../components/DashboardSidebar/DashboardSidebar' import PromptInput from '../components/PromptInput/PromptInput' +import { shouldShowQuickstart } from '../lib/quickstart' +import { startDashboardTour } from '../lib/dashboard-tour' import FloatingParticles from '../components/FloatingParticles/FloatingParticles' import type { SelectedModel } from '../components/ModelSelector/ModelSelector' import styles from './DashboardPage.module.css' @@ -108,6 +110,8 @@ export default function DashboardPage() { return parseStoredJson(localStorage.getItem('dashboard:selectedModel'), null) }) const [sidebarOpen, setSidebarOpen] = useState(false) + const [showQuickstart, setShowQuickstart] = useState(false) + const tourStartedRef = useRef(false) const visiblePrompts = showAllPrompts ? examplePrompts : examplePrompts.slice(0, INITIAL_VISIBLE) @@ -285,6 +289,27 @@ export default function DashboardPage() { } }, [user]) + // First-login quickstart guide — show once per account. + useEffect(() => { + if (!user) return + let cancelled = false + supabase + .from('profiles') + .select('has_seen_quickstart') + .eq('id', user.id) + .single() + .then(({ data, error }) => { + if (error) { + logError('DashboardQuickstartFlag', error) + return + } + if (!cancelled && shouldShowQuickstart(data?.has_seen_quickstart)) { + setShowQuickstart(true) + } + }) + return () => { cancelled = true } + }, [user]) + // Fetch and subscribe to global notifications for the dashboard bell. useEffect(() => { if (!user) return @@ -506,6 +531,24 @@ export default function DashboardPage() { setPromptDefault(prompt) } + const markQuickstartSeen = useCallback(async () => { + setShowQuickstart(false) + if (!user) return + const { error } = await supabase + .from('profiles') + .update({ has_seen_quickstart: true }) + .eq('id', user.id) + if (error) logError('DashboardQuickstartDismiss', error) + }, [user]) + + // Launch the spotlight tour once the dashboard has rendered its anchors. + useEffect(() => { + if (!showQuickstart || authLoading || projectsLoading || tourStartedRef.current) return + tourStartedRef.current = true + const timer = setTimeout(() => startDashboardTour(() => { void markQuickstartSeen() }), 400) + return () => clearTimeout(timer) + }, [showQuickstart, authLoading, projectsLoading, markQuickstartSeen]) + const filteredProjects = projects .filter((p) => { if (activeFilter === 'starred') return p.starred @@ -625,7 +668,7 @@ export default function DashboardPage() { What do you want to build, {firstName}? -
+