9a87fc577759

add the operator's profile at /@<handle>: the hex backdrop of real save ids with its glow island, the signature image served from data/assets or the handle in sacramento, settings-driven identity, public stats, the language bar, recent activity and repo cards, and a 404 for any other handle (v3.0.31)

time
2026-09-26 20:00 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
b623f609221b
8 files package.json+1 −1public/css/kepr.css+304 −2public/js/kepr.js+16 −1src/issues/store.ts+11 −0src/pages/profile.ts+134 −0src/releases/releases.ts+14 −0src/server/app.ts+7 −1tests/front.test.ts+68 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.30",3+	"version": "3.0.31",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+304 −2
@@ -1605,9 +1605,288 @@16051605 	color: var(--text-faint);16061606 }16071607 1608+/* ----- profile ------------------------------------------------------------ */1609+1610+/* The cover runs the full width of the window while the page keeps its column. */1611+.profile-cover {1612+	position: relative;1613+	height: 380px;1614+	margin: -24px calc(50% - 50vw) 0;1615+	overflow: hidden;1616+	border-bottom: 1px solid var(--bg-surface);1617+}1618+.hex-backdrop {1619+	position: absolute;1620+	inset: 0;1621+	display: flex;1622+	flex-direction: row-reverse;1623+	justify-content: center;1624+	align-items: center;1625+	gap: 13px;1626+	pointer-events: none;1627+	user-select: none;1628+	font-family: var(--mono);1629+	font-size: 13px;1630+	line-height: 16px;1631+	color: var(--accent-dim);1632+}1633+/* Characters top to bottom, columns right to left, each breathing on its own clock. */1634+.hex-col {1635+	writing-mode: vertical-rl;1636+	text-orientation: upright;1637+	letter-spacing: 3px;1638+	opacity: 0.05;1639+	animation: hex-breathe 17s ease-in-out infinite;1640+	transition:1641+		color 1.6s ease,1642+		text-shadow 1.6s ease;1643+}1644+.hex-col:nth-child(3n) {1645+	color: var(--accent);1646+	animation-duration: 23s;1647+	animation-delay: -7s;1648+}1649+.hex-col:nth-child(4n + 1) {1650+	animation-duration: 29s;1651+	animation-delay: -13s;1652+}1653+.hex-col:nth-child(5n + 2) {1654+	animation-duration: 19s;1655+	animation-delay: -3s;1656+}1657+.hex-col:nth-child(7n) {1658+	animation-delay: -21s;1659+}1660+.hex-col.glow {1661+	color: var(--accent-bright);1662+	text-shadow: 0 0 6px var(--accent-tint-12);1663+}1664+@keyframes hex-breathe {1665+	0%,1666+	100% {1667+		opacity: 0.05;1668+	}1669+	50% {1670+		opacity: 0.22;1671+	}1672+}1673+.profile-cover-fade {1674+	position: absolute;1675+	left: 0;1676+	right: 0;1677+	bottom: 0;1678+	height: 55%;1679+	background: linear-gradient(to bottom, transparent, var(--bg));1680+	pointer-events: none;1681+}1682+.profile-identity {1683+	position: absolute;1684+	bottom: 40px;1685+	left: 0;1686+	right: 0;1687+	max-width: 980px;1688+	margin: 0 auto;1689+	padding: 0 48px;1690+}1691+.profile-username {1692+	margin: 0 0 8px;1693+	font-family: "Sacramento", cursive;1694+	font-weight: 400;1695+	font-size: 3.625rem;1696+	line-height: 1;1697+	letter-spacing: -0.02em;1698+	text-transform: lowercase;1699+	color: var(--text-heading);1700+	user-select: none;1701+	animation: fadeUp 500ms ease both;1702+}1703+.profile-signature {1704+	display: block;1705+	max-height: 96px;1706+	max-width: min(360px, 100%);1707+}1708+.profile-tagline {1709+	margin: 0 0 6px;1710+	font-family: var(--mono);1711+	font-size: 0.75rem;1712+	letter-spacing: 0.15em;1713+	color: var(--text-faint);1714+}1715+.profile-op-badge {1716+	display: block;1717+	font-size: 0.6875rem;1718+	letter-spacing: 0.18em;1719+	color: var(--accent);1720+}1721+1722+.profile-portrait-section {1723+	display: grid;1724+	grid-template-columns: 320px minmax(0, 1fr);1725+	gap: 40px;1726+	align-items: start;1727+	padding-top: 40px;1728+}1729+.profile-left {1730+	display: flex;1731+	flex-direction: column;1732+	gap: 16px;1733+}1734+.profile-identity-card,1735+.profile-languages-bar {1736+	padding: 20px 24px;1737+	border: 1px solid var(--bg-elevated);1738+	border-radius: 4px;1739+}1740+.profile-identity-bio {1741+	margin: 0;1742+	font-size: 0.875rem;1743+	line-height: 1.5;1744+	color: var(--text-muted);1745+}1746+.profile-identity-location {1747+	margin: 6px 0 0;1748+	font-family: var(--mono);1749+	font-size: 0.8125rem;1750+	color: var(--text-muted);1751+}1752+.profile-website-pill {1753+	display: inline-block;1754+	max-width: 100%;1755+	margin-top: 10px;1756+	padding: 3px 8px;1757+	overflow: hidden;1758+	text-overflow: ellipsis;1759+	white-space: nowrap;1760+	border: 1px solid var(--text-faint);1761+	border-radius: 3px;1762+	background: var(--bg-surface);1763+	font-family: var(--mono);1764+	font-size: 0.6875rem;1765+	color: var(--accent);1766+}1767+.profile-website-pill:hover {1768+	border-color: var(--accent);1769+	text-decoration: none;1770+}1771+.profile-stats-grid {1772+	display: grid;1773+	grid-template-columns: 1fr 1fr;1774+	border: 1px solid var(--bg-elevated);1775+	border-radius: 4px;1776+	overflow: hidden;1777+}1778+.profile-stat-tile {1779+	padding: 16px;1780+	border-right: 1px solid var(--bg-elevated);1781+	border-bottom: 1px solid var(--bg-elevated);1782+}1783+.profile-stat-tile:nth-child(2n) {1784+	border-right: none;1785+}1786+.profile-stat-tile:nth-last-child(-n + 2) {1787+	border-bottom: none;1788+}1789+.profile-stat-number {1790+	margin-bottom: 4px;1791+	font-weight: 600;1792+	font-size: 1.75rem;1793+	line-height: 1;1794+	color: var(--accent);1795+}1796+.profile-stat-label,1797+.profile-section-label {1798+	font-family: var(--mono);1799+	font-size: 0.625rem;1800+	text-transform: uppercase;1801+	letter-spacing: 0.08em;1802+	color: var(--text-faint);1803+}1804+.profile-lang-bar {1805+	display: block;1806+	width: 100%;1807+	height: 6px;1808+	margin-bottom: 8px;1809+	border-radius: 3px;1810+	background: var(--bg-surface);1811+}1812+.profile-lang-bar rect {1813+	fill: var(--accent);1814+	opacity: 0.6;1815+}1816+.profile-lang-bar rect:nth-child(2) {1817+	fill: var(--sage);1818+}1819+.profile-lang-bar rect:nth-child(3) {1820+	fill: var(--accent-flash);1821+	opacity: 0.45;1822+}1823+.profile-lang-bar rect:nth-child(4) {1824+	fill: var(--accent-dim);1825+}1826+.profile-lang-labels {1827+	display: flex;1828+	flex-wrap: wrap;1829+	gap: 10px;1830+	font-family: var(--mono);1831+	font-size: 0.625rem;1832+	color: var(--text-muted);1833+}1834+.profile-section-label {1835+	margin-bottom: 16px;1836+	letter-spacing: 0.2em;1837+}1838+.profile-event {1839+	display: grid;1840+	grid-template-columns: 96px 12px minmax(0, 1fr) auto;1841+	gap: 10px;1842+	align-items: baseline;1843+	padding: 7px 0;1844+	border-bottom: 1px solid var(--bg-surface);1845+	font-family: var(--mono);1846+	font-size: 0.6875rem;1847+	animation: fadeUp 300ms ease both;1848+}1849+.profile-event-repo,1850+.profile-event-message {1851+	overflow: hidden;1852+	text-overflow: ellipsis;1853+	white-space: nowrap;1854+}1855+.profile-event-repo,1856+.profile-event-glyph {1857+	color: var(--accent);1858+}1859+.profile-event-glyph {1860+	font-size: 0.625rem;1861+}1862+.profile-event-message {1863+	color: var(--text-muted);1864+}1865+.profile-event-message:hover {1866+	color: var(--text-heading);1867+}1868+.profile-event-time {1869+	font-size: 0.625rem;1870+	white-space: nowrap;1871+	color: var(--text-faint);1872+}1873+.profile-empty-state {1874+	font-style: italic;1875+	font-size: 0.8125rem;1876+	color: var(--text-faint);1877+}1878+.profile-repos-section {1879+	margin-top: 40px;1880+	padding-top: 40px;1881+	border-top: 1px solid var(--bg-surface);1882+}1883+16081884 @media (max-width: 860px) {1609-	.headline {1885+	.headline,1886+	.profile-portrait-section {16101887 		grid-template-columns: minmax(0, 1fr);1888+	}1889+	.headline {16111890 		gap: 8px;16121891 		padding-top: 48px;16131892 	}@@ -1621,6 +1900,23 @@16211900 		margin-top: -88px;16221901 		padding-top: 80px;16231902 	}1903+	.profile-cover {1904+		height: 280px;1905+		margin-top: -16px;1906+	}1907+	.profile-identity {1908+		bottom: 24px;1909+		padding: 0 16px;1910+	}1911+	.profile-username {1912+		font-size: 2.5rem;1913+	}1914+	.profile-event {1915+		grid-template-columns: 72px 12px minmax(0, 1fr);1916+	}1917+	.profile-event-time {1918+		display: none;1919+	}16241920 }16251921 16261922 /* Last in the file, after every animation it stops, so it wins without !important. */@@ -1639,10 +1935,16 @@16391935 	.save-message-head,16401936 	.ticker-row,16411937 	.ticker-track,1938+	.hex-col,16421939 	.headline h1,16431940 	.headline-tagline,16441941 	.headline-cloud,1645-	.repo-card {1942+	.repo-card,1943+	.profile-username,1944+	.profile-event {16461945 		animation: none;1946+	}1947+	.hex-col {1948+		opacity: 0.12;16471949 	}16481950 }

