Files
website/src/pages/index.astro
T

850 lines
30 KiB
Plaintext
Raw Normal View History

---
const title = 'Blakeslee Research — research that runs';
const description =
'Blakeslee Research engineers infrastructure that keeps AI agents—and the digital intelligence to come—forever online and forever active, including Nucleic, the home for persistent, orchestrated, observable coding agents.';
const canonical = new URL('/', Astro.site);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Deliberately no viewport-fit=cover. It looks like the fix for the bands behind
Safari's chrome and it is not: measured on iOS 27, screen.height is 874 while
100lvh is 754 and both safe-area insets report 0, cover or not. Safari does not
hand those 120px to the page, so no element and no viewport unit reaches them —
see the note on .ambient for what actually resolves it. -->
<meta name="viewport" content="width=device-width" />
<!-- Legacy only: Safari 26 ignores theme-color outright — its chrome tint and its
rubber-band fill both come from CSS background-color (fixed elements at the
viewport edges first, then body; see the notes on body and .ambient). Kept at
--paper for older Safari and for Chromium's toolbar, same value, same reason:
every surface a browser fills around the page must be the page-edge colour. -->
<meta name="theme-color" content="#0a100c" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<link rel="icon" href="/blakeslee-icon.svg" type="image/svg+xml" sizes="any" />
<link
rel="preload"
href="/fonts/archivo.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/jetbrains-mono.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Blakeslee Research" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL('/og.png', Astro.site)} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Blakeslee Research wordmark and wolf-and-pine mark" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL('/og.png', Astro.site)} />
</head>
<body>
<div class="ambient" aria-hidden="true">
<span class="ambient-haze"></span>
<span class="ambient-drift"></span>
<span class="ambient-grain"></span>
</div>
<div class="page-shell">
<header class="masthead" aria-label="Blakeslee Research">
<div class="brand-lockup">
<img
class="mark"
src="/blakeslee-icon.svg"
width="378"
height="392"
alt="Blakeslee Research wolf and pine mark"
/>
<span class="brand-name">Blakeslee<br />Research</span>
</div>
</header>
<main>
<section class="hero" aria-labelledby="hero-heading">
<!-- Headline and card share one wrapper so they sit on a single flex line
that can break. Grid alone cannot: a headline too wide for its columns
keeps its area and simply prints over whatever occupies the next one. -->
<div class="hero-lead">
<h1 id="hero-heading" class="statement" aria-label="Research that runs.">
<span class="line line-research"><span class="word">Research</span></span>
<span class="line line-runs"><span class="word"><span class="that">that</span> <span class="runs">runs.</span></span></span>
</h1>
<a class="product-card" href="https://nucleic.blakeslee.xyz">
<span class="card-index">01 / Flagship system</span>
<span class="product-name">Nucleic <span aria-hidden="true">↗</span></span>
<span class="product-description">
Where agents live — persisted, orchestrated, observed.
</span>
<span class="card-rule" aria-hidden="true"></span>
</a>
</div>
<p class="mission">
We engineer infrastructure that keeps intelligence forever online and forever
active — immortal by design.
</p>
</section>
</main>
<footer class="footer">
<p>© 2026 Blakeslee Research</p>
<div class="footer-links">
<a href="mailto:[email protected]">Contact</a>
<a href="https://version.blakeslee.xyz" target="_blank" rel="noopener noreferrer">Code <span aria-hidden="true">↗</span></a>
</div>
</footer>
</div>
</body>
</html>
<style is:global>
@font-face {
font-family: 'Archivo';
src: url('/fonts/archivo.woff2') format('woff2');
font-style: normal;
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/jetbrains-mono.woff2') format('woff2');
font-style: normal;
font-weight: 400 700;
font-display: swap;
}
:root {
color-scheme: dark;
--ink: #eef3ee;
--secondary: #aebdae;
--muted: #93a494;
--pine: #3fae74;
--sage: #c6d8c6;
--forest: #144b35;
--paper: #0a100c;
--line: rgb(238 243 238 / 16%);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--paper);
}
body {
margin: 0;
min-width: 320px;
min-height: 100svh;
overflow-x: 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
canvas. Transparent here meant Safari's own dark-scheme grey, which is the bar
that split from the page on every bounce, on macOS and iOS alike. So body must
carry --paper. That used to be forbidden: .ambient sits at z-index -2, and
against the root stacking context body's background paints on top of every
negative-z layer, burying the field entirely (the every-pixel-exactly-#0a100c
failure). isolation makes body the stacking context instead, which flips that
order — body's background first, then its negative-z children above it. */
isolation: isolate;
background-color: var(--paper);
font-family: 'Archivo', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Ambient field: two soft bands of light crossing at different angles, and a fine
grain that dithers away the banding flat gradients show on near-black. */
.ambient {
position: fixed;
z-index: -2;
top: 0;
right: 0;
left: 0;
/* Deliberately not inset: 0. A fixed box pinned to the bottom stops at the small
viewport, leaving the strip between svh and lvh unpainted by this layer — which
matters because the toolbar retracts and hands those pixels back to the page.
Sized to the large viewport, the layer owns them in both states; what it puts
there is the ::after note's business. */
height: 100%;
height: 100lvh;
overflow: hidden;
pointer-events: none;
/* Visually inert — the canvas beneath is already this colour — but Safari 26
derives its chrome tint by sampling fixed elements at the viewport edges before
it falls back to body, and a transparent answer from this layer hands the
toolbar its default grey instead of the page's own dark. */
background-color: var(--paper);
}
/* The grey bar, properly.
iOS reserves a strip above and below the layout viewport for its own chrome, and
fills it from the document canvas — which takes its paint from the root element
and nowhere else. Measured on iOS 27: screen.height 874, 100lvh 754, both safe-area
insets 0. Those 120px are not addressable by the page. No height, no viewport unit
and no viewport-fit=cover reaches them; earlier attempts only moved the seam.
So the canvas is the single point, and there is exactly one thing to get right:
every surface Safari fills around the page, and the page's own colour where it
meets them, must be the same paint. Those surfaces can only be flat colours, and
the field is a moving light whose edge tone drifts, so matching them by picking a
constant is a value that is wrong again as soon as the bands move — and wrong in a
different way in each scroll state, which is what made the bar come and go.
The field is therefore brought to zero at both edges of the viewport. --paper is
then the only colour in play: canvas, theme-color, overscroll, the chrome strips,
and the page itself where it touches any of them. There is no second value left
for Safari to swap in, so no scroll state, toolbar collapse or fixed-layer
reposition has anything to expose. That is the property to preserve — if this ever
needs editing, keep the edges at --paper rather than tuning a colour to match.
The one thing that has to be right is where the bottom reaches --paper, and it is
not a number to taste. This layer is fixed and 100lvh tall, but the bottom edge of
what the reader can see is only at 100lvh with the toolbar collapsed; expanded, it
is at 100svh, ~100px higher, and Safari slides between the two mid-scroll. Ending
the fade at the element's own bottom therefore left the visible edge sitting part
way up the fade whenever the toolbar was out — lighter than the strip below it, so
the bar came back — and clean again once the toolbar went away. A boundary at a
coordinate that moves is not a fix, it is the same bug relocated.
Nor can that band be held flat, which was the next thing tried and the next thing
to fail. Reaching --paper at 100svh and holding it to 100lvh does put the same
paint at both places the edge can rest — but it also creates ~220px of dead colour
under a lit field, and a hard fling overscrolls far enough to expose the whole of
it at once. It read as a dark bar, correctly: it was one. Dodging a moving
boundary by flattening the region it moves through is still a patch, and a fast
scroll is all it took to find it.
So neither the value nor its slope may jump anywhere the viewport can reach. The
field decays continuously and never stops decaying: by 100svh it is within a
fraction of one RGB step of --paper, and it spends the rest of the way to 100lvh
closing that fraction. Both candidate edges match the strip beyond them to well
under the grain, there is no flat stretch for an overscroll to reveal, and there
is no point along the run where the rate visibly changes. The whole excursion is
about twelve steps of RGB spread over half the screen, so what this reads as is a
light that falls off toward the bottom — which is the intent, not a correction.
If this is ever edited, the property to keep is that shape: monotonic, no flat
stretch below 100svh, and near-nothing of the field left by the time the edge can
land on it. Do not reintroduce a stop that arrives at --paper and sits there. Top
edge is simpler — the layout viewport's top does not move, so it only has to
arrive. Static, so the layer still composites without repainting per frame. */
.ambient::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
var(--paper) 0,
rgb(10 16 12 / 0%) 8rem,
rgb(10 16 12 / 0%) 48%,
rgb(10 16 12 / 35%) 70%,
rgb(10 16 12 / 70%) 80%,
rgb(10 16 12 / 93%) 86%,
rgb(10 16 12 / 98.5%) 92%,
var(--paper) 100%
);
}
/* Oversized so the bands can travel a long way without an edge ever entering the
frame — what should be visible is the light moving, never the thing carrying it. */
.ambient-haze,
.ambient-drift {
position: absolute;
top: -60%;
left: -60%;
width: 220%;
height: 220%;
will-change: transform;
}
.ambient-haze {
background: linear-gradient(
106deg,
transparent 31%,
rgb(63 174 116 / 6%) 45%,
rgb(96 186 137 / 2.5%) 53%,
transparent 63%
);
transform: translate3d(-9%, 3%, 0);
animation: haze-drift 46s -7s ease-in-out infinite alternate;
}
/* The second band is wider, crosses the first at a shallow angle, and runs on a
period that shares no factor with it. Where the two overlap the field lifts a
little; because 46s and 67s only realign every 51 minutes, that bright seam
wanders instead of pulsing on a beat you can count. */
.ambient-drift {
background: linear-gradient(
-24deg,
transparent 20%,
rgb(120 196 152 / 3%) 43%,
rgb(63 174 116 / 4.5%) 59%,
transparent 79%
);
transform: translate3d(7%, -5%, 0);
animation: drift-across 67s -21s ease-in-out infinite alternate;
}
.ambient-grain {
position: absolute;
inset: -50%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.022;
}
a {
color: inherit;
}
a:focus-visible {
outline: 2px solid var(--sage);
outline-offset: 5px;
}
.page-shell {
/* One inset for everything. It is deliberately generous: at mid widths a tighter
margin puts the copy and the window edge in each other's way. */
--edge: clamp(1.35rem, 4.8vw, 5rem);
--shell-y: clamp(1.25rem, 2.6vh, 2.5rem);
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 1600px);
min-height: 100svh;
margin: 0 auto;
padding: var(--shell-y) var(--edge);
}
.masthead,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.masthead {
padding-bottom: clamp(1rem, 2vw, 1.75rem);
border-bottom: 1px solid var(--line);
}
.brand-lockup {
display: flex;
align-items: center;
gap: 0.75rem;
}
.mark {
display: block;
width: 2.65rem;
height: auto;
flex-shrink: 0;
}
.brand-name {
color: var(--ink);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.05;
user-select: none;
}
.card-index,
.footer {
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
letter-spacing: 0.12em;
line-height: 1.45;
text-transform: uppercase;
}
.card-index,
.footer p {
color: var(--muted);
}
main {
display: grid;
}
.hero {
--hero-gap: clamp(1rem, 2vw, 2rem);
/* One column of the 12-column grid, and the spans the hero composition uses.
The flex line below is sized off these so it lands on the same lines. */
--hero-col: calc((100% - 11 * var(--hero-gap)) / 12);
--hero-span-8: calc(var(--hero-col) * 8 + 7 * var(--hero-gap));
--hero-span-4: calc(var(--hero-col) * 4 + 3 * var(--hero-gap));
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--hero-gap);
row-gap: clamp(1.9rem, 4.4vh, 4rem);
min-height: 0;
align-content: center;
padding: clamp(1.75rem, 4.5vh, 4.5rem) 0 clamp(1.5rem, 3vh, 3rem);
}
/* 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
text-only zoom, Safari's minimum font size, a large browser default) the
headline's minimum width outgrows its share, the line breaks, and the card
steps down instead of being printed over. */
.hero-lead {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
column-gap: var(--hero-gap);
row-gap: clamp(1.5rem, 3vh, 2.5rem);
}
.statement {
/* Optical, not metric: the R carries a left side bearing, so a box that starts on
the grid line prints ~0.078em to the right of it. The mission copy below opens
on a W, whose diagonal reaches the origin with no bearing at all, so the two
only look aligned once the headline is pulled back by that much. Measured off
rendered pixels, not the font metrics, and kept in em so it holds across the
whole font-size clamp. */
--optical-inset: 0.078em;
display: grid;
/* Flex sizes the border box, so the basis has to give back what the negative
margin takes or the card lands inside the eight columns. The half-pixel keeps
sub-pixel rounding in the two calc()s from ever summing past the hero width and
breaking the line at normal size; flex-grow hands it straight back. */
flex: 1 1 calc(var(--hero-span-8) + var(--optical-inset) - 0.5px);
/* Fills the row the way a stretched grid item did, so on the narrow desktop
widths where the card is the taller of the two, the headline's own rows keep
the extra height instead of collapsing to the card's baseline. */
align-self: stretch;
justify-items: start;
margin: 0 0 0 calc(-1 * var(--optical-inset));
font-size: clamp(3.2rem, min(9vw, 18vh), 8.25rem);
font-weight: 600;
letter-spacing: -0.03em;
line-height: 0.98;
}
.line {
display: block;
/* Clips the word's drift exactly as overflow: hidden did, without making the line
a scroll container. A scroll container reports an automatic minimum size of
zero, which told the flex line above that the headline could always be squeezed
into its columns — so it never broke, and the type printed over the card. */
clip-path: inset(0);
padding: 0 0.08em 0.1em 0;
opacity: 0;
animation: rise-in 900ms cubic-bezier(0.2, 0.75, 0.2, 1) forwards;
}
.line-research {
animation-delay: 320ms;
}
.line-runs {
margin-left: 2.2em;
animation-delay: 480ms;
}
.word {
display: block;
padding-right: 0.06em;
color: inherit;
}
/* Rests at zero so the headline's left edge is the one the copy below aligns to.
The drift now arrives at that edge instead of parking 0.045em to the right of
it, which was what pushed "Research" off the mission line. */
.line-research .word {
transform: translate3d(0, 0, 0);
animation: drift-right 12s 1.35s ease-in-out backwards;
}
/* Deliberately wrappable. There is ~5% of slack between "that runs." and the card
at every width, so the pair only ever separates when the type has been scaled
past what the column can hold — at which point two short lines inside the
column beat one long line across the card. */
.line-runs .word {
transform: translate3d(0, -0.035em, 0);
animation: drift-up 11s 1.5s ease-in-out backwards;
}
.line-runs .that {
color: var(--secondary);
}
.line-runs .runs {
color: transparent;
background: linear-gradient(100deg, #cdead9 8%, #2f8f5c 82%);
background-clip: text;
-webkit-background-clip: text;
}
.mission {
grid-column: 1 / span 4;
grid-row: 2;
max-width: 29rem;
margin: 0;
color: var(--secondary);
font-size: clamp(0.95rem, 1.25vw, 1.14rem);
line-height: 1.5;
opacity: 0;
animation: fade-up 700ms 850ms ease-out forwards;
}
.product-card {
position: relative;
display: grid;
/* Never grows, so the half-pixel the headline gives up comes back to the headline
alone and the card starts exactly on the ninth column line. */
flex: 0 1 var(--hero-span-4);
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 1.15rem 0 1.05rem;
border-top: 1px solid var(--line);
text-decoration: none;
opacity: 0;
animation: fade-up 700ms 980ms ease-out forwards;
}
.product-card::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
background: var(--pine);
content: '';
transform: scaleX(0);
transform-origin: left;
transition: transform 350ms ease;
}
.product-card:hover::after,
.product-card:focus-visible::after {
transform: scaleX(1);
}
.card-index {
color: var(--pine);
}
.product-name {
font-size: clamp(1.75rem, 3vw, 2.8rem);
font-weight: 600;
letter-spacing: -0.04em;
line-height: 1;
}
.product-description {
max-width: 18rem;
color: var(--secondary);
font-size: 0.95rem;
line-height: 1.45;
}
.footer {
padding-top: 1rem;
border-top: 1px solid var(--line);
opacity: 0;
animation: fade-in 700ms 1.1s ease-out forwards;
}
.footer p {
margin: 0;
}
/* Uppercase mono at 0.12em tracking is wide enough that a phone will break both
the copyright and the arrow off its label. Neither is allowed to wrap; the
mobile query below stacks them instead when the row no longer fits. */
.footer p,
.footer a {
white-space: nowrap;
}
.footer-links {
display: flex;
align-items: center;
gap: 1.25rem;
}
.footer a {
color: var(--muted);
text-decoration-thickness: 1px;
text-underline-offset: 0.35rem;
}
@keyframes rise-in {
from {
transform: translate3d(0, 72%, 0) rotate(1.5deg);
opacity: 0;
}
to {
transform: translate3d(0, 0, 0) rotate(0);
opacity: 1;
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-up {
from {
transform: translate3d(0, 1rem, 0);
opacity: 0;
}
to {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
@keyframes drift-right {
from { transform: translate3d(-0.045em, 0, 0); }
}
@keyframes drift-up {
from { transform: translate3d(0, 0, 0); }
}
@keyframes haze-drift {
from { transform: translate3d(-9%, 3%, 0) rotate(-0.8deg); }
to { transform: translate3d(10%, -4%, 0) rotate(0.8deg); }
}
/* Travels further and slower than the haze, and scales as it goes, so the two
bands never hold the same relative position for long. */
@keyframes drift-across {
from { transform: translate3d(7%, -5%, 0) scale(1.04); }
to { transform: translate3d(-9%, 7%, 0) scale(1.13); }
}
@media (max-width: 720px) {
/* A phone has no width to spare, so the inset falls back to what it takes to keep
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 {
grid-template-columns: 1fr;
row-gap: clamp(1.75rem, 5vh, 3rem);
padding-top: clamp(2.5rem, 8vh, 5rem);
}
.statement {
font-size: clamp(2.9rem, 14vw, 5.5rem);
}
.line-runs {
margin-left: 1.2em;
}
/* One column, so there is nothing for the headline to collide with and no need
for the flex line. Dissolving the wrapper puts all three back on the hero grid,
where explicit rows keep the phone order the design has always had — the DOM
carries the card first because that is where it reads on the desktop layout. */
.hero-lead {
display: contents;
}
.statement,
.mission,
.product-card {
grid-column: 1;
}
.statement {
grid-row: 1;
}
.mission {
grid-row: 2;
max-width: 25rem;
}
/* Stacked, the card is a separate thought from the hero copy rather than the
column beside it, so it wants more air above than the hero's row rhythm gives. */
.product-card {
grid-row: 3;
margin-top: clamp(0.75rem, 3.5vh, 2.25rem);
}
/* One line each: side by side these two need ~360px of tracking-heavy mono, which
is more than a 390px screen has once the edge inset is paid. */
.footer {
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;
}
}
@media (max-width: 440px) {
.mark {
width: 2.35rem;
}
.statement {
font-size: clamp(2.5rem, 13.5vw, 4.2rem);
letter-spacing: -0.025em;
}
.footer {
font-size: 0.72rem;
}
}
/* Short viewports: compress the vertical rhythm so the composition still lands
on one screen. Declared after the width queries so it wins where they overlap. */
@media (max-height: 780px) and (min-width: 721px) {
.hero {
row-gap: clamp(1.1rem, 2.8vh, 2.25rem);
padding: clamp(0.75rem, 2.2vh, 2rem) 0 clamp(0.5rem, 1.8vh, 1.5rem);
}
.statement {
font-size: clamp(2.25rem, min(9vw, 15vh), 8.25rem);
}
}
/* Short phones can't fit the whole composition at a headline size worth reading,
so tighten the spacing but leave the type alone and let the page scroll. */
@media (max-height: 700px) and (max-width: 720px) {
.hero {
row-gap: clamp(1.1rem, 2.9vh, 2rem);
padding: clamp(0.75rem, 2.2vh, 1.75rem) 0 clamp(0.5rem, 1.4vh, 1.25rem);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation: none !important;
transition: none !important;
}
/* The ambient bands are deliberately absent from this list. Each one's base
transform is an endpoint of its own keyframes, so with the animation off they
hold a frame of it — clearing them would snap both back to the origin and
stack the two washes on top of each other. */
.masthead,
.line,
.mission,
.product-card,
.footer {
opacity: 1;
transform: none;
}
}
</style>