diff --git a/CHANGELOG.md b/CHANGELOG.md
index b993319..2f0d917 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ This project follows [Semantic Versioning](https://semver.org/) and groups chang
by theme. Dates are when the release landed on `main` — 1.1.0 through 1.6.0 shipped
the same day, as a rapid burst of improvements, so they share a date.
+## [Unreleased]
+
+### Added
+
+- **Vee-guided first-run walkthrough.** New users are met by Vee on their first Library open; the coachmark steps through a seeded demo repo (Library card → Verdict tab → Blueprint canvas) with plain narration and a spotlight on each target element. Implemented in `onboarding.js` / `coachmark.js`; copy lives in `onboarding-copy.js`.
+- **Milestone "power tour"** offered after approximately five real scans: a second coachmark sequence introducing the cross-library tools — Ask, Corkboard (Alternatives / Synergies), multi-select Compare, Radar / auto-organize, and Discover.
+
+---
+
## [3.1.0] — 2026-06-16 · _Interactive Canvas (Blueprint · Guided Tour · Corkboard · Stack Studio)_
### Added
diff --git a/README.md b/README.md
index 66301b0..e044031 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,8 @@ A scan opens to a **verdict landing** and fans out into focused tabs:
Plus **SKTPG** (a one-tap State / Known-pitfalls / Trajectory / Proof / Growth read), framework lenses, and capability re-tagging.
+**First run:** Vee walks new users through a seeded demo repo (Library → Verdict → Blueprint) via a coachmark tour. After roughly five real scans a second "power tour" introduces the cross-library tools: Ask, Corkboard analysis, multi-select compare, Radar, and Discover.
+
---
## 🆕 What's new
diff --git a/backup.js b/backup.js
index 353a24a..b023405 100644
--- a/backup.js
+++ b/backup.js
@@ -7,6 +7,8 @@
// library — analyzed repos, the semantic graph (nodes + edges) and the local
// scan cache — round-trips through one human-readable JSON file.
+import { SNAPSHOT_CAP } from './snapshots.js';
+
export const BACKUP_FORMAT = 'repolens-backup';
export const BACKUP_VERSION = 2;
@@ -15,9 +17,9 @@ export const BACKUP_VERSION = 2;
// past these is dropped with a surfaced warning (never silently).
export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000, scenes: 2000 };
-// Per-repo snapshot ring-buffer cap (mirrors SNAPSHOT_CAP in snapshots.js); each
+// Per-repo snapshot ring-buffer cap — single source of truth in snapshots.js; each
// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
-const SNAP_CAP = 30;
+const SNAP_CAP = SNAPSHOT_CAP;
const arr = (x) => (Array.isArray(x) ? x : []);
const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId);
@@ -88,7 +90,12 @@ export function validateBackup(obj) {
cache: clamp('cache', arr(obj.cache).filter(cacheOk)),
collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
- snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({ ...r, snaps: arr(r.snaps).slice(-SNAP_CAP) }))),
+ snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({
+ ...r,
+ // Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
+ // file may carry a non-array `flags` that would later throw in snapshotTrend.
+ snaps: arr(r.snaps).slice(-SNAP_CAP).map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
+ }))),
scenes: clamp('scenes', arr(obj.scenes).filter(sceneOk)),
};
return { ok: errors.length === 0, errors, warnings, value };
diff --git a/canvas-export.js b/canvas-export.js
index d5f3b1a..c5b0535 100644
--- a/canvas-export.js
+++ b/canvas-export.js
@@ -18,20 +18,23 @@ export function toCanvasSvg(scene) {
const pos = Object.fromEntries(nodes.map((n) => [n.id, n]));
const minX = Math.min(0, ...nodes.map((n) => num(n.x))) - 20;
const minY = Math.min(0, ...nodes.map((n) => num(n.y))) - 20;
- const maxX = Math.max(...nodes.map((n) => num(n.x) + NW), 200) + 20;
+ const maxX = Math.max(...nodes.map((n) => num(n.x) + (num(n._w) || NW)), 200) + 20;
const maxY = Math.max(...nodes.map((n) => num(n.y) + NH), 200) + 60;
const edgeSvg = edges.map((e) => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return '';
- const x1 = num(a.x) + NW, y1 = num(a.y) + NH / 2, x2 = num(b.x), y2 = num(b.y) + NH / 2, mx = (x1 + x2) / 2;
+ // Start the edge at the source node's real right edge (auto-width `_w`), not the
+ // fixed constant, so edges on wide cards stay attached.
+ const aw = num(a._w) || NW;
+ const x1 = num(a.x) + aw, y1 = num(a.y) + NH / 2, x2 = num(b.x), y2 = num(b.y) + NH / 2, mx = (x1 + x2) / 2;
return ``;
}).join('');
const nodeSvg = nodes.map((n) => {
- const x = num(n.x), y = num(n.y);
- return `` +
- `${esc(n.label)}`;
+ const x = num(n.x), y = num(n.y), w = num(n._w) || NW;
+ return `` +
+ `${esc(n.label)}`;
}).join('');
const annSvg = ann.map((a) => {
@@ -55,14 +58,14 @@ export function toExcalidraw(scene) {
for (const n of scene.nodes || []) {
const rid = `rect-${n.id}`, tid = `txt-${n.id}`;
- const x = num(n.x), y = num(n.y);
+ const x = num(n.x), y = num(n.y), w = num(n._w) || NW;
elements.push(base(rid, {
- type: 'rectangle', x, y, width: 132, height: 44,
+ type: 'rectangle', x, y, width: w, height: 44,
backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6',
boundElements: [{ type: 'text', id: tid }],
}));
elements.push(base(tid, {
- type: 'text', x: x + 8, y: y + 14, width: 116, height: 20, text: String(n.label),
+ type: 'text', x: x + 8, y: y + 14, width: w - 16, height: 20, text: String(n.label),
fontSize: 16, fontFamily: 1, textAlign: 'center', verticalAlign: 'middle', containerId: rid,
originalText: String(n.label), lineHeight: 1.25,
}));
@@ -72,7 +75,7 @@ export function toExcalidraw(scene) {
for (const e of scene.edges || []) {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) continue;
- const x1 = num(a.x) + 132, y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22;
+ const x1 = num(a.x) + (num(a._w) || NW), y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22;
elements.push(base(`arrow-${e.id}`, {
type: 'arrow', x: x1, y: y1, width: x2 - x1, height: y2 - y1,
points: [[0, 0], [x2 - x1, y2 - y1]],
diff --git a/coachmark.js b/coachmark.js
new file mode 100644
index 0000000..70c690c
--- /dev/null
+++ b/coachmark.js
@@ -0,0 +1,70 @@
+// coachmark.js
+// DOM coachmark tour: a dimming veil, a spotlight around a target element, a card
+// (with Vee) anchored beside it, Back/Next/Skip + keyboard. No deps, MV3-safe.
+import { renderMascot, setMascotState } from './mascot.js';
+
+const GAP = 12, MARGIN = 8;
+
+/** Pure: where to put the card relative to a target rect (or center if null). */
+export function placeCard(rect, card, vp) {
+ if (!rect) return { side: 'center', left: (vp.w - card.w) / 2, top: (vp.h - card.h) / 2 };
+ const below = rect.y + rect.height + GAP, above = rect.y - GAP - card.h;
+ const side = (below + card.h <= vp.h) ? 'below' : (above >= 0 ? 'above' : 'below');
+ const top = side === 'below' ? below : above;
+ let left = rect.x + rect.width / 2 - card.w / 2;
+ left = Math.max(MARGIN, Math.min(left, vp.w - card.w - MARGIN));
+ return { side, left, top: Math.max(MARGIN, Math.min(top, vp.h - card.h - MARGIN)) };
+}
+
+/**
+ * @param {{steps:Array, copy:object, onExit?:Function}} args
+ * step = { target:selector|null, copyKey, mascotState, before? }
+ * @returns {{ next, prev, exit }}
+ */
+export function startCoachmark({ steps, copy, onExit }) {
+ let i = 0;
+ const reduce = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const veil = document.createElement('div'); veil.className = 'cm-veil';
+ const spot = document.createElement('div'); spot.className = 'cm-spotlight';
+ const card = document.createElement('div'); card.className = 'cm-card';
+ const veeSlot = document.createElement('div'); veeSlot.className = 'cm-vee';
+ const vee = renderMascot(veeSlot);
+ const text = document.createElement('p'); text.className = 'cm-text';
+ const ctl = document.createElement('div'); ctl.className = 'cm-ctl';
+ const back = document.createElement('button'); back.textContent = 'Back';
+ const next = document.createElement('button'); next.textContent = 'Next';
+ const skip = document.createElement('button'); skip.textContent = 'Skip'; skip.className = 'cm-skip';
+ ctl.append(skip, back, next);
+ card.append(veeSlot, text, ctl);
+ veil.append(spot); document.body.append(veil, card);
+
+ async function render() {
+ const s = steps[i];
+ if (s.before) { try { await s.before(); } catch { /* step action best-effort */ } }
+ setMascotState(vee, s.mascotState || 'idle');
+ text.textContent = copy[s.copyKey] || '';
+ back.disabled = i === 0;
+ next.textContent = i === steps.length - 1 ? 'Done' : 'Next';
+ const el = s.target ? document.querySelector(s.target) : null;
+ const vp = { w: innerWidth, h: innerHeight };
+ if (el) {
+ el.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
+ const r = el.getBoundingClientRect();
+ spot.style.cssText = `display:block;left:${r.x - 6}px;top:${r.y - 6}px;width:${r.width + 12}px;height:${r.height + 12}px`;
+ const p = placeCard({ x: r.x, y: r.y, width: r.width, height: r.height }, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
+ card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
+ } else {
+ spot.style.display = 'none';
+ const p = placeCard(null, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
+ card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
+ }
+ }
+ function go(n) { i = Math.max(0, Math.min(steps.length - 1, n)); render(); }
+ function step(d) { (i + d >= steps.length) ? exit() : go(i + d); }
+ function exit() { veil.remove(); card.remove(); removeEventListener('keydown', onKey); onExit && onExit(); }
+ const onKey = (e) => { if (e.key === 'Escape') exit(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); };
+ back.onclick = () => step(-1); next.onclick = () => step(1); skip.onclick = exit;
+ addEventListener('keydown', onKey);
+ render();
+ return { next: () => step(1), prev: () => step(-1), exit };
+}
diff --git a/demo-repo.js b/demo-repo.js
new file mode 100644
index 0000000..41edbea
--- /dev/null
+++ b/demo-repo.js
@@ -0,0 +1,73 @@
+// demo-repo.js
+// A pre-baked, clearly-marked DEMO scan (honojs/hono) so the first-run tour can
+// show real surfaces. Tagged __demo__ so it's excluded from stats/export and torn down.
+import { buildBlueprintScene } from './blueprint-adapter.js';
+
+export const DEMO_REPO = {
+ repoId: 'honojs/hono',
+ __demo__: true,
+ platform: 'github', language: 'TypeScript', license: 'MIT', stars: 21000,
+ category: 'Web framework', tags: ['edge', 'router'],
+ description: 'Small, fast web framework for the edges.',
+ saved_at: '2026-01-01T00:00:00.000Z',
+ eli5: 'A sample read: a tiny web framework that runs on edge runtimes using web-standard requests.',
+ fit: 'strong',
+ health: { score: 92 },
+ pros: ['Tiny and fast', 'Runs on most runtimes', 'Typed routing'],
+ cons: ['Smaller ecosystem than Express'],
+ red_flags: [],
+ capabilities: ['routing', 'middleware', 'edge'],
+ deepDive: {
+ atoms: [
+ { id: 'app', name: 'Hono app', kind: 'entrypoint', purpose: 'Creates the app and registers routes.' },
+ { id: 'router', name: 'router', kind: 'subsystem', purpose: 'Matches a request to a handler.' },
+ { id: 'context', name: 'Context', kind: 'subsystem', purpose: 'Wraps request and response per call.' },
+ { id: 'middleware', name: 'middleware', kind: 'module', purpose: 'Runs before/after handlers.' },
+ { id: 'handler', name: 'handler', kind: 'module', purpose: 'Your route logic.' },
+ { id: 'adapter', name: 'runtime adapter', kind: 'module', purpose: 'Binds to a runtime (Workers, Deno, Node).' },
+ ],
+ lineage: {
+ links: [
+ { from: 'app', to: 'router', relation: 'depends-on' },
+ { from: 'router', to: 'context', relation: 'triggers' },
+ { from: 'context', to: 'middleware', relation: 'triggers' },
+ { from: 'middleware', to: 'handler', relation: 'triggers' },
+ { from: 'app', to: 'adapter', relation: 'depends-on' },
+ ],
+ roots: ['app'], leaves: ['handler'],
+ },
+ },
+};
+
+/** The blueprint scene for the demo (so the Canvas tab renders real content).
+ * Tagged __demo__ so exportStores can drop it without coupling the store to this
+ * fixture (saveScene persists the scene verbatim, so the tag survives). */
+export function demoScene() {
+ return { ...buildBlueprintScene({ deepDive: DEMO_REPO.deepDive, repoId: DEMO_REPO.repoId, title: DEMO_REPO.repoId }), __demo__: true };
+}
+
+/** True only for the seeded demo row. */
+export function isDemo(repo) {
+ return !!(repo && (repo.__demo__ === true || (repo.repoId === DEMO_REPO.repoId && repo.__demo__)));
+}
+
+/**
+ * Tear down the seeded demo (repo row + blueprint scene). Best-effort.
+ * Uses a dynamic import so this fixture module never pulls the IndexedDB/store
+ * layer into its static import graph (it's also imported by pure unit tests).
+ */
+export async function clearDemoEverywhere() {
+ try {
+ const { deleteRepo, deleteScene, deleteSnapshots, scrollPoints } = await import('./store.js');
+ // Only tear down when the stored honojs/hono row is actually the demo —
+ // never delete a real scan that happens to share the demo's id.
+ const points = await scrollPoints();
+ const row = points.find((p) => p?.payload?.repoId === DEMO_REPO.repoId);
+ if (row?.payload?.__demo__ === true) {
+ await deleteRepo(DEMO_REPO.repoId);
+ await deleteScene(demoScene().id);
+ // Defense-in-depth: clear any snapshot orphan left by an already-seeded user.
+ await deleteSnapshots(DEMO_REPO.repoId);
+ }
+ } catch { /* best-effort teardown */ }
+}
diff --git a/docs/superpowers/plans/2026-06-16-vee-onboarding.html b/docs/superpowers/plans/2026-06-16-vee-onboarding.html
new file mode 100644
index 0000000..6e39d1e
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-vee-onboarding.html
@@ -0,0 +1,408 @@
+
RepoLens — Implementation Plan
Vee Onboarding Walkthrough — Implementation Plan
+
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use - [ ].
+
Goal: Two Vee-guided tours — a first-run seeded-demo intro (Verdict → Blueprint → Corkboard) and a milestone "power" tour at ≥5 real repos — that make RepoLens welcoming, with narration in Vee's dry, anti-slop voice.
+
Architecture: A reusable DOM coachmark engine (veil + spotlight + element-anchored card + Vee) driven by step lists in onboarding.js; a seeded demo repo fixture so the intro shows real surfaces; flags in chrome.storage.local. All copy lives in one onboarding-copy.js deck guarded by a machine-checked anti-slop test. Zero backend, zero telemetry, MV3-safe, theme-token-based.
+
Tech Stack: Vanilla ES modules, no deps, Vitest, IndexedDB (store.js), reuses mascot.js (Vee), scene.js/validateScene, the canvas tour's card look.
+
Spec:docs/superpowers/specs/2026-06-16-vee-onboarding-design.{md,html}. Branch:feat/vee-onboarding (off the canvas branch; spec already committed).
+
+
Anti-slop ruleset (used by Task 2's test)
+
Derived from hexiecs/talk-normal, realrossmanngroup/no_ai_slop_writing_rules, stephenturner/skill-deslop. Every Vee line must pass:
+
No banned vocab (case-insensitive): unlock, supercharge, elevate, leverage, harness, streamline, empower, revolutionize, showcase, enhance, foster, facilitate, dive in, delve, seamless, effortless, robust, comprehensive, powerful, cutting-edge, game-changer, game-changing, transformative, remarkable, crucial, intricate, meticulous, landscape, tapestry, ecosystem, synergy, supercharge, get ready, the fun stuff, that's it, you're all set, easy, right.
No em dash (—) — the #1 AI tell; use periods/commas.
≤ 1 ! across the whole deck.
The Vee formula: name a specific thing → say what it means in one adjective-free clause → stop.
+
File structure
+
File
Responsibility
New/Modify
onboarding-copy.js
The single copy deck: every Vee line, in-voice + de-slopped. Pure data.
Step lists (intro Stage-A/B + milestone) built from the deck; flag/stage state machine; triggers maybeStartLibraryOnboarding/maybeContinueOnboarding/maybeOfferMilestoneTour/startOnboarding.
New
coachmark.js
DOM coachmark: pure placeCard() geometry + startCoachmark({steps,onExit}) (veil/spotlight/card/Vee/nav).
Task 2: Copy deck + the anti-slop test (onboarding-copy.js)
+
Files: Create onboarding-copy.js, tests/onboarding-copy.test.js. This test is the de-slop gate.
+
☐ Step 1 — write the anti-slop test FIRST:
+
// tests/onboarding-copy.test.js
+import { describe, it, expect } from 'vitest';
+import { COPY } from '../onboarding-copy.js';
+
+const BANNED = ['unlock','supercharge','elevate','leverage','harness','streamline','empower','revolutionize','showcase','enhance','foster','facilitate','dive in','delve','seamless','effortless','robust','comprehensive','powerful','cutting-edge','game-changer','game-changing','transformative','remarkable','crucial','intricate','meticulous','landscape','tapestry','ecosystem','synergy','get ready','the fun stuff',"that's it","you're all set"];
+
+const lines = Object.entries(COPY); // [key, text]
+
+describe('Vee copy is de-slopped', () => {
+ it('uses no banned AI-slop vocab', () => {
+ for (const [k, t] of lines) {
+ const low = String(t).toLowerCase();
+ for (const b of BANNED) expect(low.includes(b), `"${k}": banned "${b}" in: ${t}`).toBe(false);
+ }
+ });
+ it('uses no em dashes (the #1 AI tell)', () => {
+ for (const [k, t] of lines) expect(String(t).includes('—'), `"${k}" has an em dash`).toBe(false);
+ });
+ it('keeps exclamation marks to at most one across the whole deck', () => {
+ const total = lines.reduce((n, [, t]) => n + (String(t).match(/!/g) || []).length, 0);
+ expect(total).toBeLessThanOrEqual(1);
+ });
+ it('every line is short (≤ 140 chars) and non-empty', () => {
+ for (const [k, t] of lines) { expect(String(t).length, k).toBeGreaterThan(0); expect(String(t).length, k).toBeLessThanOrEqual(140); }
+ });
+});
+
☐ Step 2 — run, expect FAIL:npx vitest run tests/onboarding-copy.test.js
+
☐ Step 3 — implement onboarding-copy.js (every line in Vee's voice; {N} is interpolated at render):
+
// onboarding-copy.js
+// Vee's narration, in one place. Calm, candid, dry: name a specific thing, say what
+// it means in one plain clause, stop. No hype, no em dashes, no exclamation spam.
+export const COPY = {
+ introGreet: "I'm Vee. I read the source so you don't have to. Two minutes?",
+ introCard: 'Every repo you scan lands here. Fit, health, and the notes you take.',
+ introCorkboard: 'Your whole library as a board. The lines show how repos relate.',
+ introSearch: 'Search by name, or ask the library a question in plain words.',
+ introOpen: 'One click opens the full read on a repo.',
+ verdict: "The honest answer on whether to use it, before the README's pitch.",
+ blueprint: "How it's built, as a map you can drag. The tour button walks you through it.",
+ farewell: 'That covers it. Everything stays in your browser, nothing phones home.',
+ // milestone "power" tour
+ milestoneOffer: "{N} scans in. Your library's deep enough now for the tools that compare and connect repos. Want a look?",
+ milestoneAsk: "Ask across everything you've scanned, in plain words.",
+ milestoneCorkboard: 'See how your repos relate. Run Alternatives or Synergies to draw more lines.',
+ milestoneCompare: 'Select a few, then compare them side by side or wire them into a stack.',
+ milestoneOrganize: 'Sort a real library: the radar, auto-organize, and collections.',
+ milestoneDiscover: "Find new repos from the ones you've adopted.",
+};
+
☐ Step 4 — run, expect PASS:npx vitest run tests/onboarding-copy.test.js
Files: Create onboarding.js, tests/onboarding.test.js. Pure parts (step builders, milestone threshold, stage machine) are tested; the trigger functions that touch chrome.storage/DOM are thin and verified live.
+
☐ Step 1 — failing test:
+
// tests/onboarding.test.js
+import { describe, it, expect } from 'vitest';
+import { introStageA, introStageB, milestoneSteps, shouldOfferMilestone, MILESTONE_AT } from '../onboarding.js';
+
+describe('onboarding step lists', () => {
+ it('intro stages are non-empty, reference real copy keys + selectors', () => {
+ for (const list of [introStageA(), introStageB(), milestoneSteps()]) {
+ expect(list.length).toBeGreaterThan(0);
+ for (const s of list) { expect(typeof s.copyKey).toBe('string'); expect('target' in s).toBe(true); }
+ }
+ });
+ it('milestone offers at the threshold, not before, and not once seen', () => {
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT, milestoneTourSeen: false, onboardingSeen: true })).toBe(true);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT - 1, milestoneTourSeen: false, onboardingSeen: true })).toBe(false);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT + 9, milestoneTourSeen: true, onboardingSeen: true })).toBe(false);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT, milestoneTourSeen: false, onboardingSeen: false })).toBe(false);
+ });
+});
+
☐ Step 2 — run, expect FAIL:npx vitest run tests/onboarding.test.js
+
☐ Step 3 — implement onboarding.js (pure builders + gate exported; trigger wrappers below them). Selectors target real Library/output anchors; adapt the exact selectors to the live DOM when wiring (Tasks 6–7), but these are the intended anchors:
(The trigger wrappers maybeStartLibraryOnboarding/maybeContinueOnboarding/maybeOfferMilestoneTour/startOnboarding are added in Tasks 6–7 where they read chrome.storage and call startCoachmark — kept out of this pure module's tested surface, or added here as thin async fns that import coachmark.js + demo-repo.js. Implementer's choice; keep the tested exports above pure.)
+
☐ Step 4 — run, expect PASS + full suite:npx vitest run tests/onboarding.test.js then npx vitest run
Files: Create coachmark.js, tests/coachmark.test.js. Pure placeCard geometry is unit-tested; the DOM mount (veil/spotlight/card/Vee/nav) is concrete + verified live.
+
☐ Step 1 — failing test (pure geometry):
+
// tests/coachmark.test.js
+import { describe, it, expect } from 'vitest';
+import { placeCard } from '../coachmark.js';
+
+const VP = { w: 1000, h: 700 };
+const CARD = { w: 320, h: 150 };
+
+describe('placeCard', () => {
+ it('places the card BELOW a target near the top', () => {
+ const p = placeCard({ x: 400, y: 40, width: 120, height: 36 }, CARD, VP);
+ expect(p.side).toBe('below');
+ expect(p.top).toBeGreaterThan(40 + 36);
+ });
+ it('places the card ABOVE a target near the bottom', () => {
+ const p = placeCard({ x: 400, y: 650, width: 120, height: 36 }, CARD, VP);
+ expect(p.side).toBe('above');
+ expect(p.top + CARD.h).toBeLessThanOrEqual(650);
+ });
+ it('keeps the card within the viewport horizontally', () => {
+ const p = placeCard({ x: 980, y: 300, width: 40, height: 36 }, CARD, VP);
+ expect(p.left).toBeGreaterThanOrEqual(8);
+ expect(p.left + CARD.w).toBeLessThanOrEqual(VP.w - 8);
+ });
+ it('centers when target is null', () => {
+ const p = placeCard(null, CARD, VP);
+ expect(p.side).toBe('center');
+ expect(Math.round(p.left)).toBe(Math.round((VP.w - CARD.w) / 2));
+ });
+});
+
☐ Step 2 — run, expect FAIL:npx vitest run tests/coachmark.test.js
Files: Modify library.html, library.js. DOM glue — verified live (no unit test). Read library.js init/render, the chrome.storage.local usage (the guideSeen pattern), allRows, the stats render, the ⌘K palette command list, and openRow before editing.
+
☐ Step 1 — add the demo badge + empty-state chip in library.html (the chip lives in the empty-state markup library.js renders, so add it in the #empty template inside library.js instead — see Step 2). In library.html no structural change is required beyond confirming #empty exists.
+
☐ Step 2 — library.js: import and wire. Add near the other imports:
+
import { introStageA, shouldOfferMilestone, milestoneSteps, COPY } from './onboarding.js';
+import { startCoachmark } from './coachmark.js';
+import { DEMO_REPO, demoScene, isDemo } from './demo-repo.js';
+import { saveRepo, deleteRepo, saveScene } from './store.js';
Adapt: the demo teardown — on intro completion (Stage B's farewell) clear the demo; on any Library load when onboardingSeen is true, sweep stray demos: for (const r of allRows) if (isDemo(r)) await deleteRepo(r.repoId). Make offerMilestone a 3-button prompt (Show me / Maybe later / Don't ask) rather than auto-running — wire "Maybe later" to re-offer at ≥10, "Don't ask"/complete → set milestoneTourSeen. Exclude isDemo rows from the stats counts in the stats render. Add a "Take the tour" command to the ⌘K palette and a "👋 New here? Take the tour" chip to the #empty template, both calling startIntro().
+
☐ Step 3 — verify:node --check library.js; npx vitest run (all green); reload the extension, confirm the intro fires on an empty library and the demo card appears.
Task 7: Wire Stage B in output-tab (output-tab.js)
+
Files: Modify output-tab.js. DOM glue — verified live. Read how output-tab gets its data + the Canvas tab (Task-14 of the canvas plan: data-tab="27", renderCanvas).
+
☐ Step 1 — output-tab.js: import + call on init:
+
import { introStageB, COPY } from './onboarding.js';
+import { startCoachmark } from './coachmark.js';
+import { clearDemoEverywhere } from './demo-repo.js'; // small helper: delete demo repo + scene
introStageB() step 2 has before: () => show(27) to open the Canvas tab before spotlighting the Blueprint — add that before hook in onboarding.js's introStageB (it can call the page's tab-switch). Add clearDemoEverywhere() to demo-repo.js (deletes the demo repo via deleteRepo + its scene via deleteScene). Guard each step's target so it no-ops if absent.
+
☐ Step 2 — verify:node --check output-tab.js; npx vitest run; reload + run the intro through to Stage B.
☐ Step 3 — store.js/backup.js: exclude __demo__ rows from exportStores/buildBackup and from any library-stats count (filter !isDemo(r) / !r.payload?.__demo__). Add a tiny test in tests/backup-scenes.test.js or a new test asserting a __demo__ repo is dropped from buildBackup.
☐ Step 1 — copy-reviewer gate: dispatch a code/copy reviewer over onboarding-copy.js with the anti-slop ruleset (banned vocab, em dash, exclamation cap, the Vee formula). Apply any fixes; the tests/onboarding-copy.test.js machine-check must still pass. (This is the human-judgment layer the test can't cover: does each line sound like a dry senior engineer, not a brochure?)
☐ Step 2 — onboarding-demo.html: a standalone page that mounts coachmark.js over a few fake target elements with the real COPY, so the look can be screenshotted without the extension context (mirrors canvas-demo.html).
☐ Step 3 — docs: CHANGELOG entry + README "What you get" note: a Vee-guided first-run tour + a milestone power tour. Commit.
+
+
Final verification
+
☐ npx vitest run — all green (new: demo-repo, onboarding-copy [the anti-slop gate], onboarding, coachmark).
☐ node --check on every new/changed .js.
☐ Live smoke: fresh profile → open Library → intro fires, demo seeds, Vee walks Stage A → opens demo → Stage B (Verdict + Blueprint) → demo cleared, onboardingSeen set. Scan 5 real repos → milestone offer appears. Replay from chip / ⌘K / Settings. Reduced-motion + a couple themes. Screenshot via onboarding-demo.html.
+
Out of scope
+
Telemetry of any kind. Branching tours by persona. Voiced/audio Vee.
+
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-06-16-vee-onboarding.md b/docs/superpowers/plans/2026-06-16-vee-onboarding.md
new file mode 100644
index 0000000..5a9d7db
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-vee-onboarding.md
@@ -0,0 +1,527 @@
+# Vee Onboarding Walkthrough — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use `- [ ]`.
+
+**Goal:** Two Vee-guided tours — a first-run seeded-demo intro (Verdict → Blueprint → Corkboard) and a milestone "power" tour at ≥5 real repos — that make RepoLens welcoming, with narration in Vee's dry, anti-slop voice.
+
+**Architecture:** A reusable DOM **coachmark engine** (veil + spotlight + element-anchored card + Vee) driven by step lists in `onboarding.js`; a seeded **demo repo** fixture so the intro shows real surfaces; flags in `chrome.storage.local`. All copy lives in one **`onboarding-copy.js` deck** guarded by a machine-checked **anti-slop test**. Zero backend, zero telemetry, MV3-safe, theme-token-based.
+
+**Tech Stack:** Vanilla ES modules, no deps, Vitest, IndexedDB (`store.js`), reuses `mascot.js` (Vee), `scene.js`/`validateScene`, the canvas tour's card look.
+
+**Spec:** `docs/superpowers/specs/2026-06-16-vee-onboarding-design.{md,html}`. **Branch:** `feat/vee-onboarding` (off the canvas branch; spec already committed).
+
+---
+
+## Anti-slop ruleset (used by Task 2's test)
+
+Derived from `hexiecs/talk-normal`, `realrossmanngroup/no_ai_slop_writing_rules`, `stephenturner/skill-deslop`. Every Vee line must pass:
+- **No banned vocab** (case-insensitive): `unlock, supercharge, elevate, leverage, harness, streamline, empower, revolutionize, showcase, enhance, foster, facilitate, dive in, delve, seamless, effortless, robust, comprehensive, powerful, cutting-edge, game-changer, game-changing, transformative, remarkable, crucial, intricate, meticulous, landscape, tapestry, ecosystem, synergy, supercharge, get ready, the fun stuff, that's it, you're all set, easy, right`.
+- **No em dash** (`—`) — the #1 AI tell; use periods/commas.
+- **≤ 1 `!`** across the whole deck.
+- **The Vee formula:** name a specific thing → say what it means in one adjective-free clause → stop.
+
+## File structure
+| File | Responsibility | New/Modify |
+|---|---|---|
+| `onboarding-copy.js` | The single copy deck: every Vee line, in-voice + de-slopped. Pure data. | New |
+| `demo-repo.js` | `honojs/hono` fixture (analysis + atoms/lineage + scene) tagged `__demo__`; `seedDemo`/`clearDemo`/`isDemo`. | New |
+| `onboarding.js` | Step lists (intro Stage-A/B + milestone) built from the deck; flag/stage state machine; triggers `maybeStartLibraryOnboarding`/`maybeContinueOnboarding`/`maybeOfferMilestoneTour`/`startOnboarding`. | New |
+| `coachmark.js` | DOM coachmark: pure `placeCard()` geometry + `startCoachmark({steps,onExit})` (veil/spotlight/card/Vee/nav). | New |
+| `themes.css` | `.cm-veil`/`.cm-spotlight`/`.cm-card`/`.cm-badge-demo` (token-based, reduced-motion). | Modify |
+| `library.js` | Wire intro + milestone triggers after init; empty-state chip; ⌘K command; exclude `__demo__` from stats. | Modify |
+| `output-tab.js` | `maybeContinueOnboarding()` on init (Stage B). | Modify |
+| `options.{js,html}` | "Replay onboarding" button. | Modify |
+| `settings-backup.js` | Allowlist `onboardingSeen`, `milestoneTourSeen`. | Modify |
+| `store.js`/`backup.js` | Exclude `__demo__` from stats + export/backup. | Modify |
+| `CHANGELOG.md`/`README.md` | Note the walkthrough. | Modify |
+
+Shared shapes: copy line = `string`; step = `{ target:string|null, copyKey:string, mascotState:string, before?:()=>Promise }`; flags `onboardingSeen`/`milestoneTourSeen`/`onboardingStage` in `chrome.storage.local`.
+
+---
+
+### Task 1: Demo-repo fixture (`demo-repo.js`)
+
+**Files:** Create `demo-repo.js`, `tests/demo-repo.test.js`.
+
+- [ ] **Step 1 — failing test:**
+```js
+// tests/demo-repo.test.js
+import { describe, it, expect } from 'vitest';
+import { DEMO_REPO, demoScene, isDemo } from '../demo-repo.js';
+import { validateScene } from '../scene.js';
+
+describe('demo fixture', () => {
+ it('is a valid analysis payload tagged __demo__', () => {
+ expect(DEMO_REPO.repoId).toBe('honojs/hono');
+ expect(DEMO_REPO.__demo__).toBe(true);
+ expect(typeof DEMO_REPO.eli5).toBe('string');
+ expect(DEMO_REPO.health && typeof DEMO_REPO.health.score).toBe('number');
+ expect(Array.isArray(DEMO_REPO.deepDive.atoms)).toBe(true);
+ expect(DEMO_REPO.deepDive.atoms.length).toBeGreaterThanOrEqual(4);
+ expect(Array.isArray(DEMO_REPO.deepDive.lineage.links)).toBe(true);
+ });
+ it('builds a valid blueprint scene', () => {
+ const s = demoScene();
+ expect(validateScene(s).ok).toBe(true);
+ expect(s.nodes.length).toBe(DEMO_REPO.deepDive.atoms.length);
+ });
+ it('isDemo detects the demo and nothing else', () => {
+ expect(isDemo(DEMO_REPO)).toBe(true);
+ expect(isDemo({ repoId: 'a/b' })).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/demo-repo.test.js`
+
+- [ ] **Step 3 — implement `demo-repo.js`:**
+```js
+// demo-repo.js
+// A pre-baked, clearly-marked DEMO scan (honojs/hono) so the first-run tour can
+// show real surfaces. Tagged __demo__ so it's excluded from stats/export and torn down.
+import { buildBlueprintScene } from './blueprint-adapter.js';
+
+export const DEMO_REPO = {
+ repoId: 'honojs/hono',
+ __demo__: true,
+ platform: 'github', language: 'TypeScript', license: 'MIT', stars: 21000,
+ category: 'Web framework', tags: ['edge', 'router'],
+ description: 'Small, fast web framework for the edges.',
+ saved_at: '2026-01-01T00:00:00.000Z',
+ eli5: 'A sample read: a tiny web framework that runs on edge runtimes using web-standard requests.',
+ fit: 'strong',
+ health: { score: 92 },
+ pros: ['Tiny and fast', 'Runs on most runtimes', 'Typed routing'],
+ cons: ['Smaller ecosystem than Express'],
+ red_flags: [],
+ capabilities: ['routing', 'middleware', 'edge'],
+ deepDive: {
+ atoms: [
+ { id: 'app', name: 'Hono app', kind: 'entrypoint', purpose: 'Creates the app and registers routes.' },
+ { id: 'router', name: 'router', kind: 'subsystem', purpose: 'Matches a request to a handler.' },
+ { id: 'context', name: 'Context', kind: 'subsystem', purpose: 'Wraps request and response per call.' },
+ { id: 'middleware', name: 'middleware', kind: 'module', purpose: 'Runs before/after handlers.' },
+ { id: 'handler', name: 'handler', kind: 'module', purpose: 'Your route logic.' },
+ { id: 'adapter', name: 'runtime adapter', kind: 'module', purpose: 'Binds to a runtime (Workers, Deno, Node).' },
+ ],
+ lineage: {
+ links: [
+ { from: 'app', to: 'router', relation: 'depends-on' },
+ { from: 'router', to: 'context', relation: 'triggers' },
+ { from: 'context', to: 'middleware', relation: 'triggers' },
+ { from: 'middleware', to: 'handler', relation: 'triggers' },
+ { from: 'app', to: 'adapter', relation: 'depends-on' },
+ ],
+ roots: ['app'], leaves: ['handler'],
+ },
+ },
+};
+
+/** The blueprint scene for the demo (so the Canvas tab renders real content). */
+export function demoScene() {
+ return buildBlueprintScene({ deepDive: DEMO_REPO.deepDive, repoId: DEMO_REPO.repoId, title: DEMO_REPO.repoId });
+}
+
+/** True only for the seeded demo row. */
+export function isDemo(repo) {
+ return !!(repo && (repo.__demo__ === true || repo.repoId === DEMO_REPO.repoId && repo.__demo__));
+}
+```
+
+- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/demo-repo.test.js`
+- [ ] **Step 5 — commit:** `git add demo-repo.js tests/demo-repo.test.js && git commit -m "feat(onboarding): honojs/hono DEMO fixture + demoScene/isDemo"`
+
+---
+
+### Task 2: Copy deck + the anti-slop test (`onboarding-copy.js`)
+
+**Files:** Create `onboarding-copy.js`, `tests/onboarding-copy.test.js`. **This test is the de-slop gate.**
+
+- [ ] **Step 1 — write the anti-slop test FIRST:**
+```js
+// tests/onboarding-copy.test.js
+import { describe, it, expect } from 'vitest';
+import { COPY } from '../onboarding-copy.js';
+
+const BANNED = ['unlock','supercharge','elevate','leverage','harness','streamline','empower','revolutionize','showcase','enhance','foster','facilitate','dive in','delve','seamless','effortless','robust','comprehensive','powerful','cutting-edge','game-changer','game-changing','transformative','remarkable','crucial','intricate','meticulous','landscape','tapestry','ecosystem','synergy','get ready','the fun stuff',"that's it","you're all set"];
+
+const lines = Object.entries(COPY); // [key, text]
+
+describe('Vee copy is de-slopped', () => {
+ it('uses no banned AI-slop vocab', () => {
+ for (const [k, t] of lines) {
+ const low = String(t).toLowerCase();
+ for (const b of BANNED) expect(low.includes(b), `"${k}": banned "${b}" in: ${t}`).toBe(false);
+ }
+ });
+ it('uses no em dashes (the #1 AI tell)', () => {
+ for (const [k, t] of lines) expect(String(t).includes('—'), `"${k}" has an em dash`).toBe(false);
+ });
+ it('keeps exclamation marks to at most one across the whole deck', () => {
+ const total = lines.reduce((n, [, t]) => n + (String(t).match(/!/g) || []).length, 0);
+ expect(total).toBeLessThanOrEqual(1);
+ });
+ it('every line is short (≤ 140 chars) and non-empty', () => {
+ for (const [k, t] of lines) { expect(String(t).length, k).toBeGreaterThan(0); expect(String(t).length, k).toBeLessThanOrEqual(140); }
+ });
+});
+```
+
+- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/onboarding-copy.test.js`
+
+- [ ] **Step 3 — implement `onboarding-copy.js`** (every line in Vee's voice; `{N}` is interpolated at render):
+```js
+// onboarding-copy.js
+// Vee's narration, in one place. Calm, candid, dry: name a specific thing, say what
+// it means in one plain clause, stop. No hype, no em dashes, no exclamation spam.
+export const COPY = {
+ introGreet: "I'm Vee. I read the source so you don't have to. Two minutes?",
+ introCard: 'Every repo you scan lands here. Fit, health, and the notes you take.',
+ introCorkboard: 'Your whole library as a board. The lines show how repos relate.',
+ introSearch: 'Search by name, or ask the library a question in plain words.',
+ introOpen: 'One click opens the full read on a repo.',
+ verdict: "The honest answer on whether to use it, before the README's pitch.",
+ blueprint: "How it's built, as a map you can drag. The tour button walks you through it.",
+ farewell: 'That covers it. Everything stays in your browser, nothing phones home.',
+ // milestone "power" tour
+ milestoneOffer: "{N} scans in. Your library's deep enough now for the tools that compare and connect repos. Want a look?",
+ milestoneAsk: "Ask across everything you've scanned, in plain words.",
+ milestoneCorkboard: 'See how your repos relate. Run Alternatives or Synergies to draw more lines.',
+ milestoneCompare: 'Select a few, then compare them side by side or wire them into a stack.',
+ milestoneOrganize: 'Sort a real library: the radar, auto-organize, and collections.',
+ milestoneDiscover: "Find new repos from the ones you've adopted.",
+};
+```
+
+- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/onboarding-copy.test.js`
+- [ ] **Step 5 — commit:** `git add onboarding-copy.js tests/onboarding-copy.test.js && git commit -m "feat(onboarding): Vee copy deck + machine-checked anti-slop test"`
+
+---
+
+### Task 3: Tour logic (`onboarding.js`)
+
+**Files:** Create `onboarding.js`, `tests/onboarding.test.js`. Pure parts (step builders, milestone threshold, stage machine) are tested; the trigger functions that touch `chrome.storage`/DOM are thin and verified live.
+
+- [ ] **Step 1 — failing test:**
+```js
+// tests/onboarding.test.js
+import { describe, it, expect } from 'vitest';
+import { introStageA, introStageB, milestoneSteps, shouldOfferMilestone, MILESTONE_AT } from '../onboarding.js';
+
+describe('onboarding step lists', () => {
+ it('intro stages are non-empty, reference real copy keys + selectors', () => {
+ for (const list of [introStageA(), introStageB(), milestoneSteps()]) {
+ expect(list.length).toBeGreaterThan(0);
+ for (const s of list) { expect(typeof s.copyKey).toBe('string'); expect('target' in s).toBe(true); }
+ }
+ });
+ it('milestone offers at the threshold, not before, and not once seen', () => {
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT, milestoneTourSeen: false, onboardingSeen: true })).toBe(true);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT - 1, milestoneTourSeen: false, onboardingSeen: true })).toBe(false);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT + 9, milestoneTourSeen: true, onboardingSeen: true })).toBe(false);
+ expect(shouldOfferMilestone({ realCount: MILESTONE_AT, milestoneTourSeen: false, onboardingSeen: false })).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/onboarding.test.js`
+
+- [ ] **Step 3 — implement `onboarding.js`** (pure builders + gate exported; trigger wrappers below them). Selectors target real Library/output anchors; adapt the exact selectors to the live DOM when wiring (Tasks 6–7), but these are the intended anchors:
+```js
+// onboarding.js
+import { COPY } from './onboarding-copy.js';
+
+export const MILESTONE_AT = 5;
+
+// Each step: { target (CSS selector or null=center), copyKey, mascotState, before? }
+export function introStageA() {
+ return [
+ { target: null, copyKey: 'introGreet', mascotState: 'idle' },
+ { target: '[data-node-demo], #grid .lib-card', copyKey: 'introCard', mascotState: 'idle' },
+ { target: '#lib-btn-corkboard', copyKey: 'introCorkboard', mascotState: 'thinking' },
+ { target: '#search', copyKey: 'introSearch', mascotState: 'idle' },
+ { target: '#grid .lib-card', copyKey: 'introOpen', mascotState: 'idle' },
+ ];
+}
+export function introStageB() {
+ return [
+ { target: '.v-fit, .lc-chip', copyKey: 'verdict', mascotState: 'strong' },
+ { target: '[data-tab="27"]', copyKey: 'blueprint', mascotState: 'thinking' },
+ { target: null, copyKey: 'farewell', mascotState: 'idle' },
+ ];
+}
+export function milestoneSteps() {
+ return [
+ { target: '#lib-ask-input', copyKey: 'milestoneAsk', mascotState: 'idle' },
+ { target: '#lib-btn-corkboard', copyKey: 'milestoneCorkboard', mascotState: 'thinking' },
+ { target: '#lib-btn-select, [data-act="select"]', copyKey: 'milestoneCompare', mascotState: 'idle' },
+ { target: '#lib-btn-radar', copyKey: 'milestoneOrganize', mascotState: 'idle' },
+ { target: '#lib-btn-discover', copyKey: 'milestoneDiscover', mascotState: 'idle' },
+ ];
+}
+
+/** Pure gate for the milestone offer. */
+export function shouldOfferMilestone({ realCount, milestoneTourSeen, onboardingSeen }) {
+ return !!onboardingSeen && !milestoneTourSeen && realCount >= MILESTONE_AT;
+}
+
+export { COPY };
+```
+(The trigger wrappers `maybeStartLibraryOnboarding`/`maybeContinueOnboarding`/`maybeOfferMilestoneTour`/`startOnboarding` are added in Tasks 6–7 where they read `chrome.storage` and call `startCoachmark` — kept out of this pure module's tested surface, or added here as thin async fns that import `coachmark.js` + `demo-repo.js`. Implementer's choice; keep the tested exports above pure.)
+
+- [ ] **Step 4 — run, expect PASS + full suite:** `npx vitest run tests/onboarding.test.js` then `npx vitest run`
+- [ ] **Step 5 — commit:** `git add onboarding.js tests/onboarding.test.js && git commit -m "feat(onboarding): step lists + milestone gate (pure)"`
+
+---
+
+### Task 4: Coachmark engine (`coachmark.js`)
+
+**Files:** Create `coachmark.js`, `tests/coachmark.test.js`. Pure `placeCard` geometry is unit-tested; the DOM mount (veil/spotlight/card/Vee/nav) is concrete + verified live.
+
+- [ ] **Step 1 — failing test (pure geometry):**
+```js
+// tests/coachmark.test.js
+import { describe, it, expect } from 'vitest';
+import { placeCard } from '../coachmark.js';
+
+const VP = { w: 1000, h: 700 };
+const CARD = { w: 320, h: 150 };
+
+describe('placeCard', () => {
+ it('places the card BELOW a target near the top', () => {
+ const p = placeCard({ x: 400, y: 40, width: 120, height: 36 }, CARD, VP);
+ expect(p.side).toBe('below');
+ expect(p.top).toBeGreaterThan(40 + 36);
+ });
+ it('places the card ABOVE a target near the bottom', () => {
+ const p = placeCard({ x: 400, y: 650, width: 120, height: 36 }, CARD, VP);
+ expect(p.side).toBe('above');
+ expect(p.top + CARD.h).toBeLessThanOrEqual(650);
+ });
+ it('keeps the card within the viewport horizontally', () => {
+ const p = placeCard({ x: 980, y: 300, width: 40, height: 36 }, CARD, VP);
+ expect(p.left).toBeGreaterThanOrEqual(8);
+ expect(p.left + CARD.w).toBeLessThanOrEqual(VP.w - 8);
+ });
+ it('centers when target is null', () => {
+ const p = placeCard(null, CARD, VP);
+ expect(p.side).toBe('center');
+ expect(Math.round(p.left)).toBe(Math.round((VP.w - CARD.w) / 2));
+ });
+});
+```
+
+- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/coachmark.test.js`
+
+- [ ] **Step 3 — implement `coachmark.js`:**
+```js
+// coachmark.js
+// DOM coachmark tour: a dimming veil, a spotlight around a target element, a card
+// (with Vee) anchored beside it, Back/Next/Skip + keyboard. No deps, MV3-safe.
+import { renderMascot, setMascotState } from './mascot.js';
+
+const GAP = 12, MARGIN = 8;
+
+/** Pure: where to put the card relative to a target rect (or center if null). */
+export function placeCard(rect, card, vp) {
+ if (!rect) return { side: 'center', left: (vp.w - card.w) / 2, top: (vp.h - card.h) / 2 };
+ const below = rect.y + rect.height + GAP, above = rect.y - GAP - card.h;
+ const side = (below + card.h <= vp.h) ? 'below' : (above >= 0 ? 'above' : 'below');
+ const top = side === 'below' ? below : above;
+ let left = rect.x + rect.width / 2 - card.w / 2;
+ left = Math.max(MARGIN, Math.min(left, vp.w - card.w - MARGIN));
+ return { side, left, top: Math.max(MARGIN, Math.min(top, vp.h - card.h - MARGIN)) };
+}
+
+/**
+ * @param {{steps:Array, copy:object, autoplay?:boolean, onExit?:fn}} args
+ * step = { target:selector|null, copyKey, mascotState, before? }
+ * @returns {{ next, prev, exit }}
+ */
+export function startCoachmark({ steps, copy, onExit }) {
+ let i = 0;
+ const reduce = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const veil = document.createElement('div'); veil.className = 'cm-veil';
+ const spot = document.createElement('div'); spot.className = 'cm-spotlight';
+ const card = document.createElement('div'); card.className = 'cm-card';
+ const veeSlot = document.createElement('div'); veeSlot.className = 'cm-vee';
+ const vee = renderMascot(veeSlot);
+ const text = document.createElement('p'); text.className = 'cm-text';
+ const ctl = document.createElement('div'); ctl.className = 'cm-ctl';
+ const back = document.createElement('button'); back.textContent = 'Back';
+ const next = document.createElement('button'); next.textContent = 'Next';
+ const skip = document.createElement('button'); skip.textContent = 'Skip'; skip.className = 'cm-skip';
+ ctl.append(skip, back, next);
+ card.append(veeSlot, text, ctl);
+ veil.append(spot); document.body.append(veil, card);
+
+ async function render() {
+ const s = steps[i];
+ if (s.before) { try { await s.before(); } catch { /* step action best-effort */ } }
+ setMascotState(vee, s.mascotState || 'idle');
+ text.textContent = copy[s.copyKey] || '';
+ back.disabled = i === 0;
+ next.textContent = i === steps.length - 1 ? 'Done' : 'Next';
+ const el = s.target ? document.querySelector(s.target) : null;
+ const vp = { w: innerWidth, h: innerHeight };
+ if (el) {
+ el.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
+ const r = el.getBoundingClientRect();
+ spot.style.cssText = `display:block;left:${r.x - 6}px;top:${r.y - 6}px;width:${r.width + 12}px;height:${r.height + 12}px`;
+ const p = placeCard({ x: r.x, y: r.y, width: r.width, height: r.height }, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
+ card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
+ } else {
+ spot.style.display = 'none';
+ const p = placeCard(null, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
+ card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
+ }
+ }
+ function go(n) { i = Math.max(0, Math.min(steps.length - 1, n)); render(); }
+ function step(d) { (i + d >= steps.length) ? exit() : go(i + d); }
+ function exit() { veil.remove(); card.remove(); removeEventListener('keydown', onKey); onExit && onExit(); }
+ const onKey = (e) => { if (e.key === 'Escape') exit(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); };
+ back.onclick = () => step(-1); next.onclick = () => step(1); skip.onclick = exit;
+ addEventListener('keydown', onKey);
+ render();
+ return { next: () => step(1), prev: () => step(-1), exit };
+}
+```
+
+- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/coachmark.test.js` then `npx vitest run`
+- [ ] **Step 5 — commit:** `git add coachmark.js tests/coachmark.test.js && git commit -m "feat(onboarding): coachmark engine (pure placeCard + veil/spotlight/card/Vee)"`
+
+---
+
+### Task 5: Coachmark styles (`themes.css`)
+
+**Files:** Modify `themes.css` (append, token-based, reduced-motion-guarded).
+
+- [ ] **Step 1 — append:**
+```css
+/* ── Onboarding coachmark ── */
+.cm-veil { position: fixed; inset: 0; z-index: 9000; background: color-mix(in srgb, var(--text, #000) 55%, transparent); }
+.cm-spotlight { position: fixed; border-radius: 10px; box-shadow: 0 0 0 9999px color-mix(in srgb, var(--text,#000) 55%, transparent), 0 0 0 2px var(--accent, #c2691c); transition: all .2s var(--ease-out, ease); pointer-events: none; }
+.cm-card { position: fixed; z-index: 9001; width: 320px; max-width: 92vw; background: var(--surface, #fffdf6); color: var(--text, #211c14); border: 1px solid var(--border, #b9a273); border-radius: 12px; padding: 14px 16px; box-shadow: 0 12px 30px rgba(0,0,0,.3); }
+.cm-vee { width: 56px; height: 56px; float: right; margin: 0 0 6px 10px; }
+.cm-text { margin: 2px 0 12px; font-size: 14.5px; line-height: 1.5; }
+.cm-ctl { display: flex; gap: 8px; align-items: center; }
+.cm-ctl button { padding: 6px 12px; border-radius: 8px; font-size: 13px; cursor: pointer; border: 1px solid var(--border, #b9a273); background: var(--surface, #fffdf6); color: var(--text); }
+.cm-ctl .cm-skip { margin-right: auto; border: none; background: transparent; color: var(--text-sub, #6b5a36); }
+.cm-badge-demo { font: 700 9px/1 ui-monospace, monospace; letter-spacing: .1em; color: #fff; background: var(--accent, #c2691c); border-radius: 4px; padding: 2px 5px; margin-left: 6px; vertical-align: middle; }
+@media (prefers-reduced-motion: reduce) { .cm-spotlight { transition: none; } }
+```
+
+- [ ] **Step 2 — verify** braces balanced (`node -e` brace count) + `npx vitest run` green.
+- [ ] **Step 3 — commit:** `git add themes.css && git commit -m "feat(onboarding): coachmark styles (token-based, reduced-motion)"`
+
+---
+
+### Task 6: Wire the Library (intro trigger, milestone offer, replay chip, ⌘K, demo seed/teardown)
+
+**Files:** Modify `library.html`, `library.js`. DOM glue — verified live (no unit test). **Read `library.js` init/render, the `chrome.storage.local` usage (the `guideSeen` pattern), `allRows`, the stats render, the ⌘K palette command list, and `openRow` before editing.**
+
+- [ ] **Step 1 — add the demo badge + empty-state chip in `library.html`** (the chip lives in the empty-state markup library.js renders, so add it in the `#empty` template inside `library.js` instead — see Step 2). In `library.html` no structural change is required beyond confirming `#empty` exists.
+
+- [ ] **Step 2 — `library.js`:** import and wire. Add near the other imports:
+```js
+import { introStageA, shouldOfferMilestone, milestoneSteps, COPY } from './onboarding.js';
+import { startCoachmark } from './coachmark.js';
+import { DEMO_REPO, demoScene, isDemo } from './demo-repo.js';
+import { saveRepo, deleteRepo, saveScene } from './store.js';
+```
+After `init()` + first `render()`, add:
+```js
+async function checkOnboarding() {
+ const { onboardingSeen, milestoneTourSeen } = await chrome.storage.local.get(['onboardingSeen','milestoneTourSeen']);
+ const real = (allRows || []).filter((r) => !isDemo(r));
+ if (!onboardingSeen) {
+ if (real.length === 0) return startIntro(); // brand-new → run intro
+ await chrome.storage.local.set({ onboardingSeen: true }); // returning user, skip
+ }
+ if (shouldOfferMilestone({ realCount: real.length, milestoneTourSeen: !!milestoneTourSeen, onboardingSeen: true })) {
+ offerMilestone(real.length);
+ }
+}
+async function startIntro() {
+ await saveRepo(DEMO_REPO); await saveScene(demoScene()); // seed demo (real surfaces)
+ await refreshRows(); render(); // re-render so the demo card shows
+ startCoachmark({
+ steps: introStageA(), copy: COPY,
+ onExit: async () => { await chrome.storage.local.set({ onboardingStage: 'verdict' }); openRow(DEMO_REPO.repoId); },
+ });
+}
+function offerMilestone(n) {
+ startCoachmark({
+ steps: [{ target: null, copyKey: 'milestoneOffer', mascotState: 'idle' }],
+ copy: { ...COPY, milestoneOffer: COPY.milestoneOffer.replace('{N}', n) },
+ onExit: async () => { /* offer card's Next leads into the tour */ startCoachmark({ steps: milestoneSteps(), copy: COPY, onExit: () => chrome.storage.local.set({ milestoneTourSeen: true }) }); },
+ });
+}
+```
+> Adapt: the demo teardown — on intro completion (Stage B's farewell) clear the demo; on any Library load when `onboardingSeen` is true, sweep stray demos: `for (const r of allRows) if (isDemo(r)) await deleteRepo(r.repoId)`. Make `offerMilestone` a 3-button prompt (Show me / Maybe later / Don't ask) rather than auto-running — wire "Maybe later" to re-offer at ≥10, "Don't ask"/complete → set `milestoneTourSeen`. Exclude `isDemo` rows from the stats counts in the stats render. Add a "Take the tour" command to the ⌘K palette and a "👋 New here? Take the tour" chip to the `#empty` template, both calling `startIntro()`.
+
+- [ ] **Step 3 — verify:** `node --check library.js`; `npx vitest run` (all green); reload the extension, confirm the intro fires on an empty library and the demo card appears.
+- [ ] **Step 4 — commit:** `git add library.html library.js && git commit -m "feat(onboarding): Library intro trigger + milestone offer + replay + demo seed/teardown"`
+
+---
+
+### Task 7: Wire Stage B in output-tab (`output-tab.js`)
+
+**Files:** Modify `output-tab.js`. DOM glue — verified live. **Read how output-tab gets its data + the Canvas tab (Task-14 of the canvas plan: `data-tab="27"`, `renderCanvas`).**
+
+- [ ] **Step 1 — `output-tab.js`:** import + call on init:
+```js
+import { introStageB, COPY } from './onboarding.js';
+import { startCoachmark } from './coachmark.js';
+import { clearDemoEverywhere } from './demo-repo.js'; // small helper: delete demo repo + scene
+```
+After the page renders a repo, add:
+```js
+async function maybeContinueOnboarding(d) {
+ const { onboardingStage } = await chrome.storage.local.get('onboardingStage');
+ if (onboardingStage !== 'verdict') return;
+ await chrome.storage.local.remove('onboardingStage');
+ startCoachmark({
+ steps: introStageB(), copy: COPY,
+ onExit: async () => {
+ await clearDemoEverywhere(); // remove the seeded demo
+ await chrome.storage.local.set({ onboardingSeen: true });
+ },
+ });
+}
+```
+> `introStageB()` step 2 has `before: () => show(27)` to open the Canvas tab before spotlighting the Blueprint — add that `before` hook in `onboarding.js`'s `introStageB` (it can call the page's tab-switch). Add `clearDemoEverywhere()` to `demo-repo.js` (deletes the demo repo via `deleteRepo` + its scene via `deleteScene`). Guard each step's target so it no-ops if absent.
+
+- [ ] **Step 2 — verify:** `node --check output-tab.js`; `npx vitest run`; reload + run the intro through to Stage B.
+- [ ] **Step 3 — commit:** `git add output-tab.js demo-repo.js && git commit -m "feat(onboarding): output-tab Stage-B continuation + demo teardown"`
+
+---
+
+### Task 8: Flags allowlist, replay button, demo exclusions
+
+**Files:** Modify `settings-backup.js`, `options.{js,html}`, `store.js`, `backup.js`.
+
+- [ ] **Step 1 — `settings-backup.js`:** add `'onboardingSeen'`, `'milestoneTourSeen'` to `SAFE_SETTING_KEYS`. `node --check`.
+- [ ] **Step 2 — `options.html`/`options.js`:** add a **"Replay onboarding"** button → `chrome.storage.local.set({ onboardingSeen: false, milestoneTourSeen: false }); chrome.tabs.create({ url: chrome.runtime.getURL('library.html') });`
+- [ ] **Step 3 — `store.js`/`backup.js`:** exclude `__demo__` rows from `exportStores`/`buildBackup` and from any library-stats count (filter `!isDemo(r)` / `!r.payload?.__demo__`). Add a tiny test in `tests/backup-scenes.test.js` or a new test asserting a `__demo__` repo is dropped from `buildBackup`.
+- [ ] **Step 4 — verify + commit:** `npx vitest run`; `git add -A && git commit -m "feat(onboarding): allowlist flags, replay button, exclude __demo__ from export/stats"`
+
+---
+
+### Task 9: De-slop voice review + docs + demo harness
+
+**Files:** `onboarding-copy.js` (review only), `CHANGELOG.md`, `README.md`, `onboarding-demo.html` (new, standalone).
+
+- [ ] **Step 1 — copy-reviewer gate:** dispatch a code/copy reviewer over `onboarding-copy.js` with the anti-slop ruleset (banned vocab, em dash, exclamation cap, the Vee formula). Apply any fixes; the `tests/onboarding-copy.test.js` machine-check must still pass. (This is the human-judgment layer the test can't cover: does each line sound like a dry senior engineer, not a brochure?)
+- [ ] **Step 2 — `onboarding-demo.html`:** a standalone page that mounts `coachmark.js` over a few fake target elements with the real `COPY`, so the look can be screenshotted without the extension context (mirrors `canvas-demo.html`).
+- [ ] **Step 3 — docs:** CHANGELOG entry + README "What you get" note: a Vee-guided first-run tour + a milestone power tour. Commit.
+
+---
+
+## Final verification
+- [ ] `npx vitest run` — all green (new: demo-repo, onboarding-copy [the anti-slop gate], onboarding, coachmark).
+- [ ] `node --check` on every new/changed `.js`.
+- [ ] **Live smoke:** fresh profile → open Library → intro fires, demo seeds, Vee walks Stage A → opens demo → Stage B (Verdict + Blueprint) → demo cleared, `onboardingSeen` set. Scan 5 real repos → milestone offer appears. Replay from chip / ⌘K / Settings. Reduced-motion + a couple themes. Screenshot via `onboarding-demo.html`.
+
+## Out of scope
+- Telemetry of any kind. Branching tours by persona. Voiced/audio Vee.
diff --git a/docs/superpowers/specs/2026-06-16-vee-onboarding-design.html b/docs/superpowers/specs/2026-06-16-vee-onboarding-design.html
new file mode 100644
index 0000000..d169a06
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-vee-onboarding-design.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+Vee's First-Run Walkthrough — Design Spec
+
+
+
+
+
+
RepoLens · Design Spec · Draft for review
+
🔭 Vee's First-Run Walkthrough
+
Two Vee-guided tours: a first-run intro (seeded demo — Verdict → Blueprint → Corkboard in ~30s) and a milestone "power" tour that fires once you've scanned a few real repos, unlocking the multi-repo features. Zero backend, zero telemetry.
+
2026-06-16 · branch feat/vee-onboarding (off the canvas branch) · status: draft for review
+
+
1Idea in one paragraph
+
On a brand-new user's first open of the Library, Vee offers a guided walkthrough. Because the library is empty, the tour seeds one rich demo scan — a real, recognizable repo (honojs/hono) clearly badged DEMO — so Vee can spotlight real surfaces: the Library card, the Corkboard, search/Ask, then hand off to the repo's Verdict and Blueprint (which live on the separate output-tab page). When the tour ends (finish, skip, or first real scan) the demo is removed and onboardingSeen is set. Replayable forever from three entry points. Everything is client-side; nothing leaves the browser.
+
+
2Goals / Non-goals
+
+
+
Goals
+
+
A delightful, skippable first-run tour that demonstrates (not just describes) the verdict, the canvas Blueprint, and the Corkboard.
+
Reuse existing assets: the Vee mascot (mascot.js), the guideSeen first-run-flag pattern, the canvas tour's card/step look, and the demo fixtures we already built.
+
Zero backend, zero telemetry, MV3-safe, theme-token-based, reduced-motion-safe, all 13 themes.
+
Replayable from an empty-state chip, the ⌘K palette, and Settings.
+
+
+
+
Non-goals
+
+
No analytics/telemetry of any kind (no "tour completion" tracking off-device).
+
No new animation dependencies (reuse what's vendored + CSS).
+
Not a per-feature help system (that's LENS_GUIDE); this is a one-time orientation.
+
No changes to the scan/LLM pipeline.
+
+
+
+
+
3The two-stage flow (the key architectural fact)
+
+ The Verdict and Blueprint render in output-tab.html (opened as its own tab when you open a repo), while the Library + Corkboard are library.html. A single-page coachmark can't span both — so the tour is two chained stages, coordinated by an onboardingStage flag in chrome.storage.local.
+
+
+
Stage A — Library (library.js)
+
+
Vee greets, center: "Hi, I'm Vee — I read the source so you don't have to. Quick tour?"[Take the tour] [Skip]
+
Seed the demo (honojs/hono, badged DEMO) → it appears in the grid. Spotlight the demo card: "Every repo you scan lands here — fit, health, the works." (Vee idle)
+
Switch to the Corkboard view; spotlight it: "Your whole library becomes a red-string board of how things relate." (Vee thinking)
+
Spotlight Search + Ask: "Find anything, or ask your library in plain English."
+
Final Stage-A beat — spotlight the demo card / a "See the full read →" prompt: "Let's open one." → sets onboardingStage='verdict' and opens the demo's output-tab. Stage A ends.
+
+
+
Stage B — output-tab (output-tab.js), continues when onboardingStage==='verdict'
+
+
Spotlight the Verdict fit-chip + bottom line: "The straight answer — should you use it — before the README's pitch." (Vee strong)
+
Open the Canvas tab → Blueprint; spotlight it: "And here's how it's built, as a map you can drag — try the Guided Tour button." (Vee thinking)
+
Vee waves: "You're set — all in your browser, zero telemetry." → clear the demo, set onboardingSeen=true, clear onboardingStage.
+
+
Skip at any point → clear the demo, set onboardingSeen, clear stage. Reduced-motion → instant cuts, no eased scroll/fades.
+
+
3BThe milestone "power" tour (second tour)
+
Once the library has real depth, a second optional tour unlocks the multi-repo features — and it runs on the user's real repos, so no demo seeding/teardown is needed (single-stage, all on the Library page).
+
+ Trigger: on Library load, when real (non-__demo__) repo count ≥ 5, !milestoneTourSeen, and the intro is done (onboardingSeen) — a non-blocking Vee prompt (a toast, not a forced modal): "Wow, you've been busy — {N} repos scanned! Ready for the fun stuff? Want a tour of what your library can do now?"[Show me] [Maybe later] [Don't ask again]. Snooze: "Maybe later" re-offers at ≥ 10; "Don't ask again" — or completing the tour — sets milestoneTourSeen (the 5/10 threshold is a tunable constant).
+
+
Content (spotlights, on real data):
+
+
Ask bar — "Now ask across your whole library in plain English."
+
Corkboard — "See how your repos relate — run Alternatives / Synergies / Versus to draw more red string."
+
Select → Compare (side-by-side decision matrix) and Stack Studio (wire 2–6 into a system).
+
Radar / Auto-organize / Collections — "Organize a real library."
+
Discover — "Recommendations from what you've adopted."
+
+
Architecture: same coachmark.js engine; a second step list in onboarding.js; single-stage; no demo. Replayable from the same three entries (a "Power-features tour").
+
+
3CVee's voice & the anti-slop pass
+
Vee's narration is the soul of the tour — it must read like a person, not an AI. All tour copy lives in one editable deck in onboarding.js so the voice stays consistent and tunable, and every line passes a de-slop pass before it ships.
+
+ Voice (the bible): calm, candid, dry. The senior engineer who read the source so you don't have to — honest about risk, allergic to hype. Observation, not encouragement. Short declaratives; a little dry wit; never exclamatory cheerleading.
+
In-voice rewrites (replacing the slop-adjacent sketches above):
+
+
Intro greet → "I'm Vee. I read the source so you don't have to. Two minutes?"
+
Verdict → "The straight answer — should you use this — before the README starts selling."
+
Milestone → "{N} scans deep. Your library's big enough now for the tools that compare and connect them. Want a look?"
+
+
Process: write in-voice → run the de-slop ruleset/tool (the "stop-the-slop" checklist, sourced in the plan) over every line → a copy-reviewer subagent flags any remaining AI-tell before merge.
+
+
4Architecture / components (reuse-first)
+
+
File
Responsibility
State
+
coachmark.js
Reusable DOM coachmark: dimming veil, spotlight cut-out around a target element, a narration card anchored near the target (Vee inside via renderMascot/setMascotState), Back/Next/Skip + keyboard (←/→/Esc), scroll-into-view. startCoachmark({steps, onExit, onDone}). Shares the canvas tour's look + step shape, but DOM-targeted (card anchors to elements, not bottom-center — the one real difference that warrants a dedicated module).
NEW
+
onboarding.js
Intro step lists (Stage-A/B) and the milestone "power" tour list, flag logic, entry points: maybeStartLibraryOnboarding() (library init), maybeContinueOnboarding() (output-tab init), maybeOfferMilestoneTour() (library init, ≥5 real repos), startOnboarding(which) (replay either tour). before? = async hook a step runs before spotlighting (switch to Corkboard, open the Canvas tab).
NEW
+
demo-repo.js
The honojs/hono fixture: full analysis payload (verdict + deepDive.atoms/lineage) tagged __demo__. seedDemo() (only if library empty & no demo exists), clearDemo(), isDemo(). DEMO badge on the card.
NEW
+
mascot.js
Reuse renderMascot, setMascotState. Tour shows Vee regardless of the verdict-header mascotEnabled toggle (Vee is the guide here).
REUSE
+
library.js
After init+render, call maybeStartLibraryOnboarding() (gated: !onboardingSeen && empty → run; !onboardingSeen && non-empty → set seen & skip). Add the empty-state "👋 Take the tour" chip + a ⌘K command. Exclude __demo__ from stats.
MODIFY
+
output-tab.js
On init, call maybeContinueOnboarding() (runs Stage B when onboardingStage==='verdict').
MODIFY
+
options.{js,html}
A "Replay onboarding" button → clears the flags & starts the tour.
MODIFY
+
settings-backup.js
Add onboardingSeenand milestoneTourSeen to SAFE_SETTING_KEYS.
MODIFY
+
backup.js/store.js
Exclude __demo__ repos from export/backup + grid stats, so the demo never pollutes a real library/share.
onboarding.test.js (step lists, flag/stage transitions), demo-repo.test.js (fixture validity + validateScene on its scene + isDemo), coachmark pure geometry. DOM glue verified live.
NEW
+
+
+
5Demo fixture (honojs/hono)
+
A crafted, honest sample with a clear DEMO marker. Shape mirrors a real scan payload (repoId, fit/verdict fields, health, pros/cons, eli5, …) plus deepDive.atoms (≈6: router / context / middleware / handler / adapter / helpers) + lineage.links so the Blueprint renders, plus a corkboard scene. Marked __demo__: true. Wording is sample-flavored ("a representative read") to avoid mis-stating the real project. Removed on teardown.
+
+
6Trigger · teardown · replay · gating
+
+
Trigger: first library.html open with !onboardingSeen && empty library. Non-empty + unseen → silently set onboardingSeen (returning user).
+
Teardown: finish/skip → clearDemo() + onboardingSeen=true + clear onboardingStage. Defensive: on any Library load, if seen, sweep stray __demo__. Also clear on the first real scan.
+
Replay: empty-state chip · ⌘K command · Settings button — all call startOnboarding() (re-seeds the demo, runs Stage A).
+
a11y: veil + card focus-managed; Esc skips; Back/Next are real buttons; targets scrolled into view; all motion behind prefers-reduced-motion.
+
Security: narration + labels via escapeHtml/textContent; no inline handlers (MV3 CSP); no network.
+
+
+
7Testing
+
Pure logic is unit-tested (Vitest): step-list builders (non-empty, sequential, valid target selectors), the flag/stage state machine, the demo fixture (valid analysis shape; its scene passes validateScene; isDemo true/false), and any extracted coachmark geometry (target rect → card placement). The DOM coachmark glue (veil/spotlight/card mount, cross-page handoff) is verified live in the extension, per the repo's no-DOM-test convention — plus a standalone onboarding-demo.html harness for a screenshot pass.
+
+
8Risks & mitigations
+
+
Risk
Mitigation
+
Cross-page handoff fragility (Stage A → output-tab Stage B)
One onboardingStage flag; each page guards "does my target exist?" and exits gracefully if not.
+
Demo pollutes the real library/backup
__demo__ tag excluded from export/backup/stats + aggressive teardown sweep.
+
Targets that don't exist (e.g. Corkboard button hidden in a narrow window)
Each step checks its target; skip the step (and its app-action) if absent.
+
Tour fires for returning users
Gated on empty library + onboardingSeen.
+
Over-staying its welcome
≤8 beats, Skip always visible, never re-fires once seen.
+
+
+
9Open questions
+
+
Should Stage B (Verdict/Blueprint in output-tab) be mandatory or optional? (Provisional: chained, but Stage A's last beat is a clear opt-in "See the full read →", so a user who stops at Stage A still got value.)
+
Seed the demo into the persistent repos store (tagged, excluded) vs in-memory/session? (Provisional: persistent + __demo__-tagged + excluded from export/stats, since both pages must read it; teardown is robust.)
+
+
+
+
+
+
diff --git a/docs/superpowers/specs/2026-06-16-vee-onboarding-design.md b/docs/superpowers/specs/2026-06-16-vee-onboarding-design.md
new file mode 100644
index 0000000..a5d281a
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-vee-onboarding-design.md
@@ -0,0 +1,111 @@
+# Vee's First-Run Walkthrough — Design Spec
+
+> Status: **Draft for review** · 2026-06-16 · Branch: continue on `feat/canvas-engine`.
+> Goal: make RepoLens *welcoming* and obvious by having the **Vee mascot guide two tours** — a **first-run intro** (seeded demo, shows Verdict → Blueprint → Corkboard in ~30s) and a **milestone "power" tour** that fires once the user has scanned a few real repos, unlocking the multi-repo features that only become useful with a populated library. Zero backend, zero telemetry.
+
+## 1. Idea in one paragraph
+
+On a brand-new user's **first open of the Library**, Vee offers a guided walkthrough. Because the library is empty, the tour **seeds one rich demo scan** (a real, recognizable repo — `honojs/hono` — clearly badged **DEMO**) so Vee can spotlight *real* surfaces: the Library card, the **Corkboard**, search/Ask, then hand off to the repo's **Verdict** and **Blueprint** (which live on the separate output-tab page). When the tour ends (finish, skip, or first real scan), the demo is removed and `onboardingSeen` is set. It's replayable forever from three entry points. Everything is client-side; nothing leaves the browser.
+
+## 2. Goals / Non-goals
+
+**Goals**
+- A delightful, skippable first-run tour that *demonstrates* (not just describes) the verdict, the canvas Blueprint, and the Corkboard.
+- Reuse existing assets: the **Vee mascot** (`mascot.js`), the **`guideSeen` first-run-flag pattern**, the canvas tour's **card/step look**, and the **demo fixtures** we already built.
+- Zero backend, zero telemetry, MV3-safe, theme-token-based, reduced-motion-safe, all 13 themes.
+- Replayable from an empty-state chip, the ⌘K palette, and Settings.
+
+**Non-goals**
+- No analytics/telemetry of any kind (no "tour completion" tracking off-device).
+- No new animation dependencies (reuse what's vendored + CSS).
+- Not a per-feature help system (that's `LENS_GUIDE`); this is a one-time orientation.
+- No changes to the scan/LLM pipeline.
+
+## 3. The two-stage flow (the key architectural fact)
+
+The Verdict and Blueprint render in **`output-tab.html`** (opened as its own tab when you open a repo), while the Library + Corkboard are **`library.html`**. A single-page coachmark can't span both, so the tour is **two chained stages**, coordinated by an `onboardingStage` flag in `chrome.storage.local`:
+
+**Stage A — Library (`library.js`):**
+1. Vee greets, center: *"Hi, I'm Vee — I read the source so you don't have to. Quick tour?"* `[Take the tour] [Skip]`
+2. **Seed the demo** (`honojs/hono`, badged DEMO) → it appears in the grid. Spotlight the demo **card**: *"Every repo you scan lands here — fit, health, the works."* (Vee `idle`.)
+3. Switch to the **Corkboard** view; spotlight it: *"Your whole library becomes a red-string board of how things relate."* (Vee `thinking`.)
+4. Spotlight **Search + Ask**: *"Find anything, or ask your library in plain English."*
+5. Final Stage-A beat — spotlight the demo card / a "See the full read →" prompt: *"Let's open one."* → sets `onboardingStage='verdict'` and **opens the demo's output-tab** (`output-tab.html?key=…` for the demo). Stage A ends.
+
+**Stage B — output-tab (`output-tab.js`), continues automatically when `onboardingStage==='verdict'`:**
+6. Spotlight the **Verdict** fit-chip + bottom line: *"The straight answer — should you use it — before the README's pitch."* (Vee `strong`.)
+7. Open the **Canvas tab → Blueprint**; spotlight it: *"And here's how it's built, as a map you can drag — try the Guided Tour button."* (Vee `thinking`.)
+8. Vee waves: *"You're set — all in your browser, zero telemetry."* → **clear the demo**, set `onboardingSeen=true`, clear `onboardingStage`.
+
+If the user skips at any point: clear the demo, set `onboardingSeen`, clear stage. Reduced-motion → instant cuts, no eased scroll/fades.
+
+## 3B. The milestone "power" tour (second tour)
+
+Once the library has real depth, a second optional tour unlocks the multi-repo features — and it runs on the user's **real** repos, so **no demo seeding/teardown** is needed (simpler than the intro: single-stage, all on the Library page).
+
+- **Trigger:** on Library load, when real (non-`__demo__`) repo count **≥ 5**, `!milestoneTourSeen`, and the intro is done (`onboardingSeen`). Shows a **non-blocking Vee prompt** (a toast/coachmark, not a forced modal): *"Wow, you've been busy — {N} repos scanned! Ready for the fun stuff? Want a tour of what your library can do now?"* `[Show me] [Maybe later] [Don't ask again]`.
+- **Snooze:** "Maybe later" re-offers at **≥ 10**; "Don't ask again" — or completing the tour — sets `milestoneTourSeen`. (Threshold 5/10 is a constant, easy to tune.)
+- **Content (spotlights, on real data):**
+ 1. **Ask** bar — *"Now ask across your whole library in plain English."*
+ 2. **Corkboard** — *"See how your repos relate — run Alternatives / Synergies / Versus to draw more red string."*
+ 3. **Select → Compare** (side-by-side decision matrix) and **Stack Studio** (wire 2–6 into a system).
+ 4. **Radar / Auto-organize / Collections** — *"Organize a real library."*
+ 5. **Discover** — *"Recommendations from what you've adopted."*
+- **Architecture:** same `coachmark.js` engine; a **second step list** in `onboarding.js`; single-stage; no demo. Replayable from the same three entries (offered as a "Power-features tour").
+
+## 3C. Vee's voice & the anti-slop pass
+
+Vee's narration is the soul of the tour — it must read like a person, not an AI. **All tour copy lives in one editable deck in `onboarding.js`** so the voice stays consistent and tunable, and every line passes a **de-slop pass** before it ships.
+
+- **Voice (the bible):** calm, candid, dry. The senior engineer who read the source so you don't have to — honest about risk, allergic to hype. Observation, not encouragement. Short declaratives; a little dry wit; never exclamatory cheerleading.
+- **Banned slop:** hype verbs (unlock, supercharge, elevate, seamless, effortless, leverage, harness, "dive in", game-changer), manufactured excitement ("Wow!", "Get ready to…", "the fun stuff"), filler ("in today's world", "simply", "just", "that's it!"), exclamation spam, the em-dash-rule-of-three cadence, generic praise.
+- **In-voice rewrites** (replacing the slop-adjacent sketches above):
+ - Intro greet → *"I'm Vee. I read the source so you don't have to. Two minutes?"*
+ - Verdict → *"The straight answer — should you use this — before the README starts selling."*
+ - Milestone → *"{N} scans deep. Your library's big enough now for the tools that compare and connect them. Want a look?"*
+- **Process:** write in-voice → run the **de-slop ruleset/tool** (the "stop-the-slop" checklist, sourced in the plan) over every line → a **copy-reviewer subagent** flags any remaining AI-tell before merge.
+
+## 4. Architecture / components (reuse-first)
+
+| File | Responsibility | New/Modify |
+|---|---|---|
+| `coachmark.js` | NEW. Reusable DOM coachmark: a dimming **veil**, a **spotlight** cut-out around a target element (from its bounding rect), a **narration card anchored near the target** (with Vee inside via `renderMascot`/`setMascotState`), Back/Next/Skip + keyboard (←/→/Esc), scroll-into-view. `startCoachmark({steps, onExit, onDone})` → `{next,prev,exit}`. Shares the visual language + step shape with the canvas `tour-runner`, but DOM-targeted (card anchors to elements, not bottom-center — the one real difference that warrants a dedicated module). | New |
+| `onboarding.js` | NEW. Defines the intro step lists (Stage-A/Stage-B) **and the milestone "power" tour step list**, the flag logic, and the entry points: `maybeStartLibraryOnboarding()` (library init), `maybeContinueOnboarding()` (output-tab init), `maybeOfferMilestoneTour()` (library init, ≥5 real repos), and `startOnboarding(which)` (manual replay of either tour). `before?` is an async hook a step runs before spotlighting (e.g. switch to Corkboard, open the Canvas tab). | New |
+| `demo-repo.js` | NEW. The `honojs/hono` fixture: a full analysis payload (verdict fields + `deepDive.atoms`/`lineage`) tagged `__demo__`. `seedDemo()` (write to the repos store + build its scene, only if the library is empty and no `__demo__` exists), `clearDemo()` (remove the repo + scene + any session entry), `isDemo(repo)`. The DEMO badge renders on the card. | New |
+| `mascot.js` | Reuse `renderMascot`, `setMascotState`. No change (the tour shows Vee regardless of the verdict-header `mascotEnabled` toggle — Vee is the guide here). | Reuse |
+| `library.js` | After init+render, call `maybeStartLibraryOnboarding()` (gated: `!onboardingSeen` && library empty → run; `!onboardingSeen` && non-empty → set `onboardingSeen` and skip, user isn't new). Add the **empty-state "👋 Take the tour" chip** and a ⌘K **"Take the tour"** command → `startOnboarding()`. Exclude `__demo__` from stats counts. | Modify |
+| `output-tab.js` | On init, call `maybeContinueOnboarding()` (runs Stage B when `onboardingStage==='verdict'`). | Modify |
+| `options.js` / `options.html` | A **"Replay onboarding"** button → clears `onboardingSeen` (+ `onboardingStage`) and opens the Library / starts the tour. | Modify |
+| `settings-backup.js` | Add `onboardingSeen` **and `milestoneTourSeen`** to `SAFE_SETTING_KEYS` (so "I've seen it" travels with settings export). | Modify |
+| `backup.js` / `store.js` | Exclude `__demo__`-tagged repos from `exportStores`/`buildBackup` and from the grid stats, so the demo never pollutes a real library/share. | Modify |
+| `themes.css` | Coachmark styles: `.cm-veil` (token-dimmed backdrop), `.cm-spotlight` (highlight ring around target), `.cm-card` (narration card + Vee slot), `.cm-badge-demo`. Token-based, reduced-motion-guarded. | Modify |
+| `tests/*` | `onboarding.test.js` (step lists non-empty, ordered, target selectors are strings; flag/stage transitions), `demo-repo.test.js` (fixture validity: valid analysis shape + `validateScene` on its scene; `isDemo`), `coachmark` pure helpers (target-rect → card-position math, if extracted). DOM glue verified live. | New |
+
+## 5. Demo fixture (`honojs/hono`)
+
+A crafted, **honest** sample with a clear DEMO marker. Shape mirrors a real scan payload (`repoId`, `fit`/verdict fields, `health`, `pros`/`cons`, `eli5`, …) plus `deepDive.atoms` (≈6: router/context/middleware/handler/adapter/helpers) + `lineage.links` so the Blueprint renders, plus a corkboard scene. Marked `__demo__: true`. Wording is sample-flavored ("a representative read") to avoid mis-stating the real project. Removed on teardown.
+
+## 6. Trigger, teardown, replay, gating
+
+- **Trigger:** first `library.html` open with `!onboardingSeen` && empty library. Non-empty + unseen → silently set `onboardingSeen` (returning user).
+- **Teardown:** on finish/skip → `clearDemo()` + `onboardingSeen=true` + clear `onboardingStage`. Defensive: on any Library load, if `onboardingSeen` is true, sweep any stray `__demo__`. Also clear demo on the user's first **real** scan.
+- **Replay:** empty-state chip · ⌘K command · Settings button — all call `startOnboarding()` (re-seeds demo, runs Stage A). Replays don't require clearing `onboardingSeen`.
+- **a11y:** veil + card are focus-managed; Esc skips; Back/Next are real buttons; targets are scrolled into view; all motion behind `prefers-reduced-motion`.
+- **Security:** all narration + labels via `escapeHtml`/`textContent`; no inline handlers (MV3 CSP); no network.
+
+## 7. Testing
+
+Pure logic is unit-tested (Vitest): the step-list builders (non-empty, sequential, valid target selectors), the flag/stage state machine, the demo fixture (valid analysis shape; its scene passes `validateScene`; `isDemo` true/false), and any extracted coachmark geometry (target rect → card placement). The DOM coachmark glue (veil/spotlight/card mount, cross-page handoff) is verified **live** in the extension, per the repo's no-DOM-test convention — plus a standalone `onboarding-demo.html` harness for a screenshot pass.
+
+## 8. Risks & mitigations
+
+- **Cross-page handoff fragility** (Stage A → output-tab Stage B) → coordinate via one `onboardingStage` flag; each page guards "does my target exist?" and exits gracefully if not.
+- **Demo pollutes the real library/backup** → `__demo__` tag excluded from export/backup/stats + aggressive teardown sweep.
+- **Targets that don't exist** (e.g. Corkboard button hidden in a narrow window) → each step checks its target; skip the step (and its app-action) if absent.
+- **Tour fires for returning users** → gated on empty library + `onboardingSeen`.
+- **Over-staying its welcome** → ≤8 beats, Skip always visible, never re-fires once seen.
+
+## 9. Open questions
+
+1. Should Stage B (Verdict/Blueprint in output-tab) be **mandatory** or optional (Stage A could end with "open it yourself")? (Provisional: **chained, but Stage A's last beat is a clear opt-in "See the full read →"** so a user who stops at Stage A still got value.)
+2. Seed the demo into the **persistent repos store** (tagged, excluded) vs an **in-memory/session** demo. (Provisional: **persistent + `__demo__`-tagged + excluded from export/stats**, since both pages must read it; teardown is robust.)
diff --git a/library-data.js b/library-data.js
index 840f90a..009fada 100644
--- a/library-data.js
+++ b/library-data.js
@@ -25,6 +25,9 @@ export function libraryRow(payload) {
return {
repoId,
name: repoId.split('/').pop() || repoId,
+ // Carry the demo flag through so isDemo(row) stays true after a reload turns the
+ // seeded demo payload back into a row (used for stats exclusion + teardown).
+ __demo__: p.__demo__ === true,
platform: p.platform || '',
fit,
fitDelta,
diff --git a/library.html b/library.html
index ec03470..ce71db4 100644
--- a/library.html
+++ b/library.html
@@ -221,6 +221,10 @@
.lib-empty { text-align: center; padding: 80px 20px; color: var(--sub); }
.lib-empty h2 { font-size: 17px; color: var(--text); margin: 0 0 8px; }
+ .lib-tour-chip { margin-top: 18px; padding: 8px 16px; border-radius: 999px; cursor: pointer;
+ border: 1px solid var(--accent); background: transparent; color: var(--accent);
+ font-size: 13px; font-weight: 600; transition: background var(--dur-fast, 120ms) var(--ease-out, ease), color var(--dur-fast, 120ms) var(--ease-out, ease); }
+ .lib-tour-chip:hover, .lib-tour-chip:focus-visible { background: var(--accent); color: var(--bg, #fff); }
.lib-empty code { font: 600 12px var(--mono); background: var(--panel); border: 1px solid var(--border); padding: 2px 7px; border-radius: 5px; color: var(--cyan); }
/* Empty-state glyph — a magnifying glass that draws itself in. */
.lib-empty-glyph { display: block; margin: 0 auto 18px; color: var(--accent); }
diff --git a/library.js b/library.js
index 931e720..21adff5 100644
--- a/library.js
+++ b/library.js
@@ -3,7 +3,10 @@
// show), and each card manages its repo: click to reopen the saved analysis, hover for
// re-scan / source / remove actions.
-import { scrollPoints, deleteRepo, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots, getLibraryGraph, getScene, saveScene } from './store.js';
+import { scrollPoints, deleteRepo, deleteSnapshots, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots, getLibraryGraph, getScene, saveScene, saveRepo, deleteScene } from './store.js';
+import { introStageA, shouldOfferMilestone, milestoneSteps, COPY } from './onboarding.js';
+import { startCoachmark } from './coachmark.js';
+import { DEMO_REPO, demoScene, isDemo } from './demo-repo.js';
import { buildLibraryScene } from './library-scene.js';
import { layoutCorkboard } from './canvas-layout.js';
import { mountCanvas } from './canvas-engine.js';
@@ -188,6 +191,7 @@ function card(r, i = 0) {
${hilite(r.name, hq)}
+ ${isDemo(r) ? 'DEMO' : ''}
${owner ? `${hilite(owner, hq)}` : ''}
${platformBadge}
${esc(r.fit.label)}
@@ -839,11 +843,13 @@ async function bulkDecide(decision) {
function renderStats() {
const host = document.getElementById('stats');
if (!host) return;
- const s = libraryStats(allRows);
+ // The seeded demo repo is a tour prop, not part of the user's real library.
+ const realRows = allRows.filter((r) => !isDemo(r));
+ const s = libraryStats(realRows);
if (!s.total) { host.classList.add('hidden'); host.innerHTML = ''; return; }
host.classList.remove('hidden');
const pill = (level, n) => (n ? html`${n} ${level}` : '');
- const staleCount = allRows.filter((r) => {
+ const staleCount = realRows.filter((r) => {
if (!r.savedAt) return false;
return (Date.now() - new Date(r.savedAt).getTime()) > 30 * 86_400_000;
}).length;
@@ -955,7 +961,7 @@ async function exportLibrary() {
try {
setStatus('Preparing backup…');
const [stores, cached] = await Promise.all([exportStores(), listCached().catch(() => [])]);
- const backup = buildBackup({ repos: stores.repos, nodes: stores.nodes, edges: stores.edges, cache: cached, collections: stores.collections, decisions: stores.decisions, snapshots: stores.snapshots });
+ const backup = buildBackup({ repos: stores.repos, nodes: stores.nodes, edges: stores.edges, cache: cached, collections: stores.collections, decisions: stores.decisions, snapshots: stores.snapshots, scenes: stores.scenes });
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -1340,7 +1346,9 @@ async function renderCorkboard() {
const g = ev.target.closest('[data-node]');
if (!g) return;
const id = g.dataset.node;
- if (id && id.includes('/')) openRow(id); // repo node ids are "owner/name"
+ // Open only true repo nodes. Detect by the engine's kind class — idea nodes use
+ // their title as id and titles often contain '/', so id-shape was a false positive.
+ if (id && g.classList.contains('rl-kind-repo')) openRow(id);
});
}
@@ -2452,6 +2460,7 @@ function initLibraryPalette() {
} },
{ name: 'Select mode', description: 'Select repos for bulk actions', action: () => setSelectionMode(!selectionMode) },
{ section: 'Vee', name: '✦ Auto-decide all undecided (Vee)', description: 'Apply Vee\'s fit-based suggestion to every undecided rated repo', action: () => applyVeeSuggestions() },
+ { name: 'Take the tour', description: 'Replay the Vee walkthrough', action: () => startIntro() },
{ name: 'Open Settings', action: () => chrome.runtime.openOptionsPage() },
];
@@ -2477,6 +2486,121 @@ function initLibraryPalette() {
});
}
+// ─── Vee onboarding: first-run intro + milestone offer ────────────────────────
+// The tour needs the demo repo to exist as a real card, so we seed it, mark the
+// run pending, and reload — init then loads the demo as an ordinary row and
+// checkOnboarding() resumes the coachmark. (See the empty-state branch in init.)
+const INTRO_PENDING = 'rl_intro_pending';
+
+async function seedDemo() {
+ // Never clobber a real scan that happens to share the demo's id (honojs/hono).
+ if (allRows.some((r) => r.repoId === DEMO_REPO.repoId && !isDemo(r))) return false;
+ try {
+ await saveRepo(DEMO_REPO);
+ await saveScene(demoScene());
+ return true;
+ } catch {
+ return false; // store failure → caller shows the normal empty state instead of a dead blank page
+ }
+}
+
+// Explicit replay (empty-state chip / palette): seed + reload into the tour.
+// seedDemo() no-ops if a real honojs/hono exists or the store fails; either way
+// we reload — the tour runs against whatever grid is present.
+async function startIntro() {
+ if (sessionStorage.getItem(INTRO_PENDING)) return; // a run is already queued
+ await seedDemo();
+ sessionStorage.setItem(INTRO_PENDING, '1');
+ location.reload();
+}
+
+function runIntroTour() {
+ startCoachmark({
+ steps: introStageA(),
+ copy: COPY,
+ onExit: async () => {
+ // Stage B picks up in the output tab (Task 7) — hand it the demo + a marker.
+ try { await chrome.storage.local.set({ onboardingSeen: true, onboardingStage: 'verdict' }); }
+ catch { /* storage best-effort */ }
+ // openCachedAnalysis writes the demo payload into chrome.storage.session and opens
+ // output-tab.html?key=… (the path Stage B reads). openRow would miss — the demo lives
+ // in the IndexedDB repos/scenes stores, not the listCached() cache that backs cacheByRepo.
+ await openCachedAnalysis(DEMO_REPO);
+ },
+ });
+}
+
+// A self-contained 3-way prompt (Show me / Maybe later / Don't ask) for the
+// milestone tour. Reuses the coachmark veil/card classes; removes itself on choice.
+function offerMilestone(realCount) {
+ const veil = document.createElement('div'); veil.className = 'cm-veil';
+ const cardEl = document.createElement('div'); cardEl.className = 'cm-card';
+ cardEl.setAttribute('role', 'dialog');
+ cardEl.setAttribute('aria-modal', 'true');
+ cardEl.setAttribute('aria-label', 'Take the milestone tour?');
+ const textEl = document.createElement('p'); textEl.className = 'cm-text';
+ textEl.textContent = (COPY.milestoneOffer || '').replace('{N}', String(realCount));
+ const ctl = document.createElement('div'); ctl.className = 'cm-ctl';
+ const show = document.createElement('button'); show.textContent = 'Show me';
+ const later = document.createElement('button'); later.textContent = 'Maybe later';
+ const never = document.createElement('button'); never.textContent = "Don't ask"; never.className = 'cm-skip';
+ ctl.append(never, later, show);
+ cardEl.append(textEl, ctl);
+ veil.append(cardEl);
+ // Remember where focus was so we can hand it back on close (a11y: no focus loss).
+ const prevFocus = document.activeElement;
+ const focusables = [never, later, show]; // DOM/tab order
+ const persist = (patch) => chrome.storage.local.set(patch).catch(() => {});
+ const onKeydown = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); later.click(); return; }
+ if (e.key !== 'Tab') return;
+ // Trap Tab/Shift+Tab so focus cycles the three buttons (wrap first↔last).
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ };
+ const close = () => {
+ document.removeEventListener('keydown', onKeydown);
+ veil.remove();
+ if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus(); // restore focus
+ };
+ show.onclick = () => { close(); persist({ milestoneTourSeen: true }); startCoachmark({ steps: milestoneSteps(), copy: COPY }); };
+ later.onclick = () => { close(); persist({ milestoneSnoozeAt10: true }); }; // Escape maps here (snooze)
+ never.onclick = () => { close(); persist({ milestoneTourSeen: true }); };
+ document.addEventListener('keydown', onKeydown);
+ document.body.append(veil);
+ show.focus(); // move focus to the primary action on mount
+}
+
+// Run after the grid is rendered (init's non-empty path). Resumes a pending intro
+// first, otherwise gates the milestone offer on real (non-demo) repo count.
+async function checkOnboarding() {
+ if (sessionStorage.getItem(INTRO_PENDING)) {
+ sessionStorage.removeItem(INTRO_PENDING);
+ runIntroTour();
+ return;
+ }
+ let prefs = {};
+ try { prefs = await chrome.storage.local.get(['onboardingSeen', 'milestoneTourSeen', 'milestoneSnoozeAt10']); }
+ catch { return; }
+ const real = allRows.filter((r) => !isDemo(r));
+ // A returning user who never saw the intro: mark it seen silently (no demo seed).
+ if (!prefs.onboardingSeen) {
+ try { await chrome.storage.local.set({ onboardingSeen: true }); } catch { /* best-effort */ }
+ }
+ // Snooze: "Maybe later" defers the offer until the library reaches ≥10 real repos.
+ let snoozed = !!prefs.milestoneSnoozeAt10;
+ if (snoozed && real.length >= 10) {
+ snoozed = false;
+ try { await chrome.storage.local.set({ milestoneSnoozeAt10: false }); } catch { /* best-effort */ }
+ }
+ if (snoozed) return;
+ if (shouldOfferMilestone({ realCount: real.length, milestoneTourSeen: prefs.milestoneTourSeen, onboardingSeen: true })) {
+ offerMilestone(real.length);
+ }
+}
+
async function init() {
document.getElementById('settings')?.addEventListener('click', () => chrome.runtime.openOptionsPage());
document.getElementById('lib-btn-grid')?.addEventListener('click', showGridView);
@@ -2552,14 +2676,37 @@ async function init() {
}
if (!allRows.length) {
+ // Brand-new user, no run queued: seed the demo and reload so the demo loads as
+ // a normal card (init then skips this branch and checkOnboarding resumes the tour).
+ const { onboardingSeen } = await chrome.storage.local.get('onboardingSeen').catch(() => ({}));
+ if (!onboardingSeen && !sessionStorage.getItem(INTRO_PENDING)) {
+ // Only queue the tour if the demo actually seeded; otherwise (real honojs/hono
+ // present or store failed) fall through to the normal empty state below.
+ if (await seedDemo()) {
+ sessionStorage.setItem(INTRO_PENDING, '1');
+ location.reload();
+ return;
+ }
+ }
// veeSvg() and EMPTY_GLYPH are static, code-owned strings — safe for the
// STATIC-only showEmpty (no user data ever reaches innerHTML here).
const vee = mascotOn ? `
${veeSvg()}
` : EMPTY_GLYPH;
showEmpty(
- `${vee}
No repos yet
Open any GitHub / GitLab / npm / PyPI page and click the RepoLens icon — every scan lands here automatically.
`
+ `${vee}
No repos yet
Open any GitHub / GitLab / npm / PyPI page and click the RepoLens icon — every scan lands here automatically.
`
);
+ document.getElementById('lib-tour-chip')?.addEventListener('click', startIntro);
return;
}
+ // Returning user: sweep any stray demo rows left behind by an interrupted tour.
+ if (allRows.some((r) => isDemo(r))) {
+ const { onboardingSeen } = await chrome.storage.local.get('onboardingSeen').catch(() => ({}));
+ if (onboardingSeen && !sessionStorage.getItem(INTRO_PENDING)) {
+ for (const r of allRows) if (isDemo(r)) { await deleteRepo(r.repoId); await deleteSnapshots(r.repoId); }
+ try { await deleteScene(demoScene().id); } catch { /* scene may not exist */ }
+ allRows = allRows.filter((r) => !isDemo(r));
+ if (!allRows.length) { location.reload(); return; } // back to the clean empty state
+ }
+ }
renderCaps();
renderCollections();
// Pre-fill search from URL hash (e.g., library.html#search=owner/repo)
@@ -2672,6 +2819,9 @@ async function init() {
render();
});
}
+
+ // Vee onboarding: resume a pending intro, or offer the milestone tour.
+ await checkOnboarding();
}
init();
diff --git a/onboarding-copy.js b/onboarding-copy.js
new file mode 100644
index 0000000..5aae46b
--- /dev/null
+++ b/onboarding-copy.js
@@ -0,0 +1,19 @@
+// onboarding-copy.js
+// Vee's narration, in one place. Calm, candid, dry: name a specific thing, say what
+// it means in one plain clause, stop. No hype, no em dashes, no exclamation spam.
+export const COPY = {
+ introGreet: "I'm Vee. I read the source so you don't have to. Two minutes?",
+ introCard: 'Every repo you scan lands here, with its fit score, health, and your notes.',
+ introCorkboard: 'The same library as a board. A line means two repos are related.',
+ introSearch: 'Search by name, or ask the library a question in plain words.',
+ introOpen: 'One click opens the full read on a repo.',
+ verdict: "The honest answer on whether to use it, before the README's pitch.",
+ blueprint: "How it's built, as a map you can drag. The tour button steps through it.",
+ farewell: 'That covers it. Everything stays in your browser, nothing phones home.',
+ milestoneOffer: "{N} scans in. That's enough to compare and connect repos. Want to see those tools?",
+ milestoneAsk: "Ask across everything you've scanned, in plain words.",
+ milestoneCorkboard: 'Run Alternatives or Synergies to draw the lines between your repos.',
+ milestoneCompare: 'Select a few, then compare them side by side or wire them into a stack.',
+ milestoneOrganize: 'For a library this size: a radar view, auto-organize, and collections.',
+ milestoneDiscover: "Find new repos from the ones you've adopted.",
+};
diff --git a/onboarding-demo.html b/onboarding-demo.html
new file mode 100644
index 0000000..c55298d
--- /dev/null
+++ b/onboarding-demo.html
@@ -0,0 +1,92 @@
+
+
+
+
+ Onboarding demo — RepoLens Vee coachmark
+
+
+
+
+
RepoLens — Vee onboarding coachmark (demo)
+
Fake target elements let the spotlight land on something real. No extension, no API.