view file

public/js/kepr.js+16 −1
@@ -1,5 +1,6 @@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 and the Atlas panel and filter.2+// styled timeline tooltip, copy buttons, line-range highlighting, the Atlas panel and filter,3+// and the profile backdrop's glow.34 45 function timelineTips() {56 	const dots = document.querySelectorAll(".timeline .dot");@@ -140,7 +141,21 @@140141 	} catch {}141142 }142143 144+// 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.146+function hexGlow() {147+	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);155+}156+143157 timelineTips();144158 copyButtons();145159 lineRanges();146160 atlas();161+hexGlow();

view file

src/issues/store.ts+11 −0
@@ -135,3 +135,14 @@135135 			.run(issueId, author, body, at).lastInsertRowid,136136 	);137137 }138+139+/** Issues anyone can see, across every public repo: the profile's count (§21.2). */140+export function publicIssueCount(db: Database): number {141+	return (142+		db143+			.query<{ n: number }, []>(144+				"SELECT COUNT(*) AS n FROM issues i JOIN repos r ON r.name = i.repo WHERE r.public = 1 AND i.hidden = 0",145+			)146+			.get()?.n ?? 0147+	);148+}

view file

src/pages/profile.ts+134 −0
@@ -0,0 +1,134 @@1+import type { Hono } from "hono";2+import { isSafePath } from "../bucket/repo.ts";3+import { publicIssueCount } from "../issues/store.ts";4+import { languageLines } from "../mirror/queries.ts";5+import { publicDownloadCount } from "../releases/releases.ts";6+import { setting } from "../server/db.ts";7+import { type Html, html } from "./html.ts";8+import { cacheFor, notFound, type PageContext, page, relativeTime } from "./layout.ts";9+import {10+	type LibraryRepo,11+	publicLibrary,12+	type RecentSave,13+	recentSaves,14+	repoCards,15+} from "./library.ts";16+17+const HEX_COLUMNS = 48;18+const RECENT_ACTIVITY = 20;19+const BAR_LANGUAGES = 4;20+const HTTP_URL = /^https?:\/\//i;21+22+/** §21.2's profile of the one operator; any other handle is a 404. */23+export function registerProfilePage(app: Hono, ctx: PageContext): void {24+	app.get("/:handle{@[^/]+}", (c) => {25+		const handle = setting(ctx.kepr, "handle") ?? "";26+		if (handle === "" || c.req.param("handle") !== `@${handle}`) return notFound(c, ctx);27+		const library = publicLibrary(ctx);28+		const bio = setting(ctx.kepr, "bio") ?? "";29+		cacheFor(c, false);30+		return c.html(31+			page(ctx, {32+				title: `@${handle}`,33+				description: bio || `@${handle}'s repositories.`,34+				path: `/@${handle}`,35+				body: html`<div class="profile">36+${cover(ctx, handle, recentSaves(library, HEX_COLUMNS))}37+<section class="profile-portrait-section">38+<div class="profile-left">39+${identityCard(ctx, bio)}40+${stats(ctx, library)}41+${languageBar(library)}42+</div>43+<div class="profile-right">44+<div class="profile-section-label">recent activity</div>45+${activity(recentSaves(library, RECENT_ACTIVITY))}46+</div>47+</section>48+<section class="profile-repos-section">49+<div class="profile-section-label">repos · ${library.length}</div>50+${repoCards(library)}51+</section>52+</div>`,53+			}),54+		);55+	});56+}57+58+function cover(ctx: PageContext, handle: string, recent: RecentSave[]): Html {59+	const since = setting(ctx.kepr, "building_since");60+	const signature = setting(ctx.kepr, "signature");61+	// The backdrop repeats the newest IDs when there are fewer saves than columns.62+	const columns =63+		recent.length === 064+			? []65+			: Array.from({ length: HEX_COLUMNS }, (_, i) => recent[i % recent.length]?.save.id);66+	// Without an uploaded signature, the handle in Sacramento stands in for it, as on Kepr 2.67+	const name =68+		signature && isSafePath(signature) && !signature.includes("/")69+			? html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="${handle}">`70+			: handle;71+	return html`<section class="profile-cover">72+<div class="hex-backdrop" aria-hidden="true">${columns.map((id) => html`<span class="hex-col">${id}</span>`)}</div>73+<div class="profile-cover-fade"></div>74+<div class="profile-identity">75+<h1 class="profile-username">${name}</h1>76+${since && html`<p class="profile-tagline">building since ${since}</p>`}77+<span class="profile-op-badge mono">◈ operator</span>78+</div>79+</section>`;80+}81+82+function identityCard(ctx: PageContext, bio: string): Html | null {83+	const location = setting(ctx.kepr, "location");84+	const link = setting(ctx.kepr, "link") ?? "";85+	const hasLink = HTTP_URL.test(link);86+	if (!bio && !location && !hasLink) return null;87+	return html`<div class="profile-identity-card">88+${bio && html`<p class="profile-identity-bio">${bio}</p>`}89+${location && html`<p class="profile-identity-location">${location}</p>`}90+${hasLink && html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`}91+</div>`;92+}93+94+function stats(ctx: PageContext, library: LibraryRepo[]): Html {95+	const tiles: [string, number][] = [96+		["repos", library.length],97+		["saves", library.reduce((sum, repo) => sum + repo.stats.saves, 0)],98+		["issues", publicIssueCount(ctx.kepr)],99+		["downloads", publicDownloadCount(ctx.kepr)],100+	];101+	return html`<div class="profile-stats-grid">${tiles.map(([label, n]) => html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`)}</div>`;102+}103+104+// Lines per language summed across public repos, as SVG so the widths need no inline style (§25).105+function languageBar(library: LibraryRepo[]): Html | null {106+	const lines = new Map<string, number>();107+	for (const repo of library) {108+		for (const [language, n] of languageLines(repo.mirror)) {109+			lines.set(language, (lines.get(language) ?? 0) + n);110+		}111+	}112+	const top = [...lines].sort((a, b) => b[1] - a[1]).slice(0, BAR_LANGUAGES);113+	const total = top.reduce((sum, [, n]) => sum + n, 0);114+	if (total === 0) return null;115+	let x = 0;116+	const segments = top.map(([language, n]) => {117+		const width = (100 * n) / total;118+		const segment = html`<rect x="${x.toFixed(2)}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;119+		x += width;120+		return segment;121+	});122+	return html`<div class="profile-languages-bar">123+<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${segments}</svg>124+<div class="profile-lang-labels">${top.slice(0, 3).map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>125+</div>`;126+}127+128+function activity(recent: RecentSave[]): Html {129+	if (recent.length === 0) return html`<p class="profile-empty-state">no activity yet</p>`;130+	return html`<div class="profile-timeline">${recent.map(131+		({ repo, save }) =>132+			html`<div class="profile-event"><a class="profile-event-repo" href="/${repo}">${repo}</a><span class="profile-event-glyph">✦</span><a class="profile-event-message" href="/${repo}/saves/${save.id}">${save.message.split("\n")[0]}</a><span class="profile-event-time">${relativeTime(save.created_at)}</span></div>`,133+	)}</div>`;134+}

