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
9 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 −0
package.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": {

view file

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 }

view file

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 db

view file

src/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 }

view file

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+}

view file

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">

view file

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+}

view file

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);

view file

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+});

view file