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:

+ +

File structure

+
FileResponsibilityNew/Modify
onboarding-copy.jsThe single copy deck: every Vee line, in-voice + de-slopped. Pure data.New
demo-repo.jshonojs/hono fixture (analysis + atoms/lineage + scene) tagged __demo__; seedDemo/clearDemo/isDemo.New
onboarding.jsStep lists (intro Stage-A/B + milestone) built from the deck; flag/stage state machine; triggers maybeStartLibraryOnboarding/maybeContinueOnboarding/maybeOfferMilestoneTour/startOnboarding.New
coachmark.jsDOM 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.jsWire intro + milestone triggers after init; empty-state chip; ⌘K command; exclude __demo__ from stats.Modify
output-tab.jsmaybeContinueOnboarding() on init (Stage B).Modify
options.{js,html}"Replay onboarding" button.Modify
settings-backup.jsAllowlist onboardingSeen, milestoneTourSeen.Modify
store.js/backup.jsExclude __demo__ from stats + export/backup.Modify
CHANGELOG.md/README.mdNote the walkthrough.Modify
+

Shared shapes: copy line = string; step = { target:string|null, copyKey:string, mascotState:string, before?:()=>Promise<void> }; 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.

+ +
// 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);
+  });
+});
+ + +
// 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__));
+}
+ +
+

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.

+ +
// 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); }
+  });
+});
+ + +
// 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.",
+};
+ +
+

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.

+ +
// 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);
+  });
+});
+ + +
// 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.)

+ +
+

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.

+ +
// 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));
+  });
+});
+ + +
// 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 };
+}
+ +
+

Task 5: Coachmark styles (themes.css)

+

Files: Modify themes.css (append, token-based, reduced-motion-guarded).

