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.
Epic 8 — Storefront subscription widget (derived view)
Read-only per-epic slice of
BRD.md§9, lines 3458–3867. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-8.x), not a line number. Regenerates on everydev → mainsync viaderive-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 --><!-- traceability:end:BRD:Epic-8 -->Prototype: Product Page · Cart · Widget Configuration · Catalyst + Buyer Portal composite PDP · Headless SDK
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 --><!-- traceability:end:US-8.1 -->Prototype: Product Page
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 radiosubscribe_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
getPlansreturns 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 witharia-describedbylinking 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=Yvia 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.optionSelectionscarrying 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
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 --><!-- traceability:end:US-8.2 -->Prototype: Cart
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 --><!-- traceability:end:US-8.3 -->Prototype: Headless SDK
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 calluseSubscriptionOptions(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 previewuseAddSubscriptionToCart(): (args) => Promise<Cart>useSubscriptions(customerToken): { data, mutate }useSubscriptionAction(subscriptionId): { pause, skip, swap, ... }
- REST underneath: all hooks hit our public
/api/v1/*endpoints withAuthorization: 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
US-8.4: Widget theming
<!-- traceability:start:US-8.4 --><!-- traceability:end:US-8.4 -->Prototype: Widget Configuration
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 --><!-- traceability:end:US-8.5 -->Prototype: Widget Configuration
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 --><!-- traceability:end:US-8.6 -->Prototype: Product Page
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."