Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 59 additions & 68 deletions src/components/marketing/LensHero.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
/**
* The `lens-hero` block (src/blocks.ts): the brand lens as a living scene beside the headline.
* The lens follows the pointer, breathes, narrows as the hero scrolls away and takes the colour of
* the activity log's current line (lens.ts). Every word comes from the block's props. Without
* JavaScript, at motion "none" or under reduced motion it is a still lens with the whole log shown.
* The `lens-hero` block (src/blocks.ts): the brand lens as a page's opening scene. The lens sits
* between the headline and the actions and comes out of the dark: it lights up once, then its
* light slowly swells and fades, follows the pointer a little and dims as the page scrolls on
* (lens.ts). Under it, one line of the activity log at a time. Every word comes from the props.
* Without JavaScript, at motion "none" or under reduced motion: a still, lit lens and the whole log.
*/
import { external, href } from "./href.ts";

Expand Down Expand Up @@ -34,89 +35,79 @@ const parts =
at >= 0 && props.highlight
? [props.heading.slice(0, at), props.highlight, props.heading.slice(at + props.highlight.length)]
: [props.heading, "", ""];

// The instrument ring: 72 ticks, every sixth one long.
const ticks = Array.from({ length: 72 }, (_, i) => ({ angle: i * 5, long: i % 6 === 0 }));
// Fine radial fibres of the iris.
const fibres = Array.from({ length: 60 }, (_, i) => i * 6);
const first = props.log[0]?.state ?? "watching";
---
<section class="mk-lens" data-lens data-state={first} aria-labelledby="mk-lens-title">
<div class="mk-lens__sky" aria-hidden="true">
<span class="mk-lens__cloud mk-lens__cloud--a"></span>
<span class="mk-lens__cloud mk-lens__cloud--b"></span>
<span class="mk-lens__cloud mk-lens__cloud--c"></span>
<span class="mk-lens__grid"></span>
</div>
<div class="mk-lens__inner">
<div class="mk-lens__copy">
<div class="mk-lens__top">
{props.eyebrow && <p class="mk-lens__eyebrow">{props.eyebrow}</p>}
<h1 id="mk-lens-title" class="mk-lens__title">
{parts[0]}{parts[1] && <span class="mk-lens__highlight">{parts[1]}</span>}{parts[2]}
</h1>
{props.body && <p class="mk-lens__body">{props.body}</p>}
<p class="mk-lens__actions">
{primary && <a class="mk-button mk-button--primary" href={primary} rel={external(primary) ? "noopener" : undefined}>{props.primaryLabel}</a>}
{props.secondaryLabel && secondary && <a class="mk-button mk-lens__ghost" href={secondary} rel={external(secondary) ? "noopener" : undefined}>{props.secondaryLabel}</a>}
</p>
<div class="mk-lens__log">
<p class="mk-lens__log-title">{props.logLabel}</p>
<ol>
{props.log.map((item, i) => (
<li data-state={item.state} data-step={i}><span class="mk-lens__dot" aria-hidden="true"></span>{item.text}</li>
))}
</ol>
</div>
</div>
<div class="mk-lens__stage" aria-hidden="true">
<div class="mk-lens__glow"></div>
<svg class="mk-lens__svg" viewBox="-200 -200 400 400" focusable="false">
<div class="mk-lens__halo"></div>
<svg class="mk-lens__svg" viewBox="-300 -300 600 600" focusable="false">
<defs>
<radialGradient id="mk-lens-housing" cx="0" cy="0" r="200" gradientUnits="userSpaceOnUse">
<stop offset="0.6" stop-color="#1c100b" />
<stop offset="1" stop-color="#0a0605" />
<radialGradient id="mk-lens-bezel" cx="-60" cy="-90" r="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3a2a22" />
<stop offset="0.55" stop-color="#140c09" />
<stop offset="1" stop-color="#050302" />
</radialGradient>
<radialGradient id="mk-lens-iris" cx="0" cy="0" r="98" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff4ec" />
<stop offset="0.22" stop-color="#ffd2b3" />
<stop offset="0.5" stop-color="#ee8a64" />
<stop offset="0.82" stop-color="#b4441f" />
<stop offset="1" stop-color="#5a200c" />
<linearGradient id="mk-lens-metal" x1="-300" y1="-300" x2="300" y2="300" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#b9a296" />
<stop offset="0.35" stop-color="#3b2c25" />
<stop offset="0.7" stop-color="#1a110d" />
<stop offset="1" stop-color="#6e5a4f" />
</linearGradient>
<radialGradient id="mk-lens-glass" cx="0" cy="0" r="240" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1c0c06" />
<stop offset="0.7" stop-color="#070403" />
<stop offset="1" stop-color="#020101" />
</radialGradient>
<radialGradient id="mk-lens-pupil" cx="0" cy="0" r="30" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff" />
<stop offset="0.45" stop-color="#fff4ec" />
<stop offset="1" stop-color="#fff4ec" stop-opacity="0" />
<radialGradient id="mk-lens-light" cx="0" cy="0" r="150" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fffaf4" />
<stop offset="0.1" stop-color="#ffe6cc" />
<stop offset="0.26" stop-color="#ffb27a" />
<stop offset="0.5" stop-color="#f6821f" />
<stop offset="0.74" stop-color="#b4441f" stop-opacity="0.75" />
<stop offset="1" stop-color="#5a200c" stop-opacity="0" />
</radialGradient>
</defs>
<circle r="196" fill="url(#mk-lens-housing)" />
<g class="mk-lens__ticks">
{ticks.map((t) => (
<line x1="0" y1={t.long ? -186 : -182} x2="0" y2="-176" transform={`rotate(${t.angle})`} />
))}
<g class="mk-lens__body">
<circle r="288" fill="url(#mk-lens-bezel)" />
<circle r="286" fill="none" stroke="url(#mk-lens-metal)" stroke-width="3" />
<circle r="244" fill="url(#mk-lens-glass)" />
<circle r="244" fill="none" stroke="#f6c3ad" stroke-opacity="0.12" stroke-width="1.5" />
<circle r="196" fill="none" stroke="#f6c3ad" stroke-opacity="0.07" stroke-width="1" />
</g>
<circle class="mk-lens__arc" r="168" pathLength="100" />
<g class="mk-lens__eye">
<circle r="150" fill="#2a0f06" />
<circle r="118" fill="none" stroke="#f6c3ad" stroke-opacity="0.55" stroke-width="1.6" />
<g class="mk-lens__iris">
<circle r="98" fill="url(#mk-lens-iris)" />
<g class="mk-lens__fibres">
{fibres.map((a) => (
<line x1="0" y1="-34" x2="0" y2="-94" transform={`rotate(${a})`} />
))}
</g>
<circle class="mk-lens__tint" r="98" />
<g class="mk-lens__pupil">
<circle r="30" fill="url(#mk-lens-pupil)" />
<circle r="9" fill="#ffffff" />
</g>
<g class="mk-lens__iris">
<g class="mk-lens__light">
<circle r="150" fill="url(#mk-lens-light)" />
</g>
<g class="mk-lens__pupil">
<circle r="18" fill="#ffffff" />
</g>
<ellipse class="mk-lens__shine" cx="-52" cy="-62" rx="38" ry="17" transform="rotate(-32 -52 -62)" />
</g>
<circle class="mk-lens__ping" r="104" />
<circle class="mk-lens__ping" r="160" />
<path class="mk-lens__glass" d="M -170 -120 A 210 210 0 0 1 60 -205 A 230 230 0 0 0 -150 -60 Z" />
</svg>
<button class="mk-lens__pause" type="button" data-lens-toggle data-pause={props.pauseLabel} data-play={props.playLabel} hidden>{props.pauseLabel}</button>
</div>
<div class="mk-lens__bottom">
{props.body && <p class="mk-lens__body-text">{props.body}</p>}
<p class="mk-lens__actions">
{primary && <a class="mk-button mk-button--primary" href={primary} rel={external(primary) ? "noopener" : undefined}>{props.primaryLabel}</a>}
{props.secondaryLabel && secondary && <a class="mk-button mk-lens__ghost" href={secondary} rel={external(secondary) ? "noopener" : undefined}>{props.secondaryLabel}</a>}
</p>
<div class="mk-lens__status">
<p class="mk-lens__status-label">{props.logLabel}</p>
<ol>
{props.log.map((item, i) => (
<li data-state={item.state} data-step={i}><span class="mk-lens__dot" aria-hidden="true"></span>{item.text}</li>
))}
</ol>
<button class="mk-lens__pause" type="button" data-lens-toggle data-pause={props.pauseLabel} data-play={props.playLabel} hidden>{props.pauseLabel}</button>
</div>
</div>
</div>
</section>
Expand Down
60 changes: 20 additions & 40 deletions src/components/marketing/lens.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
/**
* The lens hero's motion (LensHero.astro): the lens follows the pointer through critically damped
* springs, one per depth layer, drifts on its own when the pointer is still or coarse, narrows as
* the hero scrolls away, and steps through the activity log, taking each line's state. One rAF
* loop per hero, stopped offscreen, on a hidden tab and once everything has settled. Nothing moves
* at motion "none", under reduced motion or after the reader pauses it.
* The lens hero's motion (LensHero.astro). The lens lights up once (CSS), then follows the pointer
* a little through critically damped springs, one per depth layer, drifts on its own when the
* pointer is still or coarse, and dims as the hero scrolls away (`--lens-exit`). Under it the
* activity log shows one line at a time. One rAF loop per hero, stopped offscreen, on a hidden tab
* and once settled. Nothing moves at motion "none", under reduced motion or after a pause.
*/

