Skip to content

Commit cfa429e

Browse files
committed
feat(tour): in-app founder walkthrough (8 steps) + server-side state
The first time a founder lands in the admin shell after claiming their invite, they see an empty L2 with no context for what to do next. This PR ships an in-app guided tour ("WalkMe-style") to teach them what they're looking at and walk them to their first agent connection. Eight steps, ~25 words each, ~90 seconds total: 1. Welcome to your L2 (semantic substrate, Layer 8) 2. One Group / one L2 — model B explained 3. API Keys → connect your first agent 4. Network → watch the graph grow 5. Review → approve knowledge 6. Dashboard → day-over-day operations 7. Personas → invite humans, assign personas 8. You're operational # Engine Custom spotlight + popover (no library) — keeps the brand chrome (Fraunces / cyan-violet / dark) intact without restyling someone else's component. SVG-mask spotlight, viewport-clamped popover, ESC to dismiss, dots for progress, "Skip" to abandon. # Persistence Server-side via `users.tour_state` (TEXT/JSON) — migration 0024 adds the column, new GET/PUT `/api/v1/users/me/tour-state` endpoints persist the state shape `{completed_at, dismissed_at, current_step}`. Resume mid-tour after refresh; tour stays "done" across browsers/devices. # Trigger Auto-fire on first login (when state row is empty AND no dismissed_at) plus a `?` button in the header for manual replay. # Tests `test_tour_routes.py` — 4 endpoint tests (default-on-fresh-user, round-trip, "now" sentinel stamping, 404-on-missing-user race).
1 parent 09ea979 commit cfa429e

12 files changed

