Product groups (sibling colours) — #420
No new Shopify scope, nothing written to Shopify. Every source below is a read: GraphQL fields already covered by existing scopes, a merchant metafield, or the store’s own public storefront HTML.
Problem
Many stores sell each colour as a separate product, glued together in the storefront by a swatch
app. Example: Icedout sunglasses “Leon” / “Leon Brown” / “Leon Blue” are three distinct Shopify
products, one variant each, stitched into one swatch picker on icedout.bg by StarApps’ “Combined
Listings – Product Groups”. Shopify’s product JSON knows nothing about that grouping — each product
looks standalone to catalog_products.
Tryvio keeps the group itself, so the try-on modal can offer the sibling colours and swap the product without a page redirect (#421, see Part B).
Data model
Migration: 20260821_product_groups.sql.
product_groups
One row per (store, product) — a product is in at most one group. The group itself has no row of
its own (no name/settings); it’s just the set of rows sharing group_id.
| Column | Notes |
|---|---|
id | uuid PK |
store_id | FK stores, on delete cascade |
group_id | uuid — shared by every member of the group; not a foreign key to anything |
product_id | FK catalog_products, on delete cascade — a deleted product leaves its group automatically |
label | text, default '' — the colour name shown; '' = unknown, UI/widget fall back to the product title |
swatch_hex | text, check #rrggbb (or null) |
position | integer, default 0 |
source | check in ('auto_combined_listing', 'auto_metafield', 'auto_storefront', 'manual') |
source_key | text — stable key of the auto source (cl:<parent gid>, mf:<value>, sa:<starapps group id>); null for manual |
created_at / updated_at | timestamptz |
Constraints: unique(store_id, product_id) (one group per product), unique(group_id, position).
Index: (store_id, group_id, position) — the two reads are “all groups of a store” (products API,
one query) and “this group’s members”. RLS enabled, service-role only — no anon/authenticated
policies.
product_group_scans
Storefront-scan state, one row per store (PK store_id, FK stores, on delete cascade).
| Column | Notes |
|---|---|
status | idle | running | done | unsupported | failed |
supported | boolean, nullable — null = not probed yet, false = the storefront carries no group data we recognise |
cursor_id | resume point over catalog_products ordered by id; null = start from the beginning |
scanned_count / total_count / groups_found | progress counters |
last_error | text |
started_at / finished_at / updated_at | timestamptz |
Sources
Rules across all sources: manual wins over auto and survives re-syncs (planAutoGroupRows skips
products already held by a manual row); one group per product — the first derived group to claim
a product keeps it; groups with fewer than 2 resolvable members are dropped; editing an auto
group in the UI saves it as manual from then on.
1. auto_combined_listing — Shopify native Combined Listings
The ProductSync query (runShopifyProductSync, Catalog sync)
now selects combinedListingRole on every product. For nodes where that role is PARENT, one
follow-up query per parent — not nested in the page query (GraphQL cost) —
product(id: $id) {
combinedListing {
combinedListingChildren(first: 50) {
nodes {
product { id }
parentVariant { title selectedOptions { name value } }
}
}
}
}The result is stored on the parent’s catalog_products.metadata as combinedListingRole +
combinedListing.children[{ productId, label }]. Label resolution order: the colour option value
(matching Colour/Color/Цвят/… by name), else the joined option values, else the variant title.
The derived group = the parent (position 0, label '') + its children.
2. auto_metafield — recognised plain merchant metafields
A metafield whose value names the group. Recognised keys (GROUP_METAFIELD_KEYS in
src/lib/products/product-groups.ts):
custom.color_groupcustom.colour_groupcustom.product_groupcustom.sibling_grouptryvio.color_grouptryvio.colour_group
Labels are derived from the colour word found in the product title (extractColourWord).
StarApps keeps its own group in an app-owned metafield we cannot read — that data does not land here via this source. See source 3 for how StarApps groups are actually picked up.
3. auto_storefront — crawling the public product page
StarApps (and similar swatch apps) render the full group into every public product page as
window.vkcl_data.product_group_data:
[{ group_id, group_name, products: [
{ id, handle, published, product_display_name: { en: "Black" }, product_position }
] }]parseStarAppsGroupsFromHtml parses it out of the raw HTML (fixture:
src/lib/products/__fixtures__/starapps-leon-product-page.html, captured from icedout.bg,
2026-08-21).
Crawl: runStorefrontGroupScanSlice (src/lib/server/product-groups-storefront.ts). The
merchant clicks “Detect colour groups” in /products, which repeatedly POSTs
/api/shopify/product-groups/detect. Each call is a bounded slice — ≤40 page fetches / ~20s each,
maxDuration 60 — and the client keeps calling until remaining === 0. A cursor walks
catalog_products ordered by id; a page that turns out to already be in a group yields all its
siblings in that one fetch, so they’re skipped without a separate request. The first fetch decides
supported (hasStarAppsEmbed) — if the storefront carries no recognisable embed, the scan stops
after that one fetch and status becomes unsupported. { reset: true } re-detects the whole store
from scratch, dropping existing auto_storefront rows first. GET on the same route returns the
current scan state (for the “detecting… X/Y” progress caption).
4. manual
The merchant links products directly in /products → Edit try-on → Colours (linked
products).
Sync integration
At the end of a full catalog sync (final chunk of executeShopifyProductSync),
deriveAndApplyAutoGroupsForStore runs and replaces only auto_combined_listing +
auto_metafield rows for the store — auto_storefront rows are untouched (they’re owned by the
scan, not the sync) and manual rows are untouched (they always win).
API
GET /api/shopify/products now returns, per product, an additive field:
group: {
id: string
source: 'auto_combined_listing' | 'auto_metafield' | 'auto_storefront' | 'manual'
members: Array<{
productId: string
label: string
swatchHex: string | null
position: number
title: string
handle: string
thumbnailUrl: string | null
tryOnEnabled: boolean
status: string
}>
} | nullBuilt with one store-wide listProductGroupRows query, joined in memory
(attachProductGroups) — not N+1. Thumbnail precedence: merchant’s chosen reference image →
primary image → first image. A member with try-on off is kept in the list but flagged
(tryOnEnabled: false); a group left with fewer than 2 visible members is hidden entirely.
PUT /api/shopify/product-groups — body { groupId?, members: [{ productId, label?, swatchHex? }] }
(2–50 members). This is a replace-set: every id is ownership-checked
(filterOwnedProductIds → 403 on a foreign id), an unknown groupId → 404, and a member already in
another group is moved here (the old group is pruned if it drops below 2 members). Response:
{ ok, group }.
DELETE /api/shopify/product-groups?groupId= — unlinks the group.
GET /api/shopify/product-groups/suggestions — AC7 title-based suggestions: same title minus a
colour word + same vendor + same type, ≥2 candidates, ≥1 recognised colour word, excludes products
already grouped. Colour lexicon (EN/BG/RO) lives in extractColourWord.
GET / POST /api/shopify/product-groups/detect — the storefront-scan trigger/status route
described above.
Every route: resolveAuthorizedShopForShopifyRequest, store-scoped queries, and
revalidateTag(merchantProductsCacheTag) after any write.
UI
/products → Edit try-on drawer → section “Colours (linked products)”
(ProductGroupEditor, src/app/products/product-group-editor.tsx):
- Staged into a DRAFT lane (
row.colour_group) that feeds the page’s one save bar — nothing writes until Save. - Member rows: thumbnail, colour-label input, swatch-colour input, remove.
- Add via
ProductEntityPicker(name + thumbnail, existing members excluded); >10 members shows a filter box. - No group yet → “Link as colours” CTA; a title-based suggestion may show as a chip, “Link these N”.
- “Unlink group” stages the removal with an Undo affordance.
- AC5 — adding a product that already belongs to another group opens a confirm dialog naming that group’s current members, CTA “Move it here”.
- Fewer than 2 members after edits → inline error, blocks save.
- A source badge distinguishes “Detected automatically” vs “Linked by you”.
- Header button “Detect colour groups” with a live state caption (idle / detecting X/Y / done / not supported on this storefront).
- Save =
planGroupWrites→ onePUTper changed group, oneDELETEper unlink, then a silent reload of the product list.
Code map
| File | Role |
|---|---|
src/lib/products/product-groups.ts | Pure, browser-safe logic (label extraction, colour lexicon, planning) |
src/lib/server/product-groups.ts | Server-side reads/writes, sync integration |
src/lib/server/product-groups-storefront.ts | StarApps HTML crawl / scan slices |
src/app/products/product-group-editor.tsx | Drawer UI |
src/lib/server/storefront-product-snapshot.ts | Snapshot builder — adds siblings to StorefrontProductSnapshot |
02_app/shopify-app/extensions/tryvio-theme/src/tryvio-modal/index.js | Widget colour row + switchSiblingProduct |
Tests: product-groups.test.ts (incl. the #421 sibling-builder block), save-model-groups.test.ts,
product-groups-ui.test.ts, product-groups-storefront.test.ts,
api/shopify/product-groups/route.test.ts, storefront-snapshot-siblings.test.ts.
Real-seam: scripts/real-seam/rs-420-product-groups.mjs.
Part B (#421) — the widget: colour row + in-modal swap
Additive, backward-compatible API changes only — old cached widgets keep working unchanged.
Deploy order: API first, then the widget (manual deploy-widget.mjs).
API
StorefrontProductSnapshot (src/lib/server/storefront-product-snapshot.ts) gained
siblings: StorefrontSibling[]:
type StorefrontSibling = {
productId: string
handle: string
title: string
label: string
swatchHex: string | null
thumbnailUrl: string | null
productUrl: string
current: boolean
}buildStorefrontProductSnapshot(product, selected, imageSidesCollectionRule, siblings?) — the
caller resolves the list (it’s a DB read); omitted/empty → siblings: [] and every other field
stays byte-identical to the pre-#421 snapshot (AC4). Old cached widgets simply ignore the new
field (AC5).
Pure builder buildStorefrontSiblings({ currentProductId, rows, candidates }) in
src/lib/products/product-groups.ts: members in position order; only try-on ON (the store’s
auto_enable_new_products default applied), widget_visible !== false and status === 'active'
members are offered — the CURRENT product is always kept and marked current: true. label
falls back to the product title when the DB label is ''. productUrl = /products/<handle>.
Fewer than 2 offerable members (or the current product not in the group) → [].
Server read listStorefrontSiblings({ storeId, product, storeAutoEnable }) in
src/lib/server/product-groups.ts: the product’s own product_groups row (group_id) →
getProductGroupRows + listStorefrontProductsByIds → the builder. Thumbnail =
memberThumbnailUrl (merchant’s reference pick → primary → first).
GET /api/proxy runs it inside the existing Promise.allSettled fan-out (a failed read → []).
POST /api/storefront/session runs it in Promise.all beside createTryOnSession
(.catch → [], logged as session.siblings_failed) and attaches it to product.siblings in
the response. No new endpoints, no new scopes, nothing written to Shopify.
Widget (tryvio-modal.js, lazy stage)
Built from src/tryvio-modal/index.js; the tryvio-theme.js 10 KB app-block budget is
untouched. cloneProductSnapshot (product-utils.js and the tryvio-widget.js mirror) carries
siblings through.
A colour row .tryvio-modal__siblings (rail .tryvio-modal__siblings-rail, chips
.tryvio-modal__sibling reusing the shared .tryvio-modal__variant-swatch thumb, or a
swatchHex dot + label when there’s no thumbnail) is appended under the product line of the
product card:
- Shown on the upload/preview/result cards; hidden on the camera’s slim card.
- While a generation runs the row is
aria-disabledand a tap shows thewidget.sibling_waithint. - Only rendered when the ACTIVE product is one of the siblings — a saved look from a different product hides it.
- More than 10 colours scroll horizontally; the current chip is scrolled into view (AC6).
Tap → switchSiblingProduct:
- A busy guard makes a double-tap a single request.
POST /api/storefront/sessionfor the sibling’s handle — a NEW session, since the try-on is per product.- On success,
config.sessionId/productHandle/promptFamily/productbecome the sibling’s,config.tryvioProductSource = "sibling_colour", and the variant picker is REBUILT from the sibling’s ownvariantPicker(rebuildVariantPicker, AC8 — never the previous product’s shades relabelled under the new product). - The result image, related strip and discount card reset; cookies + cart attributes are
repointed (
syncCartAttributes); the shopper’s photo (selectedImageDataUrl) is kept. - From the
resultstate the modal returns topreview(photo kept) — the result belonged to the colour it was generated in. - The page URL never changes. “View product” (the card title/thumbnail) deep-links to the
sibling’s
productUrl. - Analytics event
tryon_sibling_switchfires witheventPayload { fromHandle, toHandle, groupSize }and the newtryonSessionId.
Failure (network / 403): everything rolls back to the previous product, an inline error
(widget.sibling_switch_error, mapped through mapStorefrontError) is shown, and the row is
live again — tapping the colour again IS the retry (AC7). No blank state.
The try-on route itself is unchanged — after a swap the widget simply sends the sibling’s
productHandle and the new sessionId.
i18n
New keys, EN + BG, merchant-editable in the translation catalog (group default):
widget.colour (“Colour” / “Цвят”), widget.sibling_wait, widget.sibling_switch_error.
Proof
02_app/shopify-app/playwright-debug/proof-421-sibling-colours.js — 16 checks covering AC1–AC8
plus the generating-lock and double-tap edges → 05_tasks/proof/421/ (desktop + 390px
screenshots, screen recording video/ac2-sibling-swap.webm). Vitest:
src/lib/products/product-groups.test.ts (#421 block),
src/lib/server/storefront-snapshot-siblings.test.ts, plus the session and proxy route tests.