# repo-explainer

Adopt this skill: https://proskills.md/skills/other/cyvid7-darus10-repo-explainer

Page: https://proskills.md/skills/other/cyvid7-darus10-repo-explainer
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: SKILL.md
size: 8954
sha: 41416fa430fe66c37ccbe9587d60fe09d3cf75f9
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/SKILL.md

```text
---
name: repo-explainer
description: 'Turn a codebase into a guided, interactive HTML tour: architecture map, step-through end-to-end flows with file:line citations, data model, and a "what changed and why" timeline of recent human and agent merges. Use when someone asks to explain, onboard onto, map, document, or catch up on a repository (backend, frontend, full-stack, mobile, library or CLI), or to show how a request flows end to end.'
---

# Repo Explainer

You produce **one self-contained HTML file** that teaches a repo the way Brilliant teaches math: one idea at a time, with interactive diagrams and a big Continue button.

**You only write data.** The design, components, icons, animation and layout are finished and tested. Your job is to investigate the repo and fill `explainer.json` with true, cited facts. The CLI turns that into the page.

`SKILL_DIR` below means the folder this file is in. Run the CLI with `node "$SKILL_DIR/bin/repo-explainer.mjs" <command>` (Node 18.3+, no install step).

## Workflow

Copy this checklist into your todo list and work through it in order.

0. **(Optional) Check the cost.** `repo-explainer estimate <repo>` prints a rough per-model cost for this repo's size.
1. **Scan.** Run `repo-explainer scan <repo>`. It gives you facts to verify, not conclusions: stack, entry points, route handlers, data files, env var *names*, scripts, and a suggested preset.
2. **Pick a preset.** Run `repo-explainer presets` and take the suggestion unless it is clearly wrong. Each preset fixes the layers, the chapter order, the flows you must trace and the questions you must answer. See `presets/<id>.json`.
3. **Start the data file.** Run `repo-explainer init <repo> --preset <id> --out <repo>/.explainer/explainer.json`. Every `TODO` in it is deliberate.
4. **Investigate.** Follow `references/investigation-playbook.md`. Read the entry points, then trace each `mustTrace` flow through the real code, one call at a time. Write down `file:line` as you go.
5. **Fill the data** using `references/data-schema.md`. Start each flow from a pattern: run `repo-explainer patterns`, then `repo-explainer patterns <id>`, copy its steps, and rename its roles to your node ids.
6. **Draft the changes chapter.** Run `repo-explainer changes <repo> --data <file> --write --since "14 days ago"`. Add `--merges` when the repo merges PRs, and `--remote https://github.com/<o>/<r>` for links. Then replace each `why` TODO with the reason from the PR description, linked issue or commit body. If you cannot find a reason, say "No reason recorded". **Never invent one.**
7. **Build with file checks.** Run `repo-explainer build <file> --repo <repo> --strict`. Fix every error and warning, since `--strict` fails on warnings too. `--repo` proves every cited path exists.
8. **Look at it.** Open the HTML (add `--open`, or use a browser tool). Click a node, step through every flow to the trophy, answer the quiz, and narrow the window to phone width. Fix the data, not the template.
9. **Hand off.** Report the output path, the flows you traced, and anything you could not verify.

## Brand and UX guardrails (non-negotiable)

The look is a deliberate system: a Duolingo-style palette inside a Brilliant-style lesson flow. Consistency across every repo's explainer is the point, so treat these as hard rules:

- **Do not edit** `template/`, `styles.css`, the icons, the tokens or the layout to make one explainer "fit". If something does not fit, change the data: shorter labels, fewer actors, a split flow. If the template is genuinely missing something, stop and say so. That is a template change for a maintainer and a separate PR.
- **Do not add** CSS, `<style>`, inline styles, scripts, fonts, emoji icon sets, images, gradients, shadows, extra colors or extra border radii. No custom HTML in any string either: everything renders as plain text by design.
- **Do not hand-write the HTML or draw your own diagrams.** Every visual comes from the data. Need an icon? Set `kind` to a name in `assets/icons/`. Need a color? Set the layer `color` to one of `green, blue, navy, midnight, yellow, pink`. Need the logo? Use `assets/logo/`. To see everything available, run `repo-explainer gallery`.
- **Respect the shape of a lesson:**
  - **One idea per step.** A step `label` is a short action (≤ 6 words, e.g. "Checks the session"). A `detail` is at most 2 sentences.
  - **4–12 steps per flow.** More than that means splitting it into two flows.
  - **3–7 actors per flow, and 4–8 layers on the map.** More than that means grouping them.
  - **Labels:** node labels ≤ 3 words, layer labels ≤ 2 words, edge labels ≤ 3 words.
  - **Concepts:** 3–6, each with an `analogy` when one exists.
  - **Quiz:** 3–5 questions. Each asks "where would you change X" or "why does Y happen", never trivia. `explain` says why the answer is right.
- **Accessibility is already handled.** It covers keyboard support, reduced motion, focus rings and contrast. Don't route around it: no text in place of icons, no meaning carried by color alone in your copy ("the green box" → "the Task service").

## How to write: gist first, no padding

A reader should get the idea from the first five words. Write like you are explaining it to a teammate at a whiteboard.

1. **Lead with the point.** Say what happens, then (if needed) one detail. Not "This step is responsible for handling the validation of…" but "Rejects bad input with a 422."
2. **One idea per sentence, about 15 words or fewer.** Two sentences max per `detail`.
3. **Use plain words.** Say "checks", not "performs verification of". Say "saves", not "persists the entity". Say "uses", not "leverages".
4. **Name real things.** Say "`createTask()` in the task service", not "the relevant business logic".
5. **No dashes as punctuation.** That means no em dash, no en dash, and no spaced hyphen. Split into two sentences, or use a colon or comma. (`validate` warns on these.)
6. **No filler.** Cut "basically", "simply", "it's worth noting", "in order to", "various", and "robust/seamless/powerful".
7. **Present tense, sentence case, and "you" for the reader.** No exclamation marks; the trophy screen already has one.
8. **`why` answers "why this way?"** in one sentence, naming the cost it avoids. Example: "Email can take seconds; queuing keeps the click under 100 ms."

| Instead of | Write |
|---|---|
| "The middleware layer is responsible for performing authentication checks — ensuring users are logged in." | "Checks the session. No session means a 401." |
| "Leverages a queue-based architecture in order to asynchronously process emails." | "Puts the email on the queue. The worker sends it." |
| "This is basically where the various business rules live." | "All business rules live here." |

## Truthfulness rules

- **Cite everything.** Every node lists its `files`. Every flow step has a `file` (`path:line`). `--repo` must pass.
- **Read before you claim.** The scan only tells you where to look. Never describe code you have not opened.
- **The why is the point of a change entry.** Take it only from the PR, issue or commit. Keep `authorType` from the collector (`agent` / `human`) unless the history clearly shows otherwise.
- **Never copy secrets.** Use env var names only, never values. Do not put credentials, tokens, internal hostnames or customer data in any field.
- **When unsure, say so** in the step `detail` (e.g. "Retries appear to be 3. Set in queue.ts, not verified at runtime.").

## Reuse map

| Need | Use | Never |
|---|---|---|
| A component box | `nodes[]` with `kind` | an image or custom SVG |
| An arrow between parts | `edges[]` (`kind: async` for dashed) | prose like "A → B" |
| A request flow | `flows[]`, started from `patterns/*.json` | a hand-made sequence diagram |
| Why something is built that way | `step.why`, `concept.analogy` | a long `detail` |
| Recent work | `repo-explainer changes` → `changes[]` | a pasted git log |
| Folder guide | `tree[]` (`key: true` for hot spots) | a full file listing |
| Logo, icons, palette | `assets/`, `repo-explainer gallery` | redrawing or recoloring |

## Updating an existing explainer

For a repo that already has one (e.g. `.explainer/explainer.json`):

1. Run `changes --write` to append new merges.
2. Fill in each new `why`.
3. Re-check any node or flow whose cited files appear in those changes.
4. Bump `meta.generatedAt` and `meta.commit`.
5. Rebuild with `--strict`.

Keep the JSON in the repo so the next agent updates it instead of starting over.

## References

- `references/data-schema.md`: every field, its limits and an example.
- `references/investigation-playbook.md`: how to trace flows in each kind of stack.
- `references/design-system.md`: tokens and the reasoning behind each rule (read-only context).
- `references/ux-principles.md`: why the page is built as a guided lesson.
- `examples/taskflow.explainer.json`: a complete, valid example to imitate.
```

## File: README.md
size: 8876
sha: f1c44ab1d04f9ea4f2f1a242b7c7e6efcf358253
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/README.md

````text
<p align="center">
  <img src="assets/logo/lockup.svg" alt="Repo Explainer" height="56">
</p>

<p align="center">
  <b>Turn any codebase into a guided, interactive tour.</b><br>
  An agent skill that maps the system, walks every request end to end, and shows what changed and why.
</p>

<p align="center">
  <a href="https://github.com/Cyvid7-Darus10/repo-explainer/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Cyvid7-Darus10/repo-explainer/actions/workflows/ci.yml/badge.svg"></a>
  <a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache%202.0-58cc02"></a>
  <img alt="Node 18.3 or newer" src="https://img.shields.io/badge/node-%E2%89%A518.3-1cb0f6">
  <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-042c60">
</p>

<p align="center">
  <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html"><b>Live demo</b></a>
  &nbsp;·&nbsp;
  <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/gallery.html"><b>Design kit</b></a>
  &nbsp;·&nbsp;
  <a href="#quick-start"><b>Quick start</b></a>
  &nbsp;·&nbsp;
  <a href="#what-a-run-costs"><b>Cost</b></a>
</p>

<p align="center">
  <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html"><img src="docs/media/demo.gif" alt="A Repo Explainer tour: the hero, the architecture map, stepping through a request, recent changes, the data model and a quiz" width="860"></a>
</p>

## Why

Understanding a repo used to mean reading it. A few people wrote the code, you knew who to ask, and the history told a story.

Picture a busy repo today. Twenty merge requests land in a day. Some come from engineers, many from coding agents working in parallel. The tests pass, but nobody holds the whole picture:

- **What is new** since you last looked?
- **Why was it added?** The reason sits in a PR description nobody will open again.
- **Who changed it:** a person you can ask, or an agent you can't?
- **How does a request flow now?** The wiki diagram is from last quarter.

Reading every diff does not scale. Repo Explainer has your agent do the reading, then **teach** the result like a good lesson: one idea at a time, diagrams you can click, and every claim linked to a real line of code.

## What you get

One self-contained HTML page per repo:

- **The big picture.** An architecture map. Click a part to see what it does, its files, and who it talks to.
- **End to end.** Step through each request on a sequence diagram with a Continue button. Every hop cites `file:line`.
- **What changed, and why.** Recent merges split by human and agent, each with its reason. **Show on map** lights up the parts that moved.
- **The data.** An ER diagram with cardinality, then a clear field table per entity.
- **Check yourself.** "Where would you change X?" questions with instant feedback.

Re-run it tomorrow and it only reads the new merges.

| | |
|---|---|
| <img src="docs/media/hero.png" alt="Hero with the main request route"> | <img src="docs/media/map.png" alt="Architecture map with a part selected"> |
| **Start.** Reading time and the main route at a glance. | **Map.** Click a part to see its files, neighbours and flows. |
| <img src="docs/media/flow.png" alt="Sequence diagram beside a step card with code"> | <img src="docs/media/changes.png" alt="Timeline of recent changes with reasons"> |
| **Flows.** One hop at a time, with the code that does it. | **Changes.** Agent or human, the reason, and the risk. |
| <img src="docs/media/data.png" alt="ER diagram with a field table"> | <img src="docs/media/quiz.png" alt="Quiz with instant feedback"> |
| **Data.** Relationships first, then fields. | **Quiz.** Instant feedback, with the why. |

## Quick start

### As an agent skill

```sh
git clone https://github.com/Cyvid7-Darus10/repo-explainer ~/.claude/skills/repo-explainer
```

Then ask your agent:

> Explain this repo for a new engineer. Trace sign in and checkout end to end, and include what changed this week.

The agent follows [`SKILL.md`](SKILL.md). It scans the repo, picks a preset, traces flows through the real code, fills in `explainer.json`, and builds the page. Any agent that can read a skill file and run Node can use it.

### From the command line

```sh
npx github:Cyvid7-Darus10/repo-explainer scan ./my-repo
npx github:Cyvid7-Darus10/repo-explainer init ./my-repo
npx github:Cyvid7-Darus10/repo-explainer changes ./my-repo --data explainer.json --write --merges
npx github:Cyvid7-Darus10/repo-explainer build explainer.json --repo ./my-repo --strict --open
```

Needs Node 18.3 or newer. Nothing to install. The output is one HTML file you can commit, attach to a PR, or host anywhere.

<details>
<summary><b>All commands</b></summary>

| Command | What it does |
|---|---|
| `scan <repo>` | Stack, entry points, route handlers with `file:line`, data files, env var names (never values), scripts, and a suggested preset |
| `estimate [repo]` | Rough agent cost per run, per model |
| `init <repo> [--preset id]` | A starter `explainer.json` with `TODO`s to fill |
| `changes <repo> [--data f --write]` | Draft change entries from git: human or agent, type, PR link, parts touched |
| `validate <file> [--repo dir] [--strict]` | Check references, lesson shape, writing style, and that every cited file exists |
| `build <file> [--out f] [--repo dir]` | Validate, then render the page |
| `presets` | Presets for full stack, backend API, frontend, mobile, and library or CLI repos |
| `patterns [id]` | Reusable flow shapes: auth, webhook, async job, cache, and more |
| `gallery` | The design kit page |
| `assets` | Logo and icon SVGs |

</details>

## What a run costs

The agent does the costly part: reading code and writing the explainer. These are estimated costs **per run** at [Anthropic API prices](https://claude.com/pricing), checked October 25, 2026.

| Run | Fable 5.1 | Opus 5.5 | Sonnet 5.5 | Haiku 5.5 |
|---|---|---|---|---|
| Update (new merges only) | $0.95 | $0.44 | $0.22 | $0.014 |
| Small repo (under 300 source files) | $1.60 | $0.74 | $0.37 | $0.024 |
| Medium repo (300 to 2,000 files) | $3.65 | $1.82 | $0.91 | $0.32 |
| Large repo (over 2,000 files) | $9.50 | $5.00 | $2.50 | $0.93 |

- **Sonnet 5.5** is a good default.
- **Opus 5.5** pays off on large or unfamiliar systems.
- **Haiku 5.5** suits a daily "what changed" refresh.

These are estimates, not quotes. They assume most repeated context comes from the prompt cache. Real cost depends on how many flows you ask for and how much code the agent reads. Run `repo-explainer estimate ./my-repo` for your repo's size. On a Claude subscription, usage counts against your plan instead. The assumptions are in [`scripts/lib/estimate.mjs`](scripts/lib/estimate.mjs).

## Built to stay consistent

Agents only write data. The design, components, icons and motion are fixed and tested, so every explainer looks the same and readers learn it once.

- **One data contract**, checked on every build. See [`references/data-schema.md`](references/data-schema.md).
- **Presets and flow patterns** so agents start from a proven shape.
- **Writing rules:** gist first, short sentences, no filler. Enforced as warnings.
- **Design guardrail tests** fail CI on shadows, gradients, off-palette colors or `innerHTML`.
- **Truthfulness:** every part and step cites real files, and `--repo` checks they exist. Change reasons come from PRs and commits, never made up.

## Security and privacy

- The scanner reads env var **names** from example files only. It never opens `.env`.
- All repo text renders as plain text, and embedded data is escaped. Links must be `http` or `https`.
- The page makes no network calls except loading Google Fonts. It falls back to system fonts offline.

Report vulnerabilities through [SECURITY.md](SECURITY.md).

## Credits

- **Visual design** follows [Duolingo](https://www.duolingo.com)'s design language: the palette and its color names, the type scale, and the pressable buttons.
- **Lesson format** is inspired by [Brilliant](https://brilliant.org): one idea per step, with a Continue button.
- **Explanation design** draws on Bret Victor's [Explorable Explanations](https://worrydream.com/ExplorableExplanations/), Red Blob Games, and the C4 model.

Repo Explainer is independent and not affiliated with Duolingo or Brilliant. It includes none of their logos, mascots, illustrations or fonts. Type is [Nunito](https://fonts.google.com/specimen/Nunito) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/). The icons are original.

## Contributing

Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) and run `npm run check` before opening a PR.

## License

[Apache License 2.0](LICENSE). If you use or adapt this project, keep the [NOTICE](NOTICE) file, which credits Cyrus David Pastelero, as the license requires.
````

## File: scripts/lib/assets.mjs
size: 3176
sha: 61337f18d0d2270ecbdb194cd2553d64c32115a0
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/assets.mjs

```text
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { TEMPLATE_DIR, escapeHtml } from './build.mjs';
import { NODE_KINDS } from './schema.mjs';

