shop-build-section
Build customizer-editable storefront sections on this multi-tenant content platform via the shop MCP server. Produces a Liquid section with a COMPLETE {% schema %} (per-section settings + blocks + presets), wired to the global theme palette (settings.* CSS vars), then places it on a page template so it shows in the theme customizer. Use when the user asks to add/build/edit a storefront section, a hero/banner/feature/FAQ/testimonial block, or make part of a page editable. ALWAYS ask which fields should be editable and which global tokens to use before building.
Build sections through the shop MCP (build_section, set_section_settings, set_global_settings, list_theme_files, get_theme_file, upsert_theme_file, customize_theme, publish_draft_theme, create_theme_preview, clear_storefront_cache).
Read
shop-core-rulesfirst. This skill is the MCP-live "how to author one section" recipe; the cross-cutting rules are NOT repeated here: §B extract design spec · §C images · §D verify-on-draft (the gate) · §E links · §F design contract (theme.js + theme-settings-vars + tailwind.css + Style group) · §G palette tokens + §G.2 Tailwind utility classes (PREFERbg-accent/py-section/md:grid-cols-3; never hard-coded hex or default-palette utils likebg-blue-600) · §H NO-HARDCODE + render rules (5 menu handles, per-template data keys, product-block values,collection/productsettings don't resolve) · §I durability. Deep Liquid theory (inheritance, Pattern-B i18n, locale files) = Layer-2 storefront-theme-design. Requireswebstore:write;get_contextreports the activetheme_id.
0. Learn the repo's Liquid conventions FIRST (don't guess)
Before writing any Liquid, ground yourself in how this codebase writes sections — the read tools are your few-shot examples:
list_theme_files { path_prefix: "sections/", include_inherited: true }— what sections exist + names.get_theme_file { path: "sections/<close-to-what-you-need>.liquid" }— read 2–3 real sections end-to-end and copy the conventions: the wrapper marker (data-section-v2), container/spacing, BEM-ish classes; CSS-var tokens (core §G) — never hard-code hex/fonts; snippet reuse ({% render 'card', item: r %}),{{ block.shopify_attributes }}in block loops; the exact{% schema %}shape.get_theme_file { path: "layout/theme.liquid" }once to see the available globalsettings.*tokens.
Match those conventions — a design-faithful section that also looks native to the codebase.
⚠️ Aspect-ratio on a stretched grid/flex item overlaps its neighbor. Never put aspect-video/aspect-[…] on the grid CELL itself when the row has items-stretch siblings: the stretch fixes the height, the ratio recomputes the width beyond the cell's track, and the media spills over the next column. Put the ratio on an inner w-full wrapper, or make the cell relative with an absolute inset-0 object-cover image and let the content column drive the row height.
Reuse the BASE data sections — don't reinvent them (core §H)
Before building a custom list section, check the inherited base sections (list_theme_files {include_inherited:true} + get_theme_file). The base ships data-driven sections with the right admin pickers:
- a content-records grid — a content-type picker + a loop over the
contentDrop ({% for r in content.<type> %}) for a records listing on any page.
Reusing them gives the merchant the real picker. Setting types: a content-type picker names the type; loop its records via the content Drop (core §H).
Content-type list grids — use the SERVER-SIDE filter/search/sort, not client JS
When the section is a content-type list page (it loops records), the storefront filters/searches/sorts/paginates server-side from URL query params — and rendering the controls is MANDATORY (the write is REJECTED without them, section-list-controls): ONE plain <form method="get"> with a q input (value="{{ search }}"), facet checkboxes from content_type.fields[].options (checked via filters[f.code] contains o), a sort select bound to {{ sort }}, plus the pagination nav after the grid — let the GET reload work, no client JS. Style the controls in the site's design language. Full grammar + snippet: shop-create-content-type ("Filter, search, sort & pagination").
PRESERVE core functionality when overriding an existing section
Rewriting an existing section — especially chrome (header/footer) — is RESTYLING, not rebuilding. Read the original (get_theme_file) and keep every functional piece, changing only markup/styles: the mobile nav drawer, locale/language switchers, announcement rotation.
⚠️ The nav-menu snippet ships NO CSS of its own. {% render 'nav-menu' %} emits bare ul.nav-menu > li.nav-item > a.nav-link (+ .nav-submenu for dropdowns); the styles live in the BASE web-header.liquid's own <style> — which your override REPLACES. Copy that nav CSS block into your section's <style> (desktop .web-nav .nav-menu{display:flex;list-style:none;…} + dropdown rules; drawer .nav-drawer__links .nav-menu{…}) and keep the web-nav / nav-drawer__links wrapper classes on the <nav> elements — or re-author those selectors in your design. A nav-menu render with no .nav-menu CSS = the menu shows as a plain bulleted list, and the write is REJECTED (section-nav-menu-css). These are platform CORE — do NOT drop them because they weren't in the visual; restyle and keep working. Only omit one the design explicitly excludes; when in doubt keep it and ask. Core §D must confirm they still work.
Logo = admin config, not a hard-coded SVG. The header AND footer must render the admin store logo {{ shop.logo_url }} (set in Settings ▸ Branding; exposed to Liquid even though list_liquid_objects omits it), with an optional per-theme {"type":"image","id":"logo"} override + logo_width range: {% assign logo_src = section.settings.logo | default: shop.logo_url %}{% if logo_src != blank %}<img src="{{ logo_src }}" alt="{{ shop.name | escape }}">{% else %}<text/icon fallback>{% endif %}. Baking only a wordmark/SVG = the merchant's admin logo never shows. Full rule + snippet: core §H "Logo MUST come from config".
Interactivity must be REAL — use the base theme's hooks (core §D verifies this)
A section that looks right but whose slider doesn't slide / accordion doesn't open is a FAIL. The base theme ships a full interaction layer in assets/theme.js (linked in layout/theme.liquid), auto-wired via data-* attributes. Read it (get_theme_file { path: "assets/theme.js" }) and reuse the hooks:
- slider →
data-slider,data-slider-track,data-slider-slide,data-slider-prev/-next,data-slider-dot,data-autoplay+data-interval(seesections/slider.liquid). - tabs →
data-tabs/data-tabs-trigger/data-tabs-panel. accordion/FAQ →data-accordion/data-accordion-item/data-accordion-icon. - mobile nav drawer →
data-drawer/-open/-close. scroll-reveal →data-anim. - Mark JS-hydrated regions
data-island(+data-context) so behavior re-inits for injected content.
Cart-like flows (WebPick) — order requests without payments
The platform has NO cart/checkout, but the base theme ships WebPick (assets/theme.js, localStorage, auto-wired like the other data-* hooks). WHEN to use — the brief implies picking SEVERAL items before ONE contact step: food/drink ordering, multi-room/tour/service booking requests, quote requests over a product list, wishlists. When NOT: single-item inquiries (a plain form is simpler) and anything implying real payment — never fake a checkout/price-total-as-invoice step; the flow ALWAYS ends in an is_form submission the merchant follows up manually.
Wire it with markup only (JS is already there; clicks are delegated so injected content works):
- On each item card:
<button data-pick-add data-pick-id="{{ r.slug }}" data-pick-title="{{ r.title }}" data-pick-price="{{ r.price }}" data-pick-image="{{ r.image }}">Add to order</button>(data-pick-metafor a variant note). - Chrome/header: a
data-pick-openbutton with a<span data-pick-count hidden>badge. - A
[data-pick-drawer]slide-over (same contract as the nav drawer) containing[data-pick-list](WebPick rendersul.pick-listrows with qty/remove controls — structural CSS ships in theme.css, restyle via.pick-list/.pick-item/.pick-qty/.pick-removescoped to your section),[data-pick-total](currency suffix viadata-pick-currency),[data-pick-empty]for the empty state. - The submit step: an is_form content type (name/phone + a required
itemstextarea field) + a<form data-pick-form>— on submit WebPick fills its[name="items"]field with a readable per-line summary; keep the field as a visible read-only textarea or a hidden input. CallWebPick.clear()in your AJAX success handler. - JS API for anything custom:
window.WebPick.{items,add,inc,remove,clear,count,total,serialize}; listen to theweb-pick:changedocument event.
⚠️ CLOSE THE FLOW — every part below is MANDATORY; each missing one is a repeat of a reported bug:
0. The [data-pick-drawer] panel must EXIST in the theme (author it in the header or footer chrome). theme.js ships the ENGINE only — it toggles the drawer YOU wrote; a data-pick-open button with no [data-pick-drawer] element anywhere renders a cart button that does NOTHING when clicked (theme.js injects a bare fallback panel as a last resort, but the designed drawer is your job).
- Drawer CTA → the form itself. The drawer's checkout CTA links to the pick form's ANCHOR —
href="/pages/<order-page>#<form-section-id>"(put thatidon the form section's outermost wrapper). A bare page URL drops the visitor at the TOP of a long page (hero first, form 2–3 screens down) and the button reads as dead. Same page?href="#<id>"is enough. Adddata-pick-closeon the CTA so the drawer shuts as it navigates. - Inline confirmation on success. The pick form submits via AJAX (
/api/storefront/forms/submit, FormData +X-Requested-With: fetch) and on success shows a clear confirmation near the form in the site's language — what was picked and that the merchant will contact them. Never a silent field reset, never a full-page navigation. - Reset the pick state. The success handler calls
WebPick.clear()— the count badge returns to 0 and the drawer shows its empty state. A stale badge after a successful order reads as "my order didn't go through". - The items field the merchant READS carries
WebPick.serialize()text ("1× Tên món — 1.650.000₫" per line) — NEVERJSON.stringifyof the raw pick array; raw JSON in the inbox is unreadable for the merchant. - Search controls must SEARCH. A search icon/box in the design wires to a GET form targeting a content-type list route (
<form method="get" action="/<entity>"><input type="search" name="q">— the route searches server-side); no searchable list on the site → drop the icon. Never ship a search button that opens nothing. - No hand-rolled cart. Never re-implement the pick list in section/footer script (in-memory arrays die on navigation and double-fire against the delegated engine) — author markup hooks only and let theme.js drive.
If an interaction has no hook, write real JS in theme.js style (IIFE on DOMContentLoaded, re-binds for islands) — not a CSS-only fake. Use real data (the content Drop for record loops). If you override layout/theme.liquid, you MUST keep theme.js + the theme-settings-vars block — core §F (the write guard rejects a layout missing either).
1. Gather requirements — ASK before building (then core §A / §B)
ONE section = ONE design block (core §K). A section is a hero, OR a feature grid, OR an FAQ — NOT a whole page. Building a page = placing SEVERAL small sections in order; NEVER cram hero+features+FAQ+CTA into a single monolithic section (the merchant then can't reorder/toggle/restyle the blocks — it defeats the customizer). Header & footer are GLOBAL chrome — restyle the existing header/web-header + footer/web-footer sections (core §H), don't rebuild them per page.
Every section MUST be customizer-editable: each piece of content the merchant might change is a {% schema %} setting. Confirm (batched):
- Purpose & layout — what it shows (hero, feature grid, testimonials, FAQ, CTA…).
- Which fields editable — each: label, type (
text,richtext,image_picker,color,range,select,url,content_type…), sensible default. Every label SAYS what it edits, in the site's language ("Heading", "Hero background image" — in the site's language) — 2+ auto-numbered labels ("Image 1", "Heading 2"…) get the write REJECTED (core §F); same-shape repeats are blocks, never numbered settings. - Repeatable items? — N of something (features, slides, logos) = blocks (own settings +
limit). Items that themselves contain a list of sub-items (a menu of dropdowns, a footer of link columns, tabs) = nested blocks (§2b). - Which global tokens — which palette tokens (core §G); prefer tokens over hard-coded colors.
- Which page —
index,page,content-list,content-detail, etc., and position.
Vague ask ("add a hero")? Propose a concrete setting list and confirm first.
2. Build the section
Pass a spec to build_section. Provide a real Liquid body when you can; otherwise the generator scaffolds from the settings.
build_section {
spec: {
name: "Hero Banner",
category: "content",
settings: [
{ type: "text", id: "heading", label: "Heading", default: "Welcome" },
{ type: "richtext", id: "subtitle", label: "Subtitle" },
{ type: "image_picker", id: "bg", label: "Background image" },
{ type: "url", id: "cta_url", label: "Button link" },
{ type: "text", id: "cta_label", label: "Button label", default: "Learn more" }
],
blocks: [
{ type: "stat", name: "Stat", limit: 4, settings: [
{ type: "text", id: "value", label: "Value" },
{ type: "text", id: "label", label: "Label" } ] }
],
body: "<section class=\"hero py-section bg-paper text-ink\" data-section-v2><div class=\"max-w-7xl mx-auto px-gutter\">…{{ section.settings.heading }}…<div class=\"grid md:grid-cols-4 gap-gutter\">{% for block in section.blocks %}…{% endfor %}</div></div></section>"
}
}
Rules the tool enforces / you must follow:
- Per-section values
{{ section.settings.<id> }}; block values{{ block.settings.<id> }}inside{% for block in section.blocks %}. - Use global tokens (core §G), not hard-coded color/spacing/font literals. PREFER the token-mapped Tailwind utility classes (core §G.2) for layout/spacing/sizing/type/responsive (
py-section bg-paper text-ink,max-w-7xl mx-auto px-gutter,grid md:grid-cols-3 gap-gutter,font-display text-3xl md:text-5xl); a<style>block is for what utilities can't express. Color utilities are token-backed only (bg-accent/text-ink) —bg-blue-600/bg-[#fff]are REJECTED. - A
presetis always added so it shows in "add section". - The
{% schema %}is validated (balanced tags, unique ids, known types) — same as the core §F design contract (upsert_theme_filevalidates hand-written schemas too). - The canonical Style group is AUTO-INJECTED if your spec omits it:
visible,bg_color,text_color,heading_color,padding_top,padding_bottom,border_color,border_width,border_radius. The render engine consumes these on the wrapper — do NOT reference them in the body or reuse the ids. - Pattern B (localized-by-default yet editable): schema default
""+{{ section.settings.x | default: 'i18n.key' | t }}— Layer-2 storefront-theme-design.
2b. Nested / child blocks (item-within-item)
A block can itself hold child blocks — an item inside an item, to ANY depth. This is how you build things a flat block list can't: multi-level nav menus / mega-menus (a "dropdown" that holds nav links, and dropdowns inside dropdowns), footer link columns (a "column" with a heading + its own links), tabs / accordion groups (a "tab" holding its content items), nested card groups. Reach for it whenever the merchant needs "a thing that contains a variable list of sub-things."
Declare children by giving a block its own blocks: [...] (recursive) + optional max_blocks:
build_section { spec: {
name: "Site Header", tag: "header",
blocks: [
{ type: "link", name: "Nav link", settings: [
{ type: "text", id: "label", label: "Label", default: "Link" },
{ type: "url", id: "url", label: "Link URL", default: "/" } ] },
{ type: "dropdown", name: "Dropdown", max_blocks: 12,
settings: [ { type: "text", id: "label", label: "Label", default: "Menu" },
{ type: "url", id: "url", label: "Link URL (optional)" } ],
blocks: [ { type: "@theme" } ] } // ← accepts children (see below)
],
body: "<nav class=\"web-nav\">{% render 'nav-menu', blocks: section.blocks %}</nav>"
} }
{ type: "@theme" }as a block's only child entry = "accept every top-level block type this section declares." It is the self-referential hook for unbounded, self-similar nesting (a dropdown can contain links AND more dropdowns). To restrict children to specific types instead, list them explicitly:blocks: [ { type: "link", name, settings:[…] } ].- Nesting depth is capped at 6 levels (validator).
Render children — a block exposes its children as block.blocks. Two patterns:
- Fixed depth (e.g. a footer column = heading + one level of links): just loop inside the loop —
{% for col in section.blocks %}<h4>{{ col.settings.heading }}</h4>{% for l in col.blocks %}<a href="{{ l.settings.url }}">{{ l.settings.label }}</a>{% endfor %}{% endfor %} - Arbitrary depth (menus): a recursive snippet that re-renders itself for
block.blocks. The base theme shipssnippets/nav-menu.liquidfor exactly this — call{% render 'nav-menu', blocks: section.blocks %}and it walks the whole tree. To author your own: a snippet that takes ablocksparam, loops it, and for any item with children does{% render 'my-snippet', blocks: block.blocks %}.
Seed nested content in a preset (and via set_section_settings) by nesting blocks inside a preset/instance block:
set_section_settings { template: "index", section_id: "header", type: "web-header",
blocks: [
{ type: "link", settings: { label: "Home", url: "/" } },
{ type: "dropdown", settings: { label: "Pages" }, blocks: [
{ type: "link", settings: { label: "About", url: "/about" } },
{ type: "link", settings: { label: "Contact", url: "/contact" } } ] }
] }
The merchant then edits the tree in the customizer's Blocks panel: expanding a parent block reveals its child block list with its own add / reorder / delete.
Schema-completeness checklist (every section must pass)
- Every merchant-visible string/image/link/label is a
section.settings.*/block.settings.*setting — nothing hard-coded in the Liquid. - Repeated items (cards/slides/logos/FAQs/stats) are blocks with a
limit. - Images are
image_pickersettings (URL fromupload_image), never inline<img src="https://…">. - Colors/spacing/fonts use global tokens or token-mapped Tailwind utilities — zero hard-coded hex/font literals and zero default-palette/arbitrary color utilities (
bg-blue-600/bg-[#fff]) (core §G/§G.2; verify the "Theme settings" panel changes the section). - Every setting has a semantic label in the site's language ("Heading", "Booking button (label)" — in the site's language) + sensible default; selects list real
options. Auto-numbered labels ("Image 1", "Heading 2"…) = lint REJECT (core §F) — repeated shapes are blocks. - A
presetexists. - Re-open the customizer and confirm the section + all fields render and edit live.
3. Place it on a page
set_section_settings adds/updates the section instance on a template + per-section settings + order:
set_section_settings {
template: "index",
section_id: "hero",
type: "hero-banner", // = the section file handle
settings: { heading: "Our Latest Work", cta_label: "View all" },
blocks: [ { type: "stat", settings: { value: "10k+", label: "Customers" } } ],
position: 0
}
This makes the section show on the page AND in the customizer with its settings.
Build-and-place each section ATOMICALLY, in visual order (top → bottom). When building a multi-section page, do them ONE AT A TIME:
build_sectionfor section i, then IMMEDIATELYset_section_settings { position: i }for that same section, THEN move to section i+1. Do NOT batch allbuild_sectioncalls first and allset_section_settingsafter — that delays every placement to the end, so the live preview shows nothing until the build finishes and can't fill in region by region. Pass an explicit 0-basedpositionmatching the section's visual index so the pageorderis deterministic and matches an approved mockup even on a retry. (Canonical rule: core §K.)
4. Verify + publish
Apply the full core §D gate: work on a draft (customize_theme), screenshot_preview { path, theme_id:"<draft>", section:".selector" } (or omit section for full page) at mobile/tablet/desktop, capture each section incl. header/footer, grade attribute-by-attribute against the §B spec (any mismatch = FAIL, 3–5+ iterations), exercise interactivity, walk every link (core §E). Only once it matches: publish_draft_theme → clear_storefront_cache. A green mutation is not "done"; a screenshot passing the §B checklist is. Durability: core §I.
