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
↓ kepr-8ac8cee0.tar.gzatlas at this save
files changed7 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 −1package.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": {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 {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 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 }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>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 }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>');