Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff5cf47cf
|
||
|
|
cc2b426119
|
@@ -42,11 +42,17 @@ export interface BrandProps {
|
||||
/** Accessible name of the link; the images are decorative. */
|
||||
label: string;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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];
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
src={suffix.src}
|
||||
@@ -88,7 +96,7 @@ export function Brand({ href, label, surface, className }: BrandProps) {
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
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>
|
||||
|
||||
@@ -268,7 +268,12 @@ export function MobileNav({
|
||||
{/* 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. */}
|
||||
<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
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
|
||||
@@ -29,20 +29,33 @@
|
||||
last, slowest part of the decelerating slide. The translate is physical
|
||||
(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
|
||||
Arabic too. Pure CSS, so it runs from the server-rendered HTML without
|
||||
waiting for hydration, and the `both` fill keeps the suffix hidden through
|
||||
the delay instead of flashing in first. It replays whenever the navigation
|
||||
mounts again, e.g. on a client-side navigation back to a page. Under
|
||||
prefers-reduced-motion the animation is 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. */
|
||||
Arabic too.
|
||||
|
||||
After that one-time reveal the mark keeps breathing: after a long rest
|
||||
(~26s of stillness per cycle) it slides back out from behind the P and
|
||||
returns, so the wordmark quietly draws the eye without the glyphs moving
|
||||
while they are visible. On the home page none of this runs (Brand.tsx
|
||||
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 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sh-brand-suffix__mark {
|
||||
animation: sh-brand-suffix-in var(--motion-duration-reveal)
|
||||
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
|
||||
animation:
|
||||
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) {
|
||||
@@ -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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from "react";
|
||||
import {
|
||||
buildLocalizedHref,
|
||||
getDirection,
|
||||
isCurrentPath,
|
||||
LanguageToggle,
|
||||
type Locale,
|
||||
} from "../LanguageSwitcher";
|
||||
@@ -114,6 +115,9 @@ export function Navigation({
|
||||
href={resolvedHomeHref}
|
||||
label={strings.homeLinkLabel}
|
||||
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
|
||||
|
||||
@@ -203,22 +203,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 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-zoom:hover img {
|
||||
transform: scale(1.09);
|
||||
.sh-lift--grow:hover {
|
||||
transform: translateY(-5px) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -583,12 +583,6 @@ export function PageHero({
|
||||
aria-hidden="true"
|
||||
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>
|
||||
|
||||
{/* The colour band (478:16360, redrawn in the drafts' home
|
||||
|
||||
@@ -89,10 +89,47 @@ import {
|
||||
PageHero,
|
||||
PageSection,
|
||||
SectionHeader,
|
||||
type PageHeroWordmark,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
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. */
|
||||
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>
|
||||
* itself is marked decorative rather than having a screen reader read the same
|
||||
* sentence twice.
|
||||
*
|
||||
* The frame and the picture hover apart, as the third draft has them: the
|
||||
* frame lifts and takes the accent hairline (`sh-lift`) while the picture
|
||||
* closes in inside it (`sh-zoom`, which also does the clipping).
|
||||
* The frame and the picture move as one: the frame lifts and takes the accent
|
||||
* hairline (`sh-lift`) and the whole tile grows under the pointer
|
||||
* (`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[] }) {
|
||||
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 (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<li key={image.src} className="sh-reveal">
|
||||
<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
|
||||
src={image.src}
|
||||
alt=""
|
||||
@@ -479,7 +562,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
/>
|
||||
|
||||
<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}
|
||||
lead={lead}
|
||||
leadWithActions
|
||||
@@ -498,6 +581,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
},
|
||||
]}
|
||||
reveal
|
||||
wordmark={HERO_WORDMARK[theme]}
|
||||
/* 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
|
||||
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 */}
|
||||
{/* `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
|
||||
aria-labelledby="home-approach-heading"
|
||||
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">
|
||||
<SectionHeader
|
||||
id="home-approach-heading"
|
||||
|
||||
Reference in New Issue
Block a user