Files

550 lines
16 KiB
CSS

/* ============================================================================
HOME MOTION - the reveals the redesigned home page runs
----------------------------------------------------------------------------
Every animation in the Claude Design draft ("SHARP-ER Home v2 Dark"), carried
over as classes so the page modules stay free of inline keyframes and raw
values. Durations and easings are the motion tokens from semantic.css; the
few long, decorative loops are expressed as multiples of them rather than as
bare seconds, so retiming the system retimes the page.
TWO KINDS OF MOTION LIVE HERE
* entrance - time based, runs once from the server-rendered HTML
(.sh-rise, .sh-bar-grow), staggered with .sh-rise--2..5
* scroll - scroll-driven (`animation-timeline`), so a band reveals as
it enters the viewport and the top progress bar tracks the
document
WHY THE @supports GUARD: scroll-driven timelines ship in Chromium only. The
from-state (opacity 0) is applied ONLY where `animation-timeline: view()` is
understood, so Safari and Firefox render every band at rest, fully visible,
instead of holding an invisible element forever.
REDUCED MOTION: app.css already collapses every animation to 0.01ms. The
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
DIRECTION: `transform-origin`, `linear-gradient()`, `clip-path: inset()` and
`translateX()` have no logical keywords, so the progress bar, the brand
stripe, the headline wipe, the hover rule and the logo marquee each carry an
explicit RTL override. These are the only physical directions in the file,
and they exist because the property itself has no logical form - every box
edge elsewhere is still ps-/pe-/start-/end-.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* -------------------------------------------------------------------------
ENTRANCE - hero content, first paint
---------------------------------------------------------------------- */
.sh-rise {
animation: sh-rise var(--motion-duration-reveal) var(--motion-ease-entrance)
var(--sh-rise-delay, 0ms) both;
}
/* The hero's cascade. The step is the system's fast duration, so the whole
stagger retimes with the token instead of drifting on hard-coded ms. */
.sh-rise--2 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 0.75);
}
.sh-rise--3 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 1.5);
}
.sh-rise--4 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 2.25);
}
.sh-rise--5 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
}
/* The home headline. The third design drop stops lifting it and uncovers it
instead, from the inline start, so the words are read into place rather
than slid in. It reads the SAME `--sh-rise-delay` as the cascade above, so
`.sh-rise--3` &c. stagger the wipe alongside everything else. */
.sh-wipe {
animation: sh-wipe var(--motion-duration-reveal) var(--motion-ease-entrance)
var(--sh-rise-delay, 0ms) backwards;
}
[dir="rtl"] .sh-wipe {
animation-name: sh-wipe-rtl;
}
/* `backwards`, not `both`: the clip has to be GONE once the wipe is over.
`inset(0)` at rest would keep clipping to the border box, which shaves a
descender or an Arabic mark off a tight heading line. */
/* app.css shortens every animation but leaves its delay alone, which for a
clip means the headline is not merely faded but absent until the delay has
passed. Removing the animation outright puts the words on screen from the
first paint, which is what a reduced-motion reader asked for. */
@media (prefers-reduced-motion: reduce) {
.sh-wipe {
animation: none;
}
}
/* The brand stripe under the hero picture, drawn on from the inline start. */
.sh-bar-grow {
transform-origin: left center;
animation: sh-bar-grow var(--motion-duration-reveal)
var(--motion-ease-entrance) calc(var(--motion-duration-fast) * 4) both;
}
[dir="rtl"] .sh-bar-grow {
transform-origin: right center;
}
/* -------------------------------------------------------------------------
AMBIENT LOOPS - decorative, never carrying meaning
---------------------------------------------------------------------- */
/* The two soft washes behind the hero. Long, slow drifts: 22.5x and 30x the
reveal duration (18s / 24s at the shipped token). */
.sh-glow {
animation: sh-glow calc(var(--motion-duration-reveal) * 22.5) ease-in-out
infinite;
}
/* A highlight travelling across the hero picture. */
.sh-sheen {
background-size: 240% 100%;
animation: sh-sheen calc(var(--motion-duration-reveal) * 8) linear infinite;
}
/* The live dot in the hero status pill. */
.sh-pulse {
animation: sh-pulse calc(var(--motion-duration-reveal) * 2.75) ease-in-out
infinite;
}
/* The network logo wall, drifting past. The track carries the tiles TWICE,
so moving it exactly half its own width lands on an identical frame and
the loop never shows a seam; 42.5x the reveal duration is the draft's 34s
at the shipped token.
`width` is set here rather than with a `w-max` utility on purpose: the
reduced-motion block below has to be able to take it back, and a utility
sits in a later layer than this one.
It stops under the pointer and whenever anything inside it takes focus, so
a reader can hold the movement still to look at - or tab to - a logo
(WCAG 2.2.2). Under reduced motion it stops being a track at all: the row
simply wraps and the duplicate half is removed, so nothing moves and no
logo is shown twice. */
.sh-marquee {
width: max-content;
animation: sh-marquee calc(var(--motion-duration-reveal) * 42.5) linear
infinite;
}
/* RTL reads the other way, so the drift does too. */
[dir="rtl"] .sh-marquee {
animation-name: sh-marquee-rtl;
}
.sh-marquee:hover,
.sh-marquee:focus-within {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.sh-marquee {
width: auto;
flex-wrap: wrap;
animation: none;
}
.sh-marquee__clone {
display: none;
}
}
/* The wall fades out at both ends instead of being cut off mid-tile. `black`
and `transparent` here are alpha, not colour - a mask reads the channel
only - which is why this is not a surface token. The ramp is symmetric, so
it needs no RTL form. */
.sh-marquee-mask {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
}
/* -------------------------------------------------------------------------
INTERACTION
---------------------------------------------------------------------- */
/* Tiles that rise a little under the pointer. No shadow: the draft's hover
glow is deliberately left out. */
.sh-lift {
transition:
transform var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-lift:hover {
transform: translateY(-5px);
}
}
/* A gallery tile that grows as a WHOLE frame instead of pushing the picture
inside it - the draft hovers the frame, not the image. It keeps `.sh-lift`'s
rise and adds a uniform scale to it. Written after `.sh-lift:hover` so the
compound transform wins at equal specificity; `.sh-lift`'s transition
already covers `transform`, so the two move together. */
@media (hover: hover) {
.sh-lift--grow:hover {
transform: translateY(-5px) scale(1.04);
}
}
/* An accent rule drawn across the top of a tile under the pointer, growing
from the inline start. It is a pseudo-element so the tile's own markup
stays a heading and a paragraph. */
.sh-rule-top {
position: relative;
overflow: hidden;
}
.sh-rule-top::before {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: var(--surface-accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--motion-duration-slow)
var(--motion-ease-entrance);
}
[dir="rtl"] .sh-rule-top::before {
transform-origin: right center;
}
@media (hover: hover) {
.sh-rule-top:hover::before {
transform: scaleX(1);
}
}
/* An FAQ answer opening under its question. */
.sh-answer {
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
both;
}
/* -------------------------------------------------------------------------
SCROLL DRIVEN - Chromium today, inert and harmless elsewhere
---------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.sh-reveal {
animation: sh-rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 70%;
}
/* Cards and pictures: the same reveal with a touch of scale. */
.sh-reveal--in {
animation-name: sh-reveal-in;
animation-range: entry 0% entry 80%;
}
/* The edge tiles of a row land in from their own side of the line - the
reading start and the reading end - instead of rising, so a row drags
into place edge-first while its middle rises. `translateX` has no
logical keyword, so the RTL block swaps the two names: the drift always
comes off the start edge, whichever script is on screen. */
.sh-reveal--from-start {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end {
animation-name: sh-rise-from-start;
}
/* A beat between the tiles of a row: each item after the first holds at
its hidden state until it is a little deeper in the viewport, so a row
cascades start-to-end instead of landing as one. Only the start of the
range shifts; `.sh-reveal--in` and the edge slides keep their ends. */
.sh-reveal--step-1 {
animation-range-start: entry 12%;
}
.sh-reveal--step-2 {
animation-range-start: entry 24%;
}
/* The hero picture drifting as the page scrolls past it. It is scaled up
before it travels, so the drift can never uncover an edge of the
frame. Chromium-only, like every timeline here: elsewhere the picture
simply sits still, which is the state the design falls back to. */
.sh-pan {
animation: sh-pan linear both;
animation-timeline: view();
animation-range: entry 100% exit 0%;
}
}
@supports (animation-timeline: scroll()) {
/* The reading-progress rule across the top of the page. It is painted
ONLY inside this @supports block: without a scroll timeline the bar
would sit there permanently at full width, which is a rule the design
never asked for. */
.sh-progress {
background-image: linear-gradient(
to right,
var(--surface-accent),
var(--text-accent) 55%,
var(--surface-danger)
);
transform-origin: left center;
animation: sh-bar-grow linear both;
animation-timeline: scroll();
}
[dir="rtl"] .sh-progress {
background-image: linear-gradient(
to left,
var(--surface-accent),
var(--text-accent) 55%,
var(--surface-danger)
);
transform-origin: right center;
}
}
/* THE SAME REVEAL, WITHOUT A TIMELINE. `_page-kit/reveal.ts` tags a
`.sh-reveal` element `.sh-reveal-js` (hidden) in browsers that lack
scroll timelines, then `.sh-reveal-js--in` plays `sh-rise` once it
scrolls into view. Both classes are inert without the helper, so
content is always readable. */
.sh-reveal-js {
opacity: 0;
}
.sh-reveal-js--in {
animation: sh-rise var(--motion-duration-reveal)
var(--motion-ease-entrance) both;
}
/* The edge tiles keep their drift here too: the reader who never sees a
scroll timeline still gets the same from-start / from-end arrival. The
RTL mirrors swap the names, as the Chromium block above does. */
.sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
/* The same beat, expressed in time where there is no timeline. `both`
already answers for the hidden hold during the delay. */
.sh-reveal--step-1.sh-reveal-js--in {
animation-delay: var(--motion-duration-fast);
}
.sh-reveal--step-2.sh-reveal-js--in {
animation-delay: calc(var(--motion-duration-fast) * 2);
}
}
}
@keyframes sh-rise {
from {
opacity: 0;
transform: translateY(26px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-reveal-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
/* The same reveal turned sideways. `--from-start` is picked up by the RTL
block as the mirror of `--from-end`, so both names exist and the swap is
legible there instead of shipping one keyframe and praying. */
@keyframes sh-rise-from-start {
from {
opacity: 0;
transform: translateX(-20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-rise-from-end {
from {
opacity: 0;
transform: translateX(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sh-glow {
0%,
100% {
opacity: 0.5;
transform: translate3d(-4%, 0, 0) scale(1);
}
50% {
opacity: 0.85;
transform: translate3d(6%, -3%, 0) scale(1.12);
}
}
@keyframes sh-sheen {
from {
background-position: -160% 0;
}
to {
background-position: 260% 0;
}
}
@keyframes sh-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.72);
}
}
@keyframes sh-wipe {
from {
opacity: 0.4;
clip-path: inset(0 100% 0 0);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@keyframes sh-wipe-rtl {
from {
opacity: 0.4;
clip-path: inset(0 0 0 100%);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@keyframes sh-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes sh-marquee-rtl {
from {
transform: translateX(0);
}
to {
transform: translateX(50%);
}
}
@keyframes sh-pan {
from {
transform: translateY(-5%) scale(1.14);
}
to {
transform: translateY(5%) scale(1.14);
}
}
@keyframes sh-answer {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}