@bettercms-ai/mcp 0.73.2 → 0.74.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.
@@ -0,0 +1,3259 @@
1
+ ---
2
+ title: Kit catalogue
3
+ description: The 149 modelled website sections of the BetterCMS kit, by category and edge tag, with each one's model notes (why, HTML signals, mcp, pitfalls). Generated from the dashboard kit; do not edit.
4
+ ---
5
+
6
+ # Kit catalogue
7
+
8
+ <!-- GENERATED by packages/mcp/scripts/kit-reference.ts from packages/mcp/src/kit-catalog.json. Regenerate from the dashboard: bun run kit:export -- --backend <backend checkout> -->
9
+
10
+ Find the closest entry by edge tag or category, then follow its **Do** and **Pitfalls**. Each entry's create_component props: bettercms://kit/entry/{id} (MCP resource). Search this file for an id or a tag rather than reading it whole.
11
+
12
+ ## Contents
13
+
14
+ - [Edge tags](#edge-tags)
15
+ - [Navigation & chrome](#navigation--chrome)
16
+ - [Heroes](#heroes)
17
+ - [Features & storytelling](#features--storytelling)
18
+ - [Social proof](#social-proof)
19
+ - [Media & galleries](#media--galleries)
20
+ - [Commerce](#commerce)
21
+ - [Pricing & plans](#pricing--plans)
22
+ - [Conversion & forms](#conversion--forms)
23
+ - [Listings & feeds](#listings--feeds)
24
+ - [Long-form & docs](#long-form--docs)
25
+ - [Company & people](#company--people)
26
+ - [Events & places](#events--places)
27
+ - [Data & interactive](#data--interactive)
28
+ - [Advanced patterns](#advanced-patterns)
29
+ - [Core examples](#core-examples)
30
+
31
+ ## Edge tags
32
+
33
+ - `deep-nesting` (4+ levels of drill-in): nav-mega-featured-promo, nav-docs-sidebar, footer-mega-locations, hero-personalized, comparison-vs-competitors, commerce-product-detail, commerce-cart-cross-sell, commerce-shop-the-look, commerce-store-locator, commerce-product-comparison, pricing-usage-calculator, pricing-feature-matrix, conversion-multi-step-form, conversion-quiz-product-finder, changelog, public-roadmap, recipe, course-curriculum, legal-policy, press-kit, event-detail, locations-directory, restaurant-menu, location-page, product-configurator, page-builder-six-deep, personalized-hero, core-megaMenu
34
+ - `repeater` (Arrays of objects): nav-simple-cta, nav-announcement-utility, nav-docs-sidebar, nav-ecommerce-header, nav-locale-header, announcement-countdown, cookie-consent, breadcrumb-config, footer-multi-column, footer-minimal, footer-mega-locations, hero-split-media, hero-video-fullbleed, hero-slider, hero-product, hero-lead-form, hero-app-download, hero-stats, hero-logo-wall, hero-personalized, feature-icon-grid, feature-alternating-rows, steps-how-it-works, timeline-company-history, tabs-with-media, accordion-feature-image-swap, comparison-vs-competitors, stats-band, problem-solution, testimonial-video-slider, wall-of-love, logo-cloud, press-mentions, awards-certifications, masonry-gallery, lightbox-gallery-filters, before-after-slider, commerce-product-grid-query, commerce-product-detail, commerce-featured-product, commerce-collection-header-facets, commerce-bundle-builder, commerce-cart-cross-sell, commerce-shop-the-look, commerce-size-guide, commerce-store-locator, commerce-product-comparison, pricing-tiers-billing-toggle, pricing-usage-calculator, pricing-feature-matrix, pricing-single-plan-addons, pricing-enterprise-card, pricing-faq, conversion-newsletter-consent, conversion-lead-capture, conversion-booking-embed, conversion-waitlist-referral, conversion-contact-section, conversion-quiz-product-finder, conversion-trust-badges, resource-library, podcast-episode-list, changelog, public-roadmap, search-results-page, code-tabs, glossary, recipe, course-curriculum, legal-policy, case-study, podcast-episode, leadership-bios, values-culture, job-posting, about-timeline, press-kit, event-detail, locations-directory, restaurant-menu, location-page, data-table-specs, chart-attrition, roi-calculator, product-configurator, stockist-map, product-compare-slider, reader-poll, code-snippet-tabs, page-builder-six-deep, polymorphic-modules, localized-section, personalized-hero, responsive-layout-settings, references-web, changelog-nested-validation, dynamic-query-listing, overlays-with-triggers, scrollytelling, core-faq, core-megaMenu, core-tabs, core-pricing
35
+ - `polymorphic-array` (Array of mixed block types): wall-of-love, mixed-media-carousel, commerce-product-detail, conversion-multi-step-form, polymorphic-modules, core-tabs, core-bento
36
+ - `slots` (Blocks placed inside a block): nav-mega-featured-promo, bento-feature-showcase, page-builder-six-deep, grid-cell-layout, overlays-with-triggers, core-tabs, core-bento
37
+ - `variants` (Layout variants of one block): nav-simple-cta, nav-mega-featured-promo, cookie-consent, hero-split-media, hero-centered-gradient, hero-stats, hero-logo-wall, hero-split-testimonial, feature-icon-grid, feature-alternating-rows, bento-feature-showcase, steps-how-it-works, tabs-with-media, accordion-feature-image-swap, comparison-vs-competitors, stats-band, manifesto-pull-quote, problem-solution, integrations-grid, testimonial-grid, testimonial-video-slider, case-study-cards, logo-cloud, press-mentions, video-section, before-after-slider, image-hotspots, interactive-3d-lottie-embed, parallax-image-overlay, commerce-product-grid-query, commerce-featured-product, commerce-promo-countdown, pricing-tiers-billing-toggle, conversion-cta-band, conversion-contact-section, conversion-trust-badges, blog-index, category-filter-bar, related-posts, changelog, author-bio, code-tabs, team-grid, leadership-bios, values-culture, careers-list, about-timeline, events-list, webinar-registration, restaurant-menu, page-builder-six-deep, global-announcement-overrides, responsive-layout-settings, grid-cell-layout, scrollytelling, section-theme-tokens, core-tabs, core-pricing, core-bento, core-hero
38
+ - `conditional` (Fields shown or hidden by other values): nav-announcement-utility, nav-mega-featured-promo, nav-docs-sidebar, nav-ecommerce-header, nav-locale-header, sticky-mobile-cta-bar, announcement-countdown, cookie-consent, breadcrumb-config, hero-split-media, hero-centered-gradient, hero-video-fullbleed, hero-slider, hero-product, hero-search, hero-lead-form, hero-app-download, hero-logo-wall, hero-split-testimonial, hero-event, hero-personalized, feature-alternating-rows, bento-feature-showcase, timeline-company-history, tabs-with-media, comparison-vs-competitors, manifesto-pull-quote, integrations-grid, testimonial-grid, testimonial-video-slider, case-study-cards, logo-cloud, awards-certifications, review-summary, video-section, image-hotspots, interactive-3d-lottie-embed, mixed-media-carousel, parallax-image-overlay, ugc-social-feed, commerce-product-grid-query, commerce-product-detail, commerce-featured-product, commerce-collection-header-facets, commerce-bundle-builder, commerce-cart-cross-sell, commerce-promo-countdown, commerce-store-locator, commerce-product-comparison, pricing-tiers-billing-toggle, pricing-usage-calculator, pricing-feature-matrix, pricing-single-plan-addons, pricing-enterprise-card, pricing-faq, pricing-annual-savings-banner, conversion-cta-band, conversion-newsletter-consent, conversion-lead-capture, conversion-multi-step-form, conversion-booking-embed, conversion-waitlist-referral, conversion-contact-section, conversion-quiz-product-finder, conversion-exit-intent-popup, conversion-trust-badges, blog-index, category-filter-bar, related-posts, resource-library, podcast-episode-list, changelog, public-roadmap, search-results-page, rich-blog-post, author-bio, docs-page, kb-article, glossary, recipe, course-curriculum, legal-policy, case-study, podcast-episode, team-grid, leadership-bios, careers-list, job-posting, press-kit, events-list, event-detail, webinar-registration, locations-directory, restaurant-menu, location-page, data-table-specs, chart-attrition, roi-calculator, product-configurator, stockist-map, product-compare-slider, countdown-live-counter, reader-poll, safe-embed, code-snippet-tabs, conditional-source-matrix, global-announcement-overrides, localized-section, personalized-hero, responsive-layout-settings, references-web, changelog-nested-validation, dynamic-query-listing, overlays-with-triggers, scrollytelling, a11y-critical-media-form, section-theme-tokens, core-faq, core-pricing
39
+ - `references` (Links to pages or CMS items): nav-mega-featured-promo, nav-docs-sidebar, nav-ecommerce-header, breadcrumb-config, footer-multi-column, hero-product, hero-search, hero-lead-form, hero-split-testimonial, integrations-grid, testimonial-grid, testimonial-video-slider, case-study-cards, review-summary, image-hotspots, mixed-media-carousel, ugc-social-feed, commerce-product-grid-query, commerce-product-detail, commerce-featured-product, commerce-collection-header-facets, commerce-bundle-builder, commerce-cart-cross-sell, commerce-shop-the-look, commerce-size-guide, commerce-store-locator, commerce-product-comparison, pricing-enterprise-card, pricing-faq, conversion-newsletter-consent, conversion-lead-capture, conversion-multi-step-form, conversion-waitlist-referral, conversion-contact-section, conversion-quiz-product-finder, conversion-exit-intent-popup, blog-index, category-filter-bar, related-posts, resource-library, podcast-episode-list, public-roadmap, search-results-page, rich-blog-post, author-bio, docs-page, kb-article, glossary, recipe, course-curriculum, case-study, podcast-episode, team-grid, leadership-bios, careers-list, job-posting, about-timeline, events-list, event-detail, webinar-registration, location-page, table-comparison, product-configurator, stockist-map, product-compare-slider, reader-poll, polymorphic-modules, conditional-source-matrix, references-web, rich-text-embedded-components, dynamic-query-listing, overlays-with-triggers, core-journalStory
40
+ - `dynamic-query` (Pulls N items from a collection by filter): nav-docs-sidebar, hero-search, integrations-grid, testimonial-grid, case-study-cards, ugc-social-feed, commerce-product-grid-query, commerce-collection-header-facets, commerce-store-locator, blog-index, category-filter-bar, related-posts, resource-library, podcast-episode-list, changelog, search-results-page, author-bio, glossary, team-grid, careers-list, events-list, locations-directory, conditional-source-matrix, references-web, dynamic-query-listing, core-faq
41
+ - `global-synced` (Shared block with per-instance overrides): nav-simple-cta, nav-announcement-utility, nav-mega-featured-promo, nav-docs-sidebar, nav-ecommerce-header, nav-locale-header, announcement-countdown, cookie-consent, footer-multi-column, footer-minimal, footer-mega-locations, hero-app-download, docs-page, global-announcement-overrides, core-megaMenu
42
+ - `localized` (Per-field translations): nav-announcement-utility, nav-locale-header, announcement-countdown, cookie-consent, feature-icon-grid, tabs-with-media, stats-band, manifesto-pull-quote, press-mentions, masonry-gallery, lightbox-gallery-filters, video-section, before-after-slider, rich-blog-post, recipe, legal-policy, restaurant-menu, countdown-live-counter, localized-section, core-journalStory, core-hero
43
+ - `rich-text` (Portable text body with embeds): feature-alternating-rows, timeline-company-history, accordion-feature-image-swap, manifesto-pull-quote, commerce-product-detail, pricing-single-plan-addons, pricing-faq, conversion-lead-capture, conversion-booking-embed, changelog, rich-blog-post, docs-page, kb-article, legal-policy, case-study, leadership-bios, about-timeline, polymorphic-modules, changelog-nested-validation, rich-text-embedded-components, core-journalStory
44
+ - `media-heavy` (Images, video, galleries, focal points): nav-ecommerce-header, footer-mega-locations, hero-split-media, hero-centered-gradient, hero-video-fullbleed, hero-slider, hero-product, hero-search, hero-app-download, hero-stats, hero-logo-wall, hero-split-testimonial, hero-event, feature-alternating-rows, bento-feature-showcase, timeline-company-history, tabs-with-media, accordion-feature-image-swap, integrations-grid, testimonial-video-slider, wall-of-love, logo-cloud, press-mentions, masonry-gallery, lightbox-gallery-filters, video-section, before-after-slider, image-hotspots, interactive-3d-lottie-embed, mixed-media-carousel, parallax-image-overlay, ugc-social-feed, commerce-product-detail, commerce-featured-product, commerce-collection-header-facets, commerce-shop-the-look, commerce-size-guide, pricing-enterprise-card, conversion-cta-band, conversion-booking-embed, conversion-waitlist-referral, conversion-quiz-product-finder, conversion-exit-intent-popup, conversion-trust-badges, podcast-episode-list, rich-blog-post, recipe, course-curriculum, case-study, podcast-episode, values-culture, about-timeline, press-kit, location-page, page-builder-six-deep, polymorphic-modules, grid-cell-layout, rich-text-embedded-components, scrollytelling, a11y-critical-media-form, section-theme-tokens, core-bento, core-journalStory, core-hero
45
+ - `embed` (iframe, HTML, code or third-party): video-section, interactive-3d-lottie-embed, ugc-social-feed, commerce-store-locator, conversion-booking-embed, conversion-contact-section, podcast-episode-list, rich-blog-post, docs-page, code-tabs, podcast-episode, careers-list, webinar-registration, stockist-map, safe-embed, code-snippet-tabs, rich-text-embedded-components, core-journalStory
46
+ - `form` (Collects visitor input): footer-multi-column, hero-lead-form, pricing-enterprise-card, conversion-newsletter-consent, conversion-lead-capture, conversion-multi-step-form, conversion-waitlist-referral, conversion-contact-section, conversion-quiz-product-finder, conversion-exit-intent-popup, resource-library, public-roadmap, kb-article, job-posting, webinar-registration, roi-calculator, reader-poll, overlays-with-triggers, a11y-critical-media-form
47
+ - `commerce` (Price, inventory, variants): nav-mega-featured-promo, nav-ecommerce-header, sticky-mobile-cta-bar, footer-multi-column, hero-product, review-summary, image-hotspots, mixed-media-carousel, ugc-social-feed, commerce-product-grid-query, commerce-product-detail, commerce-featured-product, commerce-collection-header-facets, commerce-bundle-builder, commerce-cart-cross-sell, commerce-promo-countdown, commerce-shop-the-look, commerce-product-comparison, pricing-tiers-billing-toggle, pricing-usage-calculator, pricing-single-plan-addons, pricing-annual-savings-banner, conversion-quiz-product-finder, event-detail, restaurant-menu, table-comparison, product-configurator, product-compare-slider, conditional-source-matrix, grid-cell-layout, core-pricing
48
+ - `personalization` (Audience or A/B variants): hero-personalized, commerce-cart-cross-sell, commerce-promo-countdown, pricing-annual-savings-banner, conversion-quiz-product-finder, conversion-exit-intent-popup, personalized-hero
49
+ - `validation` (Limits, required, patterns): nav-simple-cta, sticky-mobile-cta-bar, announcement-countdown, cookie-consent, footer-multi-column, footer-minimal, footer-mega-locations, hero-centered-gradient, hero-video-fullbleed, hero-slider, hero-lead-form, hero-app-download, hero-stats, hero-event, hero-personalized, feature-icon-grid, steps-how-it-works, timeline-company-history, comparison-vs-competitors, stats-band, problem-solution, testimonial-grid, testimonial-video-slider, wall-of-love, logo-cloud, press-mentions, awards-certifications, review-summary, masonry-gallery, lightbox-gallery-filters, video-section, before-after-slider, image-hotspots, interactive-3d-lottie-embed, ugc-social-feed, commerce-product-grid-query, commerce-product-detail, commerce-collection-header-facets, commerce-bundle-builder, commerce-cart-cross-sell, commerce-promo-countdown, commerce-shop-the-look, commerce-size-guide, commerce-store-locator, commerce-product-comparison, pricing-tiers-billing-toggle, pricing-usage-calculator, pricing-feature-matrix, pricing-single-plan-addons, pricing-faq, pricing-annual-savings-banner, conversion-newsletter-consent, conversion-lead-capture, conversion-multi-step-form, conversion-booking-embed, conversion-waitlist-referral, conversion-quiz-product-finder, conversion-exit-intent-popup, conversion-trust-badges, blog-index, related-posts, resource-library, changelog, public-roadmap, search-results-page, rich-blog-post, docs-page, code-tabs, kb-article, glossary, recipe, course-curriculum, legal-policy, case-study, podcast-episode, team-grid, values-culture, job-posting, about-timeline, press-kit, event-detail, webinar-registration, locations-directory, restaurant-menu, location-page, data-table-specs, table-comparison, chart-attrition, roi-calculator, product-configurator, product-compare-slider, countdown-live-counter, reader-poll, safe-embed, code-snippet-tabs, conditional-source-matrix, global-announcement-overrides, localized-section, personalized-hero, references-web, changelog-nested-validation, dynamic-query-listing, a11y-critical-media-form, section-theme-tokens, core-faq, core-megaMenu, core-pricing, core-journalStory
50
+ - `a11y` (Alt text, captions, labels): announcement-countdown, cookie-consent, breadcrumb-config, hero-split-media, hero-video-fullbleed, hero-slider, hero-logo-wall, tabs-with-media, accordion-feature-image-swap, comparison-vs-competitors, stats-band, testimonial-video-slider, wall-of-love, logo-cloud, awards-certifications, review-summary, masonry-gallery, lightbox-gallery-filters, video-section, before-after-slider, image-hotspots, interactive-3d-lottie-embed, mixed-media-carousel, parallax-image-overlay, commerce-product-detail, commerce-shop-the-look, commerce-size-guide, commerce-product-comparison, pricing-feature-matrix, pricing-enterprise-card, conversion-newsletter-consent, conversion-multi-step-form, conversion-contact-section, conversion-exit-intent-popup, conversion-trust-badges, category-filter-bar, podcast-episode-list, search-results-page, author-bio, glossary, legal-policy, podcast-episode, team-grid, leadership-bios, values-culture, press-kit, events-list, locations-directory, location-page, data-table-specs, table-comparison, chart-attrition, stockist-map, countdown-live-counter, safe-embed, localized-section, rich-text-embedded-components, overlays-with-triggers, scrollytelling, a11y-critical-media-form, section-theme-tokens, core-hero
51
+ - `seo-structured` (Emits JSON-LD): breadcrumb-config, footer-mega-locations, hero-event, awards-certifications, review-summary, video-section, commerce-product-detail, commerce-store-locator, pricing-faq, rich-blog-post, glossary, recipe, course-curriculum, podcast-episode, job-posting, event-detail, restaurant-menu, location-page, table-comparison, stockist-map, core-journalStory
52
+ - `responsive-layout` (Per-breakpoint layout settings): sticky-mobile-cta-bar, bento-feature-showcase, masonry-gallery, mixed-media-carousel, parallax-image-overlay, commerce-product-grid-query, commerce-product-comparison, pricing-feature-matrix, category-filter-bar, resource-library, locations-directory, product-compare-slider, responsive-layout-settings, grid-cell-layout, core-bento
53
+
54
+ ## Navigation & chrome
55
+
56
+ ### Simple navbar with CTA (`nav-simple-cta`)
57
+
58
+ Tags: global-synced, repeater, validation, variants. Proves: Smallest global header · inline link rows · one button unit · tap-to-call link · design kept out of content
59
+
60
+ **Why this shape.** A header is site chrome, so it is ONE global (kind: global) that every page renders — never a block copied onto each page, or a menu change becomes 40 edits. Links are an array of tiny objects (label + link) that open inline because two fields don't deserve a drill-in. The CTA is a `button` (label + link + style as one unit) so the label can never drift from where it goes. Phone is a `link` of type phone, not a string, so the renderer gets a valid tel: href. Theme, sticky, transparency and the mobile menu style live in Design: they change how it looks, not what it says.
61
+
62
+ **HTML signals.**
63
+ - <header> (or role=banner) as the first child of <body>, containing <nav> with a short <ul> of <a>
64
+ - A logo <a href="/"> wrapping an <img>/<svg> at the start of the row
65
+ - One visually distinct <a>/<button> at the end of the row (filled background) — the CTA
66
+ - An <a href="tel:…"> or phone icon; a hamburger <button aria-expanded> hidden on desktop
67
+ - position: sticky/fixed on the header; identical markup on every crawled page
68
+
69
+ **Do.** Detect on 2+ pages with identical markup → create ONE global `header` and reference it from layouts; never per page. Map logo <img> → logo (alt → logoText if no visible text), each top-level <nav> <a> → links[] { label: textContent, link: internal page if same origin else url }. The last styled anchor/button → button (style from its class: filled → primary, bordered → outline). tel: anchors → phone. position sticky/fixed → isSticky: true; a transparent background over a hero → isTransparent: true. Ignore the hamburger markup — it's the renderer's job.
70
+
71
+ **Pitfalls.**
72
+ - A 'Book a table' widget that opens a third-party modal is still a button with an external link — don't model the widget's script.
73
+ - Don't create a link for the logo; it always goes home.
74
+
75
+ Not carried by a stored component: category, item openMode, kind:global, variants.
76
+
77
+ ### Header with announcement + utility row (`nav-announcement-utility`)
78
+
79
+ Tags: global-synced, conditional, repeater, localized. Proves: Three stacked rows in one global · conditional row content · tab groups per row · design values split from content
80
+
81
+ **Why this shape.** Three visual rows, ONE global: the rows always ship together and share a sticky behavior, so splitting them into three globals would make 'what stays on scroll' impossible to express. Tab groups mirror the rows (Main row / Top rows / Design) so a marketer changing the shipping message never sees the menu. The announcement is an object with an `showAnnouncement` switch and its fields conditional on it — turning the strip off keeps the copy for next time. Its color is a design choice and lives in Design, not inside the announcement object.
82
+
83
+ **HTML signals.**
84
+ - Two or three stacked rows inside <header>: a full-width colored strip with one sentence, then a row of small links, then the main nav
85
+ - The strip text contains shipping/discount wording ('Free shipping', '% off', 'Use code')
86
+ - Utility row uses smaller type and icons (truck, help, map pin) and is display:none under ~768px
87
+ - Only the bottom row has position: sticky
88
+
89
+ **Do.** Treat the stacked rows as ONE global. Colored single-sentence strip → announcement { showAnnouncement: true, message, link: the strip's <a> if any }; its background color → announcementTone (red/orange → sale, brand color → brand, gray → neutral). Small links above the main nav → utilityLinks (keep the icon name if an icon library class is present). Main <nav> anchors → items. If the strip is a separate dismissible element with its own close button and a countdown, use `announcement-countdown` instead and leave this one's announcement disabled.
90
+
91
+ **Pitfalls.**
92
+ - A rotating announcement (several messages cycling) is an array, not one string — switch `message` to an array of {message, link} rather than joining with ·.
93
+ - Don't put the account/cart icons in utility links — those are app state; see `nav-ecommerce-header`.
94
+
95
+ Not carried by a stored component: ai, category, document preview path, item openMode, kind:global.
96
+
97
+ ### Mega menu with featured cards + promo slot (`nav-mega-featured-promo`)
98
+
99
+ Tags: deep-nesting, slots, global-synced, conditional, references, variants, commerce. Proves: Six levels deep · product references inside a menu · a marketer-owned promo SLOT next to structured columns
100
+
101
+ **Why this shape.** Same global header shape as the core Mega menu, but the panel separates what is STRUCTURED from what is CAMPAIGN. Link columns and product cards are typed fields (a converter and a validator can reason about them); the promo is a `slot` limited to one block from a short allow-list, so marketing can drop in a card, image or video for a launch without a developer adding fields. Product cards are REFERENCES to Shopify products — photo, name, price and stock flow in, so the menu never shows a stale price. `opens` switches between a plain link and a panel; the panel object only shows when needed. The badge is a popover object because it's 3 small fields edited rarely, and it carries its own expiry.
102
+
103
+ **HTML signals.**
104
+ - Top-level nav <li> containing a <div> panel (often role=menu or [data-mega], display:none until hover/aria-expanded)
105
+ - Panel holds 2–4 <ul> lists each preceded by a small heading — the columns
106
+ - Image cards inside the panel with a price or 'Shop' link — product cards (look for /products/ hrefs and price text)
107
+ - A visually different tile (full-bleed image or colored card) at the panel edge — the promo
108
+ - Small pills (<span class="badge">New</span>) next to some links
109
+
110
+ **Do.** Per top-level <li>: if it has a panel → opens: 'panel', else opens: 'link'. Panel headings + their <ul> → columns[] (a trailing 'View all/Shop all' anchor → viewAll, not a link). Card with a price and a /products/<handle> href → featuredCards[] { source: 'product', product: resolve handle to the Shopify product id }; card without price → { source: 'custom', image, title, button }. The remaining odd-one-out tile → promo slot as ctaCard (text+button), imageBlock (image+caption) or videoBlock. Pills → badge.label; red/sale colors → tone: 'sale'. Never copy the price text into a field.
111
+
112
+ **Pitfalls.**
113
+ - Product cards that hard-code price text go stale on the next price change — always resolve to a reference.
114
+ - If two menu items share the same panel content, that is still two panels in content; don't invent a shared-panel global for it.
115
+ - Hover-only panels fail keyboard users — the converter should default `openOn` to click when the source has no aria-expanded handling.
116
+
117
+ Not carried by a stored component: ai, category, kind:global, reference target, slot allow, variants.
118
+
119
+ ### Docs sidebar navigation (`nav-docs-sidebar`)
120
+
121
+ Tags: deep-nesting, repeater, global-synced, conditional, dynamic-query, references. Proves: Four-level tree at a fixed depth · curated vs auto from the page tree · version switcher · status badges
122
+
123
+ **Why this shape.** A docs sidebar is shared by every docs page, so it is a global. Most teams want BOTH modes: a curated order (what to read first) and an automatic tree (no one forgets to add the new page), so `source` decides which fields show. The tree is modelled to a FIXED depth — Section › Page › Sub-page — because our schema language has no recursive types; three levels is also the usability ceiling for a sidebar. Badges are a select (a fixed vocabulary the renderer can color and the search index can filter), not free text. Versions are a small inline array because each one is just a label, a link and a status.
124
+
125
+ **HTML signals.**
126
+ - <aside>/<nav aria-label="Docs"> fixed to the left with a long nested <ul> and collapsible groups (<details>/<button aria-expanded>)
127
+ - Group headings in small caps above lists of links whose hrefs share a /docs/ prefix
128
+ - A version <select> or dropdown ('v3', 'latest') at the top; a search input with a ⌘K / Ctrl K hint
129
+ - Pills like 'New', 'Beta', 'Deprecated' next to links
130
+ - The same sidebar on every /docs/* page with the current link marked aria-current=page
131
+
132
+ **Do.** Crawl 2+ /docs pages; identical sidebars → ONE global `docsSidebar`. If the tree order matches the URL/folder order exactly and there's no hand-written grouping → source: 'auto' with root = the docs index page; otherwise source: 'curated'. Group headings → sections[].title; links under them → pages[]; nested lists → children[] (flatten anything deeper into the sub-page level and report it). Pills → badge (map unknown text to '' and report). Version dropdown options → versions[] (status 'legacy' for anything not marked latest).
133
+
134
+ **Pitfalls.**
135
+ - Sidebars 4+ levels deep can't be represented — flatten to sub-pages and flag it rather than inventing nested objects.
136
+ - Don't import the 'On this page' table of contents — it's generated from each page's headings.
137
+
138
+ Not carried by a stored component: category, item openMode, kind:global.
139
+
140
+ ### E-commerce header (`nav-ecommerce-header`)
141
+
142
+ Tags: global-synced, commerce, references, repeater, conditional, media-heavy. Proves: Search, account and cart config in one global · product references for search · app state (cart count) NOT modelled
143
+
144
+ **Why this shape.** Everything a store header SAYS is content; everything it COUNTS is app state. The cart count, the signed-in name and search results come from the commerce/session layer at runtime and are deliberately not fields. What remains — categories, search hints, empty states, which way the cart opens — is one global. Popular products are references (photos and prices stay live). Suggested searches are `tags` because they're short strings with no other properties. Groups follow the visitor's mental model (Content / Search / Account & cart) so the merchandiser doesn't wade through cart copy to change a category.
145
+
146
+ **HTML signals.**
147
+ - Header with a search <input type="search"> or <form role="search"> plus account and bag/cart icons on the right
148
+ - A cart icon with a numeric badge (e.g. <span class="cart-count">2</span>) — runtime state
149
+ - A row of collection links (/collections/…, /shop/…) under or beside the logo
150
+ - Search dropdown markup with 'Popular searches' or 'Trending' lists and product tiles
151
+ - A hidden cart drawer (<aside aria-label="Cart"> / role=dialog) elsewhere in the DOM
152
+
153
+ **Do.** Create ONE global `storeHeader`. Collection links → categories[] (a link colored red / labeled Sale → isHighlighted: true). Search placeholder → searchPlaceholder; 'Popular searches' text items → suggestions; product tiles inside the search dropdown → popularProducts resolved by handle to Shopify ids. A cart drawer element present → cartOpens: 'drawer' and copy its empty-state text/button into emptyCart/drawerButton. NEVER create fields for the cart count, prices, the customer name or currency — report them as runtime.
154
+
155
+ **Pitfalls.**
156
+ - Copying '(2)' from the cart badge into content is the most common converter mistake — it's session state.
157
+ - Free-shipping thresholds belong to commerce settings, not the header; reference them from the renderer.
158
+
159
+ Not carried by a stored component: category, kind:global, reference target, variants.
160
+
161
+ ### Multi-locale header with switchers (`nav-locale-header`)
162
+
163
+ Tags: localized, global-synced, conditional, repeater. Proves: Localized labels inside a global · switcher config with conditional lists · what belongs to site settings vs content
164
+
165
+ **Why this shape.** Labels are `localized: true` (one field, a value per locale) instead of one header per language: the menu STRUCTURE is shared, only the words change, so adding a link once adds it everywhere. Links point at pages, and the router resolves each locale's translation — no per-locale URLs typed in. The switcher's lists are content today because the site owner curates order and native names, but they mirror what should be site settings (published locales, enabled currencies) — see the gap note. Display (codes vs names) and placement are Design.
166
+
167
+ **HTML signals.**
168
+ - <html lang> differs across crawled URLs (/de/, /fr/ prefixes or subdomains) while the header markup is identical
169
+ - A dropdown listing language names in their own script (Deutsch, Français, 日本語) and/or currency codes/symbols
170
+ - <link rel="alternate" hreflang="…"> tags in <head> (native SEO — don't model them)
171
+ - A 'Shop in your language?' geo banner or modal
172
+
173
+ **Do.** Crawl the same page in each locale. Header with identical structure → ONE global; for each nav label, write the per-locale strings into the localized `label` (match items by href position, not by text). Language dropdown options → languages[] { locale from hreflang/path prefix, nativeName: the option text }. Currency options → currencies (ISO codes). Never model hreflang tags — the platform emits them. If headers differ structurally by locale (different items), report it: that's a per-market header, not a translation.
174
+
175
+ **Pitfalls.**
176
+ - Using flags for languages (🇬🇧 for English) — map to codes and set display: 'code'.
177
+ - Translating the link target by typing /de/... URLs — link to the page and let locale routing resolve it.
178
+
179
+ Not carried by a stored component: category, item openMode, kind:global.
180
+
181
+ ### Sticky bottom CTA bar (mobile) (`sticky-mobile-cta-bar`)
182
+
183
+ Tags: responsive-layout, conditional, validation, commerce. Proves: A per-template block with breakpoint visibility, scroll triggers and a secondary contact action
184
+
185
+ **Why this shape.** Unlike the header, this bar is placed per template (product pages, a landing page), so it's an ordinary block, not a global. Breakpoint visibility is a multiselect because our schema has no per-breakpoint value type — this is the honest minimum (see gaps). The price is a switch, not a number: it's read live from the product the page is about. The second action is a select with a conditional phone link, so a chat action doesn't ask for a number it won't use.
186
+
187
+ **HTML signals.**
188
+ - A fixed element at bottom: 0 with a single prominent button, often display:none ≥ 768px
189
+ - IntersectionObserver / scroll listener toggling a class like is-visible on the bar
190
+ - Duplicates the main CTA text from the hero or buy box
191
+ - env(safe-area-inset-bottom) padding
192
+
193
+ **Do.** position: fixed + bottom: 0 + one button → `stickyBar`, placed on the template it appears on (not global unless it's on every page). Read media queries for showOn. A scroll listener tied to the hero → trigger: 'pastHero'. Copy the price only as showPrice: true; never the number. If the button submits a cart form, keep href '#buy' and report that the renderer must wire it to the product's add-to-cart.
194
+
195
+ **Pitfalls.**
196
+ - 'Add to cart' is an ACTION, not a link — our button type only carries links (gap: action buttons).
197
+ - A bar covering the cookie banner or chat launcher: the renderer must stack them; nothing to model.
198
+
199
+ Not carried by a stored component: ai, category, document preview path.
200
+
201
+ ### Announcement bar with countdown (`announcement-countdown`)
202
+
203
+ Tags: global-synced, conditional, validation, repeater, localized, a11y. Proves: Scheduled global with time zone · countdown with an after-end state · dismiss memory · rotating messages
204
+
205
+ **Why this shape.** A standalone global (not part of the header) because it has its own lifecycle: scheduled on and off, often swapped weekly while the header stays put. Messages are a small array so rotation is data, not a hack of joined strings. The countdown is an object with an `showCountdown` switch whose fields appear only when it's on, including what happens at zero — the moment most hand-built bars break. Dismissal is behavior that belongs to the content (how long a closed bar stays closed). The pause control is locked on for marketers because WCAG 2.2.2 requires moving content to be pausable.
206
+
207
+ **HTML signals.**
208
+ - A full-width strip above <header> with one sentence and a close (×) <button aria-label="Close">
209
+ - Digits ticking every second (setInterval) formatted as 02d 14h 05m, plus an end timestamp in a data attribute
210
+ - localStorage/cookie key like announcement_dismissed
211
+ - Several messages in a track with translateX/opacity animation — rotation
212
+
213
+ **Do.** Separate global `countdownBar`, independent from the header. Each rotating text → messages[] { text, link }. A countdown's target timestamp (data-end, a Date string in the script) → countdown.endsAt WITH its offset; never store the rendered '02d 14h'. A close button → isDismissible: true; a cookie/localStorage max-age → rememberDays. If the script hides the bar at zero → afterEnd: 'hide'. Map red/sale colors to tone: 'sale'.
214
+
215
+ **Pitfalls.**
216
+ - Storing the countdown as text ('2 days left') — it's wrong the next day. Always a datetime with offset.
217
+ - Hide-after earlier than the countdown end can't be caught today (gap: cross-field validation).
218
+
219
+ Not carried by a stored component: ai, category, document preview path, item openMode, kind:global.
220
+
221
+ ### Cookie consent banner (`cookie-consent`)
222
+
223
+ Tags: global-synced, repeater, validation, a11y, localized, conditional, variants. Proves: Consent categories as data · action labels that are NOT links · developer-locked keys · localized legal copy
224
+
225
+ **Why this shape.** Consent is legal copy plus a contract with the site's scripts, so the two halves are modelled differently: categories are an array of objects whose `key` is developer-locked (scripts gate on it) while labels and descriptions are localized and editable. 'Accept all' and 'Reject all' are STRINGS, not buttons — our `button` type is label + link and these don't navigate. Layout (bar/box/modal) is a block variant, not a field, because it changes the component not the copy. It's a global: one consent UI for the whole site.
226
+
227
+ **HTML signals.**
228
+ - A fixed bottom/corner element or role=dialog with the words cookie/consent/privacy and 2–3 buttons (Accept / Reject / Preferences)
229
+ - A second hidden panel with toggles (<input type="checkbox" role="switch">) per category
230
+ - Scripts with type="text/plain" and data-category="analytics" (a consent manager pattern)
231
+ - A cookie like cookie_consent / cc_cookie / OptanonConsent
232
+
233
+ **Do.** Create ONE global `cookieBanner`. Banner heading/body → title/body; the policy <a> → policyLink. Button TEXTS → labels.* (never buttons). Each toggle in the preferences panel → categories[] { key from data-category/name attribute, label, description, required: disabled+checked toggle }. If the site uses a third-party CMP (OneTrust, Cookiebot) via a script tag, report it instead of modelling — that content lives in the vendor's dashboard.
234
+
235
+ **Pitfalls.**
236
+ - Pre-ticked optional categories are unlawful in the EU — the converter must set isOnByDefault: false and report any source that pre-ticks.
237
+ - A 'Reject all' hidden behind 'Preferences' is a dark pattern; flag it, don't replicate it.
238
+
239
+ Not carried by a stored component: category, kind:global, variants.
240
+
241
+ ### Breadcrumb trail (`breadcrumb-config`)
242
+
243
+ Tags: conditional, seo-structured, a11y, references, repeater. Proves: A mostly-automatic block: trail from the page tree, manual override only when needed · native JSON-LD
244
+
245
+ **Why this shape.** Breadcrumbs are derived data — the right default is 'from the page tree' with zero typing, so the block is mostly settings. A manual `crumbs` array appears only when `source` is manual (campaign pages whose URL doesn't match their place in the IA). `parent` is a reference so a product can sit under its collection even when the URL is flat. The BreadcrumbList JSON-LD is emitted natively from the SAME resolved trail, so there is no schema/SEO field here — modelling one would create a second truth.
246
+
247
+ **HTML signals.**
248
+ - <nav aria-label="Breadcrumb"> with an <ol> of <a> separated by › or /
249
+ - The last item has aria-current="page" and isn't a link
250
+ - JSON-LD script with "@type": "BreadcrumbList" (native — read it to verify, don't model it)
251
+ - Appears directly above the H1 on inner pages, absent on home
252
+
253
+ **Do.** If every crumb's href is a prefix of the current URL path → source: 'auto' and model nothing else. Otherwise → source: 'manual', crumbs = the middle crumbs (drop Home and the current page). Read separator glyph → separator. Never create JSON-LD fields; verify the platform output matches the source's BreadcrumbList instead.
254
+
255
+ **Pitfalls.**
256
+ - Importing the full trail per page as manual data — it rots the first time a page moves. Prefer auto.
257
+ - A breadcrumb that's really a 'Back to results' link is navigation state, not a trail.
258
+
259
+ Not carried by a stored component: category, item openMode.
260
+
261
+ ### Multi-column footer (`footer-multi-column`)
262
+
263
+ Tags: global-synced, repeater, references, form, validation, commerce. Proves: Link groups · newsletter as a form reference · social profiles · legal row with a year token · payment badges as a fixed set
264
+
265
+ **Why this shape.** One global, grouped by what people change together: Links, Newsletter, Legal. Link groups are an array of objects each holding an array of links — two levels, so the converter never flattens columns into one list. The newsletter is a REFERENCE to a form in Forms: fields, consent text, double opt-in and the destination (Mailchimp, Klaviyo) live in Forms, so the footer can't silently drop the consent checkbox. Payment badges are a multiselect from a fixed set because the renderer owns the official logos — authors pick, never upload. `{year}` keeps the copyright from going stale every January.
266
+
267
+ **HTML signals.**
268
+ - <footer> (role=contentinfo) with 3–5 lists of links, each under a small heading
269
+ - A newsletter <form> with a single email input and a submit button
270
+ - A row of social icons linking to instagram.com / youtube.com etc.
271
+ - A bottom row with © year, Privacy/Terms links and a strip of card logos (svg titled Visa, Mastercard…)
272
+
273
+ **Do.** ONE global `footer`. Each heading + <ul> → groups[] { title, links }. Social anchors → socialLinks[] { platform from the hostname, url }. A form with one email input → create/reuse a newsletter form and set newsletterForm to its id; its heading/paragraph → newsletterHeading/Body. © text → copyright with the 4-digit year replaced by {year}. Card logo <svg>/<img> titles → payments (unknown brands → report). Never model the form's inputs or hidden list ids in the footer.
274
+
275
+ **Pitfalls.**
276
+ - Flattening columns into one long links array loses the headings — keep groups.
277
+ - Uploading payment logos as images: they go stale and break brand rules. Use the fixed set.
278
+
279
+ Not carried by a stored component: ai, category, item openMode, kind:global, variants.
280
+
281
+ ### Minimal footer (`footer-minimal`)
282
+
283
+ Tags: global-synced, repeater, validation. Proves: The smallest useful global: one line of links, an email, socials, the year token — nothing invented
284
+
285
+ **Why this shape.** Restraint is the point: a minimal footer still has structure (links, socials, email are three different things) but no groups and no form. Email is a `link` of type email so the renderer gets mailto: plus a subject line; storing a bare string would lose that. Same `{year}` token as the big footer. It's a global so a studio with 30 case-study pages edits it once.
286
+
287
+ **HTML signals.**
288
+ - A single-row <footer> under 120px tall with a few inline links and © text
289
+ - A visible mailto: link with the address as its text
290
+ - 2–4 icon-only social links (aria-label or <title> naming the network)
291
+ - Optionally a large display-type sentence above the row
292
+
293
+ **Do.** Footer with no headings and fewer than ~8 links → `compactFooter` (else use `footer-multi-column`). mailto: anchor → email { type: 'email', email, subject from ?subject= }. Inline anchors → links; icon anchors → socialLinks by hostname. © line → copyright with the year replaced by {year}.
294
+
295
+ **Pitfalls.**
296
+ - Don't promote a minimal footer to the multi-column model just to have one footer type — the editor gets empty groups to ignore.
297
+
298
+ Not carried by a stored component: ai, category, item openMode, kind:global.
299
+
300
+ ### Mega footer with locations + app badges (`footer-mega-locations`)
301
+
302
+ Tags: global-synced, deep-nesting, repeater, seo-structured, validation, media-heavy. Proves: Locations → opening hours at four levels · geopoints · store badges with URL patterns · LocalBusiness data from content
303
+
304
+ **Why this shape.** Locations are the deep part: Footer › Locations › Location › Hours › Row is four drill levels, which is exactly what real multi-site restaurants and retailers need (split hours, brunch-only Sundays, closed Mondays). Hours are structured rows (days multiselect + 24-hour times), never a free-text 'Tue–Thu 5–10', because the same data feeds LocalBusiness/Restaurant JSON-LD and 'Open now' — natively, without SEO fields. `geo` is a geopoint so directions and maps never geocode a typo. App badges are a select + URL with a pattern, rendered with official artwork. If the site also has a Locations collection, this array should become `array<reference>` to it (see pitfalls).
305
+
306
+ **HTML signals.**
307
+ - Footer columns each with a place name, a street address, a phone link and a small hours table
308
+ - Google Maps links (maps.google.com/?q=… or /maps/place/) or embedded map iframes per location
309
+ - App Store / Google Play badge images linking to apps.apple.com and play.google.com
310
+ - JSON-LD with @type Restaurant/LocalBusiness and openingHoursSpecification
311
+
312
+ **Do.** Per location block: name, address (keep line breaks), tel: → phone, maps link coordinates (@lat,lng or ?ll=) → geo, the hours table → hours[] (group days with identical times; 'Closed' → isClosed: true; convert 5pm → 17:00). If a /locations/* page exists per place, create a Locations collection and store references here instead. Badge <a> by hostname → appBadges { store, url }; never import the badge images. Verify the platform's JSON-LD matches the source's openingHoursSpecification.
313
+
314
+ **Pitfalls.**
315
+ - Free-text hours ('Tue–Thu 5–10ish') can't power Open now or structured data — always normalize to rows.
316
+ - Duplicating locations in the footer AND a Locations collection — two truths; use references once a collection exists.
317
+ - Times without a zone are read in the site's time zone; a chain spanning zones needs a zone per location (not modelled).
318
+
319
+ Not carried by a stored component: category, item openMode, kind:global.
320
+
321
+ ## Heroes
322
+
323
+ ### Split hero with media switch (`hero-split-media`)
324
+
325
+ Tags: variants, conditional, media-heavy, a11y, repeater. Proves: One media slot that can be an image, a video or a small gallery · a phone crop · benefit list as plain strings
326
+
327
+ **Why this shape.** The core Hero has one image; real split heroes swap between an image, a muted video and a 2–4 image gallery. One `mediaType` select drives which media fields show, so the content model never holds an image AND a video that disagree. The poster is its own required field because the video type can't require one. The phone crop is a separate optional image, not a crop setting, because art direction on phones is often a different photo. Key points are an array of strings — no icon, no link — so the converter doesn't invent features. Side (left/right/overlap) is a variant; fit, theme and spacing are Design.
328
+
329
+ **HTML signals.**
330
+ - First <section> after the header with a two-column grid: text column (h1, p, 1–2 links) + media column
331
+ - Media column contains <img>, <picture> with a different mobile <source>, or <video autoplay muted loop playsinline poster>
332
+ - A short <ul> of 2–3 items with check icons under the paragraph
333
+ - Column order flipped via flex-direction: row-reverse or order — the media-left variant
334
+
335
+ **Do.** One H1 + paragraph + buttons + a single media column → `mediaHero`. <video> → mediaType 'video', video = src, poster = poster attribute (create an asset from it). Several <img> in the media column → mediaType 'gallery'. <picture> with a (max-width) <source> of a different image → mobileImage. Check-mark list items → points (text only). Media column first in the DOM or row-reverse → variant 'media-left'. object-fit: contain → fit 'contain'.
336
+
337
+ **Pitfalls.**
338
+ - A hero with BOTH a background video and a side image is a different section (full-bleed video) — don't merge.
339
+ - Alt text on a decorative hero photo should still describe it when it shows the product.
340
+
341
+ Not carried by a stored component: ai, category, item settings, variants.
342
+
343
+ ### Centered hero with gradient + screenshot (`hero-centered-gradient`)
344
+
345
+ Tags: variants, conditional, validation, media-heavy. Proves: Announcement chip as a link unit · highlighted phrase in a plain string · gradient as design data, not CSS in content
346
+
347
+ **Why this shape.** The gradient is DESIGN data: a style select plus colors in the Design group, so a rebrand changes three values instead of rewriting CSS strings pasted into content. Which color fields show depends on the style (mesh needs three, plain needs none). The highlighted phrase is a separate string that must match part of the headline — a pragmatic stand-in for inline marks in a single-line field (richtext would let authors add bold, links and line breaks we don't want in an H1). The announcement chip is an inline object of label + link because it's a link with its own copy, not a button.
348
+
349
+ **HTML signals.**
350
+ - A centered <section> (text-align:center, max-width ~720px) with a pill-shaped <a> above the H1
351
+ - A <span> inside the H1 with a gradient/color class — the highlighted phrase
352
+ - background-image: linear-gradient/radial-gradient on the section, or absolutely positioned blurred blobs (mesh)
353
+ - A large <img> of app UI below the buttons, often with a perspective transform or bottom fade
354
+ - A small muted line under the buttons ('No credit card required')
355
+
356
+ **Do.** Centered H1 + optional pill link → `launchHero`. Pill → chip { label: text, link: href }. A <span>/<em> inside the H1 → highlightedWords = its text; heading = the H1's full plain text. Parse the section's gradient: linear-gradient(angle, a, b) → { style: 'linear', angle, from: a, to: b }; radial-gradient → 'radial'; blurred absolutely-positioned blobs → 'mesh' with up to three colors. Snap colors to the nearest brand token when one is within ΔE 5 (report the rest). Screenshot <img> → screenshot; variant 'screenshot' when present else 'text'.
357
+
358
+ **Pitfalls.**
359
+ - Editing the headline can orphan the highlight — the renderer must ignore a highlight that no longer matches (no cross-field validation yet).
360
+ - Don't store raw CSS gradient strings — they can't be re-themed or validated.
361
+
362
+ Not carried by a stored component: ai, category, document preview path, variants.
363
+
364
+ ### Full-bleed video hero with captions (`hero-video-fullbleed`)
365
+
366
+ Tags: media-heavy, a11y, conditional, repeater, validation. Proves: An Accessibility tab: caption tracks, transcript, text alternative · poster required · pause control locked on
367
+
368
+ **Why this shape.** Video heroes fail accessibility more than any other section, so accessibility gets its own tab instead of being buried. Caption tracks are an array (one per language: language + menu label + .vtt file) because the `video` type can't carry tracks itself. A text alternative is required — a muted background film still communicates. The pause control is locked on for marketers with the WCAG reason shown. The poster is required: it's what people with reduced motion and slow connections actually see. Autoplay/loop/overlay/height are Design; loop only appears when autoplay is on.
369
+
370
+ **HTML signals.**
371
+ - <video autoplay muted loop playsinline poster=…> absolutely positioned behind the H1 (object-fit: cover)
372
+ - <track kind="captions" srclang=… src=….vtt> children (often missing — report it)
373
+ - A pause/play <button aria-label="Pause video"> overlaid in a corner
374
+ - @media (prefers-reduced-motion) rule hiding the video or pausing it
375
+ - A gradient/black overlay div between video and text
376
+
377
+ **Do.** Background <video> behind text → `videoHero`. src (or the highest-quality <source>) → video.url; poster attribute → poster (upload as an asset; if missing, grab the first frame and report). Each <track> → captions[] { language: srclang, label, file }. If there is no <track> and the film has speech, report an a11y gap — don't invent captions. aria-label/description near the video → textAlternative; else draft one with AI and mark it for review. Overlay opacity → overlay %.
378
+
379
+ **Pitfalls.**
380
+ - A YouTube iframe can't autoplay muted reliably as a background — prefer MP4/Vimeo background mode; flag YouTube sources.
381
+ - Captions and transcript are different things: captions sync to the film, a transcript is a page. Model both.
382
+
383
+ Not carried by a stored component: ai, category, item openMode.
384
+
385
+ ### Slider hero (`hero-slider`)
386
+
387
+ Tags: repeater, media-heavy, conditional, validation, a11y. Proves: Slides array with per-slide schedule and art direction · autoplay settings only when autoplay is on · carousel a11y name
388
+
389
+ **Why this shape.** Slides are an array of objects with the same shape (each with ONE button — a slide with two CTAs is a sign it should be its own hero). Per-slide text position and text color live on the slide because they depend on that photo; section-level behavior lives in Behavior/Design. Each slide carries an optional schedule so a sale slide can appear and vanish without a republish. `interval` only shows when autoplay is on. The carousel name is required content because screen readers announce it ('Featured collections, carousel').
390
+
391
+ **HTML signals.**
392
+ - Swiper/Splide/Slick/Flickity class names (swiper-slide, splide__slide, slick-slide) or [aria-roledescription="carousel"]
393
+ - 2+ full-width slides, each with its own H2/H1, paragraph, button and background image
394
+ - Dots (<button aria-label="Go to slide 2">) and prev/next arrows
395
+ - data-autoplay / autoplay: { delay: 6000 } in the init script
396
+
397
+ **Do.** Carousel library markup at the top of the page → `heroCarousel`. Each non-cloned slide → slides[] (skip duplicates the library clones for looping: .swiper-slide-duplicate, .slick-cloned). Only the first slide's heading should be an H1 in the renderer. Read autoplay delay (ms → s) into interval; fade effect → transition 'fade'. Background images per slide → image; mobile <source> → mobileImage. Missing aria-label on the carousel → draft label from the slides' theme and report.
398
+
399
+ **Pitfalls.**
400
+ - Cloned slides doubling the count — dedupe by image URL + heading.
401
+ - Slide schedules that leave fewer than 2 live slides: the renderer should fall back to a static hero (min can't be enforced over time).
402
+
403
+ Not carried by a stored component: ai, category, document preview path, fieldset (derived).
404
+
405
+ ### Product hero with variant picker (`hero-product`)
406
+
407
+ Tags: references, commerce, media-heavy, conditional, repeater. Proves: Live commerce data through a reference · overrides that fall back to the product · Add to cart is an action label
408
+
409
+ **Why this shape.** Everything that changes with stock and price is a REFERENCE to the commerce product — the hero stores only editorial choices: an optional headline override, tagline, badge, highlights, and HOW to present the buy box (which option pickers, price style). Override fields say 'leave empty to use the product …' because the schema can't yet declare fallback semantics. Add to cart is a string label, not a `button`: it performs an action, it doesn't navigate. The gallery is optional and only overrides the store photos when set.
410
+
411
+ **HTML signals.**
412
+ - A product form: <form action="/cart/add"> with <select name="id"> or radio swatches and a submit button
413
+ - Price markup with a <s>/<del> compare-at price and currency symbol; Klarna/Afterpay/Shop Pay installment widgets
414
+ - Product JSON (<script type="application/json" data-product-json> or ShopifyAnalytics.meta.product)
415
+ - A thumbnail strip + main image (gallery) beside the buy box
416
+ - JSON-LD @type Product with offers (native — derived from the reference)
417
+
418
+ **Do.** Product form at the top of a landing/product page → `productHero`. Resolve the product by its handle/id from the product JSON → product reference. NEVER copy price, compare-at, SKU, stock or option values into fields. If the visible H1 differs from the product title → heading override; else leave empty. Option groups shown as pickers → pickers (by option name: Color/Color → color). Submit button text → addLabel. Gallery images that equal the store's product media → leave gallery empty.
419
+
420
+ **Pitfalls.**
421
+ - Hard-coding the price in the tagline ('Just $165!') — it goes stale; flag any currency in copy.
422
+ - Picker options are a fixed list here; a product with a 'Handle wrap' option can't be picked until options come from the reference (gap).
423
+
424
+ Not carried by a stored component: ai, category, document preview path, reference target, variants.
425
+
426
+ ### Search hero (`hero-search`)
427
+
428
+ Tags: references, conditional, dynamic-query, media-heavy. Proves: Search box config · manual vs automatic suggestions · topic chips as references · scope and results page
429
+
430
+ **Why this shape.** The search box itself is behavior, so the block stores its CONFIG: what it searches (scope), what it says (placeholder, an accessible button label) and where suggestions come from. `suggestionSource` switches between hand-written tags and a dynamic 'most searched' query (with its window), so a converter can mark a site's trending list as dynamic instead of freezing it. Topic chips are references to the Topics collection, so a renamed topic updates here. Results render on the site's search page — no results UI is modelled.
431
+
432
+ **HTML signals.**
433
+ - A hero whose main control is <form role="search"> / <input type="search"> with a large placeholder
434
+ - Links below the input like /search?q=… ('Popular: …') — suggestions
435
+ - Pill links to /topics/*, /tags/* or /category/* — topic chips
436
+ - Form action pointing to /search, /recipes or an Algolia/Typesense endpoint (scope)
437
+
438
+ **Do.** Hero containing a search form → `searchHero`. placeholder attribute → placeholder; the form action path → scope (/recipes → 'recipes', /search → 'all'). Links with ?q= → suggestions (the q values, decoded) and suggestionSource 'manual'; if they change between two crawls a day apart → 'popular'. Pills linking to taxonomy pages → topics, resolved to Topics entries by slug (create missing ones and report).
439
+
440
+ **Pitfalls.**
441
+ - Don't model the autocomplete dropdown's results — they come from the search index.
442
+
443
+ Not carried by a stored component: ai, category.
444
+
445
+ ### Lead-form hero (`hero-lead-form`)
446
+
447
+ Tags: form, references, conditional, validation, repeater. Proves: A form REFERENCE plus per-placement settings: hidden source fields, label override, message vs redirect
448
+
449
+ **Why this shape.** The form is a separate form in Forms referenced by the hero — its inputs, validation, spam protection and where submissions go belong to Forms and are reused across the site. What's specific to THIS placement stays here: a heading, a submit-label override, what happens after sending (message vs redirect, with the right field shown for each) and hidden values that tag where leads came from. Modelling the inputs as hero fields would fork the form every time it's placed.
450
+
451
+ **HTML signals.**
452
+ - A hero section that contains a <form> with 3–6 inputs (name, email, company, message) beside or below the pitch copy
453
+ - <input type="hidden" name="source" value="…"> or utm_* hidden inputs
454
+ - HubSpot/Typeform/Formspark embeds (hbspt.forms.create, data-tf-widget) — a third-party form
455
+ - A small privacy sentence under the submit button
456
+
457
+ **Do.** Hero + <form> → `leadFormHero`. Create (or match by its input set) a BetterCMS form from the inputs — labels, types, required — and set `form` to its id. Hidden inputs with static values → hiddenFields (skip ones filled by script, e.g. utm_* read from the URL — those are automatic). Submit text → submitLabel only if it differs from the form's default. A JS redirect after submit or form action to /thanks → afterSubmit 'redirect'. For HubSpot/Typeform embeds, report the provider and form id instead of recreating it.
458
+
459
+ **Pitfalls.**
460
+ - Copying inputs into the hero's schema — the form then can't be reused or receive submissions properly.
461
+ - Hidden values that contain personal data (an email from the URL) must not be stored in content.
462
+
463
+ Not carried by a stored component: ai, category, item openMode, item settings, variants.
464
+
465
+ ### App download hero (`hero-app-download`)
466
+
467
+ Tags: media-heavy, conditional, validation, global-synced, repeater. Proves: A field synced from a global (store links) inside a normal block · QR code derived from a link · device screens
468
+
469
+ **Why this shape.** Store links appear in the hero, the footer and emails, so the badges field is SYNCED from an 'App links' global (field-level `synced`) — one App Store URL change updates every badge, while the rest of the hero stays local. The QR code is DERIVED from a URL (we store the target, never an uploaded QR image that can't be updated); its fields only show when it's on. Badges are a select + URL with a pattern because the renderer draws the official artwork. Screenshots are a small image array; the device frame and background are Design.
470
+
471
+ **HTML signals.**
472
+ - Official App Store / Google Play badge images linking to apps.apple.com and play.google.com
473
+ - A QR code <img>/<svg>/<canvas> near the badges, often desktop-only
474
+ - Phone mockup frames (rounded rect with notch) around screenshots, sometimes overlapping
475
+ - Smart-banner or apple-itunes-app meta tag in <head> (an app exists)
476
+
477
+ **Do.** Hero with store badges → `appDownloadHero`. Badge hrefs → badges[] { store by hostname, url }; also create/update the 'App links' global with them. A QR image: decode it if possible and set qr.target to the decoded URL (never store the QR image); else ask. Screenshots inside device frames → screens (crop to the screen area if the frame is baked into the image, and report). Star rating text → rating.
478
+
479
+ **Pitfalls.**
480
+ - Uploading badge or QR images — they can't be updated and the QR silently breaks when the link changes.
481
+ - Ratings copied from the store go stale; the help text says so because there's no live source.
482
+
483
+ Not carried by a stored component: ai, category, document preview path, item openMode.
484
+
485
+ ### Stats hero (`hero-stats`)
486
+
487
+ Tags: repeater, validation, variants, media-heavy. Proves: Numbers stored as numbers with prefix/suffix (count-up safe) · footnoted sources · an 'as of' date
488
+
489
+ **Why this shape.** Each stat stores the NUMBER as a number with separate prefix/suffix/decimals, not '$4.2M' as text — the renderer can count up, format per locale (4,2 M in German) and a validator can check it. Labels are short and required. `source` per stat plus an `asOf` date make claims auditable, which nonprofits and regulated industries need. Two layouts are variants; count-up is Design. The shared Stat block in the core library uses a string value — this entry exists to show when the number must stay a number.
490
+
491
+ **HTML signals.**
492
+ - Large numerals (font-size ≥ 40px) in a row of 2–4 items, each with a short caption under it
493
+ - data-count / data-target attributes or a countUp.js/odometer script
494
+ - Footnote markers (¹ ² or *) next to numbers with sources at the bottom
495
+ - Text like 'as of March 2026' or 'FY2025' near the row
496
+
497
+ **Do.** Hero + 2–4 big numerals → `statsHero`. Parse each numeral: leading non-digits → prefix, trailing letters/% /+ → suffix, the number → value (use data-target when present — the visible text may be mid-animation, e.g. '0'), decimals from the fraction part. Caption → label. Footnotes → source on the matching stat. A date near the row → asOf (ISO).
498
+
499
+ **Pitfalls.**
500
+ - Reading '0' from a count-up that hasn't run — always prefer data-target/data-count.
501
+ - '1.2k' style abbreviations: store 1.2 with suffix 'k', not 1200, so the copy stays as written.
502
+
503
+ Not carried by a stored component: ai, category, fieldset (derived), variants.
504
+
505
+ ### Hero with logo wall (`hero-logo-wall`)
506
+
507
+ Tags: repeater, media-heavy, a11y, conditional, variants. Proves: Logos as a grid array with the company name as the accessible text · monochrome treatment and marquee as design
508
+
509
+ **Why this shape.** Each logo is an object whose REQUIRED `name` is the accessible text; the image's alt help line is turned off (description: '') and the name is copied into alt, so authors aren't asked twice. That's the a11y rule converters most often break: logos with alt='logo'. Logos are an array in a grid layout so they read as tiles. One-color treatment and the marquee are Design — the same content can be still or scrolling. Speed only appears for the marquee. At scale (the same logos on pricing, about and case studies) this array should become references to a Customers collection.
510
+
511
+ **HTML signals.**
512
+ - A row/grid of 4–18 small <img>/<svg> elements with similar heights and grayscale filters (filter: grayscale(1) / opacity)
513
+ - A small label above them ('Trusted by…', 'Used by teams at…')
514
+ - A duplicated track with @keyframes translateX — the marquee (logos appear twice in the DOM)
515
+ - Alt texts like 'logo', 'client-1' (weak) or company names (good)
516
+
517
+ **Do.** Hero + a strip of logos → `logoWallHero`. Dedupe logos duplicated for the marquee (same src) and set motion 'marquee'. For each logo: name = alt if it's a real name, else the file name or <title> cleaned up, else ask — never 'logo'. grayscale filter or single-fill SVGs → treatment 'mono'. If the same set appears on 3+ pages, create a Customers collection and switch to references (report it).
518
+
519
+ **Pitfalls.**
520
+ - Alt text 'logo' x12 — screen readers hear nothing useful. Name is required for that reason.
521
+ - Marquee without a pause control is a WCAG 2.2.2 problem; the renderer pauses on hover/focus and reduced motion.
522
+
523
+ Not carried by a stored component: ai, category, variants.
524
+
525
+ ### Split hero with testimonial card (`hero-split-testimonial`)
526
+
527
+ Tags: references, conditional, variants, media-heavy. Proves: Pick from the Testimonials collection OR write one here — a conditional reference/inline pair inside an object
528
+
529
+ **Why this shape.** Social proof should usually come from ONE place (the Testimonials collection) so a quote that's withdrawn disappears everywhere. But landing pages often need a one-off quote, so `source` toggles between a reference and inline fields — never both visible, never both stored as the truth. The card is an object so the whole quote travels together when the variant changes from 'over the photo' to 'under the copy'.
530
+
531
+ **HTML signals.**
532
+ - A <figure>/<blockquote> card with a photo, name and role positioned absolutely over the hero image
533
+ - Star icons or '★★★★★' next to the quote
534
+ - The same quote text appearing on a /reviews or /testimonials page — collection candidate
535
+
536
+ **Do.** Hero with a quote card → `testimonialHero`. Search the Testimonials collection for the quote text (normalized, ≥ 90% match): found → source 'collection', entry = its id; not found → create the testimonial entry if the site has a testimonials page, else source 'custom' and fill quote/name/role/photo/rating. Card positioned over the image → variant 'overlap'.
537
+
538
+ **Pitfalls.**
539
+ - Quotes typed inline on every landing page drift from the approved wording — prefer the collection.
540
+
541
+ Not carried by a stored component: ai, category, variants.
542
+
543
+ ### Event hero (`hero-event`)
544
+
545
+ Tags: conditional, seo-structured, validation, media-heavy. Proves: Date/time with zone · in-person/online/hybrid venue with conditional map or stream · sold-out swap · Event data from content
546
+
547
+ **Why this shape.** The event's FACTS (start/end with time zone, format, venue, map pin, stream) are structured because the same values drive the countdown, the add-to-calendar file and native Event JSON-LD — no separate SEO fields. Format decides which place fields show (venue/address/pin for in-person, stream for online, both for hybrid). Ticket status is one select that swaps the button: on sale → registerButton, sold out → waitlistButton, cancelled → a required note. If the site lists many events, these fields belong on an Events collection entry and the hero becomes a single reference (see pitfalls).
548
+
549
+ **HTML signals.**
550
+ - A hero with a date/time line (<time datetime=…>), a place line and a 'Register'/'Get tickets' button
551
+ - A ticking countdown (days/hours/minutes) bound to a data-date attribute
552
+ - Ticketing links (eventbrite.com, tito.io, lu.ma) or a 'Sold out' badge
553
+ - JSON-LD @type Event with eventAttendanceMode (Offline/Online/Mixed) and location/VirtualLocation
554
+ - An 'Add to calendar' link or .ics download
555
+
556
+ **Do.** Hero with a date + place + ticket button → `eventHero`. <time datetime> or the countdown's data-date → startsAt with its offset (ask for the zone if the source has none). JSON-LD eventAttendanceMode → format (Offline → in-person, Online → online, Mixed → hybrid); location.name/address/geo → venue/address/location. Ticket link → registerButton; 'Sold out' text → status 'sold-out'. Never model the add-to-calendar link — it's generated. If the page is one of many event pages, create an Events collection and report it.
557
+
558
+ **Pitfalls.**
559
+ - Dates stored as text ('Nov 14, 6pm') can't drive the countdown or JSON-LD — always a datetime with offset.
560
+ - End before start isn't caught today (gap: cross-field validation).
561
+
562
+ Not carried by a stored component: ai, category.
563
+
564
+ ### Personalized hero (audiences + A/B) (`hero-personalized`)
565
+
566
+ Tags: personalization, conditional, repeater, validation, deep-nesting. Proves: Default content + audience versions that override only what they set · rule types with conditional inputs · an experiment split
567
+
568
+ **Why this shape.** Personalization is modelled as ONE hero with a default ('Everyone') and an ordered array of audience versions that override only the fields they fill — so a returning-visitor version can change just the button. Each audience holds a rule object whose inputs depend on the rule type (a string for UTM/referrer, countries for geo, a segment select for CRM), making Hero › Audiences › Audience › Rule a four-level drill. The A/B test is a separate object in its own tab; its settings only appear when it's on. Overrides are plain optional fields with 'leave empty to keep' help, because the schema can't yet express inherit-from-default.
569
+
570
+ **HTML signals.**
571
+ - Server-rendered HTML differs between crawls with different ?utm_campaign= or Accept-Language/geo — compare responses
572
+ - Multiple hidden hero variants in the DOM with data-variant / data-audience attributes
573
+ - Experiment SDKs: Optimizely, VWO, Google Optimize remnants, LaunchDarkly, Ninetailed, Statsig (window.optimizely, _vwo_code)
574
+ - Flicker-prevention snippets (async-hide, opacity:0 on body until the SDK decides)
575
+
576
+ **Do.** Only mark a hero personalized with evidence: hidden variants in the DOM or different responses for different UTM/geo requests. The variant shown with no parameters → the Everyone fields. Each other variant → audiences[] with ONLY the fields that differ (leave the rest empty), rule from the parameter that triggered it. Experiment SDK config (traffic allocation, goal event) → test. Never import third-party experiment ids as content; report the SDK so the team can decide whether BetterCMS replaces it.
577
+
578
+ **Pitfalls.**
579
+ - Copying the whole hero into every audience — later edits to Everyone no longer reach them. Store only the differences.
580
+ - Two audiences that both match: order decides; the converter must preserve the source's precedence.
581
+ - Personalized copy must not be indexed as the canonical content — crawlers see Everyone.
582
+
583
+ Not carried by a stored component: ai, category.
584
+
585
+ ## Features & storytelling
586
+
587
+ ### Icon feature grid (`feature-icon-grid`)
588
+
589
+ Tags: repeater, variants, validation, localized. Proves: Bounded repeater · per-item optional link · column count as a variant, not a number field
590
+
591
+ **Why this shape.** Features are an ARRAY OF OBJECTS because they are owned by this section and never reused elsewhere — if the same benefits appear on five product pages, promote them to a `col_features` collection and switch the array to references. Column count is a VARIANT (3/4/list), not a number, because the layout changes (icon beside vs above text), and variants swap without losing content. The per-feature link is a `link` (not a button) because it is a quiet text link; the section CTA is a `button`. Icon style and dividers are design and live in the Design group.
592
+
593
+ **HTML signals.**
594
+ - A heading followed by a grid (`display:grid` / `grid-cols-3|4`) of 3–12 sibling cards with identical structure
595
+ - Each card starts with an `<svg>` or icon font `<i>` (24–48px) before an `<h3>`/`<h4>` and one `<p>`
596
+ - No images larger than 64px inside the cards (that would be a card grid, not a feature grid)
597
+ - Optional trailing `<a>` per card with text like “Learn more” / “Read more”
598
+
599
+ **Do.** Map the section `<h2>` → heading, the small uppercase text above it → eyebrow, the first `<p>` after it → intro. For each repeated card: SVG/icon → `icon` (match to the nearest Tabler name by the icon's `aria-label`, class name like `ti-flame`/`lucide-flame`, or path similarity; fall back to `sparkles`), `<h3>` → title, `<p>` → body, trailing anchor → link (`type: "url"` for absolute hrefs, `page` when the href resolves to a known page). Pick the variant from the computed column count at desktop width (3 → three, 4 → four, 2 with icon beside text → list). Do not create fields for card background, padding or icon color — they are theme tokens.
600
+
601
+ **Pitfalls.**
602
+ - Do not invent a `columns` number field — column count is the variant; a free number lets editors break the layout.
603
+ - Feature cards with large photos are an image card grid, not this block.
604
+ - Icon fonts with ligatures (Material Symbols) store the icon as TEXT content — read the text, not the class.
605
+
606
+ Not carried by a stored component: ai, category, variants.
607
+
608
+ ### Alternating feature rows (`feature-alternating-rows`)
609
+
610
+ Tags: repeater, conditional, media-heavy, variants, rich-text. Proves: Per-row media type (image or video) via conditional fields · auto-alternating side with per-row override
611
+
612
+ **Why this shape.** Rows are an ARRAY OF OBJECTS — each row is a mini-section with its own media, so a slot would be overkill and would let editors drop unrelated blocks in. The image side is DERIVED (section `startSide` + row index) and only stored when an editor overrides it (`side: auto|left|right`), so reordering rows keeps the zigzag intact. Image vs video is a `mediaType` select with conditional fields rather than two optional fields, so exactly one is required and validation is honest. Bullet points are a primitive string array, not rich text, so the checkmark list renders consistently.
613
+
614
+ **HTML signals.**
615
+ - Two or more consecutive blocks each with a 2-column layout (text + media) where the DOM order or `flex-direction: row-reverse` / `order-last` flips on alternating rows
616
+ - Each block has its own `<h2>`/`<h3>`, a `<p>` and often a `<ul>` with check icons
617
+ - Media is an `<img>`/`<picture>` or a muted `<video autoplay loop playsinline>`
618
+ - Class names like `feature-row`, `zigzag`, `media-text`, `lg:flex-row-reverse` on every second row
619
+
620
+ **Do.** Group consecutive text+media rows into ONE featureRows block (not one block per row). Per row: `<h2|h3>` → title, small text above → eyebrow, paragraphs → body (keep `<strong>`, `<em>`, `<a>`; drop wrappers), `<ul>` items → points (strip icons), anchor/button → button. `<video>` → mediaType video + video.url from `<source src>` and poster from `poster=`; else image with `alt` from the img. Detect `startSide` from where the FIRST row's media sits; only set a row's `side` when it breaks the alternation. Media aspect ratio → mediaRatio (round to the nearest option).
621
+
622
+ **Pitfalls.**
623
+ - Storing `side` on every row breaks the zigzag the moment rows are reordered — store `auto` unless the source deliberately breaks the pattern.
624
+ - Autoplay videos without a poster flash black; always extract or generate a poster.
625
+
626
+ Not carried by a stored component: ai, category, group (nested), group unused (props), item settings, variants.
627
+
628
+ ### Feature bento (responsive spans) (`bento-feature-showcase`)
629
+
630
+ Tags: slots, responsive-layout, conditional, media-heavy, variants. Proves: Slot of blocks with per-breakpoint span, phone order and conditional background in slotSettings
631
+
632
+ **Why this shape.** The cells are a SLOT (blocks inside a block) because bento cells are genuinely heterogeneous — a screenshot, a stat, a quote, a clip — and each needs its own schema. Unlike the core bento (one `span` preset), sizing here is RESPONSIVE: desktop column/row spans, a tablet width and a phone order are stored in `_settings` on each child, so the cell's CONTENT stays clean and the layout data travels with the cell when it is reordered. The background image is a slot setting (layout/surface), not cell content, and only appears when surface = image.
633
+
634
+ **HTML signals.**
635
+ - A CSS grid container with children using different `grid-column: span N` / `col-span-2` / `row-span-2` classes
636
+ - Children of different internal shapes (one has a big image, one a giant number, one a blockquote)
637
+ - Breakpoint-prefixed span classes (`md:col-span-2 lg:col-span-3`) or `order-*` classes for mobile
638
+ - Rounded tiles with differing backgrounds (dark, brand color, photo)
639
+
640
+ **Do.** Treat each grid child as a slot child; classify by content: big number + short label → statBlock, img/video dominant → imageBlock/videoBlock, blockquote/quote marks → quoteBlock, icon+h3+p → featureBlock, heading+button → ctaCard, else textBlock. Read spans from computed `grid-column-end`/`grid-row-end` at 1280px → desktopWidth/rowsDesktop, at 768px → tabletWidth (full when it spans every column). DOM order vs visual order on a 375px viewport → mobileOrder only when they differ. Background color → surface (match to brand tokens; photo backgrounds → surface image + background).
641
+
642
+ **Pitfalls.**
643
+ - Do not put span values on the child block's own fields — reusing that block elsewhere would carry a meaningless span.
644
+ - Phones stack cells; if the source reorders them with CSS `order`, capture it or the story reads out of order.
645
+ - 6-column variants need spans up to 6; validate spans against the variant's column count.
646
+
647
+ Not carried by a stored component: ai, category, slot allow, variants.
648
+
649
+ ### Steps / how it works (`steps-how-it-works`)
650
+
651
+ Tags: repeater, variants, validation. Proves: Order is the number (never stored) · optional per-step image used by one variant only
652
+
653
+ **Why this shape.** Steps are an ORDERED ARRAY; the step number is computed from the index so drag-to-reorder can never desync it. `duration` is a short string ("10 days", "Day 3") rather than a number+unit pair because it is display copy, not data anyone filters on. The per-step image is optional and only rendered by the Vertical variant — keeping it on the item (instead of a separate array) means switching variants never loses content.
654
+
655
+ **HTML signals.**
656
+ - An `<ol>` (or divs) of 2–6 items each starting with a large number (“1”, “01”, “Step 1”) or a numbered circle
657
+ - Connector lines between items (`::before`/`::after` borders, SVG dashes)
658
+ - Headings like “How it works”, “Getting started”, “What happens next”
659
+
660
+ **Do.** Strip the visible number from each step's title (“01 — Start a claim” → “Start a claim”) and set numberStyle from its format (1 → digits, 01 → padded, none visible → none). Per step: heading → title, paragraph → body, small pill/meta text (“2 min”) → duration, icon → icon. Pick the variant from the layout direction (horizontal → row, vertical with images → stack, bordered tiles → cards). A dashed line/border between numbers → showConnector true.
661
+
662
+ **Pitfalls.**
663
+ - Never store the number as content — the converter must strip it, otherwise reordering shows “3, 1, 2”.
664
+ - HowTo rich results were retired by Google; do not emit HowTo JSON-LD for this block.
665
+
666
+ Not carried by a stored component: ai, category, variants.
667
+
668
+ ### Timeline / company history (`timeline-company-history`)
669
+
670
+ Tags: repeater, conditional, media-heavy, validation, rich-text. Proves: Date precision select drives which date field shows · display order derived from the date, not the array
671
+
672
+ **Why this shape.** Milestones are an array of objects owned by the About page — history is not reused, so no collection. Date PRECISION is modelled explicitly: most history only knows a year, so a single `date` field would force fake “Jan 1” dates. A `year` number shows for year precision and a real `date` for month/day; `label` is an optional display override ("Spring 2016") while sorting still uses the real value. The page sorts by date, so editors can append without hunting for the right slot.
673
+
674
+ **HTML signals.**
675
+ - A vertical line (`border-left`, `::before` with full height) with dots/markers and items on alternating sides
676
+ - Each item leads with a year or date in a large or monospace style (“2014”, “Spring 2016”)
677
+ - `<time datetime=…>` elements, or `<ol class="timeline">`
678
+ - Horizontal scroll containers with year markers (`overflow-x: auto` + `scroll-snap`)
679
+
680
+ **Do.** Parse each item's date text: a bare 4-digit year → precision year + `year`; “Month YYYY” → month + date YYYY-MM-01; full dates → day. Seasonal or fuzzy text ("Spring 2016", "Early days") → keep the parsed date AND put the original text into `label`. Prefer `<time datetime>` when present. Item heading → title, paragraphs → body (keep links). Larger or emphasized items → isHighlighted. Detect order by comparing the first and last dates.
681
+
682
+ **Pitfalls.**
683
+ - Storing “2014” in a date field becomes 2014-01-01 and later renders as “Jan 1, 2014”.
684
+ - Two milestones with the same year keep their array order as the tie-breaker — keep the array roughly sorted.
685
+
686
+ Not carried by a stored component: ai, category, group (nested), group unused (props), variants.
687
+
688
+ ### Tabs with media (`tabs-with-media`)
689
+
690
+ Tags: repeater, conditional, media-heavy, variants, a11y, localized. Proves: Fixed-shape tabs (no slot) · image/video conditional per tab · interval shown only when auto-advance is on
691
+
692
+ **Why this shape.** This is the FIXED-SHAPE sibling of the core Tabs block: every tab has the same copy + media layout, so an array of objects gives marketers a predictable form and the converter a predictable target. Use the core slot-based Tabs only when tab panels hold arbitrary blocks. Media is a select + conditional image/video (exactly one required); the poster only shows for video. Auto-advance and its interval are design, and the interval is hidden until auto-advance is on.
693
+
694
+ **HTML signals.**
695
+ - `role="tablist"` with `role="tab"` buttons and `role="tabpanel"` siblings, OR buttons with `aria-controls`/`data-tab` toggling panels
696
+ - Each panel contains the SAME structure: heading + paragraph + one large screenshot or video
697
+ - A progress bar inside the active tab button (auto-advance)
698
+ - Screenshots inside a browser-chrome frame (three dots, fake URL bar)
699
+
700
+ **Do.** Tab button text → label (first line), secondary text inside the button → summary. Panel heading → title, paragraphs → body, panel button/link → button. Panel `<video>` → mediaType video (+ poster), `<img>` → screenshot. If tab buttons have an animated progress bar or the source JS rotates tabs with setInterval, set autoplay true and read the interval (ms → seconds). Browser/phone mockup chrome around media → frame. If panels differ structurally (one has a pricing table, one a form), use the core slot-based `tabs` instead.
701
+
702
+ **Pitfalls.**
703
+ - Auto-advancing tabs must stop on interaction and respect reduced motion — the renderer handles it; never model a “pause” field.
704
+ - Tab labels longer than ~20 characters wrap on the top variant and break the underline.
705
+
706
+ Not carried by a stored component: ai, category, group (nested), group unused (props), variants.
707
+
708
+ ### Accordion features with image swap (`accordion-feature-image-swap`)
709
+
710
+ Tags: repeater, media-heavy, a11y, variants, rich-text. Proves: One required image per item drives a shared media slot · open-state as design, not content
711
+
712
+ **Why this shape.** Each accordion item OWNS the image shown while it is open, so the image lives on the item (required) rather than in a parallel array the editor would have to keep in sync. Which item starts open is a design toggle, not a per-item boolean, so there can never be two “default open” items. It is an array, not the FAQ block: these are product features with media, not questions, and must not emit FAQPage JSON-LD.
713
+
714
+ **HTML signals.**
715
+ - `<details>/<summary>` or buttons with `aria-expanded` in one column, and ONE large image in the other column
716
+ - JS that swaps the image `src` (or toggles opacity on stacked images) when a row opens
717
+ - Several `<img>` stacked absolutely with only one visible, one per accordion row
718
+
719
+ **Do.** Count accordion rows and images: if image count equals row count (stacked or swapped), map row i → image i. Summary/button text → title, revealed content → body (portable rich text, keep links), trailing link → link. If rows have NO images, this is an FAQ or plain accordion, not this entry. Image column side → variant. If the first row is expanded by default → isFirstOpen; a progress bar on the open row → autoplay.
720
+
721
+ **Pitfalls.**
722
+ - Images of different aspect ratios cause the layout to jump on every click — warn when the source images differ.
723
+ - Do not tag these rows as FAQ structured data — they are not questions.
724
+
725
+ Not carried by a stored component: ai, category, group (nested), group unused (props), variants.
726
+
727
+ ### Comparison vs competitors (`comparison-vs-competitors`)
728
+
729
+ Tags: deep-nesting, conditional, repeater, validation, a11y, variants. Proves: Brand columns + one `table` keyed by brand key (reorder-safe) · row groups · Yes / no with words · partial answers as Text cells · source in the table's own note + a required last-checked date
730
+
731
+ **Why this shape.** A comparison table is 2-D data: brand COLUMNS (name, logo, badge, price, button: the header) + one `table` (CPO D42) whose brand columns are keyed by the same `key`, so adding or reordering a competitor never shifts a value into the wrong column. Rows sit in row groups (tbody + th scope=rowgroup) instead of 5 drill levels. Brand columns are Yes / no, written as ✓ plus the words 'Included' / 'Not included'; a row of words is a Text row and a partial answer is one Text cell ('Partly: rules written by hand'); explainers are footnotes, so their words stay on the page. The source line is the table's own note (rendered under the table and linked with aria-describedby), not a second block field; the last-checked date is a required field because comparative advertising claims go stale. If the same competitors appear on several pages, columns should become references to a `col_competitors` collection.
732
+
733
+ **HTML signals.**
734
+ - A `<table>` (or CSS grid) whose header row contains brand names/logos, with the first data column of capability names
735
+ - Cells containing check/cross SVGs, ✓ ✗ characters or `aria-label="Included"`
736
+ - One header column visually highlighted (tinted background, “Recommended” badge)
737
+ - `<tbody>` sections or full-width rows acting as group headers (`colspan`)
738
+ - Small print below: “Compared on …”, “Based on public pricing”, trademark notice
739
+
740
+ **Do.** Header cells → columns (slugify the name for `key`; img → logo; highlighted column → isHighlighted + badge text) AND the table's brand columns with `_key` = that key, type Yes / no. Body via the table detector (docs/TABLES.md): first cell → row header (tooltip → footnote); check icon/✓/“Yes”/aria-label included → true; cross/✗/“—”/“No” → false; “Limited”/“Partial” + words → a Text cell with those words; a row of words → row type Text. Full-width rows (colspan) start a row group. Small print under the table (source, trademark notice) → the table's `notes`; parse a date in it → verifiedOn, otherwise set verifiedOn to the import date and flag it for review. Never emit a separate `footnote` field on the block.
741
+
742
+ **Pitfalls.**
743
+ - RESOLVED (D42): cells are keyed by column; never emit positional cells.
744
+ - Icons without text alternatives fail screen readers; the enum lets the renderer output “Included”/“Not included”.
745
+ - Comparative claims without a date and source are a legal risk — `verifiedOn` is required on purpose.
746
+
747
+ Not carried by a stored component: ai, category, variants.
748
+
749
+ ### Stats band (`stats-band`)
750
+
751
+ Tags: repeater, validation, variants, a11y, localized. Proves: Numbers stored as numbers (prefix/value/suffix) so they localise and count up · per-stat source + as-of date
752
+
753
+ **Why this shape.** A stat is split into prefix · number · suffix so the number is real data: it can animate, be formatted per locale (1.2M vs 1,2 M) and be checked. The core `statBlock` stores a display string ("120k+") for quick bento cells — this section is for numbers that matter and need a SOURCE and AS-OF date (health, finance and B2B claims get audited). Count-up is design; the renderer skips it under reduced motion and always outputs the final number in the HTML.
754
+
755
+ **HTML signals.**
756
+ - A row of 2–5 items each with a very large number (font-size ≥ 2.5rem) and a small label beneath
757
+ - `data-count`, `data-target`, `countup`, `odometer` attributes or classes
758
+ - Numbers with units/magnitudes: “94%”, “$2.4B”, “11 min”, “10k+”
759
+ - Footnote markers (*, †, superscript numbers) linking to a sources line
760
+
761
+ **Do.** Parse each big number with a regex: leading non-digits → prefix, the number (strip thousands separators; respect the page locale's decimal mark) → value, trailing text → suffix (keep the leading space if present), digits after the decimal → decimals. Prefer the `data-target`/`data-count` attribute over the visible text when both exist (the visible text may be mid-animation “0”). Label → label. Footnote text with a URL → source; any date in it → asOf. Presence of count-up attributes → useCountUp true.
762
+
763
+ **Pitfalls.**
764
+ - Scraping during a count-up animation captures “0” — read the data attribute or wait for the final value.
765
+ - “1.2M” is value 1.2 + suffix M, not 1200000 with no suffix, unless the design formats compact numbers itself.
766
+
767
+ Not carried by a stored component: ai, category, variants.
768
+
769
+ ### Manifesto / pull quote (`manifesto-pull-quote`)
770
+
771
+ Tags: conditional, rich-text, variants, localized. Proves: Mode select swaps an array of rich-text lines for a single quote · overlay shown only when a background exists (notEmpty)
772
+
773
+ **Why this shape.** One block covers both a multi-line manifesto and a single pull quote because they share attribution and treatment; a `mode` select decides which content field shows so editors never see both. Each manifesto line is a tiny RICH-TEXT value (italic for emphasis only) rather than a plain string with markup, so emphasis survives translation. Attribution is a small inline object. The overlay slider only appears when a background image exists (`notEmpty` condition).
774
+
775
+ **HTML signals.**
776
+ - Very large type (≥ 2.5rem) over a full-width section with a few short lines or one `<blockquote>`
777
+ - `<blockquote>` with `<cite>` or a `<figcaption>` holding name/role, often with a signature `<img>`
778
+ - Lines revealed one at a time on scroll (`data-aos`, GSAP `SplitText`, staggered opacity)
779
+
780
+ **Do.** One `<blockquote>` (or one paragraph in display type) → mode quote, quote text WITHOUT surrounding quotation marks (the renderer adds typographic quotes). Several short display-size lines/paragraphs → mode lines, one line per element, keeping `<em>`/`<i>` as italic marks and dropping spans used only for color. `<cite>`/caption → attribution.name / role; signature image → attribution.signature. Section background-image → background + overlay from the gradient/scrim opacity.
781
+
782
+ **Pitfalls.**
783
+ - Quotation marks typed into content double up with the design's quotes — strip them.
784
+ - Lines split by `<br>` inside one paragraph are still separate lines.
785
+
786
+ Not carried by a stored component: category, document preview path, item settings, variants.
787
+
788
+ ### Problem → solution (`problem-solution`)
789
+
790
+ Tags: repeater, variants, validation. Proves: Pairs keep both columns aligned · column labels as small objects with defaults
791
+
792
+ **Why this shape.** Problems and solutions are stored as PAIRS, not two independent arrays, because the strongest version of this section lines each pain up with its fix — two arrays drift (four problems, three solutions) and the paired layout breaks. The two-column variant reads the same data column-wise. Column labels and summaries are small objects with sensible defaults so the common case needs no typing.
793
+
794
+ **HTML signals.**
795
+ - Two columns labeled “Before / After”, “Without X / With X”, “The old way / The new way”
796
+ - One column with ✗ / red / strikethrough items, the other with ✓ / green items
797
+ - Equal item counts in both columns, often vertically aligned
798
+
799
+ **Do.** Find the two lists; take their headers → problem.label / solution.label. If counts match, zip item i of each list into a pair; if they differ, create pairs for the shorter length and append the leftovers with an empty partner and a review flag. Small numeric callouts (“6 hours saved”) → metric. Strike-through/red styling → tone warning. Layout: rows where each problem sits next to its fix → pairs, two independent stacks → columns.
800
+
801
+ **Pitfalls.**
802
+ - Unequal lists in the source must not be silently truncated — flag them.
803
+
804
+ Not carried by a stored component: ai, category, variants.
805
+
806
+ ### Integrations grid (`integrations-grid`)
807
+
808
+ Tags: dynamic-query, references, conditional, variants, media-heavy. Proves: Dynamic query vs manual references to a collection · filter chips derived from referenced categories
809
+
810
+ **Why this shape.** Integrations are REFERENCED, not inlined: each one has its own detail page (/integrations/stripe), logo, category and description, and appears on several pages — that is a collection (`col_integrations`) with a category reference (`col_integration_categories`). The section stores only HOW to pick: a dynamic query (categories, featured, limit, sort) so new integrations appear automatically, or a manual list of references for a curated row. Filter chips are derived from the categories of what is shown, so there is no second list to maintain. Inline the items only when the site has no integration pages and fewer than ~12 logos.
811
+
812
+ **HTML signals.**
813
+ - A grid of 12+ tiles each with a third-party logo, a product name and a short line, linking to `/integrations/<slug>`
814
+ - Filter chips or a sidebar of categories (“All”, “Payments”, “CRM”) with `data-filter`/`aria-pressed`
815
+ - A search input filtering the tiles; a “See all integrations” link
816
+
817
+ **Do.** If tiles link to per-integration pages (shared `/integrations/` prefix), create or match `col_integrations` entries (name, slug from href, logo, one-line description, category from the active filter or a `data-category` attribute) and set source to `query` with the categories present. If tiles do not link anywhere, use source `manual` and create entries anyway so logos are reusable. Filter chip labels → integration category entries (skip “All”). A visible search box → showSearch. Count tiles shown before a “See all” link → limit.
818
+
819
+ **Pitfalls.**
820
+ - Brand logos have usage rules — keep the original SVG, never recolor it in content; `isMonochrome` is a render-time filter.
821
+ - Category options cannot come from a sibling array in our schema, which is exactly why categories are a referenced collection.
822
+
823
+ Not carried by a stored component: ai, category, variants.
824
+
825
+ ## Social proof
826
+
827
+ ### Testimonial grid (`testimonial-grid`)
828
+
829
+ Tags: references, dynamic-query, conditional, variants, validation. Proves: References to a Testimonials collection · pick vs query (tags, limit, min rating) · uniqueness
830
+
831
+ **Why this shape.** Testimonials are REFERENCES to `col_testimonials` because the same quote appears on the home page, product pages and the reviews page, and each one carries facts that must not drift between copies: the customer's consent date, their exact words, rating, photo and the product they bought. The section only stores which ones and how many. A query mode (by tag, limit, minimum rating) keeps product pages fresh without hand-curation. Inline the quotes only for a one-off campaign page whose quotes appear nowhere else.
832
+
833
+ **HTML signals.**
834
+ - A grid/masonry (`column-count`, `grid-auto-flow: dense`) of 3+ cards each with a quote, a person name, a role/company and often an avatar
835
+ - Star ratings (5 SVG stars, `aria-label="5 out of 5 stars"`, ★ characters)
836
+ - `<blockquote>` + `<figcaption>`/`<cite>` per card
837
+ - Headings like “What customers say”, “Reviews”, “Loved by …”
838
+
839
+ **Do.** For each card, create or match a `col_testimonials` entry: quote (strip surrounding quotation marks), name, role, company, photo, rating (count filled stars or parse aria-label), product if the card names one. Match existing entries by name + first 40 characters of the quote before creating. Store references in source `pick` in DOM order. Uniform card heights → grid; varying heights → masonry; one large first card → featured. Never import a testimonial without a name — flag it.
840
+
841
+ **Pitfalls.**
842
+ - Inlining quotes creates copies that drift when the customer asks for an edit or removal — reference them.
843
+ - Grids look broken with 4 or 5 items in a 3-column layout; validation nudges multiples of 3.
844
+ - Do not emit Review JSON-LD for testimonials about your own business — Google ignores self-serving reviews.
845
+
846
+ Not carried by a stored component: ai, category, variants.
847
+
848
+ ### Video testimonial slider (`testimonial-video-slider`)
849
+
850
+ Tags: media-heavy, a11y, conditional, repeater, variants, references, validation. Proves: Video vs quote slides via conditional fields · captions file required for a11y · transcript as rich text
851
+
852
+ **Why this shape.** Slides are an array of objects with a `kind` select: video slides REQUIRE a poster and a captions file (WCAG 1.2.2 — and most people watch muted), quote slides hide all video fields. The person is a small inline object because video stories are often recorded for one campaign; an optional reference to the written testimonial links consent and rating back to the collection when it exists. The transcript is rich text so it can carry speaker labels and is indexable.
853
+
854
+ **HTML signals.**
855
+ - A carousel (`swiper`, `splide`, `embla`, `scroll-snap-type: x`) of tall cards with a play button over a portrait poster
856
+ - `<video>` with `<track kind="captions" src="….vtt">`, or YouTube/Vimeo iframes opened in a lightbox
857
+ - A short quote and a name/role/company under each video, sometimes a duration badge (“1:24”)
858
+ - “Read transcript” toggles or `<details>` blocks
859
+
860
+ **Do.** Per slide: `<video><source>` / iframe src / `data-video-url` → video.url (normalize YouTube/Vimeo to canonical watch URLs); `poster=` or the card background → poster; `<track kind=captions|subtitles>` → captions (download the .vtt; if missing on a hosted MP4, flag the slide — do NOT publish without captions). Overlay quote → pullQuote, name/role/company → person. Duration badge → duration (m:ss). Slides with no video → kind quote. Transcript blocks → transcript (keep speaker names bold).
861
+
862
+ **Pitfalls.**
863
+ - Hosted MP4s with no captions file fail accessibility — the converter must flag, not skip, them.
864
+ - Autoplaying a slider while a video plays is hostile; the renderer pauses advance once any video starts.
865
+
866
+ Not carried by a stored component: ai, category, variants.
867
+
868
+ ### Wall of love (`wall-of-love`)
869
+
870
+ Tags: polymorphic-array, repeater, validation, media-heavy, a11y. Proves: Polymorphic array (social post · store review · photo post) each with its own required source link and rights flag
871
+
872
+ **Why this shape.** A wall of love mixes DIFFERENT kinds of proof, so `items` is a POLYMORPHIC ARRAY: each kind (`socialPost`, `review`, `photoPost`) has its own required fields instead of one bloated object where half the fields don't apply. Every item stores a link to the ORIGINAL so the wall stays verifiable (and FTC-safe); reposted photos carry an explicit `hasRights` flag. Posts are inlined rather than referenced because they are snapshots of third-party content that only this wall shows; if the brand runs a UGC programme, move them into a `col_ugc` collection.
873
+
874
+ **HTML signals.**
875
+ - A masonry of cards that look like embedded tweets/posts (avatar, @handle, network logo, date) mixed with review cards (stars + reviewer) and photo tiles
876
+ - Embedded `blockquote.twitter-tweet`, `blockquote.instagram-media` or iframes from `platform.twitter.com`/`instagram.com`
877
+ - Columns animating vertically in opposite directions (`@keyframes` translateY loops)
878
+ - Headings like “Wall of love”, “#hashtag”, “Don't take our word for it”
879
+
880
+ **Do.** Classify each card: has @handle + network icon/embed → socialPost (network from the icon/embed host); has stars + reviewer → review (platform from the logo or link host; default site); image-dominant with handle → photoPost. Copy text EXACTLY. Extract the permalink from embed `cite=`/`href` → url; if a socialPost/photoPost has no permalink, flag it (url is required). Set hasRights false on every imported photoPost and flag for the editor to confirm — never assume permission. Opposite-scrolling columns → marquee.
881
+
882
+ **Pitfalls.**
883
+ - Third-party embed scripts (twitter widgets.js) slow the page and break when posts are deleted — store the content, link the original.
884
+ - Reposting customer photos without permission is a copyright problem; the required flag forces the question.
885
+
886
+ Not carried by a stored component: ai, category, variants.
887
+
888
+ ### Case study cards (`case-study-cards`)
889
+
890
+ Tags: references, dynamic-query, conditional, variants. Proves: Array of objects wrapping a reference + per-card override · three ways to choose (pick, by industry, latest)
891
+
892
+ **Why this shape.** Case studies are full CMS items (/customers/masa-bakery) with their own logo, industry, hero metric and body, so the cards REFERENCE `col_case_studies` — card title, logo, image and URL all come from the entry. Each pick is wrapped in a small object so a page can override the headline metric for its audience (the pricing page wants “paid 9 days sooner”, the home page wants “6 hours saved”) without editing the story. Query modes (industry, latest) suit industry landing pages that should update when a new story ships.
893
+
894
+ **HTML signals.**
895
+ - Cards with a customer logo, a bold result metric (“3× faster”, “−40% cost”), a short summary and a “Read story” link to `/customers/…` or `/case-studies/…`
896
+ - An industry/segment label on each card
897
+ - Large cover photos of the customer's team or premises
898
+
899
+ **Do.** For each card, follow its link: if the target is a case-study page, create/match a `col_case_studies` entry (title, slug from the URL, logo, industry, hero metric, cover image; the article body goes through the long-form converter). Reference it. If the card's visible metric differs from the story page's main metric, store it in metricOverride. When the cards are under an industry heading or filtered by `?industry=`, set source industry with that industry.
900
+
901
+ **Pitfalls.**
902
+ - Copying the card title/logo into the section duplicates the story — reference it, override only what differs.
903
+ - `col_case_studies` must exist before import; create the collection first in the conversion plan.
904
+
905
+ Not carried by a stored component: ai, category, variants.
906
+
907
+ ### Logo cloud (`logo-cloud`)
908
+
909
+ Tags: repeater, variants, a11y, validation, conditional, media-heavy. Proves: Grid array of tiny popover objects · required company name for a11y · marquee-only field hidden by variant
910
+
911
+ **Why this shape.** Logos are an array of tiny objects edited in a POPOVER (name + file + optional link + optical size) and shown as a grid of tiles, because a logo cloud is managed visually. The company NAME is required and separate from the image's alt because it is what screen readers announce and what the marquee duplicates with `aria-hidden`. Grayscale, height and speed are design; speed only appears for the marquee variant. If the same logos appear on several pages (customers, retailers), promote them to a global block (synced) or a `col_customers` collection.
912
+
913
+ **HTML signals.**
914
+ - A row/grid of 4–30 small `<img>`/`<svg>` logos with no other text except one label (“Trusted by…”, “As seen at”)
915
+ - CSS filters `isGrayscale(1)` / `opacity: .6` that reset on hover
916
+ - A duplicated track with `aria-hidden="true"` and a `@keyframes` translateX animation (marquee)
917
+
918
+ **Do.** Each logo → name from `alt`, `title`, `aria-label` or the file name (title-case it: `williams-sonoma.svg` → Williams Sonoma); logo = original file (prefer SVG source). Wrapping `<a>` → link. If the track is duplicated for the marquee, import ONE copy only. Detect grayscale from the CSS filter, logoHeight from the rendered height. Variant: animated track → marquee, bordered cells → grid, else row.
919
+
920
+ **Pitfalls.**
921
+ - Marquees duplicate logos in the DOM — de-duplicate on import or you get every logo twice.
922
+ - Alt text like “logo” or empty alt on linked logos fails a11y; the required name fixes it.
923
+ - Conditions on `_variant` work today because the variant is stored on the block value — this is a schema-language gap worth making official.
924
+
925
+ Not carried by a stored component: category, item openMode, variants.
926
+
927
+ ### Press mentions (`press-mentions`)
928
+
929
+ Tags: repeater, validation, variants, media-heavy, localized. Proves: Quotes tied to a required source link · logo + quote + journalist per mention · rotator variant
930
+
931
+ **Why this shape.** Each mention is an object with the quote, outlet logo and a REQUIRED article link, because a press quote without its source is unverifiable and editors are tempted to paraphrase. Mentions are inlined (not a collection) on most sites because the press page is the only other place they appear; if the site has a /press page listing every article, create `col_press` and reference it instead. `isFeatured` lets the rotator start on the strongest quote.
932
+
933
+ **HTML signals.**
934
+ - Publication logos (often grayscale) paired with short italic quotes, each linking to an external article
935
+ - “As seen in”, “In the press”, “Featured in” labels
936
+ - Rotators where clicking a logo swaps the quote above it (`aria-selected`, `data-quote`)
937
+
938
+ **Do.** Per mention: logo img → logo (keep the original file), `alt`/file name → outlet, quote text → quote (strip curly quotes), wrapping or nearby external link → article (type url, newTab true), byline → author, date text → date. Logos WITHOUT quotes are a logo cloud, not this block. Mark the first or visually largest quote as featured.
939
+
940
+ **Pitfalls.**
941
+ - Never shorten a quote by paraphrasing — use an ellipsis and keep the source link.
942
+ - Outlet logos are trademarks; use the outlet's press-kit file and do not recolour it in content.
943
+
944
+ Not carried by a stored component: category, variants.
945
+
946
+ ### Awards & certifications (`awards-certifications`)
947
+
948
+ Tags: conditional, validation, repeater, seo-structured, a11y. Proves: Kind select changes required fields (certificate id + expiry for certifications) · expiry hides stale badges
949
+
950
+ **Why this shape.** Badges are an array of objects with a `kind` that changes what is REQUIRED: a certification needs an expiry (`validUntil`) and ideally a certificate number and verify link; an award needs a year. The renderer hides an expired certification automatically, which is the bug this entry exists to catch — sites routinely show lapsed badges for years. Badge images stay untouched (issuers have usage rules). If the brand has a trust center with the same badges, this should be a global block (synced) so a renewal updates every page.
951
+
952
+ **HTML signals.**
953
+ - A row of square/circular badge images (SOC 2, ISO, HIPAA, PCI, awards) with small captions
954
+ - Links to trust centres (`trust.`, `security.` subdomains), Vanta/Drata/SafeBase pages or certification registries
955
+ - Text like “Certified”, “Compliant”, “Award winner”, “Best of 2026”
956
+
957
+ **Do.** Per badge: image → image, alt/caption → name; classify: SOC, ISO, HITRUST, PCI, FedRAMP, GDPR-seal → certification; “award”, “best”, “winner”, year-bearing titles → award; association logos → membership. Linked registry/trust pages → verify. For certifications with no expiry in the source, set validUntil to one year from the import date and FLAG it for confirmation. If the source says “HIPAA certified”, flag the claim — there is no official HIPAA certification.
958
+
959
+ **Pitfalls.**
960
+ - Expired certifications shown as current are a compliance risk — `validUntil` is required on certifications for that reason.
961
+ - “HIPAA certified” and “GDPR certified” badges are marketing inventions; the converter flags them.
962
+ - Organization JSON-LD `award` should only list awards, never certifications.
963
+
964
+ Not carried by a stored component: category, variants.
965
+
966
+ ### Review summary (`review-summary`)
967
+
968
+ Tags: seo-structured, references, conditional, validation, a11y, commerce. Proves: Synced vs typed ratings (developer-locked provider) · 5-bucket distribution validation · AggregateRating only with a product subject
969
+
970
+ **Why this shape.** Ratings are DATA, not copy: when a review app is connected the average, count and distribution are synced (the provider is developer-locked) and editors only choose presentation; manual mode exists for sources that can't sync. The distribution is exactly five buckets (validation min=max=5) so bars always render 5→1. The block references its SUBJECT product because AggregateRating structured data is only valid for a specific product/service, and must match what is visible — so JSON-LD is emitted only when a subject is set (the toggle is hidden otherwise). Featured reviews reference the testimonials collection.
971
+
972
+ **HTML signals.**
973
+ - A big average number (“4.8”) next to stars and “Based on 2,418 reviews”
974
+ - Five horizontal bars labeled 5★…1★ with counts or percentages (`<progress>`, `<meter>`, width-% divs)
975
+ - Review-app markup: `jdgm-`, `okendo-`, `yotpo-`, `trustpilot-widget` classes or their script tags
976
+ - `application/ld+json` with `AggregateRating` / `ratingValue` / `reviewCount`
977
+
978
+ **Do.** Detect the review app from class prefixes/scripts → dataSource connected + provider (and tell the developer to connect it); otherwise manual. Read average/count from JSON-LD first (most reliable), then visible text. Bars → ratingCounts: parse each bar's count; if only percentages are shown, compute counts from the total and round so they sum to it. Badges for other platforms → platforms with their profile links. The product the page is about → subject. Never emit AggregateRating for store-wide/self-serving ratings.
979
+
980
+ **Pitfalls.**
981
+ - AggregateRating on a home page about your own business is ignored or penalised — only with a product subject.
982
+ - Typed-in ratings drift from the real ones; prefer the connected source and show “Synced …” in the UI.
983
+ - Distribution counts that don't sum to the total look fake; validate the sum.
984
+
985
+ Not carried by a stored component: category, item openMode, itemNoun (derived), reference target, variants.
986
+
987
+ ## Media & galleries
988
+
989
+ ### Masonry gallery (`masonry-gallery`)
990
+
991
+ Tags: media-heavy, a11y, responsive-layout, repeater, validation, localized. Proves: Bulk image array as tiles · caption + structured credit per photo · columns per breakpoint as design
992
+
993
+ **Why this shape.** Photos are an array of small objects (image + caption + credit + optional link) shown as a GRID of tiles because galleries are managed visually and in bulk. Caption and credit are separate: the caption is localised content, the credit is attribution (name + link) that must survive translation and appear in the lightbox. Alt text lives on the image value itself. Column counts per breakpoint are DESIGN in the Design group — never per-photo sizes, because masonry keeps each photo's natural aspect ratio from its stored width/height.
994
+
995
+ **HTML signals.**
996
+ - Many `<img>` (6+) inside a container with `column-count`, `columns-3`, `grid-template-rows: masonry`, or Masonry.js / `data-masonry`
997
+ - Images of differing aspect ratios with no fixed height
998
+ - `<figure>` + `<figcaption>` per image, often with “Photo: Name” or © text
999
+ - Links to full-size images (`href` ending .jpg) or useLightbox libraries (PhotoSwipe, GLightbox, fancybox) via `data-pswp-*`, `data-fancybox`
1000
+
1001
+ **Do.** Each figure → a photo: highest-resolution source (`srcset` largest, or the useLightbox `href`) → image.url, intrinsic width/height from `data-pswp-width`/natural size, `alt` → image.alt. Split `<figcaption>`: text after “Photo:”, “©”, “Credit:” or in a `<small>`/`<cite>` → credit.name (+ any link), the rest → caption. Count CSS columns at 1280/768/375px → desktopColumns/tabletColumns/phoneColumns. Lightbox library present → useLightbox true.
1002
+
1003
+ **Pitfalls.**
1004
+ - Importing the thumbnail instead of the full-size file — always prefer the lightbox href or the largest srcset entry.
1005
+ - Credits pasted into captions get translated; keep them in the credit object.
1006
+ - Empty alt is right for decorative photos but wrong for a portfolio — flag empties for review.
1007
+
1008
+ Not carried by a stored component: category, document preview path, validation.items, variants.
1009
+
1010
+ ### Gallery with category filters (`lightbox-gallery-filters`)
1011
+
1012
+ Tags: media-heavy, a11y, repeater, validation, localized. Proves: Filters defined once as key/label objects · photos tag themselves by key · URL-synced filter state
1013
+
1014
+ **Why this shape.** Filter categories are defined ONCE on the section (label + stable key, localised labels) and each photo tags itself with keys, so renaming “Dining” to “Restaurant” never touches the photos and a photo can sit in two filters. The keys are a `tags` field today because our schema can't populate a select's options from a sibling array — a real gap (see notes). If the same photos are reused across pages, move them into the media library with media tags and switch the section to a query.
1015
+
1016
+ **HTML signals.**
1017
+ - Filter buttons above a grid (`data-filter`, `aria-pressed`, Isotope/MixItUp/`data-category`) and tiles carrying category classes (`.dining`, `data-groups='["spa"]'`)
1018
+ - A uniform tile grid (fixed aspect ratio, `object-fit: cover`)
1019
+ - A lightbox on click; sometimes a “Load more” button
1020
+
1021
+ **Do.** Filter button labels → categories (skip the “All”/“Everything” chip → allLabel; key from `data-filter` minus `.`/`*` or slugified label). For each tile, read its category classes/data attributes → categories (keys). Image from the lightbox href or largest srcset, alt → alt, lightbox caption → caption. Tile aspect → variant (1:1 square, else landscape). Visible count before “Load more” → initialCount. `?filter=`/hash usage in links → useUrlFilter.
1022
+
1023
+ **Pitfalls.**
1024
+ - A photo tagged with a key that no filter defines is invisible under every filter except All — validate keys against the filters.
1025
+ - Free-typed keys are error-prone; a select fed by the section's filters is the right control (schema gap).
1026
+
1027
+ Not carried by a stored component: category, document preview path, item openMode, validation.items, variants.
1028
+
1029
+ ### Video section (YouTube · Vimeo · hosted) (`video-section`)
1030
+
1031
+ Tags: media-heavy, a11y, seo-structured, conditional, validation, embed, localized, variants. Proves: Source select switches link vs file · captions per language required for hosted · chapters with timestamp validation · VideoObject JSON-LD
1032
+
1033
+ **Why this shape.** One block covers all three hosts with a `source` select: YouTube/Vimeo store a link (the renderer lazy-loads a facade until play), hosted stores a FILE so we can transcode and serve renditions. Captions are a per-language array required for hosted video (WCAG 1.2.2) — YouTube/Vimeo carry their own. Chapters are validated timestamps so the renderer can seek and emit `Clip` markup. Title, description, poster, duration and upload date are REQUIRED because they feed VideoObject JSON-LD — that is native SEO generated from content the section already needs, not modelled meta fields. Playback settings are design; loop only shows with autoplay.
1034
+
1035
+ **HTML signals.**
1036
+ - `<iframe src="https://www.youtube(-nocookie).com/embed/…">`, `player.vimeo.com/video/…`, `lite-youtube` / `lite-vimeo` facades
1037
+ - `<video>` with `<source type="video/mp4">`, `poster=` and `<track kind="captions" srclang=…>`
1038
+ - A list of timestamps (“1:12 Making the moulds”) next to or under the player
1039
+ - `application/ld+json` with `VideoObject`, `duration` (ISO 8601 like PT6M48S), `uploadDate`
1040
+
1041
+ **Do.** Host from the iframe/src domain → source; canonicalise YouTube/Vimeo to watch URLs (`youtube.com/watch?v=ID`, `vimeo.com/ID`) and keep `?start=`/`#t=` as startAt. `<video><source>` → source hosted + file (download the MP4); every `<track>` → captions (srclang → language, label → label, kind). Read title/description/duration/uploadDate from VideoObject JSON-LD first (convert PT6M48S → 6:48), else from visible text. Timestamp list → chapters (first must be 0:00 — prepend “Intro” if missing). `autoplay muted loop` attributes → design flags.
1042
+
1043
+ **Pitfalls.**
1044
+ - Autoplay with sound is blocked by browsers; autoplay always means muted here.
1045
+ - Missing uploadDate/duration silently drops the video from rich results — they are required for that reason.
1046
+ - Embedding raw YouTube iframes costs ~1 MB of JS per video; the renderer uses a facade until play.
1047
+
1048
+ Not carried by a stored component: category, item openMode, variants.
1049
+
1050
+ ### Before / after slider (`before-after-slider`)
1051
+
1052
+ Tags: media-heavy, a11y, repeater, validation, variants, localized. Proves: Paired required images per comparison · localised labels with defaults · single vs tabs via one array
1053
+
1054
+ **Why this shape.** A comparison is a PAIR of required images plus labels, so it's an object; an array of comparisons covers both the single and tabbed layouts without two blocks. Labels default to “Before/After” but are editable and localised (“1987 / 2025” is stronger). Divider start, orientation and the first-view nudge are design. Both images keep their own alt — screen readers get “Before: …, After: …” and a keyboard-operable slider from the renderer.
1055
+
1056
+ **HTML signals.**
1057
+ - Two stacked images in one container where the top one is clipped (`clip-path: inset(0 50% 0 0)` or a width %) with a draggable handle
1058
+ - `<input type="range">` overlaid on images, or libraries `img-comparison-slider`, `twentytwenty`, `juxtapose`, `beer-slider`
1059
+ - Labels “Before” / “After” pinned in the corners
1060
+
1061
+ **Do.** Find the two images inside the comparison container: the clipped/overlay one is usually AFTER (check the label nearest each image rather than assuming). Labels → beforeLabel/afterLabel. Initial clip/range value → start. Vertical clip (inset top/bottom) → orientation vertical. Multiple comparisons under tabs → one block with several comparisons and variant tabs. Compare the two images' natural sizes; if they differ by more than 2%, flag it.
1062
+
1063
+ **Pitfalls.**
1064
+ - Before and after with different crops make the slider useless — dimensions should match (not enforceable in our schema yet).
1065
+ - A slider built from a div with mouse events only is not keyboard accessible; the renderer must use a real range input.
1066
+
1067
+ Not carried by a stored component: category, document preview path, variants.
1068
+
1069
+ ### Image with hotspots (shop the look) (`image-hotspots`)
1070
+
1071
+ Tags: media-heavy, conditional, references, commerce, a11y, validation, variants. Proves: Points stored as % coordinates · each pin opens a live product reference OR a note (conditional) · required pin labels
1072
+
1073
+ **Why this shape.** Each hotspot stores its position as PERCENTAGES of the image (x, y 0–100), not pixels, so pins stay put at every screen size — which is why the image's crop must not change after pins are placed. A pin opens either a PRODUCT (a Shopify reference — name, price and stock stay live, never copied) or a NOTE (title/body/link); the `kind` select shows only the relevant fields. Every pin needs a text label because pins are tiny buttons with no visible text. The phone image is optional and shows the hotspots as a list, since pins on a small crop are unusable.
1074
+
1075
+ **HTML signals.**
1076
+ - A relatively-positioned image wrapper containing absolutely-positioned buttons with `style="left: 24%; top: 58%"`
1077
+ - Small circular buttons (“+”, numbers, pulsing dots) that open popovers with a product name, price and Add-to-cart
1078
+ - Shopify product handles/IDs in `data-product-id`, `data-handle` or links to `/products/…` inside the popovers
1079
+
1080
+ **Do.** For each positioned pin: parse `left`/`top` (or `inset`, `transform: translate`) as percentages of the wrapper → x/y (convert px using the wrapper's rendered size if needed, round to 0.5). Popover with a product link/handle → kind product + reference (resolve handle → Shopify GID); else kind note with heading → title, text → body, link → link. Label from the pin's `aria-label`, else the product title or note title. Keep the base image at its original crop.
1081
+
1082
+ **Pitfalls.**
1083
+ - Pixel positions break on every other screen size — always convert to %.
1084
+ - Copying product price into the note text goes stale; reference the product.
1085
+ - Re-cropping the image after placing pins moves every pin. A visual point picker would prevent this (schema gap).
1086
+
1087
+ Not carried by a stored component: category, reference target, variants.
1088
+
1089
+ ### 3D model / Lottie / 360° embed (`interactive-3d-lottie-embed`)
1090
+
1091
+ Tags: embed, conditional, media-heavy, a11y, validation, variants. Proves: Embed kind decides file vs allow-listed URL · required still fallback + accessible name · load-on-click for performance
1092
+
1093
+ **Why this shape.** Three very different embeds share one block because they share the hard parts: a REQUIRED still fallback (loading, slow networks, reduced motion), a REQUIRED accessible name, and a load strategy. `kind` decides what is stored — uploaded files for 3D (GLB + optional USDZ for iOS AR) and Lottie, or an iframe URL for hosted scenes. The URL is pattern-validated against an allow-list of hosts so a marketer can't paste an arbitrary site into an iframe; raw HTML/script embeds are deliberately NOT offered here (that's a developer-only code block).
1094
+
1095
+ **HTML signals.**
1096
+ - `<model-viewer src="….glb" ios-src="….usdz" camera-orbit=… auto-rotate ar>`
1097
+ - `<lottie-player>`, `<dotlottie-player>`, `lottie.loadAnimation({ path: '….json' })` or `data-animation-path`
1098
+ - Iframes from `my.spline.design`, `sketchfab.com/models/…/embed`, `my.matterport.com/show`, `kuula.co/share`
1099
+ - A canvas element (`<canvas>` from three.js/Spline runtime) with a poster image fallback
1100
+
1101
+ **Do.** `<model-viewer>` → kind model: src → model (download the GLB), ios-src → iosModel, camera-orbit → cameraOrbit, auto-rotate → useAutoRotate, poster → fallback, alt → label. Lottie players → kind lottie: src/path → lottie file, loop/autoplay/hover/scroll triggers → loop/playOn. Allow-listed iframes → kind iframe + embedUrl; any other iframe host → do NOT import into this block — emit a developer task instead. If no poster exists, capture a screenshot of the first frame as fallback and flag it.
1102
+
1103
+ **Pitfalls.**
1104
+ - A 40 MB GLB will tank the page — validate size and recommend Draco/meshopt compression.
1105
+ - Iframe embeds from un-vetted hosts are a security hole — the host allow-list is intentional.
1106
+ - Lottie animations that loop forever must stop for reduced-motion users; the renderer shows the fallback instead.
1107
+
1108
+ Not carried by a stored component: category, variants.
1109
+
1110
+ ### Mixed-media carousel (`mixed-media-carousel`)
1111
+
1112
+ Tags: polymorphic-array, media-heavy, a11y, responsive-layout, references, conditional, commerce. Proves: Polymorphic slides (photo · video · quote · product) · per-breakpoint slides visible · interval shown only with autoplay
1113
+
1114
+ **Why this shape.** Slides are a POLYMORPHIC ARRAY (`imageSlide`, `videoSlide`, `quoteSlide`, `productSlide`) because each kind needs different required fields — a video needs a poster, a product needs a live Shopify reference, a quote needs an author. Using small object types (not a slot of full blocks) keeps slides lightweight and the carousel's rendering predictable. Products are REFERENCES so price/stock stay live. Slides-per-view per breakpoint, autoplay, interval (hidden unless autoplay) and controls are design.
1115
+
1116
+ **HTML signals.**
1117
+ - Carousel libraries: `swiper-slide`, `splide__slide`, `embla__slide`, `glide__slide`, `keen-slider__slide`, or `scroll-snap-type: x mandatory`
1118
+ - Slides with different internal structures (one `<img>`, one `<video>`, one `<blockquote>`, one product card with price)
1119
+ - `slidesPerView`/`perPage` options in inline JS or `data-*` attributes; prev/next buttons, pagination dots
1120
+
1121
+ **Do.** Classify each slide by content: `<video>`/video iframe → videoSlide (poster required; extract or generate one), `<blockquote>`/quote marks + name → quoteSlide, price + add-to-cart or `/products/` link → productSlide (resolve to a Shopify GID), otherwise imageSlide. Read the library config (`slidesPerView`, breakpoints, `autoplay.delay`, `loop`) → perViewDesktop/perViewMobile, autoplay + interval (ms → s), loop. Pagination/arrows/progress elements → controls. Ignore cloned slides (`swiper-slide-duplicate`, `aria-hidden` clones).
1122
+
1123
+ **Pitfalls.**
1124
+ - Loop mode clones slides in the DOM — import only originals.
1125
+ - Autoplaying carousels need a pause control and must stop on focus (WCAG 2.2.2); the renderer adds both.
1126
+
1127
+ Not carried by a stored component: category, group unused (props), reference target, variants.
1128
+
1129
+ ### Full-bleed parallax image with overlay text (`parallax-image-overlay`)
1130
+
1131
+ Tags: media-heavy, a11y, responsive-layout, variants, conditional. Proves: Art-directed phone image · decorative flag · 9-position text grid shown only when there is text · parallax strength only for the parallax variant
1132
+
1133
+ **Why this shape.** A full-bleed image is mostly DESIGN with a little content: the photo, an optional art-directed phone crop (a 2:1 landscape becomes a sliver on phones), and optional overlay text. Focal points live on the image values so automatic crops keep the subject. `isDecorative` is explicit because empty alt must be a decision, not an accident. Text position, overlay (with a recommended 35–55% range in the help), height and parallax strength are design; position only shows when there is a heading, and parallax strength only for the parallax variant. The renderer disables parallax for reduced motion and on iOS where `background-attachment: fixed` is unsupported.
1134
+
1135
+ **HTML signals.**
1136
+ - A full-viewport-width section with `background-image` and `background-attachment: fixed`, or an `<img>` translated on scroll (`data-speed`, `data-parallax`, Rellax, GSAP ScrollTrigger)
1137
+ - A gradient/scrim overlay (`linear-gradient(rgba(0,0,0,.5)…)`, an absolutely positioned dark `div`)
1138
+ - `<picture>` with `<source media="(max-width: …)">` providing a different crop for phones
1139
+ - Little or no text — a heading and maybe one button — positioned in a corner
1140
+
1141
+ **Do.** Background image or `<img>` → image (largest source); a `<picture><source media>` for small screens → mobileImage. Empty `alt=""` or `role="presentation"` → isDecorative true. Scrim opacity → overlay (gradient vs flat from the background value). Text block alignment (flex justify/alignment) → position. Section height in vh → height (≥95 → screen, ≥75 → tall, else medium). `background-attachment: fixed` → variant fixed; scroll-linked transforms → parallax with intensity from the speed factor; none → static. Photo credit text → credit.
1142
+
1143
+ **Pitfalls.**
1144
+ - `background-attachment: fixed` doesn't work on iOS Safari — the renderer falls back to static.
1145
+ - Text over a busy photo fails contrast; the overlay help gives a range and the renderer checks contrast.
1146
+ - A background-image has no alt — converting to an `<img>` with alt (or decorative) is part of the import.
1147
+
1148
+ Not carried by a stored component: ai, category, variants.
1149
+
1150
+ ### Social photo feed (UGC) (`ugc-social-feed`)
1151
+
1152
+ Tags: dynamic-query, embed, conditional, media-heavy, references, commerce, validation. Proves: Connected account vs approved hashtag vs hand-picked · developer-locked connection · rights + permalink required per photo
1153
+
1154
+ **Why this shape.** A social feed is a DYNAMIC source, not content: the connected account is developer-locked (credentials live in Integrations), hashtag mode only shows posts approved in the media library, and manual mode stores hand-picked photos. Picked photos carry a required permalink and a required rights flag because UGC reposting needs the creator's permission. Products shown in a photo are references so “Shop this photo” stays live. We never store an embed script — the renderer fetches through the integration and caches.
1155
+
1156
+ **HTML signals.**
1157
+ - Instagram widget scripts/markup: `elfsight`, `snapwidget`, `curator.io`, `taggbox`, `behold.so`, `juicer.io`, `instafeed`
1158
+ - A grid of square images linking to `instagram.com/p/…` or `tiktok.com/@…/video/…`
1159
+ - A heading with a hashtag or @handle and a “Follow us” button
1160
+
1161
+ **Do.** If a feed widget script is present: source account (or hashtag if the widget config names one), copy the handle into the developer task to connect the integration — do NOT copy the widget script. If the grid is static images linking to posts: source manual, each tile → a post with permalink from the link, handle from the alt/caption, hasRights false + flag for review. Products tagged in a tile (shop-the-look overlays) → products references.
1162
+
1163
+ **Pitfalls.**
1164
+ - Widget scripts from UGC vendors add 200–500 KB and third-party cookies — replace, don't import.
1165
+ - Never mark rightsGranted true on import; only a person can confirm permission.
1166
+
1167
+ Not carried by a stored component: category, reference target, variants.
1168
+
1169
+ ## Commerce
1170
+
1171
+ ### Product grid (query) (`commerce-product-grid-query`)
1172
+
1173
+ Tags: dynamic-query, commerce, conditional, references, repeater, validation, variants, responsive-layout. Proves: A saved query (collection · filters · sort · limit) vs hand-picked products · per-breakpoint columns · card display toggles
1174
+
1175
+ **Why this shape.** A grid is a QUERY, not a list: most merch grids should keep themselves current (new arrivals, best sellers, a material), so the default `source` is a saved query object — collection reference + filter rows + sort + limit — and hand-picking is the conditional alternative. Filters are an array of tiny objects (field · rule · value) so they render inline and stay readable; price and stock are never stored here, the card reads them live through each Products entry's Shopify link. Card toggles and columns are design, so they live in the Design group (columns per breakpoint = responsive-layout).
1176
+
1177
+ **HTML signals.**
1178
+ - a repeated card list (ul/div grid) where each card has an <img>, a product title link to /products/…, and a price with a currency symbol
1179
+ - a struck-through price (<s>, <del>, .compare-at, .price--sale) next to a second price
1180
+ - 'Add to cart' / 'Quick add' buttons or forms posting to /cart/add
1181
+ - a heading plus a 'View all' / 'Shop all' link to /collections/…
1182
+ - Shopify markup: data-product-id, .product-card, .card--product, /cdn/shop/products/ image URLs
1183
+
1184
+ **Do.** Map the heading/eyebrow/intro text. For each card, resolve the /products/<handle> link to a Products entry (create a stub entry linked to the Shopify product if missing) — never store the card's price, title or image as section values. If the cards share an obvious rule (all one collection URL, 'New', 'Best sellers'), emit source:'query' with the matching filter/sort and limit = number of cards; otherwise source:'manual' with the entry ids in DOM order. Count grid-template-columns / Tailwind grid-cols-* at the md+ breakpoint for columnsDesktop and the base breakpoint for columnsMobile. Carousel markup (overflow-x, swiper, flickity, scroll-snap) → _variant:'carousel'.
1185
+
1186
+ **Pitfalls.**
1187
+ - Copying prices into the section: they go stale the moment Shopify changes them.
1188
+ - A query that matches nothing renders an empty section — the renderer hides the block and the editor warns, so don't add placeholder products.
1189
+ - Filter values are untyped strings today (price '200' vs 200); the converter must normalize units.
1190
+ - excludeCurrent only applies inside a product template; on a static page it is a no-op.
1191
+
1192
+ Not carried by a stored component: ai, category, variants.
1193
+
1194
+ ### Product detail page (`commerce-product-detail`)
1195
+
1196
+ Tags: commerce, deep-nesting, polymorphic-array, conditional, references, media-heavy, repeater, rich-text, validation, seo-structured, a11y. Proves: A Products ENTRY (not a block): Shopify-synced vs native commerce · polymorphic gallery tied to options · options → variants matrix · grouped specs · policy accordions
1197
+
1198
+ **Why this shape.** D27: a product page is a Products ENTRY, so this is `kind: 'entry'`, not a block. The one decision that shapes everything is `source`: when Shopify owns commerce, price/options/variants/stock are hidden and the entry only stores the Shopify GID plus EDITORIAL content (story, gallery, highlights, specs, care, policies); the 'native' path shows the full options → variants matrix for stores without Shopify. Options are an array of objects whose values are themselves objects (label + swatch color/image) because swatches need data, not just strings. The gallery is a polymorphic array (image | video | 3D) and each image can be scoped to option values so picking 'Ember red' swaps the photos. Specs are groups of rows (rendered as a table with th scope) instead of rich text so they can be compared, filtered and emitted in Product JSON-LD `additionalProperty`. Policies default to the store-wide ones (one source of truth) with a per-product override.
1199
+
1200
+ **HTML signals.**
1201
+ - a single page with one <h1> product name, a price block and a form posting to /cart/add with a hidden variant id
1202
+ - option pickers: radio groups / fieldsets named Size, Color, Finish; swatch buttons with background colors or images
1203
+ - a thumbnail strip + main image (or a <video>/<model-viewer>) — the media gallery
1204
+ - a <table> or <dl> of label/value pairs under 'Specs', 'Specifications', 'Details', 'Dimensions'
1205
+ - accordions/<details> titled Shipping, Returns, Care, Warranty
1206
+ - existing JSON-LD with @type Product and offers
1207
+
1208
+ **Do.** Create/locate the Products entry by the URL handle; set source:'shopify' and shopifyProduct when Shopify markers exist (window.ShopifyAnalytics, /products/<handle>.js, cdn.shopify.com), and then DO NOT import price, compare-at, options, variants or stock — they sync. Otherwise source:'native' and parse options from the pickers, variants from the variant JSON (often a <script type=application/json> with variants[]). Gallery: each media → galleryImage/galleryVideo/gallery3d, alt from the img alt (never the filename), forValues from data-variant/data-color attributes. Specs: each table/dl → one specGroup (caption or preceding heading → title). Shipping/returns accordions: if the text is identical across products, set useStorePolicies:true and put the text in Settings once; otherwise emit policies. Never create meta/og fields — page meta is native.
1209
+
1210
+ **Pitfalls.**
1211
+ - Duplicating Shopify price/stock into the entry — two sources drift within a day.
1212
+ - compare-at must be higher than price; the schema can't express a cross-field rule yet (gap), so the editor validates on save.
1213
+ - Variant combinations are typed strings; renaming an option value orphans gallery `forValues` and variant rows.
1214
+ - Video posters are required: autoplaying video without a poster hurts LCP on the product page.
1215
+
1216
+ Not carried by a stored component: ai, category, document preview path, kind:entry (props), reference target, validation.items.
1217
+
1218
+ ### Featured product spotlight (`commerce-featured-product`)
1219
+
1220
+ Tags: references, commerce, variants, conditional, media-heavy, repeater. Proves: One product reference + per-placement overrides (headline, image) that fall back to the entry · inline add-to-cart vs link
1221
+
1222
+ **Why this shape.** The spotlight is a REFERENCE plus optional overrides: name, price, rating, image and stock all come from the Products entry, and the section only stores what is placement-specific (headline, story, image override, selling points). Every override says 'leave empty to use…' so the fallback is explicit. The action is a conditional pair: 'Add to cart' needs only a label (the renderer adds the variant picker and swaps to Pre-order/Sold out itself); 'Open the product' is a normal button unit.
1223
+
1224
+ **HTML signals.**
1225
+ - a two-column section with one large product image and one product name + price + add-to-cart/'Shop now'
1226
+ - a single /products/<handle> link or a single add-to-cart form in the section
1227
+ - short check-icon bullet list beside the image
1228
+ - an eyebrow like 'New', 'Featured', 'Product of the month'
1229
+
1230
+ **Do.** Resolve the single product link to a Products entry → product. Compare the visible name/description/image with the entry: only emit headline/body/image when they DIFFER (that's an override); otherwise leave them empty. A form posting to /cart/add → action:'add' (+ addLabel from the submit text); a plain link → action:'link' with a button unit. Image position decides the variant (image first in DOM / on the left → image-left).
1231
+
1232
+ **Pitfalls.**
1233
+ - Copying the product name into `heading` freezes it; renames in Shopify won't flow through.
1234
+ - A deleted or archived product leaves the section empty — the renderer hides it and the editor flags the broken reference.
1235
+
1236
+ Not carried by a stored component: ai, category, variants.
1237
+
1238
+ ### Collection header + filters (`commerce-collection-header-facets`)
1239
+
1240
+ Tags: commerce, dynamic-query, references, repeater, conditional, validation, media-heavy. Proves: Collection reference with header overrides · facet config array (source × display) with conditional swatch/range settings · sort options
1241
+
1242
+ **Why this shape.** The header is presentation over a collection reference (title/intro fall back to the collection), and the filter sidebar is CONFIGURATION, not content: an array of facet objects, each saying what it filters on (option, field, tag, price, availability) and how it shows (checkbox, swatch, pills, range). Display-specific settings are conditional siblings — swatch colors only for swatches, step only for ranges — so a facet stays a short form. Facet values themselves are never listed: they're computed from the products, so new colors appear without an edit.
1243
+
1244
+ **HTML signals.**
1245
+ - a page title + description followed by a filter sidebar/drawer with grouped checkbox lists, color swatches and a price slider
1246
+ - a 'Sort by' <select> with options like Featured, Best selling, Price low to high
1247
+ - URL params like ?filter.v.option.color= or ?sort_by= (Shopify), ?min_price= (WooCommerce)
1248
+ - a 'n products' count and pagination/'Load more' below the grid
1249
+
1250
+ **Do.** Header text → title (only if it differs from the collection name) / description; hero image → banner. For each filter group: label = group heading; infer source from the param name (filter.v.option.* → option + key, filter.p.m.* metafield → field, filter.v.price → price + range, filter.v.availability → availability). Swatch groups → display:'swatch' and copy each swatch's background color into swatches[]. Do NOT import the filter VALUES — they are computed from products. Sort <select> options → sortOptions (map labels to the enum), selected option → defaultSort.
1251
+
1252
+ **Pitfalls.**
1253
+ - defaultSort must be one of sortOptions; select options can't depend on another field yet (gap), so it's validated on save.
1254
+ - Importing facet values as content makes new colors invisible until someone edits the page.
1255
+
1256
+ Not carried by a stored component: ai, category, variants.
1257
+
1258
+ ### Bundle builder (choose N) (`commerce-bundle-builder`)
1259
+
1260
+ Tags: commerce, conditional, references, repeater, validation. Proves: Eligible products (query or picked) · pick min/max · tiered discount OR fixed bundle price as a conditional pair · tier rows with validation
1261
+
1262
+ **Why this shape.** A bundle is RULES around a product set: the eligible list is references (price and stock stay live), the pick limits are two numbers side by side, and the savings model is a conditional pair — tiered steps (an inline array of quantity/percent/label) or one fixed bundle price — because those two are the deals real stores run. The actual checkout discount lives in Shopify; the section shows its id developer-locked so marketers can see what enforces the promise without breaking it.
1263
+
1264
+ **HTML signals.**
1265
+ - copy like 'Pick 3', 'Build your bundle', 'Mix & match', 'Save x% when you buy n'
1266
+ - product cards with Add/Remove toggles and a running selection tray or progress bar (1 of 3 selected)
1267
+ - a discount ladder ('2 items: 10% · 3 items: 15%')
1268
+ - a single 'Add bundle to cart' button that stays disabled until enough items are chosen
1269
+
1270
+ **Do.** Resolve each selectable card to a Products entry → eligible[]. Parse the deal copy: 'n for $X' → pricing:'fixed' + bundlePrice + max=n; ladder copy → pricing:'tiers' with one row per step. min = the first step's quantity (or the disabled-until count). Never invent a discount code — leave discountCode empty for a developer to wire.
1271
+
1272
+ **Pitfalls.**
1273
+ - min ≤ max and ascending tier quantities can't be enforced by the schema yet (no cross-field rules).
1274
+ - The page promise and the Shopify discount can disagree; the converter must flag bundles without a discountCode.
1275
+
1276
+ Not carried by a stored component: ai, category.
1277
+
1278
+ ### Cart upsell & cross-sell (`commerce-cart-cross-sell`)
1279
+
1280
+ Tags: commerce, references, conditional, deep-nesting, personalization, repeater, validation. Proves: Rules array: when (condition type) → then (offer references) with condition-specific inputs · fallback list · placement
1281
+
1282
+ **Why this shape.** Cart offers are RULES, not a product list: an ordered array where each rule is `when` (one select) → condition-specific input (product refs, a tag, or an amount, each a conditional sibling) → `offer` (a small drill-in object of product refs, a message with a {{remaining}} token, and an optional discount). First match wins, which is how merchandisers reason about it, and a fallbackOffers list covers the rest. Rules can be paused with `isActive` so experiments don't destroy setup.
1283
+
1284
+ **HTML signals.**
1285
+ - inside a cart drawer/page (form action=/cart, .cart-drawer): a heading like 'You may also like', 'Complete the set', 'Goes well with'
1286
+ - a small horizontal product list with '+ Add' buttons inside the cart
1287
+ - free-shipping progress copy: 'You're $12 away from free shipping'
1288
+ - data attributes from upsell apps (Rebuy, ReConvert, Shopify product recommendations intent=complementary)
1289
+
1290
+ **Do.** Static cross-sell markup only shows ONE state, so emit a single fallback list from the visible products and, if a free-shipping bar exists, one 'subtotal-below' rule with the threshold parsed from the copy. If an app config is reachable (Rebuy JSON, recommendations endpoint intent=complementary), translate its rules into rules[]. Never fabricate rules from a single screenshot; leave rules empty and set fallback.
1291
+
1292
+ **Pitfalls.**
1293
+ - Rule order matters (first match wins); the converter must preserve source order.
1294
+ - percentOff needs a matching Shopify discount — otherwise the price promised in the cart won't apply at checkout.
1295
+ - A rule's trigger can't reference products already in the offer set; 'hideInCart' handles it at render.
1296
+
1297
+ Not carried by a stored component: category.
1298
+
1299
+ ### Promo banner with code + countdown (`commerce-promo-countdown`)
1300
+
1301
+ Tags: commerce, conditional, validation, personalization, variants. Proves: Discount code with pattern validation · fixed-deadline vs per-visitor countdown (conditional) · what happens after it ends · schedule + audience
1302
+
1303
+ **Why this shape.** Three ideas, each a conditional cluster: the CODE (pattern-validated string, plus 'apply for them' only when a code exists — `notEmpty` condition), the COUNTDOWN (none / a real deadline / a per-visitor evergreen timer, each showing only its own input), and what happens AFTER it ends (hide or swap the message). Schedule and audience are separate from the countdown on purpose: the banner can be visible before the timer starts. The evergreen help text keeps it honest — fake urgency is a dark pattern.
1304
+
1305
+ **HTML signals.**
1306
+ - a thin full-width bar above the header with sale copy and a code in <code>/<strong>/a 'Copy' button
1307
+ - a ticking timer: elements for days/hours/minutes/seconds, data-countdown / data-end attributes or a script with a target timestamp
1308
+ - a close (×) button with aria-label='Close announcement'
1309
+ - a link that applies a discount: /discount/<CODE>?redirect=…
1310
+
1311
+ **Do.** Copy text → message; the code → code (upper-case it); a /discount/<CODE> link → isAutoApplied: true. A timer with a fixed timestamp in markup/script → countdown:'deadline' + endsAt (keep the offset); a timer seeded from localStorage/cookie → 'evergreen' + durationHours. Position above the nav → _variant:'bar', otherwise 'band'. Close button → isDismissible: true.
1312
+
1313
+ **Pitfalls.**
1314
+ - Time zones: an ISO string without an offset ends the sale at the wrong hour for half the country.
1315
+ - hideAfter earlier than endsAt can't be caught by the schema (no cross-field rules).
1316
+ - Evergreen timers that reset on refresh are deceptive; the renderer persists the start per visitor.
1317
+
1318
+ Not carried by a stored component: ai, category, variants.
1319
+
1320
+ ### Shop the look (hotspots) (`commerce-shop-the-look`)
1321
+
1322
+ Tags: media-heavy, references, deep-nesting, repeater, commerce, a11y, validation. Proves: Looks › look › hotspots › hotspot: image coordinates as numbers + a product reference per point · accessible labels for every dot
1323
+
1324
+ **Why this shape.** Four levels: section › looks › look › hotspots › hotspot. A hotspot is a product REFERENCE plus two percentage coordinates (so a re-crop or responsive resize keeps the dot on the object) plus a REQUIRED screen-reader label — a dot is a button with no visible text, so the label is not optional. `showProductList` guarantees the products are reachable without precise tapping on a phone.
1325
+
1326
+ **HTML signals.**
1327
+ - an image wrapper with absolutely positioned buttons/dots (style='left:42%;top:58%')
1328
+ - popovers/tooltips that show a product name, price and 'Shop' link on dot click
1329
+ - a 'Shop the look' / 'Get the look' heading with a product list beside the image
1330
+ - data attributes like data-hotspot, data-x/data-y, .lookbook__dot
1331
+
1332
+ **Do.** Each image with positioned dots → one look. For each dot: read left/top percentages (convert px to % using the image's natural size), resolve the popover's product link to a Products entry, and write label from the dot's aria-label or the product name + where it is. Never drop a dot because it lacks an aria-label — generate one and flag it for review.
1333
+
1334
+ **Pitfalls.**
1335
+ - Pixel coordinates break on resize; always store percentages.
1336
+ - Coordinates are raw numbers today — there's no visual point picker bound to the image yet (gap).
1337
+ - Dots without labels fail WCAG 4.1.2; the label is required for that reason.
1338
+
1339
+ Not carried by a stored component: ai, category, document preview path, variants.
1340
+
1341
+ ### Size guide table (`commerce-size-guide`)
1342
+
1343
+ Tags: repeater, validation, a11y, references, media-heavy. Proves: `table` type: sizes as row headers, a column group per unit (both measurements stored, never converted) · measuring steps · applies-to references
1344
+
1345
+ **Why this shape.** Each size table is a `table` (CPO D42): sizes are row headers (th scope=row), and every measurement appears once per unit inside a column group (Inches, Centimetres → th scope=colgroup), so the unit switch only shows or hides a group and never computes conversions (rounding a size chart is how returns happen). Cells are keyed by column, so reordering never misaligns a size. The caption is required because a <table> without a caption is a guessing game for screen readers. The guide is referenced FROM products via `appliesTo`, so one guide serves the whole range and the product page links to it next to the size picker.
1346
+
1347
+ **HTML signals.**
1348
+ - a <table> with a first column of sizes (XS/S/M/L/XL or numbers) and measurement columns
1349
+ - a 'Size guide' / 'Size chart' link next to the size option that opens a modal/drawer
1350
+ - an in/cm or US/EU toggle switching the table values
1351
+ - a 'How to measure' illustration with numbered instructions
1352
+
1353
+ **Do.** For each table run the table detector (docs/TABLES.md): caption from <caption> or the preceding heading; first column → row headers (`headerColumn: true`); measurement headers → columns. If a unit toggle exists, read both states (data attributes often hold the other unit) and emit one column group per unit with the same measurements in the same order; never convert numerically. Images in the guide → measureImage with descriptive alt.
1354
+
1355
+ **Pitfalls.**
1356
+ - RESOLVED (D42): cells are keyed by column, so a missing value is an empty cell, never a shifted row.
1357
+ - The unit switch is the renderer's job (show the active column group); both groups stay in the HTML for search and AI.
1358
+ - Auto-converting inches to cm produces values the brand never published.
1359
+ - A size guide pasted as an image fails accessibility; the converter should OCR it into rows and keep the image only as the diagram.
1360
+
1361
+ Not carried by a stored component: category.
1362
+
1363
+ ### Store locator (`commerce-store-locator`)
1364
+
1365
+ Tags: references, dynamic-query, conditional, deep-nesting, repeater, validation, embed, seo-structured. Proves: Locations collection query vs written-here list · geopoint · weekly hours + holiday exceptions with HH:MM validation · map provider settings
1366
+
1367
+ **Why this shape.** Locations are real-world entities that deserve their own pages, so the default source is a Locations collection; the inline list exists for small brands and is what the sample shows so the full shape is visible. Hours are modelled the way people say them (grouped days + opens/closes in 24-hour strings, pattern-validated) plus dated exceptions, which is exactly what LocalBusiness `openingHoursSpecification` needs — so rich results come from content, not a second copy. Map provider/center/zoom are settings; the API key is never a content field.
1368
+
1369
+ **HTML signals.**
1370
+ - an embedded map (Mapbox GL canvas, maps.googleapis.com iframe/script) next to a scrolling list of addresses
1371
+ - a 'Find a store' / 'Stockists' heading with a ZIP/city search input and 'Use my location'
1372
+ - address blocks with phone links (tel:) and opening hours tables (Mon–Fri 10:00–18:00)
1373
+ - JSON-LD LocalBusiness/Store, or data-lat/data-lng on list items
1374
+ - store-locator app markers (Stockist, Storemapper, Storepoint)
1375
+
1376
+ **Do.** If >4 locations or each has its own URL, create Locations entries and set source:'collection'; else inline. Parse each address block; geocode only if lat/lng are absent from data attributes/JSON-LD (flag geocoded pins for review). Normalise hours to grouped days + HH:MM (12h '6pm' → '18:00'). Holiday notices → exceptions with ISO dates. Map vendor from the script URL → provider; never import API keys.
1377
+
1378
+ **Pitfalls.**
1379
+ - Time is a string with a pattern because there is no time-of-day primitive (gap).
1380
+ - Overnight hours (closes 01:00) need care: closes < opens means past midnight.
1381
+ - The Locations collection does not exist in the demo seed, so the collection path can't be previewed here.
1382
+
1383
+ Not carried by a stored component: category, variants.
1384
+
1385
+ ### Product comparison (2–4) (`commerce-product-comparison`)
1386
+
1387
+ Tags: references, commerce, deep-nesting, repeater, validation, conditional, a11y, responsive-layout. Proves: 2–4 product references as columns · a `table` keyed by product id (reorder-safe) · row groups · Yes / no columns with per-row and per-cell types
1388
+
1389
+ **Why this shape.** Columns are product REFERENCES (name, photo, price and Add button come from the entry); the specs are one `table` (CPO D42) whose product columns are KEYED BY THE PRODUCT ID, so reordering `products` re-orders the columns with their cells. Rows are editorial and grouped (Cooking, Carry → tbody + th scope=rowgroup). Product columns are Yes / no, so the renderer writes ✓ with the words 'Included' / 'Not included' (never a bare icon); a row can be Text or Number ('Best for', 'Weight') and one cell can differ ('No lid — use foil'). The highlighted column is a column flag, not an index.
1390
+
1391
+ **HTML signals.**
1392
+ - a <table> whose header row has 2–4 product images/names with prices and buttons
1393
+ - rows of ✓/✕ icons or 'Yes/No' with an occasional text value
1394
+ - a 'Compare' heading, 'Which one is right for you?' copy, or a 'Hide identical' toggle
1395
+ - a highlighted column with a ribbon like 'Best seller' / 'Our pick'
1396
+
1397
+ **Do.** Header cells → resolve each product link to a Products entry; emit `products` in column order and give each spec column `_key` = that entry id. Body via the table detector (docs/TABLES.md): <tbody> per group or a full-width heading row → `rowGroups`; first cell → row header; ✓/✕ icons, aria-labels and sr-only text → Yes / no; a row of words or numbers → that row's `type`; a partial icon with a note → a Text cell. The ribbon column → `highlight: true` on that column, its words → highlightLabel.
1398
+
1399
+ **Pitfalls.**
1400
+ - RESOLVED (D42): cells are keyed by product id, not position; the highlight is a column flag.
1401
+ - Keeping `products` and the spec columns in step (add a product → add its column) is the editor's job today; a column source ('one column per item of products') is the follow-up (gap 3).
1402
+ - Icon-only cells need text alternatives; Yes / no cells render ✓ plus the words, which is what search and AI read.
1403
+
1404
+ Not carried by a stored component: ai, category.
1405
+
1406
+ ## Pricing & plans
1407
+
1408
+ ### Plans with monthly / yearly switch (`pricing-tiers-billing-toggle`)
1409
+
1410
+ Tags: commerce, conditional, validation, repeater, variants. Proves: Both cycle prices written per tier (no computed discount) · per-seat pricing as a conditional · 'Everything in X, plus' inheritance · developer-locked payment price IDs
1411
+
1412
+ **Why this shape.** Unlike the core Pricing block (one amount + a global yearly-discount slider), real SaaS pricing pages publish BOTH cycle prices per plan because they rarely match one tidy percentage ($49 → $39 is 20%, $25 → $20 is 20%, but legacy plans drift). So each plan stores monthly + yearly as explicit numbers, conditional on a 4-way pricing select (free / flat / per seat / custom), with per-seat adding a minimum seats field. 'Everything in X, plus' is modelled as an `inherits` label + a features list of only the deltas, which is how readers scan tiers. Stripe price IDs sit in a collapsed, developer-locked fieldset: the page and checkout share one plan row, so a marketer can change the copy but never which price is charged.
1413
+
1414
+ **HTML signals.**
1415
+ - a Monthly/Yearly (Annual) segmented switch or toggle above 2–4 plan columns, often with a 'Save 20%' pill
1416
+ - each column: plan name, price with '/mo' or '/user/month', a CTA, a ✓ feature list
1417
+ - data-monthly / data-yearly attributes or two price spans toggled by the switch
1418
+ - 'Everything in Pro, plus' lead-in lines
1419
+ - checkout links containing price_… / plan= / priceId= parameters
1420
+
1421
+ **Do.** Read BOTH states of the switch (data attributes, hidden spans, or the script's price map) — never derive yearly from monthly with a percentage. Price text with '/user' or '/seat' → pricing:'seat'; '$0' or 'Free' → 'free'; 'Contact'/'Custom'/'Let's talk' → 'custom' + customLabel. 'Everything in X' → inherits:'X' and only the following bullets → features. Badge ribbons → badge. Stripe/Paddle IDs found in checkout URLs → stripeMonthly/stripeYearly (they're developer-locked afterwards). The switch's initially checked state → toggle.defaultCycle.
1422
+
1423
+ **Pitfalls.**
1424
+ - A 'Save 20%' badge that isn't true for every plan is misleading; the schema can't check it yet (no computed fields).
1425
+ - `inherits` is a free string, not a reference to a sibling plan — renaming a plan leaves stale 'Everything in' lines (gap: references within the same entry).
1426
+ - Per-seat prices must say 'per seat' near the number, not only in the footnote.
1427
+
1428
+ Not carried by a stored component: ai, category, document preview path, variants.
1429
+
1430
+ ### Usage-based price calculator (`pricing-usage-calculator`)
1431
+
1432
+ Tags: deep-nesting, repeater, validation, conditional, commerce. Proves: Slider inputs keyed for a formula · rates › graduated tiers per input (4 levels) · base price + included units · contact-sales threshold
1433
+
1434
+ **Why this shape.** A calculator is INPUTS + a PRICE MODEL, and the model must be data, not a formula string a marketer can break: sliders are keyed objects (key developer-locked, label/hint/range editable), and each rate points at a slider key and holds included units, a bundle size and graduated steps — section › rates › rate › tiers › tier. That covers the common SaaS shapes (base fee + included allowance + graduated overage, flat add-on per block) without code. The 'Talk to sales above' threshold is conditional with its own button so very large estimates route to a human, which is both honest and how volume deals actually close.
1435
+
1436
+ **HTML signals.**
1437
+ - <input type=range> sliders with live-updating number labels and a total price that changes as they move
1438
+ - copy like 'Estimate your cost', 'Pricing calculator', '$0.40 per 100k'
1439
+ - a breakdown list (base + overage lines) under a big monthly total
1440
+ - a script with a rate table / tiers array (look for upTo, unitPrice, included)
1441
+
1442
+ **Do.** Each range input → one slider (min/max/step from attributes, start from value; label from <label for>). Detect log scales from non-linear value mapping in the script (Math.pow/Math.log). Rates: prefer reading the rate table from the page script or the pricing table text ('first 2M included, then $0.40 per 100k up to 10M'); graduated steps → tiers with upTo, the last step without upTo. Never encode the formula as a string field. If the page shows 'Contact us' past a value, set contactAbove.
1443
+
1444
+ **Pitfalls.**
1445
+ - No computed/formula field exists, so the total is calculated by the renderer from the rate data — the schema can't preview the number in the editor (gap).
1446
+ - `rate.input` should be a select of slider keys from the same block (gap: options from a sibling array).
1447
+ - min < start < max is a cross-field rule the schema can't express yet.
1448
+ - Graduated vs volume pricing are different maths; this model is graduated only — say so in notes for volume-priced brands.
1449
+
1450
+ Not carried by a stored component: category.
1451
+
1452
+ ### Full feature comparison matrix (`pricing-feature-matrix`)
1453
+
1454
+ Tags: deep-nesting, repeater, conditional, validation, responsive-layout, a11y. Proves: Plans as keyed columns · one `table` keyed by plan key · Yes / no columns with per-row (limits) and per-cell (partial, add-on, soon) types · row groups · price row → Offers JSON-LD · sticky header + mobile mode
1455
+
1456
+ **Why this shape.** A pricing matrix was the deepest section most sites have (groups › rows › cells, 5 drill levels). Now it is one `table` (CPO D42): plan columns are KEYED by the plan key, so a missing cell is a dash, not a shifted column, and adding a plan never scrambles rows. Plan columns are Yes / no (✓ with the words 'Included' / 'Not included'); a limit row is Text; a partial, add-on or 'coming soon' is one cell with its own type. Groups are row groups (tbody + th scope=rowgroup). The price row is Money, which is what the opt-in Offers JSON-LD reads. The plan cards above still own name, price line and button (`plans`). Phone behavior is a design choice, not a layout hack.
1457
+
1458
+ **HTML signals.**
1459
+ - a large <table> (or role=table grid) under the plan cards with a header row of plan names + prices + buttons
1460
+ - row group headings spanning all columns (<th colspan>, <tbody> per group, or accordion sections)
1461
+ - cells with ✓/✕ icons, short limits ('2M', '30 days'), '+$29/mo' add-on text, 'Coming soon' pills
1462
+ - info-icon tooltips next to feature names
1463
+ - position: sticky on the header row
1464
+
1465
+ **Do.** Header cells → plans[] (lowercase keys from plan names) AND the matrix's plan columns with `_key` = that key, type Yes / no. Run the table detector (docs/TABLES.md): group heading rows / tbody per group → rowGroups; feature cell → row header, its tooltip (title / aria-describedby / data-tooltip) → a footnote; ✓ → true, ✕ / dash / empty → false; a row of limits ('2M', '30 days') → row type Text; '+$29/mo' / 'Add-on' / 'Soon' / a caveat → that cell's own Text type. A header price line → a Money 'Price a month' row (Offers JSON-LD reads it). Collapsed groups are a renderer setting.
1466
+
1467
+ **Pitfalls.**
1468
+ - RESOLVED (D42): cells are keyed by plan key; nothing is aligned by position.
1469
+ - Plan columns and `plans` must share keys; keeping them in step is the editor's job until a column source exists (gap 3). The validator should flag a column key with no plan.
1470
+ - Collapsed row groups and feature tooltips are presentation; tooltips become footnotes so their words stay on the page for search and AI.
1471
+ - Price lines in the header duplicate the plan cards above — they should reference one plan source (gap: plans as CMS items).
1472
+ - Icon-only cells need words: Yes / no cells render ✓ / – plus visually hidden 'Included' / 'Not included'.
1473
+
1474
+ Not carried by a stored component: category.
1475
+
1476
+ ### Single plan with add-ons (`pricing-single-plan-addons`)
1477
+
1478
+ Tags: commerce, repeater, conditional, validation, rich-text. Proves: One plan object + add-on array with units, quantities and preselection · eligibility flags · live total
1479
+
1480
+ **Why this shape.** Single-plan businesses (clinics, studios, memberships) sell one plan plus extras, so the shape is one drill-in `plan` object and an `addons` array — each add-on carrying its unit (per session / month / once), whether it's a checkbox or a quantity stepper (conditional max), and eligibility flags that matter to buyers (HSA/FSA). Preselected add-ons are allowed but the help text keeps them honest. The minimum term is its own field so it can sit next to the price instead of being buried in the small print.
1481
+
1482
+ **HTML signals.**
1483
+ - one prominent plan card with a price per month and an 'includes' list
1484
+ - a list of optional extras with checkboxes or +/- quantity steppers and individual prices
1485
+ - a running total that updates as extras are ticked ('Your monthly total')
1486
+ - small print about minimum terms, cancellation, insurance/HSA
1487
+
1488
+ **Do.** Plan card → plan (name, price, interval from '/mo', includes from its list). Each optional extra → addons[] with price and unit parsed from '/session', '/mo', 'one-time'; a stepper input → allowQuantity: true + maxQuantity from its max attribute; a pre-checked checkbox → isPreselected: true. Terms paragraphs → finePrint (rich text, keep links). Do not compute the total — the renderer does.
1489
+
1490
+ **Pitfalls.**
1491
+ - Pre-checked paid add-ons can be a dark pattern; keep them rare and visible.
1492
+ - A minimum term hidden in small print is a trust killer — it has its own field on purpose.
1493
+ - Mixed units (per month + per session) can't be summed into one honest monthly number without an assumption; show the breakdown.
1494
+
1495
+ Not carried by a stored component: category, document preview path.
1496
+
1497
+ ### Enterprise contact card (`pricing-enterprise-card`)
1498
+
1499
+ Tags: conditional, references, form, repeater, a11y, media-heavy. Proves: Action select → form reference (opens in place) / calendar URL / plain link · compliance badges · customer logos with alt · inline quote
1500
+
1501
+ **Why this shape.** The enterprise card's only job is the next step, so the core field is `action`: open a FORM (a reference to a form in Forms — fields, notifications and spam rules live there once, not per section), BOOK a call (a validated scheduling URL), or go to a page (a normal button unit). Each path shows only its own inputs. Proof is grouped separately: compliance as a multiselect of real certifications (help text forbids aspirational ticks), logos with the company name doubling as alt text, and a small inline quote.
1502
+
1503
+ **HTML signals.**
1504
+ - a dark or bordered panel after/beside the plan columns with 'Enterprise', 'Custom', 'Contact sales'
1505
+ - a button that opens a modal form, a Calendly/Cal.com embed or links to /contact-sales
1506
+ - SOC 2 / ISO / HIPAA badge images or text
1507
+ - a small logo row and a single quote inside the panel
1508
+
1509
+ **Do.** A button opening a modal with a <form> → action:'form'; match the form's field names against existing forms in Forms and reference the best match, otherwise create a form with those fields (never inline them into the section). Calendly/Cal.com/HubSpot meeting links → action:'calendar' + calendarUrl. Badges → compliance (map by text/alt). Logo <img alt> → logos[].name.
1510
+
1511
+ **Pitfalls.**
1512
+ - Inlining the contact-sales fields into the section creates a second form with separate submissions and rules.
1513
+ - Compliance badges are claims; the converter imports them but flags them for a human to confirm.
1514
+
1515
+ Not carried by a stored component: ai, category.
1516
+
1517
+ ### Pricing FAQ (`pricing-faq`)
1518
+
1519
+ Tags: rich-text, seo-structured, conditional, references, repeater, validation. Proves: Grouped questions (written here or FAQs collection) · price tokens in answers so copy never drifts from the plans · FAQPage rich results
1520
+
1521
+ **Why this shape.** A pricing FAQ is different from a general FAQ in two ways: it's GROUPED (plans, payments, security) — so groups › items, one level deeper than the core FAQ example — and its answers quote prices, which is where pricing pages most often contradict themselves. Answers therefore support {{plan.cycle}} tokens resolved from the Plans block on the same page, documented in helpMore. Written-here vs FAQs-collection stays a conditional pair (the collection reference is filtered to billing questions), and FAQPage rich results are a toggle because two FAQ blocks on one page must not both emit it.
1522
+
1523
+ **HTML signals.**
1524
+ - a 'Pricing FAQ', 'Billing questions' or 'Frequently asked' heading right after the plans
1525
+ - <details>/<summary> or aria-expanded accordions, sometimes with group sub-headings
1526
+ - answers that mention the same prices as the plan cards
1527
+ - existing FAQPage JSON-LD
1528
+
1529
+ **Do.** Group sub-headings → groups; each question/answer → items (answer as rich text, keep links). When an answer contains a price that equals a plan price on the same page, replace it with the matching token ({{pro.monthly}}) and note it in the conversion receipt. If the site has an FAQs collection with the same questions, prefer source:'collection' with references. Existing FAQPage JSON-LD → useRichResults: true (and remove the hand-written script).
1530
+
1531
+ **Pitfalls.**
1532
+ - Tokens are plain strings inside rich text — typos aren't caught until preview (gap: typed inline references to plan fields).
1533
+ - Two FAQ blocks emitting FAQPage on one URL is invalid; the toggle exists for that.
1534
+
1535
+ Not carried by a stored component: ai, category, variants.
1536
+
1537
+ ### Pay-yearly savings banner (`pricing-annual-savings-banner`)
1538
+
1539
+ Tags: personalization, conditional, validation, commerce. Proves: Savings expressed three ways (percent / months free / amount) as a conditional unit · audience targeting (members on monthly) · placement
1540
+
1541
+ **Why this shape.** Unlike a sale banner, this one is about the BILLING CYCLE and only makes sense to some people, so audience is first-class (members on monthly need a signed-in portal; visitors see the public version). The saving can be told three ways — percent, months free, dollar amount — and each is a conditional numeric with its own unit and range, substituted into the message through a single {{saving}} token, so the copy and the number can't disagree.
1542
+
1543
+ **HTML signals.**
1544
+ - a slim notice on a pricing/billing page: 'Switch to annual and save 20%', '2 months free'
1545
+ - links with ?cycle=yearly / ?billing=annual / /billing/switch
1546
+ - rendered only for logged-in users (inside an account/portal layout)
1547
+
1548
+ **Do.** Copy → message with the saving replaced by {{saving}}; detect the unit ('%' → percent, 'months free' → months, '$' → amount). A banner inside an authenticated layout → audience:'monthly-members'; on a public pricing page → 'visitors' or 'everyone'. Its URL path → placements.
1549
+
1550
+ **Pitfalls.**
1551
+ - The saving isn't computed from the actual plan prices (no computed fields) — a price change can make the banner untrue.
1552
+ - Targeting signed-in members needs the portal integration; without it the banner silently shows to no one.
1553
+
1554
+ Not carried by a stored component: ai, category.
1555
+
1556
+ ## Conversion & forms
1557
+
1558
+ ### CTA band (`conversion-cta-band`)
1559
+
1560
+ Tags: variants, conditional, media-heavy. Proves: Variants that swap layout without losing content · two button units · background as a conditional design cluster (color vs photo + overlay)
1561
+
1562
+ **Why this shape.** The CTA band is the most-reused section on any site, so the schema optimises for swapping: three variants share one content shape, and fields one layout doesn't use (image on Centered) are kept rather than deleted so switching back loses nothing. Buttons are button units (label + link + style). Background is design, modelled as a conditional cluster — a color, or a photo with an overlay slider whose help gives the readable range.
1563
+
1564
+ **HTML signals.**
1565
+ - a full-width section with high-contrast background, one heading, at most one sentence and 1–2 buttons
1566
+ - placed just before the footer or between long sections
1567
+ - a background-image with a semi-transparent overlay (::before with rgba/opacity or bg-black/40)
1568
+ - no form inputs (otherwise it's a newsletter or lead form)
1569
+
1570
+ **Do.** Heading/eyebrow/body text → fields; each <a>/<button> styled as a button → primaryButton/secondaryButton units (style from class: solid → primary, bordered → outline). Pick the variant from layout: centered text → centered, a sibling <img> column → split (+ image), flex row with buttons at the end → inline. Background image → background:'photo' + photo + overlay from the overlay opacity; solid color → map to the nearest brand token.
1571
+
1572
+ **Pitfalls.**
1573
+ - Fields can't be conditioned on the variant (gap) — `image` is always visible and its help explains why.
1574
+ - Hard-coded hex colors break dark mode and brand updates; map to tokens.
1575
+
1576
+ Not carried by a stored component: ai, category, variants.
1577
+
1578
+ ### Newsletter sign-up with consent (`conversion-newsletter-consent`)
1579
+
1580
+ Tags: form, references, conditional, validation, a11y, repeater. Proves: The simple inline form: visible label + placeholder + submit label · interest checkboxes that tag subscribers · consent mode (checkbox vs notice) · success with an optional code
1581
+
1582
+ **Why this shape.** The newsletter is the 'simple inline form' case: the visitor sees one email field, but submissions still go to a form (the reference), so double opt-in, list sync and suppression live in one place. The section owns only presentation: a REQUIRED visible label (a placeholder is not a label — WCAG 3.3.2), the placeholder, the submit label (a string, not a button unit, because submit isn't a link), optional interest checkboxes that map to subscriber tags, and the consent mode — an unticked checkbox or a notice — with rich-text wording so the privacy link is a real link.
1583
+
1584
+ **HTML signals.**
1585
+ - a <form> with exactly one type=email input (maybe a first-name input) and one submit button
1586
+ - copy like 'Subscribe', 'Join the list', 'Get the newsletter', 'No spam'
1587
+ - a consent checkbox or a small line mentioning privacy/unsubscribe
1588
+ - action URLs or scripts from Klaviyo, Mailchimp (list-manage.com), ConvertKit, Beehiiv
1589
+
1590
+ **Do.** Match the form to an existing form in Forms by its action/list id; otherwise create one with an email field (and first name if present) and reference it. Visible <label> → emailLabel (if only a placeholder exists, generate 'Email address' and flag it); button text → submitLabel; checkbox groups → interests with tags from their value attributes. A consent checkbox → consent:'checkbox' + its label as consentText; a fine-print line → 'notice'. Never inline the list id into the section.
1591
+
1592
+ **Pitfalls.**
1593
+ - Placeholder-only fields fail accessibility; emailLabel is required for that reason.
1594
+ - A pre-ticked consent box is not valid consent under GDPR — the option says 'Unticked'.
1595
+ - Submit is a string, not `type: button`: our button unit is label + LINK, and a submit action has no link (gap: action buttons).
1596
+
1597
+ Not carried by a stored component: ai, category, variants.
1598
+
1599
+ ### Lead capture form + success state (`conversion-lead-capture`)
1600
+
1601
+ Tags: form, references, conditional, repeater, validation, rich-text. Proves: Form reference + section-level presentation · success: message / redirect / file download as a conditional trio · hidden attribution fields with tokens
1602
+
1603
+ **Why this shape.** The FORM (fields, validation, notifications, CRM sync, spam protection) is a form in Forms referenced here; everything the section adds is presentation and context: the pitch, the reasons, an optional submit-label override for this placement, hidden attribution fields with submit-time tokens, and the success state. Success is a conditional trio — message, redirect (best for analytics) or download (message + file) — because those are the three things marketers actually choose between. Hidden fields are advanced: most editors never need them.
1604
+
1605
+ **HTML signals.**
1606
+ - a two-column section: persuasive copy + bullets on one side, a multi-field <form> on the other
1607
+ - form fields beyond email (company, phone, store type, message) and a submit button with an action verb
1608
+ - <input type=hidden> fields named utm_*, lead_source, page_url
1609
+ - a thank-you state in markup (hidden div), a redirect in the submit handler, or a gated PDF link
1610
+ - HubSpot (hsforms), Marketo (mktoForm), Typeform or Formspree embeds/actions
1611
+
1612
+ **Do.** Extract the form's fields into a form in Forms (or match an existing one by field names) and reference it — never copy fields into the section. Copy and bullets → heading/body/points. Hidden inputs → hiddenFields, converting server-rendered values to tokens where they're clearly UTM/page values. Detect the success path: a hidden success div → message; window.location / redirect param → redirect; a PDF link revealed after submit → download.
1613
+
1614
+ **Pitfalls.**
1615
+ - A per-section copy of the fields forks submissions and notification rules.
1616
+ - Redirect pages must exist and be noindexed natively — never add a robots field to the section.
1617
+ - Token names in hidden fields are free strings; a typo silently sends an empty value.
1618
+
1619
+ Not carried by a stored component: ai, category, variants.
1620
+
1621
+ ### Multi-step form (`conversion-multi-step-form`)
1622
+
1623
+ Tags: form, polymorphic-array, deep-nesting, conditional, validation, references, a11y. Proves: form grouped into steps by field key OR steps › polymorphic fields › options built here · visitor-side showIf branching · progress UI
1624
+
1625
+ **Why this shape.** Two honest paths. The recommended one references a form in Forms and only GROUPS its fields into steps by key (presentation stays here, data stays in Forms). The 'Built here' path exists for one-off forms and shows the full inline shape: steps › fields (a polymorphic array of text | choice | scale | consent) › answers — 4 drill levels, each field type carrying only the settings it needs. Branching ('ask for the surgery date only if they had surgery') is visitor-side DATA in `showIf`, deliberately separate from schema `hidden`, which only controls what the editor sees. The sample is a clinic intake, so 'Remember answers' is off — health data shouldn't sit in a browser.
1626
+
1627
+ **HTML signals.**
1628
+ - one <form> split into fieldsets/panels with Next/Back buttons and only one visible at a time
1629
+ - a progress bar, step indicator (1 · 2 · 3) or 'Step 2 of 3'
1630
+ - fields that appear when an earlier answer changes (data-show-if, x-show, conditional rendering)
1631
+ - Typeform/Jotform/Tally-style one-question-per-screen layouts
1632
+
1633
+ **Do.** Prefer source:'form': create or match a form in Forms with ALL fields (flattened), then emit formSteps with each panel's field names as fieldKeys. Use source:'inline' only when asked for a one-off. Map inputs: text/email/tel/date/textarea → textField with format; radio/checkbox groups → choiceField (+ options with value keys); range inputs → scaleField; consent checkboxes → consentField. Translate show/hide scripts into showIf on the dependent field (equals / not / answered). Health, financial or ID fields → set saveProgress:false and flag the form for a compliance review.
1634
+
1635
+ **Pitfalls.**
1636
+ - Schema conditions can't read a parent's value (gap), so the two step arrays sit side by side at the root instead of one array whose items change shape.
1637
+ - `showIf.field` is a string key, not a reference to an earlier field (gap: references within the same entry) — renaming a key breaks the branch silently.
1638
+ - Inline-built forms fork submissions and notifications from the Forms area; keep them rare.
1639
+ - Persisting answers in localStorage is a privacy risk for health data.
1640
+
1641
+ Not carried by a stored component: category.
1642
+
1643
+ ### Booking / reservations embed (`conversion-booking-embed`)
1644
+
1645
+ Tags: embed, conditional, repeater, validation, media-heavy, rich-text. Proves: Provider select → provider-specific ids (conditional) · experiences array with party-size range and deposits · large-party handoff
1646
+
1647
+ **Why this shape.** Booking is an EMBED with content around it. The provider select drives exactly one conditional id field (Resy venue number, OpenTable rid, Tock slug, Cal.com link — each validated in its own format) so editors never paste raw script tags. Experiences are editorial cards (photo, length, party range, price, deposit) that open the widget on a developer-locked booking type. Large parties are handed off to a private-dining page above a threshold, which is how restaurants actually avoid six-top no-shows.
1648
+
1649
+ **HTML signals.**
1650
+ - scripts/iframes from widgets.resy.com, opentable.com/widget, exploretock.com, cal.com/embed, calendly.com
1651
+ - a 'Reserve', 'Book a table' or 'Book an appointment' heading with a date/party-size picker
1652
+ - cards for different seatings or services with durations and per-person prices
1653
+ - cancellation/deposit policy text near the widget
1654
+
1655
+ **Do.** Identify the provider from the script/iframe host and extract ONLY the id (data-venue-id / rid / slug / cal link) — never store the script. Seating/service cards → experiences (price per person and party size parsed from copy). A 'larger parties' link → largePartyFrom + largePartyButton. Policy paragraphs → policy (rich text).
1656
+
1657
+ **Pitfalls.**
1658
+ - Storing the widget script as HTML makes it uneditable and a security risk; store ids only.
1659
+ - partyMin ≤ partyMax is a cross-field rule the schema can't express (gap).
1660
+ - Widget heights vary by provider; the renderer reserves space to avoid layout shift.
1661
+
1662
+ Not carried by a stored component: category.
1663
+
1664
+ ### Waitlist with referral rewards (`conversion-waitlist-referral`)
1665
+
1666
+ Tags: form, references, conditional, repeater, validation, media-heavy. Proves: Reuses one form with a list tag · referral reward ladder · share channels + message token · an honest signup counter with a minimum
1667
+
1668
+ **Why this shape.** A waitlist is a sign-up with a GOAL. Instead of a new form per launch, it reuses the newsletter form and stamps a `listTag`, so launch emails go only to this list while consent and sync stay central. Referral rewards are a small inline ladder (friends → reward), share channels a multiselect, and the share message carries a {{link}} token. The counter is a toggle with a minimum, never a typed number — social proof that's live and true or not shown at all.
1669
+
1670
+ **HTML signals.**
1671
+ - 'Join the waitlist', 'Get early access', 'Notify me' with an email form for a product not yet on sale
1672
+ - a referral panel after signup: personal link, share buttons, 'You're #1,284 in line'
1673
+ - a reward ladder ('Refer 3 friends to…')
1674
+ - a 'n people have joined' counter
1675
+ - scripts from Viral Loops, Prefinery, GetWaitlist, KickoffLabs
1676
+
1677
+ **Do.** Match the form to the existing newsletter/marketing form and set listTag from the list/segment/tag in the embed config (or slugify the product name). Never import a hard-coded 'n people joined' number — set counter:true and let the renderer count; if the count is a fake static number, flag it. Reward copy → rewards with friends parsed from the number.
1678
+
1679
+ **Pitfalls.**
1680
+ - One form per launch fragments consent records; tag the shared list instead.
1681
+ - Static 'join 12,000 others' numbers are often invented; the schema doesn't allow typing one.
1682
+ - {{position}} only makes sense with referrals on; the editor warns when it's used without them.
1683
+
1684
+ Not carried by a stored component: ai, category.
1685
+
1686
+ ### Contact section (`conversion-contact-section`)
1687
+
1688
+ Tags: form, references, repeater, conditional, embed, variants, a11y. Proves: Channel cards with typed links (email / phone / chat / visit) · form reference · optional map pin + address · layout variants
1689
+
1690
+ **Why this shape.** A contact section is a directory, a form and a place. Channels are an array of small objects with a typed `link` (email, phone, section, page — the link unit already knows mailto/tel), plus hours and an honest reply time. The form is a reference (topic routing and notifications belong to the form in Forms, not to this block, so no `topics` field here) and is optional for the channels-only layout. The map is an opt-in cluster: a geopoint for the pin and a written address, because a map alone is inaccessible.
1691
+
1692
+ **HTML signals.**
1693
+ - mailto: and tel: links grouped with labels like 'Customer service', 'Press', 'Wholesale'
1694
+ - a contact <form> with name/email/message (often a topic select)
1695
+ - an embedded map iframe (google.com/maps/embed) or static map image with an address block
1696
+ - opening hours and 'we reply within…' copy
1697
+
1698
+ **Do.** Each mailto:/tel:/chat trigger → one channel (kind from the scheme, label from the nearest heading, detail = visible text). The <form> → match/create a form in Forms and reference it; a topic <select> becomes a field ON that form (with routing set in Forms), not a section field. Map iframes → showMap:true, parse lat/lng from the embed URL (q= or !3d!4d) into location, and copy the address text.
1699
+
1700
+ **Pitfalls.**
1701
+ - Email addresses and phone numbers duplicated across header, footer and contact drift apart — prefer site settings + references (gap: global values referenced from sections).
1702
+ - Address must be text as well as a pin; maps aren't accessible on their own.
1703
+
1704
+ Not carried by a stored component: category, variants.
1705
+
1706
+ ### Quiz / product finder (`conversion-quiz-product-finder`)
1707
+
1708
+ Tags: deep-nesting, conditional, references, repeater, validation, form, personalization, media-heavy, commerce. Proves: Six drill levels: questions › answers › scores › product refs · visitor-side branching (showIf) · results mapped to products with a fallback · optional email gate
1709
+
1710
+ **Why this shape.** The deepest conversion pattern: section › questions › question › answers › answer › scores › score (+ a product reference inside). Scoring beats decision trees for product finders because answers can push several products up or rule one out (negative points), and it survives adding a product without rewriting branches. Branching is still possible with a visitor-side `showIf` on a question. Results are COPY keyed by product (products without copy fall back to their tagline) plus a required fallback, so the quiz never ends empty. The email gate is optional and never blocks results.
1711
+
1712
+ **HTML signals.**
1713
+ - a 'Take the quiz' / 'Find your…' start screen, then one question per screen with large answer buttons or picture cards
1714
+ - a progress indicator (Question 2 of 4) and Back button
1715
+ - a results screen with 1–3 recommended products and 'why it suits you' copy
1716
+ - scripts from Octane AI, RevenueHunt, Typeform, Jebbit, or a JSON config with questions/answers/weights
1717
+
1718
+ **Do.** If a quiz config is reachable (Octane/RevenueHunt JSON, inline script), map it directly: questions → questions with generated keys, answer weights/tags → scores with product references (resolve product handles to Products entries). If only markup is visible, import questions and answers and leave scores EMPTY for a merchandiser to fill — never guess weights. Result screens → results (copy per product). Email steps → useEmailGate + a Forms reference.
1719
+
1720
+ **Pitfalls.**
1721
+ - `showIf.field` and answer keys are strings, not references; renaming a key silently breaks branching (gap: references within the same entry, with rename-tracking).
1722
+ - Picture-card images can't be shown/hidden by the PARENT question's style (gap: parent-scoped conditions), so the image field is always visible with an explaining hint.
1723
+ - A gate that hides results until an email is given is a dark pattern; results always show.
1724
+
1725
+ Not carried by a stored component: ai, category.
1726
+
1727
+ ### Exit-intent popup (`conversion-exit-intent-popup`)
1728
+
1729
+ Tags: conditional, personalization, form, references, validation, a11y, media-heavy. Proves: Trigger rules (exit / delay / scroll / click) with per-trigger inputs · page targeting · frequency capping · offer as code / form / link
1730
+
1731
+ **Why this shape.** A popup isn't a page section — it's a site-wide GLOBAL with rules, so `kind: 'global'`. Three rule clusters, each a select with conditional inputs: the trigger (exit / delay / scroll / click — the click selector is developer-locked), targeting (included and excluded paths as string lists, audience, devices; checkout excluded by default), and frequency capping (per visit / every N days / once). The offer is a conditional trio — email-for-a-code (a Forms reference + the code revealed after sign-up), a bare code, or a button. The decline text is required and its help bans confirmshaming.
1732
+
1733
+ **HTML signals.**
1734
+ - a hidden role=dialog / aria-modal element with an overlay, revealed by a script on mouseleave/visibilitychange/scroll/timeout
1735
+ - copy like 'Wait!', 'Before you go', 'Get 10% off your first order'
1736
+ - an email input + a code reveal after submit
1737
+ - a decline link ('No thanks') and a close button
1738
+ - scripts from Klaviyo forms, Privy, Justuno, OptinMonster, Sumo
1739
+
1740
+ **Do.** Create ONE popup global per dialog. Read trigger settings from the script/app config (exitIntent, delay ms → seconds, scroll %) and targeting from its URL rules; frequency from cookie/localStorage expiry (days). Email forms → match the form in Forms and reference it. Confirmshaming decline copy should be imported as-is but flagged with a suggested neutral rewrite.
1741
+
1742
+ **Pitfalls.**
1743
+ - Full-screen popups on phones count as intrusive interstitials for Google; the size help says so.
1744
+ - Never show on /checkout — it's in the default excludes.
1745
+ - Focus must move into the dialog and return on close; that's the renderer's job, so there's no field for it.
1746
+
1747
+ Not carried by a stored component: ai, category, kind:global.
1748
+
1749
+ ### Trust badges & guarantees row (`conversion-trust-badges`)
1750
+
1751
+ Tags: repeater, a11y, variants, conditional, validation, media-heavy. Proves: Typed badges (guarantee / shipping / certification…) with logo only when certified · payment icons as a multiselect · a synced review score that can't be typed
1752
+
1753
+ **Why this shape.** Trust rows are claims, so the schema keeps them honest and accessible. Each badge is TYPED: guarantees, shipping, returns and origin use an icon, while certifications require the official logo (with alt text naming it) — the condition swaps icon for logo. Payment methods are a multiselect of known marks (the renderer owns the correct SVGs; nobody uploads a blurry Visa logo). The review score is a toggle that pulls a live number from the connected app — there is no field to type 4.9.
1754
+
1755
+ **HTML signals.**
1756
+ - a row of 3–6 small icon + short text items near the buy button or above the footer ('Free shipping', '30-day returns', 'Lifetime warranty')
1757
+ - payment brand SVGs (Visa, Mastercard, Amex, Apple Pay, Shop Pay, PayPal) in a list
1758
+ - certification marks (B Corp, 1% for the Planet, Climate Neutral) as images
1759
+ - a star rating with '4.9 from 2,400 reviews' text, often from a reviews widget
1760
+
1761
+ **Do.** Each icon+text item → badge (kind from keywords: ship → shipping, return/refund → returns, warranty/guarantee → guarantee, 'made in'/'poured in' → origin; known certification marks → certification with the image as logo). Payment SVGs → payments by brand (match file names/titles/aria-labels). A star rating from a reviews widget → showRating:true; never import the number. Text-only certifications without a logo stay 'certification' and are flagged for a logo.
1762
+
1763
+ **Pitfalls.**
1764
+ - Typed review numbers go stale and can mislead; only the live score is allowed.
1765
+ - Payment icons for methods not enabled at checkout are a common trust-breaker.
1766
+ - Icon-only badges need text; the title is required.
1767
+
1768
+ Not carried by a stored component: category, document preview path, variants.
1769
+
1770
+ ## Listings & feeds
1771
+
1772
+ ### Blog index (`blog-index`)
1773
+
1774
+ Tags: dynamic-query, references, conditional, variants, validation. Proves: A listing stores a query, not copies · featured pick as a conditional reference · pagination modes
1775
+
1776
+ **Why this shape.** Posts are Journal entries; the index is a section on the Journal static page that holds only a query (topics, authors, order, page size), so a new post appears without anyone editing the page. The collection is developer-locked because the card template is wired to that collection's fields. The editorial pick is the one exception — a single optional reference shown only when Featured story = Pick one — and excludeFeatured stops it showing twice. Card visibility and image ratio are design, so they live in the Design group, not on each post.
1777
+
1778
+ **HTML signals.**
1779
+ - Three or more sibling <article> cards with the same shape: image, h2/h3 link, <time>, short paragraph
1780
+ - Card hrefs share a prefix (/blog/, /journal/, /news/) — that prefix names the collection
1781
+ - One card visibly larger or first in its own row before the grid (featured)
1782
+ - <nav aria-label="Pagination"> with numbered links or rel="next", or a 'Load more' button
1783
+ - Category chips or a 'Filed under' label on each card (topics reference)
1784
+
1785
+ **Do.** 1) Find or create the collection from the card shape: title (card heading), slug (last href segment), excerpt (card paragraph), coverImage (card image + alt), publishedAt (<time datetime>), author, topics. 2) Import each card as an entry; follow the href for the body. 3) Create ONE blogIndex section with posts.collection = that collection. Set topics only when the page URL or heading scopes it (/blog/category/camp → topic 'camp'). 4) featuredMode: 'pick' + featuredPost when the first card is larger, else 'latest'. 5) pagination from the DOM (numbered nav → numbered; 'Load more' → loadMore; none visible → none). pageSize = cards on page 1 minus the featured card, rounded to columns. Never write card titles into the section.
1786
+
1787
+ **Pitfalls.**
1788
+ - Copying the 9 visible cards into an inline array freezes the list — new posts never appear.
1789
+ - Featured post listed again in the grid when excludeFeatured is off.
1790
+ - Infinite scroll with no numbered fallback leaves old posts unreachable for crawlers.
1791
+ - pageSize that isn't a multiple of columns leaves a ragged last row.
1792
+ - A topic-scoped index (/journal/topic/camp) is a page template on the Topics collection, not a copy of this section per topic.
1793
+
1794
+ Not carried by a stored component: ai, category, variants.
1795
+
1796
+ ### Category filter bar (`category-filter-bar`)
1797
+
1798
+ Tags: references, dynamic-query, conditional, variants, responsive-layout, a11y. Proves: Chips from a taxonomy collection (all or picked) · filters a list on the page or navigates · mobile behavior per breakpoint
1799
+
1800
+ **Why this shape.** The chips are a projection of the Topics collection, so the section holds either 'every topic' (a query) or an ordered list of topic references — never typed labels, which drift from the real taxonomy. Whether a chip filters in place or navigates is behavior the marketer chooses; the URL parameter is wired in code (locked). It points at the list it filters by anchor because the schema has no 'section on this page' reference. Mobile treatment is per-breakpoint design.
1801
+
1802
+ **HTML signals.**
1803
+ - <nav> or role="tablist" of 3–15 short links/buttons just above a card grid
1804
+ - First item reads 'All' / 'Everything' and has aria-current or an active class
1805
+ - Hrefs like ?category=x, ?topic=x or /category/x/
1806
+ - Horizontal overflow-x: auto on small screens (scroll), or a <select> fallback
1807
+
1808
+ **Do.** Map each chip label to an existing taxonomy entry (create Topics if missing; slug from the href). If the chips equal the full taxonomy, set source 'all'; otherwise 'pick' in DOM order. action = 'filter' when hrefs are query strings or buttons, 'navigate' when they are /category/x paths. targetList = anchor of the next listing section (add one to it if missing). Don't store chip labels as strings.
1809
+
1810
+ **Pitfalls.**
1811
+ - Typed chip labels drift from topic names after a rename — always references.
1812
+ - A filter bar with no targetList silently does nothing.
1813
+ - Counts that include drafts or future posts — counts must use the published query.
1814
+ - Wrapping 12 chips on a phone pushes the list below the fold.
1815
+
1816
+ Not carried by a stored component: category, variants.
1817
+
1818
+ ### Related posts (`related-posts`)
1819
+
1820
+ Tags: conditional, references, dynamic-query, validation, variants. Proves: Automatic, hand-picked or mixed (pins first, then auto) · match rules · fallback when nothing matches
1821
+
1822
+ **Why this shape.** Related posts live on the post template (one placement for every post), so the default must work with zero editing: automatic matching on shared topics. Editors who care can pin a few (mixed) or hand-pick all (manual). The three-way mode is the condition that shows picks vs. match rules — the same field never means two things. References carry the picks so a renamed post stays linked; the current post is excluded by the renderer, not by validation.
1823
+
1824
+ **HTML signals.**
1825
+ - Heading like 'Related', 'Keep reading', 'You might also like', 'More from' near the article end
1826
+ - 2–4 small cards after </article> or before the comments/footer
1827
+ - Cards link to the same URL prefix as the current page
1828
+ - Sometimes inside <aside> with a smaller heading level (h2/h3)
1829
+
1830
+ **Do.** Place one relatedPosts on the post TEMPLATE, not per post. If the source CMS shows the same cards for posts with the same category, use mode 'auto' with matchOn ['topics']. If a post shows cards that don't share its topics, convert those to picks on that post's instance (mode 'mixed'). count = cards shown.
1831
+
1832
+ **Pitfalls.**
1833
+ - Manual picks on a template apply to every post — picks belong on the post's own instance.
1834
+ - Auto with no fallback renders an empty heading on a post with a unique topic.
1835
+ - Picking the current post (self-reference) — the renderer must skip it.
1836
+ - Unpublished picks: the reference resolves only to published entries on the live site.
1837
+
1838
+ Not carried by a stored component: category, variants.
1839
+
1840
+ ### Resource library (`resource-library`)
1841
+
1842
+ Tags: dynamic-query, repeater, conditional, references, form, validation, responsive-layout. Proves: Multi-facet filters defined as data (field · control · order) · gated downloads by type · pinned items above the query
1843
+
1844
+ **Why this shape.** Resources are a collection; the library is a query plus a list of facets. Facets are DATA (label · which field · control · order) so marketers can add 'Industry' without code — but the field each facet reads is developer-locked, because it must be an indexed field. Gating is a page-level decision by resource type, not a flag copied onto every resource, and the form is a reference so submissions land in one place.
1845
+
1846
+ **HTML signals.**
1847
+ - A left column (or top bar) of <fieldset>/<details> groups with checkboxes and counts in parentheses
1848
+ - A search <input type=search> above a card grid
1849
+ - Cards with a type badge (Guide, Report, Template) and a 'Download' or 'Read' action
1850
+ - URL query like ?type=guide&topic=close updating as filters change
1851
+ - A modal or redirect with a form before a PDF opens (gated)
1852
+
1853
+ **Do.** Create col_resources with fields title, summary, type (select from the badges), topics, industry, file/url, cover. Import every card. Each filter group becomes one facet: label = group legend; field = the collection field whose values match the checkbox labels (create the field if missing, then ask the developer to index it); control from the DOM (checkbox list → checkbox, pill buttons → chips, <select> → dropdown). If downloads open a form, set gate 'form' and create/attach the form; restrict gateTypes to the badges that trigger it.
1854
+
1855
+ **Pitfalls.**
1856
+ - Facet values typed into the section drift from entry values — values always come from the collection.
1857
+ - A facet on a non-indexed field works in the demo, then times out at 5,000 resources.
1858
+ - Gating every type (including blog-like guides) hurts organic traffic; gate by type.
1859
+ - Pinned items that also match the filters appear twice unless the renderer de-duplicates.
1860
+
1861
+ Not carried by a stored component: ai, category, variants.
1862
+
1863
+ ### Podcast episode list (`podcast-episode-list`)
1864
+
1865
+ Tags: dynamic-query, media-heavy, references, conditional, repeater, embed, a11y. Proves: Show-level details + platform links (repeater) · episodes by query with season filter · inline player vs episode page
1866
+
1867
+ **Why this shape.** Episodes are entries (they have their own page, audio file, show notes, transcript — see the Podcast episode entry), so the list is a query with a season and type filter. Show-level facts (name, art, hosts, platforms) are said once on the section because there is one show per page; if a brand runs several shows, promote them to a Shows collection and swap these fields for a show reference. Platform links are a small repeater of service + link so badges render with the right logo and order.
1868
+
1869
+ **HTML signals.**
1870
+ - A square cover image next to 'Listen on' badges (Apple Podcasts, Spotify, YouTube logos)
1871
+ - Repeated rows with episode number, date, duration (e.g. 48 min) and a play button
1872
+ - <audio> elements or an embedded player iframe (Transistor, Buzzsprout, Simplecast, Spotify)
1873
+ - A link to an RSS feed (application/rss+xml) or 'Subscribe' button
1874
+
1875
+ **Do.** Read the show's RSS feed if linked — it is the cleanest source: channel → show fields; each <item> → an episode entry (enclosure url → audio, itunes:duration, itunes:episode/season, content:encoded → show notes). Badges → platforms (match by logo alt/href host). Place one podcastEpisodes section with the collection query; set featured 'latest' when the top episode is larger.
1876
+
1877
+ **Pitfalls.**
1878
+ - Copying the embed iframe as the 'episode' loses the audio file and show notes — import from RSS.
1879
+ - Show art under 1400 px is rejected by Apple Podcasts.
1880
+ - Two players playing at once when several inline players exist — the renderer must use one shared player.
1881
+ - Trailers at the top of a 'newest' list unless types excludes them.
1882
+
1883
+ Not carried by a stored component: category, variants.
1884
+
1885
+ ### Changelog (`changelog`)
1886
+
1887
+ Tags: deep-nesting, repeater, conditional, rich-text, validation, dynamic-query, variants. Proves: Releases → changes → type tags · breaking/deprecated changes reveal a migration note · collection or written-here source
1888
+
1889
+ **Why this shape.** A changelog is releases → changes, and each change carries a TYPE that drives its badge, the type filter and — for Breaking/Deprecated only — a required migration note and removal date (conditional on the change's own type). Small products write releases right in the section; once releases need their own URLs, an RSS feed or reuse in the dashboard, switch the source to the Releases collection (same item shape) and the section becomes a query. Version is validated and unique because it doubles as the anchor.
1890
+
1891
+ **HTML signals.**
1892
+ - Repeated date + version headings (h2/h3 like 'v2.14.0' or 'September 29, 2026')
1893
+ - Colored badges per line: New, Improved, Fixed, Deprecated, Breaking
1894
+ - A vertical line/timeline down the left with dots per release
1895
+ - Links to an RSS/Atom feed or 'Subscribe to updates'
1896
+ - Anchors per version (#v2-14-0) for deep links
1897
+
1898
+ **Do.** Split on version/date headings → releases. Within each, every list item or sub-heading → a change; type from its badge text (map 'Added'→new, 'Changed'→improved, 'Removed'→breaking); the first sentence → title, the rest → details. If a change says 'deprecated'/'breaking' and names a replacement, put that sentence in migration. Use source 'collection' when the old site gives each release its own URL; otherwise 'inline'.
1899
+
1900
+ **Pitfalls.**
1901
+ - Free-text badges ('Bugfix', 'Fix', 'Fixed') break the type filter — always map to the select.
1902
+ - Breaking changes without a migration note — validation must block publish.
1903
+ - Version strings with spaces or dates as versions collide as anchors.
1904
+ - 60+ releases inline makes the section slow to edit — move to the collection.
1905
+
1906
+ Not carried by a stored component: category, segmented>4, validation.items, variants.
1907
+
1908
+ ### Public roadmap (`public-roadmap`)
1909
+
1910
+ Tags: deep-nesting, repeater, conditional, references, form, validation. Proves: Columns → items with status, quarter and read-only vote counts · shipped items link to a release · voting + suggestion form
1911
+
1912
+ **Why this shape.** A board is columns → items, three levels deep (section → column → item → its release). Each column has a STAGE (a select, not just a title) so styling and filtering don't depend on wording. Votes are read-only data the site writes — editors see them but can't fake them. A shipped item must link to its release, which is a reference so it follows renames. Feedback settings sit in their own group because they are a different job from editing the plan.
1913
+
1914
+ **HTML signals.**
1915
+ - 3–5 side-by-side columns headed Planned / In progress / Shipped (kanban)
1916
+ - Cards with an upvote arrow and a number
1917
+ - Quarter labels like 'Q1 2027' or 'Later'
1918
+ - A 'Suggest a feature' / 'Submit an idea' button opening a form
1919
+ - Often an embedded Canny / Productboard / Featurebase widget (iframe) — import its items as data
1920
+
1921
+ **Do.** Columns from the board headers (stage from the header text). Each card → item: title, summary (first paragraph), area (tag), quarter, votes (read-only — import the number once, then the site owns it). Cards in a 'Done/Shipped' column get shipped=true; try to match them to a release by name, else leave release empty and flag it. A Canny-style iframe: fetch its public JSON if available, otherwise keep it as an embed and say so.
1922
+
1923
+ **Pitfalls.**
1924
+ - Item stage inferred from column title text ('Now', 'Next', 'Later') — map it to the stage select explicitly.
1925
+ - The release condition lives on the item (shipped flag) because a field can't see its parent column's stage.
1926
+ - Editable vote counts invite fake numbers — keep votes read-only.
1927
+ - Dates as promises ('Ships Oct 3') — use quarters.
1928
+
1929
+ Not carried by a stored component: category, variants.
1930
+
1931
+ ### Search results page (`search-results-page`)
1932
+
1933
+ Tags: dynamic-query, references, conditional, repeater, validation, a11y. Proves: Configure search, don't store results · sources with weights · pinned results per keyword · empty states
1934
+
1935
+ **Why this shape.** A results page never stores results; it stores how search behaves on this site. Sources map to indexes that are built in code, so the collection is locked while the label, boost and page size are editorial. Pinned results are the one hand-curated part — keyword tags → a reference — with an optional end date so seasonal pins don't rot. The empty state is its own group because it is what most people judge search by.
1936
+
1937
+ **HTML signals.**
1938
+ - A page at /search with ?q= or ?query= in the URL and a prominent <input type=search>
1939
+ - Result tabs or filters by type (All, Products, Recipes, Articles) with counts
1940
+ - Highlighted <mark> or <em> around matching words in snippets
1941
+ - A 'No results for …' message with suggestions
1942
+
1943
+ **Do.** Don't import results. Create one searchResults section on the /search page. Tabs → sources (match each tab to a collection; ask the developer to index any that aren't). Popular or suggested chips → popularSearches. Visible 'Featured' or 'Top result' cards for a query you can reproduce → pinned. Copy the no-results text into the empty group with {query} for the echoed term.
1944
+
1945
+ **Pitfalls.**
1946
+ - Pins pointing at unpublished items show nothing — validate on publish.
1947
+ - Typo tolerance 2 on short words (≤4 letters) returns noise.
1948
+ - A label-less search input fails WCAG 1.3.1 / 4.1.2 — label is required.
1949
+ - Search pages must be noindex — the platform does it natively; never add a field for it.
1950
+
1951
+ Not carried by a stored component: category, reference target, variants.
1952
+
1953
+ ## Long-form & docs
1954
+
1955
+ ### Rich blog post (`rich-blog-post`)
1956
+
1957
+ Tags: rich-text, references, media-heavy, embed, conditional, validation, seo-structured, localized. Proves: One rich-text body holds key takeaways, callouts, tables, code, images, video, pull quotes, toggles, footnotes and linked components · sponsor disclosure is conditional
1958
+
1959
+ **Why this shape.** The post is a Journal ENTRY; the page template around it is fixed sections (post header from title/dek/byline/coverImage → body with optional ToC → closingButton → Author bio → Related posts). Everything readers scan inside the article — tables, callouts, code, video, FAQ toggles, linked components — lives in ONE rich-text body because its order is editorial and different every time. What the site queries (authors, topics, dates, isFeatured) are real fields. Sponsorship is three fields revealed by one toggle so the disclosure label can't be forgotten. Reading time is derived, so it's read-only.
1960
+
1961
+ **HTML signals.**
1962
+ - <article> with one <h1>, a byline (rel=author link, <time datetime>) and a large hero <img>
1963
+ - Body with h2/h3, <figure>/<figcaption>, <table>, <pre><code>, <blockquote>, <details>
1964
+ - Boxed asides with an icon (Tip / Note / Warning) → callouts
1965
+ - Mid-article product or newsletter card unlike the prose → component instance
1966
+ - 'Sponsored', 'In partnership with', 'Paid partnership' near the byline
1967
+
1968
+ **Do.** Create the entry: title (h1), slug (URL), dek (subtitle/first <p class=lede>), coverImage (hero img + alt + figcaption), authors/topics (match or create entries), publishedAt (<time datetime> or article:published_time), updatedAt (dateModified). Body: convert the article content to the rich-text format — h2/h3 kept, figure→data-rt=image, table→data-rt=table with thead, aside.note/tip/warning→data-rt=callout with tone, iframes from YouTube/Vimeo→data-rt=video, details→data-rt=toggle; repeated mid-article cards that match a Blocks component→data-rt=component with overrides for the differing text. Strip share bars, author box and related cards (template sections). Never copy <meta> into fields.
1969
+
1970
+ **Pitfalls.**
1971
+ - An H1 inside the body duplicates the title — the renderer demotes it, the converter should too.
1972
+ - Copying the author box into the body instead of referencing the author.
1973
+ - Pasting a CTA card as HTML loses the link to the component — use an instance.
1974
+ - Sponsored links without rel=sponsored — set by the sponsorLink default.
1975
+ - updatedAt bumped for typo fixes makes every post look fresh and erodes trust.
1976
+
1977
+ Not carried by a stored component: ai, category, kind:entry (props).
1978
+
1979
+ ### Author bio (`author-bio`)
1980
+
1981
+ Tags: references, conditional, variants, dynamic-query, a11y. Proves: One reference drives photo, name, role, bio and socials · per-placement bio override · latest posts by that author
1982
+
1983
+ **Why this shape.** Authors are a collection (name, slug, role, photo with alt, short bio, long bio, socials array, expertise topics); the bio box is a thin section that renders one of them, so a new headshot updates every post. The reference can be empty on the post template and fall back to the post's first author. A per-placement bio override is a conditional rich-text field — rare, but guest posts need it — rather than a second 'guest author' type. 'Their latest posts' is a tiny query (author = this, exclude current).
1984
+
1985
+ **HTML signals.**
1986
+ - A box after the article with a round headshot, name, a one-paragraph bio and social icons
1987
+ - rel="author" links or links to /author/{slug}
1988
+ - 'Written by', 'About the author', 'More from {name}'
1989
+ - Person JSON-LD with sameAs social URLs (native output from the author entry)
1990
+
1991
+ **Do.** Create/match the author entry from the box: name, role (line under the name), photo (+ alt 'Portrait of {name}'), bio, socials (each icon link → platform + url). Place ONE authorBio on the post template with author empty (falls back to the post's first author). Only set author + bioSource 'custom' when a specific page shows a different bio.
1992
+
1993
+ **Pitfalls.**
1994
+ - Typing the author's name and photo into each post — they drift on the first rebrand.
1995
+ - Headshot alt text 'image' or empty — use 'Portrait of {name}'.
1996
+ - 'Latest posts' listing the current post.
1997
+ - Two authors: the box must render both or say why only the first.
1998
+
1999
+ Not carried by a stored component: category, variants.
2000
+
2001
+ ### Docs page (`docs-page`)
2002
+
2003
+ Tags: rich-text, references, global-synced, conditional, embed, validation. Proves: Sidebar position by reference to a nav group · auto ToC with depth · prev/next auto or manual · deprecation reveals a notice
2004
+
2005
+ **Why this shape.** A docs page is an entry; the template around it is: shared sidebar (a global built from Doc groups — each page only says which group and position), breadcrumbs, the body, an auto 'On this page' list from H2/H3, prev/next, feedback, and the footer. Prev/next defaults to the sidebar order so editors rarely touch it; manual is there for tutorials that jump. Status is a select whose non-stable values reveal a required notice and, for deprecated, a replacement reference — so the warning can't be half-done. Code that readers copy goes in Code tabs instances, not raw <pre> blocks.
2006
+
2007
+ **HTML signals.**
2008
+ - Three columns: left nav tree, article, right 'On this page' list of anchors
2009
+ - Breadcrumbs and 'Previous' / 'Next' cards at the bottom
2010
+ - Admonitions (.admonition, .callout, :::note) and tabbed code blocks
2011
+ - 'Edit this page on GitHub' link; 'Last updated on' date
2012
+ - A version switcher or 'Beta' / 'Deprecated' banners
2013
+
2014
+ **Do.** Rebuild the sidebar tree as Doc groups (one per top-level nav heading) and record each page's group + position from the nav DOM order. Body: MDX/HTML → rich text; admonitions → callouts (note/info→info, tip→tip, caution/danger/warning→warning); tabbed code → codeTabs component instances (one block per tab group, see code-tabs); plain <pre> stays a code block. Don't import the right-hand ToC — it's derived. 'Beta'/'Deprecated' banners → status + statusNote.
2015
+
2016
+ **Pitfalls.**
2017
+ - Hard-coding prev/next per page — they break when pages are reordered; leave pager on auto.
2018
+ - Importing the ToC as content duplicates headings.
2019
+ - Docusaurus/Mintlify tabs flattened into one <pre> lose the other languages.
2020
+ - Deprecated without a replacement link — the reader is stranded.
2021
+ - Relative links (../guides/x) must be rewritten to page references on import.
2022
+
2023
+ Not carried by a stored component: category, kind:entry (props).
2024
+
2025
+ ### Code tabs (`code-tabs`)
2026
+
2027
+ Tags: embed, repeater, validation, variants. Proves: One example in N languages · language is a select, not a free label · tabs sync across the page by key
2028
+
2029
+ **Why this shape.** Docs show the same call in several languages; each snippet is an item with a LANGUAGE select (drives highlighting, the default tab name and the sync), an optional label override ('Node.js'), filename and highlight ranges. It is a block so it can sit inside a docs body as a component instance or on its own. syncKey lets every code-tabs block on the page switch together when a reader picks Python once.
2030
+
2031
+ **HTML signals.**
2032
+ - A tab bar of language names (cURL, Node, Python…) above a <pre><code class="language-*">
2033
+ - Several <pre> siblings where only one is visible at a time (role=tabpanel)
2034
+ - A copy-to-clipboard button in the code header
2035
+ - data-lang / data-language attributes; localStorage keys like 'preferred-language'
2036
+
2037
+ **Do.** One codeTabs per tab group. For each panel: language from class="language-x" / tab text (normalize: 'Node'/'JS'→javascript, 'Shell'/'Bash'→curl if it calls curl), code from <code> textContent (keep indentation, decode entities), filename from the panel header. Lines with a highlight class → highlight ranges. Same tab labels across groups → same syncKey.
2038
+
2039
+ **Pitfalls.**
2040
+ - innerHTML of highlighted code carries <span> soup — store textContent only.
2041
+ - Free-text language labels ('Node 18') break highlighting — map to the select, keep the label separately.
2042
+ - API keys in sample code — replace real-looking secrets with $ENV placeholders.
2043
+ - The `code` field's built-in language list (html/css/js/liquid/json) doesn't cover API languages — the select carries it.
2044
+
2045
+ Not carried by a stored component: category, openMode, variants.
2046
+
2047
+ ### Knowledge-base article (`kb-article`)
2048
+
2049
+ Tags: rich-text, form, conditional, references, validation. Proves: 'Was this helpful?' modelled as settings, not markup · 'No' path is quick reasons, a form or a contact button · review ownership
2050
+
2051
+ **Why this shape.** Help articles are entries in a KB collection with a required category (it drives the help-center tree and breadcrumbs). The feedback widget is part of the template, but its BEHAVIOUR is per-article content — the question, the thanks line and what happens after 'No' (quick reasons, a form reference, or a contact button), each revealed by one select. lastReviewed is required and owner is a reference so stale articles get routed to a person. Internal notes never render.
2052
+
2053
+ **HTML signals.**
2054
+ - 'Was this article helpful?' with Yes/No or thumbs buttons at the end
2055
+ - Breadcrumbs Help Center › Category › Article; 'Updated N days ago'
2056
+ - Numbered steps with UI screenshots and bolded menu paths
2057
+ - Zendesk/Intercom/HelpScout markup (article-body, .article-votes)
2058
+
2059
+ **Do.** Category from the breadcrumb (create KB categories). Body: keep ordered lists, bold UI paths and screenshots (alt from the step text). The vote widget → feedback settings; if 'No' opens a reason list, copy the reasons; if it links to a contact form, set onNo 'form' and attach/create the form. 'Updated' date → lastReviewed. Don't import vote counts — they're analytics.
2060
+
2061
+ **Pitfalls.**
2062
+ - Vote buttons copied as HTML into the body (they can't record anything).
2063
+ - Screenshots with alt 'screenshot' — describe what the reader should look for.
2064
+ - No owner → stale answers nobody reviews.
2065
+ - Plan badges as body text drift from pricing — use appliesTo.
2066
+
2067
+ Not carried by a stored component: category, kind:entry (props).
2068
+
2069
+ ### Glossary A–Z (`glossary`)
2070
+
2071
+ Tags: dynamic-query, repeater, conditional, references, seo-structured, a11y, validation. Proves: Terms written here or from a Glossary collection · auto letter index · aliases and see-also links · each term deep-linkable
2072
+
2073
+ **Why this shape.** A glossary is a list of term objects sorted by the renderer, with the A–Z bar derived from first letters (never stored). Small sites write terms in the section; once terms should be linked from articles, have their own URLs or be reused, switch the source to the Glossary collection (same item shape) and the section becomes a query. Aliases feed the filter box and search; see-also is plain tags because a reference can't point at a sibling array item — the collection version uses real references.
2074
+
2075
+ **HTML signals.**
2076
+ - A row of A–Z letter links (#a, #b…) above alphabetised groups
2077
+ - <dl> with <dt> terms and <dd> definitions, or h3 + p pairs
2078
+ - Each term has an id for deep links
2079
+ - DefinedTermSet / DefinedTerm JSON-LD in the source (native output on our side)
2080
+
2081
+ **Do.** Each <dt>/<h3> → term; following <dd>/<p> → definition; element id → anchor. 'Also known as …' lines → aliases. Inline 'See: X' links that point at other terms → seeAlso (by name). Drop the letter bar and letter headings — they're derived. If the site has /glossary/{term} pages, use source 'collection' and import each as an entry.
2082
+
2083
+ **Pitfalls.**
2084
+ - Storing the letter headings ('A', 'B') as terms.
2085
+ - See-also tags that don't match a term exactly render as dead text.
2086
+ - 300 inline terms is the ceiling — beyond that, use the collection.
2087
+ - Localised term lists re-sort per language (Ä, Ø, Ł) — sort with the locale's collation.
2088
+
2089
+ Not carried by a stored component: category, validation.items, variants.
2090
+
2091
+ ### Recipe (`recipe`)
2092
+
2093
+ Tags: deep-nesting, repeater, seo-structured, media-heavy, conditional, references, validation, localized. Proves: Ingredient groups → items with amount/unit/prep · steps with images and optional timers · nutrition · Recipe JSON-LD from fields
2094
+
2095
+ **Why this shape.** Recipes are entries because every part is queried or validated: course, method and dietary drive filters; times and servings feed scaling and the Recipe JSON-LD (prepTime, cookTime, totalTime, recipeYield, recipeIngredient, recipeInstructions as HowToStep with image, nutrition, aggregateRating) — all emitted natively from these fields. Ingredients are groups → items (four drill levels: recipe → group → item → product) with numeric amount + unit select, so scaling and unit conversion are possible; typed lines like '2 cups flour' can't scale. Step timers are optional and revealed per step. Template: hero (title, summary, image, times) → ingredients + steps two-column → notes → nutrition → author bio → related recipes.
2096
+
2097
+ **HTML signals.**
2098
+ - Recipe JSON-LD in the source (the most reliable signal) or WPRM/Tasty Recipes plugin markup (.wprm-recipe)
2099
+ - A 'Prep · Cook · Total' strip and 'Serves N' / servings stepper
2100
+ - <ul> of ingredients with amounts and units; group sub-headings like 'For the glaze'
2101
+ - <ol> of steps, often with step photos; 'Jump to recipe' / 'Print' buttons
2102
+ - A nutrition table (Calories, Protein, Fat, Carbs)
2103
+
2104
+ **Do.** Prefer the page's Recipe JSON-LD: recipeIngredient strings → parse into amount (handle ½, 1/2, ranges '2–3' → amount + amountMax), unit (normalize 'tablespoons'→tbsp), name, prep (text after a comma). recipeInstructions → steps (HowToSection → separate steps with the section name as title). ISO-8601 durations PT25M → minutes. Without JSON-LD, use the WPRM/Tasty classes. Group headings inside the ingredient list → ingredientGroups. Never import the 'Jump to recipe'/'Print' buttons.
2105
+
2106
+ **Pitfalls.**
2107
+ - Ingredient lines as plain strings — scaling and unit switches become impossible.
2108
+ - Unicode fractions (½ ⅓) and ranges parsed as one number.
2109
+ - Total time typed by hand disagrees with prep + cook — keep it derived.
2110
+ - Dietary claims set at recipe level while an optional ingredient breaks them.
2111
+ - Timer fields filled but hasTimer off — hidden values still ship unless cleared on save.
2112
+
2113
+ Not carried by a stored component: category, kind:entry (props), reference target.
2114
+
2115
+ ### Course curriculum (`course-curriculum`)
2116
+
2117
+ Tags: deep-nesting, repeater, conditional, references, validation, media-heavy, seo-structured. Proves: Modules → lessons → resources → file (4 drill levels) · lesson type reveals video / reading / quiz / live fields · free previews
2118
+
2119
+ **Why this shape.** The curriculum sits on the course page and references the Course entry (title, price, instructors, enrolment). Modules → lessons → resources is the deepest editorial structure on the site, so every level drills in with clear previews ('Video · 12 min'). The lesson TYPE is a select that reveals exactly the fields that type needs (video + captions, reading, quiz reference, live start) — no lesson carries empty video and quiz fields at once. Lengths are numbers so totals are computed, not typed. Course JSON-LD (syllabusSections, timeRequired) is derived natively.
2120
+
2121
+ **HTML signals.**
2122
+ - Accordion of 'Module 1 / Week 1' headings with lesson rows inside
2123
+ - Per-row icons for video / article / quiz and durations like '12 min'
2124
+ - 'Preview' or 'Free' badges on some lessons; lock icons on others
2125
+ - Totals like '6 modules · 42 lessons · 9h 30m'
2126
+
2127
+ **Do.** Each accordion header → module (title, summary from its subtitle). Each row → lesson: type from the row icon/label, minutes from '12:34' or '12 min' (round up), isFreePreview from a Preview/Free badge. Downloadable attachments under a lesson → resources (kind file); external links → kind link. Ignore the totals line — it's derived. Don't import lock icons; set unlock 'previous' when the source says lessons unlock in order.
2128
+
2129
+ **Pitfalls.**
2130
+ - Durations typed as text ('1h 05m') can't be summed.
2131
+ - Video lessons without captions — the captions field should be checked before publish (a11y).
2132
+ - Live lesson times stored in local time without a zone.
2133
+ - Five levels inline (course → module → lesson → resource → file) — keep resources tiny and inline.
2134
+
2135
+ Not carried by a stored component: category, segmented>4, variants.
2136
+
2137
+ ### Legal / policy page (`legal-policy`)
2138
+
2139
+ Tags: deep-nesting, repeater, rich-text, validation, conditional, a11y, localized. Proves: Numbered sections → clauses with optional plain-English summaries · effective date + version history with archived copies
2140
+
2141
+ **Why this shape.** Policies are static PAGES (one per policy, in the System folder), but their text is structured: sections → clauses, so numbering, anchors and the contents list are generated and stay correct when a clause is inserted. Each clause may carry a plain-English note (conditional), the pattern readers trust. The effective date and version are required; past versions are a repeater with an archived PDF so you can prove what a customer agreed to. policyType is developer-locked because footer and checkout links resolve by it.
2142
+
2143
+ **HTML signals.**
2144
+ - Headings numbered '1.', '1.1', 'Section 4' or roman numerals
2145
+ - 'Effective date', 'Last updated', 'Version' near the title
2146
+ - A contents list of #anchors at the top
2147
+ - Two-column 'In plain English' or 'TL;DR' asides beside clauses
2148
+ - Links to previous versions or PDFs
2149
+
2150
+ **Do.** Strip typed numbers from headings ('4.2 Returns window' → 'Returns window'); top-level numbered headings → sections, sub-numbers → clauses. Keep existing element ids as anchors (links in emails depend on them). Side notes / TL;DR boxes → hasSummary + plain. 'Last updated' → effectiveDate (ask if the page shows both). Older version links → versions items with archive files downloaded.
2151
+
2152
+ **Pitfalls.**
2153
+ - Changing an anchor breaks links in sent emails and order confirmations.
2154
+ - Typed numbering drifts on insert — numbering is generated.
2155
+ - 'Last updated' bumped for typos implies the terms changed.
2156
+ - Localized policies must be reviewed per locale, not machine-translated — flag localized fields.
2157
+
2158
+ Not carried by a stored component: category, kind:entry (props).
2159
+
2160
+ ### Case study (`case-study`)
2161
+
2162
+ Tags: rich-text, repeater, references, conditional, media-heavy, validation. Proves: Customer facts as an object · headline metrics repeater · challenge/solution/results as three rich-text bodies · quote written here or referenced
2163
+
2164
+ **Why this shape.** Case studies are entries in a Customers collection so the customer logo wall, the filterable customer list (industry, size, region, product) and pricing-page proof all query them. Customer facts are real fields in their own group, not prose. The story is three required rich-text bodies — challenge, solution, results — because every case study must answer all three and the template styles each part. Headline metrics are a 2–4 item repeater (value, label, comparison). The quote can be written here or referenced from Testimonials when it's reused elsewhere — one select decides.
2165
+
2166
+ **HTML signals.**
2167
+ - A customer logo and big numbers row near the top ('3 days', '92%')
2168
+ - Sections headed Challenge / Solution / Results (or Problem / Approach / Outcome)
2169
+ - A large pull quote with name, title and headshot
2170
+ - A side panel: Industry, Company size, Location, Products used
2171
+ - URL under /customers/ or /case-studies/
2172
+
2173
+ **Do.** Side panel facts → customer group fields (map 'Industry: SaaS' to the select). The numbers row → metrics (value as written, label underneath, 'from X' → context). Split the body at Challenge/Solution/Results headings into the three richtext fields (drop the headings themselves). A pull quote that also appears on other pages → create a Testimonial and use quoteSource 'testimonial'. Download links to a PDF → pdf.
2174
+
2175
+ **Pitfalls.**
2176
+ - Metrics without approval — help text says so; numbers are what legal checks.
2177
+ - One undivided body: the Challenge/Solution/Results layout can't be enforced.
2178
+ - Customer logos as JPG with a white box — ask for SVG/transparent PNG.
2179
+ - Duplicate quotes typed in three places drift — reference Testimonials.
2180
+
2181
+ Not carried by a stored component: ai, category, kind:entry (props).
2182
+
2183
+ ### Podcast episode (`podcast-episode`)
2184
+
2185
+ Tags: media-heavy, embed, references, repeater, seo-structured, a11y, validation, conditional. Proves: Audio file + show notes + timestamped chapters + transcript (a11y) · guests by reference · feeds the RSS and PodcastEpisode JSON-LD
2186
+
2187
+ **Why this shape.** Episodes are entries: each has a URL, audio, notes and a transcript, and the same entry feeds three outputs — the episode page, the podcast RSS feed (enclosure, itunes:duration, itunes:episode/season/episodeType, content:encoded from show notes) and PodcastEpisode JSON-LD — all native. Duration is read from the file (read-only) so the feed is never wrong. Chapters are a tiny inline repeater with a time pattern. Episode number is required only for full episodes (bonus and trailers aren't numbered). The transcript is either rich text or a caption file — one select reveals one field.
2188
+
2189
+ **HTML signals.**
2190
+ - An <audio> player or a Transistor/Buzzsprout/Simplecast iframe as the page's main media
2191
+ - 'Episode 42', 'S3 E42', a duration like '48:54'
2192
+ - Timestamps (12:30) linking to positions in the player
2193
+ - 'Show notes', 'Mentioned in this episode', 'Transcript' sections
2194
+ - Guest cards with headshots and links
2195
+
2196
+ **Do.** Pull the episode from the show's RSS <item> when available (enclosure → audio, itunes:* → numbering/type/duration/isExplicit, content:encoded → showNotes, podcast:chapters/podcast:transcript → chapters/transcript). From the page: '12:30 — Topic' lines → chapters; 'Transcript' section → transcript (speaker labels bolded). Guests → people entries. Don't store the embed iframe; store the audio file.
2197
+
2198
+ **Pitfalls.**
2199
+ - Episode number typed into the title ('Ep. 42 —') duplicates in apps.
2200
+ - Summary with HTML/links — some apps show raw tags.
2201
+ - Missing transcript fails accessibility and loses search traffic.
2202
+ - Changing the audio file URL after publish re-downloads in every subscriber's app — replace in place.
2203
+
2204
+ Not carried by a stored component: category, kind:entry (props).
2205
+
2206
+ ## Company & people
2207
+
2208
+ ### Team grid (`team-grid`)
2209
+
2210
+ Tags: references, dynamic-query, conditional, variants, a11y, validation. Proves: Everyone by query or hand-picked people · department filter tabs · card fields as toggles · optional 'We're hiring' card
2211
+
2212
+ **Why this shape.** People are a collection (name, slug, role, department, location, photo with alt, pronouns, short bio, socials, start/end dates) because the same person is an author, an event speaker and a team-page face. The grid is either everyone (a query: departments, hide alumni, order) or a hand-picked, ordered list of references — the mode select shows one or the other. Card fields and photo treatment are design. The hiring card is three fields behind one toggle.
2213
+
2214
+ **HTML signals.**
2215
+ - A grid of square/portrait headshots with a name and a smaller role line under each
2216
+ - Department tabs or headings (Engineering, Design, Operations)
2217
+ - Hover states revealing a bio, fun fact or LinkedIn icon
2218
+ - A final card that's not a person: 'We're hiring' / 'Join us'
2219
+
2220
+ **Do.** Each face → a People entry (name, role, photo with alt 'Portrait of {name}', department from the enclosing heading/tab, socials from icons). If the grid shows everyone in the company, use source 'query' with the department order; if it's a curated subset (e.g. 'Meet the founders'), use 'pick' in DOM order. The non-person card → hiring fields.
2221
+
2222
+ **Pitfalls.**
2223
+ - Retyping people per page — a new photo must update everywhere.
2224
+ - Portraits with empty alt; decorative hover images need alt="" on the duplicate.
2225
+ - Former employees lingering — set end dates, keep excludeAlumni on.
2226
+ - Department tabs with one person each look broken — hide tabs under ~12 people.
2227
+
2228
+ Not carried by a stored component: category, variants.
2229
+
2230
+ ### Leadership with bios (`leadership-bios`)
2231
+
2232
+ Tags: references, conditional, rich-text, repeater, variants, a11y. Proves: Groups (Leadership, Board, Advisors) → person references with per-placement title and bio overrides · bio in modal, drawer or inline
2233
+
2234
+ **Why this shape.** Leaders are People entries, so their photo and bio are shared with the team grid, press kit and event pages. The section is groups → member objects; each member is a reference plus per-placement overrides (title here, affiliation for outside board members, an optional page-specific bio behind a toggle). The same person can sit in two groups (CEO and board chair) without duplicating them. Where the bio opens is a design choice; dialogs and drawers must restore focus (a11y).
2235
+
2236
+ **HTML signals.**
2237
+ - Larger portraits than a team grid with name + C-level title, often in a separate band
2238
+ - 'Read bio' buttons opening a modal/dialog (role=dialog, aria-modal)
2239
+ - Sub-headings 'Leadership', 'Board of directors', 'Advisors', 'Investors'
2240
+ - Board members listed with their firm ('Partner, Accel')
2241
+
2242
+ **Do.** Create/match People entries for each person (photo, name, title, bio from the modal content). Sub-headings → groups. If the modal bio differs from the person's main bio (or no person page exists), put it on the member with ownBio true. Board titles that differ from the job title → titleOverride; firm names → affiliation. bioOpens from the interaction (modal → dialog, accordion → inline).
2243
+
2244
+ **Pitfalls.**
2245
+ - Duplicating the CEO as a second person for the board group — reference twice instead.
2246
+ - Modal bios that don't trap and return focus.
2247
+ - Bios in first person on one page and third on another — the override exists for this.
2248
+ - Investor logos mixed into this section — they belong in a logo wall.
2249
+
2250
+ Not carried by a stored component: category, variants.
2251
+
2252
+ ### Values & culture (`values-culture`)
2253
+
2254
+ Tags: repeater, media-heavy, variants, validation, a11y. Proves: Values with a real example each · perks as a light repeater · culture photo wall with required alt
2255
+
2256
+ **Why this shape.** Values are a short repeater of title → meaning → a real example (the field that separates a credible values page from a poster). Perks are a light inline repeater (icon, label, detail). The photo wall is an array of images with alt required, laid out as a grid. Layout and background are variants/design. Nothing here is shared, so it stays a section — no collection needed.
2257
+
2258
+ **HTML signals.**
2259
+ - 3–8 short headed blocks with an icon or number ('01', '02') and a paragraph each
2260
+ - Headings like 'Our values', 'What we believe', 'How we work'
2261
+ - A grid of benefit icons with one-line labels (perks)
2262
+ - A collage of candid team photos
2263
+
2264
+ **Do.** Each headed block → a value (icon from its SVG name if recognizable, else leave empty). A second sentence that tells a concrete story → example. Benefits list → perks. Team photo collage → photos (write alt from context; never 'team photo'). Numbered styling → variant 'numbered'.
2265
+
2266
+ **Pitfalls.**
2267
+ - Photos without alt (or alt='team') — describe who's doing what.
2268
+ - Perks with prices or policies that change — keep them short and dated in HR, not here.
2269
+ - Nine-value lists — validation caps at eight; past five, nobody remembers them.
2270
+
2271
+ Not carried by a stored component: ai, category, item settings, variants.
2272
+
2273
+ ### Careers list (`careers-list`)
2274
+
2275
+ Tags: dynamic-query, conditional, references, variants, embed. Proves: Open roles from the Jobs collection or a hiring system (Greenhouse, Lever, Ashby) · grouped by department or location · filters
2276
+
2277
+ **Why this shape.** Roles are entries (each with its own page and JobPosting data), so the list is a query — or, for companies already on an ATS, a synced source whose provider and board are developer-locked. One select decides which settings show. Grouping and the filter set are editorial; filters hide themselves below six roles because three dropdowns over two roles reads as broken. The empty state is mandatory content: most of the year a small company has no roles.
2278
+
2279
+ **HTML signals.**
2280
+ - Department headings with rows of job titles, each with location and 'Remote'/'Hybrid' tags
2281
+ - Links to boards.greenhouse.io, jobs.lever.co, jobs.ashbyhq.com, or an embedded board iframe
2282
+ - Filter dropdowns for Department / Location / Team
2283
+ - 'No open positions' message with a general application link
2284
+
2285
+ **Do.** If job links go to an ATS host, set source 'ats', provider from the host and board from the path; don't import jobs (they sync). If jobs have pages on the site, import each as a Jobs entry (see job-posting) and use source 'cms'. Department headings → groupBy 'department'; location headings → 'location'. Copy the empty-state text if visible.
2286
+
2287
+ **Pitfalls.**
2288
+ - Copying job rows into the section — they're stale the day a role closes.
2289
+ - Closed roles still listed — the query hard-filters on status.
2290
+ - ATS iframes are unstyled and invisible to search — prefer the sync.
2291
+ - Salary on the row but not on the job page (or vice versa) — one source: the job entry.
2292
+
2293
+ Not carried by a stored component: category, variants.
2294
+
2295
+ ### Job posting (`job-posting`)
2296
+
2297
+ Tags: seo-structured, conditional, references, repeater, validation, form. Proves: Workplace reveals remote regions or office days · salary range with currency and period · apply by form, link or email · JobPosting JSON-LD
2298
+
2299
+ **Why this shape.** A job is an entry because it needs its own URL, it's listed by the careers query, and it emits JobPosting JSON-LD natively (title, description from summary + about, datePosted from publish, validThrough, employmentType, jobLocation from location references or applicantLocationRequirements + jobLocationType TELECOMMUTE for remote, baseSalary from the range). The select values are the schema.org enums on purpose. Workplace reveals exactly one of locations / office days / remote regions; the apply method reveals exactly one of form / URL / email. Responsibilities and requirements are arrays of strings, not one rich-text blob, so the template can count and style them.
2300
+
2301
+ **HTML signals.**
2302
+ - Headings 'About the role', 'What you'll do', 'What you bring', 'Nice to have', 'Benefits'
2303
+ - A meta strip: Department · Location · Full-time · Remote/Hybrid
2304
+ - A salary range like '$52,000 – $61,000 a year' (pay transparency)
2305
+ - An 'Apply' button to an ATS or an embedded application form
2306
+ - JobPosting JSON-LD in the source
2307
+
2308
+ **Do.** Use the page's JobPosting JSON-LD when present (map employmentType, baseSalary.value.minValue/maxValue/unitText, jobLocationType). Bullet lists under 'What you'll do' → responsibilities, 'What you bring/Requirements' → requirements, 'Nice to have/Bonus' → bonusPoints (one string per <li>). Benefit lists are company-wide → turn on showBenefits and import them once into the shared block. Apply button host → applyMethod url; mailto: → email; on-page form → create a form and reference it.
2309
+
2310
+ **Pitfalls.**
2311
+ - salaryMax lower than salaryMin — cross-field validation isn't in the schema language yet; the renderer must refuse it.
2312
+ - Remote roles with a physical location set confuse Google — the conditional hides it.
2313
+ - Closed roles deleted instead of closed: their URLs 404 and lose links. Close them.
2314
+ - Benefits retyped on every job drift — use the shared block.
2315
+
2316
+ Not carried by a stored component: category, item settings, kind:entry (props).
2317
+
2318
+ ### About & mission with timeline (`about-timeline`)
2319
+
2320
+ Tags: repeater, media-heavy, rich-text, references, variants, validation. Proves: Mission + story (rich text) · founders by reference · stats · dated milestones with optional photo and link · order and orientation as design
2321
+
2322
+ **Why this shape.** The about page's story is one section with three jobs: a mission sentence (plain text, required — it's quoted elsewhere), a short rich-text story, and a timeline. Founders are references to People so their portraits match the team page. Milestones are an unordered repeater sorted by year + month at render time, so inserting 2020 doesn't mean dragging. Orientation and order are variants/design, never content.
2323
+
2324
+ **HTML signals.**
2325
+ - Years as big numerals (2017, 2019…) along a vertical or horizontal line
2326
+ - Headings like 'Our story', 'Our journey', 'How it started', 'Milestones'
2327
+ - A one-sentence mission statement set large near the top
2328
+ - Founder portraits with names and 'Co-founder' roles
2329
+
2330
+ **Do.** Each year marker + caption → a milestone (year as a number, month if shown, first line → title, remainder → body, adjacent image → image). The large standalone sentence → mission; surrounding paragraphs → story. Founder photos → People references (create if missing). Number rows ('212k pans') → stats. Don't reproduce the line/dots.
2331
+
2332
+ **Pitfalls.**
2333
+ - Years stored as strings ('2017–2019') can't sort — use the start year and put the range in the title.
2334
+ - A timeline of 30 tiny events — the cap is 30; most stories need 6–10.
2335
+ - Founder bios duplicated here — reference people instead.
2336
+
2337
+ Not carried by a stored component: ai, category, variants.
2338
+
2339
+ ### Press & brand kit (`press-kit`)
2340
+
2341
+ Tags: deep-nesting, repeater, media-heavy, conditional, validation, a11y. Proves: Asset groups → assets → formats → file (4 levels) · kind reveals photo credit or logo background · boilerplate, facts, contact and coverage
2342
+
2343
+ **Why this shape.** A press page is mostly downloads, so the core is asset groups → assets → format variants → file: four drill levels, each with a clear preview (logo thumbnail, 'SVG · 4 KB'). The asset KIND reveals what that kind needs — a background for logos (so a cream logo isn't shown on white), a required credit for photos. Boilerplate is plain text with a hard limit because it's pasted into articles. Coverage is a small repeater of outlet/title/date/link. Nothing is shared elsewhere, so it stays a section; a brand with several sub-brands would promote assets to the media library with tags.
2344
+
2345
+ **HTML signals.**
2346
+ - Logo thumbnails on light and dark tiles with 'SVG' / 'PNG' / 'EPS' download links
2347
+ - A 'Download press kit (.zip)' button
2348
+ - An 'About {company}' paragraph and a 'Fast facts' list (Founded, HQ, Founders)
2349
+ - 'Press contact' with an email; 'As seen in' / 'In the press' article list
2350
+
2351
+ **Do.** Download every linked asset into the media library (keep original filenames). Group by on-page headings (Logos, Product photos, Founders). Each tile → asset: name from its caption, kind from the group/file type, preview from the tile image (alt describes it), formats from each download link's extension. Tile background dark → background 'dark'. Photo credit lines → credit. Article list → mentions (outlet from the logo alt or text).
2352
+
2353
+ **Pitfalls.**
2354
+ - Download links that point at the old CDN — re-upload, don't hotlink.
2355
+ - Logos previewed on the wrong background become invisible.
2356
+ - Photos without credits get printed uncredited — credit is required for photos.
2357
+ - A stale .zip that no longer matches the assets shown.
2358
+
2359
+ Not carried by a stored component: category, group unused (props), variants.
2360
+
2361
+ ## Events & places
2362
+
2363
+ ### Events list (`events-list`)
2364
+
2365
+ Tags: dynamic-query, conditional, variants, references, a11y. Proves: Upcoming / past split from one query · past events offer recordings · time-zone display rule · list, cards, calendar or map
2366
+
2367
+ **Why this shape.** Events are entries (each has a page, a time, a place and Event JSON-LD), so the list is a query with types/locations filters. The upcoming/past split is computed from each event's end time, never a status someone has to flip — the section only decides what to show and how many. Recordings only make sense for past events, so that toggle appears only when past events are shown. Time display is an explicit rule because 'Thursday 6pm' is ambiguous the moment an event is online.
2368
+
2369
+ **HTML signals.**
2370
+ - Rows with a big day number + short month (12 NOV), title, time and city
2371
+ - Tabs 'Upcoming' / 'Past', or 'Past events' heading further down
2372
+ - 'Register', 'Get tickets' or 'Watch recording' buttons per row
2373
+ - Embedded Eventbrite/Luma/Meetup links or widgets
2374
+
2375
+ **Do.** Import each row as an Events entry (follow the link for details — see event-detail). Place one eventsList with show 'both' if a Past section exists. If rows link to Eventbrite/Luma pages, keep the ticket URL on each event as its button link, not on the list. Filters from visible dropdowns.
2376
+
2377
+ **Pitfalls.**
2378
+ - Past events lingering as 'upcoming' because a status field was never flipped — the split is computed.
2379
+ - Times shown without a zone on online events.
2380
+ - Deleting past events loses their recordings and links — keep them, the list hides them.
2381
+ - Calendar variant with three events looks empty — use the list until there are ~8 a month.
2382
+
2383
+ Not carried by a stored component: category, variants.
2384
+
2385
+ ### Event detail (conference) (`event-detail`)
2386
+
2387
+ Tags: deep-nesting, references, seo-structured, conditional, commerce, repeater, validation. Proves: Days → sessions → speakers (references) · attendance mode reveals venue or stream · ticket tiers with sale windows · status maps to Event JSON-LD
2388
+
2389
+ **Why this shape.** An event is an entry: it has a page and emits Event JSON-LD natively (startDate/endDate with the zone, eventStatus from Status, eventAttendanceMode from Attendance, location from the venue reference or a VirtualLocation from the stream, offers from tiers with validFrom/price/availability, performer from speakers). The agenda is days → sessions → speaker references — three nested levels plus the speaker CMS items — and a session's FORMAT reveals speakers, abstract, track and level (breaks have none). Venues and speakers are collections because they recur across years. Ticket tiers are commerce data with sale windows that switch themselves off.
2390
+
2391
+ **HTML signals.**
2392
+ - An agenda table/grid with time ranges (09:00–09:45), rooms/tracks and speaker avatars
2393
+ - Day tabs (Day 1 / Day 2) or dated headings
2394
+ - Ticket cards with prices, 'Early bird ends …', 'Sold out' badges
2395
+ - A venue block with address and map; 'Watch online' or 'Livestream' mentions
2396
+ - Event JSON-LD with eventStatus / eventAttendanceMode
2397
+
2398
+ **Do.** Speakers → Speakers entries (photo, name, title, company, bio from the speaker modal). Venue → Venues entry. Agenda: each day tab → day; each row → session (start/end parsed to 24-hour HH:MM in the event's zone, format from labels like 'Keynote'/'Break'/'Lunch', speakers matched by name). Ticket cards → tiers (price as a number; 'Sold out' → availability). If tickets are sold on Tito/Eventbrite, use those URLs as each tier's button link.
2399
+
2400
+ **Pitfalls.**
2401
+ - Agenda times in the visitor's zone instead of the event's — store HH:MM + the event zone.
2402
+ - Speakers typed into session titles ('… with Mira Tanaka') instead of referenced.
2403
+ - End time before start (cross-field) — not expressible in validation yet; the renderer must refuse it.
2404
+ - Cancelled events deleted rather than marked — Google keeps showing the old date.
2405
+ - Sold-out early-bird tiers left 'On sale' after salesEnd — use the window, not the badge.
2406
+
2407
+ Not carried by a stored component: category, item settings, kind:entry (props).
2408
+
2409
+ ### Webinar registration (`webinar-registration`)
2410
+
2411
+ Tags: form, conditional, references, embed, validation, variants. Proves: One section across the lifecycle: upcoming (form) → live (stream) → on demand (recording, optionally gated) · CRM campaign locked
2412
+
2413
+ **Why this shape.** A webinar page lives through three states, and the same URL must work in each: before (time + registration form), during (embedded stream), after (recording, optionally behind the same form). One STAGE select reveals the fields each state needs, so nobody builds a second page for the replay. The form is a reference (submissions land in Forms and sync to the locked CRM campaign). Speakers are People references. Webinars with many sessions graduate to the Event entry (event-detail).
2414
+
2415
+ **HTML signals.**
2416
+ - A date/time with time zone and a registration form beside a speaker list
2417
+ - 'Register now', 'Save my seat', 'Watch on demand', 'Watch the recording'
2418
+ - HubSpot/Marketo/Pardot form embeds; Zoom/ON24/Livestorm links
2419
+ - 'What you'll learn' bullet list
2420
+
2421
+ **Do.** Stage from the page: a form + future date → upcoming; a video + 'on demand' → on-demand (isGated true if a form shows first). Rebuild the embedded HubSpot/Marketo form as a BetterCMS form with the same fields and reference it; keep the original campaign id for the developer. Bullets → takeaways. Speaker cards → People.
2422
+
2423
+ **Pitfalls.**
2424
+ - A second '/webinar-replay' page splits links and SEO — switch the stage instead.
2425
+ - Times without a zone ('Thursday 10am').
2426
+ - Leaving stage 'live' after the event — the page shows a dead stream.
2427
+ - Gating on-demand recordings of public talks lowers reach; gate deliberately.
2428
+
2429
+ Not carried by a stored component: ai, category, group unused (props), item settings, variants.
2430
+
2431
+ ### Locations directory (`locations-directory`)
2432
+
2433
+ Tags: dynamic-query, conditional, repeater, deep-nesting, validation, a11y, responsive-layout. Proves: Map + list from a Locations query or written here · per-day hours array (isClosed reveals nothing) · services filter · near-me search
2434
+
2435
+ **Why this shape.** Each location is a pin, an address, a phone, per-day hours and services. Small brands write them right in the section; once a location needs its own page (location-page) or appears in events and job posts, the source flips to the Locations collection with the same item shape. Hours are an array of day objects where `isClosed` reveals or hides open/close times — the item shape both the directory and LocalBusiness openingHoursSpecification need. Map behavior (view, zoom, clustering) is its own group; phone layout is design.
2436
+
2437
+ **HTML signals.**
2438
+ - A map (Google Maps / Mapbox / Leaflet container) beside a scrollable list of addresses
2439
+ - 'Find a store', 'Store locator', 'Stockists', 'Locations' headings; a ZIP/city search box
2440
+ - Hours tables Mon–Sun, 'Closed' on some days, 'Open now' badges
2441
+ - data-lat / data-lng attributes or a locations JSON blob in a <script>
2442
+ - 'Get directions' links to maps.google.com
2443
+
2444
+ **Do.** Look for the locations JSON the map is fed (script tag or XHR) — it usually has lat/lng, hours and services; prefer it over scraping. Each location → an item (or a Locations entry if it has its own page; then use source 'collection'). Parse hours into seven day objects: 'Mon–Fri 10–6' expands to five days with 10:00/18:00; 'Closed' → isClosed true. Pin = lat/lng from data attributes or geocode the address.
2445
+
2446
+ **Pitfalls.**
2447
+ - closed must default to false or the time fields stay hidden on new rows.
2448
+ - Hours as one text line ('Tue–Sat 10–6') can't drive 'Open now' or JSON-LD.
2449
+ - Overnight hours (18:00–02:00): closes earlier than opens means next day — say so in help, handle in the renderer.
2450
+ - Pins at the geocoded centroid of a mall instead of the door.
2451
+ - A map without a list fails keyboard and screen-reader users — the list is the primary UI.
2452
+
2453
+ Not carried by a stored component: category, variants.
2454
+
2455
+ ### Restaurant menu (`restaurant-menu`)
2456
+
2457
+ Tags: deep-nesting, repeater, commerce, conditional, variants, localized, validation, seo-structured. Proves: Menus → sections → items → price variants (4 levels) · fixed, variant or market price · dietary and allergen tags · 86'd items gray out
2458
+
2459
+ **Why this shape.** A menu is the canonical deep repeater: menus → sections → dishes → size variants. Price is a MODE (one price, sizes, or market price) so 'MP' never sits in a number field and sizes never get crammed into a description. Dietary and allergen values are fixed option lists (legend + filter + Menu JSON-LD suitableForDiet). 'Available today' grays a dish out instead of deleting it, so a sold-out special comes back tomorrow with one tap. It stays a section shared to restaurants by reference; a group with many locations and shared dishes would promote dishes to a collection.
2460
+
2461
+ **HTML signals.**
2462
+ - Section headings (Antipasti, Mains, Wine) with dish name + price on the same line, dotted leaders
2463
+ - Small letter codes after names (V, VG, GF) and a key at the bottom
2464
+ - Two prices per row with labels ('Glass / Bottle', 'Small / Large')
2465
+ - 'MP' or 'Market price' instead of a number
2466
+ - Tabs or anchors for Lunch / Dinner / Drinks; a 'Download PDF menu' link
2467
+
2468
+ **Do.** Tabs/anchors → menus (copy service hours if shown). Headings → sections. Each row → dish: name, description (the smaller line), price (strip currency symbols; 'MP' → priceType market; two labeled prices → variants), dietary from the letter codes via the page's key, spice from chilli icons. A PDF-only menu: extract its text the same way and attach the PDF. Never keep prices inside descriptions.
2469
+
2470
+ **Pitfalls.**
2471
+ - '$18 / $32' in one field — sizes must be variants.
2472
+ - Allergen data that disagrees with the kitchen sheet is a safety issue — owners must sign off.
2473
+ - Deleting sold-out dishes loses them; use available.
2474
+ - Prices typed with symbols ('$18') break sorting and JSON-LD.
2475
+ - Four nesting levels: keep sizes inline (openMode inline) so the deepest level isn't another drill.
2476
+
2477
+ Not carried by a stored component: category, variants.
2478
+
2479
+ ### Single location page (`location-page`)
2480
+
2481
+ Tags: seo-structured, repeater, conditional, references, media-heavy, a11y, validation, deep-nesting. Proves: Address object + pin · per-day hours with split shifts · holiday overrides · booking provider reveals its own fields · Restaurant JSON-LD
2482
+
2483
+ **Why this shape.** Each restaurant is an entry because it has its own page and emits Restaurant JSON-LD natively: address (PostalAddress from the address fields), geo, telephone, priceRange, servesCuisine, hasMenu (menu references), acceptsReservations (from the provider), and openingHoursSpecification from the hours array — with special dates mapped to specialOpeningHoursSpecification. Hours are days → shifts, so lunch + dinner and 'closed Mondays' are both data, and a day's shifts appear only when it isn't closed. The booking provider reveals exactly the field it needs (a venue ID for Resy/OpenTable/Tock, a tel: button for phone). Address parts are separate fields because search engines and maps need them separately.
2484
+
2485
+ **HTML signals.**
2486
+ - An address block, phone link (tel:), and a map or 'Get directions' link
2487
+ - An hours table with day names and one or two time ranges per day; 'Closed' on some days
2488
+ - A Resy/OpenTable/Tock widget or button (resy.com, opentable.com, exploretock.com URLs)
2489
+ - 'Holiday hours' notices; 'Private dining' section with a form or PDF
2490
+ - LocalBusiness / Restaurant JSON-LD in the source
2491
+
2492
+ **Do.** Prefer the page's Restaurant JSON-LD for address, geo, phone, priceRange and openingHoursSpecification. Expand compressed hours ('Tue–Thu 5–10pm' → three days, one shift each; 'Fri–Sat 11:30–2:30, 5–11' → two shifts). Holiday notices → specialHours. Booking widget host → bookingProvider and venue ID from its URL/params. Menus → Menus entries/sections (see restaurant-menu) referenced here. Photos → gallery with alt.
2493
+
2494
+ **Pitfalls.**
2495
+ - Overnight closes (01:00) read as earlier than opens — mean next day; the renderer must handle it.
2496
+ - Hours duplicated on Google Business Profile drift — this entry should be the source you sync from.
2497
+ - Special hours never cleaned up — past dates hide themselves but stay as history.
2498
+ - A booking widget without a phone fallback excludes people who can't use it.
2499
+ - closed defaults to false or new days hide their shifts.
2500
+
2501
+ Not carried by a stored component: category, kind:entry (props).
2502
+
2503
+ ## Data & interactive
2504
+
2505
+ ### Spec table (`data-table-specs`)
2506
+
2507
+ Tags: repeater, conditional, validation, a11y. Proves: Typed columns · rows from manual / CSV / collection · default sort · caption + row headers for screen readers
2508
+
2509
+ **Why this shape.** Columns are DATA, not schema: an editor adds a 'Capacity' column without a developer, so the rows live in ONE `table` field (CPO D42): typed, keyed columns (Number in lb, Money in USD, Yes / no with its words), rows whose cells are keyed by column key (reorder-safe), the first column as row headers, caption + notes + footnotes, and design (cards on phones, sticky header). Source is a segmented select so typed rows, a CSV file and a collection are mutually exclusive branches. Sorting is a per-column flag; the filter box is `table.filterable`.
2510
+
2511
+ **HTML signals.**
2512
+ - <table> with <thead><th scope="col"> and more than 2 rows — or a CSS grid with role="table" / role="row"
2513
+ - Header cells with sort affordances: aria-sort, buttons inside <th>, ▲▼ icons
2514
+ - Numeric columns right-aligned or with units (in, lb, $, %) in every cell of a column
2515
+ - A <caption> or a heading directly above the table
2516
+ - Under 640px rows turn into stacked cards with label: value pairs (data-label attributes)
2517
+
2518
+ **Do.** Read the <table> with the table detector (docs/TABLES.md): <caption> (or the nearest heading) → `table.caption` (write one if missing — never empty); <thead> th → columns in order with stable `_key`s (camel-cased header text); units stripped into `unit` ('Weight (lb)' → Number, unit lb); per-column type from every body cell (numbers → Number, currency symbol → Money + code, ✓/✗/Yes/No → Yes / no, ★ or x/5 → Rating, <a> in every cell → Link); a first column of th[scope=row] (or an empty top-left header) → `headerColumn: true`; footnote marks (sup, *) → `footnotes` + the cell's `note`. More than 60 rows or a feed → `source: csv` and emit the CSV. Zebra striping and card layouts are design (`design.striped`, `design.responsive`), not fields.
2519
+
2520
+ **Pitfalls.**
2521
+ - RESOLVED (D42): the `table` type types cells by their column (a cell may carry its own type), keys cells by column `_key`, and edits as a grid with paste from Sheets / Excel / CSV — no more drill-in per cell.
2522
+ - SCHEMA GAP: `defaultSort.column` must match a column `_key` of `table`; nothing checks it yet (gap 3, options from a sibling field).
2523
+ - Past ~60 rows, keep the data in CSV or a collection; the grid stays usable but the page gets heavy.
2524
+ - Do not model 'striped' or 'compact' as booleans — they are variants.
2525
+
2526
+ Not carried by a stored component: ai, category, document preview path, variants.
2527
+
2528
+ ### Table (`table-comparison`)
2529
+
2530
+ Tags: validation, a11y, seo-structured, references, commerce. Proves: The `table` type (D42): caption · row headers · typed columns (Number, Money, Yes / no, Rating, Reference) · row groups · per-row and per-cell types · footnotes · notes · ItemList JSON-LD
2531
+
2532
+ **Why this shape.** The reference entry for the `table` type (CPO D42). The section is a heading + intro + ONE `table` field: columns are typed and keyed (Number in kg, Money in USD, Yes / no with its own words, Rating, a Reference column that links each row to its product), cells are keyed by column so a reorder never misaligns, rows sit in row groups, the first column is the row header. One odd value ('Heats with the fire') is a cell with its own type; the highlighted row is a flag, not a color typed into content. ItemList JSON-LD is opted in because the table IS the comparison on the page.
2533
+
2534
+ **HTML signals.**
2535
+ - <table> with <caption>, <thead><th scope=col>, body <th scope=row>
2536
+ - Units repeated down a column (kg, min, $) and ✓/– icons with hidden words
2537
+ - Full-width rows (colspan) or several <tbody> acting as group headings
2538
+ - Footnote marks (sup, *) tied to a numbered list under the table
2539
+
2540
+ **Do.** Run the table detector (docs/brain/tables.md): caption from <caption> or the heading above (set design.hideCaption when the heading says it); header cells → columns with keys and types inferred from every body cell; first-column th → headerColumn; colspan / tbody headings → rowGroups; ✓/✗ → Yes / no; links to known entries → Reference; footnote marks → footnotes + cell.note; text under the table → notes. JSON-LD only when the table is the page's offer.
2541
+
2542
+ **Pitfalls.**
2543
+ - Never merge body cells; repeat the value.
2544
+ - A blank cell in a yes/no column is 'Not included'; a blank cell anywhere else is 'No value'.
2545
+ - Don't retype units in cells — the column carries them.
2546
+
2547
+ Not carried by a stored component: ai, category, document preview path.
2548
+
2549
+ ### Chart with annotations (`chart-attrition`)
2550
+
2551
+ Tags: repeater, conditional, a11y, validation. Proves: Chart type → series → data points (3 levels) · annotations · text alternative required · data from CSV or URL
2552
+
2553
+ **Why this shape.** A chart is three levels of data (chart → series → points) plus presentation (axes, annotations) — so data and display are separate groups and points open inline (two primitives each) to avoid a drill per number. The written takeaway is REQUIRED because the drawing is inaccessible; the optional data table is the second fallback. Annotations reference a point by its label, not by index, so reordering points never moves a note.
2554
+
2555
+ **HTML signals.**
2556
+ - <svg> with many <path>/<rect> elements and axis tick <text> nodes, or a <canvas> with a chart library global (Chart.js, Recharts, Highcharts, ECharts)
2557
+ - A legend list of colored swatches + series names
2558
+ - Inline JSON in a <script> (data-chart, __NEXT_DATA__ series) or a data-src attribute pointing at a .csv/.json
2559
+ - Figure + figcaption with 'Source:' text
2560
+
2561
+ **Do.** Never rasterise the chart. Recover the data first: (1) inline JSON / data attributes / library config objects; (2) the hidden accessible table many chart libraries render; (3) only as a last resort read axis ticks + bar heights and mark the entry for human review. Map each dataset to a `series` item and each label/value pair to a `point`. If there are more than 24 points per series or the data comes from a URL, use `source: csv|url` and emit the file. Write `summary` from the figcaption or by stating the trend in one sentence — it is required. Map 'Source: …' to sourceLabel/sourceLink. Library-specific styling (tooltips, gridlines, animation) stays in code; only color per series is content.
2562
+
2563
+ **Pitfalls.**
2564
+ - SCHEMA GAP: no grid/table editor for numeric data — 2 series × 12 points is 24 inline rows. A `table` or `dataset` field type (paste from a spreadsheet) is needed.
2565
+ - SCHEMA GAP: `annotation.at` must equal an existing point label (cross-array reference) — the language can't express 'one of the x values in series', so it is a free string.
2566
+ - SCHEMA GAP: no constraint that donut charts have exactly one series with 2–5 points — validation is per field, never conditional on another field's value.
2567
+ - Color picker accepts any hex; the series palette should be restricted to brand chart tokens for contrast (see section-theme-tokens).
2568
+ - Bar charts with a non-zero `yMin` mislead; the help text warns but nothing prevents it.
2569
+
2570
+ Not carried by a stored component: ai, category, variants.
2571
+
2572
+ ### ROI calculator (`roi-calculator`)
2573
+
2574
+ Tags: form, repeater, conditional, validation. Proves: Typed inputs (slider · number · select) → formula outputs · conditional control settings · locked formulas
2575
+
2576
+ **Why this shape.** A calculator is a tiny program: named inputs, formulas over those names, formatted outputs. Inputs and outputs are arrays with a stable `key` so formulas survive relabelling and reordering. Control-specific settings (range for sliders/numbers, choices for select) are conditional on `control`, so an editor never sees a 'Step' for a dropdown. Formulas are developer-locked: marketers can change copy and defaults, not maths. Email capture is a reference to a real form, never a second hidden form model.
2577
+
2578
+ **HTML signals.**
2579
+ - <input type="range"> / <input type="number"> / <select> with JS listeners and no form submit
2580
+ - Output elements (<output>, aria-live regions) whose text changes on input
2581
+ - Inline JS with arithmetic on input values (e.g. value * 0.72) or a data-formula attribute
2582
+ - 'How we calculate' / 'Assumptions' disclosure link near the result
2583
+
2584
+ **Do.** Each interactive control → an `input`: label from <label>, key from the input's name/id camel-cased, control from the element type (range → slider), min/max/step/value from attributes, <option>s → choices with numeric values. Read the bundled JS for the arithmetic: each computed output → an `output` with its formula rewritten in terms of input keys (constants inline, e.g. 0.72), format from the rendered text ($ → currency, % → percent). If the formula calls functions, lookup tables or tiered pricing you can't express as arithmetic, keep the calculator as a code component and expose ONLY copy fields — flag it, never approximate a formula. Assumptions text → richtext; a submit/email step → `allowEmailResults` + reference to a created form.
2585
+
2586
+ **Pitfalls.**
2587
+ - SCHEMA GAP: no formula/expression field type. `code` stores a string; nothing parses it, checks that it only references existing input/output keys, or detects cycles (costSaved → perEmployee → costSaved).
2588
+ - SCHEMA GAP: `unique` on input/output keys is not enforced, and keys must be unique ACROSS two arrays (inputs and outputs share one namespace) — no cross-array uniqueness exists.
2589
+ - SCHEMA GAP: cross-field numeric rules (min < defaultValue < max, step divides the range) can't be expressed; each number validates alone.
2590
+ - SCHEMA GAP: `readOnly: "developer"` is the only field-level permission — no 'finance can edit formulas' role scope.
2591
+ - A select's choices need numeric values; a text-only dropdown ("Small team") must be given a number or it breaks the maths.
2592
+
2593
+ Not carried by a stored component: ai, category, variants.
2594
+
2595
+ ### Product configurator (`product-configurator`)
2596
+
2597
+ Tags: commerce, deep-nesting, repeater, conditional, references, validation. Proves: Steps → options → availability rules (4 levels) · price deltas · single vs multi pick · Shopify variant references
2598
+
2599
+ **Why this shape.** Steps → options is a two-level repeater; each option carries its own price delta, image and Shopify product so the cart can be built without a lookup table. Dependencies ('griddle needs the XL grate') live ON the dependent option as an availability object — four levels deep (Configurator › Step › Option › Availability) — referencing another pick by 'stepKey.optionKey' so rules survive reordering. Inventory and base price come from Shopify (reference), never retyped.
2600
+
2601
+ **HTML signals.**
2602
+ - Numbered steps (Step 1 of 3) with radio groups or toggle cards per step
2603
+ - Option labels with '+$60' / '−$10' price adjustments
2604
+ - A running total / sticky summary that updates on change
2605
+ - Options disabled with notes like 'Requires XL' or 'Sold out' (aria-disabled, data-requires)
2606
+ - Shopify variant ids in data attributes or a cart/add.js form
2607
+
2608
+ **Do.** Each step container (fieldset/legend or numbered heading) → a `step`; radio groups → selection single, checkbox groups → multiple + maxPicks from any limit text. Each option → label, detail, image (the swatch color if it's a color chip), priceDelta parsed from '+$60' (signed number, no currency symbol), variant from data-variant-id/gid. Detect dependencies from disabled-state logic or text ('Fits the XL grate only') and write `availability.rule` as stepKey.optionKey using the keys you generated — never by index. If rules involve more than one other pick (A and B, A or B), or quantities, keep the logic in code and only model the copy, and add a pitfall note. Depth check: if options have their own sub-options (a third level of choice), split each step into its own block/component rather than nesting a fifth level.
2609
+
2610
+ **Pitfalls.**
2611
+ - SCHEMA GAP: conditions can only read SIBLING fields — an option's `swatch` should show only when its PARENT step's `display` is swatches, which can't be expressed, so the field is always visible.
2612
+ - SCHEMA GAP: availability.rule is a string pattern, not a reference to a real step/option; renaming a key breaks rules silently. Needs an same-entry reference type ('pick a sibling item').
2613
+ - SCHEMA GAP: no boolean logic in rules (requires A AND B, excludes any of …) and no cross-step validation (two defaults in a single-pick step, `maxPicks` < number of defaults).
2614
+ - SCHEMA GAP: option keys need to be unique within their step only (scoped uniqueness) — `unique` is neither scoped nor enforced.
2615
+ - Price deltas drift from Shopify variant prices; prefer reading the variant price and keeping priceDelta only for non-Shopify add-ons.
2616
+
2617
+ Not carried by a stored component: ai, category, reference target, variants.
2618
+
2619
+ ### Map with markers (`stockist-map`)
2620
+
2621
+ Tags: repeater, conditional, embed, a11y, references, seo-structured. Proves: Categories → markers → popup → button · geopoints · manual or collection source · list fallback for keyboard users
2622
+
2623
+ **Why this shape.** Categories are an array (editors add 'Pop-ups' without a developer) and drive both filter chips and pin styling; each marker stores a geopoint, a readable address (the list fallback reads it, not the map) and a popup object that drills in to a button — Map › Place › Popup › Button. A collection source exists because stockist lists outgrow a block fast. Provider is developer-locked because it depends on an API key in the environment; consent is a content decision.
2624
+
2625
+ **HTML signals.**
2626
+ - A Leaflet/MapLibre/Mapbox/Google Maps container (div.leaflet-container, .mapboxgl-map, iframe to maps.google.com/embed)
2627
+ - Marker data in JSON (data-markers, window.locations) or repeated <li> with address + lat/lng data attributes
2628
+ - Filter chips above the map toggling categories
2629
+ - Popups with an image, a line of text and 'Directions' links
2630
+ - LocalBusiness / Store JSON-LD per location
2631
+
2632
+ **Do.** Read marker data from the JSON the map is initialised with (or the visible list); each → `marker` with geopoint {lat,lng,label}. Derive `categories` from distinct marker types/filters; set each marker's `category` to the generated key. Popup HTML → popup.image/body/button. If there are more than 50 places or the list is a CMS feed, create a Locations collection and set `source: collection` — each location becomes an entry (it has its own page, hours and JSON-LD). Never extract the provider API key; set `provider` only. A static map image with no interactivity is NOT this entry — model it as an image.
2633
+
2634
+ **Pitfalls.**
2635
+ - SCHEMA GAP: a marker's category should be a select whose options come from the sibling `categories` array (dynamic options); it is a patterned string with no integrity check.
2636
+ - SCHEMA GAP: opening hours want a structured type (day-of-week ranges, exceptions/holidays) to emit LocalBusiness openingHoursSpecification; strings can't be validated against real times.
2637
+ - SCHEMA GAP: no address type (street, locality, region, postcode, country) — needed for JSON-LD PostalAddress and geocoding; a multi-line text loses structure.
2638
+ - Turning off `showList` leaves keyboard and screen-reader users with no way to reach the places.
2639
+
2640
+ Not carried by a stored component: ai, category, variants.
2641
+
2642
+ ### Product comparison slider (`product-compare-slider`)
2643
+
2644
+ Tags: references, repeater, conditional, validation, commerce, responsive-layout. Proves: References to products · attributes pulled from referenced fields or typed per product · swipe on phones
2645
+
2646
+ **Why this shape.** Comparison values belong to the PRODUCTS, not the section — so rows default to 'pull this field from each referenced product' and only rows that aren't product data ('Best for') are typed here, keyed by product reference. That keeps prices and weights in one place. Products are an array of references with min 2 / max 4 and uniqueness; the phone behavior is an explicit choice because a 4-column table is unreadable at 390px.
2647
+
2648
+ **HTML signals.**
2649
+ - A table or CSS grid whose column headers are product cards (image, name, price, button)
2650
+ - Row labels for specs (Weight, Capacity, Warranty) repeated across products
2651
+ - Horizontal scroll-snap on mobile (overflow-x: auto; scroll-snap-type: x) or 'swipe to compare' hints
2652
+ - Ticks/crosses or 'Best value' highlighting in cells
2653
+
2654
+ **Do.** Resolve each column header to an existing product entry (match by URL/handle/SKU) and add it to `products`; if a product doesn't exist, create it first — never type product names into the section. For each row: if the value matches a field on the product model (weight, price…), emit `source: field` with that field; otherwise `source: custom` with one value per product reference. Ticks → 'Yes'/'No' values or a boolean product field. Scroll-snap / swipe UI → `mobile: swipe`. If the page compares more than 4 products or more than 14 rows, it's a spec database: build a Products-table entry (data-table with collection source) instead.
2655
+
2656
+ **Pitfalls.**
2657
+ - SCHEMA GAP: `field` options should be generated from the referenced collection's fields (dynamic options); here they're a hard-coded list that drifts when the Products model changes.
2658
+ - SCHEMA GAP: `recommended` must be one of `products` (constrained reference) — not expressible.
2659
+ - SCHEMA GAP: array `unique: true` (no duplicate product) is declared but not enforced by the validator.
2660
+ - SCHEMA GAP: custom `values` should have exactly one entry per product (a matrix keyed by another array); adding a fourth product leaves rows with three values and nothing warns.
2661
+
2662
+ Not carried by a stored component: ai, category, variants.
2663
+
2664
+ ### Countdown and live counter (`countdown-live-counter`)
2665
+
2666
+ Tags: conditional, validation, localized, a11y. Proves: Datetime + time zone · what happens after zero (conditional) · live counter from a static rate or an API · aria-live
2667
+
2668
+ **Why this shape.** The timer is a datetime plus an explicit time zone (a launch is 9am Pacific for everyone), and the zero moment is a real content state: the after-launch heading and button are conditional fields so the section never shows 00:00:00 for a week. The live counter is a drill-in object with its own nested conditionals (enabled → source → rate fields or endpoint), which is the pattern for any self-contained sub-feature. Unit labels are localized strings because 'hrs' is copy.
2669
+
2670
+ **HTML signals.**
2671
+ - Digits grouped as dd:hh:mm:ss updated by setInterval, often with data-countdown / data-end attributes holding an ISO date
2672
+ - Labels like 'days hrs min sec' under the digits
2673
+ - 'X people waiting' / 'Y sold' numbers polling an endpoint (fetch in a loop)
2674
+ - Launch banner copy: 'Drops Friday', 'Ends in'
2675
+
2676
+ **Do.** Read the target from data attributes or the JS (new Date('…')); if it has no offset, ask for or default the brand's zone and set `timezone` — never store a local time without one. Units shown → `units`; their labels → `labels`. Find what the script does at zero (hide/replace text) and map to `afterEnd`, extracting the replacement copy. A polling number → counter.source api (copy the endpoint, developer-locked); a number computed from a start + rate → source rate. If the counter is fabricated (random increments with no data), do not migrate it — flag it.
2677
+
2678
+ **Pitfalls.**
2679
+ - SCHEMA GAP: datetime has no time zone component; the zone is a separate select and nothing forces them to be read together (and DST is the renderer's job).
2680
+ - SCHEMA GAP: conditions don't chain — `startValue` is shown when source = rate even if the parent `showLiveCounter` toggle is off, because a field can only test one sibling. Needs `hidden` with AND/OR or nested condition groups.
2681
+ - SCHEMA GAP: no 'scheduled content' primitive — after-launch copy should be a scheduled change (release) rather than a second set of fields.
2682
+ - Countdowns that reset per visitor are dark patterns (and illegal under EU UCPD guidance); the schema has no per-visitor mode on purpose.
2683
+
2684
+ Not carried by a stored component: ai, category, variants.
2685
+
2686
+ ### Poll (`reader-poll`)
2687
+
2688
+ Tags: form, conditional, repeater, validation, references. Proves: Question type switches options vs scale vs open answer · results visibility · close date · one vote per reader
2689
+
2690
+ **Why this shape.** The answer type drives everything below it, so options, scale and the open-answer settings are mutually exclusive conditional branches of one `type` select. Rules (results visibility, close date, eligibility) are a separate tab because they change behavior, not copy. Votes are SUBMISSIONS — runtime data in Forms, never stored on the block — so the schema has no results fields at all.
2691
+
2692
+ **HTML signals.**
2693
+ - A <form> with one question and radio/checkbox inputs, submitting via fetch, often with 'Vote' as the button
2694
+ - Results rendered as horizontal percentage bars after submit
2695
+ - 'Poll closes …' text or a disabled state with 'Final results'
2696
+ - Third-party poll scripts (Crowdsignal, Typeform embed) — map those to the embed entry instead
2697
+
2698
+ **Do.** Question text → `question`; radio inputs → type single, checkboxes → multiple (+ maxPicks if a limit is stated), a range/star/NPS row → scale with min/max and end labels, textarea → open. Each option label (+ image) → `pollOption`. Never import vote counts. If the page has several questions in sequence, it's a SURVEY: create a form (Forms) with those fields and place a form block — do not nest questions in this poll block.
2699
+
2700
+ **Pitfalls.**
2701
+ - SCHEMA GAP: cross-field rules (scale.min < scale.max, maxPicks ≤ number of options, closesAt in the future) can't be written as validation.
2702
+ - SCHEMA GAP: 'images on every option or none' is a whole-array rule; validation is per item.
2703
+ - SCHEMA GAP: no read-only, platform-computed fields — editors can't see live tallies inside the schema form; results live in another surface.
2704
+ - Multiple-question surveys modelled as nested arrays in a block become an unversioned form builder — use Forms.
2705
+
2706
+ Not carried by a stored component: ai, category, variants.
2707
+
2708
+ ### Embed (iframe, HTML, script) (`safe-embed`)
2709
+
2710
+ Tags: embed, conditional, a11y, validation. Proves: Kind switches URL vs code · sandbox + permissions developer-locked · consent placeholder · required iframe title
2711
+
2712
+ **Why this shape.** Embeds are the only block that runs someone else's code, so security choices (sandbox, permissions, raw HTML, script URLs) are a developer-locked tab and content choices (title, size, caption, fallback) stay editable. The kind select makes URL vs code branches exclusive. Consent is a drill-in object with its own conditionals: a block can stay compliant per embed instead of relying on a site-wide script blocker. The iframe title is required — without it the frame is unlabelled for screen readers.
2713
+
2714
+ **HTML signals.**
2715
+ - <iframe> with src on a third-party domain (stackblitz, codepen, figma, calendly, typeform, airtable, google maps)
2716
+ - <script src> to a widget host next to a placeholder <div id=…> (Calendly, HubSpot forms, Intercom)
2717
+ - <blockquote class="twitter-tweet|instagram-media"> + a platform script (oEmbed providers)
2718
+ - Consent wrappers: data-cookieconsent, data-category, class="cmplz-placeholder", 'Load content' buttons
2719
+
2720
+ **Do.** <iframe> → kind iframe: copy src (force https), title (if missing, write one from the provider + context — required), width/height → size (aspect if responsive wrapper padding-top %, else fixed px), sandbox and allow attributes → sandbox/permissions verbatim. Known oEmbed URLs (YouTube, Vimeo, Figma, CodePen, Tweets) → kind oembed with the canonical URL; video specifically → prefer the video block. Script widgets → kind script with the src; the inline config goes into `html` only if it's static. Never migrate tracking pixels, analytics tags or chat widgets as embeds — they belong to site settings (scripts). If a consent wrapper exists, map its category and text to `consent`.
2721
+
2722
+ **Pitfalls.**
2723
+ - SCHEMA GAP: no host allowlist validation — `pattern` checks https only; a real policy (allowed domains per site, CSP frame-src sync) has no schema expression.
2724
+ - SCHEMA GAP: `readOnly: "developer"` locks whole fields; there's no 'marketers can REMOVE a sandbox flag but not ADD one' (monotonic permissions).
2725
+ - SCHEMA GAP: the code field has no language setting in the schema (`html` vs `js`), so the editor guesses; see code-snippet-tabs.
2726
+ - oEmbed providers change markup; store the URL, never the provider's HTML snapshot.
2727
+ - allow-scripts + allow-same-origin on a same-origin URL removes the sandbox entirely — the help warns, nothing blocks it.
2728
+
2729
+ Not carried by a stored component: category, variants.
2730
+
2731
+ ### Code snippet with tabs (`code-snippet-tabs`)
2732
+
2733
+ Tags: repeater, validation, conditional, embed. Proves: Tabs per language/package manager · line highlights by pattern · synced tab groups · optional runnable playground
2734
+
2735
+ **Why this shape.** Each tab is one file in one language, so language and filename sit with the code; highlights are a validated range string because that's how authors think about lines. Package-manager tabs across a docs page should switch together — a sync group key, not a global setting. The playground is a conditional drill-in object: most snippets aren't runnable and shouldn't show its settings.
2736
+
2737
+ **HTML signals.**
2738
+ - <pre><code class="language-ts"> blocks (Prism, Shiki, highlight.js markup: span.token, data-line)
2739
+ - A tablist (role="tablist") switching between <pre> panels labeled npm / pnpm / yarn / bun
2740
+ - Copy buttons (navigator.clipboard) and filename headers above the code
2741
+ - 'Open in StackBlitz/CodeSandbox' buttons or forms posting to those hosts
2742
+
2743
+ **Do.** Each tab panel → `codeTab`: label from the tab button, language from the class (language-xxx / data-language), filename from the header, code from the TEXT content (strip highlighting spans, keep whitespace exactly, decode entities). Highlighted lines (data-line, .highlighted) → `highlight` as ranges. Tabs labeled with package managers → `syncGroup: package-manager`. A single <pre> with no tabs is still this block with one tab — unless it's inside a long-form article, where it belongs in the rich-text body as a code block.
2744
+
2745
+ **Pitfalls.**
2746
+ - SCHEMA GAP: the `code` field type has no `language` (or filename / highlightedLines) option in the schema; the control lets the user pick HTML/CSS/JS/Liquid only. Languages like Shell, TS, Python, Go need a schema-level `code: { language, languages[] }` setting.
2747
+ - SCHEMA GAP: tab labels should be unique within a snippet; `unique` isn't enforced.
2748
+ - Highlight ranges aren't checked against the code's line count.
2749
+ - Never store highlighted HTML (token spans) — store plain text and highlight at render.
2750
+
2751
+ Not carried by a stored component: category, variants.
2752
+
2753
+ ## Advanced patterns
2754
+
2755
+ ### Page builder, six levels deep (`page-builder-six-deep`)
2756
+
2757
+ Tags: deep-nesting, slots, repeater, variants, media-heavy. Proves: Guide › Tab › Columns › Column › Accordion › Item › Card › Button — slots inside arrays inside slots · breadcrumb folding + Pathway
2758
+
2759
+ **Why this shape.** Real page builders alternate between ARRAYS (items the section owns: tabs, columns, accordion items) and SLOTS (any block from the library: columns, accordion, text, image). Each slot is a seam where the content stops belonging to this section and becomes a reusable block with its own schema and variants; each array is where content is specific to its parent. The card is an object (always one per item), the button is the one-unit button screen. Depth is legal here because every level is a different KIND of thing with its own preview row — drill-in keeps each frame small.
2760
+
2761
+ **HTML signals.**
2762
+ - role="tablist" with role="tabpanel" siblings, each panel holding a CSS grid/flex row of columns
2763
+ - <details>/<summary> or buttons with aria-expanded inside a column
2764
+ - Expanded panels containing a card (image + heading + paragraph + link/button)
2765
+ - Repeated structural wrappers (.row > .col > .accordion > .item > .card) 5+ levels deep
2766
+
2767
+ **Do.** Measure depth by counting CONTENT boundaries, not DOM nodes: each element that repeats siblings of the same shape (tabs, columns, items) is an array level; each element whose inner structure differs from its siblings (a column holding an accordion next to a column holding an image) is a slot level. Wrappers that only add spacing/grid are design, not levels. Model arrays for same-shaped siblings and slots where siblings differ. Rules: (1) up to 6 content levels → one section, as here; (2) if any slot child is used on more than one page, extract it as a block (D17) and place it as a linked instance; (3) if depth would exceed 6, or a level has more than ~10 children, split the deepest repeated subtree into its own block (e.g. the accordion becomes a standalone FAQ block referenced from the column); (4) never make a column an array item AND a slot at the same time. Paths: emit _key on every array item and slot child so drill-in paths stay stable across reorders.
2768
+
2769
+ **Pitfalls.**
2770
+ - SCHEMA GAP: a TypeSchema can't declare its own nested block types — slot `allow` names blocks from a global registry, so a converted section that introduces Columns/Accordion must also register them (handled here via the new optional KitEntry.blocks; production needs a schema-level `blocks` or a registry write).
2771
+ - SCHEMA GAP: no per-slot depth limit — nothing stops an author nesting Columns inside Columns inside an Accordion card forever. Needs `maxDepth` or 'cannot contain itself' on slot fields.
2772
+ - SCHEMA GAP: fieldsets and groups exist only at the TypeSchema root; array item objects (Tab, Column, Item) can't group their own fields.
2773
+ - Breadcrumb at 7 drills (Guide / Seasoning / Steps and kit / Steps / Season over fire… / Heat it upside down / Card / Button) must fold into the Pathway chip on a 380px inspector — this entry is the test case.
2774
+ - Accordion item cards inside a 1/3 column are cramped; that's a variant decision, not a field.
2775
+
2776
+ Not carried by a stored component: ai, category, slot allow, variants.
2777
+
2778
+ ### Any-block module list (`polymorphic-modules`)
2779
+
2780
+ Tags: polymorphic-array, media-heavy, references, rich-text, repeater. Proves: One array mixing 8 item types (_type per item) · type picker on add · references and media inside items
2781
+
2782
+ **Why this shape.** A feature story is a sequence where ORDER is content and each item is a different shape. A polymorphic array (`of` with 8 object types, each item stamped `_type`) keeps every module typed and validated while letting the author interleave freely. These module types are local to the Feature model — they aren't reusable site blocks, so they're inline `of` types rather than a slot of library blocks. Rule of thumb: inline types when the shapes belong to one model; a slot when the blocks are shared across pages and need variants.
2783
+
2784
+ **HTML signals.**
2785
+ - <article> whose direct children alternate between prose <div>s, <figure>s, <blockquote>s, galleries and asides
2786
+ - Repeated wrapper classes with a type suffix (.module--quote, .module--gallery, data-module="stat")
2787
+ - Full-bleed figures breaking out of the text column (negative margins, width: 100vw)
2788
+ - Inline 'Read next' cards linking to another story mid-article
2789
+
2790
+ **Do.** Walk the article's top-level children in order. Classify each into one module type by its dominant element (figure+img → imageModule, multiple imgs → galleryModule, blockquote → quoteModule, iframe/video → videoModule, large numeral + label → statModule, aside/callout → calloutModule, internal story link card → relatedModule, otherwise textModule). Merge consecutive prose paragraphs into ONE textModule (never one module per <p>). Unknown shapes → textModule with the HTML preserved and a review flag. If more than 40 modules, or most modules are prose with occasional media, use a rich-text body with embedded blocks instead (see rich-text-embedded-components).
2791
+
2792
+ **Pitfalls.**
2793
+ - SCHEMA GAP: no per-type count limits inside a polymorphic array (e.g. at most one relatedModule, galleries never adjacent) — `validation` applies to the whole array.
2794
+ - SCHEMA GAP: no way to share module types between models without copying FieldSchema objects; a named, reusable object type registry (Sanity's defineType for objects) is missing for non-block types.
2795
+ - Changing an item's type (quote → callout) isn't a schema operation; the editor must delete and re-add, losing the _key.
2796
+ - A textModule per paragraph is the most common bad conversion — it explodes the list and breaks reading flow.
2797
+
2798
+ Not carried by a stored component: ai, category, kind:entry (props).
2799
+
2800
+ ### Conditional source matrix (`conditional-source-matrix`)
2801
+
2802
+ Tags: conditional, references, dynamic-query, commerce, validation. Proves: Manual / collection / query / external branches · nested conditionals two levels down · empty-state fallback
2803
+
2804
+ **Why this shape.** Four sources are four different data contracts, so they're exclusive branches of one select — and the bigger branches are drill-in OBJECTS so each can have its own conditionals (query.price → min/max, query.sort → metafield, external.provider → intent/seed vs endpoint/mapping). Nesting the conditionals inside objects is how a sibling-only condition system expresses a second level. Inactive branches keep their values (the query is still there if you switch back), and the renderer reads only the active branch.
2805
+
2806
+ **HTML signals.**
2807
+ - Product cards rendered client-side from a fetch (Shopify recommendations API /recommendations/products.json, Algolia, Klaviyo)
2808
+ - data-collection / data-handle / data-tags / data-limit attributes on the section wrapper
2809
+ - 'You might also like' / 'Goes well with' headings with a product carousel
2810
+ - Server-rendered lists with sort/filter query params in the 'View all' link
2811
+
2812
+ **Do.** Decide the source from how the products got there, not how they look: hard-coded product links → manual (resolve each to a Shopify reference); a collection URL or data-handle → collection; filter params (tag, price, sort) → query; a call to a recommendations endpoint → external. Fill only the active branch; leave others empty. Map the 'View all' link to cta. When the HTML is a snapshot of a dynamic list, NEVER freeze it into a manual list — that silently stops it updating; flag it if you can't tell.
2813
+
2814
+ **Pitfalls.**
2815
+ - SCHEMA GAP: conditions test one sibling for equality — no AND/OR, no comparison (>), no reading a parent or the whole entry (e.g. 'show seed only when this block is on a product page'). Nested objects are a workaround, not a model.
2816
+ - SCHEMA GAP: values in hidden branches are kept and published; there's no 'clear when hidden' option, so stale manual product ids ship in the payload of an external-source block.
2817
+ - SCHEMA GAP: required-if — `maxPrice` is required only when visible; that works because hidden fields skip validation, but 'required when price = between AND minPrice is set' can't be written.
2818
+ - minPrice < maxPrice can't be validated (cross-field).
2819
+
2820
+ Not carried by a stored component: ai, category, reference target, variants.
2821
+
2822
+ ### Global block with per-page overrides (`global-announcement-overrides`)
2823
+
2824
+ Tags: global-synced, conditional, validation, variants. Proves: Synced fields from a global · locked vs overridable fields · instance-only settings · override map in the value
2825
+
2826
+ **Why this shape.** A global block has three kinds of field and the schema shows them as three groups: synced fields a page MAY override (message, link, tone — marked `synced`), fields owned by the global that pages can't touch (offer terms, schedule — developer-locked with a reason), and instance-only settings that never exist on the global (hide on this page). The value carries `_ref` (the global's id) and `_overrides` (only the fields this page changed), matching D28's instance = reference + overrides, so editing the global still reaches every page except the overridden field. `published` here is the global's own value, which is what the overridden fields differ from.
2827
+
2828
+ **HTML signals.**
2829
+ - A thin full-width bar above the header, identical on every page of the crawl except a few
2830
+ - Dismiss (×) button with a cookie/localStorage key (announcement-dismissed)
2831
+ - Small-print text ('Ends Oct 12', 'US only') next to the message
2832
+ - Pages where the bar's text differs from the site-wide version (candidate overrides)
2833
+
2834
+ **Do.** Crawl first, then decide: an element repeated with identical content on ≥80% of pages is a GLOBAL — create it once (kind: global) from the most common version. Pages whose copy differs get an instance with `_overrides` containing ONLY the differing fields; never create a second global for a variant. Offer terms and dates that never differ → global-owned (locked). If a page lacks the bar, set visibility: hide on that page rather than omitting the instance silently. If more than half the fields differ on more than a third of pages, it isn't one global — model a regular block instead.
2835
+
2836
+ **Pitfalls.**
2837
+ - SCHEMA GAP: no per-field `overridable` flag. `synced` marks inheritance and `readOnly: "developer"` locks, but 'locked in instances, editable in the global' is a different permission from 'developer only' — the language conflates them.
2838
+ - SCHEMA GAP: the override map (`_overrides`) and instance reference (`_ref`) are value conventions, not schema — nothing validates that an override key is an overridable field.
2839
+ - SCHEMA GAP: instance-only fields (visibility, hideReason) live in the same schema as the global's fields; there's no way to say 'this field exists only on instances'.
2840
+ - Overriding `tone` on one page makes the bar look broken next to the header; consider locking design fields too.
2841
+
2842
+ Not carried by a stored component: ai, category, kind:global, variants.
2843
+
2844
+ ### Localized section with RTL (`localized-section`)
2845
+
2846
+ Tags: localized, repeater, conditional, a11y, validation. Proves: Localized fields · per-locale and per-field translation status · fallback chain · Arabic right-to-left sample
2847
+
2848
+ **Why this shape.** Two localization models meet here. Field-level (`localized: true` on heading/body/cta) is how the editor shows a locale badge and switches values per locale — it's the right primitive. But the language has nowhere to store status, direction or fallback per locale, so the closest honest model is an internationalized array (one item per locale, Sanity's @sanity/internationalized-array pattern) carrying the translated copy plus status, direction and a per-field status list. Shared things (the image file) stay single; its alt text is copy, so it's translated.
2849
+
2850
+ **HTML signals.**
2851
+ - <html lang> / hreflang alternates across crawled URLs (/pt/, /ar/) with the same section structure
2852
+ - dir="rtl" on <html> or on the section; mirrored layouts (padding-inline-start)
2853
+ - A language switcher in the header
2854
+ - Identical structure with different text across locale paths
2855
+
2856
+ **Do.** Crawl every hreflang alternate. Match the same section across locales by position + structure, never by text. The default locale's copy fills the localized fields; every other locale becomes a `translation` item with its copy, `dir` from the dir attribute (rtl for ar/he/fa/ur), status 'approved' for human-published pages (or 'machine' if the site marks machine translation). Missing locales get a `missing` item so the gap is visible. Never translate during conversion. Keep the image shared unless a locale uses a different photo — then the image becomes localized too and you note it.
2857
+
2858
+ **Pitfalls.**
2859
+ - SCHEMA GAP: `localized: true` is a flag only — no value shape for per-locale values, no translation status, no per-locale direction, and no fallback chain in the schema. The internationalized array here duplicates the localized fields by hand.
2860
+ - SCHEMA GAP: the locale list is a hard-coded select; it should come from site settings (dynamic options).
2861
+ - SCHEMA GAP: `unique: true` on `locale` is declared but not enforced — two pt-PT items can exist.
2862
+ - SCHEMA GAP: no `dir`/`lang` awareness in text fields — the editor won't render the Arabic copy right-to-left while typing.
2863
+ - Recommended lengths are per English; German runs ~30% longer and Arabic ~25% shorter, so a single recommendedMax misfires per locale.
2864
+
2865
+ Not carried by a stored component: ai, category, variants.
2866
+
2867
+ ### Personalized hero with A/B test (`personalized-hero`)
2868
+
2869
+ Tags: personalization, deep-nesting, conditional, repeater, validation. Proves: Default fallback · audience segments with rules · priority · experiment variants with split weights · 5 levels deep
2870
+
2871
+ **Why this shape.** Personalization is three layers with a strict precedence: audiences (first match wins), then the experiment (for everyone unmatched), then the default — which is required and complete so the page is never blank. Audience and variant content are partial objects that inherit empty fields from the default, so editors only write what changes. The deepest path (Hero › A/B test › Version › Content › Button) is five frames.
2872
+
2873
+ **HTML signals.**
2874
+ - Multiple hidden copies of the hero (display:none, data-variant, data-audience attributes) or a flicker-guard style (async-hide, .optimize-loading)
2875
+ - Experimentation scripts: Optimizely, VWO, Google Optimize remnants, Statsig, LaunchDarkly, Mutiny, Intellimize
2876
+ - Server responses differing by query string (utm_campaign) or Vary headers
2877
+ - Clearbit/6sense reveal calls before the hero renders
2878
+
2879
+ **Do.** A static crawl only sees ONE variant — never invent audiences. Use what the page reveals: variant markup in the DOM, experiment configs inlined by the testing tool, or utm-specific versions you were given URLs for. The visible version becomes `default` (complete). Each discovered audience → `segment` with rules translated from the tool's targeting (unknown targeting → a rule with attribute 'utmCampaign' and a review flag). Experiment configs → `experiment.variants` with their weights; the original is the control. If the testing tool rewrites more than this hero (multi-section tests), don't model it per block — flag it for a page-level experiment.
2880
+
2881
+ **Pitfalls.**
2882
+ - SCHEMA GAP: no cross-item validation — version weights must sum to 100 and exactly one version should be the control; neither can be expressed.
2883
+ - SCHEMA GAP: no audience/segment collection to reference; rules are re-typed in every block that personalizes (a `reference: { to: ['audience'] }` target is needed).
2884
+ - SCHEMA GAP: partial content objects need 'inherits from default' semantics in the schema (show the default as a placeholder, mark overridden fields); today it's a description.
2885
+ - SCHEMA GAP: a variant's hidden `content` is still required-validated by its own rules if `isControl` flips — conditions hide but can't relax `required` declaratively.
2886
+ - Personalized copy must not change the H1 for SEO purposes; the native page title stays the default's.
2887
+
2888
+ Not carried by a stored component: ai, category, document preview path, variants.
2889
+
2890
+ ### Responsive layout settings (`responsive-layout-settings`)
2891
+
2892
+ Tags: responsive-layout, repeater, conditional, variants. Proves: Columns, gap and spacing per breakpoint · inherit from the larger screen · per-item visibility and mobile order
2893
+
2894
+ **Why this shape.** Layout is design, so it lives in its own Layout tab, split by breakpoint because the decisions differ per screen (4 columns → 2 → swipe). Tablet inherits desktop until switched off — editors only touch what changes. Per-item visibility and mobile order sit on the item because they're about THAT card. Arrangement on phones is a choice of patterns (cards select), not a column count, because 'swipe' isn't a number of columns.
2895
+
2896
+ **HTML signals.**
2897
+ - Media-query grid changes: grid-template-columns: repeat(4, …) at ≥1280px, repeat(2, …) at ≥768px, one column below
2898
+ - Responsive utility classes: md:grid-cols-2 lg:grid-cols-4, hidden md:block, order-first sm:order-none
2899
+ - Scroll-snap carousels that only apply under a breakpoint
2900
+ - Different padding utilities per breakpoint (py-12 lg:py-24)
2901
+
2902
+ **Do.** Read the computed layout at three widths (1440, 834, 390) rather than parsing CSS. Desktop values → `desktop`; if tablet matches desktop, set `tablet.useDesktopLayout: true` and leave the rest empty; otherwise fill only what differs. Phone: one column → stack, horizontal overflow with snap → carousel (+ showPeek if the next card is partially visible), two columns → grid. Map spacing to tokens (S/M/L/XL) by nearest value — never store raw pixels. Items hidden at a width → remove that breakpoint from `showOn`; reordered items → `mobileOrder`.
2903
+
2904
+ **Pitfalls.**
2905
+ - SCHEMA GAP: no responsive value type (`responsive<T>`: one field with a value per breakpoint and inheritance). Modelling it as three objects triplicates fields and the inherit toggle is a hand-made convention.
2906
+ - SCHEMA GAP: breakpoints are hard-coded in descriptions (768/1280); they should come from the site's design tokens.
2907
+ - SCHEMA GAP: `mobileOrder` should be unique among items and ≤ item count — cross-item validation isn't available.
2908
+ - Hiding content on phones hides it from mobile-first indexing too; prefer reordering over hiding.
2909
+
2910
+ Not carried by a stored component: category, variants.
2911
+
2912
+ ### Grid with per-cell layout (`grid-cell-layout`)
2913
+
2914
+ Tags: slots, variants, media-heavy, responsive-layout, commerce. Proves: Slot cells with column span, row span, surface and alignment settings · mixed block types per cell
2915
+
2916
+ **Why this shape.** Cells are a SLOT (any library block) because a gift grid mixes products, photos, quotes and offers; the cell's geometry (spans, surface, alignment, padding) belongs to the PLACEMENT, not to the block — the same product card could be 1×1 here and 2×2 elsewhere — so it lives in `slotSettings` and is stored under `_settings`. Numeric spans (sliders) replace bento's preset sizes when the design needs arbitrary rectangles.
2917
+
2918
+ **HTML signals.**
2919
+ - CSS grid with per-child grid-column: span N / grid-row: span N (or col-span-2 row-span-2 utilities)
2920
+ - Children of different kinds (product card, figure, blockquote, CTA) in one grid
2921
+ - Per-cell background classes (bg-dark, bg-brand) and place-self/align-self alignment
2922
+ - grid-auto-flow: dense
2923
+
2924
+ **Do.** Each grid child → one slot child: classify its content into a library block (_type) and copy the content fields; read its computed grid-column/grid-row span → colSpan/rowSpan; background → nearest surface token; align-self/justify-self → alignY/alignX; padding → token. Children hidden under 768px → hideOnMobile. If two children are the same kind with the same geometry in a regular rhythm, it's a plain card grid (array), not this layout grid. If spans fall into exactly 4 presets, prefer the Bento block.
2925
+
2926
+ **Pitfalls.**
2927
+ - SCHEMA GAP: no cross-cell validation that spans fit the column count (a 3-wide cell in a 4-column grid after a 2-wide one wraps unexpectedly); `colSpan` max can't depend on the parent's variant.
2928
+ - SCHEMA GAP: slotSettings can't be conditional on the child's block type (e.g. padding 'none' only for images) and can't vary per breakpoint.
2929
+ - Dense packing changes the visual order but not the DOM order — screen readers follow the DOM.
2930
+
2931
+ Not carried by a stored component: ai, category, slot allow, variants.
2932
+
2933
+ ### References web (`references-web`)
2934
+
2935
+ Tags: references, dynamic-query, conditional, repeater, validation. Proves: Products, authors, recipes and pages referenced manually, by filter, or pinned + auto-filled · reverse 'used in' as a gap
2936
+
2937
+ **Why this shape.** Everything here is a pointer, so nothing is retyped: products from Shopify, writers from Authors, the recipe from Recipes, a static page for the guide. 'Read next' has three honest modes — picked, automatic, or pinned + automatic — and the automatic part is a drill-in object of filter references (topics), so editors curate the top slot and let the query fill the rest. The section is the canonical example of reference fan-out: one block touches five collections.
2938
+
2939
+ **HTML signals.**
2940
+ - Product cards linking to /products/* under an article
2941
+ - An author bio card (photo, name, role, link to /authors/*)
2942
+ - 'Read next' / 'More from' lists where the first item is constant across stories and the rest vary (pinned + auto)
2943
+ - Links to a guide page with a consistent label
2944
+
2945
+ **Do.** Resolve EVERY link to an existing page or CMS item before creating anything: product URLs → Shopify product references; author links → Authors entries (create missing authors as entries, never inline names); /recipes/* → Recipes entries; static pages → page references. For 'Read next': compare across several crawled stories — items that repeat are pinned, items that vary and share a topic → auto with those topics. If you see the same footer on every story with identical items, it's a global block, not this. Unresolvable links stay as links in a text field with a review flag.
2946
+
2947
+ **Pitfalls.**
2948
+ - SCHEMA GAP: no reverse references / 'used in' — the platform knows where a product or author is referenced, but a schema can't declare a read-only computed backlinks field (Sanity shows incoming references natively).
2949
+ - SCHEMA GAP: reference filters (`reference.filter`) exist in types.ts but aren't used by the picker, so 'only published stories in the Recipes topic' can't constrain `pinned`.
2950
+ - SCHEMA GAP: `excludeCurrent` and 'pinned count toward total' are semantics of the query that the schema can't describe — they're renderer conventions.
2951
+ - SCHEMA GAP: `unique` on reference arrays isn't enforced; the same story can be pinned twice.
2952
+ - Referencing pages by id survives slug changes; storing hrefs doesn't.
2953
+
2954
+ Not carried by a stored component: category, reference target.
2955
+
2956
+ ### Changelog with nested validation (`changelog-nested-validation`)
2957
+
2958
+ Tags: validation, repeater, conditional, rich-text. Proves: Releases → changes → packages · semver and issue-key patterns · min/max at every level · required-if breaking
2959
+
2960
+ **Why this shape.** A changelog is the purest nested repeater: releases → changes → packages, each level with its own limits and formats. Validation sits where the knowledge is: semver on versions, the tracker key on issues, package names restricted to the org scope. 'Migration notes required when breaking' is the required-if pattern: a conditional field (shown when breaking) with `required`, which only validates while visible. Packages are three short strings, so they open inline.
2961
+
2962
+ **HTML signals.**
2963
+ - Headings with version numbers (## 5.0.0 — 2026-09-30) followed by grouped lists (Added / Changed / Fixed)
2964
+ - Keep a Changelog structure, 'BREAKING' labels, links to issue trackers (NW-1042, #1234)
2965
+ - Inline code and fenced code blocks in migration sections
2966
+ - An RSS/Atom link for releases
2967
+
2968
+ **Do.** Each version heading → `release` (version from the heading text, date from the heading or <time>). Group headings (Added/Fixed…) set `kind` for the list items under them; each list item → `change` with its summary (first sentence, ≤120 chars), issue keys extracted by pattern, 'BREAKING'/'⚠' → breaking: true with the following paragraph(s)/code as migration (keep code blocks). Package bumps mentioned as 'pkg@x → y' → packages. Split rule: more than ~30 releases, or releases with their own URLs, → model Release as a COLLECTION (one entry per version, sorted by date) and place a dynamic-query block; never keep years of history in one block.
2969
+
2970
+ **Pitfalls.**
2971
+ - SCHEMA GAP: `unique` on version is declared but not enforced; duplicates and ordering (newest first, versions descending) can't be validated.
2972
+ - SCHEMA GAP: required-if only works by hiding the field; 'required when breaking, but still visible and optional otherwise' can't be expressed. Needs `validation.requiredWhen: Condition`.
2973
+ - SCHEMA GAP: `min` on a string means characters, on an array items, on a number value — one overloaded key; no word-count or line-count limits.
2974
+ - SCHEMA GAP: patterns validate strings only; a `date` can't be constrained (not in the future, after the previous release).
2975
+ - Inline packages with three fields each get cramped; the inline openMode threshold (≤3 primitives) is exactly at its limit here.
2976
+
2977
+ Not carried by a stored component: category, variants.
2978
+
2979
+ ### Rich text with components and annotations (`rich-text-embedded-components`)
2980
+
2981
+ Tags: rich-text, references, embed, a11y, media-heavy. Proves: Body with linked component instances (D28), inline entry links, footnote and glossary annotations, image and table islands
2982
+
2983
+ **Why this shape.** Prose is the content; components, tables and images are ISLANDS inside it, stored as the D28 `data-rt` markup (component = library ref + id + overrides, so editing the original Product card still reaches this story except the overridden badge). Inline product links carry the entry id, not just the href, so they survive slug changes. Footnotes and terms terms are annotations: a mark in the text (`data-footnote`, `data-term`) pointing at a keyed item in a sibling array, so the note text stays editable and numbered in reading order.
2984
+
2985
+ **HTML signals.**
2986
+ - <article> prose with embedded non-prose elements: product cards, newsletter sign-ups, tables, figures
2987
+ - <sup><a href="#fn1"> footnote markers and an <ol class="footnotes"> at the end
2988
+ - <abbr title> / <dfn> / tooltip spans with definitions
2989
+ - Internal links to /products/* or /recipes/* inside paragraphs
2990
+
2991
+ **Do.** Convert prose to the richtext HTML subset (p, h2–h4, lists, blockquote, strong/em, a, code). For every embedded non-prose element: if it matches a library block (product card, CTA) emit a `data-rt="component"` island referencing it (overrides = only fields that differ from the library version); tables/images/callouts use their `data-rt` islands. Internal links → add data-link-type="entry" + data-entry-id after resolving the target. Footnote markers → `<sup data-footnote="key">` and move each footnote's text into `footnotes` with the same key. Definitions → `terms` + `data-term`. If an island is used in more than one article, it must be a library component, not a copied HTML snippet.
2992
+
2993
+ **Pitfalls.**
2994
+ - SCHEMA GAP: the richtext field can't declare WHAT it allows — styles (h2–h4 only), marks, annotation types (footnote, term, internal link) or the component allowlist. Sanity's `of` on block fields does this; ours accepts any island.
2995
+ - SCHEMA GAP: annotations are attributes in HTML; nothing validates that `data-footnote` keys exist in `footnotes` (or that every footnote is used). Needs typed annotation marks pointing at items.
2996
+ - SCHEMA GAP: inline references (entry links) are link-picker values serialised into the HTML, so the platform's reference graph can't see them without parsing the body.
2997
+ - Footnote numbers in the text are render-time; storing '1', '2' in the markup goes stale when paragraphs move (shown here only for readable fallback).
2998
+
2999
+ Not carried by a stored component: ai, category, kind:entry (props).
3000
+
3001
+ ### Dynamic query listing (`dynamic-query-listing`)
3002
+
3003
+ Tags: dynamic-query, conditional, repeater, references, validation. Proves: Collection + filter rows (field · operator · value) + sort + limit + pagination + pinned + empty state
3004
+
3005
+ **Why this shape.** A listing is a QUERY, not a list of items: the block stores the question (collection, filter rows, match all/any, sort rules, limit) and the platform answers it at build/request time, so the section never goes stale. Each filter row is field · operator · value with the value input switching by operator (single value, list, day count, or none for 'is filled in'). Pinned references and the empty state cover the two editorial needs a query can't: 'this one first' and 'what if nothing matches'. Numbered pagination exists because 'load more' isn't crawlable.
3006
+
3007
+ **HTML signals.**
3008
+ - A card grid whose items share a template and link into one URL pattern (/journal/*, /topics/*)
3009
+ - Pagination (?page=2, rel=next), 'Load more' buttons fetching JSON
3010
+ - The same section showing different items on different topic/category pages (filter = current page)
3011
+ - Empty-state copy ('No stories yet') in the markup or on sparse pages
3012
+
3013
+ **Do.** Never convert a feed into static items. Infer the query by comparing the listing across pages and over time: shared topic → filter in; dates all recent → withinDays; order → sort rules; count → limit; pagination UI → pagination. Items that appear in a fixed first position across crawls → pinned. If the source collection doesn't exist yet, create it and import the items as entries FIRST, then emit this block pointing at it. If the listing is hand-curated (no shared criteria, rarely changes) use a references array instead.
3014
+
3015
+ **Pitfalls.**
3016
+ - SCHEMA GAP: a filter's `field` (and a sort rule's) should be a select populated from the chosen collection's fields, and the value input should take that field's TYPE (reference picker for topics, date for publishedAt). The schema can't derive options or types from another field's value.
3017
+ - SCHEMA GAP: `pinned` references are hard-wired to col_journal; they should target whatever `collection` is chosen (reference targets from a sibling value).
3018
+ - SCHEMA GAP: no live query preview/count in the schema (e.g. '7 entries match') and no validation that the query returns anything.
3019
+ - SCHEMA GAP: AND/OR groups beyond one level (all of (A, any of (B, C))) can't be modelled without recursive object types — the language has no recursion.
3020
+
3021
+ Not carried by a stored component: category, variants.
3022
+
3023
+ ### Modal, drawer and popover (`overlays-with-triggers`)
3024
+
3025
+ Tags: slots, conditional, a11y, form, repeater, references. Proves: Overlays with their own slot content, opened by triggers on other blocks (click, delay, scroll, exit) · focus and dismiss rules
3026
+
3027
+ **Why this shape.** Overlays are page-level content that isn't in the flow: they're modelled as one block holding an array of overlays, each with its own slot of library blocks (so a modal can reuse a quote or a CTA card) plus an optional form reference. Triggers on OTHER blocks are ordinary buttons linking to #id — the overlay owns the id, the button owns nothing special — which keeps the button field type unchanged. Automatic triggers and frequency caps are opt-in and live on the overlay. Size/side are conditional on kind; popovers have no backdrop setting.
3028
+
3029
+ **HTML signals.**
3030
+ - <dialog> elements or role="dialog" aria-modal="true" containers hidden by default
3031
+ - Buttons with aria-haspopup="dialog", aria-controls, data-modal-target or href="#id" pointing at those containers
3032
+ - Exit-intent / scroll-depth / timer scripts (mouseleave on document, IntersectionObserver at 70%)
3033
+ - Off-canvas drawers (transform: translateX(100%), .drawer, .offcanvas)
3034
+
3035
+ **Do.** Find every hidden dialog/drawer/popover container and its trigger(s). Each → an `overlay` with id from the container id (kebab-case), kind from role/position, content converted to library blocks in the slot, forms → reference a created form. Rewrite each trigger button's link to `{ type: 'section', href: '#id' }`. Timer/scroll/exit scripts → `triggers` with their thresholds; localStorage/cookie caps → frequency. Cookie banners and age gates are NOT overlays — they belong to site settings. If an overlay appears on many pages with the same content, make the overlay set a global block.
3036
+
3037
+ **Pitfalls.**
3038
+ - SCHEMA GAP: no link type 'open overlay' and no intra-page reference — triggers are '#id' strings, so renaming an overlay id silently breaks every button that opens it.
3039
+ - SCHEMA GAP: `unique` on overlay ids isn't enforced, and ids must also not collide with section anchors on the same page (page-wide uniqueness).
3040
+ - SCHEMA GAP: popovers need an anchor element; positioning relative to a specific button can't be expressed beyond the #id link.
3041
+ - Exit-intent doesn't exist on touch devices; the label says '(desktop)' because the schema can't scope a trigger per device.
3042
+ - Focus trap, Esc to close and focus return are renderer requirements, not fields — never make them toggles.
3043
+
3044
+ Not carried by a stored component: category, slot allow.
3045
+
3046
+ ### Sticky scroll story (`scrollytelling`)
3047
+
3048
+ Tags: media-heavy, repeater, conditional, a11y, variants. Proves: Steps synced to scroll with media swaps (image / video / stat) · trigger offset · reduced-motion and phone fallbacks
3049
+
3050
+ **Why this shape.** Scrollytelling pairs each step's text with what the sticky panel shows, so media is a drill-in object ON the step (not a parallel array that can drift out of sync). The media kind is a conditional switch (image / video + poster / big number / keep previous). Motion settings are a separate tab, and both fallbacks — reduced motion and phones — are required choices, because a sticky scroll story is unusable for many people without them.
3051
+
3052
+ **HTML signals.**
3053
+ - position: sticky media container beside a column of tall text blocks (min-height: 80–100vh each)
3054
+ - Scrollama / GSAP ScrollTrigger / IntersectionObserver code toggling an active step class
3055
+ - Media elements with data-step attributes swapped via opacity
3056
+ - prefers-reduced-motion media queries around the transitions
3057
+
3058
+ **Do.** Each text step → `scrollStep`; find the media shown while it's active (data-step mapping, or the media element index) and put it in that step's `media` — never in a separate list. Steps that don't change the media → kind 'same'. Read the trigger offset from the library config (ScrollTrigger start: 'top center' → 50). If there's no reduced-motion handling in the source, default reducedMotion: stack and note it. Over 8 steps → split into two stories; each scroll story should make one point.
3059
+
3060
+ **Pitfalls.**
3061
+ - SCHEMA GAP: 'keep previous' media depends on the previous array item; the schema can't validate that the first step doesn't use it (position-dependent rules).
3062
+ - SCHEMA GAP: no animation/timeline type — transitions are a select of presets; anything choreographed (scrubbed video, SVG path drawing) stays in code with copy fields only.
3063
+ - Videos in sticky panels must be muted and short; there's no duration validation on the video type.
3064
+ - Preview `media: "media.image"` is empty for stat/video steps — preview can't fall back across paths.
3065
+
3066
+ Not carried by a stored component: ai, category, variants.
3067
+
3068
+ ### Accessibility-critical media and form (`a11y-critical-media-form`)
3069
+
3070
+ Tags: a11y, media-heavy, form, validation, conditional. Proves: Captions per language, transcript, audio-described version · decorative vs informative images · labels, hints and error messages
3071
+
3072
+ **Why this shape.** Accessibility needs are content, so they're fields with rules, not afterthoughts: caption tracks are an array (one per language and kind) with at least one required; the transcript is required rich text; the audio-described cut is its own video. Images declare their ROLE because alt text depends on it — informative needs alt, complex needs alt plus a long description (conditional + required), decorative must have EMPTY alt. Form fields carry a visible label, autocomplete purpose, a hint and a fix-it error message (required only when the field is required).
3073
+
3074
+ **HTML signals.**
3075
+ - <video> with <track kind="captions|subtitles|descriptions" srclang label>
3076
+ - Transcript sections (details/summary 'Transcript', or a heading 'Transcript' after a video)
3077
+ - <img alt=""> / role="presentation" (decorative) vs descriptive alt; aria-describedby / longdesc links for charts
3078
+ - <label for>, aria-describedby hints, aria-invalid + error text, autocomplete attributes on inputs
3079
+
3080
+ **Do.** Copy every <track> into `tracks` (kind, srclang → language, label, src → file). Never auto-generate captions during conversion — if none exist, leave tracks empty so validation flags it. A transcript on the page → `transcript`; if absent, flag (it's required). For each image: alt="" or role=presentation → role decorative (keep alt empty); an image of a chart/diagram → complex and pull the long description from aria-describedby/longdesc/adjacent text; otherwise informative with the existing alt (rewrite 'image of…' phrasing). For forms: labels from <label> (if only placeholders exist, convert placeholder → label and flag), autocomplete from attributes, error messages from the validation script or aria-errormessage.
3081
+
3082
+ **Pitfalls.**
3083
+ - SCHEMA GAP: alt text lives INSIDE the image value; conditions and validation can't reach a sub-key, so 'alt required unless decorative' and 'alt must be empty when decorative' can't be expressed. The image type needs a built-in `decorative` flag and an alt rule. Proof: the kit validator flags this entry's own decorative image (figures.1) with "No alt text" — a false positive the schema can't prevent.
3084
+ - SCHEMA GAP: no file-type constraint on `file` (accept: .vtt) and no way to validate a caption track's language matches the video's.
3085
+ - SCHEMA GAP: 'at least one track of kind captions' is a rule about item VALUES in an array; only item counts can be validated.
3086
+ - SCHEMA GAP: the sign-up form is modelled inline because forms are a separate system; ideally the block only references the form and the form owns labels/errors.
3087
+ - Autoplay over 5 seconds without a pause control fails WCAG 2.2.2; the help warns, nothing blocks it.
3088
+
3089
+ Not carried by a stored component: category, document preview path.
3090
+
3091
+ ### Section theme tokens (`section-theme-tokens`)
3092
+
3093
+ Tags: variants, conditional, a11y, validation, media-heavy. Proves: Dark section inside a light page · surface + accent override from brand tokens · spacing tokens · all in the Design group
3094
+
3095
+ **Why this shape.** Everything about how the section LOOKS sits in the Design tab and is expressed as tokens (scheme, surface, accent, spacing, width) — never raw hex or pixels — so the brand kit stays the single source and dark sections flip every color consistently. Conditionals keep the tab calm: the photo and overlay appear only for the Photo surface, the accent choice only when overriding. Content (quote, name, photo, button) is untouched by any design choice, so switching variant or scheme never loses copy.
3096
+
3097
+ **HTML signals.**
3098
+ - A section with its own data-theme="dark" / .dark / .theme-inverse class inside a light page
3099
+ - Background image with a semi-transparent overlay layer (::before with rgba/opacity)
3100
+ - CSS custom properties redefined on the section (--accent, --surface) or utility classes like bg-neutral-900 text-white
3101
+ - Section-specific padding utilities (py-24) differing from neighbours
3102
+
3103
+ **Do.** Map computed styles to the nearest TOKEN, never copy values: background luminance < 0.2 → scheme dark (+ surface inverse unless it's a photo); a background-image with an overlay → surface image + overlay % from the overlay's alpha; accent = the button color matched to the brand kit's accents (if no match within ΔE 5, keep the default and flag); padding → nearest spacing token; max-width → width token. If the source uses a color the brand kit doesn't have, propose adding it to the brand kit rather than storing a hex in the block.
3104
+
3105
+ **Pitfalls.**
3106
+ - SCHEMA GAP: no token-reference type — `accent` is a hard-coded select that must be kept in sync with the brand kit by hand; it should be a dynamic option list from the brand kit (and `color` fields should be restrictable to tokens).
3107
+ - SCHEMA GAP: no contrast validation between surface/overlay and text color (WCAG 1.4.3); 'overlay below 35% on a bright photo' can't be flagged.
3108
+ - SCHEMA GAP: no responsive values for spacing (see responsive-layout-settings).
3109
+ - SCHEMA GAP: design fields are repeated per block; a shared, named object type (sectionTheme) can't be referenced by name, only copied.
3110
+
3111
+ Not carried by a stored component: category, variants.
3112
+
3113
+ ## Core examples
3114
+
3115
+ ### FAQ (`core-faq`)
3116
+
3117
+ Tags: repeater, conditional, validation, dynamic-query. Proves: Small items still drill in · conditional source · developer lock · presence in a row
3118
+
3119
+ **Why this shape.** Questions are same-shaped rows, so `items` is one list whose row holds a plain-string question and a rich-text answer (bold, links and lists live in answers). `source` switches between questions written here and references into a FAQs collection; each list shows only for its own choice, so the two never disagree. Search, rich results, columns and the anchor are Settings, not copy, and the anchor is locked to people who manage the schema.
3120
+
3121
+ **HTML signals.**
3122
+ - Repeated <details> with a <summary> holding the question (often an <h3> plus a chevron <svg>) and a body <div> holding the answer
3123
+ - The first <details> carries `open`
3124
+ - A hand-written <script type="application/ld+json"> with "@type":"FAQPage" beside the band
3125
+ - A closing line with a support link under the list ("Still stuck? Talk to support")
3126
+
3127
+ **Do.** create_component with sectionType 'FAQ'; each prop needs a `target` into your own blockJson (a root `section` with `style`). eyebrow/heading/intro → text props on heading/text blocks. items → ONE `table` prop: config.fields question (text, required) + answer (richtext, required), plus category (select) and open (boolean); ui { preview: { title: 'question' }, itemLabel: 'Question', icon: 'help-circle' }. source → a select with ui.control 'segmented'; give items and collection a `showIf` on source ('inline' / 'collection'), placed after source. collection → multi-reference with config.contentModelId of the FAQs model. showSearch/richResults/columns/anchor → ui.tab 'Settings'. Accordion and Two columns are two components sharing sectionType 'FAQ' with the same prop keys.
3128
+
3129
+ **Pitfalls.**
3130
+ - richResults does nothing by itself: FAQPage markup comes from update_page schemaType 'FAQPage', and the builder finds the first list whose rows have keys containing `question` and `answer`. Delete the hand-written JSON-LD.
3131
+ - `open` on the first item is behaviour in the worked example and stays in the template; keep the per-item boolean only if the template reads it.
3132
+ - Bind the question on the <h3>, not the <summary>, or a save erases the chevron.
3133
+
3134
+ Not carried by a stored component: ai, variants.
3135
+
3136
+ ### Mega menu (`core-megaMenu`)
3137
+
3138
+ Tags: deep-nesting, repeater, global-synced, validation. Proves: Five levels deep · breadcrumb truncation · global block (synced) · link + button units
3139
+
3140
+ **Why this shape.** The navigation is a GLOBAL block: one copy synced across every page, edited once. Its menu is five levels deep (Navigation › menu item › column › link › badge) to prove drill-in and breadcrumb truncation. A menu item `kind` decides whether it is a plain link or opens a mega panel of columns plus a featured card, so a link row never carries panel data. Sticky, transparent and the mobile style sit in a Behaviour tab; the logo and its screen-reader text share a Brand fieldset.
3141
+
3142
+ **HTML signals.**
3143
+ - A <header> on every page holding <nav aria-label="Main"> with a logo <a><img></a> and a <ul> of menu items
3144
+ - A <button aria-expanded aria-controls> that opens a hidden panel, beside plain <a> items that just link
3145
+ - Panel columns: a title <p> over a <ul> of links, each link a label <span>, an optional description <span> and an optional badge ("New")
3146
+ - Sign-in link and a primary button at the end of the bar
3147
+
3148
+ **Do.** Site chrome is the Layout, never a page section: get_layout, then update_layout scope 'global' with `add-field` commands on the reserved `navigation` Section (ifMatch from get_layout), set-field-value for the rows, publish_layout. Read the Section's existing logo, link and button fields first and add only what is missing. If the header is a component placed with `add-component`, each prop needs a `target` into your own blockJson: items → a `table` prop (ui { itemLabel: 'Menu item', preview: { title: 'label' } }) whose rows hold label, link (a `link` value), kind (select) and ONE nested `table` of links with `column` as a text sub-field; cta → a `button` prop on a button block's `props`. logo/logoText → ui.group 'brand'; sticky/transparent/mobile → ui.tab 'Behaviour'.
3149
+
3150
+ **Pitfalls.**
3151
+ - Two list levels is the cap for a component's props and the depth chrome is proven to bind at: store menu → links and put the column title on each link row. The kit's third level (columns) and the badge object do not fit; flatten the badge to a scalar on the link row.
3152
+ - A nav placed on a page as a section renders nowhere, and a page that draws its own header gets no Layout copy on top: remove page-level copies.
3153
+ - The worked example keeps `sticky`, colours and height in code; expose the Behaviour props only if the template reads them.
3154
+
3155
+ Not carried by a stored component: ai, category, kind:global, variants.
3156
+
3157
+ ### Tabs (`core-tabs`)
3158
+
3159
+ Tags: slots, polymorphic-array, repeater, variants. Proves: Array of tabs, each a slot of blocks · block picker · drilling into a nested block
3160
+
3161
+ **Why this shape.** Tabs are a list (2–6), and each tab is a label, an optional icon and a SLOT of blocks, so a panel can hold text, images, features, stats, quotes, CTA cards, product cards or video in any order. The allow list keeps panels to small content blocks. Which tab opens first and whether the URL remembers it are settings; underline, pills and side tabs are layout variants.
3162
+
3163
+ **HTML signals.**
3164
+ - A role="tablist" of <button role="tab" aria-controls aria-selected>, often each with an icon <i>
3165
+ - One role="tabpanel" per tab, all but the first `hidden`
3166
+ - Panels that hold their own rows of cards, images and stats rather than one paragraph
3167
+
3168
+ **Do.** A band one page owns can stay a `section` block holding a `tabs` block {tabs:[{id,label,children}]} (worked example 8). As a component: create_component with a sectionType naming the family (e.g. 'Tabs'); each prop needs a `target` into your own blockJson. tabs → ONE `table` prop (ui { itemLabel: 'Tab', preview: { title: 'label' }, icon: 'layout-list' }) with config.fields label (text, required), icon (`icon`) and content: a `slot` sub-field with config.multiple, maxItems 6 and config.componentIds; point the table at your tabs block the way §14's slider table targets props.slides. heading → text prop; defaultTab → number with config.min 1, max 6. Underline, Pills and Side tabs are three components sharing sectionType 'Tabs' with the same prop keys.
3169
+
3170
+ **Pitfalls.**
3171
+ - The kit's slot allow list names kit blocks, not components: config.componentIds must list published components of this project.
3172
+ - Slot children are `{ componentId, overrides? }` with no `_variant`: a variant is its own component.
3173
+ - Give every block, tab and instance a stable `id`; changing one orphans the edits made against it.
3174
+
3175
+ Not carried by a stored component: ai, category, slot allow, variants.
3176
+
3177
+ ### Pricing (`core-pricing`)
3178
+
3179
+ Tags: repeater, conditional, commerce, variants, validation. Proves: Tiers → features · conditional Contact sales when price is custom · inline small items
3180
+
3181
+ **Why this shape.** Tiers are same-shaped rows (1–4). A tier's priceType decides what shows: a fixed tier shows the amount and billing interval, a custom tier swaps in a Contact sales button, so a tier never shows a price and 'Contact sales' at once. Features are tiny rows (label, included, tooltip) that open inline. The comparison table, currency, the monthly/yearly switch and its discount are Display settings; Three tiers, Two tiers and Comparison table are variants.
3182
+
3183
+ **HTML signals.**
3184
+ - A grid of plan <article>s, each with an <h3> name, a short audience line, a price, a <ul class="checks"> and one CTA <a>
3185
+ - One card flagged with a ribbon or modifier class ("Most popular")
3186
+ - A price <span> with data-monthly / data-yearly next to a role="radiogroup" billing toggle ("Save 20%")
3187
+ - A price in words ("Let's talk") linking to contact sales instead of an amount
3188
+ - A <table class="compare"> with one column per plan and check / cross icons labelled by aria-label
3189
+
3190
+ **Do.** create_component with sectionType 'Pricing' and category 'conversion'; it lands as a draft, then publish_component. Each prop needs a `target` into your own blockJson: tiers → ONE `table` prop targeting props.rows of a `section` block whose children mirror one row, ui { itemLabel: 'Tier', preview: { title: 'name' }, icon: 'tiers' }; its config.fields hold name, tagline, priceType (select, ui.control 'segmented'), price (number) and interval with `showIf` priceType eq 'fixed', contactSales and cta as `button` sub-fields, featured (boolean) and features as a nested `table` of scalars. comparison → a second `table` prop, one row per feature and one key per tier. currency/toggle/discount → ui.tab 'Display'. Variants are sibling components sharing sectionType 'Pricing' and the same prop keys.
3191
+
3192
+ **Pitfalls.**
3193
+ - Store numbers, never '$24/mo'. A price in words is its own text key, shown when the numbers are empty.
3194
+ - The nested features table is the deepest a prop goes: its rows take scalars only.
3195
+ - A tier added to the comparison is a new sub-field, a schema change. No Offer JSON-LD is built from prices; paste it as `schema` on update_page when needed.
3196
+
3197
+ Not carried by a stored component: ai, category, variants.
3198
+
3199
+ ### Bento grid (`core-bento`)
3200
+
3201
+ Tags: slots, polymorphic-array, variants, responsive-layout, media-heavy. Proves: Slot with per-cell layout settings (size, surface) · variants
3202
+
3203
+ **Why this shape.** A bento is a SLOT of 3–9 cells, and each cell is any allowed block (image, stat, quote, text, CTA card, product card, video). Size (1×1, 2×1, 1×2, 2×2) and surface (light, dark, brand) belong to the cell's place in this grid, not to the block, so they are per-slot settings rather than fields on every block. The gap is a design select; four and three columns are variants.
3204
+
3205
+ **HTML signals.**
3206
+ - A grid section whose cells span different column/row counts (wide, tall and large tiles beside small ones)
3207
+ - Cells of mixed kinds in one grid: a photo, a big number with a label, a quote, a product card, a CTA card, a video
3208
+ - Some cells on a dark or brand-coloured surface while their neighbours are light
3209
+
3210
+ **Do.** create_component with a sectionType naming the family (e.g. 'Bento'); each prop needs a `target` into your own blockJson. cells → a `slot` prop with config.multiple, minItems 3, maxItems 9, config.componentIds of your published cell components, and config.slotSettings span (select 1x1/2x1/1x2/2x2, ui.control 'cards') and tone (select light/dark/brand, ui.control 'segmented'); target a container's child list (props.children on a section, props.columns.<n> on a columns block). Each child is `{ componentId, overrides? }` and its settings sit in `_settings`. eyebrow/heading → text props; gap → select with ui.control 'segmented'. 4 columns and 3 columns are two components sharing sectionType 'Bento' with the same prop keys.
3211
+
3212
+ **Pitfalls.**
3213
+ - The kit's allow list names kit blocks; config.componentIds must name this project's published components, and the renderer needs their definitions or every cell draws empty.
3214
+ - A slot child has no `_variant`: a variant is its own component.
3215
+ - slotSettings are leaf fields only; a cell's own copy stays in the cell component's props.
3216
+
3217
+ Not carried by a stored component: ai, category, slot allow, variants.
3218
+
3219
+ ### Journal story (`core-journalStory`)
3220
+
3221
+ Tags: rich-text, references, media-heavy, seo-structured, localized, validation, embed. Proves: An entry: groups, fieldsets, references, rich text, SEO limits, changed-since-publish
3222
+
3223
+ **Why this shape.** A story is an ENTRY, one of many, with its own URL: content in groups (Content, Media, Settings) and fieldsets (Byline, a collapsed Gear review, Publishing). Authors and topics are references to their own collections, not copied names; the reviewed product is a reference to commerce. The body is the one long rich-text field. Layout, accent, internal tags, related stories and developer-only custom CSS are Settings. It proves references, rich text, SEO-friendly limits (title, excerpt) and changed-since-publish.
3224
+
3225
+ **HTML signals.**
3226
+ - An <article> (often itemtype BlogPosting) with an <h1>, a dek <p>, a byline with <a rel="author"> and <time datetime>
3227
+ - A cover <figure> with <img> and <figcaption> above a prose body of <h2>, <p>, lists, figures, tables and code
3228
+ - A topic link above the title and a reading time ("7 min read") in the byline
3229
+ - One of many pages under a shared route such as /blog/<slug>
3230
+
3231
+ **Do.** Not a component: create_content_model for the stories (a collection with urlPattern '/blog/:slug' and one dynamic page), and create_content_entry per story. Find or create the author and topic entries first and reference them by id. title, slug, excerpt, cover, gallery and film are fields; body is the ONE `document` field (top level, at most one per schema); dates are ISO 8601 with their offset. Publish authors and topics, then the post, then the template page. The `component` shape below is props-shaped only for comparison; if you place a story teaser as a component, each prop needs a `target` into your own blockJson.
3232
+
3233
+ **Pitfalls.**
3234
+ - Never model SEO fields: entry meta (metaTitle, metaDescription, og, canonical) goes in update_content_entry `meta`, and BlogPosting markup is automatic for a collection with a body, a date and an author.
3235
+ - Reading time is computed by the template; no field stores it today, so don't copy "7 min read" into readingTime.
3236
+ - Bind the body wrapper as kind `document`, never `richtext`, or a save repaints the article from plain text.
3237
+
3238
+ Not carried by a stored component: ai, kind:entry (props), reference target.
3239
+
3240
+ ### Hero (`core-hero`)
3241
+
3242
+ Tags: variants, media-heavy, localized, a11y. Proves: Buttons as one unit · inline proof object · cards, segmented and slider controls
3243
+
3244
+ **Why this shape.** The opening band: an eyebrow, a headline (required, 90 max, 60 recommended), supporting text, two buttons and a proof line of rating plus label. Each button is one unit, so label, link and look travel together. The proof is a small object because the stars are drawn from the number. Media (a required image, an optional background video) and Design (alignment, theme, overlay, fill-the-screen) are their own tabs, so copy, media and look never mix; split, centered and full-bleed are variants.
3245
+
3246
+ **HTML signals.**
3247
+ - The first <section> after the header with one <h1>, a short eyebrow <p> above it and a lede <p> below
3248
+ - Two links styled as buttons (btn-primary / btn-ghost), sometimes with an arrow icon
3249
+ - A <picture>/<img> beside the copy (split) or absolutely positioned behind it with a dark scrim <div> (full-bleed)
3250
+ - A rating line: star glyphs with aria-label="Rated 4.8 out of 5" and "from 310 reviews"
3251
+
3252
+ **Do.** create_component with sectionType 'Hero' and category 'hero'; root is a `section` block with `style`, content in props.children. Each prop needs a `target` into your own blockJson: eyebrow/body → text props on text blocks (props.html), heading → props.text of a level-1 heading block, primary/secondary → one `button` prop each (label, link and look in one unit) on a button block's `props`, image → an `image` prop on an image block's props.src plus a text prop on props.alt, video → a video block's props.url, proof → an `object` prop with config.fields rating (number) and label (text). Put image/video in ui.tab 'Media' and align/theme/overlay/fullHeight in ui.tab 'Design' (theme with ui.control 'segmented'). Split image, Centered and Full-bleed image are three components sharing sectionType 'Hero' with the same prop keys.
3253
+
3254
+ **Pitfalls.**
3255
+ - Overlay and height have no section token today: the scrim and spacing stay in the template's classes unless the template reads those props.
3256
+ - object-position is the image's focal point (50% 35% → { x: 0.5, y: 0.35 }); store the number 4.8, never the star glyphs.
3257
+ - A variant that renames a key (title vs heading) silently drops that content on a swap.
3258
+
3259
+ Not carried by a stored component: ai, variants.