From 03968d351368ece62a1d2bf0f3c7818e6b9d3df7 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:10:43 +0300 Subject: [PATCH] feat(theme): lens-hero block, the brand lens as a living home-page scene A new section block for the home page: the headline beside the Cloudhal lens (the mark's rings, iris and pupil as inline SVG). The lens follows the pointer through critically damped springs per depth layer, drifts on its own when the pointer is still or coarse, narrows as the hero scrolls away, and steps through a short activity log whose states tint the arc and glow. Every word is a block prop. One rAF loop, stopped offscreen, on hidden tabs and once settled; two CSS loops (breathing glow, instrument ring) with a pause button; static at motion none, under reduced motion and without JavaScript. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- src/DESIGN.md | 1 + src/blocks.ts | 67 +++++ src/components/marketing/LensHero.astro | 127 +++++++++ src/components/marketing/lens.ts | 173 ++++++++++++ src/styles/features/lens-hero.css | 346 ++++++++++++++++++++++++ src/styles/global.css | 1 + src/theme.ts | 2 + 7 files changed, 717 insertions(+) create mode 100644 src/components/marketing/LensHero.astro create mode 100644 src/components/marketing/lens.ts create mode 100644 src/styles/features/lens-hero.css diff --git a/src/DESIGN.md b/src/DESIGN.md index eda1373..90dd325 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -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. diff --git a/src/blocks.ts b/src/blocks.ts index b7e116c..b105d9f 100644 --- a/src/blocks.ts +++ b/src/blocks.ts @@ -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", diff --git a/src/components/marketing/LensHero.astro b/src/components/marketing/LensHero.astro new file mode 100644 index 0000000..fc1b1a8 --- /dev/null +++ b/src/components/marketing/LensHero.astro @@ -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"; +--- +
+ +
+
+ {props.eyebrow &&

{props.eyebrow}

} +

+ {parts[0]}{parts[1] && {parts[1]}}{parts[2]} +

+ {props.body &&

{props.body}

} +

+ {primary && {props.primaryLabel}} + {props.secondaryLabel && secondary && {props.secondaryLabel}} +

+
+

{props.logLabel}

+
    + {props.log.map((item, i) => ( +
  1. {item.text}
  2. + ))} +
+
+
+ +
+
+ + diff --git a/src/components/marketing/lens.ts b/src/components/marketing/lens.ts new file mode 100644 index 0000000..b2ac852 --- /dev/null +++ b/src/components/marketing/lens.ts @@ -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 = (s: string) => root.querySelector(s); + const svg = q(".mk-lens__svg"); + const eye = q(".mk-lens__eye"); + const iris = q(".mk-lens__iris"); + const pupil = q(".mk-lens__pupil"); + const stage = q(".mk-lens__stage"); + const toggle = q("[data-lens-toggle]"); + const items = [...root.querySelectorAll(".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("[data-lens]:not([data-lens-on])")) { + root.dataset.lensOn = ""; + mount(root); + } +} diff --git a/src/styles/features/lens-hero.css b/src/styles/features/lens-hero.css new file mode 100644 index 0000000..83260ad --- /dev/null +++ b/src/styles/features/lens-hero.css @@ -0,0 +1,346 @@ +/* + * The lens hero (src/components/marketing/LensHero.astro): a dark scene in every colour mode, so + * the lens glows the same way for every reader. Its own colours are fixed and checked against its + * own background; the brand role still drives the button. The iris stays the brand's orange and + * takes only a hint of each state; the arc, the glow and the log's dots carry the state colour. lens.ts writes the layer transforms, + * --lens-exit (0 → 1 as the hero scrolls away) and --lens-progress (the activity log's share). + * Two things loop (the breathing glow, the slow instrument ring); both stop when the reader + * pauses, at motion "none" and under reduced motion. + */ +.mk-lens { + --lens-bg: #0b0807; + --lens-text: #f7efe9; + --lens-muted: #c9bdb4; + --lens-subtle: #a3968d; + --lens-line: rgb(246 195 173 / 0.16); + --lens-tint: #f6821f; + --lens-tint-alpha: 0; + --lens-exit: 0; + --lens-progress: 1; + position: relative; + isolation: isolate; + overflow: clip; + margin-inline: calc(50% - 50vw); + padding-block: clamp(2.5rem, 7vw, 6rem) clamp(3rem, 7vw, 6rem); + background: var(--lens-bg); + color: var(--lens-text); +} +.mk-lens[data-state="working"] { + --lens-tint: #5dbcc5; + --lens-tint-alpha: 0.18; +} +.mk-lens[data-state="waiting"] { + --lens-tint: #f2c14e; + --lens-tint-alpha: 0.16; +} +.mk-lens[data-state="live"] { + --lens-tint: #4cc38a; + --lens-tint-alpha: 0.18; +} + +/* The sky: warm haze and a faint instrument grid, moving with the scroll only. */ +.mk-lens__sky { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} +.mk-lens__cloud { + position: absolute; + border-radius: 50%; + filter: blur(48px); + transform: translateY(calc(var(--lens-exit) * var(--depth, 0px))); +} +.mk-lens__cloud--a { + --depth: 80px; + inset: -20% 35% 45% -15%; + background: radial-gradient(closest-side, rgb(180 68 31 / 0.38), transparent); +} +.mk-lens__cloud--b { + --depth: 140px; + inset: 10% -10% -10% 45%; + background: radial-gradient(closest-side, rgb(246 130 31 / 0.22), transparent); +} +.mk-lens__cloud--c { + --depth: 40px; + inset: 55% 20% -40% 5%; + background: radial-gradient(closest-side, rgb(93 188 197 / 0.1), transparent); +} +.mk-lens__grid { + position: absolute; + inset: 0; + background: + repeating-linear-gradient(90deg, var(--lens-line) 0 1px, transparent 1px 56px), + repeating-linear-gradient(0deg, var(--lens-line) 0 1px, transparent 1px 56px); + opacity: 0.35; + mask-image: radial-gradient(60% 70% at 70% 45%, #000, transparent); +} + +.mk-lens__inner { + display: grid; + gap: clamp(1.5rem, 4vw, 3rem); + align-items: center; + max-width: 76rem; + margin-inline: auto; + padding-inline: 1rem; +} +@media (min-width: 900px) { + .mk-lens__inner { + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + min-height: min(78vh, 720px); + } +} + +.mk-lens__eyebrow { + margin: 0 0 1rem; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #ffb98f; +} +.mk-lens__title { + margin: 0; + max-width: 15ch; + font-family: var(--ch-font-display, inherit); + font-size: clamp(2.4rem, 5.4vw, 4.6rem); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.035em; + color: var(--lens-text); +} +.mk-lens__highlight { + background: linear-gradient(95deg, #ffd8bd, #ff9a5c 45%, #f6821f); + background-clip: text; + color: transparent; +} +.mk-lens__body { + margin: 1.25rem 0 0; + max-width: 34rem; + font-size: clamp(1.05rem, 1.6vw, 1.2rem); + line-height: 1.6; + color: var(--lens-muted); +} +.mk-lens__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 2rem 0 0; +} +.mk-lens .mk-lens__ghost { + border-color: rgb(247 239 233 / 0.35); + color: var(--lens-text); +} +.mk-lens .mk-lens__ghost:hover { + border-color: var(--lens-text); +} + +/* The activity log: every line readable without JavaScript; lens.ts lights them in turn. */ +.mk-lens__log { + margin-top: 2.25rem; + max-width: 26rem; + padding: 1rem 1.25rem; + border: 1px solid var(--lens-line); + border-radius: var(--ch-radius-m, 0.75rem); + background: rgb(255 255 255 / 0.03); +} +.mk-lens__log-title { + margin: 0 0 0.5rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--lens-subtle); +} +.mk-lens__log ol { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; +} +.mk-lens__log li { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.95rem; + color: var(--lens-muted); + transition: color 400ms ease; +} +.mk-lens__dot { + flex: none; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: #f6821f; + transition: transform 400ms ease, box-shadow 400ms ease; +} +.mk-lens__log li[data-state="working"] .mk-lens__dot { background: #5dbcc5; } +.mk-lens__log li[data-state="waiting"] .mk-lens__dot { background: #f2c14e; } +.mk-lens__log li[data-state="live"] .mk-lens__dot { background: #4cc38a; } +.mk-lens[data-live] .mk-lens__log li { + color: var(--lens-subtle); +} +.mk-lens[data-live] .mk-lens__log li.is-done { + color: var(--lens-muted); +} +.mk-lens[data-live] .mk-lens__log li.is-on { + color: var(--lens-text); +} +.mk-lens[data-live] .mk-lens__log li.is-on .mk-lens__dot { + transform: scale(1.35); + box-shadow: 0 0 0 4px rgb(255 255 255 / 0.08); +} + +/* The lens. */ +.mk-lens__stage { + position: relative; + justify-self: center; + width: min(84vw, 380px); + aspect-ratio: 1; + order: -1; + transform: translateY(calc(var(--lens-exit) * -15%)) scale(calc(1 + var(--lens-exit) * 0.06)); +} +@media (min-width: 900px) { + .mk-lens__stage { + width: min(44vw, 600px); + order: 0; + } +} +.mk-lens__glow { + position: absolute; + inset: -18%; + border-radius: 50%; + background: radial-gradient( + closest-side, + color-mix(in oklab, var(--lens-tint) 55%, transparent), + color-mix(in oklab, var(--lens-tint) 18%, transparent) 55%, + transparent + ); + opacity: calc(1 - var(--lens-exit) * 0.6); + transition: background 800ms ease; + animation: mk-lens-breathe 6.5s ease-in-out infinite; +} +.mk-lens__svg { + position: relative; + display: block; + width: 100%; + height: 100%; + overflow: visible; +} +.mk-lens__ticks line { + stroke: #f6c3ad; + stroke-opacity: 0.28; + stroke-width: 1.2; +} +.mk-lens__ticks { + transform-origin: 0 0; + animation: mk-lens-turn 140s linear infinite; +} +.mk-lens__arc { + fill: none; + stroke: var(--lens-tint); + stroke-width: 2.4; + stroke-linecap: round; + stroke-dasharray: calc(var(--lens-progress) * 100) 100; + transform: rotate(-90deg); + opacity: 0.85; + transition: stroke-dasharray 900ms cubic-bezier(0.2, 0.7, 0.2, 1), stroke 800ms ease; +} +.mk-lens__fibres line { + stroke: #5a200c; + stroke-opacity: 0.22; + stroke-width: 1; +} +.mk-lens__fibres { + transform-origin: 0 0; + animation: mk-lens-iris 6.5s ease-in-out infinite; +} +.mk-lens__tint { + fill: var(--lens-tint); + opacity: var(--lens-tint-alpha); + mix-blend-mode: color; + transition: fill 800ms ease, opacity 800ms ease; +} +.mk-lens__shine { + fill: #ffffff; + opacity: 0.14; +} +.mk-lens__ping { + fill: none; + stroke: #4cc38a; + stroke-width: 2; + opacity: 0; + transform-box: fill-box; + transform-origin: center; +} +.mk-lens.is-ping .mk-lens__ping { + animation: mk-lens-ping 1100ms cubic-bezier(0.2, 0.7, 0.2, 1) 1; +} + +.mk-lens__pause { + position: absolute; + inset-block-end: 0; + inset-inline-end: 0; + padding: 0.35rem 0.75rem; + border: 1px solid var(--lens-line); + border-radius: 999px; + background: rgb(11 8 7 / 0.7); + color: var(--lens-muted); + font-size: 0.8rem; + cursor: pointer; +} +.mk-lens__pause:hover { + color: var(--lens-text); +} +.mk-lens__pause:focus-visible { + outline: 2px solid #ffb98f; + outline-offset: 2px; +} + +@keyframes mk-lens-breathe { + 50% { + transform: scale(1.03); + opacity: calc(0.88 - var(--lens-exit) * 0.6); + } +} +@keyframes mk-lens-iris { + 50% { + transform: rotate(4deg) scale(1.015); + } +} +@keyframes mk-lens-turn { + to { + transform: rotate(360deg); + } +} +@keyframes mk-lens-ping { + from { + opacity: 0.7; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(1.5); + } +} + +.mk-lens[data-paused] *, +.mk-lens[data-paused] { + animation-play-state: paused !important; +} +@media (prefers-reduced-motion: reduce) { + .mk-lens *, + .mk-lens { + animation: none !important; + transition: none !important; + } + .mk-lens__stage { + transform: none; + } +} +:root[data-ch-motion="none"] .mk-lens *, +:root[data-ch-motion="none"] .mk-lens { + animation: none !important; + transition: none !important; +} diff --git a/src/styles/global.css b/src/styles/global.css index ef0c32a..c3d5fad 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -31,3 +31,4 @@ @import "./features/languages.css"; /* cloudhal-marketing: the marketing blocks (src/components/marketing), last so they win. */ @import "./features/marketing.css"; +@import "./features/lens-hero.css"; diff --git a/src/theme.ts b/src/theme.ts index c86ca9b..211d4e1 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -13,6 +13,7 @@ import LatestPosts from "./components/LatestPosts.astro"; import CtaBand from "./components/marketing/CtaBand.astro"; import FeatureGrid from "./components/marketing/FeatureGrid.astro"; import Hero from "./components/marketing/Hero.astro"; +import LensHero from "./components/marketing/LensHero.astro"; import LinkCards from "./components/marketing/LinkCards.astro"; import PricingTable from "./components/marketing/PricingTable.astro"; import Steps from "./components/marketing/Steps.astro"; @@ -35,6 +36,7 @@ export const blocks: BlockComponents = { "pricing-table": PricingTable, "link-cards": LinkCards, "cta-band": CtaBand, + "lens-hero": LensHero, }; /** Entries per category/tag archive page (`/category//page/`). */