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-theme.jsreadshideOnSoldOut, live-fetches/products/<handle>.js, and hides the trigger when all variants are unavailable (no flash — the trigger isopacity:0until resolved). - Collection / home cards —
tryvio-collection.jsmakes 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.
Primary states
Gating
consent — .tryvio-consent
Shown on the very first open when no consent record exists in
localStorage key tryvio_consent_v1 (value { [shopDomain]: true }).
Accepting writes that record and advances to the capture entry state.
| Element | Copy / detail |
|---|---|
| Title | ”Before we start your try-on” |
| Body | ”We need your permission to process your photo so we can generate your virtual try-on.” |
| Checkbox | ”I accept the Terms of Service [and Privacy Policy]“ |
| CTA | ”Accept” — disabled until the checkbox is checked |
| Note | ”You can close this window anytime before generating.” |
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).
| Variant | Trigger | Extra element |
|---|---|---|
| Plain | remaining === 0, no email gate | Reset line: “Your next try-ons unlock {time}.” |
| Email-gate | requiresEmail: 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.
| Element | Copy / detail |
|---|---|
| Title | ”Virtual try-on is temporarily paused” |
| Body | Invites 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 → minified tryvio-theme.js U literal
(tryOnLimitReached read from the top level, demandSignalUrl from storefront) →
runModal(U) → 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.
| Element | Copy / detail |
|---|---|
| Icon | Lock |
| Headline | rateLimits.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 checkbox | Checked by default |
| Terms checkbox | Unchecked 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-state | Trigger | Visual |
|---|---|---|
| Empty | Default on desktop entry | Dropzone label “Drop photo here or click to browse”; hint “JPG, PNG, WebP or iPhone photos · Max 25MB” |
| Recent strip | localStorage 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-over | File 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.
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.
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.
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.
Gallery (“My Try-Ons”)
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.
Related / Similar items
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 / data | Effect |
|---|---|
config.discount | Adds discount card or offer code to result |
config.bundle | Adds “Complete the look” card |
config.captureEmailUrl + requiresEmail: true | Activates 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.demandSignalUrl | Shows the “Tell the brand you want this” CTA on the store-cap screen |
rateLimits.emailGateBonusLimit | N in “Unlock N more try-ons” (default 5) |
rateLimits.leadCaptureHeadline | Email-capture modal headline |
Past try-ons in tryvio_gallery_v1 | Gallery toggle visible |
Photo in sessionStorage tryvio_photo_v1 | Skips upload/camera, enters preview directly |
| Desktop vs mobile | Desktop entry → upload; mobile entry → camera |
Locale (en / bg) | All visible copy strings |
Animations
| Name | Applied to | Spec |
|---|---|---|
ttn-panel-in | Panel entrance | translateY(16px) scale(.97) → identity, 280 ms cubic-bezier(.22,1,.36,1) |
ttn-fade-in-up (animateIn) | Preview / result enter | Fade + upward translate |
| Loading-fill progress | .tryvio-modal__loading fill track | Width animated to current percent |
| Gallery drawer | Open / close | Slide transition |
| Result controls fade | .tryvio-modal__close + actions | Opacity 0 on image tap; restored on next tap |
| Pinch zoom | Result image | CSS 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




Capture






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

















