Bundle builder, build spec v3.1
Everything needed to build the v3 demo as a Shopify section the merchant edits in the theme editor, on native discounts, on the theme's own elements, so that a Concept update carries it and nothing depends on an app. File by file, with the gate that closes each step.
What "native theme builder" changes
Native wherever a native thing exists. Native discounts, the theme's own bundle element and slot markup, its steppers, its sticky sidebar, its countdown, its buttons, its stylesheet. Our code is one section, one stylesheet and one script that speak to the theme through its public contracts, so a Concept update carries the builder instead of breaking it.
Why this is the governing rule: this store updates Concept by re-applying its customisations on the new copy (the 992px breakpoint and two theme.js guards are already on that list in NANOBAG-DOCS.md). Every theme behaviour we re-implement is one more thing to port; every theme behaviour we inherit is ported for free. The trade is that we accept the theme's rendering where it differs from the demo, and those cases are named below.
- Ladder rungs are section blocks. One block per rung with the bag count and three percentages: everyday, Black Friday, Cyber Monday. A rung is shown in a campaign when its percentage for that campaign is above zero, or it is the free-shipping rung, or the gift rules carry a gift at that threshold. So one block list serves all three ladders and rung 2 (Black Friday only) or rung 15 (Cyber only) need no special casing.
- Gifts are never authored.
window.NB_GIFT_RULES.tiersSpecis already rendered on every page bylayout/theme.liquid:541and is market-resolved. The builder sums its cumulative increments per threshold and writes the gift copy itself. Each block keeps areward_fallbacktext that only renders if that script has not booted (pre-consent EU), and a QA gate compares the two. - Campaign mode is a setting (
auto,everyday,bf,cyber) with four ISO 8601 date fields.autois decided in the browser from the dates, because the page is edge cached and Liquid'snowis frozen at cache time. The merchant can force a mode for a preview and forget to flip it back;autoself-heals when the window closes. - The product per market is a contextual setting. The editor writes
templates/page.bundle.context.<market>.jsonwhen the merchant picks a product while previewing a market, exactly as it already does fornb_variant_cardson the homepage. - The
nb_bundle_offersmetaobject from the September plan is dropped. Its only job was per-market scheduling, and the campaigns start at the same moment everywhere. If a market ever needs its own percentages, that is a v2 block setting, not a data model.
Files
| File | State | What it does |
|---|---|---|
sections/nb-bundle-builder.liquid | rewrite draft 1 | Markup, schema, server-rendered rail and first band, config JSON for the script |
assets/nb-bundle-builder.css | rewrite draft 1 | The v3 demo styles minus preview chrome, scoped to .nb-bb, tokens per campaign |
assets/nb-bundle-builder.js | rewrite draft 1 | <nb-bundle-builder> extends the theme's ProductBundle: four overrides, cart seed, ladder, money; the clock is the theme's countdown-timer |
templates/page.bundle.json | edit | Add one nb-bundle-builder instance; the three stock bundle sections stay in the file, untouched |
templates/page.bundle.context.*.json | new, 8 files | Product per market, see the handle table |
locales/*.json (32) | edit | sections.nb_bundle_builder.* keys, plural-safe |
assets/nb-bundle-modal.js | new, phase 2 | <nb-bundle-modal> extends the theme's QuickView |
sections/image-with-text-overlay.liquid | edit, phase 2 | Trigger button setting and markup; this is the Rollouts arm |
sections/cart-drawer.liquid, snippets/free-shipping-bar.liquid, sections/overlay-group.json | edit, own commit | Free-shipping bar switches from a money threshold to the weight rule |
assets/custom.css | edit, own commit | section#shopify-pc__banner { z-index: 34 }, not on Shubham today |
Section schema
Settings the merchant sees, in editor order. Ranges and defaults are the ones the demo runs with.
{
"name": "NB Bundle builder",
"tag": "section",
"class": "section-nb-bundle-builder",
"settings": [
{ "type": "product", "id": "product", "label": "Bundle product",
"info": "Pick this market's product while previewing that market." },
{ "type": "text", "id": "heading", "label": "Heading", "default": "Build your bundle" },
{ "type": "text", "id": "subheading", "label": "Offer sentence (everyday)",
"default": "Three bags ship free. Ten bags save 25% and come with 7 more free." },
{ "type": "header", "content": "Campaign" },
{ "type": "select", "id": "campaign_mode", "label": "Mode", "default": "auto",
"options": [
{ "value": "auto", "label": "Automatic from the dates" },
{ "value": "everyday", "label": "Force everyday" },
{ "value": "bf", "label": "Force Black Friday" },
{ "value": "cyber", "label": "Force Cyber Monday" } ] },
{ "type": "text", "id": "bf_starts_at", "label": "Black Friday starts", "default": "2026-11-27T00:00:00+08:00",
"info": "ISO 8601 with the offset. Store time is HKT." },
{ "type": "text", "id": "bf_ends_at", "label": "Black Friday ends", "default": "2026-11-29T23:59:59+08:00" },
{ "type": "text", "id": "cyber_starts_at", "label": "Cyber Monday starts", "default": "2026-11-30T00:00:00+08:00" },
{ "type": "text", "id": "cyber_ends_at", "label": "Cyber Monday ends", "default": "2026-11-30T23:59:59+08:00" },
{ "type": "text", "id": "offer_bf", "label": "Offer sentence (Black Friday)", "default": "Up to 35% off. Three bags ship free." },
{ "type": "text", "id": "offer_cyber", "label": "Offer sentence (Cyber Monday)", "default": "Up to 40% off. Fifteen bags is the deepest rung." },
{ "type": "checkbox", "id": "show_stockup", "label": "Show the 15-bag stock-up card on Cyber Monday", "default": true },
{ "type": "header", "content": "Rewards" },
{ "type": "range", "id": "free_shipping_bags", "label": "Free shipping from (bags)", "min": 0, "max": 10, "step": 1, "default": 3,
"info": "Shipping is weight based: 49.9 g, and every bag is 20 g. Three is the first true value. Zero hides the rung." },
{ "type": "checkbox", "id": "show_preset", "label": "Show the \"Add N more\" button", "default": true },
{ "type": "header", "content": "Layout" },
{ "type": "range", "id": "padding_top", "min": 0, "max": 100, "step": 4, "unit": "px", "default": 56, "label": "Top padding" },
{ "type": "range", "id": "padding_bottom", "min": 0, "max": 100, "step": 4, "unit": "px", "default": 96, "label": "Bottom padding" }
],
"blocks": [
{ "type": "rung", "name": "Rung", "limit": 10, "settings": [
{ "type": "range", "id": "bags", "label": "Bags", "min": 1, "max": 20, "step": 1, "default": 4 },
{ "type": "range", "id": "pct_everyday", "label": "% off, everyday", "min": 0, "max": 60, "step": 5, "default": 0 },
{ "type": "range", "id": "pct_bf", "label": "% off, Black Friday", "min": 0, "max": 60, "step": 5, "default": 0 },
{ "type": "range", "id": "pct_cyber", "label": "% off, Cyber Monday", "min": 0, "max": 60, "step": 5, "default": 0 },
{ "type": "text", "id": "reward_fallback", "label": "Reward text if gift rules are unavailable",
"info": "Gifts are read live from the gift rules. This only shows before that script loads." } ] }
],
"presets": [ { "name": "NB Bundle builder", "blocks": [
{ "type": "rung", "settings": { "bags": 1, "reward_fallback": "Free carabiner" } },
{ "type": "rung", "settings": { "bags": 2, "pct_bf": 10, "pct_cyber": 10 } },
{ "type": "rung", "settings": { "bags": 3, "reward_fallback": "Free shipping" } },
{ "type": "rung", "settings": { "bags": 4, "pct_everyday": 10, "pct_bf": 20, "pct_cyber": 20, "reward_fallback": "1 free bag" } },
{ "type": "rung", "settings": { "bags": 6, "pct_everyday": 15, "pct_bf": 25, "pct_cyber": 25, "reward_fallback": "3 free bags and 3 carabiners" } },
{ "type": "rung", "settings": { "bags": 8, "pct_everyday": 20, "pct_bf": 30, "pct_cyber": 30, "reward_fallback": "5 free bags and 5 carabiners" } },
{ "type": "rung", "settings": { "bags": 10, "pct_everyday": 25, "pct_bf": 35, "pct_cyber": 35, "reward_fallback": "7 free bags and 7 carabiners" } },
{ "type": "rung", "settings": { "bags": 15, "pct_cyber": 40 } } ] } ],
"disabled_on": { "groups": ["header", "footer", "custom.overlay"] }
}
Visibility rule, applied in Liquid and again in JS: a rung renders in campaign C when pct_C > 0, or bags == free_shipping_bags, or tiersSpec has a threshold equal to bags. Blocks are sorted by bags ascending regardless of editor order. Two blocks with the same bags is a theme-check error we add ourselves.
Data: one source per fact
| Fact | Source | Read by |
|---|---|---|
| Gift per threshold | NB_GIFT_RULES.tiersSpec, cumulative increments {threshold, bag, carab}, market resolved | JS, summed by threshold at boot |
| Cash percentage per rung per campaign | section blocks, mirrored by one native automatic discount per rung | Liquid renders, JS reads the config JSON; checkout reads the discounts |
| Free-shipping rung | section setting | both |
| Active campaign | section dates, evaluated in the browser, 60 s interval | JS |
| Prices, colour names, availability | the market's product, server rendered | Liquid; never re-fetched |
| Bag count for the ladder | /cart.js on boot and on every cartUpdate, excluding lines that carry _nb_gift | JS |
| Session picks | sessionStorage, keyed by section id and market | JS |
Gift derivation from tiersSpec
// tiersSpec: [{threshold:1,bag:0,carab:1},{threshold:4,bag:1,carab:0},{threshold:6,bag:2,carab:2}, ...]
// cumulative: at 6 bags you hold 3 free bags and 3 carabiners
const cum = []; let bag = 0, carab = 0;
for (const t of spec.sort((a, b) => a.threshold - b.threshold)) {
bag += t.bag; carab += t.carab;
cum.push({ bags: t.threshold, fb: bag, car: carab });
}
// a rung's gift = the cum entry with the largest bags <= rung.bags
The discount is native
One Shopify automatic "amount off products" discount per rung that carries a percentage, scoped to the bundle-eligible collection, minimum quantity equal to the rung, scheduled per campaign. No app. This is what the store already ran from November 2022 to September 2024 ("4 / 6 / 8 bags - bundle discount") and again for BFCM 2024, verified in the Admin API on 14 Sep. The two Shopify rules it rests on, verified against the help centre the same day: when eligible discounts cannot combine, "the best discount for the customer's cart is always applied", and for a collection-scoped discount "only these items contribute to the minimum quantity". Gift lines are free because the gift variants are priced at zero, not through a discount, so nothing has to combine with them; the gift products simply stay out of the collection.
| Discount | Min qty | Off | Active |
|---|---|---|---|
| Bundle: 4 bags | 4 | 10% | always |
| Bundle: 6 bags | 6 | 15% | always |
| Bundle: 8 bags | 8 | 20% | always |
| Bundle: 10 bags | 10 | 25% | always |
| Black Friday: 2 bags | 2 | 10% | 27 to 29 Nov |
| Black Friday: 4 / 6 / 8 / 10 bags | 4 / 6 / 8 / 10 | 20 / 25 / 30 / 35% | 27 to 29 Nov |
| Cyber Monday: 2 / 4 / 6 / 8 / 10 bags | 2 / 4 / 6 / 8 / 10 | 10 / 20 / 25 / 30 / 35% | 30 Nov |
| Cyber Monday: 15 bags | 15 | 40% | 30 Nov |
The market cache guard from the September plan stays: on boot compare the rendered market handle to the localization cookie; on mismatch reload once (a sessionStorage flag prevents a loop). A reload, not a client-side patch, because the product JSON endpoint returns untranslated option values.
The script: extend the theme's element, do not replace it
<nb-bundle-builder> is class NbBundleBuilder extends customElements.get('product-bundle'), registered inside customElements.whenDefined('product-bundle').then(...) because theme.js is deferred. The receipt uses the theme's own slot markup, so every inherited method finds what it expects. Four methods are overridden; everything else is the theme's.
| Method | Ours or inherited | Why |
|---|---|---|
addVariant(product, variant) | override | The theme opens a new slot per add. We find an existing slot with the same data-variant-id and bump its quantity-input; only a new variant opens a slot. Then call the inherited method for the media and content fill. |
updateProgressBar() | override | The theme fills toward minimum_quantity. Ours fills the rail by rung index and updates the dots, the money hero and the next line. |
template (getter) | override | Points at our one hidden available slot so the theme's cloning works without rendering minimum_quantity empty rows. |
onSubmitHandler(event) | override | The theme's version calls resetVariants() on a 422, which wipes the shopper's picks. Ours keeps the theme's request and success path (same fetchConfig, cart:bundled-sections, cartUpdate publish, ajaxProduct:added, drawer unless flyToCart) and adds the drop-and-retry loop below. |
updateTotal, updateTotalWithCurrency, clearVariant, reorderVariants, resetVariants, handleErrorMessage, bundleMin, bundleMax, getAvailableAvriant, getResizedImageSrc | inherited | Untouched. A theme fix to any of them reaches the builder on update. |
seedFromCart(), ladder(), campaign(), money() | new | Cart seeding on boot and on cartUpdate, rung visibility and gifts from tiersSpec, campaign clock, savings maths. |
The tile is the theme's bundle form
<div class="card product-card nb-bb__tile" data-variant="{{ v.id }}">
<span class="sr-only" data-product-bundle-title>{{ style_name }}</span> <!-- addVariant() reads this -->
<form is="product-bundle-form" aria-controls="{{ bundle_id }}" data-product-image="{{ v.featured_media | image_url: width: 180 }}">
<input type="hidden" name="id" value="{{ v.id }}">
<script type="application/json" data-selected-variant>{{ v | json }}</script>
<button type="submit" class="nb-bb__tile-btn" aria-label="Add {{ style_name }}, {{ colour }}, {{ v.price | money }}">...</button>
</form>
<div class="nb-bb__step" hidden> <!-- minus dispatches bundle:removed with the slot; plus re-submits the form -->
</div>
Submitting that form makes the theme dispatch bundle:added with the variant JSON; nothing of ours is in that path. The grid is a <motion-list> and tiles carry .card, so the theme's own reveal animation runs on them.
The receipt is the theme's sidebar
<sticky-element class="product-bundle__sidebar" aria-controls="shopify-section-header" data-gap="16"> wrapping: our money hero, one hidden available slot, then bag rows as the theme's .horizontal-product block with [data-product-bundle-variant], [data-product-bundle-variant-media], [data-product-bundle-variant-content], [data-product-bundle-variant-price], a <quantity-input> and a <product-bundle-remove-button>, then [data-product-bundle-total], [data-product-bundle-total-with-currency], a <button class="button button--primary" is="hover-button" data-product-bundle-submit> and the theme's .product-form__error-message. Those classes are styled in theme.css (with the mini-cart polish in custom.css); our stylesheet re-skins them per campaign and adds nothing structural.
Other theme elements used as they are
- Rail track:
<progress-bar style="--progress: 0%">, the theme's element; settingstyleourselves skips its in-view initialiser, which is its documented behaviour. Our dots and labels sit on top. - Countdown:
<countdown-timer data-expires="{{ ends_at }}" data-compact="true">with the four.countdown__itemchildren, exactly asproduct-card-bundlerenders it. Trade accepted: the demo's escalating sentence becomes the theme's compact d/h/m/s, restyled in our type. The clock logic is the theme's. - Steppers:
<quantity-input>withdata-min, itsplusandminusbuttons,.valueandreset(). - Money:
theme.Currency.formatMoneywiththeme.settings.moneyFormat. - Reduced motion:
theme.config.motionReduced. - Sold out: the theme's
data-sold-outlabel swap on the submit button, inherited.
The 422 loop, required
POST items[] -> 422 (all or nothing; stale inventory on a cached page is normal)
parse description -> find the variant id it names
mark that tile sold out in place, drop that slot, retry
max 3 drops, then stop and show "Could not add to cart. Nothing was charged and your picks are still here."
on success after drops -> "3 bags added. Sling, Duck sold out while you shopped and was not added."
The script tag keeps data-cookieconsent="ignore" and reads theme.settings at boot, so it belongs to the Cookiebot boot-chain set. Nothing about script order or consent attributes changes in this work. The section renders the rail and the first band from Liquid, so a pre-consent EU visitor sees the offer and the merchandise with the script deferred; the tile forms still post to /cart/add without JavaScript.
Theme contract surface, and the checklist after a Concept update
Everything the builder depends on from the theme, by name. After any base-theme update, grep each one in the new copy; if it is still there, the builder runs. This list goes into NANOBAG-DOCS.md next to the two theme.js guards already tracked there.
| Kind | Contract | Where defined today |
|---|---|---|
| Custom elements | product-bundle, product-bundle-form, product-bundle-remove-button, quantity-input, sticky-element, progress-bar, countdown-timer, motion-list, hover-button, quick-view | assets/theme.js |
| Events | bundle:added, bundle:removed, cart:bundled-sections, ajaxProduct:added, ajaxProduct:error, pubsub cartUpdate | assets/theme.js |
| Data attributes | data-product-bundle-variant, -variant-media, -variant-content, -variant-price, -total, -total-with-currency, -submit, -progress-bar, -title, data-selected-variant, data-product-image, data-minimum, data-maximum | sections/product-bundle.liquid, theme.js |
| Globals | theme.routes.cart_add_url, theme.routes.cart_url, theme.utils.fetchConfig, theme.Currency.formatMoney, theme.settings.moneyFormat, theme.settings.flyToCart, theme.config.motionReduced, theme.pubsub, window.NB_GIFT_RULES | snippets/js-variables.liquid, theme.js, snippets/nb-gift-rules.liquid |
| CSS classes | .product-bundle__sidebar, .horizontal-product*, .button, .button--primary, .btn-fill, .btn-text, .product-form__error-message, .countdown__item, .card | assets/theme.css |
Page, template and markets
Page Build your bundle, handle build-your-bundle, template page.bundle, created unpublished. The template gets one nb-bundle-builder instance appended after rich-text; the stock bundle sections in that file are left in place and stay unused.
| Context file | Market | Product handle |
|---|---|---|
page.bundle.json (base) | United States | reusable-shopping-bags |
page.bundle.context.canada.json | Canada | nanobag-ca |
page.bundle.context.united-kingdom.json | United Kingdom | nanobag-uk-1 |
page.bundle.context.eu-markets-translated.json | EU | nanobag-eu |
page.bundle.context.italy.json | Italy | nanobag-eu |
page.bundle.context.spain.json | Spain | nanobag-eu |
page.bundle.context.singapore.json | Singapore | nanobag-sg |
page.bundle.context.hong-kong.json | Hong Kong | nanobag-int |
page.bundle.context.international.json | International | nanobag-int |
Copy: locale keys
All under sections.nb_bundle_builder, following sections.freebie_banner. Plural keys use one / other and are read with | t: count: n. English first; the other 31 files get the English string so nothing renders "Translation missing" on da or pl, then real translations follow the existing scripts/register-*-translations.mjs route.
heading, offer_everyday, offer_bf, offer_cyber, banner_bf, banner_cyber, banner_deal ("Up to {{ pct }}% off")
rung_pct ("{{ pct }}% off"), rung_free_shipping, rung_carabiner, gift_bags (plural), gift_carabiners (plural)
rail_next (plural: "{{ count }} more bag for {{ reward }}" / "{{ count }} more bags for {{ reward }}")
rail_top ("Deepest rung reached at {{ pct }}% off")
band_from ("from {{ price }}"), show_all (plural), show_fewer
tile_add ("Add {{ style }}, {{ colour }}, {{ price }}"), tile_plus, tile_minus, sold_out
bundle_label, empty_nudge, money_saved ("saved with {{ pct }}% off"), money_count (plural), at_checkout ("{{ count }} bags, {{ total }} at checkout")
gifts_worth ("Worth {{ amount }}"), gifts_included, preset (plural), cta (plural), cta_empty, cta_busy, cta_retry, fine_print
success (plural), success_sub, view_cart, keep_building, err_partial (plural), err_total, err_network
stockup_title, stockup_sub, stockup_cta, countdown_far, countdown_tomorrow, countdown_hours (plural), countdown_minutes (plural), countdown_final
trust_row, faq_1_q, faq_1_a, faq_2_q, faq_2_a, faq_3_q, faq_3_a, done, skip
Two fixes that ship before the builder, as their own commits
1. The cart drawer's free-shipping bar contradicts the weight rule
sections/cart-drawer.liquid:92-115 picks a money threshold per currency from overlay-group.json (USD:40, GBP:31.23, ...) and snippets/free-shipping-bar.liquid fills a bar from cart.total_price. Shipping is actually free at 49.9 g. Two Daypacks are $51.90 and 40 g: the drawer says free, checkout charges. The builder will say "3 bags ship free" beside it.
- New drawer setting
free_shipping_rule:amount(today's behaviour) orweight. Newfree_shipping_minimum_weight, grams, default 50. - In
free-shipping-bar.liquid, weight mode readscart.total_weight, excludes gift lines the same way gift wrap is excluded, and phrases the remainder in bags:ceil((50 - weight) / 20). - The drawer is re-rendered server side through bundled sections on every cart change, so the bar stays true without JS.
Gate: two Daypacks in the drawer show "1 more bag for free shipping" in every market; three show the bar full; checkout agrees.
2. The privacy banner covers the mobile CTA
Shopify's banner sits at z-index 2000000 and covered a drawer checkout button on this store in August. The docked bundle bar is the only path to the cart on a phone. section#shopify-pc__banner { z-index: 34; } in assets/custom.css; it exists in a worktree, not on Shubham.
Phase 2: the popup on the homepage and PDP
<nb-bundle-modal> extends the theme's QuickView. It fetches /pages/build-your-bundle with ?country= forcing, extracts #nb-bb-modal-content, injects it into the drawer body, and lets QuickView handle open, close, focus and the auto-hide on cartUpdate. The section wraps its inner in that id so the same file serves both the page and the modal.
- Define it only after the theme's element exists:
customElements.whenDefined('quick-view').then(define).theme.jsis deferred and so is this file; the order between two deferred scripts is document order, and this one is rendered after it, but the guard costs nothing. - The trigger is a setting on
sections/image-with-text-overlay.liquid(nb_bundle_trigger, label, style) rendered as a second button. Instances:image_with_text_overlay_YrKrRyon the homepage,image_with_text_overlay_xd3LwMon the PDP. No new section goes intoindex.json; it is at the 25-section cap. - The page fetch is about 268 KB with the header. Acceptable on a tap; a section-render fetch was rejected because
template--<id>__ids are not stable across theme copies.
Rollouts test, 12 to 26 October
The page itself is new traffic and needs no arm. The arm is the trigger: nb_bundle_trigger off (control) vs on (treatment) on both hosts, EDIT mode, 7 markets, EU out, the NB08 and NB09 shape. Read it with sessions.rollout_treatment_ids and sales.rollout_treatment_id: sessions, bundle page views, add rate, orders per session, AOV, bags per order.
- Control serves a frozen snapshot from the start. Every fix to the builder lands before the 12th, or the test restarts.
- Optional second arm, if traffic allows: rail vs the v2 list ladder. Same section, one setting, decided by orders.
- Cyber's mint CTA is previewed on the Shubham theme, not tested; two days of data cannot carry a button-colour decision.
Build order and gates
- Shubham refresh. Commit the in-flight work in its groups,
git merge origin/main, resolve.Gate:
templates/index.jsonandtemplates/product.jsonidentical to main before any bundle edit. - The two fixes above, each its own commit.
Gate: drawer bar in bags; banner z-index verified on a phone with the banner open.
- Native discounts. The
bundle-eligiblesmart collection (tagbundle_eligibleon the paid bag products, never on gift products), then the discounts in the table above, created in admin, scheduled. Stage them onnanobag-test-storefirst: the products are imported there, the collection is one smart collection to add.Gate: on staging, a 4-bag cart checks out at 10%, 6 at 15%, 10 at 25%, 2 Daypacks at 0%; with the Black Friday set force-activated, 4 bags check out at 20% and never 30%. Screenshots kept. Live discounts are created only when the page goes live, on your say.
- Section, CSS, JS. The demo's markup and styles ported, the theme primitives swapped in, the schema above.
Gate:
shopify theme checkclean, proven to scan the file with a throwaway offense; probe passes every state at 360, 375, 390 and 1440; zero em-dashes; AA on every measured pair;innerWidth == clientWidthat all four widths. - Data. Gifts from
tiersSpec, cart seeding, the 422 loop, the market guard.Gate:
?country=DE,?country=PL,?country=SGrender their own prices and translated colour names; a stale-inventory 422 recovers with the right message. - Locale keys, 32 files.
Gate: no "Translation missing" on da or pl.
- Template, contexts, page.
Gate: pre-consent EU pageview shows rail and shelf with the script deferred;
QA_CONSENTCHECK=1passes; every context file byte-verified by CLI pull after the theme push, never trusted per commit. - Client review on the Shubham theme, then the popup.
Gate: modal opens from both hosts, no header inside it, closes on
cartUpdate.
Pushes are always shopify theme push --only <changed files> to the Shubham theme (150265266312). Nothing reaches main or live without your say; promotion is by single cherry-picked commits, each verified per file.
Rollback
- The section is additive. Removing the instance from
page.bundle.jsonand the trigger setting from the two overlay instances returns the store to today, with no residue in the cart. - The drawer fix has its
amountmode preserved; flipping the setting back is the rollback. - Discounts are native and scheduled in admin; deactivating them is independent of the theme and needs no app.