// Runs template/js/icons.js with a string-based `s()` so the exported SVG
// files are byte-for-byte the shapes the explainer draws. One source of truth.

const EXTRA_ICONS = ['robot', 'trophy', 'folder', 'file'];

function svgString(tag, attrs, ...children) {
  const attrText = Object.entries(attrs || {})
    .filter(([, v]) => v !== null && v !== undefined && v !== false)
    .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`)
    .join('');
  const inner = children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false).join('');
  return inner ? `<${tag}${attrText}>${inner}</${tag}>` : `<${tag}${attrText}/>`;
}

function loadIconModule() {
  const source = readFileSync(join(TEMPLATE_DIR, 'js', 'icons.js'), 'utf8');
  const context = vm.createContext({ s: svgString });
  vm.runInContext(`${source}\n;globalThis.__api = { icon, brandMark, PALETTE };`, context);
  return context.__api;
}

function standalone(svg, size) {
  return svg.replace('<svg ', `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `);
}

/**
 * @returns {{ name: string, svg: string }[]} every node-kind icon plus UI extras, as standalone SVG
 */
export function exportIcons(size = 64) {
  const { icon } = loadIconModule();
  return [...NODE_KINDS, ...EXTRA_ICONS].map((name) => ({ name, svg: `${standalone(icon(name), size)}\n` }));
}

/** @returns {{ name: string, svg: string }[]} logo mark, horizontal lockup, and social card */
export function exportLogos() {
  const { brandMark } = loadIconModule();
  const mark = brandMark();
  const inner = mark.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
  const font = "font-family=\"Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif\"";
  const lockup = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64">
<g transform="translate(0 4) scale(1.75)">${inner}</g>
<text x="68" y="42" ${font} font-size="30" font-weight="900" fill="#58cc02" letter-spacing="-0.3">Repo Explainer</text>
</svg>
`;
  const social = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
<rect width="1280" height="640" fill="#ffffff"/>
<circle cx="1050" cy="320" r="260" fill="#d7ffb8"/>
<circle cx="1180" cy="120" r="44" fill="#ffc800"/>
<g transform="translate(96 150) scale(3)">${inner}</g>
<text x="96" y="370" ${font} font-size="88" font-weight="900" fill="#58cc02" letter-spacing="-2">Repo Explainer</text>
<text x="96" y="440" ${font} font-size="34" font-weight="700" fill="#4b4b4b">Understand any repo end to end, even when 20 PRs land a day.</text>
</svg>
`;
  return [
    { name: 'mark', svg: standalone(mark, 128) + '\n' },
    { name: 'lockup', svg: lockup },
    { name: 'social-card', svg: social },
  ];
}

/** @returns {Record<string, { ink: string, wash: string, text: string }>} layer palette used by the template */
export function exportPalette() {
  return JSON.parse(JSON.stringify(loadIconModule().PALETTE));
}
```

## File: scripts/lib/build.mjs
size: 3168
sha: 82be970a7cb23f0500717ac669e192b6ee3f19d7
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/build.mjs

```text
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'template');

// Order matters only for readability; all modules share one function scope.
const SCRIPT_ORDER = ['dom.js', 'icons.js', 'map.js', 'models.js', 'flow.js', 'changes.js', 'lessons.js', 'sections.js', 'app.js'];

const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

export function escapeHtml(value) {
  return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
}

/**
 * Serialise data so it can never close the surrounding <script> tag.
 * @param {unknown} data
 * @returns {string}
 */
export function serializeForScript(data) {
  return JSON.stringify(data)
    .replace(/</g, '\<')
    .replace(/>/g, '\>')
    .replace(/&/g, '\&')
    .split(String.fromCharCode(0x2028)).join('\ ')
    .split(String.fromCharCode(0x2029)).join('\ ');
}

function readTemplate(templateDir, order = SCRIPT_ORDER) {
  const read = (file) => readFileSync(join(templateDir, file), 'utf8');
  const scripts = order.map((file) => `// ---- ${file} ----\n${read(join('js', file))}`).join('\n');
  return { html: read('explainer.html'), styles: read('styles.css'), scripts };
}

// One pass over the template, so text inserted for one placeholder (say, a
// title containing "__EXPLAINER_DATA__") is never scanned for another.
const PLACEHOLDER_PATTERN = /__TITLE__|__DESCRIPTION__|\/\*__STYLES__\*\/|\/\*__SCRIPTS__\*\/|__EXPLAINER_DATA__/g;

function assemble({ html, styles, scripts }, title, data, bootCall = '', description = '') {
  const values = {
    __TITLE__: escapeHtml(title),
    __DESCRIPTION__: escapeHtml(description),
    '/*__STYLES__*/': styles,
    '/*__SCRIPTS__*/': `(() => {\n'use strict';\n${scripts}\n${bootCall}\n})();`,
    __EXPLAINER_DATA__: serializeForScript(data),
  };
  return html.replace(PLACEHOLDER_PATTERN, (match) => values[match]);
}

/**
 * Render explainer data into one self-contained HTML file.
 * @param {object} data validated explainer data
 * @param {{ templateDir?: string }} [options]
 * @returns {string}
 */
export function buildExplainer(data, options = {}) {
  const parts = readTemplate(options.templateDir || TEMPLATE_DIR);
  const title = data && data.meta && data.meta.title ? `${data.meta.title} · Repo Explainer` : 'Repo Explainer';
  const description = data && data.meta && typeof data.meta.summary === 'string' ? data.meta.summary : '';
  return assemble(parts, title, data, 'boot();', description);
}

/**
 * Render the design-kit gallery (icons, palette, components, patterns).
 * @param {{ kinds: string[], patterns: object[], version?: string }} kit
 * @returns {string}
 */
export function buildGallery(kit, options = {}) {
  const order = SCRIPT_ORDER.filter((f) => f !== 'app.js').concat('gallery.js');
  const parts = readTemplate(options.templateDir || TEMPLATE_DIR, order);
  return assemble(parts, 'Repo Explainer kit', kit, 'bootGallery();', 'Icons, colors, components and flow patterns for Repo Explainer.');
}
```

## File: scripts/lib/changes.mjs
size: 4542
sha: 323ca57cfbe294378b7298182897b9ff96419da6
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/changes.mjs

```text
import { execFileSync } from 'node:child_process';

// Turns git history into draft "changes" entries. The agent must still fill
// in `why` from PR descriptions / commit bodies, and map files to node ids.

