813 lines
27 KiB
Plaintext
813 lines
27 KiB
Plaintext
---
|
|||
|
|
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" />
|
|||
<!-- --paper, and it has to stay --paper: this is one of the surfaces Safari fills
|
|||
|
|
around the page, alongside the canvas, and the field is now brought to --paper
|
||
|
|
at the viewport edges so all of them agree. A "better matching" value here is
|
||
|
|
precisely the bug — it reintroduces a colour that differs from the page edge,
|
||
|
|
which surfaces as a bar whenever Safari switches to it during a scroll. -->
|
||
|
|
<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);
|
||
|
|
/* No background here: html paints the canvas, so the fixed ambient layer below
|
||
|
|
(negative z-index) is not covered by body's own background box. */
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
/* 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 simply be held flat: those pixels are behind the chrome in one
|
||
|
|
state and are ordinary page in the other, so a flat patch there is invisible with
|
||
|
|
the toolbar out and a hard-edged band the moment it retracts. Both failures are
|
||
|
|
the same failure — an edge existing anywhere the viewport boundary can land.
|
||
|
|
|
||
|
|
So there is no edge. The field decays to --paper continuously, and is already
|
||
|
|
exactly --paper by 100svh, above the highest position the visible bottom can take;
|
||
|
|
everything from there to 100lvh is the same paint. Every boundary the viewport
|
||
|
|
could land on is gone, in either state and part way through the transition between
|
||
|
|
them. The decay is spread over ~14rem because the whole excursion is about five
|
||
|
|
steps of RGB — under a step per 40px, below anything the eye reads as an edge, and
|
||
|
|
the grain dithers the remainder. Keep that shape if this is ever edited: reach
|
||
|
|
--paper at or before 100svh, and get there gradually. Top edge likewise, though it
|
||
|
|
is simpler — the layout viewport's top does not move, so it only needs 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%) calc(100svh - 14rem),
|
||
|
|
var(--paper) 100svh,
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
.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: view()) {
|
||
|
|
@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 linear both;
|
||
|
|
animation-timeline: view();
|
||
/* "entry" opens before the footer's edge reaches the fold, not at it: parked,
|
|||
|
|
it already measures 9-11% entered across phone sizes. So the start has to be
|
||
|
|
past that or the footer sits permanently part-faded at rest — 20% clears it
|
||
|
|
on every size tested with room to spare, and still begins the reveal within
|
||
|
|
the first few pixels of scroll. Ending at 45% means it reads as arrived at
|
||
|
|
roughly a third of the travel, instead of still fading while it already
|
||
|
|
looks fully on screen. */
|
||
|
|
animation-range: entry 20% entry 45%;
|
||
}
|
|||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes footer-reveal {
|
||
|
|
from {
|
||
|
|
transform: translate3d(0, 1.15rem, 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>
|