Compare commits

...
2 Commits
Author SHA1 Message Date
Illusion 2ff5cf47cf more changes
- Restore the SHARP-ER in the homepage hero section
- Match the design for the 3-image section in the homepage
2026-09-23 23:36:36 +02:00
Illusion cc2b426119 more style changes
- Removed the sheen over the homepage hero image
- Animated the SHARP-ER logo in the navbar
- More padding on the bottom of the hero section in the homepage
2026-09-23 23:22:57 +02:00
7 changed files with 196 additions and 43 deletions
@@ -42,11 +42,17 @@ export interface BrandProps {
/** Accessible name of the link; the images are decorative. */ /** Accessible name of the link; the images are decorative. */
label: string; label: string;
surface: NavSurface; surface: NavSurface;
/**
* Run the "-ER" reveal and its breathing loop. Off on the home page, whose
* hero plays the same reveal once - a second, looping copy beside it is
* jarring. Anywhere else the two animations run as designed.
*/
animate?: boolean;
className?: string; className?: string;
} }
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */ /** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
export function Brand({ href, label, surface, className }: BrandProps) { export function Brand({ href, label, surface, animate = true, className }: BrandProps) {
const { base, suffix } = BRAND_LOCKUP[surface]; const { base, suffix } = BRAND_LOCKUP[surface];
return ( return (
@@ -75,10 +81,12 @@ export function Brand({ href, label, surface, className }: BrandProps) {
/> />
{/* The clipping box starts at the P's right edge, so the mark inside {/* The clipping box starts at the P's right edge, so the mark inside
it can slide out from behind the P (Navigation.css). */} it can slide out from behind the P (Navigation.css). Without
`animate` the box and the reveal classes are dropped and the full
lockup simply sits there. */}
<span <span
style={{ flex: `${suffix.drawnWidth} 1 0%` }} style={{ flex: `${suffix.drawnWidth} 1 0%` }}
className="sh-brand-suffix block min-w-0" className={cx("block min-w-0", animate && "sh-brand-suffix")}
> >
<img <img
src={suffix.src} src={suffix.src}
@@ -88,7 +96,7 @@ export function Brand({ href, label, surface, className }: BrandProps) {
loading="eager" loading="eager"
decoding="async" decoding="async"
draggable={false} draggable={false}
className="sh-brand-suffix__mark block h-auto w-full" className={cx("block h-auto w-full", animate && "sh-brand-suffix__mark")}
/> />
</span> </span>
</span> </span>
@@ -268,7 +268,12 @@ export function MobileNav({
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and {/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */} the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]"> <div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} /> <Brand
href={homeHref}
label={strings.homeLinkLabel}
surface={surface}
animate={!isCurrentPath(homeHref, pathname)}
/>
<button <button
ref={closeRef} ref={closeRef}
type="button" type="button"
@@ -29,20 +29,33 @@
last, slowest part of the decelerating slide. The translate is physical last, slowest part of the decelerating slide. The translate is physical
(translateX) on purpose: the lockup row is pinned to dir="ltr" because a (translateX) on purpose: the lockup row is pinned to dir="ltr" because a
Latin wordmark never mirrors, so the P is always left of the suffix, in Latin wordmark never mirrors, so the P is always left of the suffix, in
Arabic too. Pure CSS, so it runs from the server-rendered HTML without Arabic too.
waiting for hydration, and the `both` fill keeps the suffix hidden through
the delay instead of flashing in first. It replays whenever the navigation After that one-time reveal the mark keeps breathing: after a long rest
mounts again, e.g. on a client-side navigation back to a page. Under (~26s of stillness per cycle) it slides back out from behind the P and
prefers-reduced-motion the animation is removed outright - app.css only returns, so the wordmark quietly draws the eye without the glyphs moving
collapses the duration and would leave the delay holding the suffix while they are visible. On the home page none of this runs (Brand.tsx
hidden - so the full name is simply there from the start. */ drops the classes when the lockup links the page you are on): the hero
there plays the same reveal once, and a second, looping copy right above
it would be jarring. The two
animations are sequenced by delay - the reveal (once, `both` fill, so the
suffix stays hidden through its delay instead of flashing in) hands over
to the loop, whose later position in the list wins from its start, and
whose own delay is when the revealed lockup is simply at rest. Pure CSS,
so it runs from the server-rendered HTML without waiting for hydration.
Under prefers-reduced-motion both animations are removed outright -
app.css only collapses the duration and would leave the delay holding the
suffix hidden - so the full name is simply there from the start. */
.sh-brand-suffix { .sh-brand-suffix {
overflow: hidden; overflow: hidden;
} }
.sh-brand-suffix__mark { .sh-brand-suffix__mark {
animation: sh-brand-suffix-in var(--motion-duration-reveal) animation:
var(--motion-ease-entrance) var(--motion-delay-reveal) both; sh-brand-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both,
sh-brand-breathe calc(var(--motion-duration-reveal) * 37.5)
ease-in-out calc(var(--motion-duration-reveal) * 5) infinite;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -68,6 +81,53 @@
} }
} }
/* One 30s breath: dwell at rest (~26s - the stillness is what stretches, the
gestures stay at a human pace), slide back out behind the P (the reverse of
the reveal - solid while it pulls away, fading as it leaves), pass on
hidden, then play the reveal again, and hold. Looped, with the cycle ending
where it starts so the seam is invisible. Each slide is ONE eased interval
of roughly one reveal duration - the opacity stop inside the out-slide is
opacity-only, so it never splits the transform's easing and the glyphs
cannot stall or hitch mid-gesture. */
@keyframes sh-brand-breathe {
/* settled */
0%, 87% {
transform: translateX(0);
opacity: 1;
animation-timing-function: var(--motion-ease-standard);
}
/* pull back solid - opacity only, so the 87→90 transform still runs one
eased interval and the slide never hitches */
88.5% {
opacity: 1;
}
/* gone, behind the P */
90% {
transform: translateX(-100%);
opacity: 0;
}
/* pass, hidden, then the reveal again - one eased slide back in */
94% {
transform: translateX(-100%);
opacity: 0;
animation-timing-function: var(--motion-ease-entrance);
}
97% {
transform: translateX(0);
opacity: 1;
}
/* settled until the cycle hands over (0% is settled too) */
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes sh-nav-menu-in { @keyframes sh-nav-menu-in {
from { from {
opacity: 0; opacity: 0;
@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { import {
buildLocalizedHref, buildLocalizedHref,
getDirection, getDirection,
isCurrentPath,
LanguageToggle, LanguageToggle,
type Locale, type Locale,
} from "../LanguageSwitcher"; } from "../LanguageSwitcher";
@@ -114,6 +115,9 @@ export function Navigation({
href={resolvedHomeHref} href={resolvedHomeHref}
label={strings.homeLinkLabel} label={strings.homeLinkLabel}
surface={surface} surface={surface}
/* The home page's own hero plays the reveal once; the brand beside
it stays still there instead of running a second copy. */
animate={!isCurrentPath(resolvedHomeHref, pathname)}
/> />
<DesktopNav <DesktopNav
+7 -15
View File
@@ -203,22 +203,14 @@
} }
} }
/* A picture pushing in a little while the frame around it is hovered. The /* A gallery tile that grows as a WHOLE frame instead of pushing the picture
frame clips, so the picture grows into its own box and not over the page. inside it - the draft hovers the frame, not the image. It keeps `.sh-lift`'s
Paired with .sh-lift the tile rises and its picture closes in, which is rise and adds a uniform scale to it. Written after `.sh-lift:hover` so the
how the draft hovers the gallery. */ compound transform wins at equal specificity; `.sh-lift`'s transition
.sh-zoom { already covers `transform`, so the two move together. */
overflow: hidden;
}
.sh-zoom img {
transition: transform var(--motion-duration-slow)
var(--motion-ease-standard);
}
@media (hover: hover) { @media (hover: hover) {
.sh-zoom:hover img { .sh-lift--grow:hover {
transform: scale(1.09); transform: translateY(-5px) scale(1.04);
} }
} }
@@ -583,12 +583,6 @@ export function PageHero({
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]" className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
/> />
{/* A highlight travelling across the picture - the draft's
`sheen`. Decorative, and it rides on top of both washes. */}
<span
aria-hidden="true"
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
/>
</div> </div>
{/* The colour band (478:16360, redrawn in the drafts' home {/* The colour band (478:16360, redrawn in the drafts' home
+98 -8
View File
@@ -89,10 +89,47 @@ import {
PageHero, PageHero,
PageSection, PageSection,
SectionHeader, SectionHeader,
type PageHeroWordmark,
} from "./_page-kit/sections"; } from "./_page-kit/sections";
import "./_page-kit/motion.css"; import "./_page-kit/motion.css";
/**
* The hero lockup above the headline: the full SHARP-ER pair at the SVGs' own
* box (218 + 112 wide), picked per surface the way the navigation's brand
* picks it (Brand.tsx) - white "inverse" exports on the dark hero, the ink
* pair on a light one. `alt` is empty: the sticky bar's brand link already
* carries the accessible project name.
*/
const HERO_WORDMARK: Record<AppTheme, PageHeroWordmark> = {
dark: {
src: "/assets/sharp-wordmark-inverse.svg",
alt: "",
width: 218,
height: 46,
drawnWidth: 218,
suffix: {
src: "/assets/sharp-er-suffix-inverse.svg",
width: 112,
height: 46,
drawnWidth: 112,
},
},
light: {
src: "/assets/sharp-wordmark.svg",
alt: "",
width: 218,
height: 46,
drawnWidth: 218,
suffix: {
src: "/assets/sharp-er-suffix.svg",
width: 112,
height: 46,
drawnWidth: 112,
},
},
};
/** The network logo wall - a wide but finite grid. */ /** The network logo wall - a wide but finite grid. */
const PARTNER_PAGE_SIZE = 36; const PARTNER_PAGE_SIZE = 36;
/** /**
@@ -312,24 +349,70 @@ function StatusPill({ label }: { label: string }) {
} }
/** /**
* The approach band's three pictures. The CMS alt text doubles as the visible * The approach band's pictures. The CMS alt text doubles as the visible
* caption - so the picture is described once, in the caption, and the <img> * caption - so the picture is described once, in the caption, and the <img>
* itself is marked decorative rather than having a screen reader read the same * itself is marked decorative rather than having a screen reader read the same
* sentence twice. * sentence twice.
* *
* The frame and the picture hover apart, as the third draft has them: the * The frame and the picture move as one: the frame lifts and takes the accent
* frame lifts and takes the accent hairline (`sh-lift`) while the picture * hairline (`sh-lift`) and the whole tile grows under the pointer
* closes in inside it (`sh-zoom`, which also does the clipping). * (`sh-lift--grow`) - the draft grows the frame, it does not push the picture
* inside it.
*
* THREE PICTURES RENDER AS THE DRAFT'S MOSAIC (figma 559:4089): the middle
* tile dominates - 605px wide, full height, full opacity - and the two flanks
* sit shorter beside it (427/426px wide, 0.72 of its height) at 50% opacity,
* vertically centred on the middle. One or two pictures fall back to the
* uniform grid, where the mosaic's "faded flank" reading has no partner.
*/ */
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) { function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
if (images.length === 0) return null; if (images.length === 0) return null;
const mosaic = images.length === 3;
if (mosaic) {
return (
/* The track widths are the draft's own drawing (605 centre, ~427/426
flanks); the aspect ratios keep each tile at its drawn proportions, so
the row only reads as a mosaic from lg up and stacks full-width, full
opacity below it. `lg:items-center` sits the shorter flanks level with
the middle one, as the draft has them. gap-3xl is the closest step to
the draft's 60px. */
<ul className="grid list-none grid-cols-1 gap-3xl lg:grid-cols-[427fr_605fr_426fr] lg:items-center">
{images.map((image, index) => (
<li key={image.src} className="sh-reveal">
<figure className="flex flex-col gap-xs">
<div
className={cx(
"sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]",
index === 1 ? "aspect-[605/310]" : "aspect-[427/228] lg:opacity-50",
)}
>
<img
src={image.src}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
{image.alt ? (
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
{image.alt}
</figcaption>
) : null}
</figure>
</li>
))}
</ul>
);
}
return ( return (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => ( {images.map((image) => (
<li key={image.src} className="sh-reveal"> <li key={image.src} className="sh-reveal">
<figure className="flex flex-col gap-xs"> <figure className="flex flex-col gap-xs">
<div className="sh-lift sh-zoom rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]"> <div className="sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
<img <img
src={image.src} src={image.src}
alt="" alt=""
@@ -479,7 +562,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
/> />
<PageHero <PageHero
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-md)]" className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)]"
title={headline} title={headline}
lead={lead} lead={lead}
leadWithActions leadWithActions
@@ -497,7 +580,8 @@ export default function Home({ loaderData }: Route.ComponentProps) {
reverse: true, reverse: true,
}, },
]} ]}
reveal reveal
wordmark={HERO_WORDMARK[theme]}
/* EU visibility sits at the top of the hero (the draft's badge). The /* EU visibility sits at the top of the hero (the draft's badge). The
emblem matches the page surface the hero stands on, which the theme emblem matches the page surface the hero stands on, which the theme
cookie decides - dark emblem in dark mode, light emblem in light cookie decides - dark emblem in dark mode, light emblem in light
@@ -521,11 +605,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
/> />
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */} {/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
{/* `bleed` drops the 80rem Container so the band's content - the three
pictures especially - runs the full width of the viewport, the way
the draft draws the mosaic. The gutter the Container used to add is
re-applied here, and the header column still constrains itself to its
own measure, so only the gallery actually strays to the edges. */}
<PageSection <PageSection
aria-labelledby="home-approach-heading" aria-labelledby="home-approach-heading"
band band
bleed
> >
<div className="flex flex-col gap-3xl"> <div className="flex w-full flex-col gap-3xl px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]">
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center"> <div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
<SectionHeader <SectionHeader
id="home-approach-heading" id="home-approach-heading"