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
↓ kepr-9a87fc57.tar.gzatlas at this save
files changed8 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 −2package.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": {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 }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();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+}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+}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+}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);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;");