diff --git a/src/pages/index.astro b/src/pages/index.astro
index efe6b81..61129ae 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -127,10 +127,10 @@ const canonical = new URL('/', Astro.site);
-
@@ -192,32 +192,21 @@ const canonical = new URL('/', Astro.site);
}
html {
+ height: 100%;
min-width: 320px;
+ overflow: hidden;
/* Longhand on purpose, here and on body: the background-color value is the one
thing Safari reads for its fills, and the shorthand form is how it silently
goes missing (background: resets background-color when edited carelessly). */
background-color: var(--paper);
}
- /* Desktop keeps no rubber band, and this went back and forth twice — don't move it
- again without new information. Unlocked, macOS Safari still banded even with
- every page-side pixel owned (body gap fill, ambient apron): parts of its bounce
- are chrome-side material — the render-time tint sample, the refresh spinner
- well — that no page CSS reaches, and the owner judged the occasional band worse
- than the lost bounce. Phones keep the native feel: the page actually scrolls
- there, iOS fills its gap from body correctly, and none would also cost
- pull-to-refresh. */
- @media (min-width: 721px) {
- html {
- overscroll-behavior: none;
- }
- }
-
body {
margin: 0;
min-width: 320px;
+ height: 100svh;
min-height: 100svh;
- overflow-x: hidden;
+ overflow: hidden;
color: var(--ink);
/* Safari 26 paints the rubber-band overscroll gap from body's computed
background-color and from nowhere else — not theme-color (ignored now), not the
@@ -408,8 +397,10 @@ const canonical = new URL('/', Astro.site);
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 1600px);
- min-height: 100svh;
+ height: 100svh;
+ min-height: 0;
margin: 0 auto;
+ overflow: hidden;
padding: var(--shell-y) var(--edge);
}
@@ -466,6 +457,18 @@ const canonical = new URL('/', Astro.site);
main {
display: grid;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ main::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
}
.hero {
@@ -482,6 +485,10 @@ const canonical = new URL('/', Astro.site);
padding: clamp(1.75rem, 4.5vh, 4.5rem) 0 clamp(1.5rem, 3vh, 3rem);
}
+ .hero.has-scroll-story {
+ align-content: start;
+ }
+
/* Row one of the hero, as a flex line rather than two grid areas. The two bases
add up to exactly the grid width, so at normal text size this renders where the
8/4 column split always put it — but when text alone is scaled up (Firefox's
@@ -511,7 +518,7 @@ const canonical = new URL('/', Astro.site);
.has-scroll-stack .hero-copy {
position: sticky;
- top: 50svh;
+ top: 50%;
align-self: flex-start;
transform: translateY(-50%);
}
@@ -540,7 +547,7 @@ const canonical = new URL('/', Astro.site);
}
.is-stacked-stack .technology-stage {
- top: clamp(8.5rem, 28svh, 14rem);
+ top: clamp(8.5rem, 28%, 14rem);
transform: none;
}
@@ -611,15 +618,15 @@ const canonical = new URL('/', Astro.site);
animation: drift-up 11s 1.5s ease-in-out backwards;
}
- /* The swipe is a soft pale-green blade crossing only “runs.” once as the
- second line arrives. The pseudo-element duplicates the verb, clips its
- gradient to those glyphs, and is intentionally held by .runs so “that”
- stays static. Its transparent parked positions also leave reduced-motion
- rendering unchanged. */
- .line-runs .runs::after {
- content: 'runs.';
+ /* The swipe crosses “that runs.” once as the second line arrives. It paces
+ across “that”, then significantly accelerates through “runs.” The pseudo
+ duplicates the whole phrase and clips its gradient to the glyphs; its
+ transparent parked positions leave reduced-motion rendering unchanged. */
+ .line-runs .word::after {
+ content: 'that runs.';
position: absolute;
inset: 0;
+ padding-right: 0.06em;
color: transparent;
background-image: linear-gradient(
100deg,
@@ -653,8 +660,6 @@ const canonical = new URL('/', Astro.site);
}
.line-runs .runs {
- position: relative;
- display: inline-block;
color: transparent;
background: linear-gradient(100deg, #cdead9 8%, #2f8f5c 82%);
background-clip: text;
@@ -689,7 +694,7 @@ const canonical = new URL('/', Astro.site);
.is-scroll-stack .technology-stage {
position: sticky;
- top: 50svh;
+ top: 50%;
transform: translateY(-50%);
}
@@ -792,29 +797,10 @@ const canonical = new URL('/', Astro.site);
display: flex;
}
- .technology-index button {
- appearance: none;
- border: 0;
- padding: 0.3rem;
- color: inherit;
- background: transparent;
- font: inherit;
- letter-spacing: inherit;
- line-height: inherit;
- cursor: pointer;
- transition: color 220ms ease;
- }
-
- .technology-index button:hover,
- .technology-index button:focus-visible,
- .technology-index button.is-active {
+ .technology-index-current {
color: var(--pine);
}
- .technology-index button:focus-visible {
- outline-offset: 1px;
- }
-
.technology-steps {
position: absolute;
inset: 0;
@@ -893,15 +879,12 @@ const canonical = new URL('/', Astro.site);
from { transform: translate3d(0, 0, 0); }
}
- /* The whole animation is the sweep — one iteration, no dwell keyframe — so
- the page pays for paint during a single ~0.65s window at load and never
- again. The sweep is linear on purpose: an eased segment read as the light
- accelerating through the middle of the line (it was — ease-in-out is
- fastest at its midpoint, which lands mid-word), and a sprinter at full
- speed holds a pace. The softness at entry and exit comes from the band's
- own gradient profile, not from the curve. */
+ /* The first segment advances at a measured linear pace until the light has
+ cleared “that”; the shorter final segment covers the rest of its distance
+ in one third of the time, so “runs.” gets a deliberate 3× burst. */
@keyframes sheen-pass {
from { background-position: -115% 0; }
+ 66.667% { background-position: 18% 0; }
to { background-position: 235% 0; }
}
@@ -922,25 +905,6 @@ const canonical = new URL('/', Astro.site);
the text off the bezel rather than what the desktop composition wants. */
.page-shell {
--edge: clamp(1.15rem, 5vw, 1.85rem);
-
- /* The footer is parked past the fold: on a phone its rule is a third horizontal
- line competing with the masthead's and the card's, and the page reads calmer
- without it. The drop has to pay for the footer's own height and the shell's
- bottom padding before it buys any clearance, hence a value this large.
- The (100lvh - 100svh) term is the toolbar: main is sized to the small
- viewport so the hero centres in what you can actually see, but Safari's
- bottom bar is translucent and the page shows through it, so clearing svh
- only put the footer's rule behind the controls rather than out of sight.
- Both terms collapse to the clamp anywhere the two viewports agree.
-
- The clamp is sized to park the footer just out of sight, not to bury it: it
- pays for the footer's own height and the shell's bottom padding first, and
- what is left over is the clearance below the fold — about 20-27px, enough
- that the rule cannot peek at rest, and no more. Every pixel beyond that is
- scroll the reader has to spend before the footer starts arriving. */
- --footer-drop: calc(100lvh - 100svh + clamp(5.25rem, 10.5vh, 6rem));
-
- min-height: calc(100svh + var(--footer-drop));
}
.hero {
@@ -992,7 +956,7 @@ const canonical = new URL('/', Astro.site);
}
.is-scroll-stack .technology-stage {
- top: clamp(8.5rem, 28svh, 14rem);
+ top: clamp(8.5rem, 28%, 14rem);
transform: none;
}
@@ -1006,63 +970,6 @@ const canonical = new URL('/', Astro.site);
flex-direction: column;
align-items: flex-start;
gap: 0.8rem;
- margin-top: var(--footer-drop);
- }
- }
-
- /* The phone footer is below the fold, so a timed entrance would have played out
- unseen. Driven off the scroll position instead, it and its rule arrive as they
- come up the screen. Scroll-driven animations are still the newest thing here, so
- this stays behind @supports: without it the footer keeps the timed fade above,
- which lands on load and leaves it plainly visible. */
- @supports (animation-timeline: scroll()) {
- @media (max-width: 720px) {
- /* Where the reveal runs, the footer is transparent until it is scrolled to, so
- opacity is what hides it and the layout no longer has to. That buys back both
- halves of the drop the fallback needs: the toolbar term goes, because a footer
- behind Safari's bar cannot show through it at zero alpha, and the clearance
- shrinks to the little that keeps the rule from being mid-fade at rest. All of
- it was scroll the reader had to spend before anything began to happen. */
- .page-shell {
- --footer-drop: clamp(4.75rem, 9.3vh, 5.1rem);
- }
-
- .footer {
- opacity: 0;
- animation: footer-reveal ease-out both;
- /* The scroller, not view(). view() measures the footer against the fold, so
- the reveal is a window inside the footer's own height — and the footer is
- short. Every usable window was a fraction of an already short travel: the
- fade got ~22px of the 78px the reader has, which is not a fade, it is a
- snap. It also had to start past ~20% because "entry" opens before the
- footer's edge reaches the fold, so the footer would otherwise sit part-faded
- at rest — the offsets were fighting the geometry.
-
- Against the scroller the two ends of the reveal are the two ends of the
- scroll. Nothing is cropped and nothing needs offsetting: exactly 0 at rest,
- exactly 1 at the bottom, and every pixel of thumb travel in between spent
- fading. Same distance to scroll as before, three and a half times the
- distance to fade over. It holds for any phone, because the range is defined
- by the scroll rather than by the footer's height relative to the screen.
-
- Safe against a zero-length scroll — the drop below always adds some, and
- this is inside the phone breakpoint besides. */
- animation-timeline: scroll(root block);
- }
- }
- }
-
- /* The lift is small on purpose. Over a travel this short a large one reads as the
- footer being dragged rather than arriving, and it is the movement, not the fade,
- that the eye catches as a jolt. */
- @keyframes footer-reveal {
- from {
- transform: translate3d(0, 0.55rem, 0);
- opacity: 0;
- }
- to {
- transform: translate3d(0, 0, 0);
- opacity: 1;
}
}
@@ -1134,12 +1041,15 @@ const canonical = new URL('/', Astro.site);
if (technologyStack && 'IntersectionObserver' in window && !reducedMotion.matches) {
const heroLead = technologyStack.closest('.hero-lead');
+ const hero = heroLead?.closest('.hero');
const heroCopy = heroLead?.querySelector('.hero-copy');
+ const scrollRoot = technologyStack.closest('main');
const cards = [...technologyStack.querySelectorAll('[data-technology-card]')];
const steps = [...technologyStack.querySelectorAll('[data-technology-step]')];
- const jumps = [...technologyStack.querySelectorAll('[data-technology-jump]')];
+ const progressCurrent = technologyStack.querySelector('[data-technology-progress-current]');
heroLead?.classList.add('has-scroll-stack');
+ hero?.classList.add('has-scroll-story');
if (heroLead && heroCopy) {
const stackWrappedBelow = technologyStack.offsetLeft <= heroCopy.offsetLeft + 1;
heroLead.classList.toggle('is-stacked-stack', stackWrappedBelow);
@@ -1154,11 +1064,7 @@ const canonical = new URL('/', Astro.site);
card.setAttribute('aria-hidden', String(!isActive));
});
- jumps.forEach((jump, jumpIndex) => {
- const isActive = jumpIndex === index;
- jump.classList.toggle('is-active', isActive);
- jump.setAttribute('aria-pressed', String(isActive));
- });
+ if (progressCurrent) progressCurrent.textContent = String(index + 1).padStart(2, '0');
};
activateTechnology(0);
@@ -1171,17 +1077,9 @@ const canonical = new URL('/', Astro.site);
if (Number.isInteger(index)) activateTechnology(index);
});
},
- { rootMargin: '-45% 0px -45% 0px' },
+ { root: scrollRoot, rootMargin: '-45% 0px -45% 0px' },
);
steps.forEach((step) => stepObserver.observe(step));
- jumps.forEach((jump) => {
- jump.addEventListener('click', () => {
- const index = Number(jump.dataset.technologyJump);
- if (!Number.isInteger(index) || !steps[index]) return;
- activateTechnology(index);
- steps[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
- });
- });
}