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";
+---
+
+