shop-core-rules
The canonical cross-cutting rules every shop-MCP build/redesign skill depends on — confirm-the-plan gate, extract-design-spec protocol, image-source choice + re-hosting, the verify-on-draft screenshot gate, every-link-resolves, the layout/section design contract (theme.js + theme-settings-vars + Style group), the palette→CSS-var token map, NO-HARDCODE + storefront render rules (the 5 menu handles, per-template data keys, product-block values), and durability (live vs not-baked). Read this FIRST whenever you build or restyle a store, a section, products, or content via the shop MCP — the other shop-* skills reference these §A–§M anchors instead of repeating them.
These are the rules that apply to every build, redesign, section, or content task. They live here ONCE; the focused skills (shop-builder, shop-redesign, shop-build-section, shop-create-content-type, shop-content-site) point to the §-anchors below instead of restating them. Read this before building anything.
This platform is CONTENT-ONLY — CMS pages + custom content types/records + theme sections. There are NO products, collections, cart, checkout, payments, discounts, reviews, or menus, and NO tools for any of them. Ignore any commerce wording; navigation is the header/footer link_list block.
Layering (so you know which skill does what):
- Layer 0 — this file: the cross-cutting rules.
- Layer 1 — MCP-live authoring: drive a running store over GraphQL tools (the focused skills above). Writes are live but not baked (§I).
§A — Confirm the plan BEFORE executing (never auto-run)
Building or restyling a store is many writes, some hard to undo. Do NOT create/mutate anything until the user approves a concrete plan. After gathering requirements, present a short written plan and WAIT for a clear go-ahead:
- What will be created/changed: brand palette/fonts (the extracted hex, §B), the content types + fields, how many demo records (say they're demo), the sections/pages, the nav structure.
- Scope: that this writes live data to THIS store (org/store from
get_context), and whether sample data is seeded. - Then ask plainly ("Build this? or tell me what to change"). Execute only after an explicit yes. If scope changes mid-build, re-confirm — don't silently expand.
This is a gate, not a status update — the user must be able to say no first. (Redesign of a live store raises the bar further — see shop-redesign.)
§B — Extract the design into a measurable spec FIRST
The #1 reason a build "doesn't look like the design" is skipping this: building from a vague mental image, then eyeballing "close enough". Turn the design into a written, measurable spec that is BOTH the build target AND the §D grading checklist.
- Get the real reference in front of you — never build from memory.
- Image/mockup/screenshot → Read it (re-open it every comparison; don't trust recall).
- Live URL → screenshot it (WebFetch / a browser) and read that.
- Only a text brief → you have no concrete target: draw an HTML mockup first via the draw-first gate (
shop-design-concept) and get it APPROVED. The approved mockup HTML — authored in the §G.2 Tailwind vocabulary, rendered in the store's real CSS — then IS this measurable reference (palette hex from its:root, fonts, per-section layout, copy, and thedata-mock-regionsection order). Build to it; verify each section against it (§D).
- Extract an exact spec, per section (write it down):
- Layout: structure & grid (columns, asymmetry, full-bleed vs contained, image-left/right, overlap, sticky), section order, vertical rhythm.
- Color: EXACT hex for bg, text/ink, accent, borders, button bg/text, hover. "Warm beige" is not a spec;
#F3EDE3is. - Type: heading vs body family, weights, size scale (h1/h2/body/caption), letter-spacing, case, line-height.
- Spacing & shape: section padding, gaps, container max-width, radius, border width, shadow.
- Imagery: aspect ratios, treatment (full-bleed/rounded/duotone), icon style (source real images per §C).
- Components: button shape/label style, card anatomy, badge/chip style, nav layout.
- Build to the spec, not to defaults. Set the global palette to the extracted hex/fonts FIRST (§G), then write custom Liquid/CSS to hit the layout. A stock preset that's only "close" WILL look generic — rewrite the markup. Reusing a default preset with tweaked text is the most common cause of "looks like the original theme, not the design".
§B.1 — The "homepage quality bar" (the canonical definition)
Many rules (the content-type surface contracts, the mockup gate) demand a surface be built "to the homepage quality bar". That bar is ABSOLUTE, not relative to whatever homepage got built — a page passes it when ALL of these hold:
- ≥ 3 distinct layout shapes on the page (e.g. split hero + asymmetric feature + band + grid) — at most ONE plain equal-card grid; two adjacent sections never share the same shape AND background tint.
- ≥ 3 type levels per section (eyebrow → heading → body), headings in the display font with a clear size jump over body; body measure ≤ 65ch.
- The token palette actually used (§G): at least one accent-painted band/CTA moment, contrast per §G.1's numbers — never a near-white page with one small accent button.
- One spacing rhythm (§G.2's scale): generous section padding, a consistent container + gaps, nothing cramped or touching.
- Real content: real copy in the merchant's language (no lorem/filler), real bound data (no placeholder cards left), real imagery per §C — and designed EMPTY STATES for data surfaces (an empty search/content list shows a styled message + CTA, not a blank band).
- Interactive states: hover/active on every interactive element, working nav/hamburger, motion per taste §6 where the design calls for it.
A surface missing any of these is below the bar — thin, unfinished — and fails §D even if every binding works.
§C — Images: Pexels stock by default, re-host (never hotlink)
Pexels stock photos are the default image source for every slot (hero/section/product/blog/content) via search_stock_images — pick a candidate URL, write it into the image setting, it is auto re-hosted. Do NOT ask the user which source to use. The other sources below apply only when the situation already supplies them:
- The store's own media — when the brief carries a REAL IMAGES list (the user's ingested/uploaded photos), or the user hands you URLs/uploads, those are the source for the slots they match →
upload_image. Stock is the fallback only for slots with no matching own photo. - Design-crop — when an approved design image exists (mockup or user reference), photo regions can be CROPPED OUT of it (located by vision, re-hosted) so the site ships those exact visuals.
- Unsplash — stock via real photo IDs (
images.unsplash.com/photo-<id>?w=1200); only when the design already references Unsplash URLs. - Real UI screenshot — capture an actual product screen with Chrome/Playwright when a live UI must be shown.
- Placeholder fallback (
loremflickrkeyword /picsumseed) — only for throwaway demos.
⚠️ "Image source" NEVER means "re-create the visuals as HTML/CSS markup." Every choice above OBTAINS a real image file. Never propose redrawing the UI in markup as an image source.
If Pexels isn't configured (.env has no PEXELS_API_KEY), STOP and ask for a key — don't guess, skip images, or silently fall back.
Re-host everything; never hotlink a third-party URL. Every image must live on our own media library (/storage/...). Run external URLs through upload_image { url, folder } / upload_product_image and use the returned store-owned URL. The MCP server auto-enforces this: any foreign image URL passed to a write tool (product image_urls/attrs, collection image_url, content values, blog featured_image_url/body, section/global theme settings, upsert_theme_file, even graphql_mutation variables) is fetched + re-hosted automatically — and a dead/blocked/timeout source URL fails the whole write. So pass clean, reachable URLs (Pexels preferred). Calling upload_image yourself first is still clearest and lets you reuse the hosted URL. Pick images that match the design's subject + treatment (§B); a blank/grey slot in §D = fail.
§D — Verify VISUALLY on the DRAFT — the mandatory gate (EVERY page, not just home)
Never declare success or publish without looking. Work on a draft (customize_theme → draft theme id; the live site keeps serving the old theme), then screenshot_preview { theme_id:<draft> } and read the images.
- Every page/template, not just home. Enumerate and screenshot-verify the full set the build touches: home; every content list + detail; blog list + a post; every static page (about, contact, FAQ, policies); search; 404; plus header & footer. Walk the menu + every internal link to discover pages — none skipped.
- Every section individually, incl. header & footer. Use the
sectionselector:section:"header", each content wrapper (section:".brand-hero",section:"[data-section-v2]"),section:"footer"— at mobile / tablet / desktop. Plus a full-page shot per page. Read every image. - Grade against the §B spec, attribute by attribute — not "does it look nice". Open the reference AND the draft side by side; for each section mark match/MISMATCH on layout & grid, colors (actual rendered hex vs spec), fonts (family/weight/scale/case), spacing/radius/border/shadow, imagery, components, exact copy. Any single MISMATCH = FAIL. "Roughly similar / close enough / the vibe is right" is NOT a pass.
- Iterate. Fix the failing attributes, re-screenshot, re-grade. Expect 3–5+ iterations; a first build rarely matches. Re-read the reference each loop (don't drift from memory). Biggest hard fail: it looks like the stock/original theme (a default preset reused with new text) → rewrite the markup. Also fail: empty record grids/lists, leftover template chrome (wrong logo/copy), mobile overflow, default palette instead of the extracted colors.
- Interactivity must be REAL — a screenshot is static. For every interactive element (slider/carousel, tabs, accordion, mobile nav drawer, filters, form): (1) confirm the rendered HTML carries the correct
data-*hooks ANDassets/theme.jsis loaded (curl the draft, grep the hook + the script tag); and (2) actually exercise it in a real browser (Playwright/puppeteer/chromedp — click next-slide, open the drawer, toggle a tab, submit a form) and confirm state changes. A dead slider / inert button = FAIL. Seeshop-build-section(§ "Interactivity") for the base-themedata-*hooks. - Every link resolves — see §E. Bind to live data — see §H.
- Diverging from the design, or the design doesn't cover a page/element → STOP and ASK the user; don't improvise. If a required page (a content list/detail, a policy page) has no counterpart in the reference, or you must deviate, pause and ask how it should look — never invent an unapproved look and continue.
- Only once the FULL checklist passes:
publish_draft_theme, thenclear_storefront_cache. Confirm sections + fields are editable in the customizer.
A green mutation is not "done"; a screenshot passing the full §B attribute checklist is.
§E — Every link must resolve (200, check the body) — never invent a path
Any href in a section/button/CTA/menu/nav/card/"view all"/footer MUST point to something that exists. Do NOT guess slugs (/collections/bestsellers, /products/foo, /pages/about). Derive every path from REAL data:
- pages from
create_page/publish_page; blog fromlist_blog_posts; content records from the type'surl_pattern.
After building, walk every link on every page and confirm each returns 200, not 404 — check the response BODY, not just the HTTP code (a soft 404 streams a 200 shell then notFound()). Fix or remove any dead link. A page that looks right but has 404 links is NOT done.
§F — The design contract (writes are REJECTED, not warned)
The backend enforces a hard contract on EVERY theme-file write path — upsert_theme_file, build_section, the raw graphql_mutation escape hatch, even the admin code editor. A violating write returns an error with the exact fix and nothing is saved. Don't try to bypass via graphql_mutation — the same rules run inside the backend.
layout/theme.liquidmust contain:<script src="/assets/theme.js" defer></script>,{{ content_for_layout }},{{ content_for_header }}, the<link rel="stylesheet" href="/assets/tailwind.css">(the static utility bundle — a custom layout that omits it loses every Tailwind class → broken layout), the<body data-store-slug="{{ shop.slug }}" data-currency-code="{{ shop.currency }}">data attributes (theme.js readsdata-store-slugfor the WISHLIST add/remove — omit it orshop.slugand every wishlist toggle errors), AND the<style id="theme-settings-vars">block (+ the dynamic Google-Fonts<link>), placed AFTER thetheme-skin.csslink. SEO IS DYNAMIC, not hard-coded: keep<title>{{ page_title | default: shop.meta_title | default: shop.name }}</title>+<meta name="description" content="{{ page_description | default: shop.meta_description | default: shop.description }}">(theshop.meta_title/meta_descriptionfallbacks carry the store-level SEO) — these (and{{ content_for_header }}'s og/canonical/JSON-LD) render the per-page meta you set viaupdate_store_branding+ page config. A hard-coded<title>My Store</title>orcontent=""(e.g. carried over from the mockup's literal head) FREEZES SEO — the config never shows and "fixing SEO" later does nothing (the #1 content-site SEO bug). Best: don't rewritelayout/theme.liquidon a content site at all — the base already has the dynamic head +{{ content_for_header }}+{% section 'web-header' %}; put custom fonts/palette in theme settings (set_global_settings) +theme-skin.css. Hard-coded<title>/<meta description>in the layout is now REJECTED by the write linter.- theme.js = ALL interactivity + the editor's click-a-section-to-scroll. Drop it and both die.
- theme-settings-vars block = the only thing that makes the customizer's "Theme settings" panel work. That panel is HARDCODED to the canonical
color_*/font_*/space_*/radius*/shadow_*keys (§G) and does NOT readconfig/settings_schema.json; the keys take effect ONLY through this block. Omit it → the panel saves but visually changes NOTHING (the "global settings don't map" bug). Copy both verbatim from the base /web-baselayout. <link rel="stylesheet" href="/assets/tailwind.css">— the token-mapped Tailwind utility bundle (§G.2). Both base layouts ship it; if you author a FULLY CUSTOM layout, add it AFTER thetheme.css/theme-skin.csslink (so utilities override component CSS) or every utility class in your sections silently does nothing.- Interactivity MARKUP + hooks:
theme.jsbinds to MARKUP the layout + sections ship — it's not pure JS. When you rewritelayout/theme.liquidKEEP the mobile nav-drawer (data-drawer/data-drawer-open/data-drawer-close). When you rewrite/build a section KEEP the per-element hooks theme.js wires: the nav drawer +data-anim(scroll-reveal). Any section-specific interaction (tabs / accordion / carousel) gets a small inline<script>INSIDE that section, wrapped in try/catch. Strip the drawer or motion hooks and the mobile menu or scroll-reveal silently die. - 🍔 The mobile hamburger — NEVER hand-roll it; ZERO JavaScript.
theme.jsalready wires the mobile menu by event-delegation: a click on ANY[data-drawer-open]opens the single[data-drawer];[data-drawer-close](scrim + X) + Escape close it; body-scroll locks. You write ONLY markup — a<button data-drawer-open>hamburger (shown on mobile via CSS) + a<div class="nav-drawer" data-drawer hidden>with a<div data-drawer-close>scrim, adata-drawer-closeX, and<nav class="nav-drawer__links">looping the header'slink_listblock ({% for l in section.settings.<link_list_id> %}…{% endfor %}). The contentweb-header.liquidships exactly this —get_theme_fileit and keep the hooks. ❌ Do NOT writegetElementById('burger')/addEventListener('click', …classList.toggle('open'))/ a#mobileMenudiv / a second click handler — hand-rolled hamburgers are the top broken-menu cause (two handlers cancelling to open-then-close, a drawer with nodata-drawer-open, adisplay:none!importantbeating.open, inline JS dying on one error). Exactly ONE element — the hamburger — getsdata-drawer-open. - 🔝 Sticky header — keep it permanently visible. The base header is
position:sticky;top:0;z-index:50, so it stays on screen as you scroll. You MAY toggle a class on scroll for STYLING ONLY (a shadow / solid background past ~60px). Do NOTtranslateYit off-screen,position:absoluteit, or write a hide-on-scroll-down handler unless the show-on-scroll-up branch is symmetric and verified in BOTH directions. The recurring live bug ("header doesn't come back when I scroll up") is a custom scroll handler that hides the bar going down but never reveals it going up. Simplest correct header = sticky + always visible; for a condense effect change only background/shadow/height via a toggled class, never unpin the element. ⚠️ Transparent-over-hero contrast (a real shipped "menu disappears" bug): if the header starts transparent with LIGHT text (color:var(--paper)) over a dark hero, the scrolled/.solidstate MUST set BOTH an opaque background AND a flipped text color — e.g.#nav.solid{background:var(--paper);color:var(--ink)}— so links stay legible once the hero scrolls away. Fading a dark scrim's opacity to 0 is NOT enough (that removes the only thing making the light text readable → white text on white page = invisible nav). The scrolled state ALWAYS sets background + color together; never let header legibility depend on a scrim alone. - 💬 Live chat launcher — platform-injected; open it with
data-chat-open, never rebuild it. When a store has live chat enabled (admin Settings → Live chat), the platform injects the floating chat widget (assets/chat-widget.js+window.__SF_CHAT_CONFIG) into EVERY page at render time — do NOT hand-build a chat bubble, a Messenger/Zalo iframe, or a fake "chat" button. To open the chat from ANY element (hero CTA, a consult/contact button, footer link), just add thedata-chat-openattribute (<button data-chat-open>…</button>) — the widget's delegated click handler opens the panel;window.ShopChat.open()/close()/toggle()is the JS API (safe to call before the widget loads — calls queue). The widget follows the store palette tokens automatically (or the admin's custom chat colors); its position/identity form are admin-configured — never override them from a section. - 🎯 Scope every custom CSS selector to its section (fixes the "editing a LATER section breaks an EARLIER section's CSS" bug): the storefront renderer concatenates EVERY section's
<style>into ONE global<style data-storefront-styles>block, so a bare selector (.card,.title,.grid,.btn,.container,section{…},h2{…}) in one section silently overrides that same class in ALL other sections — and since a lower-in-page section's CSS wins the cascade, editing/adding a section then BREAKS the sections above it. Each section renders inside<div data-section-name="<type>" id="section-<type>">, so PREFIX every selector in a section's custom CSS with that wrapper:[data-section-name="<type>"] .card{…}(or#section-<type> .card{…}) — exactly like the base sections (they scope with[data-section-name=…], 100+ times). NEVER emit a bare/global selector from a section. Best: use the Tailwind TOKEN classes (§G.2:bg-accent/text-ink/py-section/font-display…) — shared utilities that never collide — and add scoped custom CSS only for what utilities can't express. When you EDIT a section, keep its scoped selectors scoped; don't introduce a new global one.
sections/*.liquid→ exactly one{% schema %}(valid JSON), aname, ≥1 id-bearing setting, the full canonical Style group (visible,bg_color,text_color,heading_color,padding_top,padding_bottom,border_color,border_width,border_radius— AUTO-injected bybuild_section; consumed by the render engine on the wrapper, do NOT reference them in the body or reuse the ids), unique ids, and every non-Style setting id referenced in the body (section.settings.<id>). Files using{% render %}/section.settings[...]indirection are exempt from the reference rule. Apresetis auto-added so it shows in "add section".- EDITORIAL COPY IS A SETTING, NOT BAKED — the section must be customizer-editable, not just customizer-recolorable. The Style group only lets the merchant change colors/padding/border. The actual WORDS must be editable too, so every editorial value gets its own
{% schema %}setting rendered viasection.settings.<id>(put the intended copy in the setting's"default"— it renders identically AND stays editable). Mandatory settings per section: the eyebrow, the heading/title (+ any italic / second-line part), the subhead/intro paragraph, and every button/CTA as TWO settings (*_label+*_url). A product / featured / "new arrivals" / collection section ALSO declares a{"type":"collection"}setting + alimit(number) (+ optionalcolumns) so the merchant chooses what shows. Repeated sibling items (USP tiles, process steps, testimonials, FAQ Q&A, logo wall, nav-less card rows) are schema BLOCKS — one block type with its settings, looped oversection.blocks— NEVER N hard-coded copies. ⚠️ The classic FAIL (and why this rule exists): the agent BAKES the eyebrow/heading/CTA as literal text in the body (so it references nosection.settings, the schema is "valid" with only the Style group), and the merchant opens the section to find they can recolor it but can't change a single word. A section whose customizer panel shows ONLY the Style group while its copy is baked = REJECTED-in-spirit (a §D/§F fail) — declare those content settings. - SETTING LABELS SAY WHAT THEY EDIT, in the site's language — auto-numbered labels are REJECTED. The customizer shows each
labelverbatim, so"label":"Image 1","Heading 2","Text 3","Button 4"turn the panel into a wall of anonymous inputs the merchant can't navigate. Name every setting for the thing it edits, in the site's language ("Heading","Short description","Hero background image","Booking button (label)"— in the site's language). Two+ numbered labels in one schema = the write is REJECTED (section-setting-labels). Several settings repeating one shape (one per card/tab/day/step) is the signal they belong in a BLOCK with a semantic name — never numbered settings. templates/*.json→ valid{sections, order};orderentries must exist insections; every sectiontypemust already exist in the resolved theme (write the section file FIRST, then the template).config/settings_schema.json→ valid array of{name, settings[]}groups.
§G — Palette → CSS-var tokens (the editable-skin standard)
Set the brand ONCE globally with set_global_settings; sections then inherit it via CSS-var tokens — either referenced in a <style> block (var(--accent)) or via the token-mapped Tailwind utilities (bg-accent, §G.2). Never hard-code hex/font literals in a section.
set_global_settings merges these canonical top-level settings_data keys (others preserved; the nested sections map never clobbered). These exact key names are the ONLY ones mapped to CSS vars AND surfaced by the customizer "Theme settings" panel — invent a name (primary_color, brand) and nothing maps.
settings_data key | CSS var (use in sections) | meaning |
|---|---|---|
color_accent | var(--accent) | brand/primary |
color_accent_soft / color_accent_ink | var(--accent-soft) / var(--accent-ink) | accent tint / accent text |
color_accent_2 / color_accent_2_soft | var(--accent-2) / var(--accent-2-soft) | OPTIONAL second brand hue (bg-accent-2) for a two-color, less-monotone palette — falls back to the primary accent if unset |
color_ink / color_ink_2 | var(--ink) / var(--ink-2) | text |
color_mute / color_mute_2 | var(--mute) / var(--mute-2) | muted text |
color_paper / color_cream / color_white | var(--paper) / var(--cream) / var(--white) | backgrounds |
color_line / color_line_2 | var(--line) / var(--line-2) | borders |
color_sale | var(--sale) | sale price |
font_heading / font_body / font_mono | var(--font-display) / var(--font-sans) / var(--font-mono) | fonts |
space_section / space_gutter | var(--section-y) / var(--gutter) | spacing (px) |
radius / radius_sm / radius_lg | var(--radius*) | corner radius (px) |
shadow_1/2/3 | var(--sh-1/2/3) | shadows |
Always set at least: color_accent, color_accent_soft, color_accent_ink, color_ink, color_paper, color_mute, color_line, font_heading, font_body, space_section, radius — so the panel shows the BRAND, not system fallbacks. Per-design extras (color_mint, radius_pill, …) are auto-surfaced; inspect the theme's assets/theme-skin.css :root{} for the full set. Verify: changing a color in the "Theme settings" panel visibly updates a section — if not, the section hard-codes colors (fix it) or the keys weren't canonical. (How CSS vars are wired end-to-end + adding new tokens: Layer 2 storefront-theme-design.)
FONT ↔ LANGUAGE — the chosen font MUST cover the store's script, or text breaks into fallback/tofu glyphs. The layout loads font_heading/font_body dynamically from Google Fonts (css2?family=<name>&display=swap); if the font has no subset for the store's language, every glyph it can't render falls back per-character (mismatched/tofu boxes). This is the #1 typography defect on non-Latin / accented stores. Rule: match the font to the language detected for the build (same language as the copy — see LANGUAGE rule).
- Latin with stacked diacritics / extended Latin: the ONLY rule is the family must LIST that language's subset on Google Fonts — there are MANY, so DON'T fall back to the same 2-3 every time (that monotony is part of why builds look samey). ⚠️ AVOID the AI-generator cliché fonts — the tell-tale
Fraunces+Space Groteskpairing, and defaulting toInter/Poppins/Montserrat/DM Sanseverywhere — those instantly read as "made by AI". Plenty of sans AND serif/DISPLAY faces qualify for most languages and have more character (Nunito Sans,Hanken Grotesk,Source Sans 3,Lora,Noto Sans/Noto Serif, and display serifs likePlayfair Display,Cormorant,Spectral,Newsreader,Bricolage Grotesque,Instrument Serif— CHECK each family's subset list for the site's language). Always VERIFY the chosen family lists the site language's subset before setting it (some decorative/older cuts don't — those would tofu). Pick a display face with real character for headings; verify, don't blanket-avoid. - CJK (中文 / 日本語 / 한국어): use
Noto Sans SC/TC/Noto Sans JP/Noto Sans KR(or theSerifvariants) — Latin-only fonts render CJK as boxes. - Arabic / Hebrew / Thai / Devanagari / Cyrillic / Greek: pick a font that lists that script (
Noto Sans Arabic,Cairo,Tajawal;Noto Sans Hebrew;Noto Sans Thai,Sarabun;Noto Sans Devanagari; any font withcyrillic/greeksubset). When unsure, theNoto Sans/Noto Seriffamily covers nearly every script — a safe default. - Verify in §D: after setting fonts, screenshot a heading + body paragraph that actually contains the language's accented/script characters and confirm NO fallback/box glyphs. A heading in the wrong font = FAIL.
§G.1 — CONTRAST: text MUST be legible on its own background (a hard rule, not taste)
The #1 readability defect is low-contrast text — pale/muted text on a white, tinted, gradient, or light-accent surface (e.g. grey feature-card copy on a faint purple gradient). Every text element must clearly contrast the surface it actually sits on. Enforce:
- Pick the text token FOR the background, not the default.
var(--ink)(+var(--ink-2)for sub-headings) is for text onvar(--paper)/var(--cream)/white only. On a tinted/gradient/accent card → use a dark ink that still reads on that tint (or a darker custom token); on an accent/dark band → usevar(--accent-ink)/ a near-white. NEVER leave default ink-on-white text sitting on a colored or gradient panel and hope it reads. var(--mute)is for SECONDARY microcopy only (timestamps, captions, helper lines) — NEVER for paragraphs, feature-card descriptions, or anything the visitor must actually read. Body copy =var(--ink)/var(--ink-2).- Target WCAG AA: body/small text ≥ 4.5:1, large headings (≥24px or bold ≥19px) ≥ 3:1 against the background behind them. If a pairing is below that, darken the text or lighten/solidify the surface — do not ship it.
- ICONS must contrast their own tile too. A Lucide icon inherits
currentColor, so an accent-colored icon on an accent / accent-soft chip OF THE SAME HUE is INVISIBLE (the classic "icon blends into its square"). Pair them deliberately: an accent glyph on a neutral/soft chip (var(--cream)/var(--paper)/a faint tint), OR a white/paper glyph (color: var(--white)) on a SOLID accent chip — the glyph color and the chip background must be clearly different. Same for icons sitting directly on a section background. Never letcolorand the chip'sbackgroundresolve to the same token. - ⚠️ NO EMOJI — anywhere, ever (global, hard rule, every build + mockup + redesign). Emoji (🛒 ✨ 🔥 ✅ 🚀 ⭐ 📦 👍 💎 🎉 …) are BANNED across EVERYTHING you author: not as icons, not in the eyebrow/heading/subhead, not in badges/pills/labels, not in button or CTA text, not in body copy, list bullets, USP tiles, testimonials, FAQ, stats, footer, nav, alt text, nor section/page titles. They render differently per OS/browser, break the type rhythm and vertical alignment, clash with the chosen font, garble stacked diacritics on some fonts, and read as AI-generic/template. Use a Lucide glyph (
{% render 'icon', name:'…' %}/ inline<svg>for brand-social) wherever you'd reach for a pictograph, and plain text everywhere else (a real bullet•/–, not 👉). The ONLY exception is genuine merchant/customer CONTENT that already contains an emoji (a product name the merchant typed, a real review) — preserve that, but NEVER author new emoji yourself. A mockup or built section containing an emoji is a FAIL — strip it. - Text over an image / gradient needs a solid or scrim overlay (a semi-opaque dark/light layer) so the contrast holds over the busiest part of the image — never raw text straight on a photo.
- ⚠️ LINKS never the browser default. Tailwind preflight is OFF, so a bare
<a>falls back to blue + underline — off-design (the recurring blue-underlined-links bug). Give EVERY<a>an explicit token color (text-inkfor inline/nav/footer;text-accentonly for a real CTA link; a light token on a dark panel) and no underline unless the design deliberately shows one (then style it — color +underline-offset, not the default). The base CSS resets<a>tocolor:inherit;text-decoration:none, but author it explicitly so it reads right in every context. - ⚠️ BUTTONS must pass contrast like any text (the recurring wrong-button-color / weak-contrast / stray-underline bug). A PRIMARY button = a solid accent fill + a light label (
bg-accent+text-paper/white) — never an accent label on an accent/same-hue chip (invisible), never a pale label on a pale fill. A SECONDARY = bordered/ghost withtext-inkon paper. NO underline on a button (it's not a link). Define the hover state so the label stays legible (set the hover bg, keep the label contrasting). Check each button against WCAG AA. - §D verify includes contrast: on every screenshot, any text you have to squint at, or that nearly blends into its card/background, is a FAIL — fix the token pairing before the section passes. The deterministic QA also flags low-contrast/illegible text as a high-severity defect.
§G.2 — Tailwind utility classes (the layout / spacing / type layer — PREFER these)
Both base themes ship a static, token-mapped Tailwind v4 bundle at assets/tailwind.css (linked after theme.css). PREFER utility classes over hand-written <style> for layout, spacing, sizing, typography, borders/radius, effects, and responsive — they're faster to write, consistent, and already loaded. A <style> block is still fine for anything utilities don't cover (complex gradients, pseudo-elements, keyframes). There is no build step: the bundle is a FIXED vocabulary — a class outside it silently does nothing, and arbitrary values don't work (w-[673px] → use inline style="width:673px" for an exact one-off; colors still go through tokens).
Colors stay on the palette tokens. The bundle exposes ONLY semantic, token-backed color utilities, so a merchant palette change still recolors everything live:
bg / text / border / ring / fill / stroke / decoration+-{accent, accent-soft, accent-ink, accent-2, accent-2-soft, ink, ink-2, mute, mute-2, paper, cream, cream-2, line, line-2, sale, white, black}→ e.g.bg-paper text-ink border-line,text-accent,hover:bg-accent-soft,bg-accent-2. (These map tovar(--accent)/var(--accent-2)/var(--ink)/… — the §G token map.accent-2is the optional second brand hue; it falls back to the primary accent if the store never setscolor_accent_2.)- BANNED (write REJECTED —
section-nontoken-color-class): Tailwind's default palette (bg-blue-600,text-red-500,border-gray-200) and arbitrary color literals (bg-[#fff],text-[rgb(...)]). They bypass the palette AND aren't in the bundle (render nothing). For a merchant-controlled color, add acolorsetting and inline it:style="background: {{ section.settings.x }}".bg-[var(--accent)](token-bound arbitrary) is allowed.
Vocabulary (stay inside it):
- Layout:
flex inline-flex grid hidden block contents,flex-col flex-row flex-wrap,items-* justify-* self-* content-* place-*,grid-cols-1..12 col-span-* row-span-* grid-flow-*,gap-* gap-x-* gap-y-*. - Spacing / rhythm:
p/px/py/pt/pr/pb/pl-{0,px,1,2,3,4,5,6,8,10,12,16,20,24,32,40,48,64,80,96},m*,mx-auto; PLUS rhythm tokenspy-section/pt-section/pb-section(=var(--section-y)) for page-section vertical padding andpx-gutter(=var(--gutter)) for the content gutter. - Sizing:
w-* h-*,max-w-{xs..7xl, prose, full}(usemax-w-7xl mx-auto px-gutteras the standard centered content container),min-h-screen,aspect-{square,video}. - Type:
text-{xs..9xl},font-{normal,medium,semibold,bold},font-display(headings) /font-sans(body) /font-mono,text-{left,center,right},leading-* tracking-* uppercase italic truncate line-clamp-*. - Border / radius / shadow:
border border-2 border-t/r/b/l,rounded rounded-{sm,md,lg,xl,2xl,3xl,full},shadow shadow-{sm,md,lg,xl}+ brandshadow-1/2/3(=var(--sh-*)). - Effects / motion:
opacity-*,transition transition-colors duration-* ease-*,scale-*,overflow-hidden object-cover object-center, positionrelative absolute inset-0 top-0 z-*,backdrop-blur-*. - Responsive (mobile-first): prefixes
sm: md: lg:on layout/spacing/sizing/type —grid-cols-1 md:grid-cols-3,px-4 md:px-8,text-3xl md:text-5xl,hidden md:flex(these REPLACE hand-written media queries, §L). Stateshover: focus-visible: group-hover:on colors/opacity/shadow/scale/underline.
Scroll-reveal is still the data-anim convention (theme.js), not a Tailwind class. The 9 canonical Style-group knobs still apply on the section wrapper (inline styles win over utilities) — keep declaring them (§F). Example section body:
<section class="py-section bg-paper text-ink">
<div class="max-w-7xl mx-auto px-gutter">
<h2 class="font-display text-3xl md:text-5xl">{{ section.settings.heading }}</h2>
<div class="grid md:grid-cols-3 gap-gutter mt-8">
{% for block in section.blocks %}
<article class="rounded-lg border border-line bg-cream p-6 transition hover:shadow-2" {{ block.shopify_attributes }}>
<h3 class="font-display text-xl">{{ block.settings.title }}</h3>
<p class="text-mute mt-2">{{ block.settings.body }}</p>
</article>
{% endfor %}
</div>
</div>
</section>
§H — NO HARD-CODE: bind to live data + obey the render rules
Everything on this platform is admin-managed (CMS pages, custom content types + records, nav links). A section that bakes link <li> HTML or content cards as fixed Liquid/blocks instead of looping the live object is WRONG — it goes stale the moment the merchant edits admin (= an automatic §D fail). Loop the real objects. Editorial design blocks (hero copy, feature tiles, press logos, trust items) ARE legitimately {% schema %} blocks — but anything backed by a DB table (content records) MUST come from its live object.
Navigation — the header/footer nav link BLOCKS, NOT a menu tool
There is no menu tool on this platform. Nav lives in the header/footer section's nav blocks — link blocks (label + url) plus dropdown blocks that hold child link/dropdown blocks for sub-menus (nested blocks, unbounded depth — shop-build-section §2b). The base web-header/web-footer render the whole tree via the recursive {% render 'nav-menu', blocks: section.blocks %} snippet. The merchant adds/edits links in the customizer's Blocks panel; each must point at a REAL destination — never a hard-coded <a> list. A dead #/placeholder link is a FAIL.
Per-template data the storefront exposes (loop these — don't invent)
From build-page-data.ts. Wrong key = empty section. The Liquid template global = the route KIND string (context-builder.ts sets template: route.kind): the real values are home (NOT index), page, content-list, content-detail, search, account-*. ⚠️ list_liquid_objects may print aliased names — trust the route-kind values here.
| Template | Real objects to loop |
|---|---|
| home | page, settings, plus any section-bound content Drop lists |
| page (a CMS page) | the page's own sections + their settings/blocks |
| content-list | records, <type_code>, <singular>, pagination, search, sort, filters, content_type |
| content-detail | record, <singular>, content_type |
| every page (global) | content Drop (all content types), customer, shop, settings, request, routes, the header/footer nav link_list |
The content Drop — loop content records (never hard-code a card)
A custom content type's records are exposed via the content Drop: {% for r in content.<type_code> %}. Each record exposes its EAV fields plus r.url (the detail route, already built — link with <a href="{{ r.url }}">, never a hand-built path) and r.image (a REAL re-hosted URL). Expose the type choice as a section setting, never a hard-coded record. On a content-list template use records (the type in scope), not the global content Drop. ⚠️ NEVER reconstruct an image URL — use {{ r.image }} (re-hosted); a reconstructed external URL hotlinks and breaks (§C).
♻️ REUSE components, don't re-author per page
A pattern that appears on more than one page — a content card, a section heading/eyebrow block, a button/CTA, a testimonial/FAQ/feature tile, a breadcrumb — is a snippet rendered with {% render 'name', … %}, authored ONCE and reused, NEVER copy-pasted-then-diverged across sections/pages. Reuse keeps the design coherent, the customizer predictable, and a later style change a one-file edit. Before hand-writing markup for something that already exists as a snippet, render the existing one.
Icons — {% render 'icon', name: '<lucide-name>' %}, NEVER a CDN <i data-lucide>
The storefront ships the FULL Lucide set as snippets/icon.liquid and loads NO Lucide CDN runtime, so a raw <i data-lucide="cart"></i> (the form an HTML mockup uses) renders EMPTY on the live site — the raw-<i>-icon-renders-empty bug. Author icons as {% render 'icon', name: 'cart', size: 24 %} (names map 1:1 to Lucide; pass size or class). Brand / social glyphs (facebook, instagram, youtube, tiktok, twitter/x, linkedin, pinterest, zalo) are NOT in the set — {% render 'icon', name:'facebook' %} is empty too — so paste an inline <svg> with the brand path for those. (Safety net: every theme-file write is auto-normalized server-side — a leftover <i data-lucide="x"> is rewritten to {% render 'icon', name:'x' %} and the dead CDN <script> is stripped — but author the snippet directly; the net does not cover brand glyphs.)
A content-type section setting yields a type CODE, not the records
A content-type section setting stores the type code string; loop its records via the content Drop: {% for r in content[section.settings.type] %} (or a fixed {% for r in content.services %}). ⚠️ On an AI build YOU must set_section_settings the picker to a REAL created type (and image_picker settings to real hosted images) — a section left at settings:{} renders blank images + an empty grid ("looks like the demo but empty"), the #1 failure.
Header & footer = EXISTING layout chrome — RESTYLE the existing section, never bake a new hardcoded one
The header and footer are the site chrome: they live in the layout and render on EVERY page (not in templates/*.json). They ALREADY EXIST as editable section types — web-header / web-footer — and they ALREADY loop their link_list nav block and read shop.logo_url.
So to change the header/footer you EDIT THAT EXISTING SECTION — get_theme_file { path:"sections/web-header.liquid" } → upsert_theme_file to restyle its markup/CSS, and/or set_section_settings for its settings. Do NOT build a NEW header/hero section with a hard-coded <nav>/<a> link list and drop it on the homepage — that is the #1 way the build ends up with a "hard" header: a baked nav that ignores the merchant's menu, duplicates the real chrome, and won't update when they edit admin. Symptoms you got it wrong: nav <li>/<a> written as literal Liquid, the menu not changing when you edit main-menu, or two headers stacked.
FIRST verify the layout actually RENDERS the section — else your edits are DEAD. Some templates (older web-content layouts) hardcode the chrome inline in layout/theme.liquid — a literal <header class="web-header">…</header> / <footer> — instead of calling {% section 'web-header' %} / {% section 'web-footer' %}. When that's the case, editing sections/web-header.liquid changes NOTHING on the storefront — the layout never renders that section, so the hardcoded chrome always wins (the "I styled the header but the live site is unchanged / shows the old header" symptom). So: get_theme_file { path:"layout/theme.liquid" } and check it calls {% section 'web-header' %}. If it INLINES the header/footer instead, FIX THE LAYOUT — rewrite the inline <header>…</header> to {% section 'web-header' %} (keep any {% unless layout_bare %}…{% endunless %} wrapper) and the footer to {% section 'web-footer' %} — THEN restyle the section. Editing only the section file while the layout is hardcoded is the trap.
When restyling the header, KEEP the live bindings: nav from the header's link BLOCKS — 'link' items + nested 'dropdown'/'column' blocks rendered by the recursive {% render 'nav-menu', blocks: section.blocks %} snippet — and the logo from shop.logo_url (§ logo rule below). Populate the header + footer link blocks so the chrome self-populates (the editor's saved blocks win; a template's declared blocks are the fallback).
Logo MUST come from config — render shop.logo_url (the #1 missed binding)
The store's logo IS already in admin config: Settings ▸ Branding writes store.logo_url (+ favicon_url), and the storefront exposes it to Liquid as {{ shop.logo_url }} / {{ shop.favicon_url }} (context-builder.ts — alongside shop.name, shop.meta_title, shop.og_image_url, shop.social_links). ⚠️ The list_liquid_objects tool's shop summary is incomplete — it omits logo_url; do NOT conclude "there's no logo object" from it. The logo object exists.
So a header/footer that hard-codes ONLY a brand SVG or text wordmark and never reads shop.logo_url is WRONG — the merchant sets a logo in admin and it never appears (= a §D fail). Render the admin logo as the primary source, with an optional per-theme image override and a text/icon fallback (so an unset logo never shows a broken <img>):
<a class="logo" href="/">
{%- assign logo_src = section.settings.logo | default: shop.logo_url -%}
{%- if logo_src != blank -%}
<img src="{{ logo_src }}" alt="{{ shop.name | escape }}"{% if section.settings.logo_width %} style="width:{{ section.settings.logo_width }}px;"{% endif %}>
{%- else -%}
{{ section.settings.logo_text | default: shop.name }}{%- comment -%} or the brand SVG/wordmark {%- endcomment -%}
{%- endif -%}
</a>
Give each chrome section an optional {"type":"image","id":"logo","label":"Logo image (override)"} + a {"type":"range","id":"logo_width","unit":"px"} so a designer CAN override per-theme — but the default behavior with zero section config must already show shop.logo_url. Both shop.logo_url and the image setting are plain URL strings (§B/§C). Use the same {{ shop.favicon_url }} for the <link rel="icon"> in the layout head.
SEO title / description / OG image + brand name MUST come from admin config — NEVER hardcode
Same principle as the logo. The page <title>, meta description, OG image, favicon and brand/store name are all admin-managed, not theme constants:
- Where they live: Settings ▸ Branding/SEO →
update_store_branding { meta_title, meta_description, og_image_url, logo_url, favicon_url }(works with thewmk_key — verified); per-page overrides viacreate_page/updatePage { meta_title, meta_description, og_image_url }. The store name isshop.name(note:updateStore { name }regenerates the slug and can fail on a slug-collision /stores_updated_by_fk— don't rely on it; set name in admin). - What the storefront exposes: the backend computes per-page
page_title/page_description/page_imagefrom the page'smeta_title ?? title/meta_description/og_image_url, and surfaces store defaults asshop.meta_title/shop.meta_description/shop.og_image_url/shop.name/shop.logo_url/shop.favicon_url(context-builder.ts). - Bind, don't bake — in
layout/theme.liquid(and any section), read those vars; never write a literal title/description/brand into the markup:<title>{{ page_title | default: shop.meta_title | default: shop.name }}</title>{%- assign d = page_description | default: shop.meta_description -%}{% if d != blank %}<meta name="description" content="{{ d | escape }}">{% endif %}- logo fallback when no admin logo =
{{ shop.name }}(mark ={{ shop.name | slice: 0 }}), NOT a hardcoded wordmark like"bozuro". {{ content_for_header }}already injects canonical/OG/JSON-LD from the admin SEO config — don't duplicate or override it with hardcoded tags.
- Set the brand in admin via MCP (so values live in Settings, not the theme): call
update_store_brandingformeta_title/meta_description/og_image_urland alogo_url(a hosted image, or a small inlinedata:image/svg+xml,…wordmark as a stop-gap the merchant can replace). A site that shows the right brand only because the string is baked into the theme is WRONG — configure it in admin and read it.
Reuse a BASE data section instead of reinventing: a content-records grid (a content-type picker + the
contentDrop loop). Recipe for building a section:shop-build-section.
§I — Durability: MCP writes are LIVE but NOT baked
MCP tools write live to the running store (through the same resolvers as the customizer), so they are authoritative for that store. They are not part of any baked/seeded dataset, so a from-scratch DB reseed won't reproduce them.
§J — NO DUPLICATES: read what exists, then reuse/update (idempotent writes)
The most common mess on a real store is stacking duplicates — two "Home"/"About" menu items, three "Contact" pages, repeated collections — because a create_* was fired without first checking what's already there. It happens most across a multi-step build, a continue/resume, a retried turn, or a delegated sub-task. A create_* is NOT idempotent: re-running it makes a SECOND row. Before you create ANYTHING the store could already have, LIST it first and reuse or update the existing one instead of adding a twin.
- Nav links (the header/footer link blocks): there is NO menu tool — nav lives in the header/footer section's 'link' blocks. Update the existing blocks via
set_section_settings(don't append a link that already exists); to change a link, edit its block, never add a near-copy. - Pages: call
list_pagesfirst and match by slug/title beforecreate_page.create_pagenow REFUSES an existing slug (it errors and points you toupdate_page— nothing is created), so re-create no longer silently mints a-2twin. To EDIT a page, useupdate_page(whole doc) orupdate_page_sections(one section at a time, incremental — preferred for building a page section-by-section);delete_pageremoves an accidental duplicate. ⚠️ Rawgraphql_mutation { createPage }bypasses the duplicate guard and DOES auto-dedup to-2— use the typed tools, not raw GraphQL, for pages. - Collections / blog posts / content records:
list_collections/list_blog_posts/list_content_recordsfirst; match by slug/url_key/title andupdate_*the existing one. - Products:
list_products/get_productby SKU beforecreate_product; a re-import must update by SKU, never add a twin SKU. - Across turns / sub-tasks: the
[action taken earlier] <tool> <args>lines in the conversation are YOUR own record of what you already created this chat — read them before creating the same thing again. A delegated sub-task gets a fresh context, so its brief must say what already exists (or it mustlist_*first) so it doesn't re-create what an earlier step made.
Rule of thumb: read → decide reuse/update/create → act. If unsure whether something already exists, LIST; never "create and hope".
§K — Compose a page: GLOBAL chrome + MANY small sections — NEVER one monolith
A storefront page = the global chrome (header + footer, shared by every page) plus a STACK of SMALL sections, one per design block (hero, logo strip, feature grid, stats, gallery, testimonials, pricing, FAQ, CTA…). Build it that way — NOT as one giant section.
- Header & footer are GLOBAL, layout-level chrome — the
header/footer(shop theme) orweb-header/web-footer(web/content theme) sections, shared across ALL pages. RESTYLE those existing sections (§H "Header & footer = layout chrome"); never re-create the header/footer per page, and never fold them into a page's content section. One header, one footer, for the whole site. - ONE section = ONE block. Each design block becomes its OWN section type + instance with its own
{% schema %}(andblocksfor repeating items inside it — e.g. a "features" section iteratessection.blocks, a "faq" section iterates question blocks). A page is then several such instances placed in order on its template. Blocks can NEST — a block may hold child blocks (block.blocks) to any depth for item-within-item structures (nav menus/dropdowns, footer link columns, tabs, accordions); see shop-build-section §2b. - NEVER bake a whole page into a SINGLE monolithic section (e.g. one
home/<store>-homesection containing hero + features + FAQ + CTA in one Liquid file). That is WRONG: the merchant can't reorder / toggle / restyle / translate individual blocks in the customizer, nothing is reusable across pages, and the whole page is one opaque blob — it defeats the customizer-editable model (§F) and reads as a hard-coded landing page, not an editable theme. Symptom you did it wrong: one section instance whose id is the page name and whose schema crams every unrelated block. - Building/delegating: create N small section types, place each on the template in order (or one
build_section_taskper block — delegate sections ONE at a time). Reuse the base data sections (product-blocks,new-arrivals,main-blog…) instead of reinventing. The home template ends up{ order:[hero, features, testimonials, faq, cta], sections:{…} }, not{ order:[home], sections:{home:{…everything…}} }. - ATOMIC, ORDERED PLACEMENT — build then place each section, top to bottom, never batch. For each section in visual order:
build_sectionit, then IMMEDIATELYset_section_settings { position: i }(explicit 0-based index), THEN move to the next. Do NOT write all the section Liquid first and all the template JSON after — that hides every change until the end, so the live preview can't fill in region by region as you build (and an approved mockup can't be matched top-to-bottom). The visual order is the source of truth; on a redesign or an approved-mockup build it's thedata-mock-regionorder from the design. - A CMS page (about/contact/custom) builds the SAME way — STRUCTURE FIRST, then fill. Theme-template pages (home, PDP, PLP…) place sections with
set_section_settings; a CMS page keeps its sections in the page record, so use its twinupdate_page_sections: ①create_pageFIRST (an empty/skeleton page — so it EXISTS and the editor can open it without a 404; never build, link, or scroll to a/pages/<slug>that doesn't exist yet), then ②update_page_sections { id, section_id, type, settings, position }once per section, in order (it merges per-field likeset_section_settingsand the preview follows to the page + swaps the section in). Never assemble the wholesections_datain memory and dump it via onecreate_page/update_page.
§L — RESPONSIVE IS NOT OPTIONAL: the desktop + tablet + mobile contract (most visitors are on a phone)
Every section and the global chrome MUST work at THREE widths: desktop (≥1024px), TABLET (768–1024px), and mobile phone (390px) — not just the two ends. A layout can look right on desktop + phone yet break at the tablet width (orphan columns, overflow, cramped type), so tablet is judged with the SAME rigor. Desktop-only (or desktop+phone-only) output is a FAILED build — verify §D always shoots desktop, tablet AND mobile and judges all three. Provide BOTH a tablet breakpoint @media (max-width:1024px) and a mobile breakpoint @media (max-width:768px) (or a fluid grid that reflows on its own).
The burger menu (the #1 mobile failure) — required behavior:
- Below the breakpoint the desktop nav hides and a hamburger button appears — pinned to a CORNER of the header bar (left edge or right edge, per the design), never floating mid-bar next to the logo.
- It opens a drawer (slide from the burger's side) or full-screen panel with
position:fixed; top:0; height:100vh (or 100dvh), above the page (z-indexover header/content), plus a backdrop. - It MUST close on: tapping the X / the burger again, tapping the backdrop, tapping any link inside, and Escape. A
<details>-based drawer needs JS for backdrop/link close — clicks INSIDE the panel don't auto-close it. Test every close path in the §D verify. - Lock body scroll while open (
overflow:hiddenon open, restore on close); the open panel itself scrolls if taller than the screen. - Menu items: full-width rows, ≥44px tall, visible dividers — not a desktop nav squeezed vertical.
Layout / typography / touch:
- No horizontal overflow, ever (the most common defect): fixed widths, large min-widths, unwrapped long words, edge-to-edge absolute elements.
max-width:100%images, wrap text, audit anyposition:absolute. - Grids step down at EACH breakpoint: desktop 3-4 columns → ~2 columns on tablet (≤1024px) → 1-2 columns on mobile (≤768px) (
grid-template-columns:1frorrepeat(auto-fit,minmax(260px,1fr))reflows for free). Watch the orphan-last-row rule (§K) at the tablet 2-col count too. Side-by-side hero (text+image) stacks text-first. - Type scales down: headings via
clamp()(e.g.clamp(28px,6vw,56px)); body ≥15px; line-length stays readable. - Touch targets ≥44×44px with breathing room — buttons/links never closer than ~8px; full-width primary CTAs on mobile.
- Spacing compresses: desktop
--section-ypaddings shrink ~40-50% under 768px; tables/long rows become cards or horizontal-scroll WITH visible affordance. - Sticky headers shrink on mobile (compact height) and never cover anchor-scroll targets (
scroll-margin-top).
Verify: §D shoots desktop, tablet AND mobile, each judged with the SAME rigor — open the burger in your head (and with read_rendered/JS when possible): if any close path is missing, any row overflows at ANY of the three widths, an orphan column appears at the tablet 2-col step, or any text is clipped, the section FAILS and you fix before moving on.
Nav lives in BLOCKS, never a text setting: any navigation a section holds (header nav, footer columns, drawer) MUST be authored as 'link' blocks (+ nested 'dropdown'/'column' blocks for sub-menus — blocks NEST) rendered via {% render 'nav-menu', blocks: section.blocks %} or a {% for block in section.blocks %} loop — the customizer renders blocks as an editable list (label + url). A text setting holding nav labels/URLs is wrong.
§M — MONEY: every price through | money (the currency-formatting contract)
This platform has NO catalog/cart/order prices. But a content record or section MAY show a price (a services table, a menu, a plan). If it does, format the numeric value for the store currency with the | money filter — {{ record.price | money }} formats per shop.currency (VND → 990.000 ₫, USD → 19.99). NEVER hard-code a symbol/decimals or print a raw number. For a price your JS computes at runtime, use the shared helper window.__money(n) (reads <body data-currency-code> + lang via Intl = the same result); never hand-format in JS (n.toFixed(2), n + '$').
