
Archlet
- 11 installs
- 43 repo stars
- Updated June 5, 2026
- superdesigndev/archlet
Helps with ai & agent building tasks during AI-assisted development.
About
archlet is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- archlet
- AI & Agent Building
- AI-coding skill
Archlet by the numbers
- 11 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #11,769 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/superdesigndev/archlet --skill archletAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 43 |
| Last updated | June 5, 2026 |
| Repository | superdesigndev/archlet ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
archlet
You produce files under .archlet/ that the viewer renders. Pick the mode from the request:
- MAP — "map / visualize the architecture", "refresh the map", no specific change given.
- DIFF — a change is referenced (PR, commit, range
A..B, branch, or "my changes / staged").
Needs an existing .archlet/data.js — run MAP first if absent.
Nodes form a tree of any depth via parent: top-level nodes (source roots / apps) contain modules, which can nest further when a module is big enough to warrant it. Keep it shallow — add a level only when it earns its keep; the viewer expands/collapses at every level. Always finish by launching the viewer (see end).
---
Mode 1 — MAP → .archlet/data.js
The single source of truth the viewer reads:
/* GENERATED by the archlet skill. */
window.ARCH = {
config: { project, layers: { <key>: { label, border, ink, fill? }, ... } }, // project = repo name (viewer title); layers = the taxonomy YOU choose
nodes: [{ id, name, g, parent?, brief, role, rt, path, methods? }], // g = a layer key
edges: [{ s, t, k, l?, src, method }], // src = "file:line"
methodEdges: [{ sNode, sMethod, tNode, tMethod, src }], // method→method calls (step 5)
};- node —
idinternal unique slug (never shown; namespace it, e.g.coding-toolsvsfoundation-tools) ·
name short heading on the card · g a layer key · parent the containing node's id, nested as deep as the structure needs (omit for top-level nodes) · brief ≤ ~6-word tagline · role one sentence · rt key file(s) · path dir relative to repo root · methods (optional, leaf nodes) [{ name, src:"file:line", calls? }] — the top ~3–6 functions by call count.
- edge.k — a short lowercase kind YOU choose (static
import, runtime wire, app→engine…); keep the
vocabulary small so the viewer can style by kind. method: mechanical (tool) | manual (grepped). Containment is implicit via parent — never emit containment edges.
- edge.l (optional) — what flows over a wire edge (
messages.create,POST /webhook); set it only where
it adds signal, never a count (3×).
- methodEdge — a call from one node's method to another's.
sMethod/tMethodmust exist in those nodes'
methods and src is the call site, else the edge is dropped.
Procedure: 1. Scan structure — find source roots (apps/*, packages/*, src); they become the top-level nodes. Read docs. 2. Choose the layer taxonomy (the key judgment) — a few layers, calm palette, e.g. frontends / control-plane / runtime / shared-pkg / external. Put them in config.layers. 3. Extract the mechanical floor — required, via `npx`:
- Routes + import/call graph (codegraph):
npx -y @colbymchenry/codegraph@latest init -i, then
npx -y @colbymchenry/codegraph@latest query "" --kind route --limit 99999 --json for the route inventory. The import + call graph lives in .codegraph/codegraph.db (query it directly).
- Import graph (madge), JS/TS roots only:
npx -y madge@latest --json --ts-config <root>/tsconfig.json <root>/src,
then collapse file→file edges to module→module. Non-JS/TS roots: use that language's tool (go mod graph, pydeps) or codegraph's import graph instead. Build on these — only drop to Grep/Read for what a static tool can't see (most wire/cross-process edges). 4. Build — use a workflow, one agent per major module. Split the repo into its handful of large/important modules (in a monorepo, each app/package; in a single package, the main subsystems) and give each its own agent. Each agent picks core modules (routes/controller/plugin/tool/store/service, or doc-named), emits intra-module import edges (mechanical), and stitches wire edges out of its module by matching client call sites (fetch/SDK/db/proxy/queue) to a target or external node, each with a real file:line (manual). Wire edges are NOT in the import graph and are the most valuable. 5. Methods + methodEdges — from codegraph's call graph (.codegraph/codegraph.db; don't skip — this is what makes nodes legible). Derive mechanically, don't invent names:
- `node.methods` — top functions defined under each leaf node's
path, ranked by call in-degree:
SELECT n.name, n.file_path||':'||n.start_line AS src, (SELECT COUNT(*) FROM edges e WHERE e.kind='calls' AND e.target=n.id) AS calls FROM nodes n WHERE n.kind IN ('method','function') AND n.file_path LIKE '<node.path>/%' ORDER BY calls DESC LIMIT 6;
- `methodEdges` —
callsedges crossing node boundaries, mapped to{ sNode, sMethod, tNode, tMethod, src };
keep only if both method names survived into their nodes' methods. Bucket each side's file_path to its node by longest path prefix. 6. Write, then validate — write .archlet/data.js, run `npx archlet validate` (checks shape + referential integrity), fix what it reports, re-run until clean. Spot-check wire edges against their file:line — drop, don't fabricate. 7. Seed a starter diff — by default. A fresh map is static; overlaying a real recent change shows the DIFF feature working and where activity is concentrated. Pick one coherent change (prefer a recently-merged PR via gh pr list --state merged --limit 15 --json number,title,additions,changedFiles,url; else the largest meaningful commit from git log), discounting lockfile / generated / dist noise. Build it as in Mode 2, register it in the manifest but leave default null so the clean map opens first. Skip silently if history is too thin (shallow clone, lone initial commit, only trivial changes); never fabricate one. Mention it on handoff.
Launch the viewer — npx archlet view is a blocking server that auto-opens the browser; start it in the background and tell the user it's at http://localhost:4173 (stop the process to shut it down).
---
Mode 2 — DIFF → .archlet/diffs/<name>.js
Overlay the files a change touches onto the existing map. Read .archlet/data.js first for node paths.
1. Get the changed files (each: path, additions, deletions):
| source | command |
|---|---|
| PR | gh pr view <n> --repo <owner/name> --json files,title,additions,deletions,changedFiles,url,number |
| commit | git show --numstat --format= <sha> |
| range / branch | git diff --numstat <A>..<B> (use <base>...<head> for branch-vs-merge-base) |
| working tree / staged | git diff --numstat · git diff --numstat --staged |
--numstat lines are <add>\t<del>\t<path> (- = binary).
2. Map files → nodes. For each path, find the node whose path is the longest prefix; else fall back to its top-level root/app node; else report it unmapped. Aggregate per node { files, add, del, paths }. Compute contains = ancestor node ids of every changed node (walk parent).
3. Write the overlay .archlet/diffs/<name>.js:
window.ARCH = window.ARCH || {};
window.ARCH.pr = {
label: "<human label>", // "PR #565" · "commit a1b2c3d" · "working tree"
title: "<optional>", url: "<optional>", number: <n>, // number only for PRs (enables ?pr=<n>)
add, del, files,
nodes: { "<nodeId>": { files, add, del, paths:[...] }, ... },
contains: [ "<ancestorNodeId>", ... ],
links: { // OPTIONAL — connectivity the change alters
add: [ { s, t }, ... ], // a dependency/wire INTRODUCED
cut: [ { s, t }, ... ], // one REMOVED
},
};links.add renders as a green + arrow, links.cut as a dashed ✕ — between the nodes' visible ancestors. Only fill links when the change actually adds/removes a node→node connection (a new/removed import, fetch, db/proxy call); omit for internal-only changes. <name>: pr-<n>, else commit-<sha7> / wip / staged.
4. Register it in .archlet/diffs/manifest.js (create if missing; keep existing entries):
window.DIFFS = {
default: "<name>",
available: [ { name, label, number?, add, del, files }, ... ], // newest first
};Run `npx archlet validate`, fix any issues, then launch the viewer in the background (as above) at http://localhost:4173. The viewer has a Diffs menu; switch via the menu, ?pr=<n>, or ?diff=<name>.
---
Rules
- Provenance or it didn't happen — every wire edge needs a real
file:line; never invent edges. - You decide the taxonomy (small);
data.jsis the single source of truth and a draft the user can hand-edit. - Always end by opening the map in the background, whichever mode you ran — don't leave a command to paste.
- Monorepo gotchas: CommonJS/NestJS DI deps aren't plain imports; apps importing another app's built
dist;
dynamic plugin/route registration (invisible to static tools — read the registrar).
/* GENERATED by the archlet skill. */
window.ARCH = {
config: {
project: 'archlet',
layers: {
cli: { label: 'CLI (Node)', border: '#9db8d6', ink: '#3b6fb0' },
viewer: { label: 'Viewer (Browser)', border: '#a7d0bb', ink: '#3f8f6b' },
data: { label: '.archlet Contract', border: '#d6c08f', ink: '#8a6d22', fill: '#faf6ec' },
gen: { label: 'Skill (Generator)', border: '#cbb3da', ink: '#7a4fa6' },
ext: { label: 'External', border: '#cfc6b8', ink: '#6b6256', fill: '#f3f1ea' },
},
},
nodes: [
// ── CLI: the `archlet` command (bin + lib) ──────────────────────────────
{ id: 'cli', name: 'CLI', g: 'cli', brief: 'the archlet command',
role: 'The `archlet` command — serves the map and validates the generated data files.',
rt: 'bin/archlet.mjs' },
{ id: 'cli-entry', name: 'archlet (entry)', g: 'cli', parent: 'cli', brief: 'argv → lib/<cmd>',
role: 'Parses argv and dynamically imports a same-named lib/<cmd>.mjs module to run it.',
rt: 'bin/archlet.mjs', path: 'bin/archlet.mjs' },
{ id: 'cmd-view', name: 'view', g: 'cli', parent: 'cli', brief: 'serve .archlet + viewer',
role: 'HTTP server that serves the packaged viewer plus the user’s .archlet/ data, then opens the browser.',
rt: 'lib/view.mjs', path: 'lib/view.mjs',
methods: [
{ name: 'run', src: 'lib/view.mjs:19', calls: 0 },
{ name: 'openBrowser', src: 'lib/view.mjs:8', calls: 1 },
] },
{ id: 'cmd-validate', name: 'validate', g: 'cli', parent: 'cli', brief: 'shape + ref-integrity check',
role: 'Checks data.js, diffs, and the manifest against an embedded schema mini-language.',
rt: 'lib/validate.mjs', path: 'lib/validate.mjs',
methods: [
{ name: 'run', src: 'lib/validate.mjs:126', calls: 0 },
{ name: 'checkFields', src: 'lib/validate.mjs:57', calls: 4 },
{ name: 'checkVal', src: 'lib/validate.mjs:37', calls: 4 },
{ name: 'loadGlobals', src: 'lib/validate.mjs:72', calls: 3 },
{ name: 'checkArchRefs', src: 'lib/validate.mjs:80', calls: 1 },
{ name: 'checkDiffRefs', src: 'lib/validate.mjs:113', calls: 1 },
] },
{ id: 'lib-util', name: 'util', g: 'cli', parent: 'cli', brief: 'shared paths + flags',
role: 'Shared helpers: repo / package / viewer paths and a tiny CLI flag parser.',
rt: 'lib/util.mjs', path: 'lib/util.mjs',
methods: [
{ name: 'flag', src: 'lib/util.mjs:9', calls: 2 },
] },
// ── Viewer: the browser-side map ────────────────────────────────────────
{ id: 'viewer', name: 'Viewer', g: 'viewer', brief: 'browser-side map',
role: 'The static viewer that renders .archlet/ into an interactive, zoomable map.',
rt: 'viewer/index.html' },
{ id: 'viewer-shell', name: 'index.html', g: 'viewer', parent: 'viewer', brief: 'HTML shell + load order',
role: 'HTML shell that loads d3, data.js, the diff manifest, and the engine in dependency order.',
rt: 'viewer/index.html', path: 'viewer/index.html' },
{ id: 'viewer-engine', name: 'graph.js', g: 'viewer', parent: 'viewer', brief: 'D3 render engine',
role: 'D3 force-layout engine: draws the node tree, edges, method-call edges, and diff overlays with expand / collapse.',
rt: 'viewer/graph.js', path: 'viewer/graph.js',
methods: [
{ name: 'start', src: 'viewer/graph.js:22' },
{ name: 'draw', src: 'viewer/graph.js:351' },
{ name: 'computeView', src: 'viewer/graph.js:188' },
{ name: 'setFocus', src: 'viewer/graph.js:293' },
{ name: 'tick', src: 'viewer/graph.js:280' },
{ name: 'openInEditor', src: 'viewer/graph.js:60' },
] },
{ id: 'viewer-styles', name: 'styles.css', g: 'viewer', parent: 'viewer', brief: 'viewer styling',
role: 'Stylesheet for the viewer chrome, cards, links, method dots, and diff markers.',
rt: 'viewer/styles.css', path: 'viewer/styles.css' },
// ── The shared contract: generated .archlet/ data ───────────────────────
{ id: 'data-contract', name: '.archlet/ data', g: 'data', brief: 'window.ARCH source of truth',
role: 'Generated data.js (window.ARCH) plus diff overlays and manifest — the single contract the viewer reads and validate checks.',
rt: '.archlet/data.js', path: '.archlet' },
// ── The skill: what generates the contract ──────────────────────────────
{ id: 'skill', name: 'SKILL.md', g: 'gen', brief: 'agent map generator',
role: 'Instructions a coding agent follows to scan the repo and write .archlet/data.js (MAP) or diff overlays (DIFF).',
rt: 'SKILL.md', path: 'SKILL.md' },
// ── External ────────────────────────────────────────────────────────────
{ id: 'd3', name: 'D3 v7', g: 'ext', brief: 'force layout + SVG',
role: 'D3 library loaded from a CDN; supplies the force simulation and SVG selections the engine drives.',
rt: 'cdn.jsdelivr.net/npm/d3@7' },
{ id: 'codegraph', name: 'codegraph', g: 'ext', brief: 'import + call graph',
role: 'npx tool the skill runs to extract routes and the import / call graph into .codegraph/codegraph.db.',
rt: '@colbymchenry/codegraph' },
{ id: 'madge', name: 'madge', g: 'ext', brief: 'JS/TS import graph',
role: 'npx tool the skill runs to extract the file→file import graph for JS / TS source roots.',
rt: 'madge' },
],
edges: [
// CLI dispatch + internal imports
{ s: 'cli-entry', t: 'cmd-view', k: 'dispatch', l: 'view', src: 'bin/archlet.mjs:18', method: 'manual' },
{ s: 'cli-entry', t: 'cmd-validate', k: 'dispatch', l: 'validate', src: 'bin/archlet.mjs:18', method: 'manual' },
{ s: 'cmd-view', t: 'lib-util', k: 'import', src: 'lib/view.mjs:5', method: 'mechanical' },
// CLI → the served / validated artifacts
{ s: 'cmd-view', t: 'viewer-shell', k: 'serves', src: 'lib/view.mjs:28', method: 'manual' },
{ s: 'cmd-view', t: 'data-contract', k: 'serves', l: 'data.js + diffs', src: 'lib/view.mjs:42', method: 'manual' },
{ s: 'cmd-validate', t: 'data-contract', k: 'validates', src: 'lib/validate.mjs:142', method: 'manual' },
// Viewer load chain (index.html script tags) + runtime reads
{ s: 'viewer-shell', t: 'd3', k: 'loads', src: 'viewer/index.html:30', method: 'manual' },
{ s: 'viewer-shell', t: 'data-contract', k: 'loads', l: 'window.ARCH', src: 'viewer/index.html:31', method: 'manual' },
{ s: 'viewer-shell', t: 'viewer-engine', k: 'loads', src: 'viewer/index.html:33', method: 'manual' },
{ s: 'viewer-shell', t: 'viewer-styles', k: 'loads', src: 'viewer/index.html:7', method: 'manual' },
{ s: 'viewer-engine', t: 'data-contract', k: 'reads', l: 'window.ARCH', src: 'viewer/graph.js:23', method: 'manual' },
{ s: 'viewer-engine', t: 'd3', k: 'uses', src: 'viewer/graph.js:12', method: 'manual' },
// Generation pipeline (the skill drives everything)
{ s: 'skill', t: 'data-contract', k: 'writes', l: 'data.js', src: 'SKILL.md:26', method: 'manual' },
{ s: 'skill', t: 'codegraph', k: 'runs', src: 'SKILL.md:56', method: 'manual' },
{ s: 'skill', t: 'madge', k: 'runs', src: 'SKILL.md:61', method: 'manual' },
{ s: 'skill', t: 'cli-entry', k: 'runs', l: 'archlet view/validate', src: 'SKILL.md:88', method: 'manual' },
],
methodEdges: [
// the one cross-file call the call graph captured: view.run() reads flags via util.flag()
{ sNode: 'cmd-view', sMethod: 'run', tNode: 'lib-util', tMethod: 'flag', src: 'lib/view.mjs:21' },
],
};
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
# Dependencies
node_modules/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Build / cache
dist/
.cache/
*.tgz
#!/usr/bin/env node
const [cmd, ...rest] = process.argv.slice(2);
const HELP = `archlet — view an architecture map produced by the archlet skill.
archlet view [--port 4173] serve .archlet/ (the map) at localhost + open browser
[--no-open] don't auto-open the browser
archlet validate [path] check .archlet/ data + diffs against the schema
Everything else (generating .archlet/data.js, diffs, etc.) is done by the
\`archlet\` Claude Code skill — install it with: npx skills add superdesigndev/archlet`;
const COMMANDS = new Set(['view', 'validate']); // each command is backed by a same-named lib/<cmd>.mjs
if (!cmd || ['help', '-h', '--help'].includes(cmd)) { console.log(HELP); process.exit(0); }
if (!COMMANDS.has(cmd)) { console.error('unknown command: ' + cmd + '\n'); console.log(HELP); process.exit(1); }
import(new URL(`../lib/${cmd}.mjs`, import.meta.url))
.then(m => m.run(rest))
.catch(e => { console.error('error: ' + (e && e.message || e)); process.exit(1); });
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const cwd = process.cwd();
export const archDir = `${cwd}/.archlet`;
export const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url))); // package root (parent of lib/)
export const viewerDir = `${pkgRoot}/viewer`;
export const flag = (args, name, def) => {
const i = args.indexOf(name);
return i >= 0 ? (args[i + 1] ?? true) : def;
};
// archlet validate — shape + referential-integrity checks for the files the
// `archlet` skill generates. Zero deps; the schema lives in SPEC below.
//
// Shape mini-language (one token per field, whitespace-separated):
// name:s required string name:s? optional string
// name:n number name:b boolean name:o any object
// name:[t] array of type t name:(a|b) enum of literals
// name:type a nested object type defined elsewhere in SPEC
// A trailing `?` on the type makes the field optional.
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve, basename, relative } from 'node:path';
import vm from 'node:vm';
const SPEC = {
// ── .archlet/data.js → window.ARCH ──
arch: 'config:config nodes:[node] edges:[edge] methodEdges:[methodEdge]?',
config: 'project:s layers:o',
layer: 'label:s border:s? ink:s? fill:s?',
node: 'id:s name:s g:s parent:s? brief:s role:s rt:s path:s? methods:[method]?',
method: 'name:s src:s calls:n?',
edge: 's:s t:s k:s l:s? src:s method:(mechanical|manual)',
methodEdge: 'sNode:s sMethod:s tNode:s tMethod:s src:s',
// ── .archlet/diffs/<name>.js → window.ARCH.pr ──
pr: 'label:s title:s? url:s? number:n? add:n del:n files:n nodes:o contains:[s] links:links?',
links: 'add:[link]? cut:[link]?',
link: 's:s t:s',
prNode: 'files:n add:n del:n paths:[s]',
// ── .archlet/diffs/manifest.js → window.DIFFS ──
diffs: 'default:s? available:[avail]',
avail: 'name:s label:s? number:n? add:n? del:n? files:n?',
};
const SRC_RE = /.+:\d+$/; // "file:line"
// ── tiny spec interpreter ───────────────────────────────────────────────────
function checkVal(val, type, path, errs) {
type = type.trim();
if (type.startsWith('[') && type.endsWith(']')) {
if (!Array.isArray(val)) return errs.push(`${path}: expected array`);
return val.forEach((v, i) => checkVal(v, type.slice(1, -1), `${path}[${i}]`, errs));
}
if (type.startsWith('(') && type.endsWith(')')) {
const opts = type.slice(1, -1).split('|');
if (!opts.includes(val)) errs.push(`${path}: expected one of ${opts.join('|')}, got ${JSON.stringify(val)}`);
return;
}
if (type === 's') return typeof val === 'string' || errs.push(`${path}: expected string`);
if (type === 'n') return typeof val === 'number' || errs.push(`${path}: expected number`);
if (type === 'b') return typeof val === 'boolean' || errs.push(`${path}: expected boolean`);
if (type === 'o') return (val && typeof val === 'object') || errs.push(`${path}: expected object`);
const spec = SPEC[type];
if (!spec) return errs.push(`${path}: unknown type "${type}"`);
checkFields(val, spec, path, errs);
}
function checkFields(obj, spec, path, errs) {
if (obj == null || typeof obj !== 'object') return errs.push(`${path}: expected object`);
for (const tok of spec.split(/\s+/).filter(Boolean)) {
const ci = tok.indexOf(':');
const name = tok.slice(0, ci);
let type = tok.slice(ci + 1);
const optional = type.endsWith('?');
if (optional) type = type.slice(0, -1);
const has = Object.prototype.hasOwnProperty.call(obj, name) && obj[name] != null;
if (!has) { if (!optional) errs.push(`${path}.${name}: missing required field`); continue; }
checkVal(obj[name], type, `${path}.${name}`, errs);
}
}
// ── load a viewer data file (classic script assigning window.* globals) ──────
function loadGlobals(file) {
const ctx = { window: {}, console };
vm.createContext(ctx);
vm.runInContext(readFileSync(file, 'utf8'), ctx, { filename: file });
return ctx.window;
}
// ── referential integrity: the part a pure schema can't express ──────────────
function checkArchRefs(arch, errs) {
const nodes = arch.nodes || [];
const ids = new Set();
for (const n of nodes) {
if (ids.has(n.id)) errs.push(`node "${n.id}": duplicate id`);
ids.add(n.id);
}
// config.layers is a typed map; validate each layer's shape once, up front —
// independent of which nodes reference it (the SPEC mini-language has no map-of-type).
const layerMap = (arch.config && arch.config.layers) || {};
const layers = new Set(Object.keys(layerMap));
for (const [k, v] of Object.entries(layerMap)) checkVal(v, 'layer', `config.layers.${k}`, errs);
const methodsByNode = {};
for (const n of nodes) {
if (!layers.has(n.g)) errs.push(`node "${n.id}".g="${n.g}" is not a key in config.layers`);
if (n.parent != null && !ids.has(n.parent)) errs.push(`node "${n.id}".parent="${n.parent}" is not a node id`);
methodsByNode[n.id] = new Set((n.methods || []).map(m => m.name));
for (const m of n.methods || []) if (!SRC_RE.test(m.src)) errs.push(`node "${n.id}" method "${m.name}".src="${m.src}" is not file:line`);
}
for (const [i, e] of (arch.edges || []).entries()) {
if (!ids.has(e.s)) errs.push(`edge[${i}].s="${e.s}" is not a node id`);
if (!ids.has(e.t)) errs.push(`edge[${i}].t="${e.t}" is not a node id`);
if (!SRC_RE.test(e.src)) errs.push(`edge[${i}].src="${e.src}" is not file:line`);
}
for (const [i, me] of (arch.methodEdges || []).entries()) {
for (const side of ['sNode', 'tNode']) if (!ids.has(me[side])) errs.push(`methodEdge[${i}].${side}="${me[side]}" is not a node id`);
if (ids.has(me.sNode) && !methodsByNode[me.sNode].has(me.sMethod)) errs.push(`methodEdge[${i}].sMethod="${me.sMethod}" not in node "${me.sNode}".methods[]`);
if (ids.has(me.tNode) && !methodsByNode[me.tNode].has(me.tMethod)) errs.push(`methodEdge[${i}].tMethod="${me.tMethod}" not in node "${me.tNode}".methods[]`);
if (!SRC_RE.test(me.src)) errs.push(`methodEdge[${i}].src="${me.src}" is not file:line`);
}
return ids;
}
function checkDiffRefs(pr, nodeIds, errs, label) {
for (const id of Object.keys(pr.nodes || {})) {
if (!nodeIds.has(id)) errs.push(`${label}: nodes["${id}"] is not a node id in data.js`);
else checkVal(pr.nodes[id], 'prNode', `${label}.nodes.${id}`, errs);
}
for (const id of pr.contains || []) if (!nodeIds.has(id)) errs.push(`${label}: contains "${id}" is not a node id`);
for (const kind of ['add', 'cut']) for (const [i, l] of ((pr.links && pr.links[kind]) || []).entries()) {
if (!nodeIds.has(l.s)) errs.push(`${label}: links.${kind}[${i}].s="${l.s}" is not a node id`);
if (!nodeIds.has(l.t)) errs.push(`${label}: links.${kind}[${i}].t="${l.t}" is not a node id`);
}
}
// ── entry point ──────────────────────────────────────────────────────────────
export async function run(args = []) {
const target = args.find(a => !a.startsWith('-')) || '.archlet';
const isDataFile = basename(target) === 'data.js';
const dir = isDataFile ? resolve(target, '..') : resolve(target);
const dataFile = isDataFile ? resolve(target) : join(dir, 'data.js');
if (!existsSync(dataFile)) {
console.error(`error: ${dataFile} not found — run the archlet skill (MAP mode) first.`);
process.exit(1);
}
const errs = [];
let nodeIds = new Set();
let checked = 0;
// 1) data.js
const arch = loadGlobals(dataFile).ARCH;
if (!arch) errs.push(`${rel(dataFile)}: window.ARCH was not assigned`);
else {
checkFields(arch, SPEC.arch, 'ARCH', errs);
nodeIds = checkArchRefs(arch, errs);
checked++;
console.log(` ${errs.length ? '·' : '✓'} ${rel(dataFile)} — ${(arch.nodes || []).length} nodes, ${(arch.edges || []).length} edges, ${(arch.methodEdges || []).length} method edges`);
}
// 2) diffs (optional)
const diffsDir = join(dir, 'diffs');
if (existsSync(diffsDir)) {
const manifestFile = join(diffsDir, 'manifest.js');
if (existsSync(manifestFile)) {
const diffs = loadGlobals(manifestFile).DIFFS;
if (!diffs) errs.push(`diffs/manifest.js: window.DIFFS was not assigned`);
else { checkFields(diffs, SPEC.diffs, 'DIFFS', errs); checked++; }
}
for (const f of readdirSync(diffsDir).filter(f => f.endsWith('.js') && f !== 'manifest.js')) {
const pr = loadGlobals(join(diffsDir, f)).ARCH?.pr;
const label = `diffs/${f}`;
if (!pr) { errs.push(`${label}: window.ARCH.pr was not assigned`); continue; }
checkFields(pr, SPEC.pr, label, errs);
checkDiffRefs(pr, nodeIds, errs, label);
checked++;
console.log(` ${'✓'} ${label} — ${Object.keys(pr.nodes || {}).length} touched nodes`);
}
}
if (errs.length) {
console.error(`\n✗ ${errs.length} problem${errs.length === 1 ? '' : 's'}:\n`);
for (const e of errs) console.error(` • ${e}`);
console.error('');
process.exit(1);
}
console.log(`\n✓ valid — ${checked} file${checked === 1 ? '' : 's'} pass shape + referential checks.`);
}
function rel(p) { return relative(process.cwd(), p); }
import { createServer } from 'node:http';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { join, extname, normalize } from 'node:path';
import { spawn } from 'node:child_process';
import { archDir, viewerDir, flag, cwd } from './util.mjs';
// open a URL in the system's default browser; best-effort, never throws
function openBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'cmd' : 'xdg-open';
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
try {
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
} catch { /* headless / no browser — the printed URL still works */ }
}
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };
export function run(args) {
if (!existsSync(join(archDir, 'data.js'))) throw new Error('no .archlet/ here — generate the map first by running the `archlet` skill in your coding agent (e.g. Claude Code): install with `npx skills add superdesigndev/archlet`, then ask it to map this repo');
const port = +flag(args, '--port', 4173);
const srv = createServer((req, res) => {
let p = normalize(decodeURIComponent(req.url.split('?')[0])).replace(/^(\.\.[/\\])+/, '');
if (p === '/' || p === '\\') p = '/index.html';
// data.js + diffs/* belong to the user's .archlet; everything else is the packaged viewer
const fromArch = p === '/data.js' || p.startsWith('/diffs/');
const cands = fromArch ? [join(archDir, p)] : [join(viewerDir, p), join(archDir, p)];
const file = cands.find(f => existsSync(f) && statSync(f).isFile());
if (!file) {
// tolerate a missing diff registry so .archlet only needs data.js
if (p === '/diffs/manifest.js') {
res.writeHead(200, { 'content-type': 'text/javascript', 'cache-control': 'no-store' });
return res.end('window.DIFFS = { default: null, available: [] };');
}
res.writeHead(404); return res.end('not found: ' + p);
}
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
// inject the absolute repo root onto window.ARCH so the viewer can turn the
// repo-relative `file:line` provenance into absolute editor deep-links (open-in-editor)
if (fromArch && p === '/data.js') {
return res.end(readFileSync(file, 'utf8') + `\nif (window.ARCH) window.ARCH.root = ${JSON.stringify(cwd)};\n`);
}
res.end(readFileSync(file));
});
const open = !flag(args, '--no-open', false);
srv.listen(port, () => {
const url = `http://localhost:${port}`;
console.log(`archlet → ${url} (serving ${archDir})`);
if (open) openBrowser(url);
console.log('Ctrl+C to stop');
});
}
{
"name": "archlet",
"version": "0.1.0",
"description": "Generate an interactive, provenance-backed architecture map of a codebase into .archlet/ — layered modules, expand/collapse, PR diff overlays.",
"type": "module",
"bin": {
"archlet": "bin/archlet.mjs"
},
"files": [
"bin",
"lib",
"viewer",
"SKILL.md"
],
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "git+https://github.com/superdesigndev/archlet.git"
},
"homepage": "https://github.com/superdesigndev/archlet#readme",
"bugs": {
"url": "https://github.com/superdesigndev/archlet/issues"
},
"scripts": {
"dev": "node bin/archlet.mjs"
},
"keywords": [
"architecture",
"code-graph",
"dependency-graph",
"diagram",
"documentation",
"codegraph"
],
"license": "MIT"
}
archlet
!archlet on archlet
A small helper that tries to keep Engineers in step with the pace of Vibe Coding.
We're a team that's been doing Vibe Coding for a while, so we keep running into the same thing: the architecture you think you have in your head, and the one the code has quietly grown into, slowly drift apart (sometimes in one corner, sometimes to the point where you don't quite recognize the whole thing anymore haha)
archlet does something pretty plain: it lets your coding agent read the whole codebase, draw it as a map you can click into and drill down through, and show it to you. After a PR, you can also overlay that change onto the map to see exactly what it touched.
If this is a pain you share, and you happen to have some Tokens to burn, give it a try. If it helps you even a little, those Tokens weren't wasted.
The simplest way to try it
Paste this to your coding agent (Claude Code, Codex, ...) from inside your codebase:
Follow https://raw.githubusercontent.com/superdesigndev/archlet/main/SKILL.md and build a map for this projectThat's it. The agent reads the instructions, maps the repo, and opens the viewer for you.
How it works
Two pieces:
- A skill — handed to a coding agent like Claude Code. It scans the code, picks a layering, and writes the architecture into
.archlet/data.js(the single source of truth, and a draft you're free to hand-edit). - A viewer — a small local server that renders
.archlet/into a map you can expand and collapse.
archlet doesn't draw the underlying graph from scratch — it stands on the shoulders of two excellent tools, and owes them most of the credit:
- **codegraph** — does the real extraction. The skill runs it to pull out the route inventory and the import + call graph, which lands in
.codegraph/codegraph.db. The call graph in particular is what lets the map go beyond "files that import files." - **madge** — the trusty JS/TS import-graph workhorse, used to cross-check and fill in module dependencies on JS/TS roots.
What the agent adds on top is the judgement: reading that raw graph, picking a layering, naming the modules, and turning it into something you can actually click through. That's the part that burns the Tokens. The CLI itself only handles two small jobs: serving the map and validating the data.
Usage
If you'd rather install the skill properly first:
npx skills add superdesigndev/archletThen ask it to map this repo — something as plain as "map this project's architecture." Either way, when it's done it writes .archlet/ and brings up a viewer:
npx archlet view # open the map at localhost:4173
npx archlet validate # check that .archlet/ data is self-consistentTo see how a change lands on the map, ask it to "overlay PR #123." It generates a diff overlay you can switch to from the viewer's Diffs menu.
A few notes
- This is an early, small thing. It's happy to help where it can; it isn't trying to solve everything.
- It isn't easy on Tokens — it's essentially trading compute for clarity. Whether that's worth it is your call.
- The map is drawn by an agent, so it will occasionally misread something.
MIT License.
/* archlet architecture map — ENGINE (rendering + interaction)
* ----------------------------------------------------------------------------
* You normally don't edit this. The map's content lives in architecture.data.js;
* styling lives in architecture.css. The only thing here you might tweak is the
* THEME block (layer colors / labels) and the LAYOUT tuning constants below.
*
* Loaded as a classic <script> AFTER d3 (global `d3`) and architecture.data.js
* (global `window.ARCH`), so it works from file:// without a server.
* ----------------------------------------------------------------------------
*/
(function () {
const d3 = window.d3;
if (!d3 || !window.ARCH) {
document.body.insertAdjacentHTML('beforeend',
'<div style="position:fixed;inset:0;display:grid;place-items:center;color:#b66;font-family:monospace">' +
'Failed to load D3 or data. Check your connection (D3 is loaded from a CDN) and refresh.</div>');
return;
}
start();
function start() {
const nodes = window.ARCH.nodes;
// ── TITLE: name the map after the analysed project, not a hardcoded brand ──
// `config.project` is set per repo by the `archlet` skill; fall back gracefully.
const rawProject = (window.ARCH.config && window.ARCH.config.project) || '';
const project = rawProject ? rawProject.charAt(0).toUpperCase() + rawProject.slice(1) : '';
const heading = project ? project + ' Architecture Map' : 'Architecture Map';
document.title = project ? project + ' · Architecture Map' : 'Architecture Map';
const h1 = document.querySelector('header h1');
if (h1) h1.textContent = heading;
let PR = window.ARCH.pr || null; // active diff overlay (loaded on demand by the Diffs menu)
// ── open-in-editor ──────────────────────────────────────────────────────────
// Turn a repo-relative `file:line` provenance string into an editor deep-link.
// There is no universal "open in my editor" scheme, so the target is a configurable
// URL template. Default is VS Code; switch with ?editor=<key> or archletSetEditor(<key>)
// (persisted in localStorage). VS Code-family links need an absolute path, which the
// `archlet view` server injects as window.ARCH.root; opening from file:// won't have it.
const EDITOR_PRESETS = {
vscode: 'vscode://file/{abs}:{line}:{col}',
cursor: 'cursor://file/{abs}:{line}:{col}',
windsurf: 'windsurf://file/{abs}:{line}:{col}',
vscodium: 'vscodium://file/{abs}:{line}:{col}',
jetbrains: 'jetbrains://idea/navigate/reference?path={abs}',
};
const REPO_ROOT = ((window.ARCH && window.ARCH.root) || '').replace(/\/+$/, '');
(function () { const q = new URLSearchParams(location.search).get('editor');
if (q && EDITOR_PRESETS[q]) try { localStorage.setItem('archlet.editor', q); } catch {} })();
function editorTemplate() {
let key; try { key = localStorage.getItem('archlet.editor'); } catch {}
return (key && EDITOR_PRESETS[key]) || EDITOR_PRESETS.vscode;
}
window.archletSetEditor = k => {
if (!EDITOR_PRESETS[k]) return console.warn('[archlet] unknown editor; options:', Object.keys(EDITOR_PRESETS).join(', '));
try { localStorage.setItem('archlet.editor', k); } catch {}
console.log('[archlet] editor →', k);
};
function openInEditor(src) {
if (!src) return;
const m = String(src).match(/^(.*?):(\d+)(?::(\d+))?$/);
const file = (m ? m[1] : String(src)).replace(/^\/+/, ''), line = m ? m[2] : '1', col = (m && m[3]) || '1';
if (!REPO_ROOT) console.warn('[archlet] no repo root — absolute editor links need `npx archlet view`; sending a relative path.');
const abs = REPO_ROOT ? REPO_ROOT + '/' + file : file;
const url = editorTemplate().replace('{abs}', abs).replace('{file}', file).replace('{line}', line).replace('{col}', col);
window.location.href = url;
}
// a node's clickable target: its key file (rt), falling back to its dir (path)
function openNode(d) {
let f = (d.rt || '').split(/[,\s]+/).filter(Boolean)[0] || d.path;
if (!f) return;
if (!f.includes('/') && d.path) f = d.path.replace(/\/+$/, '') + '/' + f;
openInEditor(f);
}
// render links are derived from the model edges: drop an import edge when a
// non-import edge already connects the same pair; dedupe by (s,t,k).
const baseLinks = (() => {
const edges = window.ARCH.edges || [];
const nonImport = new Set(edges.filter(e => e.k !== 'import').map(e => e.s + '|' + e.t));
const seen = new Set(), out = [];
for (const e of edges) {
if (e.k === 'import' && nonImport.has(e.s + '|' + e.t)) continue;
const key = e.s + '|' + e.t + '|' + (e.k || '');
if (seen.has(key)) continue; seen.add(key);
out.push({ s: e.s, t: e.t, l: e.l, k: e.k });
}
return out;
})();
// ── THEME: per-layer border / text / fill colors + legend labels ──────────
// Layers come from window.ARCH.config.layers (generated per repo by `archlet`);
// this palette is the built-in default / fallback when no config is present.
const DEFAULT_LAYERS = {
fe: { label: 'Frontends', border: '#9db8d6', ink: '#3b6fb0' },
cp: { label: 'Control Plane', border: '#d6c08f', ink: '#8a6d22' },
rt: { label: 'Agent Runtime', border: '#a7d0bb', ink: '#3f8f6b' },
eng: { label: 'Shared Engine', border: '#7fb0ff', ink: '#2f6db0', fill: '#eef4fc' },
sub: { label: 'Engine Internals', border: '#8fcccc', ink: '#2f8a8a' },
pkg: { label: 'Shared Packages', border: '#cbb3da', ink: '#7a4fa6' },
ext: { label: 'External Services', border: '#cfc6b8', ink: '#6b6256', fill: '#f3f1ea' },
};
const LAYERS = (window.ARCH.config && window.ARCH.config.layers) || DEFAULT_LAYERS;
const pick = (f, d) => Object.fromEntries(Object.entries(LAYERS).map(([k, v]) => [k, v[f] != null ? v[f] : d]));
const BORDER = pick('border', '#cfc6b8');
const INK = pick('ink', '#6b6256');
const FILL = pick('fill'); // undefined where a layer has no fill — call sites already fall back to '#fff'
const LAYER = pick('label', '');
// ── LAYOUT tuning ─────────────────────────────────────────────────────────
const DIST = { inner: 150, import: 195, base: 215 };
const CHARGE = -1500;
const COLLIDE_PAD = 32;
// approx glyph advance (px) per text style — used to size boxes and wrap text
const CHAR_W = { name: 7.1, brief: 5.2, method: 5.6, wrap: 4.7 };
// build the legend from the theme so colors live in one place;
// each row toggles the visibility of its layer's modules
const hiddenLayers = new Set();
(function buildLegend() {
const el = document.getElementById('legend');
if (!el) return;
el.innerHTML = Object.keys(LAYER).map(k =>
`<div class="row" data-layer="${k}" title="click to hide / show this layer"><span class="sw" style="border-color:${BORDER[k]};background:${FILL[k] || '#fff'}"></span>${LAYER[k]}</div>`
).join('');
el.querySelectorAll('.row').forEach(row => row.addEventListener('click', () => {
const k = row.dataset.layer;
hiddenLayers.has(k) ? hiddenLayers.delete(k) : hiddenLayers.add(k);
row.classList.toggle('off', hiddenLayers.has(k));
draw(true);
}));
})();
// ── hierarchy helpers ─────────────────────────────────────────────────────
const byId = new Map(nodes.map(n => [n.id, n]));
const childrenOf = {};
nodes.forEach(n => { if (n.parent) (childrenOf[n.parent] ||= []).push(n.id); });
const isCollapsible = id => !!(childrenOf[id] && childrenOf[id].length);
const containLinks = nodes.filter(n => n.parent).map(n => ({ s: n.parent, t: n.id, k: 'inner' }));
// word-wrap a string to a pixel width (approx; SVG text has no auto-wrap)
function wrapText(text, width) {
if (!text) return [];
const maxChars = Math.max(10, Math.floor((width - 18) / CHAR_W.wrap));
const lines = []; let cur = '';
for (let w of text.split(/\s+/)) {
while (w.length > maxChars) { if (cur) { lines.push(cur); cur = ''; } lines.push(w.slice(0, maxChars)); w = w.slice(maxChars); }
if (!cur) cur = w;
else if ((cur + ' ' + w).length <= maxChars) cur += ' ' + w;
else { lines.push(cur); cur = w; }
}
if (cur) lines.push(cur);
return lines;
}
// box size + top-down internal layout (offsets relative to box top)
nodes.forEach(n => {
// id is an internal unique slug; show a human heading instead, and only keep brief
// as a subtitle when it isn't already serving as the heading.
n._head = n.name || n.brief || n.id;
const sub = n.name ? (n.brief || '') : '';
const wName = n._head.length * CHAR_W.name, wBrief = sub.length * CHAR_W.brief;
const mNames = (n.methods || []).map(m => m.name.length);
const wMeth = mNames.length ? Math.max(...mNames) * CHAR_W.method + 16 : 0;
n.w = Math.max(108, Math.min(232, Math.max(wName, wBrief, wMeth) + 30));
n.briefLines = wrapText(sub, n.w);
let y = 13; const L = { nameY: y, briefYs: [], methodYs: [] };
y += 3;
for (let i = 0; i < n.briefLines.length; i++) { y += 10; L.briefYs.push(y); }
if (n.methods && n.methods.length) {
y += 6; L.dividerY = y; y += 5;
for (let i = 0; i < n.methods.length; i++) { y += 11; L.methodYs.push(y); }
y += 5;
} else { y += 8; }
n.h = Math.max(30, y);
n._layout = L;
});
// degree → hub flag (over full graph)
const deg = {};
baseLinks.concat(containLinks).forEach(l => { deg[l.s] = (deg[l.s] || 0) + 1; deg[l.t] = (deg[l.t] || 0) + 1; });
nodes.forEach(n => { n.deg = deg[n.id] || 1; n.hub = n.deg >= 6; });
const expanded = new Set();
function visible(n) { let p = n.parent; while (p) { if (!expanded.has(p)) return false; p = byId.get(p).parent; } return true; }
function lift(id) { let n = byId.get(id); while (n.parent && !expanded.has(n.parent)) n = byId.get(n.parent); return n.id; }
function computeView() {
const vnodes = nodes.filter(n => visible(n) && !hiddenLayers.has(n.g));
const vis = new Set(vnodes.map(n => n.id));
const seen = new Set(); const vlinks = [];
for (const l of baseLinks) {
const s = lift(l.s), t = lift(l.t);
if (s === t || !vis.has(s) || !vis.has(t)) continue;
const key = s + '__' + t + '__' + (l.k || '');
if (seen.has(key)) continue; seen.add(key);
vlinks.push({ s, t, k: l.k, l: l.l });
}
for (const n of nodes) if (n.parent && expanded.has(n.parent) && vis.has(n.id) && vis.has(n.parent)) vlinks.push({ s: n.parent, t: n.id, k: 'inner' });
applyLinkDiff(vlinks);
return { vnodes, vlinks };
}
// diff link overlay: tag connections the active diff adds/cuts, and inject ones
// that aren't already on the map (a cut edge usually no longer exists; a new one not yet).
// Endpoints are lifted to their visible ancestor so it works while subsystems are collapsed.
function applyLinkDiff(vlinks) {
if (!PR || !PR.links) return;
const tag = {}, want = [];
for (const kind of ['add', 'cut']) for (const e of (PR.links[kind] || [])) {
if (!byId.has(e.s) || !byId.has(e.t)) continue;
const s = lift(e.s), t = lift(e.t);
if (s === t) continue; // both endpoints collapsed into the same box
tag[s + '>' + t] = kind;
want.push({ s, t, kind });
}
for (const l of vlinks) { const c = tag[l.s + '>' + l.t]; if (c) l.chg = c; }
const present = new Set(vlinks.map(l => l.s + '>' + l.t));
for (const e of want) {
const key = e.s + '>' + e.t;
if (present.has(key)) continue; present.add(key);
vlinks.push({ s: e.s, t: e.t, chg: e.kind, synth: true });
}
}
// ── svg scaffold ──────────────────────────────────────────────────────────
const svg = d3.select('svg');
const W = () => svg.node().clientWidth || window.innerWidth;
const H = () => svg.node().clientHeight || window.innerHeight;
svg.append('defs').html(`
<marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="#5a554a"/></marker>
<marker id="arrow-add" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="#4f9b76"/></marker>
<marker id="arrow-method" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="#5b8bd0"/></marker>
`);
const root = svg.append('g');
const linkG = root.append('g');
const labelG = root.append('g');
const nodeG = root.append('g');
const methodEdgeG = root.append('g'); // above nodes so method lines reach the row dots
const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('mousedown.cursor', () => svg.classed('grabbing', true)).on('mouseup.cursor', () => svg.classed('grabbing', false));
let nodeSel, linkSel, elabelSel, diffmarkSel, adj = {};
let labelsOn = true, movedFlag = false, seedCounter = 0, focusId = null;
const sim = d3.forceSimulation([])
.force('link', d3.forceLink([]).id(d => d.id)
.distance(d => d.k === 'inner' ? DIST.inner : d.k === 'import' ? DIST.import : DIST.base)
.strength(d => d.k === 'inner' ? 0.5 : 0.2))
.force('charge', d3.forceManyBody().strength(CHARGE))
.force('collide', d3.forceCollide().radius(d => Math.max(d.w, d.h) / 2 + COLLIDE_PAD))
.on('tick', tick);
function center() { sim.force('x', d3.forceX(W() / 2).strength(0.05)); sim.force('y', d3.forceY(H() / 2).strength(0.05)); }
center();
// rounded horizontal elbow: sx,sy → turn column at mx → tx,ty (square fallback when there's no room for the fillet)
function hElbow(sx, sy, tx, ty, mx, r) {
if (Math.abs(ty - sy) < 2 * r || Math.abs(mx - sx) < r || Math.abs(mx - tx) < r) return `M${sx},${sy}H${mx}V${ty}H${tx}`;
const a = mx >= sx ? 1 : -1, b = tx >= mx ? 1 : -1, vd = ty >= sy ? 1 : -1;
return `M${sx},${sy}H${mx - a * r}Q${mx},${sy} ${mx},${sy + vd * r}V${ty - vd * r}Q${mx},${ty} ${mx + b * r},${ty}H${tx}`;
}
// orthogonal (elbow) connector between two boxes, with rounded corners
function orthPath(s, t) {
const dx = t.x - s.x, dy = t.y - s.y, r = 8;
if (Math.abs(dx) >= Math.abs(dy)) {
const sg = dx >= 0 ? 1 : -1;
const sx = s.x + sg * s.w / 2, tx = t.x - sg * t.w / 2;
return hElbow(sx, s.y, tx, t.y, (sx + tx) / 2, r);
}
const sg = dy >= 0 ? 1 : -1;
const sy = s.y + sg * s.h / 2, ty = t.y - sg * t.h / 2, my = (sy + ty) / 2;
if (Math.abs(t.x - s.x) < 2 || Math.abs(my - sy) < r || Math.abs(my - ty) < r) return `M${s.x},${sy}V${my}H${t.x}V${ty}`;
const hd = t.x >= s.x ? 1 : -1;
return `M${s.x},${sy}V${my - sg * r}Q${s.x},${my} ${s.x + hd * r},${my}H${t.x - hd * r}Q${t.x},${my} ${t.x},${my + sg * r}V${ty}`;
}
function tick() {
if (linkSel) linkSel.attr('d', d => orthPath(d.source, d.target));
if (elabelSel) elabelSel.attr('x', d => (d.source.x + d.target.x) / 2).attr('y', d => (d.source.y + d.target.y) / 2 - 2);
if (diffmarkSel) diffmarkSel.attr('x', d => (d.source.x + d.target.x) / 2).attr('y', d => (d.source.y + d.target.y) / 2 + 4);
if (nodeSel) nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
if (focusId) positionMethodEdges(); // re-point the focused node's method edges as nodes drift
}
// absolute [x,y] of method row i's dot (left of the row)
function methodPort(n, i) { return [n.x - n.w / 2 + 12, n.y - n.h / 2 + n._layout.methodYs[i] - 3]; }
// method-level call edges for the focused node: resolved ONCE on focus/redraw, re-pointed cheaply each tick
let focusEdges = [], medgeSel = null;
function setFocus(id) {
focusId = id;
focusEdges = [];
if (id != null && nodeSel) {
const vis = new Map(); nodeSel.each(d => vis.set(d.id, d));
for (const e of (window.ARCH.methodEdges || [])) {
if (e.sNode !== id && e.tNode !== id) continue;
const s = vis.get(e.sNode), t = vis.get(e.tNode);
if (!s || !t || !s.methods || !t.methods) continue;
const si = s.methods.findIndex(m => m.name === e.sMethod);
const ti = t.methods.findIndex(m => m.name === e.tMethod);
if (si >= 0 && ti >= 0) focusEdges.push({ s, t, si, ti });
}
}
medgeSel = methodEdgeG.selectAll('path').data(focusEdges).join('path').attr('class', 'medge').attr('marker-end', 'url(#arrow-method)');
positionMethodEdges();
}
function positionMethodEdges() {
if (!medgeSel) return;
medgeSel.attr('d', e => {
const [sx, sy] = methodPort(e.s, e.si), [tx, ty] = methodPort(e.t, e.ti);
// both dots sit on the left of their rows: run out to a shared channel left of both boxes, then in
const lx = Math.min(e.s.x - e.s.w / 2, e.t.x - e.t.w / 2) - 22;
return hElbow(sx, sy, tx, ty, lx, 6);
});
}
// seed adjacency only for linked nodes; isolated nodes fall back to {self} in onEnter
function buildAdj(vlinks) { adj = {}; vlinks.forEach(l => { (adj[l.s] ||= new Set([l.s])).add(l.t); (adj[l.t] ||= new Set([l.t])).add(l.s); }); }
const drag = d3.drag()
.on('start', (e, d) => { movedFlag = false; if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { movedFlag = true; d.fx = e.x; d.fy = e.y; })
.on('end', (e) => { if (!e.active) sim.alphaTarget(0); });
const tip = document.getElementById('tip');
function onEnter(e, d) {
const near = adj[d.id] || new Set([d.id]);
nodeSel.classed('dim', n => !near.has(n.id)).classed('hot', n => n.id === d.id);
linkSel.classed('dim', l => l.source.id !== d.id && l.target.id !== d.id).classed('hot', l => l.source.id === d.id || l.target.id === d.id);
elabelSel.classed('show', l => l.source.id === d.id || l.target.id === d.id); // always reveal this node's labels on hover
const act = isCollapsible(d.id) ? `<div class="t-act">▸ click to ${expanded.has(d.id) ? 'collapse' : 'expand'} (${childrenOf[d.id].length} modules)</div>` : '';
const openHint = (d.methods && d.methods.length) ? '⌘/⌥-click node or a method to open in editor' : '⌘/⌥-click to open in editor';
tip.innerHTML = `<div class="t-name">${d._head}</div><div class="t-layer">${LAYER[d.g]}</div><div class="t-role">${d.role}</div><div class="t-rt">${d.rt}</div>${act}<div class="t-act">${openHint}</div>`;
tip.style.opacity = 1;
setFocus(d.id); // method-level call edges for this module
}
function onMove(e) { const pad = 16; let x = e.clientX + pad, y = e.clientY + pad; if (x + 312 > innerWidth) x = e.clientX - 312; if (y + tip.offsetHeight > innerHeight) y = e.clientY - tip.offsetHeight - pad; tip.style.left = x + 'px'; tip.style.top = y + 'px'; }
function onLeave() { nodeSel.classed('dim', false).classed('hot', false); linkSel.classed('dim', false).classed('hot', false); elabelSel.classed('show', labelsOn); tip.style.opacity = 0; setFocus(null); }
function onClick(e, d) {
if (movedFlag) return;
if (e.metaKey || e.altKey) { e.stopPropagation(); openNode(d); return; }
if (!isCollapsible(d.id)) return;
e.stopPropagation();
if (expanded.has(d.id)) expanded.delete(d.id); else expanded.add(d.id);
draw(true); onEnter(e, d);
}
function draw(reheat) {
const { vnodes, vlinks } = computeView();
vnodes.forEach(n => { if (n.x == null) { const p = n.parent && byId.get(n.parent); const a = (seedCounter++) * 0.7; n.x = (p ? p.x : W() / 2) + Math.cos(a) * (p ? 44 : 0); n.y = (p ? p.y : H() / 2) + Math.sin(a) * (p ? 44 : 0); } });
vlinks.forEach(l => { l.source = l.s; l.target = l.t; });
linkSel = linkG.selectAll('path').data(vlinks, d => d.s + '>' + d.t + '>' + (d.k || '') + '>' + (d.chg || ''))
.join('path').attr('class', d => 'link ' + (d.k || '') + (d.chg ? ' ' + d.chg : (PR ? ' pr-dim' : '')))
.attr('marker-end', d => d.chg === 'cut' ? null : d.chg === 'add' ? 'url(#arrow-add)'
: (d.k === 'inner' ? null : 'url(#arrow)'));
elabelSel = labelG.selectAll('text.edgelabel').data(vlinks.filter(d => d.l), d => d.s + '>' + d.t)
.join('text').attr('class', 'edgelabel').classed('show', labelsOn)
.classed('pr-dim', d => !!(PR && !d.chg)).attr('text-anchor', 'middle').text(d => d.l);
// a + / ✕ glyph at the midpoint of each added / severed connection
diffmarkSel = labelG.selectAll('text.diffmark').data(vlinks.filter(d => d.chg), d => d.s + '>' + d.t + '>' + d.chg)
.join('text').attr('class', d => 'diffmark ' + d.chg).attr('text-anchor', 'middle')
.text(d => d.chg === 'add' ? '+' : '✕');
nodeSel = nodeG.selectAll('g.node').data(vnodes, d => d.id).join(
enter => {
const g = enter.append('g').attr('class', 'node');
g.append('rect').attr('class', 'box').attr('rx', 8).attr('ry', 8)
.attr('width', d => d.w).attr('height', d => d.h).attr('x', d => -d.w / 2).attr('y', d => -d.h / 2)
.attr('fill', d => FILL[d.g] || '#fff').attr('stroke', d => BORDER[d.g]);
g.each(function (d) {
const gg = d3.select(this), L = d._layout, top = -d.h / 2;
gg.append('text').attr('class', 'nm').attr('text-anchor', 'middle').attr('fill', INK[d.g])
.attr('y', top + L.nameY).text(d._head);
d.briefLines.forEach((line, i) => {
gg.append('text').attr('class', 'bf').attr('text-anchor', 'middle').attr('y', top + L.briefYs[i]).text(line);
});
if (d.methods && d.methods.length) {
gg.append('line').attr('class', 'mdiv').attr('x1', -d.w / 2 + 8).attr('x2', d.w / 2 - 8)
.attr('y1', top + L.dividerY).attr('y2', top + L.dividerY);
d.methods.forEach((mm, i) => {
gg.append('circle').attr('class', 'mdot').attr('cx', -d.w / 2 + 12).attr('cy', top + L.methodYs[i] - 3).attr('r', 3.4);
const mt = gg.append('text').attr('class', 'mth').attr('x', -d.w / 2 + 21).attr('y', top + L.methodYs[i]).text(mm.name);
if (mm.src) mt.classed('clickable', true).on('click', e => { e.stopPropagation(); openInEditor(mm.src); });
});
}
});
const cg = g.filter(d => isCollapsible(d.id));
cg.append('circle').attr('class', 'badge').attr('r', 7).attr('cx', d => d.w / 2).attr('cy', d => -d.h / 2).attr('stroke', d => INK[d.g]);
cg.append('text').attr('class', 'badgetx').attr('x', d => d.w / 2).attr('y', d => -d.h / 2 + 3.5).attr('fill', d => INK[d.g]);
g.call(drag).on('mouseenter', onEnter).on('mousemove', onMove).on('mouseleave', onLeave).on('click', onClick);
return g;
}, update => update, exit => exit.remove()
);
nodeSel.select('text.badgetx').text(d => expanded.has(d.id) ? '−' : '+');
// diff overlay: change rings + change badges, fully data-driven so switching diffs updates live
nodeSel.classed('changed', d => !!(PR && PR.nodes[d.id])).classed('contains', d => !!(PR && !PR.nodes[d.id] && PR.contains.includes(d.id)))
.classed('pr-dim', d => !!(PR && !PR.nodes[d.id] && !PR.contains.includes(d.id)));
nodeSel.each(function (d) {
const g = d3.select(this), c = PR && PR.nodes && PR.nodes[d.id];
g.select('text.chg').remove();
if (c) {
const t = g.append('text').attr('class', 'chg').attr('text-anchor', 'middle').attr('y', d.h / 2 + 10);
t.append('tspan').attr('class', 'add').text(`+${c.add}`);
t.append('tspan').attr('class', 'sep').text(' / ');
t.append('tspan').attr('class', 'del').text(`−${c.del}`);
}
});
buildAdj(vlinks);
if (focusId != null) setFocus(focusId); // re-resolve focused method edges against the new node set
sim.nodes(vnodes);
sim.force('link').links(vlinks);
if (reheat) sim.alpha(0.7).restart();
}
// pan/zoom so a world-space box {x,y,width,height} fills the viewport (capped scale, optional animation)
function fitToBox(box, cap, duration) {
const scale = Math.min(W() / box.width, H() / box.height, cap);
const tx = W() / 2 - scale * (box.x + box.width / 2);
const ty = H() / 2 - scale * (box.y + box.height / 2);
(duration ? svg.transition().duration(duration) : svg).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
function fitView(animate) {
const b = root.node().getBBox();
if (!b.width || !isFinite(b.width)) return;
fitToBox({ x: b.x - 70, y: b.y - 70, width: b.width + 140, height: b.height + 140 }, 1.1, animate ? 500 : 0);
}
// ── controls ──────────────────────────────────────────────────────────────
const bL = document.getElementById('btn-labels');
bL.onclick = () => { labelsOn = !labelsOn; bL.classList.toggle('on', labelsOn); elabelSel.classed('show', labelsOn); };
const bE = document.getElementById('btn-expand');
bE.onclick = () => {
const anyCollapsed = nodes.some(n => isCollapsible(n.id) && !expanded.has(n.id));
nodes.forEach(n => { if (isCollapsible(n.id)) { anyCollapsed ? expanded.add(n.id) : expanded.delete(n.id); } });
bE.textContent = anyCollapsed ? 'Collapse all' : 'Expand all'; bE.classList.toggle('on', anyCollapsed);
draw(true); setTimeout(() => fitView(true), 60);
};
let frozen = false;
const bF = document.getElementById('btn-freeze');
bF.onclick = () => { frozen = !frozen; bF.classList.toggle('on', frozen); if (frozen) sim.stop(); else sim.alpha(0.3).restart(); };
document.getElementById('btn-reset').onclick = () => { nodes.forEach(n => { n.fx = null; n.fy = null; }); sim.alpha(1); for (let i = 0; i < 200; i++) sim.tick(); tick(); fitView(true); sim.alpha(0.25).restart(); };
addEventListener('resize', () => { center(); sim.alpha(0.2).restart(); });
// ── initial render: pre-settle synchronously, then fit ──────────────────────
draw(false);
nodes.forEach((n, i) => { const a = (i / nodes.length) * Math.PI * 2; n.x = W() / 2 + Math.cos(a) * 300; n.y = H() / 2 + Math.sin(a) * 230; });
sim.alpha(1);
for (let i = 0; i < 280; i++) sim.tick();
tick();
fitView(false);
requestAnimationFrame(() => fitView(false));
sim.alphaTarget(0).alpha(0.25).restart();
// ── diff overlays: a menu of available diffs + runtime switching ─────────────
const btnDiffs = document.getElementById('btn-pr'); // the toolbar button toggles the menu
const menu = document.createElement('div'); menu.className = 'panel diffmenu'; document.body.appendChild(menu);
const avail = ((window.DIFFS && window.DIFFS.available) || []).map(x => typeof x === 'string' ? { name: x } : x);
const diffCache = {};
function fitToNodes(ids) {
const pts = nodeSel.data().filter(d => ids.includes(d.id));
if (!pts.length) return;
const xs = pts.map(d => d.x), ys = pts.map(d => d.y);
const minx = Math.min(...xs) - 150, miny = Math.min(...ys) - 110;
fitToBox({ x: minx, y: miny, width: Math.max(...xs) + 150 - minx, height: Math.max(...ys) + 110 - miny }, 1.5, 650);
}
function applyOverlay() {
PR.contains.forEach(id => { if (isCollapsible(id)) expanded.add(id); });
draw(true);
setTimeout(() => fitToNodes(Object.keys(PR.nodes)), 140);
}
function clearOverlay() { draw(true); }
function openMenu() { renderMenu(); menu.classList.add('show'); btnDiffs.classList.add('on'); }
function closeMenu() { menu.classList.remove('show'); btnDiffs.classList.remove('on'); }
function loadDiff(name, cb) {
if (diffCache[name]) return cb(diffCache[name]);
const s = document.createElement('script');
s.src = 'diffs/' + name + '.js';
s.onload = () => { diffCache[name] = window.ARCH.pr; cb(window.ARCH.pr); };
s.onerror = () => { console.warn('[archlet] diff not found:', s.src); cb(null); };
document.head.appendChild(s);
}
function selectDiff(name) { loadDiff(name, ov => { if (!ov) return clearDiff(); PR = ov; activeName = name; applyOverlay(); openMenu(); }); }
function clearDiff() { PR = null; activeName = ''; clearOverlay(); renderMenu(); }
let activeName = '';
function diffItem(d, on) {
const full = diffCache[d.name] || {};
const label = d.label || (d.number ? 'PR #' + d.number : d.name);
const title = d.title || full.title || '';
const url = d.url || full.url || '';
const files = d.files != null ? d.files : full.files;
const add = d.add != null ? d.add : full.add;
const del = d.del != null ? d.del : full.del;
const stat = add != null ? `<span class="add">+${add}</span><span class="sep"> / </span><span class="del">−${del}</span>` : '';
const meta = [];
if (files != null) meta.push(`${files} file${files === 1 ? '' : 's'}`);
if (url) meta.push(`<a class="dm-link" href="${url}" target="_blank" rel="noopener">view ↗</a>`);
return `<button class="dm-item${on ? ' on' : ''}" data-name="${d.name}">`
+ `<span class="dm-top"><span class="dm-label">${label}</span>${stat ? `<span class="dm-stat">${stat}</span>` : ''}</span>`
+ (title ? `<span class="dm-sub">${title}</span>` : '')
+ (meta.length ? `<span class="dm-meta">${meta.join('<span class="sep"> · </span>')}</span>` : '')
+ `</button>`;
}
function renderMenu() {
menu.innerHTML = '<div class="dm-hd">Diff overlays</div>' +
avail.map(d => diffItem(d, d.name === activeName)).join('') +
`<button class="dm-item${!activeName ? ' on' : ''}" data-name=""><span class="dm-top"><span class="dm-label">None</span></span><span class="dm-sub">no overlay</span></button>`;
menu.querySelectorAll('.dm-item').forEach(b => b.onclick = (e) => {
if (e.target.closest('.dm-link')) return; // let the external link click through
e.stopPropagation(); // keep the menu open so it reads as the active-diff panel
const n = b.dataset.name; n ? selectDiff(n) : (clearDiff(), closeMenu());
});
}
btnDiffs.onclick = (e) => { e.stopPropagation(); menu.classList.contains('show') ? closeMenu() : openMenu(); };
document.addEventListener('click', () => { menu.classList.remove('show'); btnDiffs.classList.remove('on'); });
if (!avail.length) btnDiffs.style.display = 'none';
// initial overlay: ?diff=<name> | ?pr=<n> | manifest default
const q = new URLSearchParams(location.search);
const initial = q.get('diff') || (q.get('pr') ? 'pr-' + q.get('pr') : ((window.DIFFS && window.DIFFS.default) || null));
if (initial) setTimeout(() => selectDiff(initial), 650);
} // end start()
})();
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Architecture Map</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header><h1>Architecture Map</h1></header>
<div class="panel controls">
<button id="btn-pr">Diffs</button>
<button id="btn-labels" class="on">Edge Labels</button>
<button id="btn-expand">Expand all</button>
<button id="btn-freeze">Freeze</button>
<button id="btn-reset">Reshuffle</button>
</div>
<!-- legend rows are generated from the theme in graph.js -->
<div class="panel legend" id="legend"></div>
<div id="stage"><svg></svg></div>
<div class="tip" id="tip"></div>
<!-- Load order: d3 (global) → data (window.ARCH) → diff registry → engine. -->
<!-- The engine loads the active diff overlay at runtime (?pr=<n> / ?diff=<name> / manifest default), -->
<!-- so this file never references a specific PR and never needs to change. -->
<!-- Classic scripts so this works by double-clicking the file (no server needed). -->
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="data.js"></script>
<script src="diffs/manifest.js"></script>
<script src="graph.js"></script>
</body>
</html>
:root {
--bg: #f7f5ef;
--ink: #2a2a28;
--ink-soft: #8a8378;
--line: #ddd7ca;
--accent: #5b8bd0;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); }
body { font-family: var(--sans); color: var(--ink); }
#stage { position: fixed; inset: 0; }
svg { display: block; width: 100%; height: 100%; cursor: grab; background: var(--bg); }
svg.grabbing { cursor: grabbing; }
.link { fill: none; stroke: #b3ada1; stroke-opacity: 0.75; stroke-width: 1; }
.link.import { stroke: #c0aecf; stroke-opacity: 0.7; stroke-dasharray: 4 4; }
.link.inner { stroke: #9ec9c6; stroke-opacity: 0.7; }
.link.dim { stroke-opacity: 0.08 !important; }
.link.hot { stroke: #3a3a38 !important; stroke-opacity: 1 !important; stroke-width: 1.6; }
.node rect.box { fill: #fff; stroke-width: 1.4; cursor: pointer; }
.node text { pointer-events: none; font-family: var(--sans); }
.node text.nm { font-size: 11.5px; font-weight: 640; }
.node text.bf { font-size: 8.8px; fill: var(--ink-soft); }
.node circle.badge { fill: #fff; stroke-width: 1.2; cursor: pointer; }
.node text.badgetx { font-size: 10px; font-weight: 700; pointer-events: none; text-anchor: middle; }
.node.dim { opacity: 0.22; }
.node line.mdiv { stroke: #e6e0d3; stroke-width: 1; pointer-events: none; }
.node circle.mdot { fill: #fff; stroke: #b0a695; stroke-width: 1.3; pointer-events: none; }
.node text.mth { font-size: 8.4px; fill: #6b6256; font-family: var(--mono); pointer-events: none; }
.node text.mth.clickable { pointer-events: auto; cursor: pointer; }
.node text.mth.clickable:hover { fill: var(--accent); text-decoration: underline; }
/* focus-mode method -> method call edges (blue, drawn under nodes) */
.medge { fill: none; stroke: var(--accent); stroke-width: 1.3; stroke-opacity: 0.92; pointer-events: none; }
/* PR change overlay — warning orange, signals risk */
.node.contains rect.box { stroke: #e2a877; stroke-width: 2; stroke-dasharray: 5 3; }
.node.changed rect.box { stroke: #d9651f; stroke-width: 2.6; }
.node text.chg { font-size: 8.5px; font-weight: 700; fill: #9a9484; font-family: var(--mono); pointer-events: none; }
/* diff +additions / −deletions: green / red across node labels and the diff menu */
.node text.chg .add, .diffmenu .dm-stat .add { fill: #4f9b76; color: #4f9b76; }
.node text.chg .del, .diffmenu .dm-stat .del { fill: #cf5454; color: #cf5454; }
/* diff mode: de-emphasize everything not touched by the diff so changes stand out */
.node.pr-dim { opacity: 0.2; }
.link.pr-dim { stroke-opacity: 0.12 !important; }
.edgelabel.show.pr-dim { opacity: 0.2; }
/* link changes the diff introduces / removes: green = newly connected, dashed slate = severed */
.link.add { stroke: #4f9b76 !important; stroke-opacity: .95 !important; stroke-width: 1.9; stroke-dasharray: none !important; }
.link.cut { stroke: #98a2bd !important; stroke-opacity: .85 !important; stroke-width: 1.5; stroke-dasharray: 2 5 !important; }
.diffmark { font-family: var(--sans); font-weight: 800; font-size: 12px; pointer-events: none;
paint-order: stroke; stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; }
.diffmark.add { fill: #4f9b76; }
.diffmark.cut { fill: #7c87a8; }
.controls button#btn-pr.on { border-color: #5d6f96; color: #5d6f96; background: #eef1f7; }
/* diff overlays dropdown menu — doubles as the active-diff info panel */
.diffmenu { right: 20px; top: 56px; width: 300px; max-height: 70vh; overflow-y: auto; padding: 6px; display: none; }
.diffmenu.show { display: block; }
.diffmenu .dm-hd { font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: #9a9484; padding: 6px 8px 5px; }
.diffmenu .dm-item { display: block; width: 100%; text-align: left; cursor: pointer; font: inherit;
background: transparent; border: 1px solid transparent; border-radius: 7px; padding: 8px 10px; color: var(--ink); }
.diffmenu .dm-item + .dm-item { margin-top: 2px; }
.diffmenu .dm-item:hover { background: #f3efe7; }
.diffmenu .dm-item.on { border-color: #aab6cf; background: #eef1f7; }
.diffmenu .dm-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.diffmenu .dm-label { font-weight: 700; font-size: 13px; color: #34406b; font-family: var(--mono); white-space: nowrap; }
.diffmenu .dm-stat { font-family: var(--mono); font-size: 11px; font-weight: 700; flex: none; }
.diffmenu .dm-stat .sep { color: #b6b0a2; font-weight: 400; }
.diffmenu .dm-sub { display: block; font-size: 11.5px; color: #4a463d; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.diffmenu .dm-meta { display: block; font-family: var(--mono); font-size: 10px; color: #908874; margin-top: 4px; }
.diffmenu .dm-meta .sep { color: #c4bdac; }
.diffmenu .dm-link { color: #5d6f96; text-decoration: none; font-weight: 600; }
.diffmenu .dm-link:hover { text-decoration: underline; }
.edgelabel { font-size: 8.6px; fill: #97907f; font-family: var(--mono); opacity: 0; pointer-events: none;
paint-order: stroke; stroke: var(--bg); stroke-width: 3.5px; stroke-linejoin: round; }
.edgelabel.show { opacity: 1; }
header { position: fixed; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 12px; padding: 14px 20px; pointer-events: none; }
header h1 { font-size: 14px; font-weight: 650; margin: 0; letter-spacing: -0.01em; color: var(--ink); }
.panel { position: fixed; z-index: 10; background: rgba(255,255,255,.85);
border: 1px solid var(--line); border-radius: 8px; backdrop-filter: blur(6px); }
.legend { left: 20px; bottom: 20px; padding: 12px 14px; }
.legend .row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #6c665a; padding: 2.5px 4px; margin: 0 -4px; border-radius: 5px; cursor: pointer; user-select: none; transition: background .12s, opacity .12s; }
.legend .row:hover { background: #f1ede4; }
.legend .row.off { opacity: 0.4; text-decoration: line-through; }
.legend .row.off .sw { background: #e6e2d8 !important; border-color: #c4bdac !important; }
.legend .sw { width: 14px; height: 10px; border-radius: 3px; border: 1.4px solid; background: #fff; flex: none; }
.controls { right: 20px; top: 16px; display: flex; gap: 6px; padding: 7px; pointer-events: auto; }
.controls button { font: inherit; font-size: 12px; cursor: pointer; color: var(--ink);
background: #fff; border: 1px solid var(--line); border-radius: 5px; padding: 5px 11px; }
.controls button:hover { border-color: var(--accent); background: #eef4fc; }
.controls button.on { border-color: var(--accent); color: var(--accent); background: #eef4fc; }
.tip { position: fixed; z-index: 20; max-width: 300px; padding: 11px 13px; pointer-events: none;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
font-size: 12px; line-height: 1.5; opacity: 0; transition: opacity .1s; }
.tip .t-name { font-weight: 650; font-size: 13px; margin-bottom: 2px; }
.tip .t-layer { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); margin-bottom: 6px; }
.tip .t-role { color: #4a463d; }
.tip .t-rt { color: #908874; font-size: 11px; margin-top: 6px; font-family: var(--mono); }
.tip .t-act { color: var(--accent); font-size: 11px; margin-top: 7px; }