fba6c5b73ce5

stream real public save IDs gently behind the profile (v3.1.20)

time
2026-10-08 17:35 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
gentle profile save stream
parent
e2328c2de1b6
5 files package.json+1 −1public/css/kepr.css+35 −23public/js/kepr.js+12 −21src/pages/profile.ts+10 −5tests/front.test.ts+7 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.19",3+	"version": "3.1.20",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+35 −23
@@ -2023,7 +2023,7 @@20232023 	display: flex;20242024 	flex-direction: row-reverse;20252025 	justify-content: center;2026-	align-items: center;2026+	align-items: flex-start;20272027 	gap: 13px;20282028 	pointer-events: none;20292029 	user-select: none;@@ -2032,44 +2032,56 @@20322032 	line-height: 16px;20332033 	color: var(--accent-dim);20342034 }2035-/* Characters top to bottom, columns right to left, each breathing on its own clock. */2035+/* Matching groups keep every column continuous across the loop boundary. */20362036 .hex-col {2037+	display: flex;2038+	flex-direction: column;2039+	flex-shrink: 0;2040+	opacity: 0.22;2041+	animation: hex-stream 120s linear infinite;2042+	animation-delay: -43s;2043+}2044+.hex-group {2045+	display: flex;2046+	flex-direction: column;2047+	gap: 36px;2048+	padding-bottom: 36px;2049+}2050+.hex-id {20372051 	writing-mode: vertical-rl;20382052 	text-orientation: upright;20392053 	letter-spacing: 3px;2040-	opacity: 0.05;2041-	animation: hex-breathe 17s ease-in-out infinite;2042-	transition:2043-		color 1.6s ease,2044-		text-shadow 1.6s ease;2054+	opacity: 0.45;20452055 }2056+.hex-id:first-child {2057+	color: var(--accent);2058+	opacity: 1;2059+}20462060 .hex-col:nth-child(3n) {20472061 	color: var(--accent);2048-	animation-duration: 23s;2049-	animation-delay: -7s;2062+	animation-duration: 144s;2063+	animation-delay: -77s;20502064 }20512065 .hex-col:nth-child(4n + 1) {2052-	animation-duration: 29s;2053-	animation-delay: -13s;2066+	animation-duration: 160s;2067+	animation-delay: -119s;20542068 }20552069 .hex-col:nth-child(5n + 2) {2056-	animation-duration: 19s;2057-	animation-delay: -3s;2070+	animation-duration: 108s;2071+	animation-delay: -19s;20582072 }20592073 .hex-col:nth-child(7n) {2060-	animation-delay: -21s;2074+	animation-delay: -91s;20612075 }2062-.hex-col.glow {2063-	color: var(--text-heading);2064-	text-shadow: 0 0 6px rgba(255, 173, 51, 0.2);2076+.hex-backdrop.paused .hex-col {2077+	animation-play-state: paused;20652078 }2066-@keyframes hex-breathe {2067-	0%,2068-	100% {2069-		opacity: 0.05;2079+@keyframes hex-stream {2080+	from {2081+		transform: translateY(-50%);20702082 	}2071-	50% {2072-		opacity: 0.22;2083+	to {2084+		transform: translateY(0);20732085 	}20742086 }20752087 .profile-cover-fade {

view file

public/js/kepr.js+12 −21
@@ -1,7 +1,7 @@11 // Kepr's browser islands (§18). Every page is complete without this file; it only adds the22 // ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling,33 // line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in4-// editor, the phone nav menu, and the profile's glow.4+// editor, the phone nav menu, and the profile's visibility pause.55 66 // Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.77 const KINDS = [@@ -957,25 +957,16 @@957957 	} catch {}958958 }959959 960-// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as961-// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.962-function hexGlow() {963-	const columns = document.querySelectorAll(".hex-col");964-	if (columns.length === 0) return;965-	const reduce = matchMedia("(prefers-reduced-motion: reduce)");966-	let timer = null;967-	const update = () => {968-		clearInterval(timer);969-		for (const column of columns) column.classList.remove("glow");970-		if (reduce.matches) return;971-		timer = setInterval(() => {972-			const column = columns[Math.floor(Math.random() * columns.length)];973-			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;974-			column.classList.add("glow");975-			setTimeout(() => column.classList.remove("glow"), 3200);976-		}, 1500);977-	};978-	reduce.addEventListener("change", update);960+function hexStream() {961+	const backdrop = document.querySelector(".hex-backdrop");962+	if (!backdrop) return;963+	let visible = true;964+	const update = () => backdrop.classList.toggle("paused", !visible || document.hidden);965+	new IntersectionObserver(([entry]) => {966+		visible = entry.isIntersecting;967+		update();968+	}).observe(backdrop);969+	document.addEventListener("visibilitychange", update);979970 	update();980971 }981972 @@ -1001,4 +992,4 @@1001992 navMenu();1002993 lineRanges();1003994 atlas();1004-hexGlow();995+hexStream();

view file

src/pages/profile.ts+10 −5
@@ -58,11 +58,13 @@5858 function cover(ctx: PageContext, handle: string, recent: RecentSave[]): Html {5959 	const since = setting(ctx.kepr, "building_since");6060 	const signature = setting(ctx.kepr, "signature");61-	// The backdrop repeats the newest IDs when there are fewer saves than columns.61+	// Duplicate each group so the loop joins without replacing or inventing save IDs.6262 	const columns =6363 		recent.length === 06464 			? []65-			: Array.from({ length: HEX_COLUMNS }, (_, i) => recent[i % recent.length]?.save.id);65+			: Array.from({ length: HEX_COLUMNS }, (_, i) =>66+					Array.from({ length: 4 }, (_, j) => recent[(i + j) % recent.length]?.save.id),67+				);6668 	// Without an uploaded signature, the handle in Sacramento stands in for it, as on Kepr 2.6769 	const name =6870 		signature && isSafePath(signature) && !signature.includes("/")@@ -71,9 +73,12 @@7173 				)}" alt="${handle}">`7274 			: handle;7375 	return html`<section class="profile-cover">74-<div class="hex-backdrop" aria-hidden="true">${columns.map(75-		(id) => html`<span class="hex-col">${id}</span>`,76-	)}</div>76+<div class="hex-backdrop" aria-hidden="true">${columns.map((ids) => {77+		const group = html`<span class="hex-group">${ids.map(78+			(id) => html`<span class="hex-id">${id}</span>`,79+		)}</span>`;80+		return html`<span class="hex-col">${group}${group}</span>`;81+	})}</div>7782 <div class="profile-cover-fade"></div>7883 <div class="profile-identity">7984 <h1 class="profile-username">${name}</h1>

view file

tests/front.test.ts+7 −1
@@ -165,8 +165,14 @@165165 		expect(html).toContain('class="profile-timeline" tabindex="0"');166166 		expect(html).toContain('role="region" aria-label="recent activity"');167167 		for (const save of realSaves()) {168-			expect(html).toContain(`<span class="hex-col">${save.id}</span>`);168+			expect(html).toContain(`<span class="hex-id">${save.id}</span>`);169169 			expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);170+		}171+		const groups = [...html.matchAll(/<span class="hex-group">([\s\S]*?)<\/span><\/span>/g)];172+		expect(groups).toHaveLength(96);173+		for (let i = 0; i < groups.length; i += 2) {174+			expect(groups[i]?.[1]).toBe(groups[i + 1]?.[1]);175+			expect(groups[i]?.[1].match(/class="hex-id"/g)).toHaveLength(4);170176 		}171177 		for (const id of checkpoints()) expect(html).not.toContain(id);172178 		expect(html).not.toContain(vaultSave);

view file