b623f609221b
add the front page and browse: the drifting save-message ticker, the library's count of distinct objects, the file-type and category cloud, latest offered and shared repo cards with one tick per save, all from public repos and real saves only (v3.0.30)
- time
- 2026-09-26 19:59 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- de6037e2c124
↓ kepr-b623f609.tar.gzatlas at this save
files changed9 files
package.json+1 −1public/css/kepr.css+316 −18src/mirror/queries.ts+15 −0src/mirror/store.ts+6 −2src/pages/front.ts+96 −0src/pages/layout.ts+2 −1src/pages/library.ts+60 −0src/server/app.ts+2 −0tests/front.test.ts+141 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.29",3+ "version": "3.0.30",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+316 −18
@@ -1126,24 +1126,6 @@11261126 }11271127 }11281128 1129-/* Later in the file than every animation it stops, so it wins without !important. */1130-@media (prefers-reduced-motion: reduce) {1131- .tri-outer,1132- .tri-inner,1133- .repo-name,1134- .repo-contributor,1135- .repo-desc,1136- .repo-homepage,1137- .repo-meta,1138- .repo-tabs,1139- .install-lines,1140- .repo-section,1141- .repo-details,1142- .save-message-head {1143- animation: none;1144- }1145-}1146-11471129 /* ----- builds ------------------------------------------------------------- */11481130 11491131 .release {@@ -1347,4 +1329,320 @@13471329 height: 0;13481330 overflow: hidden;13491331 opacity: 0;1332+}1333+1334+/* ----- front page --------------------------------------------------------- */1335+1336+/* Kepr 2's hero band, full width, reaching up behind the nav (32px mark + 48px padding). */1337+body > nav {1338+ position: relative;1339+ z-index: 1;1340+}1341+.ticker {1342+ position: relative;1343+ height: 304px;1344+ margin: -104px calc(50% - 50vw) 0;1345+ padding: 96px 0 20px;1346+ display: flex;1347+ flex-direction: column;1348+ justify-content: space-evenly;1349+ overflow: hidden;1350+ pointer-events: none;1351+ user-select: none;1352+ border-bottom: 1px solid var(--border);1353+ font-family: var(--mono);1354+ color: var(--text-secondary);1355+ mask-image: linear-gradient(1356+ to right,1357+ transparent,1358+ #000 48px,1359+ #000 calc(100% - 48px),1360+ transparent1361+ );1362+}1363+.ticker-row {1364+ white-space: nowrap;1365+ line-height: 1.2;1366+}1367+.ticker-track {1368+ display: inline-flex;1369+ gap: 64px;1370+ padding-right: 64px;1371+ animation: ticker-drift var(--drift) linear infinite;1372+}1373+/* Kepr 2's depth bands: nearer rows are larger, brighter and faster. */1374+.ticker-row:nth-child(1) {1375+ font-size: 9px;1376+ opacity: 0.05;1377+ --drift: 260s;1378+}1379+.ticker-row:nth-child(2) {1380+ font-size: 18px;1381+ opacity: 0.1;1382+ --drift: 150s;1383+ animation: ticker-glow 29s 4s ease-in-out infinite;1384+}1385+.ticker-row:nth-child(3) {1386+ font-size: 7px;1387+ opacity: 0.04;1388+ --drift: 320s;1389+}1390+.ticker-row:nth-child(4) {1391+ font-size: 13px;1392+ opacity: 0.07;1393+ --drift: 200s;1394+ animation: ticker-glow 37s 17s ease-in-out infinite;1395+}1396+.ticker-row:nth-child(5) {1397+ font-size: 22px;1398+ opacity: 0.12;1399+ --drift: 120s;1400+}1401+.ticker-row:nth-child(6) {1402+ font-size: 8px;1403+ opacity: 0.045;1404+ --drift: 290s;1405+}1406+.ticker-row:nth-child(7) {1407+ font-size: 15px;1408+ opacity: 0.08;1409+ --drift: 170s;1410+}1411+@keyframes ticker-drift {1412+ to {1413+ transform: translateX(-50%);1414+ }1415+}1416+@keyframes ticker-glow {1417+ 0%,1418+ 88%,1419+ 100% {1420+ color: var(--text-secondary);1421+ }1422+ 94% {1423+ color: var(--accent);1424+ opacity: 0.3;1425+ text-shadow: 0 0 3px var(--accent-tint-12);1426+ }1427+}1428+1429+.headline {1430+ display: grid;1431+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);1432+ gap: 48px;1433+ align-items: center;1434+ padding: 56px 0 40px;1435+}1436+.headline h1 {1437+ font-size: clamp(2rem, 5vw, 3.5rem);1438+ font-weight: 400;1439+ line-height: 1.15;1440+ color: var(--text-heading);1441+ margin: 0 0 20px;1442+ animation: fadeUp 480ms ease both;1443+}1444+.headline-tagline {1445+ margin: 0;1446+ font-size: 0.9375rem;1447+ line-height: 1.7;1448+ color: var(--text-secondary);1449+ animation: fadeUp 480ms 80ms ease both;1450+}1451+.headline-cloud {1452+ display: flex;1453+ flex-wrap: wrap;1454+ justify-content: center;1455+ align-items: center;1456+ gap: 10px 22px;1457+ padding: 20px;1458+ animation: fadeIn 600ms 160ms ease both;1459+}1460+.tc-tag {1461+ font-family: var(--mono);1462+ letter-spacing: 0.03em;1463+ white-space: nowrap;1464+ color: var(--accent-dim);1465+ transition:1466+ color 0.2s,1467+ opacity 0.2s,1468+ text-shadow 0.2s;1469+}1470+.tc-tag:hover {1471+ color: var(--accent);1472+ opacity: 1;1473+ text-shadow: 0 0 14px var(--accent-tint-12);1474+}1475+.tc-1 {1476+ font-size: 10px;1477+ opacity: 0.42;1478+}1479+.tc-2 {1480+ font-size: 12px;1481+ opacity: 0.56;1482+}1483+.tc-3 {1484+ font-size: 15px;1485+ opacity: 0.7;1486+}1487+.tc-4 {1488+ font-size: 18px;1489+ opacity: 0.85;1490+ font-weight: 500;1491+}1492+.tc-5 {1493+ font-size: 23px;1494+ opacity: 1;1495+ font-weight: 500;1496+}1497+1498+.featured-label {1499+ font-size: 0.6875rem;1500+ font-weight: 400;1501+ letter-spacing: 0.08em;1502+ text-transform: uppercase;1503+ color: var(--text-secondary);1504+ margin: 0 0 12px;1505+}1506+.landing-self-host {1507+ margin: 32px 0 0;1508+ font-size: 0.75rem;1509+}1510+.landing-self-host a {1511+ color: var(--text-secondary);1512+}1513+1514+/* ----- repo cards (front page, browse, profile) --------------------------- */1515+1516+.card-grid {1517+ display: grid;1518+ grid-template-columns: repeat(2, minmax(0, 1fr));1519+ gap: 16px;1520+}1521+.repo-card {1522+ display: flex;1523+ flex-direction: column;1524+ padding: 20px 22px 16px;1525+ border: 1px solid var(--bg-elevated);1526+ border-radius: 4px;1527+ background: var(--bg);1528+ transition:1529+ border-color 120ms,1530+ background 120ms;1531+ animation: fadeUp 350ms ease both;1532+}1533+.repo-card:hover {1534+ text-decoration: none;1535+ border-color: var(--text-faint);1536+ background: var(--accent-tint-04);1537+}1538+.repo-card-head {1539+ display: flex;1540+ align-items: baseline;1541+ justify-content: space-between;1542+ gap: 12px;1543+}1544+.repo-card-name {1545+ font-size: 1.375rem;1546+ line-height: 1.1;1547+ color: var(--accent);1548+ overflow: hidden;1549+ text-overflow: ellipsis;1550+ white-space: nowrap;1551+}1552+.repo-card-saves {1553+ font-size: 0.6875rem;1554+ white-space: nowrap;1555+}1556+.repo-card-desc {1557+ margin: 8px 0 0;1558+ font-size: 0.8125rem;1559+ line-height: 1.5;1560+ color: var(--text-secondary);1561+ display: -webkit-box;1562+ -webkit-line-clamp: 2;1563+ -webkit-box-orient: vertical;1564+ overflow: hidden;1565+}1566+.repo-card-ticks {1567+ display: block;1568+ width: 100%;1569+ height: 36px;1570+ margin: auto 0 10px;1571+ padding-top: 12px;1572+ box-sizing: content-box;1573+ border-bottom: 1px solid var(--bg-surface);1574+}1575+.repo-card-ticks line {1576+ stroke: var(--accent);1577+ stroke-width: 1;1578+ opacity: 0.45;1579+ vector-effect: non-scaling-stroke;1580+}1581+.repo-card:hover .repo-card-ticks line {1582+ opacity: 0.8;1583+}1584+.repo-card-foot {1585+ display: flex;1586+ align-items: center;1587+ flex-wrap: wrap;1588+ gap: 6px;1589+ font-family: var(--mono);1590+ font-size: 0.625rem;1591+ color: var(--text-muted);1592+}1593+.cat-chip,1594+.repo-card-lang {1595+ padding: 1px 6px;1596+ border: 1px solid var(--border);1597+ border-radius: 2px;1598+ background: var(--bg-surface);1599+}1600+.cat-chip {1601+ color: var(--accent-dim);1602+}1603+.repo-card-when {1604+ margin-left: auto;1605+ color: var(--text-faint);1606+}1607+1608+@media (max-width: 860px) {1609+ .headline {1610+ grid-template-columns: minmax(0, 1fr);1611+ gap: 8px;1612+ padding-top: 48px;1613+ }1614+ .card-grid {1615+ grid-template-columns: minmax(0, 1fr);1616+ }1617+}1618+@media (max-width: 768px) {1619+ .ticker {1620+ height: 226px;1621+ margin-top: -88px;1622+ padding-top: 80px;1623+ }1624+}1625+1626+/* Last in the file, after every animation it stops, so it wins without !important. */1627+@media (prefers-reduced-motion: reduce) {1628+ .tri-outer,1629+ .tri-inner,1630+ .repo-name,1631+ .repo-contributor,1632+ .repo-desc,1633+ .repo-homepage,1634+ .repo-meta,1635+ .repo-tabs,1636+ .install-lines,1637+ .repo-section,1638+ .repo-details,1639+ .save-message-head,1640+ .ticker-row,1641+ .ticker-track,1642+ .headline h1,1643+ .headline-tagline,1644+ .headline-cloud,1645+ .repo-card {1646+ animation: none;1647+ }13501648 }src/mirror/queries.ts+15 −0
@@ -162,6 +162,21 @@162162 return { line: [...saves.values()], byPath };163163 }164164 165+/** Lines per language at head, most first, vendored code left out as in the header's language. */166+export function languageLines(db: Database): [string, number][] {167+ return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];168+}169+170+/** Every distinct object (unique file contents) a real save refers to, at any point in history. */171+export function objectHashes(db: Database): string[] {172+ return db173+ .query<{ hash: string }, []>(174+ `SELECT DISTINCT f.hash FROM save_files f JOIN saves s ON s.id = f.save_id WHERE s.${NOT_CHECKPOINT}`,175+ )176+ .all()177+ .map((row) => row.hash);178+}179+165180 /** Every release record the bucket holds, in no particular order. */166181 export function releaseRecords(db: Database): ReleaseRecord[] {167182 return dbsrc/mirror/store.ts+6 −2
@@ -125,6 +125,9 @@125125 db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);126126 })();127127 }128+ // Mirrors synced before the per-language lines existed get them now, not at the next save.129+ const head = mirrorState(db, "head");130+ if (head && mirrorState(db, "languages") === null) refreshStats(db, head);128131 open.set(path, db);129132 return db;130133 }@@ -243,8 +246,9 @@243246 const language = LANGUAGES[file.path.split(".").pop() ?? ""];244247 if (language) byLanguage.set(language, (byLanguage.get(language) ?? 0) + count);245248 }246- const language = [...byLanguage].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";249+ const languages = [...byLanguage].sort((a, b) => b[1] - a[1]);247250 const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)");248251 setState.run("lines", String(lines));249- setState.run("language", language);252+ setState.run("language", languages[0]?.[0] ?? "");253+ setState.run("languages", JSON.stringify(languages));250254 }src/pages/front.ts+96 −0
@@ -0,0 +1,96 @@1+import type { Hono } from "hono";2+import { languageLines, objectHashes } from "../mirror/queries.ts";3+import { setting } from "../server/db.ts";4+import { type Html, html } from "./html.ts";5+import { cacheFor, type PageContext, page, plural } from "./layout.ts";6+import { type LibraryRepo, publicLibrary, recentSaves, repoCards } from "./library.ts";7+8+const TICKER_ROWS = 7;9+const TICKER_ITEMS = 10;10+const LATEST_OFFERED = 4;11+12+/** §21.1's front page and §21.3's browse. */13+export function registerFrontPages(app: Hono, ctx: PageContext): void {14+ app.get("/", (c) => {15+ const library = publicLibrary(ctx);16+ const tagline = setting(ctx.kepr, "tagline") ?? "";17+ // §21.1: distinct objects across every public repo's saves, as Kepr 2 counted object refs.18+ const known = new Set(library.flatMap((repo) => objectHashes(repo.mirror))).size;19+ const latest = library20+ .filter((repo) => repo.stats.updated !== null)21+ .sort((a, b) => (b.stats.updated ?? 0) - (a.stats.updated ?? 0))22+ .slice(0, LATEST_OFFERED);23+ cacheFor(c, false);24+ return c.html(25+ page(ctx, {26+ title: "",27+ description: tagline,28+ path: "/",29+ body: html`${ticker(library)}30+<section class="headline">31+<div class="headline-text">32+<h1>The library knows<br>${plural(known, "thing")}.</h1>33+${tagline && html`<p class="headline-tagline">${tagline}</p>`}34+</div>35+${wordCloud(library)}36+</section>37+${latest.length > 0 && html`<section class="featured"><h2 class="featured-label mono">latest offered</h2>${repoCards(latest)}</section>`}38+<p class="landing-self-host mono"><a href="/docs">run your own library →</a></p>`,39+ }),40+ );41+ });42+ app.get("/browse", (c) => {43+ const library = publicLibrary(ctx);44+ cacheFor(c, false);45+ return c.html(46+ page(ctx, {47+ title: "browse",48+ description: `Every public repository, ${plural(library.length, "repo")}.`,49+ path: "/browse",50+ body: html`<h1 class="file-page-title">browse</h1>51+<p class="muted mono">${plural(library.length, "repo")}</p>52+${library.length > 0 ? repoCards(library) : html`<p class="muted mono save-empty">nothing public yet</p>`}`,53+ }),54+ );55+ });56+}57+58+// Rows of recent save messages drifting at different depths; the motion is CSS, and stops under59+// reduced motion. Each row's messages are written twice so sliding by half its width loops.60+function ticker(library: LibraryRepo[]): Html {61+ const messages = recentSaves(library, TICKER_ROWS * TICKER_ITEMS).map(62+ ({ save }) => save.message.split("\n")[0] as string,63+ );64+ if (messages.length === 0) return html``;65+ const rows = Array.from({ length: TICKER_ROWS }, (_, row) => {66+ const items = Array.from(67+ { length: TICKER_ITEMS },68+ (_, i) => messages[(row * 3 + i) % messages.length],69+ );70+ const spans = items.map((message) => html`<span>${message}</span>`);71+ return html`<div class="ticker-row"><div class="ticker-track">${spans}${spans}</div></div>`;72+ });73+ return html`<div class="ticker" aria-hidden="true">${rows}</div>`;74+}75+76+// §21.1: file types (the header's languages) mixed with categories, each weighed by how many77+// public repos have it, as Kepr 2 weighed its cloud.78+function wordCloud(library: LibraryRepo[]): Html {79+ const weights = new Map<string, number>();80+ for (const repo of library) {81+ const labels = languageLines(repo.mirror).map(([language]) => language);82+ if (repo.settings.category) labels.push(repo.settings.category);83+ for (const label of new Set(labels)) weights.set(label, (weights.get(label) ?? 0) + 1);84+ }85+ if (weights.size === 0) return html``;86+ const max = Math.max(...weights.values());87+ // Ordered by a hash of the label, so the big words scatter instead of leading the cloud.88+ const tags = [...weights].sort((a, b) => scatter(a[0]) - scatter(b[0]));89+ return html`<div class="headline-cloud" aria-label="file types and categories">${tags.map(([label, weight]) => html`<span class="tc-tag tc-${Math.ceil((weight / max) * 5)}">${label}</span>`)}</div>`;90+}91+92+function scatter(label: string): number {93+ let hash = 0x9e3779b9;94+ for (const char of label) hash = (Math.imul(31, hash) + (char.codePointAt(0) ?? 0)) | 0;95+ return hash;96+}src/pages/layout.ts+2 −1
@@ -36,7 +36,8 @@3636 export function page(ctx: PageContext, options: PageOptions): string {3737 const site = setting(ctx.kepr, "site_name") ?? "kepr";3838 const branding = setting(ctx.kepr, "branding");39- const title = `${options.title} — ${site}`;39+ // The front page is the site itself, so its title is the bare site name.40+ const title = options.title ? `${options.title} — ${site}` : site;4041 const url = `https://${ctx.domain}${options.path}`;4142 return html`<!doctype html>4243 <html lang="en">src/pages/library.ts+60 −0
@@ -0,0 +1,60 @@1+import { type RepoStats, repoStats, type Save, timeline } from "../mirror/queries.ts";2+import { publicRepoNames } from "../server/db.ts";3+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";11+import { readmeAtHead, readmeIntro } from "./repo.ts";12+13+/** A public repo as the front page, browse and the profile show it. `saves` are oldest first. */14+export type LibraryRepo = PublicRepo & { stats: RepoStats; description: string; saves: Save[] };15+16+export type RecentSave = { repo: string; save: Save };17+18+/** Every public repo in card order (§20: private ones never reach a page, count or card). */19+export function publicLibrary(ctx: PageContext): LibraryRepo[] {20+ return publicRepoNames(ctx.kepr).flatMap((name) => {21+ const repo = openPublicRepo(ctx, name);22+ if (!repo) return [];23+ const description = readmeIntro(name, readmeAtHead(repo))?.text ?? "";24+ return [{ ...repo, stats: repoStats(repo.mirror), description, saves: timeline(repo.mirror) }];25+ });26+}27+28+/** The newest saves across the library, checkpoints never among them. */29+export function recentSaves(library: LibraryRepo[], limit: number): RecentSave[] {30+ return library31+ .flatMap((repo) => repo.saves.slice(-limit).map((save) => ({ repo: repo.settings.name, save })))32+ .sort((a, b) => b.save.created_at - a.save.created_at)33+ .slice(0, limit);34+}35+36+export function repoCards(library: LibraryRepo[]): Html {37+ return html`<div class="card-grid">${library.map(repoCard)}</div>`;38+}39+40+function repoCard(repo: LibraryRepo): Html {41+ const { name, category } = repo.settings;42+ const { stats } = repo;43+ return html`<a class="repo-card" href="/${name}">44+<div class="repo-card-head"><span class="repo-card-name">${name}</span><span class="repo-card-saves mono">${plural(stats.saves, "save")}</span></div>45+${repo.description && html`<p class="repo-card-desc">${repo.description}</p>`}46+${ticks(repo.saves)}47+<div class="repo-card-foot">${category && html`<span class="cat-chip">${category}</span>`}${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>48+</a>`;49+}50+51+// §21.2's mini-timeline: one tick per save, placed by time like the repo page's dots.52+function ticks(saves: Save[]): Html {53+ const first = saves[0]?.created_at ?? 0;54+ const span = (saves[saves.length - 1]?.created_at ?? 0) - first;55+ const lines = saves.map((save) => {56+ const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;57+ return html`<line x1="${x.toFixed(2)}" x2="${x.toFixed(2)}" y1="9" y2="27"/>`;58+ });59+ return html`<svg class="repo-card-ticks" viewBox="0 0 100 36" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>`;60+}src/server/app.ts+2 −0
@@ -8,6 +8,7 @@88 import { registerIssuePages } from "../issues/pages.ts";99 import { registerArchivesPage } from "../pages/archives.ts";1010 import { registerFileRoutes } from "../pages/files.ts";11+import { registerFrontPages } from "../pages/front.ts";1112 import { registerHousekeeping } from "../pages/housekeeping.ts";1213 import { registerReleasesPage } from "../pages/releases.ts";1314 import { registerRepoPage } from "../pages/repo.ts";@@ -55,6 +56,7 @@5556 });5657 const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };5758 registerHousekeeping(app, ctx);59+ registerFrontPages(app, ctx);5860 registerRepoPage(app, ctx);5961 registerFileRoutes(app, ctx);6062 registerSavePage(app, ctx);tests/front.test.ts+141 −0
@@ -0,0 +1,141 @@1+import type { Database } from "bun:sqlite";2+import { beforeAll, describe, expect, test } from "bun:test";3+import { mkdtemp, unlink, writeFile } from "node:fs/promises";4+import { tmpdir } from "node:os";5+import { join } from "node:path";6+import type { Hono } from "hono";7+import { openBucket } from "../src/bucket/reader.ts";8+import { syncAll } from "../src/bucket/sync.ts";9+import { openMirror } from "../src/mirror/store.ts";10+import { createApp } from "../src/server/app.ts";11+import { openKeprDb } from "../src/server/db.ts";12+import { makeRepos } from "./fixture.ts";13+14+let app: Hono;15+let kepr: Database;16+let dataDir: string;17+let alphaSaves: { id: string; message: string; is_checkpoint: boolean }[];18+let vaultSave: string;19+const body = async (path: string) => (await app.request(path)).text();20+21+// alpha (public): a save, a checkpoint holding a draft no real save keeps, then a second save.22+// vault (private): one save whose message, category, language and ID must never show.23+beforeAll(async () => {24+ const repos = await makeRepos(["alpha", "vault"]);25+ const root = join(repos.bucketDir, "..");26+ await writeFile(join(root, "work/alpha/draft.txt"), "a draft only a checkpoint holds\n");27+ repos.koh("alpha", "checkpoint", "checkpoint message never shown");28+ await unlink(join(root, "work/alpha/draft.txt"));29+ await writeFile(join(root, "work/alpha/README.md"), "# alpha\n\nThe alpha project, grown.\n");30+ await writeFile(join(root, "work/alpha/main.ts"), "export const a = 1;\n");31+ repos.koh("alpha", "save", "grow alpha");32+ await writeFile(join(root, "work/vault/main.zig"), "const x = 1;\n");33+ repos.koh("vault", "save", "vault message");34+ alphaSaves = JSON.parse(repos.koh("alpha", "log", "--json")).saves;35+ vaultSave = JSON.parse(repos.koh("vault", "log", "--json")).head_id;36+37+ dataDir = await mkdtemp(join(tmpdir(), "kepr-front-"));38+ kepr = openKeprDb(dataDir);39+ const bucket = openBucket({ kind: "folder", path: repos.bucketDir });40+ await syncAll({41+ bucket,42+ kepr,43+ mirrorDir: join(dataDir, "mirror"),44+ tarballDir: join(dataDir, "tarballs"),45+ });46+ kepr.run("UPDATE repos SET public = 1, category = 'tools' WHERE name = 'alpha'");47+ kepr.run("UPDATE repos SET category = 'secretcat' WHERE name = 'vault'");48+ for (const [key, value] of [49+ ["handle", "ada"],50+ ["tagline", "A library of small tools."],51+ ["bio", "Writes small tools."],52+ ["location", "Lisbon"],53+ ["link", "https://ada.example"],54+ ["building_since", "may 2026"],55+ ]) {56+ kepr.query("INSERT INTO settings (key, value) VALUES (?, ?)").run(key, value);57+ }58+ kepr.run(59+ `INSERT INTO issues (repo, number, title, body, type, filed_at, hidden) VALUES60+ ('alpha', 1, 'a', 'b', 'bug', 1, 0), ('alpha', 2, 'c', 'd', 'bug', 1, 1),61+ ('vault', 1, 'e', 'f', 'bug', 1, 0)`,62+ );63+ kepr.run(64+ `INSERT INTO downloads (repo, version, platform, count) VALUES65+ ('alpha', '1.0.0', 'linux-x86_64', 7), ('alpha', '0.9.0', 'linux-x86_64', 100),66+ ('vault', '1.0.0', 'linux-x86_64', 1000)`,67+ );68+ kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('alpha', '0.9.0', 1)");69+ app = createApp(70+ {71+ domain: "kepr.example",72+ dataDir,73+ adminPasswordHash: "$argon2id$x",74+ bucket: { kind: "folder", path: repos.bucketDir },75+ },76+ kepr,77+ );78+});79+80+const checkpoint = () => alphaSaves.find((save) => save.is_checkpoint)?.id as string;81+const realSaves = () => alphaSaves.filter((save) => !save.is_checkpoint);82+83+describe("front page", () => {84+ test("counts distinct objects of public repos' real saves only", async () => {85+ // alpha's real saves hold .kohignore, two READMEs and main.ts; its mirror also holds the86+ // draft only the checkpoint kept, and the private repo's objects are elsewhere.87+ const mirror = openMirror(join(dataDir, "mirror"), "alpha");88+ expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 5 });89+ expect(await body("/")).toContain("The library knows<br>4 things.");90+ });91+92+ test("the ticker, cloud and cards show public saves, never checkpoints or private repos", async () => {93+ const html = await body("/");94+ expect(html).toContain('<div class="ticker" aria-hidden="true">');95+ expect(html).toContain("<span>grow alpha</span>");96+ expect(html).toContain("<span>release alpha</span>");97+ expect(html).toContain('class="tc-tag tc-5">tools<');98+ expect(html).toContain('class="tc-tag tc-5">TypeScript<');99+ expect(html).toContain('<span class="repo-card-saves mono">2 saves</span>');100+ expect(html).toContain("The alpha project, grown.");101+ expect(html).toContain("A library of small tools.");102+ expect(html).toContain('href="/docs">run your own library →</a>');103+ for (const secret of [104+ "checkpoint message",105+ "vault",106+ "secretcat",107+ "Zig",108+ checkpoint(),109+ vaultSave,110+ ]) {111+ expect(html).not.toContain(secret);112+ }113+ });114+115+ test("the nav has browse, docs and support, and no login link", async () => {116+ const html = await body("/");117+ expect(html).toContain(118+ '<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a>',119+ );120+ expect(html).not.toContain("sign-in");121+ });122+123+ test("browse lists public repos as cards", async () => {124+ const html = await body("/browse");125+ expect(html).toContain('<a class="repo-card" href="/alpha">');126+ expect(html).not.toContain("vault");127+ // One tick per real save.128+ expect(html.match(/<line x1=/g)?.length).toBe(realSaves().length);129+ });130+});131+132+test("motion stops under prefers-reduced-motion", async () => {133+ const css = await Bun.file(join(import.meta.dir, "../public/css/kepr.css")).text();134+ const blocks = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]135+ .map((m) => m[1] as string)136+ .join("\n");137+ for (const selector of [".ticker-row", ".ticker-track", ".tri-outer"]) {138+ expect(blocks).toContain(selector);139+ }140+ expect(blocks).toContain("animation: none;");141+});