upnova-store-analysis SKILL.md, verbatim

The AI skill used to run the SnugWell onboarding analysis and setup. Exact source file, no re-rendering, so step numbers and syntax are as written. Temporary host, rendered 2026-11-05.

---
name: upnova-store-analysis
description: Use when asked to suggest upsells for a Shopify store.
tags: [upnova, upsell, offer-fit, shopify, analysis, mcp]
---

# Upnova Store Analysis

> Profile note: full copy lives at the default-profile path (canonical). This profile copy is a mirror; patch the canonical first.

## Core method (condensed)

0a0. **AUTOMATED (2026-09-06):** the upsell-analysis-flow auto-runs ALL of: sitewide offer (0a1), corner style (0a2), discount inventory + combination (0a3) - including pulling discount USAGE (orders + $ by discount_title via ShopifyQL) into a 'Discount inventory' table with per-row product-combo status. Shown in the report. Manual analysis still verifies; automation is default enforcement.

0a1. **SITEWIDE OFFER DETECTION (mandatory, Son rule, 2026-09-06).** At the START of every analysis, scan the live website for any SITEWIDE offer. Homepage + a PDP + /cart. Sources: announcement bar text, sale banners/countdowns, promo collections, window.* upsell-app globals (EasyBundle etc.). Detect e.g. "X% off your order", storewide sale, sitewide BXGY.
   - **Why:** if sitewide offers already exist, Upnova MIGHT need to REPLACE them, not optimize alongside. But replacement is CONFIRMED BY SON first - never assume; flag it and let Son decide replace-vs-optimize.
   - Distinguish a real sitewide offer from a signup/list-capture incentive (e.g. "15% off joining the tribe" / text-to-join) - the latter is not a sitewide discount to replace.

