Skip to content

Commit 6427b55

Browse files
Merge pull request #27 from New1Direction/feat/vee-onboarding
feat(onboarding): Vee-guided first-run tour + milestone power tour
2 parents c3e45d2 + f49dc69 commit 6427b55

33 files changed

Lines changed: 2053 additions & 24 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ This project follows [Semantic Versioning](https://semver.org/) and groups chang
77
by theme. Dates are when the release landed on `main` — 1.1.0 through 1.6.0 shipped
88
the same day, as a rapid burst of improvements, so they share a date.
99

10+
## [Unreleased]
11+
12+
### Added
13+
14+
- **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`.
15+
- **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.
16+
17+
---
18+
1019
## [3.1.0] — 2026-06-16 · _Interactive Canvas (Blueprint · Guided Tour · Corkboard · Stack Studio)_
1120

1221
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ A scan opens to a **verdict landing** and fans out into focused tabs:
4141

4242
Plus **SKTPG** (a one-tap State / Known-pitfalls / Trajectory / Proof / Growth read), framework lenses, and capability re-tagging.
4343

44+
**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.
45+
4446
---
4547

4648
## 🆕 What's new

backup.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// library — analyzed repos, the semantic graph (nodes + edges) and the local
88
// scan cache — round-trips through one human-readable JSON file.
99

10+
import { SNAPSHOT_CAP } from './snapshots.js';
11+
1012
export const BACKUP_FORMAT = 'repolens-backup';
1113
export const BACKUP_VERSION = 2;
1214

@@ -15,9 +17,9 @@ export const BACKUP_VERSION = 2;
1517
// past these is dropped with a surfaced warning (never silently).
1618
export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000, scenes: 2000 };
1719

18-
// Per-repo snapshot ring-buffer cap (mirrors SNAPSHOT_CAP in snapshots.js); each
20+
// Per-repo snapshot ring-buffer cap — single source of truth in snapshots.js; each
1921
// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
20-
const SNAP_CAP = 30;
22+
const SNAP_CAP = SNAPSHOT_CAP;
2123

2224
const arr = (x) => (Array.isArray(x) ? x : []);
2325
const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId);
@@ -88,7 +90,12 @@ export function validateBackup(obj) {
8890
cache: clamp('cache', arr(obj.cache).filter(cacheOk)),
8991
collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
9092
decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
91-
snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({ ...r, snaps: arr(r.snaps).slice(-SNAP_CAP) }))),
93+
snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({
94+
...r,
95+
// Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
96+
// file may carry a non-array `flags` that would later throw in snapshotTrend.
97+
snaps: arr(r.snaps).slice(-SNAP_CAP).map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
98+
}))),
9299
scenes: clamp('scenes', arr(obj.scenes).filter(sceneOk)),
93100
};
94101
return { ok: errors.length === 0, errors, warnings, value };

canvas-export.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,23 @@ export function toCanvasSvg(scene) {
1818
const pos = Object.fromEntries(nodes.map((n) => [n.id, n]));
1919
const minX = Math.min(0, ...nodes.map((n) => num(n.x))) - 20;
2020
const minY = Math.min(0, ...nodes.map((n) => num(n.y))) - 20;
21-
const maxX = Math.max(...nodes.map((n) => num(n.x) + NW), 200) + 20;
21+
const maxX = Math.max(...nodes.map((n) => num(n.x) + (num(n._w) || NW)), 200) + 20;
2222
const maxY = Math.max(...nodes.map((n) => num(n.y) + NH), 200) + 60;
2323

2424
const edgeSvg = edges.map((e) => {
2525
const a = pos[e.from], b = pos[e.to];
2626
if (!a || !b) return '';
27-
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;
27+
// Start the edge at the source node's real right edge (auto-width `_w`), not the
28+
// fixed constant, so edges on wide cards stay attached.
29+
const aw = num(a._w) || NW;
30+
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;
2831
return `<path class="ce-edge ce-${esc(e.rel)}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none"/>`;
2932
}).join('');
3033

3134
const nodeSvg = nodes.map((n) => {
32-
const x = num(n.x), y = num(n.y);
33-
return `<g class="ce-node ce-kind-${esc(n.kind)}"><rect x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>` +
34-
`<text x="${x + NW / 2}" y="${y + NH / 2}" text-anchor="middle" dominant-baseline="central">${esc(n.label)}</text></g>`;
35+
const x = num(n.x), y = num(n.y), w = num(n._w) || NW;
36+
return `<g class="ce-node ce-kind-${esc(n.kind)}"><rect x="${x}" y="${y}" width="${w}" height="${NH}" rx="8"/>` +
37+
`<text x="${x + w / 2}" y="${y + NH / 2}" text-anchor="middle" dominant-baseline="central">${esc(n.label)}</text></g>`;
3538
}).join('');
3639

3740
const annSvg = ann.map((a) => {
@@ -55,14 +58,14 @@ export function toExcalidraw(scene) {
5558

5659
for (const n of scene.nodes || []) {
5760
const rid = `rect-${n.id}`, tid = `txt-${n.id}`;
58-
const x = num(n.x), y = num(n.y);
61+
const x = num(n.x), y = num(n.y), w = num(n._w) || NW;
5962
elements.push(base(rid, {
60-
type: 'rectangle', x, y, width: 132, height: 44,
63+
type: 'rectangle', x, y, width: w, height: 44,
6164
backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6',
6265
boundElements: [{ type: 'text', id: tid }],
6366
}));
6467
elements.push(base(tid, {
65-
type: 'text', x: x + 8, y: y + 14, width: 116, height: 20, text: String(n.label),
68+
type: 'text', x: x + 8, y: y + 14, width: w - 16, height: 20, text: String(n.label),
6669
fontSize: 16, fontFamily: 1, textAlign: 'center', verticalAlign: 'middle', containerId: rid,
6770
originalText: String(n.label), lineHeight: 1.25,
6871
}));
@@ -72,7 +75,7 @@ export function toExcalidraw(scene) {
7275
for (const e of scene.edges || []) {
7376
const a = pos[e.from], b = pos[e.to];
7477
if (!a || !b) continue;
75-
const x1 = num(a.x) + 132, y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22;
78+
const x1 = num(a.x) + (num(a._w) || NW), y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22;
7679
elements.push(base(`arrow-${e.id}`, {
7780
type: 'arrow', x: x1, y: y1, width: x2 - x1, height: y2 - y1,
7881
points: [[0, 0], [x2 - x1, y2 - y1]],

coachmark.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// coachmark.js
2+
// DOM coachmark tour: a dimming veil, a spotlight around a target element, a card
3+
// (with Vee) anchored beside it, Back/Next/Skip + keyboard. No deps, MV3-safe.
4+
import { renderMascot, setMascotState } from './mascot.js';
5+
6+
const GAP = 12, MARGIN = 8;
7+
8+
/** Pure: where to put the card relative to a target rect (or center if null). */
9+
export function placeCard(rect, card, vp) {
10+
if (!rect) return { side: 'center', left: (vp.w - card.w) / 2, top: (vp.h - card.h) / 2 };
11+
const below = rect.y + rect.height + GAP, above = rect.y - GAP - card.h;
12+
const side = (below + card.h <= vp.h) ? 'below' : (above >= 0 ? 'above' : 'below');
13+
const top = side === 'below' ? below : above;
14+
let left = rect.x + rect.width / 2 - card.w / 2;
15+
left = Math.max(MARGIN, Math.min(left, vp.w - card.w - MARGIN));
16+
return { side, left, top: Math.max(MARGIN, Math.min(top, vp.h - card.h - MARGIN)) };
17+
}
18+
19+
/**
20+
* @param {{steps:Array, copy:object, onExit?:Function}} args
21+
* step = { target:selector|null, copyKey, mascotState, before? }
22+
* @returns {{ next, prev, exit }}
23+
*/
24+
export function startCoachmark({ steps, copy, onExit }) {
25+
let i = 0;
26+
const reduce = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
27+
const veil = document.createElement('div'); veil.className = 'cm-veil';
28+
const spot = document.createElement('div'); spot.className = 'cm-spotlight';
29+
const card = document.createElement('div'); card.className = 'cm-card';
30+
const veeSlot = document.createElement('div'); veeSlot.className = 'cm-vee';
31+
const vee = renderMascot(veeSlot);
32+
const text = document.createElement('p'); text.className = 'cm-text';
33+
const ctl = document.createElement('div'); ctl.className = 'cm-ctl';
34+
const back = document.createElement('button'); back.textContent = 'Back';
35+
const next = document.createElement('button'); next.textContent = 'Next';
36+
const skip = document.createElement('button'); skip.textContent = 'Skip'; skip.className = 'cm-skip';
37+
ctl.append(skip, back, next);
38+
card.append(veeSlot, text, ctl);
39+
veil.append(spot); document.body.append(veil, card);
40+
41+
async function render() {
42+
const s = steps[i];
43+
if (s.before) { try { await s.before(); } catch { /* step action best-effort */ } }
44+
setMascotState(vee, s.mascotState || 'idle');
45+
text.textContent = copy[s.copyKey] || '';
46+
back.disabled = i === 0;
47+
next.textContent = i === steps.length - 1 ? 'Done' : 'Next';
48+
const el = s.target ? document.querySelector(s.target) : null;
49+
const vp = { w: innerWidth, h: innerHeight };
50+
if (el) {
51+
el.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
52+
const r = el.getBoundingClientRect();
53+
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`;
54+
const p = placeCard({ x: r.x, y: r.y, width: r.width, height: r.height }, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
55+
card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
56+
} else {
57+
spot.style.display = 'none';
58+
const p = placeCard(null, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp);
59+
card.style.left = p.left + 'px'; card.style.top = p.top + 'px';
60+
}
61+
}
62+
function go(n) { i = Math.max(0, Math.min(steps.length - 1, n)); render(); }
63+
function step(d) { (i + d >= steps.length) ? exit() : go(i + d); }
64+
function exit() { veil.remove(); card.remove(); removeEventListener('keydown', onKey); onExit && onExit(); }
65+
const onKey = (e) => { if (e.key === 'Escape') exit(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); };
66+
back.onclick = () => step(-1); next.onclick = () => step(1); skip.onclick = exit;
67+
addEventListener('keydown', onKey);
68+
render();
69+
return { next: () => step(1), prev: () => step(-1), exit };
70+
}

