1c735cca38e4

count public lines and languages at the newest real save so a checkpoint at head never reaches the cloud, bar, cards or header, follow reduced-motion changes in the hex glow, and test encoded traversal and nested asset paths (v3.0.33)

time
2026-09-26 20:10 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
53b804b9db21
5 files package.json+1 −1public/js/kepr.js+17 −8src/mirror/queries.ts+1 −1src/mirror/store.ts+15 −7tests/front.test.ts+36 −6
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.32",3+	"version": "3.0.33",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/js/kepr.js+17 −8
@@ -142,16 +142,25 @@142142 }143143 144144 // The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as145-// Kepr 2's canvas did. Nothing moves under reduced motion.145+// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.146146 function hexGlow() {147147 	const columns = document.querySelectorAll(".hex-col");148-	if (columns.length === 0 || matchMedia("(prefers-reduced-motion: reduce)").matches) return;149-	setInterval(() => {150-		const column = columns[Math.floor(Math.random() * columns.length)];151-		if (document.querySelectorAll(".hex-col.glow").length >= 3) return;152-		column.classList.add("glow");153-		setTimeout(() => column.classList.remove("glow"), 3200);154-	}, 1500);148+	if (columns.length === 0) return;149+	const reduce = matchMedia("(prefers-reduced-motion: reduce)");150+	let timer = null;151+	const update = () => {152+		clearInterval(timer);153+		for (const column of columns) column.classList.remove("glow");154+		if (reduce.matches) return;155+		timer = setInterval(() => {156+			const column = columns[Math.floor(Math.random() * columns.length)];157+			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;158+			column.classList.add("glow");159+			setTimeout(() => column.classList.remove("glow"), 3200);160+		}, 1500);161+	};162+	reduce.addEventListener("change", update);163+	update();155164 }156165 157166 timelineTips();

view file

src/mirror/queries.ts+1 −1
@@ -162,7 +162,7 @@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. */165+/** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */166166 export function languageLines(db: Database): [string, number][] {167167 	return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];168168 }

view file

src/mirror/store.ts+15 −7
@@ -125,9 +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);128+	// Mirrors whose stats predate `stats_save` (per-language lines, counted past checkpoints) are129+	// recounted now, not at the next save.130+	if (mirrorState(db, "head") && mirrorState(db, "stats_save") === null) refreshStats(db);131131 	open.set(path, db);132132 	return db;133133 }@@ -198,7 +198,7 @@198198 		applyStrips(db);199199 		setState.run("head", landing.head);200200 		setState.run("releases_latest", landing.releasesLatest);201-		refreshStats(db, landing.head);201+		refreshStats(db);202202 	})();203203 }204204 @@ -226,13 +226,20 @@226226 	db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");227227 }228228 229-// Lines and language at `head` never change between syncs, so they are counted once here, not per view.230-function refreshStats(db: Database, head: string): void {229+// Lines and languages never change between syncs, so they are counted once here, not per view. They230+// come from the newest real save, not `head`: a checkpoint's files are not public history (§21.4).231+function refreshStats(db: Database): void {232+	const save =233+		db234+			.query<{ id: string }, []>(235+				"SELECT id FROM saves WHERE is_checkpoint = 0 ORDER BY rowid DESC LIMIT 1",236+			)237+			.get()?.id ?? "";231238 	const files = db232239 		.query<{ path: string; data: Uint8Array }, [string]>(233240 			"SELECT f.path, o.data FROM save_files f JOIN objects o ON o.hash = f.hash WHERE f.save_id = ?",234241 		)235-		.all(head);242+		.all(save);236243 	let lines = 0;237244 	const byLanguage = new Map<string, number>();238245 	for (const file of files) {@@ -251,4 +258,5 @@251258 	setState.run("lines", String(lines));252259 	setState.run("language", languages[0]?.[0] ?? "");253260 	setState.run("languages", JSON.stringify(languages));261+	setState.run("stats_save", save);254262 }

view file

