8ac8cee09624

bound the Atlas to Kepr 2's 520px scroll area, shorten the timeline to a 64px strip without its legend, and drop versions from the HEAD picker (v3.0.100)

time
2026-09-29 18:00 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
match kepr 2 repo page
parent
0ee0106284c7
7 files package.json+1 −1public/css/kepr.css+12 −36public/js/kepr.js+8 −23src/pages/atlas.ts+11 −36src/pages/repo.ts+7 −13src/pages/timeline.ts+1 −12tests/pages.test.ts+1 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.99",3+	"version": "3.0.100",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+12 −36
@@ -439,11 +439,6 @@439439 	cursor: grab;440440 	touch-action: pan-y;441441 }442-.timeline-legend {443-	margin: 0 0 14px;444-	font-size: 0.625rem;445-	color: var(--text-faint);446-}447442 .timeline-tip {448443 	position: fixed;449444 	z-index: 200;@@ -590,31 +585,6 @@590585 	border: 1px solid var(--text-faint);591586 	box-shadow: 0 18px 48px rgba(0, 0, 0, 0.48);592587 }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;605-	color: var(--text-faint);606-	cursor: pointer;607-}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 {615-	color: var(--accent);616-	background: rgba(200, 154, 90, 0.06);617-}618588 .atlas-picker-filter {619589 	padding: 8px 10px;620590 	background: var(--bg);@@ -709,11 +679,22 @@709679 .atlas-filter::placeholder {710680 	color: var(--text-faint);711681 }682+/* Kepr 2's Atlas height: the tree and the panel each scroll inside it. */712683 .atlas {713684 	display: grid;714685 	grid-template-columns: minmax(0, 1fr);715686 	gap: 0 20px;687+	height: 520px;688+	border-top: 1px solid var(--bg-elevated);689+	border-bottom: 1px solid var(--bg-elevated);716690 }691+.atlas-tree,692+.atlas-panel {693+	min-height: 0;694+	overflow-y: auto;695+	scrollbar-width: thin;696+	scrollbar-color: var(--border) transparent;697+}717698 .atlas:has(> .atlas-panel:not([hidden])) {718699 	grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr);719700 }@@ -774,12 +755,7 @@774755 	white-space: nowrap;775756 }776757 .atlas-panel {777-	position: sticky;778-	top: 16px;779-	align-self: start;780-	max-height: calc(100vh - 32px);781-	overflow-y: auto;782-	padding: 4px 0 12px 16px;758+	padding: 12px 0 12px 16px;783759 	border-left: 1px solid var(--border);784760 }785761 .atlas-hint {

view file

public/js/kepr.js+8 −23
@@ -59,14 +59,15 @@5959 	const xOf = (t) => (strip * (t - start)) / (end - start);6060 	const resize = () => {6161 		width = canvas.parentElement.getBoundingClientRect().width;62-		height = Math.min(320, Math.max(120, 120 + Math.floor(Math.log2(saves.length + 1) * 30)));62+		// Kepr 2's band was tall to hold two lanes; with one line, only room for the dots and label.63+		height = 64;6364 		canvas.width = Math.round(width * devicePixelRatio);6465 		canvas.height = Math.round(height * devicePixelRatio);6566 		canvas.style.width = `${width}px`;6667 		canvas.style.height = `${height}px`;6768 		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);6869 		strip = Math.max(width * 3, saves.length * 48);69-		radius = Math.min(5, height * 0.022);70+		radius = 4;7071 		let previous = Number.NEGATIVE_INFINITY;7172 		for (const save of saves) {7273 			save.x = Math.max(xOf(save.t), previous + radius + 8);@@ -420,31 +421,16 @@420421 	panel.hidden = false;421422 }422423 423-// Kepr 2's Atlas picker: saves or versions, filtered as you type; a click outside or Escape424-// closes it.424+// Kepr 2's Atlas picker: saves filtered as you type; a click outside or Escape closes it.425425 function atlasPicker() {426426 	const picker = document.querySelector(".atlas-picker");427427 	if (!picker) return;428-	const tabs = picker.querySelector(".atlas-picker-tabs");429428 	const filter = picker.querySelector(".atlas-picker-filter");430-	const lists = [...picker.querySelectorAll(".atlas-picker-list")];431-	let shown = lists[0];432-	const narrow = () => {429+	const rows = [...picker.querySelectorAll(".atlas-picker-row")];430+	filter.addEventListener("input", () => {433431 		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);432+		for (const row of rows) row.hidden = query !== "" && !row.dataset.q.includes(query);433+	});448434 	picker.addEventListener("toggle", () => {449435 		if (picker.open) filter.focus();450436 	});@@ -454,7 +440,6 @@454440 	picker.addEventListener("keydown", (event) => {455441 		if (event.key === "Escape") picker.open = false;456442 	});457-	tabs.hidden = false;458443 	filter.hidden = false;459444 }460445 

view file

src/pages/atlas.ts+11 −36
@@ -1,6 +1,5 @@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";43 import { isText } from "./files.ts";54 import { type Html, html } from "./html.ts";65 import {@@ -59,15 +58,14 @@5958 /**6059  * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without6160  * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser62- * 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.61+ * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves to62+ * view it at instead.6463  */6564 export function atlasSection(6665 	repo: PublicRepo,6766 	saveId: string,6867 	at: string | null,6968 	saves: Save[],70-	releases: Release[],7169 ): Html {7270 	const touches = fileTouches(repo.mirror, saveId);7371 	const foldersTouches = folderTouches(touches);@@ -102,7 +100,7 @@102100 		"file",103101 	)}</span></summary>104102 <div class="atlas-bar">105-${picker(view, saves, releases)}103+${picker(view, saves)}106104 ${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}107105 <button type="button" class="atlas-search-btn mono" hidden>search</button>108106 <input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>@@ -115,41 +113,18 @@115113 </section>`;116114 }117115 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>`;116+// Kepr 2's "explore through time": every save, newest first. The filter is kepr.js's.117+function picker(view: View, saves: Save[]): Html {126118 	return html`<details class="atlas-picker">127119 <summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>128120 <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>130121 <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>122+<div class="atlas-picker-list">${saves.toReversed().map((save) => {123+		const message = save.message.split("\n")[0] as string;124+		return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${125+			view.repo126+		}?at=${save.id}#atlas" data-q="${`${save.id} ${message}`.toLowerCase()}"><span class="atlas-picker-id">${save.id}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">· ${relativeTime(save.created_at)}</span></a>`;127+	})}</div>153128 </div>154129 </details>`;155130 }

view file

src/pages/repo.ts+7 −13
@@ -11,7 +11,7 @@1111 	saveByPrefix,1212 	timeline,1313 } from "../mirror/queries.ts";14-import { type Release, visibleReleases } from "../releases/releases.ts";14+import { visibleReleases } from "../releases/releases.ts";1515 import { setting } from "../server/db.ts";1616 import { atlasSection } from "./atlas.ts";1717 import { type Html, html } from "./html.ts";@@ -48,7 +48,6 @@4848 		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);4949 		const readme = readmeAtHead(repo);5050 		const name = repo.settings.name;51-		const releases = visibleReleases(ctx.kepr, repo.mirror, name);5251 		const intro = readmeIntro(name, readme);5352 		cacheFor(c, false);5453 		return c.html(@@ -56,8 +55,8 @@5655 				title: name,5756 				description: intro?.text ?? "",5857 				path: `/${name}`,59-				body: html`${header(ctx, repo, releases, intro?.html ?? null)}60-${headId(repo.mirror) && sections(repo, releases, pageNumber, atSave?.id ?? null)}58+				body: html`${header(ctx, repo, intro?.html ?? null)}59+${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}6160 ${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,6261 			}),6362 		);@@ -82,13 +81,13 @@8281 	return bytes ? new TextDecoder().decode(bytes) : null;8382 }8483 85-function header(ctx: PageContext, repo: PublicRepo, releases: Release[], intro: Html | null): Html {84+function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {8685 	const { name, homepage, installLines } = repo.settings;8786 	const steal = `koh steal ${ctx.domain}/${name}`;8887 	const stats = repoStats(repo.mirror);8988 	const handle = setting(ctx.kepr, "handle");9089 	// §21.4: the version is the newest release; with none, the header shows no version.91-	const newest = releases[0];90+	const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];9291 	const meta = [9392 		newest && `v${newest.version}`,9493 		plural(stats.saves, "save"),@@ -125,12 +124,7 @@125124 </header>`;126125 }127126 128-function sections(129-	repo: PublicRepo,130-	releases: Release[],131-	requestedPage: number,132-	at: string | null,133-): Html {127+function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {134128 	const name = repo.settings.name;135129 	const saves = timeline(repo.mirror);136130 	// Past the last page, even at an unbindable number like Infinity, is page 1.@@ -141,7 +135,7 @@141135 		newer: pageNumber > 1 ? pageNumber - 1 : null,142136 		older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,143137 	};144-	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves, releases);138+	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves);145139 	return html`<section class="repo-section">146140 <details class="repo-section-toggle" open>147141 <summary class="section-head"><span class="section-label">timeline</span></summary>

view file

src/pages/timeline.ts+1 −12
@@ -2,14 +2,6 @@22 import { type Html, html } from "./html.ts";33 import { formatDate } from "./layout.ts";44 5-const DAY = 86400;6-// Kepr 2's labels for saves per day across the whole history.7-const PACE: [number, string][] = [8-	[1, "very active"],9-	[0.3, "active"],10-	[0.1, "occasional"],11-];12-135 /**146  * §21.4's dotted timeline as SVG, so it draws without JavaScript (kepr.js swaps it for Kepr 2's157  * drifting canvas, reading each save from its dot): one dot per save on a dashed line, x by@@ -36,13 +28,10 @@3628 			repo3729 		}/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>`;3830 	});39-	const perDay = saves.length / Math.max(span / DAY, 1);40-	const pace = PACE.find(([min]) => perDay > min)?.[1] ?? "quiet";4131 	return html`<svg class="timeline" width="100%" height="56" role="img" aria-label="${4232 		saves.length4333 	} saves over time">4434 <line class="timeline-line" x1="0" x2="100%" y1="28" y2="28"/>4535 ${dots}46-</svg>47-<p class="timeline-legend mono">${pace} · density = activity · position = time</p>`;36+</svg>`;4837 }

view file

tests/pages.test.ts+1 −1
@@ -112,7 +112,7 @@112112 		expect(page).toContain("3 saves");113113 		expect(page).toContain('data-copy="nix profile add x"');114114 		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);115-		expect(page).toContain("density = activity · position = time");115+		expect(page).not.toContain("density = activity");116116 		expect(historyIds(page)).toEqual([head(), second(), first()]);117117 		expect(page).toContain('data-copy="koh steal kepr.example/demo"');118118 		expect(page).toContain('href="/demo/releases" data-panel="releases">builds</a>');

view file