@avocadostudio-ai/blocks 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -106,3 +106,15 @@ section {
106
106
  padding: 20px 16px;
107
107
  }
108
108
  }
109
+
110
+ /* The wrapper `renderBlockIcon` puts around an icon that is an image URL rather
111
+ than a glyph. It exists so the editable marker has an element that can hold
112
+ the image Change button — an `<img>` cannot contain anything. Sized entirely
113
+ by the icon inside it, so every block's own icon rules still decide the
114
+ layout. */
115
+ .block-icon-wrap {
116
+ display: block;
117
+ position: relative;
118
+ width: fit-content;
119
+ line-height: 0;
120
+ }
@@ -8,19 +8,35 @@ export { normalizeRichTextBody };
8
8
  * image URL. Anything else (a bare icon-library name, descriptive text, an
9
9
  * invented/relative URL) renders nothing instead of leaking raw text or a
10
10
  * broken <img> into the page. Returns null when there's nothing safe to show.
11
+ *
12
+ * `editablePath` marks the icon for the overlay. Both branches end up on a
13
+ * `<span>`, including the image one — a marker on the `<img>` would be found by
14
+ * the editor and could never hold the Change button, which is appended *into*
15
+ * the marked element. The span also carries `data-editable-kind`, because the
16
+ * one thing the overlay cannot work out on its own is that a field named `icon`
17
+ * sometimes holds a picture: its fallback guesses from the prop name, and
18
+ * nothing about "icon" says image.
11
19
  */
12
20
  export declare function renderBlockIcon(rawValue: unknown, opts: {
13
21
  className: string;
14
22
  size: number;
23
+ editablePath?: string;
15
24
  }): JSX.Element | null;
16
25
  export declare function decodeSoftHyphenEntities(input: string): string;
17
26
  export declare function normalizeSoftHyphenEntities<T>(value: T, seen?: WeakSet<object>): T;
18
27
  type ButtonProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
19
28
  href: string;
20
29
  children: ReactNode;
30
+ /**
31
+ * Open the link in a new tab. Off unless the editor turned it on, so no
32
+ * already-published link changes behaviour on its own, and honoured only for
33
+ * an external destination — `linkAttrs` decides, and pairs `target="_blank"`
34
+ * with the `rel="noopener noreferrer"` that makes it safe.
35
+ */
36
+ newTab?: boolean;
21
37
  };
22
- export declare function PrimaryButton({ href, children, className, ...rest }: ButtonProps): import("react/jsx-runtime").JSX.Element;
23
- export declare function SecondaryButton({ href, children, className, ...rest }: ButtonProps): import("react/jsx-runtime").JSX.Element;
24
- export declare function TertiaryButton({ href, children, className, ...rest }: ButtonProps): import("react/jsx-runtime").JSX.Element;
38
+ export declare const PrimaryButton: ({ href, newTab, children, className, ...rest }: ButtonProps) => import("react/jsx-runtime").JSX.Element;
39
+ export declare const SecondaryButton: ({ href, newTab, children, className, ...rest }: ButtonProps) => import("react/jsx-runtime").JSX.Element;
40
+ export declare const TertiaryButton: ({ href, newTab, children, className, ...rest }: ButtonProps) => import("react/jsx-runtime").JSX.Element;
25
41
  export declare function renderInline(text: string): (string | JSX.Element)[];
26
42
  export declare function renderRichTextContent(input: string): (import("react/jsx-runtime").JSX.Element | import("react/jsx-runtime").JSX.Element[])[];
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { parseInline, parseRichText, normalizeRichTextBody, resolveRichTextHeadingLevel } from "@avocadostudio-ai/shared";
2
+ import { linkAttrs, parseInline, parseRichText, normalizeRichTextBody, resolveRichTextHeadingLevel } from "@avocadostudio-ai/shared";
3
3
  export { BlockImage } from "./block-image.js";
4
4
  export { RichTextDocument, renderRichTextDocument } from "./_richtext-document.js";
5
5
  // The grammar itself lives in @avocadostudio-ai/shared so that this renderer,
@@ -21,16 +21,34 @@ function isIconGlyph(value) {
21
21
  * image URL. Anything else (a bare icon-library name, descriptive text, an
22
22
  * invented/relative URL) renders nothing instead of leaking raw text or a
23
23
  * broken <img> into the page. Returns null when there's nothing safe to show.
24
+ *
25
+ * `editablePath` marks the icon for the overlay. Both branches end up on a
26
+ * `<span>`, including the image one — a marker on the `<img>` would be found by
27
+ * the editor and could never hold the Change button, which is appended *into*
28
+ * the marked element. The span also carries `data-editable-kind`, because the
29
+ * one thing the overlay cannot work out on its own is that a field named `icon`
30
+ * sometimes holds a picture: its fallback guesses from the prop name, and
31
+ * nothing about "icon" says image.
24
32
  */
25
33
  export function renderBlockIcon(rawValue, opts) {
26
34
  const value = typeof rawValue === "string" ? rawValue.trim() : "";
27
35
  if (value.length === 0)
28
36
  return null;
37
+ const marker = opts.editablePath
38
+ ? {
39
+ "data-editable-target": opts.editablePath,
40
+ "data-editable-target-label": opts.editablePath,
41
+ "data-editable-label": opts.editablePath,
42
+ // Selectable and, when it holds a URL, changeable — but never typed
43
+ // into. See `isInlineEditable` in preview-adapter, and `f.icon`.
44
+ "data-editable-inline": "false"
45
+ }
46
+ : {};
29
47
  if (isIconUrl(value)) {
30
- return (_jsx("img", { className: opts.className, src: value, alt: "", width: opts.size, height: opts.size, loading: "lazy" }));
48
+ return (_jsx("span", { className: `block-icon-wrap ${opts.className}-wrap`, ...marker, "data-editable-kind": "image", children: _jsx("img", { className: opts.className, src: value, alt: "", width: opts.size, height: opts.size, loading: "lazy" }) }));
31
49
  }
32
50
  if (isIconGlyph(value)) {
33
- return (_jsx("span", { className: opts.className, "aria-hidden": "true", children: value }));
51
+ return (_jsx("span", { className: opts.className, "aria-hidden": "true", ...marker, children: value }));
34
52
  }
35
53
  return null;
36
54
  }
@@ -76,15 +94,14 @@ export function normalizeSoftHyphenEntities(value, seen) {
76
94
  }
77
95
  return value;
78
96
  }
