Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f204419
docs: Vee onboarding spec — intro + milestone tours, anti-slop voice
New1Direction Jun 16, 2026
6d71e97
docs: Vee onboarding implementation plan (+ HTML)
New1Direction Jun 16, 2026
78c3c02
feat(onboarding): honojs/hono DEMO fixture + demoScene/isDemo
New1Direction Jun 16, 2026
ebbace4
feat(onboarding): Vee copy deck + machine-checked anti-slop test
New1Direction Jun 16, 2026
179b4a3
feat(onboarding): step lists + milestone gate (pure)
New1Direction Jun 16, 2026
e0b0238
feat(onboarding): coachmark engine (pure placeCard + veil/spotlight/c…
New1Direction Jun 16, 2026
3429523
feat(onboarding): coachmark styles (token-based, reduced-motion)
New1Direction Jun 16, 2026
f8dc047
feat(onboarding): Library intro trigger + milestone offer + replay + …
New1Direction Jun 16, 2026
d5fd320
feat(onboarding): output-tab Stage-B continuation + demo teardown
New1Direction Jun 16, 2026
0161152
fix(onboarding): demo can't clobber or delete a real honojs/hono scan…
New1Direction Jun 16, 2026
0d5433c
feat(onboarding): allowlist flags, replay button, exclude __demo__ fr…
New1Direction Jun 16, 2026
7256e24
fix(onboarding): de-slop pass on Vee copy (drop brochure cadence + so…
New1Direction Jun 16, 2026
57ca0f2
docs(onboarding): standalone coachmark demo harness + CHANGELOG/README
New1Direction Jun 16, 2026
ac8d466
fix(onboarding): open demo in output tab (Stage B handoff), correct m…
New1Direction Jun 16, 2026
ff7802b
fix(onboarding): Stage B only continues over the demo (guard on isDemo)
New1Direction Jun 16, 2026
f49dc69
fix(stack): pre-merge review — demo snapshot leak, untrusted backup f…
New1Direction Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down
21 changes: 12 additions & 9 deletions canvas-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path class="ce-edge ce-${esc(e.rel)}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none"/>`;
}).join('');

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

const annSvg = ann.map((a) => {
Expand All @@ -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,
}));
Expand All @@ -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]],
Expand Down
70 changes: 70 additions & 0 deletions coachmark.js
Original file line number Diff line number Diff line change
@@ -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 };
}
73 changes: 73 additions & 0 deletions demo-repo.js
Original file line number Diff line number Diff line change
@@ -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 */ }
}
Loading
Loading