commit 9a8ae72dd6e87bda884f338244705af42b569ee1
parent 6057483ebf91f2fa5dd7b429483b1cbf6166f343
Author: $: DAΞMON <zer0sec.xp@icloud.com>
Date: Wed, 30 Sep 2026 04:31:25 +0100
Open each page as a circle from the link that was pressed
Navigation crossfaded (Astro's ClientRouter default) while the theme
toggle wiped in a circle from the control. Pages now open with the
same circle, from the click point, matching the main site. The origin
has to go back on in astro:after-swap, because the swap replaces the
attributes on <html>, inline style included.
The transition overlay swallowed clicks for the full 960ms, so
::view-transition now passes pointer events through to the new page.
Tested: npm run check (0 errors), npm test (15/15). In the dev server,
the origin matched the click and survived a replayed swap; the theme
wipe stays scoped to .theme-vt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat:
2 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -99,6 +99,63 @@ const ogImage = new URL('og.png', Astro.site).href;
document.addEventListener('astro:after-swap', apply);
})();
</script>
+ <!-- Page iris. Every internal navigation opens the next page as a
+ circle from the link that was pressed: the theme wipe's gesture,
+ at page pace. The CSS is beside the theme wipe in global.css;
+ this only supplies the origin and the end radius. -->
+ <script is:inline>
+ (function () {
+ function centre(el) {
+ var r = el.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ }
+
+ /* Where the last click landed. Capture phase, so it is recorded
+ before the ClientRouter's own listener takes the click. A click
+ with detail 0 came from the keyboard and has no pointer
+ position, so it opens from the middle of the control. */
+ var press = null;
+ document.addEventListener('click', function (e) {
+ var at = e.detail === 0 && e.target instanceof Element
+ ? centre(e.target)
+ : { x: e.clientX, y: e.clientY };
+ at.t = Date.now();
+ press = at;
+ }, true);
+
+ var origin = null;
+ function paint() {
+ if (!origin) return;
+ var s = document.documentElement.style;
+ s.setProperty('--vt-x', origin.x + 'px');
+ s.setProperty('--vt-y', origin.y + 'px');
+ s.setProperty('--vt-r', origin.r + 'px');
+ }
+
+ /* A press older than a second did not cause this navigation (back,
+ forward, a scripted one), so it falls back to the link Astro
+ reports, then to the middle of the screen. The radius reaches
+ the farthest corner exactly, so the circle finishes as it
+ covers the page rather than part-way through an overshoot. */
+ document.addEventListener('astro:before-preparation', function (e) {
+ var at = press && Date.now() - press.t < 1000 ? press
+ : e.sourceElement instanceof Element ? centre(e.sourceElement)
+ : { x: innerWidth / 2, y: innerHeight / 2 };
+ origin = {
+ x: at.x,
+ y: at.y,
+ r: Math.hypot(Math.max(at.x, innerWidth - at.x), Math.max(at.y, innerHeight - at.y)),
+ };
+ paint();
+ });
+
+ /* The swap replaces <html>'s attributes with the new page's, and
+ the inline style carrying these three goes with them, so they
+ are put back before the new page is painted, for the same
+ reason as the theme note above. */
+ document.addEventListener('astro:after-swap', paint);
+ })();
+ </script>
{import.meta.env.PROD && (
<Fragment>
{/* Vercel Web Analytics + Speed Insights. Zero-dependency: Vercel serves
diff --git a/src/styles/global.css b/src/styles/global.css
@@ -1059,8 +1059,9 @@ main, .site-header, .site-footer { position: relative; z-index: 2; }
.patt-src:hover { color: var(--iris); border-color: color-mix(in oklab, var(--iris) 50%, transparent); }
/* ---- Theme toggle: circular-reveal wipe ---------------------------------- */
-/* Scoped to .theme-vt so page navigations keep Astro's default crossfade.
- The toggle sets --vt-x/--vt-y (origin) and --vt-r (end radius) in JS. */
+/* Scoped to .theme-vt so it outranks the page iris below, which runs on
+ every navigation. The toggle sets --vt-x/--vt-y (origin) and --vt-r
+ (end radius) in JS. */
:root.theme-vt::view-transition-old(root),
:root.theme-vt::view-transition-new(root) {
animation: none; /* cancel the default fade */
@@ -1079,6 +1080,36 @@ main, .site-header, .site-footer { position: relative; z-index: 2; }
}
}
+/* ---- Page iris: circular reveal on navigation -------------------------- */
+/* Every internal navigation (Astro's ClientRouter) opens the next page as a
+ circle from the link that was pressed, the theme wipe's gesture at page
+ pace. The inline script in Base.astro sets --vt-x/--vt-y and the exact
+ farthest-corner radius --vt-r. The theme wipe's rules above are scoped to
+ .theme-vt, which outranks these, so each keeps its own timing. */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none; /* replace Astro's default crossfade */
+ mix-blend-mode: normal;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ ::view-transition-new(root) {
+ animation: page-iris 960ms cubic-bezier(0.4, 0, 0.2, 1) both;
+ }
+ @keyframes page-iris {
+ from { clip-path: circle(0 at var(--vt-x, 50%) var(--vt-y, 50%)); }
+ to { clip-path: circle(var(--vt-r, 150vmax) at var(--vt-x, 50%) var(--vt-y, 50%)); }
+ }
+}
+
+/* The transition layer sits over the whole page and, left alone, swallows
+ every click until it finishes: at 960ms, a page that ignores the reader
+ for a second. The live DOM underneath is already the new page, so clicks
+ go straight through to it. */
+::view-transition {
+ pointer-events: none;
+}
+
/* ============================================================================
Cyberdeck integration — additive hooks that wire the shared chrome objects
(`.corners`, `.scanlines`, `.plate-dawn`) into the page furniture. Kept in