From 2cb6ccc94784d4f22331e6c99e034cf8221c71bf Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:29:04 -0700 Subject: [PATCH 01/12] =?UTF-8?q?spec(SD-0002=20=C2=A71):=20business=20con?= =?UTF-8?q?text=20=E2=80=94=20products=20bulk=20CSV=20import/export=20(#13?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 specs/SD-0002-products-bulk-csv-import-export.md diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md new file mode 100644 index 0000000..81122ca --- /dev/null +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -0,0 +1,277 @@ +# Solution Design: Products — bulk CSV import/export + +| | | +| --- | --- | +| **Anchor** | [wiggleverse/wiggleverse-ecomm#13](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/13) (`type/feature`, `priority/P1`) — parent epic [#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12) | +| **Author(s)** | Claude (session ecomm-0030), for Ben Stull | +| **Reviewers / approvers** | Ben Stull | +| **Status** | `draft` | +| **Version** | v0.1.0 | +| **Source artifacts** | BDD corpus: [prototype rfcs 11-05-\* (products import/export)](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs) · [research/shopify/products](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify/products/products.md) · Prototype: [wiggleverse-ecomm-prototype](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype) · Reference: Shopify product CSV · Builds on: [SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md) · Supersedes: — | + +**Change log** + +| Date | Version | Change | By | +| --- | --- | --- | --- | +| 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben | + +--- + +## 1. Business Context + +*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.* + +### 1.1 Executive Summary + +A merchant's catalog already exists before they ever meet this platform — in +spreadsheets, in their current platform's export, in the working files they +price and re-price every season. The value at stake is letting that catalog +*become* their storefront without re-entering it, letting catalog-wide changes +(a season's prices, a supplier's new costs) land as one gesture instead of +hundreds, and guaranteeing the catalog can leave whole at any time — so +adopting the platform never means retyping a business in, and never means +locking a business up. The beneficiaries are merchants — most acutely those +migrating from another platform — and the platform itself: every downstream +capability of an ecomm (browsing, orders, checkout) presupposes a real catalog +existing, and today none can. + +### 1.2 Background + +ecomm is Wiggleverse's multi-tenant Shopify alternative. Its first Solution +Design ([SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md)) shipped +sign-up, identity, and single-storefront creation: a merchant can exist and +their storefront can exist — and that storefront is, by design, an honestly +empty shell ("There's nothing to manage yet"). + +The Products MVP epic ([#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12)) +is the next move: give the storefront a real product catalog. The epic makes a +deliberate boundary decision: the MVP ships **no per-item editing** — bulk +ingestion and extraction is the *only* way product data moves. Its sibling +Features display the catalog +([#14](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/14), +list + read-only detail) and extend it to bundled offerings +([#15](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/15), +kits); **this** Feature is the mechanism they all feed from. + +The backdrop that makes bulk-first the right boundary: real merchant catalogs +are not built one item at a time inside an admin tool. Merchants switching +platforms hold their entire catalog as an export of their current system — +hundreds of products, each with variations, images, and prices. Merchants who +stay maintain their catalogs batch-wise: a seasonal price list, a supplier's +updated cost sheet, a spreadsheet kept as the working source of truth. The +catalog's natural lifecycle is bulk in, bulk update, bulk out. + +### 1.3 Business Actors / Roles + +Real-world roles — they exist whether or not this platform does. + +| Role | Responsible for (in the business) | +| --- | --- | +| **Merchant** | Owns a storefront's catalog: what is sold, at what price, shown how. Keeps it current as seasons, suppliers, and stock change. | +| **Migrating merchant** | A merchant switching platforms. Their catalog already lives, complete, in their current system and its export files; they decide whether switching is worth the cost. | +| **Platform builder** | Builds and validates the capabilities a catalog feeds (browsing, orders, demos). Needs realistic catalogs to exist before those capabilities can be exercised. | + +### 1.4 Problem Statement + +A storefront can exist but cannot hold anything sellable: there is no way to +get product data into the platform at all, and the MVP deliberately offers no +per-item editing to fall back on. For the merchant whose catalog already +exists elsewhere — the common case — hand re-entry of hundreds of products, +each with variations, imagery, and prices, is a switching cost that simply +ends the conversation. The gap recurs after adoption: catalog maintenance is +batch-shaped (whole-catalog price changes, supplier updates), and one-at-a-time +upkeep mismatches how merchants actually work their data. And data that goes +in must come out: a catalog that cannot be extracted whole makes the platform +a trap, which careful merchants correctly treat as a reason not to enter. + +### 1.5 Pain Points + +| # | Pain | Who feels it | Cost / frequency today | +| --- | --- | --- | --- | +| PP-1 | A created storefront is a dead end — nothing to sell, nothing to manage, nothing to show | Merchant | Total: blocks any real use of the platform; felt by every merchant immediately after sign-up | +| PP-2 | An existing catalog would have to be re-keyed item by item to move over | Migrating merchant | Hundreds of products × dozens of fields each — a prohibitive switching cost; prospective merchants don't switch | +| PP-3 | Catalog-wide changes (season prices, supplier costs) have no whole-catalog gesture | Merchant | Recurring — every season, supplier change, or repricing event; effort scales with catalog size | +| PP-4 | No realistic catalog can be stood up to build, demo, or validate against | Platform builder | Continuous — every product-dependent capability (#14, #15, future orders) is blocked or faked | +| PP-5 | Whatever goes in cannot be gotten back out — the catalog would be trapped | Merchant | Felt at the adoption decision (a reason to say no) and at every backup/audit/leave moment after | + +### 1.6 Targeted Business Outcomes + +| # | Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured | +| --- | --- | --- | --- | --- | --- | +| BO-1 | Merchants with existing catalogs adopt — populating a storefront is hours, not weeks | # storefronts holding a real catalog; effort to first full catalog | 0 storefronts can hold a catalog → a several-hundred-product catalog lands in under an hour of merchant effort | Sign-up → storefront flow (SD-0001) stays intact | Platform records (operationalized §8.1); reviewed monthly | +| BO-2 | Catalog upkeep happens *in* the platform — merchants refresh in bulk rather than letting data go stale or leaving | # storefronts performing repeat bulk updates | No upkeep possible → catalog-holding storefronts update in bulk as their prices/data change | Existing catalog data never corrupted by an update | Platform records; reviewed monthly | +| BO-3 | "My data would be trapped" is a non-objection — the catalog is extractable whole, always | Full-catalog extraction available and exercised | No extraction exists → every storefront's complete catalog can be taken out on demand, at any time | Extraction completeness: nothing the merchant supplied is unrecoverable | Platform records; reviewed quarterly | +| BO-4 | Product-dependent work is unblocked — sibling and downstream capabilities build against real catalogs | #14 / #15 / future order work proceeding against realistic data | Nothing to build against → realistic catalogs standable in minutes | — | Delivery record of #14/#15; per Feature | + +### 1.7 Scope (business) + +- **In scope:** populating a storefront's catalog in bulk from records the + merchant already holds; updating the catalog in bulk; extracting the + complete catalog at any time. "Catalog" here means products in full + commercial reality: variations (size/color and the like), imagery, + organization (vendor, type, tags, category), pricing/cost, stock levels, + and publication state. +- **Out of scope:** per-item create/edit (a later epic — the MVP's deliberate + boundary); removing products in bulk (nothing is deleted by this mechanism — + see §2); automated or scheduled synchronization with other platforms; + movement of non-catalog data (customers, orders — other epics); the meaning + and behavior of bundled offerings (kits — Feature #15, which rides this + mechanism but defines its own semantics). +- **Non-goals:** real-time integration with other commerce systems (the + supplied file is the boundary, deliberately); making the MVP a catalog + *editing* tool of any kind. + +### 1.8 Assumptions · Constraints · Dependencies + +- **Assumptions:** merchants hold, or can readily produce, their catalog as a + structured file (a spreadsheet, their current platform's export). *Risk if + wrong:* a merchant with no digital catalog at all has no entry path in this + MVP. Catalogs at MVP scale run to a few thousand items. *Risk if wrong:* + larger catalogs need heavier machinery (a named seam, §2). +- **Constraints:** bulk-only is Epic #12's deliberate MVP boundary — this + design must not quietly grow per-item editing. One storefront per account + (SD-0001 INV-4) — a merchant's catalog context is unambiguous. +- **Dependencies:** SD-0001's shipped surfaces (identity, storefront, admin + shell) — live as of v0.4.0. Sibling #14 displays what this ingests; sibling + #15 extends what the records can express — both sequence after this + mechanism exists. + +### 1.9 Business Use Cases + +**BUC-1 — As a migrating merchant, I can bring my existing catalog into my storefront in one bulk gesture, so that switching platforms doesn't mean re-keying my business.** + +```gherkin +Scenario: BUC-1 — An existing catalog comes over whole + Given a merchant whose catalog of several hundred products lives in an + export from their current platform + When they supply that catalog to their storefront in one gesture + Then the storefront holds the full catalog — every product with its + variations, imagery, organization, and prices + And nothing had to be re-entered by hand +``` + +- **BUC-1 acceptance criteria:** the catalog the storefront holds matches the + records the merchant supplied, completely; the merchant's effort is one + supply-and-review gesture, not per-product work. + +**BUC-1a — unhappy path: unusable records are reported, not silently dropped.** + +```gherkin +Scenario: BUC-1a — Unusable records come back with actionable feedback + Given a merchant supplies catalog records of which some are incomplete, + contradictory, or unreadable + When the storefront cannot accept some of the records + Then the merchant is told exactly which records failed and why, in terms + they can act on + And they can correct and re-supply the problem records + And nothing is silently dropped, guessed at, or invented +``` + +- **BUC-1a acceptance criteria:** every rejected record is identified with an + actionable reason; accepted vs. rejected is fully accounted for — the + merchant is never left wondering what made it in. + +**BUC-2 — As a merchant, I can see exactly what a bulk change will do to my catalog before it happens, so that I never damage my own data by accident.** + +```gherkin +Scenario: BUC-2 — A bulk change is reviewed before it takes effect + Given a merchant about to apply a bulk change to a catalog their business + depends on + When they supply the new records + Then they see what will be added, what will change (and to what), and what + is in error — before anything takes effect + And the change happens only when they say so +``` + +- **BUC-2 acceptance criteria:** the preview is complete and faithful (what it + shows is exactly what applying does); declining costs nothing; nothing + changes without the merchant's explicit go-ahead. *(OHM: consent & agency — + the merchant authorizes the change knowing what it is.)* + +**BUC-3 — As a merchant, I can update my whole catalog at once, so that batch-shaped changes (seasonal prices, supplier costs) cost one gesture instead of hundreds.** + +```gherkin +Scenario: BUC-3 — A catalog-wide change lands as one gesture + Given a storefront holding a catalog, and a season's repricing touching + most of it + When the merchant supplies the updated records + Then the affected products are updated to the new values + And the unaffected products are untouched + And no duplicates are created +``` + +- **BUC-3 acceptance criteria:** updates land on the products they target; + re-supplying unchanged records changes nothing (supplying the same catalog + twice never doubles it); the merchant's effort does not scale with catalog + size. + +**BUC-4 — As a merchant, I can take my complete catalog out at any time, so that my business data is never trapped in the platform.** + +```gherkin +Scenario: BUC-4 — The catalog leaves whole, on demand + Given a storefront holding a catalog the merchant built up over time + When the merchant asks for their catalog + Then they receive the complete catalog — every product, variation, image, + and price — in a form they can use elsewhere + And what they receive could rebuild the same catalog faithfully +``` + +- **BUC-4 acceptance criteria:** extraction is always available, complete + (nothing the merchant supplied is unrecoverable), and round-trips — what + comes out can go back in and reproduce the same catalog. *(OHM: agency — + leaving is always possible; the platform earns retention, never extorts + it.)* + +--- + +## 2. Solution Proposal + +*(not yet authored — next in the section-at-a-time loop)* + +## 3. Product Personas + +*(not yet authored)* + +## 4. Product Use Cases + +*(not yet authored)* + +## 5. UX Layout + +*(not yet authored)* + +## 6. Technical Design + +*(not yet authored)* + +## 7. Delivery Plan + +*(not yet authored)* + +## 8. Success Metrics & Measurement + +*(not yet authored)* + +## 9. Telemetry, Observability & Monitoring + +*(not yet authored)* + +## 10. Operations + +*(not yet authored)* + +## 11. Documentation plan + +*(not yet authored)* + +## 12. Traceability matrix + +*(not yet authored)* + +## 13. Open Questions & Decisions log + +*(not yet authored)* + +## 14. Glossary & References + +*(not yet authored)* -- 2.52.0 From 966323b7c3c550965c49e61dde8c9c4884bd8628 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:35:48 -0700 Subject: [PATCH 02/12] =?UTF-8?q?spec(SD-0002=20=C2=A72):=20solution=20pro?= =?UTF-8?q?posal=20=E2=80=94=20canonical=20CSV,=20Shopify=20dialect,=20ups?= =?UTF-8?q?ert=20+=20preview-confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 81122ca..0b7970d 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -226,7 +226,79 @@ Scenario: BUC-4 — The catalog leaves whole, on demand ## 2. Solution Proposal -*(not yet authored — next in the section-at-a-time loop)* +**Build software:** CSV file import/export in the storefront admin — a new +**Products** surface where a merchant uploads a CSV to add and update products +in bulk, and downloads a CSV of the full catalog at any time. + +**The shape of it:** + +- **One canonical CSV format**, Shopify-flavored: one row per variant, + consecutive rows sharing a `Handle` form one product (with its options, + variants, and images). The column set is a deliberate superset of Shopify's + product CSV — adding component columns (the kits seam, #15), volume, + cost/tax fields — so a Shopify export is structurally familiar and our + format never needs to churn when siblings arrive. The full column + specification is §6. +- **Two accepted dialects, auto-detected:** the canonical format, and + Shopify's own product-CSV export (mapped onto canonical at the boundary). + The header row identifies the dialect; the merchant never picks from a + menu. This is the migrating merchant's path (BUC-1): their existing export + imports directly. +- **Import is upsert-only.** A row whose `Handle` is new creates a product; a + known `Handle` updates it (variants matched by option-value combination). + Nothing is ever deleted by an import — the mechanism is non-destructive by + construction, so a partial file can never silently destroy catalog + (BUC-2's safety, structurally). +- **Validate-all → preview → confirm.** The whole file is validated and + diffed against the catalog before anything is written. The merchant sees a + summary — N products to add, M to update, K rows in error — and can drill + into any record to see exactly which fields will change and to what. + Nothing applies until they confirm; confirming applies the valid records + and leaves the error rows listed, unapplied, and actionable (BUC-1a, + BUC-2). Unrecognized columns are warned about in the preview and ignored, + never silently dropped. +- **Synchronous, with a stated capacity** of ~5,000 rows per file. No job + queue in the MVP; the design names the async seam for when catalogs + outgrow it (§6.9). One deliberate exception rides that seam's first rail: + **images**. +- **Images are fetched and hosted.** `Image Src` URLs are downloaded into + platform object storage after the catalog rows commit, with renditions + (thumbnail / card / detail) generated at fetch. Images that can't be + fetched, aren't images, or fall below the resolution bar are **rejected** + (the product shows a placeholder), with the outcome reported per image. + The catalog is self-contained: exports reference our hosted copies, and + the storefront never depends on a third-party host staying alive. +- **Imports are first-class records.** Every import run is kept — file name, + dialect detected, who ran it, add/update/error counts, image-fetch + outcomes — with a detail view the merchant can leave and return to + (the post-commit image phase reports there and on the Products page). +- **Export is one gesture:** the complete catalog (optionally filtered by + status) as a canonical CSV. Export → re-import is lossless and idempotent — + the acceptance bar from #13 and BUC-4's round-trip guarantee. + +**Why this approach.** CSV is the medium merchant catalogs already live in: +every platform exports it, every spreadsheet tool speaks it, and the +migrating merchant (the sharpest pain, PP-2) holds one today. Accepting +Shopify's dialect directly converts the largest migration population's +existing artifact into a working catalog with zero transformation effort. +Upsert-only plus preview-confirm makes a powerful bulk mechanism safe to hand +to a non-technical merchant: the destructive operations simply don't exist, +and every change is consented to with full knowledge (OHM: consent & agency). + +**Alternatives weighed** (detail in §6.7): a per-item editing UI first +(rejected — Epic #12's explicit boundary, and it does nothing for migration); +integration APIs / direct platform connectors (rejected — heavy, narrow, and +premature before the catalog model exists); accepting spreadsheet formats +(XLSX) (rejected for MVP — CSV covers the actual artifacts merchants hold; +XLSX is an adapter behind the same boundary later); do-nothing / concierge +data entry by the operator (rejected — doesn't scale past a demo and builds +no product capability). + +**Solution-specific non-goals:** no deletion semantics of any kind in the +CSV; no scheduled/automated re-import; no foreign dialects beyond Shopify's +product CSV in the MVP; no kit *semantics* (the format carries the columns; +#15 defines their meaning); no image editing/cropping — the platform hosts +what the merchant's URLs provide, or rejects it. ## 3. Product Personas -- 2.52.0 From f4e2a9b7920357c94166231d91cc86cacd735621 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:42:11 -0700 Subject: [PATCH 03/12] =?UTF-8?q?spec(SD-0002=20=C2=A73=E2=80=934):=20prod?= =?UTF-8?q?uct=20personas=20+=20product=20use=20cases=20PUC-1..11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 173 +++++++++++++++++- 1 file changed, 171 insertions(+), 2 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 0b7970d..73dc51f 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -302,11 +302,180 @@ what the merchant's URLs provide, or rejects it. ## 3. Product Personas -*(not yet authored)* +| Product persona | In ecomm | Maps to business role(s) | +| --- | --- | --- | +| **Merchant** | The authenticated storefront owner (SD-0001 §3), now extended: manages their catalog from the admin's new **Products** section — imports, reviews, confirms, exports. The MVP's only authenticated user type. | Merchant; Migrating merchant | + +The **Platform builder** (§1.3) gets no persona of their own: they exercise +the same Merchant surface with demo data (stand up a storefront, import a +realistic catalog). The **Operator** persona (SD-0001) is untouched by this +design. ## 4. Product Use Cases -*(not yet authored)* +Corpus grounding: scenario IDs cite the prototype corpus's products +import/export scenarios +([rfcs 11-05-0032–0037](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs)). +Divergences are marked — the load-bearing one: the corpus matches rows to +products **by SKU** (11-05-0035); this design matches products **by `Handle`** +and variants **by option-value combination**, with SKU as correctable data +(rationale in §6.7). + +**PUC-1 — The Products section exists, honestly** *(product-only; creates the surface #14 fills in)* + +```gherkin +Scenario: PUC-1 — Products page, before and after a catalog exists + Given a Merchant in their storefront admin + When they open "Products" in the admin navigation + Then with an empty catalog they see an empty state: "No products yet", + a primary "Import products" action, and links to the sample CSV and + column reference + And with a populated catalog they see the product count, "Import products" + and "Export" actions, and the import history — with an honest note that + the browsable product list arrives with a coming release (#14) +``` + +**PUC-2 — Upload a file to import** *(realizes BUC-1; corpus 11-05-0034)* + +```gherkin +Scenario: PUC-2 — A Merchant starts an import + Given a Merchant on the Products page + When they choose "Import products" and select a .csv file + Then the file uploads and a "Validating…" state shows progress + And they land on the import preview (PUC-3) when validation completes +``` + +**PUC-3 — Preview: see exactly what will happen** *(realizes BUC-2; the consent gate)* + +```gherkin +Scenario: PUC-3 — The preview summarizes and lets the Merchant drill in + Given a Merchant whose file has been validated + Then they see which dialect was recognized ("Canonical" or "Shopify + product CSV — mapped") + And a summary: N products to add, M to update, U unchanged, K rows in error + And any columns that will not be imported, listed by name + And selecting any product in the preview shows its record-level detail: + fields to be set (for adds) or a field-by-field before → after diff + (for updates) + And nothing has been written to the catalog yet +``` + +```gherkin +Scenario: PUC-3a — Declining costs nothing + Given a Merchant on the import preview + When they choose "Cancel" + Then they return to the Products page and the catalog is unchanged + And no trace of the canceled import remains in the import history +``` + +**PUC-4 — Confirm: the import applies** *(realizes BUC-1, BUC-3)* + +```gherkin +Scenario: PUC-4 — Confirming applies the valid records + Given a Merchant on a preview showing additions and updates + When they choose "Import N products" + Then an "Importing…" state shows progress + And they land on the import run's detail page: products added, products + updated, rows in error (each listed and unapplied), and the image-fetch + phase begins (PUC-7) + And the catalog now holds exactly what the preview showed +``` + +**PUC-5 — Row errors come back actionable** *(realizes BUC-1a; corpus 11-05-0036)* + +```gherkin +Scenario: PUC-5 — Bad rows are reported, good rows are not blocked + Given a Merchant's file where some rows are invalid (e.g. a malformed + price, a variant row whose options contradict its product) + Then the preview's error table lists each failed row: line number, the + column at fault, and what's wrong, in plain language + And confirming imports the valid rows and leaves the error rows unapplied + And the same error table remains on the import run's detail page +``` + +```gherkin +Scenario: PUC-5a — A file the importer cannot work with is rejected whole + Given a Merchant uploads a file that is not parseable CSV, is missing a + required column (e.g. "Title"), exceeds the row capacity (§2), or + matches no known dialect + Then the upload is rejected with one clear message naming the problem + And nothing is imported and no import run is recorded +``` + +**PUC-6 — A Shopify export imports directly** *(realizes BUC-1; the migration path)* + +```gherkin +Scenario: PUC-6 — A migrating Merchant brings their Shopify export + Given a Merchant with the product CSV their Shopify store exported + When they upload it unmodified + Then the preview shows "Shopify product CSV — mapped" and the standard + summary + And Shopify columns with no canonical equivalent are listed as not + imported (warned, never silent) + And confirming lands their catalog: products, variants, options, images, + prices, tags, status +``` + +**PUC-7 — Image outcomes are reported, not assumed** *(realizes BUC-1a for imagery)* + +```gherkin +Scenario: PUC-7 — The image-fetch phase reports per image + Given a confirmed import whose records reference image URLs + Then the run detail shows live progress ("Fetching images: 312 of 4,950") + And when complete, each image's outcome: fetched, rejected (below the + resolution bar / not an image), or failed (unreachable) + And products whose images were rejected or failed show a placeholder and + carry a visible badge on the Products page + And the Merchant can re-import corrected URLs to retry (upsert, BUC-3) +``` + +**PUC-8 — Import history** *(realizes BUC-2's accountability)* + +```gherkin +Scenario: PUC-8 — Past imports are reviewable + Given a Merchant who has run imports before + When they open the import history on the Products page + Then they see each run: when, file name, dialect, who ran it, and its + add / update / error / image-outcome counts + And opening a run shows its full detail page (PUC-4, PUC-5, PUC-7), + unchanged since the run finished +``` + +**PUC-9 — Export the catalog** *(realizes BUC-4; corpus 11-05-0032, 11-05-0033 — diverges: MVP filter is status only, vendor/collection filters deferred)* + +```gherkin +Scenario: PUC-9 — One gesture produces the whole catalog + Given a Merchant on the Products page with a populated catalog + When they choose "Export" (optionally filtering by status: + all / active / draft / archived) + Then a canonical-format CSV downloads containing every matching product — + all variants, options, tags, prices, and image URLs (pointing at the + platform's hosted copies) + And with an empty catalog the "Export" action is disabled, with a note +``` + +**PUC-10 — The round-trip is visibly lossless** *(realizes BUC-3, BUC-4; the #13 acceptance bar)* + +```gherkin +Scenario: PUC-10 — Re-importing an export is a no-op, and says so + Given a Merchant who exports their catalog and re-imports the file + unmodified + Then the preview shows every product as "unchanged" — nothing to add, + nothing to update, no errors + And confirming (or canceling) leaves the catalog identical +``` + +**PUC-11 — Learning the format** *(realizes BUC-1 for the cold-start merchant)* + +```gherkin +Scenario: PUC-11 — The sample CSV and column reference teach the shape + Given a Merchant who has never seen the canonical format + When they download the sample CSV from the Products page or import screen + Then it opens in their spreadsheet tool with a handful of example products — + including one with variants and one with images + And the linked column reference documents every column: name, meaning, + required or optional, accepted values +``` ## 5. UX Layout -- 2.52.0 From 08ded5fa1b4cf3ac11ac630e3da26a59028ba0d4 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:45:56 -0700 Subject: [PATCH 04/12] =?UTF-8?q?spec(SD-0002=20=C2=A75):=20UX=20layout=20?= =?UTF-8?q?=E2=80=94=204=20screens;=20design-tooling=20decision:=20Code=20?= =?UTF-8?q?directly=20(rubric=20=E2=88=921);=20reconcile=20PUC-7=20badges?= =?UTF-8?q?=20+=20PUC-10=20no-op=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 73dc51f..87a7ca5 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -425,7 +425,9 @@ Scenario: PUC-7 — The image-fetch phase reports per image And when complete, each image's outcome: fetched, rejected (below the resolution bar / not an image), or failed (unreachable) And products whose images were rejected or failed show a placeholder and - carry a visible badge on the Products page + are surfaced on the Products page — pre-#14 as an aggregate notice + ("N products have image problems") linking to the run detail; per-product + badges arrive with #14's list view And the Merchant can re-import corrected URLs to retry (upsert, BUC-3) ``` @@ -462,7 +464,9 @@ Scenario: PUC-10 — Re-importing an export is a no-op, and says so unmodified Then the preview shows every product as "unchanged" — nothing to add, nothing to update, no errors - And confirming (or canceling) leaves the catalog identical + And the import action is unavailable, with the note "Nothing to change — + your catalog already matches this file" + And canceling leaves the catalog identical ``` **PUC-11 — Learning the format** *(realizes BUC-1 for the cold-start merchant)* @@ -479,7 +483,117 @@ Scenario: PUC-11 — The sample CSV and column reference teach the shape ## 5. UX Layout -*(not yet authored)* +Text is the source of truth; wireframes are generated from these descriptions +on demand and never committed. All surfaces live inside SD-0001's admin shell +(its header: storefront name left, signed-in email + sign out right). + +### 5.1 Design tooling decision + +- **Decision:** Code directly. +- **Rationale:** rubric score **−1** (claude-design-vs-code.md): Q1 **+1** + (net-new Products section, no data-surface precedent in the codebase), + Q2 **−1** (this section describes the UI to wireframe level — named + components, enumerated states), Q3 **0** (one admin section, multi-step but + utilitarian), Q4 **+1** (no design system in the ecomm app yet), Q5 **−1** + (solo build, operator is the only reviewer), Q6 **−1** (work is dominantly + logic: parsing, diffing, upsert, image pipeline). Default at −1 is Code; no + override fires — the import preview is novel but structural, and prose + specifies it adequately. + +### 5.2 Screen: Products (serves PUC-1, PUC-7, PUC-8, PUC-9, PUC-11) + +- **Purpose:** the catalog's home in the admin; where imports start, exports + download, and past imports are reviewed. Created by this design; #14 fills + in the browsable list. +- **Layout (top → bottom):** + - **Header row:** title "Products" + product count (e.g. "Products · 412"); + actions right-aligned: secondary **"Export"** (opens a small menu: status + filter All / Active / Draft / Archived, then downloads — PUC-9; disabled + with a note when the catalog is empty), primary **"Import products"** + (→ §5.3). + - **Notices band (conditional):** image-problems aggregate notice — "N + products have image problems" linking to the most recent run's detail + (PUC-7); shown while any product's image status is rejected/failed. + - **Primary content, empty catalog:** empty state — "No products yet. Bulk + import is how product data gets in." with the **"Import products"** + action repeated, plus links **"Download sample CSV"** and **"Column + reference"** (PUC-11). + - **Primary content, populated catalog:** the honest pre-#14 state — the + product count and one line: "Your catalog is loaded. The browsable + product list arrives with an upcoming release." (replaced by #14's list). + - **Import history (section):** table of runs, newest first — date · file + name · dialect · added / updated / errors · image outcomes (e.g. + "4,938 ✓ · 12 ✗") · status (Importing… / Fetching images… / Complete / + Complete with problems). Row click → run detail (§5.5). Empty state: "No + imports yet." +- **States:** loading: skeleton for count + history · error: honest failure + banner with retry (INV-9 spirit) · empty/populated per above. +- **Notifications:** none beyond the notices band (no toasts here; outcomes + live on durable surfaces). + +### 5.3 Screen: Import — upload (serves PUC-2, PUC-5a, PUC-11) + +- **Purpose:** choose and validate a file. +- **Layout:** + - **Header:** "Import products", back to Products. + - **Primary content:** drop zone / file picker ("CSV, up to 5,000 rows"); + beneath it the format help: "Works with the canonical format or a Shopify + product export." + links to sample CSV and column reference (PUC-11). + - **Actions:** none beyond the picker — selecting a file starts upload + + validation immediately (one fewer click; canceling is free, PUC-3a). +- **States:** idle · uploading/validating: progress ("Validating… 2,140 of + 4,950 rows") with cancel · file-level rejection (PUC-5a): error banner + naming the one problem ("This file is missing the required column + 'Title'"), picker remains for retry · success: → preview (§5.4). +- **Notifications:** none (errors render in place). + +### 5.4 Screen: Import — preview (serves PUC-3, PUC-3a, PUC-5, PUC-6, PUC-10) + +- **Purpose:** the consent gate — see exactly what will happen, then decide. +- **Layout:** + - **Header:** "Import preview — ⟨file name⟩"; dialect line: "Recognized: + Shopify product CSV — mapped to canonical" or "Canonical format" (PUC-6). + - **Warnings band (conditional):** columns not imported, listed by name + (unknown-column warning); collapsible when long. + - **Summary tiles:** **N to add · M to update · U unchanged · K errors** — + each tile filters the detail list below. + - **Detail list:** one row per product (grouped from its variant rows): + handle, title, change kind (add / update / unchanged / error), variant + count. Expanding a row drills in (PUC-3): adds show the fields to be + set; updates show a field-by-field **before → after** diff (only changed + fields, per variant where applicable); errors show the error detail. + - **Error table (under the errors filter):** line number · column · plain- + language problem ("row 214: 'Variant Price' — '12,50' is not a price"). + - **Actions (sticky footer):** primary **"Import N products"** (disabled + when nothing valid to apply — all-unchanged reads "Nothing to change — + your catalog already matches this file", PUC-10); secondary **"Cancel"** + (returns to Products, no trace, PUC-3a). +- **States:** the preview *is* a state machine of its content (mixes of + add/update/unchanged/error per above); plus stale-session expiry: a preview + left open past its validity window (§6) prompts re-validation rather than + applying stale diffs. +- **Notifications:** none. + +### 5.5 Screen: Import run detail (serves PUC-4, PUC-5, PUC-7, PUC-8) + +- **Purpose:** the durable record of one confirmed import — what applied, what + errored, how the image fetch went. The post-confirm landing page and the + import-history destination. +- **Layout:** + - **Header:** file name · imported ⟨relative time⟩ by ⟨email⟩ · dialect. + - **Result summary:** added / updated / errors counts (mirrors the preview's + final numbers). + - **Images section:** while fetching — progress "Fetching images: 312 of + 4,950" (updates live); when done — outcome summary ("4,938 fetched · + 9 rejected · 3 failed") and an outcomes table listing only problems: + product handle · variant · image URL · outcome (rejected: below the + resolution bar / not an image · failed: unreachable) · what to do + ("correct the URL and re-import"). All-clean collapses to one line. + - **Error table:** the run's unapplied rows (line · column · problem), + exactly as the preview showed them (PUC-5). +- **States:** applying ("Importing…") · fetching images · complete · complete + with problems (errors and/or image problems present). +- **Notifications:** in-app only (decided: no email summaries in the MVP). ## 6. Technical Design -- 2.52.0 From a9137d17da028caffafa03f63c714141081ef93c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:49:15 -0700 Subject: [PATCH 05/12] =?UTF-8?q?spec(SD-0002=20=C2=A76.1=E2=80=936.4):=20?= =?UTF-8?q?invariants=20INV-10..18,=20architecture,=20data=20model,=20REST?= =?UTF-8?q?=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 170 +++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 87a7ca5..979239c 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -597,7 +597,175 @@ on demand and never committed. All surfaces live inside SD-0001's admin shell ## 6. Technical Design -*(not yet authored)* +Builds on SD-0001's shipped architecture (FastAPI backend, layered +`main → domains → platform` with import-linter enforcement; React/Vite admin +SPA; PostgreSQL everywhere). Invariant numbering continues from SD-0001 +(INV-1…9) so citations stay unique app-wide. + +### 6.1 Invariants + +- **INV-10 — Import never deletes.** No code path of the import/export + mechanism removes a product, variant, or image record from the catalog. + The domain API exposes no bulk-delete; "upsert" is the only mutation. + *Enforced:* domain surface (no delete function exists) + tests asserting + catalog cardinality never decreases across any import. +- **INV-11 — Nothing applies without confirmation.** Validation and preview + write nothing to catalog tables; only an explicit confirm mutates, and it + applies exactly the diff the preview showed — or refuses as stale + (BUC-2's faithfulness, mechanically). *Enforced:* the preview path holds + no write connection to catalog tables (tests assert zero writes); confirm + recomputes the diff and compares its fingerprint to the previewed one + (§6.5, PUC-3/4). +- **INV-12 — Export → import round-trips to a no-op.** Exporting a catalog + and re-importing the file unmodified produces an empty diff (PUC-10, + issue #13's acceptance bar). The canonical codec is lossless for every + field the catalog stores. *Enforced:* property-style test — for arbitrary + catalogs: `diff(catalog, import(export(catalog))) = ∅`. +- **INV-13 — `Handle` is product identity; the option-value combination is + variant identity.** Per storefront, one product per handle; per product, + one variant per (Option1, Option2, Option3) value combination. SKU is + correctable *data*, never identity. *Enforced:* unique indexes + (`(storefront_id, handle)`; `(product_id, option1_value, option2_value, + option3_value)`). +- **INV-14 — Every catalog row carries its storefront.** All product-domain + tables reference `storefront_id` directly or via their product; every + query is storefront-scoped (extends SD-0001 INV-5 to its first real + tenant-data domain). *Enforced:* FK constraints + service-layer scoping + + tests with two storefronts asserting zero bleed. +- **INV-15 — Imported HTML is sanitized at the boundary.** `Description` + HTML is sanitized against an allowlist (formatting tags; no scripts, + event handlers, or embeds) before storage. The catalog never stores HTML + we would not render. *Enforced:* sanitizer in the import path + tests + with hostile fixtures. +- **INV-16 — Surfaces serve only hosted images.** No storefront or admin + surface ever hotlinks a merchant-supplied URL; an image is + fetched-and-hosted (with renditions) or it is a placeholder with a + tracked status. The source URL is retained as data (provenance, retry). + *Enforced:* render paths take storage keys, not URLs; tests. +- **INV-17 — Dialects normalize at the boundary.** Every accepted file is + mapped to the canonical row model *before* validation and diffing; the + products domain knows exactly one model. A new dialect (XLSX, another + platform) is a new edge adapter, never a second code path through the + domain. *Enforced:* layering — dialect adapters live at the API/codec + edge; domain functions accept only canonical rows. +- **INV-18 — Import work is bounded and fails honestly.** Hard caps, + rejected whole-file with a named reason (PUC-5a): ≤ 5,000 data rows, + ≤ 10 MB file. Image fetching is bounded per image (≤ 20 MB, 30 s + timeout, content-type must be an image format we process). Over a cap is + an honest refusal, never a partial silent attempt (SD-0001 INV-9). + *Enforced:* checks at the upload/fetch boundaries + tests at the caps. + +### 6.2 High-level architecture + +```mermaid +flowchart LR + subgraph Browser + SPA[Admin SPA
Products section §5] + end + subgraph "ecomm backend (FastAPI, one process)" + BFF[REST BFF
main.py] + subgraph domains + ACC[accounts] + SF[storefronts] + PROD[products ◄ new
catalog · import/export
csv codec · dialects · diff] + end + subgraph platform + DB[db
Postgres + migrations] + OBJ[objectstore ◄ new
port: local / GCS] + IMG[images ◄ new
validate + renditions] + DEPS[deps] + end + BG[image-fetch task
in-process, post-commit ◄ new] + end + STORE[(PostgreSQL)] + GCS[(Object storage
GCS · dev: local disk)] + HOSTS[Merchant image hosts
egress fetch] + + SPA -->|JSON + multipart /api/*| BFF + BFF --> PROD + BFF --> ACC + BFF --> SF + PROD --> DB + PROD --> OBJ + PROD --> IMG + DB --> STORE + OBJ --> GCS + BG --> HOSTS + BG --> OBJ + BG --> DB +``` + +- **`products` domain (new)** — owns the catalog (products, variants, + images) and the import/export use cases: the canonical row model, the CSV + codec (parse/serialize), dialect detection + the Shopify mapping + (INV-17), validation, the diff engine (catalog × canonical rows → + add/update/unchanged/error), upsert application (INV-10/11/13), import + drafts and runs. Must never mint identity or storefronts; everything it + touches is storefront-scoped (INV-14). +- **`platform/objectstore` (new)** — a small port (`put / get / delete / + public_url`) with two adapters per the mailer pattern: local-disk + (dev/tests) and GCS (PPE/prod; bucket + credentials from deployment + config, SD-0001 INV-8). Owns no semantics. +- **`platform/images` (new)** — pure image processing: verify the bytes are + a decodable image (jpeg/png/webp), check the resolution bar, emit + renditions (thumb ~160px / card ~480px / detail ≤ 1600px — downscale + only). Model-free, deterministic, no I/O of its own. +- **Image-fetch task (new)** — the post-commit phase (PUC-7): an in-process + asyncio task per run that walks the run's pending images, fetches with + INV-18's bounds, runs `platform/images`, stores renditions via + `objectstore`, and updates per-image status + run progress. **This is the + async seam:** it is the only fire-and-forget work in the app; when + catalogs outgrow the in-process model, this task body moves onto a real + queue without changing its inputs/outputs (§6.9 covers crash recovery). +- **REST BFF** — gains the `/api/products/*` endpoints (§6.4); still owns no + business logic (SD-0001 INV-6). +- **Admin SPA** — gains the Products section (§5); renders what the BFF + returns; live progress is polling (no websockets in the MVP — deliberate). + +### 6.3 Data model & ownership + +All owned by the `products` domain; system of record PostgreSQL. Monetary +values are `NUMERIC`; no currency column — the MVP is single-implicit-currency +(open question Q-1, §13). + +| Entity | Key fields | Notes | +| --- | --- | --- | +| `product` | `id`, `storefront_id` (FK, INV-14), `handle` (unique per storefront, INV-13), `title`, `description_html` (sanitized, INV-15), `vendor`, `product_type` (`standalone`; `kit_virtual` / `kit_assembled` reserved for #15), `google_product_category`, `tags` (`text[]`), `status` (`draft \| active \| archived`), `published` (bool), `option1_name`…`option3_name`, timestamps | Option *names* live on the product; a no-option product has all names NULL and exactly one variant. | +| `variant` | `id`, `product_id` (FK), `position`, `option1_value`…`option3_value` (unique combo per product, INV-13), `sku`, `barcode`, `price`, `cost`, `weight` + `weight_unit`, `volume` + `volume_unit`, `tax_id_1`, `tax_id_2`, `inventory_tracker`, `inventory_qty`, `image_id` (FK → `product_image`, nullable), timestamps | The no-option product's single variant has NULL option values (uniqueness via `NULLS NOT DISTINCT`). SKU is indexed (component references, #15) but not unique — it is data, not identity. | +| `product_image` | `id`, `product_id` (FK), `position`, `source_url`, `alt_text`, `status` (`pending \| fetched \| rejected_low_res \| rejected_not_image \| failed`), `failure_reason`, storage keys (`original`, `thumb`, `card`, `detail`), `import_run_id` (FK — the run that introduced/last fetched it), `fetched_at` | Identity within a product is `source_url`: re-importing the same URL is "unchanged"; a new URL at a position is an update. INV-16 governs serving. | +| `import_draft` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, file blob (objectstore key), diff summary + fingerprint, `unknown_columns`, `expires_at` (~1 h), `created_at` | The preview's server side. Canceled or expired drafts are deleted outright — no trace (PUC-3a). Never appears in history. | +| `import_run` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, counts (`products_added`, `products_updated`, `rows_errored`), `status` (`applying \| fetching_images \| complete \| complete_with_problems`), `created_at`, `completed_at` | Created only at confirm (PUC-4). The durable record (PUC-8). | +| `import_run_error` | `id`, `run_id` (FK), `line_number`, `column_name`, `message` | One row per rejected CSV row (PUC-5); merchant-language message. | + +**Deliberately absent:** any `component` / kit table — the CSV format +reserves the columns (§6.5 dialect spec), but the entities, validation, and +meaning are #15's design. In this MVP a non-`standalone` `Type` or a +non-empty `Component n` column is a row error ("kits arrive in a coming +release"). + +### 6.4 Interfaces & contracts + +All under `/api/products/*`; session cookie + storefront membership required +(SD-0001's gates); errors share SD-0001's shape +`{"error": {"code", "message"}}`. Adding fields is free; renaming/removing is +a spec change. + +| Endpoint | In | Out | Errors | +| --- | --- | --- | --- | +| `POST /api/products/imports` | multipart CSV file | `201 {draft}` — dialect, summary counts, `unknown_columns`, `expires_at` (draft persisted server-side, INV-11) | `400 not_csv` · `400 missing_required_column` · `400 unknown_dialect` · `400 too_many_rows` · `413 file_too_large` (all PUC-5a — no draft created) | +| `GET /api/products/imports/drafts/{id}` | — | `200 {draft}` summary (re-fetch for the preview shell) | `404` · `410 draft_expired` | +| `GET /api/products/imports/drafts/{id}/records` | `?kind=add\|update\|unchanged\|error`, paging | `200 {records: [{handle, title, kind, variant_count, detail}]}` — `detail` carries field-level before → after diffs (PUC-3) or the error rows | `404` · `410 draft_expired` | +| `POST /api/products/imports/drafts/{id}/confirm` | — | `201 {run_id}` — diff re-computed and fingerprint-checked, applied in one transaction (INV-11), image task started, draft deleted | `404` · `410 draft_expired` · `409 preview_stale` (catalog changed since validation — re-upload) · `409 nothing_to_apply` (PUC-10) | +| `DELETE /api/products/imports/drafts/{id}` | — | `204` — draft and its blob deleted, no trace (PUC-3a) | `404` (idempotent enough) | +| `GET /api/products/imports/runs` | paging | `200 {runs: [...]}` — history, newest first (PUC-8) | — | +| `GET /api/products/imports/runs/{id}` | — | `200 {run}` — counts, status, error rows, image outcomes + `image_progress {done, total}`; the SPA polls this while `status != complete*` (PUC-4/7) | `404` | +| `GET /api/products/export` | `?status=all\|active\|draft\|archived` | `200 text/csv` (streamed) — canonical format, hosted image URLs (PUC-9, INV-12) | `409 empty_catalog` | +| `GET /api/products/sample.csv` | — | `200 text/csv` — the documented sample (PUC-11) | — | +| `GET /api/products/summary` | — | `200 {product_count, image_problem_count, latest_run_id}` — drives the Products page header + notice band (§5.2) | — | + +*(§§6.5–6.10 — per-PUC design incl. the canonical column spec and Shopify +mapping, NFRs, decisions, testing, failure modes, migration — next in the +loop.)* ## 7. Delivery Plan -- 2.52.0 From 4869c6141b7cf165489f9523834aaf585c558955 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:53:28 -0700 Subject: [PATCH 06/12] =?UTF-8?q?spec(SD-0002=20=C2=A76.5=E2=80=936.10):?= =?UTF-8?q?=20column=20spec,=20dialect=20mapping,=20per-PUC=20sequences,?= =?UTF-8?q?=20hosted-URL=20no-refetch,=20NFRs=20incl.=20SSRF=20guard,=20de?= =?UTF-8?q?cisions,=20testing,=20failure=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 285 +++++++++++++++++- 1 file changed, 281 insertions(+), 4 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 979239c..74ea2e1 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -732,7 +732,7 @@ values are `NUMERIC`; no currency column — the MVP is single-implicit-currency | --- | --- | --- | | `product` | `id`, `storefront_id` (FK, INV-14), `handle` (unique per storefront, INV-13), `title`, `description_html` (sanitized, INV-15), `vendor`, `product_type` (`standalone`; `kit_virtual` / `kit_assembled` reserved for #15), `google_product_category`, `tags` (`text[]`), `status` (`draft \| active \| archived`), `published` (bool), `option1_name`…`option3_name`, timestamps | Option *names* live on the product; a no-option product has all names NULL and exactly one variant. | | `variant` | `id`, `product_id` (FK), `position`, `option1_value`…`option3_value` (unique combo per product, INV-13), `sku`, `barcode`, `price`, `cost`, `weight` + `weight_unit`, `volume` + `volume_unit`, `tax_id_1`, `tax_id_2`, `inventory_tracker`, `inventory_qty`, `image_id` (FK → `product_image`, nullable), timestamps | The no-option product's single variant has NULL option values (uniqueness via `NULLS NOT DISTINCT`). SKU is indexed (component references, #15) but not unique — it is data, not identity. | -| `product_image` | `id`, `product_id` (FK), `position`, `source_url`, `alt_text`, `status` (`pending \| fetched \| rejected_low_res \| rejected_not_image \| failed`), `failure_reason`, storage keys (`original`, `thumb`, `card`, `detail`), `import_run_id` (FK — the run that introduced/last fetched it), `fetched_at` | Identity within a product is `source_url`: re-importing the same URL is "unchanged"; a new URL at a position is an update. INV-16 governs serving. | +| `product_image` | `id`, `product_id` (FK), `position`, `source_url`, `alt_text`, `status` (`pending \| fetched \| rejected_low_res \| rejected_not_image \| failed`), `failure_reason`, storage keys (`original`, `thumb`, `card`, `detail`), `import_run_id` (FK — the run that introduced/last fetched it), `fetched_at` | Identity within a product is `source_url`: re-importing the same URL is "unchanged" — never re-fetched. A **platform-hosted URL** (an export's `Image Src`) resolves to the existing image record by storage key — never fetched (same storefront required, else row error; INV-12/14). A fetch happens only for a genuinely new URL, or as retry of a `failed`/`rejected` one. INV-16 governs serving. | | `import_draft` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, file blob (objectstore key), diff summary + fingerprint, `unknown_columns`, `expires_at` (~1 h), `created_at` | The preview's server side. Canceled or expired drafts are deleted outright — no trace (PUC-3a). Never appears in history. | | `import_run` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, counts (`products_added`, `products_updated`, `rows_errored`), `status` (`applying \| fetching_images \| complete \| complete_with_problems`), `created_at`, `completed_at` | Created only at confirm (PUC-4). The durable record (PUC-8). | | `import_run_error` | `id`, `run_id` (FK), `line_number`, `column_name`, `message` | One row per rejected CSV row (PUC-5); merchant-language message. | @@ -763,9 +763,286 @@ a spec change. | `GET /api/products/sample.csv` | — | `200 text/csv` — the documented sample (PUC-11) | — | | `GET /api/products/summary` | — | `200 {product_count, image_problem_count, latest_run_id}` — drives the Products page header + notice band (§5.2) | — | -*(§§6.5–6.10 — per-PUC design incl. the canonical column spec and Shopify -mapping, NFRs, decisions, testing, failure modes, migration — next in the -loop.)* +### 6.5 Per–Product-Use-Case design + +#### 6.5.1 The canonical CSV format (the contract behind every PUC) + +UTF-8 (BOM tolerated) · comma-delimited · RFC 4180 quoting · header row +required · caps per INV-18. + +**Row grammar (Shopify-style):** consecutive rows sharing a `Handle` form one +product. The product's first row carries the product-level fields (`Title` +required there); each row may carry a variant (its `Option n Value`s + +`Variant *` fields), an image (`Image Src` + `Image Position`/`Alt Text`), +or both. Image-only rows (just `Handle` + `Image *`) are valid — that's how +a product carries more images than variants. + +| Column | Level | Required | Notes | +| --- | --- | --- | --- | +| `Handle` | product | every row | identity (INV-13): lowercase letters, numbers, dashes | +| `Title` | product | first row of a product | | +| `Description` | product | — | HTML allowed; sanitized (INV-15) | +| `Vendor` | product | — | free text | +| `Type` | product | — | `standalone` (default); `kit_virtual` / `kit_assembled` reserved — non-`standalone` is a row error until #15 | +| `Google Product Category` | product | — | taxonomy string | +| `Tags` | product | — | comma-separated within the cell | +| `Status` | product | — | `draft \| active \| archived`; default `active` on add | +| `Published` | product | — | `TRUE \| FALSE`; default `TRUE` on add | +| `Option1–3 Name` | product | with values | e.g. "Size"; a value without its name is a row error | +| `Option1–3 Value` | variant | per variant | the variant's identity combo (INV-13) | +| `Variant SKU`, `Variant Barcode` | variant | — | SKU indexed, not identity | +| `Variant Price`, `Variant Cost` | variant | — | decimal; no currency (Q-1) | +| `Variant Weight` + `Unit`, `Variant Volume` + `Unit` | variant | — | decimal + unit string | +| `Variant Tax ID 1–2` | variant | — | opaque references | +| `Variant Inventory Tracker`, `Variant Inventory Qty` | variant | — | qty integer ≥ 0 | +| `Variant Position` | variant | — | display order; defaults to file order | +| `Image Src`, `Image Position`, `Image Alt Text` | image | — | URL (or platform-hosted URL on re-import — §6.3) | +| `Variant Image` | variant | — | URL for this variant's specific image | +| `Component 1–10 SKU` / `Quantity` | variant | — | **reserved for #15** — non-empty is a row error ("kits arrive in a coming release") | + +**Update semantics (blank vs absent):** a column *absent from the file* is +untouched by the import; a cell *present but empty* clears that field (or +resets it to its default). The preview's diff shows clears explicitly — this +is previewable, deliberate behavior, not a footgun (INV-11). + +**The Shopify dialect** is recognized by its exact header set and mapped at +the boundary (INV-17): `Body (HTML)` → `Description`, `Product Category` → +`Google Product Category`, `Cost per item` → `Variant Cost`, +`Variant Grams`/`Variant Weight Unit` → weight fields; `Handle`, `Title`, +`Vendor`, `Tags`, `Status`, `Published`, options, SKU/barcode/price, +inventory and image columns map directly. Shopify columns with no canonical +home — `Type` (Shopify's free-text type), `Compare At Price`, SEO fields, +Google Shopping fields, fulfillment/shipping/tax-code flags, `Gift Card` — +are **warned as not-imported** in the preview (PUC-6); the implementation +plan pins the exhaustive mapping table as a fixture. + +#### 6.5.2 Import: upload → validate → preview (PUC-2, PUC-3, PUC-3a, PUC-5a, PUC-6) + +```mermaid +sequenceDiagram + actor M as Merchant + participant S as SPA + participant A as BFF + participant P as products domain + participant O as objectstore + participant D as Postgres + M->>S: select CSV file + S->>A: POST /api/products/imports (multipart) + A->>P: import_validate(storefront, file) + P->>P: parse · detect dialect · map to canonical (INV-17) + alt file-level problem (PUC-5a) + P-->>A: not_csv / missing_required_column / unknown_dialect / too_many_rows + A-->>S: 400 — no draft, no trace + else valid file + P->>P: validate rows · diff against catalog (read-only, INV-11) + P->>O: store file blob (private) + P->>D: insert import_draft (summary, fingerprint, expires_at) + A-->>S: 201 draft {dialect, counts, unknown_columns} + S-->>M: preview (§5.4); records paged via GET …/records + end + opt cancel (PUC-3a) + M->>S: Cancel + S->>A: DELETE …/drafts/{id} + A->>P: discard(draft) — row + blob deleted, no trace + end +``` + +The diff engine compares canonical rows to the storefront's catalog +per-product: field-by-field over product, variants (matched by option combo, +INV-13), and images (matched by `source_url`, with platform-hosted URLs +resolved to existing records — §6.3 — so an exported file diffs clean, +INV-12). Every product classifies as add / update / unchanged / error; +errors carry line + column + merchant-language message (PUC-5). + +#### 6.5.3 Import: confirm → apply (PUC-4, PUC-5, PUC-10) + +```mermaid +sequenceDiagram + actor M as Merchant + participant S as SPA + participant A as BFF + participant P as products domain + participant D as Postgres + participant T as image-fetch task + M->>S: "Import N products" + S->>A: POST …/drafts/{id}/confirm + A->>P: import_apply(draft) + P->>P: recompute diff · compare fingerprint (INV-11) + alt fingerprint differs + P-->>A: 409 preview_stale → re-upload + else nothing to apply (PUC-10) + P-->>A: 409 nothing_to_apply + else applies + P->>D: ONE transaction: upsert products/variants/images (INV-10/13),
insert import_run + errors, delete draft + P->>T: start(run) — images with status=pending + A-->>S: 201 {run_id} + S-->>M: run detail (§5.5), polling + end +``` + +Per-image work in the transaction: new `source_url` → `pending` row; +unchanged URL → untouched; platform-hosted URL → resolved, untouched; +prior `failed`/`rejected` URL re-supplied → reset to `pending` (the retry +path, PUC-7). + +#### 6.5.4 The image-fetch phase (PUC-7) + +```mermaid +sequenceDiagram + participant T as image-fetch task + participant H as merchant image host + participant I as platform/images + participant O as objectstore + participant D as Postgres + loop pending images of the run (bounded concurrency ~4) + T->>H: GET source_url (INV-18 bounds: 20 MB · 30 s · SSRF guard §6.6) + alt unreachable / oversize / wrong type + T->>D: status=failed + reason + else bytes received + T->>I: decode · resolution bar · renditions (thread pool) + alt not an image / below the bar + T->>D: status=rejected_* + reason + else ok + T->>O: put original + thumb/card/detail + T->>D: status=fetched + storage keys + end + end + T->>D: bump run image_progress + end + T->>D: run status → complete / complete_with_problems +``` + +Idempotent per image (keyed by image id; statuses only move +`pending → terminal`), so the task can die and resume at any point (§6.9). +The SPA polls `GET …/runs/{id}` for progress and outcomes. + +#### 6.5.5 Export (PUC-9) · the simple PUCs + +Export streams the catalog (optionally status-filtered) through the canonical +serializer — the same codec the importer parses (one codec, two directions; +INV-12 is a property of that codec plus hosted-URL resolution). `Image Src` +carries the hosted `detail`-rendition URL (or the original `source_url` for +never-fetched/`failed` images, so nothing is lost). PUC-1 (Products page) is +`GET /api/products/summary`; PUC-8 (history) is the runs list/detail +endpoints; PUC-11 (sample CSV) is a static documented asset served at +`GET /api/products/sample.csv` and linked from the column reference (DOC-2, +§11). + +### 6.6 Non-functional requirements & cross-cutting concerns + +- **Security & privacy:** all endpoints behind SD-0001's session + + storefront-membership gates; every query storefront-scoped (INV-14). + **SSRF guard on image fetch:** http/https only, public IPs only (resolve + and reject private/link-local/metadata ranges), no redirects across the + guard, bounded size/time (INV-18). HTML sanitization at the boundary + (INV-15). Uploaded file blobs are private objects, deleted with their + draft; catalog data is merchant business data (no shopper PII in this + feature). Run records and errors are retained indefinitely (they are the + merchant's audit trail); drafts expire in ~1 h. +- **Performance & scale:** validate + diff ≤ 5,000 rows in < 10 s + (one catalog read, in-memory diff); apply in one transaction < 5 s; + export streams (no whole-catalog buffering); image phase: ~4 concurrent + fetches, Pillow in a worker thread pool — a 5,000-image run completes in + tens of minutes *in the background* without touching request latency. +- **Availability & resilience:** the import transaction is short; the image + task is resumable across restarts/deploys (§6.9); a dead merchant image + host degrades to per-image `failed`, never a hung import. +- **Observability hooks:** `import_run.id` is the correlation id — the + fetch task and all run-scoped log lines carry it (substance in §9). +- **Accessibility:** preview/summary tiles and tables keyboard-navigable; + progress updates `aria-live`; diff colors paired with +/− glyphs (not + color-only). + +### 6.7 Key decisions & alternatives considered + +| Decision | Chosen | Alternatives considered | Why chosen | +| --- | --- | --- | --- | +| Matching identity | `Handle` for products, option-combo for variants (INV-13) | SKU as identity (the corpus's 11-05-0035) | Real catalogs have missing/duplicated SKUs; a SKU typo must be *correctable by import*, which identity can't be. Divergence from corpus recorded in §4. | +| Image handling | Fetch-and-host with renditions; reject low-res | Hotlink merchant URLs; validate-only + hotlink; accept low-res with warning | Hotlinking leaks shopper traffic to third parties, rots, and breaks INV-16; rejected-low-res keeps every hosted image renderable at all surfaces (operator decision). | +| Image timing | Post-commit background phase | Fetch during import (stalls minutes); fetch at preview (wasted on cancel) | Catalog lands in seconds; images are the async seam's first tenant. | +| Background model | In-process asyncio task, resumable | Job queue (Cloud Tasks/celery); cron sweep | No queue infra for an MVP with one fire-and-forget job; the task body is queue-portable when scale demands (§6.9 names the seam). | +| Preview state | Server-side `import_draft` + fingerprint | Client holds the diff and posts it back; re-validate blindly at confirm | The server applying exactly what it previewed is INV-11; a client-posted diff is unverifiable; fingerprint check catches catalog drift honestly (`409 preview_stale`). | +| Format | Canonical = Shopify-flavored superset | Invent a clean format; exact Shopify schema | Familiarity + migration head start without inheriting Shopify's gaps (no kit seam, no volume/tax fields). | +| Shopify free-text `Type` | Warned, not imported | Map into `Tags`; map into canonical `Type` | Canonical `Type` is structural (`standalone`/kits, #15); silently folding a category string into it (or tags) invents data the merchant didn't write. | +| Blank vs absent | Absent column untouched; empty cell clears | Empty cell ignored (Shopify-ish); empty cell always clears including absent | Merchants must be able to clear a field via import; the preview shows every clear (INV-11), making the sharper semantic safe. | +| Progress delivery | SPA polls run detail | WebSockets/SSE | One poll endpoint, no connection infra; seconds-stale progress is fine for this UX. | +| Notifications | In-app only | Email summaries via SmtpMailer | Operator decision; revisit if imports go async/long. | +| Object storage | New `platform/objectstore` port (local/GCS) | Store images as DB blobs; reuse a public bucket directly in domain code | Follows the mailer port pattern (SD-0001); DB blobs bloat backups and can't serve renditions efficiently. | + +### 6.8 Testing strategy + +Two-tier per handbook §9: unit/integration locally and in CI; E2E browser +tests (Playwright) against localhost and PPE as the pipeline gates. + +- **Unit — codec & dialects:** parse/serialize round-trip property tests + (INV-12: `diff(catalog, import(export(catalog))) = ∅` over generated + catalogs); a real Shopify export fixture mapping correctly; unknown-column + warning extraction; blank-vs-absent semantics; every §6.5.1 row-error rule + has a fixture. +- **Unit — diff engine:** add/update/unchanged/error classification; + option-combo matching incl. the no-option single variant; hosted-URL + resolution (no re-fetch); clear-vs-untouched fields; fingerprint stability. +- **Unit — images:** `platform/images` against fixture bytes (good jpeg/png/ + webp, tiny image, not-an-image, oversize); rendition dimensions. +- **Unit — sanitizer:** hostile HTML fixtures (scripts, handlers, embeds). +- **Integration:** apply transactionality (a mid-apply failure leaves zero + catalog change); two-storefront isolation (INV-14); fetch-task resume + after a simulated kill; SSRF guard against private-range URLs. +- **E2E browser scenarios (named):** + `e2e_import_preview_confirm` (canonical file → preview counts → drill-in + diff → confirm → run detail → summary count updated); + `e2e_import_shopify_dialect` (unmodified Shopify fixture, mapped banner + + not-imported warnings); `e2e_import_errors_actionable` (bad rows: error + table lists line/column/reason; valid rows import); + `e2e_import_file_rejected` (missing `Title` column — whole-file message, + no run recorded); `e2e_import_cancel_no_trace`; + `e2e_roundtrip_noop` (export → re-import → all-unchanged, import action + disabled); `e2e_export_download` (status filter respected); + `e2e_image_outcomes` (fixture image host serving one good / one 404 / one + tiny image → statuses, placeholder, Products-page notice). The E2E suite + runs against a local fixture image host (no real egress in tests). +- **Bootstrap:** SD-0001's INV-1 test extends — fresh DB → sign-up → + storefront → import sample CSV → export matches. + +### 6.9 Failure modes, rollback & flags + +- **Failure mode:** crash mid-apply → **behavior:** transaction rolls back — + zero catalog change; draft intact; SPA shows honest error → + **rollback:** none needed → **alerted by:** ALR-2 (§9.3). +- **Failure mode:** process restart (deploy) mid image-fetch → + **behavior:** on startup, a recovery scan finds runs in `fetching_images` + with `pending` images and resumes the task; per-image idempotency (§6.5.4) + makes re-entry safe → **alerted by:** TEL/log line on recovery; ALR-3 if a + run stays incomplete past threshold. +- **Failure mode:** merchant image host down/slow → **behavior:** per-image + timeout → `failed` with reason; run completes `complete_with_problems`; + retry is a re-import (PUC-7) → no alert (expected operation). +- **Failure mode:** object storage unavailable → **behavior:** affected + images `failed` honestly; uploads (drafts) refuse with a clear error → + **alerted by:** ALR-3. +- **Failure mode:** draft expiry / catalog drift between preview and confirm + → **behavior:** `410 draft_expired` / `409 preview_stale`; UI prompts + re-upload — never applies stale diffs (INV-11). +- **Feature flag / kill switch:** none — the Products section ships whole; + rollback is redeploying the prior version (pipeline §7.3). Migrations are + additive new tables, so the prior version runs unaffected against the + migrated schema (SD-0001 INV-7 forward-only). +- **The async seam, named:** if catalogs outgrow the in-process model + (imports > 5k rows, image sets taking hours), the fetch-task body moves + onto a real queue (Cloud Tasks or a worker process) with identical + inputs/outputs; `import_run`/`product_image` statuses already model the + asynchrony, so the UX and API do not change shape. + +### 6.10 Migration & compatibility + +Greenfield within a live app: no existing catalog data to migrate. New +additive tables (§6.3) via forward-only migrations (SD-0001 INV-7); the +admin nav gains "Products"; no existing endpoint or contract changes. +SD-0001's INV-1 (empty is a working state) holds — an empty catalog is the +designed Products-page empty state. **Backwards compatibility:** none owed +yet (no external API consumers). **Cutover:** ships whole behind the +ordinary release pipeline; no dual-write, no point of no return short of +merchants having imported real data (which INV-10/12 protect). ## 7. Delivery Plan -- 2.52.0 From 50574fe68bf56c4fdf2bf33189a4d3bfe08a8a72 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 12:38:36 -0700 Subject: [PATCH 07/12] =?UTF-8?q?spec(SD-0002=20=C2=A76.3.1):=20storage=20?= =?UTF-8?q?design=20=E2=80=94=20Cloud=20SQL=20additive,=20per-env=20GCS=20?= =?UTF-8?q?bucket,=20key=20layout,=20app-served=20images=20with=20CDN=20se?= =?UTF-8?q?am?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 74ea2e1..64a81e0 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -743,6 +743,57 @@ meaning are #15's design. In this MVP a non-`standalone` `Type` or a non-empty `Component n` column is a row error ("kits arrive in a coming release"). +#### 6.3.1 Storage: GCP resources, layout & serving + +**Catalog data — Cloud SQL for PostgreSQL, no new resource.** The §6.3 +tables are additive forward-only migrations in the existing per-environment +Cloud SQL instance (PPE: `ecomm-ppe-pg`; prod: its sibling when prod stands +up). At MVP scale (≤ 5k products × ~30 columns + run/error records) this is +tens of megabytes. Image *bytes* never enter Postgres (§6.7). + +**Image bytes — one GCS bucket per environment**, project +`wiggleverse-ecomm`, same region as the VM, Standard class, uniform +bucket-level access, versioning off, **no public access**. Provisioning is a +flotilla/launch-app gesture (a `provision-bucket` sibling of +`provision-datastore`), run by the operator when the first slice needing it +reaches PPE (handbook §8.5); the bucket name reaches the app via deployment +config (SD-0001 INV-8). The VM's service account gets +`roles/storage.objectAdmin` scoped to this bucket only. Local dev and tests +use the `objectstore` port's local-disk adapter under the identical key +scheme — no GCS, no emulator. + +**Key layout** — tenant-scoped, immutable, cache-friendly: + +``` +gs://wiggleverse-ecomm--media/ + storefronts/{storefront_id}/product-images/{image_id}/original ← bytes as fetched + storefronts/{storefront_id}/product-images/{image_id}/thumb.webp ← ~160px + storefronts/{storefront_id}/product-images/{image_id}/card.webp ← ~480px + storefronts/{storefront_id}/product-images/{image_id}/detail.webp ← ≤1600px + import-drafts/{draft_id}/upload.csv ← preview blobs +``` + +- The `storefronts/{storefront_id}/` prefix extends INV-14 into storage. +- Keys are `image_id`-addressed and **written once, never mutated** — a + changed image is a new image id, so every object is immutable and serves + with `Cache-Control: public, max-age=31536000, immutable`. +- Renditions normalize to **WebP**; the `original` keeps its source format + (provenance; renditions can be re-derived if the bar or sizes change). +- `import-drafts/` carries a **GCS lifecycle rule: delete after 1 day** — + the deterministic safety net behind app-level draft deletion (PUC-3a), so + a crash can never leak a merchant's uploaded file. + +**Serving — app-served with a CDN seam (decided, §6.7).** Surfaces load +images from `GET /api/products/images/{image_id}/{rendition}` (§6.4): the +app authorizes (image → storefront, INV-14/16), streams from the bucket, and +sets the immutable cache headers. Exported `Image Src` URLs therefore live +on the **app's own domain** — they survive the `market.wiggleverse.org` +rebrand (#23) via ordinary redirects, and hosted-URL recognition (§6.3) is a +same-origin path parse. **The seam:** when shopper-facing storefronts (#24) +bring real image traffic, a CDN-fronted bucket (LB + Cloud CDN + +`images.`) takes over the same keys; only the URL builder and the +serving route change. + ### 6.4 Interfaces & contracts All under `/api/products/*`; session cookie + storefront membership required @@ -762,6 +813,7 @@ a spec change. | `GET /api/products/export` | `?status=all\|active\|draft\|archived` | `200 text/csv` (streamed) — canonical format, hosted image URLs (PUC-9, INV-12) | `409 empty_catalog` | | `GET /api/products/sample.csv` | — | `200 text/csv` — the documented sample (PUC-11) | — | | `GET /api/products/summary` | — | `200 {product_count, image_problem_count, latest_run_id}` — drives the Products page header + notice band (§5.2) | — | +| `GET /api/products/images/{image_id}/{rendition}` | `rendition ∈ original\|thumb\|card\|detail` | `200 image bytes` — storefront-authorized, streamed from the bucket, `Cache-Control: immutable` (§6.3.1, INV-16) | `404` (incl. wrong storefront) · `409 not_fetched` (status ≠ fetched → SPA shows placeholder) | ### 6.5 Per–Product-Use-Case design @@ -968,6 +1020,8 @@ endpoints; PUC-11 (sample CSV) is a static documented asset served at | Progress delivery | SPA polls run detail | WebSockets/SSE | One poll endpoint, no connection infra; seconds-stale progress is fine for this UX. | | Notifications | In-app only | Email summaries via SmtpMailer | Operator decision; revisit if imports go async/long. | | Object storage | New `platform/objectstore` port (local/GCS) | Store images as DB blobs; reuse a public bucket directly in domain code | Follows the mailer port pattern (SD-0001); DB blobs bloat backups and can't serve renditions efficiently. | +| Image serving | App-served route + private bucket, CDN seam named (§6.3.1) | Public bucket with direct GCS URLs; CDN + custom domain now; signed URLs | Exported URLs stay on the app domain (rebrand-safe #23, hosted-URL recognition trivial); bucket stays private; CDN waits for shopper traffic (#24). Signed URLs ruled out — expiry poisons the round-trip (INV-12). | +| Rendition format | WebP renditions, original kept as-source | Keep source format everywhere; AVIF | WebP: universal browser support + good compression; original retained so renditions can be re-derived (incl. a future AVIF tier). | ### 6.8 Testing strategy -- 2.52.0 From 07abd48b505632fbe3e4fc300903509520ca4a89 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 12:42:37 -0700 Subject: [PATCH 08/12] =?UTF-8?q?spec(SD-0002=20=C2=A77):=20delivery=20pla?= =?UTF-8?q?n=20=E2=80=94=20SLICE-5..8,=20spine-first=20sequencing,=20rollo?= =?UTF-8?q?ut=20via=20pipeline,=20risks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 102 +++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 64a81e0..f644f3e 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -1100,7 +1100,107 @@ merchants having imported real data (which INV-10/12 protect). ## 7. Delivery Plan -*(not yet authored)* +Slice numbering continues app-wide from SD-0001 (SLICE-1…4), like the +invariants: this design owns **SLICE-5…8**. + +### 7.1 Approach / strategy + +**Spine first, infra second, compatibility last.** The codec + diff engine is +the design's novel heart and its riskiest logic — it lands first, with the +full preview/confirm UX, so the consent model (INV-11) is real from day one. +Export lands second and immediately locks the round-trip property (INV-12) +while the data model is still images-light — cheaper to fix losslessness bugs +before the image pipeline complicates identity. Images land third (the +infra-heaviest slice: first GCS bucket, first background work). The Shopify +dialect lands last: by then it is a pure mapping at a stable, well-tested +boundary (INV-17), not a moving target. SLICE-8 can run in parallel with +SLICE-7 if sessions overlap — it touches only the dialect adapter and docs. + +Execution convention per the standard: each slice is its own coding session — +just-in-time implementation plan → execute → verify → ship through the §9 +pipeline → merge + version bump — in dependency order. The design pass +happens once, here; if executing a slice proves this spec wrong, amend the +spec, then replan. + +### 7.2 Slicing plan + +#### SLICE-5 — Import spine (canonical, no images) → completes PUC-1, PUC-2, PUC-3 (+3a), PUC-4, PUC-5 (+5a), PUC-8, and PUC-11's sample download + +- **Depends on:** — (SD-0001 shipped surfaces only) +- **Ships:** `products` domain + §6.3 tables (images table present; + fetch phase stubbed to no-op); canonical parser, validation, diff engine, + draft/confirm/run endpoints (§6.4); Products page, upload, preview with + drill-in, run detail (no images section), import history; sample CSV. +- **Definition of done:** BUC-1/1a/2 acceptance criteria met for the + canonical path; INV-10/11/13/14/15/18 enforced + unit/integration suites + green; E2E green: `e2e_import_preview_confirm`, + `e2e_import_errors_actionable`, `e2e_import_file_rejected`, + `e2e_import_cancel_no_trace`; TEL-1/2 emitting (§9); DOC-1 updated. + +#### SLICE-6 — Export & the round-trip lock → completes PUC-9, PUC-10 + +- **Depends on:** SLICE-5 +- **Ships:** canonical serializer (one codec, two directions), streamed + export endpoint + status filter UI, `nothing_to_apply` no-op preview + behavior, the INV-12 property test over text-field catalogs. +- **Definition of done:** BUC-3/4 acceptance criteria met; INV-12 property + test green in CI; E2E green: `e2e_export_download`, `e2e_roundtrip_noop`; + TEL-3 emitting; DOC-1 updated. + +#### SLICE-7 — Images pipeline end-to-end → completes PUC-7 + +- **Depends on:** SLICE-6 (export must exist so hosted-URL round-trip is + testable the day hosted URLs exist) +- **Ships:** `platform/objectstore` (local + GCS adapters) and + `platform/images`; **the per-environment media bucket — provisioned via + the flotilla `provision-bucket` gesture when this slice reaches PPE + (§6.3.1, handbook §8.5)**; fetch task with SSRF guard, bounds, and + restart recovery; image-serving endpoint; hosted-URL resolution in diff + and export (INV-12 extended over images); run-detail images section, + Products-page notice band; INV-16 in force. +- **Definition of done:** PUC-7 acceptance met; INV-12 property test + extended over image URLs; integration: fetch-resume after kill, SSRF + fixtures; E2E green: `e2e_image_outcomes` (fixture image host); TEL-4/5 + + ALR-3 live; DOC-1 updated (bucket gesture documented in the operator + guide). + +#### SLICE-8 — Shopify dialect + public format docs → completes PUC-6, PUC-11 + +- **Depends on:** SLICE-5 (codec boundary); parallelizable with SLICE-7 +- **Ships:** header-set dialect detection, the Shopify→canonical mapping + with its exhaustive fixture table (§6.5.1), preview's "mapped" banner + + not-imported warnings; the **column reference** public doc + final sample + CSV (DOC-2/3 — the §7.3 launch gate for the migration path). +- **Definition of done:** BUC-1 acceptance met for an unmodified real + Shopify export fixture; E2E green: `e2e_import_shopify_dialect`; DOC-2/3 + published; traceability matrix (§12) audited complete. + +### 7.3 Rollout / launch plan + +Via the mandatory pipeline (handbook §9), per slice: localhost + E2E → PPE +(`https://ecomm-ppe.wiggleverse.org`) + E2E → prod. **Prod does not exist +yet** (SD-0001's prod stand-up under `market.wiggleverse.org` is the cursor's +next stop): any slice completing before it lands ships to a green PPE and +rides the prod stand-up when it arrives — the PPE gate is never skipped +either way. Infra provisioned on demand at the stage that needs it +(§8.5-handbook): the media bucket at SLICE-7's PPE arrival (and again at its +prod arrival), operator-run. Prod is a single in-place slot (no blue/green +yet, per SD-0001). Rollback per slice = redeploy prior version (additive +migrations keep it safe, §6.10). **Non-code launch gates:** DOC-2/3 (column +reference + sample) published before SLICE-8 exposes the migration path; +§9.3 monitoring live with its slice; support path = tracker, pre-v1 (§10.2). + +### 7.4 Risks & mitigations + +| Risk | Likelihood / impact | Mitigation | +| --- | --- | --- | +| Diff-engine edge cases (blank-vs-absent, option matching, no-option variants) breed subtle catalog corruption | M / H | Property tests + per-rule fixtures (§6.8); INV-11 preview faithfulness makes any miss visible before it applies; INV-10 caps the blast radius (nothing deletes) | +| 5k-row validate+diff misses the < 10 s target on the shared VM | M / M | Measure at SLICE-5 with a 5k fixture; the cap is config, can drop without spec change (INV-18); async seam already named | +| Shopify export format drifts or has regional variants | M / M | Detection by exact header set fails *honestly* to `unknown_dialect` (never misparses); mapping is fixture-pinned; unknown columns warn rather than fail | +| Image fetch is an attack surface (SSRF, decompression bombs) | M / H | §6.6 guard (public IPs only, no cross-guard redirects, size/time/type bounds, Pillow decode caps) + integration fixtures | +| Deploy cadence kills in-process fetch tasks mid-run | M / L | Restart recovery is designed-in (§6.9) and integration-tested; per-image idempotency | +| Bucket provisioning gesture stalls SLICE-7 at PPE (operator availability) | L / L | Gesture named in the slice DoD; flotilla command handed to operator at session start, not discovery at deploy time | +| Scope creep toward in-app editing ("just one edit form") | L / M | §1.7 boundary + INV-10; editing is a future epic, not a slice | ## 8. Success Metrics & Measurement -- 2.52.0 From e4cd24e631175ffc35330d7a7804fdcb4660a682 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:05:48 -0700 Subject: [PATCH 09/12] =?UTF-8?q?spec(SD-0002=20=C2=A7=C2=A78=E2=80=9311):?= =?UTF-8?q?=20metrics,=20telemetry=20TEL-1..6,=20alerts=20ALR-2/3=20+=20ru?= =?UTF-8?q?nbooks=20RB-2..4,=20ops,=20docs=20plan=20DOC-1..4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index f644f3e..562f1ef 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -1204,19 +1204,131 @@ reference + sample) published before SLICE-8 exposes the migration path; ## 8. Success Metrics & Measurement -*(not yet authored)* +### 8.1 Business success metrics + +| Outcome | Measured by (source) | Baseline captured how | Owner | Review cadence | +| --- | --- | --- | --- | --- | +| BO-1 (catalog adoption) | # storefronts with `product_count > 0`, and upload→confirmed-run conversion time (TEL-1, TEL-2 + DB) | Trivially zero pre-launch (no mechanism exists) | Ben | Monthly | +| BO-2 (in-platform upkeep) | # storefronts with ≥ 2 confirmed runs; days between runs (TEL-2) | Zero pre-launch | Ben | Monthly | +| BO-3 (no lock-in) | Export availability (uptime of the endpoint) + exports performed (TEL-3) | n/a — exists from SLICE-6 | Ben | Quarterly | +| BO-4 (downstream unblocked) | #14 / #15 delivery proceeding against imported catalogs | Tracker record, not telemetry | Ben | Per Feature | + +### 8.2 Product success metrics + +| # | Metric (scores PUC-n) | Target | Fed by | Review cadence | +| --- | --- | --- | --- | --- | +| PM-1 | Import completion: share of merchants who, having uploaded a file, reach a confirmed run within 24 h (PUC-2→4) | ≥ 80% | TEL-1 + TEL-2 | Monthly | +| PM-2 | Row-error rate per confirmed run — proxies how learnable the format and how actionable our errors are (PUC-5, PUC-11) | median < 5% | TEL-2 | Monthly | +| PM-3 | Image fetch success per run (PUC-7) | ≥ 95% fetched | TEL-4 | Monthly | +| PM-4 | Upload→preview latency, ≤ 5k-row files (PUC-3) | p95 < 10 s | TEL-1 | Monthly | ## 9. Telemetry, Observability & Monitoring -*(not yet authored)* +Scaled to the single-VM deployment: structured log events + a handful of +log-based alerts. Deliberately omitted: metrics stack, tracing, dashboards — +the runs list (PUC-8) *is* the import dashboard; revisit at shopper-traffic +scale (#24). + +### 9.1 Telemetry + +Log-event telemetry (structured JSON, app log). No file names, no URLs, no +catalog content in events — counts and durations only (file names/URLs stay +in DB records the merchant owns). No secret bytes ever (hard rule §6.3-handbook). + +| # | Event / signal | Trigger | Payload (shape) | Privacy class / retention | Consumed by | +| --- | --- | --- | --- | --- | --- | +| TEL-1 | `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` | none / log retention (30 d) | PM-1, PM-4, BO-1 | +| TEL-2 | `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` | none / 30 d | BO-1, BO-2, PM-1, PM-2 | +| TEL-3 | `catalog_exported` | export streamed | `storefront_id, status_filter, product_count, duration_ms` | none / 30 d | BO-3 | +| TEL-4 | `image_phase_completed` | a run's fetch task finishes | `run_id, fetched, rejected, failed, duration_ms` | none / 30 d | PM-3, ALR-3 | +| TEL-5 | `image_phase_recovered` | startup recovery resumes a run | `run_id, pending_resumed` | none / 30 d | ALR-3, RB-3 | +| TEL-6 | `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` | none / 30 d | ALR-2 | + +### 9.2 Observability + +- **Logs:** structured JSON to stdout → journald (Cloud Logging where the VM + agent ships them); every run-scoped line carries `run_id` (§6.6); 30-day + retention. +- **Metrics / traces:** none beyond TEL events — deliberate at this scale. +- **Health:** `/healthz` unchanged (process, DB, migrations — SD-0001). + Image-task health is *data*, not a health endpoint: runs stuck in + `fetching_images` are visible in the runs list and caught by ALR-3. +- **Dashboards:** none; "is the import healthy?" → the import history + (PUC-8); "why did it fail?" → logs filtered by `run_id`. + +### 9.3 Monitoring & alerts + +SLO/ALR numbering continues app-wide (SLO-1/ALR-1 are SD-0001's health-check +pair under the deploy contract). + +| # | SLO | Window | Protects | +| --- | --- | --- | --- | +| SLO-2 | 95% of validations (≤ 5k rows) complete < 10 s | 30 d | PUC-3 responsiveness (PM-4) | +| SLO-3 | 99% of image phases reach a terminal state < 60 min | 30 d | PUC-7 — outcomes arrive while the merchant still cares | + +| # | Alert / monitor | Condition / threshold | Severity | Notifies | Runbook | +| --- | --- | --- | --- | --- | --- | +| ALR-2 | Import apply failing | any TEL-6 event | notify (email, log-based alert) | operator (Ben) | RB-2 | +| ALR-3 | Image phase stuck | run in `fetching_images` > 2 h, or same run recovered (TEL-5) ≥ 3× | notify | operator (Ben) | RB-3 | + +Pre-v1 honesty: "notify" is a log-based email alert, no paging. ## 10. Operations -*(not yet authored)* +### 10.1 Operational plan + +- **Operators / roles:** Ben (operator); no on-call — pre-v1 alerts are + notify-only. +- **Routine tasks:** none daily. Per release: the §7.3 pipeline. Monthly: + glance at bucket size/cost against the §10.3 guardrail. One-time per + environment: the `provision-bucket` flotilla gesture (SLICE-7). +- **Runbooks** (RB-1 is SD-0001's health-restore; numbering continues): + +| # | Runbook | Answers | Lives at | +| --- | --- | --- | --- | +| RB-2 | Import apply failed: locate by `draft_id`/`run_id` in logs; confirm rollback left catalog unchanged (INV-11); advise re-upload; file bug | ALR-2 | operator guide (framework repo), DOC-1 | +| RB-3 | Image phase stuck: check task log by `run_id`; verify bucket reachability + SA permissions; restart app — startup recovery resumes idempotently (§6.9) | ALR-3 | operator guide, DOC-1 | +| RB-4 | Provision the media bucket (the flotilla gesture, per environment) | SLICE-7 stand-up | operator guide, DOC-1 | + +### 10.2 Support & incident response + +Pre-v1, one line: the operator self-supports; problems are filed on the +tracker (`wiggleverse/wiggleverse-ecomm` issues); affected merchants (when +they exist) hear back on their issue. + +### 10.3 Maintenance & lifecycle + +- **Dependency patching:** Patchwatch → remediation pipeline, as app-wide. + Note the new patch-sensitive surface: **Pillow** (image decoding of + untrusted bytes) and the HTML sanitizer — prioritize their advisories. +- **Backup / restore:** catalog data rides Cloud SQL automated backups + (existing). Image objects: immutable keys + GCS soft delete (7 d) are the + MVP story; **no cross-region bucket backup yet** — accepted pre-v1 risk + (originals are re-importable only while merchant URLs live), revisit at + prod stand-up alongside the restore drill (Q-2, §13). +- **Capacity & cost:** ~5k images ≈ 10–15 GB ≈ well under $1/mo storage; + serving egress rides the VM. Guardrail: the project billing budget alert + (scaffold default) flags drift. +- **Deprecation / sunset:** this mechanism is the MVP's only mutation path; + when a future editing epic arrives, import/export persists as the bulk + path (no sunset) — only its "only mechanism" status retires. ## 11. Documentation plan -*(not yet authored)* +- **Where docs live:** spec — content repo (this file); developer docs + + operator guide — framework repo; merchant-facing format docs — served by + the app itself (the admin links them, PUC-11). + +| # | Document | Audience | Location | New / updated | Owned by slice | +| --- | --- | --- | --- | --- | --- | +| DOC-1 | Operator guide: import/export ops — RB-2/3/4, bucket gesture, caps/config surface | operator | framework repo operator guide | updated | every slice (5–8) | +| DOC-2 | CSV column reference (every column: meaning, required, accepted values; dialect notes) | merchant | app-served page, source in framework repo | new | SLICE-8 | +| DOC-3 | Sample CSV (worked examples incl. variants + images) | merchant | app-served asset (`sample.csv`) | new SLICE-5; finalized SLICE-8 | SLICE-5 / SLICE-8 | +| DOC-4 | `products` domain dev notes (canonical model, diff engine, dialect adapters, objectstore/images ports) | developer | framework repo | new | SLICE-5, extended 6–8 | + +DOC-2/3 are the §7.3 launch gate for the migration path: published before +SLICE-8 ships. Docs update in the same PR/slice as the change they describe +(§4.1-handbook). ## 12. Traceability matrix -- 2.52.0 From 0116afbba9dce0d3d0560e78f7b547718893dae2 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:10:09 -0700 Subject: [PATCH 10/12] =?UTF-8?q?spec(SD-0002=20=C2=A7=C2=A712=E2=80=9314)?= =?UTF-8?q?:=20traceability=20matrix,=20decisions=20log=20D-1..13=20+=20Q-?= =?UTF-8?q?1..3,=20glossary;=20self-review=20fixes;=20v0.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 562f1ef..6509aca 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -14,6 +14,7 @@ | Date | Version | Change | By | | --- | --- | --- | --- | | 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben | +| 2026-06-11 | v0.2.0 | Full draft complete, §§1–14, authored section-at-a-time with operator review at each boundary (session ecomm-0030) | Claude + Ben | --- @@ -1135,7 +1136,8 @@ spec, then replan. canonical path; INV-10/11/13/14/15/18 enforced + unit/integration suites green; E2E green: `e2e_import_preview_confirm`, `e2e_import_errors_actionable`, `e2e_import_file_rejected`, - `e2e_import_cancel_no_trace`; TEL-1/2 emitting (§9); DOC-1 updated. + `e2e_import_cancel_no_trace`; TEL-1/2/6 emitting + ALR-2 live (§9); + DOC-1 updated; DOC-3 sample shipped. #### SLICE-6 — Export & the round-trip lock → completes PUC-9, PUC-10 @@ -1332,12 +1334,79 @@ SLICE-8 ships. Docs update in the same PR/slice as the change they describe ## 12. Traceability matrix -*(not yet authored)* +| Pain | Business UC | Product UC | Slice | Tests | Measured by | +| --- | --- | --- | --- | --- | --- | +| PP-1 dead-end storefront | BUC-1 | PUC-1, PUC-2, PUC-3, PUC-4 | SLICE-5 | `e2e_import_preview_confirm`; bootstrap test (§6.8) | BO-1 / PM-1 ← TEL-1, TEL-2 | +| PP-2 re-keying to migrate | BUC-1, BUC-1a | PUC-5, PUC-5a, PUC-6, PUC-11 | SLICE-5, SLICE-8 | `e2e_import_shopify_dialect`, `e2e_import_errors_actionable`, `e2e_import_file_rejected` | BO-1 / PM-2 ← TEL-1, TEL-2 | +| PP-3 no batch gesture | BUC-2, BUC-3 | PUC-3, PUC-3a, PUC-4, PUC-10 | SLICE-5, SLICE-6 | `e2e_import_cancel_no_trace`, `e2e_roundtrip_noop`; INV-12 property test | BO-2 / PM-1 ← TEL-2 | +| PP-3 (imagery upkeep) | BUC-1a | PUC-7 | SLICE-7 | `e2e_image_outcomes`; fetch-resume + SSRF integration | PM-3 ← TEL-4, TEL-5 | +| PP-4 builders blocked | BUC-1 | PUC-1…PUC-11 (the mechanism itself) | SLICE-5–8 | bootstrap test end-to-end | BO-4 (tracker record) | +| PP-5 trapped data | BUC-4 | PUC-9, PUC-10 | SLICE-6 | `e2e_export_download`, `e2e_roundtrip_noop` | BO-3 ← TEL-3 | + +Audit: every PUC lands in a slice (PUC-11's sample in SLICE-5, finalized +SLICE-8); every BUC is realized; every BO has a §8.1 source; every +TEL event is consumed (§9.1); both alerts have runbooks (§9.3 → §10.1). ## 13. Open Questions & Decisions log -*(not yet authored)* +Question/decision numbering is per-spec (SD-0001's D-1…8 and Q-1…3 are its +own; cite cross-spec as "SD-0002 D-13"). + +**Open** + +| # | Question | Owner | Blocks | +| --- | --- | --- | --- | +| Q-1 | Currency: prices are bare decimals with one implicit currency. When does a storefront get a currency setting, and what happens to existing imports then? | Ben | nothing in this MVP; revisit before checkout/orders work | +| Q-2 | Bucket backup: no cross-region backup of image objects pre-v1 (accepted risk, §10.3). Decide backup + restore drill at prod stand-up. | Ben | prod hardening, not this Feature | +| Q-3 | The exact image resolution bar (minimum dimensions for `rejected_low_res`) — pinned in SLICE-7's implementation plan against real merchant fixtures. | SLICE-7 session | SLICE-7 only | + +**Resolved** (operator decisions, brainstorming session ecomm-0030; technical +rationale for engineering choices lives in §6.7) + +| # | Decision | Resolution | Date | +| --- | --- | --- | --- | +| D-1 | Canonical column set | Shopify-flavored superset, operator-specified (§6.5.1): options/variants, images, inventory, cost/weight/volume/tax, kit-seam `Component` columns | 2026-06-11 | +| D-2 | Foreign dialects | Canonical + Shopify product CSV, auto-detected by header set; merchant never picks | 2026-06-11 | +| D-3 | Deletion semantics | Upsert-only; the CSV mechanism never deletes (INV-10) | 2026-06-11 | +| D-4 | Kits scope | Seam only: format reserves `Type` values + `Component` columns; semantics are #15's | 2026-06-11 | +| D-5 | Import flow | Validate-all → preview with per-record drill-in diffs → confirm; sync now (~5k rows), async seam named | 2026-06-11 | +| D-6 | Error delivery | On-screen (preview + run detail) only; no email summaries | 2026-06-11 | +| D-7 | UI home | A Products page created by this design; #14 fills in the list | 2026-06-11 | +| D-8 | Variant identity | Option-value combination; SKU is correctable data (diverges from corpus 11-05-0035, §6.7) | 2026-06-11 | +| D-9 | Images | Fetch-and-host with renditions at fetch; post-commit background phase; **reject** low-res (placeholder) rather than accept-with-warning | 2026-06-11 | +| D-10 | Export shape | Full catalog + status filter; canonical format; hosted image URLs | 2026-06-11 | +| D-11 | Format learning | Sample CSV + column reference, app-served (DOC-2/3) | 2026-06-11 | +| D-12 | Re-import of images | Platform-hosted and already-fetched URLs are never re-fetched; fetch only new URLs + retries of failed/rejected (round-trip integrity, INV-12) | 2026-06-11 | +| D-13 | Storage & serving | Catalog in existing Cloud SQL (additive); images in a per-env private GCS bucket, app-served with immutable caching; CDN named as the #24 seam (§6.3.1) | 2026-06-11 | ## 14. Glossary & References -*(not yet authored)* +- **Handle** — a product's stable identifier in the CSV and the catalog: + lowercase letters, numbers, dashes (INV-13). +- **Variant** — one purchasable form of a product, identified by its + option-value combination; every product has ≥ 1 (a no-option product has + exactly one). +- **Option** — a named axis of variation ("Size"); names live on the + product, values on the variant. +- **Canonical row model** — the internal representation every dialect maps + to before validation (INV-17). +- **Dialect** — an accepted CSV header-shape: canonical or Shopify product + CSV. +- **Draft vs run** — a *draft* is an unconfirmed, expiring preview (no + trace if canceled); a *run* is the durable record of a confirmed import. +- **Rendition** — a derived image size (thumb / card / detail), generated at + fetch, immutable. +- **Hosted URL** — an `Image Src` pointing at this platform's image-serving + route; resolves to an existing image record, never fetched. +- **Upsert** — add-or-update by identity; this mechanism's only mutation. +- **Round-trip** — export → re-import of the unmodified file diffs to + nothing (INV-12). +- **References:** anchor [#13](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/13) · + epic [#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12) · + siblings [#14](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/14), + [#15](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/15) · + [SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md) · + corpus [rfcs 11-05-0032–0037](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs) · + [research/shopify/products](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify/products/products.md) · + Shopify product-CSV documentation (external; the dialect fixture is the + binding artifact, §6.5.1) · handbook §§3.3, 4.3, 8.5, 9. -- 2.52.0 From 8a0c7bc35eccf35a73ccd77dcee6c6e33dd717b7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:46:48 -0700 Subject: [PATCH 11/12] =?UTF-8?q?spec(SD-0002=20=C2=A75.1,=20D-14):=20comm?= =?UTF-8?q?it=20Claude=20Design=20hi-fi=20bundle=20for=20the=20Products=20?= =?UTF-8?q?surface;=20amend=20design-tooling=20decision=20to=20Design=20?= =?UTF-8?q?=E2=86=92=20Code;=20adopt=20toast=20refinement;=20v0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 46 +- .../.thumbnail | Bin 0 -> 15633 bytes .../Products - Bulk CSV Import.html | 92 ++ .../_adherence.oxlintrc.json | 378 +++++++ .../_ds_bundle.js | 959 ++++++++++++++++++ .../_ds_manifest.json | 1 + .../assets/fonts/fonts.css | 10 + .../fonts/fraunces-v38-latin-500italic.woff2 | Bin 0 -> 22888 bytes .../fonts/fraunces-v38-latin-italic.woff2 | Bin 0 -> 22852 bytes .../assets/fonts/inter-v20-latin-500.woff2 | Bin 0 -> 24272 bytes .../assets/fonts/inter-v20-latin-600.woff2 | Bin 0 -> 24452 bytes .../fonts/inter-v20-latin-regular.woff2 | Bin 0 -> 23664 bytes .../fonts/space-grotesk-v22-latin-500.woff2 | Bin 0 -> 13312 bytes .../fonts/space-grotesk-v22-latin-700.woff2 | Bin 0 -> 12840 bytes .../readme.md | 170 ++++ .../styles.css | 8 + .../tokens/colors.css | 60 ++ .../tokens/spacing.css | 55 + .../tokens/typography.css | 53 + .../app/app.jsx | 170 ++++ .../app/data.jsx | 435 ++++++++ .../app/importflow.jsx | 556 ++++++++++ .../app/products.jsx | 257 +++++ .../app/shell.jsx | 117 +++ .../app/tweaks-panel.jsx | 541 ++++++++++ .../app/ui.jsx | 176 ++++ 26 files changed, 4069 insertions(+), 15 deletions(-) create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_bundle.js create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_manifest.json create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fonts.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fraunces-v38-latin-500italic.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fraunces-v38-latin-italic.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-500.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-600.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-regular.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/space-grotesk-v22-latin-500.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/space-grotesk-v22-latin-700.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/readme.md create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/app.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/data.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/importflow.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 6509aca..d0c4efb 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -15,6 +15,7 @@ | --- | --- | --- | --- | | 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben | | 2026-06-11 | v0.2.0 | Full draft complete, §§1–14, authored section-at-a-time with operator review at each boundary (session ecomm-0030) | Claude + Ben | +| 2026-06-11 | v0.3.0 | Claude Design hi-fi bundle committed (`ui/designs/SD-0002-…`); §5.1 decision amended to Design → Code (D-14); §5.2 toast refinement adopted | Claude + Ben | --- @@ -484,22 +485,34 @@ Scenario: PUC-11 — The sample CSV and column reference teach the shape ## 5. UX Layout -Text is the source of truth; wireframes are generated from these descriptions -on demand and never committed. All surfaces live inside SD-0001's admin shell -(its header: storefront name left, signed-in email + sign out right). +Text is the source of truth; the committed **Claude Design hi-fi bundle** at +[`ui/designs/SD-0002-products-bulk-csv-import-export/`](../ui/designs/SD-0002-products-bulk-csv-import-export/) +renders these descriptions interactively (all four screens + states, built on +the wiggleverse design-system bundle) and serves as SLICE-5's visual +bootstrap artifact. Where bundle and prose disagree, this prose governs +(GUIDE §3.2). All surfaces live inside SD-0001's admin shell (its header: +storefront name left, signed-in email + sign out right; the bundle's sidebar +treatment is indicative — the shell remains SD-0001's). ### 5.1 Design tooling decision -- **Decision:** Code directly. -- **Rationale:** rubric score **−1** (claude-design-vs-code.md): Q1 **+1** - (net-new Products section, no data-surface precedent in the codebase), - Q2 **−1** (this section describes the UI to wireframe level — named - components, enumerated states), Q3 **0** (one admin section, multi-step but - utilitarian), Q4 **+1** (no design system in the ecomm app yet), Q5 **−1** - (solo build, operator is the only reviewer), Q6 **−1** (work is dominantly - logic: parsing, diffing, upsert, image pipeline). Default at −1 is Code; no - override fires — the import preview is novel but structural, and prose - specifies it adequately. +- **Decision:** Design → Code, with the Claude Design project scoped to: the + Products section (Products page · import upload · preview · run detail). + Export bundle committed at `ui/designs/SD-0002-products-bulk-csv-import-export/`; + per the rubric's integration note it is a **bootstrap artifact, not a + sync** — after SLICE-5 lands it, iteration happens in code, and Claude + Design returns only when a new spec independently scores for it. +- **Rationale:** the rubric scored **−1 → Code directly** as drafted (Q1 +1 + net-new section, Q2 −1 wireframe-level prose, Q3 0, Q4 +1 no app design + system, Q5 −1 solo review, Q6 −1 logic-dominant). The operator then ran + the Design step anyway — the shareable-artifact override in effect — and + produced the hi-fi bundle from this spec's §§4–5, validating the prose + rather than replacing it. Both the score and the actual path are recorded + so neither is re-litigated (D-14, §13). +- **Design notes carried into implementation:** the bundle's thin + single-weight geometric icon set is flagged (by the bundle itself) as a + **net-new admin icon set**, not yet house brand; the design-system bundle + (`_ds/wiggleverse-design-system-…`) is the same one SD-0001's designs use. ### 5.2 Screen: Products (serves PUC-1, PUC-7, PUC-8, PUC-9, PUC-11) @@ -529,8 +542,10 @@ on demand and never committed. All surfaces live inside SD-0001's admin shell imports yet." - **States:** loading: skeleton for count + history · error: honest failure banner with retry (INV-9 spirit) · empty/populated per above. -- **Notifications:** none beyond the notices band (no toasts here; outcomes - live on durable surfaces). +- **Notifications:** toasts for the two fire-and-forget gestures — export + started/completed and sample-CSV downloaded (a design-bundle refinement); + import outcomes never toast — they live on durable surfaces (preview, run + detail, notices band). ### 5.3 Screen: Import — upload (serves PUC-2, PUC-5a, PUC-11) @@ -1378,6 +1393,7 @@ rationale for engineering choices lives in §6.7) | D-11 | Format learning | Sample CSV + column reference, app-served (DOC-2/3) | 2026-06-11 | | D-12 | Re-import of images | Platform-hosted and already-fetched URLs are never re-fetched; fetch only new URLs + retries of failed/rejected (round-trip integrity, INV-12) | 2026-06-11 | | D-13 | Storage & serving | Catalog in existing Cloud SQL (additive); images in a per-env private GCS bucket, app-served with immutable caching; CDN named as the #24 seam (§6.3.1) | 2026-06-11 | +| D-14 | Design artifact | Operator ran the Claude Design step (overriding the rubric's Code-directly score); hi-fi bundle committed at `ui/designs/SD-0002-…/` as SLICE-5's bootstrap artifact; prose (§5) remains the source of truth (§5.1) | 2026-06-11 | ## 14. Glossary & References diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail b/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail new file mode 100644 index 0000000000000000000000000000000000000000..57d51341cd1cf83d5b492c09a7043e73d7dbcf4e GIT binary patch literal 15633 zcmXwAb6_PswC`=Z#n!gHwQbwBZQHiB<<_=s+qP}*>wfROKV~MmlSy)u9R1EY;c_yf zFh4PW0s#TRh>Hm+009B`zRTGVVBdGa`FZ~D2f$H5Q~;=I3j6fCB4n&4ZXzuW^y|9} z0R#v&2Lk=qzJP#0asfaf-%r55-*SQf|1E$o7v%rTK-vEq<_s~L0|9Y)iVN{8 zxdAS{P3??U-8Yv{M2>?*z`*S3INiw{Oj+z*F(#xW7!t+g>rxa(9g>YxC7)Qd zU6PgSvk1W?ONLFIk8BOF%F_&PyD8C*qe*#H=~E}r9jMpo@MlP zx~{wK-?Ch{JCC!ccb;&vQh!NZXMKvATUwX(C7cqPgE9fH|M1VabRc-+U;@SjME6)A z&{aMGb#{yJ5#=JRK!eVmMinSvIG?Ez*On2YOEd3PI#O|%$ivL&JC0xb9-a$xHZie^ zOG?(;X{2#*x}eaE?FPOtGCW_NZIC$YO!qy_d84sZ7?=?&99vQib zGGe$-kTIp79Mk)olu-mwVn|PkJO*F*YPO9TZ=@t*z{*e@fAzjEhWkPEx*ALYpq1LS ztFS{jrrOeJkf${Rb%6k`RqMD&CQU3wsgy08W9XI=)MDYJy??1+oj9Y#&Q5$2Nllv& z88xckv$QKSuA=;K^t`l4A5NAOX6_*hjg`o%m+^UtBekQeux%U&u5 z&s#}kcxce3S)#LA$7ZXI8a7n$>2j0k+SiU>=jB!M4t)DX`*r;9)AHTm_E!f}AQi1v z2Nh|e{MlKD!?8^_G%QRiWq-u-=eh2O>*}Rt;@_8zdxz6Lpb;Alj&`g=Ra%{-7cGTJ z*(h*jpDvG7Z1$zfrpGIrc*bgNdE(X0dGf@>dINC$$x7$5G&e2^R$J&j4=cp%Xo=}7 z7mOB8QZLJ!p@ju>;&?HMU+M8fq}3A!c(KF5*6OS6-JXMo%bXUVO$@+-fk+I@81UtV zD*5=_vC9;-Vp@H>B}_~n!O0LH*(kKx(MlZAKgA@(VyLNM80dM3Ot4i|k5kgqWE?FC@RME-SN!J~33kb5p3gXeYQJ1Zp@ zmljF($V|xM5}umnEt@pLpep4sXemMj(h=AjG{p~a(EdtA2d$<#DR5J|O7C~X#Bd-# z=QkGoyupn1><&TIs*n!G7W`H$sOR>rCgu!?RH34P;l$>ARwm6%@6b&)#VkV$m@<_oLaHZNHwWV=$A0# zRR!WnEd5}ME8%6is}WY3Aa0<@1Gl+yU=V-iusifxk&{YK@^D?>yU7^r-M6CLmTKU9 z)$e0K`blcpgnEdGL#uzp3$jh_jZf}7hvU7|!P#a(4RDZEUPeQy-9<(>_GobVO1av~ z=H|~;xcN;p<(NW2H2p}%R55!|P7|J-F{l(a9<0jtoJuVs1m<+2bir@h$M(R(%Z zfh|jB?*>IpJ)n_~p<8|Lg_szUA^t)v5(tTx4B!I1rjlmFa&>1PEU5 zvU=*cf*kdm&?1gL;?hqscLae;;FclGKvRTsOTbz#wy;_R|MrwHvF7tDl12Odn~n^G z5;b6Ia!{G}f|(D}LA2f_eynFIE<5+C4o{ zT^!xIWYG&oBo#fc85uH^0p4qAv$mwK zi{@}H-2&9*CvYUh&C+GN@G@?=(4vFSSg;sVbCsEBXN?7D6e$SAR8Ks=LZrE8CN&{w^7OgLg) zZ`<+%9|Ha@B`GK}0CNEU(HX~}s?oqr(|tXS`LJs&u&(4SN_I2A5-Mc&wBY+Mn}IH2 z0EGRBxnuSxjT967>&Hw;6KhqFc1*T_yltX;UgY;PJ1nzf@!*N|t1^4XPw$K#A-*>@w=R;{9 zM~{|w;Jy#P@0boa1K=>9cR}v6Y`aOf-M-Y_FiZxVrfDl{dThJh#kW2t-CxI+c?tgB zYx1V}K8fa}s_J-XW}P3Dg4Ds8BylJM0XC56XByc|7Z*-TbRCOs(Kk~aLZ`u}M?XfM zcd8mVuwELoy?C5-EI7LTv)Q*b>Q@+sH!VRrbhp+6j|a|e;%CJpbcJ+0UZXO$Iu>cW zdG2E}bf^Cs+%9%AFV}OOXFYFyUfxaeex`j4`o8&hxF5A~-9GTNdg;r>Z=C-Wl*x^&xN?L0V2sn2c>57}}xOi!7WA#T~@P%Y-&@%WfY_qC$y^AoWYrz3Oe zx4br14V7KIG6R-yVC=Q`vSP}O;+7njEq_K%0{RuqLr&Oj>;3ol$1R#{ne?AT{nGoD z0f=J5?3@&NCdeZ96DF-BO?{NM=)5nIxF7Jf zYqfGgkKb)T9o!ABJ3PGT9C(Al@u}Eo;VH>;7`t@ z-3ir7r_^ui`(D7*>~`=JZ6Dsp<6}UzEH#jdCSHm=b?C%GO2CjUjV8cC7mtlZzhUb0 zqrh>RWO*9{c0^S6T4jDq+nIq2Apvc>Bbc-t^S0F*F(}cYbD4nog#mx{v7d(L=dclt zY(Mj=3Ck{JFR`sIW;_RLL!k;|SIbTT?4H+|8=7pqWcr;w&s}EoIMXs%*w%9ItfX6s z)Jwq{b}e-`F2gDl?ot+t*$JhtuSD1DG;fiQEI6!lE|`9bpeV=TAlg=Hwa>o!>+JLN z*ea_{NobJm9b>g_GIwl41h~uB_SaJN!SmM*hQ3vhITwVNnTzugPrI0plO5Et7xWsrfBD8xLZEanHJvE$)xF&_&oo^We_-`FW(y>y0;u3VL_L}BXT zo_{xXuM7IP@2M3B*-vrA>0=L|{?0<86q}Abq~JAJZK+5YSmuY2Ulj9e#NQL6qCmY+ zOoMLZG<>mK#LV|Ow*8XedD}Sc^UObH23C&s16ccAe4LzUXujh?+QUvFijZ&1+fNs^ z>ZjvNzR7_~%^O@*rl7#O3D*#R5#p9SZ<3S3tMmHIm zGylkSXmZpU2874$B}Yn1s+>+bQ*Sxgq8mF*{oB-m5c$X=q|f~~sZ#U2K|T86VKd0w zPGxfMZ3~mfBts-ij1vc0U+G{?4f2um9E24(=nAJTlS&O(Vc4lpQp2U6JhN6mA@J|? zTT1gEo@HqonyY-Aw4QjUisR8RFbw;{JX(FyT@H_J8r=)NqR(uHjn9E|TV@E&L}(OO zda;5c#YlGqp!B5dT^}-h9GVpo=&56-oK>dB1B>IXv!viw=xuN~n2#`FoKZD_&}Z2q zR?1^>=$wtH1Q0}F-D4|*Q8C7i>Pi*LV5N1r(zJ=AVv31yIl4Y5o{Os2@>$p&(a>sx zH^I=?7|F zXLQv35RsAbQdK~fN6xDzMG|jg;!zJq=D;JF3pVk0Cl}>ow#MrYn8EEH^=ShJL08vy zO}ke1x-sWyxNpPXVlNjfCk=5`bh5{@L(aN(xHpN4KU>upyAmK@H5oY>KgYJbwSR)8 zu2+RcGQ&ROg6|c=Mk3&-6KC)Cic4W6)B3MDOW<-ki^Zb_b};sMLkNxE$3V;W`VUp< zIHgW)Ck2MO65V`IgQnX6AWh<@**yN7Bkz*(&qYN_rNfL5-AhTm7^4oXb(KQ`jzZxx z8+Ar)S2Ve~PgMu5e$cPk$z(ABF9!95I0YQ61M$8u?1kvNOb;unzkbm==4`s#IgyFs zhA+zAWA?CvI#+|9)|iL^YthuAO7t1|S$u!13Gn~c)gH+outBpv-mhp$mxw&GOd)}h z`52W0KGLQoMq=MfTV~99mc@x`l;KyfXSY@OMN|JNVk=7!X22Uu&8oV;zg3SLe9)y& zq_lo}t&>kue)2tJ~w>#&7ASWAg{h3$Eo%to^;DgKS>x$k%!O(0{FwB|E@rd3XKZt z6TbfFH-{c$8Gq!5oodqiFwp9#(lCCEb90Bg_W>8Hzao*F|M2`zoaWr_$A zf_tdw^!7`q3nD@(2Wyy<6H7F* zBlTX2tI4iYL;X(I$8*=6U6$IU)$*6uZmN;8a^$Mil)5+Ww=JtNd^RTHy#y=voYUor z#>vhMlV6@HzwnY45+XWy=p^ugEeCAY7GKjG%NsB|YY$x^^OHGX7X3G=75t<~War0L zH*6)I&ji}lDd1JkegNFk3&ZP;!M;Jvy3!^lYDx>K{Td+{@^8D7y%u}-3XTb6V47Ky zd9S2lE-z4JmLx(l)&L6ww!+>?whlxd84*0;{^5c)IKcx|h%x)%W#iZ@T9vqFCe~47 z{oLXYX?=Xx92aLd5^NbiVg`7W1F-&9)@-Omp1CN*@i#Qrr@CXAHtBu{G7 zQquyVW{L7v*Jn0D?t2kH>EMcriJB(y&HeiFA;3uc{1RPAp8RnD)we0!|Zhb&ZOy5pL*#@ z_`>NSBM%NL9&{WzVx7oTSY1T#7M&y2Npl)EO@psYx8frmlTtT6Eow4iluS?p4iW?m zl*oGRi7Ar{SIND4kU5W=^RonlUd|T=cAd#o)%ZGULO!3hqtRH?9Wy^ZI@8_y1&qYe zjVB`vPVbn98qMgMbNZ3d&njW{8)4f)!OwX{52~6<8&(0tgNq6XCDBmuASEdK`>2M- zw!vdZa>4_a;`w~B0K!x6_e>+yg4wyfA#!+5&~bgP2~=|%mwJJ4G?nOYgrX>>wof{U zNc^|m4|8*Sf2xF$<(PPKc0%JWtD)RdQ%qX&)bV_Xgy&dhg@m{)p&%|#{e2-*478DP zn_-VfQx$1qV*2?G1R~LmF`Lo-#(p~1sMK;S+8*e;#xgYgdjgDvhPS7NvX9>*vB1_# zLW(7qcV5J^9Bey50`$Ui#FW*;c{&4)_}tm+k&ZPk7r^Xm^a>#?O2nmJt6`d`Hs7W& z%|$3C;Yce6u_%{X5l0;MbnjXvf-e)oQuEyY46M?`-S$_HpgN`7H8qkj@Ib@bR)^lE*;M^11@KJlL;74Th!6i&a=>UgYI7tlP~h-5Q_^;;N%|CXSBG;u$~Ho{4ZnLKckEOb9H z{$EQU7b1y-?`Pp15&zW#T*>|SdBJ<#4Hje?oF$Drn7Ij?QAJtfZ|0nB4D0iOo}F()ABrAC}CpE_k`UlIaOAnA4)1;ZJCA*CJ+ zHz1!L#vU>uj*noW3EBm+h>O-2+9hTb)4h#l{bqBX9m1&zu%0xDdcue0Fb6FI0DgAu zyV;b^Zd{oo0IG(6kikEM`KDecRg+W=vo}j*LyJ_7+LmurX35L^?3E^sx!wL+ZvUj{ z8oSn)CILBCdTl)0`|bw@Anmv4BD$2vg-xTHfnFV0y3y3<9DHBs&SmCaIm}Hi+bK;X zXZ}x0u7Kj`sX9)D55S5`wGII8Q)gjIkk*vz&3yTRh+={#$ufY|q#6x=!54Sk>_68m zedB?+#$t2~2dUYc(uhIyK$E}NaPXkSY;_qYkc!|JE5%3YL`8w`HiqFw&#SfKueL~c z?U`Wcb``zA0G;etE+xJi9q6tX!4y3`S>mq~Xfh>pHR%p^dKz2a?=cJrYz`Hgq+|kN z%Vouw{?S5u2ml4} zwB|m8(6DLM;`{~CKMnk2>Sx6IY4EXRdUkqBYrHhaoMIAC(_yyFqDKWt#3yhhvj_&s z`YSrKfDjuxfZLA{1iTe!96g87b0Ca-2Nr}xB@=s1itt&(PFwQ>5cb*H;25eu{XT4E zLIhouVw7UEtY^ zo(Bv&>G(&+Jddx&`~};S*a4fmLw4AcKLmQyEb0s1p~NdeuBbs=ixxN#kI*4psIcUQ zwY%6r(NbC-xPgz6s|9T~v5``Ti|(jDvrZ0w;`fNOTjlR8wt8iQf&3z&lUOM=kH-KW zrOgrZtE8T^TfOOlt5LiXKcS;<9I%XPOxWiZH6;j7Q>GLhx$5da!n`keiy8G z{`qR{5P{vHgVT2`LaJEa(aP^UQYJIBhimA{#AFLiasQM=R4bVphYRGm{^li!TV>bZ zUP;15fNbQm_!^fZN?tPFQ3qDgzMdD}Z}zlDfZEp1Ol@-hI{KYnaAmx?k4#?<2)ihwP7*?C|bL@MMx4Ss#rIT_11#^t*GmXpwzHft$xB z?PuMk;B{3-JRzPfj^#dP)NDs9P!u50C{vBHs8>sx0aMiMKpQpZWXZ!P+RbXGoR3zh zaTSjVFE0rx=~zh@8!77ySaXy$N@WsbT?x8kRg% z2#S>3tvKZi9KcWv4*ak?0$?hnAN>qfrzbV1Nz!`@78;iNqIp3I-GeXz-`TJ8GEzHS z9f}LCej)3oN&V*^hjUul2g#qHZXK1?8zzl0w;t8Hfmx_2XIPzX2+_R@Ol46b{k!NE zSL}ZWItop$KqZls_O8B9w9cX?Pjq?@SWqmzJwlpTkOF8 zOk$?4k%pYKlx~n|i?_Ht9GqDE>*b>Q zV{7c|+T1sYzx@fQs^f73E9@Cr7(oVQqozk_4#cU5$jRv6zi|bH-G1i|(qbs00y!Fq zKQD;khMQZT+76F*+R3V*&q?puT+Tq*VS`&4VB?)>>m{=kL?D*jSC**65WwB04S7#_ z<4~8&cl*=)L1&CD2LK~Zo6#~7-%w5W{(RXTi&pecNG&fi310tWJ^ZnOP;1miPF9OR zVxZ#r7?-x1GXK&hb*?Zc?xLpg-8xC=ccx~R+|O&&wIdGVFz;8nL)?{una@>!u9J$1 z&^W^mYE;K5KICIZHit&gh@I0EsX8kGY9_>NK`fee2kA2P{$R)W4Rpmcj$w0nkWs&a zVK4CxXM8Ci`69L)gZPZ-On$5u&8Cc7qT{5804lUA$>L>YK#>U? zxhHkW$Ue$Qr_o}t8r_aY2kx30m~*{ZOO|fe7|jCq8fR)6$@4C0kve{f$W}F}He3Rr z#v))q&FN5<)m+EOYDjy)KiF6d6y>q-ExhtbIQT0_9p%YJ2Jf4VxBY2^A+Y3Sykn>X zdOFv(A|%I~L*GKkc?^o-cRrVmZXeGJrTKaw(j&*M5`{<$c_ieyVEtEF zNBb7MnO?RS^Q!;+33`t3GoUT(EwsOx1JOqnk)8$hX{=@#G5w|a{5nrZK|M}gMdF~^&#j^kD?jp|_rMApI0J|k*3z5wd-U9~@6pwaS%uI<|5?av-;N6#>?Pyr z28Cf5j&wTVJ|?&s@$WY!pulZtEr{eiLb%(nPvv$f_P?H!he%ni5(Vtc&6RD&8+0*o zvi%*!GqnN+xi<9TGzNp+136KaoQf$EspH+zv?erw`unbc|9C=g zst<`27oyk@OT9XmPf6i@k7V#~4tJAzKM*ZlKib_5p}$;VUl%16BTg=Emd=6G;~y+; zSs8Iux^R%f`ezpxA>Jv}w0oOQY4%*aUM*$EbiW2=52n99>Y-F0o-$JpkaFAwwzwSm zGc>#y++3y9DIU4!O7Gfz3Uxoe;P?Di^uSSq2b0th@J6o|_CpWfl&wNLk+^})ahy+P zyMj9&qeS#L@pCV0SYBzsObCDBBShMt_T@S`F^fq?ssck;45Z;fDvl?> zsEZOwdrZ=|yiNPuRe626;%crWcR4)7NfH8U5o0SKr1W%v+YByXJ8*hmN|7S{O1vMl zoC0D+IP5enO;%zEu1im%)y~F7V_5z^1lE3jq&?PlNRK4{97dLce69f%Fs$B5|9mGf z@H5um2MKBMq}h{bCqI5qc2k-rgXa@g*pFU(#icQkt3c#ggY&-=Cj5MM{q-Cf_OL|FdXu}z>XBp>yo>&Kv{<&fG^zUQPMaiNKTUE7 zp2w|4bg)ia(FBDpOrI8`o8c!0oH2XWX62l(DKhZ22TG_L5XYb?k3$ErsfIhMPEcJ# z;EmOc)*3#S>5CkS17agS80RtGK7=CrlqVw_UDXo(2U#Kif*8XB2_zj}S;NMvuGcP= zktBp;cwB+_08~*>xR691$38lahm&h-KWb&Wx1(C{jnT((@0Wepk!z{igh~)(XRz9M z&f5l3q@W4Sm~iz7Q7$B1tPqM6?^mhfuP$p!2!iOfAC|~o_{$kFIUB{WkJA(19&nb& zohs&P3GC$%{8K4t^r@9rM*P@M@b+Z*%uAVJnTyH+bCqQ9z?4+UR|L9>O_ZWcH+Fh= z_?k`NN@B|p6DjfRsOXMzM%JIjH|SG%9Ce!BN(wVNf%s>PZUn3FwrM!9&}Ch719}(V z=nKp9;#jsfjv22=lu-&uPz#*e(hlpN`7wlV=HgJXf@rU$oa=G%9d)x zuCoUBH}_DDP3JKHC#rGvRh?aAW%7tjdF)#f;6vIW-L8fXnC_X+HTd@D=Gu29u=8lm zuK1)(+Gx46ym`S13>%e|irM8^v=xDCU#n~sLE0r=XCT0d7bZfk^;t-G`LQxsKfK~P zSrhk46hwCwIXRdHSyoPLmJZ-7x&I@9asfRbAA0dgQ^&6O@-Z{;>k3OhWv6?_Qz>B* zXhkFn3@q_{@VS_+-aQR9Cd5#u{x-Wu0Pm)$6k@L(*BeY*rwj9=UWP1J2yQS1+n8Nj zO2Yy(YE^Mpl|~;!Q9TCzBJ%TGg4;<3BX3Uc!xoEE#Dt-4VC@Hya5Ihtcr=MSLjNmt zEG5sd!-~xIYjWCUG-7re6=|esojK;=l%RrPLhDYP8h1M}E@n!MVK?oNMy5u$RyL=- z=WBZ-QhZ5(WtY=#(_FtiQOL3QD3+Pq7_KPe0c@8?^WD9sxjg&S3;qZ#`bf{)FT!S< zD8XhHO6ztn#GfL`2$p}dDA;dTYzmz@--qCx%yFGiD*fK6SE(qsjk;c z2(sNzVT8-dh`pP3t2IS+cX`T(m=+)YX^#!BTp|?g6+d-uj6mtfFW&Z$#+2bDO6>Mg z$INk)v7kz+bGJKOxJ*S^ZOz0>Rx(hjkU0R2oi=mHRnetveBu}_l6&u8pW3`DNt<|yMn97~ zua8{peA|{>O|;ohUW2nsw5^)V+vd;of@(TA9j^XVL##DlxN{Nzg*$M36}PbFrC?Ym z!N5TRtD9q_AaBn3P{^#U%ax*{3v9{Z28C0Q(?#u6?<)U?o(;B(0yTcW{@`@@ zPk~`qIj<{T0;r&>G?Ca8T;1>&>dNBe<}E2AMDGTUbt5&I18*F(S2+76cLT60(>-TU z%y4!BXI4L~HNab5pZQY1<&p(xU3yT#5pZOTBw3#Y4haVbb*x_%fF^b}%0HYlsR z-*|xYSjBtrLP)Fm+dMQ$b z6U$5VhxgsT!Dp|zps1aDkfY zX}+ocRcBI!zP_9*H0=r3G`ee;714r;slE5ej(i)3{fujd#tU4nxH6GJ5FSk=tY%p* z2yGAptuJlsz(pJ@Ks%C=iFFM3LUe;^6_7pcS2r)`bgF`qfF}MqQp;WDR>41q7}@2P zh~(p3f#lwFZ6Rl1krAcfD;lV<;=mK1JA2jAPsX(vq+toWrt$^4lWC>!oeL&fbp zX+WP9r=r@aCLK2GRV{L0&6_7yJQT)v_q^2le1W9tIh-I|Qr}uIyDQ%&Y5{Qfedfm+ zNh(c8^Za(xY&Lm4%$ERYaQ9NCTVCU3^%)=XOWBz#>G^ZevyvXP$!O4e%GblVo0%bD;74x{Ste?!|0&>dpLL1ShHx-!f47Nzp7#S$nhe z;~S0FSYC5n8|#w?LlmK>345s;Yi9aYwOt!|$Ab*tr zMdZA-vIoc02}`pZ31llwA@#y~q5k4^PB=cYQ6^|0{UMB5Z|wzwj!1qJalVr;Bv>E0 zB#i?m34v3${$CfubVR5P+ma5wg2uibUP+Ud7_2%VRw(*gED+U${oTDO02 zK`y_6P;|sOrytWsXI(r$a$`l#v}r9ojXoST5}5m;ZJl>}$U+)-#<@okoS~E8LvM@4 zcIbZWXU?^^DHJ z=-fz3=M3m|rlDbB|Kro)#caC>Y3H?e-GP-3mU<`Y_t{D;?s8N?fAhfhU(4_pTk+x9 z?ZlG{aw)9!B(9^OM*`)&Cfb z6&RGz7u(71)Iy{h$DGmi`9y?!h%e=4!N*pc{v9;Rug=yN2z`BNXVO#*x0f6(l~fjO6s-uM?(2jQbW-P%qtB`ev9js9w{TL zE=7?>zGtfYQHDlR=3`3Vy(=NHF?VVBpz$aKrLL&Op5j>@BRuGE)6tY(qExW6=hCG; zDmGSJJ4-eLGn3xI)RC&AsJOTwj{xr=+t^qzp(jD*A1Lq*1Q3A!mPvhhx{AkIuFk*Y z2qXP1zwk!%5)eN6FrgGYrO;~S1XJUnRz*Qp38IB$C5hWGcl<9X(hRf=6QB)J zk-zTvenj{2mN14I(HW5>U89G z)=Tn(9sKi|>gFJ_@8|hI>E_rkN)X>p@}>Hx0SM>D8pAq)FM)6gOUs1W1Yrg_U9Gq%*8*9Ncxr;H(mR9)tEcM~o8vUgwGw0Q&B=f=Xon83N&d5nh z1!Kz=jZ(NDTjjEtFv5O-`}@mlMZcr`RHfZU34!7M%JVjs*N3D2Gf`-7%|D{NqA6LK z$IDXQ;1zxf`2xA8vuYKFoQf(bC_|~9fnnWfLEx@0YX?2MV>YjxM*4@JBvIeizN^2$ z?%iX$zFr^p(i>xdL#7`u-a4S8`r`69>8Bt58d#+YC5On_t!L~mYg`a)l8Py;II*3y zHut&qMDPbQfQGM@oe>!uO+)PUv)(Ok?=EwBx*WK6*$FIT7q3Cncy@W$NIO;a@Kd_CAZXbP}KUWlj=h69TuhASA|}27t)1o&lTN zi=g*^<=cObpkNA=f%*UyP5ubcXIf4m4=PG~Pfnd8Pv@y(#DYE z;9C^}{&;Gq9mC6h3DU|LGk<3@p0;SO#DyyrruqA^{VNs4kotfl2A5<#9UZRZy$P8{ zl@z>ia~{Z(I%M#-zg(-R&UH6A-YNG_YZI+H{2yRqtc0X?2TzYAkT-|x*OJ_QmI)gf zIL>#vQ(n4{O3Q>wM-Y3kcowHAk^8R6AHj!T&(q%CrG%B^9@Whfd;|@N#1Iu|B2j!%Nn-b|GbvyVSEHrAHPv{tRCZMugQqEh_2m_%E%BBK z0*l3$m8gXN1MahwwQK#QdKVUuSiU77=Q0}5y&1sJJjFrHm1V9W#|mOb-5V3xT@y34S^#g*d%J$s0)g7Pn* z)w~my-jpgqW0~@%CF|TL#<|naI+GpR>{hnW$Y$<%gG62F;qrw{ZTM8f2hS=-4dxX^ zU}F2`!b*$__qvlH89l*N$D1v*k89ac2WiJ6zkRIZwSWFQ*d-YO9SetFx<-UX>xIvI z>n?H0#5l7WL6Gfz2S`U-7byd%`L0?zRr?|3$TL%)v@m|=}x$maP2%Qz* zR9=ArczRi*`*Lj+B7!D~Y;86?Wl(k0?zz^bDMzt2m31^1yRt{Kwts>BOqbVd^-}FanWe;m7Uc<2cm4I^$$yCDi>qs9IP^_P;oBTuea1j*9rV zYE!;Jp)C{LgUW}G89lPFP++lb7)!hU`tgPFnBKl{d~2eUQU$OfeS6#Z2KGYu$dMIH zAXKZh6hgpBqr6Sr08PC(+#dO7RVqk&i<^kr_|ZK}!nu`|+9*EHSKPvA2(iPd7rEqX z_JL^M2r1ppn-ix0Wem{ZcwtWah2PHcjRoR;uCfXVA_?6ghtjvr46L0M`pxxo;qgb+ zcgoLgU=!7-%3O-^^z`%LIaG>7TkUe@UcIsl&(&$N%ax!?zBiWQzl~Kw z_SfzH`YGk2?*(;Ug`GzbLg(aX1Ls8%3Z~b_8A|TZzJs(vOeVwCbkQm*5@N5_%@M;n zd_ry3B=+-3PX*f5^Q|9GUC2q&xD3*c!sx-{AKtwPA!n=l_WJ&UBwOzFv)LZcC5kI$ zG@aBg`&=THTGh`b&R9~id@XgjRz%#JAoh@5!qWYftEH039GFU2i1y!BS&H}_^kZtX z*HKJ1%pBqK%3^)?9oQlJqN3T1PdhS=NFa_TgwIQ{9CwyN+L4T$djC5pC`59X0%lFr zXFVsBU0EKY|K`$8m@67!wai&wElDKz1L9!)LG65O`Z#j%u&?{$E&BJF>`t_hW{xCL z%_KrDw6We!RYL1bVK8aIQ~;yK6V}!?yC$Bll+e;z+DT zi_*9A-ww?QFbas+Wx&l(9~4nu+I%@7;8D8L#mNQ+S9mO7W*%yVGN(|P7#(i?mN?}8F_FwcOe*I zw`F=m3p(gwN8hGlQKEmR$DsCCI&|#hvHa1(c$GnALygE72orz?DszkfU$O)>@O;f+ zxA%Jp2US)83d%@iXIE06l#I-XeM{HEf|Mi?ww{jnq45AvHu!2hKDEKOU$fY7-_YS? z<_`lG7dh$4515j{$!SCM^$vb69a;fBT+MF=M?{1uJUl_<83Chl4{iZGL6legokPYa z48KznOC%aw%~mbzbLJ>{C3)+lyNw%n=$(ac__La+vH2LgOg)pT@{=A;+K*CL;kx*n z-BCsQoxT28RT>+8{erx1A2@IhY*^Km9>Y_0iz4k2VEPW=`wlOhfrtYgkDnAY?SRGj z23El3oKrCUv!Z&MyFV|LhyRifM*b1b_Wg3#PinpPzVbMzAnZ}H@t!_se>k?B>u85> zk~aaHlT*jzv@pM;W#{>Qu~m}w&_n&-Mlu2)dT6AtT2^-!-5J49>z?m-!OcC& zLr;E=7wXZGA5Uc=X&lv?jIQC|YoC2=#DZ#N;6?yZA7R#TUlv}IX(5$0J*PhYi`1-f zglBU;OY*wU+)kphFn~)GGn(dkrvk>EjHs@<(yvn()~HYs`psEOo8|vgu*8lQDBh6 z3=oQ@e2*#vCX?|-($V~9n~jL+<0Dz|t-%2c8PE=IxtbxQNOQbRZ8n#|QV&u86lxR* zI|yuE(bP95ZF7QG_X(~vRS4pjno$@B15N_rrQp&qAaT7sF$tL~*Fezv1TkY0V1{$6 z+VFXkBqDDaC1y(_t6%=5{1a)mcj=2=#GURXt(EwFl&*gq%$gN7YOL)DtDf#Tr0EM2BZ3m$);Frt;(zucvOsiSy8R1a?4)Fsd&hoU~NghYiej z^$(xpW2DHbV%2{by(S!BX=ALgs)}Y&W_Vhz>HRkL50hBHb@yMMkaieaj*C&U?6|*y zxQBBKtE&_^I5;({aOK%XONguecfB{V_KT+(3x8! zoAJ0FK&@jFrVhfY+9!X_`zL8##wL%;RL_62pC)=>@r6F@nGO37YyCEt%e8o(%$UdIaw9l@s5|_nAncq-a^S#rKVTdln?g1`Jy7y#q2cQDcSGTr_(<= zwfTlbS?Ora{UvJ$D^1UjvMl@c%m7IYXn~<(t6y2kmLT-zm7&>W+_c1<6mhtme3+dk z>5zp7M{-g_GdwS?#h43UR_~Iz()2M;gN|>Gtg+qM->Hr$z`8FV$0WT37Q}&}Vi!WM zXR6(05H!5m&jfXfM(Sk%BOhT%8~+4jH^K> zaHSuO?%i + + + + +Products — Bulk CSV Import / Export + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json new file mode 100644 index 0000000..236d2d9 --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json @@ -0,0 +1,378 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/brand/**", + "components/cards/**", + "components/core/**", + "components/navigation/**", + "ui_kits/wiggleverse-www/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Space Grotesk|Inter|Fraunces))/i]", + "message": "Font not provided by the design system. Available: Space Grotesk, Inter, Fraunces." + }, + { + "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|href|showWordmark|assetBase|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: variant, size, href, showWordmark, assetBase." + }, + { + "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|footer|onLight)$/]", + "message": " variant must be one of 'primary' | 'footer' | 'onLight'." + }, + { + "selector": "JSXOpeningElement[name.name='BuildCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:tag|title|children|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: tag, title, children." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|href|onLight|disabled|type|onClick|key|ref|className|style|children)$/]", + "message": " +
+
+ {eyebrow && {eyebrow}} +

{title}

+
+ {right} +
+ + ); +} + +/* ================================ UPLOAD ================================== */ + +const SAMPLE_FILES = [ + { key: 'canonical', name: 'spring-2026-pricing.csv', size: '48 rows · 11 KB', desc: 'Canonical format — a seasonal repricing with new arrivals, updates, and a few bad rows.', icon: 'file' }, + { key: 'shopify', name: 'shopify-store-export.csv', size: '1,894 rows · 2.1 MB', desc: 'An unmodified Shopify product export — auto-detected and mapped to canonical.', icon: 'file' }, + { key: 'roundtrip', name: 'wander-wool-catalog-export.csv', size: '1,620 rows · 1.7 MB', desc: 'Your own export, re-imported — the lossless round-trip (nothing to change).', icon: 'refresh' }, +]; +const REJECT_FILE = { key: 'reject', name: 'orders-q1.csv', size: '900 rows · 240 KB', desc: 'A file that is not a product catalog — to see an honest whole-file rejection.', icon: 'alert' }; + +function UploadScreen({ onBack, onValidated, toast }) { + const [phase, setPhase] = _ifState('idle'); // idle | validating | rejected + const [progress, setProgress] = _ifState({ done: 0, total: 0, file: '' }); + const [dragOver, setDragOver] = _ifState(false); + const timer = _ifRef(null); + const fileInput = _ifRef(null); + + _ifEffect(() => () => clearInterval(timer.current), []); + + const start = (file) => { + if (file.key === 'reject') { + setPhase('rejected'); + return; + } + const total = file.rows; + setProgress({ done: 0, total, file: file.name }); + setPhase('validating'); + clearInterval(timer.current); + const step = Math.max(1, Math.round(total / 22)); + timer.current = setInterval(() => { + setProgress((p) => { + const done = Math.min(total, p.done + step); + if (done >= total) { + clearInterval(timer.current); + setTimeout(() => onValidated(file.key), 320); + } + return { ...p, done }; + }); + }, 70); + }; + + const ROWS = { canonical: 48, shopify: 1894, roundtrip: 1620 }; + const pick = (key, name) => start({ key, name, rows: ROWS[key] }); + + const onDrop = (e) => { + e.preventDefault(); setDragOver(false); + pick('canonical', 'spring-2026-pricing.csv'); + toast('Reading dropped file…'); + }; + + return ( +
+ + + {phase === 'validating' ? ( + +
+ + Validating… +
+
+ {progress.file} — {progress.done.toLocaleString()} of {progress.total.toLocaleString()} rows +
+ +
+ + Canceling is free — nothing has been written. +
+
+ ) : ( + <> + {/* drop zone */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + onClick={() => fileInput.current && fileInput.current.click()} + style={{ + border: `2px dashed ${dragOver ? 'var(--wv-violet)' : 'var(--paper-line-strong)'}`, + background: dragOver ? 'var(--violet-tint)' : 'var(--surface-card-light)', + borderRadius: '16px', padding: '2.6rem 2rem', textAlign: 'center', cursor: 'pointer', + transition: 'border-color var(--dur-fast), background var(--dur-fast)', + }}> + { pick('canonical', 'spring-2026-pricing.csv'); }} /> +
+ +
+
Drop a CSV here, or click to choose
+
CSV, up to 5,000 rows · 10 MB. Selecting a file starts validation right away.
+
+ Works with the canonical format or a Shopify product export — we detect which. +
+
+
+ + {/* file-level rejection */} + {phase === 'rejected' && ( +
+ +
+
This file can't be imported as a catalog.
+
It's missing the required column Title and matches no known dialect. Nothing was imported and no import run was recorded. Fix the file and try again.
+
+
+ )} + + {/* sample files */} +
+
Or try a sample file
+
+ {[...SAMPLE_FILES, REJECT_FILE].map((f) => ( + + ))} +
+
+ + )} +
+ ); +} + +/* ================================ PREVIEW ================================= */ + +function FieldRow({ label, value, note }) { + return ( +
+
{label}
+
+ {value} + {note && ⚠ {note}} +
+
+ ); +} + +function RecordDetail({ rec }) { + const d = rec.detail || {}; + if (rec.kind === 'add') { + return ( +
+
New product — fields to be set
+
+ {d.product.map((row, i) => )} +
+
+
+
Variants ({rec.variants}) · sample
+
+ {d.variantsPreview.map((v, i) => ( +
+ {v[0]} + {v[1]} + {v[2]} +
+ ))} + {rec.variants > d.variantsPreview.length &&
+ {rec.variants - d.variantsPreview.length} more variants
} +
+
+
+ {d.images} image{d.images === 1 ? '' : 's'} to fetch +
+
+
+ ); + } + if (rec.kind === 'update') { + return ( +
+
Changes — before → after
+
+ {d.changes.map((c, i) => ( +
+
+
{c.field}
+
{c.scope}
+
+
+
+ ))} +
+
+ ); + } + if (rec.kind === 'error') { + return ( +
+
+ +
+ Row {rec.line} · {rec.column} +
{rec.message}
+
This product will not be imported. Correct the row and re-import — the valid products still go through.
+
+
+
+ ); + } + return
This product matches your catalog exactly — nothing to change.
; +} + +function PreviewRow({ rec, expanded, onToggle }) { + return ( +
+ + {expanded &&
} +
+ ); +} + +function ErrorTable({ errors }) { + return ( + + + + + + + + + + + {errors.map((e, i) => ( + + + + + + ))} + +
LineColumnWhat's wrong
{e.line}{e.column} + {e.title} — {e.message} +
+
+ ); +} + +function PreviewScreen({ scenario, onCancel, onConfirm }) { + const order = ['add', 'update', 'unchanged', 'error']; + const firstActive = order.find((k) => scenario.summary[k] > 0) || 'unchanged'; + const [active, setActive] = _ifState(firstActive); + const [expanded, setExpanded] = _ifState(() => new Set()); + const [warnOpen, setWarnOpen] = _ifState(true); + + const toggle = (h) => setExpanded((s) => { const n = new Set(s); n.has(h) ? n.delete(h) : n.add(h); return n; }); + + const applyCount = scenario.summary.add + scenario.summary.update; + const records = scenario.records[active] || []; + const extra = (scenario.extra && scenario.extra[active]) || 0; + + return ( +
+ +
+ + Recognized: {scenario.dialect} +
+
+ } + /> + + {/* nothing-written reassurance */} +
+ Nothing has been written to your catalog yet. This is a preview. +
+ + {/* warnings band */} + {scenario.unknownColumns.length > 0 && ( +
+ + {warnOpen && ( +
+ {scenario.unknownColumns.map((c) => ( + {c} + ))} +
+ )} +
+ )} + + {/* summary tiles */} +
+ setActive('add')} disabled={scenario.summary.add === 0} /> + setActive('update')} disabled={scenario.summary.update === 0} /> + setActive('unchanged')} disabled={scenario.summary.unchanged === 0} /> + setActive('error')} disabled={scenario.summary.error === 0} /> +
+ + {/* detail list / error table */} +
+ + + {active === 'error' ? 'These rows are reported, not applied.' : active === 'unchanged' ? 'Re-supplied rows that already match your catalog.' : 'Expand any product to see exactly what will happen.'} + +
+ + {active === 'error' + ? + : ( + + {records.length === 0 + ?
Nothing in this group.
+ : records.map((rec) => toggle(rec.handle)} />)} + {extra > 0 && ( +
+ + {extra.toLocaleString()} more {active} {extra === 1 ? 'product' : 'products'} in this file +
+ )} +
+ )} + + {/* sticky footer */} +
+
+ {scenario.noop + ? Nothing to change — your catalog already matches this file. + : {applyCount.toLocaleString()} products will be added or updated{scenario.summary.error ? , {scenario.summary.error} rows left in error : ''}.} +
+
+ + +
+
+ + ); +} + +/* ============================== RUN DETAIL =============================== */ + +function RunDetailScreen({ run, scenario, images, onBack, live }) { + // live: true → animate applying → fetching → complete. false → show final state. + const [phase, setPhase] = _ifState(live ? 'applying' : 'done'); + const [fetched, setFetched] = _ifState(live ? 0 : images.fetched + images.problems.length); + const timer = _ifRef(null); + const total = images.total; + + _ifEffect(() => { + if (!live) return; + const t1 = setTimeout(() => setPhase('fetching'), 1100); + return () => clearTimeout(t1); + }, [live]); + + _ifEffect(() => { + if (phase !== 'fetching') return; + if (fetched >= total) { setPhase('done'); return; } + const id = setTimeout(() => setFetched((f) => Math.min(total, f + 1)), 120); + return () => clearTimeout(id); + }, [phase, fetched, total]); + + const counts = run || { added: scenario.summary.add, updated: scenario.summary.update, errors: scenario.summary.error }; + const hasProblems = images.problems.length > 0 || counts.errors > 0; + const statusLabel = phase === 'applying' ? 'Importing…' : phase === 'fetching' ? 'Fetching images…' : (hasProblems ? 'Complete · with problems' : 'Complete'); + const statusColor = phase === 'done' ? (hasProblems ? 'var(--st-update)' : 'var(--st-add)') : 'var(--wv-violet)'; + + return ( +
+ + {phase === 'done' ? : } + {statusLabel} + + } + /> +
+ Imported {run ? run.rel : 'just now'} by {window.IEData.STOREFRONT.email} · {scenario.dialect} +
+ + {/* result summary */} +
+ + + +
+ + {/* images section */} +
+