demo-repo.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// demo-repo.js
2+
// A pre-baked, clearly-marked DEMO scan (honojs/hono) so the first-run tour can
3+
// show real surfaces. Tagged __demo__ so it's excluded from stats/export and torn down.
4+
import { buildBlueprintScene } from './blueprint-adapter.js';
5+
6+
export const DEMO_REPO = {
7+
repoId: 'honojs/hono',
8+
__demo__: true,
9+
platform: 'github', language: 'TypeScript', license: 'MIT', stars: 21000,
10+
category: 'Web framework', tags: ['edge', 'router'],
11+
description: 'Small, fast web framework for the edges.',
12+
saved_at: '2026-01-01T00:00:00.000Z',
13+
eli5: 'A sample read: a tiny web framework that runs on edge runtimes using web-standard requests.',
14+
fit: 'strong',
15+
health: { score: 92 },
16+
pros: ['Tiny and fast', 'Runs on most runtimes', 'Typed routing'],
17+
cons: ['Smaller ecosystem than Express'],
18+
red_flags: [],
19+
capabilities: ['routing', 'middleware', 'edge'],
20+
deepDive: {
21+
atoms: [
22+
{ id: 'app', name: 'Hono app', kind: 'entrypoint', purpose: 'Creates the app and registers routes.' },
23+
{ id: 'router', name: 'router', kind: 'subsystem', purpose: 'Matches a request to a handler.' },
24+
{ id: 'context', name: 'Context', kind: 'subsystem', purpose: 'Wraps request and response per call.' },
25+
{ id: 'middleware', name: 'middleware', kind: 'module', purpose: 'Runs before/after handlers.' },
26+
{ id: 'handler', name: 'handler', kind: 'module', purpose: 'Your route logic.' },
27+
{ id: 'adapter', name: 'runtime adapter', kind: 'module', purpose: 'Binds to a runtime (Workers, Deno, Node).' },
28+
],
29+
lineage: {
30+
links: [
31+
{ from: 'app', to: 'router', relation: 'depends-on' },
32+
{ from: 'router', to: 'context', relation: 'triggers' },
33+
{ from: 'context', to: 'middleware', relation: 'triggers' },
34+
{ from: 'middleware', to: 'handler', relation: 'triggers' },
35+
{ from: 'app', to: 'adapter', relation: 'depends-on' },
36+
],
37+
roots: ['app'], leaves: ['handler'],
38+
},
39+
},
40+
};
41+
42+
/** The blueprint scene for the demo (so the Canvas tab renders real content).
43+
* Tagged __demo__ so exportStores can drop it without coupling the store to this
44+
* fixture (saveScene persists the scene verbatim, so the tag survives). */
45+
export function demoScene() {
46+
return { ...buildBlueprintScene({ deepDive: DEMO_REPO.deepDive, repoId: DEMO_REPO.repoId, title: DEMO_REPO.repoId }), __demo__: true };
47+
}
48+
49+
/** True only for the seeded demo row. */
50+
export function isDemo(repo) {
51+
return !!(repo && (repo.__demo__ === true || (repo.repoId === DEMO_REPO.repoId && repo.__demo__)));
52+
}
53+
54+
/**
55+
* Tear down the seeded demo (repo row + blueprint scene). Best-effort.
56+
* Uses a dynamic import so this fixture module never pulls the IndexedDB/store
57+
* layer into its static import graph (it's also imported by pure unit tests).
58+
*/
59+
export async function clearDemoEverywhere() {
60+
try {
61+
const { deleteRepo, deleteScene, deleteSnapshots, scrollPoints } = await import('./store.js');
62+
// Only tear down when the stored honojs/hono row is actually the demo —
63+
// never delete a real scan that happens to share the demo's id.
64+
const points = await scrollPoints();
65+
const row = points.find((p) => p?.payload?.repoId === DEMO_REPO.repoId);
66+
if (row?.payload?.__demo__ === true) {
67+
await deleteRepo(DEMO_REPO.repoId);
68+
await deleteScene(demoScene().id);
69+
// Defense-in-depth: clear any snapshot orphan left by an already-seeded user.
70+
await deleteSnapshots(DEMO_REPO.repoId);
71+
}
72+
} catch { /* best-effort teardown */ }
73+
}

0 commit comments

Comments
 (0)