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/`). */