Widget States

Widget States

The storefront modal is a state machine driven by setState(state) in tryvio-modal.js. currentState is one of the primary values below; several overlay/sub-states layer on top without replacing the primary state.

Related: Widget Components · Try-On Pipeline.


Visibility — sold-out hiding (B17)

Before any state, the widget may not render at all. The per-store setting Hide try-on on sold-out products (store_widget_config.hide_on_sold_out, default on, surfaced in the proxy config as hideOnSoldOut) hides the Try it on button for a product whose every variant is sold out. If at least one variant is available the button stays — and the widget always operates on the shopper’s selected variant (never a silent swap; a sold-out selected variant is the in-modal B2 case).

  • Product page — tryvio-boot.js reads hideOnSoldOut, live-fetches /products/<handle>.js, and hides the trigger when all variants are unavailable (no flash — the trigger is opacity:0 until resolved).
  • Collection / home cards — tryvio-collection.js makes one proxy call (first card handle) to learn the setting, then resolves each card’s availability lazily (IntersectionObserver, cached per handle) and skips injecting the button on fully sold-out cards.
  • Fail open — unknown availability / fetch error ⇒ show the button (B2 still protects the cart in-modal).
  • Canonical rule: apps/web/src/lib/widget/widget-visibility.ts → shouldHideWidget. Availability is read live from Shopify, not the synced catalog.

