commit 492c102c8e36d8970cc69a94527a7eed505a6519
parent ac6a01a5d715d47344e34f897e6803f987818b72
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:
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; } }