view file

src/releases/releases.ts+14 −0
@@ -96,3 +96,17 @@9696 		.all(repo);9797 	return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));9898 }99+100+/** Downloads of every visible release of every public repo: the profile's count (§21.2). */101+export function publicDownloadCount(kepr: Database): number {102+	return (103+		kepr104+			.query<{ n: number }, []>(105+				`SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d106+				JOIN repos r ON r.name = d.repo AND r.public = 1107+				LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version108+				WHERE COALESCE(s.hidden, 0) = 0`,109+			)110+			.get()?.n ?? 0111+	);112+}

view file

src/server/app.ts+7 −1
@@ -10,6 +10,7 @@1010 import { registerFileRoutes } from "../pages/files.ts";1111 import { registerFrontPages } from "../pages/front.ts";1212 import { registerHousekeeping } from "../pages/housekeeping.ts";13+import { registerProfilePage } from "../pages/profile.ts";1314 import { registerReleasesPage } from "../pages/releases.ts";1415 import { registerRepoPage } from "../pages/repo.ts";1516 import { registerSavePage } from "../pages/save.ts";@@ -47,9 +48,13 @@4748 	app.use(4849 		bodyLimit({ maxSize: MAX_BODY_BYTES, onError: (c) => c.text("Request body too large", 413) }),4950 	);51+	// `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature.52+	const assetsDir = join(config.dataDir, "assets");5053 	app.get("/static/:path{.+}", async (c) => {5154 		const path = c.req.param("path");52-		const file = Bun.file(join(PUBLIC_DIR, path));55+		const file = Bun.file(56+			path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path),57+		);5358 		if (!isSafePath(path) || !(await file.exists())) return c.notFound();5459 		c.header("Cache-Control", "public, max-age=3600");5560 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });@@ -57,6 +62,7 @@5762 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };5863 	registerHousekeeping(app, ctx);5964 	registerFrontPages(app, ctx);65+	registerProfilePage(app, ctx);6066 	registerRepoPage(app, ctx);6167 	registerFileRoutes(app, ctx);6268 	registerSavePage(app, ctx);

