Files
kandev/.agents/skills/pr-walkthrough/references/shell.html
T
2026-08-22 21:46:57 +01:00

1035 lines
55 KiB
HTML

<!doctype html>
<!--
PR WALKTHROUGH SHELL (self-contained, file:// loadable).
This is a fixed design asset. build.py reads it, replaces
the topbar placeholders (PR title, PR files URL, PR number, repo) and the
content marker inside <main>, then writes the page. Placeholder tokens use
double curly braces and appear only once each, in the live markup below.
The CSS and JS below stay identical for every walkthrough.
-->
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{PR_TITLE}} - Walkthrough</title>
<!-- External resources (all pinned, loaded from CDN).
Tailwind loads here as a classic script. The four ESM libraries below
are declared in this import map so every external URL is visible at the
top. The module script near the end imports them by their bare name
(mermaid, marked, dompurify, shiki). To change a version, edit only the
URLs here. -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<script type="importmap">
{
"imports": {
"mermaid": "https://cdn.jsdelivr.net/npm/mermaid@11.17.0/dist/mermaid.esm.min.mjs",
"marked": "https://cdn.jsdelivr.net/npm/marked@12.0.2/+esm",
"dompurify": "https://cdn.jsdelivr.net/npm/dompurify@3.4.14/+esm",
"shiki": "https://cdn.jsdelivr.net/npm/shiki@3.23.0/+esm"
}
}
</script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { maxWidth: { content: '72rem' }, colors: {
brand: { DEFAULT: '#4f46e5', soft: '#818cf8', deep: '#3730a3' },
} } },
};
</script>
<style>
/* Visual language shared with kandev.ai landing: Figtree for UI copy,
Geist Mono for code, indigo actions, near-black product surfaces, and
compact shadcn-style radii. The font URLs are pinned to the landing
repository commit used for this design. */
@font-face {
font-family: 'Figtree'; font-style: normal; font-weight: 300 900; font-display: swap;
src: url('https://cdn.jsdelivr.net/gh/kdlbs/landing@71c3f609df07d6ff67fbe7c9213482d0c0c22c70/public/fonts/figtree/figtree-300-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Geist Mono'; font-style: normal; font-weight: 100 900; font-display: swap;
src: url('https://cdn.jsdelivr.net/gh/kdlbs/landing@71c3f609df07d6ff67fbe7c9213482d0c0c22c70/public/fonts/geist-mono/geist-mono-100-normal.woff2') format('woff2');
}
:root {
color-scheme: light;
--wt-bg: #ffffff;
--wt-fg: #0d0d10;
--wt-muted: #71717a;
--wt-border: rgba(13, 13, 16, .12);
--wt-surface: #f7f7f8;
--wt-primary: #4f46e5;
--wt-primary-soft: #818cf8;
--wt-primary-deep: #3730a3;
--wt-dark-surface: #111114;
--wt-radius: .5rem;
}
html.dark {
color-scheme: dark;
--wt-bg: #0d0d10;
--wt-fg: #fafafa;
--wt-muted: rgba(255, 255, 255, .58);
--wt-border: rgba(255, 255, 255, .10);
--wt-surface: #111114;
}
html { scroll-behavior: smooth; }
body {
margin: 0; overflow-x: hidden; background: var(--wt-bg); color: var(--wt-fg);
font-family: 'Figtree', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
letter-spacing: -.01em;
}
.wt-topbar {
background: rgba(255, 255, 255, .88); border-color: var(--wt-border);
box-shadow: 0 1px 0 rgba(13, 13, 16, .02);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
html.dark .wt-topbar { background: rgba(13, 13, 16, .88); }
.wt-main { line-height: 1.7; }
.wt-main h2, .wt-main h3 { letter-spacing: -.035em; }
.wt-main h2 { font-weight: 650; }
.wt-main section { border-color: var(--wt-border); }
.wt-main .rounded-xl { border-radius: var(--wt-radius); }
.wt-main .rounded-lg { border-radius: .375rem; }
.wt-main .border-slate-200, .wt-main .border-slate-200\/70 { border-color: var(--wt-border) !important; }
.wt-main .bg-slate-50\/60 { background: var(--wt-surface) !important; }
.wt-footer { border-color: var(--wt-border); color: var(--wt-muted); }
.wt-brand { display: inline-flex; align-items: center; gap: .5rem; min-height: 2.5rem; color: var(--wt-fg); text-decoration: none; }
.wt-brand:hover { color: var(--wt-primary); }
.wt-brand-mark {
display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem;
border-radius: .375rem; color: white; background: linear-gradient(135deg, #4f46e5, #818cf8);
box-shadow: 0 0 0 1px rgba(13, 13, 16, .08), 0 8px 18px rgba(79, 70, 229, .22);
font-size: .95rem; font-weight: 800; letter-spacing: -.08em;
}
.wt-brand-name { font-size: .875rem; font-weight: 650; }
.wt-brand-divider { width: 1px; height: 1rem; background: var(--wt-border); }
.wt-brand-pr { font-size: .8125rem; font-weight: 600; white-space: nowrap; }
.wt-mobile-nav { position: relative; }
.wt-mobile-nav summary {
display: inline-flex; align-items: center; min-height: 2.5rem; padding: 0 .625rem; border: 1px solid var(--wt-border);
border-radius: .375rem; color: var(--wt-muted); cursor: pointer; list-style: none; font-size: .75rem; font-weight: 600;
}
.wt-mobile-nav summary::-webkit-details-marker { display: none; }
.wt-mobile-nav summary::after { content: '⌄'; margin-left: .35rem; font-size: .75rem; }
.wt-mobile-nav[open] summary { color: var(--wt-fg); border-color: rgba(79, 70, 229, .5); }
.wt-mobile-menu {
position: absolute; top: calc(100% + .5rem); left: 0; z-index: 50; min-width: 10rem; padding: .3rem;
border: 1px solid var(--wt-border); border-radius: .5rem; background: var(--wt-bg);
box-shadow: 0 12px 28px rgba(13, 13, 16, .14);
}
.wt-mobile-menu a {
display: block; min-height: 2.5rem; padding: .65rem .75rem; border-radius: .375rem; color: var(--wt-muted);
font-size: .8125rem; font-weight: 550; text-decoration: none;
}
.wt-mobile-menu a:hover, .wt-mobile-menu a:focus-visible { background: rgba(79, 70, 229, .09); color: var(--wt-primary); outline: none; }
.wt-theme-toggle {
display: inline-flex; align-items: center; justify-content: center; min-width: 2.5rem; min-height: 2.5rem;
border: 1px solid var(--wt-border); border-radius: .375rem; color: var(--wt-muted); background: transparent; cursor: pointer;
}
.wt-theme-toggle:hover { border-color: rgba(79, 70, 229, .5); color: var(--wt-primary); background: rgba(79, 70, 229, .07); }
/* Shiki dual-theme via defaultColor:false. Light by default, dark on html.dark. */
.shiki, .shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
html.dark .shiki, html.dark .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; }
.shiki { padding: 14px 16px; border-radius: var(--wt-radius); overflow-x: auto; font-family: 'Geist Mono', ui-monospace, monospace; font-size: .82rem; line-height: 1.55; border: 1px solid var(--wt-border); }
/* GitHub-style diff lines. applyLineDiff() tags changed lines with
.diff.add / .diff.remove from the data-added / data-removed indices
build.py emits. Each .line is a full-width block so the tint spans the
whole row, and a +/- glyph sits in the gutter. */
.shiki.has-diff code { display: inline-block; min-width: 100%; }
/* Each row is a full-width block so its tint spans the whole line. Shiki
renders one <span class="line"> per row and separates rows with a literal
newline text node; stripNewlines() removes those in JS so no untinted
blank box appears between rows. An empty line still needs to occupy one
row: an inserted zero-width space (via ::after on .diff-blank) gives it
height without adding a visible glyph, so blank added lines tint solid. */
.shiki .line { display: block; width: 100%; padding: 0 16px 0 1.4em; margin: 0 -16px; position: relative; }
.shiki .line.diff-blank::after { content: "\200b"; }
.shiki .line.diff::before { position: absolute; left: 4px; top: 0; opacity: .75; }
.shiki .line.diff.add { background: #e6ffec; }
/* A blank added/removed line keeps its tint but shows no +/- glyph: an empty
row does not need a marker, and drawing one reintroduces the overflow. */
.shiki .line.diff.add:not(.diff-blank)::before { content: "+"; color: #1a7f37; }
.shiki .line.diff.remove { background: #ffebe9; }
.shiki .line.diff.remove:not(.diff-blank)::before { content: "-"; color: #cf222e; }
html.dark .shiki .line.diff.add { background: rgba(46,160,67,.15) !important; }
html.dark .shiki .line.diff.add::before { color: #3fb950; }
html.dark .shiki .line.diff.remove { background: rgba(248,81,73,.15) !important; }
html.dark .shiki .line.diff.remove::before { color: #f85149; }
/* Token spans carry their own editor background from Shiki. On a diff line
that background paints over the line tint, so make the spans transparent
and let the .line tint show through. */
.shiki .line.diff, .shiki .line.diff span,
html.dark .shiki .line.diff, html.dark .shiki .line.diff span { background: transparent !important; }
/* Risk heatmap: the JS tags flagged lines with .risk-medium / .risk-high
after Shiki renders. The signal is a colored left bar (inset box-shadow),
so it coexists with the diff add/remove background on the same line. A
faint tint is added only on non-diff lines, where there is no diff color
to preserve. A right-edge dot marks the line as hoverable for its note. */
.shiki .line.risk-medium { box-shadow: inset 3px 0 0 0 #eab308; }
.shiki .line.risk-high { box-shadow: inset 3px 0 0 0 #ef4444; }
.shiki .line.risk-medium:not(.diff) { background: rgba(234,179,8,.10); }
.shiki .line.risk-high:not(.diff) { background: rgba(239,68,68,.10); }
html.dark .shiki .line.risk-medium:not(.diff) { background: rgba(234,179,8,.14) !important; }
html.dark .shiki .line.risk-high:not(.diff) { background: rgba(239,68,68,.14) !important; }
.shiki .line.risk-line { cursor: help; }
.shiki .line.risk-line::after {
content: ""; position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 7px; height: 7px; border-radius: 999px; opacity: .9;
}
.shiki .line.risk-medium.risk-line::after { background: #eab308; }
.shiki .line.risk-high.risk-line::after { background: #ef4444; }
/* Block risk chip in the code label row. Colored by initRisk (riskStyle),
same thresholds as the risk section and topbar badge. */
.block-risk-chip {
display: inline-flex; align-items: center; gap: .25rem; margin-left: .5rem;
padding: .05rem .45rem; border-radius: 999px; font-size: .66rem;
font-weight: 700; letter-spacing: .01em; vertical-align: middle; cursor: help;
}
.block-risk-chip .block-risk-word { font-weight: 600; opacity: .75; }
.block-risk-chip .block-risk-level { font-weight: 700; }
.block-risk-only { font-weight: 700; }
/* Hover tooltip for a flagged line's note, positioned by the JS. */
#risk-tip {
position: fixed; z-index: 60; max-width: 320px; pointer-events: none;
background: #0f172a; color: #f8fafc; font-size: .72rem; line-height: 1.4;
padding: .4rem .55rem; border-radius: .4rem; box-shadow: 0 6px 20px rgba(0,0,0,.25);
opacity: 0; transition: opacity .1s ease;
}
html.dark #risk-tip { background: #1e293b; border: 1px solid rgba(255,255,255,.1); }
#risk-tip.show { opacity: 1; }
#risk-tip .rt-sev { font-weight: 700; text-transform: uppercase; font-size: .62rem; letter-spacing: .04em; }
#risk-tip .rt-sev.medium { color: #fbbf24; }
#risk-tip .rt-sev.high { color: #f87171; }
/* Review split button: a primary link plus a dropdown toggle sharing one
rounded shell. The menu is absolutely positioned under the shell. */
.review-split { position: relative; display: inline-flex; align-items: stretch; height: 2.125rem; }
.review-split-main { display: inline-flex; align-items: center; height: 100%; padding: 0 .625rem; font-size: .8125rem;
font-weight: 600; line-height: 1; white-space: nowrap; color: #fff; background: var(--wt-primary); border-radius: .375rem 0 0 .375rem; }
.review-split-main:hover { filter: brightness(1.08); }
.review-split-toggle { display: inline-flex; align-items: center; justify-content: center; height: 100%; width: 1.5rem;
font-size: .625rem; line-height: 1; color: #fff; background: var(--wt-primary);
border-radius: 0 .375rem .375rem 0; border-left: 1px solid rgba(255,255,255,.25); cursor: pointer; }
.review-split-toggle:hover { filter: brightness(1.08); }
.review-split-menu { position: absolute; right: 0; top: calc(100% + .375rem); min-width: 220px; z-index: 50;
background: var(--wt-bg); border: 1px solid var(--wt-border); border-radius: .375rem; padding: .25rem;
box-shadow: 0 10px 30px rgba(2,6,23,.18); }
html.dark .review-split-menu { background: var(--wt-dark-surface); border-color: var(--wt-border); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
.review-split-item { display: block; width: 100%; text-align: left; padding: .5rem .625rem; font-size: .8125rem;
white-space: nowrap; border-radius: .375rem; color: inherit; cursor: pointer; background: transparent; }
.review-split-item:hover { background: rgba(148,163,184,.15); }
.prose-body { line-height: 1.7; }
/* Mermaid sizes the SVG to its intrinsic width, which is often far
narrower than the container, so the diagram reads tiny. The render JS
reads the viewBox aspect and sets the width/height inline: a normal
diagram fills the width; a very wide "LR" chain gets a fixed height and
scrolls sideways so nodes stay legible. These are the fallbacks. */
.mermaid-host { width: 100%; }
.mermaid-host svg { width: 100%; max-width: 100%; height: auto; }
.anchor-target { scroll-margin-top: 5rem; }
/* ---- Interactive code canvas ---- */
/* Full-bleed: the canvas breaks out of the centered content column to span
the whole viewport width, so the graph gets horizontal room without the
user maximizing. The prose around it (h2, intro, and the list details)
stays inside max-w-content. The margin/transform pair recentres a
100vw-wide box against the centered column it lives in. */
#canvas-wrap.canvas-full-bleed { width: 100vw; margin-left: 50%; transform: translateX(-50vw); }
/* 100vw counts the scrollbar, so clip the tiny horizontal overflow it adds. */
body { overflow-x: hidden; }
/* Suppress text selection so panning does not highlight node text. */
#canvas-wrap { cursor: grab; user-select: none; -webkit-user-select: none; }
#canvas-wrap.panning { cursor: grabbing; }
/* The panel is for reading, so allow selecting its text. */
#canvas-wrap .cpanel { user-select: text; -webkit-user-select: text; }
/* Maximize: the canvas fills the viewport for closer exploration.
An opaque background hides the page content behind the fixed layer. */
#canvas-wrap.maximized {
position: fixed; inset: 0; z-index: 60; height: auto !important;
width: auto; border-radius: 0; margin: 0;
/* Override the full-bleed recentring so the fixed layer fills the viewport. */
transform: none;
background: var(--wt-surface);
}
html.dark #canvas-wrap.maximized { background: var(--wt-dark-surface); }
html.canvas-max, body.canvas-max { overflow: hidden; }
#canvas-stage { will-change: transform; }
/* Boundary boxes. Each box groups the nodes that live in one service,
job, or runtime. Boxes sit behind the nodes and pass clicks through. */
.cgroup {
position: absolute; box-sizing: border-box; z-index: 1; pointer-events: none;
border-radius: .625rem; border: 1.5px dashed rgba(79,70,229,.34);
background: rgba(79,70,229,.035);
}
html.dark .cgroup { border-color: rgba(129,140,248,.35); background: rgba(129,140,248,.045); }
.cgroup-label {
position: absolute; top: -12px; left: 16px;
display: inline-flex; align-items: center; gap: 6px;
font-size: .72rem; font-weight: 700; letter-spacing: .02em;
padding: 3px 10px; border-radius: 999px;
background: var(--wt-bg); color: var(--wt-primary-deep); border: 1px solid rgba(79,70,229,.3);
}
html.dark .cgroup-label { background: var(--wt-dark-surface); color: #c7d2fe; border-color: rgba(129,140,248,.35); }
.cgroup-label .dot { width: 8px; height: 8px; border-radius: 999px; background: currentColor; opacity: .7; }
.cnode {
position: absolute; width: 340px; box-sizing: border-box; z-index: 2;
border-radius: .5rem; border: 1px solid var(--wt-border);
background: var(--wt-bg); box-shadow: 0 1px 2px rgba(13,13,16,.06);
overflow: hidden;
}
html.dark .cnode { background: var(--wt-dark-surface); border-color: var(--wt-border); box-shadow: 0 1px 2px rgba(0,0,0,.4); }
/* The selected node is highlighted while its detail panel is open. */
.cnode.selected { z-index: 10; border-color: var(--wt-primary); box-shadow: 0 0 0 2px rgba(79,70,229,.32), 0 12px 30px rgba(13,13,16,.16); }
html.dark .cnode.selected { border-color: var(--wt-primary-soft); box-shadow: 0 0 0 2px rgba(129,140,248,.45), 0 12px 30px rgba(0,0,0,.55); }
.cnode-head { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px 8px; border-bottom: 1px solid rgba(148,163,184,.18); }
html.dark .cnode-head { border-color: rgba(255,255,255,.08); }
.cnode-title { font-weight: 600; font-size: .9rem; }
.cnode-file { font-family: 'Geist Mono', ui-monospace, monospace; font-size: .72rem; color: var(--wt-muted); text-decoration: none; }
.cnode-file:hover { color: var(--wt-primary); }
html.dark .cnode-file { color: #94a3b8; }
.cnode-sig { font-family: 'Geist Mono', ui-monospace, monospace; font-size: .74rem; padding: 8px 12px; color: #52525b; white-space: pre-wrap; word-break: break-word; }
html.dark .cnode-sig { color: #cbd5e1; }
/* A small hint that the node opens a detail panel on click. */
.cnode-more { font-size: .68rem; font-weight: 600; color: var(--wt-primary-deep); padding: 0 12px 9px; }
html.dark .cnode-more { color: #a5b4fc; }
/* Per-node detail source. Hidden in the flow; copied into the panel on click. */
.cnode-detail { display: none; }
.cedge-src { display: none; }
/* ---- Right-side detail panel ---- */
.cpanel {
position: absolute; top: 0; right: 0; bottom: 0; width: 620px; max-width: 95%;
z-index: 40; transform: translateX(100%); transition: transform .18s ease;
background: var(--wt-bg); border-left: 1px solid var(--wt-border);
box-shadow: -12px 0 30px rgba(13,13,16,.12);
display: flex; flex-direction: column; overflow: hidden;
}
html.dark .cpanel { background: var(--wt-dark-surface); border-color: var(--wt-border); box-shadow: -12px 0 30px rgba(0,0,0,.5); }
.cpanel.open { transform: translateX(0); }
/* Drag the left edge to resize the panel. */
.cpanel-resizer {
position: absolute; top: 0; left: -3px; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 41; touch-action: none;
}
.cpanel-resizer::before {
content: ''; position: absolute; top: 0; bottom: 0; left: 3px; width: 2px;
background: transparent; transition: background .15s ease;
}
.cpanel-resizer:hover::before, .cpanel.resizing .cpanel-resizer::before { background: var(--wt-primary); }
.cpanel.resizing { transition: none; }
.cpanel-head {
display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px;
border-bottom: 1px solid rgba(148,163,184,.2);
}
html.dark .cpanel-head { border-color: rgba(255,255,255,.08); }
.cpanel-title { font-weight: 700; font-size: 1rem; line-height: 1.3; }
.cpanel-file { display: inline-block; margin-top: 4px; font-family: 'Geist Mono', ui-monospace, monospace; font-size: .72rem; color: var(--wt-muted); text-decoration: none; }
.cpanel-file:hover { color: var(--wt-primary); }
html.dark .cpanel-file { color: #94a3b8; }
.cpanel-close {
margin-left: auto; flex: none; width: 30px; height: 30px; border-radius: 8px;
border: 1px solid rgba(148,163,184,.35); background: transparent; cursor: pointer;
font-size: 1.1rem; line-height: 1; color: inherit;
}
.cpanel-close:hover { background: rgba(148,163,184,.15); }
.cpanel-body { padding: 14px 16px 20px; overflow: auto; }
.cpanel-desc { font-size: .86rem; line-height: 1.6; color: #475569; margin-bottom: 12px; }
html.dark .cpanel-desc { color: #cbd5e1; }
.cpanel-block-label { font-size: .72rem; font-weight: 700; color: #64748b; margin: 14px 0 6px; }
html.dark .cpanel-block-label { color: #94a3b8; }
.cpanel-block-label:first-child { margin-top: 0; }
/* Rendered Markdown inside the panel (tables, lists, links). */
.md-rendered { font-size: .84rem; line-height: 1.6; color: #334155; }
html.dark .md-rendered { color: #cbd5e1; }
.md-rendered table { border-collapse: collapse; width: 100%; margin: 6px 0; font-size: .8rem; }
.md-rendered th, .md-rendered td { border: 1px solid rgba(148,163,184,.3); padding: 5px 9px; text-align: left; }
.md-rendered th { background: rgba(148,163,184,.12); font-weight: 700; }
html.dark .md-rendered th, html.dark .md-rendered td { border-color: rgba(255,255,255,.12); }
html.dark .md-rendered th { background: rgba(255,255,255,.06); }
.md-rendered a { color: #4f46e5; text-decoration: none; }
.md-rendered a:hover { text-decoration: underline; }
html.dark .md-rendered a { color: #818cf8; }
.md-rendered code { font-family: 'Geist Mono', ui-monospace, monospace; font-size: .92em; background: rgba(148,163,184,.15); padding: 1px 5px; border-radius: 4px; }
.md-rendered ul, .md-rendered ol { padding-left: 1.25rem; margin: 6px 0; }
.md-rendered p { margin: 6px 0; }
.cedge-path { stroke: var(--wt-primary); stroke-width: 2; fill: none; }
html.dark .cedge-path { stroke: var(--wt-primary-soft); }
.cedge-arrow { fill: var(--wt-primary); stroke: none; }
html.dark .cedge-arrow { fill: var(--wt-primary-soft); }
.cedge-label {
position: absolute; transform: translate(-50%, -50%);
font-size: .7rem; padding: 2px 7px; border-radius: 999px;
background: #eef2ff; color: var(--wt-primary-deep); border: 1px solid #c7d2fe; white-space: nowrap;
}
html.dark .cedge-label { background: #1e1b4b; color: #c7d2fe; border-color: #3730a3; }
/* ---- Risk meter ---- */
.risk-track { height: 10px; border-radius: 999px; background: linear-gradient(90deg,#22c55e,#eab308,#ef4444); }
.risk-knob { width: 3px; height: 18px; border-radius: 2px; background: #0f172a; box-shadow: 0 0 0 2px #fff; }
html.dark .risk-knob { background: #f8fafc; box-shadow: 0 0 0 2px var(--wt-bg); }
@media (max-width: 640px) {
.wt-main h2 { font-size: 1.5rem; line-height: 1.2; }
.wt-main h3 { font-size: 1rem; }
.review-split-main { padding: 0 .5rem; font-size: .75rem; }
.review-split-toggle { width: 1.35rem; }
.cnode { width: 320px; }
.cpanel { width: 100%; max-width: 100%; border-left: 0; }
.cpanel-head { padding: 12px 14px; }
.cpanel-body { padding: 12px 14px 18px; }
.mermaid-host { overflow-x: auto; }
}
</style>
</head>
<body class="antialiased">
<!-- Floating tooltip for a risky code line's note (positioned by JS). -->
<div id="risk-tip" role="tooltip" hidden></div>
<!-- ============================ TOPBAR ============================ -->
<header class="wt-topbar sticky top-0 z-40 border-b">
<div class="mx-auto max-w-content px-4 sm:px-6 h-14 flex items-center justify-between gap-3">
<a href="{{PR_URL}}" target="_blank" rel="noopener"
class="wt-brand shrink-0" title="Open the pull request on GitHub">
<span class="wt-brand-mark" aria-hidden="true">k</span>
<span class="wt-brand-name hidden sm:inline">Kandev</span>
<span class="wt-brand-divider hidden sm:inline" aria-hidden="true"></span>
<span class="wt-brand-pr">PR #{{PR_NUMBER}}</span>
</a>
<nav class="hidden md:flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
<a href="#why" class="hover:text-brand">Why</a>
<a id="nav-architecture" href="#architecture" class="hover:text-brand">Architecture</a>
<a href="#changes" class="hover:text-brand">Changes</a>
<a id="nav-data" href="#data" class="hover:text-brand">Data</a>
<a href="#risk" class="hover:text-brand">Risk</a>
<a href="#review" class="hover:text-brand">Review</a>
</nav>
<details id="mobile-nav" class="wt-mobile-nav md:hidden">
<summary aria-label="Jump to walkthrough section">Sections</summary>
<nav class="wt-mobile-menu" aria-label="Walkthrough sections">
<a href="#why">Why</a>
<a id="mobile-nav-architecture" href="#architecture">Architecture</a>
<a href="#changes">Changes</a>
<a id="mobile-nav-data" href="#data">Data</a>
<a href="#risk">Risk</a>
<a href="#review">Review</a>
</nav>
</details>
<div class="flex items-center gap-2">
<!-- Risk badge: score and level, colored by initRisk from data-score
with the same thresholds as the risk section. Links to #risk. -->
<a id="topbar-risk" href="#risk" data-score="{{RISK_SCORE}}"
class="hidden sm:inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold"
title="Jump to the risk section">
<span class="opacity-70 font-normal">Risk</span>
<span id="topbar-risk-score">{{RISK_SCORE}}</span>
<span id="topbar-risk-level"></span>
</a>
<!-- Review split button: primary opens the GitHub review pane; the
dropdown copies a gh CLI approve command. No credentials live in
this page, so it never approves directly. build.py fills the PR
files URL (the PR URL plus /files) and the repo (owner/repo). -->
<div id="review-split" class="review-split">
<a class="review-split-main" href="{{PR_FILES_URL}}#submit-review"
target="_blank" rel="noopener" title="Review on GitHub">Review</a>
<button id="review-split-toggle" class="review-split-toggle" aria-haspopup="true"
aria-expanded="false" aria-label="More review actions">▾</button>
<div id="review-split-menu" class="review-split-menu" role="menu" hidden>
<button id="copy-approve-cmd" class="review-split-item" role="menuitem"
data-cmd="gh pr review {{PR_NUMBER}} --repo {{REPO}} --approve">
Copy approve command
</button>
</div>
</div>
<button id="theme-toggle" class="wt-theme-toggle" aria-label="Toggle theme">
<span class="hidden dark:inline">☀︎</span><span class="inline dark:hidden">☾</span>
</button>
</div>
</div>
</header>
<main class="wt-main mx-auto max-w-content px-4 py-9 sm:px-6 sm:py-12 prose-body">
{{CONTENT}}
</main>
<footer class="wt-footer mx-auto max-w-content px-4 py-8 text-xs border-t sm:px-6">
Generated walkthrough. Prose uses Simplified Technical English.
</footer>
<script type="module">
// ---- Theme toggle (persisted) ----
const root = document.documentElement;
const saved = localStorage.getItem('wt-theme');
if (saved === 'light') root.classList.remove('dark');
else if (saved === 'dark') root.classList.add('dark');
document.getElementById('theme-toggle').addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('wt-theme', root.classList.contains('dark') ? 'dark' : 'light');
});
// ---- Hide nav links whose section the renderer omitted ----
// build.py drops the Architecture and Data sections on a PR that has no
// such data, so their topbar anchors would scroll nowhere. Hide each link
// unless its section exists.
for (const id of ['architecture', 'data']) {
if (!document.getElementById(id)) {
const link = document.getElementById('nav-' + id);
if (link) link.hidden = true;
const mobileLink = document.getElementById('mobile-nav-' + id);
if (mobileLink) mobileLink.hidden = true;
}
}
const mobileNav = document.getElementById('mobile-nav');
if (mobileNav) {
mobileNav.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => mobileNav.removeAttribute('open'));
});
}
// ---- Review split button: dropdown + copy approve command ----
(function initReviewSplit() {
const split = document.getElementById('review-split');
const toggle = document.getElementById('review-split-toggle');
const menu = document.getElementById('review-split-menu');
const copyBtn = document.getElementById('copy-approve-cmd');
if (!split || !toggle || !menu || !copyBtn) return;
const setOpen = (open) => {
menu.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
};
toggle.addEventListener('click', (e) => {
e.stopPropagation();
setOpen(menu.hidden);
});
// clipboard API is blocked on many file:// origins, so fall back to a
// hidden textarea + execCommand.
const copyText = async (text) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) { /* fall through */ }
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (_) { return false; }
};
copyBtn.addEventListener('click', async () => {
const ok = await copyText(copyBtn.dataset.cmd || '');
const original = copyBtn.textContent;
copyBtn.textContent = ok ? 'Copied to clipboard' : 'Copy failed, select manually';
setTimeout(() => { copyBtn.textContent = original; setOpen(false); }, 1200);
});
document.addEventListener('click', (e) => { if (!split.contains(e.target)) setOpen(false); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') setOpen(false); });
})();
// ---- Risk meter: place the knob and set the level from data-score ----
(function initRisk() {
// One source of truth for the level text and color, shared by the risk
// section pill and the topbar badge so they never diverge.
const clampScore = (v) => Math.min(10, Math.max(1, Number(v) || 1));
const riskStyle = (score) => {
if (score <= 3) return { text: 'Low', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300' };
if (score <= 6) return { text: 'Medium', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300' };
return { text: 'High', cls: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300' };
};
// Risk section: place the knob and color the level pill.
const meter = document.getElementById('risk-meter');
if (meter) {
const score = clampScore(meter.dataset.score);
const { text, cls } = riskStyle(score);
const knob = document.getElementById('risk-knob-wrap');
if (knob) knob.style.left = ((score - 1) / 9 * 100) + '%';
const level = document.getElementById('risk-level');
if (level) {
// Keep any author-set label text; only add the color classes.
if (!level.textContent.trim() || level.textContent.includes('{{')) level.textContent = text;
level.className = 'ml-auto rounded-full px-3 py-1 text-sm font-semibold ' + cls;
}
}
// Topbar badge: same score, same color, plus the level word.
const badge = document.getElementById('topbar-risk');
if (badge && !badge.dataset.score.includes('{{')) {
const score = clampScore(badge.dataset.score);
const { text, cls } = riskStyle(score);
const badgeLevel = document.getElementById('topbar-risk-level');
if (badgeLevel) badgeLevel.textContent = text;
badge.className = 'hidden sm:inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold hover:opacity-90 transition-opacity ' + cls;
} else if (badge) {
// No score supplied (badge placeholder unreplaced): hide it.
badge.style.display = 'none';
}
// Per-block risk chips: same score, same color thresholds, plus the word.
document.querySelectorAll('.block-risk-chip').forEach((chip) => {
const score = clampScore(chip.dataset.score);
const { text, cls } = riskStyle(score);
const lvl = chip.querySelector('.block-risk-level');
if (lvl) lvl.textContent = text;
chip.className = 'block-risk-chip ' + cls;
});
})();
// ---- Mermaid (ESM, pinned via the head import map) ----
// Diagrams render once with the theme active at load. Both themes stay readable
// in light and dark, so a later toggle does not re-render them.
// The diagram source is agent-generated from PR content and this page is
// published, so the rendered SVG goes through DOMPurify before innerHTML.
// In securityLevel 'loose' Mermaid does not sanitize HTML in node labels, so
// an unsanitized label could carry an event handler that runs on assignment.
// Mermaid v11 renders node labels as HTML (div/span) inside <foreignObject>.
// The plain svg profile treats those children as SVG and strips them, so the
// labels vanish and shapes render empty. ADD_TAGS + HTML_INTEGRATION_POINTS
// keep the label markup while script/on* handlers are still removed.
Promise.all([import('mermaid'), import('dompurify')])
.then(([{ default: mermaid }, { default: DOMPurify }]) => {
const dark = root.classList.contains('dark');
mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'loose' });
const sanitizeSvg = (svg) => DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['dominant-baseline', 'xmlns', 'requiredExtensions'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
});
document.querySelectorAll('pre.mermaid-src').forEach((pre, i) => {
const host = document.createElement('div');
host.className = 'mermaid-host';
pre.replaceWith(host);
mermaid.render('m' + i, pre.textContent).then(({ svg }) => {
host.innerHTML = sanitizeSvg(svg);
const el = host.querySelector('svg');
if (el) {
// Render every diagram at its intrinsic size and never scale it up.
// Mermaid designs each node at a comfortable size; the earlier code
// forced a fixed height on wide "LR" chains and full width on tall
// "TD" charts, and both of those upscaled the nodes (a 94px-tall LR
// strip stretched to 190px doubled every node). Instead, read the
// viewBox and pin the SVG to its natural width and height. max-width
// 100% lets a diagram wider than the column shrink to fit while
// keeping its aspect ratio, and the host scrolls sideways only when
// the natural width genuinely exceeds the available space. Nothing
// is ever magnified, so nodes stay legible at their designed size.
const vb = (el.getAttribute('viewBox') || '').split(/\s+/).map(Number);
const w = Math.round(vb[2] || el.clientWidth || 1);
el.style.width = w + 'px';
el.style.height = 'auto';
el.style.maxWidth = '100%';
el.style.display = 'block';
el.style.margin = '0 auto';
host.style.overflowX = 'auto';
}
});
});
}).catch((e) => console.warn('Mermaid failed:', e));
// ---- Markdown render (ESM, pinned via the head import map) ----
// A code block marked data-render="markdown" renders as HTML, not as a code
// excerpt. Use it for content the reader wants rendered, such as the PR
// comment the change produces.
// marked does not sanitize raw HTML, and markdown blocks can carry
// PR-controlled text on a published page, so DOMPurify sanitizes the
// rendered HTML before it goes into innerHTML to block script injection.
const mdReady = Promise.all([import('marked'), import('dompurify')])
.then(([{ marked }, { default: DOMPurify }]) => {
document.querySelectorAll('pre.code-block[data-render="markdown"]').forEach((b) => {
const src = b.querySelector('code').textContent;
const div = document.createElement('div');
div.className = 'md-rendered';
div.innerHTML = DOMPurify.sanitize(marked.parse(src, { breaks: false }));
b.replaceWith(div);
});
}).catch((e) => console.warn('Markdown failed:', e));
// ---- Shiki dual-theme highlight (ESM, pinned via the head import map) ----
// Returns a promise so the canvas can lay out after node heights settle.
const shikiReady = Promise.all([
mdReady,
import('shiki'),
]).then(async ([, { createHighlighter, bundledLanguages }]) => {
// Markdown blocks were replaced above, so only code excerpts remain.
const blocks = [...document.querySelectorAll('pre.code-block')];
// lang values come from agent JSON, so an unknown id is a realistic
// input. createHighlighter rejects the whole batch if any single lang is
// not Shiki-bundled, which would blank every block on the page. Resolve
// each lang against the bundled set and fall back to 'text', so one bad
// id degrades that block alone instead of the whole page.
const langOf = (b) =>
(b.dataset.lang && b.dataset.lang in bundledLanguages) ? b.dataset.lang : 'text';
const langs = [...new Set(blocks.map(langOf))];
const hl = await createHighlighter({ themes: ['github-light', 'github-dark'], langs });
for (const b of blocks) {
const isDiff = b.dataset.diff === 'true';
const riskRaw = b.dataset.risk || '';
const code = b.querySelector('code').textContent;
const html = hl.codeToHtml(code, {
lang: langOf(b),
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false,
});
const tpl = document.createElement('template');
tpl.innerHTML = html.trim();
const out = tpl.content.firstElementChild; // the .shiki <pre>
stripLineNewlines(out);
if (isDiff) { out.classList.add('has-diff'); applyLineDiff(out, b.dataset); }
b.replaceWith(out);
if (riskRaw) applyLineRisk(out, riskRaw);
}
}).catch((e) => { console.warn('Shiki failed:', e); });
// ---- Strip inter-line newlines ----
// Shiki emits one <span class="line"> per row separated by a literal "\n"
// text node. With .line as a block, each of those newlines renders as an
// extra untinted blank row, showing a dark gap under every line. Remove the
// text nodes that sit directly between the line spans; the block display
// already puts each row on its own line.
function stripLineNewlines(pre) {
const code = pre.querySelector('code');
if (!code) return;
for (const node of [...code.childNodes]) {
if (node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) {
node.remove();
}
}
}
// ---- Per-line diff tint ----
// build.py records the added/removed line numbers in data-added / data-removed
// (1-based, comma-separated) and emits clean code with no "[!code]" marker.
// Tinting by index is robust: a comment token inside a string (for example a
// Rust r#"...//..."# literal) can never be mistaken for a diff marker.
function applyLineDiff(pre, ds) {
const rows = [...pre.querySelectorAll('.line')];
const tag = (raw, cls) => {
for (const n of (raw || '').split(',')) {
const i = parseInt(n, 10);
if (i >= 1 && i <= rows.length) {
const row = rows[i - 1];
row.classList.add('diff', cls);
// An empty added/removed line keeps its tint but drops the +/-
// glyph, so it cannot overflow onto the next row.
if (!row.textContent.trim()) row.classList.add('diff-blank');
}
}
};
tag(ds.added, 'add');
tag(ds.removed, 'remove');
}
// ---- Per-line risk heatmap ----
// After Shiki renders a block, tag the lines whose text contains a flagged
// "match" substring. The tint bar is CSS (.risk-medium / .risk-high); the
// note shows in a shared floating tooltip on hover. Matching by code text
// (not line index) keeps flags stable when the excerpt is edited.
function applyLineRisk(pre, riskRaw) {
let lines;
try { lines = JSON.parse(riskRaw); } catch (e) { return; }
if (!Array.isArray(lines) || !lines.length) return;
const rows = [...pre.querySelectorAll('.line')];
for (const flag of lines) {
const sev = (flag.severity || '').toLowerCase();
if (sev !== 'medium' && sev !== 'high') continue; // "low" draws nothing
const needle = flag.match || '';
if (!needle) continue;
for (const row of rows) {
if (!row.textContent.includes(needle)) continue;
row.classList.add('risk-' + sev, 'risk-line');
row.dataset.riskSev = sev;
row.dataset.riskNote = flag.note || '';
break; // tag the first matching line only, so N flags -> N lines
}
}
}
// Shared floating tooltip for any .risk-line, wired once via delegation.
(function initRiskTip() {
const tip = document.getElementById('risk-tip');
if (!tip) return;
const show = (row) => {
const sev = row.dataset.riskSev || '';
const note = row.dataset.riskNote || '';
tip.innerHTML = '<span class="rt-sev ' + sev + '">' + sev + ' risk</span>'
+ (note ? '<div>' + note.replace(/[&<>]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])) + '</div>' : '');
tip.hidden = false;
const r = row.getBoundingClientRect();
tip.style.left = Math.min(r.left, window.innerWidth - 340) + 'px';
tip.style.top = (r.bottom + 6) + 'px';
requestAnimationFrame(() => tip.classList.add('show'));
};
const hide = () => { tip.classList.remove('show'); tip.hidden = true; };
document.addEventListener('mouseover', (e) => {
const row = e.target.closest && e.target.closest('.risk-line');
if (row) show(row); else if (!e.target.closest('#risk-tip')) hide();
});
document.addEventListener('mouseout', (e) => {
if (e.target.closest && e.target.closest('.risk-line')) hide();
});
window.addEventListener('scroll', hide, true);
})();
// ---- Interactive code canvas: pan, zoom, expand, arrows ----
(function initCanvas() {
const wrap = document.getElementById('canvas-wrap');
const stage = document.getElementById('canvas-stage');
const svg = document.getElementById('canvas-edges');
if (!wrap || !stage || !svg) return;
const SVGNS = 'http://www.w3.org/2000/svg';
let tx = 0, ty = 0, scale = 1;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const apply = () => { stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; };
// Draw one arrow per .cedge, from the FROM node edge to the TO node edge.
function drawEdges() {
svg.innerHTML = '';
document.querySelectorAll('.cedge-label').forEach((n) => n.remove());
const edges = [...stage.querySelectorAll('.cedge')];
for (const e of edges) {
const a = stage.querySelector(`.cnode[data-id="${e.dataset.from}"]`);
const b = stage.querySelector(`.cnode[data-id="${e.dataset.to}"]`);
if (!a || !b) continue;
const ax = a.offsetLeft, ay = a.offsetTop, aw = a.offsetWidth, ah = a.offsetHeight;
const bx = b.offsetLeft, by = b.offsetTop, bw = b.offsetWidth, bh = b.offsetHeight;
const acx = ax + aw / 2, acy = ay + ah / 2;
const bcx = bx + bw / 2, bcy = by + bh / 2;
// Anchor on the near vertical or horizontal edge of each node.
const dx = bcx - acx, dy = bcy - acy;
let x1, y1, x2, y2;
if (Math.abs(dx) >= Math.abs(dy)) {
x1 = dx >= 0 ? ax + aw : ax; y1 = acy;
x2 = dx >= 0 ? bx : bx + bw; y2 = bcy;
} else {
x1 = acx; y1 = dy >= 0 ? ay + ah : ay;
x2 = bcx; y2 = dy >= 0 ? by : by + bh;
}
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
const c1x = Math.abs(dx) >= Math.abs(dy) ? mx : x1;
const c1y = Math.abs(dx) >= Math.abs(dy) ? y1 : my;
const c2x = Math.abs(dx) >= Math.abs(dy) ? mx : x2;
const c2y = Math.abs(dx) >= Math.abs(dy) ? y2 : my;
const path = document.createElementNS(SVGNS, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`);
path.setAttribute('class', 'cedge-path');
path.setAttribute('marker-end', 'url(#arrow)');
svg.appendChild(path);
if (e.dataset.label) {
const lab = document.createElement('div');
lab.className = 'cedge-label';
lab.textContent = e.dataset.label;
lab.style.left = mx + 'px';
lab.style.top = my + 'px';
stage.appendChild(lab);
}
}
}
// Arrowhead marker.
const defs = document.createElementNS(SVGNS, 'defs');
defs.innerHTML = '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" class="cedge-arrow"/></marker>';
svg.appendChild(defs);
// Wrap each boundary box around its member nodes. build.py sizes the boxes
// from an estimated node height, but real heights depend on content (a
// wrapped title, a tall signature). After Shiki settles heights, remeasure
// each group from its nodes' actual geometry so the dashed box never clips
// a node that grew past the estimate.
const GROUP_PAD = 20, GROUP_TOP_PAD = 28;
function sizeGroups() {
for (const box of stage.querySelectorAll('.cgroup')) {
const g = box.dataset.group;
if (!g) continue;
const nodes = [...stage.querySelectorAll(`.cnode[data-group="${CSS.escape(g)}"]`)];
if (!nodes.length) continue;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) {
minX = Math.min(minX, n.offsetLeft); minY = Math.min(minY, n.offsetTop);
maxX = Math.max(maxX, n.offsetLeft + n.offsetWidth);
maxY = Math.max(maxY, n.offsetTop + n.offsetHeight);
}
box.style.left = (minX - GROUP_PAD) + 'px';
box.style.top = (minY - GROUP_TOP_PAD) + 'px';
box.style.width = (maxX - minX + GROUP_PAD * 2) + 'px';
box.style.height = (maxY - minY + GROUP_TOP_PAD + GROUP_PAD) + 'px';
}
}
// Fit all nodes into view.
function fit() {
// Include boundary boxes so their labels and edges are not clipped.
const items = [...stage.querySelectorAll('.cnode, .cgroup')];
if (!items.length) { tx = ty = 0; scale = 1; apply(); return; }
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of items) {
minX = Math.min(minX, n.offsetLeft); minY = Math.min(minY, n.offsetTop);
maxX = Math.max(maxX, n.offsetLeft + n.offsetWidth); maxY = Math.max(maxY, n.offsetTop + n.offsetHeight);
}
const pad = 60;
const cw = wrap.clientWidth, ch = wrap.clientHeight;
const bw = (maxX - minX) + pad * 2, bh = (maxY - minY) + pad * 2;
// Cap at 1: a CSS transform that scales the stage above 1 upscales the
// already-rendered node text and blurs it. A small graph is shown at its
// natural, crisp size; the user can zoom in with the + button if needed.
scale = clamp(Math.min(cw / bw, ch / bh), 0.3, 1);
tx = (cw - bw * scale) / 2 - (minX - pad) * scale;
ty = (ch - bh * scale) / 2 - (minY - pad) * scale;
apply();
}
// Zoom around a screen point.
function zoomAt(clientX, clientY, factor) {
const rect = wrap.getBoundingClientRect();
const px = clientX - rect.left, py = clientY - rect.top;
const ns = clamp(scale * factor, 0.3, 2.5);
const k = ns / scale;
tx = px - (px - tx) * k; ty = py - (py - ty) * k;
scale = ns; apply();
}
// No wheel-to-zoom: the wheel must scroll the page, even with the pointer
// over the canvas. Zoom is manual, through the +/- buttons. The panel keeps
// its own wheel handler (below) so scrolling code in the panel does not
// bubble to the page.
// Drag to pan. A click on a node (no drag) toggles the node body.
let panning = false, moved = false, sx = 0, sy = 0, otx = 0, oty = 0;
wrap.addEventListener('pointerdown', (ev) => {
panning = true; moved = false; sx = ev.clientX; sy = ev.clientY; otx = tx; oty = ty;
wrap.classList.add('panning'); wrap.setPointerCapture(ev.pointerId);
});
wrap.addEventListener('pointermove', (ev) => {
if (!panning) return;
const ddx = ev.clientX - sx, ddy = ev.clientY - sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) moved = true;
tx = otx + ddx; ty = oty + ddy; apply();
});
wrap.addEventListener('pointerup', (ev) => {
panning = false; wrap.classList.remove('panning');
try { wrap.releasePointerCapture(ev.pointerId); } catch (_) {}
if (!moved) {
// Pointer capture makes ev.target the wrap element, so resolve the
// real element under the pointer to find the clicked node.
const hit = document.elementFromPoint(ev.clientX, ev.clientY);
if (!hit) return;
if (hit.closest && hit.closest('.cpanel')) return; // clicks inside the panel
const node = hit.closest && hit.closest('.cnode');
if (node && !(hit.closest && hit.closest('a'))) {
openPanel(node);
} else if (!node) {
// A click on empty canvas closes the open panel.
closePanel();
}
}
});
// ---- Right-side detail panel ----
const panel = document.getElementById('canvas-panel');
const panelTitle = document.getElementById('cpanel-title');
const panelFile = document.getElementById('cpanel-file');
const panelBody = document.getElementById('cpanel-body');
const panelClose = document.getElementById('cpanel-close');
let selectedNode = null;
function openPanel(node) {
const detail = node.querySelector('.cnode-detail');
if (!detail || !panel) return;
if (selectedNode) selectedNode.classList.remove('selected');
selectedNode = node; node.classList.add('selected');
panelTitle.textContent = detail.dataset.title || node.querySelector('.cnode-title')?.textContent || '';
const url = detail.dataset.fileUrl || '';
if (url) {
panelFile.textContent = (detail.dataset.file || 'View diff') + ' ↗';
panelFile.href = url; panelFile.style.display = '';
} else {
panelFile.style.display = 'none';
}
// Clone the detail content (description + code blocks) into the panel.
panelBody.innerHTML = '';
[...detail.children].forEach((c) => panelBody.appendChild(c.cloneNode(true)));
panel.classList.add('open'); panel.setAttribute('aria-hidden', 'false');
}
function closePanel() {
if (!panel) return;
panel.classList.remove('open'); panel.setAttribute('aria-hidden', 'true');
if (selectedNode) { selectedNode.classList.remove('selected'); selectedNode = null; }
}
if (panelClose) panelClose.addEventListener('click', (e) => { e.stopPropagation(); closePanel(); });
// The panel sits inside the canvas. Stop its pointer events from panning.
if (panel) {
panel.addEventListener('pointerdown', (e) => e.stopPropagation());
panel.addEventListener('wheel', (e) => e.stopPropagation());
}
// Drag the left edge to resize the panel. The width grows as the pointer
// moves left toward the canvas.
const panelResizer = document.getElementById('cpanel-resizer');
if (panel && panelResizer) {
panelResizer.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.stopPropagation();
const startX = e.clientX;
const startW = panel.getBoundingClientRect().width;
panel.classList.add('resizing');
panelResizer.setPointerCapture(e.pointerId);
const onMove = (mv) => {
const next = startW + (startX - mv.clientX);
const maxW = (wrap.clientWidth || window.innerWidth) * 0.95;
panel.style.width = Math.max(320, Math.min(next, maxW)) + 'px';
};
const onUp = (up) => {
panel.classList.remove('resizing');
try { panelResizer.releasePointerCapture(up.pointerId); } catch (_) {}
panelResizer.removeEventListener('pointermove', onMove);
panelResizer.removeEventListener('pointerup', onUp);
};
panelResizer.addEventListener('pointermove', onMove);
panelResizer.addEventListener('pointerup', onUp);
});
}
// Maximize: fill the viewport, then re-fit. Toggle back to inline size.
function toggleMax() {
const on = wrap.classList.toggle('maximized');
document.documentElement.classList.toggle('canvas-max', on);
document.body.classList.toggle('canvas-max', on);
wrap.querySelectorAll('.label-max').forEach((s) => { s.style.display = on ? 'none' : 'inline'; });
wrap.querySelectorAll('.label-min').forEach((s) => { s.style.display = on ? 'inline' : 'none'; });
requestAnimationFrame(() => { drawEdges(); fit(); });
}
wrap.querySelectorAll('[data-cz]').forEach((btn) => {
btn.addEventListener('pointerdown', (e) => e.stopPropagation());
btn.addEventListener('click', (e) => {
e.stopPropagation();
const rect = wrap.getBoundingClientRect();
if (btn.dataset.cz === 'in') zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.2);
else if (btn.dataset.cz === 'out') zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1 / 1.2);
else if (btn.dataset.cz === 'max') toggleMax();
else fit();
});
});
// Esc closes the detail panel first, then leaves the maximized canvas.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (panel && panel.classList.contains('open')) { closePanel(); return; }
if (wrap.classList.contains('maximized')) toggleMax();
});
// Lay out after Shiki settles node heights, then fit.
shikiReady.finally(() => { requestAnimationFrame(() => { sizeGroups(); drawEdges(); fit(); }); });
window.addEventListener('resize', () => { sizeGroups(); drawEdges(); });
})();
</script>
</body>
</html>