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
↓ kepr-0ee01062.tar.gzatlas at this save
files changed13 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 −26package.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": {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 {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();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.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> {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}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 }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 {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";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 {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 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());