Lines changed: 844 additions & 9 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Founder-tour: ``users.tour_state`` JSON-text column.
2+
3+
Revision ID: 0024_user_tour_state
4+
Revises: 0023_persona_assignment_audit
5+
Create Date: 2026-05-14
6+
7+
Per-user tour-completion state for the in-app onboarding walkthrough.
8+
Stored as a TEXT column holding JSON of the shape:
9+
10+
{
11+
"completed_at": "2026-05-14T11:55:00Z" | null,
12+
"current_step": 3,
13+
"dismissed_at": "2026-05-14T11:53:00Z" | null
14+
}
15+
16+
NULL row means "the tour has never been shown" — frontend auto-fires.
17+
``completed_at`` set means the user finished it. ``dismissed_at`` set
18+
means they X'd out before finishing — still don't auto-fire again, but
19+
the `?` button can replay.
20+
21+
# Why a single JSON column (not a separate table)
22+
23+
The data is tiny, never queried across users, never joined. A relation
24+
would be over-engineering. JSON in TEXT matches the convention used
25+
elsewhere (``invites.target_l2_id`` etc.) — sqlite stores it as TEXT
26+
and the Python side type-coerces via Pydantic on read.
27+
28+
# Idempotency
29+
30+
Standard ``_column_names`` guard mirrors every other additive migration
31+
in the chain. Re-run is a no-op; downgrade drops the column.
32+
"""
33+
34+
from collections.abc import Sequence
35+
36+
import sqlalchemy as sa
37+
from alembic import op
38+
39+
revision: str = "0024_user_tour_state"
40+
down_revision: str | Sequence[str] | None = "0023_persona_assignment_audit"
41+
branch_labels: str | Sequence[str] | None = None
42+
depends_on: str | Sequence[str] | None = None
43+
44+
45+
def _column_names(bind: sa.engine.Connection, table_name: str) -> set[str]:
46+
inspector = sa.inspect(bind)
47+
if table_name not in inspector.get_table_names():
48+
return set()
49+
return {col["name"] for col in inspector.get_columns(table_name)}
50+
51+
52+
def upgrade() -> None:
53+
"""Add ``users.tour_state`` (TEXT, nullable)."""
54+
bind = op.get_bind()
55+
if "tour_state" not in _column_names(bind, "users"):
56+
op.add_column("users", sa.Column("tour_state", sa.Text(), nullable=True))
57+
58+
59+
def downgrade() -> None:
60+
"""Drop ``users.tour_state``. Sqlite needs batch-mode for DROP COLUMN."""
61+
with op.batch_alter_table("users") as batch:
62+
batch.drop_column("tour_state")

server/backend/src/cq_server/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .reflect import router as reflect_router
4545
from .reputation_routes import router as reputation_router
4646
from .review import router as review_router
47+
from .tour_routes import router as tour_router
4748
from .scoring import apply_confirmation, apply_flag
4849
from .store import normalize_domains
4950
from .store._sqlite import SqliteStore
@@ -445,6 +446,9 @@ def _fresh_conn() -> sqlite3.Connection:
445446
# Mounted on api_router so it lives under both / and /api/v1, same as
446447
# every other API route. Decision 30 sets the spec.
447448
api_router.include_router(theme_router)
449+
# Founder-tour persistence — GET/PUT /api/v1/users/me/tour-state for
450+
# the in-app onboarding walkthrough.
451+
api_router.include_router(tour_router)
448452

449453

450454
@api_router.get("/health")

server/backend/src/cq_server/migrations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
# Phase 2 (task #100) — chain head after porting fork-delta tables to
3737
# Alembic. Update this string when adding a new migration so test
3838
# assertions and ops scripts stay in sync with the actual chain head.
39-
HEAD_REVISION = "0023_persona_assignment_audit"
39+
HEAD_REVISION = "0024_user_tour_state"
4040

4141

4242
def _find_alembic_ini() -> Path:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Founder-tour persistence — per-user `tour_state` read/write.
2+
3+
Tiny endpoint pair behind ``/api/v1/users/me/tour-state``:
4+
5+
* ``GET`` → return the JSON blob (or an empty default if NULL).
6+
* ``PUT`` → upsert the JSON blob. No partial updates — caller sends
7+
the full shape every time. Keeps the contract honest.
8+
9+
Authentication is the standard cookie/bearer dep (``get_current_user``).
10+
Per-user — there is no admin-fetch-other-user surface here.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import logging
17+
from datetime import UTC, datetime
18+
from typing import Any
19+
20+
from fastapi import APIRouter, Depends, HTTPException
21+
from pydantic import BaseModel
22+
from sqlalchemy import text
23+
24+
from .auth import get_current_user
25+
from .deps import get_store
26+
from .store._sqlite import SqliteStore
27+
28+
log = logging.getLogger("tour_routes")
29+
30+
router = APIRouter(prefix="/users/me", tags=["tour"])
31+
32+
33+
class TourState(BaseModel):
34+
"""Shape of the per-user tour-completion state.
35+
36+
All fields are optional — the frontend treats absence of
37+
``completed_at`` as "tour has not been finished; auto-fire OK"
38+
unless ``dismissed_at`` is set (then replay only via the
39+
`?` launcher, never auto-fire).
40+
"""
41+
42+
completed_at: str | None = None
43+
dismissed_at: str | None = None
44+
current_step: int = 0
45+
46+
47+
def _parse(raw: str | None) -> TourState:
48+
"""Decode the TEXT column into a ``TourState``. Empty → defaults."""
49+
if not raw:
50+
return TourState()
51+
try:
52+
data = json.loads(raw)
53+
except json.JSONDecodeError:
54+
# Corrupt row — surface as "fresh" rather than 500ing the UI.
55+
log.warning("tour_state JSON decode failed; returning defaults")
56+
return TourState()
57+
if not isinstance(data, dict):
58+
return TourState()
59+
return TourState(
60+
completed_at=data.get("completed_at"),
61+
dismissed_at=data.get("dismissed_at"),
62+
current_step=int(data.get("current_step", 0)),
63+
)
64+
65+
66+
def _serialize(state: TourState) -> str:
67+
"""Encode for the TEXT column. Compact JSON, stable key order."""
68+
return json.dumps(
69+
{
70+
"completed_at": state.completed_at,
71+
"dismissed_at": state.dismissed_at,
72+
"current_step": state.current_step,
73+
},
74+
sort_keys=True,
75+
separators=(",", ":"),
76+
)
77+
78+
79+
def _read_sync(store: SqliteStore, username: str) -> str | None:
80+
with store._engine.connect() as conn: # noqa: SLF001
81+
row = conn.execute(
82+
text("SELECT tour_state FROM users WHERE username = :u"),
83+
{"u": username},
84+
).fetchone()
85+
return row[0] if row is not None else None
86+
87+
88+
def _write_sync(store: SqliteStore, username: str, blob: str) -> int:
89+
with store._engine.begin() as conn: # noqa: SLF001
90+
result = conn.execute(
91+
text("UPDATE users SET tour_state = :s WHERE username = :u"),
92+
{"s": blob, "u": username},
93+
)
94+
return int(result.rowcount or 0)
95+
96+
97+
@router.get("/tour-state", response_model=TourState)
98+
async def get_tour_state(
99+
username: str = Depends(get_current_user),
100+
store: SqliteStore = Depends(get_store),
101+
) -> TourState:
102+
"""Return the caller's tour-state, or empty defaults if never set."""
103+
raw = await store._run_sync(_read_sync, store, username) # noqa: SLF001
104+
return _parse(raw)
105+
106+
107+
@router.put("/tour-state", response_model=TourState)
108+
async def put_tour_state(
109+
payload: TourState,
110+
username: str = Depends(get_current_user),
111+
store: SqliteStore = Depends(get_store),
112+
) -> TourState:
113+
"""Replace the caller's tour-state with the supplied blob.
114+
115+
Server stamps ``completed_at`` / ``dismissed_at`` to now if the
116+
caller sent the literal string ``"now"`` — convenience so the
117+
frontend doesn't need to grab a clock.
118+
"""
119+
now = datetime.now(UTC).isoformat()
120+
if payload.completed_at == "now":
121+
payload.completed_at = now
122+
if payload.dismissed_at == "now":
123+
payload.dismissed_at = now
124+
125+
blob = _serialize(payload)
126+
rowcount = await store._run_sync(_write_sync, store, username, blob) # noqa: SLF001
127+
if rowcount == 0:
128+
raise HTTPException(status_code=404, detail="User not found")
129+
return payload
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for the founder-tour persistence endpoints.
2+
3+
Covers the GET/PUT contract on ``/api/v1/users/me/tour-state``:
4+
5+
* GET on a fresh user → empty defaults (never-touched row reads as
6+
``{completed_at: null, dismissed_at: null, current_step: 0}``).
7+
* PUT round-trips an arbitrary state and survives a follow-up GET.
8+
* The "now" sentinel on PUT gets stamped to an ISO-8601 timestamp.
9+
* Anonymous callers 401 (the auth dep is on the router).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from collections.abc import Iterator
15+
from pathlib import Path
16+
17+
import pytest
18+
from fastapi.testclient import TestClient
19+
20+
from cq_server.app import app
21+
from cq_server.auth import get_current_user, hash_password
22+
from cq_server.deps import require_api_key
23+
24+
25+
@pytest.fixture()
26+
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
27+
monkeypatch.setenv("CQ_DB_PATH", str(tmp_path / "tour.db"))
28+
monkeypatch.setenv("CQ_JWT_SECRET", "test-secret-thirty-two-chars-min!")
29+
monkeypatch.setenv("CQ_API_KEY_PEPPER", "test-pepper")
30+
app.dependency_overrides[require_api_key] = lambda: "founder"
31+
app.dependency_overrides[get_current_user] = lambda: "founder"
32+
with TestClient(app) as c:
33+
# Seed the row so the UPDATE path has something to hit.
34+
from cq_server.app import _get_store
35+
36+
store = _get_store()
37+
store.sync.create_user("founder", hash_password("pw-1234567"))
38+
yield c
39+
app.dependency_overrides.pop(require_api_key, None)
40+
app.dependency_overrides.pop(get_current_user, None)
41+
42+
43+
def test_get_fresh_user_returns_defaults(client: TestClient) -> None:
44+
resp = client.get("/api/v1/users/me/tour-state")
45+
assert resp.status_code == 200, resp.text
46+
assert resp.json() == {"completed_at": None, "dismissed_at": None, "current_step": 0}
47+
48+
49+
def test_put_then_get_round_trips(client: TestClient) -> None:
50+
put = client.put(
51+
"/api/v1/users/me/tour-state",
52+
json={"completed_at": None, "dismissed_at": None, "current_step": 3},
53+
)
54+
assert put.status_code == 200, put.text
55+
assert put.json()["current_step"] == 3
56+
57+
get = client.get("/api/v1/users/me/tour-state")
58+
assert get.status_code == 200
59+
body = get.json()
60+
assert body["current_step"] == 3
61+
assert body["completed_at"] is None
62+
63+
64+
def test_put_now_sentinel_stamps_iso_timestamp(client: TestClient) -> None:
65+
put = client.put(
66+
"/api/v1/users/me/tour-state",
67+
json={"completed_at": "now", "current_step": 8},
68+
)
69+
assert put.status_code == 200
70+
body = put.json()
71+
assert body["current_step"] == 8
72+
# ISO-8601 with timezone — starts with year and includes 'T'.
73+
assert body["completed_at"] is not None
74+
assert body["completed_at"][:4].isdigit()
75+
assert "T" in body["completed_at"]
76+
77+
78+
def test_put_404_for_missing_user(client: TestClient) -> None:
79+
"""If the user row vanishes mid-flight, PUT surfaces 404 not 500."""
80+
from cq_server.app import _get_store
81+
from sqlalchemy import text
82+
83+
# Delete the seeded user — simulates the mid-flight race.
84+
store = _get_store()
85+
with store._engine.begin() as conn: # noqa: SLF001
86+
conn.execute(text("DELETE FROM users WHERE username = :u"), {"u": "founder"})
87+
88+
resp = client.put(
89+
"/api/v1/users/me/tour-state",
90+
json={"completed_at": None, "current_step": 1},
91+
)
92+
assert resp.status_code == 404, resp.text

