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
1 change: 1 addition & 0 deletions src/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ cloudhal-marketing is the theme of cloudhal.com, grown from the blog starter: a
- Photographs from the media library, landscape for heroes and cards (`16:9` or `4:3`), never the same image twice on a page. Alt text says what is in the picture, in the page's language.

## Pages
- Product home (cloudhal.com): a `lens-hero` first (the brand lens beside the promise, with a three-to-five line log of how a change goes live), then one scroll story, proof, features and the closing `cta-band`. One `lens-hero` per page, never below another section.
- Home (editorial): hero band with the publication's promise → content list of the newest articles → a `muted` band with two or three sections or categories as cards → newsletter or about call to action.
- Landing: hero band → three to four features in a `Grid` → proof (`Stat` row or `testimonial`) → pricing (`Tabs` or `Toggle` with bound prices) → `faq` → call to action band.
- About: one short paragraph of purpose → people or values as cards → contact call to action.
Expand Down
67 changes: 67 additions & 0 deletions src/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,75 @@ const linkItem = {
},
} as const;

/** One line of the lens hero's activity log; `state` tints the lens while the line is current. */
const lensLogItem = {
type: "object",
additionalProperties: false,
required: ["text", "state"],
properties: {
text: { type: "string", minLength: 1, maxLength: 80, title: "Text" },
state: {
type: "string",
enum: ["watching", "working", "waiting", "live"],
title: "Lens state",
},
},
} as const;

/** The home page's opening: the brand lens as a living scene (src/components/marketing/LensHero.astro). */
const lensHero: BlockType = {
name: "lens-hero",
title: "Lens hero",
description:
"A page's opening scene: the headline beside the brand lens, which follows the pointer, breathes, narrows as the page scrolls and changes colour with a short log of what an agent is doing. One per page, first.",
icon: "Aperture",
category: "layout",
where: ["section"],
props: {
type: "object",
additionalProperties: false,
required: ["heading", "primaryLabel", "primaryHref", "log", "logLabel", "pauseLabel", "playLabel"],
properties: {
eyebrow: { type: "string", maxLength: 60, title: "Eyebrow" },
heading: { type: "string", minLength: 1, maxLength: 120, title: "Heading" },
highlight: {
type: "string",
maxLength: 60,
title: "Highlighted words",
description: "Words of the heading drawn in the brand gradient (they must occur in it).",
},
body: { type: "string", maxLength: 400, title: "Text" },
primaryLabel: { type: "string", minLength: 1, maxLength: 40, title: "Button" },
primaryHref: { type: "string", format: "url", title: "Button link" },
secondaryLabel: { type: "string", maxLength: 40, title: "Second button" },
secondaryHref: { type: "string", format: "url", title: "Second button link" },
logLabel: { type: "string", minLength: 1, maxLength: 60, title: "Log title" },
log: { type: "array", minItems: 2, maxItems: 6, title: "Activity log", items: lensLogItem },
pauseLabel: { type: "string", minLength: 1, maxLength: 30, title: "Pause button" },
playLabel: { type: "string", minLength: 1, maxLength: 30, title: "Play button" },
},
},
example: {
props: {
heading: "Agents build your site. Nothing ships unseen.",
highlight: "Nothing ships unseen.",
primaryLabel: "Try the playground",
primaryHref: "https://try.cloudhal.com",
logLabel: "On this site",
log: [
{ text: "Agent drafts a new section", state: "working" },
{ text: "Waiting for your approval", state: "waiting" },
{ text: "Published, rollback ready", state: "live" },
],
pauseLabel: "Pause motion",
playLabel: "Play motion",
},
},
};

