From 8d996a7441a9502d101a64eab4f84e47e31c925b Mon Sep 17 00:00:00 2001 From: Zeid Diez Date: Fri, 17 Jul 2026 00:21:18 -0700 Subject: [PATCH] Add guided new user tutorial Signed-off-by: Zeid Diez --- AI.md | 8 + Readme.md | 1 + apps/desktop/src/main.tsx | 2 + apps/web/src/main.tsx | 2 + docs/gph-user-flows-review.md | 6 +- packages/ui/src/AppShell.tsx | 5 +- packages/ui/src/index.ts | 1 + packages/ui/src/theme/global.css | 291 ++++++++++++++++++ .../ui/src/tutorial/NewUserTutorial.test.tsx | 125 ++++++++ packages/ui/src/tutorial/NewUserTutorial.tsx | 245 +++++++++++++++ .../ui/src/tutorial/NewUserTutorialView.tsx | 101 ++++++ packages/ui/src/tutorial/index.ts | 3 + packages/ui/src/tutorial/tutorial-state.ts | 76 +++++ .../views/projects/ProjectsListView.test.tsx | 10 + .../src/views/projects/ProjectsListView.tsx | 5 + tests/e2e/new-user-tutorial.spec.ts | 39 +++ 16 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/tutorial/NewUserTutorial.test.tsx create mode 100644 packages/ui/src/tutorial/NewUserTutorial.tsx create mode 100644 packages/ui/src/tutorial/NewUserTutorialView.tsx create mode 100644 packages/ui/src/tutorial/index.ts create mode 100644 packages/ui/src/tutorial/tutorial-state.ts create mode 100644 tests/e2e/new-user-tutorial.spec.ts diff --git a/AI.md b/AI.md index 2667f56..9ceaf11 100644 --- a/AI.md +++ b/AI.md @@ -676,6 +676,14 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call - replacing the stale read-only flow report with `docs/gph-user-flows-review.md`, updating `Readme.md`, and adding focused regressions across AppShell, CommandPalette, WorkItemModal, ProjectsListView, BoardView, TableView, and RoadmapView - verifying the finished pass with 53 focused UI tests, the complete 230-test repository suite, all workspace TypeScript checks, a successful production web build, and matched-state desktop/mobile browser comparisons - aligning the Playwright project bootstrap with the calmer launcher behavior through a shared `createProjectFromLauncher()` helper, so E2E coverage chooses `New project` before submitting the creation modal instead of depending on the removed automatic first-run modal +- added the guided New user tutorial by: + - placing a `New user tutorial` link directly beneath the launcher’s Demo project explanation and registering `/tutorial` in both the web and desktop shells without treating it as an active project route + - adding a focused tutorial landing surface that sets expectations, lists the covered workspace areas, and refuses to replace a dirty or otherwise unsaved active project + - opening a disposable `buildDemoProject()` bundle named `Tutorial Project`, keeping its storage trust unsaved, and clearly explaining that tutorial changes remain sample data unless explicitly exported + - adding an external tutorial state store persisted in `sessionStorage`, tied to the exact generated tutorial project id so the guide ends instead of leaking into another project + - mounting a non-modal, keyboard-readable tutorial panel in `AppShell` with 13 steps, progress semantics, Back/Next/Exit controls, responsive bottom-sheet behavior, and real route transitions across Overview, Board, Backlog, Table, Roadmap, Calendar, Docs, Bug Triage, My Work, Search/commands, Trash, and Settings + - synchronizing the wizard with manual sidebar navigation so users can skip ahead or revisit a feature without being forced back to the previous route + - adding focused launcher/tutorial component coverage plus a full Playwright journey that advances through every tutorial stop and verifies the final handoff back to Overview ## Open follow-on planning diff --git a/Readme.md b/Readme.md index 609c1c4..ae57de4 100644 --- a/Readme.md +++ b/Readme.md @@ -44,6 +44,7 @@ For real project work, run the repo locally or use the future release builds. Grillo is past the skeleton stage. The current app includes: - Workspace launcher with new, open/import, demo, recent-project, template preview, explicit browser-vs-folder storage guidance, and a calm first-run state that waits for the user to choose a path. +- A guided New user tutorial beneath the demo entry that opens safe sample data and walks through all 12 core workspace surfaces in a resumable 13-step wizard. - Overview, board, backlog, table, roadmap, calendar, docs, bug triage, my work, search, trash, and settings surfaces. - Board-local column management for adding unmapped workflow statuses, removing columns without deleting their statuses or work items, and creating a new categorized status and column together. - Explicit save-state UI in the project header, including save destination, dirty state, save failures, manual save/retry, switch project, and guarded close-project actions. diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 1ea6b9c..b8883e3 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -14,6 +14,7 @@ import { ProjectsListView, OpenProjectView, DemoFolderView, + NewUserTutorialView, ThemeProvider, useRestoreProjectSession } from "@gph/ui"; @@ -35,6 +36,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9fd1f22..0006358 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,6 +14,7 @@ import { ProjectsListView, OpenProjectView, DemoFolderView, + NewUserTutorialView, ThemeProvider, useRestoreProjectSession } from "@gph/ui"; @@ -42,6 +43,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/docs/gph-user-flows-review.md b/docs/gph-user-flows-review.md index b25e725..4639699 100644 --- a/docs/gph-user-flows-review.md +++ b/docs/gph-user-flows-review.md @@ -2,6 +2,8 @@ **Review date:** 2026-07-16 +**Feature update:** 2026-07-17 — added the guided New user tutorial described below. + **Scope:** The complete browser product journey, reviewed in the running app at desktop and mobile widths, followed by implementation and regression verification. **Primary environment:** Local web app, demo workspace, 1280 x 720 desktop viewport, and 390 x 844 mobile viewport. @@ -16,7 +18,7 @@ The audit did expose several interaction-level problems that source review alone ### 1. Workspace launcher, first run, open/import, and demo -**Journey:** Open Grillo -> review launcher -> create a project, open/import a bundle, or enter the demo -> arrive at Overview. +**Journey:** Open Grillo -> review launcher -> create a project, open/import a bundle, enter the demo, or start the New user tutorial -> arrive at Overview. **Health after this pass:** Good. @@ -24,6 +26,7 @@ The audit did expose several interaction-level problems that source review alone - When no project is open, the sidebar contains only Projects, Open, and Demo. Project views and Settings no longer lead users into guarded routes that cannot work yet. - Closing an unsaved demo still uses the existing confirmation dialog, but confirming now returns to the clean launcher without immediately reopening project creation. - Disabled primary actions now look inactive instead of retaining the full green emphasis of an available action. +- The Demo project explanation now includes a `New user tutorial` link. Its landing page opens a disposable demo only when existing unsaved work is protected, then a 13-step wizard moves through every core project surface with visible progress, Back/Next/Exit controls, and manual sidebar-route synchronization. ### 2. Create, save, switch, and close project @@ -136,6 +139,7 @@ The audit did expose several interaction-level problems that source review alone - Reflowed work-item metadata for mobile and replaced native multi-select labels with checkbox chips. - Shortened Bug Triage action copy without losing item-specific accessible labels. - Improved disabled primary-action styling and mobile Overview density. +- Added the safe demo-backed New user tutorial entry, landing page, persistent route-aware wizard, and responsive desktop/mobile presentation. ## Verification diff --git a/packages/ui/src/AppShell.tsx b/packages/ui/src/AppShell.tsx index 3b4ba20..bf987b1 100644 --- a/packages/ui/src/AppShell.tsx +++ b/packages/ui/src/AppShell.tsx @@ -36,6 +36,7 @@ import { Button, ConfirmDialog, HelpTip, IconButton, InlineAlert, ToastProvider, import { PROJECT_NAV_ITEMS } from "./nav-config"; import { useWorkspaceStore } from "./store/workspace-store"; import { hasRegisteredSavedRoute, savedViewsForBundle, viewRoute } from "./views/planning/view-helpers"; +import { NewUserTutorial } from "./tutorial"; export type AppShellProps = { appMode: "web" | "desktop"; @@ -455,7 +456,8 @@ function AppShellFrame({ appMode, appDistribution = "local", children }: AppShel location.pathname !== "/" && !location.pathname.startsWith("/projects") && !location.pathname.startsWith("/open") && - !location.pathname.startsWith("/demo"), + !location.pathname.startsWith("/demo") && + !location.pathname.startsWith("/tutorial"), [location.pathname] ); @@ -664,6 +666,7 @@ function AppShellFrame({ appMode, appDistribution = "local", children }: AppShel /> ) : null} + ); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 06c988a..4cac979 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -26,3 +26,4 @@ export * from "./views/search"; export * from "./views/settings"; export * from "./views/projects"; export * from "./work-item"; +export * from "./tutorial"; diff --git a/packages/ui/src/theme/global.css b/packages/ui/src/theme/global.css index e30619f..f3b49fd 100644 --- a/packages/ui/src/theme/global.css +++ b/packages/ui/src/theme/global.css @@ -1845,6 +1845,25 @@ a.btn { background: var(--color-bg-muted); border: 1px solid var(--color-border-subtle); } +.workspace-tutorial-link { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + margin-top: 2px; + color: var(--color-accent); + font-size: var(--font-size-sm); + font-weight: 700; +} +.workspace-tutorial-link:hover { + color: var(--color-accent-hover); + text-decoration: underline; + text-underline-offset: 3px; +} +.workspace-tutorial-link svg { + width: 17px; + height: 17px; +} .workspace-inline-note { padding: 10px 12px; border-radius: var(--radius-sm); @@ -1861,6 +1880,278 @@ a.btn { font-size: var(--font-size-sm); } +/* ---- New user tutorial ---- */ +.tutorial-landing { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); + align-items: stretch; + gap: var(--space-4); + width: min(1080px, 100%); + margin: 0 auto; + padding: clamp(20px, 4vw, 48px); + overflow: auto; +} +.tutorial-landing-card, +.tutorial-landing-features { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + background: var(--color-bg-surface); + box-shadow: var(--shadow-1); +} +.tutorial-landing-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-4); + padding: clamp(24px, 5vw, 52px); +} +.tutorial-landing-icon { + display: grid; + place-items: center; + width: 58px; + height: 58px; + border-radius: var(--radius-lg); + background: var(--color-accent-soft); + color: var(--color-accent); +} +.tutorial-landing-icon svg { + width: 30px; + height: 30px; +} +.tutorial-landing-copy { + max-width: 680px; +} +.tutorial-landing-kicker, +.tutorial-step-label { + color: var(--color-accent); + font-size: var(--font-size-xs); + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.tutorial-landing-copy h1, +.tutorial-landing-features h2 { + margin: 6px 0 10px; +} +.tutorial-landing-copy p { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.65; +} +.tutorial-landing-benefits { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} +.tutorial-landing-benefits > div { + display: flex; + align-items: flex-start; + gap: 9px; + min-width: 0; + padding: 12px; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + background: var(--color-bg-muted); +} +.tutorial-landing-benefits svg { + flex: 0 0 auto; + width: 18px; + height: 18px; + color: var(--color-accent); +} +.tutorial-landing-benefits span { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.tutorial-landing-benefits small { + color: var(--color-text-secondary); + line-height: 1.35; +} +.tutorial-landing-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} +.tutorial-landing-note { + margin: calc(var(--space-2) * -1) 0 0; +} +.tutorial-landing-features { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--space-4); + padding: clamp(24px, 4vw, 40px); + background: linear-gradient(155deg, var(--color-bg-surface), var(--color-bg-muted)); +} +.tutorial-feature-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.tutorial-feature-list span { + padding: 7px 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 999px; + background: var(--color-bg-surface); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 700; +} +.new-user-tutorial { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 45; + display: flex; + flex-direction: column; + width: min(430px, calc(100vw - 40px)); + max-height: calc(100vh - 96px); + overflow: hidden; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + background: var(--color-bg-elevated); + box-shadow: var(--shadow-3); +} +.new-user-tutorial-header, +.new-user-tutorial-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3); +} +.new-user-tutorial-header { + padding-bottom: 10px; +} +.new-user-tutorial-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.new-user-tutorial-icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--color-accent-soft); + color: var(--color-accent); +} +.new-user-tutorial-icon svg { + width: 20px; + height: 20px; +} +.new-user-tutorial-progress { + height: 4px; + margin: 0 var(--space-3); + overflow: hidden; + border-radius: 999px; + background: var(--color-bg-muted); +} +.new-user-tutorial-progress span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-accent); + transition: width 180ms ease-out; +} +.new-user-tutorial-body { + overflow: auto; + padding: var(--space-4) var(--space-3) var(--space-3); +} +.new-user-tutorial-step-meta { + align-items: baseline; + gap: var(--space-2); +} +.new-user-tutorial-body h2 { + margin: 7px 0 8px; + font-size: 20px; + line-height: 1.3; +} +.new-user-tutorial-body h2:focus { + outline: none; +} +.new-user-tutorial-body > p { + margin: 0; + line-height: 1.55; +} +.new-user-tutorial-try { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + margin-top: var(--space-3); + padding: 12px; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + background: var(--color-accent-soft); +} +.new-user-tutorial-try > svg { + width: 18px; + height: 18px; + color: var(--color-accent); +} +.new-user-tutorial-try p { + margin: 3px 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.45; +} +.new-user-tutorial-footer { + border-top: 1px solid var(--color-border-subtle); + background: var(--color-bg-surface); +} + +@media (max-width: 820px) { + .tutorial-landing { + grid-template-columns: 1fr; + } + .tutorial-landing-benefits { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .tutorial-landing { + padding: 14px; + } + .tutorial-landing-card, + .tutorial-landing-features { + padding: 22px; + } + .new-user-tutorial { + right: 0; + bottom: 0; + left: 0; + width: 100%; + max-height: min(72vh, 580px); + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + } + .new-user-tutorial-header, + .new-user-tutorial-footer { + padding-right: 14px; + padding-left: 14px; + } + .new-user-tutorial-body { + padding-right: 14px; + padding-left: 14px; + } +} + +@media (prefers-reduced-motion: reduce) { + .new-user-tutorial-progress span { + transition: none; + } +} + /* ---- Item detail ---- */ .item-detail { display: flex; diff --git a/packages/ui/src/tutorial/NewUserTutorial.test.tsx b/packages/ui/src/tutorial/NewUserTutorial.test.tsx new file mode 100644 index 0000000..08f69f4 --- /dev/null +++ b/packages/ui/src/tutorial/NewUserTutorial.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Link, MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { buildProjectFromTemplate } from "@gph/core"; +import { useProjectStore } from "../store/project-store"; +import { NewUserTutorial } from "./NewUserTutorial"; +import { NewUserTutorialView } from "./NewUserTutorialView"; +import { + endNewUserTutorial, + getNewUserTutorialSnapshot, + startNewUserTutorial +} from "./tutorial-state"; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +describe("NewUserTutorial", () => { + beforeEach(() => { + cleanup(); + endNewUserTutorial(); + sessionStorage.clear(); + useProjectStore.setState({ + bundle: null, + storageKey: null, + storagePath: null, + storageTrust: "unsaved", + isDirty: false, + lastSource: null, + saveStatus: "idle", + lastSavedAt: null, + saveError: null + }); + }); + + afterEach(() => { + cleanup(); + endNewUserTutorial(); + }); + + it("starts a disposable demo and opens the guided overview", async () => { + render( + + + } /> + } /> + + + ); + + await userEvent.click(screen.getByRole("button", { name: "Start tutorial" })); + + await waitFor(() => { + expect(screen.getByTestId("location")).toHaveTextContent("/overview"); + }); + expect(useProjectStore.getState().bundle?.project.name).toBe("Tutorial Project"); + expect(useProjectStore.getState().storageTrust).toBe("unsaved"); + expect(getNewUserTutorialSnapshot()).toEqual({ + active: true, + projectId: useProjectStore.getState().bundle?.project.id, + stepIndex: 0 + }); + }); + + it("protects an existing unsaved project", () => { + useProjectStore.setState({ + bundle: buildProjectFromTemplate("software-project", "Unsaved work"), + storageTrust: "unsaved", + isDirty: true + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Start tutorial" })).toBeDisabled(); + expect(screen.getByText(/Save or close the current unsaved project/i)).toBeInTheDocument(); + }); + + it("moves forward and backward through real feature routes", async () => { + startNewUserTutorial("tutorial-project"); + render( + + + + + ); + + const tutorial = screen.getByRole("dialog", { name: "New user tutorial" }); + expect(screen.getByRole("heading", { name: "See the whole project at a glance" })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open Board" })); + expect(screen.getByTestId("location")).toHaveTextContent("/board"); + expect(screen.getByRole("heading", { name: "Move work through its workflow" })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByTestId("location")).toHaveTextContent("/overview"); + expect(tutorial).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Exit tutorial" })); + expect(screen.queryByRole("dialog", { name: "New user tutorial" })).not.toBeInTheDocument(); + }); + + it("follows manual workspace navigation without forcing a route", async () => { + startNewUserTutorial("tutorial-project"); + render( + + Open Calendar manually + + + + ); + + await userEvent.click(screen.getByRole("link", { name: "Open Calendar manually" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "See commitments by date" })).toBeInTheDocument(); + }); + expect(screen.getByRole("progressbar", { name: "Tutorial progress" })).toHaveAttribute("aria-valuenow", "6"); + }); +}); diff --git a/packages/ui/src/tutorial/NewUserTutorial.tsx b/packages/ui/src/tutorial/NewUserTutorial.tsx new file mode 100644 index 0000000..51d7e25 --- /dev/null +++ b/packages/ui/src/tutorial/NewUserTutorial.tsx @@ -0,0 +1,245 @@ +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { ArrowLeft, ArrowRight, GraduationCap, Lightbulb } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Button } from "../components"; +import { + endNewUserTutorial, + getNewUserTutorialSnapshot, + setNewUserTutorialStep, + subscribeToNewUserTutorial +} from "./tutorial-state"; + +export type NewUserTutorialStep = { + description: string; + label: string; + nextLabel: string; + route: string; + title: string; + tryThis: string; +}; + +const NEW_USER_TUTORIAL_STEPS: NewUserTutorialStep[] = [ + { + label: "Overview", + title: "See the whole project at a glance", + description: "Overview turns project activity into a quick health check: what is moving, what is blocked, and what needs attention next.", + tryThis: "Scan the status totals, upcoming work, and milestone progress in the tutorial project.", + route: "/overview", + nextLabel: "Open Board" + }, + { + label: "Board", + title: "Move work through its workflow", + description: "Board groups items by status so the team can see flow, open details, and move work forward without losing context.", + tryThis: "Open a card to explore descriptions, labels, assignees, comments, and activity history.", + route: "/board", + nextLabel: "Open Backlog" + }, + { + label: "Backlog", + title: "Prioritize what comes next", + description: "Backlog gives planning work a quieter list view with priority, status, due dates, and ownership aligned for fast scanning.", + tryThis: "Compare the urgent bug with the release work and decide what the team should pull next.", + route: "/backlog", + nextLabel: "Open Table" + }, + { + label: "Table", + title: "Edit many details efficiently", + description: "Table is the dense operational view for sorting, filtering, choosing columns, and updating selected work in bulk.", + tryThis: "Select a row to reveal bulk edit controls, then open Columns to tailor the view.", + route: "/table", + nextLabel: "Open Roadmap" + }, + { + label: "Roadmap", + title: "Plan milestones over time", + description: "Roadmap connects milestones and scheduled work so release timing, scope, and dependencies stay visible.", + tryThis: "Use a milestone grip with the pointer or arrow keys to adjust its target date.", + route: "/roadmap", + nextLabel: "Open Calendar" + }, + { + label: "Calendar", + title: "See commitments by date", + description: "Calendar places due work into a seven-day planning surface with an agenda for the details that matter now.", + tryThis: "Choose a day to create a dated item, or open existing work from the agenda.", + route: "/calendar", + nextLabel: "Open Docs" + }, + { + label: "Docs", + title: "Keep decisions beside delivery", + description: "Docs stores project notes, briefs, and checklists next to the work they explain, including direct links back to items.", + tryThis: "Open the Launch checklist and follow one of its linked work items.", + route: "/docs", + nextLabel: "Open Bug triage" + }, + { + label: "Bug triage", + title: "Turn reports into actionable bugs", + description: "Bug triage brings severity, reproduction steps, expected behavior, and disposition actions into one review flow.", + tryThis: "Inspect the responsive-header report and review its severity and reproduction context.", + route: "/bugs", + nextLabel: "Open My work" + }, + { + label: "My work", + title: "Focus each person on their queue", + description: "My work groups assigned items around a selected team member so personal priorities stay clear without hiding project context.", + tryThis: "Switch between Alex and Sam to see how the same project becomes a personal queue.", + route: "/mywork", + nextLabel: "Open Search" + }, + { + label: "Search & commands", + title: "Find anything and move quickly", + description: "Search finds project work and documents, while the command palette jumps to views and common actions from anywhere.", + tryThis: "Search for \"first-run,\" then use Search commands in the header or press Ctrl K.", + route: "/search", + nextLabel: "Open Trash" + }, + { + label: "Trash", + title: "Recover work before it is gone", + description: "Trash keeps deleted items recoverable so cleanup does not immediately become permanent data loss.", + tryThis: "Notice the restore path; permanent deletion stays a separate, deliberate action.", + route: "/trash", + nextLabel: "Open Settings" + }, + { + label: "Settings", + title: "Shape Grillo around the project", + description: "Settings controls workflows, item types, members, labels, views, automation, storage, and portable imports or exports.", + tryThis: "Browse the tabs to see which parts are project structure and which parts help move or protect data.", + route: "/settings", + nextLabel: "Wrap up" + }, + { + label: "Complete", + title: "You know your way around Grillo", + description: "You have visited every core workspace surface. The tutorial project remains open, so you can keep experimenting with sample data.", + tryThis: "Create an item with the C shortcut, use Ctrl K to jump views, or return to Projects when you are ready for your own workspace.", + route: "/overview", + nextLabel: "Finish tutorial" + } +]; + +export function NewUserTutorial({ activeProjectId }: { activeProjectId: string | null }) { + const state = useSyncExternalStore( + subscribeToNewUserTutorial, + getNewUserTutorialSnapshot, + getNewUserTutorialSnapshot + ); + const location = useLocation(); + const navigate = useNavigate(); + const titleRef = useRef(null); + const lastStepIndex = NEW_USER_TUTORIAL_STEPS.length - 1; + const stepIndex = Math.min(state.stepIndex, lastStepIndex); + const step = NEW_USER_TUTORIAL_STEPS[stepIndex]; + const tutorialProjectAvailable = Boolean(activeProjectId && activeProjectId === state.projectId); + + useEffect(() => { + if (state.active && !tutorialProjectAvailable) { + endNewUserTutorial(); + } + }, [state.active, tutorialProjectAvailable]); + + useEffect(() => { + if (!state.active || stepIndex === lastStepIndex) return; + const routeStepIndex = NEW_USER_TUTORIAL_STEPS + .slice(0, lastStepIndex) + .findIndex((entry) => entry.route === location.pathname); + if (routeStepIndex >= 0 && routeStepIndex !== stepIndex) { + setNewUserTutorialStep(routeStepIndex); + } + }, [lastStepIndex, location.pathname, state.active, stepIndex]); + + useEffect(() => { + if (state.active) titleRef.current?.focus(); + }, [state.active, stepIndex]); + + if (!state.active || !tutorialProjectAvailable) return null; + + const goToStep = (nextStepIndex: number) => { + const boundedIndex = Math.max(0, Math.min(nextStepIndex, lastStepIndex)); + setNewUserTutorialStep(boundedIndex); + navigate(NEW_USER_TUTORIAL_STEPS[boundedIndex].route); + }; + + const advance = () => { + if (stepIndex === lastStepIndex) { + endNewUserTutorial(); + return; + } + goToStep(stepIndex + 1); + }; + + return ( + + ); +} diff --git a/packages/ui/src/tutorial/NewUserTutorialView.tsx b/packages/ui/src/tutorial/NewUserTutorialView.tsx new file mode 100644 index 0000000..f7956d0 --- /dev/null +++ b/packages/ui/src/tutorial/NewUserTutorialView.tsx @@ -0,0 +1,101 @@ +import { buildDemoProject } from "@gph/core"; +import { CheckCircle2, Clock3, GraduationCap, ShieldCheck } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Button, InlineAlert } from "../components"; +import { useProjectStore } from "../store/project-store"; +import { startNewUserTutorial } from "./tutorial-state"; + +const TUTORIAL_FEATURES = [ + "Overview", + "Board", + "Backlog", + "Table", + "Roadmap", + "Calendar", + "Docs", + "Bug triage", + "My work", + "Search", + "Trash", + "Settings" +]; + +export function NewUserTutorialView() { + const navigate = useNavigate(); + const bundle = useProjectStore((state) => state.bundle); + const isDirty = useProjectStore((state) => state.isDirty); + const storageTrust = useProjectStore((state) => state.storageTrust); + const setBundle = useProjectStore((state) => state.setBundle); + const hasUnprotectedWork = Boolean(bundle) && (isDirty || storageTrust === "unsaved"); + + const startTutorial = () => { + if (hasUnprotectedWork) return; + const tutorialBundle = buildDemoProject("Tutorial Project"); + setBundle(tutorialBundle, { + storageKey: null, + storagePath: null, + storageTrust: "unsaved" + }); + startNewUserTutorial(tutorialBundle.project.id); + navigate("/overview"); + }; + + return ( +
+
+ +
+ New user tutorial +