type Vec = { x: number; y: number };
/** A depth layer: how far it travels (viewBox units at full reach) and its time constant (ms). */
type Layer = { el: SVGGraphicsElement | HTMLElement; reach: number; tau: number; pos: Vec };
type Layer = { el: SVGGElement; reach: number; tau: number; pos: Vec };

const IDLE_AFTER = 2800; // ms without pointer movement before the lens drifts on its own
const STEP_EVERY = 2800; // ms per activity-log line
const HOLD_LAST = 4200; // ms the last line stays before the log starts again
const TAU_SCROLL = 160;
const STEP_EVERY = 3200; // ms per activity-log line
const HOLD_LAST = 4800; // ms the last line stays before the log starts again
const TAU_SCROLL = 180;

function still(): boolean {
const level = document.documentElement.dataset.chMotion;
Expand All @@ -28,25 +28,22 @@ function mount(root: HTMLElement): void {
if (still()) return;
const q = <T extends Element>(s: string) => root.querySelector<T>(s);
const svg = q<SVGSVGElement>(".mk-lens__svg");
const eye = q<SVGGElement>(".mk-lens__eye");
const body = q<SVGGElement>(".mk-lens__body");
const iris = q<SVGGElement>(".mk-lens__iris");
const pupil = q<SVGGElement>(".mk-lens__pupil");
const stage = q<HTMLElement>(".mk-lens__stage");
const toggle = q<HTMLButtonElement>("[data-lens-toggle]");
const items = [...root.querySelectorAll<HTMLLIElement>(".mk-lens__log li")];
if (!svg || !eye || !iris || !pupil || !stage) return;
const items = [...root.querySelectorAll<HTMLLIElement>(".mk-lens__status li")];
if (!svg || !body || !iris || !pupil) return;

const layers: Layer[] = [
{ el: svg, reach: 4, tau: 300, pos: { x: 0, y: 0 } },
{ el: eye, reach: 10, tau: 200, pos: { x: 0, y: 0 } },
{ el: iris, reach: 22, tau: 120, pos: { x: 0, y: 0 } },
{ el: pupil, reach: 12, tau: 110, pos: { x: 0, y: 0 } },
{ el: body, reach: 5, tau: 320, pos: { x: 0, y: 0 } },
{ el: iris, reach: 24, tau: 140, pos: { x: 0, y: 0 } },
{ el: pupil, reach: 10, tau: 110, pos: { x: 0, y: 0 } },
];
const fine = matchMedia("(pointer: fine)").matches;
let target: Vec = { x: 0, y: 0 };
let lastMove = -Infinity;
let scroll = 0;
let pupilScale = 1;
let visible = true;
let paused = false;
let raf = 0;
Expand All @@ -69,17 +66,12 @@ function mount(root: HTMLElement): void {
wake();
};

/** Where the gaze goes when nobody is steering it: a slow Lissajous drift at 30% reach. */
/** Where the gaze goes when nobody steers it: a slow Lissajous drift at 30% reach. */
const drift = (t: number): Vec => ({
x: 0.3 * Math.sin((t / 11000) * Math.PI * 2),
y: 0.3 * Math.sin((t / 17000) * Math.PI * 2 + 1.3),
});

const stateScale = () => {
const s = root.dataset.state;
return s === "waiting" ? 1.16 : s === "working" ? 0.86 : 1;
};

const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, now - (last || now));
Expand All @@ -92,20 +84,12 @@ function mount(root: HTMLElement): void {
l.pos.x = approach(l.pos.x, goal.x, dt, l.tau);
l.pos.y = approach(l.pos.y, goal.y, dt, l.tau);
if (Math.abs(l.pos.x - goal.x) + Math.abs(l.pos.y - goal.y) > 0.002) moving = true;
l.el.style.transform = `translate(${l.pos.x * l.reach}px, ${l.pos.y * l.reach}px)`;
}
const rect = root.getBoundingClientRect();
const exit = Math.min(1, Math.max(0, -rect.top / (rect.height * 0.85)));
const exit = Math.min(1, Math.max(0, -rect.top / (rect.height * 0.8)));
scroll = approach(scroll, exit, dt, TAU_SCROLL);
if (Math.abs(scroll - exit) > 0.001) moving = true;
const wantPupil = stateScale() * (1 - 0.3 * scroll);
pupilScale = approach(pupilScale, wantPupil, dt, 220);
if (Math.abs(pupilScale - wantPupil) > 0.002) moving = true;

const [housing, ring, irisL, pupilL] = layers as [Layer, Layer, Layer, Layer];
housing.el.style.transform = `translate(${housing.pos.x * housing.reach}px, ${housing.pos.y * housing.reach}px)`;
ring.el.style.transform = `translate(${ring.pos.x * ring.reach}px, ${ring.pos.y * ring.reach}px)`;
irisL.el.style.transform = `translate(${irisL.pos.x * irisL.reach}px, ${irisL.pos.y * irisL.reach}px)`;
pupilL.el.style.transform = `translate(${pupilL.pos.x * pupilL.reach}px, ${pupilL.pos.y * pupilL.reach}px) scale(${pupilScale})`;
root.style.setProperty("--lens-exit", scroll.toFixed(3));

if (moving && visible && !document.hidden) wake();
Expand All @@ -115,13 +99,9 @@ function mount(root: HTMLElement): void {
if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame);
}

