← All epicsBRD.md §9 · lines 3458–3867

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 8 — Storefront subscription widget (derived view)

Read-only per-epic slice of BRD.md §9, lines 3458–3867. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-8.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (6): US-8.1, US-8.2, US-8.3, US-8.4, US-8.5, US-8.6
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 8 — Storefront subscription widget

<!-- traceability:start:BRD:Epic-8 -->

Prototype: Product Page · Cart · Widget Configuration · Catalyst + Buyer Portal composite PDP · Headless SDK

<!-- traceability:end:BRD:Epic-8 -->

Value: Shoppers can choose subscription options on PDP/cart with zero merchant theme customization on Stencil stores, and via a typed SDK on headless.

Epic context. The storefront widget is the primary conversion surface. It must render in ≤ 200ms perceived latency, inherit brand, and communicate value without requiring the shopper to read documentation.

US-8.1: Widget on Stencil PDP

<!-- traceability:start:US-8.1 -->

Prototype: Product Page

<!-- traceability:end:US-8.1 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber

As a Subscriber, I want to see subscription options on the product page, so that I can choose one-time or recurring without jumping pages.

Acceptance criteria:

  • Given a subscribable product and a Stencil theme, When I load the PDP, Then a widget renders with: "One-time purchase" vs. "Subscribe and save" radio, interval dropdown (if multiple), savings indication, and next-charge preview.
  • Given I pick "Subscribe," When I add to cart, Then the cart line-item carries subscription intent custom fields.
  • (Hive #1419) Given a plan with sales_mode='subscribe_only', When the widget renders, Then the one-time option is hidden, the subscribe radio is the only purchase path, and the CTA reads "Subscribe & save N%".
  • (Hive #1419) Given a plan with sales_mode='one_time_only', When the widget renders, Then the subscribe radio is hidden and only one-time purchase is offered.
  • (drift reconciliation 2026-05-21, commit caf75c69) Given a product with no plans configured in D1, When the widget hydrates with empty plans, Then the widget renders a one-time-only fieldset with Add-to-Cart — mixed-cart contract: every product offers a purchase path, with or without subscription.

UI states. (rendering contract — ui-states block convention, #1851)

<!-- ui-states US-8.1 -->
surface: "/products/[slug] PDP — SubscriptionWidget.svelte, mounted in apps/storefront-svelte/src/routes/products/[slug]/+page.svelte between the price and add-to-cart; a single <fieldset> purchase widget."
idle:
  render: "Eligible default (plan sales_mode='subscribe_and_one_time'): a radio group — 'Auto-Refill' (subscribe & save, default-selected) and 'One-time purchase' — above a member/list price head with a 'member −N%' pill, a delivery-cadence button row when the product has >1 active plan, a next-charge preview ('Next order DATE, then every N months'), an optional minimum-commitment note, and the CTA 'Start Auto-Refill · save N%'."
  primary_action: "Submit the CTA in Auto-Refill mode to add a subscription line to the BC cart (POST ?/addToCart) and redirect to /cart; or pick 'One-time purchase' and 'Add to cart'."
loading:
  trigger: "apiClient.getPlans({ bcProductId }) fired on mount via $effect; loading=true until it settles."
  render: "A skeleton matching the final layout — three pulsing bars on a card with aria-busy='true'; no purchase controls render until plans resolve."
error:
  surfaced_at: "Add-to-cart submit failures render inline at the foot of the subscribe fieldset as <p role='alert' class='text-error'> (SubscriptionWidget.svelte:761-763), scoped to the widget — never a page-level banner or a vanishing toast."
  render: "On submit failure: 'Could not add to cart. Try again.' (or the thrown exception message). On plan-load failure (getPlans rejects) the widget DEGRADES to the one-time-only purchase path and logs console.error('[SubscriptionWidget] getPlans failed', { degraded_to: 'one-time-only' }) (SubscriptionWidget.svelte:133-149) — there is no user-visible 'subscribe unavailable' notice (gap — see gapNotes)."
  recovery: "Submit error: the CTA re-enables (submitting resets in the finally block) so the shopper retries the same action. Load error: the degraded one-time-only 'Add to cart' stays available, so a purchase path is never lost (mixed-cart contract)."
empty:
  render: "Not a list surface. 'Empty' = the product has zero active plans (getPlans returns [] or all rows archived): the widget renders a one-time-only <fieldset> with the list price and an 'Add to cart' button (SubscriptionWidget.svelte:508-537), preserving the mixed-cart contract that every PDP offers a purchase action."
  cta: "Add to cart (one-time)."
inputs:
  - field: "purchase_mode"
    control: "radio"
    allowed_values: "one_time | subscribe (bound to mode; radios hidden per plan sales_mode — see edge_status)"
  - field: "delivery_cadence"
    control: "select"
    allowed_values: "one toggle button per active plan ('every {interval_count} {unit}'); shown only in subscribe mode when >1 active plan exists"
  - field: "prepaid_enabled"
    control: "checkbox"
    allowed_values: "on | off (subscribe mode only — toggles the pay-upfront sub-option)"
  - field: "prepaid_cycles"
    control: "select"
    allowed_values: "3 | 6 | 12 (toggle buttons; shown only when prepaid_enabled is on)"
edge_status:
  - status: "plan sales_mode='subscribe_only' — the one-time radio is hidden; subscribe is the only purchase path"
    badge: "subscribe & save"
    affordance: "Pick a cadence and submit 'Start Auto-Refill · save N%' to add the subscription to the cart"
  - status: "plan sales_mode='one_time_only' — the subscribe radio is hidden"
    affordance: "'Add to cart' for the one-time purchase (no subscription path offered for this plan)"
  - status: "B2B product (isB2b=true) — purchase radios are replaced by a 'Contact sales' panel"
    badge: "B2B"
    affordance: "Follow the 'Contact sales' link (b2bContactHref); when no href is configured the panel shows text directing the shopper to the merchant's sales team — a non-actionable fallback (gap — see gapNotes)"
  - status: "plan carries trial_days>0 — a navy 'N-day free trial' banner; the BC checkout is $0 and the first real charge is deferred"
    badge: "Free trial"
    affordance: "Submit 'Start Auto-Refill' to add the trialing subscription to the cart ($0 today; first charge on the previewed date)"
  - status: "plan carries a cycle discount (cycle_discount_pct or tiered cycle_discounts) — a coral 'Intro offer' banner ('N% off your first order')"
    badge: "Intro offer"
    affordance: "Submit 'Start Auto-Refill' to add the subscription at the previewed intro pricing"
  - status: "prepaid sub-option enabled (US-6.2) — a term selector (3/6/12) with an upfront-total preflight"
    affordance: "Pick a term and submit 'Pay $X upfront' to add a prepaid intent (cycles) to the cart — one charge, no recurring billing"
  - status: "processorKind='stripe' — the subscribe CTA reads 'Set up payment with Stripe' and opens a #stripe-elements-mount panel"
    affordance: "Click 'Set up payment with Stripe' to open the Elements mount; today the mount is a stub ('Stripe payment setup loading…') with no Stripe.js wired, so submission cannot complete (gap — see gapNotes)"
disabled_focus:
  keyboard: "The purchase-mode radios, delivery-cadence buttons, the prepaid checkbox and term buttons, and the submit CTA are all native <input>/<button> elements reachable in DOM tab order — no div-onClick dead-ends; the CTA shows a visible focus ring."
  focus_move: "While submitting, the radios, cadence buttons, and CTA set disabled=true (the CTA label becomes 'Adding to cart…'), removing them from tab order to prevent double-submit; on success the page navigates to /cart."
  guard: "The CTA is disabled when submitting OR when subscribe mode has no plan selected (mode==='subscribe' && !selectedPlanId); add-to-cart is a single intentional, reversible click with no typed-confirm."
# closes — #1851 loop-closer: the error state is bound to a proving render assertion. The
# impl-sweep fixed the silent-degrade bug (the no-plans fallback now surfaces a load-failure
# notice); this binding LOCKS it — the lint ERRORs if @render:US-8.1:load-error stops existing.
closes:
  error: "render:US-8.1:load-error"

UX notes.

  • Placement: merchant-configurable; default position is between price and add-to-cart
  • Component hierarchy: radio group (one-time / subscribe) → interval dropdown (shown only if subscribe selected & multiple intervals offered) → savings indicator → next-charge preview ("Next order on Nov 21, then every month")
  • States (updated 2026-05-21 per Hive #1419 + drift reconciliation from #1415 caf75c69):
    • loading — skeleton matching final layout
    • eligible — normal widget; shape depends on the plan's sales_mode (Hive #1419):
      • subscribe_and_one_time (default): One-time radio + Subscribe radio
      • subscribe_only: Subscribe radio only; One-time hidden; CTA is "Subscribe & save N%"
      • one_time_only: One-time only; Subscribe radio hidden
    • no-plans-but-purchasable — when getPlans returns 0 rows for a product, the widget renders a one-time-only purchase path (price + Add-to-Cart). This preserves the mixed-cart contract: every PDP must always offer a purchase action. Supersedes the prior "ineligible (hidden)" state for products that DO have a valid one-time price.
    • ineligible (hidden) — reserved for products that are genuinely not purchasable (e.g., out-of-stock variant + no-stockout-purchase merchant setting; B2B-only product not surfaced for retail shoppers). The widget hides entirely; the PDP shows no purchase UI.
    • error — getPlans rejects (network, API 5xx, malformed response). Widget degrades to one-time-only render path AND emits console.error('[SubscriptionWidget] getPlans failed', { bcProductId, error, degraded_to: 'one-time-only' }) (commit 41765062). Server-side beacon is a future follow-up.
  • Accessibility: proper <fieldset>/<legend>, radio group with aria-describedby linking to savings/preview text, keyboard-navigable interval dropdown
  • Mobile: widget stacks vertically; interval dropdown becomes a native select on touch

Data contract.

  • Storefront JS: fetches GET /api/v1/storefront/products/{bc_product_id}/subscription-options?channel_id=X&customer_group_id=Y via our CDN-cached endpoint
  • Response: { plan: {id, intervals, pricing_preview, eligibility, trial, commitment}, widget_config: {theme, copy_variant} }
  • Add-to-cart: invokes BC Stencil's native cart API with lineItem.optionSelections carrying subscription intent as custom fields — schema: { sub_plan_id, sub_interval_key, sub_quantity }
  • Telemetry: widget.impression, widget.option_changed, widget.add_to_cart_subscription

Success metrics.

  • Functional: widget renders on 100% of subscribable PDPs across top 20 Stencil themes
  • Product: % of PDP visitors adding subscription vs. one-time ≥ 20% (target; varies by category)
  • Operational (target): P95 widget render < 400ms (including first paint, excluding font load)

Dependencies.

  • US-4.2 (plan must be active)
  • Cart custom-field propagation (US-9.1)

Non-functional.

  • Widget bundle size ≤ 15KB gzipped
  • Works without merchant theme changes on Stencil 2.x; themes using heavy shadow-DOM may require manual mount
<!-- normative-requirements US-8.1 - artifact: widget.impression kind: event fit: emitted once per PDP subscription-widget render closes: grep:apps/api/src - artifact: widget.option_changed kind: event fit: emitted when the shopper changes the interval/option selection closes: grep:apps/api/src - artifact: widget.add_to_cart_subscription kind: event fit: emitted when a subscription line-item is added to cart closes: grep:apps/api/src - artifact: sub_plan_id kind: field fit: subscription-intent custom field carried on the cart line-item closes: grep:apps/api/src -->

Risks / open questions.

  • Some Stencil themes override the PDP template in ways that break our mount point. Provide a "manual mount snippet" for edge cases.

US-8.2: Widget on cart

<!-- traceability:start:US-8.2 -->

Prototype: Cart

<!-- traceability:end:US-8.2 -->

Phase: MVP · Persona: Subscriber

As a Subscriber with mixed-intent cart, I want clear visual distinction between subscription and one-time items, so that I don't miss what I'm committing to.

Acceptance criteria:

  • Given my cart contains both subscription and one-time items, When the cart renders, Then subscription items show a distinct badge, interval label, and "Remove subscription commitment" shortcut.
  • Given I change a subscription line's quantity or interval in cart, When the cart updates, Then the line-item custom fields update accordingly.

UI states.

<!-- ui-states US-8.2 -->
surface: "Storefront cart (Svelte) — cart/+page.svelte renders each line via CartLine.svelte, which composes SubscriptionLineItem.svelte for subscription lines. Persona: Subscriber with a mixed-intent cart."
idle:
  render: "Subscription cart lines get a distinct treatment: a left accent border + tint on the line (CartLine, subscriptionPlan != null) and a SubscriptionLineItem block with a 'Subscription' badge, an interval label ('Recurring every <n> <interval>(s)') and a 'Next charge <date>' line (computeNextChargeAt). One-time lines render no badge. Each line carries a quantity form ('Qty' number input + 'Update') and a 'Remove' form."
  primary_action: "Edit quantity ('Update' -> ?/updateQty) or remove the line ('Remove' -> ?/removeLine); proceed to checkout from CartTotals."
loading:
  render: "Server-rendered: the cart and its line items arrive from the SvelteKit +page load, so the cart route has no client-side loading spinner — first paint is either the populated cart or the empty state. The qty/remove mutations are POST form actions; their in-flight state today is the browser's native form submission (a full-page action round-trip), not an inline disabled/busy control (see gaps)."
error:
  surfaced_at: "North-star: a failed quantity update or remove surfaces an inline role=alert directly beside the affected cart line, scoped to that line — never a vanishing toast or a silent no-op."
  render: "The failure reason for the qty/remove action (e.g. quantity out of range, line no longer in cart)."
  recovery: "Re-submit the qty change or remove; the entered quantity is preserved. Today these are SvelteKit form actions (?/updateQty, ?/removeLine) that fall back to the route +error boundary rather than an inline per-line message — see gaps."
empty:
  render: "When the cart has no line items the page renders an explicit empty state: 'Your cart is empty.' with a 'Start shopping' button and links to the homepage / categories (cart/+page.svelte isEmpty branch) — never a blank surface."
  cta: "'Start shopping' -> homepage; browse a category."
edge_status:
  - status: "subscription line in a mixed cart"
    affordance: "Rendered with the 'Subscription' badge, interval label and next-charge date (SubscriptionLineItem) plus the accent border (CartLine) so it is visually distinct from one-time lines."
  - status: "one-time line"
    affordance: "Standard line treatment with no subscription badge; quantity editable, removable."
  - status: "remove a subscription commitment (AC1)"
    affordance: "The 'Remove' button (?/removeLine) removes the whole line. North-star 'Remove subscription commitment' should also offer convert-to-one-time rather than full deletion (see gaps)."
  - status: "change interval in cart (AC2)"
    affordance: "Not built in cart — only quantity is editable. The interval is changed from the product page before subscribing, or from the subscriber portal after checkout (Change cadence, US-18.8); an in-cart interval picker is the north-star (see gaps)."
inputs:
  - field: "quantity"
    control: "number"
    allowed_values: "integer 1-99 ('Qty' input, ?/updateQty)"
  - field: "interval"
    control: "select"
    allowed_values: "the plan's offered delivery intervals — north-star; not rendered in cart today (see gaps)"
disabled_focus:
  keyboard: "Every control is a real native element in tab order — the 'Qty' input type=number, the 'Update' button type=submit, the 'Remove' button type=submit, and the product/image links — no div-onClick; each shows a focus ring."
  focus_move: "Qty/remove submit reloads the cart (form action); north-star returns focus to the affected line or its replacement control after the update."
  guard: "'Remove' is a single submit and is reversible by re-adding the product; north-star confirms before removing a subscription commitment (it is the costlier action)."
  gaps: "Two AC gaps. AC1: the 'Remove subscription commitment shortcut' is a generic whole-line 'Remove' (CartLine lines 92-100, ?/removeLine), not a convert-to-one-time action. AC2: in-cart interval change is not built — CartLine offers only a quantity form (?/updateQty, lines 71-91) with no interval picker, so 'line-item custom fields update on interval change' has no surface. Also, qty/remove failures are not surfaced inline beside the line — they fall to the SvelteKit +error boundary / action result rather than a role=alert on the affected line."

US-8.3: Headless SDK

<!-- traceability:start:US-8.3 -->

Prototype: Headless SDK

<!-- traceability:end:US-8.3 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Developer

As a Developer building a Catalyst storefront, I want typed React hooks for subscription options and add-to-cart, so that I integrate in hours not weeks.

Acceptance criteria:

  • Given I install @bc-subscriptions/react, When I call useSubscriptionOptions(productId), Then I get { intervals, pricingPreview, eligibility }.
  • Given I call addSubscriptionToCart({ productId, variantId, intervalKey }), When it resolves, Then the BC cart is updated with correct line-item custom fields.
  • Given eligibility fails (customer group, region, channel), When I call the hook, Then I receive a typed ineligibility reason.

UX notes.

  • Not user-facing; developer experience matters: great TypeScript types, tree-shakeable bundle, 1-page quickstart

Data contract.

  • Package: @bc-subscriptions/react (and a framework-agnostic @bc-subscriptions/core)
  • Hooks:
    • useSubscriptionOptions(productId, { channelId, customerId }): { data, isLoading, error } — returns plan + pricing preview
    • useAddSubscriptionToCart(): (args) => Promise<Cart>
    • useSubscriptions(customerToken): { data, mutate }
    • useSubscriptionAction(subscriptionId): { pause, skip, swap, ... }
  • REST underneath: all hooks hit our public /api/v1/* endpoints with Authorization: Bearer {subscriber_token}

Success metrics.

  • Functional (target): full portal reproducible in < 500 LOC in Next.js example app (we ship this)
  • Product (target): ≥ 50% of headless merchants use the SDK directly vs. building their own
  • Operational (target): SDK version adoption (% on latest minor) ≥ 70%

Dependencies.

  • Epic 27 (REST API)

Non-functional.

  • Bundle: core ≤ 10KB gzipped; React bindings ≤ 5KB additional
  • Types: 100% type-covered, no any
  • SemVer: breaking changes only at major versions; deprecations logged 1 minor in advance
<!-- normative-requirements US-8.3 - artifact: useSubscriptionOptions kind: other fit: React hook returning { intervals, pricingPreview, eligibility } for a product closes: grep:packages/react/src - artifact: useAddSubscriptionToCart kind: other fit: React hook that adds a subscription line to the BC cart with the correct line-item custom fields closes: grep:packages/react/src - artifact: useSubscriptions kind: other fit: React hook listing the customer's subscriptions (BRD name); impl exposes client.subscriptions.list() but NO React hook wraps it — only useSubscription(subId) for a single resource closes: gap:#1706 - artifact: useSubscriptionAction kind: other fit: React hook exposing per-subscription actions {pause, skip, swap} (BRD name); impl shipped as useSubscriptionMutations(subId) with {pause, resume, cancel, …} closes: gap:#1706 -->

US-8.4: Widget theming

<!-- traceability:start:US-8.4 -->

Prototype: Widget Configuration

<!-- traceability:end:US-8.4 -->

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want the widget to inherit my theme's typography and colors automatically, so that it looks native on my storefront.

Acceptance criteria:

  • Given a Stencil theme with standard CSS variables, When the widget mounts, Then it reads --color-primary, --font-body, etc., and uses them.
  • Given my theme lacks these variables, When the widget mounts, Then it falls back to a neutral default.

UI states.

<!-- ui-states US-8.4 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber storefront (Svelte/Tailwind) — PDP subscription widget CSS variable theming layer; renders within the Stencil theme on any product detail page where a subscription plan is available."
idle:
  render: "The subscription widget mounts and applies the host Stencil theme's CSS variables (--color-primary, --font-body, --color-accent, --border-radius-base, etc.) to its internal design tokens. When a required variable is absent, the widget substitutes a neutral default individually (mid-blue primary, system sans-serif font, 4 px border-radius) — no flash of unstyled content, no invisible elements. The plan-selector, cadence picker, and subscribe button all reflect the resolved token set."
  primary_action: "No theming-specific CTA — the widget renders with theme tokens applied passively on mount."
loading:
  trigger: "CSS variable resolution is a single synchronous getComputedStyle pass on widget mount."
  render: "No async loading phase for theming; if the widget script itself is deferred, the host page controls any loading indicator outside the widget's scope."
error:
  surfaced_at: "CSS variable reads cannot throw at runtime. If the widget script errors during mount, a minimal 'Subscribe' button with neutral-default styling renders inline in the widget's position."
  render: "Fallback 'Subscribe' button (neutral styling) linking to the product's standard BC add-to-cart path — no subscription setup, but purchase flow is not blocked."
  recovery: "Merchant investigates via the browser console and reloads the page. The fallback button ensures purchase flow is not dead-ended by a widget mount failure."
empty:
  render: "If the product has no active subscription plans, the widget does not render — the PDP shows the standard BC add-to-cart control only. CSS variable theming is irrelevant for this product."
  cta: "n/a — the widget is absent when no plans exist; theming does not introduce an empty-list surface."
edge_status:
  - status: "Theme is headless or custom (no Stencil CSS variables present on the document root)"
    affordance: "Widget applies the full neutral-default token set; all controls remain functional; merchant adds CSS variables to their custom theme stylesheet to match their brand"
  - status: "Partial theme variables — some set, some absent"
    affordance: "Each absent variable falls back individually to its neutral default; present variables are applied; widget does not warn — partial theming is intentional opt-in behavior"
disabled_focus:
  keyboard: "All widget controls (plan-selector radio group, cadence <select>, subscribe <button>) are real focusable HTML elements in tab order — no div-onClick; CSS variable application does not alter the accessibility tree or tab order; a visible focus ring (2 px outline using the resolved --color-primary token or the neutral-default blue) is applied via :focus-visible to every interactive element."

US-8.5: A/B widget copy

<!-- traceability:start:US-8.5 -->

Prototype: Widget Configuration

<!-- traceability:end:US-8.5 -->

Phase: P3 · Persona: Merchant Admin

As a Merchant Admin, I want to A/B test widget copy (CTA text, savings framing, default selection), so that I optimize conversion.

Acceptance criteria:

  • Given I create a copy variant in admin, When the widget renders, Then it splits 50/50 between control and variant by subscriber session.
  • Given the test runs for N days, When I review results, Then I see conversion rate and statistical confidence per variant.

UI states.

<!-- ui-states US-8.5 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) — widget copy A/B test management page; two sub-surfaces: (1) a variant creation panel for defining control and variant copy, and (2) a results review table showing conversion rate and statistical confidence per variant. Phase P3."
idle:
  render: "A page titled 'Widget Copy A/B Tests' lists active and concluded tests in a BigDesign Table with columns: Test name, Status (Running / Concluded), Control CTA, Variant CTA, Conversion control (%), Conversion variant (%), Statistical confidence (%), Started. A '+ New test' primary Button opens the variant creation panel."
  primary_action: "'+ New test' → opens a creation Panel with fields for variant copy and split configuration; 'Save' activates the test; 'Cancel' closes the panel without creating."
loading:
  trigger: "On page mount, GET /api/v1/admin/ab-tests fetches the test list with per-variant conversion and confidence statistics."
  render: "While fetching, a BigDesign Spinner renders centered in the Table body area; the '+ New test' Button remains active."
error:
  surfaced_at: "An inline BigDesign Message (type='error') above the results Table when the stats fetch fails — never a vanishing toast. Scoped to the results table; the creation panel is unaffected."
  render: "Error copy: 'Couldn't load test results — check your connection and retry.' A 'Retry' Button inside the Message re-fires the fetch."
  recovery: "Merchant clicks Retry in the error Message; the creation panel remains open and usable while results are unavailable."
empty:
  render: "When no tests exist (tests === []), the Table is replaced by a BigDesign Panel with copy 'No copy tests yet — create one to start optimizing conversions.' and a '+ New test' primary Button."
  cta: "'+ New test' — opens the variant creation panel."
inputs:
  - field: "test_name"
    control: "text"
    label: "Test name — BigDesign Input; displayed as the test label in the results table"
  - field: "control_cta"
    control: "text"
    label: "Control CTA text — the existing widget subscribe button copy serving as the baseline (e.g. 'Subscribe & Save 10%')"
  - field: "variant_cta"
    control: "text"
    label: "Variant CTA text — the challenger copy shown to 50% of sessions"
  - field: "savings_framing"
    control: "select"
    label: "Savings framing style"
    allowed_values:
      - "percentage (e.g. Save 10%)"
      - "amount (e.g. Save $2.00)"
      - "none"
  - field: "default_selection"
    control: "select"
    label: "Default selection pre-selected in widget"
    allowed_values:
      - "subscribe (subscription option pre-selected)"
      - "one-time (one-time option pre-selected)"
edge_status:
  - status: "Test running — sample size below minimum; statistical confidence insufficient"
    affordance: "Confidence column shows 'Insufficient data' with a tooltip explaining the minimum-sample requirement; the 'Conclude test' action is hidden until confidence reaches threshold"
  - status: "Test concluded — confidence ≥ 95%; variant outperforms control"
    affordance: "'Apply variant' Button promotes the variant copy to the live control; 'Archive' archives the test without applying the variant"
  - status: "Test concluded — confidence ≥ 95%; no improvement detected"
    affordance: "'Archive' Button archives the test; the existing control copy remains active; no copy change is applied"
disabled_focus:
  keyboard: "All controls are real BigDesign components (Input / Select / Button) in tab order — no div-onClick. The creation Panel traps focus while open (Tab cycles through test-name → control CTA → variant CTA → savings framing select → default selection select → Save → Cancel); Escape closes the panel and returns focus to the '+ New test' Button. Results Table action Buttons (Apply variant / Archive) are real <button> elements in tab order."
  focus_move: "On panel open, focus moves to the test-name Input; on close or cancel, focus returns to the '+ New test' Button."

US-8.6: Pre-purchase education panel

<!-- traceability:start:US-8.6 -->

Prototype: Product Page

<!-- traceability:end:US-8.6 -->

Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber

As a Subscriber, I want to understand how subscriptions work (cancel anytime, skip, swap) before committing, so that I'm not anxious about signing up.

Acceptance criteria:

  • Given a merchant-enabled education panel, When I expand it on the widget, Then I see short FAQ items with icons (Cancel anytime, Change cadence, Skip a shipment).
  • Given I dismiss the panel, When I return to the PDP, Then it remembers my dismissal per session.

UI states.

<!-- ui-states US-8.6 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber storefront (Svelte/Tailwind) — expandable pre-purchase education panel embedded within the PDP subscription widget; merchant-enabled; shown below the plan selector and subscribe CTA."
idle:
  render: "A collapsed 'Learn how subscriptions work ▾' toggle button sits below the subscribe CTA. When expanded, it reveals three FAQ cards with icons: 'Cancel anytime' (X-circle icon), 'Change cadence' (refresh icon), 'Skip a shipment' (pause icon) — each with one sentence of plain-language copy. A 'Got it ×' dismiss button appears at the panel's foot."
  primary_action: "Toggle button — expands or collapses the education panel."
loading:
  trigger: "Panel content is part of the widget's configuration payload loaded at widget-init time. No async fetch occurs on expand."
  render: "No loading state — FAQ items are available synchronously; the panel opens without a spinner."
error:
  surfaced_at: "If the widget config payload is missing the education items (merchant misconfiguration or deploy gap), the toggle button is not rendered — the panel is suppressed entirely rather than showing broken content."
  render: "No error message is shown to the subscriber — the panel is silently absent when config is missing. The subscribe CTA remains fully functional."
  recovery: "Merchant corrects the widget configuration in admin; the panel reappears on the next page load."
empty:
  render: "When the merchant has not enabled the education panel (feature flag off), the panel and toggle button are not rendered — no placeholder or hidden element is inserted in the subscribe flow."
  cta: "n/a — the panel is entirely absent when not enabled; the subscribe CTA occupies the space directly."
edge_status:
  - status: "Panel dismissed this session (sessionStorage key set after 'Got it ×' click)"
    affordance: "Toggle button and panel are not shown for the remainder of the browser session per the AC ('it remembers my dismissal per session'); starting a new browser session clears the dismiss key and the panel is shown again"
  - status: "Panel expanded"
    affordance: "'Got it ×' dismiss button collapses the panel and writes the session-dismiss key to sessionStorage"
  - status: "Panel collapsed (default, not yet dismissed this session)"
    affordance: "'Learn how subscriptions work ▾' toggle button is active and keyboard-activatable (Space or Enter)"
disabled_focus:
  keyboard: "The toggle button is a real <button> in tab order — not a div-onClick; Space/Enter activates it. When expanded, the three FAQ cards are non-interactive display elements; the 'Got it ×' dismiss is a real <button> in tab order."
  focus_move: "On dismiss ('Got it ×'), focus returns to the toggle button; the subscriber can continue navigating the PDP without a focus trap."