79
- export function PrimaryButton({ href, children, className, ...rest }) {
80
- return (_jsx("a", { href: href, className: ["btn-primary", className].filter(Boolean).join(" "), ...rest, children: children }));
81
- }
82
- export function SecondaryButton({ href, children, className, ...rest }) {
83
- return (_jsx("a", { href: href, className: ["btn-secondary", className].filter(Boolean).join(" "), ...rest, children: children }));
84
- }
85
- export function TertiaryButton({ href, children, className, ...rest }) {
86
- return (_jsx("a", { href: href, className: ["btn-tertiary", className].filter(Boolean).join(" "), ...rest, children: children }));
97
+ function button(variant) {
98
+ return function Button({ href, newTab, children, className, ...rest }) {
99
+ return (_jsx("a", { ...linkAttrs(href, newTab), className: [variant, className].filter(Boolean).join(" "), ...rest, children: children }));
100
+ };
87
101
  }
102
+ export const PrimaryButton = button("btn-primary");
103
+ export const SecondaryButton = button("btn-secondary");
104
+ export const TertiaryButton = button("btn-tertiary");
88
105
  function renderInlineToken(token) {
89
106
  switch (token.type) {
90
107
  case "strong":
@@ -17,5 +17,5 @@ export function Banner(props) {
17
17
  customStyle.backgroundColor = backgroundColor;
18
18
  if (textColor.length > 0)
19
19
  customStyle.color = textColor;
20
- return (_jsx("section", { className: `banner ${variantClass[variant] ?? variantClass.info}`, style: Object.keys(customStyle).length > 0 ? customStyle : undefined, children: _jsxs("div", { className: "banner__inner section__inner", children: [_jsx("p", { className: "banner__text", "data-editable-target": "text", "data-editable-target-label": "text", "data-editable-label": "text", children: renderInline(text) }), ctaText.length > 0 && ctaHref.length > 0 && (_jsx(PrimaryButton, { href: ctaHref, className: "banner__cta", "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: ctaText }))] }) }));
20
+ return (_jsx("section", { className: `banner ${variantClass[variant] ?? variantClass.info}`, style: Object.keys(customStyle).length > 0 ? customStyle : undefined, children: _jsxs("div", { className: "banner__inner section__inner", children: [_jsx("p", { className: "banner__text", "data-editable-target": "text", "data-editable-target-label": "text", "data-editable-label": "text", children: renderInline(text) }), ctaText.length > 0 && ctaHref.length > 0 && (_jsx(PrimaryButton, { href: ctaHref, newTab: props.ctaNewTab === true, className: "banner__cta", "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: ctaText }))] }) }));
21
21
  }
@@ -7,5 +7,11 @@ export function Card(props) {
7
7
  const variant = String(props.variant ?? "default");
8
8
  const isFullBleed = variant === "full-bleed" && imageUrl.length > 0;
9
9
  const HeadingTag = resolveHeadingTag("Card", props);
10
- return (_jsx("section", { children: _jsx("div", { className: "section__inner", children: _jsxs("article", { className: `card${isFullBleed ? " card--full-bleed" : ""}`, children: [isFullBleed ? (_jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__bg-image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 50vw", loading: "lazy", "data-editable-target": "imageUrl", "data-editable-target-label": "image", "data-editable-label": "image" })) : (imageUrl.length > 0 && (_jsx("div", { className: "card__image-wrap", "data-editable-target": "imageUrl", "data-editable-target-label": "image", children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 50vw", loading: "lazy" }) }))), _jsxs("div", { className: isFullBleed ? "card__overlay-content" : undefined, children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("p", { "data-editable-target": "description", "data-editable-target-label": "description", "data-editable-label": "description", children: String(props.description ?? "") }), _jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") })] })] }) }) }));
10
+ return (_jsx("section", { children: _jsx("div", { className: "section__inner", children: _jsxs("article", { className: `card${isFullBleed ? " card--full-bleed" : ""}`, children: [isFullBleed ? (
11
+ // The marker belongs on a wrapper here too. It sat on the
12
+ // `<BlockImage>` — an `<img>` — where the editor finds the field and
13
+ // the Change button, which is appended into the marked element, can
14
+ // never mount. The default variant below already got this right,
15
+ // which is why only the full-bleed card was missing its button.
16
+ _jsx("div", { className: "card__bg-image-wrap", "data-editable-target": "imageUrl", "data-editable-target-label": "image", "data-editable-label": "image", children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__bg-image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 50vw", loading: "lazy" }) })) : (imageUrl.length > 0 && (_jsx("div", { className: "card__image-wrap", "data-editable-target": "imageUrl", "data-editable-target-label": "image", children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 50vw", loading: "lazy" }) }))), _jsxs("div", { className: isFullBleed ? "card__overlay-content" : undefined, children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("p", { "data-editable-target": "description", "data-editable-target-label": "description", "data-editable-label": "description", children: String(props.description ?? "") }), _jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), newTab: props.ctaNewTab === true, "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") })] })] }) }) }));
11
17
  }
@@ -53,6 +53,17 @@
53
53
  border: none;
54
54
  }
55
55
 
56
+ /* The element the full-bleed image marker sits on, and therefore the element
57
+ the editor's Change button mounts into. Deliberately no `z-index`: with
58
+ `z-index: auto` it does not open a stacking context, so the button's own
59
+ z-index competes with the gradient (`::after`, 1) and the overlay content (2)
60
+ in the card's context and wins. Give this rule a z-index and the button is
61
+ trapped underneath the gradient — present, hoverable, and invisible. */
62
+ .card--full-bleed .card__bg-image-wrap {
63
+ position: absolute;
64
+ inset: 0;
65
+ }
66
+
56
67
  .card--full-bleed .card__bg-image {
57
68
  position: absolute;
58
69
  inset: 0;
@@ -5,13 +5,16 @@ export function CardGrid(props) {
5
5
  const cards = Array.isArray(props.cards) ? props.cards : [];
6
6
  const columns = String(props.columns ?? "3");
7
7
  const cardVariant = String(props.cardVariant ?? "default");
8
+ const subtitle = typeof props.subtitle === "string" ? props.subtitle.trim() : "";
8
9
  const HeadingTag = resolveHeadingTag("CardGrid", props);
9
10
  const ItemHeadingTag = resolveItemHeadingTag("CardGrid", props);
10
- return (_jsx("section", { className: "card-grid-section", children: _jsxs("div", { className: "section__inner", children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("div", { className: `card-grid${columns !== "3" ? ` card-grid--${columns}-col` : ""}`, children: cards.map((item, idx) => {
11
+ return (_jsx("section", { className: "card-grid-section", children: _jsxs("div", { className: "section__inner", children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), subtitle.length > 0 && (_jsx("p", { className: "card-grid-section__subtitle", "data-editable-target": "subtitle", "data-editable-target-label": "subtitle", "data-editable-label": "subtitle", children: subtitle })), _jsx("div", { className: `card-grid${columns !== "3" ? ` card-grid--${columns}-col` : ""}`, children: cards.map((item, idx) => {
11
12
  const row = (item ?? {});
12
13
  const imageUrl = typeof row.imageUrl === "string" ? row.imageUrl.trim() : "";
13
14
  const imageAlt = typeof row.imageAlt === "string" ? row.imageAlt.trim() : "";
14
15
  const isFullBleed = cardVariant === "full-bleed" && imageUrl.length > 0;
15
- return (_jsxs("article", { className: `card${isFullBleed ? " card--full-bleed" : ""}`, "data-editable-target": `cards[${idx}]`, "data-editable-target-label": `cards[${idx}]`, "data-editable-label": `cards[${idx}]`, children: [isFullBleed ? (_jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__bg-image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 33vw", loading: "lazy", "data-editable-target": `cards[${idx}].imageUrl`, "data-editable-target-label": `cards[${idx}].imageUrl`, "data-editable-label": `cards[${idx}].imageUrl` })) : (imageUrl.length > 0 && (_jsx("div", { className: "card__image-wrap", "data-editable-target": `cards[${idx}].imageUrl`, "data-editable-target-label": `cards[${idx}].imageUrl`, "data-editable-label": `cards[${idx}].imageUrl`, children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 33vw", loading: "lazy" }) }))), _jsxs("div", { className: isFullBleed ? "card__overlay-content" : undefined, children: [_jsx(ItemHeadingTag, { "data-editable-target": `cards[${idx}].title`, "data-editable-target-label": `cards[${idx}].title`, "data-editable-label": `cards[${idx}].title`, children: String(row.title ?? "") }), _jsx("p", { "data-editable-target": `cards[${idx}].description`, "data-editable-target-label": `cards[${idx}].description`, "data-editable-label": `cards[${idx}].description`, children: String(row.description ?? "") }), _jsx(PrimaryButton, { href: String(row.ctaHref ?? "#"), "data-editable-target": `cards[${idx}].ctaText`, "data-editable-target-label": `cards[${idx}].ctaText`, "data-editable-label": `cards[${idx}].ctaText`, children: String(row.ctaText ?? "") })] })] }, idx));
16
+ return (_jsxs("article", { className: `card${isFullBleed ? " card--full-bleed" : ""}`, "data-editable-target": `cards[${idx}]`, "data-editable-target-label": `cards[${idx}]`, "data-editable-label": `cards[${idx}]`, children: [isFullBleed ? (
17
+ // On a wrapper, not the `<img>` — see the note in Card.
18
+ _jsx("div", { className: "card__bg-image-wrap", "data-editable-target": `cards[${idx}].imageUrl`, "data-editable-target-label": `cards[${idx}].imageUrl`, "data-editable-label": `cards[${idx}].imageUrl`, children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__bg-image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 33vw", loading: "lazy" }) })) : (imageUrl.length > 0 && (_jsx("div", { className: "card__image-wrap", "data-editable-target": `cards[${idx}].imageUrl`, "data-editable-target-label": `cards[${idx}].imageUrl`, "data-editable-label": `cards[${idx}].imageUrl`, children: _jsx(BlockImage, { src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : "Card image", className: "card__image", width: 768, height: 512, sizes: "(max-width: 768px) 100vw, 33vw", loading: "lazy" }) }))), _jsxs("div", { className: isFullBleed ? "card__overlay-content" : undefined, children: [_jsx(ItemHeadingTag, { "data-editable-target": `cards[${idx}].title`, "data-editable-target-label": `cards[${idx}].title`, "data-editable-label": `cards[${idx}].title`, children: String(row.title ?? "") }), _jsx("p", { "data-editable-target": `cards[${idx}].description`, "data-editable-target-label": `cards[${idx}].description`, "data-editable-label": `cards[${idx}].description`, children: String(row.description ?? "") }), _jsx(PrimaryButton, { href: String(row.ctaHref ?? "#"), newTab: row.ctaNewTab === true, "data-editable-target": `cards[${idx}].ctaText`, "data-editable-target-label": `cards[${idx}].ctaText`, "data-editable-label": `cards[${idx}].ctaText`, children: String(row.ctaText ?? "") })] })] }, idx));
16
19
  }) })] }) }));
17
20
  }
@@ -16,6 +16,21 @@
16
16
  margin-bottom: 16px;
17
17
  }
18
18
 
19
+ /* With a subtitle the heading sits tighter against it, and the subtitle carries
20
+ the gap to the grid — otherwise the two stack with 16px between them and read
21
+ as unrelated. */
22
+ .card-grid-section:has(.card-grid-section__subtitle) :is(h1, h2, h3, h4, h5, h6) {
23
+ margin-bottom: 6px;
24
+ }
25
+
26
+ .card-grid-section__subtitle {
27
+ margin: 0 0 16px;
28
+ max-width: 62ch;
29
+ font-size: 1.0625rem;
30
+ line-height: 1.6;
31
+ color: var(--body-secondary);
32
+ }
33
+
19
34
  .card-grid .card {
20
35
  background-color: var(--surface);
21
36
  background-image:
@@ -14,6 +14,9 @@ export function Carousel(props) {
14
14
  const description = String(item.description ?? "");
15
15
  const ctaText = String(item.ctaText ?? "");
16
16
  const ctaHref = String(item.ctaHref ?? "");
17
- return (_jsxs("div", { className: "carousel__slide", children: [imageUrl.length > 0 && (_jsx(BlockImage, { className: "carousel__image", src: imageUrl, alt: imageAlt, width: 1200, height: 600, sizes: "(max-width: 900px) 100vw, 80vw", priority: idx === 0, loading: idx === 0 ? "eager" : "lazy" })), (heading.length > 0 || description.length > 0 || ctaText.length > 0) && (_jsxs("div", { className: "carousel__caption", children: [heading.length > 0 && (_jsx(ItemHeadingTag, { "data-editable-target": `items[${idx}].heading`, "data-editable-target-label": `items[${idx}].heading`, "data-editable-label": `items[${idx}].heading`, children: renderInline(heading) })), description.length > 0 && (_jsx("p", { "data-editable-target": `items[${idx}].description`, "data-editable-target-label": `items[${idx}].description`, "data-editable-label": `items[${idx}].description`, children: renderInline(description) })), ctaText.length > 0 && ctaHref.length > 0 && (_jsx(PrimaryButton, { href: ctaHref, className: "carousel__cta", "data-editable-target": `items[${idx}].ctaText`, "data-editable-target-label": `items[${idx}].ctaText`, "data-editable-label": `items[${idx}].ctaText`, children: ctaText }))] }))] }, idx));
17
+ return (_jsxs("div", { className: "carousel__slide", children: [imageUrl.length > 0 && (
18
+ // Marked on the wrapper: the Change button is appended into
19
+ // the marked element, and an `<img>` holds no children.
20
+ _jsx("div", { className: "carousel__media", "data-editable-target": `items[${idx}].imageUrl`, "data-editable-target-label": `items[${idx}].imageUrl`, "data-editable-label": `items[${idx}].imageUrl`, children: _jsx(BlockImage, { className: "carousel__image", src: imageUrl, alt: imageAlt, width: 1200, height: 600, sizes: "(max-width: 900px) 100vw, 80vw", priority: idx === 0, loading: idx === 0 ? "eager" : "lazy" }) })), (heading.length > 0 || description.length > 0 || ctaText.length > 0) && (_jsxs("div", { className: "carousel__caption", children: [heading.length > 0 && (_jsx(ItemHeadingTag, { "data-editable-target": `items[${idx}].heading`, "data-editable-target-label": `items[${idx}].heading`, "data-editable-label": `items[${idx}].heading`, children: renderInline(heading) })), description.length > 0 && (_jsx("p", { "data-editable-target": `items[${idx}].description`, "data-editable-target-label": `items[${idx}].description`, "data-editable-label": `items[${idx}].description`, children: renderInline(description) })), ctaText.length > 0 && ctaHref.length > 0 && (_jsx(PrimaryButton, { href: ctaHref, newTab: item.ctaNewTab === true, className: "carousel__cta", "data-editable-target": `items[${idx}].ctaText`, "data-editable-target-label": `items[${idx}].ctaText`, "data-editable-label": `items[${idx}].ctaText`, children: ctaText }))] }))] }, idx));
18
21
  }) }), items.length > 1 && (_jsxs(_Fragment, { children: [_jsx("button", { className: "carousel__btn carousel__btn--prev", "aria-label": "Previous slide", type: "button", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "15 18 9 12 15 6" }) }) }), _jsx("button", { className: "carousel__btn carousel__btn--next", "aria-label": "Next slide", type: "button", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "9 18 15 12 9 6" }) }) }), _jsx("div", { className: "carousel__dots", children: items.map((_, idx) => (_jsx("button", { className: `carousel__dot${idx === 0 ? " carousel__dot--active" : ""}`, "aria-label": `Go to slide ${idx + 1}`, type: "button" }, idx))) })] }))] }) }));
19
22
  }
@@ -33,6 +33,13 @@
33
33
  border: 1px solid var(--border);
34
34
  }
35
35
 
36
+ /* Wrapper the image marker sits on. `position: relative` only so the Change
37
+ button, which the overlay absolutely positions, lands on the image. */
38
+ .carousel__media {
39
+ display: block;
40
+ position: relative;
41
+ }
42
+
36
43
  .carousel__image {
37
44
  width: 100%;
38
45
  aspect-ratio: 16 / 9;
@@ -3,5 +3,5 @@ import { resolveHeadingTag } from "@avocadostudio-ai/shared";
3
3
  import { PrimaryButton, SecondaryButton } from "../_shared.js";
4
4
  export function CTA(props) {
5
5
  const HeadingTag = resolveHeadingTag("CTA", props);
6
- return (_jsx("section", { className: "cta-section", children: _jsxs("div", { className: "section__inner", children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("p", { "data-editable-target": "description", "data-editable-target-label": "description", "data-editable-label": "description", children: String(props.description ?? "") }), _jsxs("div", { className: "cta__buttons", children: [_jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") }), typeof props.secondaryCtaText === "string" && props.secondaryCtaText.length > 0 && (_jsx(SecondaryButton, { href: String(props.secondaryCtaHref ?? "#"), "data-editable-target": "secondaryCtaText", "data-editable-target-label": "secondaryCtaText", "data-editable-label": "secondaryCtaText", children: props.secondaryCtaText }))] })] }) }));
6
+ return (_jsx("section", { className: "cta-section", children: _jsxs("div", { className: "section__inner", children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("p", { "data-editable-target": "description", "data-editable-target-label": "description", "data-editable-label": "description", children: String(props.description ?? "") }), _jsxs("div", { className: "cta__buttons", children: [_jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), newTab: props.ctaNewTab === true, "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") }), typeof props.secondaryCtaText === "string" && props.secondaryCtaText.length > 0 && (_jsx(SecondaryButton, { href: String(props.secondaryCtaHref ?? "#"), newTab: props.secondaryCtaNewTab === true, "data-editable-target": "secondaryCtaText", "data-editable-target-label": "secondaryCtaText", "data-editable-label": "secondaryCtaText", children: props.secondaryCtaText }))] })] }) }));
7
7
  }
@@ -7,6 +7,6 @@ export function FeatureGrid(props) {
7
7
  const HeadingTag = resolveHeadingTag("FeatureGrid", props);
8
8
  return (_jsx("section", { children: _jsxs("div", { className: "section__inner", children: [_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: String(props.title ?? "") }), _jsx("ul", { className: `feature-grid${columns !== "3" ? ` feature-grid--${columns}-col` : ""}`, children: items.map((item, idx) => {
9
9
  const row = (item ?? {});
10
- return (_jsxs("li", { className: "feature-card", children: [renderBlockIcon(row.icon, { className: "feature-card__icon", size: 40 }), _jsx("strong", { "data-editable-target": `features[${idx}].title`, "data-editable-target-label": `features[${idx}].title`, "data-editable-label": `features[${idx}].title`, children: String(row.title ?? "") }), _jsx("p", { "data-editable-target": `features[${idx}].description`, "data-editable-target-label": `features[${idx}].description`, "data-editable-label": `features[${idx}].description`, children: String(row.description ?? "") })] }, idx));
10
+ return (_jsxs("li", { className: "feature-card", children: [renderBlockIcon(row.icon, { className: "feature-card__icon", size: 40, editablePath: `features[${idx}].icon` }), _jsx("strong", { "data-editable-target": `features[${idx}].title`, "data-editable-target-label": `features[${idx}].title`, "data-editable-label": `features[${idx}].title`, children: String(row.title ?? "") }), _jsx("p", { "data-editable-target": `features[${idx}].description`, "data-editable-target-label": `features[${idx}].description`, "data-editable-label": `features[${idx}].description`, children: String(row.description ?? "") })] }, idx));
11
11
  }) })] }) }));
12
12
  }
@@ -16,6 +16,14 @@ export function Gallery(props) {
16
16
  const imageUrl = String(img.imageUrl ?? "");
17
17
  const alt = String(img.alt ?? "");
18
18
  const caption = String(img.caption ?? "");
19
- return (_jsxs("figure", { className: "gallery__item", children: [imageUrl.length > 0 && (_jsx(BlockImage, { className: "gallery__image", src: imageUrl, alt: alt, width: 800, height: 600, sizes: "(max-width: 768px) 100vw, 33vw", loading: idx < 4 ? "eager" : "lazy" })), caption.length > 0 && (_jsx("figcaption", { className: "gallery__caption", "data-editable-target": `images[${idx}].caption`, "data-editable-target-label": `images[${idx}].caption`, "data-editable-label": `images[${idx}].caption`, children: renderInline(caption) }))] }, idx));
19
+ return (_jsxs("figure", { className: "gallery__item", children: [imageUrl.length > 0 && (
20
+ /*
21
+ * The marker goes on this wrapper, not on the image. The
22
+ * overlay mounts its Change button by appending it into the
23
+ * marked element, and an `<img>` cannot contain anything — so
24
+ * a marker there is found by the editor, counts as
25
+ * instrumented, and can never produce a button.
26
+ */
27
+ _jsx("div", { className: "gallery__media", "data-editable-target": `images[${idx}].imageUrl`, "data-editable-target-label": `images[${idx}].imageUrl`, "data-editable-label": `images[${idx}].imageUrl`, children: _jsx(BlockImage, { className: "gallery__image", src: imageUrl, alt: alt, width: 800, height: 600, sizes: "(max-width: 768px) 100vw, 33vw", loading: idx < 4 ? "eager" : "lazy" }) })), caption.length > 0 && (_jsx("figcaption", { className: "gallery__caption", "data-editable-target": `images[${idx}].caption`, "data-editable-target-label": `images[${idx}].caption`, "data-editable-label": `images[${idx}].caption`, children: renderInline(caption) }))] }, idx));
20
28
  }) })] }) }));
21
29
  }
