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.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.
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 byrollout-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) andtryvio-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): readsconfig.rolloutPercent(default100if not a number), and when< 100resolves the bucket and fireswidget_rollout_bucketbefore anything else. An"out"bucket hides the launcher (root.style.display = "none") and returns — notryon_widget_impressionis 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 samerolloutBucketForand 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 nowidget_rollout_bucketevents 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,
disabledat the CSS 0.45 (never an inlineopacity:1), hint reads “Tryvio is temporarily unavailable.” (widget.try_on_unavailablewhen translations are available). ?tryvio_debug=1→ the badge names the failed leg and reason; atryon_widget_errorbeacon 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’sarmAttentionCue, only after config resolved and the button is enabled/revealed (never inside the #273 pre-config window), on the firstIntersectionObserverentry (threshold 0.6). - Cards — armed by
tryvio-collection.js’sarmCardCueat 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
pointerenteron a cued trigger removes its own cue class immediately. Opening the widget (any click on a cued or uncued trigger) writessessionStoragekeytryvio_attn_dismissed_v1and 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 asdata-button-attention(card bootstrap script tag) /data-tryvio-attention(launcher root); liquid uses== falsecomparisons, not thedefault:filter, becausedefault:eats an explicit false. - Budget — zero bytes in
tryvio-theme.js(#72’s 10 KB app-block cap); the trigger JS lives intryvio-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_openedasctx.btnAttn(on/off/unknown) — see Analytics & ROI → Widget event context.
Primary states
Gating
Consent — a row inside the capture screen (.tryvio-modal__consent-row)
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.
| Element | Copy / detail |
|---|---|
| Checkbox | ”I accept the Terms of Service [and Privacy Policy]” (links via .tryvio-modal__consent-link) |
| Gate | Only Generate is gated: aria-disabled until ticked. Taking or choosing a photo stays free, because it never leaves the browser. |
| Nudge | Pressing Generate unticked sets data-nudge on the row for ~700 ms instead of sending anything |
| After ticking | The 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).
| 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 → 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.
| 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.
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 (
resumePendinginstalls a placeholderwindow._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 toRESUME_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, nevergenerating(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 reportstryon_result_viewedthere. The saved result carries what it IS (resultItem— a look’s gallery item withisBundle, its complements and a kit’s components — andtriedProducts), so a look reopens as the look (merged receipt, cart, offer) and itstryon_result_viewedcarries the sametriedProductsthe 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
requiresEmailthat 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-minibutton appended tobody(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 localStoragetryvio_modal_state_v2viawindow._TN.resumeMinimized; stage 1 exposeskit.loadConfigto 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_viewednow 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.
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, with the #601 add/change bubble (tier ladder + embedded look-search) |
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

















