prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 9f62143c4d933d3547cc92b82182a157a4aa1d6d
parent 114089d7423e6abdf8f4f1444689e6f2b333e7e4
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 01:57:18 +0000

feat(web): line-by-line reveal on tab switch and project detail

After an htmx swap of the content or project detail, visible lines
appear one after another within a ~450ms budget, like output being
printed. Skipped under prefers-reduced-motion.

Diffstat:
Minternal/web/scripts/app.js | 5+++++
Ainternal/web/scripts/modules/reveal.js | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/styles/layout.css | 7+++++++
3 files changed, 84 insertions(+), 0 deletions(-)

diff --git a/internal/web/scripts/app.js b/internal/web/scripts/app.js @@ -5,6 +5,7 @@ import { initHelp } from "./modules/help.js"; import { initLayout } from "./modules/layout.js"; import { initLightbox } from "./modules/lightbox.js"; import { initProjects } from "./modules/projects.js"; +import { revealLines } from "./modules/reveal.js"; function initApp() { initHeader(); @@ -25,8 +26,12 @@ function initApp() { switch (e.target.id) { case "content": + initDynamicContent(); + revealLines(e.target); + break; case "project-detail": initDynamicContent(); + revealLines(e.target); break; default: break; diff --git a/internal/web/scripts/modules/reveal.js b/internal/web/scripts/modules/reveal.js @@ -0,0 +1,72 @@ +const SKIP = new Set(["STYLE", "SCRIPT", "TEMPLATE", "NOSCRIPT"]); +const ATOMIC = new Set(["PRE", "IMG", "SVG", "VIDEO", "CANVAS", "IFRAME", "TABLE"]); +const BLOCKISH = /^(block|flex|grid|list-item|table|flow-root)$/; +const BUDGET_MS = 450; +const MAX_STEP_MS = 14; + +const pending = new Map(); + +export function revealLines(root) { + if (!root || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + for (const [r] of pending) { + if (r === root || r.contains(root) || root.contains(r)) cleanup(r); + } + + const lines = collectLines(root).filter(onScreen); + if (lines.length === 0) return; + + const step = Math.min(MAX_STEP_MS, BUDGET_MS / lines.length); + root.style.setProperty("--line-ms", `${step}ms`); + lines.forEach((el, i) => { + el.style.setProperty("--line", String(i)); + el.classList.add("line-in"); + }); + + const timer = window.setTimeout(() => cleanup(root), step * lines.length + 50); + pending.set(root, { lines, timer }); +} + +function cleanup(root) { + const entry = pending.get(root); + if (!entry) return; + pending.delete(root); + window.clearTimeout(entry.timer); + root.style.removeProperty("--line-ms"); + for (const el of entry.lines) { + el.classList.remove("line-in"); + el.style.removeProperty("--line"); + } +} + +function onScreen(el) { + const r = el.getBoundingClientRect(); + return r.bottom > 0 && r.top < window.innerHeight && r.width > 0; +} + +function collectLines(root) { + const lines = []; + const walk = (el) => { + for (const child of el.children) { + if (SKIP.has(child.tagName)) continue; + if (getComputedStyle(child).display === "none") continue; + if (isLine(child)) { + lines.push(child); + } else { + walk(child); + } + } + }; + walk(root); + return lines; +} + +function isLine(el) { + if (ATOMIC.has(el.tagName.toUpperCase())) return true; + if (el.classList.contains("tags") || el.classList.contains("tag")) return true; + for (const child of el.children) { + if (SKIP.has(child.tagName)) continue; + if (BLOCKISH.test(getComputedStyle(child).display)) return false; + } + return true; +} diff --git a/internal/web/styles/layout.css b/internal/web/styles/layout.css @@ -324,3 +324,10 @@ a:hover { min-width: 260px; } } + +/* line-by-line reveal on tab switch */ +.line-in { + animation: lineIn 0s steps(1) both; + animation-delay: calc(var(--line) * var(--line-ms)); +} +@keyframes lineIn { from { visibility: hidden; } }