0a2. **CORNER-STYLE DETECTION (mandatory, Son rule, 2026-09-06).** From the live site, detect SHARP (0px) vs ROUNDED corners. Pull the theme base.css (`/cdn/shop/t/<id>/assets/base.css`) and read the radius tokens on the page: `--buttons-radius`, `--inputs-radius`, `--media-radius` (these are set in the page's inline style). If ~0px -> SHARP.
   - **Why:** if the store is SHARP, switch the Upnova cart template to **cart-winston-sharp** (NOT the default rounded cart-winston) so the cart matches the store's aesthetic. Add this to the cart-setup step. If ROUNDED, keep the standard rounded template.
   - **AUTOMATED (2026-09-06):** the merchant MCP `update_cart_settings` now accepts `cart_template_id` (switches template, preserves settings), and the upsell-analysis-flow does this switch automatically when `writeCartConfig=true` and the store is sharp on `cart-winston`. Performed live on Danni (cart-winston -> cart-winston-sharp, verified).
   - Verified: Danni has `--buttons-radius:0px --inputs-radius:0px --media-radius:0px` -> SHARP -> must use cart-winston-sharp.

0a3. **DISCOUNT INVENTORY + COMBINATION CHECK (mandatory, Son rule, 2026-09-06, DGP session).** Every analysis report MUST list the shop's popular + automatic discounts AND check discount combination, alerting if product-discount combination is not enabled. Two layers:
   - **Usage (confirmed via ShopifyQL):** `FROM sales SHOW discounts, orders GROUP BY discount_title SINCE -90d UNTIL today` (also `GROUP BY discount_code` and `GROUP BY discount_type`). Gives popular codes, order counts, $ discounted. `discount_type` splits fixed_amount vs percentage.
   - **Combination config (`combinesWith`) = read via `get_shop_discounts` MCP tool.** Returns native discounts with product/order/shipping combination flags: AUTOMATIC (full) + MOST-COMMON codes (top ~25, ShopifyQL 90d). Bounded for speed. Backend: Gadget action `admin__getShopDiscounts`. Until live, report usage via ShopifyQL and mark combination not-yet-verifiable rather than guessing.
   - **HARD WARNING RULE (Son, 2026-09-06):** for EVERY popular code and EVERY automatic discount where `combinesWith.productDiscounts=false`, the report MUST warn that product-discount combination is NOT enabled (cannot stack with product discounts, including Upnova BXGY). Applies to all reports. Also state which ACTIVE discounts DO have product-combination ON, and flag the impact when Upnova offers run alongside.

0a4. **BRAND COLOR + PRODUCT-TITLE FONT DETECTION (mandatory, Son rule, 2026-09-10).** Every analysis must detect the shop's brand color AND the product-page title font, then apply that font to EVERY product title in Upnova surfaces: cart, cross-sells, bundles, bundle pages, bundle widgets, all widgets. Automated, not manual styling.
   - **Font detection recipe (verified on Stantt, 2026-09-10; browser-first since 2026-09-10, 22/22 on the live-shop suite):**
     1. Pick a real PDP handle: `curl -sL -A "<browser UA>" https://<store>/collections/all`, regex `/products/<handle>` out of the HTML (exclude gift-card handles).
     2. Ground truth = live DOM: get the product title from `/products/<handle>.json`, load the PDP in a headless browser (playwright, `brandStyleProbe.ts` in upnova-automation), locate the element whose text equals the title (punctuation-normalized), read `getComputedStyle(el).fontFamily`. Resolves every var(), @font-face and override automatically.
     3. Normalize: first family, strip variant suffixes (`-Regular`, `_BookHeavy`), underscores to hyphens.
     4. Static CSS trace (title selector -> theme CSS font-family) is the FALLBACK only when the browser probe fails. Verified limits: scored 13/22 across 22 live shops; JS-hydration themes and Dawn-family var chains defeat it.
   - **Decoy warning:** 3P app configs embed their own `font_family` fields (Stantt banner app carried `"font_family":"Graphik-Regular"`); only the live-DOM computed style counts.
   - **Application (no font re-loading needed):** @font-face declared in the shop document renders inside our shadow roots too. Widgets global `--font-family-primary: inherit` already exists (`widgets/src/styles/globals.css`); title elements across `page/cart-*`, cross-sell, bundle-builder templates consume `var(--upnova-product-title-font, var(--font-family-primary))`. Because custom properties inherit through shadow boundaries, ONE injected rule carries everywhere: `#upezCart, #upezPdpUpsell, upnova-widget { --upnova-product-title-font: "<detected font>"; }` via the app-embed snippet or cart settings `css`. Store the detected font + brand color in shop settings during the run so injection is config, not code. Verify: seeded cart, read computed fontFamily of a product title inside the shadow root.
   - Brand color detection stays as already specified: live PDP button `getComputedStyle(...).backgroundColor` -> hex, NOT theme CSS vars.

0. **EXISTING-DEALS RESEARCH FIRST (mandatory, Son rule,2026-09-05, dannigyrlsplayhouse session).** Before any data analysis for an onboarding, research what upsell/promo deals are ALREADY live on the shop. Sources: live storefront window globals (e.g. `window.easybundles_ext_data` for EasyBundle/GiftKart exposes bundle config, subscription trial status, orderSummary bundle revenue, and `shopOrderDataBeforeInstall` for pre-install revenue/orders), `/collections.json` + per-collection `/collections/<handle>/products.json` for promo collections ($X SALE, BUY 2 GET 1, BLOW OUT), and a third-party upsell-app scan (EasyBundle, UpCart, Kaching, AfterSell, Vitals). Then: replicate the working mechanic in Upnova, and the data analysis MUST take their existing deals into account - e.g. an existing B2G1 deal can make 3-item orders the #1 order size (48% observed on dannigyrlsplayhouse); that is the deal working, not organic basket behavior, and it inflates free-rider on any verbatim copy (74% observed). The report gets an explicit "existing deals inventory" section before the offer proposals.
1. Two-tier honesty model. Public catalogue is PROVISIONAL. Upnova MCP ShopifyQL data is CONFIRMED and decisive.
2. Resolve the shop, set_active_shop, check the shop field on every response.
3. Step 1 is order-size distribution. >=60 percent 1-item orders = Case A (BXGY), else Case B (Bundle / Free Gift with Bundle).
4. BXGY: X = top by ORDER COUNT, Y = from co-purchase matrix. Single-type orders -> Y is a second X. Use 20 percent off, never 10.
5. Free Gift: threshold above native AOV, never total_sales/orders.
6. Coverage-first portfolio: Bundle + Free Gift base, one BXGY with several Y, Order Volume campaign-only.
7. Charts mandatory. "Show me graphs." matplotlib Agg, verify with vision.
8. Third-party upsell app scan. MUST list non-Upnova upsell apps loaded live, even if Upnova is not live. Grep stacked script src for Kaching, AfterSell, Vitals, Attrac, Instant, ReConvert, EasyGift. Also note whether Upnova embed is mounted.
9. Per-offer metrics: REACH and FREE-RIDER are both mandatory rows.
   - Reach = percent of SHOPPERS exposed to the offer (placement x orders touching it), independent of qualification. Son: relabel coverage to reach.
   - Free-rider = percent of qualifying orders that would trigger anyway. Gift auto-SKU inflation note: report real paid rate, not raw cohort.
10. Full offer config deliverable, not a one-line verdict. Per offer give exact X with prices + proof, Y products with prices, discount, widget surface and placement, trigger collections, REACH percent, FREE-RIDER percent, rationale. Model on /Users/upnova/goforth-report/index.html.
11. Free-rider design gate. Son hard rule: free-rider must NOT be over 30 percent. Every proposed offer: FREE-RIDER <= 30, or the config is wrong and must be re-tuned.
    - Free Gift: free-rider = share of orders already at/above threshold. To lower it, rope the threshold; if no threshold keeps it <= 30 and leaves a meaningful qualifying share, mark offer not-fit-as-proposed and pivot.
    - BXGY / Product Volume: free-rider = share already buying the multi-unit config. Use the real paid figure, not the gift-inflated cohort.
    - Tuning frame: free-rider is the cost side of reach. If over 30 percent clear anyway, that is mostly dead money, redesign.
    - When every candidate is over 30 percent, say so and pivot.
    - Threshold tuning is automatic since 2026-09-13 (upnova-automation PR #52): tuneThreshold() scans upward from the AOV default for a threshold that keeps free-rider <= 30 with >= 10% of orders qualifying. Bimodal order distributions (cheap add-on peak + main peak) need this; AOV*1.1 can sit inside a 70%-free-rider peak (Glaze). Empty-string product_type (shop never fills the field) falls through to a product_title-based co-purchase instead of skipping the BXGY candidate. Sitewide detection strips style/script so inline CSS cannot fake a sitewide offer.

## HTML report look-and-feel

Design tokens from the DESIGN.md in the upnova-brain vault. Light system: page #F9FAFB, white surface, text #111827 / #4B5563, borders #E5E7EB, accent green #6DB43A. Font Inter. Charts as inline SVG, not a Chart.js CDN, because the CDN may be unreachable and leave blank canvas. Model on Go Forth report.

## Offer previews in reports (Son, hard rules 2026-09-05)

When the report covers offers that were BUILT (even in draft), it must embed a preview of the **ACTUAL offer** - the merchant's real products and the real mechanic - never a generic/brand-showcase widget. Rules:

1. Preview the actual offer config via the upez-frontend internal preview routes (see `upnova-mcp-merchant` skill, "Offer preview URLs" + `references/offer-preview-rendering.md`). Generic Storybook showcase stories are NOT acceptable for offer previews.
2. The internal preview routes cannot be iframed from the report origin (CSP frame-ancestors + application/liquid), so **screenshot the rendered preview in a real browser and embed the image** (hosted alongside the report, e.g. assets/ on the Pages deploy). Verify the pixels/DOM text before shipping.
3. If offers pivoted mid-session (e.g. B2G1 free replicate changed to buy-1-get-2nd-25%-off), re-sync EVERYTHING that advertises the mechanic before capturing: the cart marquee (cart settings, live immediately), linked widget headlines, and the report copy. A preview showing a stale deal is wrong.
4. Draft BXGY discounts do not reach `window.BUY_X_GET_YZ_DISCOUNTS` on preview pages, so strikethrough pricing cannot be previewed in draft; the promo surfaces (marquee + widget headlines) are what can be shown.

## Field lessons (store-analysis runs)

- Offer/widget TITLE validation rejects parentheses (and probably other specials). Allowed: letters, numbers, spaces, - _ . , ! ? % $. Write "Draft v2", not "(Draft)".
- The "50% off sitewide" banner can be compare-at pricing baked into every variant (sale price = actual price, no code). Detect by reading AU-market product JSON (?country=AU) compare_at_price, not just banner text. Then no replace-vs-optimize decision is needed; say so explicitly.
- Cart cross-sell preview mirrors suppress a suggested product that is ALREADY in the sample cart. If the offer's Y is the same product as X (a second-unit play), fill the sample cart with a DIFFERENT product so the upsell card renders, and state that substitution in the report caption.
- upnova-preview implements surfaces for buyXGetY and freeGiftWithBundle only; freeGift, productVolume, orderVolume and bundleBuilder print "No preview surface implemented". Hand over verbatim config cards + admin links for those (bundle builder has its own isolated preview route; see upnova-bundle-builder). These preview-tool rules should migrate to upnova-offer-preview-links once that user-owned skill is curator-adopted; bundle-builder headline richtext rules live in upnova-bundle-builder.
- Wrangler in this shell needs CLOUDFLARE_ACCOUNT_ID=ecc0d8f964e120dfbcd93ab2bce2c4d6 exported or it fails with two-account ambiguity; single-file preview mirrors deploy fine once the Pages project exists.
- ShopifyQL GROUP BY order_name/product_title hits the 2000-row cap on stores with 3k+ orders; split the window in two and merge locally.
- Storefront JSON/.js endpoints 429 from CLI (bot wall) after a few hits; fetch them from inside a browser session on the store origin instead.

## Create-phase notes (verified Sep 2026)

- create_buy_x_get_y_offer errors with createFlow unknown field, but the offer IS created; verify with list_offers, never blind-retry. List flows; if empty, create widget manually via create_flow.
- Check store password page BEFORE building.

## MECHANIC-CONFLICT GATE (Son hard rule)

Before ANY create_* offer call, cross-check the existing-deals inventory (step 0) for MECHANIC overlap on the same SKUs. If a third-party app (Kaching, Vitals, etc.) already runs a quantity-break / volume / bundle deal on that product family, an Upnova offer on the SAME mechanic conflicts: discounts stack on the same units, and the Upnova version can regress the customer's existing deal. Do NOT build it, not even as a draft. Detection is not enough: the offer-fit decision must CONSUME the existing-deals inventory before building.
- Conflicting mechanic found -> mark that offer type not-fit-as-proposed in the report with the overlap evidence (order counts using the existing deal), and pivot the build to a non-conflicting surface: bundle builder across product families, free-gift threshold, cross-sell to non-competing SKUs.
- A multi-unit deal already live on the hero SKU (e.g. a "2 Pillows 15%" bar with 430 orders behind it) is proof the mechanic works AND proof Upnova must not duplicate it. Route the multi-unit intent to Bundle Builder instead, and document the rejected build (with its evidence and preview) in the report so the decision is auditable.

## PRODUCT SHORT DESCRIPTIONS (Son rule, 2026-11-03, SnugWell)

As part of EVERY offer build (onboarding or analysis run), write `upez_cart.short_description` copy for every product in the built offers' pools (bundle builder pick pool, cross-sell widgets, free-gift pickers) via the merchant MCP `set_product_short_descriptions`. One write personalizes three surfaces: bundle builder cards, cart cross-sell cards, and free-gift picker cards.
- Copy rules: under 10 words, lead with the shopper benefit or the pain the product relieves, never repeat the product title. Charset is limited by validation: letters, numbers, spaces, - _ . , ! ? % $.
- Only trust the tool's per-product Shopify read-back (`written: true`); the older model-write path reported success while writing nothing (see the mcp-merchant skill).
- Rendering caveat: COLLECTION-scoped pools render immediately; product-scoped pools need upsell PR #105 merged and the extension deployed (bundle-builder.liquid enrichment gap). The free-gift picker and cart cross-sell render both pool types today.
- Verify visually on the isolated preview route after deploys land, then note the descriptions in the report's offer config.

## Prior-report retrieval (Son asks to see or re-host a past analysis)

Resolve the referenced deliverable BEFORE building anything new. Shorthand nouns in Son's one-liners ("the skill analysis", "onboard") map to the onboarding/analysis report this skill produces. Recovery order: session_search for the analysis run to recover the report URL and its version state (the URL lives in the session transcript, not on disk - reports are built in scratch dirs and deployed straight to Cloudflare Pages, so filesystem hunting finds nothing), then verify the URL is live with a plain fetch (web_extract avoids terminal approval stalls in chat sessions). Hand over the link plus deployment state: which version is deployed and which pending work it does not yet include. Redeploy only if the URL is dead or the live content is stale.

## Deliverables

- Google Doc report must embed the decision chart.
- HTML reports: deliver as a DURABLE Cloudflare Pages URL, not an ephemeral trycloudflare tunnel (Son: deploy it permanent, Go Forth-style). Steps: wrangler pages project create <slug> --production-branch main, then wrangler pages deploy <dir> --project-name <slug> --branch main. Live at https://<slug>.pages.dev/. Verify with curl -A browser-UA (a bare urllib/default UA gets a 403 bot-block; a browser UA returns 200).
- HTML + cloudflared tunnel variant. Decision chart needs a flat product array (unwrap /products.json first).

## References

- google-doc-report-delivery.md, shopifyql-verification-queries.md, Go Forth report at /Users/upnova/goforth-report/index.html