const FIELD = '';
const RECORD = '';
const DEFAULT_AGENT_PATTERNS = [/\[bot\]/i, /\bbot\b/i, /agent/i, /claude/i, /copilot/i, /codex/i, /devin/i, /cursor/i, /dependabot/i, /renovate/i];
const AGENT_TRAILERS = /^(Co-Authored-By|Generated-By|Assisted-By):.*\b(claude|copilot|codex|devin|cursor|agent|bot)\b/im;
const CONVENTIONAL = /^(feat|fix|refactor|perf|docs|test|chore|ci|security|build|style)(\(.+?\))?!?:\s*/i;
const PR_REF = /(?:#|!)(\d+)/;

function git(repo, args) {
  try {
    return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
  } catch (error) {
    throw new Error(`git ${args[0]} failed in ${repo}: ${error.stderr || error.message}`);
  }
}

/**
 * @param {string} author
 * @param {string} body
 * @param {RegExp[]} agentPatterns
 * @returns {'agent' | 'human'}
 */
export function classifyAuthor(author, body, agentPatterns = DEFAULT_AGENT_PATTERNS) {
  if (agentPatterns.some((p) => p.test(author))) return 'agent';
  return AGENT_TRAILERS.test(body) ? 'agent' : 'human';
}

/**
 * Parse one commit's subject into a title, kind and PR reference.
 * @param {string} subject
 */
export function parseSubject(subject) {
  const kindMatch = subject.match(CONVENTIONAL);
  const kind = kindMatch ? kindMatch[1].toLowerCase() : undefined;
  const ref = (subject.match(PR_REF) || [])[0];
  const title = subject.replace(CONVENTIONAL, '').replace(/\s*\((?:#|!)\d+\)\s*$/, '').replace(/^Merge pull request #\d+ from \S+\s*/, '').trim();
  return { title: title || subject, kind: kind === 'build' || kind === 'style' ? 'chore' : kind, ref };
}

// Each record: sha F date F author F subject F body F <newline-separated files>
function parseLog(raw) {
  return raw.split(RECORD).filter((chunk) => chunk.trim()).map((chunk) => {
    const [sha, date, author, subject, body = '', files = ''] = chunk.split(FIELD);
    return {
      sha: sha.trim(), date, author, subject, body: body.trim(),
      files: files.split('\n').map((f) => f.trim()).filter(Boolean),
    };
  });
}

/**
 * Map changed files onto node ids by the longest matching path prefix in node.files.
 * @param {string[]} files
 * @param {{ id: string, files?: string[] }[]} nodes
 * @returns {string[]}
 */
export function mapFilesToNodes(files, nodes = []) {
  const prefixes = nodes.flatMap((node) => (node.files || []).map((f) => ({ id: node.id, prefix: f.replace(/[:#]L?\d+.*$/, '') })))
    .filter((p) => p.prefix)
    .sort((a, b) => b.prefix.length - a.prefix.length);
  const ids = files.map((file) => (prefixes.find((p) => file === p.prefix || file.startsWith(p.prefix.endsWith('/') ? p.prefix : `${p.prefix}/`)) || {}).id);
  return [...new Set(ids.filter(Boolean))];
}

/**
 * Read recent history and draft change entries.
 * @param {string} repo path to a git repo
 * @param {{ since?: string, limit?: number, mergesOnly?: boolean, nodes?: object[], remoteUrl?: string }} [options]
 */
export function collectChanges(repo, options = {}) {
  const { since = '14 days ago', limit = 40, mergesOnly = false, nodes = [], remoteUrl } = options;
  const format = `${RECORD}%H${FIELD}%cI${FIELD}%an${FIELD}%s${FIELD}%b${FIELD}`;
  const args = ['log', `--since=${since}`, `-n${limit}`, `--pretty=format:${format}`, '--name-only'];
  if (mergesOnly) args.push('--merges', '--first-parent', '-m');
  if (remoteUrl && !/^https?:\/\//i.test(remoteUrl)) throw new Error(`invalid --remote "${remoteUrl}": must start with http:// or https://`);
  const commits = parseLog(git(repo, args));
  return commits.map((commit) => {
    const { title, kind, ref } = parseSubject(commit.subject);
    const prNumber = ref ? ref.slice(1) : undefined;
    return {
      id: commit.sha.slice(0, 10),
      title,
      date: commit.date,
      author: commit.author,
      authorType: classifyAuthor(commit.author, commit.body),
      ref: ref || commit.sha.slice(0, 7),
      url: remoteUrl && prNumber ? `${remoteUrl.replace(/\/$/, '')}/pull/${prNumber}` : undefined,
      kind,
      what: commit.body.split('\n').filter((l) => l.trim() && !/^[\w-]+-By:/i.test(l)).slice(0, 3).join(' ').slice(0, 400) || undefined,
      why: 'TODO: the reason for this change (read the PR description or linked issue)',
      nodes: mapFilesToNodes(commit.files, nodes),
      files: commit.files.slice(0, 20),
    };
  });
}
```

## File: scripts/lib/estimate.mjs
size: 2896
sha: 874272101287b18b598aceee3e5bffe6d9b7bfc8
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/estimate.mjs

```text
// Rough cost of one agent run, by repo size and model. Usage profiles come
// from what a run does: scan, read entry points and traced files, write the
// JSON, rebuild a few times. Most re-sent context is served from the prompt
// cache, which is why cache reads dominate the token count but not the bill.

// USD per million tokens. Source: https://claude.com/pricing (checked 2026-10-25).
export const PRICING = {
  checkedAt: '2026-10-25',
  source: 'https://claude.com/pricing',
  models: {
    'fable-5.1': { label: 'Fable 5.1', input: 10, output: 50, cacheRead: 0.25 },
    'opus-5.5': { label: 'Opus 5.5', input: 4, output: 20, cacheRead: 0.2 },
    'sonnet-5.5': { label: 'Sonnet 5.5', input: 2, output: 10, cacheRead: 0.1 },
    // Haiku 5.5 is cheaper for prompts ≤100K tokens; small runs stay under it.
    'haiku-5.5': {
      label: 'Haiku 5.5', input: 0.1, output: 0.5, cacheRead: 0.01,
      longContext: { above: 100000, input: 0.5, output: 2.5, cacheRead: 0.05 },
    },
  },
};

// Millions of tokens per run. "fresh" = uncached input (file reads, tool output).
export const USAGE = {
  update: { label: 'Update (new merges only)', fresh: 0.04, cacheRead: 0.6, output: 0.008, peakContext: 60000 },
  small: { label: 'Small repo (< 300 source files)', fresh: 0.06, cacheRead: 1.0, output: 0.015, peakContext: 80000 },
  medium: { label: 'Medium repo (300 to 2,000 files)', fresh: 0.15, cacheRead: 3.6, output: 0.025, peakContext: 130000 },
  large: { label: 'Large repo (2,000+ files)', fresh: 0.4, cacheRead: 12, output: 0.05, peakContext: 250000 },
};

const SOURCE_EXT = /\.(m?[jt]sx?|py|go|rb|java|kt|swift|rs|php|cs|c|cc|cpp|h|scala|ex|exs|dart|vue|svelte|sql)$/;

/**
 * @param {{ languages?: { ext: string, count: number }[], fileCount?: number }} scan
 * @returns {'small' | 'medium' | 'large'}
 */
export function sizeOf(scan) {
  const sourceFiles = (scan.languages || []).filter((l) => SOURCE_EXT.test(`x${l.ext}`)).reduce((n, l) => n + l.count, 0);
  if (sourceFiles >= 2000) return 'large';
  if (sourceFiles >= 300) return 'medium';
  return 'small';
}

/**
 * @param {keyof USAGE} profile
 * @param {keyof PRICING['models']} model
 * @returns {number} estimated USD for one run
 */
export function costOf(profile, model) {
  const use = USAGE[profile];
  const base = PRICING.models[model];
  const rate = base.longContext && use.peakContext > base.longContext.above ? base.longContext : base;
  return use.fresh * rate.input + use.cacheRead * rate.cacheRead + use.output * rate.output;
}

export function costTable(profiles = Object.keys(USAGE)) {
  return profiles.map((profile) => ({
    profile,
    label: USAGE[profile].label,
    costs: Object.fromEntries(Object.keys(PRICING.models).map((model) => [model, costOf(profile, model)])),
  }));
}

export function formatUsd(value) {
  return value < 0.1 ? `$${value.toFixed(3)}` : `$${value.toFixed(2)}`;
}
```

## File: scripts/lib/init.mjs
size: 2408
sha: 01a62018dc2d6ac0d58346e6f52a017466a13de0
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/init.mjs

```text
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SCHEMA_VERSION } from './schema.mjs';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const PRESETS_DIR = join(ROOT, 'presets');
export const PATTERNS_DIR = join(ROOT, 'patterns');

function readJsonDir(dir) {
  return readdirSync(dir).filter((f) => f.endsWith('.json')).sort()
    .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
}

export function listPresets() {
  return readJsonDir(PRESETS_DIR);
}

export function listPatterns() {
  return readJsonDir(PATTERNS_DIR);
}

export function loadPreset(id) {
  const preset = listPresets().find((p) => p.id === id);
  if (!preset) throw new Error(`unknown preset "${id}" (available: ${listPresets().map((p) => p.id).join(', ')})`);
  return preset;
}

/**
 * Build a starter explainer from a preset and (optionally) scan facts.
 * Every TODO is intentional: validate warns until the agent replaces them.
 * @param {string} presetId
 * @param {object} [scan] output of scanRepo()
 */
export function createStarter(presetId, scan = {}) {
  const preset = loadPreset(presetId);
  const today = new Date().toISOString().slice(0, 10);
  return {
    schemaVersion: SCHEMA_VERSION,
    meta: {
      title: `${scan.name || 'This repo'}, explained`,
      repo: scan.name || 'TODO: owner/repo',
      summary: 'TODO: two plain sentences. What this does, and for whom.',
      stack: (scan.frameworks || []).slice(0, 8),
      preset: preset.id,
      generatedAt: today,
      chapters: preset.chapters,
    },
    layers: preset.layers,
    nodes: preset.starterNodes.map((node) => ({ ...node, summary: 'TODO: one sentence on what this part does', files: [] })),
    edges: [],
    flows: preset.mustTrace.slice(0, 1).map((goal, i) => ({
      id: `flow-${i + 1}`, title: 'TODO: flow name', trigger: 'TODO: what starts it', summary: goal, steps: [],
    })),
    changes: [],
    tree: (scan.topLevel || []).filter((t) => t.path.endsWith('/')).slice(0, 12).map((t) => ({ path: t.path, note: 'TODO' })),
    dataModel: [],
    concepts: [],
    quiz: [],
    glossary: [],
    runbook: (scan.scripts || []).filter((sc) => /^(dev|start|test|build)$/.test(sc.name)).slice(0, 4)
      .map((sc) => ({ label: sc.name, command: `npm run ${sc.name}`, note: sc.command })),
  };
}
```

## File: scripts/lib/scan.mjs
size: 10438
sha: 531a64bf9e22a6f08463137dd595082446e24d92
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/scan.mjs

```text
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { basename, extname, join, relative, sep } from 'node:path';

// Facts, not conclusions: the scan tells an agent where to look. The agent
// still has to read the code before claiming anything in the explainer.

const IGNORED_DIRS = new Set([
  '.git', 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit', '.turbo', '.vercel',
  'coverage', 'vendor', 'target', '.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache',
  '.idea', '.vscode', 'Pods', 'DerivedData', '.gradle', '.dart_tool', 'tmp', '.cache',
]);

const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
const MAX_FILES = 20000;
const MAX_GREP_BYTES = 200 * 1024;
const MAX_ROUTE_HITS = 80;

const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'pyproject.toml', 'requirements.txt',
  'go.mod', 'Cargo.toml', 'Gemfile', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json', 'Package.swift',
  'pubspec.yaml', 'mix.exs', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'wrangler.toml',
  'vercel.json', 'netlify.toml', 'serverless.yml', 'Procfile', 'fly.toml'];

const JS_FRAMEWORKS = {
  next: 'Next.js', react: 'React', vue: 'Vue', nuxt: 'Nuxt', svelte: 'Svelte', '@sveltejs/kit': 'SvelteKit', astro: 'Astro',
  '@remix-run/react': 'Remix', 'solid-js': 'Solid', '@angular/core': 'Angular', 'react-native': 'React Native', expo: 'Expo',
  electron: 'Electron', vite: 'Vite', express: 'Express', fastify: 'Fastify', koa: 'Koa', hono: 'Hono', '@nestjs/core': 'NestJS',
  '@trpc/server': 'tRPC', graphql: 'GraphQL', 'socket.io': 'Socket.IO', prisma: 'Prisma', '@prisma/client': 'Prisma',
  'drizzle-orm': 'Drizzle', typeorm: 'TypeORM', mongoose: 'Mongoose', sequelize: 'Sequelize', knex: 'Knex',
  '@supabase/supabase-js': 'Supabase', firebase: 'Firebase', bullmq: 'BullMQ', ioredis: 'Redis', redis: 'Redis',
  stripe: 'Stripe', 'next-auth': 'NextAuth', '@clerk/nextjs': 'Clerk', zod: 'Zod', tailwindcss: 'Tailwind CSS',
  '@tanstack/react-query': 'TanStack Query', 'ai': 'Vercel AI SDK', '@anthropic-ai/sdk': 'Anthropic SDK', openai: 'OpenAI SDK',
  'commander': 'Commander CLI', yargs: 'yargs CLI', '@modelcontextprotocol/sdk': 'MCP SDK',
};

const TEXT_FRAMEWORKS = [
  { file: /^(pyproject\.toml|requirements.*\.txt)$/, pattern: /\b(fastapi|django|flask|sqlalchemy|celery|pydantic|alembic|typer|click|streamlit|langchain)\b/gi },
  { file: /^go\.mod$/, pattern: /\b(gin-gonic\/gin|labstack\/echo|gofiber\/fiber|go-chi\/chi|gorm\.io|sqlc|cobra)\b/gi },
  { file: /^Cargo\.toml$/, pattern: /^\s*(axum|actix-web|rocket|tokio|diesel|sqlx|clap|serde|tauri)\s*=/gim },
  { file: /^Gemfile$/, pattern: /\b(rails|sinatra|sidekiq|rspec)\b/gi },
  { file: /^(pom\.xml|build\.gradle(\.kts)?)$/, pattern: /\b(spring-boot|quarkus|micronaut|hibernate|ktor)\b/gi },
  { file: /^composer\.json$/, pattern: /\b(laravel\/framework|symfony)\b/gi },
];

const ROUTE_PATTERNS = [
  /\b(app|router|server|api)\.(get|post|put|patch|delete|all|route)\s*\(\s*['"`]/,
  /@(app|router|bp|api)\.(get|post|put|patch|delete|route)\s*\(/,
  /@(Get|Post|Put|Patch|Delete|Controller|RequestMapping|GetMapping|PostMapping)\s*\(/,
  /\bexport\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/,
  /\b(r|e|g|mux|router)\.(GET|POST|PUT|PATCH|DELETE|HandleFunc|Handle)\s*\(/,
  /\bpath\s*\(\s*['"][^'"]*['"]\s*,/,
  /\.route\s*\(\s*['"]\//,
];

const ROUTE_FILE_HINTS = [/\/api\//, /routes?\//, /controllers?\//, /handlers?\//, /\/pages\/api\//, /route\.(t|j)sx?$/, /urls\.py$/, /views\.py$/];

const DATA_HINTS = [/schema\.prisma$/, /drizzle.*schema/i, /\/migrations?\//, /models?\.(py|rb|go|ts)$/, /\/models\//, /\/entities\//, /\.sql$/, /supabase\/migrations/];

const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.js', 'src/main.ts', 'src/main.tsx', 'src/main.js', 'src/app.ts', 'src/server.ts',
  'index.ts', 'index.js', 'server.js', 'server.ts', 'app.py', 'main.py', 'manage.py', 'main.go', 'cmd', 'src/main.rs', 'src/lib.rs',
  'app/layout.tsx', 'src/app/layout.tsx', 'pages/_app.tsx', 'src/pages/_app.tsx', 'App.tsx', 'src/App.tsx', 'lib/main.dart'];

const FRONTEND = new Set(['Next.js', 'React', 'Vue', 'Nuxt', 'Svelte', 'SvelteKit', 'Astro', 'Remix', 'Solid', 'Angular', 'Vite']);
const MOBILE = new Set(['React Native', 'Expo']);
const BACKEND = new Set(['Express', 'Fastify', 'Koa', 'Hono', 'NestJS', 'tRPC', 'fastapi', 'django', 'flask', 'gin-gonic/gin',
  'labstack/echo', 'gofiber/fiber', 'go-chi/chi', 'axum', 'actix-web', 'rocket', 'rails', 'sinatra', 'spring-boot', 'laravel/framework']);

function walk(root) {
  const files = [];
  const stack = [root];
  while (stack.length && files.length < MAX_FILES) {
    const dir = stack.pop();
    let entries = [];
    try {
      entries = readdirSync(dir, { withFileTypes: true });
    } catch {
      continue; // unreadable directory: skip, the scan is best-effort
    }
    entries.forEach((entry) => {
      if (entry.isSymbolicLink()) return;
      const full = join(dir, entry.name);
      if (entry.isDirectory()) {
        if (!IGNORED_DIRS.has(entry.name)) stack.push(full);
      } else if (entry.isFile() && !IGNORED_FILES.has(entry.name)) {
        files.push(relative(root, full).split(sep).join('/'));
      }
    });
  }
  return files.sort();
}

function readSafe(path, limit = MAX_GREP_BYTES) {
  try {
    if (statSync(path).size > limit) return '';
    return readFileSync(path, 'utf8');
  } catch {
    return '';
  }
}

function languages(files) {
  const counts = files.reduce((acc, file) => {
    const ext = extname(file).toLowerCase();
    return ext ? { ...acc, [ext]: (acc[ext] || 0) + 1 } : acc;
  }, {});
  return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([ext, count]) => ({ ext, count }));
}

function topLevel(root, files) {
  const counts = files.reduce((acc, file) => {
    const [head, ...rest] = file.split('/');
    const key = rest.length ? `${head}/` : head;
    return { ...acc, [key]: (acc[key] || 0) + 1 };
  }, {});
  return Object.entries(counts).map(([path, fileCount]) => ({ path, files: fileCount }));
}

function packageFacts(root, files) {
  const pkgFiles = files.filter((f) => basename(f) === 'package.json' && f.split('/').length <= 4);
  const frameworks = new Set();
  const scripts = [];
  pkgFiles.forEach((file) => {
    let pkg;
    try {
      pkg = JSON.parse(readSafe(join(root, file)));
    } catch {
      return; // malformed package.json: report nothing rather than guess
    }
    const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
    Object.keys(deps).forEach((dep) => { if (JS_FRAMEWORKS[dep]) frameworks.add(JS_FRAMEWORKS[dep]); });
    Object.entries(pkg.scripts || {}).slice(0, 15).forEach(([name, command]) => scripts.push({ package: file, name, command }));
  });
  return { frameworks, scripts };
}

function textFrameworks(root, files, frameworks) {
  files.filter((f) => f.split('/').length <= 3).forEach((file) => {
    const rule = TEXT_FRAMEWORKS.find((r) => r.file.test(basename(file)));
    if (!rule) return;
    const content = readSafe(join(root, file));
    [...content.matchAll(rule.pattern)].forEach((m) => frameworks.add(m[1].toLowerCase()));
  });
  return frameworks;
}

function routes(root, files) {
  const hits = [];
  const candidates = files.filter((f) => /\.(m?[jt]sx?|py|go|rb|java|kt|php|rs|cs)$/.test(f));
  const prioritised = [...candidates.filter((f) => ROUTE_FILE_HINTS.some((r) => r.test(`/${f}`))),
    ...candidates.filter((f) => !ROUTE_FILE_HINTS.some((r) => r.test(`/${f}`)))];
  for (const file of prioritised) {
    if (hits.length >= MAX_ROUTE_HITS) break;
    const lines = readSafe(join(root, file)).split('\n');
    lines.forEach((line, i) => {
      if (hits.length < MAX_ROUTE_HITS && ROUTE_PATTERNS.some((p) => p.test(line))) {
        hits.push({ file: `${file}:${i + 1}`, line: line.trim().slice(0, 140) });
      }
    });
  }
  const fileRoutes = files.filter((f) => /(^|\/)app\/.*\/route\.(t|j)sx?$|(^|\/)pages\/api\//.test(f));
  return { handlers: hits, fileRoutes: fileRoutes.slice(0, 60) };
}

function envKeys(root, files) {
  const examples = files.filter((f) => /(^|\/)\.env\.(example|sample|template)$|(^|\/)\.env\.local\.example$/.test(f));
  const keys = new Set();
  examples.forEach((file) => readSafe(join(root, file)).split('\n').forEach((line) => {
    const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]+)\s*=/);
    if (match) keys.add(match[1]); // names only — values are never read into the scan
  }));
  return { files: examples, keys: [...keys].sort() };
}

function suggestPreset(frameworks, routeFacts, dataFiles) {
  const has = (set) => [...frameworks].some((f) => set.has(f));
  const hasApi = has(BACKEND) || routeFacts.handlers.length > 0 || routeFacts.fileRoutes.length > 0;
  if (has(MOBILE)) return 'mobile-app';
  if (has(FRONTEND) && (hasApi || dataFiles.length > 0)) return 'fullstack-web';
  if (has(FRONTEND)) return 'frontend-spa';
  if (hasApi) return 'backend-api';
  return 'library-cli';
}

/**
 * Collect navigation facts about a repository.
 * @param {string} root absolute or relative path to the repo
 * @returns {object} facts for an agent to verify, never to copy blindly
 */
export function scanRepo(root) {
  if (!existsSync(root) || !statSync(root).isDirectory()) {
    throw new Error(`scan failed: "${root}" is not a directory`);
  }
  const files = walk(root);
  const { frameworks, scripts } = packageFacts(root, files);
  textFrameworks(root, files, frameworks);
  const routeFacts = routes(root, files);
  const dataFiles = files.filter((f) => DATA_HINTS.some((r) => r.test(`/${f}`))).slice(0, 60);
  return {
    name: basename(root.replace(/\/$/, '')) || 'repo',
    fileCount: files.length,
    truncated: files.length >= MAX_FILES,
    topLevel: topLevel(root, files),
    languages: languages(files),
    manifests: files.filter((f) => MANIFESTS.includes(basename(f)) && f.split('/').length <= 4),
    frameworks: [...frameworks].sort(),
    entrypoints: ENTRY_CANDIDATES.filter((c) => existsSync(join(root, c))),
    routes: routeFacts,
    dataFiles,
    env: envKeys(root, files),
    scripts,
    docs: files.filter((f) => /(^|\/)(README|ARCHITECTURE|CONTRIBUTING|ADR|docs\/).*/i.test(f) && f.split('/').length <= 3).slice(0, 30),
    suggestedPreset: suggestPreset(frameworks, routeFacts, dataFiles),
  };
}
```

## File: scripts/lib/schema.mjs
size: 1754
sha: b0f6544b3014e182f6f3e83a0166cb998b0bbd13
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/schema.mjs

```text
// Single source of truth for the explainer data contract.
// Documented for humans in references/data-schema.md — keep them in sync.

export const SCHEMA_VERSION = 1;

export const CHAPTER_KEYS = ['map', 'flows', 'changes', 'tree', 'data', 'concepts', 'quiz', 'glossary', 'run'];

export const NODE_KINDS = [
  'user', 'ui', 'mobile', 'api', 'service', 'db', 'queue', 'cache',
  'external', 'job', 'cli', 'auth', 'storage', 'library',
];

export const LAYER_COLORS = ['green', 'blue', 'navy', 'midnight', 'yellow', 'pink'];

export const STEP_KINDS = ['request', 'response', 'async', 'data', 'internal'];

export const EDGE_KINDS = ['sync', 'async', 'data'];

export const AUTHOR_TYPES = ['human', 'agent'];

export const CHANGE_KINDS = ['feat', 'fix', 'refactor', 'perf', 'docs', 'test', 'chore', 'ci', 'security'];

export const PLACEHOLDER = /\bTODO\b/;

// Lesson-shape limits from SKILL.md. Exceeding them is a warning (an error
// under --strict) because it makes the page harder to read, not broken.
export const LIMITS = {
  stepsPerFlow: { min: 3, max: 12 },
  actorsPerFlow: 7,
  layers: 8,
  nodeLabelWords: 4,
  stepLabelWords: 7,
  edgeLabelWords: 4,
  detailSentences: 3,
  concepts: 6,
  quiz: 6,
};

export const MARKUP = /<\/?[a-z][a-z0-9-]*(\s[^>]*)?>/i;

// Prose fields are read by people; code-ish fields are exempt from style checks.
export const NON_PROSE_KEYS = new Set(['code', 'file', 'files', 'path', 'command', 'url', 'ref', 'id', 'type', 'name', 'lang', 'from', 'to', 'layer', 'kind', 'color', 'date', 'generatedAt', 'commit', 'repo', 'author', 'authorType', 'chapters', 'preset', 'stack', '$schema']);

// Em dash, en dash, or a spaced hyphen used as punctuation.
export const DASH = /\s[—–-]\s|—|–/;
```

## File: scripts/lib/validate.mjs
size: 11088
sha: 922536157eddf2b0a237654d39ce8e2fac08a6cb
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/scripts/lib/validate.mjs

```text
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import {
  AUTHOR_TYPES, CHANGE_KINDS, CHAPTER_KEYS, DASH, EDGE_KINDS, LAYER_COLORS, LIMITS, MARKUP, NODE_KINDS, NON_PROSE_KEYS, PLACEHOLDER, STEP_KINDS,
} from './schema.mjs';

/**
 * @typedef {{ errors: string[], warnings: string[] }} ValidationResult
 */

const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const isText = (value) => typeof value === 'string' && value.trim().length > 0;
const arr = (value) => (Array.isArray(value) ? value : []);

function collector() {
  const errors = [];
  const warnings = [];
  return {
    error: (path, message) => errors.push(`${path}: ${message}`),
    warn: (path, message) => warnings.push(`${path}: ${message}`),
    result: () => ({ errors, warnings }),
  };
}

function requireText(report, path, value) {
  if (!isText(value)) report.error(path, 'must be a non-empty string');
}

function checkEnum(report, path, value, allowed) {
  if (value !== undefined && !allowed.includes(value)) {
    report.warn(path, `"${value}" is not one of ${allowed.join(", ")}. It will render with a default style`);
  }
}

function checkUniqueIds(report, path, items) {
  const seen = new Set();
  items.forEach((item, i) => {
    requireText(report, `${path}[${i}].id`, item && item.id);
    if (item && seen.has(item.id)) report.error(`${path}[${i}].id`, `duplicate id "${item.id}"`);
    if (item) seen.add(item.id);
  });
  return seen;
}

function checkMeta(report, meta) {
  if (!isObject(meta)) return report.error('meta', 'is required');
  requireText(report, 'meta.title', meta.title);
  requireText(report, 'meta.summary', meta.summary);
  arr(meta.chapters).forEach((key, i) => {
    if (!CHAPTER_KEYS.includes(key)) report.error(`meta.chapters[${i}]`, `unknown chapter "${key}" (use ${CHAPTER_KEYS.join(', ')})`);
  });
  return undefined;
}

function checkGraph(report, data) {
  const layers = arr(data.layers);
  const nodes = arr(data.nodes);
  if (!layers.length) report.error('layers', 'needs at least one layer');
  if (!nodes.length) report.error('nodes', 'needs at least one node');
  const layerIds = checkUniqueIds(report, 'layers', layers);
  layers.forEach((layer, i) => {
    requireText(report, `layers[${i}].label`, layer.label);
    checkEnum(report, `layers[${i}].color`, layer.color, LAYER_COLORS);
  });
  const nodeIds = checkUniqueIds(report, 'nodes', nodes);
  nodes.forEach((node, i) => {
    requireText(report, `nodes[${i}].label`, node.label);
    if (!layerIds.has(node.layer)) report.error(`nodes[${i}].layer`, `"${node.layer}" is not a layer id`);
    checkEnum(report, `nodes[${i}].kind`, node.kind, NODE_KINDS);
    if (!arr(node.files).length && node.kind !== 'user') report.warn(`nodes[${i}].files`, `"${node.id}" cites no files, so readers cannot find it in the code`);
  });
  layers.forEach((layer, i) => {
    if (!nodes.some((node) => node.layer === layer.id)) report.warn(`layers[${i}]`, `layer "${layer.id}" has no nodes`);
  });
  arr(data.edges).forEach((edge, i) => {
    if (!nodeIds.has(edge.from)) report.error(`edges[${i}].from`, `"${edge.from}" is not a node id`);
    if (!nodeIds.has(edge.to)) report.error(`edges[${i}].to`, `"${edge.to}" is not a node id`);
    checkEnum(report, `edges[${i}].kind`, edge.kind, EDGE_KINDS);
  });
  return nodeIds;
}

function checkFlows(report, flows, nodeIds) {
  if (!flows.length) report.warn('flows', 'no end-to-end flow traced. This is the most useful chapter');
  checkUniqueIds(report, 'flows', flows);
  flows.forEach((flow, i) => {
    requireText(report, `flows[${i}].title`, flow.title);
    const steps = arr(flow.steps);
    if (!steps.length) report.error(`flows[${i}].steps`, 'needs at least one step');
    steps.forEach((step, j) => {
      const path = `flows[${i}].steps[${j}]`;
      requireText(report, `${path}.label`, step.label);
      if (!nodeIds.has(step.from)) report.error(`${path}.from`, `"${step.from}" is not a node id`);
      if (!nodeIds.has(step.to)) report.error(`${path}.to`, `"${step.to}" is not a node id`);
      checkEnum(report, `${path}.kind`, step.kind, STEP_KINDS);
      if (!isText(step.file)) report.warn(`${path}.file`, 'no file cited for this step');
    });
  });
}

function checkChanges(report, changes, nodeIds) {
  changes.forEach((change, i) => {
    const path = `changes[${i}]`;
    requireText(report, `${path}.title`, change.title);
    if (Number.isNaN(Date.parse(change.date))) report.error(`${path}.date`, 'must be an ISO date');
    checkEnum(report, `${path}.authorType`, change.authorType, AUTHOR_TYPES);
    checkEnum(report, `${path}.kind`, change.kind, CHANGE_KINDS);
    if (change.url !== undefined && !/^https?:\/\//i.test(String(change.url))) report.error(`${path}.url`, 'must start with http:// or https://');
    if (!isText(change.why)) report.warn(`${path}.why`, 'missing the reason. The "why" is the point of this chapter');
    arr(change.nodes).forEach((id, j) => {
      if (!nodeIds.has(id)) report.error(`${path}.nodes[${j}]`, `"${id}" is not a node id`);
    });
  });
}

function checkLessons(report, data) {
  arr(data.quiz).forEach((q, i) => {
    requireText(report, `quiz[${i}].question`, q.question);
    const options = arr(q.options);
    if (options.length < 2) report.error(`quiz[${i}].options`, 'needs at least two options');
    if (!Number.isInteger(q.answer) || q.answer < 0 || q.answer >= options.length) {
      report.error(`quiz[${i}].answer`, `must be an index between 0 and ${Math.max(options.length - 1, 0)}`);
    }
  });
  arr(data.concepts).forEach((c, i) => {
    requireText(report, `concepts[${i}].title`, c.title);
    requireText(report, `concepts[${i}].body`, c.body);
  });
  const models = new Set(arr(data.dataModel).map((m) => m.name));
  arr(data.dataModel).forEach((m, i) => arr(m.relations).forEach((rel, j) => {
    if (!models.has(rel.to)) report.warn(`dataModel[${i}].relations[${j}].to`, `"${rel.to}" is not a model in dataModel`);
  }));
}

function citedFiles(data) {
  return [
    ...arr(data.nodes).flatMap((n) => arr(n.files)),
    ...arr(data.flows).flatMap((f) => arr(f.steps).map((st) => st.file)),
    ...arr(data.concepts).map((c) => c.file),
    ...arr(data.tree).map((t) => t.path),
  ].filter(isText);
}

function checkFilesExist(report, data, repoRoot) {
  const missing = [...new Set(citedFiles(data))]
    .map((file) => file.replace(/[:#]L?\d+(-L?\d+)?$/, '').replace(/\/$/, ''))
    .filter((file) => !existsSync(resolve(repoRoot, file)));
  missing.forEach((file) => report.error('files', `cited path "${file}" does not exist in ${repoRoot}`));
}

const words = (value) => (isText(value) ? value.trim().split(/\s+/).length : 0);
const sentences = (value) => (isText(value) ? (value.match(/[.!?](\s|$)/g) || []).length : 0);

function checkLessonShape(report, data) {
  if (arr(data.layers).length > LIMITS.layers) report.warn('layers', `${arr(data.layers).length} layers. Group into ${LIMITS.layers} or fewer`);
  arr(data.nodes).forEach((n, i) => {
    if (words(n.label) > LIMITS.nodeLabelWords) report.warn(`nodes[${i}].label`, `"${n.label}" is long. Keep node labels to ${LIMITS.nodeLabelWords} words`);
  });
  arr(data.edges).forEach((e, i) => {
    if (words(e.label) > LIMITS.edgeLabelWords) report.warn(`edges[${i}].label`, `keep edge labels to ${LIMITS.edgeLabelWords} words`);
  });
  arr(data.flows).forEach((flow, i) => {
    const steps = arr(flow.steps);
    const { min, max } = LIMITS.stepsPerFlow;
    if (steps.length && (steps.length < min || steps.length > max)) report.warn(`flows[${i}].steps`, `${steps.length} steps. Aim for ${min}–${max}; split long flows`);
    const actors = new Set(steps.flatMap((st) => [st.from, st.to])).size;
    if (actors > LIMITS.actorsPerFlow) report.warn(`flows[${i}]`, `${actors} actors. Group parts so a flow has at most ${LIMITS.actorsPerFlow}`);
    steps.forEach((st, j) => {
      if (words(st.label) > LIMITS.stepLabelWords) report.warn(`flows[${i}].steps[${j}].label`, `keep step labels to ${LIMITS.stepLabelWords} words. Put the rest in detail`);
      if (sentences(st.detail) > LIMITS.detailSentences) report.warn(`flows[${i}].steps[${j}].detail`, `one idea per step. Keep detail to ${LIMITS.detailSentences} sentences`);
    });
  });
  if (arr(data.concepts).length > LIMITS.concepts) report.warn('concepts', `keep to ${LIMITS.concepts} key ideas`);
  if (arr(data.quiz).length > LIMITS.quiz) report.warn('quiz', `keep to ${LIMITS.quiz} questions`);
}

// Writing rule from SKILL.md: say it plainly, no dashes as punctuation.
function checkProseStyle(report, data) {
  const walk = (value, path, key) => {
    if (NON_PROSE_KEYS.has(key)) return;
    if (typeof value === 'string' && DASH.test(value)) report.warn(path, 'uses a dash as punctuation. Split it into two sentences, or use a colon or comma');
    else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`, key));
    else if (value !== null && typeof value === 'object') Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k, k));
  };
  walk(data, '', '');
}

// Strings render as text, so markup would show up literally — and signals an
// attempt to restyle the page. Code fields are exempt.
function checkNoMarkup(report, data) {
  const walk = (value, path, key) => {
    if (typeof value === 'string' && key !== 'code' && MARKUP.test(value)) report.error(path, 'contains HTML markup. Strings are plain text; styling comes from the template');
    else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`, key));
    else if (isObject(value)) Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k, k));
  };
  walk(data, '', '');
}

function checkPlaceholders(report, data) {
  const hits = [];
  const walk = (value, path) => {
    if (typeof value === 'string' && PLACEHOLDER.test(value)) hits.push(path);
    else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
    else if (isObject(value)) Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k));
  };
  walk(data, '');
  hits.forEach((path) => report.warn(path, 'still contains a TODO placeholder'));
}

/**
 * Validate an explainer data object.
 * @param {unknown} data
 * @param {{ repoRoot?: string }} [options] when repoRoot is given, every cited file must exist there
 * @returns {ValidationResult}
 */
export function validateExplainer(data, options = {}) {
  const report = collector();
  if (!isObject(data)) {
    report.error('root', 'explainer data must be a JSON object');
    return report.result();
  }
  checkMeta(report, data.meta);
  const nodeIds = checkGraph(report, data);
  checkFlows(report, arr(data.flows), nodeIds);
  checkChanges(report, arr(data.changes), nodeIds);
  checkLessons(report, data);
  checkLessonShape(report, data);
  checkNoMarkup(report, data);
  checkProseStyle(report, data);
  checkPlaceholders(report, data);
  if (options.repoRoot) checkFilesExist(report, data, options.repoRoot);
  return report.result();
}
```

## File: references/data-schema.md
size: 4402
sha: 8c970a6dc6e2adce9b0ad3b85e5fd6446da85b3e
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/references/data-schema.md

```text
# Data schema (`explainer.json`)

One JSON file drives the whole page. The enforced contract is `scripts/lib/schema.mjs` + `scripts/lib/validate.mjs`; this page explains it. A complete example lives at `examples/taskflow.explainer.json`.

Every string is rendered as **plain text**: markup is rejected, and newlines only matter inside `code`.

## `meta` (required)

| Field | Type | Notes |
|---|---|---|
| `title` | string, **required** | `"<Repo>, explained"` reads well. |
| `summary` | string, **required** | Two plain sentences: what it does and for whom. |
| `repo` | string | `owner/name`. |
| `stack` | string[] | ≤ 8 chips, e.g. `"Next.js 15"`. |
| `preset` | string | The preset id you used. |
| `chapters` | string[] | The order of chapters, from `map, flows, changes, tree, data, concepts, quiz, glossary, run`. Chapters with no data are skipped automatically. |
| `generatedAt` | ISO date | Shown in the footer. |
| `commit` | string | The SHA the explainer describes. |

`intros` (optional) overrides a chapter's heading or lead: `{ "flows": { "title": "…", "lead": "…" } }`.

## `layers` (required, 1–8)

`{ id, label, color }`. Layers become the map's columns, from left to right in array order, so order them by distance from the user. `color` is one of `green, blue, navy, midnight, yellow, pink`.

## `nodes` (required)

`{ id, label, layer, kind, tech?, summary, files[] }`

- `kind` is one of `user, ui, mobile, api, service, db, queue, cache, external, job, cli, auth, storage, library`, and picks the icon (see the gallery).
- `files` holds paths or folders that exist (`--repo` checks this). Folders end in `/`.
- Keep `label` to 3 words or fewer and `summary` to one sentence.

## `edges`

`{ from, to, label?, kind? }`. `kind`: `sync` (default, solid), `async` (dashed) or `data`. Selecting a node animates only its own edges, so a dense graph stays readable.

## `flows`: the core chapter

`{ id, title, trigger, summary, steps[] }`, with 4–12 steps and at most 7 distinct actors.

Each step is `{ from, to, label, kind, detail?, why?, file, code?, lang? }`:

| Field | Notes |
|---|---|
| `from`, `to` | Node ids. When they are the same, the step is drawn as a self-loop (internal work). |
| `kind` | `request`, `response` (dashed), `async` (dashed), `data` or `internal`. |
| `label` | ≤ 6 words, an action: "Checks the session". |
| `detail` | ≤ 2 sentences: what happens. |
| `why` | Optional. The design reason, shown as a green callout. Use it sparingly, where a reader would ask "why like this?" |
| `file` | `path:line`. Required in practice: it is a warning without it, and an error under `--strict`. |
| `code` | Optional. ≤ 8 lines of the real code at that line, trimmed. |

## `changes`: what changed and why

`{ id, title, date, author, authorType, ref, url?, kind, what, why, risk?, nodes[], files[] }`

- Draft these with `repo-explainer changes`. It fills `authorType` (`agent` / `human`, detected from author names and `Co-Authored-By` trailers), `kind` (from conventional-commit prefixes), `files`, and `nodes` (mapped through each node's `files`).
- **You** fill in `why` from the PR description, linked issue or commit body. If there is none, write "No reason recorded".
- `risk` is optional: what could break, or what to watch.

## `tree`

`{ path, note, key? }`. Indentation comes from path depth. Mark the 2–4 folders where most changes happen with `key: true`.

## `dataModel`

`{ name, summary?, fields: [{ name, type, note?, ref? }], relations: [{ to, kind }] }`, where `to` is another model's `name`.

- The chapter draws an ER diagram from `relations`. Use `kind` values `has many`, `has one`, `belongs to` or `many to many`, so the lines get 1 / N cardinality. List each pair once; the mirror relation is merged.
- `id` is marked PK. A field is marked FK when it is named `<model>Id` or `<model>_id`, or when you set `ref` to the model name (e.g. `assigneeId` with `ref: "User"`). Ids of outside systems, like `stripeCustomerId`, are not FKs.

## `concepts` (3–6)

`{ title, body, analogy?, file?, code?, lang? }`. One idea each.

## `quiz` (3–5)

`{ question, options[], answer, explain }`, where `answer` is the zero-based index. Ask "where would you change X?" or "why does Y happen?".

## `glossary`, `runbook`

- `glossary`: `{ term, definition }`.
- `runbook`: `{ label, command, note? }`. Commands get a Copy button.
```

## File: references/design-system.md
size: 3179
sha: 2fdf2306acc092f68aa001033142907681bb9c13
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/references/design-system.md

```text
# Design system

> **Credit.** The visual language (palette, type scale, 12px radius, and the thick-bottom-border "pressable" buttons) follows **Duolingo**'s design language, including its color names (Ecto Green, Macaw Blue, Eel, Lingot). The one-idea-per-step lesson flow with a Continue button is inspired by **Brilliant**. Repo Explainer is an independent open-source project, not affiliated with or endorsed by Duolingo or Brilliant. It ships no Duolingo or Brilliant logos, mascots, illustrations or fonts: the type is Nunito, and the icons are original.

This page is **read-only context for agents**. The tokens live in `template/styles.css`, and explainers never override them.

## Tokens

| Token | Value | Use |
|---|---|---|
| `--color-ecto-green` | `#58cc02` | Primary buttons, display headings, the current step |
| `--color-ecto-green-dark` | `#58a700` | Bottom border of green buttons (the press effect) |
| `--color-eel-light` | `#d7ffb8` | Correct-answer wash, callouts, done-step badges |
| `--color-lingot-lime` | `#a5ed6e` | **Outline only.** Active pills, callout borders. Never a fill. |
| `--color-macaw-blue` | `#1cb0f6` | Secondary actions, links, focus ring, active edges |
| `--color-eel-dark-blue` | `#042c60` | Headings and labels |
| `--color-midnight` | `#000437` | Code blocks |
| `--color-charcoal` / `--color-ash` | `#4b4b4b` / `#777` | Body text / secondary text |
| `--color-swan` / `--color-polar` | `#e5e5e5` / `#f7f7f7` | Borders and inactive states / soft washes |
| `--color-bee`, `--color-pink` | `#ffc800`, `#ff86d0` | Illustration accents and layer colors only |
| `--color-cardinal` | `#ff4b4b` | Wrong quiz answers, **only** |

**Type.** Nunito 500/700/800/900 (with JetBrains Mono for code) and letter-spacing `0.053em`. Display headings are Nunito 900 with `-0.02em` tracking, always 28px or larger.

**Shape.** One radius, 12px (pills use 999px only for progress bars and dots). Borders are 2px, and pressable elements get a 4px bottom border that collapses to 2px when pressed.

## Rules and their reasons

| Rule | Why |
|---|---|
| No box-shadows | Depth comes from borders. Shadows read as a different product and blur at small sizes. |
| No gradients | Flat color keeps diagrams legible and the palette recognizable. |
| One accent per meaning | Green = progress/correct, blue = navigate/select, cardinal = wrong. Adding colors dilutes the signal. |
| Layer colors come from a fixed set of 6 | Keeps every repo's map consistent, so readers can learn the visual vocabulary once. |
| Icons by `kind` only | One family of flat illustrations. Mixed icon styles look broken. |
| Motion is short (200–500ms), eases out, plays once | It explains change (a step advancing, a reveal) instead of decorating. Everything respects `prefers-reduced-motion`. |
| Text is plain | It prevents XSS from repo content and stops one-off restyling. |

## Guardrail tests

`test/brand.test.mjs` fails the build if the stylesheet gains a `box-shadow`, a gradient, a color outside the token list, or a border radius other than 12px, 8px (tags), 6px (kbd) or 999px (dots/bars). Change the tests only together with this document, in a dedicated PR.
```

## File: references/investigation-playbook.md
size: 3700
sha: dd46b02000d130d961e6b2920312405de40f8c6f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/references/investigation-playbook.md

```text
# Investigation playbook

Goal: a reader who finishes the explainer can answer "where would I change X?" for the repo's core behaviour. Get there by tracing real calls, not by summarising folder names.

## 1. Orient (10 minutes)

1. Run `repo-explainer scan <repo>` and read the output top to bottom.
2. Read the README, any `ARCHITECTURE.md` or ADRs, and `CONTRIBUTING.md`. Note what they *claim*, then verify it.
3. Open every manifest in `scan.manifests`. The dependencies tell you the moving parts.
4. List the deployable units: apps, services, workers, and cron. Each one usually becomes one or more nodes.

## 2. Draw the map first

- **Nodes are runtime responsibilities, not folders.** "Auth middleware", not `src/middleware/`.
- **Aim for 6–14 nodes.** If you have more, group them; if fewer, you are probably hiding the interesting part.
- **Edges are calls, reads/writes, or messages,** and each gets a label naming the call ("createTask()", "enqueue", "webhook").
- **Put external services in their own layer** so readers see where the system boundary is.

## 3. Trace each flow call by call

Start at the trigger (a click, an HTTP route, a cron, a webhook, a CLI command). Then repeat:

1. Open the file and find the exact line that hands off to the next part.
2. Record `{from, to, label, file:line}`.
3. Follow the call into the next file.

Stop when the response returns or the side effect lands.

Where to find the next hop:

| Stack | Entry | Next hop is usually |
|---|---|---|
| Next.js / Remix / SvelteKit | `app/**/page.tsx`, `route.ts`, server actions, `middleware.ts` | an imported service, ORM call, or `fetch` |
| Express / Fastify / Hono / Nest | `app.get(…)`, `@Get()`, router files | middleware chain → controller → service |
| FastAPI / Django / Flask | `@router.get`, `urls.py` → `views.py` | dependency-injected service, ORM model |
| Go (net/http, gin, chi, echo) | `main.go` route registration | handler → service interface → repository |
| Rails / Laravel | `routes.rb` / `routes/web.php` | controller action → model / job |
| SPA (React/Vue) | router config → page component | data hook (`useQuery`, store action) → API client |
| Mobile | app entry → navigator → screen | view model → repository → network/local DB |
| Queue workers | worker bootstrap, `process()`/`perform` | job handler → service → external API |
| CLI / library | `bin` in package.json, `main()`, exported index | arg parser → command → core |

Include the **error path** once per flow (validation failure, auth failure, retry), as a step or in a `detail`.

## 4. Find the "why"

Look in commit messages, PR descriptions, ADRs, and comments near unusual code, especially comments containing `because`, `workaround` or `NOTE`. Put a reason in `step.why` only when you found it or it follows directly from the code (e.g. "verifies the signature so events can't be forged"). Don't speculate.

## 5. Changes chapter

1. Run `repo-explainer changes <repo> --data <file> --write`. Narrow the window with `--since` if the repo is busy; 20 merges a day means 2–3 days is plenty.
2. For each entry, open the PR or commit and write `why` in one or two sentences. Add `risk` when the change touches money, auth, data deletion, or concurrency.
3. Drop noise: lockfile bumps, formatting, bot version bumps (unless they matter).

## 6. Check yourself before building

- [ ] Every node has `files`, and every step has `file:line`.
- [ ] Each flow reads correctly as a story when you only read the step labels.
- [ ] Quiz answers are findable from the explainer itself.
- [ ] No env values, tokens, or customer data anywhere.
- [ ] `build --repo <repo> --strict` passes.
```

## File: references/ux-principles.md
size: 2282
sha: 665f0229e200d8fe5bfa738826c565d6f5a3f1ff
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/references/ux-principles.md

```text
# UX principles

Why the page is shaped like a lesson rather than a wiki.

1. **Guide, then let them explore.** A sandbox of boxes and arrows is not an explanation, because readers need to be led to the interesting part. Every chapter opens with one sentence telling the reader what to do. The flow player walks the request one hop at a time; the map is there to explore afterwards. *(After Bret Victor's "Explorable Explanations" and Red Blob Games' critique of sandbox widgets.)*
2. **Readable without interacting.** All content is visible as normal text. Interaction adds focus but never hides the only copy of a fact.
3. **Start broad, zoom on demand.** The map is the C4 "container" level. Selecting a node zooms to its files, neighbours and the flows it appears in. Flows go down to `file:line`. *(After the C4 model's context → container → component levels.)*
4. **One idea per screen.** Each step shows one hop, one sentence of what happens, and an optional "why". Concepts are a carousel, one card at a time. *(After Brilliant's lesson pacing.)*
5. **Immediate feedback.** Quiz answers grade instantly, with the reason. Finishing a flow shows a trophy and offers the next flow.
6. **Never trap the scroll.** Diagrams scroll sideways only, so the page always moves with the wheel. Sticky headers and the sticky step card keep context without inner vertical scroll areas.
7. **Change is a first-class chapter.** When humans and agents land dozens of merges a day, "what is this system?" is only half the question. The other half is "what moved since I last looked, and why?". Each change is tied to the map ("Show on map") so readers update their mental model rather than reading a log.
8. **Calm motion.** Motion only shows change: an arrow draws in, a dot travels once, a card slides in from the direction you moved. No loops except the selected-edge flow, which shows direction. Reduced-motion users get none of it.

## Sources

- Bret Victor, *Explorable Explanations*: https://worrydream.com/ExplorableExplanations/
- Red Blob Games, notes on interactive explanations: https://redblobgames.com/x/2306-educ432
- C4 model overview: https://codesee.io/learning-center/c4-diagram
- Explorable explanation (overview): https://en.wikipedia.org/wiki/Explorable_explanation
```

## File: assets/icons/api.svg
size: 515
sha: dd3b74efe03ca6faa7272b3ebd05d5bf54fdfa96
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/api.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="4" y="9" width="24" height="14" rx="7" fill="#58cc02"/><rect x="9" y="4" width="3" height="6" rx="1.5" fill="#58a700"/><rect x="20" y="4" width="3" height="6" rx="1.5" fill="#58a700"/><path d="M11 16h10M18 13l3 3-3 3" stroke="#fff" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="14.5" y="23" width="3" height="6" rx="1.5" fill="#58a700"/></svg>
```

## File: assets/icons/auth.svg
size: 386
sha: 3bf919c5f3f59f89587051989f7b41c10515db7a
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/auth.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M10 14v-3a6 6 0 0112 0v3" stroke="#042c60" stroke-width="3" fill="none"/><rect x="6" y="13" width="20" height="16" rx="4" fill="#ffc800"/><circle cx="16" cy="20" r="2.5" fill="#042c60"/><rect x="15" y="21" width="2" height="4" rx="1" fill="#042c60"/></svg>
```

## File: assets/icons/cache.svg
size: 289
sha: f9c0585d080de28960df475c449dc15eba2b5988
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/cache.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="16" r="13" fill="#fff5d3"/><path d="M18 4L8 18h7l-2 10 11-15h-7z" fill="#ffc800" stroke="#a56d00" stroke-width="1.2" stroke-linejoin="round"/></svg>
```

## File: assets/icons/cli.svg
size: 387
sha: dcb754f268949072f4365cc7a9897939869d96cf
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/cli.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="5" width="26" height="22" rx="4" fill="#000437"/><path d="M8 12l4 4-4 4" stroke="#a5ed6e" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 21h8" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
```

## File: assets/icons/db.svg
size: 371
sha: ccf94f712843e6b8d91b93bcadcd90711ca2c6e7
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/db.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M5 8v16c0 2.8 4.9 5 11 5s11-2.2 11-5V8z" fill="#1cb0f6"/><ellipse cx="16" cy="8" rx="11" ry="5" fill="#ddf4ff" stroke="#1cb0f6" stroke-width="2"/><path d="M5 16c0 2.8 4.9 5 11 5s11-2.2 11-5" stroke="#fff" stroke-width="2" fill="none"/></svg>
```

## File: assets/icons/external.svg
size: 287
sha: 4cc74b5bbdd66d436c67fa3b9a49a6bae65e4fd6
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/external.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M9 25a6 6 0 01-.8-11.9A8 8 0 0123.6 11 6.5 6.5 0 0124 25z" fill="#1cb0f6"/><path d="M12 19h8" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
```

## File: assets/icons/file.svg
size: 247
sha: 6ea6bd1becfa4b3bb732a6ad495d14ce369f27ff
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/file.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M8 3h11l6 6v19a1 1 0 01-1 1H8a1 1 0 01-1-1V4a1 1 0 011-1z" fill="#e5e5e5"/><path d="M19 3v6h6" fill="#cfcfcf"/></svg>
```

## File: assets/icons/folder.svg
size: 223
sha: 8bf0cfed2be4c9eb113eb4f95e3a9110481b2965
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/folder.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M3 9a3 3 0 013-3h7l3 3h10a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3z" fill="#1cb0f6"/></svg>
```

## File: assets/icons/job.svg
size: 318
sha: 6d422f59f1aaeb34a3525438bf99c629e650436e
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/job.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="16" r="13" fill="#58cc02"/><circle cx="16" cy="16" r="9.5" fill="#fff"/><path d="M16 10v6.5l4 2.5" stroke="#042c60" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
```

## File: assets/icons/library.svg
size: 350
sha: a3a16faef3e8894c78388065fb87cc4600325136
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/library.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="4" y="5" width="7" height="23" rx="2" fill="#58cc02"/><rect x="12.5" y="8" width="7" height="20" rx="2" fill="#1cb0f6"/><rect x="21" y="4" width="7" height="24" rx="2" fill="#ff86d0" transform="rotate(8 24.5 16)"/></svg>
```

## File: assets/icons/mobile.svg
size: 367
sha: db4b0856c38f5087f5492a707f0f52212c8ecd84
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/mobile.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="8" y="2" width="16" height="28" rx="4" fill="#042c60"/><rect x="10.5" y="6" width="11" height="18" rx="2" fill="#fff"/><rect x="12.5" y="9" width="7" height="3" rx="1.5" fill="#58cc02"/><circle cx="16" cy="27" r="1.3" fill="#fff"/></svg>
```

## File: assets/icons/queue.svg
size: 350
sha: db018cfcd1f9ec5e638d28a0ffa825fed43ce6d5
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/queue.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="9" width="7" height="14" rx="3" fill="#ff86d0"/><rect x="12.5" y="9" width="7" height="14" rx="3" fill="#ff86d0" opacity="0.75"/><rect x="22" y="9" width="7" height="14" rx="3" fill="#ff86d0" opacity="0.45"/></svg>
```

## File: assets/icons/robot.svg
size: 382
sha: 010973a48ee9703e326ce6d5b3e29870f40da026
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/robot.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="6" y="9" width="20" height="17" rx="5" fill="#1cb0f6"/><circle cx="12" cy="17" r="2.5" fill="#fff"/><circle cx="20" cy="17" r="2.5" fill="#fff"/><rect x="15" y="3" width="2" height="6" fill="#042c60"/><circle cx="16" cy="3" r="2" fill="#ffc800"/></svg>
```

## File: assets/icons/service.svg
size: 274
sha: 8a504ee6b1bd9738fc292f27797ff358d62c3947
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/service.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 2l12 7v14l-12 7-12-7V9z" fill="#042c60"/><circle cx="16" cy="16" r="5.5" fill="#fff"/><circle cx="16" cy="16" r="2.5" fill="#58cc02"/></svg>
```

## File: assets/icons/storage.svg
size: 288
sha: 11f993300c3b78c0b558df28b74bb05e5d653ea2
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/storage.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M3 9a3 3 0 013-3h7l3 3h10a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3z" fill="#ffc800"/><rect x="3" y="12" width="26" height="15" rx="3" fill="#ffd84d"/></svg>
```

## File: assets/icons/trophy.svg
size: 397
sha: b776aae360f4f5457cb337e2bf45783836d97364
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/trophy.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M9 4h14v7a7 7 0 01-14 0z" fill="#ffc800"/><path d="M9 6H5a4 4 0 004 6M23 6h4a4 4 0 01-4 6" stroke="#ffc800" stroke-width="2.5" fill="none"/><rect x="14" y="17" width="4" height="6" fill="#a56d00"/><rect x="9" y="23" width="14" height="5" rx="2" fill="#58cc02"/></svg>
```

## File: assets/icons/ui.svg
size: 473
sha: 2959ea143e8a7d7bc32d15352a3f6dd5f509153c
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/ui.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="5" width="26" height="22" rx="4" fill="#1cb0f6"/><rect x="6" y="11" width="20" height="13" rx="2" fill="#fff"/><circle cx="7.5" cy="8" r="1.3" fill="#fff"/><circle cx="11.5" cy="8" r="1.3" fill="#fff"/><rect x="9" y="14" width="9" height="3" rx="1.5" fill="#58cc02"/><rect x="9" y="19" width="14" height="2" rx="1" fill="#e5e5e5"/></svg>
```

## File: assets/icons/user.svg
size: 238
sha: 98d1463b3e77280891a3a41886a2c8cf42201bcd
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/icons/user.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="11" r="6" fill="#ffc800"/><path d="M5 29c0-6.5 4.9-11 11-11s11 4.5 11 11z" fill="#1cb0f6"/></svg>
```

## File: assets/logo/lockup.svg
size: 569
sha: 400550b10f5457d985468465ce6539e1477e93b5
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/logo/lockup.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64">
<g transform="translate(0 4) scale(1.75)"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></g>
<text x="68" y="42" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="30" font-weight="900" fill="#58cc02" letter-spacing="-0.3">Repo Explainer</text>
</svg>
```

## File: assets/logo/mark.svg
size: 347
sha: 536d02d17959a1eafbdb82519d433ecaf47d7515
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/logo/mark.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 32 32" aria-hidden="true"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></svg>
```

## File: assets/logo/social-card.svg
size: 942
sha: 83d2f13bf355f0fb3bf3640375aa374ef911ed6e
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/assets/logo/social-card.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
<rect width="1280" height="640" fill="#ffffff"/>
<circle cx="1050" cy="320" r="260" fill="#d7ffb8"/>
<circle cx="1180" cy="120" r="44" fill="#ffc800"/>
<g transform="translate(96 150) scale(3)"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></g>
<text x="96" y="370" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="88" font-weight="900" fill="#58cc02" letter-spacing="-2">Repo Explainer</text>
<text x="96" y="440" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="34" font-weight="700" fill="#4b4b4b">Understand any repo end to end, even when 20 PRs land a day.</text>
</svg>
```

## File: .github/ISSUE_TEMPLATE/bug.md
size: 262
sha: 745bf5e9a1a4fb4e60342d2b3b254ba89b488b4e
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/.github/ISSUE_TEMPLATE/bug.md

```text
---
name: Bug report
about: Something renders wrong or a command fails
labels: bug
---

**What happened**

**What you expected**

**Steps to reproduce** (attach the `explainer.json` if you can; strip anything private)

**Environment:** Node version, OS, browser
```

## File: .github/pull_request_template.md
size: 320
sha: 5f44fd21d0c61c4662fe7e022b2bd8b5e54833f4
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/.github/pull_request_template.md

```text
## Why

<!-- The problem this solves. The diff already shows what changed. -->

## Checklist

- [ ] `npm run check` passes
- [ ] Visual change? Before/after screenshot attached, checked at 375px and with reduced motion
- [ ] Design-system change? `references/design-system.md` and `test/brand.test.mjs` updated together
```

## File: .github/workflows/ci.yml
size: 715
sha: 615d2ed1796933c9965ee8f7635484163fbd8f1b
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/.github/workflows/ci.yml

```text
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: git config --global user.email ci@example.com && git config --global user.name CI
      - run: npm run check
      - name: Example stays valid under --strict
        run: node bin/repo-explainer.mjs validate examples/taskflow.explainer.json --strict
      - name: Built files are up to date
        run: git diff --exit-code -- examples/
```

## File: .gitignore
size: 167
sha: 15d50ea02faea248356a47729bdd9e59e83d2817
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/.gitignore

```text
node_modules/
.DS_Store
*.log
/explainer.json
/gallery.html
/repo-explainer-assets/
/.playwright-mcp/
coverage/
!test/fixtures/**/node_modules/
/examples/.stress.html
```

## File: bin/repo-explainer.mjs
size: 8625
sha: 37a80ba4a37182221c0915b30caffde826c92875
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/bin/repo-explainer.mjs

```text
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { execFileSync } from 'node:child_process';
import { buildExplainer, buildGallery } from '../scripts/lib/build.mjs';
import { NODE_KINDS } from '../scripts/lib/schema.mjs';
import { validateExplainer } from '../scripts/lib/validate.mjs';
import { scanRepo } from '../scripts/lib/scan.mjs';
import { collectChanges } from '../scripts/lib/changes.mjs';
import { createStarter, listPatterns, listPresets } from '../scripts/lib/init.mjs';
import { exportIcons, exportLogos } from '../scripts/lib/assets.mjs';
import { PRICING, costTable, formatUsd, sizeOf } from '../scripts/lib/estimate.mjs';

const HELP = `repo-explainer: turn a repo into a guided, interactive HTML tour

Usage
  repo-explainer scan <repo>                       Facts to start from (stack, routes, data files, env keys)
  repo-explainer estimate [repo]                   Rough agent cost per run, per model (all sizes if no repo)
  repo-explainer init <repo> [--preset id]         Write a starter explainer.json (preset auto-picked from scan)
  repo-explainer changes <repo> [--data file]      Draft "what changed & why" entries from git history
  repo-explainer validate <file> [--repo dir]      Check the data; --repo also checks every cited file exists
  repo-explainer build <file> [--out f] [--repo d] Validate, then render one self-contained HTML file
  repo-explainer presets                           List presets
  repo-explainer patterns [id]                     List reusable flow patterns, or print one
  repo-explainer assets [--out dir]                Export logo + icon SVGs
  repo-explainer gallery [--out file]              Render the design kit (icons, palette, components, patterns)

Options
  --out, -o      Output path                      --since     git window (default "14 days ago")
  --preset, -p   Preset id                        --limit     max commits (default 40)
  --repo, -r     Repo root for file checks        --merges    only merge commits (PR merges)
  --strict       Treat warnings as errors         --write     merge drafted changes into --data file
  --open         Open the built file              --remote    repo URL for PR links (e.g. https://github.com/o/r)
`;

const OPTIONS = {
  out: { type: 'string', short: 'o' },
  preset: { type: 'string', short: 'p' },
  repo: { type: 'string', short: 'r' },
  data: { type: 'string' },
  since: { type: 'string' },
  limit: { type: 'string' },
  remote: { type: 'string' },
  merges: { type: 'boolean' },
  write: { type: 'boolean' },
  strict: { type: 'boolean' },
  open: { type: 'boolean' },
  help: { type: 'boolean', short: 'h' },
};

class UsageError extends Error {}

function readJson(path) {
  if (!existsSync(path)) throw new UsageError(`file not found: ${path}`);
  try {
    return JSON.parse(readFileSync(path, 'utf8'));
  } catch (error) {
    throw new UsageError(`could not parse ${path}: ${error.message}`);
  }
}

function writeFile(path, content) {
  mkdirSync(dirname(resolve(path)), { recursive: true });
  writeFileSync(path, content);
  process.stderr.write(`✔ wrote ${path}\n`);
}

function printJson(value) {
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

function report({ errors, warnings }, strict) {
  warnings.forEach((w) => process.stderr.write(`  ⚠ ${w}\n`));
  errors.forEach((e) => process.stderr.write(`  ✖ ${e}\n`));
  const failed = errors.length > 0 || (strict && warnings.length > 0);
  process.stderr.write(`${failed ? '✖' : '✔'} ${errors.length} error(s), ${warnings.length} warning(s)\n`);
  return !failed;
}

function requirePositional(positionals, label) {
  if (!positionals[0]) throw new UsageError(`missing ${label}\n\n${HELP}`);
  return positionals[0];
}

const COMMANDS = {
  scan(positionals) {
    printJson(scanRepo(resolve(requirePositional(positionals, '<repo>'))));
  },

  init(positionals, opts) {
    const repo = resolve(requirePositional(positionals, '<repo>'));
    const scan = scanRepo(repo);
    const preset = opts.preset || scan.suggestedPreset;
    const out = opts.out || 'explainer.json';
    if (existsSync(out)) throw new UsageError(`${out} already exists. Pass --out to choose another path`);
    writeFile(out, `${JSON.stringify(createStarter(preset, scan), null, 2)}\n`);
    process.stderr.write(`  preset: ${preset}${opts.preset ? '' : ' (suggested by scan)'}\n  next: fill the TODOs, then \`repo-explainer build ${out} --repo ${repo}\`\n`);
  },

  changes(positionals, opts) {
    const repo = resolve(requirePositional(positionals, '<repo>'));
    const data = opts.data ? readJson(opts.data) : null;
    const drafted = collectChanges(repo, {
      since: opts.since, limit: opts.limit ? Number(opts.limit) : undefined, mergesOnly: opts.merges,
      nodes: data ? data.nodes : [], remoteUrl: opts.remote,
    });
    if (!opts.write) return printJson(drafted);
    if (!data) throw new UsageError('--write needs --data <explainer.json>');
    const known = new Set((data.changes || []).map((c) => c.id));
    const merged = { ...data, changes: [...(data.changes || []), ...drafted.filter((c) => !known.has(c.id))] };
    writeFile(opts.data, `${JSON.stringify(merged, null, 2)}\n`);
    process.stderr.write(`  added ${merged.changes.length - (data.changes || []).length} change(s); fill each "why"\n`);
    return undefined;
  },

  validate(positionals, opts) {
    const file = requirePositional(positionals, '<file>');
    const ok = report(validateExplainer(readJson(file), { repoRoot: opts.repo && resolve(opts.repo) }), opts.strict);
    if (!ok) process.exitCode = 1;
  },

  build(positionals, opts) {
    const file = requirePositional(positionals, '<file>');
    const data = readJson(file);
    if (!report(validateExplainer(data, { repoRoot: opts.repo && resolve(opts.repo) }), opts.strict)) {
      process.exitCode = 1;
      return;
    }
    const out = opts.out || join(dirname(file), `${basename(file).replace(/(\.explainer)?\.json$/, '')}.html`);
    writeFile(out, buildExplainer(data));
    if (opts.open) execFileSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [out]);
  },

  estimate(positionals) {
    const size = positionals[0] ? sizeOf(scanRepo(resolve(positionals[0]))) : null;
    const rows = costTable(size ? ['update', size] : undefined);
    const models = Object.keys(PRICING.models);
    const pad = (v, n) => String(v).padEnd(n);
    process.stdout.write(`${pad('Run', 36)}${models.map((m) => pad(PRICING.models[m].label, 12)).join('')}\n`);
    rows.forEach((row) => process.stdout.write(`${pad(row.label, 36)}${models.map((m) => pad(formatUsd(row.costs[m]), 12)).join('')}\n`));
    process.stdout.write(`\nEstimates, not quotes. Prices from ${PRICING.source} (checked ${PRICING.checkedAt}).\n`);
    if (size) process.stdout.write(`This repo looks ${size}.\n`);
  },

  presets() {
    listPresets().forEach((p) => process.stdout.write(`${p.id.padEnd(16)} ${p.name}\n${' '.repeat(17)}${p.whenToUse}\n`));
  },

  patterns(positionals) {
    const all = listPatterns();
    if (!positionals[0]) {
      all.forEach((p) => process.stdout.write(`${p.id.padEnd(18)} ${p.use}\n`));
      return;
    }
    const pattern = all.find((p) => p.id === positionals[0]);
    if (!pattern) throw new UsageError(`unknown pattern "${positionals[0]}"`);
    printJson(pattern);
  },

  gallery(_positionals, opts) {
    const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
    writeFile(opts.out || 'gallery.html', buildGallery({ kinds: NODE_KINDS, patterns: listPatterns(), version: pkg.version }));
  },

  assets(_positionals, opts) {
    const out = opts.out || 'repo-explainer-assets';
    exportIcons().forEach(({ name, svg }) => writeFile(join(out, 'icons', `${name}.svg`), svg));
    exportLogos().forEach(({ name, svg }) => writeFile(join(out, 'logo', `${name}.svg`), svg));
  },
};

function main(argv) {
  const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
  const [command, ...rest] = positionals;
  if (!command || values.help) {
    process.stdout.write(HELP);
    return;
  }
  const run = COMMANDS[command];
  if (!run) throw new UsageError(`unknown command "${command}"\n\n${HELP}`);
  run(rest, values);
}

try {
  main(process.argv.slice(2));
} catch (error) {
  process.stderr.write(`✖ ${error instanceof UsageError ? error.message : `repo-explainer failed: ${error.message}`}\n`);
  process.exitCode = 1;
}
```

## File: CHANGELOG.md
size: 535
sha: 8bc420b24eeb545e4a8421c999770fbf3c98fec6
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/CHANGELOG.md

```text
# Changelog

All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org).

## 0.1.0 (2026-10-25)

First public release.

- Chapters: architecture map, end-to-end flow player, what changed and why, folder guide, ER diagram, key ideas, quiz, glossary, runbook.
- CLI commands: `scan`, `estimate`, `init`, `changes`, `validate`, `build`, `presets`, `patterns`, `gallery`, `assets`.
- 5 presets and 11 flow patterns.
- Brand, writing-style, lesson-shape and truthfulness checks.
```

## File: CODE_OF_CONDUCT.md
size: 441
sha: 6396b48b0cfd9e4f610f8c5f64ea3525850220f9
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/CODE_OF_CONDUCT.md

```text
# Code of conduct

This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).

In short: be kind, assume good intent, and keep feedback about the work.

Report unacceptable behaviour privately to the maintainer through a [GitHub security advisory](https://github.com/Cyvid7-Darus10/repo-explainer/security/advisories/new) or the email on the maintainer's GitHub profile.
```

## File: CONTRIBUTING.md
size: 1708
sha: f1049038200e1179e0fbcf122890363361089682
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/CONTRIBUTING.md

````text
# Contributing

Thanks for helping. This project has two audiences, people and agents, and one rule that keeps them happy: **explainers change data, maintainers change the template.**

## Setup

```sh
git clone https://github.com/Cyvid7-Darus10/repo-explainer
cd repo-explainer
npm run check        # tests + rebuild the example and gallery
```

No dependencies to install. Node 18.3 or newer.

## Where things live

| Path | What |
|---|---|
| `SKILL.md` | Instructions agents follow. Keep it short and imperative. |
| `bin/repo-explainer.mjs` | The CLI. |
| `scripts/lib/` | Scan, validate, build, changes, estimate. Plain ES modules. |
| `template/` | The page: `explainer.html`, `styles.css`, and `js/` modules (concatenated at build time). |
| `presets/`, `patterns/` | Repo-type presets and reusable flow shapes (JSON). |
| `references/` | Schema, playbook, design system, UX principles. |
| `test/` | `node:test` suites, including brand guardrails. |

## Pull requests

- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
- One logical change per PR. In the description, explain **why**.
- Add or update tests for behaviour changes.
- **Visual changes:** include a before/after screenshot, and check that it still feels right at 375px wide with reduced motion on.
- **Design-system changes** (tokens, radii, colors, motion) must update `references/design-system.md` and `test/brand.test.mjs` in the same PR.
- **New presets or patterns** must pass `test/scan.test.mjs`, which checks they are internally consistent.

## Writing style

Docs and UI copy follow the same rules as explainers: gist first, short sentences, plain words, no dashes as punctuation, no filler.
````

## File: docs/media/changes.png
size: 107507
sha: a7d2360faf554ce7c25018425bbfd20c2be03bee
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/changes.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/changes.png

## File: docs/media/data.png
size: 51910
sha: f2e5179b9da7ee7e1f9dcc30b7d67b4be48c474d
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/data.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/data.png

## File: docs/media/demo.gif
size: 748693
sha: 53aeb3456a75d8fda48eb1ac042eb1babbe4aa17
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/demo.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/demo.gif

## File: docs/media/flow.png
size: 105492
sha: 0c0ca68dc90928db1bcb0b967357b456375d1feb
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/flow.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/flow.png

## File: docs/media/hero.png
size: 100267
sha: 6f0027e04a10ff536256cb9de315e49aefa24ba2
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/hero.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/hero.png

## File: docs/media/map.png
size: 114329
sha: b17a8d732afe28cb5156c2ca4ac2678583a16ed7
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/map.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/map.png

## File: docs/media/quiz.png
size: 74337
sha: 7da3333a2885d56011eed656ec3af781c58c4c3c
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/quiz.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/quiz.png

## File: docs/media/social-card.png
size: 48368
sha: 2a52afb13b601b881ace03f9df445538719caf57
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/social-card.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/docs/media/social-card.png

## File: examples/gallery.html
size: 108240
sha: b7f8ae740dd9e78ae8b7112196eba4bb979fe81b
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/examples/gallery.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/examples/gallery.html

## File: examples/taskflow.explainer.json
size: 18344
sha: 61d5fd81cd5c28835c66b673dc9b9d7c124aa1ca
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/examples/taskflow.explainer.json

```text
{
  "$schema": "../references/data-schema.md",
  "meta": {
    "title": "Taskflow, explained",
    "repo": "acme/taskflow",
    "summary": "A team to-do app. A Next.js web app talks to a small API, which stores tasks in Postgres and hands slow work (emails, webhooks) to a background worker.",
    "stack": [
      "Next.js 15",
      "TypeScript",
      "Postgres",
      "Prisma",
      "BullMQ",
      "Redis",
      "Stripe"
    ],
    "preset": "fullstack-web",
    "generatedAt": "2026-10-25",
    "commit": "9f3c2ab41e"
  },
  "layers": [
    {
      "id": "client",
      "label": "Client",
      "color": "blue"
    },
    {
      "id": "edge",
      "label": "Edge & API",
      "color": "green"
    },
    {
      "id": "domain",
      "label": "Domain",
      "color": "navy"
    },
    {
      "id": "infra",
      "label": "Data & infra",
      "color": "yellow"
    },
    {
      "id": "external",
      "label": "Outside world",
      "color": "pink"
    }
  ],
  "nodes": [
    {
      "id": "user",
      "label": "Team member",
      "layer": "client",
      "kind": "user",
      "summary": "Someone using Taskflow in their browser.",
      "files": []
    },
    {
      "id": "web",
      "label": "Web app",
      "layer": "client",
      "kind": "ui",
      "tech": "Next.js App Router",
      "summary": "Pages and React components. Server components fetch data; client components handle forms and optimistic updates.",
      "files": [
        "apps/web/app/",
        "apps/web/components/"
      ]
    },
    {
      "id": "middleware",
      "label": "Auth middleware",
      "layer": "edge",
      "kind": "auth",
      "tech": "Next middleware + JWT",
      "summary": "Runs before every request. Reads the session cookie, rejects anonymous users, and puts the user id on the request.",
      "files": [
        "apps/web/middleware.ts"
      ]
    },
    {
      "id": "api",
      "label": "Tasks API",
      "layer": "edge",
      "kind": "api",
      "tech": "Route handlers",
      "summary": "Thin HTTP layer. Parses and validates input with Zod, then calls the task service. Never touches the database directly.",
      "files": [
        "apps/web/app/api/tasks/route.ts"
      ]
    },
    {
      "id": "service",
      "label": "Task service",
      "layer": "domain",
      "kind": "service",
      "tech": "Plain TypeScript",
      "summary": "All business rules live here: who can edit a task, what happens when it is completed, which events fire.",
      "files": [
        "packages/core/src/tasks/service.ts"
      ]
    },
    {
      "id": "billing",
      "label": "Billing service",
      "layer": "domain",
      "kind": "service",
      "tech": "Stripe SDK",
      "summary": "Checks plan limits (free teams get 50 open tasks) and syncs subscription state from Stripe webhooks.",
      "files": [
        "packages/core/src/billing/"
      ]
    },
    {
      "id": "db",
      "label": "Postgres",
      "layer": "infra",
      "kind": "db",
      "tech": "Prisma ORM",
      "summary": "The source of truth for teams, users and tasks.",
      "files": [
        "packages/db/prisma/schema.prisma"
      ]
    },
    {
      "id": "queue",
      "label": "Job queue",
      "layer": "infra",
      "kind": "queue",
      "tech": "BullMQ on Redis",
      "summary": "Holds slow work so requests stay fast. Each job is retried 5 times with backoff.",
      "files": [
        "packages/core/src/queue.ts"
      ]
    },
    {
      "id": "worker",
      "label": "Worker",
      "layer": "infra",
      "kind": "job",
      "tech": "Node process",
      "summary": "Pulls jobs off the queue: sends emails, fires customer webhooks, rolls up daily stats.",
      "files": [
        "apps/worker/src/index.ts"
      ]
    },
    {
      "id": "email",
      "label": "Email provider",
      "layer": "external",
      "kind": "external",
      "tech": "Resend",
      "summary": "Delivers notification emails.",
      "files": [
        "apps/worker/src/jobs/send-email.ts"
      ]
    },
    {
      "id": "stripe",
      "label": "Stripe",
      "layer": "external",
      "kind": "external",
      "summary": "Payments and subscriptions.",
      "files": [
        "apps/web/app/api/stripe/webhook/route.ts"
      ]
    }
  ],
  "edges": [
    {
      "from": "user",
      "to": "web",
      "label": "clicks"
    },
    {
      "from": "web",
      "to": "middleware",
      "label": "fetch /api/*"
    },
    {
      "from": "middleware",
      "to": "api",
      "label": "user id"
    },
    {
      "from": "api",
      "to": "service",
      "label": "createTask()"
    },
    {
      "from": "service",
      "to": "billing",
      "label": "checkLimit()"
    },
    {
      "from": "service",
      "to": "db",
      "label": "insert",
      "kind": "data"
    },
    {
      "from": "service",
      "to": "queue",
      "label": "enqueue",
      "kind": "async"
    },
    {
      "from": "queue",
      "to": "worker",
      "label": "job",
      "kind": "async"
    },
    {
      "from": "worker",
      "to": "email",
      "label": "send"
    },
    {
      "from": "stripe",
      "to": "billing",
      "label": "webhook",
      "kind": "async"
    }
  ],
  "flows": [
    {
      "id": "create-task",
      "title": "Create a task",
      "trigger": "User presses “Add task”",
      "summary": "The most common write in the app. Follow it from the click to the email the assignee receives.",
      "steps": [
        {
          "from": "user",
          "to": "web",
          "label": "Types a title, presses Add",
          "kind": "request",
          "detail": "The form is a client component. It shows the task instantly (optimistic update) before the server answers.",
          "file": "apps/web/components/new-task-form.tsx:24"
        },
        {
          "from": "web",
          "to": "api",
          "label": "POST /api/tasks",
          "kind": "request",
          "detail": "A normal fetch with the session cookie attached. The auth middleware runs first: no valid session means a 401 and the request stops.",
          "file": "apps/web/components/new-task-form.tsx:41",
          "code": "await fetch('/api/tasks', {\n  method: 'POST',\n  body: JSON.stringify({ title, assigneeId }),\n})",
          "lang": "ts",
          "why": "Doing auth once in middleware means no route can forget to check it."
        },
        {
          "from": "api",
          "to": "service",
          "label": "Validates, calls createTask()",
          "kind": "request",
          "detail": "Zod rejects bad input with a 422 before any business logic runs.",
          "file": "apps/web/app/api/tasks/route.ts:18",
          "code": "const input = CreateTask.parse(await req.json())\nconst task = await tasks.createTask(userId, input)\nreturn Response.json(task, { status: 201 })",
          "lang": "ts"
        },
        {
          "from": "service",
          "to": "service",
          "label": "Checks the plan limit",
          "kind": "internal",
          "detail": "Free teams can have 50 open tasks. Over the limit, billing.checkLimit() throws and the API answers 402.",
          "file": "packages/core/src/billing/limits.ts:9"
        },
        {
          "from": "service",
          "to": "db",
          "label": "INSERT the task",
          "kind": "data",
          "detail": "Inside a transaction together with an activity-log row, so the history can never miss a task.",
          "file": "packages/core/src/tasks/service.ts:57"
        },
        {
          "from": "service",
          "to": "queue",
          "label": "Enqueue “task.assigned”",
          "kind": "async",
          "detail": "Only if someone else was assigned. The request does not wait for the email.",
          "why": "Email providers can take seconds. Queuing keeps the click under 100 ms.",
          "file": "packages/core/src/tasks/service.ts:71"
        },
        {
          "from": "api",
          "to": "web",
          "label": "201 Created",
          "kind": "response",
          "detail": "The real task replaces the optimistic one. If anything failed, the form rolls back and shows the error.",
          "file": "apps/web/app/api/tasks/route.ts:20"
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "Worker sends the email",
          "kind": "async",
          "detail": "Usually within a second. Failed jobs retry with backoff, then the assignee gets the email.",
          "file": "apps/worker/src/index.ts:15"
        }
      ]
    },
    {
      "id": "upgrade",
      "title": "Upgrade to Pro",
      "trigger": "Stripe sends a webhook",
      "summary": "Payment happens on Stripe’s page. We only learn about it through a webhook.",
      "steps": [
        {
          "from": "stripe",
          "to": "billing",
          "label": "checkout.session.completed",
          "kind": "async",
          "detail": "The webhook route checks the Stripe signature first, then hands the event to the billing service.",
          "why": "Without the signature check, anyone could POST a fake ‘paid’ event.",
          "file": "apps/web/app/api/stripe/webhook/route.ts:10"
        },
        {
          "from": "billing",
          "to": "db",
          "label": "Set team.plan = 'pro'",
          "kind": "data",
          "detail": "Idempotent: replaying the same event changes nothing.",
          "file": "packages/core/src/billing/sync.ts:22"
        },
        {
          "from": "billing",
          "to": "queue",
          "label": "Enqueue welcome email",
          "kind": "async",
          "file": "packages/core/src/billing/sync.ts:30"
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "Job runs",
          "kind": "async",
          "file": "apps/worker/src/index.ts:15"
        },
        {
          "from": "worker",
          "to": "email",
          "label": "“Welcome to Pro”",
          "kind": "request",
          "file": "apps/worker/src/jobs/send-email.ts:8"
        }
      ]
    }
  ],
  "changes": [
    {
      "id": "pr-482",
      "title": "Move task emails onto the queue",
      "date": "2026-10-24",
      "author": "claude-agent",
      "authorType": "agent",
      "ref": "#482",
      "kind": "perf",
      "what": "createTask() used to call Resend inline. Now it enqueues a job and the worker sends it.",
      "why": "p95 for POST /api/tasks was 1.8 s because of email latency. It is now 90 ms.",
      "risk": "Emails can arrive a few seconds late; retries may duplicate an email if the provider times out after sending.",
      "nodes": [
        "service",
        "queue",
        "worker"
      ],
      "files": [
        "packages/core/src/tasks/service.ts",
        "apps/worker/src/jobs/send-email.ts"
      ]
    },
    {
      "id": "pr-479",
      "title": "Enforce the 50-task limit on the free plan",
      "date": "2026-10-24",
      "author": "maria",
      "authorType": "human",
      "ref": "#479",
      "kind": "feat",
      "what": "New billing.checkLimit() call before every insert, plus a 402 response and an upgrade banner.",
      "why": "Pricing change agreed for Q4: free stays useful for small teams, heavy users upgrade.",
      "nodes": [
        "billing",
        "service",
        "api",
        "web"
      ],
      "files": [
        "packages/core/src/billing/limits.ts"
      ]
    },
    {
      "id": "pr-475",
      "title": "Verify Stripe webhook signatures",
      "date": "2026-10-23",
      "author": "security-agent",
      "authorType": "agent",
      "ref": "#475",
      "kind": "security",
      "what": "The webhook route now rejects any request whose Stripe-Signature header does not verify.",
      "why": "An audit found the endpoint trusted any POST. Anyone could have upgraded a team for free.",
      "nodes": [
        "stripe",
        "billing"
      ],
      "files": [
        "apps/web/app/api/stripe/webhook/route.ts"
      ]
    },
    {
      "id": "pr-471",
      "title": "Split auth checks out of route handlers",
      "date": "2026-10-22",
      "author": "dev-agent-2",
      "authorType": "agent",
      "ref": "#471",
      "kind": "refactor",
      "what": "Twelve copies of the session check were replaced by one middleware.",
      "why": "Two new routes had shipped without auth. One place to check means none can forget.",
      "nodes": [
        "middleware",
        "api"
      ],
      "files": [
        "apps/web/middleware.ts"
      ]
    }
  ],
  "tree": [
    {
      "path": "apps/",
      "note": "Things you can run"
    },
    {
      "path": "apps/web/",
      "note": "The Next.js app: pages, components and API routes",
      "key": true
    },
    {
      "path": "apps/web/app/api/",
      "note": "HTTP endpoints. Thin: validate, then call packages/core"
    },
    {
      "path": "apps/web/middleware.ts",
      "note": "Auth for every request"
    },
    {
      "path": "apps/worker/",
      "note": "Background job runner (emails, webhooks, stats)"
    },
    {
      "path": "packages/",
      "note": "Shared code, no HTTP in here"
    },
    {
      "path": "packages/core/",
      "note": "Business rules. Start here to change behaviour",
      "key": true
    },
    {
      "path": "packages/db/",
      "note": "Prisma schema and migrations"
    }
  ],
  "dataModel": [
    {
      "name": "Team",
      "summary": "A workspace. Billing lives here.",
      "fields": [
        {
          "name": "id",
          "type": "uuid"
        },
        {
          "name": "name",
          "type": "text"
        },
        {
          "name": "plan",
          "type": "'free' | 'pro'",
          "note": "Synced from Stripe"
        },
        {
          "name": "stripeCustomerId",
          "type": "text?"
        }
      ],
      "relations": [
        {
          "to": "User",
          "kind": "has many"
        },
        {
          "to": "Task",
          "kind": "has many"
        }
      ]
    },
    {
      "name": "User",
      "fields": [
        {
          "name": "id",
          "type": "uuid"
        },
        {
          "name": "email",
          "type": "text",
          "note": "Unique"
        },
        {
          "name": "teamId",
          "type": "uuid"
        }
      ],
      "relations": [
        {
          "to": "Team",
          "kind": "belongs to"
        }
      ]
    },
    {
      "name": "Task",
      "summary": "The thing people actually care about.",
      "fields": [
        {
          "name": "id",
          "type": "uuid"
        },
        {
          "name": "title",
          "type": "text"
        },
        {
          "name": "status",
          "type": "'open' | 'done'"
        },
        {
          "name": "assigneeId",
          "type": "uuid?",
          "ref": "User"
        },
        {
          "name": "completedAt",
          "type": "timestamptz?",
          "note": "Set by the service, never by the client"
        }
      ],
      "relations": [
        {
          "to": "Team",
          "kind": "belongs to"
        },
        {
          "to": "User",
          "kind": "assigned to"
        }
      ]
    }
  ],
  "concepts": [
    {
      "title": "Routes are thin, the service is thick",
      "body": "API routes only parse input and shape the response. Every rule lives in packages/core: limits, permissions, side effects.",
      "analogy": "Routes are the waiter: they take the order and bring the plate. The service is the kitchen.",
      "file": "packages/core/src/tasks/service.ts"
    },
    {
      "title": "Slow things go on the queue",
      "body": "If something talks to the outside world and the user does not need the result right now, it becomes a job.",
      "analogy": "Like dropping a letter in a postbox instead of walking it to the recipient.",
      "code": "await queue.add('task.assigned', { taskId: task.id })",
      "lang": "ts"
    },
    {
      "title": "Optimistic UI",
      "body": "The web app shows your change before the server confirms it, then quietly fixes things up if the server disagrees.",
      "file": "apps/web/components/new-task-form.tsx"
    }
  ],
  "quiz": [
    {
      "question": "You need to add a rule: “archived teams can’t create tasks”. Where does it go?",
      "options": [
        "apps/web/app/api/tasks/route.ts",
        "packages/core/src/tasks/service.ts",
        "apps/web/middleware.ts",
        "The Prisma schema"
      ],
      "answer": 1,
      "explain": "Business rules belong in the service, so every caller gets them: API, worker and scripts."
    },
    {
      "question": "Why doesn’t creating a task wait for the email to send?",
      "options": [
        "Emails are sent by the browser",
        "The email is queued and the worker sends it later",
        "Emails are only sent on Pro plans"
      ],
      "answer": 1,
      "explain": "PR #482 moved emails onto the queue, which cut p95 from 1.8 s to 90 ms."
    },
    {
      "question": "What stops someone faking a Stripe ‘payment succeeded’ event?",
      "options": [
        "The auth middleware",
        "Rate limiting",
        "Webhook signature verification"
      ],
      "answer": 2,
      "explain": "Added in #475 after an audit. The middleware doesn’t run for Stripe, because Stripe has no session cookie."
    }
  ],
  "glossary": [
    {
      "term": "Optimistic update",
      "definition": "Showing a change in the UI before the server confirms it."
    },
    {
      "term": "Job",
      "definition": "A unit of background work stored in Redis and run by the worker."
    },
    {
      "term": "Plan limit",
      "definition": "The 50-open-task cap on free teams, enforced in billing/limits.ts."
    },
    {
      "term": "Idempotent",
      "definition": "Safe to run twice: the second run changes nothing."
    }
  ],
  "runbook": [
    {
      "label": "Install",
      "command": "pnpm install"
    },
    {
      "label": "Start Postgres and Redis",
      "command": "docker compose up -d db redis",
      "note": "Needs Docker Desktop running."
    },
    {
      "label": "Run the app and the worker",
      "command": "pnpm dev",
      "note": "Web on :3000, worker logs in the same terminal."
    }
  ]
}
```

## File: examples/taskflow.html
size: 114426
sha: 889a4d8a5aeaecabb66256c35bc442025b1ab509
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/examples/taskflow.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/examples/taskflow.html

## File: index.html
size: 1131
sha: 4a611dc3a0cab0c3c9bfa8f01b38164af259654d
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/index.html

```text
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Repo Explainer</title>
<meta name="description" content="Turn any codebase into a guided, interactive tour: architecture map, end-to-end flows, and what changed and why.">
<meta property="og:title" content="Repo Explainer">
<meta property="og:description" content="Turn any codebase into a guided, interactive tour: architecture map, end-to-end flows, and what changed and why.">
<meta property="og:image" content="https://cyvid7-darus10.github.io/repo-explainer/docs/media/social-card.png">
<meta name="twitter:card" content="summary_large_image">
<meta http-equiv="refresh" content="0; url=examples/taskflow.html">
<link rel="canonical" href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html">
<style>body{margin:0;font-family:system-ui,sans-serif;background:#fff;color:#4b4b4b;display:grid;place-items:center;min-height:100vh}a{color:#1cb0f6;font-weight:700}</style>
</head>
<body>
<p>Opening the <a href="examples/taskflow.html">live demo</a>…</p>
</body>
</html>
```

## File: LICENSE
size: 11358
sha: 57bc88a15a0ee8266c259b2667e64608d3f7e292
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/LICENSE

```text
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

```

## File: NOTICE
size: 302
sha: 1494190e251c8ea2d2a6901eb6f36554d08e6fc7
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/NOTICE

```text
Repo Explainer
Copyright 2026 Cyrus David Pastelero

This product includes software developed by Cyrus David Pastelero
(https://github.com/Cyvid7-Darus10/repo-explainer).

Under the Apache License 2.0, if you redistribute this software or a work based on it,
you must include this NOTICE file with it.
```

## File: package.json
size: 1433
sha: 45bf0ac4292f18a8cb48c0f43772cfc761ab03ae
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/package.json

```text
{
  "name": "repo-explainer",
  "version": "0.1.0",
  "description": "An agent skill that turns any codebase into a guided, interactive HTML tour: architecture map, step-through end-to-end flows, and a timeline of what changed and why.",
  "type": "module",
  "bin": {
    "repo-explainer": "bin/repo-explainer.mjs"
  },
  "files": [
    "bin/",
    "scripts/lib/",
    "template/",
    "presets/",
    "patterns/",
    "assets/",
    "references/",
    "SKILL.md",
    "NOTICE"
  ],
  "scripts": {
    "test": "node --test test/*.test.mjs",
    "build:example": "node bin/repo-explainer.mjs build examples/taskflow.explainer.json",
    "build:gallery": "node bin/repo-explainer.mjs gallery --out examples/gallery.html",
    "build:assets": "node bin/repo-explainer.mjs assets --out assets",
    "check": "npm test && npm run build:example && npm run build:gallery"
  },
  "engines": {
    "node": ">=18.3"
  },
  "keywords": [
    "agent-skill",
    "claude-code",
    "codebase",
    "architecture",
    "onboarding",
    "documentation",
    "sequence-diagram",
    "developer-experience"
  ],
  "license": "Apache-2.0",
  "author": "Cyrus David Pastelero",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Cyvid7-Darus10/repo-explainer.git"
  },
  "bugs": {
    "url": "https://github.com/Cyvid7-Darus10/repo-explainer/issues"
  },
  "homepage": "https://github.com/Cyvid7-Darus10/repo-explainer#readme"
}
```

## File: patterns/async-job.json
size: 1040
sha: 2c37a47c07770ab60f84b03ac86433ca81195489
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/async-job.json

```text
{
  "id": "async-job",
  "name": "Hand off to a background job",
  "use": "Slow or unreliable work moved out of the request.",
  "roles": [
    "service",
    "queue",
    "worker",
    "external"
  ],
  "steps": [
    {
      "from": "service",
      "to": "queue",
      "label": "Enqueue job",
      "kind": "async",
      "detail": "The request returns without waiting.",
      "why": "Keeps user-facing latency low."
    },
    {
      "from": "queue",
      "to": "worker",
      "label": "Deliver job",
      "kind": "async",
      "detail": "Note retry count and backoff."
    },
    {
      "from": "worker",
      "to": "external",
      "label": "Do the slow work",
      "kind": "request"
    },
    {
      "from": "worker",
      "to": "queue",
      "label": "Ack or retry",
      "kind": "async",
      "detail": "What happens after the last retry fails?"
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/auth-session.json
size: 1325
sha: 290fe80687a353340069630bac8b53a0dd233218
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/auth-session.json

```text
{
  "id": "auth-session",
  "name": "Sign in, then authenticated request",
  "use": "Login flows: credentials → session/token → later requests checked.",
  "roles": [
    "user",
    "web",
    "auth",
    "db"
  ],
  "steps": [
    {
      "from": "user",
      "to": "web",
      "label": "Submits credentials",
      "kind": "request"
    },
    {
      "from": "web",
      "to": "auth",
      "label": "POST /login",
      "kind": "request"
    },
    {
      "from": "auth",
      "to": "db",
      "label": "Look up user, verify hash",
      "kind": "data"
    },
    {
      "from": "auth",
      "to": "web",
      "label": "Set session cookie / token",
      "kind": "response",
      "detail": "Note cookie flags (HttpOnly, Secure, SameSite) or token storage.",
      "why": "Explain why this storage was chosen."
    },
    {
      "from": "web",
      "to": "auth",
      "label": "Later request carries session",
      "kind": "request"
    },
    {
      "from": "auth",
      "to": "auth",
      "label": "Verify and attach user",
      "kind": "internal",
      "detail": "Where this check lives decides whether a route can forget it."
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/cache-aside.json
size: 863
sha: e8f442c9572255125f3064bcb286aba3ce43ebd3
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/cache-aside.json

```text
{
  "id": "cache-aside",
  "name": "Cache-aside read",
  "use": "Reads that check a cache before the database.",
  "roles": [
    "service",
    "cache",
    "db"
  ],
  "steps": [
    {
      "from": "service",
      "to": "cache",
      "label": "GET key",
      "kind": "request"
    },
    {
      "from": "cache",
      "to": "service",
      "label": "Miss",
      "kind": "response"
    },
    {
      "from": "service",
      "to": "db",
      "label": "Query source of truth",
      "kind": "data"
    },
    {
      "from": "service",
      "to": "cache",
      "label": "SET key with TTL",
      "kind": "request",
      "detail": "Note TTL and how the key is invalidated on writes."
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/cli-command.json
size: 1141
sha: 5bfb8d9bdbde64f3cd8a470262f5c714adaa5790
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/cli-command.json

```text
{
  "id": "cli-command",
  "name": "CLI command",
  "use": "From argv to output and exit code.",
  "roles": [
    "caller",
    "entry",
    "core",
    "io"
  ],
  "steps": [
    {
      "from": "caller",
      "to": "entry",
      "label": "Runs the command",
      "kind": "request"
    },
    {
      "from": "entry",
      "to": "entry",
      "label": "Parse flags",
      "kind": "internal",
      "detail": "Name the arg parser; note defaults."
    },
    {
      "from": "entry",
      "to": "core",
      "label": "Call core function",
      "kind": "request"
    },
    {
      "from": "core",
      "to": "io",
      "label": "Read / write files or network",
      "kind": "data"
    },
    {
      "from": "core",
      "to": "entry",
      "label": "Result or error",
      "kind": "response"
    },
    {
      "from": "entry",
      "to": "caller",
      "label": "Print + exit code",
      "kind": "response",
      "detail": "Which exit codes mean what?"
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/fan-out-events.json
size: 839
sha: e4529bd0ba9a82e8c281a8eca4d9414e41e2c8d8
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/fan-out-events.json

```text
{
  "id": "fan-out-events",
  "name": "Publish an event, many consumers",
  "use": "Event bus / pub-sub / outbox.",
  "roles": [
    "service",
    "queue",
    "worker"
  ],
  "steps": [
    {
      "from": "service",
      "to": "queue",
      "label": "Publish event",
      "kind": "async",
      "detail": "Is it written in the same transaction (outbox)?"
    },
    {
      "from": "queue",
      "to": "worker",
      "label": "Consumer A handles it",
      "kind": "async"
    },
    {
      "from": "queue",
      "to": "worker",
      "label": "Consumer B handles it",
      "kind": "async",
      "detail": "Consumers must tolerate duplicates and reordering."
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/offline-sync.json
size: 902
sha: efbbfa35994557bae58118b33edeb5d7979eda9f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/offline-sync.json

```text
{
  "id": "offline-sync",
  "name": "Offline write, later sync",
  "use": "Mobile/local-first apps.",
  "roles": [
    "user",
    "screens",
    "storage",
    "api"
  ],
  "steps": [
    {
      "from": "user",
      "to": "screens",
      "label": "Edits while offline",
      "kind": "request"
    },
    {
      "from": "screens",
      "to": "storage",
      "label": "Save locally + mark dirty",
      "kind": "data"
    },
    {
      "from": "storage",
      "to": "api",
      "label": "Sync when online",
      "kind": "async"
    },
    {
      "from": "api",
      "to": "storage",
      "label": "Merge result",
      "kind": "response",
      "detail": "Explain the conflict rule (last-write-wins, CRDT, server wins)."
    }
  ],
  "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
}
```

## File: patterns/optimistic-ui.json
size: 911
sha: e31ebfb266bf9c1a1a7e91f46a554a067d224158
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/optimistic-ui.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/optimistic-ui.json

## File: patterns/page-load.json
size: 1177
sha: 072271c30a25fe65cd54c3cd5f3eaecb43909a70
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/page-load.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/page-load.json

## File: patterns/request-response.json
size: 1254
sha: 05cb19a9aa03998c0e718201bd852886af19ee7e
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/request-response.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/request-response.json

## File: patterns/validation-error.json
size: 824
sha: 0d89bcf985c1e5cbb041cd96c65a7e42ca5229fb
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/validation-error.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/validation-error.json

## File: patterns/webhook-in.json
size: 1124
sha: e2fd16a4260b6137b9645ecbdbf7412d2bb3bf47
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/webhook-in.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/patterns/webhook-in.json

## File: presets/backend-api.json
size: 1986
sha: 284ad76fb9587dc885b53dc2a760f2250bc8369d
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/backend-api.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/backend-api.json

## File: presets/frontend-spa.json
size: 1920
sha: b049ca17184243631db3537170ffdc3b843b5965
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/frontend-spa.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/frontend-spa.json

## File: presets/fullstack-web.json
size: 2011
sha: db00fb00235c6e59f9c459d9fcb523cd04ff9c9f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/fullstack-web.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/fullstack-web.json

## File: presets/library-cli.json
size: 1554
sha: b729561fde03efcf19823a96d85a63173f900818
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/library-cli.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/library-cli.json

## File: presets/mobile-app.json
size: 1734
sha: f063428e92418bd3bdbc050e869bb8dda28c4a0a
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/mobile-app.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/presets/mobile-app.json

## File: SECURITY.md
size: 844
sha: 9ec4dd4172df755a1dc4ab4667ee305c71b55980
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/SECURITY.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/SECURITY.md

## File: template/explainer.html
size: 1231
sha: 821a6fa8474422e94766fd03b17a0dca18c9bee1
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/explainer.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/explainer.html

## File: template/js/app.js
size: 7386
sha: 0f020607d758f3326e1919329dfdfea937bba024
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/app.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/app.js

## File: template/js/changes.js
size: 4640
sha: 23d683cdd509f8cde86af3c60f8fcda0badd96b9
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/changes.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/changes.js

## File: template/js/dom.js
size: 5311
sha: 5de171325256b39e229fe42cd28f6fd5dae8446f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/dom.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/dom.js

## File: template/js/flow.js
size: 12024
sha: fc4e356c46f91419011808c097f94448e1991cd1
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/flow.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/flow.js

## File: template/js/gallery.js
size: 5852
sha: 206e48e85bc7a76f03aa84e87c77e39e1a821cc0
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/gallery.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/gallery.js

## File: template/js/icons.js
size: 6535
sha: 94e11c952aa4f95b975a4169d89fd1121eb7ab45
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/icons.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/icons.js

## File: template/js/lessons.js
size: 4090
sha: c50d682b0f781f32b8b7134e66c831fc24c4239f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/lessons.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/lessons.js

## File: template/js/map.js
size: 8304
sha: 7059edf692ec3396897a4600e3bb26c1baec30b8
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/map.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/map.js

## File: template/js/models.js
size: 8541
sha: 9ebf097a261b817dae82c4558906af5c3aeee340
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/models.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/models.js

## File: template/js/sections.js
size: 6692
sha: 4a5173e98471e7d8c6bd590071c658415a308086
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/sections.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/js/sections.js

## File: template/styles.css
size: 36039
sha: 595269818f97b5b1b521c3ad978f60882fefd29b
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/styles.css

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/template/styles.css

## File: test/brand.test.mjs
size: 3543
sha: 8c4d8599ec02b03124c9556c8cbc1ebaed2cfa31
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/brand.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/brand.test.mjs

## File: test/build.test.mjs
size: 3206
sha: c3115a61ac35707821ec25562bc9b4af7b6b084f
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/build.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/build.test.mjs

## File: test/changes.test.mjs
size: 3521
sha: d1f098f495aba683bb38a909cc7dabcca3f26943
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/changes.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/changes.test.mjs

## File: test/estimate.test.mjs
size: 1903
sha: fb2c0868606c2a2bdd69394d5f4dce7f81f5d98d
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/estimate.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/estimate.test.mjs

## File: test/scan.test.mjs
size: 3027
sha: 76252e8f04dacbdf196406b9c37d869c285f55ca
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/scan.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/scan.test.mjs

## File: test/skill.test.mjs
size: 1261
sha: dbcbca7548b3a7cd61168bfd7c8ffca31d239241
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/skill.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/skill.test.mjs

## File: test/validate.test.mjs
size: 5169
sha: 9a9a10c6e5c3186b3feef36ec145faea48438a34
upstream: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/validate.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/cyvid7-darus10/repo-explainer/blob/HEAD/test/validate.test.mjs