+ +
/* ── 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; } }
+ +
+

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.

+ + +
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:

+
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().
+ +
+

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).

+ +
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:

+
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.
+ +
+

Task 8: Flags allowlist, replay button, demo exclusions

+

Files: Modify settings-backup.js, options.{js,html}, store.js, backup.js.

+ +
+

Task 9: De-slop voice review + docs + demo harness

+

Files: onboarding-copy.js (review only), CHANGELOG.md, README.md, onboarding-demo.html (new, standalone).

+ +
+

Final verification

+ +

Out of scope

+ +
\ 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)
+
    +
  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. +
  3. 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)
  4. +
  5. Switch to the Corkboard view; spotlight it: "Your whole library becomes a red-string board of how things relate." (Vee thinking)
  6. +
  7. Spotlight Search + Ask: "Find anything, or ask your library in plain English."
  8. +
  9. 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.
  10. +
+ +
Stage B — output-tab (output-tab.js), continues when onboardingStage==='verdict'
+
    +
  1. Spotlight the Verdict fit-chip + bottom line: "The straight answer — should you use it — before the README's pitch." (Vee strong)
  2. +
  3. 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)
  4. +
  5. Vee waves: "You're set — all in your browser, zero telemetry."clear the demo, set onboardingSeen=true, clear onboardingStage.
  6. +
+

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):

+
    +
  1. Ask bar — "Now ask across your whole library in plain English."
  2. +
  3. Corkboard"See how your repos relate — run Alternatives / Synergies / Versus to draw more red string."
  4. +
  5. Select → Compare (side-by-side decision matrix) and Stack Studio (wire 2–6 into a system).
  6. +
  7. Radar / Auto-organize / Collections"Organize a real library."
  8. +
  9. Discover"Recommendations from what you've adopted."
  10. +
+

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. +
+

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.

+ +

4Architecture / components (reuse-first)

+ + + + + + + + + + + + + +
FileResponsibilityState
coachmark.jsReusable 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.jsIntro 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.jsThe 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.jsReuse renderMascot, setMascotState. Tour shows Vee regardless of the verdict-header mascotEnabled toggle (Vee is the guide here).REUSE
library.jsAfter 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.jsOn 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.jsAdd onboardingSeen and milestoneTourSeen to SAFE_SETTING_KEYS.MODIFY
backup.js/store.jsExclude __demo__ repos from export/backup + grid stats, so the demo never pollutes a real library/share.MODIFY
themes.cssCoachmark styles: .cm-veil, .cm-spotlight, .cm-card, .cm-badge-demo — token-based, reduced-motion-guarded.MODIFY
tests/*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

+ + + + + + + +
RiskMitigation
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 usersGated on empty library + onboardingSeen.
Over-staying its welcome≤8 beats, Skip always visible, never re-fires once seen.
+ +

9Open questions

+
    +
  1. 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.)
  2. +
  3. 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.)
  4. +
+ + +
+ + 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 ? `` : 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.

+
+
+ Library · demo + + + +
+
+ +
+ + + + + +
+ +
+ +
+ +
+
+ evanw/esbuild + strong +

Zero-config JS bundler, extremely fast.

+
+
+ vitejs/vite + solid +

Dev server + bundler using Rollup.

+
+
+ webpack/webpack + care +

Mature but heavy; config overhead.

+
+
+
+
+ + + + diff --git a/onboarding.js b/onboarding.js new file mode 100644 index 0000000..0314361 --- /dev/null +++ b/onboarding.js @@ -0,0 +1,41 @@ +// onboarding.js +// Pure building blocks for the Vee onboarding tours: step lists + the milestone gate. +// (DOM trigger wrappers live in library.js / output-tab.js where they read +// chrome.storage and call the coachmark engine.) +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: '#ask-input', copyKey: 'milestoneAsk', mascotState: 'idle' }, + { target: '#lib-btn-corkboard', copyKey: 'milestoneCorkboard', mascotState: 'thinking' }, + { target: '#select', copyKey: 'milestoneCompare', mascotState: 'idle' }, + { target: '#lib-btn-radar', copyKey: 'milestoneOrganize', mascotState: 'idle' }, + { target: '#discover-btn', copyKey: 'milestoneDiscover', mascotState: 'idle' }, + ]; +} + +/** Pure gate for the milestone offer. */ +export function shouldOfferMilestone({ realCount, milestoneTourSeen, onboardingSeen }) { + return !!onboardingSeen && !milestoneTourSeen && realCount >= MILESTONE_AT; +} + +export { COPY }; diff --git a/options.html b/options.html index 369f520..9e5f17f 100644 --- a/options.html +++ b/options.html @@ -246,6 +246,8 @@

RepoLens

+

Want the first-run walkthrough again? Replay the onboarding tour from the Library.

