0ee0106284c7

match Kepr 2's repo page: slow glowing ticker, drifting timeline canvas, log in link, koh steal button, inline panels, four-row scrolling history, HEAD save picker, search button, popup file viewer, linked breadcrumbs, plain cards, no repeated counts; pin koh3 3.2.24 (v3.0.99)

time
2026-09-29 17:55 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
match kepr 2 repo page
parent
bef1b9ecd957
13 files package.json+1 −1public/css/kepr.css+385 −90public/js/kepr.js+492 −27src/pages/atlas.ts+56 −23src/pages/files.ts+9 −3src/pages/layout.ts+1 −1src/pages/library.ts+4 −28src/pages/repo.ts+62 −30src/pages/timeline.ts+5 −4src/server/login.ts+1 −1tests/front.test.ts+7 −7tests/koh-version.txt+1 −1tests/pages.test.ts+13 −26
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.98",3+	"version": "3.0.99",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+385 −90
@@ -268,6 +268,42 @@268268 	color: var(--accent);269269 	text-decoration: none;270270 }271+.repo-tab.active {272+	color: var(--accent);273+	border-bottom-color: var(--accent);274+}275+.repo-panel {276+	padding: 10px 0 16px;277+	animation: fadeIn 220ms ease both;278+}279+.repo-panel[hidden] {280+	display: none;281+}282+.repo-steal-btn {283+	display: block;284+	width: fit-content;285+	max-width: 100%;286+	font-family: var(--mono);287+	font-size: 0.75rem;288+	color: var(--text-muted);289+	background: var(--bg-surface);290+	border: 1px solid var(--border);291+	border-radius: 3px;292+	padding: 5px 11px;293+	cursor: pointer;294+	letter-spacing: 0.03em;295+	white-space: nowrap;296+	overflow: hidden;297+	text-overflow: ellipsis;298+	transition:299+		color 120ms,300+		border-color 120ms;301+}302+.repo-steal-btn:hover,303+.repo-steal-btn.copied {304+	color: var(--accent);305+	border-color: var(--accent-dim);306+}271307 .install-lines {272308 	display: flex;273309 	flex-direction: column;@@ -397,6 +433,12 @@397433 	fill: var(--accent-bright);398434 	fill-opacity: 1;399435 }436+.timeline-canvas {437+	display: block;438+	margin: 18px 0 4px;439+	cursor: grab;440+	touch-action: pan-y;441+}400442 .timeline-legend {401443 	margin: 0 0 14px;402444 	font-size: 0.625rem;@@ -483,6 +525,13 @@483525 	color: var(--text-muted);484526 	white-space: nowrap;485527 }528+/* Kepr 2's history: four rows at a time, the rest a scroll away. */529+.save-list {530+	max-height: 180px;531+	overflow-y: auto;532+	scrollbar-width: thin;533+	scrollbar-color: var(--border) transparent;534+}486535 .pager {487536 	display: flex;488537 	justify-content: space-between;@@ -498,19 +547,155 @@498547 499548 /* ----- atlas -------------------------------------------------------------- */500549 501-.atlas-where {550+.atlas-bar {551+	display: flex;552+	align-items: center;553+	flex-wrap: wrap;554+	gap: 8px 14px;502555 	margin: 10px 0 8px;503556 	font-size: 0.625rem;557+}558+.atlas-picker {559+	position: relative;560+}561+.atlas-picker [hidden] {562+	display: none;563+}564+.atlas-ref-tab {565+	list-style: none;566+	cursor: pointer;567+	padding: 4px 8px;568+	background: var(--bg);569+	border: 1px solid rgba(200, 154, 90, 0.45);570+	border-radius: 2px;571+	font-size: 0.625rem;572+	color: var(--accent);573+}574+.atlas-ref-tab::-webkit-details-marker {575+	display: none;576+}577+.atlas-ref-tab:hover {578+	color: var(--accent-dim);579+}580+.atlas-picker-popover {581+	position: absolute;582+	top: calc(100% + 6px);583+	left: 0;584+	z-index: 50;585+	display: flex;586+	flex-direction: column;587+	width: min(420px, 90vw);588+	max-height: 480px;589+	background: var(--bg);590+	border: 1px solid var(--text-faint);591+	box-shadow: 0 18px 48px rgba(0, 0, 0, 0.48);592+}593+.atlas-picker-tabs {594+	display: flex;595+	border-bottom: 1px solid var(--bg-elevated);596+}597+.atlas-picker-tabs button {598+	flex: 1;599+	padding: 7px 10px;600+	background: transparent;601+	border: 0;602+	border-right: 1px solid var(--bg-elevated);603+	font: inherit;604+	font-size: 0.625rem;504605 	color: var(--text-faint);505-	letter-spacing: 0.04em;606+	cursor: pointer;506607 }507-.atlas-where a {608+.atlas-picker-tabs button:last-child {609+	border-right: 0;610+}611+.atlas-picker-tabs button:hover {612+	color: var(--text-muted);613+}614+.atlas-picker-tabs button.active {508615 	color: var(--accent);616+	background: rgba(200, 154, 90, 0.06);509617 }618+.atlas-picker-filter {619+	padding: 8px 10px;620+	background: var(--bg);621+	border: 0;622+	border-bottom: 1px solid var(--bg-elevated);623+	outline: none;624+	font-size: 0.6875rem;625+	color: var(--text-heading);626+}627+.atlas-picker-filter::placeholder {628+	color: var(--text-faint);629+}630+.atlas-picker-list {631+	flex: 1 1 auto;632+	min-height: 0;633+	overflow: auto;634+	scrollbar-width: thin;635+	scrollbar-color: var(--border) transparent;636+}637+.atlas-picker-row {638+	display: grid;639+	grid-template-columns: 80px 1fr auto;640+	align-items: center;641+	gap: 8px;642+	padding: 6px 10px;643+	border-bottom: 1px solid rgba(42, 32, 24, 0.4);644+	color: var(--text-muted);645+}646+.atlas-picker-row:hover {647+	background: rgba(200, 154, 90, 0.06);648+	text-decoration: none;649+}650+.atlas-picker-row.active {651+	background: rgba(200, 154, 90, 0.1);652+}653+.atlas-picker-row:hover *,654+.atlas-picker-row.active * {655+	color: var(--accent);656+}657+.atlas-picker-id,658+.atlas-picker-age {659+	font-family: var(--mono);660+	font-size: 0.5625rem;661+	color: var(--text-faint);662+	white-space: nowrap;663+	overflow: hidden;664+	text-overflow: ellipsis;665+}666+.atlas-picker-msg {667+	font-size: 0.6875rem;668+	white-space: nowrap;669+	overflow: hidden;670+	text-overflow: ellipsis;671+}672+.atlas-head-link {673+	color: var(--accent);674+}675+.atlas-search-btn {676+	margin-left: auto;677+	padding: 4px 8px;678+	background: transparent;679+	border: 1px solid var(--border);680+	border-radius: 2px;681+	font: inherit;682+	font-size: 0.625rem;683+	color: var(--text-faint);684+	cursor: pointer;685+}686+.atlas-search-btn:hover {687+	color: var(--accent);688+	border-color: var(--accent-dim);689+}690+.atlas-search-btn[hidden],691+.atlas-filter[hidden] {692+	display: none;693+}510694 .atlas-filter {511-	width: 100%;512-	margin: 0 0 8px;513-	padding: 7px 10px;695+	width: 220px;696+	max-width: 100%;697+	margin-left: auto;698+	padding: 5px 10px;514699 	background: transparent;515700 	border: 1px solid var(--border);516701 	border-radius: 3px;@@ -629,34 +814,13 @@629814 	margin: 0;630815 	color: var(--text);631816 }632-.atlas-facts a,633-.atlas-recent a {817+.atlas-facts a {634818 	color: var(--accent);635819 }636820 .atlas-info-desc {637821 	font-size: 0.8125rem;638822 	color: var(--text-muted);639823 }640-.atlas-recent {641-	list-style: none;642-	margin: 12px 0 0;643-	padding: 10px 0 0;644-	border-top: 1px solid var(--border);645-	font-size: 0.75rem;646-}647-.atlas-recent li {648-	margin-bottom: 6px;649-	line-height: 1.5;650-}651-.atlas-recent .mono,652-.atlas-when {653-	font-size: 0.625rem;654-}655-.atlas-when {656-	color: var(--text-faint);657-	white-space: nowrap;658-}659-660824 /* ----- README and rendered Markdown --------------------------------------- */661825 662826 .repo-details {@@ -1368,50 +1532,60 @@13681532 .ticker-row {13691533 	white-space: nowrap;13701534 	line-height: 1.2;1535+	opacity: var(--o);1536+	animation: ticker-glow 83s var(--glow-at) ease-in-out infinite;13711537 }1538+/* The drift's duration is set by kepr.js from the track's width, so every row keeps Kepr 2's1539+   pace in px/s (--speed) however long its messages are; this is the no-JavaScript fallback. */13721540 .ticker-track {13731541 	display: inline-flex;13741542 	gap: 64px;13751543 	padding-right: 64px;1376-	animation: ticker-drift var(--drift) linear infinite;1544+	animation: ticker-drift 1500s linear infinite;13771545 }1378-/* Kepr 2's depth bands: nearer rows are larger, brighter and faster. */1546+/* Kepr 2's depth bands: nearer rows are larger, brighter and faster. Each row takes its turn at1547+   the glow, a few seconds out of every 83, spaced so one or two glow at a time. */13791548 .ticker-row:nth-child(1) {13801549 	font-size: 9px;1381-	opacity: 0.05;1382-	--drift: 260s;1550+	--o: 0.05;1551+	--speed: 12;1552+	--glow-at: -5s;13831553 }13841554 .ticker-row:nth-child(2) {13851555 	font-size: 18px;1386-	opacity: 0.1;1387-	--drift: 150s;1388-	animation: ticker-glow 29s 4s ease-in-out infinite;1556+	--o: 0.1;1557+	--speed: 23;1558+	--glow-at: -41s;13891559 }13901560 .ticker-row:nth-child(3) {13911561 	font-size: 7px;1392-	opacity: 0.04;1393-	--drift: 320s;1562+	--o: 0.04;1563+	--speed: 9;1564+	--glow-at: -77s;13941565 }13951566 .ticker-row:nth-child(4) {13961567 	font-size: 13px;1397-	opacity: 0.07;1398-	--drift: 200s;1399-	animation: ticker-glow 37s 17s ease-in-out infinite;1568+	--o: 0.07;1569+	--speed: 17;1570+	--glow-at: -29s;14001571 }14011572 .ticker-row:nth-child(5) {14021573 	font-size: 22px;1403-	opacity: 0.12;1404-	--drift: 120s;1574+	--o: 0.12;1575+	--speed: 29;1576+	--glow-at: -65s;14051577 }14061578 .ticker-row:nth-child(6) {14071579 	font-size: 8px;1408-	opacity: 0.045;1409-	--drift: 290s;1580+	--o: 0.045;1581+	--speed: 10;1582+	--glow-at: -17s;14101583 }14111584 .ticker-row:nth-child(7) {14121585 	font-size: 15px;1413-	opacity: 0.08;1414-	--drift: 170s;1586+	--o: 0.08;1587+	--speed: 20;1588+	--glow-at: -53s;14151589 }14161590 @keyframes ticker-drift {14171591 	to {@@ -1420,13 +1594,15 @@14201594 }14211595 @keyframes ticker-glow {14221596 	0%,1423-	88%,1597+	10%,14241598 	100% {14251599 		color: var(--text-secondary);1600+		opacity: var(--o);1601+		text-shadow: none;14261602 	}1427-	94% {1603+	5% {14281604 		color: var(--accent);1429-		opacity: 0.3;1605+		opacity: calc(var(--o) * 3.5);14301606 		text-shadow: 0 0 3px var(--accent-tint-12);14311607 	}14321608 }@@ -1568,47 +1744,11 @@15681744 	-webkit-box-orient: vertical;15691745 	overflow: hidden;15701746 }1571-.repo-card-ticks {1572-	display: block;1573-	width: 100%;1574-	height: 36px;1575-	margin: auto 0 10px;1576-	padding-top: 12px;1577-	box-sizing: content-box;1578-	border-bottom: 1px solid var(--bg-surface);1747+.repo-card-cat {1748+	margin: 10px 0 0;1749+	font-size: 0.9375rem;1750+	color: var(--text);15791751 }1580-.repo-card-ticks line {1581-	stroke: var(--accent);1582-	stroke-width: 1;1583-	opacity: 0.45;1584-	vector-effect: non-scaling-stroke;1585-}1586-.repo-card:hover .repo-card-ticks line {1587-	opacity: 0.8;1588-}1589-.repo-card-foot {1590-	display: flex;1591-	align-items: center;1592-	flex-wrap: wrap;1593-	gap: 6px;1594-	font-family: var(--mono);1595-	font-size: 0.625rem;1596-	color: var(--text-muted);1597-}1598-.cat-chip,1599-.repo-card-lang {1600-	padding: 1px 6px;1601-	border: 1px solid var(--border);1602-	border-radius: 2px;1603-	background: var(--bg-surface);1604-}1605-.cat-chip {1606-	color: var(--accent-dim);1607-}1608-.repo-card-when {1609-	margin-left: auto;1610-	color: var(--text-faint);1611-}16121752 16131753 /* ----- profile ------------------------------------------------------------ */16141754 @@ -1915,6 +2055,18 @@19152055 	}19162056 }19172057 @media (max-width: 768px) {2058+	.fv-header {2059+		flex-wrap: wrap;2060+		height: auto;2061+		padding: 8px 12px;2062+		gap: 8px 12px;2063+	}2064+	.fv-body {2065+		grid-template-columns: minmax(0, 1fr);2066+	}2067+	.fv-detail {2068+		display: none;2069+	}19182070 	.ticker {19192071 		height: 226px;19202072 		margin-top: -88px;@@ -1939,6 +2091,147 @@19392091 	}19402092 }19412093 2094+/* ----- file viewer (Kepr 2's Atlas modal) -------------------------------- */2095+2096+body:has(> .fv-overlay) {2097+	overflow: hidden;2098+}2099+.fv-overlay {2100+	position: fixed;2101+	inset: 0;2102+	z-index: 2000;2103+	display: flex;2104+	flex-direction: column;2105+	background: rgba(8, 7, 5, 0.97);2106+	outline: none;2107+	animation: fv-in 200ms ease both;2108+}2109+@keyframes fv-in {2110+	from {2111+		opacity: 0;2112+		transform: translateY(6px) scale(0.97);2113+	}2114+}2115+.fv-header {2116+	display: flex;2117+	align-items: center;2118+	gap: 16px;2119+	flex-shrink: 0;2120+	height: 48px;2121+	padding: 0 20px;2122+	background: var(--bg);2123+	border-bottom: 1px solid var(--bg-elevated);2124+}2125+.fv-close {2126+	background: none;2127+	border: 0;2128+	padding: 0 4px;2129+	font-size: 1.375rem;2130+	line-height: 1;2131+	color: var(--text-faint);2132+	cursor: pointer;2133+}2134+.fv-close:hover {2135+	color: var(--accent);2136+}2137+.fv-title {2138+	display: flex;2139+	align-items: baseline;2140+	gap: 8px;2141+	flex: 1;2142+	min-width: 0;2143+}2144+.fv-name {2145+	font-size: 1.25rem;2146+	color: var(--text-heading);2147+	white-space: nowrap;2148+}2149+.fv-path {2150+	font-size: 0.625rem;2151+	color: var(--text-faint);2152+	white-space: nowrap;2153+	overflow: hidden;2154+	text-overflow: ellipsis;2155+}2156+.fv-controls,2157+.fv-nav {2158+	display: flex;2159+	align-items: center;2160+	gap: 4px;2161+	flex-shrink: 0;2162+}2163+.fv-ctrl-btn {2164+	background: transparent;2165+	border: 1px solid rgba(200, 154, 90, 0.2);2166+	border-radius: 2px;2167+	padding: 3px 7px;2168+	font: inherit;2169+	font-size: 0.625rem;2170+	color: var(--text-faint);2171+	cursor: pointer;2172+	transition:2173+		color 100ms,2174+		border-color 100ms;2175+}2176+.fv-ctrl-btn:hover:not(:disabled),2177+.fv-ctrl-btn.active {2178+	color: var(--accent);2179+	border-color: rgba(200, 154, 90, 0.5);2180+	text-decoration: none;2181+}2182+.fv-ctrl-btn:disabled {2183+	opacity: 0.3;2184+	cursor: default;2185+}2186+.fv-pos {2187+	min-width: 48px;2188+	text-align: center;2189+	font-size: 0.625rem;2190+	color: var(--text-faint);2191+}2192+.fv-body {2193+	flex: 1;2194+	min-height: 0;2195+	display: grid;2196+	grid-template-columns: minmax(0, 1fr) 280px;2197+}2198+.fv-source {2199+	min-height: 0;2200+	overflow: auto;2201+	padding: 16px 28px 32px;2202+	font-family: var(--mono);2203+	font-size: 0.6875rem;2204+	color: var(--text-faint);2205+}2206+.fv-source .file-view {2207+	margin: -16px -28px 0;2208+	border: 0;2209+	background: none;2210+	overflow: visible;2211+}2212+.fv-source .code {2213+	font-size: var(--fv-size);2214+	color: var(--text);2215+}2216+.fv-wrap .fv-source .lc {2217+	white-space: pre-wrap;2218+	overflow-wrap: anywhere;2219+}2220+.fv-source .file-markdown {2221+	font-family: var(--sans);2222+	font-size: calc(var(--fv-size) + 2px);2223+	color: var(--text);2224+}2225+.fv-detail .atlas-info-desc {2226+	font-size: 0.8125rem;2227+}2228+.fv-detail {2229+	overflow-y: auto;2230+	padding: 18px 20px 24px;2231+	background: var(--bg);2232+	border-left: 1px solid var(--bg-elevated);2233+}2234+19422235 /* Last in the file, after every animation it stops, so it wins without !important. */19432236 @media (prefers-reduced-motion: reduce) {19442237 	.tri-outer,@@ -1961,7 +2254,9 @@19612254 	.headline-cloud,19622255 	.repo-card,19632256 	.profile-username,1964-	.profile-event {2257+	.profile-event,2258+	.fv-overlay,2259+	.repo-panel {19652260 		animation: none;19662261 	}19672262 	.hex-col {

view file

public/js/kepr.js+492 −27
@@ -1,31 +1,204 @@11 // Kepr's browser islands (§18). Every page is complete without this file; it only adds the2-// styled timeline tooltip, copy buttons, line-range highlighting, the Atlas panel and filter,3-// and the profile backdrop's glow.2+// ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling,3+// line-range highlighting, the Atlas panel, picker, filter and file viewer, and the profile's4+// glow.45 5-function timelineTips() {6-	const dots = document.querySelectorAll(".timeline .dot");7-	if (dots.length === 0) return;6+// Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.7+const KINDS = [8+	[/\b(release|version|bump|tag)\b/i, "accent", 1],9+	[/\b(fix|bug|patch|issue|crash|error|regress|revert)\b/i, "accent-dim", 0.8],10+	[/\b(add|new|implement|feat|introduce|wire|support)\b/i, "accent", 1],11+	[12+		/\b(refactor|restructure|reorganize|extract|move|rename|split|cleanup|clean up)\b/i,13+		"text",14+		0.85,15+	],16+	[/\b(perf|optim|speed|fast|slow|cache|memory|alloc)\b/i, "accent", 0.9],17+	[/\b(test|spec|coverage|assert)\b/i, "text-secondary", 0.74],18+	[/\b(doc|readme|comment|example|guide)\b/i, "text-secondary", 0.7],19+	[/\b(polish|style|format|lint|whitespace|typo|wording|copy)\b/i, "text-secondary", 0.66],20+	[/\b(chore|deps|update|upgrade|build|ci|config)\b/i, "text-secondary", 0.62],21+];22+const DAY = 86400;23+24+// Kepr 2's timeline (canvas.js keprThread): the saves on a strip three views wide that drifts25+// slowly and loops, dots pushed apart so none overlap. Hovering pauses it and shows the save,26+// dragging scrubs, a click opens the save. Under reduced motion it moves only when dragged.27+function timeline() {28+	const svg = document.querySelector("svg.timeline");29+	if (!svg) return;30+	const css = getComputedStyle(document.documentElement);31+	const saves = [...svg.querySelectorAll(".dot")]32+		.map((dot) => {33+			const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n");34+			const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [35+				null,36+				"text-secondary",37+				0.8,38+			];39+			const fill = css.getPropertyValue(`--${color}`).trim();40+			return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.t };41+		})42+		.sort((a, b) => a.t - b.t);43+	if (saves.length === 0) return;44+	const canvas = document.createElement("canvas");45+	canvas.className = "timeline-canvas";46+	canvas.setAttribute("role", "img");47+	canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));48+	svg.replaceWith(canvas);49+	const ctx = canvas.getContext("2d");850 	const tip = document.createElement("div");951 	tip.className = "timeline-tip";1052 	tip.hidden = true;1153 	document.body.append(tip);12-	for (const dot of dots) {13-		// The SVG <title> is the no-JavaScript tooltip; lift it out so the two never show together.14-		const title = dot.querySelector("title");15-		const [id, message, meta] = (title?.textContent ?? "").split("\n");16-		title?.remove();17-		dot.setAttribute("aria-label", `${id} ${message}`);18-		dot.addEventListener("pointerenter", (event) => {19-			tip.replaceChildren(line("tip-id", id), line("tip-msg", message), line("tip-meta", meta));20-			tip.hidden = false;54+	const reduce = matchMedia("(prefers-reduced-motion: reduce)");55+	const [first, last] = [saves[0].t, saves[saves.length - 1].t];56+	const end = Math.max(Date.now() / 1000, last) + DAY;57+	const start = Math.min(first, Date.now() / 1000) - Math.max(0, 7 * DAY - (end - first));58+	let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];59+	const xOf = (t) => (strip * (t - start)) / (end - start);60+	const resize = () => {61+		width = canvas.parentElement.getBoundingClientRect().width;62+		height = Math.min(320, Math.max(120, 120 + Math.floor(Math.log2(saves.length + 1) * 30)));63+		canvas.width = Math.round(width * devicePixelRatio);64+		canvas.height = Math.round(height * devicePixelRatio);65+		canvas.style.width = `${width}px`;66+		canvas.style.height = `${height}px`;67+		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);68+		strip = Math.max(width * 3, saves.length * 48);69+		radius = Math.min(5, height * 0.022);70+		let previous = Number.NEGATIVE_INFINITY;71+		for (const save of saves) {72+			save.x = Math.max(xOf(save.t), previous + radius + 8);73+			previous = save.x;74+		}75+		offset = Math.max(0, previous - width * 0.82);76+	};77+	// Every visible copy of each x on the looping strip.78+	const onScreen = (x, margin) => {79+		const shift = ((offset % strip) + strip) % strip;80+		return [-1, 0, 1]81+			.map((k) => x - shift + k * strip)82+			.filter((sx) => sx > -margin && sx < width + margin);83+	};84+	const hit = (event) => {85+		const box = canvas.getBoundingClientRect();86+		const [mx, my] = [event.clientX - box.left, event.clientY - box.top];87+		if (Math.abs(my - height / 2) > radius + 7) return null;88+		return (89+			saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null90+		);91+	};92+	let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [93+		null,94+		null,95+		0,96+		0,97+		0,98+		0,99+		0,100+		0,101+	];102+	const hover = (save, event) => {103+		if (save !== hovered) {104+			hovered = save;105+			tip.hidden = save === null;106+			if (save)107+				tip.replaceChildren(108+					line("tip-id", save.id),109+					line("tip-msg", save.message),110+					line("tip-meta", save.meta),111+				);112+		}113+		if (save) {21114 			const left = event.clientX + 14;22-			tip.style.left = `${left + 340 > window.innerWidth ? Math.max(8, event.clientX - 354) : left}px`;115+			tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;23116 			tip.style.top = `${event.clientY - 10}px`;24-		});25-		dot.addEventListener("pointerleave", () => {26-			tip.hidden = true;27-		});28-	}117+		}118+		canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";119+	};120+	const draw = (time) => {121+		const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;122+		lastFrame = time;123+		if (drag === null && hovered === null) {124+			if (Math.abs(velocity) > 0.5) {125+				offset -= velocity;126+				velocity *= 0.92;127+			} else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;128+		}129+		const y = height / 2;130+		ctx.clearRect(0, 0, width, height);131+		ctx.lineWidth = 1;132+		ctx.strokeStyle = css.getPropertyValue("--border");133+		const years = [new Date(start * 1000).getFullYear(), new Date(end * 1000).getFullYear()];134+		for (let year = years[0]; year <= years[1]; year++) {135+			for (const x of onScreen(xOf(new Date(year, 0, 1) / 1000), 0)) {136+				ctx.beginPath();137+				ctx.moveTo(x, height * 0.1);138+				ctx.lineTo(x, height * 0.9);139+				ctx.stroke();140+			}141+		}142+		ctx.globalAlpha = 0.7;143+		ctx.strokeStyle = css.getPropertyValue("--text-muted");144+		ctx.setLineDash([4, 4]);145+		ctx.beginPath();146+		ctx.moveTo(0, y);147+		ctx.lineTo(width, y);148+		ctx.stroke();149+		ctx.setLineDash([]);150+		ctx.font = '10px "Martian Mono", monospace';151+		ctx.textAlign = "center";152+		for (const save of saves) {153+			for (const x of onScreen(save.x, 10)) {154+				ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;155+				ctx.fillStyle = save.fill;156+				ctx.beginPath();157+				ctx.arc(x, y, radius, 0, Math.PI * 2);158+				ctx.fill();159+				if (hovered === save) {160+					ctx.fillStyle = css.getPropertyValue("--accent");161+					ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);162+				}163+			}164+		}165+		ctx.globalAlpha = 1;166+		requestAnimationFrame(draw);167+	};168+	canvas.addEventListener("pointerdown", (event) => {169+		[drag, dragFrom, downX, lastX, velocity] = [170+			event.clientX,171+			offset,172+			event.clientX,173+			event.clientX,174+			0,175+		];176+		canvas.setPointerCapture(event.pointerId);177+	});178+	canvas.addEventListener("pointermove", (event) => {179+		if (drag !== null) {180+			offset = dragFrom - (event.clientX - drag);181+			velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;182+			lastX = event.clientX;183+		}184+		if (event.pointerType === "mouse") hover(hit(event), event);185+	});186+	const release = () => {187+		drag = null;188+		resumeAt = performance.now() + 900;189+	};190+	canvas.addEventListener("pointerup", (event) => {191+		release();192+		const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);193+		if (save) location.href = save.href;194+	});195+	canvas.addEventListener("pointercancel", release);196+	canvas.addEventListener("pointerleave", (event) => {197+		if (drag === null) hover(null, event);198+	});199+	addEventListener("resize", resize);200+	resize();201+	requestAnimationFrame(draw);29202 }30203 31204 function line(className, text) {@@ -35,21 +208,116 @@35208 	return div;36209 }37210 38-function copyButtons() {39-	for (const button of document.querySelectorAll(".copy-btn[data-copy]")) {211+function copyButtons(root) {212+	for (const button of root.querySelectorAll("button[data-copy]")) {213+		const label = button.textContent;40214 		button.hidden = false;41215 		button.addEventListener("click", async () => {42216 			await navigator.clipboard.writeText(button.dataset.copy);43217 			button.textContent = "copied";44218 			button.classList.add("copied");45219 			setTimeout(() => {46-				button.textContent = "copy";220+				button.textContent = label;47221 				button.classList.remove("copied");48222 			}, 1400);49223 		});50224 	}51225 }52226 227+// A Kepr page's <main>, its links made absolute so they point where they did on their own page;228+// null if it could not be had, and the caller then goes to the page itself.229+async function fetchMain(url) {230+	try {231+		const response = await fetch(url);232+		if (!response.ok) return null;233+		const page = new DOMParser().parseFromString(await response.text(), "text/html");234+		const main = page.querySelector("main");235+		for (const link of main.querySelectorAll("a[href]:not([href^='#'])")) {236+			link.href = new URL(link.getAttribute("href"), url).href;237+		}238+		return main;239+	} catch {240+		return null;241+	}242+}243+244+// A click the browser should handle itself: a new tab or window, or a download.245+function modified(event) {246+	return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;247+}248+249+// §21.9's panels open inline under the tabs, as Kepr 2's did: the panel page without its heading.250+// A link back to the same page (the issue tabs) loads in place; any other link navigates.251+function panels() {252+	const host = document.querySelector(".repo-panel");253+	if (!host) return;254+	let open = null;255+	let wanted = "";256+	const load = async (url) => {257+		wanted = url;258+		const main = await fetchMain(url);259+		if (wanted !== url) return;260+		if (!main) {261+			location.href = url;262+			return;263+		}264+		main.querySelector(".breadcrumb")?.remove();265+		main.querySelector(".file-page-title")?.remove();266+		host.replaceChildren(...main.childNodes);267+		copyButtons(host);268+		host.hidden = false;269+	};270+	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {271+		tab.addEventListener("click", (event) => {272+			if (modified(event)) return;273+			event.preventDefault();274+			open?.classList.remove("active");275+			if (open === tab) {276+				open = null;277+				wanted = "";278+				host.hidden = true;279+				return;280+			}281+			open = tab;282+			tab.classList.add("active");283+			load(tab.href);284+		});285+	}286+	host.addEventListener("click", (event) => {287+		const link = event.target.closest("a[href]");288+		if (!link || !open || modified(event)) return;289+		if (new URL(link.href).pathname !== new URL(open.href).pathname) return;290+		event.preventDefault();291+		load(link.href);292+	});293+}294+295+// History and the Atlas's save picker hold one page of saves; scrolling to the end of either296+// brings in the next page's rows. The pager is for pages without JavaScript.297+function saveLists() {298+	for (const list of document.querySelectorAll(".save-list")) {299+		const observer = new IntersectionObserver(300+			async ([entry]) => {301+				const older = list.querySelector(".pager-older");302+				if (!entry?.isIntersecting || !older) return;303+				observer.disconnect();304+				const main = await fetchMain(older.href);305+				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);306+				if (!next) return;307+				next.querySelector(".pager a:not(.pager-older)")?.remove();308+				list.querySelector(".pager").replaceWith(...next.childNodes);309+				watch();310+			},311+			{ root: list },312+		);313+		const watch = () => {314+			const older = list.querySelector(".pager-older");315+			if (older) observer.observe(older);316+		};317+		watch();318+	}319+}320+53321 // `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.54322 function lineRanges() {55323 	const code = document.querySelector(".code");@@ -90,8 +358,11 @@90358 	const files = [...root.querySelectorAll(".atlas-file")];91359 	const key = `kepr:atlas:${root.dataset.repo}`;92360 	const opened = new Set(remembered(key));361+	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.362+	const unfold = new URLSearchParams(location.search).get("open");363+	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);93364 	for (const folder of folders) {94-		folder.open = opened.has(folder.dataset.path);365+		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);95366 		folder.addEventListener("toggle", () => {96367 			if (filter.value.trim()) return;97368 			if (folder.open) opened.add(folder.dataset.path);@@ -112,6 +383,19 @@112383 		});113384 		row.addEventListener("focus", () => show(row));114385 	}386+	const viewer = fileViewer(root);387+	for (const file of files) {388+		file.addEventListener("click", (event) => {389+			if (modified(event)) return;390+			event.preventDefault();391+			viewer(file);392+		});393+	}394+	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;395+	if (target) {396+		show(target);397+		target.scrollIntoView({ block: "center" });398+	}115399 	filter.addEventListener("input", () => {116400 		const query = filter.value.trim().toLowerCase();117401 		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);@@ -120,10 +404,177 @@120404 			folder.open = query !== "" || opened.has(folder.dataset.path);121405 		}122406 	});123-	filter.hidden = false;407+	// The filter waits behind a search button, and folds back into it when left empty.408+	const search = document.querySelector(".atlas-search-btn");409+	search.addEventListener("click", () => {410+		search.hidden = true;411+		filter.hidden = false;412+		filter.focus();413+	});414+	filter.addEventListener("blur", () => {415+		if (filter.value.trim() !== "") return;416+		filter.hidden = true;417+		search.hidden = false;418+	});419+	search.hidden = false;124420 	panel.hidden = false;125421 }126422 423+// Kepr 2's Atlas picker: saves or versions, filtered as you type; a click outside or Escape424+// closes it.425+function atlasPicker() {426+	const picker = document.querySelector(".atlas-picker");427+	if (!picker) return;428+	const tabs = picker.querySelector(".atlas-picker-tabs");429+	const filter = picker.querySelector(".atlas-picker-filter");430+	const lists = [...picker.querySelectorAll(".atlas-picker-list")];431+	let shown = lists[0];432+	const narrow = () => {433+		const query = filter.value.trim().toLowerCase();434+		for (const row of shown.children) row.hidden = query !== "" && !row.dataset.q.includes(query);435+	};436+	for (const tab of tabs.children) {437+		tab.addEventListener("click", () => {438+			for (const each of tabs.children) each.classList.toggle("active", each === tab);439+			shown = lists.find((list) => list.dataset.tab === tab.dataset.tab);440+			for (const list of lists) list.hidden = list !== shown;441+			filter.placeholder = `filter ${tab.dataset.tab}`;442+			filter.value = "";443+			narrow();444+			filter.focus();445+		});446+	}447+	filter.addEventListener("input", narrow);448+	picker.addEventListener("toggle", () => {449+		if (picker.open) filter.focus();450+	});451+	document.addEventListener("click", (event) => {452+		if (!picker.contains(event.target)) picker.open = false;453+	});454+	picker.addEventListener("keydown", (event) => {455+		if (event.key === "Escape") picker.open = false;456+	});457+	tabs.hidden = false;458+	filter.hidden = false;459+}460+461+// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the462+// row's detail panel beside it, and controls for text size, wrapping, raw, download and the463+// neighbouring files. Escape closes it; the arrow keys step through the files.464+function fileViewer(root) {465+	let [row, size, wrap] = [null, 12, false];466+	const overlay = element("div", "fv-overlay");467+	overlay.tabIndex = -1;468+	overlay.setAttribute("role", "dialog");469+	overlay.setAttribute("aria-modal", "true");470+	const close = () => {471+		overlay.remove();472+		row?.focus();473+		row = null;474+	};475+	const step = (by) => {476+		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);477+		const next = rows[rows.indexOf(row) + by];478+		if (next) open(next);479+	};480+	const restyle = () => {481+		overlay.style.setProperty("--fv-size", `${size}px`);482+		overlay.classList.toggle("fv-wrap", wrap);483+	};484+	const open = async (file, url = file.href) => {485+		row = file;486+		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);487+		const index = rows.indexOf(file);488+		const detail = file.nextElementSibling.content.cloneNode(true);489+		const name = detail.querySelector(".atlas-info-name").textContent;490+		const path = detail.querySelector(".atlas-info-path").textContent;491+		const raw = new URL(file.href);492+		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");493+		const button = (text, action, className = "fv-ctrl-btn") => {494+			const b = element("button", className, text);495+			b.type = "button";496+			b.addEventListener("click", action);497+			return b;498+		};499+		const link = (text, download) => {500+			const a = element("a", "fv-ctrl-btn", text);501+			a.href = raw.href;502+			if (download) a.download = name;503+			return a;504+		};505+		const source = element("div", "fv-source", "loading…");506+		const panel = element("aside", "fv-detail");507+		panel.append(detail);508+		const header = element("div", "fv-header");509+		const title = element("div", "fv-title");510+		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));511+		const controls = element("div", "fv-controls mono");512+		controls.append(513+			button("A−", () => {514+				size = Math.max(9, size - 1);515+				restyle();516+			}),517+			button("A+", () => {518+				size = Math.min(20, size + 1);519+				restyle();520+			}),521+			button("⇌ wrap", (event) => {522+				wrap = !wrap;523+				event.currentTarget.classList.toggle("active", wrap);524+				restyle();525+			}),526+			link("raw", false),527+			link("download", true),528+		);529+		controls.children[2].classList.toggle("active", wrap);530+		const nav = element("div", "fv-nav mono");531+		const back = button("←", () => step(-1), "fv-ctrl-btn");532+		const forward = button("→", () => step(1), "fv-ctrl-btn");533+		back.disabled = index <= 0;534+		forward.disabled = index >= rows.length - 1;535+		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);536+		header.append(button("×", close, "fv-close"), title, controls, nav);537+		const body = element("div", "fv-body");538+		body.append(source, panel);539+		overlay.replaceChildren(header, body);540+		restyle();541+		if (!overlay.isConnected) document.body.append(overlay);542+		overlay.focus();543+		const main = await fetchMain(url);544+		if (row !== file) return;545+		if (!main) {546+			location.href = url;547+			return;548+		}549+		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();550+		main.querySelector(".file-meta:not(:last-child)")?.remove();551+		source.replaceChildren(...main.childNodes);552+	};553+	overlay.addEventListener("keydown", (event) => {554+		if (event.key === "Escape") close();555+		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {556+			event.preventDefault();557+			step(event.key === "ArrowLeft" ? -1 : 1);558+		}559+	});560+	// The Markdown view's rendered/source toggle points at the file page: switch in place.561+	overlay.addEventListener("click", (event) => {562+		const link = event.target.closest(".fv-source a[href]");563+		if (!link || !row || modified(event)) return;564+		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;565+		event.preventDefault();566+		open(row, link.href);567+	});568+	return open;569+}570+571+function element(tag, className, text) {572+	const node = document.createElement(tag);573+	node.className = className;574+	if (text !== undefined) node.textContent = text;575+	return node;576+}577+127578 // Storage can be missing, refused (private windows, blocked windows) or hold anything; only a128579 // list of folder paths is trusted, and otherwise the tree starts closed.129580 function remembered(key) {@@ -163,8 +614,22 @@163614 	update();164615 }165616 166-timelineTips();167-copyButtons();617+// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it618+// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.619+function tickerPace() {620+	for (const track of document.querySelectorAll(".ticker-track")) {621+		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));622+		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;623+	}624+}625+626+// Widths are only final once Martian Mono has loaded.627+document.fonts.ready.then(tickerPace);628+timeline();629+copyButtons(document);630+panels();631+saveLists();632+atlasPicker();168633 lineRanges();169634 atlas();170635 hexGlow();

view file

src/pages/atlas.ts+56 −23
@@ -1,5 +1,6 @@11 import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";22 import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";3+import type { Release } from "../releases/releases.ts";34 import { isText } from "./files.ts";45 import { type Html, html } from "./html.ts";56 import {@@ -58,9 +59,16 @@5859 /**5960  * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without6061  * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser61- * island shows on hover or select.62+ * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves and63+ * versions to view it at instead.6264  */63-export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {65+export function atlasSection(66+	repo: PublicRepo,67+	saveId: string,68+	at: string | null,69+	saves: Save[],70+	releases: Release[],71+): Html {6472 	const touches = fileTouches(repo.mirror, saveId);6573 	const foldersTouches = folderTouches(touches);6674 	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };@@ -87,19 +95,18 @@8795 		});8896 	}8997 	const view = { repo: repo.settings.name, at };90-	const where = at91-		? html`at <a href="/${92-				view.repo93-			}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`94-		: "HEAD";9598 	return html`<section class="repo-section" id="atlas">9699 <details class="repo-section-toggle" open>97100 <summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(98101 		entries.length,99102 		"file",100103 	)}</span></summary>101-<p class="atlas-where mono">${where}</p>104+<div class="atlas-bar">105+${picker(view, saves, releases)}106+${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}107+<button type="button" class="atlas-search-btn mono" hidden>search</button>102108 <input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>109+</div>103110 <div class="atlas" data-repo="${view.repo}">104111 <div class="atlas-tree">${children(root, view)}</div>105112 <aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>@@ -108,6 +115,45 @@108115 </section>`;109116 }110117 118+// Kepr 2's "explore through time": every save, newest first, and every version tied to a save119+// still on the line. The tabs and the filter are kepr.js's; without it, the saves list alone.120+function picker(view: View, saves: Save[], releases: Release[]): Html {121+	const ids = new Set(saves.map((save) => save.id));122+	const row = (id: string, label: string, message: string, age: string, active: boolean) =>123+		html`<a class="atlas-picker-row${active ? " active" : ""}" href="/${124+			view.repo125+		}?at=${id}#atlas" data-q="${`${label} ${message}`.toLowerCase()}"><span class="atlas-picker-id">${label}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">${age}</span></a>`;126+	return html`<details class="atlas-picker">127+<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>128+<div class="atlas-picker-popover">129+<div class="atlas-picker-tabs mono" hidden><button type="button" class="active" data-tab="saves">saves</button><button type="button" data-tab="versions">versions</button></div>130+<input class="atlas-picker-filter mono" type="search" placeholder="filter saves" aria-label="Filter saves" hidden>131+<div class="atlas-picker-list" data-tab="saves">${saves132+		.toReversed()133+		.map((save) =>134+			row(135+				save.id,136+				save.id,137+				save.message.split("\n")[0] as string,138+				`· ${relativeTime(save.created_at)}`,139+				save.id === view.at,140+			),141+		)}</div>142+<div class="atlas-picker-list" data-tab="versions" hidden>${releases143+		.filter((release) => ids.has(release.save))144+		.map((release) =>145+			row(146+				release.save,147+				release.version,148+				release.save,149+				`· ${relativeTime(release.publishedAt)}`,150+				release.save === view.at,151+			),152+		)}</div>153+</div>154+</details>`;155+}156+111157 function children(folder: AtlasFolder, view: View): Html[] {112158 	const byName = (a: { name: string }, b: { name: string }) =>113159 		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);@@ -134,7 +180,7 @@134180 		],135181 		description,136182 		base,137-	)}${recent(file.saves, view)}</template>`;183+	)}</template>`;138184 }139185 140186 function folderRow(folder: AtlasFolder, view: View): Html {@@ -162,7 +208,7 @@162208 		],163209 		description,164210 		base,165-	)}${recent(folder.saves, view)}</template>211+	)}</template>166212 <div class="atlas-children">${children(folder, view)}</div>167213 </details>`;168214 }@@ -215,19 +261,6 @@215261 	return html`<a href="/${216262 		view.repo217263 	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;218-}219-220-function recent(saves: Save[], view: View): Html | null {221-	if (saves.length === 0) return null;222-	return html`<ul class="atlas-recent">${saves223-		.slice(-5)224-		.reverse()225-		.map(226-			(save) =>227-				html`<li><a class="mono" href="/${228-					view.repo229-				}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,230-		)}</ul>`;231264 }232265 233266 // Each folder's saves are the line's saves that touched any path under it, in line order.

view file

src/pages/files.ts+9 −3
@@ -96,11 +96,17 @@9696 	return `/${found.repo}/raw/${pathUrl(found.path)}${found.at ? `?at=${found.at}` : ""}`;9797 }9898 99+// Each folder opens the repo's Atlas with that folder unfolded (kepr.js reads `open`).99100 function breadcrumb(found: Found): Html {100101 	const dirs = found.path.split("/").slice(0, -1);101-	return html`<p class="breadcrumb mono"><a href="/${102-		found.repo103-	}">${found.repo}</a>${dirs.map((dir) => ` / ${dir}`)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;102+	const folderHref = (depth: number) => {103+		const query = new URLSearchParams(found.at ? { at: found.at } : {});104+		query.set("open", dirs.slice(0, depth + 1).join("/"));105+		return `/${found.repo}?${query}#atlas`;106+	};107+	return html`<p class="breadcrumb mono"><a href="/${found.repo}">${found.repo}</a>${dirs.map(108+		(dir, depth) => html` / <a href="${folderHref(depth)}">${dir}</a>`,109+	)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;104110 }105111 106112 async function fileBody(found: Found, source: boolean): Promise<Html> {

view file

src/pages/layout.ts+1 −1
@@ -71,7 +71,7 @@7171 		site7272 	}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>7373 ${branding && html`<span class="wordmark-by">${branding}</span>`}74-<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a></span>74+<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in">log in</a></span>7575 </nav>7676 <main>7777 ${options.body}

view file

src/pages/library.ts+4 −28
@@ -1,13 +1,7 @@11 import { type RepoStats, repoStats, type Save, timeline } from "../mirror/queries.ts";22 import { publicRepoNames } from "../server/db.ts";33 import { type Html, html } from "./html.ts";4-import {5-	openPublicRepo,6-	type PageContext,7-	type PublicRepo,8-	plural,9-	relativeTime,10-} from "./layout.ts";4+import { openPublicRepo, type PageContext, type PublicRepo, plural } from "./layout.ts";115 import { readmeAtHead, readmeIntro } from "./repo.ts";126 137 /** A public repo as the front page, browse and the profile show it. `saves` are oldest first. */@@ -37,32 +31,14 @@3731 	return html`<div class="card-grid">${library.map(repoCard)}</div>`;3832 }3933 34+// Kepr 2's card: name, save count, the description, and the category.4035 function repoCard(repo: LibraryRepo): Html {4136 	const { name, category } = repo.settings;42-	const { stats } = repo;4337 	return html`<a class="repo-card" href="/${name}">4438 <div class="repo-card-head"><span class="repo-card-name">${4539 		name46-	}</span><span class="repo-card-saves mono">${plural(stats.saves, "save")}</span></div>40+	}</span><span class="repo-card-saves mono">${plural(repo.stats.saves, "save")}</span></div>4741 ${repo.description && html`<p class="repo-card-desc">${repo.description}</p>`}48-${ticks(repo.saves)}49-<div class="repo-card-foot">${50-		category && html`<span class="cat-chip">${category}</span>`51-	}${stats.language && html`<span class="repo-card-lang">${stats.language}</span>`}${stats.updated !== null && html`<span class="repo-card-when">${relativeTime(stats.updated)}</span>`}</div>42+${category && html`<p class="repo-card-cat">${category}</p>`}5243 </a>`;53-}54-55-// §21.2's mini-timeline: one tick per save, placed by time like the repo page's dots.56-function ticks(saves: Save[]): Html {57-	const times = saves.map((save) => save.created_at);58-	// Replayed saves keep their own times, so the line is not in time order.59-	const first = times.reduce((a, b) => Math.min(a, b), times[0] ?? 0);60-	const span = times.reduce((a, b) => Math.max(a, b), first) - first;61-	const lines = saves.map((save) => {62-		const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;63-		return html`<line x1="${x.toFixed(2)}" x2="${x.toFixed(2)}" y1="9" y2="27"/>`;64-	});65-	return html`<svg class="repo-card-ticks" viewBox="0 0 100 36" preserveAspectRatio="none" aria-hidden="true">${66-		lines67-	}</svg>`;6844 }

view file

src/pages/repo.ts+62 −30
@@ -7,10 +7,11 @@77 	history,88 	objectBytes,99 	repoStats,10+	type Save,1011 	saveByPrefix,1112 	timeline,1213 } from "../mirror/queries.ts";13-import { visibleReleases } from "../releases/releases.ts";14+import { type Release, visibleReleases } from "../releases/releases.ts";1415 import { setting } from "../server/db.ts";1516 import { atlasSection } from "./atlas.ts";1617 import { type Html, html } from "./html.ts";@@ -28,6 +29,12 @@2829 import { timelineSvg } from "./timeline.ts";2930 3031 const PAGE_SIZE = 50;32+// Each opens inline under the tabs (kepr.js), as Kepr 2's panels did; the page is the fallback.33+const PANELS = [34+	["issues", "issues"],35+	["builds", "releases"],36+	["archives", "archives"],37+];3138 const HTTP_URL = /^https?:\/\//i;3239 3340 export function registerRepoPage(app: Hono, ctx: PageContext): void {@@ -41,6 +48,7 @@4148 		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);4249 		const readme = readmeAtHead(repo);4350 		const name = repo.settings.name;51+		const releases = visibleReleases(ctx.kepr, repo.mirror, name);4452 		const intro = readmeIntro(name, readme);4553 		cacheFor(c, false);4654 		return c.html(@@ -48,8 +56,8 @@4856 				title: name,4957 				description: intro?.text ?? "",5058 				path: `/${name}`,51-				body: html`${header(ctx, repo, intro?.html ?? null)}52-${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}59+				body: html`${header(ctx, repo, releases, intro?.html ?? null)}60+${headId(repo.mirror) && sections(repo, releases, pageNumber, atSave?.id ?? null)}5361 ${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,5462 			}),5563 		);@@ -74,12 +82,13 @@7482 	return bytes ? new TextDecoder().decode(bytes) : null;7583 }7684 77-function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {85+function header(ctx: PageContext, repo: PublicRepo, releases: Release[], intro: Html | null): Html {7886 	const { name, homepage, installLines } = repo.settings;87+	const steal = `koh steal ${ctx.domain}/${name}`;7988 	const stats = repoStats(repo.mirror);8089 	const handle = setting(ctx.kepr, "handle");8190 	// §21.4: the version is the newest release; with none, the header shows no version.82-	const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];91+	const newest = releases[0];8392 	const meta = [8493 		newest && `v${newest.version}`,8594 		plural(stats.saves, "save"),@@ -100,53 +109,76 @@100109 }101110 <p class="repo-meta">${meta.join(" · ")}</p>102111 <div class="repo-tabbar">103-<nav class="repo-tabs" aria-label="Repository panels"><a class="repo-tab" href="/${104-		name105-	}/issues">issues</a><a class="repo-tab" href="/${name}/releases">builds</a><a class="repo-tab" href="/${name}/archives">archives</a></nav>106-${107-	installLines.length > 0 &&108-	html`<div class="install-lines">${installLines.map(109-		(line) =>110-			html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,111-	)}</div>`112-}112+<nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(113+		([label, path]) =>114+			html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,115+	)}</nav>116+<div class="install-lines">117+<button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>118+${installLines.map(119+	(line) =>120+		html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,121+)}113122 </div>123+</div>124+<div class="repo-panel" hidden></div>114125 </header>`;115126 }116127 117-function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {128+function sections(129+	repo: PublicRepo,130+	releases: Release[],131+	requestedPage: number,132+	at: string | null,133+): Html {118134 	const name = repo.settings.name;119-	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);120135 	const saves = timeline(repo.mirror);121136 	// Past the last page, even at an unbindable number like Infinity, is page 1.122137 	const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;123138 	const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);124-	const count = plural(saves.length, "save");125-	const older = pageNumber * PAGE_SIZE < saves.length;139+	const pages: Pages = {140+		at,141+		newer: pageNumber > 1 ? pageNumber - 1 : null,142+		older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,143+	};144+	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves, releases);126145 	return html`<section class="repo-section">127146 <details class="repo-section-toggle" open>128-<summary class="section-head"><span class="section-label">timeline</span><span class="section-count">${129-		count130-	}</span></summary>147+<summary class="section-head"><span class="section-label">timeline</span></summary>131148 ${timelineSvg(name, saves)}132149 </details>133150 </section>134151 ${atlas}135152 <section class="repo-section" id="history">136153 <details class="repo-section-toggle" open>137-<summary class="section-head"><span class="section-label">history</span><span class="section-count">${138-		count139-	}</span></summary>154+<summary class="section-head"><span class="section-label">history</span></summary>155+${saveList(name, rows, pages)}156+</details>157+</section>`;158+}159+160+type Pages = { at: string | null; newer: number | null; older: number | null };161+162+/**163+ * One page of saves in a box that scrolls four rows at a time, as Kepr 2's; kepr.js fetches the164+ * older pages into it as it scrolls, and without JavaScript the pager links page the whole view.165+ */166+function saveList(repo: string, rows: Save[], pages: Pages): Html {167+	const pageHref = (n: number) => {168+		const query = new URLSearchParams(pages.at ? { at: pages.at } : {});169+		query.set("page", String(n));170+		return `?${query}#history`;171+	};172+	return html`<div class="save-list" data-list="history">140173 ${rows.map(141174 	(save) =>142-		html`<a class="history-row" href="/${name}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`,175+		html`<a class="history-row" href="/${repo}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`,143176 )}144177 ${145-	(pageNumber > 1 || older) &&146-	html`<p class="pager mono">${pageNumber > 1 && html`<a href="?page=${pageNumber - 1}#history">← newer</a>`}${older && html`<a href="?page=${pageNumber + 1}#history">older →</a>`}</p>`178+	(pages.newer || pages.older) &&179+	html`<p class="pager mono">${pages.newer && html`<a href="${pageHref(pages.newer)}">← newer</a>`}${pages.older && html`<a class="pager-older" href="${pageHref(pages.older)}">older →</a>`}</p>`147180 }148-</details>149-</section>`;181+</div>`;150182 }151183 152184 function details(readme: Html): Html {

view file

src/pages/timeline.ts+5 −4
@@ -11,9 +11,10 @@1111 ];1212 1313 /**14- * §21.4's dotted timeline as SVG, so it draws without JavaScript: one dot per save on a dashed15- * line, x by time (as a percentage, so it scales without squashing dots), brighter and fuller16- * where neighbours crowd within a fortieth of the span.14+ * §21.4's dotted timeline as SVG, so it draws without JavaScript (kepr.js swaps it for Kepr 2's15+ * drifting canvas, reading each save from its dot): one dot per save on a dashed line, x by16+ * time (as a percentage, so it scales without squashing dots), brighter and fuller where17+ * neighbours crowd within a fortieth of the span.1718  */1819 export function timelineSvg(repo: string, saves: Save[]): Html {1920 	const times = saves.map((save) => save.created_at);@@ -33,7 +34,7 @@3334 		const tip = `${save.id}\n${save.message.split("\n")[0]}\n${meta}`;3435 		return html`<a href="/${3536 			repo36-		}/saves/${save.id}" class="dot d${level}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;37+		}/saves/${save.id}" class="dot d${level}" data-t="${save.created_at}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;3738 	});3839 	const perDay = saves.length / Math.max(span / DAY, 1);3940 	const pace = PACE.find(([min]) => perDay > min)?.[1] ?? "quiet";

view file

src/server/login.ts+1 −1
@@ -74,7 +74,7 @@7474 }7575 7676 /**77- * §24's login at the unlinked `/sign-in`, sessions, sign-out, and the guard in front of every77+ * §24's login at `/sign-in`, sessions, sign-out, and the guard in front of every7878  * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.7979  */8080 export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {

view file

tests/front.test.ts+7 −7
@@ -122,27 +122,27 @@122122 		expect(checkpoints()).toContain((state("head") as { value: string }).value);123123 		// Lines and languages are counted at the newest real save, so tool.py is in neither.124124 		expect(state("stats_save")).toEqual({ value: realSaves()[0]?.id });125-		for (const path of ["/", "/browse", "/@ada", "/alpha"]) {125+		for (const path of ["/", "/@ada", "/alpha"]) {126126 			const html = await body(path);127127 			expect(html).toContain("TypeScript");128128 			expect(html).not.toContain("Python");129129 		}130+		expect(await body("/browse")).not.toContain("Python");130131 	});131132 132-	test("the nav has browse, docs and support, and no login link", async () => {133+	test("the nav has browse, docs, support and log in, as Kepr 2's", async () => {133134 		const html = await body("/");134135 		expect(html).toContain(135-			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a>',136+			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in">log in</a>',136137 		);137-		expect(html).not.toContain("sign-in");138138 	});139139 140-	test("browse lists public repos as cards", async () => {140+	test("browse lists public repos as Kepr 2's cards: name, saves, description, category", async () => {141141 		const html = await body("/browse");142142 		expect(html).toContain('<a class="repo-card" href="/alpha">');143+		expect(html).toContain(`${realSaves().length} saves</span>`);144+		expect(html).toContain('<p class="repo-card-cat">tools</p>');143145 		expect(html).not.toContain("vault");144-		// One tick per real save.145-		expect(html.match(/<line x1=/g)?.length).toBe(realSaves().length);146146 	});147147 });148148 

view file

tests/koh-version.txt+1 −1
@@ -1 +1 @@1-3.2.221+3.2.24

view file

tests/pages.test.ts+13 −26
@@ -1,4 +1,3 @@1-import { Database } from "bun:sqlite";21 import { beforeAll, describe, expect, test } from "bun:test";32 import { mkdir, mkdtemp, writeFile } from "node:fs/promises";43 import { tmpdir } from "node:os";@@ -8,7 +7,6 @@87 import { syncAll } from "../src/bucket/sync.ts";98 import { fileAt, objectBytes, type Save } from "../src/mirror/queries.ts";109 import { openMirror } from "../src/mirror/store.ts";11-import { repoCards } from "../src/pages/library.ts";1210 import { timelineSvg } from "../src/pages/timeline.ts";1311 import { createApp } from "../src/server/app.ts";1412 import { openKeprDb } from "../src/server/db.ts";@@ -20,6 +18,11 @@2018 let tailSaves: string[];2119 const get = (path: string) => app.request(path);2220 const body = async (path: string) => (await get(path)).text();21+// History's IDs only, not the Atlas picker's, which lists every save.22+const historyIds = (page: string) => {23+	const list = /data-list="history">(.*?)<\/div>/s.exec(page)?.[1] ?? "";24+	return [...list.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);25+};2326 2427 beforeAll(async () => {2528 	fx = await makeKohBucket();@@ -110,16 +113,16 @@110113 		expect(page).toContain('data-copy="nix profile add x"');111114 		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);112115 		expect(page).toContain("density = activity · position = time");113-		const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);114-		expect(history).toEqual([head(), second(), first()]);116+		expect(historyIds(page)).toEqual([head(), second(), first()]);117+		expect(page).toContain('data-copy="koh steal kepr.example/demo"');118+		expect(page).toContain('href="/demo/releases" data-panel="releases">builds</a>');115119 	});116120 117121 	test("a page past the last, or too large to bind, is page 1", async () => {118-		const history = (page: string) => [...page.matchAll(/class="history-id">([0-9a-f]+)</g)];119122 		for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {120123 			const response = await get(`/demo${query}`);121124 			expect([query, response.status]).toEqual([query, 200]);122-			expect(history(await response.text()).length).toBe(3);125+			expect(historyIds(await response.text()).length).toBe(3);123126 		}124127 	});125128 @@ -138,7 +141,7 @@138141 	});139142 });140143 141-describe("the timeline and card ticks", () => {144+describe("the timeline", () => {142145 	// A replayed save keeps its original time, so newest-first order is not time order.143146 	const saves = [300, 100, 500, 200].map(144147 		(created_at, i): Save => ({@@ -156,17 +159,6 @@156159 	test("span from the earliest to the latest save, whatever the line's order", () => {157160 		const dots = [...timelineSvg("demo", saves).value.matchAll(/cx="([\d.]+)%"/g)].map((m) => m[1]);158161 		expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);159-		const card = repoCards([160-			{161-				settings: { name: "demo", category: "", homepage: "", installLines: [] },162-				mirror: new Database(":memory:"),163-				stats: { saves: 4, lines: 0, language: "", updated: null },164-				description: "",165-				saves,166-			},167-		]).value;168-		const ticks = [...card.matchAll(/<line x1="([\d.]+)"/g)].map((m) => m[1]);169-		expect(ticks).toEqual(["50.00", "1.00", "99.00", "25.50"]);170162 	});171163 });172164 @@ -286,7 +278,6 @@286278 		expect(main).toContain("<dt>saves</dt><dd>2</dd>");287279 		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);288280 		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);289-		expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));290281 		const guide = panelOf(page, "guide.md");291282 		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");292283 		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);@@ -310,17 +301,13 @@310301 		expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);311302 		const src = folderPanel("src");312303 		expect(src).toContain("<dt>saves</dt><dd>3</dd>");313-		const recentIds = [314-			...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(315-				/saves\/([0-9a-f]+)/g,316-			),317-		].map((m) => m[1]);318-		expect(recentIds).toEqual([head(), second(), first()]);319304 	});320305 321306 	test("can be viewed at an older save, and an unknown save is a 404", async () => {322307 		const page = await body(`/demo?at=${second().slice(0, 8)}`);323-		expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);308+		expect(page).toContain(`title="explore through time">${second()}</summary>`);309+		expect(page).toContain(`<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`);310+		expect(page).toContain('<a class="atlas-head-link mono" href="/demo#atlas">back to HEAD</a>');324311 		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);325312 		expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");326313 		expect(panelOf(page, "main.ts")).not.toContain(head());

view file