Images

+ + {phase !== 'done' && phase === 'applying' ? ( +
+ Applying catalog rows — the image fetch begins once products commit. +
+ ) : phase === 'fetching' ? ( +
+
+ Fetching images + {fetched.toLocaleString()} of {total.toLocaleString()} +
+ +
Running in the background — your catalog is already live. You can leave this page and come back.
+
+ ) : ( +
+
+ + p.outcome === 'rejected').length} color="var(--st-error)" glyph="x" /> + p.outcome === 'failed').length} color="var(--st-error)" glyph="alert" /> +
+ {images.problems.length > 0 ? ( +
+
Only problems are listed — these products show a placeholder until you re-import a corrected URL.
+
+ {images.problems.map((p, i) => ( +
+ +
+
{p.handle} · {p.variant}
+
{p.url}
+
{p.outcome === 'rejected' ? 'Rejected' : 'Failed'} — {p.reason}
+
+
+ ))} +
+
+ ) :
All images fetched cleanly.
} +
+ )} +
+
+ + {/* error table */} + {scenario.records.error.length > 0 && ( +
+

Rows in error · unapplied

+ +
+ )} +
+ ); +} + +function ResultTile({ n, label, kind }) { + const m = KIND_META[kind]; + return ( +
+
+ + {label} +
+
{n.toLocaleString()}
+
+ ); +} + +function SummaryStat({ label, n, color, glyph }) { + return ( +
+ + + + + + {n.toLocaleString()} + {label} + +
+ ); +} + +/* shared inline styles */ +const ghostBtn = { + fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.92rem', color: 'var(--text-on-light)', + background: 'transparent', border: '1.5px solid var(--paper-line-strong)', borderRadius: 999, + padding: '.6rem 1.25rem', cursor: 'pointer', +}; +const linkStyle = { display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.88rem', textDecoration: 'none' }; +const codeStyle = { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '.84em', background: 'var(--violet-tint)', color: 'var(--wv-ink)', padding: '.08rem .35rem', borderRadius: 5 }; + +Object.assign(window, { UploadScreen, PreviewScreen, RunDetailScreen }); diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx new file mode 100644 index 0000000..45a6ecd --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx @@ -0,0 +1,257 @@ +/* products.jsx — the Products page (§5.2): catalog home, empty + populated, + export menu, notices band, import history. Exported to window as ProductsPage. */ + +var { Button, Eyebrow, Notice } = window.WiggleverseDesignSystem_94cd80 || {}; + +const { useState: _pUseState, useRef: _pUseRef, useEffect: _pUseEffect } = React; + +/* ---- real file download helpers (makes PUC-11 / PUC-9 tangible) ---- */ +function downloadText(filename, text) { + const blob = new Blob([text], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; document.body.appendChild(a); a.click(); + document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +const SAMPLE_CSV = [ + 'Handle,Title,Description,Vendor,Type,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Cost,Variant Inventory Qty,Image Src,Image Position,Image Alt Text', + 'alpine-fleece-pullover,Alpine Fleece Pullover,"

Brushed recycled fleece, cut for layering.

",Wander & Wool,standalone,"outerwear, fleece",active,TRUE,Size,S,Color,Heather Grey,AFP-S-HG,88.00,34.00,12,https://cdn.example.com/alpine-front.jpg,1,Alpine fleece — front', + 'alpine-fleece-pullover,,,,,,,,Size,M,Color,Heather Grey,AFP-M-HG,88.00,34.00,9,,,', + 'alpine-fleece-pullover,,,,,,,,Size,L,Color,Forest,AFP-L-FO,88.00,34.00,7,https://cdn.example.com/alpine-forest.jpg,2,Alpine fleece — forest', + 'harbor-tote,Harbor Canvas Tote,"

Heavyweight organic-canvas tote.

",Wander & Wool,standalone,"bags, accessories",active,TRUE,,,,,HCT-01,48.00,18.00,40,https://cdn.example.com/harbor-tote.jpg,1,Harbor tote', +].join('\n'); + +function StatusFilterMenu({ onExport, onClose }) { + const [status, setStatus] = _pUseState('all'); + const opts = [['all', 'All products'], ['active', 'Active'], ['draft', 'Draft'], ['archived', 'Archived']]; + const ref = _pUseRef(null); + _pUseEffect(() => { + const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, []); + return ( +
+
Export — filter by status
+
+ {opts.map(([v, label]) => ( + + ))} +
+
+ +
+
+ ); +} + +function HistoryStatus({ status }) { + const map = { + applying: ['Importing…', 'var(--wv-violet)', true], + fetching_images: ['Fetching images…', 'var(--wv-violet)', true], + complete: ['Complete', 'var(--st-add)', false], + complete_with_problems: ['Complete · problems', 'var(--st-update)', false], + }; + const [label, color, spin] = map[status] || map.complete; + return ( + + {spin ? : } + {label} + + ); +} + +function ImportHistory({ runs, onOpenRun }) { + return ( +
+
+

Import history

+ {runs.length} runs · newest first +
+ + + + + + + + + + + + + + {runs.map((r, i) => ( + onOpenRun(r.id)} className="ie-hrow" style={{ + borderTop: '1px solid var(--paper-line)', cursor: 'pointer', + }}> + + + + + + + + ))} + +
WhenFileDialectAdded / Updated / ErrorsImagesStatus
{r.rel} + + {r.file} + + {r.dialect} + +{r.added} + · + ±{r.updated} + · + !{r.errors} + + {(r.images.fetched + r.images.rejected + r.images.failed) > 0 + ? {r.images.fetched} ✓ · {r.images.rejected + r.images.failed} ✗ + : } +
+
+
+ ); +} + +function ProductsPage({ data, catalogEmpty, onImport, onOpenRun, toast }) { + const { STOREFRONT, HISTORY } = data; + const [menuOpen, setMenuOpen] = _pUseState(false); + const count = catalogEmpty ? 0 : STOREFRONT.productCount; + const runs = catalogEmpty ? [] : HISTORY; + + const doExport = (status) => { + setMenuOpen(false); + toast(`Exporting ${status === 'all' ? 'all' : status} products…`); + setTimeout(() => { + const header = SAMPLE_CSV.split('\n')[0]; + downloadText(`wander-wool-catalog-${status}.csv`, header + '\n' + SAMPLE_CSV.split('\n').slice(1).join('\n')); + toast(`Exported ${count.toLocaleString()} products → wander-wool-catalog-${status}.csv`, 'success'); + }, 900); + }; + + return ( +
+ {/* header row */} +
+
+ Catalog +

+ Products · {count.toLocaleString()} +

+
+
+
+ + {menuOpen && setMenuOpen(false)} />} +
+ +
+
+ + {catalogEmpty + ? An honest empty shell — nothing to manage yet. + : One canonical CSV in, your whole catalog out — any time.} + + {/* notices band */} + {!catalogEmpty && STOREFRONT.imageProblemCount > 0 && ( + + )} + + {/* primary content */} + {catalogEmpty ? ( + +
+ +
+

No products yet

+

+ Bulk import is how product data gets in. Bring a CSV — yours, or an export from your old platform — and your catalog becomes your storefront. +

+ + +
+ ) : ( + +
+ +
+
+
Your catalog is loaded — {count.toLocaleString()} products.
+
The browsable product list arrives with an upcoming release. For now, manage it in bulk: import to change it, export to take it.
+
+ List · soon +
+ )} + + {/* history */} + {runs.length > 0 + ? + : !catalogEmpty ? null : ( +
+

Import history

+ No imports yet. +
+ )} +
+ ); +} + +window.ProductsPage = ProductsPage; +window.IE_downloadText = downloadText; +window.IE_SAMPLE_CSV = SAMPLE_CSV; diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx new file mode 100644 index 0000000..dd164ca --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx @@ -0,0 +1,117 @@ +/* shell.jsx — SD-0001 admin shell: dark cosmic sidebar nav + top header + (storefront name left, signed-in email + sign out right), light paper canvas. + Exported to window as AdminShell. */ + +var { Notice, Soul } = window.WiggleverseDesignSystem_94cd80 || {}; + +const NAV_ITEMS = [ + { id: 'home', label: 'Home', icon: 'home' }, + { id: 'products', label: 'Products', icon: 'products' }, + { id: 'orders', label: 'Orders', icon: 'cart', soon: true }, + { id: 'customers', label: 'Customers', icon: 'tag', soon: true }, + { id: 'analytics', label: 'Analytics', icon: 'gauge', soon: true }, + { id: 'settings', label: 'Settings', icon: 'gauge', soon: true }, +]; + +function SidebarItem({ item, active, onClick }) { + const [hover, setHover] = _shUseState(false); + return ( + + ); +} + +function AdminShell({ current, onNav, storefront, children }) { + return ( +
+ {/* ---- Sidebar ---- */} + + + {/* ---- Main column ---- */} +
+ {/* top header */} +
+
+ + {storefront.name} + · live storefront +
+
+ {storefront.email} + +
+
+ +
+
+ {children} +
+
+
+
+ ); +} + +const { useState: _shUseState } = React; +window.AdminShell = AdminShell; diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx new file mode 100644 index 0000000..ba6eaf9 --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx @@ -0,0 +1,541 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) + +/* BEGIN USAGE */ +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, +// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "palette": ["#D97757", "#29261b", "#f6f4ef"], +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to +// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when +// options are many or long. For color tweaks always curate 3-4 options rather than +// a free picker; an option can also be a whole 2–5 color palette (the stored value +// is the array). The Tweak* controls are a floor, not a ceiling — build custom +// controls inside the panel if a tweak calls for UI they don't cover. +/* END USAGE */ +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} + + .twk-chips{display:flex;gap:6px} + .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; + padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; + box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); + transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} + .twk-chip:hover{transform:translateY(-1px); + box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} + .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), + 0 2px 6px rgba(0,0,0,.15)} + .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; + display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} + .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} + .twk-chip>span>i:first-child{box-shadow:none} + .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; + filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + // Same-window signal so in-page listeners (deck-stage rail thumbnails) + // can react — the parent message only reaches the host, not peers. + window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(() => window.requestAnimationFrame(() => clampToViewport())); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
+ {children} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + // Segments wrap mid-word once per-segment width runs out. The track is + // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px + // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 + // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall + // back to a dropdown rather than wrap. + const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length; + const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); + const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); + if (!fitsAsSegments) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// Relative-luminance contrast pick — checkmarks drawn over a swatch need to +// read on both #111 and #fafafa without per-option configuration. Hex input +// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". +function __twkIsLight(hex) { + const h = String(hex).replace('#', ''); + const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0'); + const n = parseInt(x.slice(0, 6), 16); + if (Number.isNaN(n)) return true; + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return r * 299 + g * 587 + b * 114 > 148000; +} + +const __TwkCheck = ({ light }) => ( + +); + +// TweakColor — curated color/palette picker. Each option is either a single +// hex string or an array of 1-5 hex strings; the card adapts — a lone color +// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the +// rest stacked in a sharp column on the right. onChange emits the +// option in the shape it was passed (string stays string, array stays array). +// Without options it falls back to the native color input for back-compat. +function TweakColor({ label, value, options, onChange }) { + if (!options || !options.length) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native emits lowercase hex per the HTML spec, so + // compare case-insensitively. String() guards JSON.stringify(undefined), + // which returns the primitive undefined (no .toLowerCase). + const key = (o) => String(JSON.stringify(o)).toLowerCase(); + const cur = key(value); + return ( + +
+ {options.map((o, i) => { + const colors = Array.isArray(o) ? o : [o]; + const [hero, ...rest] = colors; + const sup = rest.slice(0, 4); + const on = key(o) === cur; + return ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx new file mode 100644 index 0000000..6f8d620 --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx @@ -0,0 +1,176 @@ +/* ui.jsx — shared primitives for the import/export admin: thin geometric line icons + (consistent with the Circle-of-Equals mark, single weight — flagged as a net-new + admin icon set, not house brand), status badges, diff chips, stat tiles, progress. + Exported to window. */ + +const { useState: _uiUseState } = React; + +/* ---------- Icon set: thin, single-weight, geometric ---------- */ +const ICON_PATHS = { + products: 'M3 7.5 12 3l9 4.5-9 4.5-9-4.5Zm0 0v9l9 4.5 9-4.5v-9M12 12v9', + upload: 'M12 16V4m0 0-4 4m4-4 4 4M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3', + download: 'M12 4v12m0 0-4-4m4 4 4-4M4 20h16', + check: 'M4 12.5 9.5 18 20 6', + x: 'M6 6l12 12M18 6 6 18', + plus: 'M12 5v14M5 12h14', + minus: 'M5 12h14', + equal: 'M5 9h14M5 15h14', + alert: 'M12 3 2 20h20L12 3Zm0 6v6m0 3h.01', + clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3.5 2', + image: 'M3 5h18v14H3V5Zm0 11 5-5 4 4 3-3 6 6M9.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z', + chevron: 'M9 6l6 6-6 6', + chevronDown: 'M6 9l6 6 6-6', + file: 'M6 2h8l4 4v16H6V2Zm8 0v4h4', + home: 'M3 11.5 12 4l9 7.5M5 10v10h5v-6h4v6h5V10', + cart: 'M3 4h2l2.5 12h11L21 7H6', + gauge: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-9 4-3', + tag: 'M3 12V4h8l10 10-8 8L3 12Zm5-4.5a1 1 0 1 0 0-.01', + signout: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M10 16l-4-4 4-4M6 12h10', + search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm6 2-3.5-3.5', + arrowRight: 'M5 12h14m0 0-5-5m5 5-5 5', + arrowLeft: 'M19 12H5m0 0 5-5m-5 5 5 5', + filter: 'M3 5h18l-7 8v6l-4-2v-4L3 5Z', + refresh: 'M20 11a8 8 0 1 0-.5 4M20 5v6h-6', + link: 'M9 15l6-6M8 12l-2 2a3 3 0 0 0 4 4l2-2M16 12l2-2a3 3 0 0 0-4-4l-2 2', +}; + +function Icon({ name, size = 18, stroke = 1.6, style, ...rest }) { + return ( + + + + ); +} + +/* ---------- Circle-of-Equals mark (compact, for the admin lockup) ---------- */ +function CircleMark({ size = 26 }) { + const id = 'cm' + Math.random().toString(36).slice(2, 7); + // six equal nodes on a ring, connected by one wiggling line, empty center + const cx = 18, cy = 18, R = 13; + const pts = []; + for (let i = 0; i < 6; i++) { + const a = (Math.PI / 3) * i - Math.PI / 2; + pts.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]); + } + // wiggle path: between each pair of nodes, bow the line in/out alternately + let d = `M ${pts[0][0]} ${pts[0][1]}`; + for (let i = 1; i <= 6; i++) { + const p = pts[i % 6]; + const prev = pts[i - 1]; + const mx = (prev[0] + p[0]) / 2, my = (prev[1] + p[1]) / 2; + const out = i % 2 === 0 ? 1.18 : 0.82; + const ctrlx = cx + (mx - cx) * out, ctrly = cy + (my - cy) * out; + d += ` Q ${ctrlx} ${ctrly} ${p[0]} ${p[1]}`; + } + return ( + + + + + + + + + {pts.map((p, i) => )} + + ); +} + +/* ---------- Status / kind badges ---------- */ +const KIND_META = { + add: { glyph: 'plus', label: 'Add', cssvar: 'add' }, + update: { glyph: 'equal', label: 'Update', cssvar: 'update' }, + unchanged: { glyph: 'minus', label: 'Unchanged', cssvar: 'muted' }, + error: { glyph: 'alert', label: 'Error', cssvar: 'error' }, +}; + +function KindBadge({ kind, size = 'md' }) { + const m = KIND_META[kind]; + const pad = size === 'sm' ? '.12rem .5rem .12rem .42rem' : '.22rem .62rem .22rem .5rem'; + return ( + + + {m.label} + + ); +} + +/* ---------- Diff value: before → after ---------- */ +function DiffPair({ before, after, noop }) { + if (noop) { + return {after} (unchanged); + } + return ( + + {before} + + {after} + + ); +} + +/* ---------- Stat tile (summary filter) ---------- */ +function StatTile({ n, label, kind, active, onClick, disabled }) { + const m = KIND_META[kind]; + const accent = `var(--st-${m.cssvar})`; + return ( + + ); +} + +/* ---------- Progress bar ---------- */ +function ProgressBar({ value, max, accent = 'var(--wv-violet)', height = 8 }) { + const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0; + return ( +
+
+
+ ); +} + +/* ---------- Generic paper card ---------- */ +function Card({ children, style, pad = '1.5rem', ...rest }) { + return ( +
+ {children} +
+ ); +} + +/* ---------- Spinner (thin, geometric) ---------- */ +function Spinner({ size = 16, stroke = 2 }) { + return ( + + + + + ); +} + +Object.assign(window, { + Icon, CircleMark, KindBadge, DiffPair, StatTile, ProgressBar, Card, Spinner, KIND_META, +}); -- 2.52.0 From 6929467cf757d6fdd0cdb1a8d48678cf5a5910e5 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:57:06 -0700 Subject: [PATCH 12/12] =?UTF-8?q?spec(SD-0002):=20status=20=E2=86=92=20app?= =?UTF-8?q?roved=20(v0.3.1)=20at=20session-0030=20finalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- specs/SD-0002-products-bulk-csv-import-export.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index d0c4efb..b13312b 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -5,8 +5,8 @@ | **Anchor** | [wiggleverse/wiggleverse-ecomm#13](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/13) (`type/feature`, `priority/P1`) — parent epic [#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12) | | **Author(s)** | Claude (session ecomm-0030), for Ben Stull | | **Reviewers / approvers** | Ben Stull | -| **Status** | `draft` | -| **Version** | v0.1.0 | +| **Status** | `approved` | +| **Version** | v0.3.1 | | **Source artifacts** | BDD corpus: [prototype rfcs 11-05-\* (products import/export)](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs) · [research/shopify/products](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify/products/products.md) · Prototype: [wiggleverse-ecomm-prototype](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype) · Reference: Shopify product CSV · Builds on: [SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md) · Supersedes: — | **Change log** @@ -16,6 +16,7 @@ | 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben | | 2026-06-11 | v0.2.0 | Full draft complete, §§1–14, authored section-at-a-time with operator review at each boundary (session ecomm-0030) | Claude + Ben | | 2026-06-11 | v0.3.0 | Claude Design hi-fi bundle committed (`ui/designs/SD-0002-…`); §5.1 decision amended to Design → Code (D-14); §5.2 toast refinement adopted | Claude + Ben | +| 2026-06-11 | v0.3.1 | Status → `approved` at session finalize — every section operator-reviewed at its boundary during authoring (autonomous posture, handbook §6.5) | Claude + Ben | --- -- 2.52.0