+ diff --git a/options.js b/options.js index 9f13b31..a4cbf6c 100644 --- a/options.js +++ b/options.js @@ -76,6 +76,13 @@ loadHistory(); document.getElementById('open-library-link') ?.addEventListener('click', () => openTab(chrome.runtime.getURL('library.html'))); +// ─── Replay onboarding ──────────────────────────────────────────────────────── +// Reset the onboarding flags and open the Library, where the first-run tour fires. +document.getElementById('replayOnboardingBtn')?.addEventListener('click', async () => { + await chrome.storage.local.set({ onboardingSeen: false, milestoneTourSeen: false, milestoneSnoozeAt10: false }); + openTab(chrome.runtime.getURL('library.html')); +}); + // ─── Delay between AI calls (paces bursts; read live by background.js) ──────── const aiGap = document.getElementById('aiGap'); const aiGapLabel = document.getElementById('aiGapLabel'); diff --git a/output-tab.js b/output-tab.js index 8229e57..4975470 100644 --- a/output-tab.js +++ b/output-tab.js @@ -36,6 +36,9 @@ import { startTour } from './tour-runner.js'; import { toCanvasSvg, toExcalidraw } from './canvas-export.js'; import { getScene, saveScene } from './store.js'; import { snapshotTrend, sparkline } from './snapshots.js'; +import { introStageB, COPY } from './onboarding.js'; +import { startCoachmark } from './coachmark.js'; +import { clearDemoEverywhere, isDemo } from './demo-repo.js'; // Apply the saved theme ASAP (before render) to minimise flash. initTheme(); @@ -269,6 +272,29 @@ async function initOutputPalette(data) { }); } +// Stage B of the Vee intro: if the Library handed off (`onboardingStage:'verdict'`), +// continue the coachmark over this repo's Verdict + Blueprint, then tear the demo down. +async function maybeContinueOnboarding(d) { + const { onboardingStage } = await chrome.storage.local.get('onboardingStage'); + if (onboardingStage !== 'verdict') return; + if (!isDemo(d)) return; // Stage B only continues over the seeded demo, never a real repo + await chrome.storage.local.remove('onboardingStage'); + // Immutable copy before injecting the before-hook on the Blueprint/Canvas step. + const steps = introStageB().map((s) => ({ ...s })); + // onboarding.js is pure and can't reference show(); inject the tab switch here. + // The Blueprint step is the one targeting the Canvas tab ([data-tab="27"]). + const blueprintStep = steps.find((s) => s.copyKey === 'blueprint'); + if (blueprintStep) blueprintStep.before = () => show(27); + startCoachmark({ + steps, + copy: COPY, + onExit: async () => { + await clearDemoEverywhere(); + await chrome.storage.local.set({ onboardingSeen: true }); + }, + }); +} + async function init() { mascotOn = await isMascotEnabled(); if (mascotOn) renderMascot(document.getElementById('loading-vee'), 'scanning'); @@ -329,6 +355,7 @@ async function init() { lastData = data; renderPage(data); + await maybeContinueOnboarding(data); main.style.display = 'block'; const fitEmoji = { strong: '✅', solid: '✓', care: '⚠️', risky: '🔴' }[deriveFit(data).level] || ''; document.title = `${fitEmoji} ${data.repoId} — RepoLens`; diff --git a/repair-graph.js b/repair-graph.js index 52e417d..3eb3f66 100644 --- a/repair-graph.js +++ b/repair-graph.js @@ -49,13 +49,16 @@ export function repairGraph(raw, opts = {}) { const kind = coerceKind(n.kind); if (n.kind && coerceKind(n.kind) !== String(n.kind).toLowerCase()) add('auto-corrected', 'kind-alias', `kind "${n.kind}" → ${kind}`); + // Coerce first: Number.isFinite('100') is false, so a pinned node with string + // coords (e.g. from JSON) would otherwise collapse to (0,0). + const nx = Number(n.x), ny = Number(n.y); nodes.push({ id, label: String(n.name ?? n.label ?? id), kind, layer: n.layer != null ? String(n.layer) : null, - x: Number.isFinite(n.x) ? n.x : 0, - y: Number.isFinite(n.y) ? n.y : 0, + x: Number.isFinite(nx) ? nx : 0, + y: Number.isFinite(ny) ? ny : 0, pinned: !!n.pinned, ref: { purpose: n.purpose ?? null, files: Array.isArray(n.files) ? n.files : [] }, }); diff --git a/settings-backup.js b/settings-backup.js index 859934a..86cbeb3 100644 --- a/settings-backup.js +++ b/settings-backup.js @@ -27,6 +27,11 @@ export const SAFE_SETTING_KEYS = [ 'librarySort', 'canvasEnabled', 'canvasTourAutoplay', + // Onboarding flags — backed up together so a settings restore never lands in a + // half-onboarded state. All booleans, no secrets. + 'onboardingSeen', + 'milestoneTourSeen', + 'milestoneSnoozeAt10', ]; const pickSafe = (src) => { diff --git a/snapshots.js b/snapshots.js index d8eaf9f..430bcee 100644 --- a/snapshots.js +++ b/snapshots.js @@ -64,8 +64,10 @@ export function snapshotTrend(snaps) { const series = list.map((s) => ({ ts: s.ts, health: s.health, fit: s.fit, stars: s.stars })); const healthDelta = first.health != null && latest.health != null ? latest.health - first.health : null; - const firstFlags = new Set(first.flags || []); - const latestFlags = new Set(latest.flags || []); + // Coerce defensively: a corrupt/hostile backup can pass the envelope-only + // validateBackup with a non-array `flags` (e.g. flags:5), and `new Set(5)` throws. + const firstFlags = new Set(Array.isArray(first.flags) ? first.flags : []); + const latestFlags = new Set(Array.isArray(latest.flags) ? latest.flags : []); return { count: list.length, series, diff --git a/store.js b/store.js index 4c8b3b5..5e2486d 100644 --- a/store.js +++ b/store.js @@ -47,6 +47,9 @@ export async function saveRepo(analysis) { languages: analysis.languages ?? [], // Fit delta: snapshot the previous fit so the library can show ↑/↓ after re-scan. prevFitLevel, + // Carry the onboarding demo marker through so the seeded sample survives the + // round-trip and can be excluded from stats / torn down. Absent for real repos. + ...(analysis.__demo__ === true ? { __demo__: true } : {}), }; await idbPut('repos', { id: hashRepoId(analysis.repoId), payload }); await appendScanSnapshot(payload, existing?.payload); @@ -59,6 +62,10 @@ export async function saveRepo(analysis) { * re-scan yields a real 2-point trend instead of losing its history. */ export async function appendScanSnapshot(payload, prevPayload) { + // The onboarding demo is a fixture, not a real scan — never let it seed the + // ledger (an orphan snapshot would leak into backups and replaying the intro + // would double it up). The demo repo/scene are torn down separately. + if (payload && payload.__demo__ === true) return; if (!payload || !payload.repoId) return; try { const id = hashRepoId(payload.repoId); @@ -84,6 +91,13 @@ export async function listSnapshots(repoId) { } } +/** Remove a repo's snapshot history. Best-effort — never throws (mirrors deleteRepo). */ +export async function deleteSnapshots(repoId) { + try { + await idbDelete('snapshots', hashRepoId(repoId)); + } catch { /* best-effort */ } +} + /** All snapshot histories as a Map(repoId → snaps[]) for batch rendering. */ export async function listAllSnapshots() { try { @@ -321,7 +335,9 @@ export async function getEgoGraph(repoId) { const validRows = (rows) => (rows || []).filter((r) => r && r.id != null); -/** Gather every row from all stores for a backup envelope. */ +/** Gather every row from all stores for a backup envelope. The seeded onboarding + * demo (repo + its snapshot + its scene, all tagged __demo__) is dropped so it + * never leaks into a user's backup file. */ export async function exportStores() { const [repos, nodes, edges, collections, decisions, snapshots, scenes] = await Promise.all([ idbGetAll('repos'), @@ -332,7 +348,18 @@ export async function exportStores() { idbGetAll('snapshots'), idbGetAll('scenes'), ]); - return { repos: repos || [], nodes: nodes || [], edges: edges || [], collections: collections || [], decisions: decisions || [], snapshots: snapshots || [], scenes: scenes || [] }; + const isDemoRepo = (r) => r?.payload?.__demo__ === true; + // Snapshot rows share the repo's hashed id, so the demo repo's id is also its snapshot key. + const demoIds = new Set((repos || []).filter(isDemoRepo).map((r) => r.id)); + return { + repos: (repos || []).filter((r) => !isDemoRepo(r)), + nodes: nodes || [], + edges: edges || [], + collections: collections || [], + decisions: decisions || [], + snapshots: (snapshots || []).filter((s) => !demoIds.has(s.id)), + scenes: (scenes || []).filter((s) => s?.__demo__ !== true), + }; } /** diff --git a/tests/canvas-export.test.js b/tests/canvas-export.test.js index a2ae4f5..875107f 100644 --- a/tests/canvas-export.test.js +++ b/tests/canvas-export.test.js @@ -31,6 +31,23 @@ describe('toCanvasSvg', () => { expect(svg).not.toContain('onload'); expect(svg).not.toContain('