Visibility — traffic rollout / A/B (#484)

Also gates before any state renders, and runs before the sold-out check and before the impression event. The per-store operator setting (store_widget_config.rollout_percent, smallint, NULL = 100 = today’s behaviour) shows the “Try it on” button to only rolloutPercent% of shoppers; the rest are the control group for an honest with/without conversion comparison. Additive config field rolloutPercent on both /api/proxy and /api/storefront/page-config (missing field on an old cached widget = 100 = fully on, so nothing old ever regresses).

Bucketing is deterministic per shopper, not per page load: a 32-bit FNV-1a hash of the widget’s existing pseudo id (localStorage key tryvio_shopper_pseudo_id, shared with the impression/click events) mod 100, compared against the percent. hash % 100 < percent → "in"; otherwise "out". Because the id is stable, one shopper always lands in the same bucket, and ramping the percent up (e.g. 30 → 60) only ever moves shoppers from “out” to “in” — nobody who already sees the button loses it.

  • Canonical implementation: apps/web/src/lib/widget/rollout-bucket.ts (rolloutHash, resolveRolloutBucket, normalizeRolloutPercent), pinned by rollout-bucket.test.ts.
  • PARITY copies (the widget is classic-script JS on Shopify’s CDN and can’t import the app’s TS): tryvio-boot.js (rolloutHash / rolloutFnvMul / rolloutBucketFor) and tryvio-collection.js (identical functions, same pseudo-id key). All three must move together in the same commit, or shoppers flip groups mid-rollout.
  • PDP (tryvio-boot.js, post-config stage, kit.run): reads config.rolloutPercent (default 100 if not a number), and when < 100 resolves the bucket and fires widget_rollout_bucket before anything else. An "out" bucket hides the launcher (root.style.display = "none") and returns — no tryon_widget_impression is ever emitted for that shopper, so the impression funnel already reflects only the “in” group.
  • Collection cards (tryvio-collection.js): the card injector checks the same rolloutBucketFor and skips injecting the button entirely for "out" — no separate event; the bucket-assignment event is emitted by the PDP path only, once per shopper session.
  • At rolloutPercent === 100 (the default, and every store until this is configured), no rollout code path runs and no widget_rollout_bucket events are emitted at all.

widget_rollout_bucket event — one per shopper session (deduped via sessionStorage key tryvio_rollout_evt_v1), eventPayload = { bucket: "in" | "out", percent }. To build the A/B cohorts from it: group analytics_events rows by (shopper_pseudo_id, shopper_session_id), take the payload’s bucket for that pair as the cohort, and compare downstream funnel/conversion metrics (e.g. the same attributed-revenue logic in Analytics & ROI) between the "in" and "out" cohorts for the same date range.

Write path (operator-only, owner decision 2026-08-27): PATCH /api/admin/store-rollout with { storeId, rolloutPercent } (integer 0–100, or null to clear back to 100); anything else is a 400. UI: our admin’s store detail page (/admin/store/[storeId], ”🎯 Rollout” action). The merchant surface neither shows nor accepts the field — PATCH /api/shopify/widget-config deliberately ignores a rolloutPercent key, so a merchant tab can never change or reset a running rollout. Migration: 20260827_widget_rollout_percent.sql (adds the column + a 0–100 check constraint).

The observers are rollout-aware (#486). A rollout deliberately hides the button, so both watchers must not read it as a fault: the coverage card (lib/widget/coverage.ts) returns a rollout_active verdict when rolloutPercent < 100 — no percent, no cause, no “uncovered” products (the merchant card says a controlled A/B test is running); the health watchdog (lib/widget/health-watchdog.ts) classifies rollout_percent = 0 as paused_merchant_off (dark on purpose) and 1–99% as rollout_active — zero impressions can be sampling on a low-traffic store, so it never opens an episode; an open episode still clears when impressions resume. 100/NULL are byte-identical to the pre-#486 behaviour (pinned by NEVER-value tests). The widget_health_stats RPC carries rollout_percent since 20260828_widget_health_stats_rollout.sql (DROP + CREATE — the return type changed); until that migration is applied the row lacks the field and the TS falls through to today’s classification. The admin Rollout modal also shows the last widget version seen in widget_rollout_bucket events — old cached widgets ignore the rollout and are visible only there.


Unavailable — config or stage-2 failure (#410)

When the proxy config cannot be loaded (rejected / non-OK / non-JSON, after one ~2 s retry) or stage 2 (tryvio-boot.js) fails to load, the launcher must never be a full-opacity dead button:

  • Auto-placed / floating (product media, collection cards) → hidden (--hidden). There is no hint to show in the floating layout, so a visible-but-dead pill is the worst outcome.
  • Manual app block → stays, disabled at the CSS 0.45 (never an inline opacity:1), hint reads “Tryvio is temporarily unavailable.” (widget.try_on_unavailable when translations are available).
  • ?tryvio_debug=1 → the badge names the failed leg and reason; a tryon_widget_error beacon records it (see Widget load sequence → Failure path).

Button attention cue (#325)

Not a modal state — a pre-open attention cue on the “Try it on” trigger itself, meant to earn the first tap. CSS-only: .tryvio-product-button--attn + @keyframes ttn-attn in tryvio-theme.css — a ~2s heartbeat (scale ≤1.05) plus a sheen sweep, then ~7s idle, for 7 iterations inside a 9s cycle. prefers-reduced-motion: reduce disables the animation and the sheen entirely — zero movement, no substitute emphasis.

  • PDP — armed by tryvio-boot.js’s armAttentionCue, only after config resolved and the button is enabled/revealed (never inside the #273 pre-config window), on the first IntersectionObserver entry (threshold 0.6).
  • Cards — armed by tryvio-collection.js’s armCardCue at inject time, staggered with --ttn-attn-delay (0/1.1/2.2/3.3s, mod 4) so a grid of cards never strobes in unison.
  • Cancel on notice — a pointerenter on a cued trigger removes its own cue class immediately. Opening the widget (any click on a cued or uncued trigger) writes sessionStorage key tryvio_attn_dismissed_v1 and strips the cue class from every root on the page — the cue never reappears for the rest of that session. Storage blocked ⇒ treated as already dismissed (the safe failure for a cue is silence).
  • Merchant setting — theme-embed checkbox “Attention animation” (button_attention_cue), default on (deliberate: the default a merchant inherits must be the higher-converting one, #302). Delivered as data-button-attention (card bootstrap script tag) / data-tryvio-attention (launcher root); liquid uses == false comparisons, not the default: filter, because default: eats an explicit false.
  • Budget — zero bytes in tryvio-theme.js (#72’s 10 KB app-block cap); the trigger JS lives in tryvio-boot.js / tryvio-collection.js (unbudgeted), the animation itself in the already-loaded stylesheet.
  • Also carried on tryon_widget_impression / tryon_widget_click / tryon_modal_opened as ctx.btnAttn (on / off / unknown) — see Analytics & ROI → Widget event context.

Primary states

Gating

Consent is not a screen of its own (#235). There is no consent state and no .tryvio-consent markup anymore; #680 removed its dead CSS. When no consent record exists in localStorage key tryvio_consent_v1 (value { [shopDomain]: true }), a checkbox row sits inside the capture surface in the upload, camera and preview states.

ElementCopy / detail
Checkbox”I accept the Terms of Service [and Privacy Policy]” (links via .tryvio-modal__consent-link)
GateOnly Generate is gated: aria-disabled until ticked. Taking or choosing a photo stays free, because it never leaves the browser.
NudgePressing Generate unticked sets data-nudge on the row for ~700 ms instead of sending anything
After tickingThe row stays visible until Generate is pressed (owner, 2026-08-03), and the record is written

limit_reached — .tryvio-limit

Shown when the per-shopper rate limit returns remaining === 0 (loaded by loadRateLimitStatus via the rate-limit-status endpoint, not from proxy config).

VariantTriggerExtra element
Plainremaining === 0, no email gateReset line: “Your next try-ons unlock {time}.”
Email-gaterequiresEmail: true and config.captureEmailUrl is set.tryvio-limit__cta button: “Unlock {N} more try-ons” (N = rateLimits.emailGateBonusLimit, default 5)

Title: “You have reached your try-on limit”. There is no purchase/packages flow.

limit_reached — store overage-cap variant (storeCapReached, #62 “Option B”)

A second trigger for the same limit_reached state. The proxy config carries a top-level tryOnLimitReached: true flag (from stores.overage_cap_reached, cron-cached) — when present it takes precedence over the shopper rate limit at modal open, and no rate-limit session is created.

ElementCopy / detail
Title”Virtual try-on is temporarily paused”
BodyInvites the shopper to tell the brand
CTA”Tell the brand you want this” — shown only when storefront.demandSignalUrl is present in the config

Demand-signal CTA — tapping it POSTs JSON { shopDomain, productHandle, productTitle } to demandSignalUrl (POST /api/storefront/demand-signal; a 429 rateLimited response is treated as already-recorded). Busy-disabled while in flight (guarantees a single POST); success shows “Thanks — the brand has been notified.”, failure shows an inline error and re-enables for retry. One-shot per shop+product via sessionStorage key tryvio_demand_sent_<shop>_<handle>; the server additionally caps 10 signals/hour per shopper fingerprint.

Mid-session freshness — a 402 with code: "overage_cap_reached" from the try-on POST routes to this same screen mid-session (the config flag can lag the cron). An in-flight resumed generation is never pre-empted by the cap screen.

Config threading — proxy config → tryvio-boot.js’s modalConfig literal (tryOnLimitReached read from the top level, demandSignalUrl from storefront) → runModal(modalConfig) → openStorefrontModal(config) → passed to every createModal call as storeCapReached / demandSignalUrl.

New translation keys (en+bg, lib/i18n/translations.ts): widget.limit_cap_title / _body / _cta / _sending / _sent / _error.

Test coverage: e2e-suite scenarios 8b–8g, storybook states 08-limit-cap / 09-limit-cap-sent, committed proof playwright-debug/proof-62-limit-cap.js (12 checks), server contract pinned by api/proxy/route.test.ts + api/storefront/demand-signal/route.test.ts.

Email-capture modal — .tryvio-modal__panel--email-capture

Opened by showEmailCaptureModal when the shopper clicks the limit CTA (email-gate variant only). It renders on top of the limit_reached state; limit_reached remains the primary state underneath.

ElementCopy / detail
IconLock
HeadlinerateLimits.leadCaptureHeadline (default “Unlock more try-ons”)
Body”You’ve reached your free try-on limit. Enter your email to unlock N more.”
Email input—
Marketing-consent checkboxChecked by default
Terms checkboxUnchecked by default
Note”*Your email will never be shared with third parties.”
CTA”Unlock N More Try-Ons”

Capture

Entry rule: if sessionStorage key tryvio_photo_v1 holds a photo → go straight to preview (reuse); else on desktop → upload, on mobile → camera.

upload — .tryvio-modal__dropzone

Sub-states:

Sub-stateTriggerVisual
EmptyDefault on desktop entryDropzone label “Drop photo here or click to browse”; hint “JPG, PNG, WebP or iPhone photos · Max 25MB”
Recent striplocalStorage key tryvio_upload_history_v1 is non-empty (array of {dataUrl, fileName, timestamp}, max 5)renderUploadHistory shows a “Recent” thumbnail strip; tapping a thumb → preview. Each thumb has a corner X → in-thumb confirm bubble → removeUploadHistoryItem
Drag-overFile dragged over the dropzone.tryvio-modal__dropzone--dragover class applied

camera

Camera tab. Uses getUserMedia. On permission failure shows an inline “Camera access denied…” message. Controls: capture / switch / mirror. See Try-On Pipeline § Camera lifecycle for the iOS/Android rules.

Guidance over the live video (#319): the framing caption (or “Front-facing photo works best”) sits on a dark pill in the dock, and the lighting hint “Use a clear, well-lit photo…” is its second line. Nothing is written across the middle of the stage, where the shopper’s face is. The pill holds at least 4.5:1 over a pure-white feed. The #269 framing silhouette is fitted to the space above the dock (--ttn-dock-height), so the body part to frame is never drawn behind the cards.

preview (new photo)

After file selection or camera capture. Actions: Retake + Generate.

preview (reuse)

enterDefaultCaptureState finds a photo in sessionStorage tryvio_photo_v1 and enters preview directly — one-tap reuse, no re-upload prompt.


Generating

generating — .tryvio-modal__loading

Active while the provider task is in flight. Elements:

  • Progress track + animated fill
  • Text: “Analyzing your photo…”
  • Estimate: “Usually takes 30 to 90 seconds.”
  • Numeric percent

See Try-On Pipeline for the async callback + poll flow.

Minimized — the pill (.tryvio-mini, #738)

After Generate is pressed the widget shows the wait for ~1.5 s — counted from the tap, without waiting for the server to answer (owner, 2026-10-01; on a real store the create request takes several seconds) — then shrinks into a pill at the bottom centre of the screen (16 px above the bottom on phones + the iOS safe area, 24 px on desktop). The store page is fully usable again: no dim, no scroll lock.

  • Content: product thumbnail (up to 3 for a look), “Trying it on you…” (plural for a look), a thin progress bar and the same % as the widget’s loading bar. Past 1.25× the expected time the text becomes “Taking longer than usual…”.
  • Reopen: tapping the pill reopens the widget on the loading screen where it was (same session, the bar continues). ✕ during the wait minimizes again (a generation cannot be cancelled); ✕ after the result closes the widget for real.
  • Done / failure: on success the pill shows 100 % “Done!” for 0.6 s, then the widget opens by itself on the result. A failure opens it on the usual error screen with retry.
  • Never interrupts: if the shopper is typing in a field, has a theme dialog open (e.g. the cart drawer) or the tab is in the background, the pill waits and opens the moment they are done. While a theme dialog is open the pill hides, so it cannot cover the drawer’s checkout button.
  • Navigation: if the shopper moves to another store page (or reloads) while waiting, the pill returns on the next page where the widget runs, with the true progress, and the result opens there with the original product. Same 30-minute window as before.
  • One try-on at a time: while the pill waits, any try-on button reopens that try-on (no new session, no extra credit). Since #744 this holds from the first moment of a new page too: while boot is still bringing the pill back (resumePending installs a placeholder window._TN.mini), a launcher tap is held and opens the resumed try-on once it is back. The held tap (on a launcher or on the stand-in pill boot draws from the saved try-on) is answered at once with the opening skeleton, and stage 1’s early-tap scrim is dropped as soon as a tap reopens a try-on. The resume is claimed once per page by the first launcher, so a launcher rendered later (a lazy collection card, a re-rendered block) never draws a second pill. The gap has a 10 s limit (RESUME_GAP_MS): past it the stand-in goes, taps open normally again, and a late answer brings the pill back only if the shopper has not opened anything meanwhile — asked again once the modal bundle has loaded (resumeStillWanted, #747), since the limit can pass while it downloads. A tap on the stand-in itself is never dropped at the limit (#747): its skeleton keeps waiting (up to RESUME_TAP_CAP_MS = 25 s) and the try-on opens expanded when it arrives. The skeleton’s ✕ carries the label the modal saved with the try-on (closeLabel). When nothing turns out to be resumable, the held tap opens the launcher that was tapped. A saved pill in an unknown shape draws no stand-in; it never leaves the placeholder swallowing taps. The real pill replaces the stand-in in the same frame, without its fade-in.
  • Stuck ceiling: a try-on still pending after generationCeilingMs(maxSeconds) = max(5 min, 2.5 × the product’s expected time) is treated as failed. Since #744 (owner’s decision, 2026-10-02), Retry right after the ceiling (same session, same photo, same look) asks the status endpoint ONCE, at once (ceilingSession): a job that delivered late is shown with no second try-on; one still pending gets a new try-on immediately. Waiting on it longer was measured on prod and rejected: of the jobs still open at 5 min only 23 % deliver within the next 5, while a fresh job delivers ~95 % of the time. A look that fails stays the pending look (restoreLookAfterFailure), so Retry and the email capture’s Generate try the same look again. Since #747 Retry answers at once — the error is cleared and the wait shows while the stuck job is checked, and the bar carries on into the new try-on — and the check waits up to 15 s (RETRY_CHECK_TIMEOUT_MS; a slow answer is mostly a delivered job, whose output the route copies first), past which the new try-on starts. Every limit exit (rate limit, 402 store cap) saves the photo state, never generating (persistNotGenerating), so the next page has nothing false to resume; the ceiling matches against the look the try-on was actually SENT with (ceilingSentHandles), so a related try-on after a look gets its check too.
  • A result nobody saw yet (#744): a result that arrives while minimized or held back is saved with unseen: true; if the shopper leaves before it pops up, the next page with the widget opens it straight on the result (once — markResultSeen), and reports tryon_result_viewed there. The saved result carries what it IS (resultItem — a look’s gallery item with isBundle, its complements and a kit’s components — and triedProducts), so a look reopens as the look (merged receipt, cart, offer) and its tryon_result_viewed carries the same triedProducts the first page would have sent. The next page follows the same “never interrupts” rule: while the shopper is typing, has a theme dialog open or the tab is hidden, the result waits as the “Done!” pill and is reported seen only when it pops up. If the result is shown somewhere else first (another tab, or this page comes back from the bfcache after the next page showed it), the held pill goes away quietly instead of showing it, and counting it, a second time. A tap on another product’s try-on button before a result resumes opens that product; the result stays unseen.
  • Email capture after the shrink (#744): a 429 requiresEmail that arrives after the shrink hands the screen to the email capture (the pill goes); after a successful capture the SAME try-on modal comes back into view and generates. Before, the email screen’s success pressed Generate on a detached modal — a credit spent, nothing shown.

Implementation:

  • The modal overlay is hidden with data-minimized="true", not destroyed — its closure keeps polling. The pill is a .tryvio-mini button appended to body (role="status" live region for “done”).
  • Logic: src/tryvio-modal/minimize.js; spec + tests: apps/web/src/lib/widget/minimize-policy.ts.
  • Boot (assets/tryvio-boot.js) resumes a pending generation found in localStorage tryvio_modal_state_v2 via window._TN.resumeMinimized; stage 1 exposes kit.loadConfig to fetch the pending product’s proxy config.
  • window._TN.mini.open() is the one-at-a-time hook the launcher click checks first.
  • tryon_result_viewed now fires when the result is SEEN (on reveal), not when the poll finishes.
  • Closing/removing the modal stops polling; an in-flight status request no longer reschedules itself on a detached modal.
  • i18n keys (all 24 locales): widget.mini_generating, widget.mini_generating_many, widget.mini_overrun, widget.mini_open.

Result

result (single item)

Product card (.tryvio-modal__product-card): title, variant, price, compare-at price, offer code. Action buttons: Add to cart (.tryvio-modal__btn--cart), Download, Share, New photo (retakeBtn).

result + discount card

config.discount with showTiming: "post_result". Config keys: headline (supports {percent} placeholder), body, percent, ctaText, successText, optional code.

showDiscountCard renders .tryvio-discount as a separate card only when there is no code (shouldRenderSeparateDiscountCard). When a code is present it appears in the product-card offer field instead.

result + bundle (“Complete the look”)

config.bundle (discount + complement). Renders .tryvio-modal__bundle-card. Tapping checks the complement item → left button changes to “Try together”; the combined result sets data-mode="merged" on the panel. See the bundle/Complete-the-look feature spec.

Add/change bubble (#601). The card’s eyebrow row carries a header pill .tryvio-modal__bundle-addpill (“Add or change products”, widget.look_change_products) that opens a popup (.tryvio-modal__look-pop — anchored panel on desktop, bottom sheet on mobile) with the tier ladder, look rows, the CTL suggestion, an embedded #566 look-search module, a total, and a Done CTA that stages the picks onto config.bundle.complements. The card’s data-mode now also takes: "look" (≥2 picked products — uniform rows + total + achieved-tier badge) and "invite" (no suggestion at all — eyebrow + pill only, no product row); with 1 or 0 picked it stays the production suggestion card, now also showing the suggestion’s price. See Try-On Pipeline → Unified Add/change bubble.

Controls hidden / zoom

Tapping the result image fades controls via setResultControlsVisible. Pinch applies a CSS transform to zoom the result image.

Share banner — .tryvio-modal__share-banner

Triggered by opening the storefront URL with ?tryvio_open=1&tryvio_share=<token>. The widget fetches config.shareUrl/<token> and renders a banner “A friend shared their try-on — now try it yourself”. The retake button relabels to “Try it on yourself”.


Overlay / sub-states

These layer on top of a primary state without replacing it.

Results are saved to localStorage key tryvio_gallery_v1 by saveGalleryItem. Each item: { sessionId, productTitle, productHandle, resultImageUrl, variantId, variantTitle, price, compareAtPrice, variantImageUrl, productImageUrl, product, timestamp, isBundle?, bundleComplement? }. Max 5 items in upload history; gallery has no configured hard cap.

The toggle .tryvio-gallery__toggle (“My Try-Ons (N)”) opens .tryvio-gallery__drawer with result thumbnails. Each thumb has cart, compare, and delete (corner X → in-thumb confirm bubble → removeGalleryItem, re-renders with the drawer kept open) action buttons. Items are displayed within the configured output-retention window.

The delete X + confirm bubble is one shared component (attachThumbDelete / showThumbDeleteConfirm, classes .tryvio-thumb-del-btn / .tryvio-thumb-confirm) reused by both the gallery and the Recent strip.

Activating a gallery item makes that saved item’s product the active modal product context. A primary-only saved result clears any page-scoped config.bundle; it must not inherit the product page’s “Complete the look” complement. A saved bundle result carries its own bundleComplements and restores only those complements.

Compare

panel.__ttnEnterCompareMode(left, right) — the two items must have distinct resultImageUrl. Renders a split-slider comparison view.

storefront.relatedProductsUrl + config.storeId → “Similar items” toggle/drawer. Each card has a “Try on” button → startRelatedProductTryOn, which prompts “My current photo” (primary, if a photo is available) or “Upload new photo” (secondary).

Bundle pending

After tapping “Try together”, panel[data-bundle-pending="true"] is set and a banner .tryvio-modal__bundle-capture-banner appears: “Trying on together · Save N%”.

Inline error — .tryvio-modal__inline-error

A try-on response with { status: 'failed', error } triggers showInlineError. Example message: “Something went wrong while creating your try-on. Please try again.” with a hint line below.


Configs that affect which states render

Config / dataEffect
config.discountAdds discount card or offer code to result
config.bundleAdds “Complete the look” card, with the #601 add/change bubble (tier ladder + embedded look-search)
config.captureEmailUrl + requiresEmail: trueActivates email-gate variant of limit_reached
tryOnLimitReached (top-level)Activates the store overage-cap variant of limit_reached; takes precedence over the shopper rate limit
storefront.demandSignalUrlShows the “Tell the brand you want this” CTA on the store-cap screen
rateLimits.emailGateBonusLimitN in “Unlock N more try-ons” (default 5)
rateLimits.leadCaptureHeadlineEmail-capture modal headline
Past try-ons in tryvio_gallery_v1Gallery toggle visible
Photo in sessionStorage tryvio_photo_v1Skips upload/camera, enters preview directly
Desktop vs mobileDesktop entry → upload; mobile entry → camera
Locale (en / bg)All visible copy strings

Animations

NameApplied toSpec
ttn-panel-inPanel entrancetranslateY(16px) scale(.97) → identity, 280 ms cubic-bezier(.22,1,.36,1)
ttn-fade-in-up (animateIn)Preview / result enterFade + upward translate
Loading-fill progress.tryvio-modal__loading fill trackWidth animated to current percent
Gallery drawerOpen / closeSlide transition
Result controls fade.tryvio-modal__close + actionsOpacity 0 on image tap; restored on next tap
Pinch zoomResult imageCSS transform: scale(…)

Screenshots — the current design

Captured from the REAL widget via the Playwright harness (shopify-app/playwright-debug/widget-storybook.js

  • capture-*.js). These are the actual current states — click any screenshot to open it full-size in a new tab.

Gating

consent — terms checkbox + Accept
consent — terms checkbox + Accept
limit reached — reset time, no purchase
limit reached — reset time, no purchase
limit + email-gate — unlock 5 more
limit + email-gate — unlock 5 more
email-capture modal
email-capture modal

Capture

upload — empty dropzone
upload — empty dropzone
upload — Recent strip
upload — Recent strip
upload — drag-over
upload — drag-over
camera (shows access-denied in headless capture)
camera (shows access-denied in headless capture)
preview — new photo
preview — new photo
preview — reuse a prior photo
preview — reuse a prior photo

Mobile variants (entry differs — mobile defaults to camera):

upload empty (mobile)
upload empty (mobile)
camera (mobile)
camera (mobile)
preview (mobile)
preview (mobile)

Result

result — single product
result — single product
+ discount card (no code)
+ discount card (no code)
+ discount with code (in product card)
+ discount with code (in product card)
+ bundle suggest — complete the look
+ bundle suggest — complete the look
bundle checked — Try together
bundle checked — Try together
merged — both products
merged — both products
controls hidden — tap the image
controls hidden — tap the image
a friend shared — banner
a friend shared — banner
gallery toggle — My Try-Ons
gallery toggle — My Try-Ons
gallery drawer open
gallery drawer open
compare slider
compare slider
Similar items toggle
Similar items toggle
Similar items drawer — Try on
Similar items drawer — Try on
bundle pending — trying on together
bundle pending — trying on together
inline error
inline error