Files
sharper.ly/apps/web/app/routes/_page-kit/motion.css
T
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00

455 lines
12 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 picture pushing in a little while the frame around it is hovered. The
frame clips, so the picture grows into its own box and not over the page.
Paired with .sh-lift the tile rises and its picture closes in, which is
how the draft hovers the gallery. */
.sh-zoom {
overflow: hidden;
}
.sh-zoom img {
transition: transform var(--motion-duration-slow)
var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-zoom:hover img {
transform: scale(1.09);
}
}
/* 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 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;
}
}
}
}
@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;
}
}
@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;
}
}