server/frontend/src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { NetworkPage } from "./pages/NetworkPage"
1010
import { PersonasPage } from "./pages/PersonasPage"
1111
import { ReviewPage } from "./pages/ReviewPage"
1212
import { ThemeProvider } from "./theme"
13+
import { TourOverlay } from "./tour/TourOverlay"
14+
import { TourProvider } from "./tour/TourProvider"
1315

1416
function AppRoutes() {
1517
const { isAuthenticated } = useAuth()
@@ -45,7 +47,10 @@ export default function App() {
4547
<BrowserRouter>
4648
<ThemeProvider>
4749
<AuthProvider>
48-
<AppRoutes />
50+
<TourProvider>
51+
<AppRoutes />
52+
<TourOverlay />
53+
</TourProvider>
4954
</AuthProvider>
5055
</ThemeProvider>
5156
</BrowserRouter>

server/frontend/src/components/Layout.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react"
22
import { Link, Outlet, useLocation } from "react-router"
33
import { api } from "../api"
44
import { useAuth } from "../auth"
5+
import { TourLauncher } from "../tour/TourLauncher"
56
import { PoweredBy8thLayer } from "./PoweredBy8thLayer"
67
import { Wordmark } from "./Wordmark"
78

@@ -26,11 +27,12 @@ export function Layout() {
2627
return () => clearInterval(interval)
2728
}, [onDashboard])
2829

29-
function navLink(path: string, label: string) {
30+
function navLink(path: string, label: string, tourId?: string) {
3031
const active = location.pathname === path
3132
return (
3233
<Link
3334
to={path}
35+
data-tour-target={tourId}
3436
className={`relative font-mono-brand text-[11px] uppercase tracking-[0.22em] py-1 whitespace-nowrap transition-colors ${
3537
active
3638
? "text-[var(--ink)]"
@@ -57,22 +59,25 @@ export function Layout() {
5759
className={`${wide ? "w-full px-6" : "max-w-3xl mx-auto px-4"} py-3 flex items-center justify-between`}
5860
>
5961
<div className="flex items-center gap-3 md:gap-7">
60-
<Link to="/dashboard" className="mr-2">
62+
<Link to="/dashboard" className="mr-2" data-tour-target="welcome">
6163
<Wordmark size="md" />
6264
</Link>
63-
{navLink("/review", "Review")}
64-
{navLink("/dashboard", "Dashboard")}
65-
{navLink("/network", "Network")}
66-
{navLink("/settings/api-keys", "API Keys")}
67-
{navLink("/admin/personas", "Personas")}
65+
<span data-tour-target="group" className="contents" />
66+
{navLink("/review", "Review", "review")}
67+
{navLink("/dashboard", "Dashboard", "dashboard")}
68+
{navLink("/network", "Network", "network")}
69+
{navLink("/settings/api-keys", "API Keys", "api-keys")}
70+
{navLink("/admin/personas", "Personas", "personas")}
6871
</div>
6972
<div className="flex items-center gap-4">
73+
<TourLauncher />
7074
<span className="hidden md:inline font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[var(--ink-mute)]">
7175
{username}
7276
</span>
7377
<button
7478
type="button"
7579
onClick={logout}
80+
data-tour-target="done"
7681
className="font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[var(--ink-faint)] hover:text-[var(--rose)] transition-colors"
7782
>
7883
Logout

0 commit comments

Comments
 (0)