/** Light the log up to `step`, give the lens that line's state, and schedule the next line. */
/** Show log line `step`, give the lens its state, and schedule the next line. */
const show = () => {
items.forEach((li, i) => {
li.classList.toggle("is-done", i < step);
li.classList.toggle("is-on", i === step);
});
root.style.setProperty("--lens-progress", ((step + 1) / items.length).toFixed(3));
items.forEach((li, i) => li.classList.toggle("is-on", i === step));
const state = items[step]?.dataset.state;
if (state) root.dataset.state = state;
if (state === "live") {
Expand Down
18 changes: 17 additions & 1 deletion src/pages/[...path].astro
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,23 @@ const footer = footerMenu
? markActive(footerMenu, activeItem(footerMenu, activeRoute, ancestors))
: undefined;
// RFC-0025: the page's header and footer, and the menus they name beyond primary and footer.
const chrome = resolveChrome(site, route.kind === "page" ? route.page : undefined);
const resolvedChrome = resolveChrome(site, route.kind === "page" ? route.page : undefined);
// cloudhal-marketing: a page that opens with the lens hero (a dark scene in every colour mode) gets
// the transparent header over it, as over a loud band (RFC-0025 overHero); lens-hero.css styles
// data-over="lens".
const first = route.kind === "page" ? route.page.sections?.[0] : undefined;
const opensWithLens = first?.kind === "component" && (first as { type?: string }).type === "lens-hero";
const chrome =
opensWithLens && !resolvedChrome.header.hidden
? {
...resolvedChrome,
header: {
...resolvedChrome.header,
transparent: true,
over: "lens" as unknown as typeof resolvedChrome.header.over,
},
}
: resolvedChrome;
const menus: Record<string, ActiveNavItem[] | undefined> = { primary: nav, footer };
const extraMenus = await Promise.all(
chrome.menus
Expand Down
Loading
Loading