view file

tests/front.test.ts+68 −2
@@ -1,6 +1,6 @@11 import type { Database } from "bun:sqlite";22 import { beforeAll, describe, expect, test } from "bun:test";3-import { mkdtemp, unlink, writeFile } from "node:fs/promises";3+import { mkdir, mkdtemp, unlink, writeFile } from "node:fs/promises";44 import { tmpdir } from "node:os";55 import { join } from "node:path";66 import type { Hono } from "hono";@@ -129,12 +129,78 @@129129 	});130130 });131131 132+describe("profile", () => {133+	test("a handle that isn't the configured one is a 404", async () => {134+		expect((await app.request("/@ada")).status).toBe(200);135+		expect((await app.request("/@someone")).status).toBe(404);136+		expect((await app.request("/@")).status).toBe(404);137+	});138+139+	test("stats leave out private repos, checkpoints, hidden issues and hidden releases", async () => {140+		const numbers = [...(await body("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(141+			(m) => m[1],142+		);143+		// repos, saves, issues, downloads144+		expect(numbers).toEqual(["1", String(realSaves().length), "1", "7"]);145+	});146+147+	test("recent activity and the hex backdrop use real public saves only", async () => {148+		const html = await body("/@ada");149+		for (const save of realSaves()) {150+			expect(html).toContain(`<span class="hex-col">${save.id}</span>`);151+			expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);152+		}153+		expect(html).not.toContain(checkpoint());154+		expect(html).not.toContain(vaultSave);155+		expect(html).not.toContain("vault");156+		expect(html).not.toContain("Zig");157+	});158+159+	test("shows the settings, the handle in the signature font, and the language bar", async () => {160+		const html = await body("/@ada");161+		expect(html).toContain('<h1 class="profile-username">ada</h1>');162+		expect(html).toContain("building since may 2026");163+		expect(html).toContain("◈ operator");164+		expect(html).toContain("Writes small tools.");165+		expect(html).toContain("Lisbon");166+		expect(html).toContain('href="https://ada.example"');167+		expect(html).toContain('<span class="profile-lang-label">TypeScript</span>');168+		expect(html).toContain('<meta property="og:title" content="@ada — kepr">');169+	});170+171+	test("an uploaded signature is served from data/assets and replaces the handle", async () => {172+		await mkdir(join(dataDir, "assets"), { recursive: true });173+		await writeFile(join(dataDir, "assets/sig.svg"), "<svg xmlns='http://www.w3.org/2000/svg'/>");174+		kepr.run("INSERT INTO settings (key, value) VALUES ('signature', 'sig.svg')");175+		try {176+			expect(await body("/@ada")).toContain(177+				'<img class="profile-signature" src="/static/assets/sig.svg" alt="ada">',178+			);179+			const asset = await app.request("/static/assets/sig.svg");180+			expect(asset.status).toBe(200);181+			expect(asset.headers.get("content-type")).toContain("image/svg+xml");182+			expect((await app.request("/static/assets/../kepr.db")).status).toBe(404);183+		} finally {184+			kepr.run("DELETE FROM settings WHERE key = 'signature'");185+		}186+	});187+188+	test("with no handle set there is no profile", async () => {189+		kepr.run("UPDATE settings SET value = '' WHERE key = 'handle'");190+		try {191+			expect((await app.request("/@ada")).status).toBe(404);192+		} finally {193+			kepr.run("UPDATE settings SET value = 'ada' WHERE key = 'handle'");194+		}195+	});196+});197+132198 test("motion stops under prefers-reduced-motion", async () => {133199 	const css = await Bun.file(join(import.meta.dir, "../public/css/kepr.css")).text();134200 	const blocks = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]135201 		.map((m) => m[1] as string)136202 		.join("\n");137-	for (const selector of [".ticker-row", ".ticker-track", ".tri-outer"]) {203+	for (const selector of [".ticker-row", ".ticker-track", ".hex-col", ".tri-outer"]) {138204 		expect(blocks).toContain(selector);139205 	}140206 	expect(blocks).toContain("animation: none;");

view file