tests/front.test.ts+36 −6
@@ -29,6 +29,10 @@2929 	await writeFile(join(root, "work/alpha/README.md"), "# alpha\n\nThe alpha project, grown.\n");3030 	await writeFile(join(root, "work/alpha/main.ts"), "export const a = 1;\n");3131 	repos.koh("alpha", "save", "grow alpha");32+	// HEAD is a checkpoint adding a new language: none of it may reach a public stat.33+	await writeFile(join(root, "work/alpha/tool.py"), "print(1)\n");34+	repos.koh("alpha", "checkpoint", "checkpoint at head adds python");35+	repos.koh("alpha", "offer");3236 	await writeFile(join(root, "work/vault/main.zig"), "const x = 1;\n");3337 	repos.koh("vault", "save", "vault message");3438 	alphaSaves = JSON.parse(repos.koh("alpha", "log", "--json")).saves;@@ -77,15 +81,15 @@7781 	);7882 });7983 80-const checkpoint = () => alphaSaves.find((save) => save.is_checkpoint)?.id as string;84+const checkpoints = () => alphaSaves.filter((save) => save.is_checkpoint).map((save) => save.id);8185 const realSaves = () => alphaSaves.filter((save) => !save.is_checkpoint);8286 8387 describe("front page", () => {8488 	test("counts distinct objects of public repos' real saves only", async () => {8589 		// 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.90+		// draft and tool.py only checkpoints kept, and the private repo's objects are elsewhere.8791 		const mirror = openMirror(join(dataDir, "mirror"), "alpha");88-		expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 5 });92+		expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 6 });8993 		expect(await body("/")).toContain("The library knows<br>4 things.");9094 	});9195 @@ -105,13 +109,26 @@105109 			"vault",106110 			"secretcat",107111 			"Zig",108-			checkpoint(),112+			...checkpoints(),109113 			vaultSave,110114 		]) {111115 			expect(html).not.toContain(secret);112116 		}113117 	});114118 119+	test("a checkpoint at head adds nothing to the public stats", async () => {120+		const mirror = openMirror(join(dataDir, "mirror"), "alpha");121+		const state = (key: string) => mirror.query("SELECT value FROM state WHERE key = ?").get(key);122+		expect(checkpoints()).toContain((state("head") as { value: string }).value);123+		// Lines and languages are counted at the newest real save, so tool.py is in neither.124+		expect(state("stats_save")).toEqual({ value: realSaves()[0]?.id });125+		for (const path of ["/", "/browse", "/@ada", "/alpha"]) {126+			const html = await body(path);127+			expect(html).toContain("TypeScript");128+			expect(html).not.toContain("Python");129+		}130+	});131+115132 	test("the nav has browse, docs and support, and no login link", async () => {116133 		const html = await body("/");117134 		expect(html).toContain(@@ -150,7 +167,7 @@150167 			expect(html).toContain(`<span class="hex-col">${save.id}</span>`);151168 			expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);152169 		}153-		expect(html).not.toContain(checkpoint());170+		for (const id of checkpoints()) expect(html).not.toContain(id);154171 		expect(html).not.toContain(vaultSave);155172 		expect(html).not.toContain("vault");156173 		expect(html).not.toContain("Zig");@@ -179,7 +196,20 @@179196 			const asset = await app.request("/static/assets/sig.svg");180197 			expect(asset.status).toBe(200);181198 			expect(asset.headers.get("content-type")).toContain("image/svg+xml");182-			expect((await app.request("/static/assets/../kepr.db")).status).toBe(404);199+			await mkdir(join(dataDir, "assets/logos"));200+			await writeFile(join(dataDir, "assets/logos/mark.svg"), "<svg/>");201+			expect((await app.request("/static/assets/logos/mark.svg")).status).toBe(200);202+			// `..` spelled so the URL parser leaves it for the route, and a backslash; kepr.db sits203+			// right above assets/, so any of these reaching the disk would serve it.204+			for (const path of [205+				"/static/assets/../kepr.db",206+				"/static/assets/..%2Fkepr.db",207+				"/static/assets/%2e%2e%2fkepr.db",208+				"/static/assets/..%5Ckepr.db",209+				"/static/assets%2F..%2Fkepr.db",210+			]) {211+				expect((await app.request(path)).status).toBe(404);212+			}183213 		} finally {184214 			kepr.run("DELETE FROM settings WHERE key = 'signature'");185215 		}

view file