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); -
- +
+ 01 - + 02
@@ -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' }); - }); - }); }