Pages move the way their addresses do; a record with a picture is a card; the trainer says who a business is
Test / test (pull_request) Successful in 1m13s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 19:32:49 +02:00
co-authored by Claude Opus 5.5
parent 748241709a
commit 309e30f36d
10 changed files with 1008 additions and 451 deletions
+144
View File
@@ -0,0 +1,144 @@
// Pages move the way their addresses do.
//
// Deeper in the address comes in from the right and going back up
// comes in from the left: /kontor/ansatt sits to the right of /kontor.
// A page at the same depth comes from below, or from above when it
// sorts first, so the index is on top. The cards on a page leave as
// islands of their own and the next page's cards arrive one after
// another.
//
// The router swaps the page after its data has arrived, and in between
// shows nothing at all. So the click is held here, before the router
// sees it: the browser takes its picture of the page as it is, the
// click is handed on, and the transition waits until the new page has
// replaced the old one. Only transforms and opacity are animated, on
// layers the browser composites itself. Without View Transitions, or
// with reduced motion asked for, nothing here does anything and the
// router navigates as it always did.
(() => {
if (!document.startViewTransition) return;
const still = matchMedia("(prefers-reduced-motion: reduce)");
const MAX_CARDS = 8;
const WAIT_MS = 2500;
const handed = new WeakSet();
const segments = (path) => path.split("/").filter(Boolean);
// forward, back, down or up: which way the new page comes from.
function direction(from, to) {
const a = segments(from);
const b = segments(to);
if (b.length > a.length) return "forward";
if (b.length < a.length) return "back";
return to < from ? "up" : "down";
}
// Only the cards on screen become islands: each is a picture the
// browser has to take and hold, and one nobody sees costs the same.
function inView(card) {
const r = card.getBoundingClientRect();
return r.bottom > 0 && r.top < innerHeight;
}
function name(cards, prefix) {
cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => {
card.style.viewTransitionName = `${prefix}-${i + 1}`;
});
}
function unname() {
document.querySelectorAll(".alt-card").forEach((card) => {
card.style.viewTransitionName = "";
});
}
// Resolves once the page that was showing has been replaced by the
// one at `path`, or after WAIT_MS whatever happened.
function replaced(old, path) {
return new Promise((resolve) => {
const done = () => {
observer.disconnect();
clearTimeout(timer);
resolve();
};
const check = () => {
if (location.pathname !== path) return;
if (document.querySelector("main.loading")) return;
if (old && old.isConnected) return;
if (!document.querySelector(".alternatives, main.not-found")) return;
done();
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(done, WAIT_MS);
check();
});
}
function move(from, to, handOn) {
const root = document.documentElement;
const old = document.querySelector(".alternatives");
root.dataset.motion = direction(from, to);
name([...document.querySelectorAll(".alt-card")], "pm-out");
const transition = document.startViewTransition(async () => {
unname();
handOn();
await replaced(old, to);
name([...document.querySelectorAll(".alt-card")], "pm-in");
});
transition.finished.finally(() => {
unname();
delete root.dataset.motion;
});
}
document.addEventListener(
"click",
(ev) => {
if (still.matches || handed.has(ev) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target.closest && ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin || url.pathname === location.pathname) return;
ev.preventDefault();
ev.stopImmediatePropagation();
move(location.pathname, url.pathname, () => {
const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 });
handed.add(again);
a.dispatchEvent(again);
});
},
true,
);
// Back and forward: the address has already changed when this runs,
// so the router is told a moment later, inside the transition.
let shown = location.pathname;
window.addEventListener(
"popstate",
(ev) => {
const to = location.pathname;
const from = shown;
shown = to;
if (still.matches || handed.has(ev) || from === to) return;
ev.stopImmediatePropagation();
move(from, to, () => {
const again = new PopStateEvent("popstate", { state: ev.state });
handed.add(again);
window.dispatchEvent(again);
});
},
true,
);
// Keep `shown` right after the router's own pushes.
for (const method of ["pushState", "replaceState"]) {
const original = history[method].bind(history);
history[method] = (...args) => {
const result = original(...args);
shown = location.pathname;
return result;
};
}
})();