@@ -20,6 +20,13 @@
20
20
  background: var(--bg-1);
21
21
  }
22
22
 
23
+ /* Wrapper the image marker sits on — see the note in the renderer. Sized by the
24
+ image inside it, so the grid cell is unchanged. */
25
+ .gallery__media {
26
+ display: block;
27
+ position: relative;
28
+ }
29
+
23
30
  .gallery__image {
24
31
  width: 100%;
25
32
  aspect-ratio: 4 / 3;
@@ -9,7 +9,7 @@ export function Hero(props) {
9
9
  const eyebrow = typeof props.eyebrow === "string" ? props.eyebrow.trim() : "";
10
10
  const HeadingTag = resolveHeadingTag("Hero", props);
11
11
  const contentCenterClass = textAlign === "center" ? " hero__content--center" : "";
12
- const content = (_jsxs("div", { className: `hero__content layout-grid__col${contentCenterClass}`, children: [eyebrow.length > 0 && (_jsx("span", { className: "hero__eyebrow", "data-editable-target": "eyebrow", "data-editable-target-label": "eyebrow", "data-editable-label": "eyebrow", children: eyebrow })), _jsx(HeadingTag, { "data-editable-target": "heading", "data-editable-target-label": "heading", "data-editable-label": "heading", children: String(props.heading ?? "") }), _jsx("p", { "data-editable-target": "subheading", "data-editable-target-label": "subheading", "data-editable-label": "subheading", children: String(props.subheading ?? "") }), _jsxs("div", { className: "hero__actions", children: [_jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") }), typeof props.secondaryCtaText === "string" && props.secondaryCtaText.length > 0 && (_jsx(SecondaryButton, { href: String(props.secondaryCtaHref ?? "#"), "data-editable-target": "secondaryCtaText", "data-editable-target-label": "secondaryCtaText", "data-editable-label": "secondaryCtaText", children: props.secondaryCtaText }))] })] }));
12
+ const content = (_jsxs("div", { className: `hero__content layout-grid__col${contentCenterClass}`, children: [eyebrow.length > 0 && (_jsx("span", { className: "hero__eyebrow", "data-editable-target": "eyebrow", "data-editable-target-label": "eyebrow", "data-editable-label": "eyebrow", children: eyebrow })), _jsx(HeadingTag, { "data-editable-target": "heading", "data-editable-target-label": "heading", "data-editable-label": "heading", children: String(props.heading ?? "") }), _jsx("p", { "data-editable-target": "subheading", "data-editable-target-label": "subheading", "data-editable-label": "subheading", children: String(props.subheading ?? "") }), _jsxs("div", { className: "hero__actions", children: [_jsx(PrimaryButton, { href: String(props.ctaHref ?? "#"), newTab: props.ctaNewTab === true, "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: String(props.ctaText ?? "") }), typeof props.secondaryCtaText === "string" && props.secondaryCtaText.length > 0 && (_jsx(SecondaryButton, { href: String(props.secondaryCtaHref ?? "#"), newTab: props.secondaryCtaNewTab === true, "data-editable-target": "secondaryCtaText", "data-editable-target-label": "secondaryCtaText", "data-editable-label": "secondaryCtaText", children: props.secondaryCtaText }))] })] }));
13
13
  if (isFullWidth) {
14
14
  return (_jsxs("section", { className: "hero hero--full-width", children: [_jsx(BlockImage, { className: "hero__bg-image", src: String(props.imageUrl ?? IMAGE_PLACEHOLDER), alt: String(props.imageAlt ?? "Hero image"), width: 1536, height: 1024, sizes: "100vw", priority: true, "data-editable-target": "imageUrl", "data-editable-target-label": "Hero block image", "data-editable-label": "Hero block image" }), _jsx("div", { className: "section__inner hero__inner", children: content })] }));
15
15
  }
@@ -5,5 +5,8 @@ export function Quote(props) {
5
5
  const author = String(props.author ?? "");
6
6
  const role = String(props.role ?? "");
7
7
  const imageUrl = String(props.imageUrl ?? "");
8
- return (_jsx("section", { className: "quote-block", children: _jsxs("div", { className: "section__inner", children: [_jsxs("blockquote", { className: "quote-block__quote", children: [_jsx("span", { className: "quote-block__mark", "aria-hidden": "true", children: "\u201C" }), _jsx("div", { className: "quote-block__text", "data-editable-target": "quote", "data-editable-target-label": "quote", "data-editable-label": "quote", children: renderRichTextContent(quote) })] }), (author.length > 0 || role.length > 0) && (_jsxs("div", { className: "quote-block__attribution", children: [imageUrl.length > 0 && (_jsx(BlockImage, { className: "quote-block__avatar", src: imageUrl, alt: author || "Author", width: 48, height: 48 })), _jsxs("div", { className: "quote-block__author-info", children: [author.length > 0 && (_jsx("span", { className: "quote-block__author", "data-editable-target": "author", "data-editable-target-label": "author", "data-editable-label": "author", children: author })), role.length > 0 && (_jsx("span", { className: "quote-block__role", "data-editable-target": "role", "data-editable-target-label": "role", "data-editable-label": "role", children: role }))] })] }))] }) }));
8
+ return (_jsx("section", { className: "quote-block", children: _jsxs("div", { className: "section__inner", children: [_jsxs("blockquote", { className: "quote-block__quote", children: [_jsx("span", { className: "quote-block__mark", "aria-hidden": "true", children: "\u201C" }), _jsx("div", { className: "quote-block__text", "data-editable-target": "quote", "data-editable-target-label": "quote", "data-editable-label": "quote", children: renderRichTextContent(quote) })] }), (author.length > 0 || role.length > 0) && (_jsxs("div", { className: "quote-block__attribution", children: [imageUrl.length > 0 && (
9
+ // On the wrapper, not the <img>: the Change button is appended
10
+ // into the marked element, which a void element cannot hold.
11
+ _jsx("span", { className: "quote-block__avatar-wrap", "data-editable-target": "imageUrl", "data-editable-target-label": "imageUrl", "data-editable-label": "imageUrl", children: _jsx(BlockImage, { className: "quote-block__avatar", src: imageUrl, alt: author || "Author", width: 48, height: 48 }) })), _jsxs("div", { className: "quote-block__author-info", children: [author.length > 0 && (_jsx("span", { className: "quote-block__author", "data-editable-target": "author", "data-editable-target-label": "author", "data-editable-label": "author", children: author })), role.length > 0 && (_jsx("span", { className: "quote-block__role", "data-editable-target": "role", "data-editable-target-label": "role", "data-editable-label": "role", children: role }))] })] }))] }) }));
9
12
  }
@@ -51,6 +51,15 @@
51
51
  text-align: left;
52
52
  }
53
53
 
54
+ /* Wrapper the image marker sits on — see the note in the renderer. A flex item
55
+ of `.quote-block__attribution`, sized entirely by the avatar inside it. */
56
+ .quote-block__avatar-wrap {
57
+ display: block;
58
+ position: relative;
59
+ flex: 0 0 auto;
60
+ line-height: 0;
61
+ }
62
+
54
63
  .quote-block__avatar {
55
64
  width: 48px;
56
65
  height: 48px;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useRef, useEffect, useCallback } from "react";
4
+ import { linkAttrs } from "@avocadostudio-ai/shared";
4
5
  import { BlockImage } from "../_shared.js";
5
6
  function ChevronDown({ className }) {
6
7
  return (_jsx("svg", { className: className, width: "14", height: "14", viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 4.5L6 7.5L9 4.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }));
@@ -60,5 +61,5 @@ export function SiteHeader(props) {
60
61
  return clean === activePath;
61
62
  };
62
63
  const hasActiveChild = (link) => link.children?.some((c) => isActive(c.href)) ?? false;
63
- return (_jsxs(_Fragment, { children: [_jsx("header", { className: "site-top-nav", "data-block-chrome": "true", children: _jsxs("div", { className: "site-top-nav-inner", children: [_jsxs("a", { className: "site-brand", href: links[0]?.href ?? "/", "data-editable-target": "siteName", children: [logoUrl ? _jsx(BlockImage, { className: "site-logo", src: logoUrl, alt: `${siteName} logo`, width: 38, height: 38, "data-editable-target": "logoUrl" }) : null, _jsx("span", { className: "site-brand-text", children: siteName })] }), _jsx("nav", { className: "site-nav-links site-nav-links-desktop", "aria-label": "Primary", children: links.map((link, i) => link.children?.length ? (_jsxs("div", { className: "site-nav-dropdown", children: [_jsxs("button", { type: "button", className: `site-nav-dropdown-trigger${hasActiveChild(link) ? " is-active" : ""}`, "data-editable-target": `links[${i}].label`, "data-editable-target-label": `links[${i}].label`, children: [link.label, _jsx(ChevronDown, { className: "site-nav-chevron" })] }), _jsx("div", { className: "site-nav-dropdown-menu", children: link.children.map((child, j) => (_jsx("a", { href: child.href ?? "#", className: isActive(child.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].children[${j}].label`, "data-editable-target-label": `links[${i}].children[${j}].label`, onClick: (e) => e.currentTarget.blur(), children: child.label }, child.href))) })] }, `group-${i}`)) : (_jsx("a", { href: link.href ?? "#", className: isActive(link.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].label`, "data-editable-target-label": `links[${i}].label`, children: link.label }, link.href))) }), _jsxs("div", { ref: menuRef, className: `site-mobile-menu${isOpen ? " is-open" : ""}`, children: [_jsx("button", { ref: toggleRef, type: "button", className: "site-mobile-menu-button", "aria-expanded": isOpen, "aria-controls": menuId, "aria-label": "Toggle navigation menu", onClick: toggle, children: _jsxs("span", { className: "burger-icon", "aria-hidden": "true", children: [_jsx("i", {}), _jsx("i", {}), _jsx("i", {})] }) }), isOpen ? (_jsx("nav", { id: menuId, className: "site-nav-links site-nav-links-mobile", "aria-label": "Mobile primary", children: links.map((link, i) => link.children?.length ? (_jsxs("div", { className: "site-nav-mobile-group", children: [_jsxs("button", { type: "button", className: `site-nav-mobile-group-trigger${hasActiveChild(link) ? " is-active" : ""}`, "aria-expanded": expandedGroup === i, onClick: () => setExpandedGroup(expandedGroup === i ? null : i), "data-editable-target": `links[${i}].label`, children: [link.label, _jsx(ChevronDown, { className: `site-nav-chevron${expandedGroup === i ? " is-expanded" : ""}` })] }), expandedGroup === i ? (_jsx("div", { className: "site-nav-mobile-group-children", children: link.children.map((child, j) => (_jsx("a", { href: child.href ?? "#", className: isActive(child.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].children[${j}].label`, onClick: close, children: child.label }, `mobile-${child.href}`))) })) : null] }, `mobile-group-${i}`)) : (_jsx("a", { href: link.href ?? "#", className: isActive(link.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].label`, onClick: close, children: link.label }, `mobile-${link.href}`))) })) : null] })] }) }), isOpen ? (_jsx("div", { className: "site-mobile-backdrop", onClick: close })) : null] }));
64
+ return (_jsxs(_Fragment, { children: [_jsx("header", { className: "site-top-nav", "data-block-chrome": "true", children: _jsxs("div", { className: "site-top-nav-inner", children: [_jsxs("a", { className: "site-brand", href: links[0]?.href ?? "/", "data-editable-target": "siteName", children: [logoUrl ? _jsx(BlockImage, { className: "site-logo", src: logoUrl, alt: `${siteName} logo`, width: 38, height: 38, "data-editable-target": "logoUrl" }) : null, _jsx("span", { className: "site-brand-text", children: siteName })] }), _jsx("nav", { className: "site-nav-links site-nav-links-desktop", "aria-label": "Primary", children: links.map((link, i) => link.children?.length ? (_jsxs("div", { className: "site-nav-dropdown", children: [_jsxs("button", { type: "button", className: `site-nav-dropdown-trigger${hasActiveChild(link) ? " is-active" : ""}`, "data-editable-target": `links[${i}].label`, "data-editable-target-label": `links[${i}].label`, children: [link.label, _jsx(ChevronDown, { className: "site-nav-chevron" })] }), _jsx("div", { className: "site-nav-dropdown-menu", children: link.children.map((child, j) => (_jsx("a", { ...linkAttrs(child.href, child.newTab), className: isActive(child.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].children[${j}].label`, "data-editable-target-label": `links[${i}].children[${j}].label`, onClick: (e) => e.currentTarget.blur(), children: child.label }, child.href))) })] }, `group-${i}`)) : (_jsx("a", { ...linkAttrs(link.href, link.newTab), className: isActive(link.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].label`, "data-editable-target-label": `links[${i}].label`, children: link.label }, link.href))) }), _jsxs("div", { ref: menuRef, className: `site-mobile-menu${isOpen ? " is-open" : ""}`, children: [_jsx("button", { ref: toggleRef, type: "button", className: "site-mobile-menu-button", "aria-expanded": isOpen, "aria-controls": menuId, "aria-label": "Toggle navigation menu", onClick: toggle, children: _jsxs("span", { className: "burger-icon", "aria-hidden": "true", children: [_jsx("i", {}), _jsx("i", {}), _jsx("i", {})] }) }), isOpen ? (_jsx("nav", { id: menuId, className: "site-nav-links site-nav-links-mobile", "aria-label": "Mobile primary", children: links.map((link, i) => link.children?.length ? (_jsxs("div", { className: "site-nav-mobile-group", children: [_jsxs("button", { type: "button", className: `site-nav-mobile-group-trigger${hasActiveChild(link) ? " is-active" : ""}`, "aria-expanded": expandedGroup === i, onClick: () => setExpandedGroup(expandedGroup === i ? null : i), "data-editable-target": `links[${i}].label`, children: [link.label, _jsx(ChevronDown, { className: `site-nav-chevron${expandedGroup === i ? " is-expanded" : ""}` })] }), expandedGroup === i ? (_jsx("div", { className: "site-nav-mobile-group-children", children: link.children.map((child, j) => (_jsx("a", { ...linkAttrs(child.href, child.newTab), className: isActive(child.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].children[${j}].label`, onClick: close, children: child.label }, `mobile-${child.href}`))) })) : null] }, `mobile-group-${i}`)) : (_jsx("a", { ...linkAttrs(link.href, link.newTab), className: isActive(link.href) ? "is-active" : undefined, "data-editable-target": `links[${i}].label`, onClick: close, children: link.label }, `mobile-${link.href}`))) })) : null] })] }) }), isOpen ? (_jsx("div", { className: "site-mobile-backdrop", onClick: close })) : null] }));
64
65
  }
@@ -8,6 +8,6 @@ export function Stats(props) {
8
8
  return (_jsx("section", { className: "stats-section", children: _jsxs("div", { className: "section__inner", children: [title.length > 0 && (_jsx(HeadingTag, { "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: title })), _jsx("div", { className: "stats-grid", children: items.map((item, idx) => {
9
9
  const row = (item ?? {});
10
10
  const description = typeof row.description === "string" ? row.description.trim() : "";
11
- return (_jsxs("div", { className: "stat-item", children: [renderBlockIcon(row.icon, { className: "stat-item__icon", size: 32 }), _jsx("span", { className: "stat-item__value", "data-editable-target": `stats[${idx}].value`, "data-editable-target-label": `stats[${idx}].value`, "data-editable-label": `stats[${idx}].value`, children: String(row.value ?? "") }), _jsx("span", { className: "stat-item__label", "data-editable-target": `stats[${idx}].label`, "data-editable-target-label": `stats[${idx}].label`, "data-editable-label": `stats[${idx}].label`, children: String(row.label ?? "") }), description.length > 0 && (_jsx("span", { className: "stat-item__description", "data-editable-target": `stats[${idx}].description`, "data-editable-target-label": `stats[${idx}].description`, "data-editable-label": `stats[${idx}].description`, children: description }))] }, idx));
11
+ return (_jsxs("div", { className: "stat-item", children: [renderBlockIcon(row.icon, { className: "stat-item__icon", size: 32, editablePath: `stats[${idx}].icon` }), _jsx("span", { className: "stat-item__value", "data-editable-target": `stats[${idx}].value`, "data-editable-target-label": `stats[${idx}].value`, "data-editable-label": `stats[${idx}].value`, children: String(row.value ?? "") }), _jsx("span", { className: "stat-item__label", "data-editable-target": `stats[${idx}].label`, "data-editable-target-label": `stats[${idx}].label`, "data-editable-label": `stats[${idx}].label`, children: String(row.label ?? "") }), description.length > 0 && (_jsx("span", { className: "stat-item__description", "data-editable-target": `stats[${idx}].description`, "data-editable-target-label": `stats[${idx}].description`, "data-editable-label": `stats[${idx}].description`, children: description }))] }, idx));
12
12
  }) })] }) }));
13
13
  }
@@ -9,7 +9,16 @@ export function Testimonials(props) {
9
9
  const imageUrl = typeof row.imageUrl === "string" ? row.imageUrl.trim() : "";
10
10
  const imageAlt = typeof row.imageAlt === "string" ? row.imageAlt.trim() : "";
11
11
  const role = typeof row.role === "string" ? row.role.trim() : "";
12
- const iconNode = renderBlockIcon(row.icon, { className: "testimonial-card__icon", size: 32 });
13
- return (_jsxs("blockquote", { className: "testimonial-card", children: [iconNode ?? (_jsx("span", { className: "testimonial-card__mark", "aria-hidden": "true", children: "\u201C" })), _jsx("p", { className: "testimonial-card__quote", "data-editable-target": `items[${idx}].quote`, "data-editable-target-label": `items[${idx}].quote`, "data-editable-label": `items[${idx}].quote`, children: String(row.quote ?? "") }), _jsxs("footer", { className: "testimonial-card__footer", children: [imageUrl.length > 0 && (_jsx(BlockImage, { className: "testimonial-card__avatar", src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : String(row.author ?? ""), width: 48, height: 48, loading: "lazy", "data-editable-target": `items[${idx}].imageUrl`, "data-editable-target-label": `items[${idx}].imageUrl`, "data-editable-label": `items[${idx}].imageUrl` })), _jsxs("div", { className: "testimonial-card__attribution", children: [_jsxs("span", { className: "testimonial-card__author", "data-editable-target": `items[${idx}].author`, "data-editable-target-label": `items[${idx}].author`, "data-editable-label": `items[${idx}].author`, children: ["\u2014 ", String(row.author ?? "")] }), role.length > 0 && (_jsx("span", { className: "testimonial-card__role", "data-editable-target": `items[${idx}].role`, "data-editable-target-label": `items[${idx}].role`, "data-editable-label": `items[${idx}].role`, children: role }))] })] })] }, idx));
12
+ const iconNode = renderBlockIcon(row.icon, { className: "testimonial-card__icon", size: 32, editablePath: `items[${idx}].icon` });
13
+ return (_jsxs("blockquote", { className: "testimonial-card", children: [iconNode ?? (_jsx("span", { className: "testimonial-card__mark", "aria-hidden": "true", children: "\u201C" })), _jsx("p", { className: "testimonial-card__quote", "data-editable-target": `items[${idx}].quote`, "data-editable-target-label": `items[${idx}].quote`, "data-editable-label": `items[${idx}].quote`, children: String(row.quote ?? "") }), _jsxs("footer", { className: "testimonial-card__footer", children: [imageUrl.length > 0 && (
14
+ /*
15
+ * The marker used to sit on the `<img>` itself, which reads
16
+ * as instrumented everywhere it is checked and is not: the
17
+ * overlay mounts the Change button by appending it into the
18
+ * marked element, and an `<img>` has no children. The
19
+ * attribute was in the HTML, the editor found the field, and
20
+ * no button could ever appear on an avatar.
21
+ */
22
+ _jsx("span", { className: "testimonial-card__avatar-wrap", "data-editable-target": `items[${idx}].imageUrl`, "data-editable-target-label": `items[${idx}].imageUrl`, "data-editable-label": `items[${idx}].imageUrl`, children: _jsx(BlockImage, { className: "testimonial-card__avatar", src: imageUrl, alt: imageAlt.length > 0 ? imageAlt : String(row.author ?? ""), width: 48, height: 48, loading: "lazy" }) })), _jsxs("div", { className: "testimonial-card__attribution", children: [_jsxs("span", { className: "testimonial-card__author", "data-editable-target": `items[${idx}].author`, "data-editable-target-label": `items[${idx}].author`, "data-editable-label": `items[${idx}].author`, children: ["\u2014 ", String(row.author ?? "")] }), role.length > 0 && (_jsx("span", { className: "testimonial-card__role", "data-editable-target": `items[${idx}].role`, "data-editable-target-label": `items[${idx}].role`, "data-editable-label": `items[${idx}].role`, children: role }))] })] })] }, idx));
14
23
  }) })] }) }));
15
24
  }
@@ -52,6 +52,15 @@ img.testimonial-card__icon {
52
52
  gap: 12px;
53
53
  }
54
54
 
55
+ /* Wrapper the image marker sits on — see the note in the renderer. A flex item
56
+ of `.testimonial-card__footer`, sized by the avatar inside it. */
57
+ .testimonial-card__avatar-wrap {
58
+ display: block;
59
+ position: relative;
60
+ flex: 0 0 auto;
61
+ line-height: 0;
62
+ }
63
+
55
64
  .testimonial-card__avatar {
56
65
  width: 48px;
57
66
  height: 48px;
@@ -1,31 +1,75 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { resolveHeadingTag } from "@avocadostudio-ai/shared";
3
3
  import { PrimaryButton, renderRichTextContent, BlockImage } from "../_shared.js";
4
+ /**
5
+ * Mark an element for the field at `<prefix>.<key>`.
6
+ *
7
+ * The prefix matters more than it looks. These children used to be marked with
8
+ * flat names — `heading`, `body`, `ctaText` — which read like the other blocks'
9
+ * props and are nothing of the sort: a TwoColumn has no `heading` prop, only
10
+ * `left[]` and `right[]` of typed children. So every inline edit in this block
11
+ * committed a field draft addressed to a prop that does not exist, the schema
12
+ * dropped the unknown key on the way back through, and the edit disappeared on
13
+ * the next reconcile with no error anywhere. The coverage check saw the other
14
+ * half of the same fact: both lists reported "no marker for any item".
15
+ */
16
+ function marker(pathPrefix, key, label) {
17
+ const path = `${pathPrefix}.${key}`;
18
+ return {
19
+ "data-editable-target": path,
20
+ "data-editable-target-label": label ?? path,
21
+ "data-editable-label": label ?? path,
22
+ };
23
+ }
4
24
  function TwoColumnChild({ item, headingTag, pathPrefix }) {
5
25
  const childType = String(item.type ?? "");
6
26
  const HeadingTag = headingTag;
7
- if (childType === "heading") {
8
- return (_jsx(HeadingTag, { "data-editable-target": "heading", "data-editable-target-label": "heading", "data-editable-label": "heading", children: String(item.text ?? "") }));
27
+ if (childType === "heading" || childType === "subheading") {
28
+ const Tag = childType === "heading" ? HeadingTag : "p";
29
+ return (_jsx(Tag, { className: childType === "subheading" ? "two-column__subheading" : undefined, ...marker(pathPrefix, "text"), children: String(item.text ?? "") }));
9
30
  }
10
31
  if (childType === "paragraph") {
11
32
  const renderedBody = renderRichTextContent(String(item.text ?? ""));
12
- return (_jsx("div", { className: "two-column__body", "data-editable-target": "body", "data-editable-target-label": "body", "data-editable-label": "body", children: renderedBody }));
33
+ return (_jsx("div", { className: "two-column__body", ...marker(pathPrefix, "text"), "data-editable-multiline": "true", children: renderedBody }));
34
+ }
35
+ if (childType === "list") {
36
+ const entries = Array.isArray(item.items) ? item.items : [];
37
+ const text = String(item.text ?? "");
38
+ if (entries.length === 0 && text.length === 0)
39
+ return null;
40
+ if (entries.length === 0) {
41
+ return (_jsx("div", { className: "two-column__body", ...marker(pathPrefix, "text"), "data-editable-multiline": "true", children: renderRichTextContent(text) }));
42
+ }
43
+ return (_jsx("ul", { className: "two-column__list", children: entries.map((entry, i) => (_jsx("li", { children: String(entry ?? "") }, i))) }));
13
44
  }
14
- if (childType === "cta") {
15
- const label = String(item.label ?? "");
16
- if (!label)
45
+ if (childType === "cta" || childType === "ctas") {
46
+ /*
47
+ * `ctas` carries a `buttons` array; `cta` is the single-button spelling.
48
+ * Only the single one is marked: a button inside the array addresses
49
+ * `left[0].buttons[1].label`, two levels of nesting, which
50
+ * `supportsInlineEditablePath` rejects — so a marker there would offer an
51
+ * inline edit that cannot be committed.
52
+ */
53
+ const buttons = childType === "ctas" && Array.isArray(item.buttons)
54
+ ? item.buttons
55
+ : [{ label: item.label, href: item.href, newTab: item.newTab }];
56
+ const drawable = buttons.filter((button) => String(button?.label ?? "").length > 0);
57
+ if (drawable.length === 0)
17
58
  return null;
18
- return (_jsx("div", { className: "two-column__cta", children: _jsx(PrimaryButton, { href: String(item.href ?? "#"), "data-editable-target": "ctaText", "data-editable-target-label": "ctaText", "data-editable-label": "ctaText", children: label }) }));
59
+ return (_jsx("div", { className: "two-column__cta", children: drawable.map((button, i) => (_jsx(PrimaryButton, { href: String(button.href ?? "#"), newTab: button.newTab === true, ...(childType === "cta" ? marker(pathPrefix, "label") : {}), children: String(button.label ?? "") }, i))) }));
19
60
  }
20
61
  if (childType === "video") {
21
62
  const src = String(item.src ?? "");
22
63
  const poster = item.poster ? String(item.poster) : undefined;
23
64
  if (!src)
24
65
  return null;
25
- return (_jsx("div", { className: "two-column__media", children: _jsx("video", { className: "two-column__video", controls: true, playsInline: true, preload: "metadata", poster: poster, children: _jsx("source", { src: src, type: "video/mp4" }) }) }));
66
+ // The marker goes on the wrapper, never the <video>: the overlay mounts the
67
+ // Change button by appending it into the marked element, and a browser
68
+ // renders nothing a <video> contains.
69
+ return (_jsx("div", { className: "two-column__media", ...marker(pathPrefix, "poster", "Video poster"), "data-editable-kind": "image", children: _jsx("video", { className: "two-column__video", controls: true, playsInline: true, preload: "metadata", poster: poster, children: _jsx("source", { src: src, type: "video/mp4" }) }) }));
26
70
  }
27
71
  if (childType === "image") {
28
- return (_jsx("div", { className: "two-column__media", "data-editable-target": `${pathPrefix}.src`, "data-editable-target-label": "Image", children: _jsx(BlockImage, { className: "layout-grid__img", src: String(item.src ?? ""), alt: String(item.alt ?? ""), width: 768, height: 1024, sizes: "(max-width: 768px) 100vw, 50vw", "data-editable-label": "Image" }) }));
72
+ return (_jsx("div", { className: "two-column__media", ...marker(pathPrefix, "src", "Image"), children: _jsx(BlockImage, { className: "layout-grid__img", src: String(item.src ?? ""), alt: String(item.alt ?? ""), width: 768, height: 1024, sizes: "(max-width: 768px) 100vw, 50vw", "data-editable-label": "Image" }) }));
29
73
  }
30
74
  return null;
31
75
  }
@@ -136,3 +136,39 @@
136
136
  margin: 0.3em 0 0;
137
137
  }
138
138
 
139
+
140
+ /* A `subheading` child: a lead paragraph under the column title, not a heading
141
+ element — the block already resolves one heading level for the column and a
142
+ second one under it would misread as structure. */
143
+ .two-column__subheading {
144
+ margin: 0 0 12px;
145
+ font-size: 1.125rem;
146
+ line-height: 1.5;
147
+ font-weight: 600;
148
+ color: var(--body);
149
+ }
150
+
151
+ /* A `list` child. The same look as a list written inside a `paragraph` child's
152
+ markdown, so the two spellings of the same content do not render differently. */
153
+ .two-column__list {
154
+ margin: 0 0 16px;
155
+ padding-left: 1.4em;
156
+ list-style: disc;
157
+ color: var(--body-secondary);
158
+ line-height: 1.75;
159
+ }
160
+
161
+ .two-column__list li {
162
+ display: list-item;
163
+ }
164
+
165
+ .two-column__list li + li {
166
+ margin-top: 0.3em;
167
+ }
168
+
169
+ /* `ctas` draws more than one button; `cta` draws exactly one and is unaffected. */
170
+ .two-column__cta {
171
+ display: flex;
172
+ flex-wrap: wrap;
173
+ gap: 10px;
174
+ }
@@ -31,14 +31,25 @@ function buildEmbedUrl(type, id, autoplay, loop) {
31
31
  params.set("loop", "1");
32
32
  return `https://player.vimeo.com/video/${id}?${params.toString()}`;
33
33
  }
34
- function EmbedFacade({ type, videoId, title, autoplay, loop }) {
34
+ function EmbedFacade({ type, videoId, title, autoplay, loop, posterUrl }) {
35
35
  const [activated, setActivated] = useState(autoplay);
36
36
  if (activated) {
37
37
  return (_jsx("iframe", { src: buildEmbedUrl(type, videoId, true, loop), title: title || "Video", allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", allowFullScreen: true }));
38
38
  }
39
- const thumbUrl = type === "youtube"
40
- ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
41
- : undefined;
39
+ /*
40
+ * The poster the block was given wins over the one the platform derives.
41
+ *
42
+ * It used to be read only by the `<video>` branch, so setting a poster on a
43
+ * YouTube or Vimeo URL — which the property panel happily offers, with a
44
+ * picker and a Change button — changed nothing anybody could see. Vimeo was
45
+ * worse than a no-op: it has no `i.ytimg.com` equivalent, so its facade was a
46
+ * black rectangle with a play triangle whatever the block said.
47
+ */
48
+ const thumbUrl = posterUrl.length > 0
49
+ ? posterUrl
50
+ : type === "youtube"
51
+ ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
52
+ : undefined;
42
53
  return (_jsxs("button", { type: "button", className: "video-block__facade", onClick: () => setActivated(true), "aria-label": `Play ${title || "video"}`, children: [thumbUrl && _jsx("img", { src: thumbUrl, alt: "", className: "video-block__facade-thumb", loading: "lazy" }), _jsxs("svg", { className: "video-block__facade-play", viewBox: "0 0 68 48", "aria-hidden": "true", children: [_jsx("path", { d: "M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z", fill: "currentColor" }), _jsx("path", { d: "M45 24L27 14v20", fill: "#fff" })] })] }));
43
54
  }
44
55
  export function Video(props) {
@@ -48,5 +59,5 @@ export function Video(props) {
48
59
  const autoplay = String(props.autoplay) === "true";
49
60
  const loop = String(props.loop) === "true";
50
61
  const sourceType = detectSourceType(src);
51
- return (_jsx("section", { className: "video-block", children: _jsxs("div", { className: "section__inner", children: [_jsxs("div", { className: "video-block__frame", children: [sourceType === "youtube" && (_jsx(EmbedFacade, { type: "youtube", videoId: extractYouTubeId(src), title: title, autoplay: autoplay, loop: loop })), sourceType === "vimeo" && (_jsx(EmbedFacade, { type: "vimeo", videoId: extractVimeoId(src), title: title, autoplay: autoplay, loop: loop })), sourceType === "direct" && (_jsx("video", { src: src, poster: posterUrl || undefined, controls: true, autoPlay: autoplay, loop: loop, playsInline: true, preload: "metadata" }))] }), title.length > 0 && (_jsx("p", { className: "video-block__caption", "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: renderInline(title) }))] }) }));
62
+ return (_jsx("section", { className: "video-block", children: _jsxs("div", { className: "section__inner", children: [_jsxs("div", { className: "video-block__frame", "data-editable-target": "posterUrl", "data-editable-target-label": "Poster image", "data-editable-kind": "image", children: [sourceType === "youtube" && (_jsx(EmbedFacade, { type: "youtube", videoId: extractYouTubeId(src), title: title, autoplay: autoplay, loop: loop, posterUrl: posterUrl })), sourceType === "vimeo" && (_jsx(EmbedFacade, { type: "vimeo", videoId: extractVimeoId(src), title: title, autoplay: autoplay, loop: loop, posterUrl: posterUrl })), sourceType === "direct" && (_jsx("video", { src: src, poster: posterUrl || undefined, controls: true, autoPlay: autoplay, loop: loop, playsInline: true, preload: "metadata" }))] }), title.length > 0 && (_jsx("p", { className: "video-block__caption", "data-editable-target": "title", "data-editable-target-label": "title", "data-editable-label": "title", children: renderInline(title) }))] }) }));
52
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/blocks",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,8 +26,8 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@avocadostudio-ai/richtext": "^0.3.2",
30
- "@avocadostudio-ai/shared": "^0.3.2"
29
+ "@avocadostudio-ai/shared": "^0.4.0",
30
+ "@avocadostudio-ai/richtext": "^0.4.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "next": ">=15.0.0",