/** Marketing blocks of the cloudhal-marketing theme (English only, like the site). */
const marketingTypes: readonly BlockType[] = [
lensHero,
{
name: "feature-grid",
title: "Feature grid",
Expand Down
127 changes: 127 additions & 0 deletions src/components/marketing/LensHero.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
/**
* 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.
*/
import { external, href } from "./href.ts";

type LensState = "watching" | "working" | "waiting" | "live";
interface Props {
props: {
eyebrow?: string;
heading: string;
highlight?: string;
body?: string;
primaryLabel: string;
primaryHref: string;
secondaryLabel?: string;
secondaryHref?: string;
logLabel: string;
log: { text: string; state: LensState }[];
pauseLabel: string;
playLabel: string;
};
}
const { props } = Astro.props;
const primary = href(props.primaryHref);
const secondary = href(props.secondaryHref);

// The highlighted words, when they occur in the heading, are split out to take the gradient.
const at = props.highlight ? props.heading.indexOf(props.highlight) : -1;
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">
{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">
<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>
<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" />
</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>
</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>
<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>
<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" />
</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>
</section>

<script>
import { mountLenses } from "./lens.ts";
mountLenses();
</script>
173 changes: 173 additions & 0 deletions src/components/marketing/lens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* 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.
*/

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 };

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;

function still(): boolean {
const level = document.documentElement.dataset.chMotion;
return level === "none" || matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/** Frame-rate independent approach of `from` to `to` with time constant `tau`. */
const approach = (from: number, to: number, dt: number, tau: number) =>
from + (to - from) * (1 - Math.exp(-dt / tau));

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 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 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 } },
];
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;
let last = 0;
let step = 0;
let timer = 0;

root.dataset.live = "";
if (toggle) toggle.hidden = false;

const onPointer = (e: PointerEvent) => {
const r = svg.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const d = Math.hypot(dx, dy) || 1;
// Far pointers saturate softly instead of hitting a hard stop.
const k = Math.tanh(d / (0.5 * Math.max(innerWidth, innerHeight))) / d;
target = { x: dx * k, y: dy * k };
lastMove = performance.now();
wake();
};

/** Where the gaze goes when nobody is steering 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));
last = now;
const idle = !fine || now - lastMove > IDLE_AFTER;
const goal = paused ? { x: 0, y: 0 } : idle ? drift(now) : target;
let moving = idle && !paused;

for (const l of layers) {
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;
}
const rect = root.getBoundingClientRect();
const exit = Math.min(1, Math.max(0, -rect.top / (rect.height * 0.85)));
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();
};

function wake() {
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. */
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));
const state = items[step]?.dataset.state;
if (state) root.dataset.state = state;
if (state === "live") {
root.classList.remove("is-ping");
void root.offsetWidth; // restart the one-shot ring
root.classList.add("is-ping");
}
wake();
clearTimeout(timer);
if (paused || !visible) return;
const lastLine = step === items.length - 1;
timer = window.setTimeout(() => {
step = lastLine ? 0 : step + 1;
show();
}, lastLine ? HOLD_LAST : STEP_EVERY);
};

const setPaused = (p: boolean) => {
paused = p;
root.toggleAttribute("data-paused", p);
if (toggle) {
toggle.textContent = p ? (toggle.dataset.play ?? "") : (toggle.dataset.pause ?? "");
toggle.setAttribute("aria-pressed", String(p));
}
show();
};

toggle?.addEventListener("click", () => setPaused(!paused));
addEventListener("pointermove", onPointer, { passive: true });
addEventListener("scroll", wake, { passive: true });
document.addEventListener("visibilitychange", () => {
if (document.hidden) clearTimeout(timer);
else show();
});
new IntersectionObserver(([e]) => {
visible = Boolean(e?.isIntersecting);
if (visible) show();
else clearTimeout(timer);
}).observe(root);
show();
}

/** Starts every lens hero on the page (once each). */
export function mountLenses(): void {
for (const root of document.querySelectorAll<HTMLElement>("[data-lens]:not([data-lens-on])")) {
root.dataset.lensOn = "";
mount(root);
}
}
Loading
Loading