Learn Grillo by doing

+

+ Open a disposable sample project and follow a guided wizard through every core workspace surface. + You can interact with the demo, move backward, skip ahead from the sidebar, or exit whenever you are ready. +

+
+ +
+
+
+
+
+
+
+
+ + {hasUnprotectedWork ? ( + + Save or close the current unsaved project before starting the tutorial. This keeps your in-progress work protected. + + ) : null} + +
+ + Back to projects +
+

+ The tutorial project stays browser-local and is not saved unless you explicitly export it. +

+
+ +
+
+ What you will explore +

The complete project workflow

+
+
+ {TUTORIAL_FEATURES.map((feature) => {feature})} +
+
+
+ ); +} diff --git a/packages/ui/src/tutorial/index.ts b/packages/ui/src/tutorial/index.ts new file mode 100644 index 0000000..c9e4393 --- /dev/null +++ b/packages/ui/src/tutorial/index.ts @@ -0,0 +1,3 @@ +export * from "./NewUserTutorial"; +export * from "./NewUserTutorialView"; +export * from "./tutorial-state"; diff --git a/packages/ui/src/tutorial/tutorial-state.ts b/packages/ui/src/tutorial/tutorial-state.ts new file mode 100644 index 0000000..1fbef6e --- /dev/null +++ b/packages/ui/src/tutorial/tutorial-state.ts @@ -0,0 +1,76 @@ +export const NEW_USER_TUTORIAL_STORAGE_KEY = "gph.new-user-tutorial"; + +export type NewUserTutorialState = { + active: boolean; + projectId: string | null; + stepIndex: number; +}; + +const INITIAL_TUTORIAL_STATE: NewUserTutorialState = { + active: false, + projectId: null, + stepIndex: 0 +}; + +const listeners = new Set<() => void>(); + +function readStoredTutorialState(): NewUserTutorialState { + if (typeof sessionStorage === "undefined") return INITIAL_TUTORIAL_STATE; + try { + const stored = sessionStorage.getItem(NEW_USER_TUTORIAL_STORAGE_KEY); + if (!stored) return INITIAL_TUTORIAL_STATE; + const parsed = JSON.parse(stored) as Partial; + return { + active: parsed.active === true, + projectId: typeof parsed.projectId === "string" ? parsed.projectId : null, + stepIndex: Number.isInteger(parsed.stepIndex) && Number(parsed.stepIndex) >= 0 + ? Number(parsed.stepIndex) + : 0 + }; + } catch { + return INITIAL_TUTORIAL_STATE; + } +} + +let tutorialState = readStoredTutorialState(); + +function publishTutorialState(nextState: NewUserTutorialState): void { + tutorialState = nextState; + if (typeof sessionStorage !== "undefined") { + try { + if (nextState.active) { + sessionStorage.setItem(NEW_USER_TUTORIAL_STORAGE_KEY, JSON.stringify(nextState)); + } else { + sessionStorage.removeItem(NEW_USER_TUTORIAL_STORAGE_KEY); + } + } catch { + // The tutorial remains usable in memory when session storage is unavailable. + } + } + listeners.forEach((listener) => listener()); +} + +export function subscribeToNewUserTutorial(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getNewUserTutorialSnapshot(): NewUserTutorialState { + return tutorialState; +} + +export function startNewUserTutorial(projectId: string): void { + publishTutorialState({ active: true, projectId, stepIndex: 0 }); +} + +export function setNewUserTutorialStep(stepIndex: number): void { + publishTutorialState({ + active: true, + projectId: tutorialState.projectId, + stepIndex: Math.max(0, Math.floor(stepIndex)) + }); +} + +export function endNewUserTutorial(): void { + publishTutorialState(INITIAL_TUTORIAL_STATE); +} diff --git a/packages/ui/src/views/projects/ProjectsListView.test.tsx b/packages/ui/src/views/projects/ProjectsListView.test.tsx index 7731a76..7078e39 100644 --- a/packages/ui/src/views/projects/ProjectsListView.test.tsx +++ b/packages/ui/src/views/projects/ProjectsListView.test.tsx @@ -120,6 +120,16 @@ describe("ProjectsListView", () => { expect(screen.getByRole("button", { name: "Bug Tracker template" })).toHaveAttribute("aria-pressed", "true"); }); + it("offers a new user tutorial beneath the demo project guidance", () => { + render( + + + + ); + + expect(screen.getByRole("link", { name: "New user tutorial" })).toHaveAttribute("href", "/tutorial"); + }); + it("persists new folder-backed PWA projects immediately on create", async () => { const saves: Array<{ key: string; json: string; targetTrust: string | undefined }> = []; let currentFolder = "Client Folder"; diff --git a/packages/ui/src/views/projects/ProjectsListView.tsx b/packages/ui/src/views/projects/ProjectsListView.tsx index f63f92f..eb08109 100644 --- a/packages/ui/src/views/projects/ProjectsListView.tsx +++ b/packages/ui/src/views/projects/ProjectsListView.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { GraduationCap } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; import { buildProjectFromTemplate, @@ -474,6 +475,10 @@ export function ProjectsListView() {

Open a sample workspace to inspect the board, docs, roadmap, and settings flows without touching your own data.

+ +