fix: shorten PR walkthrough links and align shell branding (#2954)
This commit is contained in:
@@ -380,14 +380,44 @@ def render_groups(boxes):
|
||||
)
|
||||
|
||||
|
||||
def edge_lanes(edges):
|
||||
"""Assign stable slots for endpoints and repeated node pairs."""
|
||||
outgoing, incoming, pairs = {}, {}, {}
|
||||
for index, edge in enumerate(edges):
|
||||
outgoing.setdefault(edge["from"], []).append(index)
|
||||
incoming.setdefault(edge["to"], []).append(index)
|
||||
pair = tuple(sorted((edge["from"], edge["to"])))
|
||||
pairs.setdefault(pair, []).append(index)
|
||||
lanes = []
|
||||
for index, edge in enumerate(edges):
|
||||
source_edges = outgoing[edge["from"]]
|
||||
target_edges = incoming[edge["to"]]
|
||||
pair_edges = pairs[tuple(sorted((edge["from"], edge["to"])))]
|
||||
lanes.append({
|
||||
"from_lane": source_edges.index(index),
|
||||
"from_lanes": len(source_edges),
|
||||
"to_lane": target_edges.index(index),
|
||||
"to_lanes": len(target_edges),
|
||||
"pair_lane": pair_edges.index(index),
|
||||
"pair_lanes": len(pair_edges),
|
||||
})
|
||||
return lanes
|
||||
|
||||
|
||||
def render_edges(edges, ids):
|
||||
out = ""
|
||||
for e in edges:
|
||||
require(e.get("from") in ids, f'edge references unknown node "{e.get("from")}"')
|
||||
require(e.get("to") in ids, f'edge references unknown node "{e.get("to")}"')
|
||||
for e, lane in zip(edges, edge_lanes(edges)):
|
||||
lane_attrs = " ".join(
|
||||
f'data-{name.replace("_", "-")}="{value}"'
|
||||
for name, value in lane.items()
|
||||
)
|
||||
out += (f'<div class="cedge" data-from="{esc_attr(e["from"])}" '
|
||||
f'data-to="{esc_attr(e["to"])}" '
|
||||
f'data-label="{esc_attr(e.get("label", ""))}"></div>')
|
||||
f'data-label="{esc_attr(e.get("label", ""))}" '
|
||||
f'{lane_attrs}></div>')
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="https://kandev.ai/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="https://kandev.ai/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="https://kandev.ai/apple-touch-icon.png" sizes="180x180" />
|
||||
<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. -->
|
||||
<!-- External package resources use exact-version CDN URLs. Website brand
|
||||
assets intentionally use live kandev.ai URLs so new pages match the
|
||||
current site branding. Tailwind loads here as a classic script. The
|
||||
four ESM libraries below are declared in this import map so every
|
||||
package URL is visible at the top. -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
@@ -35,15 +37,14 @@
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: { extend: { maxWidth: { content: '72rem' }, colors: {
|
||||
brand: { DEFAULT: '#4f46e5', soft: '#818cf8', deep: '#3730a3' },
|
||||
brand: { DEFAULT: '#6366f1', soft: '#818cf8', deep: '#4f46e5' },
|
||||
} } },
|
||||
};
|
||||
</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. */
|
||||
/* Visual language shared with the documentation shell: Figtree for UI
|
||||
copy, Geist Mono for code, indigo actions, dark-gray surfaces, and
|
||||
compact shadcn-style radii. */
|
||||
@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');
|
||||
@@ -54,24 +55,25 @@
|
||||
}
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--wt-bg: #ffffff;
|
||||
--wt-fg: #0d0d10;
|
||||
--wt-muted: #71717a;
|
||||
--wt-border: rgba(13, 13, 16, .12);
|
||||
--wt-surface: #f7f7f8;
|
||||
--wt-bg: #f5f5f5;
|
||||
--wt-fg: #0a0a0a;
|
||||
--wt-muted: #666666;
|
||||
--wt-border: rgba(204, 204, 204, .5);
|
||||
--wt-surface: #f1f1f1;
|
||||
--wt-primary: #4f46e5;
|
||||
--wt-primary-soft: #818cf8;
|
||||
--wt-primary-deep: #3730a3;
|
||||
--wt-dark-surface: #111114;
|
||||
--wt-primary-deep: #4f46e5;
|
||||
--wt-dark-surface: #1e1e1e;
|
||||
--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;
|
||||
--wt-bg: #121212;
|
||||
--wt-fg: #ebebeb;
|
||||
--wt-muted: #b3b3b3;
|
||||
--wt-border: rgba(102, 102, 102, .2);
|
||||
--wt-surface: #191919;
|
||||
--wt-primary: #6366f1;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
@@ -80,11 +82,11 @@
|
||||
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);
|
||||
background: rgba(245, 245, 245, .95); border-color: var(--wt-border);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .02);
|
||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
html.dark .wt-topbar { background: rgba(13, 13, 16, .88); }
|
||||
html.dark .wt-topbar { background: rgba(18, 18, 18, .95); }
|
||||
.wt-main { line-height: 1.7; }
|
||||
.wt-main h2, .wt-main h3 { letter-spacing: -.035em; }
|
||||
.wt-main h2 { font-weight: 650; }
|
||||
@@ -98,10 +100,11 @@
|
||||
.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;
|
||||
overflow: hidden; border-radius: .375rem; background: var(--wt-surface);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, .08), 0 8px 18px rgba(99, 102, 241, .22);
|
||||
}
|
||||
html.dark .wt-brand-mark { box-shadow: 0 0 0 1px rgba(255, 255, 255, .1), 0 8px 18px rgba(99, 102, 241, .22); }
|
||||
.wt-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.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; }
|
||||
@@ -118,6 +121,7 @@
|
||||
border: 1px solid var(--wt-border); border-radius: .5rem; background: var(--wt-bg);
|
||||
box-shadow: 0 12px 28px rgba(13, 13, 16, .14);
|
||||
}
|
||||
html.dark .wt-mobile-menu { background: var(--wt-dark-surface); }
|
||||
.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;
|
||||
@@ -131,7 +135,14 @@
|
||||
/* 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); }
|
||||
.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); scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
||||
.shiki::-webkit-scrollbar { height: 6px; }
|
||||
.shiki::-webkit-scrollbar-track { background: transparent; }
|
||||
.shiki::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
|
||||
.shiki:hover, .shiki:focus-within { scrollbar-color: rgba(100,116,139,.55) transparent; }
|
||||
.shiki:hover::-webkit-scrollbar-thumb, .shiki:focus-within::-webkit-scrollbar-thumb { background: rgba(100,116,139,.55); }
|
||||
html.dark .shiki:hover, html.dark .shiki:focus-within { scrollbar-color: rgba(148,163,184,.5) transparent; }
|
||||
html.dark .shiki:hover::-webkit-scrollbar-thumb, html.dark .shiki:focus-within::-webkit-scrollbar-thumb { background: rgba(148,163,184,.5); }
|
||||
/* 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
|
||||
@@ -391,7 +402,9 @@
|
||||
<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-mark" aria-hidden="true">
|
||||
<img src="https://kandev.ai/brand/kandev-github-org.png" alt="" />
|
||||
</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>
|
||||
@@ -787,6 +800,25 @@
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const apply = () => { stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; };
|
||||
|
||||
// Spread edges that share a node across that node's edge. The old
|
||||
// midpoint route made every branch leave and enter at the same pixel,
|
||||
// which stacked paths and labels on dense graphs.
|
||||
function laneOffset(index, count, span, maxGap = 48) {
|
||||
const total = Math.max(1, Number(count) || 1);
|
||||
if (total === 1) return 0;
|
||||
const slot = clamp(Number(index) || 0, 0, total - 1);
|
||||
const gap = Math.min(maxGap, (span * .62) / (total - 1));
|
||||
return (slot - (total - 1) / 2) * gap;
|
||||
}
|
||||
|
||||
function cubicPoint(p0, p1, p2, p3, t) {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u ** 3 * p0.x + 3 * u ** 2 * t * p1.x + 3 * u * t ** 2 * p2.x + t ** 3 * p3.x,
|
||||
y: u ** 3 * p0.y + 3 * u ** 2 * t * p1.y + 3 * u * t ** 2 * p2.y + t ** 3 * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
// Draw one arrow per .cedge, from the FROM node edge to the TO node edge.
|
||||
function drawEdges() {
|
||||
svg.innerHTML = '';
|
||||
@@ -802,30 +834,69 @@
|
||||
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;
|
||||
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
||||
const fromLane = laneOffset(
|
||||
e.dataset.fromLane, e.dataset.fromLanes, horizontal ? ah : aw,
|
||||
);
|
||||
const toLane = laneOffset(
|
||||
e.dataset.toLane, e.dataset.toLanes, horizontal ? bh : bw,
|
||||
);
|
||||
// Reciprocal or parallel edges have the same endpoint geometry even
|
||||
// when their source/target slots differ. Give the whole node pair a
|
||||
// smaller parallel offset so those routes do not collapse into one.
|
||||
const pairLane = laneOffset(
|
||||
e.dataset.pairLane, e.dataset.pairLanes,
|
||||
(horizontal ? Math.min(ah, bh) : Math.min(aw, bw)) * .6,
|
||||
);
|
||||
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;
|
||||
if (horizontal) {
|
||||
x1 = dx >= 0 ? ax + aw : ax; y1 = acy + fromLane + pairLane;
|
||||
x2 = dx >= 0 ? bx : bx + bw; y2 = bcy + toLane + pairLane;
|
||||
} else {
|
||||
x1 = acx; y1 = dy >= 0 ? ay + ah : ay;
|
||||
x2 = bcx; y2 = dy >= 0 ? by : by + bh;
|
||||
x1 = acx + fromLane + pairLane; y1 = dy >= 0 ? ay + ah : ay;
|
||||
x2 = bcx + toLane + pairLane; 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 direction = horizontal ? (dx >= 0 ? 1 : -1) : (dy >= 0 ? 1 : -1);
|
||||
const curve = Math.max(52, (horizontal ? Math.abs(x2 - x1) : Math.abs(y2 - y1)) * .42);
|
||||
const p0 = { x: x1, y: y1 };
|
||||
const p1 = horizontal
|
||||
? { x: x1 + direction * curve, y: y1 }
|
||||
: { x: x1, y: y1 + direction * curve };
|
||||
const p2 = horizontal
|
||||
? { x: x2 - direction * curve, y: y2 }
|
||||
: { x: x2, y: y2 - direction * curve };
|
||||
const p3 = { x: x2, y: y2 };
|
||||
const path = document.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`);
|
||||
path.setAttribute('d', `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y}, ${p2.x} ${p2.y}, ${p3.x} ${p3.y}`);
|
||||
path.setAttribute('class', 'cedge-path');
|
||||
path.setAttribute('marker-end', 'url(#arrow)');
|
||||
svg.appendChild(path);
|
||||
if (e.dataset.label) {
|
||||
const labelPoint = cubicPoint(p0, p1, p2, p3, .5);
|
||||
const labelSpan = horizontal ? Math.min(ah, bh) : Math.min(aw, bw);
|
||||
const labelFromLane = laneOffset(
|
||||
e.dataset.fromLane, e.dataset.fromLanes, labelSpan, 192,
|
||||
);
|
||||
const labelToLane = laneOffset(
|
||||
e.dataset.toLane, e.dataset.toLanes, labelSpan, 192,
|
||||
);
|
||||
// Prefer the endpoint with the stronger fan-out. This keeps labels
|
||||
// apart when two different routes happen to share a midpoint.
|
||||
const endpointLabelLane = Math.abs(labelToLane) >= Math.abs(labelFromLane)
|
||||
? labelToLane : labelFromLane;
|
||||
const pairLabelLane = laneOffset(
|
||||
e.dataset.pairLane, e.dataset.pairLanes,
|
||||
labelSpan * .6, 96,
|
||||
);
|
||||
const labelLane = Number(e.dataset.pairLanes) > 1
|
||||
? pairLabelLane : endpointLabelLane;
|
||||
if (horizontal) labelPoint.y += labelLane;
|
||||
else labelPoint.x += labelLane;
|
||||
const lab = document.createElement('div');
|
||||
lab.className = 'cedge-label';
|
||||
lab.textContent = e.dataset.label;
|
||||
lab.style.left = mx + 'px';
|
||||
lab.style.top = my + 'px';
|
||||
lab.style.left = labelPoint.x + 'px';
|
||||
lab.style.top = labelPoint.y + 'px';
|
||||
stage.appendChild(lab);
|
||||
}
|
||||
}
|
||||
@@ -1027,7 +1098,7 @@
|
||||
|
||||
// Lay out after Shiki settles node heights, then fit.
|
||||
shikiReady.finally(() => { requestAnimationFrame(() => { sizeGroups(); drawEdges(); fit(); }); });
|
||||
window.addEventListener('resize', () => { sizeGroups(); drawEdges(); });
|
||||
window.addEventListener('resize', () => { sizeGroups(); drawEdges(); fit(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -333,6 +333,54 @@ class TestRenderEdges(unittest.TestCase):
|
||||
self.assertIn('data-from="n1"', html)
|
||||
self.assertIn('data-label="calls"', html)
|
||||
|
||||
def test_shared_endpoints_receive_distinct_route_lanes(self):
|
||||
html = build.render_edges(
|
||||
[
|
||||
{"from": "n1", "to": "n2", "label": "first"},
|
||||
{"from": "n1", "to": "n3", "label": "second"},
|
||||
{"from": "n2", "to": "n4", "label": "third"},
|
||||
{"from": "n3", "to": "n4", "label": "fourth"},
|
||||
],
|
||||
{"n1", "n2", "n3", "n4"},
|
||||
)
|
||||
self.assertIn(
|
||||
'data-from-lane="0" data-from-lanes="2" '
|
||||
'data-to-lane="0" data-to-lanes="1"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'data-from-lane="1" data-from-lanes="2" '
|
||||
'data-to-lane="0" data-to-lanes="1"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'data-from-lane="0" data-from-lanes="1" '
|
||||
'data-to-lane="0" data-to-lanes="2"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'data-from-lane="0" data-from-lanes="1" '
|
||||
'data-to-lane="1" data-to-lanes="2"',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_reciprocal_edges_receive_distinct_pair_lanes(self):
|
||||
html = build.render_edges(
|
||||
[
|
||||
{"from": "n1", "to": "n2", "label": "forward"},
|
||||
{"from": "n2", "to": "n1", "label": "backward"},
|
||||
],
|
||||
{"n1", "n2"},
|
||||
)
|
||||
self.assertIn(
|
||||
'data-pair-lane="0" data-pair-lanes="2"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'data-pair-lane="1" data-pair-lanes="2"',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_unknown_from_node_fails(self):
|
||||
with self.assertRaises(build.BuildError):
|
||||
build.render_edges([{"from": "nX", "to": "n1"}], {"n1"})
|
||||
@@ -417,14 +465,28 @@ class TestBuild(unittest.TestCase):
|
||||
'<h3 class="font-semibold text-brand-soft mb-1">'
|
||||
'UniqueChangeTitle</h3>', out)
|
||||
|
||||
def test_shell_matches_kandev_landing_visual_language(self):
|
||||
def test_shell_matches_docs_brand_and_dark_visual_language(self):
|
||||
out = build.build(minimal_data())
|
||||
self.assertIn("--wt-primary: #4f46e5", out)
|
||||
self.assertIn("--wt-bg: #0d0d10", out)
|
||||
self.assertIn("--wt-primary: #6366f1", out)
|
||||
self.assertIn("--wt-bg: #121212", out)
|
||||
self.assertIn("--wt-surface: #191919", out)
|
||||
self.assertIn("--wt-dark-surface: #1e1e1e", out)
|
||||
self.assertIn("--wt-fg: #ebebeb", out)
|
||||
self.assertIn("font-family: 'Figtree'", out)
|
||||
self.assertIn("font-family: 'Geist Mono'", out)
|
||||
self.assertIn('class="wt-topbar sticky', out)
|
||||
self.assertIn('class="wt-brand-mark"', out)
|
||||
self.assertIn('href="https://kandev.ai/favicon.ico"', out)
|
||||
self.assertIn('href="https://kandev.ai/icon.svg"', out)
|
||||
self.assertIn(
|
||||
'rel="apple-touch-icon" href="https://kandev.ai/apple-touch-icon.png"',
|
||||
out,
|
||||
)
|
||||
self.assertIn('src="https://kandev.ai/brand/kandev-github-org.png"', out)
|
||||
self.assertIn(
|
||||
"brand: { DEFAULT: '#6366f1', soft: '#818cf8', deep: '#4f46e5' },",
|
||||
out,
|
||||
)
|
||||
|
||||
def test_runtime_cdn_dependencies_use_exact_versions(self):
|
||||
out = build.build(minimal_data())
|
||||
@@ -445,6 +507,28 @@ class TestBuild(unittest.TestCase):
|
||||
self.assertIn('id="mobile-nav-data"', out)
|
||||
self.assertIn("@media (max-width: 640px)", out)
|
||||
|
||||
def test_shell_makes_code_scrollbars_subtle_until_hover(self):
|
||||
out = build.build(minimal_data())
|
||||
self.assertIn("scrollbar-width: thin", out)
|
||||
self.assertIn("scrollbar-color: transparent transparent", out)
|
||||
self.assertIn(".shiki::-webkit-scrollbar-thumb", out)
|
||||
self.assertIn(".shiki:hover::-webkit-scrollbar-thumb", out)
|
||||
self.assertIn(".shiki:focus-within::-webkit-scrollbar-thumb", out)
|
||||
|
||||
def test_shell_uses_lanes_to_route_canvas_edges(self):
|
||||
out = build.build(minimal_data())
|
||||
self.assertIn("function laneOffset", out)
|
||||
self.assertIn("e.dataset.fromLane", out)
|
||||
self.assertIn("e.dataset.toLane", out)
|
||||
self.assertIn("e.dataset.pairLane", out)
|
||||
self.assertIn("const labelLane", out)
|
||||
self.assertIn("const labelFromLane", out)
|
||||
self.assertIn("const labelToLane", out)
|
||||
self.assertIn("const endpointLabelLane", out)
|
||||
self.assertIn("const pairLabelLane", out)
|
||||
self.assertIn("labelPoint.x += labelLane", out)
|
||||
self.assertIn("sizeGroups(); drawEdges(); fit();", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -132,6 +132,10 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("--agent github-pr-walkthrough", self.generation)
|
||||
self.assertIn("--file .pr-walkthrough/guidelines.md", self.generation)
|
||||
self.assertIn(
|
||||
"Never put shell template sentinels such as PR_TITLE or PR_URL in a code, patch, or rendered-Markdown block.",
|
||||
self.generation,
|
||||
)
|
||||
self.assertIn("--file .pr-walkthrough/walkthrough.patch", self.generation)
|
||||
self.assertIn(".pr-walkthrough/head-context/manifest.json", self.generation)
|
||||
self.assertNotIn("render_pr_walkthrough", self.generation)
|
||||
@@ -233,7 +237,8 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
||||
|
||||
def test_publication_uploads_only_html_with_expected_metadata(self) -> None:
|
||||
for value in (
|
||||
'OBJECT_KEY="pr/${PR_NUMBER}/${HEAD_SHA}.html"',
|
||||
'SHORT_HEAD_SHA="${HEAD_SHA:0:12}"',
|
||||
'OBJECT_KEY="pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"',
|
||||
"aws s3 cp",
|
||||
'--content-type "text/html; charset=utf-8"',
|
||||
'--cache-control "public, max-age=300"',
|
||||
|
||||
@@ -171,6 +171,7 @@ jobs:
|
||||
- Read the attached guidelines and walkthrough patch first.
|
||||
- The checked-out repository is the trusted base commit. Inspect it only when needed to understand callers and surrounding architecture.
|
||||
- Use real file paths and real code excerpts from the PR head or attached diff. Do not fabricate code.
|
||||
- Never put shell template sentinels such as PR_TITLE or PR_URL in a code, patch, or rendered-Markdown block. If a source diff hunk contains a shell template sentinel, trim that hunk or use a shorter excerpt that omits the sentinel.
|
||||
- Follow the pr-walkthrough skill from the trusted repository checkout.
|
||||
- Write the complete JSON object to .pr-walkthrough/draft.json, then run the renderer.
|
||||
- If the renderer rejects the draft, correct that file and run the renderer again.
|
||||
@@ -272,7 +273,8 @@ jobs:
|
||||
|
||||
HTML_PATH=".r2-artifact/docs/pr-walkthrough/pr-${PR_NUMBER}.html"
|
||||
test -s "$HTML_PATH"
|
||||
OBJECT_KEY="pr/${PR_NUMBER}/${HEAD_SHA}.html"
|
||||
SHORT_HEAD_SHA="${HEAD_SHA:0:12}"
|
||||
OBJECT_KEY="pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"
|
||||
|
||||
aws s3 cp "$HTML_PATH" "s3://${CLOUDFLARE_R2_BUCKET}/${OBJECT_KEY}" \
|
||||
--endpoint-url "$CLOUDFLARE_R2_ENDPOINT" \
|
||||
|
||||
@@ -18,8 +18,10 @@ object on every push if the model cost is acceptable.
|
||||
Publish walkthrough HTML files to a dedicated Cloudflare R2 bucket named
|
||||
`kandev-pr-walkthroughs`, exposed through the custom domain
|
||||
`walkthrough.kandev.ai`. Store objects under
|
||||
`pr/<pull-request-number>/<head-sha>.html` and publish only the HTML, not the
|
||||
JSON generation artifact.
|
||||
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is the
|
||||
first 12 lowercase hexadecimal characters of the exact head SHA. Publish only
|
||||
the HTML, not the JSON generation artifact. The URL naming rule is recorded in
|
||||
[ADR-2026-08-23-pr-walkthrough-short-urls](2026-08-23-pr-walkthrough-short-urls.md).
|
||||
|
||||
The GitHub Actions publication job uses a bucket-scoped R2 S3-compatible
|
||||
Object Read & Write credential. The generation job receives no R2 credential;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-2026-08-23-pr-walkthrough-short-urls: Use 12-character SHA prefixes for PR walkthrough URLs
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-23
|
||||
**Area:** infra, workflow
|
||||
|
||||
## Context
|
||||
|
||||
The public PR walkthrough URL currently includes the full 40-character commit
|
||||
SHA. The full value is useful for workflow identity, but it makes a reviewer
|
||||
link unnecessarily long. A shorter value must still identify a PR snapshot
|
||||
with a low practical collision risk.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the full lowercase commit SHA in workflow inputs and validation. Use its
|
||||
first 12 lowercase hexadecimal characters for the R2 object key, public URL,
|
||||
and PR-description callout. The workflow and the PR-body helper derive this
|
||||
prefix from the trusted 40-character event SHA.
|
||||
|
||||
The fixed shell loads the current website logo and favicon from the website's
|
||||
stable public asset paths. This live branding is intentional. Package URLs in
|
||||
the shell remain pinned, but new pages follow the current website identity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Walkthrough links are 28 characters shorter while retaining a 48-bit prefix.
|
||||
- The public URL is stable for a given PR head and remains independent of the
|
||||
generated HTML bytes.
|
||||
- Different commits with the same 12-character prefix can share an object
|
||||
key. The prefix is long enough for the repository's active PR snapshots and
|
||||
is safer than a conventional 7-character display prefix.
|
||||
- Existing full-SHA objects are not renamed. New generations use the short
|
||||
path, and reruns update the PR callout to that path.
|
||||
- Existing pages can show a later website brand asset because the shell loads
|
||||
that asset at page-load time.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep the full SHA:** It avoids prefix collisions, but produces unnecessarily
|
||||
long public links.
|
||||
- **Use a 7-character prefix:** It is familiar in Git interfaces, but has a
|
||||
smaller collision space for a public object namespace.
|
||||
- **Use a generated alias:** It can make links shorter, but it requires a
|
||||
separate alias store and lifecycle rules that are not needed for a commit
|
||||
snapshot.
|
||||
@@ -196,3 +196,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-22-agent-owned-pr-walkthrough-rendering | [Keep PR walkthrough rendering agent owned and provider neutral](2026-08-22-agent-owned-pr-walkthrough-rendering.md) | superseded by 2026-08-22-pr-walkthrough-filesystem-runner | workflow, infra, security | 2026-08-22 |
|
||||
| 2026-08-22-pr-walkthrough-filesystem-runner | [Use a Filesystem Contract for PR Walkthrough Runners](2026-08-22-pr-walkthrough-filesystem-runner.md) | accepted | workflow, infra, security | 2026-08-22 |
|
||||
| 2026-08-22-pr-walkthrough-description-link | [Own a top-level PR walkthrough callout](2026-08-22-pr-walkthrough-description-link.md) | accepted | workflow, infra, security, GitHub | 2026-08-22 |
|
||||
| 2026-08-23-pr-walkthrough-short-urls | [Use 12-character SHA prefixes for PR walkthrough URLs](2026-08-23-pr-walkthrough-short-urls.md) | accepted | workflow, infra | 2026-08-23 |
|
||||
|
||||
@@ -145,6 +145,10 @@ Wave 2:
|
||||
|
||||
- [x] [task-03-prompt-driven-workflow](task-03-prompt-driven-workflow.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [task-04-public-url-and-shell-presentation](task-04-public-url-and-shell-presentation.md)
|
||||
|
||||
Execute tasks sequentially in the primary conversation unless the user
|
||||
explicitly authorizes subagents.
|
||||
|
||||
@@ -159,6 +163,7 @@ explicitly authorizes subagents.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to R2 object naming, publication, retention, or PR linking.
|
||||
- Generic R2 retention or merge-promotion changes beyond the 12-character URL
|
||||
contract recorded in ADR-2026-08-23-pr-walkthrough-short-urls.
|
||||
- Generic GitHub automation for Claude or Codex.
|
||||
- Walkthrough generation for fork pull requests.
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "04-public-url-and-shell-presentation"
|
||||
title: "Public URL and shell presentation"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["03-prompt-driven-workflow"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/pr-walkthrough/spec.md"
|
||||
---
|
||||
|
||||
# Task 04: Public URL and Shell Presentation
|
||||
|
||||
Shorten hosted walkthrough links and align the generated page with the
|
||||
website and documentation shell.
|
||||
|
||||
- **Acceptance:** The publication job and PR-body helper use the first 12
|
||||
lowercase hexadecimal characters of the trusted full head SHA.
|
||||
- **Acceptance:** The generated shell uses the website brand image, favicon,
|
||||
and documentation dark-gray palette.
|
||||
- **Acceptance:** Focused helper, workflow-contract, renderer, and responsive
|
||||
page checks pass.
|
||||
- **Verification:**
|
||||
|
||||
```text
|
||||
python3 scripts/pr-walkthrough-pr-body.test.py
|
||||
python3 .github/scripts/pr-walkthrough-workflow-contract_test.py
|
||||
cd .agents/skills/pr-walkthrough/references && python3 -m unittest test_build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
- The implementation derives a 12-character public SHA prefix from the full
|
||||
event SHA and retains full-SHA validation for trusted workflow identity.
|
||||
- The shell uses the website brand image and favicon links and the docs dark
|
||||
tokens for its shell surfaces, text, and borders.
|
||||
- `python3 scripts/pr-walkthrough-pr-body.test.py` passed (8 tests).
|
||||
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` passed
|
||||
(20 tests).
|
||||
- `python3 -m unittest test_build` passed (59 tests).
|
||||
- Context, renderer, harness, action-pinning, and actionlint checks passed.
|
||||
- Google Chrome captured the generated page at 390x844 and 1440x1000. Both
|
||||
views showed the brand mark and no topbar overflow.
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
spec: docs/specs/pr-walkthrough/spec.md
|
||||
created: 2026-08-22
|
||||
status: implementing
|
||||
status: superseded
|
||||
superseded_by: ../pr-walkthrough-portable-runner-fix/plan.md
|
||||
---
|
||||
|
||||
# Implementation Plan: Pull Request Walkthrough Generation
|
||||
@@ -101,7 +102,9 @@ The publication contract is:
|
||||
- Secret-key secret: `CLOUDFLARE_R2_SECRET_ACCESS_KEY`.
|
||||
- Existing account variable: `CLOUDFLARE_ACCOUNT_ID` remains available for
|
||||
endpoint construction and is not a secret.
|
||||
- Object key: `pr/<pull-request-number>/<head-sha>.html`.
|
||||
- Object key: `pr/<pull-request-number>/<short-head-sha>.html`, where
|
||||
`short-head-sha` is the first 12 lowercase hexadecimal characters of the
|
||||
exact head SHA.
|
||||
- Object metadata: `Content-Type: text/html; charset=utf-8` and a short cache
|
||||
lifetime that does not outlive lifecycle deletion or same-head reruns.
|
||||
|
||||
@@ -235,7 +238,7 @@ Completed 2026-08-22:
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-walkthrough-skill-renderer](task-01-walkthrough-skill-renderer.md)
|
||||
- [ ] [task-02-agent-rendering-contract](task-02-agent-rendering-contract.md)
|
||||
- [x] [task-02-agent-rendering-contract](task-02-agent-rendering-contract.md) (superseded by the portable filesystem contract)
|
||||
|
||||
Wave 2:
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
id: "02-agent-rendering-contract"
|
||||
title: "Provider-neutral agent rendering contract"
|
||||
status: in_progress
|
||||
status: superseded
|
||||
superseded_by: ../pr-walkthrough-portable-runner-fix/task-02-fixed-renderer-contract.md
|
||||
wave: 1
|
||||
depends_on: ["01-walkthrough-skill-renderer"]
|
||||
plan: "plan.md"
|
||||
|
||||
@@ -26,11 +26,13 @@ custom domain as the externally visible URL.
|
||||
S3-compatible API using a bucket-scoped Object Read & Write token. It does
|
||||
not use the existing Pages `CLOUDFLARE_API_TOKEN`.
|
||||
- **Acceptance:** The object key is
|
||||
`pr/<pull-request-number>/<head-sha>.html`, with HTML content type and a
|
||||
short cache policy suitable for label-triggered reruns.
|
||||
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is
|
||||
the first 12 lowercase hexadecimal characters of the exact head SHA. It has
|
||||
HTML content type and a short cache policy suitable for label-triggered
|
||||
reruns.
|
||||
- **Acceptance:** The job verifies the object with an R2 metadata request and
|
||||
verifies the public URL
|
||||
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<head-sha>.html`
|
||||
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<short-head-sha>.html`
|
||||
with an HTTPS GET before reporting publication success.
|
||||
- **Acceptance:** The job writes the public URL to the workflow job summary
|
||||
and exports it to the dependent trusted link job, but receives no GitHub
|
||||
|
||||
@@ -21,7 +21,8 @@ lifecycle policy.
|
||||
**Decisions:**
|
||||
[ADR-2026-08-22-pr-walkthrough-r2-hosting](../../decisions/2026-08-22-pr-walkthrough-r2-hosting.md),
|
||||
[ADR-2026-08-22-pr-walkthrough-filesystem-runner](../../decisions/2026-08-22-pr-walkthrough-filesystem-runner.md),
|
||||
[ADR-2026-08-22-pr-walkthrough-description-link](../../decisions/2026-08-22-pr-walkthrough-description-link.md)
|
||||
[ADR-2026-08-22-pr-walkthrough-description-link](../../decisions/2026-08-22-pr-walkthrough-description-link.md),
|
||||
[ADR-2026-08-23-pr-walkthrough-short-urls](../../decisions/2026-08-23-pr-walkthrough-short-urls.md)
|
||||
|
||||
**Implementation plan:**
|
||||
[Portable PR walkthrough runner fix](../../plans/pr-walkthrough-portable-runner-fix/plan.md)
|
||||
@@ -43,7 +44,13 @@ lifecycle policy.
|
||||
- The generated page contains a vertical reviewer story, a dark and light
|
||||
theme, architecture and data diagrams when supplied, highlighted code, diff
|
||||
tinting, GitHub file links, an interactive code canvas, and a linear fallback
|
||||
list.
|
||||
list. Canvas edges use separate endpoint and node-pair lanes, and their
|
||||
labels follow the routed edge without stacking. Horizontal code scrollbars
|
||||
stay subtle until a reviewer hovers over or focuses a code block. Patch and
|
||||
explicitly marked diff blocks use diff colors. Plain code blocks remain
|
||||
neutral because they provide context rather than a change. Its top bar uses
|
||||
the website brand mark and favicon, and its dark theme uses the
|
||||
documentation shell's dark-gray palette.
|
||||
- The configured workflow agent generates and renders the walkthrough for a
|
||||
non-draft same-repository pull request when it is opened, reopened, marked
|
||||
ready for review, or updated. OpenCode is the initial runner, but the skill
|
||||
@@ -64,11 +71,13 @@ lifecycle policy.
|
||||
- The workflow preserves the generated JSON and HTML as CI artifacts and
|
||||
uploads only the HTML to the `kandev-pr-walkthroughs` R2 bucket.
|
||||
- Each published object uses the key
|
||||
`pr/<pull-request-number>/<head-sha>.html` and is served at
|
||||
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<head-sha>.html`.
|
||||
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is
|
||||
the first 12 lowercase hexadecimal characters of the exact head SHA. It is
|
||||
served at
|
||||
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<short-head-sha>.html`.
|
||||
- The workflow regenerates on `synchronize` for same-repository pull request
|
||||
updates. Each generated object remains keyed by pull request number and head
|
||||
SHA.
|
||||
updates. Each generated object remains keyed by pull request number and the
|
||||
12-character prefix of the exact head SHA.
|
||||
- After public validation succeeds, a separate minimum-permission job prepends
|
||||
a prominent marker-owned walkthrough callout to the pull request
|
||||
description. A rerun replaces only that callout and preserves the rest of
|
||||
@@ -167,7 +176,7 @@ not from merge time.
|
||||
- **GIVEN** a non-draft same-repository pull request is opened, reopened, or
|
||||
marked ready for review, **WHEN** the walkthrough job runs, **THEN** it
|
||||
creates non-empty JSON and HTML artifacts and publishes the HTML under the
|
||||
current head SHA in R2.
|
||||
12-character prefix of the current head SHA in R2.
|
||||
- **GIVEN** a fetched PR head, **WHEN** the workflow computes the triple-dot
|
||||
diff, **THEN** the merge base exists and context preparation succeeds.
|
||||
- **GIVEN** a changed file contains bounded UTF-8 text, **WHEN** context is
|
||||
@@ -203,6 +212,15 @@ not from merge time.
|
||||
label contains unsafe markup, **WHEN** the page is rendered, **THEN** the
|
||||
source is escaped or sanitized and no executable PR-controlled markup is
|
||||
inserted by the renderer.
|
||||
- **GIVEN** two canvas edges share an endpoint or node pair, **WHEN** the
|
||||
canvas draws them, **THEN** their paths and labels use separate lanes and do
|
||||
not occupy the same route.
|
||||
- **GIVEN** a code block overflows horizontally, **WHEN** it is not hovered or
|
||||
focused, **THEN** its scrollbar thumb is transparent; **WHEN** it is hovered
|
||||
or focused, **THEN** a subtle thumb appears.
|
||||
- **GIVEN** a code block uses a patch or explicit diff marker, **WHEN** the
|
||||
page renders it, **THEN** added and removed lines receive diff colors, and a
|
||||
plain code block remains neutral context.
|
||||
- **GIVEN** a draft pull request or an unauthorized fork event, **WHEN** the
|
||||
workflow is triggered, **THEN** no walkthrough agent job runs.
|
||||
- **GIVEN** a successfully generated HTML file, **WHEN** the publishing job
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
START_MARKER = "<!-- kandev-pr-walkthrough-start -->"
|
||||
END_MARKER = "<!-- kandev-pr-walkthrough-end -->"
|
||||
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
PUBLIC_SHA_LENGTH = 12
|
||||
UNCHANGED = 3
|
||||
|
||||
|
||||
@@ -49,7 +50,10 @@ def validate_url(url: str, pr_number: int, head_sha: str) -> None:
|
||||
raise ValueError("pull request number must be positive")
|
||||
if not SHA_PATTERN.fullmatch(head_sha):
|
||||
raise ValueError("head SHA must be 40 lowercase hexadecimal characters")
|
||||
expected = f"https://walkthrough.kandev.ai/pr/{pr_number}/{head_sha}.html"
|
||||
expected = (
|
||||
"https://walkthrough.kandev.ai/"
|
||||
f"pr/{pr_number}/{head_sha[:PUBLIC_SHA_LENGTH]}.html"
|
||||
)
|
||||
if url != expected:
|
||||
raise ValueError(f"expected walkthrough URL {expected}")
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ import unittest
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "scripts" / "pr-walkthrough-pr-body"
|
||||
PR_NUMBER = 2906
|
||||
HEAD_SHA = "a" * 40
|
||||
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{HEAD_SHA}.html"
|
||||
HEAD_SHA = "0123456789abcdef0123456789abcdef01234567"
|
||||
SHORT_HEAD_SHA = HEAD_SHA[:12]
|
||||
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{SHORT_HEAD_SHA}.html"
|
||||
START = "<!-- kandev-pr-walkthrough-start -->"
|
||||
END = "<!-- kandev-pr-walkthrough-end -->"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user