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
↓ kepr-1c735cca.tar.gzatlas at this save
files changed5 files
package.json+1 −1public/js/kepr.js+17 −8src/mirror/queries.ts+1 −1src/mirror/store.ts+15 −7tests/front.test.ts+36 −6package.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": {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();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 }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 }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 }