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. */
|
/** 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user