Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d63842c066 | |||
| 929de076c1 | |||
| 3a77e1d732 | |||
| 238bbc3845 | |||
| 4322101fd3 | |||
| faaed0281c | |||
| 25c26592cb | |||
| 317a6b762c | |||
| 4ab2121e0d | |||
| 6b15a6ef80 | |||
| 1261db941f | |||
| 08a3887937 | |||
| 4d8daad6b7 | |||
| a5c0dd40b2 | |||
| adcf97f13e | |||
| 50ea113013 | |||
| 37e274f620 | |||
| 73cf3a933b | |||
| 3f0cfcc2d5 | |||
| 92bc17748e | |||
| c5f9f11591 | |||
| d85317a810 | |||
| 452acc97b0 | |||
| 69af932233 | |||
| e6e19e7c13 | |||
| 6ae837035a | |||
| 876ea2fec5 | |||
| ed4b6dbc60 | |||
| 2f4c3be578 | |||
| c870bc77d0 | |||
| f32c8abaa0 | |||
| de64348fbf | |||
| 275d760aeb | |||
| 8845cd526d | |||
| 18a4b00921 | |||
| be333be078 | |||
| de6b338d80 | |||
| 7d18c33281 | |||
| d7b8e8c46c | |||
| 781b4d813e | |||
| 9150eafe79 | |||
| 065722c420 | |||
| bcdbc18a1d | |||
| 6929467cf7 | |||
| 8a0c7bc35e | |||
| 0116afbba9 | |||
| e4cd24e631 | |||
| 07abd48b50 | |||
| 50574fe68b | |||
| 4869c6141b | |||
| a9137d17da | |||
| 08ded5fa1b | |||
| f4e2a9b792 | |||
| 966323b7c3 | |||
| 2cb6ccc947 | |||
| 7df301f8b0 | |||
| 4e8f3f6898 | |||
| 31fb96401a | |||
| 9848b34b19 | |||
| bfadf638a2 | |||
| afe36fd347 | |||
| f4d6961792 |
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
*.local
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# §22.4a collection manifest — read by the registry mirror.
|
||||
type: document
|
||||
visibility: public
|
||||
name: Docs
|
||||
@@ -0,0 +1,798 @@
|
||||
# Wiggleverse Maker Collective - A Platform for Makers to Connect — PR-FAQ
|
||||
|
||||
> **What this doc is.** An Amazon-style **PR-FAQ** ("working backwards") version of
|
||||
> [`maker-platform-strategy.md`](./maker-platform-strategy.md). It opens with a
|
||||
> future-dated *press release* written as if the product had already launched,
|
||||
> then answers the questions a smart skeptic would ask. It is a communication
|
||||
> artifact, not a new strategy — every claim traces to the memo, with section
|
||||
> citations (`§7`, `Appendix C`, …) into it. Where the two disagree, **the memo
|
||||
> wins** (and the memo in turn defers to the [Open Human Model](https://rfc.wiggleverse.org/p/ohm/c/default/) on load-bearing concepts).
|
||||
>
|
||||
> **Audience.** Technology experts who know Etsy/Shopify as users but aren't
|
||||
> commerce specialists — so commerce jargon (merchant of record, money
|
||||
> transmission, marketplace-facilitator tax, chargebacks, GMV) is defined inline;
|
||||
> architecture is not.
|
||||
>
|
||||
> **Product name.** "Wiggleverse Maker Collective"
|
||||
|
||||
---
|
||||
|
||||
## PRESS RELEASE
|
||||
|
||||
### Wiggleverse launches Maker Collective, a verified-maker network where makers keep their own customers — and far more of every sale than on Etsy
|
||||
|
||||
**A verified-maker network where independent makers run customized orders, drops, pre-orders, and
|
||||
clubs — not just ready-made inventory — and earn demand by vouching for each other, not by buying ads. The maker
|
||||
keeps their own storefront, checkout, customers, and far more of every sale — all-in
|
||||
fees around 4–7% versus Etsy's ~20%.**
|
||||
|
||||
**SEATTLE, WA — August 1, 2026** — Maker Collective today opened to
|
||||
its first community of independent makers — the tabletop-miniatures scene: a
|
||||
commerce platform built around the way makers actually sell. Where Shopify and Etsy assume *stock-then-sell* — make
|
||||
inventory, shelve it, wait for a buyer — many makers run on *commit-then-make*: collect
|
||||
committed demand first (a drop, a pre-order, a deposit-and-waitlist, a monthly club, a made-to-order
|
||||
commission), then produce against it. Maker Collective is built for that motion
|
||||
end to end, and adds something no storefront tool has: a **reputation-staked
|
||||
referral network** where makers send each other real buyers.
|
||||
|
||||
**The problem.** The tools makers rely on serve them poorly at exactly the moments
|
||||
that matter. Generic storefronts treat a scheduled drop or a 10-piece lottery as
|
||||
an afterthought, and marketplaces have drifted the other way: Etsy, founded on
|
||||
"handmade," is now flooded with mass-produced and AI-generated goods, so the
|
||||
buyer can no longer tell what's authentically created by a Maker. Makers are left choosing between a tool that
|
||||
doesn't fit and a marketplace that has stopped standing for anything — while
|
||||
paying marketplace fees that can approach 20% of each sale.
|
||||
|
||||
**The solution.** Maker Collective is two things at once. First, a
|
||||
**commitment-commerce engine** — scheduled drops, pre-orders and deposits,
|
||||
raffle/queue allocation, recurring clubs, made-to-order workflows, digital-file
|
||||
delivery — that runs on the maker's *own* storefront and *own* payment processor.
|
||||
Second, a **verified merchant referral network**: each maker's storefront (and, optionally, email marketing content) carries
|
||||
a "Curated By This Maker" section featuring other *verified* makers whose work
|
||||
they genuinely admire, with the curating maker's reputation on the line. Placement
|
||||
is *earned*, never sold — the opposite of pay-for-placement advertising. Every
|
||||
item carries a buyer-visible **provenance badge** (original / partly original /
|
||||
resale), so a buyer always knows what they're buying, and the platform never
|
||||
points a buyer at an Etsy or Amazon listing.
|
||||
|
||||
Critically, **Maker Collective never touches the buyer's money.** The maker is
|
||||
the merchant of record on their own processor; the platform sells optional storefront software (or Makers can bring their own existing storefront) and
|
||||
bills its fee separately. That single architectural choice keeps the platform out
|
||||
of the financial and regulatory machinery that sinks marketplaces, and lets it
|
||||
charge a fraction of Etsy's take.
|
||||
|
||||
> "Every 'Etsy but actually handmade' before us recruited angry makers and died
|
||||
> for lack of buyers, because curation and liquidity pull against each other," said
|
||||
> a spokesperson for the non-profit behind Maker Collective. "We didn't launch a
|
||||
> marketplace. We launched a great tool for one tight community, and let demand
|
||||
> *emerge* from makers vouching for makers. The network is the product; the
|
||||
> storefront is just how some makers choose to plug in."
|
||||
|
||||
**How it works.** A maker joins by invitation from an existing member who vouches
|
||||
that they make original work — a rooted trust graph, not an anonymous signup. They
|
||||
run their commitment-commerce events on a Maker Collective storefront *or* keep
|
||||
their existing Shopify store and connect it (the platform federates over both).
|
||||
Once verified, they can curate other makers and be curated; a signed referral
|
||||
token rides each "Curated By" link so the platform can credit the referrer and
|
||||
bill the referred maker — without ever sitting in the payment flow. Referral
|
||||
income draws down the maker's own future platform fees, so curating well literally
|
||||
erases your bill.
|
||||
|
||||
> "Honestly, I almost didn't bother — I already have a Shopify store and a following, and
|
||||
> I didn't want to migrate everything to Some New Platform," said a founding miniatures
|
||||
> maker. "I didn't have to move anything: I kept my store, connected it, and ran my Saturday
|
||||
> drop and my monthly club right through it. What sold me was the referrals — makers I
|
||||
> respect started sending me real buyers, because they actually like my work, not because
|
||||
> someone bought a slot. And nobody ever took a cut of money that wasn't theirs."
|
||||
|
||||
> "I follow maybe a dozen casters and painters and I live for their drops — but I
|
||||
> got burned twice buying recasts off a marketplace, and lately I can't tell what's
|
||||
> even real," said a tabletop hobbyist. "Here every piece tells me it's the
|
||||
> maker's own original work, and the makers I already trust point me to new ones I
|
||||
> end up loving. It's the people I follow — not an algorithm guessing."
|
||||
|
||||
**Availability.** Maker Collective is opening invitation-only inside one tight
|
||||
community — independent **miniatures makers** (resin/STL casters,
|
||||
sculptors, painters) — chosen because it expresses every commit-then-make motion at
|
||||
once and its makers already run drops, clubs, and made-to-order commissions. It
|
||||
expands along the adjacent-buyer arc — **miniatures → resin dice → broader tabletop**
|
||||
— as each community compounds. Makers on any controllable storefront — Wiggleverse,
|
||||
Shopify, or self-hosted — can be invited to verify and join. Learn more at makers.wiggleverse.org.
|
||||
|
||||
*Maker Collective is operated as a true non-profit: open books, no equity, no
|
||||
sale, engineered by volunteers with LLM-accelerated development. The structure
|
||||
exists so the promise — that "verified" stays incorruptible — is enforced by law,
|
||||
not by good intentions.*
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Part 1 — Customer questions (makers & buyers)
|
||||
|
||||
**What is "commitment commerce," and why is it so important to the pitch?**
|
||||
It's the inverse of normal retail. Stock commerce is *make it, shelve it, someone
|
||||
buys it* (Shopify's model). Commitment commerce is *collect committed demand, then
|
||||
make against it* — a drop, a pre-order, a deposit-and-waitlist, a monthly club, a
|
||||
made-to-order commission. Many makers live in this mode; generic tools treat it as a
|
||||
bolt-on. The memo's core claim (§2) is that the drop/pre-order/club/commission
|
||||
cadence isn't a feature of a storefront — it *is* the platform, and it's the part
|
||||
that's genuinely hard to build well (the "gnarly 20%"). The storefront itself is a
|
||||
commodity we build as little of as possible and rent the rest.
|
||||
|
||||
**I already use Etsy/Shopify. How is this actually different?**
|
||||
- **vs. Etsy:** Etsy is a marketplace that owns your buyer and takes a large cut,
|
||||
and its "handmade" guarantee has eroded. Here you own your buyer and your
|
||||
checkout, pay far less, and verification is real and reputation-staked.
|
||||
- **vs. Shopify:** Shopify is a great *stock* storefront but mediocre at the
|
||||
commit-then-make cadence, and it has no cross-merchant *referral* network where
|
||||
sellers vouch for each other (it has a *resell* network — Collective — which is
|
||||
a different thing; see below).
|
||||
- **vs. Shopify Collective / Carro:** those let merchants *resell* each other's
|
||||
products through one checkout, which forces the reseller to become merchant of
|
||||
record and handle payouts. Our network is **referral, not resale** — a vouch and
|
||||
a handoff, money stays siloed (§7, "the fork"). We deliberately don't compete on
|
||||
the plumbing, which is commoditized; we compete on *verified provenance* and
|
||||
*reputation-staked curation*, which a commission-optimized network structurally
|
||||
can't have (§3).
|
||||
|
||||
**Isn't this just Patreon, for makers?**
|
||||
Patreon is the closest comparison for *one* primitive — the monthly club — and it's
|
||||
worth being precise about why, because it's the de-facto club infrastructure in the
|
||||
beachhead (Appendix A/B). Unlike Etsy/Shopify, Patreon *isn't* the opposite motion: a
|
||||
membership is already commit-then-make (patrons commit ahead, the creator produces
|
||||
against it), so Patreon genuinely *is* doing this category for recurring clubs. But it
|
||||
sits on the wrong side of three things we treat as non-negotiable:
|
||||
- **It's in the money flow.** Patreon is merchant of record, processes the recurring
|
||||
charge, takes ~8–12% all-in, and pays out. We keep the maker as merchant of record
|
||||
on their *own* processor (recurring billing via Stripe on a Standard account) and
|
||||
bill our software fee separately — so makers keep more and get *usage-rights
|
||||
ownership of the patron*, which Patreon doesn't grant.
|
||||
- **It's a walled garden on the buyer.** You can't host a curation block on a Patreon
|
||||
page, attribute a referral through its checkout, or take the patron relationship
|
||||
with you — the same captive model as Etsy, applied to *recurring* relationships. So
|
||||
Patreon is an *invitation target*, not something we integrate into: "I'd feature
|
||||
your work the moment you own your commerce." (The one thing you can lift is your
|
||||
patron email list.)
|
||||
- **It can't build the network.** Patreon is single-creator with platform-run
|
||||
algorithmic discovery — the opposite of maker-vouches-for-maker. Every patron there
|
||||
is a follow that never enters the cross-maker graph (our North Star), and it won't
|
||||
add reputation-staked cross-maker referral for the same reason Shopify won't build
|
||||
shared identity: it would have to become a different company.
|
||||
|
||||
So the play is two-sided: **out-tool** Patreon's club with a native, out-of-flow,
|
||||
lower-fee version the maker owns (the Tool), and **out-flank** it with the cross-maker
|
||||
referral network it structurally can't grow (the Network).
|
||||
|
||||
**Isn't this just Kickstarter / Gamefound / BackerKit?**
|
||||
No — and the distinction *is* the opening: **campaign vs. cadence** (Appendix B).
|
||||
Kickstarter, **Gamefound** (tabletop-native, Kickstarter's biggest tabletop rival —
|
||||
sitting *in* the miniatures vertical), and BackerKit are built for **episodic,
|
||||
project-scale campaigns**: a big push that funds a project, then fulfillment. None of them
|
||||
serves the maker running a small drop **every other Saturday**, a 10-piece lottery, a
|
||||
monthly club, or a standing made-to-order queue — the **continuous** commitment-commerce
|
||||
cadence. That continuous, relationship-driven, small-batch motion is the unserved space
|
||||
*between* Shopify (continuous but stock-only) and Kickstarter/Gamefound (commitment but
|
||||
episodic), and it's where we play. So the posture is **coexist, not compete**: run your big
|
||||
annual campaign on Gamefound if that's the right tool for it — keep us for the continuous
|
||||
cadence *between* campaigns, plus the cross-maker referral network none of them have. Two
|
||||
structural cuts underline it: Kickstarter is itself **in the money flow** (it processes
|
||||
pledges, takes a cut, and pays out, disclaiming only *delivery* liability), where we keep
|
||||
the maker merchant-of-record on their own processor and shed both the flow and the
|
||||
delivery liability (§7/§11); and the campaign players are single-project tools with **no
|
||||
reputation-staked cross-maker referral graph** — the durable moat — which they won't build
|
||||
for the same reason the others won't.
|
||||
|
||||
**What does it cost, and what's the "~20%" claim?**
|
||||
Marketplaces like Etsy bundle everything into one fee that, all-in, can approach
|
||||
~20% of a sale (GMV = gross merchandise value, the total sold). We **unbundle**:
|
||||
you pay your own payment processor directly (their normal ~3%), and pay us a
|
||||
separate, modest software fee billed in arrears, on a tier that **auto-graduates by
|
||||
volume so you never overpay**: **Starter** at $0 + a small percentage (~2–4%) on
|
||||
captured orders (so a low-price-point maker isn't over-taxed), or **Pro** at a flat
|
||||
**$29–49/month + 0%** once your volume makes the flat fee cheaper (§7, "How the
|
||||
platform gets paid"). Worked through, all-in — *illustrative; real rates are set at
|
||||
launch*:
|
||||
- a **Starter maker doing ~$1,200/mo**: ~2–4% platform + ~3% processor ≈ **5–7%** all-in;
|
||||
- a **Pro maker doing ~$6,000/mo**: $39/mo + ~3% processor ≈ **~3.6%** all-in;
|
||||
- **Etsy, for either of them: ~20%.**
|
||||
|
||||
That's the wedge in the numbers the doc owes you, not a slogan: a maker keeps roughly
|
||||
**13–16 percentage points more of every sale** than on Etsy. We bill only on
|
||||
*captured/fulfilled* orders, never on pledges that never cleared.
|
||||
|
||||
**Do I have to abandon my Shopify store to join?**
|
||||
No — and de-risking that question is a deliberate design goal (§7, "Shopify makers:
|
||||
federate, don't migrate"). You keep Shopify as your merchant-of-record storefront
|
||||
and connect via two hooks: a **catalog sync** (Shopify's Admin API + product
|
||||
webhooks feed our verified index) and **referral attribution** (the Curated-By link
|
||||
carries a signed token that rides in as a Shopify cart attribute → order
|
||||
note attribute, read off the order webhook). No checkout customization, works on any
|
||||
plan. The "hybrid wedge": keep your evergreen catalog on Shopify, use us only for
|
||||
the drop/pre-order/club *events* Shopify handles badly. Migrate later only if you
|
||||
want to.
|
||||
|
||||
**What does "own the customer" mean here?**
|
||||
The headline meaning is **usage rights** (§7, validated in interviews): the buyer
|
||||
relationship is *yours to market to, on any channel, including off our network*.
|
||||
This is the exact inverse of Etsy/Amazon, who forbid you from marketing to "their"
|
||||
captive buyers. We can grant it unconditionally because we don't monetize the
|
||||
captive relationship — we don't have one. Separately and optionally, sovereignty-
|
||||
minded makers can keep their buyers *private from the cross-maker network graph*;
|
||||
that's an opt-out, not the core meaning.
|
||||
|
||||
**What does "verified" mean, and how do I get it?**
|
||||
Verification answers "is this a real maker of original work?" at the door, and it's
|
||||
the gate to the demand surfaces (referrals, Curated-By, the buyer feed, AI-agent
|
||||
feed). Early on, staff verify a seed set directly; at scale, **makers verify makers**
|
||||
("peer verification"), staking their own reputation — a rooted, multi-vouch trust
|
||||
graph with sampling audits, because it's the highest-stakes mechanism in the system
|
||||
(§7, "Verification"). The unverified tier still gets the full storefront tool — we
|
||||
**gate the demand, not the tool** — so verification is something makers are pulled
|
||||
toward, not blocked at.
|
||||
|
||||
**How do you know a specific *item* is original — not just that the maker is real?**
|
||||
Two different checks, and conflating them is the Etsy failure mode. **Verification** is
|
||||
about the *maker* ("a real maker of original work?"); **provenance** is per-*item* ("is
|
||||
*this product* their original work?"). A real maker's catalog is legitimately mixed — a
|
||||
potter sells their pots *and* resells pottery tools — so every item carries its own
|
||||
**provenance classification** (§7), **self-attested** by the maker, **audited** by the
|
||||
trust machinery, and **shown to the buyer**: *Original* (bought raw materials like clay
|
||||
are inputs to making, not other-sourced parts) · *Original + components* (primarily
|
||||
theirs, with identifiable parts from others attributed — the "partly original" kit case)
|
||||
· *Resale – fellow maker* (an in-network maker's original item, provenance tracing to the
|
||||
true maker — Curated-By as a catalog item) · *Resale – third-party* (commercial goods,
|
||||
tools, supplies — honest, allowed, clearly *not* original).
|
||||
|
||||
Two things make the badge a guarantee rather than a self-serve sticker. **Eligibility
|
||||
keys off it, per item:** only *original* and *original-+-in-network-components* surface as
|
||||
the maker's original work in Curated-By / the buyer feed / the agent feed; a fellow-maker
|
||||
resale surfaces only *attributed to the true maker*; **third-party resale never enters a
|
||||
trust surface** — surfacing it would launder non-original goods through a trusted face,
|
||||
the Etsy pollution failure mode from the inside. And **misclassification has teeth:**
|
||||
calling a resale "original" is a *provenance lie*, not a clerical slip — a
|
||||
**verification-revocation trigger** (§10), with self-attestation (cheap to classify)
|
||||
policed by **sampling audits plus buyer reporting** (risky to game). One useful
|
||||
consequence: because "handmade/original" are advertising claims the FTC can require you to
|
||||
substantiate, this system *is* the substantiation mechanism (§11) — the product-defining
|
||||
feature and the compliance obligation are the same build.
|
||||
|
||||
What's still open, deliberately: the precise, auditable line between *making* and
|
||||
*reselling* — purchased supplies don't taint "original," but assembling mostly-third-party
|
||||
parts isn't original either; finishing, assembling, and kitting sit in between. That
|
||||
standard is named as later work (§14 #2), not claimed as solved.
|
||||
|
||||
**What is "Curated By This Maker," and how do referrals pay?**
|
||||
Each storefront carries a section where the maker features other *verified* makers'
|
||||
products they genuinely admire — and **only with the featured maker's approval**: B opts in
|
||||
to being curated by A, per relationship, so no one is featured against their will. On a
|
||||
referred sale two things stack. **The platform's cut is fixed and never negotiated** — a
|
||||
**3–5% spread on the order** (it may scale with the sale and carry a $ cap), the network's
|
||||
one piece of referral revenue. **Everything above it is the makers' to set:** A and B define
|
||||
A's referral reward through platform tooling — a flat percentage, a tiered rate ("x% on
|
||||
orders over $y"), a max-$ cap — and can **renegotiate** it as the relationship evolves, with
|
||||
one floor, the platform spread. So B always pays *at least* the spread; if A and B set A's
|
||||
reward to zero, **no referral money changes hands and the platform still takes its spread.**
|
||||
The two legs stay independent — B pays on B's own invoice, A is *credited* separately — so
|
||||
the platform is never a conduit moving money B→A (which would be regulated money
|
||||
transmission). A's credit is **non-cashable** (it draws down A's own future platform fees,
|
||||
so curating well drives your bill toward zero). It works at **n=2** — two makers are enough
|
||||
for it to be useful, rare for a network feature (§7, "Referral economics").
|
||||
|
||||
**Won't paid referrals just become advertising in disguise?**
|
||||
That's the central risk, and the guardrail is to separate **placement** from **economics**
|
||||
(§3, §7). *Placement* is **reputation-staked vouching, never pay-for-placement**: you cannot
|
||||
buy your way into a maker's curation, the platform never sells a slot, featuring is capped
|
||||
per maker, visibly personal (name + face), gated by the featured maker's approval, and
|
||||
biased toward *complementary* makers, not rivals. The *economics* (A's referral reward) are
|
||||
a private term A and B negotiate, floored at the platform's fixed spread — but because
|
||||
placement itself is non-biddable and the curator stakes their **own audience's trust**
|
||||
(feature junk for the money and your conversions and standing erode), a richer split can't
|
||||
buy a feature it didn't earn.
|
||||
|
||||
The subtler version: among the items a maker curates, *the platform* decides which to
|
||||
surface to a given buyer — and we **do** use algorithms (personalization, popularity) to do
|
||||
it, because lifting conversions is the job. The guarantee is that **referral economics are
|
||||
never an input to that ranking** — and it's *structural*, not a pinky-swear: because the
|
||||
platform earns the **same fixed spread no matter which referred item sells**, it has **no
|
||||
incentive** to favor a higher-paying referral, so the ranking optimizes for the buyer's
|
||||
conversion, full stop. The name itself — *verified merchant referral network*, not *retail
|
||||
media* — is the backstop: the moment it starts selling slots, it's a self-evident lie.
|
||||
|
||||
**If I'm the maker being *featured*, do I have a say — and am I just paying to be advertised?**
|
||||
Full say — on both the placement and the price. **Nobody features you without your
|
||||
approval:** being curated by a specific maker is **opt-in, per relationship** (A can feature
|
||||
B only if B agrees), and *buyer-identity* participation is a separate opt-in on top, never
|
||||
bundled. **You and the curator set the terms** (above) — you're not handed a rate, you agree
|
||||
to one, floored at the platform's fixed spread. And you're **not paying for an ad:** you pay only on an
|
||||
*actual referred sale* — incremental business you wouldn't otherwise have had — and the
|
||||
placement itself can't be bought (a curator features you only on genuine merit, their own
|
||||
audience-trust on the line, capped, name-and-face). Being featured is a *vouch you both
|
||||
agreed to*, not a slot — which is exactly why it's worth more than an ad.
|
||||
|
||||
**Will you ever link a buyer to my Etsy/Amazon listing?**
|
||||
Never (§7; Appendix D). The network never routes a buyer *into* a walled garden —
|
||||
not via curation, the buyer feed, or the agent feed. A maker who's *only* on Etsy
|
||||
is an **invitation target, not a destination**: "I'd feature your work the moment you
|
||||
own your commerce." The absence of the link is the recruiting signal.
|
||||
|
||||
**Why invitation-only? That limits growth.**
|
||||
On purpose, at launch (§7, "The membership gate phases"). A trust network cold-
|
||||
starts on *density*, not breadth — one community tight enough that word-of-mouth
|
||||
replaces a marketing budget. Scarcity keeps the trust guarantee absolute while the
|
||||
verified web is small, and makes every early member high-intent. The gate loosens
|
||||
toward open signup once roots and community density exist.
|
||||
|
||||
**As a buyer, why should I care?**
|
||||
Three things, in the order they matter (the buyer value prop, memo §13):
|
||||
- **You can finally trust what you're buying** — every item shows a provenance
|
||||
badge (original / partly original / resale), the thing Etsy can no longer tell
|
||||
you, and worth *more* as AI-generated and recast fakes proliferate. That's the
|
||||
floor under everything.
|
||||
- **You're a fan, not a shopper** — you follow makers and live for their
|
||||
drop/club/commission cadence. That relationship is what brings you back; it's what
|
||||
"commitment commerce" feels like from your side.
|
||||
- **The makers you trust introduce you to new ones** — discovery comes from people
|
||||
*you* chose to follow and the makers *they* vouch for, never an algorithm pushing
|
||||
whatever converts (§7, "The buyer-facing feed").
|
||||
|
||||
**As a buyer, where do I actually shop — is there an app, a site, a login?**
|
||||
Deliberately almost nowhere at first, and that's the point (§13). **At launch the platform
|
||||
is invisible to you:** you buy on the *maker's own storefront*, as a guest, the way you
|
||||
already do — there's no "Maker Collective" destination to sign up for, and "come back for
|
||||
the next drop" runs through the maker's *own* channels (their email, Instagram, Discord).
|
||||
The one thing the platform quietly powers is **follow/notify**, so you hear about the drops
|
||||
you care about. Later — Phase 2+, opt-in — a light **"your makers, in one place" feed**
|
||||
appears: followed makers' drops, "buy it again," and the Curated-By picks of makers you
|
||||
follow, with a light identity you can return to. But it's a *retention upgrade* on top of
|
||||
the makers' own channels, **pointedly not** a marketplace you evangelize ("I shop on X") —
|
||||
every item still traces to a maker *you* chose to follow, and the platform never becomes
|
||||
your primary relationship; the maker does. The mental model: **you're a fan of makers, and
|
||||
the platform is the wiring that keeps you connected to them**, not a store you shop at.
|
||||
|
||||
**I pre-ordered, and the maker never delivered. What protects me?**
|
||||
This is the *signature* risk of commitment commerce, not an edge case — the model collects
|
||||
money before delivery, so "a verified maker takes pre-orders/deposits and ghosts" is the
|
||||
structurally most-likely scam, and a PR-FAQ that skipped it would be dishonest (§10). Two
|
||||
straight answers. **First, the platform is not a guarantor.** The same out-of-the-money-
|
||||
flow design that keeps fees low means the network never holds your funds — so it has
|
||||
nothing to refund *from*; escrow was declined deliberately (holding buyer funds is exactly
|
||||
what triggers money-transmitter licensing — §11). Your monetary recourse is a
|
||||
**chargeback against the maker's own payment processor** (the maker is merchant of
|
||||
record), and the platform's compliance-by-design checkout **eases the maker's FTC 30-Day
|
||||
Rule duty** — prompting the delay notice and one-click cancel/refund when a window slips (the
|
||||
duty is the maker's; the platform doesn't assume it) — which is your first recourse *before*
|
||||
a chargeback (§11). **Second, the platform's contribution is
|
||||
consequence, not insurance.** Non-delivery drops the maker's standing: a **low,
|
||||
buyer-visible reputation score** and **loss of all network amplification** (Curated-By, the
|
||||
buyer feed, referrals, the agent feed). They keep their storefront, but they fall out of
|
||||
every surface that sends them buyers, and you — and every future buyer — can see the score.
|
||||
That's *transparency as enforcement* (§10): the network doesn't promise nobody ever
|
||||
behaves badly; it makes bad behavior legible and costly, and keeps the trusted surfaces
|
||||
clean by construction, since a low-standing maker has already dropped out of them.
|
||||
|
||||
---
|
||||
|
||||
### Part 2 — Strategy & build questions (for the technically-minded skeptic)
|
||||
|
||||
**The single most important design choice: why "stay out of the money flow"?**
|
||||
Because touching the buyer's money detonates three regulatory regimes at once, and
|
||||
*not* touching it discharges all three (§7, §11):
|
||||
- **Money-transmitter licensing (MTL).** In the US, holding customer funds (escrow,
|
||||
a balance, a payout you control) triggers state-by-state money-transmitter
|
||||
licenses — the single most expensive regime a small org could wander into. We
|
||||
never hold buyer funds, so: none.
|
||||
- **Marketplace-facilitator sales tax.** Post-*Wayfair*, states can force a
|
||||
"marketplace facilitator" to collect and remit sales tax — but the test is
|
||||
*conjunctive*: you must both (1) facilitate the listing **and** (2) collect the
|
||||
buyer's payment. We fail prong 2 by design (the maker's processor collects), so
|
||||
the duty doesn't attach.
|
||||
- **Merchant-of-record (MoR) liability.** The MoR is the legal seller — it owns
|
||||
chargebacks, refunds, and delivery liability. We make the *maker* MoR on their own
|
||||
processor, so all of that sits with them, not us.
|
||||
|
||||
The cost of this stance is forgoing payment take-rate — but that's exactly the
|
||||
slice that *carries* the risk. The cross-maker identity moat doesn't need checkout
|
||||
anyway: the asset lives at the **follow**, captured at the account layer regardless
|
||||
of whose processor runs the charge.
|
||||
|
||||
**For the technically inclined: where exactly is the line?**
|
||||
Custody. *"Coordination and bookkeeping are free; custody — funds resting in an
|
||||
account you control — is the line"* (the spine). We can record that maker A owes
|
||||
maker B, and even notify B that their component sold in A's kit — but routing a
|
||||
single dollar from A to B is custody. When we *do* eventually need cashable payouts
|
||||
(Phase 2), we rent a licensed transmitter (Stripe Connect/Treasury, Dwolla) rather
|
||||
than becoming one. One subtlety worth flagging to an engineer who'll wire Stripe:
|
||||
Connect's *charge type* and *account type* are **legal-posture switches, not
|
||||
implementation details** — Stripe's own tutorials default to "destination charges,"
|
||||
which silently flip you to merchant-of-record and into facilitator-tax territory.
|
||||
The stance holds *only* at Standard accounts + direct charges + `application_fee`
|
||||
(§7, "Money flow").
|
||||
|
||||
**"You own the buyer" — doesn't that collide with GDPR and CAN-SPAM?**
|
||||
No, because "own" means **usage-rights to a *consented* relationship**, not a license to
|
||||
message anyone (§7, §11). The mechanics: the network captures **email + per-type marketing
|
||||
consent at checkout** and pushes the subscriber to the **maker's own ESP**, which is the
|
||||
source of truth and owns everything after — compliant unsubscribe, suppression,
|
||||
deliverability. So what the maker owns is the right to market — *on any channel, on or off
|
||||
the network* — to buyers **who consented**, with the unsubscribe machinery a real ESP
|
||||
already enforces. That's the precise inverse of Etsy/Amazon (who forbid off-platform
|
||||
marketing because the captive buyer is *their* asset), and it's lawful *because* it's
|
||||
consent-first. Two more structural points a privacy reviewer will want:
|
||||
- **Two consent domains, strictly separate.** *Maker-originating* marketing lives in the
|
||||
maker's ESP (the maker is controller); *network-originating* communications (the buyer
|
||||
feed, follow notifications, aggregate digests) run on **network-communication consent the
|
||||
network owns** — and the network **never borrows a maker's ESP list** for its own sends.
|
||||
That separation is what keeps every personal-data flow attributable to a lawful basis and
|
||||
a controller.
|
||||
- **The cross-maker identity graph is opt-in by design.** Being recognized across makers is
|
||||
a *buyer* opt-in, not a default-share — so the architecture is already aligned with the
|
||||
strictest "no sharing without affirmative consent" reading rather than retrofitting
|
||||
opt-outs. The posture is **build to the strictest law** (CCPA/CPRA plus the newer state
|
||||
laws, and **GDPR the moment EU buyers appear**), with access/deletion and
|
||||
opt-out-of-sale/sharing built in. (Per the handbook, "consent" defers to the OHM *consent*
|
||||
RFC, not a local definition.)
|
||||
|
||||
**Isn't the referral "wallet" itself a regulatory problem — stored value?**
|
||||
No, by deliberate design (§11). The fee-offset wallet credits a maker against their **own
|
||||
future platform fees** — a *discount / accounts-receivable entry*, not a balance the maker
|
||||
can withdraw or spend with third parties. So it's neither stored value, a prepaid-access
|
||||
instrument, nor transmittable money, and stays outside the money-transmission and
|
||||
stored-value regimes *by construction*. The line is **cashing out:** the moment a credit
|
||||
becomes withdrawable cash, that's the deliberate crossing that re-opens the door — which is
|
||||
exactly why cashable payouts are **gated to Phase 2 behind a rented licensed transmitter**
|
||||
(Stripe Connect/Treasury, Dwolla), never a toggle flipped early. (Flagged for counsel:
|
||||
verify the non-cashable design against each state's stored-value / prepaid-access
|
||||
definitions before launch — *flag and verify, not legal advice*.)
|
||||
|
||||
**What's the actual moat? Can't a competent team rebuild this in a week with LLMs?**
|
||||
The memo's organizing lens (§1): cheap production destroys **stock moats**
|
||||
(accumulated build/features) and rewards **flow moats** (data, network, switching
|
||||
cost) that compound with use. The honest competitive read (§11, "Novelty"): *every
|
||||
component already exists* — cross-merchant inclusion, drop/pre-order tooling,
|
||||
affiliate networks, verification badges, non-profit governance. The novelty is the
|
||||
**specific combination**, scoped to one dense vertical: verified per-item provenance
|
||||
+ reputation-staked cross-maker referral + native commitment-commerce + non-profit
|
||||
governance. **The moat is positioning and governance, not patents** — community
|
||||
standing, the rooted trust graph, the no-walled-garden value rule, and a 501(c)(3)
|
||||
structure a commission-optimized incumbent *cannot* copy without betraying its own
|
||||
customers (§7, "Why this is the defensible core": Shopify won't build cross-merchant
|
||||
shared identity because its DTC merchants would experience it as theft).
|
||||
|
||||
**What if Shopify just adds native drops and pre-orders — doesn't the tool wedge evaporate?**
|
||||
The answer depends on splitting two things both loosely called "the storefront" (§1, §3,
|
||||
§7). The **commodity surface** — cart, catalog, checkout, customer accounts — we
|
||||
deliberately *don't* build; we rent it ("build the 20%, rent the 80%," on a headless
|
||||
backend like Medusa, or federate over the maker's existing Shopify). The
|
||||
**commitment-commerce engine** — scheduled drops, raffle/queue allocation, deposits, clubs,
|
||||
made-to-order — is the part we *do* build, and the honest claim isn't that it's *hard*: it's
|
||||
that **no continuous (non-campaign) platform treats it as a first-class citizen.** Outside the
|
||||
Kickstarter-likes the table-stakes simply aren't covered first-class anywhere, so makers bolt
|
||||
the cadence onto tools that treat it as an afterthought. Being its first-class home is also the
|
||||
on-ramp to something nobody else is positioned for — wiring in the emerging generative/LLM
|
||||
maker tools (e.g. **cuttle.xyz**) that campaign platforms and stock storefronts have no reason
|
||||
to integrate, *because* they never made the cadence first-class. (It carries real
|
||||
financial/delivery liability too — taking money before delivery — which the
|
||||
out-of-the-money-flow architecture handles; but the claim is **first-class + integration
|
||||
headroom**, not difficulty.)
|
||||
|
||||
But here's the part the doc won't dodge: **the tool is the wedge, not the moat.** The memo
|
||||
is explicit that storefront hosting isn't durably defensible — Medusa makes it cheap for
|
||||
everyone, and federation means a maker doesn't even *need* our storefront to be in the
|
||||
network (§7). The tool earns the cold-start (a real business at zero network liquidity) and
|
||||
gets makers in the door; the **durable** moat is the layer Shopify structurally *won't*
|
||||
build — the cross-maker **verified-provenance + reputation-staked curation network**, which
|
||||
its own DTC merchants would experience as theft (§3, §7, "the defensible core"). So if
|
||||
Shopify shipped excellent drops tomorrow it would neutralize a *convenience*, not the moat
|
||||
— the reason a maker stays is the network it can't copy without becoming a different
|
||||
company.
|
||||
|
||||
**What's the architecture, in one breath?**
|
||||
Two layers, kept strictly separate (§7, "Storefront architecture"):
|
||||
- **Storefront layer (per maker)** — either our white-label storefront (built on
|
||||
**Medusa**, a headless Node/TS commerce backend; we add drops/pre-orders/clubs as
|
||||
custom modules) or the maker's existing Shopify store, federated.
|
||||
- **Shared network service (cross-tenant — the moat)** — the verification graph, a
|
||||
**canonical catalog index**, the cross-maker follow/identity graph, the
|
||||
referral/fee ledger, the buyer feed, and the agent feed. A standalone service with
|
||||
its own datastore that federates over heterogeneous storefronts.
|
||||
|
||||
The discipline a technical reader will appreciate: the network catalog is a
|
||||
read-optimized **index** (a normalized, verified *projection* of products that live
|
||||
and sell elsewhere), **not** a "mega-store." Pouring every maker into one Medusa
|
||||
instance would build "a thing shaped like a store that must never behave like one"
|
||||
and couple the neutral network to one engine. Each storefront stays system of
|
||||
record; the network holds the projection. Attribution is **stateless** — a signed
|
||||
token (origin maker + item + expiry + nonce) carries the referral path, so it works
|
||||
for guests in Phase 1 with no identity layer.
|
||||
|
||||
**Why a non-profit built by volunteers? Isn't that fragile?**
|
||||
The structure converts the trust position *from a promise into a guarantee* (§7,
|
||||
"Entity structure"): a 501(c)(3) legally cannot be sold or distribute profits, which
|
||||
is the strongest possible answer to "will you sell our trust for GMV the way Etsy
|
||||
did?" Open books let the community verify the incorruptibility of "verified" rather
|
||||
than take it on faith. And it's viable for a thematically exact reason: **the cost
|
||||
center that usually makes non-profit tech infeasible — engineering — is the one
|
||||
LLMs just collapsed.** The honest risk (§4, §12): the danger moved, it didn't vanish.
|
||||
The failure mode of volunteer orgs is *sustaining*, not *building*. So the critical
|
||||
core (network service, ledger, verification) must be **funded, documented, and more
|
||||
than one person deep** — not bus-factor-one. The fragile-perception risk with
|
||||
professional makers is real and is something discovery explicitly tests (§8).
|
||||
|
||||
**What happens to *my* drop if the platform goes down at 9am Saturday?**
|
||||
The honest answer has an architectural half and a staffing half. **Architecturally, the
|
||||
blast radius is small for most makers:** you are merchant of record on *your own* processor,
|
||||
and if you federate your existing Shopify store your drop and checkout run on *Shopify's*
|
||||
infrastructure — so if our cross-tenant network service is down, what degrades is *network
|
||||
features* (Curated-By, the buyer feed, the agent feed), **not your ability to take the
|
||||
order.** The sale doesn't ride on the moat layer. **Operationally,** for a maker on our
|
||||
white-label storefront the drop *does* run on our infrastructure — which is exactly why
|
||||
network-service uptime, the money-adjacent ledger, and verification are the **funded,
|
||||
documented, more-than-one-deep reliability core** (§12), explicitly *not* volunteer
|
||||
best-effort and *not* bus-factor-one. Drops are spiky by nature, and "a storefront falling
|
||||
over *during* a drop is the worst possible moment for maker trust" (§7, Hosting) — so the
|
||||
spike is designed for (autoscaling infrastructure, load-checked before a real drop), and
|
||||
the on-call reliability of those pieces is a **budget line, not a hope.** What the doc won't
|
||||
pretend: this is the single point of failure §4 names, so the reliability core is bound with
|
||||
structure (funded + redundant), and the fragile-*perception* risk with professional makers
|
||||
is something discovery explicitly tests (§8).
|
||||
|
||||
**Who decides what "verified" means — and who watches the watchers?**
|
||||
Two answers, by time horizon (memo §14 #1 — direction set, mechanics deliberately
|
||||
deferred):
|
||||
- **The core is protected by structure, not by trust.** The trust guarantee,
|
||||
non-extraction, the no-walled-garden rule, and the out-of-flow stance are held by
|
||||
the non-profit and *entrenched* — a 501(c)(3) can't sell or distribute them, and
|
||||
they aren't editable by a simple majority. So the first answer to "who watches the
|
||||
watchers" is *the structure does*, and open books make it checkable.
|
||||
- **Authority over maker issues is progressively delegated as the network scales.**
|
||||
The non-profit can't (and shouldn't) adjudicate every verification call or maker
|
||||
dispute at scale, so authority over maker-facing standards moves to
|
||||
**representatives of the network** as it grows beyond what the non-profit can
|
||||
manage — a vision of **maker self-governance modeled on a functioning democracy**:
|
||||
members *elected* to network roles (resolving disputes among them), where holding a
|
||||
role well *earns standing* in the network — the same reputation currency as making
|
||||
and vouching well. Phased in the same start-closed-open-as-earned way as
|
||||
verification itself.
|
||||
- **The mechanics are deliberately unspecified for now.** Standing up a full
|
||||
governance apparatus before the community exists would be premature; the
|
||||
*direction* (progressive delegation, phased, capture-resistant) is set — the
|
||||
machinery is later work.
|
||||
|
||||
**What actually stops collusion — a ring of fake makers vouching each other in, or weaponized reports?**
|
||||
The highest-stakes surface in the system, because one polluted "verified" item breaks the
|
||||
guarantee for every buyer and agent downstream (§7, §10). The defenses are structural, not
|
||||
best-effort:
|
||||
- **The trust graph is rooted, never flat.** It is emphatically *not* "anyone verified can
|
||||
verify anyone." Every maker enters by invitation and traces back, by a chain of vouches,
|
||||
to a seed set Wiggleverse staff verified directly — a **permanent topology** that
|
||||
persists even after open signup arrives, so a compromised subtree can be found and
|
||||
revoked at its root.
|
||||
- **Inviting pays nothing.** There is deliberately **no per-invite bounty** — a payout
|
||||
would manufacture the exact Sybil/farming incentive the rooted graph exists to resist.
|
||||
You invite people whose work you'd stake your standing on, because that is the only thing
|
||||
the edge means.
|
||||
- **The vouch is a slashable stake, and consequence flows uphill.** When a maker
|
||||
misbehaves, consequence propagates **back toward whoever vouched for them** — transitively,
|
||||
**decayed per hop, and hop-capped**: strong right next to the misbehavior (the inviter who
|
||||
can actually act), negligible by ~6 degrees out (a distant root isn't punished for a
|
||||
great-great-invitee's fraud). A bad vouch costs the voucher standing; a good one compounds
|
||||
it.
|
||||
- **The consequence is loss of standing, not expulsion.** A bad actor keeps the storefront
|
||||
tool (a paying customer; the tool was never gated) but loses a buyer-visible score and
|
||||
all amplification. Authority is layered: the **inviter** holds primary suspend authority
|
||||
over their sub-graph, a **platform floor** lets staff act directly on active buyer harm
|
||||
regardless, and a **governance appeal path** (§14 #1) protects the wrongly-penalized.
|
||||
- **Sampling audits + buyer reporting** sit underneath — and the abuse surface of the
|
||||
reporting system *itself* (false reports, retaliatory scores, collusion rings) is named
|
||||
as instrumented from day one alongside ring-detection.
|
||||
|
||||
What's deliberately deferred (and marked so): the *reputation engine's* concrete mechanics
|
||||
— the scoring math, the decay-coefficient and hop-cap *values*, the benefit-gating
|
||||
thresholds, and the false-report/collusion controls — are explicit OHM-guided open work
|
||||
(§10, §14 #1), not claimed as solved. The *shape* is settled; the *values* are later work,
|
||||
because n=2 can't calibrate them yet.
|
||||
|
||||
**Why now?**
|
||||
This is the org-level [Wiggleverse thesis](https://wiggleverse.org/about/) ("the era
|
||||
of infinite alternatives") applied to maker commerce: every era commoditizes something
|
||||
— the internet commoditized knowledge, the cloud commoditized IT and then SaaS, and
|
||||
**LLMs are now commoditizing platforms themselves.** As that happens, the three moats
|
||||
incumbents stood on each turn into anchors — which is the opening:
|
||||
- **Build-cost / scale → anchor.** The engineering to run a platform at scale was moat
|
||||
#1; LLMs deflate it, which is the only reason a no-equity non-profit can credibly
|
||||
*build and sustain* this (memo §7/§12). (Headless commerce — "build the 20%, rent the
|
||||
80%" — is the same force at the storefront layer.)
|
||||
- **Vendor lock-in → dissolving.** Commoditized custom software makes migrating between
|
||||
platforms cheap and fast; the network's federation and data portability ride this.
|
||||
- **Network effect → fragmenting — the one that matters most here.** Our *own* moat is
|
||||
a network, so the obvious objection is that incumbents' network effects make them
|
||||
unassailable. The answer: incumbents got greedy and extractive and **eroded their own
|
||||
network stickiness**, so a values-aligned alternative can now contest a network moat
|
||||
that used to be untouchable. Etsy's reckoning is that erosion made concrete — its
|
||||
active-seller base fell from ~9M (2023) to ~5.6M as it purged for quality, while AI-generated and
|
||||
recast fakes flood marketplaces, leaving verified provenance scarcer, more valuable,
|
||||
and surrounded by disaffected makers to recruit.
|
||||
|
||||
Two maker-specific accelerants sit on top: **AI shopping agents** are arriving and need
|
||||
trustworthy supply they can't scrape (the window to be their verified maker-supply
|
||||
rails is open now), and the platform's out-of-flow, never-GMV-fee, you-own-your-buyer
|
||||
stance *is* the org's non-extraction ethic in commerce form.
|
||||
|
||||
**Why is this the Wiggleverse's first product — its beachhead?**
|
||||
Mind the overloaded word: *within* this product the launch community is tabletop
|
||||
miniatures, but the product *itself* is the beachhead for the whole
|
||||
[Wiggleverse](https://wiggleverse.org/) — its first product and the proving ground for
|
||||
the org's mission (ethical, non-extractive alternatives to extractive platforms). It's
|
||||
first for four reasons:
|
||||
- **Fastest honest path to self-sustenance.** Commerce is where money moves, so
|
||||
building close to it is the quickest route to a non-profit standing on its own feet
|
||||
([why ecomm first](https://wiggleverse.org/ecomm/)) — and a self-sustaining beachhead
|
||||
funds the rest of the portfolio (apps, learn).
|
||||
- **The most complete test of the thesis.** It exercises every org bet at once:
|
||||
non-extraction (the out-of-flow stance *is* "take only what it takes to run"), the
|
||||
network moat against eroding incumbents, OHM ethics made concrete (verification,
|
||||
provenance, usage-rights), and the open-core partner ecosystem. Prove it here and the
|
||||
rest is de-risked.
|
||||
- **The ethic, legible in dollars.** "Our fee + your processor ≈ 4–7% vs Etsy's ~20%" —
|
||||
the mission is a number on every sale, not a slogan.
|
||||
- **"Small businesses are really just people."** Serving makers directly is the mission
|
||||
— treat humans as humans — applied where commerce most turned them into accounts.
|
||||
|
||||
**How big is the opportunity (TAM)?**
|
||||
The honest unit of TAM here is **makers, not the dollar size of the craft market** —
|
||||
because the platform earns per-maker subscription + referral spread, never a cut of
|
||||
GMV. (The ~$0.8–1.2T global handicrafts market is backdrop, not a revenue base.) Sized
|
||||
properly:
|
||||
- **TAM — every independent maker who runs commit-then-make.** Because the network
|
||||
*federates* over existing storefronts, the addressable supply is the whole
|
||||
controllable-storefront + marketplace install base, not just switchers: Shopify
|
||||
reports ~4.8M active merchants, Etsy ~5.6M active sellers. The true TAM is the
|
||||
commit-then-make subset — millions of makers, not thousands.
|
||||
- **SAM — commit-then-make-native makers, reached community-by-community.** The model
|
||||
only works where you show up as a member, so it's summed over verticals. The
|
||||
tabletop-miniatures beachhead is a ~$3.8–4.2B/yr market growing ~7–10%/yr; Patreon's
|
||||
~286k paying creators is a proxy for the commit-then-make creator population, of
|
||||
which tabletop is one slice.
|
||||
- **SOM — deliberately parametric, not a "capture X% of $Y" number.** That top-down
|
||||
fiction is exactly what the memo's discipline refuses. Obtainable near-term scale is
|
||||
governed by the §12 break-even (`N* ≈ F/(m−v)`): clear the dozen-maker validation
|
||||
gate in one community, reach break-even density (~150–300 makers under illustrative
|
||||
midpoints), then compound vertical by vertical. The story is "reach self-sustaining
|
||||
density in one scene, then repeat" — not a slice of a giant pie.
|
||||
|
||||
*(Figures from Shopify/Etsy/Patreon public reporting, Marketplace Pulse, and tabletop
|
||||
market-research reports; sourced links in memo §12.)*
|
||||
|
||||
**Is it sustainable? How does a no-take-rate non-profit cover costs?**
|
||||
"Non-profit" changes who keeps a surplus (no one), not the arithmetic that revenue
|
||||
must meet cost (§12). It's a **fixed-cost-coverage** problem, not a margin problem:
|
||||
`N* ≈ F / (m − v)` — where F is the fixed reliability floor, m is per-maker net
|
||||
contribution (subscription + referral spread − drawn credits), and v is marginal
|
||||
per-maker cost. Two consequences fall out without needing real numbers: (1)
|
||||
break-even is driven by keeping F lean (the LLM-deflated-cost bet) and by makers
|
||||
*graduating and referring*, not merely by adding low-GMV makers; (2) there's also a
|
||||
*ceiling* — earn too much, too commercially, and a non-profit risks **UBIT**
|
||||
(Unrelated Business Income Tax) or its exemption. An illustrative pass (explicitly
|
||||
*shape, not validated values*, §12) puts the fixed reliability floor at **F ≈
|
||||
$75–150k/yr** — funded core ops + the fee/wallet ledger + the verification audit +
|
||||
hosting — and break-even around **~150–300 makers**, where the revenue mix has flipped from thin
|
||||
Starter percentages to Pro flat fees + referral spread (≈$170k/yr at ~200 makers under
|
||||
those midpoints), well past the **dozen-maker** validation gate — and naming that gap is the
|
||||
point.
|
||||
|
||||
The number a CFO will press on is F, so the doc is blunt about it: **F is not the cloud
|
||||
bill.** The seductive error is to model F as the pilot's tens-of-dollars-a-month GCP
|
||||
invoice; the honest F is dominated by **compensated, documented, more-than-one-deep
|
||||
ownership** of the reliability core — network-service uptime, the money-adjacent ledger,
|
||||
the verification audit — none of which can be best-effort. Under-modeling F is exactly how
|
||||
an org clears break-even *on paper* and still dies of bus-factor (§4). The
|
||||
LLM-deflated-cost bet is that F can be kept **lean, not that it's near-zero** — and the
|
||||
numbers stay variables because n=2 can't calibrate per-maker GMV, churn, or graduation rate
|
||||
yet.
|
||||
|
||||
**How will you know if it's working?**
|
||||
One **North Star: the share of GMV that is cross-maker-referred** (§12). It's near-
|
||||
zero for a pile of disconnected storefronts and rises *only* as the referral network
|
||||
does real work — so a "great tool that never becomes a network" (the most-feared
|
||||
outcome) shows a low North Star and can't hide behind a vanity supply count.
|
||||
Leading indicators beneath it: follower growth → Curated-By activation → drop
|
||||
sell-through → cross-maker repeat-buyer rate. All of it computes "for free" from the
|
||||
cross-merchant order history the referral ledger already requires — and a Goodhart
|
||||
guard applies: the metric must measure *earned* referral, not manufactured slots.
|
||||
|
||||
**How do you actually acquire buyers — and what's still unproven?**
|
||||
This is the keystone, and the memo now grapples with it directly in **§13** (it used
|
||||
to be an admitted gap). The honest mechanics:
|
||||
- **Buyers don't arrive at the platform — they arrive at makers.** The platform
|
||||
acquires no one directly; that's "maker-as-discovery-engine, platform-as-pipe." The
|
||||
first ~100 buyers are *activation, not acquisition* — the founding makers'
|
||||
**existing** audiences transacting on the new rails.
|
||||
- **The first ~1,000 come from compounding + supply** — buyers who follow maker A
|
||||
start following A's vouched makers (the cross-maker repeat that lifts the North
|
||||
Star), plus more makers onboarding, each bringing an audience. Word-of-mouth inside
|
||||
one tight community is the multiplier — which is *why* the beachhead is dense, not
|
||||
broad.
|
||||
- **Net-new demand is deliberately deferred, not hidden.** Early on the network
|
||||
*reshuffles* existing maker audiences rather than creating net-new buyers — the
|
||||
correct cold-start move, but not to be mistaken for solving acquisition. The
|
||||
net-new engines arrive later: **verified taste-makers** (community voices who bring
|
||||
their own audiences, Phase 2) and **AI shopping agents** (Phase 2+).
|
||||
- **What's still unproven: essentially all of it.** The §8 discovery interviews
|
||||
talked only to makers — and to *greenfield* makers with no audience, who by
|
||||
definition can't test "will fans follow them here." So the demand moat is the
|
||||
**least-validated** part of the whole thesis. The next step is a §8 extension that
|
||||
recruits *audience-having* makers and tests buyer behavior **behaviorally** (a real
|
||||
instrumented drop; does a referral from A actually convert A's buyers into
|
||||
followers of B?), not by survey. Until that runs, treat the buyer side as a
|
||||
reasoned plan, not a validated result — which is exactly how the memo frames it.
|
||||
|
||||
**What's the biggest risk?**
|
||||
**Demand** (§4, §13). "Etsy but handmade" is a graveyard (Goimagine, Artisans
|
||||
Cooperative, Folksy, Amazon Handmade…) because *curation fights liquidity*: these
|
||||
platforms recruit angry makers (supply) and die for lack of buyers (demand). Demand
|
||||
at scale must be *earned*, not bought; paid acquisition against Etsy/Amazon is the
|
||||
losing game. The thesis bets that demand can *emerge* from makers bringing their own
|
||||
audiences and vouching for each other — but that's the **unvalidated keystone**, and
|
||||
it's gated on discovery (§8): do enough audience-having, vouch-willing makers exist
|
||||
in one tight community? Everything is downstream of that question.
|
||||
|
||||
**What's the falsifiable hypothesis here — and what would make you kill it?**
|
||||
The thesis rests on one keystone, stated to be falsifiable, not asserted: **demand can be
|
||||
*earned* — makers bring their own audiences and vouch for each other — rather than bought**
|
||||
(§4, §13). The go/no-go is concrete (§9 decision gates), and a failed gate *stops the build*,
|
||||
not just dings it:
|
||||
- **The dozen-maker gate.** Can you reach ~a dozen makers in one tight community who feel the
|
||||
*same* commit-then-make pain *and* refer you onward? Two makers justify a portable data
|
||||
model; they do **not** justify building the platform. If the dozen doesn't cohere, you
|
||||
don't build — full stop.
|
||||
- **The behavioral demand test (the decisive one).** Discovery so far talked only to makers —
|
||||
and *greenfield* ones with no audience. So before the platform is built, run a **real
|
||||
instrumented drop** and measure whether a referral from maker A actually converts A's
|
||||
buyers into followers of B. If that propagation doesn't happen, the network thesis is
|
||||
falsified at the cheapest possible point.
|
||||
- **The North Star as a live kill-signal.** Post-launch the one number is **the share of GMV
|
||||
that is cross-maker-referred** (§12) — near-zero for a pile of disconnected storefronts,
|
||||
rising *only* if the network does real work. A persistently low North Star is the explicit
|
||||
failure mode ("a great tool that never becomes a network"), and it can't hide behind a
|
||||
vanity supply count.
|
||||
The honest posture: **the tool is a real business even if the network never lights** — so
|
||||
failure is survivable, not ruinous — but the *network*, the actual moat, is gated on a
|
||||
falsifiable demand test the org commits to running *before* betting on it.
|
||||
|
||||
**What's the sequencing? You keep saying "don't launch a marketplace."**
|
||||
Four acts, each viable alone, each earning the next (§5): **Tool** (the
|
||||
commitment-commerce engine — a real business at zero network liquidity) → **Community**
|
||||
(narrow to one beachhead, add curated discovery) → **Marketplace** (light the
|
||||
referral network once supply density + community exist, so it *emerges* rather than
|
||||
launching cold into the graveyard) → **Infrastructure** (expose the verified-supply
|
||||
graph to AI shopping agents as trustworthy rails). The phasing of money is parallel:
|
||||
Phase 1 stays entirely out of the flow (stateless referrals, non-cashable wallet);
|
||||
Phase 2 adds shared identity and *one* rented cashable payout rail; Phase 3 (much
|
||||
later, opt-in) is the only point shared checkout — and the money-flow question —
|
||||
returns.
|
||||
|
||||
**Why is "agent-ready rails" in here?**
|
||||
Longer term, the verified-supply graph becomes the structured, real-time, *trustworthy*
|
||||
supply layer AI shopping agents need and can't manufacture by scraping (§3). That
|
||||
repositions the moat from "win consumer eyeballs" (unwinnable for a newcomer) to "be
|
||||
the verified maker-supply layer agents route through." Agent inclusion is gated on
|
||||
*verification* (not network membership) and defaults on for verified makers, because
|
||||
agent sales route back through the maker as MoR — net-new demand with no sovereignty
|
||||
cost. It's the hedge against the buyer feed's deliberate weakness at net-new reach.
|
||||
|
||||
**What got deliberately left out of this PR-FAQ?**
|
||||
The memo's full depth on trust-&-safety/accountability (§10), the complete
|
||||
legal/compliance analysis (§11), composite multi-maker kits (Appendix C), the
|
||||
beachhead-selection method and worked example — miniatures → dice → broad tabletop
|
||||
(Appendix A), and the crowdfunding-incumbent landscape (Appendix B). Also **honestly
|
||||
unfinished** and tracked as open work in the memo's backlog (§14): the **international
|
||||
tax / cross-border** posture (the legal analysis is US-only today, under a digital-heavy
|
||||
global beachhead), **information security & breach posture** for the cross-tenant graph,
|
||||
**content moderation beyond authenticity** (third-party IP / DMCA), the **verification
|
||||
*methodology*** (how a verifier actually confirms original work), **support/dispute
|
||||
operations** as a funded function, and a head-to-head against the **creator-commerce
|
||||
tools** (Gumroad/Payhip/Ko-fi/Fourthwall). A technical reader who wants the real
|
||||
architecture — and the honest open edges — should read the
|
||||
[strategy memo](./maker-platform-strategy.md) directly; this document is the
|
||||
elevator version, not a replacement.
|
||||
@@ -0,0 +1,807 @@
|
||||
# Maker Platform — Strategy & Architecture
|
||||
|
||||
A working memo distilling the thinking so far. It serves makers in general, but launches into one dense community first (the *beachhead*, §2/§5). The aim is to keep two doors open — a paid consultancy that's real today, and an option on a larger network play — without over-committing to the second before the evidence justifies it.
|
||||
|
||||
> **Grounded in the Open Human Model.** Every principle in this memo — *trust, consent, reputation, harm, recourse, value, agency, dignity* — is meant to rest on the shared definitions in the [**Open Human Model (OHM)**](https://rfc.wiggleverse.org/p/ohm/c/default/), Wiggleverse's version-controlled dictionary for the words that systems acting on behalf of humans turn on. Where this memo and an OHM RFC disagree, **the RFC is canonical**; where a load-bearing concept here isn't yet defined in OHM, defining it is itself OHM work (not license to coin a local meaning). The reputation/trust model below is the first place this bites — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 1. The lens: stock moats vs. flow moats
|
||||
|
||||
The organizing idea behind everything below.
|
||||
|
||||
- **Stock moat** = accumulated build, features, or content — a one-time lead. As production cost collapses (cloud did this to infrastructure; LLMs are now doing it to software production), stock moats trend toward zero, and any *new* stock advantage is matched in weeks.
|
||||
- **Flow moat** = data, network, switching cost, regulation, embedded workflow. It compounds with every customer and every day of use, and cheap production can't shortcut its accumulation.
|
||||
|
||||
The diagnostic for any product: *if a competent small team could reproduce the functionality in a week with LLM assistance, what does the customer still pay for?* Whatever survives is the real moat; if nothing survives, build-cost was the whole moat — the exposed position.
|
||||
|
||||
Cheap production lowers cost **symmetrically** — for every entrant at once — so it never picks winners. It commoditizes the build layer and pushes competition *up* to the moats it can't erode. Don't pick a fight where build-cost was going to be your only moat.
|
||||
|
||||
---
|
||||
|
||||
## 2. The thesis
|
||||
|
||||
Build for **independent makers** — positioned as "the curated maker marketplace before resellers polluted it," but executed so the durable value is flow, not stock.
|
||||
|
||||
**The core primitive: commitment commerce.** The entire "gnarly 20%" that generic tools serve badly is one mechanism wearing many costumes. Pre-order, raffle, drop, monthly club, made-to-order commission, deposit-and-waitlist — every one is *collect committed demand before production, then make against it.* That's the inverse of Shopify's *stock-then-sell* (make it, shelve it, someone buys it), which is exactly why Shopify and generic tools serve makers badly: **many** makers run on *commit-then-make*. The category you're building is **commitment commerce**, not storefront commerce — the drop/pre-order/club/commission cadence is the engine, and the storefront is just its surface. (The platform still handles ordinary *stock-then-sell* too — it is a full storefront — so a maker can run their whole catalog on it; the positioning is *commit-then-make, not **just** stock-then-sell* — both modes, with commit-then-make the differentiator and the gnarly part nobody else serves, never the platform's only mode.)
|
||||
|
||||
Two halves, treated differently:
|
||||
|
||||
- **Commitment-commerce engine (the real product).** The maker-native cadence layer Shopify lacks: scheduled drops, pre-orders and deposits, raffle/queue allocation, made-to-order workflows, recurring clubs/memberships, digital-file delivery + licensing where relevant, and variant/bundle handling. This *is* the gnarly 20% and the reason vertical infrastructure here is defensible — emphatically *not* "the same as Shopify." The defensibility isn't that the cadence is *hard* to build — it's that **no continuous (non-campaign) platform treats it as a first-class citizen**, so the table-stakes go uncovered everywhere outside the Kickstarter-likes (Appendix B); being its first-class home is also the on-ramp to integrating emerging generative/LLM maker tools (e.g. **cuttle.xyz**) that campaign platforms and stock storefronts have no reason to touch. (The white-label storefront is the commodity surface, subsumed here: build the least of it you can, rent the rest.)
|
||||
- **Cross-maker demand network (the moat).** The flow asset, accruing as a *byproduct* of the engine. Every drop/pre-order captures a buyer who *follows* a maker and commits early; a base of drop-followers across many makers is the embryonic cross-merchant identity network — the niche-scoped Shop Pay equivalent. Stock commerce gives you people who bought once; commitment commerce gives you people who *wait for* makers and commit ahead — a far stronger flow asset. Reputation-staked cross-maker curation turns that into *earned* demand, not just relocated demand.
|
||||
|
||||
The reframe: commitment commerce is not a feature of the storefront — it *is* the platform, and the mechanism that builds the flow moat, rather than something engineered separately alongside it.
|
||||
|
||||
**The beachhead principle.** "Makers in general" is the eventual market, but a network cold-start needs *density* — so launch in one craft community tight enough that word-of-mouth substitutes for a marketing budget, and where you show up as a *member, not a vendor*. Expand to adjacent communities only after the first compounds. The vertical-selection method and a worked candidate (miniatures → dice → broad tabletop) are in Appendix A.
|
||||
|
||||
**Business model (detail in §7):** the **network is the product and the value capture** (referral take + network subscription, charged identically whether a maker is on your storefront or Shopify); the **storefront is an optional, SaaS-priced convenience — never GMV-fee'd, never sold as hosting.** You are a *vertical commitment-commerce product*, not a hosting company; a partner/consultant network onboards the high-touch tail.
|
||||
|
||||
**Why now — the Wiggleverse thesis, applied to maker commerce.** The timing argument here isn't particular to makers; it is the org-level [Wiggleverse thesis](https://wiggleverse.org/about/) — *"the era of infinite alternatives"* — specialized to one vertical. Every era commoditizes something: the internet commoditized knowledge, the cloud commoditized IT infrastructure and then small SaaS, and **LLMs are now commoditizing the platforms themselves.** As they do, the three moats incumbents were built on each turn into *anchors* — and that is precisely the opening:
|
||||
|
||||
- **Build-cost / scale → anchor.** The manpower and capital to run software at platform scale was the first moat; LLMs deflate it (the §1 stock-moat lens), which is exactly what makes a volunteer/non-profit build viable here (§7, §12) — the entity and the opportunity are the same bet. (Headless commerce maturing — *build the 20%, rent the 80%*, §6 — is the same force at the storefront layer.)
|
||||
- **Vendor lock-in → dissolving.** Commoditized custom software makes migrating off a platform cheap and fast; you no longer wait for a vendor's export tools. The memo's federation, data portability, and ESP-as-source-of-truth (§7) ride this directly.
|
||||
- **Network effect → fragmenting — and this is the one that matters most here.** The memo's *own* moat is a network (§2), so the obvious objection is that incumbents' network effects make them unassailable — the §4 graveyard. The Wiggleverse answer: incumbents got **greedy and extractive and eroded their own network stickiness**, so a values-aligned alternative can now contest a network moat that used to be untouchable. (No contradiction with §1, where flow moats compound: a flow moat compounds only while it is *stewarded* — the incumbents spent theirs.) Etsy's authenticity reckoning *is* that erosion made concrete — its active-seller base fell from ~9M to ~5.6M as it purged for quality (§12 sizing) while AI-generated and recast fakes flood the marketplaces, so verified provenance is at once scarcer, more valuable, and surrounded by freshly-disaffected, recruitable supply (§3).
|
||||
|
||||
Two maker-commerce-specific accelerants sit on top of the org thesis: **AI shopping agents are standing up** and need structured, trustworthy, real-time supply they cannot scrape — the narrow window to be the verified maker-supply rails agents route through opens now (§3, §7); and the same **non-extraction ethic** the org is built on (give value, don't extract — an OHM principle) is precisely what the out-of-the-money-flow, never-GMV-fee, usage-rights-ownership stance (§7) *is*, in maker-commerce form. Miss the window and the provenance grievance normalizes, the agent rails get built by someone *in* the flow, and the build-cost advantage commoditizes for everyone at once (§1).
|
||||
|
||||
**Why this product is the Wiggleverse's beachhead.** Two nested beachheads are in play and shouldn't be confused: within *this* product, the launch *community* is miniatures (§5, Appendix A); but the product *itself* is the **beachhead for the whole [Wiggleverse](https://wiggleverse.org/) portfolio** — its first product, the proving ground for the org-level mission of ethical, non-extractive alternatives to extractive platforms. It earns that role on four counts:
|
||||
|
||||
- **The fastest honest path to standing on its own feet.** Running a non-profit and the infrastructure under a platform takes money; commerce is where money moves most, so building close to commerce is the quickest route to a sustainable economic position ([Wiggleverse — why ecomm first](https://wiggleverse.org/ecomm/)). A self-sustaining beachhead is also what funds the rest of the portfolio (the *apps* and *learn* products to come) — the §12 economics read at org scale.
|
||||
- **The hardest, most complete test of the thesis.** This product exercises *every* load-bearing Wiggleverse bet at once: **non-extraction** (the out-of-flow, never-GMV-fee stance, §7/§11, *is* the org's "take only what it takes to run"), the **flow/network moat** against eroding incumbents (the §2/§4 thesis and the three moats above), **OHM ethics made concrete** (consent, agency, dignity, value, recourse show up as verification, provenance, and usage-rights — §7/§10/§11), and the **open-core partner ecosystem** (§7 partner network). Prove it here and the rest of the portfolio is de-risked.
|
||||
- **The most tangible "give value, not extract" demonstration.** Commerce makes the ethic legible in dollars — *our fee + your processor ≈ 4–7% vs Etsy's ~20%* (§7) — so the mission is a number on every sale, not a slogan ("what is enough? — enough to keep the lights on, and no more").
|
||||
- **"Small businesses are really just people."** Serving independent makers directly *is* the OHM mission — *treat humans as humans* — applied to the place commerce had most thoroughly turned them into accounts and line items.
|
||||
|
||||
---
|
||||
|
||||
## 3. Positioning: what's yours vs. what's commodity
|
||||
|
||||
The cross-merchant inclusion mechanism already exists and is commoditized — Shopify Collective, Carro, and Shopify's Product Network all let merchants sell each other's products. Feasibility is de-risked, but it means **you cannot win on the plumbing.** If the product is "Collective for makers," Shopify extends Collective and you're gone.
|
||||
|
||||
Your differentiation is the layer the commission-optimized networks structurally can't have:
|
||||
|
||||
- **Verified provenance.** "Actually handmade/made by this person" is the trust signal buyers and AI agents can't get elsewhere, and it *appreciates* as AI-generated and drop-shipped fakes proliferate (Etsy is being flooded now). This is flow; the aggregation tech is stock.
|
||||
- **Reputation-staked curation, not pay-for-placement.** The central danger: commission-driven inclusion drifts curation from "what's genuinely good" to "what converts and pays," rebuilding Etsy's pollution from inside, laundered through trusted faces. Inclusion must be an editorial/social act with the curating maker's standing on the line.
|
||||
- **Community standing.** The demand problem is a community problem, and community is founded, not acquired.
|
||||
|
||||
Longer term, the verified-supply graph becomes **agent-ready rails** — the trustworthy, structured, real-time-inventory supply layer AI shopping agents need and can't manufacture by scraping. That repositions the moat from "win consumer eyeballs" (unwinnable for a newcomer) to "be the verified maker-supply layer agents route through."
|
||||
|
||||
---
|
||||
|
||||
## 4. Honest risks and hard truths
|
||||
|
||||
- **The graveyard is real.** Goimagine, Artisans Cooperative, Folksy, Storenvy, Amazon Handmade — "Etsy but actually handmade" has been built many times and stays small. The reason: **curation fights liquidity**, and curation is a *seller-side* value proposition. These platforms recruit angry makers (supply) and die for lack of buyers (demand). Etsy's drift into mass-produced goods wasn't betrayal — it was the gravity of GMV growth.
|
||||
- **No demand advantage yet — the binding constraint.** Demand at marketplace scale must be *earned*, not bought; paid acquisition against Etsy, Amazon, and agents is the losing game the graveyard played. Early on the network *pools existing maker audiences* (reshuffle), it doesn't create net-new buyers — the correct cold-start move, but don't mistake it for solving acquisition.
|
||||
- **Buyer-ownership tension.** Some makers guard their customer lists and will resist shared cross-merchant identity. If they want the cross-promotion demand but not the shared identity, the network's conversion advantage is capped. Surface early (and see the opt-in design in §7).
|
||||
- **n = 2 is learning, not validation.** The engineer's trap is over-abstracting for two clients when the right abstraction only reveals itself around ten.
|
||||
- **Commitment commerce carries financial/delivery liability.** Taking money before delivery inherits structural delivery risk — chargebacks, non-delivery, makers who collect pre-orders and don't ship. This is *why* the 20% is gnarly (financial risk, not just UX). The design answer (§7): stay out of the money flow so the maker, as merchant of record, carries it.
|
||||
- **Raffle/lottery legality.** The raffle drop mechanic can be regulated as a lottery/gambling depending on jurisdiction — the one primitive with real compliance exposure. Understand it *before* building it as a headline feature.
|
||||
- **Campaign vs. cadence is a product fork — pick cadence.** Episodic, project-scale crowdfunding is owned by entrenched incumbents (Kickstarter, Gamefound, BackerKit — Appendix B). The unserved gap is the *continuous* drop cadence (the biweekly drop, the monthly club). Don't drift into competing with Gamefound; own the continuous-relationship layer they don't serve. (One continuous-cadence slice *does* have an incumbent — the monthly club, owned by **Patreon**; the next bullet faces it.)
|
||||
- **Your beachhead lives on a walled garden you're recruiting them off of.** In miniatures, the de-facto club infrastructure is **Patreon** — the *membership-side* walled garden (Appendix D) and the commitment-commerce incumbent for the recurring-club primitive (Appendix B). It is a *sharper* competitor than Shopify for that slice — a club is already commit-then-make, not Shopify's stock-then-sell — and unavoidable, because your first community is already on it. The posture it forces — **recruit-out + replace-the-club (native, out-of-flow, maker-MoR) + lift-the-patron-list** — is therefore materially more load-bearing than the memo's posture toward Etsy, which is otherwise the lead walled-garden example.
|
||||
- **Volunteer-core sustainability is the single point of failure** (given the non-profit/volunteer model, §7). LLMs make a smaller core go further but don't fix volunteer attrition or bus-factor. The parts touching money, catalog data, and verification need *reliability*, not best-effort — so the critical core (network service, ledger, verification) must not be bus-factor-one. The historical killer of volunteer orgs is *sustaining*, not building; transfer the rigor you'd spend on fundraising onto this.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing: earn into the marketplace, don't launch one
|
||||
|
||||
Four acts, each viable on its own, each earning the right to the next. No stage depends on demand you can't yet generate.
|
||||
|
||||
1. **Tool.** The verified-maker commitment-commerce engine with a white-label storefront as its surface — genuinely the best home for a real maker. Useful at zero liquidity. Accrues supply, a structured provenance-stamped catalog, *and* a base of drop-followers. *A real business even if the network never lights.*
|
||||
2. **Community.** Narrow to one craft scene tight enough that word-of-mouth substitutes for a marketing budget (the beachhead). Add cross-maker discovery with an editorial point of view — the demand-generating feature you build rather than buy.
|
||||
3. **Marketplace.** Light the curated cross-inclusion network once supply density and community exist — it *emerges* from accumulated follows (§7), rather than launching cold into the graveyard.
|
||||
4. **Infrastructure.** Expose the verified-supply graph to agents; become the rails.
|
||||
|
||||
**Selection criterion that decides everything:** pick the community where you show up as a *member, not a vendor* — where you already have, or can authentically build, real standing.
|
||||
|
||||
---
|
||||
|
||||
## 6. The immediate engagement (the first makers)
|
||||
|
||||
A good investment **if** you build the right common infra. The wasted version is a generic storefront engine ("the same as Shopify") — the stock layer, valueless in every branch.
|
||||
|
||||
The reusable assets worth owning, none of which is a storefront builder:
|
||||
|
||||
1. **A clean, portable, verified catalog data model that you own.** Cheap to do well now, expensive to retrofit later.
|
||||
2. **The commitment-commerce primitives (the engine)** — scheduled drops, pre-orders/deposits, raffle/queue allocation, recurring clubs/memberships, made-to-order workflows, digital delivery + licensing, variant/bundle handling. The portable, vertical-spanning core; the storefront is just its surface.
|
||||
3. **Standing in the beachhead community** and the first reference relationships.
|
||||
|
||||
**Operating rule: build the maker-shaped 20%; rent the 80%.** Use a template, headless setup, or even Shopify itself for the commodity storefront parts. Let this be paid consultancy whose real return is knowledge, references, and a portable data asset — a cheap option on the bigger play.
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
The money-flow stance and the first network feature turn out to be the same decision viewed twice; the rest of the section builds out from there.
|
||||
|
||||
### Money flow: stay out of it (the "no")
|
||||
|
||||
The white-label model enforces this: each storefront is the maker's brand on the maker's own payment processor, so **the maker is merchant of record by construction.** The money never touches you — you sell software, they run commerce. This is *cleaner than Kickstarter*, which is itself in the money flow (processes pledges, takes a cut, pays out) and only disclaims *delivery* liability; here you avoid both the flow and the delivery liability structurally.
|
||||
|
||||
**Why staying out is right:** no money-transmitter/escrow licensing (holding customer funds triggers state-by-state MTL in the US); no chargeback exposure (it hits the maker's processor, not you); no delivery-risk balance-sheet exposure; clean SaaS margins and a faster launch; and it's maker-aligned (their brand, processor, payouts, customer, data). The cost is forgoing payment take-rate — but that's exactly the slice that carries the risk you're declining, a fair, deliberate trade. The cross-maker identity moat doesn't need checkout anyway: **the asset lives at the *follow*** ("who follows which makers and commits early"), captured at the account / "notify me" / club layer you own regardless of whose processor runs the charge.
|
||||
|
||||
If you ever want transaction economics without the MTL burden, **Stripe Connect** is the middle path — but it is a *dial, not a single choice*, and only one setting preserves the stance above. Two independent knobs decide who is merchant of record and who eats the risk:
|
||||
|
||||
- **Charge type.** *Direct charges* keep the **maker** as MoR — disputes and chargebacks hit the maker's balance, the maker pays the processing fees. *Destination charges* and *separate-charges-and-transfers* make the **platform** MoR — your balance is auto-debited for disputes, and you are now indirectly collecting and transmitting the buyer's payment, i.e. a **marketplace facilitator** for sales-tax purposes (§11).
|
||||
- **Account type.** *Standard* accounts leave negative-balance and dispute liability with the maker. *Express/Custom* push it onto the **platform**, with Stripe holding reserves against *you* for connected-account shortfalls.
|
||||
|
||||
The stance holds **only at Standard accounts + direct charges + `application_fee`**: the maker stays MoR, disputes stay on the maker's balance, you skim a fee without entering the flow. The trap is that Stripe's own marketplace tutorials default to *destination* charges (the canonical "marketplace" pattern), which silently flips every one of those properties — the payments-layer cousin of the "buy through their store" creep this memo warns about elsewhere. So **the Connect configuration is a legal-posture constraint, not an implementation detail.** The default money model remains the out-of-flow ACH/invoice fee below; Connect is reserved for the deliberate later crossings (cashable wallets, kit/taste-maker payouts), and even there uses the arm's-length setting. *(Verify per account type and per state with a payments/SALT attorney before architecting. Not legal advice.)*
|
||||
|
||||
### Unified phasing
|
||||
|
||||
One phasing governs the whole build; everything below references these stages.
|
||||
|
||||
- **Phase 1 — tool + stateless referrals (out of money flow).** White-label storefront on the maker's own processor; Curated-By via stateless signed tokens; non-cashable fee-offset wallet. No shared identity, no consent complexity, no money movement. A real consultancy/tool business on its own.
|
||||
- **Phase 2 — shared identity + the cashable-payout rail.** Shared auth + follow relationships + the consent-gated cross-maker graph (the durable network effects: follows, cross-session credit, niche-scoped recognition/conversion lift). Phase 2 also lands **one** cashable payout rail (Connect / mass-pay) that simultaneously unlocks cashable maker wallets, kit pure-supplier payouts (Appendix C), and the verified taste-maker tier — three consumers of a single scoped crossing. Optional Connect `application_fee` for transaction economics here too.
|
||||
- **Phase 2+ — buyer-facing feed.** The consumer surface where the marketplace *emerges* from accumulated follows (below).
|
||||
- **Phase 3 (much later, optional, opt-in) — shared checkout.** The only point the money-flow question returns — chosen by makers because it converts better, never forced.
|
||||
|
||||
### "Curated By This Maker" — the first network feature
|
||||
|
||||
Each maker's storefront carries a **"Curated By This Maker"** section featuring other *verified* makers' products they genuinely admire. It's the demand network as a concrete, shippable surface — and it works at **n = 2**, the holy grail for a network feature.
|
||||
|
||||
**What this is — and the name matters: a verified merchant referral network, *not* retail media.** The two look similar (a curated set of products with outbound links and attribution) but encode *opposite governance*: retail media is **paid placement** (highest payer wins the slot), while a verified merchant referral network is **reputation-staked vouching** (placement is earned; the curating maker's standing is on the line). The name is a structural guardrail — a thing called a *verified merchant referral network* can't quietly start selling slots without becoming a self-evident lie. Never frame or price it as retail media; that framing's gravity is toward selling placement, rebuilding Etsy's pollution through trusted faces.
|
||||
|
||||
**Targets are verified makers on controllable storefronts ONLY — never walled-garden listings.** The network never points its curation, buyer feed, or agent feed *outward* at an Etsy/Amazon/eBay listing. This is a **value-system rule, not a technical limit**: every such link would lend the community's trust and a maker's audience to the captive-commerce model this enterprise is an alternative to. A walled-garden-only maker is an **invitation target, not a curation/destination target** — "I'd feature your work the moment you own your commerce" is the pitch, and the *absence* of a link is the recruiting signal. (It also sharpens the moat: when every target is a verified controllable-storefront member, the "sincere vouch vs. paid slot" gray zone disappears.) Full participation matrix in Appendix D.
|
||||
|
||||
**The fork — what "buy" means:**
|
||||
|
||||
- **(A) Referral / handoff — build this.** Checkout hands off to maker B's own store and processor. A vouches, B fulfills and is paid, A optionally earns a referral credit. Money stays siloed; A is never merchant of record for B's goods. Out of the money flow.
|
||||
- **(B) Consignment / resale — avoid.** The buyer checks out on A's store for B's product; A becomes MoR for B's goods and owes B a payout — recreating the delivery-and-payout liability you designed out (mechanically what Shopify Collective is, and why it needs Shopify Payments in the middle).
|
||||
|
||||
Model A is reputation-staked human curation (the anti-pollution, can't-be-gamed kind that *is* the moat), it rides each maker's existing traffic, and it's structurally aligned (A only features B if A rates B's work). Watch the phrase **"buy through their store"** in any spec — it's where (B) creeps back via a unified-cart "convenience" that drags you to merchant-of-record. Hold the line at handoff until a shared *opt-in* checkout is a deliberate Phase-3 decision.
|
||||
|
||||
**Guardrails:**
|
||||
|
||||
- **Attribution is stateless — no identity layer required.** A signed referral token (origin maker + item + expiry + nonce) rides the A→B handoff into B's order; you read it to bill B and credit A. It tracks the *referral path*, not the buyer, so it works in Phase 1 for guests and sovereign makers. Persistent identity is only for the *durable* effects (follows, cross-session credit).
|
||||
- **Commission-corruption trap.** When Curated-By pays well, curation drifts to "feature whoever converts/pays most." Keep it reputation-staked: **B must approve being curated by A** (per-relationship opt-in — placement is consented on both sides, distinct from the default-on *catalog presence* switch below), cap how much any maker can feature, make it visibly personal (name + face), prefer "A owns/uses this," and never sell slots. The sincere vouch is the whole value.
|
||||
- **Ranking neutrality — structural, not a promise.** Among the items a maker curates, the *platform* chooses which to surface to a given buyer, and it **will** use algorithms (personalization, popularity) to lift conversions — that's the platform's job. The guarantee is that **referral economics are never an input to that ranking**, and it holds *by construction*: the platform's spread is the **same percentage no matter which referred item sells** ("Referral economics" below), so the platform has no incentive to favor a higher-paying referral. Structural indifference is a *stronger* guarantee than a flat uniform rate — it survives makers negotiating their own rewards.
|
||||
- **Complementary, not substitute.** Nudge toward complementary makers (a maker surfaces an adjacent craft, not a direct rival) — complementary curation is generative; substitute curation is cannibalistic and makers won't do it.
|
||||
|
||||
### How the platform gets paid
|
||||
|
||||
Out of the money flow, your fee is a **platform fee billed in arrears via ACH/invoice**, explicitly separate from processing (the comparable is Checkout Page: $29/mo, 0% on top of the maker's own Stripe).
|
||||
|
||||
- **Tiered "graduate" pricing.** Starter: $0/low monthly + a modest **percentage** (≈2–4%) on captured orders (cold-start-friendly). Pro: flat **$29–49/mo + 0%** (predictable, easy to collect, at scale). Auto-graduate by GMV so makers don't overpay. Use a **percentage, not a per-order flat fee**, on low-AOV makers (a flat $0.50 over-taxes a $12 item).
|
||||
- **Charge on captured/fulfilled orders, not pledges** — don't bill money that never cleared.
|
||||
- **Market the all-in transparently:** "our fee + your own processor ≈ X% vs Etsy's ~20%." Not bundling processing is the wedge — say it in numbers. Illustratively (shape, not validated rates): a Starter maker at ~$1,200/mo runs ~2–4% platform + ~3% processor ≈ **5–7%** all-in; a Pro maker at ~$6,000/mo runs $39/mo + ~3% processor ≈ **~3.6%** all-in — versus Etsy's ~20% either way, i.e. the maker keeps roughly **13–16 percentage points more of every sale.**
|
||||
|
||||
**Referral economics.** On a referred order, two layers stack. **The platform's spread is fixed and never negotiated** — ≈3–5% of the order (it may scale with the sale and carry a $ cap), the network's one piece of referral revenue, and *the same percentage regardless of which referred item sells* — which is what makes the ranking-neutrality guarantee structural rather than a promise ("Curated By This Maker" above). **Maker A's reward sits above the spread and is the makers' to set:** A and B negotiate it through platform tooling — a flat %, a tiered rate ("x% over $y"), a max-$ cap — and may **renegotiate** as the relationship evolves, with one floor, the spread. So B always pays *at least* the spread; set A's reward to zero and no referral money changes hands while the platform still earns its spread. (This negotiability is **scoped to maker↔maker** referrals; the pure non-maker taste-maker tier keeps **uniform, non-biddable** rates — it lacks the peer-respect counterweight — see "Non-maker referrers" below.) Keep the legs as **two separate events** — B→Platform (a fee on B's invoice) and Platform→A (a credit you extend) — so you're **principal on both sides, never a conduit** moving money B→A. That independence keeps it out of money-transmission territory, and holds *only* while A's credit is non-cashable.
|
||||
|
||||
- The referred order's total **subsumes** the standard fee (one clean "this is the referred rate"), rather than stacking toward Etsy-like ~19%.
|
||||
- **The fixed spread is the network's referral revenue line** (≈3–5%, $-capped) — not negotiable, and the *same percentage whatever is referred*; A's negotiated reward is the layer on top, non-cashable (below). One of the few places the network itself generates revenue.
|
||||
- **Non-cashable fee-offset wallet.** A's credit draws down *future platform fees first* — driving the curation flywheel ("curate → wipe out your fees"), lowering collection risk, and cleaner on tax (a fee discount, not income). **Cashing out re-opens the money-flow door** (rent BaaS — Connect/Treasury/Dwolla); a deliberate Phase-2 crossing, not a toggle.
|
||||
- **Pending → cleared settlement.** Credit A as *pending* (not spendable) and hold B's fee pending; settle **both legs together** after B's refund window (≈14–30 days) closes, so in-window refunds void both atomically with zero clawback. Negative balances are a debit on a continuing account: reverse pending, then available, then push any shortfall to the next ACH invoice; keep the ACH mandate active through offboarding; a rolling reserve only if a few high-volume curators make it material.
|
||||
|
||||
### Identity, sovereignty & the opt-in network
|
||||
|
||||
**Separate authentication (infrastructure) from identity ownership (policy).** Run **one shared platform auth** under every white-label store; express sovereignty as a *data-visibility policy* on top — never by splitting auth per store.
|
||||
|
||||
- **Sovereign maker:** full accounts/logins (on your shared auth), customer data private to them, zero network exposure. Sovereignty means *your buyers are private to you* — **not** "run your own login" and **not** guest-only (guest-only is *less* ownership, not more).
|
||||
- **Collective maker:** opted-in buyers participate in the cross-maker graph.
|
||||
- **Centralize auth always** — it's commodity infrastructure *and* the network's foundation. If auth fragments per store, even opted-in makers have no shared substrate to network across. The maker's choice is a flag on one system, flippable later without re-platforming.
|
||||
- **Two consent layers:** the *maker* decides whether their store participates; the *buyer* decides whether they're recognized across the network (GDPR/CCPA). Build both from the start.
|
||||
|
||||
**What "own the customer" means — usage-rights is the headline** (validated, §8). Makers mean **usage rights**: the buyer relationship is *theirs to market to, on any channel, including off the network*. This is the precise inversion of the walled-garden restriction (Etsy/Amazon forbid direct off-platform marketing because the captive buyer is *their* asset), so it's the sharpest anti-walled-garden promise — **and you can grant it unconditionally**, because you don't monetize the captive relationship. It also *subsumes portability*. So: **usage-rights ownership is the headline; network-graph *privacy* (buyers hidden from the cross-maker graph) is a separate, optional opt-out** for the fortress-minded subset — not the core meaning. Operational machinery in the consent subsection below.
|
||||
|
||||
**Opt-in network model — carrot, not stick.** It self-selects the commons-minded makers the network works for. The benefit must be **concentrated and visibly fast** or opt-in becomes a ghost town. Keep the asymmetry **additive** (joiners get referral income, placement, cross-promotion, fee offset, feed surfacing) — never **punitive** (don't cripple the standalone storefront to force joining). Design for **granular** participation, **reciprocity** (surfacing proportional to participation), and a **social-proof opt-in moment** ("makers you respect sent each other 200 buyers last month — want in?").
|
||||
|
||||
**Don't bundle "on our storefront ⇒ on the network."** Forcing full network participation on storefront makers is the coercion the opt-in model rules out, and it weakens the storefront's standalone (n=1) value. Split two switches: **catalog presence** (your products *can* be discovered/curated/fed) is reasonable to **default-on (opt-out)** for storefront makers (low-friction, low-sensitivity) — though being *featured in a specific maker's Curated-By* (a vouch carrying referral economics) is a **per-relationship opt-in** the featured maker approves (§7, "Curated By This Maker"); **buyer-identity participation** (your *buyers* are recognized cross-maker) stays a **separate, consent-gated opt-in** (sovereignty-sensitive, and it's the buyer's data). Take the convenience (auto-catalog-sync); never the coercion (forced identity sharing).
|
||||
|
||||
**Why this is the defensible core.** Shopify *won't* build cross-merchant shared identity — its customers (sovereignty-seeking DTC merchants) would experience it as the platform claiming their buyers, betraying the exact promise Shopify sells. Shopify does the *resell* network (share *products*) but not the *referral* network (share *buyers* with attribution). Your bet: community-embedded makers relate to shared identity as *belonging*, not theft. The moat isn't the storefront, the commitment-commerce mechanics, or the resell network (Shopify has that) — it's the **cross-maker identity-and-attribution layer**, the one asset an island-based incumbent is fenced out of by its own positioning.
|
||||
|
||||
### Customer ownership, consent & martech
|
||||
|
||||
"Own the customer" creates an obligation: consent has to be managed somewhere, and *where the source of truth lives* decides whether the promise is real or a disguised lock-in. The governing test: **if this maker left tomorrow, could they fully and lawfully market to their consented customers without ever touching the network again?**
|
||||
|
||||
**Source of truth: the maker's own ESP — not the network.** For a maker's *own* marketing, the maker's ESP (Mailchimp, Klaviyo, …) is authoritative and the network is a thin integration layer. This is *more* sovereign and *simpler* than a network ledger, passes the leave-tomorrow test tautologically, and dissolves the orphaned-opt-out-link problem: the unsubscribe link resolves to the ESP's native hosted unsubscribe, which the maker keeps after departure.
|
||||
|
||||
- The network captures email + per-type marketing consent at checkout and **pushes the subscriber to the maker's ESP**; the ESP owns everything after (compliant unsubscribe, suppression, deliverability).
|
||||
- Opt-outs happen in the ESP; the network **reflects them via the ESP's unsubscribe *webhooks*** (prefer webhooks to polling). The network is a connector, never an ESP.
|
||||
- **Imported pre-existing lists** then need no special handling — they live in the maker's ESP, naturally arm's-length from the network's records.
|
||||
|
||||
**Two consent domains — only one belongs in the ESP.** *Maker-originating* marketing → the maker's ESP is authoritative. *Network-originating* communications (buyer feed, aggregate marketing emails below, follow notifications) are the network's own relationship with the buyer, governed by **network-communication consent** the network must own. Keep them strictly separate: **the network may never borrow a maker's ESP list for its own sends.** "Send A's curation to A's buyers" means *A's buyers who also opted into network email* — the overlap, never A's whole ESP list.
|
||||
|
||||
**The cross-maker buyer dashboard is a thin cache + fan-out, not a ledger.** With consent scattered across N ESPs, the unified dashboard ("manage all my subscriptions," "opt out of all promotional across all makers") needs a **read cache** (webhook-synced reflection — explicitly *not* the source of truth) and **fan-out writes** into each maker's ESP. Per-*maker* full unsubscribe is easy everywhere; **per-*type* across makers is the fiddly part** (each ESP models promotional-vs-drop differently — groups/tags/interests/lists — so your normalized type taxonomy must map onto each). Every dashboard control writes per-maker (per-type) records; a buyer-level standing preference ("never promotional from anyone") acts as a *default that generates per-maker records as new consents form*, not a send-time global override.
|
||||
|
||||
**Greenfield fork: no ESP → network light-sending is the temporary source of truth.** Greenfield makers (the two interviewed) have no ESP, so the network provides **optional light first-party sending**, where it *is* authoritative because no ESP exists. Keep it deliberately minimal (never a Klaviyo competitor) and **exportable to seed a real ESP later**. The graduation path is the right shape: greenfield → grows → adopts an ESP → the network steps *back* from authority to integration. Network involvement *shrinks* as the maker matures.
|
||||
|
||||
### The network marketing channel (aggregate emails & email-embedded curation)
|
||||
|
||||
Distinct from maker-originating ESP email, the network runs its **own** marketing channel — a discovery engine, provided it obeys the feed discipline.
|
||||
|
||||
- **Personalized aggregate emails.** The network emails buyers (on the **network's** consent list) digests aggregating content/deals from *multiple* makers. The hard rule that keeps this from becoming retail-media-by-email: **content traces to the buyer's own engagement graph** — drops/deals from makers they follow, plus those makers' Curated-By picks — and the network *aggregates, personalizes, and delivers* (pipe) but **never injects platform-chosen merchants or sells placement.** Maker-as-discovery-engine, platform-as-pipe.
|
||||
- **Two-sided opt-out, both network-owned:** buyers opt out of receiving network marketing; makers opt out of being *included* in it.
|
||||
- **Curation as living discovery.** A buyer who engaged with Maker A can receive A's *current* Curated-By picks in their network digest, and as A's curation evolves the fresh picks flow into emails to A's (network-consented) buyers — turning Curated-By into a *recurring* discovery surface for the makers A vouches for, through the channel the network legitimately owns.
|
||||
- **Curated-By as an email widget in the maker's own ESP.** Makers embed their live Curated-By block in their own newsletters, with links carrying the stateless referral token — so a maker's own newsletter becomes a referral-generating surface (they earn credit; attribution works without identity). Points only to verified makers on controllable storefronts; reputation-staked, not paid; and — since email can't run JS — the widget is a send-time-pulled HTML block or a server-rendered image (refreshed per send).
|
||||
|
||||
### Verification: the gate to the trust tier
|
||||
|
||||
Verification sits at the boundary between the commodity layer and the moat layer. Three states, not two:
|
||||
|
||||
- **Unverified → full SaaS, zero network.** Any maker gets the complete commitment-commerce storefront (drops, pre-orders, clubs, own processor) — a paying customer of a genuinely good tool, but in *nothing* trust-gated (no referrals, no Curated-By either direction, no buyer feed, no agent feed). The storefront is commodity; gating it would suppress the Phase-1 wedge. Let everyone in the front door — *once the membership gate has loosened to open signup* (the phasing note below; at launch the door itself is invitation-only).
|
||||
- **Verified → eligible** for referrals, Curated-By, buyer feed, and agent feed — eligibility, not automatic inclusion.
|
||||
- **One gate, then independent switches.** Verification is the single floor; the channel opt-ins (network, agent) are independent choices within it.
|
||||
|
||||
**Curated-By and referrals are verified-only in *both* directions** — a verified maker's stake can't vouch for unverified supply, so an unverified maker can be neither curator nor target. (A useful forcing function toward verifying.)
|
||||
|
||||
**Network opt-in and agent (ACP) opt-in are independent siblings over one shared verified catalog — don't gate agents behind network membership.** A sovereign maker may refuse the maker-to-maker network yet *want* agent reach (net-new, sovereignty-neutral demand). **Verification — not network membership — is the precondition for the agent feed**, so the agent feed inherits the differentiator automatically ("*verified* products for agents," the structured trustworthy supply agents can't scrape). **Default agent inclusion ON (opt-out) for verified makers**: it maximizes the supply density that is your leverage with agents, and it's safe because agent sales route back through the maker as MoR. The agent feed is the **net-new-demand hedge** against the buyer feed's deliberate reach-weakness — the feed *deepens* existing follow graphs, agents *reach* outside them.
|
||||
|
||||
**Verification is aspirational, not punitive — by design.** Because it gates the *demand* (referrals, feed, agents), not the *tool*, makers are pulled toward verifying rather than blocked at signup, and the unverified SaaS tier becomes a **verification funnel** (already paying, catalog in-system, upgrade = "verify → unlock demand"). It also keeps the trust guarantee **absolute**: both consumer-facing surfaces are hard-gated, so anything a buyer or agent sees is verified.
|
||||
|
||||
**The membership gate itself phases — start closed, loosen toward open.** The three states above and the "front door / funnel" framing describe the **loosened end-state**. At **launch the gate is invitation-only, end to end**: there is no self-signup even for the unverified tool — every maker enters by invitation from an existing member who vouches they make original art or craft, rooted in a seed set Wiggleverse staff verify directly (the *originating edge*; full mechanics in the trust-&-safety section). Early scarcity is a feature: it lets word-of-mouth carry the beachhead, keeps the trust guarantee absolute while the rooted web is small, and makes every early edge high-intentionality. **Loosen toward open signup once roots and beachhead density exist** — then the unverified-tool front door opens, verification still gates the demand, and the funnel above kicks in at scale. This is the membership-gate analog of the peer-verification sequencing below (verify-makers-yourself-early → peer-verify-once-roots-exist): both start closed and open only as the trust web earns it.
|
||||
|
||||
**Peer verification — the scaling mechanism.** Makers verify makers, staking reputation. It's *provenance verifying provenance* (real makers spot real makers), the web *is* a trust graph (harder to copy than a checkmark DB — it deepens the moat), and vouching doubles as community formation. **But it's the highest-stakes mechanism** — a Sybil/collusion surface that fails *catastrophically* (one polluted item in the "verified" feed breaks the guarantee). Build a *rooted, staked, multi-vouch trust web with a sampling audit*, never flat "anyone verified can verify anyone":
|
||||
|
||||
- **Real, slashable stake** — a bad vouch costs the voucher (authority revoked, status reviewed), or "staked reputation" is a farmable click.
|
||||
- **Earned authority / time-trust gradient** — newly-verified can't immediately verify others, breaking ring-bootstrapping.
|
||||
- **Rooted graph** — anchor early verifications to a seed set *you* verified; every verified maker traces back to a root, so a compromised subtree can be found and revoked.
|
||||
- **Multiple independent vouches** for full status (ideally not all from one cluster).
|
||||
- **Sampling audit** (random + risk-triggered) so abuse is expensive and detectable, and **ring-pattern instrumentation** from day one.
|
||||
|
||||
**Sequencing:** do *not* launch with peer verification. Verify makers yourself early (few enough for manual review, and you *need* to establish the root set). Enable peer verification once roots exist and volume makes manual review the bottleneck. Verification is now load-bearing in four places (referrals, Curated-By, buyer feed, agent feed), so throughput is a real growth governor — design the scaling path (tiered verification, community vouching as signal, provenance-documentation standards) early.
|
||||
|
||||
### Per-item provenance: the catalog's originality layer
|
||||
|
||||
Verification answers a question about the *maker* (is this a real maker of original work?); provenance answers it about the *item* (is *this product* their original work?). A real maker's catalog is mixed — a potter sells their pots **and** resells pottery tools — and that is fine. What matters is that the buyer, and the trust surfaces, can always tell which is which. So every catalog item carries a **provenance classification** (metadata, like the cross-maker component layer below), **self-attested** by the maker, **audited** by the trust machinery, and **shown to the buyer**:
|
||||
|
||||
- **Original** — the maker's own original art or craft (the pots). Purchased raw materials and supplies (clay, glaze, blanks) are inputs to *making*, not other-sourced components — a pot thrown from bought clay is original.
|
||||
- **Original + components (composite / kit)** — primarily the maker's work but incorporating identifiable parts from others; attributes each contributor (in-network Maker Y, or flagged where out-of-network). The kit / "partial" case, reusing the cross-maker component-metadata layer below.
|
||||
- **Resale — fellow Maker** — reselling another in-network maker's original item; provenance still traces to the true maker. (This is Curated-By / consignment expressed as a catalog item.)
|
||||
- **Resale — third-party / not original** — out-of-network commercial goods, tools, or supplies (the resold pottery tools). Honest, allowed, and clearly *not* original.
|
||||
|
||||
Two rules ride on the classification:
|
||||
|
||||
- **Buyer transparency is the point.** Every item shows its provenance badge — the consumer-facing expression of the moat: not merely "this maker is verified," but "this *item* is original / partly original / a resale / not original (and contains these makers' work)." It is the precise anti-Etsy signal — you always know what you're buying — and it *appreciates* as AI-generated and drop-shipped fakes proliferate.
|
||||
- **Trust-surface eligibility keys off it, per item.** Only **original** and **original-+-(in-network)-components** items are surfaced as the maker's original work in Curated-By / buyer feed / agent feed. A **fellow-Maker resale** may surface *attributed to the true maker* (that *is* Curated-By). **Third-party resale never enters a trust surface** — surfacing it would launder non-original goods through a trusted face (the Etsy-pollution failure mode, from the inside). A composite that contains any non-original component is flagged as such wherever it appears.
|
||||
|
||||
The hard, still-open part is the **line between making and reselling** — finishing, assembling, and kitting sit in between (the standard to write, §14 #2): purchased supplies don't taint "original," but assembling mostly-third-party parts isn't original either. Misclassifying a resale as "original" is a provenance lie → a verification-revocation trigger (trust & safety, §10). Self-attestation makes classifying cheap; the sampling audit plus buyer reporting make gaming it risky.
|
||||
|
||||
### Non-maker referrers: the verified taste-maker tier (Phase 2)
|
||||
|
||||
Any website can join as a *referrer* — a taste-maker/curator (a hobby YouTuber, blogger, podcaster) who isn't a maker but has audience and taste. Financially low-risk (pay-on-conversion, no custody, no MoR, no inventory). The risk is to the **verification moat**, handled by the *same tiering as makers*:
|
||||
|
||||
- **Unverified referrer (open tier).** Generates referral links to verified makers, earns on conversion, but is **not surfaced in any trust-dependent surface** — a traffic source, self-limiting (a bad one doesn't convert) and contained (can't touch trust surfaces).
|
||||
- **Verified taste-maker (trust tier).** A legitimate community voice verified as a *trusted curator* (not a maker), reputation-staked and slashable — badge, surfacing, loses status for shilling. Verification separates the genuine taste-maker (additive) from the affiliate-spam farm (corrosive).
|
||||
|
||||
**The load-bearing guardrail for this tier: uniform, non-biddable referral rates.** A maker-curator's commission pull is counterbalanced by peer respect (and the structural ranking-neutrality guarantee), which is why **maker↔maker rewards are negotiable above the fixed spread** (§7, "Referral economics"). A pure taste-maker's incentive is more purely the fee, so that same negotiability would let them chase the highest payer — retail media through the referrer door. **For non-maker taste-makers, therefore, rates stay uniform and non-biddable** — they feature on **taste, not who pays most.** Plus **disclosure/labeling** (distinguish a maker's peer vouch from a taste-maker's disclosed-affiliate pick; FTC-required anyway), **referrer-only/one-directional** (never a destination, never a maker-verifier), and the **no-walled-garden rule** still holds.
|
||||
|
||||
**Why it's worth doing:** taste-makers are the **net-new-demand engine** the maker-only network is structurally weak at — a third source alongside maker-curation (deepens) and agents (reach), and the most community-native (a trusted human voice, not an algorithm).
|
||||
|
||||
**Phase 2, and a marginal add — not a new crossing.** A pure referrer has no fees to offset, so they need **cashable payout** — the same Connect/mass-pay rail Phase 2 already builds for cashable maker wallets and kit pure-supplier payouts. Three consumers, one scoped crossing; the marginal cost is the verification tier + uniform rate, not new money plumbing. (Paying a taste-maker is a *payout* to an affiliate — principal-on-both-sides, two independent events — not consumer money transmission.) Reserve option: a marquee taste-maker can accrue a pending cashable balance in Phase 1, paid on rail launch — don't open generally.
|
||||
|
||||
### The buyer-facing feed (Phase 2+) — how the marketplace emerges
|
||||
|
||||
A consumer surface (site/app + email) where buyers see followed makers' drops, "buy it again," and Curated-By from makers they follow. This is **how the marketplace *emerges*** from accumulated follows rather than launching cold into the curation-vs-liquidity graveyard (§5).
|
||||
|
||||
**Core principle — discovery is delegated to makers the buyer chose, never performed by the platform.** Every recommendation traces to a follow the buyer initiated. The platform never originates a recommendation — no "you might also like," no algorithmic cross-maker surfacing, no house-promoted placement. **Maker-as-discovery-engine, platform-as-pipe** — the *inverse* of the Shop app. Three payoffs: it keeps you **out of the discovery war** (you never compete with Amazon/Google/agents on recommendation quality); it keeps the feed **non-threatening to sovereign makers** (no buyer sees a maker they didn't choose or that a followed maker didn't vouch for); and it preserves **curation integrity** (follow-gated, reputation-staked curation can't be gamed toward pollution like an engagement algorithm).
|
||||
|
||||
**The deliberate tradeoff:** pure follow-gated discovery is intentionally *weaker at net-new demand* — it deepens the graph buyers already know but doesn't introduce makers outside it. Growth stays permanently "makers bring audiences and vouch," never "the platform surfaces new reach" (agents and taste-makers are the net-new hedges). The correct trade for this positioning, as long as it's chosen knowingly. Disciplines: **buyer-opt-in by nature** (doubling as cross-merchant data consent); lead with the safe end (buy-it-again → followed drops → curated-by); any move toward platform-originated/algorithmic discovery is a deliberate, opt-in-by-everyone, much-later decision.
|
||||
|
||||
### Storefront architecture & Shopify coexistence
|
||||
|
||||
**Two layers, kept separate — the core architectural decision.** A *storefront layer* (per maker) sits under a *shared cross-maker network service* (the moat). Conflating them couples the moat to one storefront engine and to single-tenant boundaries.
|
||||
|
||||
- **Storefront layer (per maker).** Either your **white-label storefront** (headless backend + themed frontend) or the maker's **existing Shopify store** (federated). Handles that maker's catalog, cart, checkout, orders, and own payment processor (MoR).
|
||||
- **Shared network service (cross-tenant — the moat).** Verification graph; **canonical catalog index** — *with a cross-maker component-metadata layer* ("this product contains a component made by Maker Y," the kit/BOM relationship expressed in the catalog itself); cross-maker identity/follow graph; **a complete per-maker order history** (every order since the maker joined — captured because referral/fee computation must see orders, and reused for the transparency below); referral/Curated-By attribution + fee/wallet ledger; buyer feed; agent/ACP feed. A standalone service with its own datastore, spanning *all* makers and federating over heterogeneous storefronts — deliberately **not** part of any storefront engine.
|
||||
|
||||
**The canonical catalog index lives in the network service — not a Medusa "mega-store."** Every maker's catalog (Shopify via Admin API, your Medusa storefronts via their API, anything else via adapters) is transformed into one normalized, verified index that powers Curated-By, the feed, and agents. A Medusa instance is a *storefront* (cart, checkout, one MoR, sellable inventory); the network catalog is a read-optimized *index* of products that live and sell elsewhere. Pouring all makers into one Medusa instance would make a thing shaped like a store that must never behave like one, and couple the neutral network to one engine. Keep each storefront as the system of record; the network holds a normalized verified *projection* — which also keeps Shopify and Medusa products *co-equal sources*, not Shopify imports into a competitor-shaped container.
|
||||
|
||||
**Cross-merchant order transparency — the positive-sum twin of the money-flow "no."** Because the network already sees every maker's orders (it must, to compute referrals) and knows which products contain other makers' components (the catalog metadata layer), it can hand each maker something no single-store tool can: **visibility into every order that touches their work anywhere in the network** — their item sold inside another maker's kit, a referral they sent that converted, a component of theirs moving through a partner's store. This is the grocery **scan-based / Direct-Store-Delivery** pattern: the supplier sees the sell-through and knows when to restock or re-engage, *without being the store*. It costs the network nothing to give (it already holds the data), it is **uniquely the network's to give** (only the cross-tenant vantage sees across stores — which deepens the moat), and it stays firmly on the right side of the line: **transparency and coordination are free; custody is not** (the kit-supplier notification in Appendix C.2 is one instance). One asset, three uses — the same order history powers the referral ledger, the network-health metrics (§12), and this reporting.
|
||||
|
||||
**Build the white-label storefront on a headless backend (Medusa recommended).** "Build the 20%, rent the 80%" in code: the headless backend (Medusa — Node/TS, modular, payment-agnostic, no per-order revenue share; alternatives Saleor, Vendure, Spree) supplies cart, catalog, orders, customers, fulfillment, BYO payment, and you add the commitment-commerce engine — **drops, pre-orders, clubs, raffle/queue — as custom backend modules.** Mental model: in Medusa, *modules are backend domain logic; the storefront is a separate frontend app* consuming the Store API. So **the storefront is not a module** — the commitment-commerce *features* are modules, the storefront is their client, and the network service above is neither (standalone). Multi-tenancy (one shared instance vs. per-maker instances) is decoupled from the moat because the network service is separate either way; for two pilots, a single instance + shared theme is plenty.
|
||||
|
||||
**Shopify makers: federate, don't migrate.** The network is storefront-agnostic, so a Shopify maker keeps Shopify (their MoR/processor) and joins via two hooks: **catalog sync** (Admin API + product webhooks into the verified index → eligible for Curated-By/feed/agents) and **referral handoff + attribution** (the Curated-By link carries a signed token that rides in as a **cart attribute → order note_attribute**, read from the order webhook to bill B / credit A — no checkout customization, any plan). **Hybrid wedge:** evergreen catalog stays on Shopify; the maker uses your platform only for the commitment-commerce *events* Shopify handles badly. Migrate later only if earned.
|
||||
|
||||
**Curated-By on Shopify** rides **one installed app** built on **theme app extensions** (not legacy ScriptTag): a draggable **app *block*** for the curator role (renders verified picks from your network service, links carry the token) and an **app *embed*** for the recipient role (reads `?ref=` on landing, writes it as a cart attribute → order note_attribute → `orders/create` webhook). The app is a thin client — curation, verification, and the ledger live in your network service. Optional app proxy for crawlable server-rendered sections. Because it rides cart attributes through native checkout, the *entire* loop — display and attribution — works on a stock Shopify store with no checkout access and no theme surgery.
|
||||
|
||||
**Strategic payoff:** the storefront-agnostic network makes your addressable makers the *entire* Shopify/Etsy/standalone install base — reachable via federation, not just makers willing to switch storefronts. The storefront is the wedge for makers who want a better tool; the network is open to anyone who verifies and syncs a catalog. This de-risks the scariest adoption question ("will makers switch storefronts?") — they don't have to. (Caveats: Shopify APIs are partly rented land, but you're not dependent on them — one source among several; Shopify takes its cut on Shopify sales, fine, since you bill your fee separately via ACH.)
|
||||
|
||||
**Admin surfaces — three, not two.** The *network* is one product with one admin for everyone; the *storefront* admin is Medusa's (your makers) or Shopify's (theirs).
|
||||
|
||||
- **Storefront admin** — only for your Medusa makers (products, drops, orders, fulfillment); a Shopify maker uses Shopify's admin.
|
||||
- **Network admin (bespoke, universal)** — curation, referral earnings + wallet, verification status + peer-vouching, follow/feed participation, network + agent opt-ins. No home in either storefront engine because it's the cross-tenant moat layer.
|
||||
- **Billing/account (bespoke, universal)** — ACH mandate, fee tier, invoices, payment history.
|
||||
|
||||
So everyone uses the bespoke network + billing admin; Medusa makers *additionally* get Medusa's storefront admin; Shopify makers get theirs from Shopify. Notes: **ACH is universal, not referral-specific** — every maker gives a debit mandate at onboarding (gate mandate-on-file as a precondition for referral eligibility, so a referral-only maker can't accrue an uncollectable fee). And **Shopify makers get a read-mostly catalog/sync/analytics view** — a trust surface: a mirror (not a second editor; Shopify stays system of record), sync health (a silently-broken sync makes products vanish from the feed), and **network analytics** uniquely yours to provide because only you see across stores ("Curated-By sent me $800 last month" does quiet retention work). **Build-ordering:** the bespoke network + billing + analytics admin is foundational and on the critical path for both populations from day one — even the two Medusa pilots need it the moment Curated-By and referrals exist.
|
||||
|
||||
### Business model: network is the product, storefront is an optional convenience
|
||||
|
||||
Now that Medusa exists and the network federates over any storefront, *centering* on storefront hosting has weakened — three erosions: the storefront is the commodity layer; Medusa makes it cheap for everyone; federation means a maker doesn't *need* your storefront to be in the network.
|
||||
|
||||
- **The network is the product and the value capture.** Charge for the network — referral take + subscription — **uniformly, whether a maker is on your storefront or Shopify.** Revenue must not depend on storefront adoption; that keeps you credibly neutral and makes TAM = *every* maker.
|
||||
- **The storefront is an optional, opt-in convenience — SaaS-priced, never GMV.** GMV-take would recreate channel conflict, tax makers for using your rails (making your storefront *worse* than self-hosted Medusa), and pull you back toward money-flow entanglements. Price it as a flat SaaS fee that covers cost.
|
||||
- **Three postures — reject the first.** *Don't* be a storefront company with a network (GMV fees, competing with Shopify on hosting — wrong center of gravity). *Do* be a network company that offers a storefront — the best-in-class native commitment-commerce experience for makers who want it or have nowhere else to be — as a wedge, not the business.
|
||||
|
||||
**"Why not just point makers to Medusa Cloud?"** Because Medusa Cloud sells *hosted infrastructure to developers*; it doesn't give a maker a working drops-and-clubs storefront. You sell a **vertical, maker-ready commitment-commerce product** where Medusa is *invisible plumbing*. The entire gap between raw infrastructure and a working maker storefront is your product — pointing a maker to Medusa Cloud is like pointing them to AWS. Corollary: **don't position or price as a hosting company** ("Medusa Cloud + modules" drags you into competing on infra margins). Hosting is a cost you absorb; the vertical experience (and the network) is what you sell.
|
||||
|
||||
### Partner / consultant network: onboarding the high-touch tail
|
||||
|
||||
Implementation consultants (community-embedded especially) onboard the **high-touch tail** without the network becoming a services business, doubling as community-aligned distribution and mirroring the partner ecosystems that grew Shopify and Medusa — a second flywheel you *enable* (certification, a directory, the referral-income share below) but don't *staff*. Guardrails: keep the product **genuinely self-serve for the median maker** (if makers *need* a consultant for a basic store, the product failed and partners are masking it), and structure partners as **referral/implementation partners, not white-label resellers**, so they don't become the relationship-owner and disintermediate you.
|
||||
|
||||
**How partners are paid — a negotiated, tapering share of the maker's *earned* referral income.** A maker may opt to bring on a partner to stand up their storefront and onboard them onto the referral network, and pay for that help out of the upside it creates: the partner earns a **share of the referral income the maker earns *as a curator*** (the negotiated Curated-By referral reward in "Referral economics" above) — not a fee on the maker's sales, and not a cut of the platform's spread. The natural shape is **front-loaded and tapering** — e.g. 100% of the maker's first $X in referral earnings to the partner, then a declining share as the maker's curation takes off — a *help-me-start, earn-out* deal, not a perpetual tax. The two negotiate and structure the schedule through **platform tooling**; the network stores the agreed terms and settles the split. This is the *one* partner-compensation leg the network touches, and it rides the same invariants as the rest of the money model, so it adds posture, not exposure:
|
||||
|
||||
- **Out of the flow, principal on both sides.** The platform never routes the maker's money to the partner (that is custody / transmission — Appendix C.2's line); it independently *reduces* the maker's referral credit and *extends* the partner a credit or payout — two events, principal on each, never a conduit (the §7 referral-economics pattern).
|
||||
- **A fourth consumer of the one Phase-2 cashable rail.** The maker's referral credit is non-cashable (fee-offset); a partner who isn't itself a maker has no platform fees to offset, so — exactly like the verified taste-maker and the kit pure-supplier — it needs *cashable* payout over the same scoped Connect/mass-pay crossing Phase 2 already builds. (A partner who *is* a maker can take non-cashable wallet credit instead.) One more rider on that crossing, not new money plumbing; Phase 1 can accrue the partner's split as *pending* and pay it on rail launch.
|
||||
- **The negotiation tooling is the ledger pattern already required.** "Partner P takes schedule S of maker A's referral earnings, tiered by cumulative $" is **stateful settlement on a running total** — the same machinery as the wholesale volume tiers (Appendix C.5): the schedule is a stored term, settlement is cumulative (not per-order-independent), and the retroactive-vs-prospective question at a tier boundary is a term the two agree and the platform applies. Build the ledger for cumulative partner splits from the start.
|
||||
- **The curation-integrity guardrail still binds.** The partner shares the maker's *earned* income but gets **no say in *whom* the maker vouches for** — otherwise a partner would push the maker toward whatever converts, reintroducing the commission-corruption drift the whole referral design resists (§3; "Curated By This Maker"). Disclosure applies where a partner relationship would read to buyers as paid placement (FTC, §11). The deal is comp for *activation*, not influence over *taste*.
|
||||
|
||||
**Cash, payment terms, and any other agreement live *outside* the network — by the same money-flow rule.** The referral-income split above is the only money leg the network handles, precisely *because* it can be done principal-on-both-sides without entering the flow. Everything else a partner and maker might agree — an **upfront cash payment, a setup fee, a retainer or hourly, milestone payments, or any other lawful arrangement** — is a **direct business agreement between the two, settled off-platform**, exactly as a maker pays any other vendor it hires. The network neither processes nor takes custody of that money, because doing so would put it squarely back in the money flow it is built to stay out of (custody is the line — "Money flow: stay out of it"; Appendix C.2; the spine). So the division is clean: **the network settles the one leg it can keep out of the flow (the referral-income share, via the cashable rail), and stays entirely clear of every direct-cash leg, which the parties arrange and settle themselves.** The tooling may *record* an off-network term for the parties' own clarity (recording is coordination, which is free) — but it never *moves* that money.
|
||||
|
||||
### Entity structure & trust positioning: non-profit, transparent, volunteer-built
|
||||
|
||||
The org is a **true non-profit (501(c)(3) or equivalent), radically transparent (open books), engineered by volunteers with LLM-accelerated development, not raising money or selling equity.** This converts the trust position from a *promise* into a *guarantee*, and it's funded-viable for a reason that's thematically exact:
|
||||
|
||||
- **The cost structure that usually makes non-profit tech infeasible is the one LLMs just collapsed.** The primary cost center — engineering — has been deflated by the same force the venture exploits. The entity and the opportunity are the same bet pointed two ways.
|
||||
- **No fundraising removes the only strong argument against true non-profit** (a PBC/B-Corp hedge exists only to preserve VC/equity optionality — which here is the door makers fear, so foreclosing it is the point).
|
||||
- **The structure *enforces* the anti-Etsy promise.** A 501(c)(3) legally can't be sold or distribute profits — the strongest answer to "will you sell our trust for GMV?" "Bind with structure, not promises," realized in the entity itself.
|
||||
- **Radical transparency is the substrate of the verification moat.** Open books/governance let the community *verify the incorruptibility* of "verified handmade" rather than take it on faith — the mechanism, not a nice-to-have.
|
||||
|
||||
**The risk moved, it didn't vanish:** apply the rigor you'd have spent on fundraising to **volunteer sustainability.** LLMs change the math but not the human dynamics (attrition, bus-factor). Separate what needs reliability (network service, ledger, verification) from what tolerates volunteer cadence (themes, nice-to-haves), and keep the critical core off bus-factor-one. Also watch the **"fragile" perception** with professional makers — non-profit + volunteer reads as *aligned* to commons-minded makers but possibly *might-fold* to those building a livelihood; transparency is the counter, and §8 should test whether it reads **safe** or **nervous**.
|
||||
|
||||
### Hosting: managed vs. self-host (and the pilot)
|
||||
|
||||
**What you sell and where you host are independent** — makers never see the infrastructure, so hosting is a pure cost/ops choice, *reversible and invisible* because Medusa is portable. **Managed (e.g., Medusa Cloud) now** (your scarce resource is attention on product + network, not infra savings); **self-host at scale** (unit economics; don't couple margins to one vendor). Multi-tenancy interacts with hosting price (per-maker instances multiply managed per-instance pricing; a shared instance is different math) — negotiate as a *multi-instance platform customer*. **The network service is hosted separately, always** — that separation is what makes the hosting choice switchable.
|
||||
|
||||
**The two-maker pilot:** self-host on **GCP — Cloud Run + Cloud SQL (Postgres)**. Cloud Run scales toward zero when idle (suits drop-spiky traffic) and avoids babysitting a cluster at n=2; the shared-instance cost is low tens of dollars/month. **Watch the drop-traffic spike** — a scheduled drop is a burst of concurrent buyers, exactly the load that embarrasses an under-provisioned instance, and a storefront falling over *during a drop* is the worst moment for maker trust; load-check before a real drop. On cost: pass-through or a flat fee is fine, but **treat cost recovery as trivial** — two happy pilot makers (your first verification roots and references) are worth far more than reconciling a small GCP invoice. Frame any charge as "covering pilot costs," not the product's pricing model.
|
||||
|
||||
---
|
||||
|
||||
## 8. Next step: maker discovery (do this before building the platform)
|
||||
|
||||
Goal: verify whether the cross-merchant-identity conversion advantage (Shop Pay's lift) is a *real moat for these makers*, and whether you can build your own version — and, more broadly, whether the demand side exists.
|
||||
|
||||
**Avoid the measurement trap.** Don't ask "how much value does Shop Pay give you" — makers can't see the conversion counterfactual, so answers are vibes. Shop Pay's lift comes from **cross-merchant identity recognition removing first-purchase friction**, so measure the driver: the revenue split between **repeat fans vs. first-time strangers**, and whether buyers **already have Shop Pay**. (Repeat-fan-heavy makers capture little of the network effect; viral/stranger-traffic makers capture a lot.)
|
||||
|
||||
**Don't concede a false tradeoff.** "Lose conversion to gain alignment" assumes you can't have the lift — but a curated cross-promotion network *structurally generates* cross-merchant identity as a byproduct (your own niche-scoped Shop Pay equivalent). You can't offer it day one (cold-start), but it's a **year-three asset, not a launch feature.** Concede the *timeline*, not the moat.
|
||||
|
||||
**Listen for (beyond what you ask):** where buyers come from today (traffic-mix tell); what they pay Shopify **all-in** vs. what they *think* (most underestimate — the gap is your opening); whether the value-alignment grievance is real willingness-to-switch or venting; whether they'd want shared buyer recognition or guard their list; and — the asset they'll least volunteer — **whether they have an audience they'd bring** (a maker with no audience is a cost, not an asset). Also test whether the **non-profit/volunteer/transparent** framing reads as *safe* or *fragile*.
|
||||
|
||||
**Weight what makers *do* over what they *say*.** "I'd switch for alignment and lower fees" is cheap and constantly contradicted by behavior (people stay on Etsy they openly resent). The makers worth building for already maintain a second channel, already *moved* on something, already bring their own buyers.
|
||||
|
||||
**Interview log — early signal (2 greenfield makers, no existing site).** Liked *quickly spinning up an in-network storefront* (a network subdomain, not a dedicated domain) over a standalone site — confirming the greenfield on-ramp and the in-network address as a *preferred default*. Both conditioned it on *owning the customer*, which probing clarified means **usage-rights** (market to them directly, use/export the list, on or off the network) — the sovereignty principle reinvented unprompted. **Two cautions:** (1) the weakest evidence tier (liking an idea ≠ adopting ≠ paying ≠ staying) — chase the *behavioral* next step (real catalog in, a real drop, a referral); (2) it validates the *supply/storefront-convenience* (commodity) layer only — the **demand/curation moat is still untested** (do they have audiences, would they vouch, would they want to be vouched-for?). The network layer remains the load-bearing unknown.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decision gates
|
||||
|
||||
Gate the real platform build on evidence, not enthusiasm:
|
||||
|
||||
- Do the first makers **refer you** to others?
|
||||
- Is the **pain consistent** across makers (the same 20%)?
|
||||
- Do target makers **have audiences**?
|
||||
- Can you reach roughly a **dozen makers** who want the same thing?
|
||||
|
||||
Two makers justify a thoughtful, portable data model. They do not justify a platform. Build the data layer and the vertical primitives now; gate everything else on the dozen.
|
||||
|
||||
These gates are stated qualitatively here; §12 gives each a quantitative instrument (and a North Star) computed from the cross-merchant order history, so the dozen-maker gate can be *measured* rather than felt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Trust & safety & maker accountability
|
||||
|
||||
Verification (§7) is only an *entry* gate — it answers "is this a real maker of original work?" at the door. This section is the other half: **what happens when a verified maker behaves badly afterward.** Commitment commerce makes this the *signature* failure mode, not an edge case — "a verified maker collects pre-orders/deposits and ghosts" is the structurally most-likely scam, because the model takes money before delivery (§4). The money-flow stance solved the *financial* exposure (the maker is MoR, so the platform never holds the funds at risk — §7), but it did **nothing** for the *reputational* exposure — and the reputational one is the exact thing the moat rests on. A single polluted "verified" item breaks the guarantee for every buyer and every agent downstream.
|
||||
|
||||
The **shape** below is settled; the **reputation engine itself is explicitly OHM-guided work** (handbook §4.4, [OHM](https://rfc.wiggleverse.org/p/ohm/c/default/)) and is gathered in the last subsection. This whole section turns on OHM concepts — **reputation, trust, harm, recourse, value, dignity** — so where it names one, the canonical RFC governs: cite it, propose it where undefined, don't coin a local meaning.
|
||||
|
||||
### The originating edge — the trust topology
|
||||
|
||||
- **Every maker enters by invitation, and the invitation *is* a vouch** that the invitee makes original art or craft — rooted in a staff-verified seed set (§7 verification). The invitation graph is a **permanent topology**: every maker traces back, by some chain of vouches, to a root the platform verified directly. (The membership gate starts invitation-only and loosens toward open signup over time — §7 — but the originating edge persists as graph structure even after the front door opens.)
|
||||
- **Inviting doesn't pay.** The incentive to invite is the commercial relationship the network creates, **never a bounty** — a per-invite payout would manufacture exactly the Sybil/farming incentive the rooted graph exists to resist. You invite people whose work you'd stake your standing on, because that is what the edge means.
|
||||
|
||||
### How reputation flows along the edge
|
||||
|
||||
- **Reputation flows *up* the originating edge** — when a maker misbehaves, consequence propagates back toward whoever vouched for them — but **transitively, decayed (a per-hop coefficient), and hop-capped.** So the impact is **strong right next to the misbehavior** (a prompt-to-act for the inviter who actually can act) and **negligible by ~6 degrees out** (a distant root isn't punished for a great-great-invitee's fraud).
|
||||
- **Framed as positive reinforcement, not punishment.** The primary direction is *earning and growing standing* by vouching well and making well; the up-the-edge consequence is the downside tail of that same mechanism, not a separate penalty system. Good vouching compounds standing; a bad vouch costs the voucher (this is the §7 verification "slashable stake," seen from the accountability side).
|
||||
|
||||
### Consequence: loss of standing, not expulsion
|
||||
|
||||
- **The consequence is a low, buyer-visible reputation score + loss of all network benefit** (Curated-By, the buyer feed, referrals, the agent feed) — **not expulsion.** The misbehaving maker **keeps the storefront and the tool** (they are a paying SaaS customer, and the tool was never the thing gated — "gate the demand, not the tool"), but **loses amplification** and **wears a score buyers can act on.** This is *transparency as enforcement*: rather than policing every maker centrally, the network makes standing legible and lets buyers route around bad actors — which keeps the trust surfaces clean *by construction*, since a low-standing maker has already fallen out of Curated-By / feed / agents.
|
||||
- **This is a "member in bad standing," not the rejected model "a."** It is emphatically **not** flat "anyone can verify anyone" (§7 warns against that). At launch every storefront holder was *invited*, so even a maker in bad standing entered through the rooted graph — they are a member who lost standing, not an anonymous bad actor who slipped the gate.
|
||||
|
||||
### Authority & appeal
|
||||
|
||||
- **The inviter holds primary suspend/expel authority** over their own sub-graph — the person who vouched is the person best placed, and most motivated (their standing is on the line), to act. Layered on top: a **platform floor for active buyer harm** (the platform can act directly when buyers are being harmed, regardless of what an inviter does), and a **governance appeal path** (§14 #1) for the maker who believes a consequence was unjust. **Expulsion is the rare extreme**, reserved for active harm — the default consequence is loss of standing, above.
|
||||
- **Provenance lies are trust violations.** Misclassifying a resale as "original" (§7 per-item provenance) is not a clerical error — it is a deception that pollutes the trust surfaces, and so it is a verification-revocation trigger handled by this machinery.
|
||||
- **The legal spine of the ghosting case is in §11.** Non-delivery isn't only a reputation event: the FTC 30-Day Rule (§11, "Consumer protection / FTC") is what a ghosting maker is *violating*, and the platform's compliance-by-design notice/refund UX is the buyer's first recourse *before* a chargeback against the maker's processor. Reputation consequence and legal recourse are two responses to the same act.
|
||||
|
||||
### Still open — the OHM-guided reputation engine (and adjacent frameworks)
|
||||
|
||||
The **shape** above is settled; the **system** that implements it is the open work, and it is OHM-guided by nature:
|
||||
|
||||
- **The reputation engine itself** — scoring, the decay-coefficient and hop-cap *values*, how good standing accrues over time, how the score is *displayed* to buyers, and the benefit-gating thresholds (what score loses Curated-By vs. the feed vs. agents) — all turn on operationalizing OHM **reputation, trust, harm, recourse, value, dignity**. Defer to the relevant RFCs; propose them where undefined (a load-bearing concept OHM hasn't defined is itself OHM work, not license to improvise — top-of-file note).
|
||||
- **Verification-revocation triggers and process** — the concrete patterns that drop a maker's standing or revoke verification (non-delivery pattern, inauthentic/AI-generated goods passed as handmade, sustained non-response), and the process around each.
|
||||
- **The buyer-harm / dispute framework** — how a buyer reports harm, how it's adjudicated, and the recourse on each side.
|
||||
- **Non-delivery handling, given the platform is out of the flow** — the platform is **not a guarantor** (it holds no funds to refund from); the buyer's monetary recourse is a chargeback against the maker's own processor (maker is MoR), and the platform's contribution is *transparency* (the score, the public consequence) plus the §11 compliance UX — not an escrow it deliberately declined to hold.
|
||||
- **False-report and collusion controls** — the abuse surface of the accountability system itself (weaponized reports, retaliatory low scores, collusion rings), instrumented from day one alongside the §7 verification ring-detection.
|
||||
|
||||
---
|
||||
|
||||
## 11. Legal & compliance
|
||||
|
||||
The memo's legal strategy is not a list of compliance chores bolted on at the end — **it is one architectural choice, viewed through a legal lens.** Staying out of the money flow (§7, "Money flow: stay out of it") was justified there as a financial-risk decision; its larger payoff is *legal*: a single structural fact — **the maker is merchant of record on the maker's own processor, and the network never touches buyer funds** — discharges obligations across three otherwise-separate regulatory categories at once. **No money transmission. No marketplace-facilitator tax duty. No merchant-of-record liability.** Where a competitor that sits in the flow must license, collect, remit, and indemnify, the network simply isn't the regulated party. The genuine open exposures are exactly the ones that *aren't* dissolved by the money-flow stance — and the discipline of this section is to separate the two: what the architecture already answers (recap, hold the line) from what it doesn't (engineer down, or flag for counsel).
|
||||
|
||||
**Convention: flag and verify — not legal advice.** Everything below is the *engineering posture* — the structural choices that keep the regulatory surface small, and the questions worth putting to specialist counsel. It is not legal advice; each item names what to verify and with whom. Where a principle turns on a load-bearing concept — *consent, harm, recourse, value, dignity* — it defers to the [Open Human Model](https://rfc.wiggleverse.org/p/ohm/c/default/) per the top-of-file note (handbook §4.4): cite the RFC, don't coin a local meaning.
|
||||
|
||||
### Money transmission (MTL) — recap from §7
|
||||
|
||||
Out of the flow → **none.** Holding customer funds (escrow, a balance, a payout you control) triggers state-by-state money-transmitter licensing in the US — the single most expensive licensing regime a small org could wander into. The network never holds buyer funds: the maker is MoR on the maker's own processor, and the platform fee is billed in arrears via ACH/invoice (§7, "How the platform gets paid"), which is the network charging *its own customer for software*, not transmitting a third party's money.
|
||||
|
||||
- **The cashable Phase-2 rail rents a licensed transmitter — it doesn't become one.** When cashable payouts arrive (maker wallet cash-out, kit pure-supplier settlement, taste-maker affiliate payout — §7), the money moves over a BaaS rail (Stripe Connect/Treasury, Dwolla, a mass-pay provider) whose operator *is* the licensed money transmitter. The network is a platform user of that rail, not the regulated transmitter — the "rent the licensed infra, don't build it" move applied to compliance.
|
||||
- **The non-cashable wallet is not stored value.** The fee-offset wallet (§7) credits a maker against *future platform fees* — a discount / accounts-receivable entry, not a balance the maker can withdraw or spend with third parties. It is therefore neither stored value, a prepaid-access instrument, nor transmittable money, and stays outside the MT and stored-value regimes by construction. **Cashing out is the deliberate crossing** that re-opens the door — which is precisely why it is gated to Phase 2 behind a rented transmitter, never a toggle. *(Verify the wallet's non-cashable design against your states' stored-value / prepaid-access definitions before launch.)*
|
||||
|
||||
### Marketplace-facilitator sales tax — the centerpiece
|
||||
|
||||
This is the high-consequence area the memo had entirely missing, and the one most likely to surprise. Post-*Wayfair*, every US state with a sales tax has enacted **marketplace-facilitator statutes** that can attach a *tax-collection-and-remittance duty* to a "marketplace facilitator" **even when it is not the merchant of record** — the whole point of these statutes was to make the platform, not thousands of small sellers, the collection point. So "the maker is MoR" does **not**, by itself, settle it; facilitator status turns on a separate test.
|
||||
|
||||
**The two-part conjunctive test.** In most states (modeled on the SSUTA / MTC definition) you are a marketplace facilitator only if you do **both**: **(prong 1)** list, advertise, or otherwise facilitate the sale of a marketplace seller's products, **and** **(prong 2)** directly or indirectly **collect the payment** from the purchaser and transmit it to the seller. Both prongs are required — it is conjunctive, not "either."
|
||||
|
||||
- **The referral-handoff model (§7, Model A) fails prong 2 — by design.** Checkout happens entirely on Maker B's own store and processor; the network passes a signed referral token, then bills B a fee and credits A — money on which the network is *principal* (its own fee revenue, its own credit), never buyer payment it collects and forwards. It arguably satisfies prong 1 (it facilitates discovery) but **cannot satisfy prong 2**, so it is not a facilitator. Many statutes reinforce this with an explicit **advertising-only / referral exclusion**: a person who merely advertises or lists products and refers purchasers to the seller, *without processing the payment*, is carved out by name. The referral network sits squarely inside that exclusion — which is *why* it is a referral network and not a checkout.
|
||||
- **The fragile edge is "indirectly collects payment."** Prong 2's "indirectly" is the word that does the damage: some statutes read it broadly enough to sweep in arrangements where the platform touches the payment flow even lightly (routing, splitting, holding briefly). The referral handoff is safe because it touches **zero** payment flow — but the margin of safety is "we never touch the money," not "we're small." The moment any feature lets the network touch buyer payment, the edge moves.
|
||||
- **Danger zones — where prong 2 attaches and facilitator status flips on:**
|
||||
- **Phase-3 shared checkout (§7).** A unified cart where the *network* collects the buyer's payment and routes/splits it to makers directly collects payment — prong 2 satisfied, facilitator duty attaches. This is exactly the "much later, optional, opt-in" crossing the phasing reserves, and **it carries a sales-tax-collection obligation as a first-class consequence**, not an afterthought.
|
||||
- **Split-payment kits (Appendix C, Model 3 — Connect destination charges).** One checkout auto-splitting to N connected accounts has the platform *indirectly collecting and transmitting* — facilitator. One more reason Model 3 is "avoid until you choose to be marketplace-of-record for kits."
|
||||
- **Connect destination/separate charges or `application_fee` where the platform is MoR.** The §7 charge-type dial that flips MoR to the platform *also* flips facilitator status, because platform-MoR means the platform is indirectly collecting the buyer's payment. The Connect setting is, again, a legal-posture decision (next).
|
||||
- **Tax calculation belongs to the maker's storefront layer — never the network.** Sales-tax *calculation, collection, and remittance* is the MoR's job, and the MoR is the maker. Both storefront engines do it natively (Shopify Tax; Medusa's tax module) and both integrate the specialist engines (Avalara, TaxJar) for nexus-aware rates and filing. The network must **not** build tax-calc — doing so would quietly adopt the very duty the architecture declines, and would invite the argument that a thing computing sales tax across makers is behaving like a facilitator. The network computes *its own* fees; the storefront computes the buyer's tax. *(Confirm facilitator status per state with a SALT attorney before any feature that touches buyer payment — especially before Phase 3.)*
|
||||
|
||||
### Stripe Connect configuration as legal posture — see §7
|
||||
|
||||
The Connect configuration is the hinge on which MTL, facilitator status, *and* merchant-of-record liability all turn, and it is **already specified in §7** ("Money flow"): the stance holds **only at Standard accounts + direct charges + `application_fee`** — maker stays MoR, disputes stay on the maker's balance, the network skims a fee without entering the flow. Destination / separate-charges-and-transfers and Express/Custom accounts each flip one or more of those properties (MoR → platform, dispute liability → platform, indirectly-collecting → true → facilitator). This section only records *why* §7's setting is a legal constraint and not an implementation detail; the dial itself is governed there. **Do not re-decide it here — cross-reference and hold the line.**
|
||||
|
||||
### Raffle / lottery — engineer out the consideration
|
||||
|
||||
The raffle drop is the one primitive (§4) with direct **gambling-law** exposure. A lottery is three elements, **conjunctive: prize + chance + consideration.** Remove any one and it is not a lottery — so the design removes the controllable one.
|
||||
|
||||
- **Free-entry allocation removes *consideration*.** The default raffle/queue mechanic allocates a *right to buy* a scarce drop by random draw, with **free entry** — no payment to enter the draw; the winner then purchases at the normal price like any other buyer. With no consideration paid for the *chance*, there is no lottery: it is allocation of limited inventory by lot, not a paid gamble (the same shape as a "no purchase necessary" sweepstakes — the chance must be free). The OHM frame is *fairness/dignity* in allocation: a transparent, equal-chance draw for scarce goods, not pay-to-play.
|
||||
- **Paid-entry raffle is off by default, pending per-state clearance.** A raffle where the buyer *pays to enter a draw for a prize* re-adds consideration and is a regulated raffle/lottery — generally unlawful for a commercial entity, lawful only for licensed nonprofits in some states, banned or tightly restricted (registration, caps, reporting) in others. It ships **disabled by default**, gated behind explicit per-state legal clearance. *Note the cross-current:* the 501(c)(3) entity (§7) may actually *open* charitable-raffle paths a commercial operator can't use — but that is a flag for counsel and a deliberate per-state opt-in, **not** a green light to turn paid raffles on. *(Verify per state — gambling / charitable-gaming law is among the most locally variable regimes there is.)*
|
||||
|
||||
### Consumer protection / FTC
|
||||
|
||||
Three distinct FTC-adjacent duties: two fall on the maker (the seller / MoR) with the platform easing compliance, one the platform's own architecture already discharges.
|
||||
|
||||
- **Affiliate disclosure (maker's + referrer's duty; platform surfaces it).** The verified-taste-maker tier and any compensated curation must carry clear, conspicuous affiliate disclosure (FTC endorsement guides, 16 CFR 255) — already required by the §7 taste-maker guardrails. The platform's job is to **make disclosure automatic in the UX**: label a disclosed-affiliate pick distinctly from a maker's reputation-staked peer vouch (reciprocal/reputational, not cash-compensated), so the buyer can tell sincere vouch from paid referral. Honest labeling here is a moat property, not just a rule — the whole anti-retail-media stance depends on the buyer seeing the difference.
|
||||
- **The pre-order / 30-day delivery rule (maker's duty; platform UX eases it).** Commitment commerce *is* taking money before delivery, which squarely triggers the FTC **Mail, Internet, or Telephone Order Merchandise Rule** ("the 30-Day Rule"): the seller must ship within the stated time — or within 30 days if none is stated — and on delay must give the buyer notice and a right to cancel for a prompt refund. This is the **maker's** obligation (maker is seller / MoR), but the commitment-commerce engine is uniquely positioned to make compliance the path of least resistance: **require a stated fulfillment window** at drop/pre-order creation, surface it to the buyer at commit time, **prompt delay notices** when a window slips, and support one-click cancel/refund. Compliance-by-design as a product feature — the platform eases the duty without assuming it. (This is also the legal spine of the §10 "verified maker collects pre-orders and ghosts" failure mode: the 30-Day Rule is what a ghosting maker *violates*, and the platform's notice/refund machinery is the buyer's first recourse before any chargeback.)
|
||||
- **Handmade-claim substantiation (platform architecture already answers it).** "Handmade," "original," "made by this person" are advertising claims subject to FTC substantiation; an unsubstantiated claim is a deceptive practice. The **per-item provenance system (§7) *is* the substantiation mechanism** — self-attested classification, audited by the sampling audit + buyer reporting, shown as a buyer-visible badge, with misclassification a verification-revocation trigger. The trust architecture the moat already requires doubles, exactly, as claim-substantiation infrastructure — a place where the product-defining feature and the compliance obligation are the same build. (OHM *value* / *dignity*: the truthfulness of "handmade" is a value claim about the maker's work, which is why it is load-bearing both legally and morally.)
|
||||
|
||||
### Privacy
|
||||
|
||||
Recap the §7 consent architecture, now read as the privacy-law posture.
|
||||
|
||||
- **Two consent domains, two consent layers, source-of-truth in the maker's ESP.** Maker-originating marketing lives in the maker's ESP (authoritative); network-originating communication consent the network owns; the maker decides store participation and the buyer decides cross-network recognition (§7, "Customer ownership, consent & martech"). This separation isn't only good product design — it is what keeps each personal-data flow attributable to a lawful basis and a controller.
|
||||
- **DPAs.** Where the network processes personal data on a maker's behalf (catalog sync, ESP push, the dashboard cache, the buyer feed) it acts as a **processor** and needs **Data Processing Agreements** with makers, plus back-to-back DPAs with its own sub-processors (ESP, hosting, the Connect/BaaS rail). Stand the DPA chain up at onboarding, not after a buyer asks.
|
||||
- **Build to the strictest state law, not a 50-state matrix.** Rather than tracking divergent regimes feature-by-feature, build to the **strictest common denominator** — CCPA/CPRA plus the newer comprehensive state laws (and GDPR the moment EU buyers appear) — and apply it everywhere: real consent capture, access/deletion rights, opt-out of "sale/sharing." The cross-maker identity graph is the privacy-sensitive surface, and it is **opt-in by design** (§7) — so the architecture is *already* aligned with the strictest "no sharing without affirmative consent" reading, rather than retrofitting opt-outs onto a default-share. The OHM *consent* RFC is canonical for what "consent" must mean across these surfaces — cite it; don't re-derive a local definition.
|
||||
|
||||
### Freedom-to-operate / patent
|
||||
|
||||
- **Low-novelty mechanics → low patent-thicket risk.** The commitment-commerce primitives (scheduled drops, pre-orders/deposits, raffle-as-allocation, clubs/memberships, referral links, cross-merchant attribution) are widely practiced, decades-deep mechanics with abundant prior art. The novelty is in the *combination and positioning*, not in any patentable mechanism — so the risk of a blocking patent thicket over the core build is low.
|
||||
- **Do a basic FTO check anyway.** Before building a headline feature, run a basic freedom-to-operate search — particularly for anything resembling a specifically-patented method (one-click-style checkout flows, specific loyalty/attribution or pledge-management methods). Cheap insurance against the rare narrow patent; not a reason for alarm.
|
||||
- **Transparency doubles as defensive prior art.** The radically-transparent / open-books posture (§7 entity) means the design is *published as it is built* — which is **defensive publication**: dated prior art that makes it harder for anyone (including a well-funded incumbent) to later patent the combination and assert it against you. The transparency that serves the verification moat pays a second dividend in patent defense — one posture, two protections.
|
||||
|
||||
### Novelty / competitive finding
|
||||
|
||||
*(Restored — this finding didn't survive the generalization from the miniatures memo and belongs here.)* The honest competitive read: **the components all already exist** — cross-merchant inclusion (Shopify Collective, Carro), pre-order/drop tooling, affiliate/referral networks, verification badges, non-profit governance — but the **specific combination is novel and unproven**: verified per-item provenance + reputation-staked cross-maker referral + native commitment-commerce + non-profit/transparent governance, scoped to one dense vertical. **The moat is positioning and governance, not patents.** Do not expect IP to defend the position; expect the defense to be community standing, the rooted trust graph (§7 verification), the no-walled-garden value rule (Appendix D), and a 501(c)(3) structure a commission-optimized incumbent structurally cannot copy without betraying its own customers (§7, "Why this is the defensible core"). This is the same truth the spine states — *the components exist; the combination is novel but unproven* — carried into the legal lens: novelty here buys no monopoly, so the work is to make the *combination* hard to replicate by other means.
|
||||
|
||||
### Entity structure / UBIT
|
||||
|
||||
- **The 501(c)(3) path** (§7, "Entity structure & trust positioning"): a true non-profit that legally cannot be sold or distribute profits — which is what converts the anti-Etsy promise from a pledge into a structural guarantee. The legal work here is *formation*: exempt-purpose drafting, governance documents, state charitable registration.
|
||||
- **The UBIT tension — flag for nonprofit counsel.** A 501(c)(3) that earns **platform fees and referral fees** is earning income from a trade or business regularly carried on — which raises **Unrelated Business Income Tax (UBIT)**. The crux is whether that fee income is *substantially related* to the exempt purpose (e.g. sustaining independent makers / a charitable-educational mission) or is merely a commercial activity that happens to fund it. Get it wrong and the exposure ranges from UBIT liability on the fee revenue to — if commercial activity comes to *dominate* — jeopardy to the exemption itself. This is the **one place the non-profit choice creates legal complexity rather than dissolving it** (the mirror image of the money-flow stance, which dissolves complexity), and it is genuinely unsettled at this memo's altitude — so it is an early, explicit **flag for specialist nonprofit/tax counsel**, who may reshape the exempt-purpose framing, structure the fee-earning activity to stay "related," or recommend a taxable subsidiary for the commercial layer. Surface it in §8/§9 diligence rather than discovering it post-formation.
|
||||
|
||||
**The through-line, restated.** The pattern of this whole section is one shape: **where the architecture stays out of the money flow, the legal burden largely dissolves** — MTL, marketplace-facilitator tax, and MoR liability are all discharged by the single choice that the maker, on the maker's own processor, is merchant of record. What remains are the exposures that *aren't* a money-flow question — and each gets handled in kind: **engineer it down** (consideration out of the raffle; compliance-by-design pre-order UX; provenance-as-substantiation; opt-in privacy aligned to the strictest law) or **flag it for counsel** (UBIT, paid-entry raffles, per-state facilitator confirmation before Phase 3). The legal strategy is not separate from the architecture — **it is the architecture, audited.** *Flag and verify — not legal advice.*
|
||||
|
||||
---
|
||||
|
||||
## 12. Sustainability economics & health metrics
|
||||
|
||||
The memo is deep on architecture and silent on whether the architecture pays for itself. That silence is the dangerous kind: ventures rarely die of a bad money-flow diagram, they die of a cost base no one modeled. Two questions sit under "is this sustainable," and they are different questions. **Does the fee model cover the cost base, and at what scale?** — the unit-economics question. And **is the network actually working?** — the health/liquidity question that turns the §9 gates from enthusiasm-readings into instruments. Both rest on the same asset: the cross-merchant order history the network already holds to compute referrals (§7, "Storefront architecture & Shopify coexistence"), which is *uniquely* able to see across stores. The *unit-economics* numbers here are deliberately left as variables — n = 2 can't calibrate them (§4) — because the contribution this section makes is the *model and the instruments*, not invented values; the **market-sizing anchors** added below are the one deliberate exception, since external market structure is publicly knowable rather than an uncalibrated internal variable. This whole section turns on OHM **value** (the metric must track *earned* value, not gamed volume) and **trust/recourse** (the trust guarantee is only as good as the funded reliability behind it); where it names one, the canonical RFC governs (top-of-file note).
|
||||
|
||||
### "Non-profit" is not "needn't cover costs"
|
||||
|
||||
The 501(c)(3)/volunteer/transparent structure (§7, "Entity structure & trust positioning") changes *who keeps any surplus* — no one; it cannot be distributed — but it does **not** change the arithmetic that revenue must meet cost or the org folds. Exemption is not exemption from a budget. The structure's funding-viability rested on one specific claim: the dominant historical cost center, engineering, has been deflated by LLMs (§7) — the entity and the opportunity are the same bet. But "deflated" is not "zero." The reliability core (network service, ledger, verification) needs *sustained* funding, not best-effort volunteer cadence (§4), and that floor is a real recurring cost the fee model must carry.
|
||||
|
||||
- **There is a ceiling as well as a floor — the §11 UBIT band.** The mirror of "must cover costs" is "must not look like a commercial marketplace wearing a non-profit badge." A 501(c)(3) earning platform and referral fees raises Unrelated Business Income Tax exposure unless that income is *substantially related* to the exempt purpose, and if commercial activity comes to *dominate*, the exemption itself is in jeopardy (§11, "Entity structure / UBIT"). So the sustainable target is a **narrow band**: enough fee revenue to fund the reliability core off bus-factor-one, framed as sustaining the exempt mission — not so much, or so commercially-shaped, that the org reads as a marketplace that incorporated as a charity. Cost-coverage and UBIT pull from opposite sides onto the same number.
|
||||
- **The discipline §7 names, applied to the numbers.** "Transfer the rigor you'd spend on fundraising onto volunteer sustainability" (§7) has a quantitative edge: a non-profit that cannot model its own break-even is already exhibiting the volunteer-org failure mode — dying of *sustaining*, not *building* (§4). Modeling it is the rigor; the model below is the minimum form of it.
|
||||
|
||||
### Unit economics — does the fee model cover the cost base?
|
||||
|
||||
Per the decision above, this is a **parametric** model: the fee *rates* are design parameters fixed in §7 ("How the platform gets paid"); the volumes and costs are named variables, because the honest values don't exist yet (§8 discovery and the first dozen makers are what populate them).
|
||||
|
||||
**Two revenue lines, both from §7.**
|
||||
|
||||
- **Subscription.** Cold-start makers pay Starter (≈2–4% of captured GMV, no/low monthly); at scale they auto-graduate to Pro (flat **$29–49/mo**, 0%). The flat Pro fee is the *predictable* margin; the Starter percentage is cold-start-friendly but thin on low-GMV makers.
|
||||
- **Referral spread.** On a referred order the platform takes a **fixed ≈3–5% spread** (the network's referral revenue line), and Maker A earns a **negotiated reward above it** (§7, "Referral economics"). The spread is the platform margin modeled here; A's reward is a *non-cashable draw against A's own future platform fees* — so it is **foregone future fee revenue**, not free money. The model must net it: referral activity generates spread *and* erodes subscription/fee revenue as credits are drawn. Treat the credit as a cost line, not a wash.
|
||||
|
||||
**A note on partner-network splits.** Where a maker pays a partner out of its earned referral income (§7, "Partner / consultant network"), the split **redistributes the maker's curator income, not the platform's spread** — the platform stays principal on both legs and its 3–5% margin is unchanged. So partner comp does not move the platform's break-even directly; it is **maker-borne activation cost, paid from the upside**, that lowers the friction of turning a maker into an *active referrer*. Model it as a driver of the referral-activation rate (and thus of the North Star, below), not as a platform cost line.
|
||||
|
||||
**The cost base — three buckets** (the §4 / launch-prompt decomposition).
|
||||
|
||||
1. **Network-service hosting.** The cross-tenant moat service is hosted *separately, always* (§7, "Hosting"); the per-maker storefront cost is largely the maker's own (their processor, their Medusa/Shopify). Mostly a fixed base with mild per-maker/per-order scaling. The §7 pilot (GCP Cloud Run + Cloud SQL, low tens of dollars/month) is the *floor* of this term — and the trap is mistaking that floor for the whole of it (below).
|
||||
2. **Verification labor.** The human cost of the trust gate — staff-verifying the seed/root set early, then the sampling audit, ring-detection, and revocation handling as peer verification scales (§7, "Verification"; §10). Scales with maker inflow and audit volume; the part that needs reliability cannot be best-effort.
|
||||
3. **Reliability core.** The funded floor that keeps buckets 1 and 2 *plus the ledger* off bus-factor-one (its own subsection below). This is the line item the cloud invoice does not show, and the one most likely to be under-modeled.
|
||||
|
||||
**The break-even shape.** Let N be active makers, with per-maker net contribution m (subscription, plus referral spread, *minus* drawn credits, netted across the Starter/Pro mix), and a cost base C = F + v·N where F is the fixed reliability-and-base term and v the marginal per-maker cost (incremental verification + hosting). Break-even is the familiar fixed-cost-coverage form:
|
||||
|
||||
```
|
||||
N* ≈ F / (m − v)
|
||||
```
|
||||
|
||||
Two things fall out of the *shape*, no values required:
|
||||
|
||||
- **This is a fixed-cost-coverage problem, not a margin problem.** Each maker contributes a small but positive m − v (mostly the subscription; the referral spread is thin and partly self-cancelling via credits). The question is therefore not "is a maker profitable" (yes, modestly) but "**how many modest contributions fund the reliability floor F**." That directly *reframes the §9 dozen-maker gate*: a dozen makers validates **demand**; break-even is a larger N governed by how lean F is kept (the §7 LLM-deflated-cost bet is precisely the bet that F is small) and how much per-maker margin the tier mix yields.
|
||||
- **N\* falls as makers *grow* and *refer*, not merely as they're *added*.** A network stuck on cold-start Starter percentages at low GMV barely moves m; break-even improves as makers graduate to flat Pro (margin firms up) and as referral activity lights (spread revenue). So the two levers that move N\* most are **F** (keep the reliability core lean — the §7 bet) and the **Starter→Pro graduation + referral-activation mix** (raise m). Adding low-GMV, non-referring makers moves break-even the least.
|
||||
|
||||
**An illustrative pass — the *shape*, not validated numbers.** To see what the formula implies, fix the variables at plausible midpoints — average active-maker GMV `G` = $30k/yr, Starter take 3%, Pro $39/mo, referral spread 4% — and let the Pro-tier mix and referred share `ρ` mature as the network lights:
|
||||
|
||||
| Makers `N` | on Pro | referred `ρ` | Pro fees | Starter % | Referral spread | **≈ revenue/yr** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2 (pilot) | 0% | 0% | — | $1.8k | — | **$1.8k** |
|
||||
| 12 (§9 gate) | 10% | 5% | $0.6k | $9.2k | $0.7k | **$10.5k** |
|
||||
| 50 | 25% | 10% | $5.9k | $30k | $6.0k | **$42k** |
|
||||
| 200 (density) | 40% | 20% | $37k | $86k | $48k | **$172k** |
|
||||
| 1,000 | 50% | 25% | $234k | $337k | $300k | **$872k** |
|
||||
|
||||
Two readings fall out, both reinforcing the parametric conclusions above. The revenue **mix flips with maturity** — ~90% thin Starter percentage at the §9 gate, but the Pro flat fee and the referral spread carry it by density and beyond (the *graduate-and-refer*, not merely *add*, point). And against a lean reliability floor `F` ≈ $75–150k/yr (the funded core ops + ledger + verification audit + hosting), break-even lands somewhere around **~150–300 makers** under these midpoints — **well past the dozen-maker §9 *demand* gate.** The dozen validates demand; sustainability is a later, larger N, and that gap is exactly the thing this section exists to name.
|
||||
|
||||
**The honest caveat (memo voice).** These are the variables, not values: n = 2 cannot calibrate per-maker GMV, the referred-GMV share, churn, or graduation rate. Naming the model is the point — and the §9 gate should start **instrumenting** the inputs (per-maker GMV, referred share) so that break-even stops being unknown by the time the dozen-maker gate is cleared. The same order-history asset that powers the metrics below makes every one of these variables measurable per maker (§7) — the model and the instruments are the same build.
|
||||
|
||||
### Market size — count makers, not craft-market GMV
|
||||
|
||||
The memo asserts *TAM = every maker* (§7, "Business model") but never sizes it. The sizing discipline that matters: **the unit of TAM is makers, not the dollar size of the craft market** — because revenue is per-maker subscription + referral spread, never a GMV take (§7). A roughly **$0.8–1.2 trillion** global handicrafts market ([Fortune Business Insights](https://www.fortunebusinessinsights.com/handicraft-market-108435)) is a category-scale backdrop, *not* our revenue base; counting *makers who run commit-then-make* is the honest denominator. And note the category distinction from the parametric caveat above: external market structure is publicly knowable, so unlike the *unit-economics* variables (per-maker GMV, churn — calibrated only by discovery), these are **researched anchors** — but anchors are proxies and ranges, not point truths.
|
||||
|
||||
- **TAM — every independent maker who runs commit-then-make.** Federation makes the addressable supply *the entire controllable-storefront + marketplace install base* (§7, "Strategic payoff"), not just makers willing to switch: Shopify reports ~4.8M active merchants (~2.5M live storefronts — [cropink](https://cropink.com/how-many-shopify-stores-are-there)), and Etsy ~5.6M active sellers — *down from ~9M in 2023* as it purged for quality ([Marketplace Pulse](https://www.marketplacepulse.com/stats/etsy-number-of-active-sellers)), which is the authenticity reckoning this thesis rides on, expressed as a number. Not all of these run commit-then-make; the true TAM is that subset — unknowable precisely, but **millions of makers, not thousands**.
|
||||
- **SAM — commit-then-make-native makers, reachable community-by-community.** The model only works where you show up as a member (§5), so the serviceable market is *summed over verticals*, not addressed at once. The beachhead (tabletop minis/dice, Appendix A) sits in a **~$3.8–4.2B/yr tabletop-miniatures market growing ~7–10%/yr** ([DataIntelo](https://dataintelo.com/report/tabletop-miniatures-game-market)); the relevant maker population is the independent casters/sculptors/dice-makers running drops and clubs. Patreon's **~286k paying creators** ([Backlinko](https://backlinko.com/patreon-users)) is a usable proxy for the *commit-then-make creator* population across all verticals — tabletop is one slice, and SAM grows dice → broad tabletop → adjacent craft scenes.
|
||||
- **SOM — left parametric, by design.** This is deliberately *not* a top-down "capture X% of a $Y market" number — that is exactly the fiction the "variables not values" discipline refuses. The obtainable near-term market is governed by the break-even `N* ≈ F/(m−v)` above: clear the **dozen-maker §9 demand gate** in one community, reach **break-even density (~150–300 makers under the illustrative midpoints)**, then compound vertical by vertical. The honest SOM story is *reach self-sustaining density in one scene, then repeat* — not a share of a giant pie.
|
||||
|
||||
### Network-health & liquidity metrics — instrumenting the §9 gates
|
||||
|
||||
The §9 gates are all qualitative — *do makers refer you? is the pain consistent? do they have audiences? can you reach a dozen?* Those are the right questions, but the network's success is fundamentally a **liquidity** outcome (§4: curation fights liquidity, and the graveyard is full of platforms that had supply and no demand). Liquidity needs liquidity instruments.
|
||||
|
||||
**North Star: the share of GMV that is cross-maker-referred.** This is the one number that is near-zero for a pile of disconnected storefronts and rises *only* as the referral network actually does work. A storefront-only success — a genuinely good commitment-commerce tool that never becomes a network — shows a **low North Star**, which is exactly the most-feared failure mode (a good tool that never lights the moat, §4/§5). It measures "the network is the product" (§7, "Business model") directly, in a single figure, in a way no vanity supply count can fake.
|
||||
|
||||
**The leading indicators beneath it** — the funnel that *predicts* the North Star, earliest-first:
|
||||
|
||||
- **Follower growth** — the §2 flow asset (drop-followers accumulating across makers, the embryonic cross-merchant identity graph). The leading-most signal: follows precede referred GMV by definition.
|
||||
- **Curated-By activation** — the share of verified makers who actually *vouch*, and the breadth of their curation. Referred GMV cannot exist without curators curating; a network rich in follows but where makers don't vouch is still dead.
|
||||
- **Drop sell-through** — the commitment-commerce engine's own vitality (do drops clear?). The tool-layer health the network rides on; a stalling engine starves the network upstream.
|
||||
- **Repeat-buyer rate, especially cross-maker repeat** — demand durability, and whether the buyer-side keystone (§13; §8) is real. A buyer who returns *across* makers is the network effect made visible at the buyer level.
|
||||
|
||||
**Mapped onto the §9 gates, making each quantitative:**
|
||||
|
||||
- *"Do makers refer you?"* → Curated-By activation + referred-GMV share (the North Star itself).
|
||||
- *"Do target makers have audiences?"* → follower growth (and the §8 "an audience they'd bring" question, now a number).
|
||||
- *"Consistent pain / a dozen who want the same thing?"* → drop sell-through + Starter→Pro graduation rate.
|
||||
|
||||
**The order-history asset computes all of this for free.** §7 already commits it: *"One asset, three uses — the same order history powers the referral ledger, the network-health metrics, and this reporting."* The cross-tenant vantage is the only place cross-maker-referred GMV and cross-maker repeat *can* be computed — no single-store tool sees across stores (which is itself moat-deepening, §7). So these metrics are not new instrumentation to fund; they fall out of the ledger the referral system already requires.
|
||||
|
||||
**Goodhart caution — the metric must measure *earned* referral.** A North Star is a target, and a target invites gaming. The corrupt way to lift "cross-maker-referred GMV" is to *manufacture* referrals — pay for placement, juice the slots — which is precisely the retail-media drift §3 and §7 ("Curated By This Maker") exist to forbid. The North Star is only valid as a measure of **reputation-staked, earned** cross-referral; optimized the wrong way it rebuilds Etsy's pollution from the inside, through the dashboard. This is the OHM **value** point in metric form: the number must track real value to buyers and makers, not gamed volume — so pair the North Star with the §7 anti-corruption guardrails (structural ranking-neutrality — the platform's spread is constant, so its algorithms ignore referral economics; capped per-maker featuring; per-relationship approval; reputation on the line; uniform rates for non-maker taste-makers) rather than reading it naked.
|
||||
|
||||
### Volunteer-sustainability economics — funding the critical core off bus-factor-one
|
||||
|
||||
§4 names volunteer-core sustainability as **the single point of failure**, and §7 sharpens it: "the risk moved, it didn't vanish" — LLMs make a smaller core go further but do nothing for attrition or bus-factor. The economic question this section must answer is therefore not "what does it cost to *build*" but "**what must the fee model *fund* to keep the critical core reliable when any one volunteer leaves.**"
|
||||
|
||||
Start from §7's own partition of what needs reliability versus what tolerates volunteer cadence:
|
||||
|
||||
- **Must be funded (the reliability core).** *Network-service uptime* — drops are spiky and a storefront falling over *during a drop* is the worst possible moment for maker trust (§7, "Hosting"). The *fee/wallet ledger* — money-adjacent correctness: pending→cleared settlement, clawback, negative-balance handling (§7, "Referral economics"). And *verification* — the root set, sampling audit, ring-detection, and revocation that hold the trust guarantee, where a single polluted "verified" item breaks the guarantee for everyone downstream (§7; §10). None of these can be best-effort.
|
||||
- **Tolerates volunteer cadence.** Themes, storefront nice-to-haves, non-critical features (§7). These can wait on a volunteer's Saturday; the core above cannot.
|
||||
|
||||
So the model's job is to fund *enough* of that reliability core that it **survives any single departure** — concretely, that the fixed term **F is not modeled as pure cloud cost.** The seductive error is to set F to the §7 pilot's tens-of-dollars-a-month GCP bill; the honest F also includes **compensated, documented, more-than-one-deep ownership** of network-service ops, the ledger, and the verification audit. Under-modeling F is exactly how an org clears break-even *on paper* and still dies of bus-factor — the volunteer-org killer §4 warns about, expressed as an accounting omission. "Bind with structure, not promises" (the spine) applied here means the reliability core is **funded, documented, and redundant by design** — not hoped for.
|
||||
|
||||
- **The UBIT mirror is favorable here (§11).** Paying core maintainers from fee revenue is ordinary non-profit operation (reasonable compensation for exempt-purpose work), and funding the *mission's reliability* is far more defensibly "substantially related" to the exempt purpose than accumulating surplus would be. So the sustainability framing actually *helps* the §11 UBIT posture rather than straining it — fees fund the exempt-purpose reliability core, which is the cleanest story to tell counsel. (Still a flag for specialist nonprofit/tax counsel, per §11 — but a flag that points toward "related," not away.)
|
||||
- **This is the trust moat's operating budget — OHM *trust/recourse* made economic.** The buyer's trust guarantee (verification and the §10 accountability machinery) is only ever as strong as the funded reliability behind it; an under-funded verification audit is a trust promise the org *cannot keep*, and a ledger run on best-effort is recourse the org cannot honor. Sustainability economics is therefore not a separate concern bolted onto the moat — it *is* the moat's budget line. Defer to the relevant OHM RFCs for *trust*, *value*, and *recourse*; where a load-bearing one is undefined, defining it is itself OHM work (top-of-file note).
|
||||
|
||||
### The through-line
|
||||
|
||||
"Non-profit" raises the bar on cost discipline rather than lowering it: the org must cover a cost base whose **dominant term is the reliability floor under the trust moat**, fund it off bus-factor-one, and do so inside the §11 UBIT band (related, not dominant-commercial). The unit economics is a **fixed-cost-coverage** problem — `N* ≈ F / (m − v)` — governed by how lean the LLM-deflated core stays and by makers *graduating and referring*, not by per-maker margin or by adding low-GMV makers. The health metrics turn §9's qualitative gates into instruments around a single North Star — **the share of GMV that is cross-maker-referred** — fed by follower growth, Curated-By activation, drop sell-through, and cross-maker repeat, and they fall out of the §7 order-history asset for free. The numbers are still variables (n = 2 cannot calibrate them); the deliverable is the **model and the instruments**, so the §9 gate can start populating them. And the line a sustainable non-profit under-funds at its peril is the one the trust moat rests on — so bind it with structure: **the reliability core is funded, documented, and more than one person deep.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Demand strategy & buyer-side go-to-market
|
||||
|
||||
The memo names demand as the unvalidated keystone everywhere — *the binding constraint* (§4), *earn into the marketplace* (§5), *the unvalidated keystone is demand, not technology* (the spine) — but never attempts a plan; everything concrete is supply-side. This section is that plan, carried to the depth the memo's own logic supports: a buyer-side value proposition stated as its own thing, where buyers actually come from, the content/community posture, and the §8 extension that tests *demand* rather than supply. It is the most consequential section because it is the keystone — and it remains, by construction, the **least validated**: the §8 interviews tested the supply/storefront-convenience layer, not this. The deliverable is the strategy and its test, not a claim that demand is proven. The section turns on OHM **trust** and **value** (the buyer's reason to return must track *earned* value, not manufactured engagement); where it names one, the canonical RFC governs (top-of-file note).
|
||||
|
||||
### The buyer value proposition — a three-pillar stack, not three claims
|
||||
|
||||
The buyer-side "why" is not one reason but three, each doing a different job in the funnel; the discipline is to say what each is *for*, never to lead with all three flatly (a value prop that leads with everything leads with nothing):
|
||||
|
||||
- **Authenticity / provenance — the precondition, not the magnet.** The floor that makes everything else safe. The per-item provenance badge and verification (§7) are the thing a buyer *cannot* get on a polluted Etsy, and the signal **appreciates** as AI-generated and recast fakes proliferate (§3). It is the advertised trust guarantee and the grievance-content angle — but trust is a tiebreaker/enabler, not a thing buyers wake up wanting. In the dice/miniatures beachhead (Appendix A) it runs *hottest*, because recasting is literal piracy there; the weighting across the three pillars is therefore community-dependent.
|
||||
- **The fan relationship — the acquisition-and-retention engine.** Commitment commerce, from the buyer's side, is *being a fan*: following a maker, waiting for the drop, joining the club, holding insider status. This is why the buyer is present at all (they follow a maker) and why they return (the cadence) — the §2 asset of "people who *wait for* makers and commit ahead," seen from the buyer. It is the pillar word-of-mouth carries and the one the beachhead's density compounds.
|
||||
- **Trusted discovery — the compounding mechanism.** "Makers I trust point me to makers I'll love" — Curated-By (§7) as the buyer experiences it. This is the §12 North Star (cross-maker-referred GMV) made human, and it is the *growth lever, not the entry hook*. Hard rule inherited from §7: it is framed as *emerging from makers the buyer chose to follow*, **never** as the platform performing discovery. Leading with discovery-as-destination is the curation-vs-liquidity graveyard (§4/§5) and violates *maker-as-discovery-engine, platform-as-pipe* (the spine).
|
||||
|
||||
### Buyer surface & the "come back" mechanic — phased like the rest
|
||||
|
||||
Mirror the §7 unified phasing — the buyer surface appears late and stays deliberately thin:
|
||||
|
||||
- **Phase 1 — invisible / maker-fronted.** Buyers transact as guests on the maker's own storefront; attribution is stateless (§7), so no buyer identity exists yet. "Come back" runs entirely through the *maker's own* channels — the maker's ESP / IG / Discord "next drop" notice (§7, martech). The platform's only retention role is powering follow/notify; it has no buyer-facing surface and needs none.
|
||||
- **Phase 2+ — the soft destination.** The buyer-facing feed (§7) becomes an *opt-in* surface — "your makers, in one place" — with a light identity buyers return to, every item still traced to a follow. This is a retention **upgrade** layered on the maker's own channels, not a replacement, and pointedly **not** a buyer-facing *brand* a buyer evangelizes ("I shop on X") — that is the marketplace-destination posture §5/§7 reject. The platform earns a thin buyer-facing presence; it never becomes the buyer's primary relationship.
|
||||
|
||||
### Where buyers actually come from
|
||||
|
||||
The conclusion forced by *platform-as-pipe* (§7) and *demand must be earned, not bought* (§4): **buyers do not arrive at the platform; they arrive at makers, and the network compounds them.**
|
||||
|
||||
- **The first ~100 are activation, not acquisition.** They are the founding (invited — §7) makers' *existing* audiences, transacting on the new rails. The platform acquires no one; the test is whether a maker's existing fans will follow them into a drop here. This is precisely why an audience-having maker is an asset and an audienceless one is a cost (§8) — restated as a buyer-acquisition fact.
|
||||
- **The first ~1,000 come from compounding plus supply.** Two engines: cross-maker propagation (a buyer who follows A discovers and follows A's Curated-By makers — the §12 cross-maker-repeat indicator), and more makers onboarding, each bringing an audience. Word-of-mouth inside the one tight beachhead (§5) is the multiplier — the reason density, not breadth, is the correct cold-start move.
|
||||
- **Net-new demand is deliberately deferred — and named, not hidden.** Everything above is *reshuffle and deepening* of audiences that already exist; the buyer feed is **by design** weak at net-new reach (§7). The two net-new hedges are scheduled, not early: **verified taste-makers (Phase 2)** are the genuine net-new-demand engine — community voices who bring *their* audiences (§7) — and **AI shopping agents (Phase 2+)** are net-new reach (§3, §7). Early demand is therefore honestly a reshuffle; pretending otherwise is the graveyard's mistake (§4). *Concede the timeline, not the moat* (§8).
|
||||
|
||||
### Content, community & SEO posture
|
||||
|
||||
The §5 *member, not vendor* principle applied to demand — and an explicit rejection of the marketplace-SEO play:
|
||||
|
||||
- **Embed in the beachhead's existing hubs; don't broadcast.** Show up inside the tabletop Discords, subreddits, painting forums, and conventions as a member of the scene (§5) — the same standing that recruits makers recruits buyers.
|
||||
- **Maker-amplified, not platform-voiced.** The platform's owned content surface is the network digest (§7, network marketing channel): followed makers' drops plus their Curated-By picks, aggregated and personalized but never platform-injected. Buyer-side SEO accrues to *makers' own* verified-provenance product pages, not a marketplace landing page — competing with Etsy/Amazon on generic "shop handmade" search is unwinnable and off-strategy (the discovery war §7 declines to fight).
|
||||
- **One native editorial voice: the authenticity grievance.** "How to spot a real cast," provenance explainers, the anti-recast / anti-AI-slop story — community-native in the beachhead, doubling as SEO and values signaling. Plus the **verified badge as a portable trust mark** makers display wherever they already are (their IG, their leaving-Etsy posts), pulling buyer awareness back to the verified graph.
|
||||
|
||||
### The §8 extension — test demand, not supply
|
||||
|
||||
§8 discovery talked only to makers, and the two interviewed were *greenfield, with no audience* (§8) — so it tested the supply/storefront-convenience layer and **could not test the demand moat at all** (you cannot measure "do a maker's fans follow them here" with makers who have no fans). The buyer-side extension must therefore:
|
||||
|
||||
- **Recruit audience-having makers specifically** — a different discovery target from the greenfield on-ramp. The demand moat can only be probed where an audience exists to move.
|
||||
- **Test behaviorally, not by survey** (§8's *weight what makers do over what they say*, carried to buyers): run a real instrumented drop; run a Curated-By referral between two makers and measure click → follow → buy **propagation** (the keystone network assumption, made measurable); measure provenance's effect on willingness-to-pay / switch; track repeat and cross-maker-repeat (§12).
|
||||
- **Avoid the Shop Pay measurement trap (§8).** Don't ask buyers what they'd value — measure the driver: the repeat-fan vs. first-time-stranger revenue mix, and whether buyers already carry a recognized cross-merchant identity.
|
||||
- **Feed the §9 gates and §12 instruments.** These tests populate the very inputs §12 said the dozen-maker gate should start instrumenting (per-maker GMV, referred share, repeat rate) — the demand strategy and the health metrics are the same build, validated together.
|
||||
|
||||
### The through-line
|
||||
|
||||
The keystone, finally grappled with rather than admitted: **buyers arrive at makers, and the network compounds them** — so the value prop is a three-pillar stack (authenticity the precondition, the fan relationship the engine, trusted discovery the compounding), the surface stays maker-fronted and only *softly* a destination, net-new demand is honestly deferred to taste-makers and agents, and the §8 extension tests it **behaviorally, with audience-having makers, before the platform is built.** It is the most consequential section and remains the least validated — by design, that is the next thing to *earn*, not assume.
|
||||
|
||||
---
|
||||
|
||||
## 14. Open sections to develop (backlog)
|
||||
|
||||
This memo is deep on the architectural/strategic axes (money flow, network mechanics, moat theory, consent) and thin on several operational ones that matter as much or more. The gaps cluster on the un-fun, operational side — which is usually where ventures actually die. Each below is a separate future session. (Trust & safety, legal & compliance, sustainability economics & health metrics, and demand strategy & buyer-side go-to-market were written up in prior sessions — now §10, §11, §12, and §13 — and have left the backlog. Concrete MVP scope, the build roadmap, and the data-model sketch are intentionally *not* tracked here: this is a **strategic memo, not a roadmap/rollout doc** — those belong in the implementation plan the strategy feeds, not in the strategy itself.) Rough priority order; with the demand keystone now drafted (§13, still to be *validated* via its §8 extension), the **live threads** are §10's open reputation-engine work (gathered in that section's last subsection) and the governance mechanics below (#1) — whose *direction* (progressive delegation to network representatives; §7/§10) is now set, but whose machinery is deliberately deferred.
|
||||
|
||||
1. **Governance — progressive delegation (direction set; mechanics deferred).** *Direction set:* a **layered hybrid**. The non-profit (its board, with fiduciary duty) retains the **legal floor and the entrenched core** — the trust guarantee, non-extraction, the no-walled-garden value rule, the out-of-flow stance — which *bind with structure*, not a changeable majority (a 501(c)(3) cannot sell or distribute them — §7). Authority over **maker issues** (verification standards, the provenance line, the §10 standing thresholds — §7, §10, §14 #2) is **progressively delegated to representatives of the network as it scales beyond what the non-profit can manage**, on the same *start-closed, open-as-the-trust-web-earns-it* phasing as the membership gate and peer verification (§7): founder/staff-led at launch, delegated as density demands. The vision is **network self-governance for maker issues, modeled on a functioning democracy**: members *elected* to network roles — dispute-resolution among them — where **holding and discharging a role well is itself a way to earn standing**, the same reputation currency as making and vouching well (§10's positive-reinforcement model). It is also how the org *scales* without the volunteer core adjudicating every dispute (§12). *Still open — and deliberately not fleshed out now:* what those representative bodies are and how they're constituted, how delegation resists capture (the §7 verification concern applied to governance itself), and the concrete appeal/dispute machinery (the §10 governance-appeal-path home). Over-specifying a governance apparatus before the community exists would be premature; the direction is set, the machinery is later work. (Also the body that stewards the §12 UBIT band.)
|
||||
|
||||
2. **Hybrid makers — the making-vs-reselling line (standard).** *Direction set:* provenance attaches **per-item, not per-maker**, via a self-attested, buyer-facing catalog classification (Original / Original + components / Resale – fellow Maker / Resale – third-party), with trust-surface eligibility keyed to it — see §7 "Per-item provenance: the catalog's originality layer." *Still open:* the precise, **auditable line between making and reselling** — purchased supplies don't taint "original," but where exactly do finishing, assembling, and kitting fall? — plus the enforcement/audit hook (ties to §10 accountability) and the exact buyer-facing label wording. Interacts with the consignment/resale "avoid" fork (§7) and the no-walled-garden value rule (Appendix D).
|
||||
|
||||
3. **International tax & cross-border operation (US-only today — flag for specialist counsel).** §11's analysis is entirely US (the *Wayfair* facilitator test, MTL, FTC). But the beachhead is **STL/digital-file-heavy and globally distributed** (UK/EU/AUS casters), so this is a *live* hole, not a someday: **EU/UK VAT on digital goods** (OSS/IOSS — VAT owed in the buyer's country from the first unit) and the EU **"deemed-supplier"** marketplace rule, which can pull a *facilitating* platform into VAT collection on logic that **does not mirror** the US "we fail prong 2" defense — so the out-of-flow stance does not automatically transfer abroad. Adjacent: **PSD2/SCA** on EU recurring club billing (the maker's processor must handle it; a "Standard account" doesn't discharge it), multi-currency display/settlement, and **KYC/AML/OFAC** onboarding for non-US makers/taste-makers on the Phase-2 cashable rail. *Direction: scope which jurisdictions Phase 1 actually serves, and put the VAT/deemed-supplier question to specialist cross-border counsel before international participants are first-class. Flag and verify — not legal advice.*
|
||||
|
||||
4. **Information security & breach posture for the cross-tenant network service.** §7/§11 cover privacy *consent* thoroughly; neither covers *security*. The shared network service holds the single most attractive breach target in the design — **every maker's full order history + buyer PII + the cross-maker identity graph** — and for a *trust* brand a breach is existential, not merely costly. Still open: encryption at rest/in transit + key management, tenant-isolation and least-privilege access to the cross-tenant store, secrets handling, and an incident-response / breach-notification plan (state laws + the GDPR 72-hour clock). *Direction set here: infosec of the network service belongs in the §12 **funded reliability core** alongside the ledger and the verification audit — a trust-moat budget line, not a volunteer-cadence nice-to-have; the concrete controls are later work.*
|
||||
|
||||
5. **Content moderation beyond authenticity (third-party IP / DMCA / prohibited goods).** Provenance verifies *handmade*, not *lawful to sell*. The minis/tabletop beachhead carries a heavy **third-party-IP/DMCA** load (fan-sculpts of others' IP; the Games Workshop takedown culture), plus counterfeit, regulated, and offensive-content surfaces — and the network *amplifies* whatever it surfaces (Curated-By, the buyer feed, the agent feed), so it inherits **amplification / contributory liability** distinct from "is it handmade." Still open: a DMCA §512 notice-and-takedown posture + designated agent, an IP-complaint / repeat-infringer policy, and the line between *verified original craft* and *originality of the depicted IP* (a verified maker can still infringe). Interacts with verification (§7) and accountability (§10). *Flag for counsel; design the takedown path before the agent feed amplifies at scale.*
|
||||
|
||||
6. **Verification methodology — the evidentiary act (currently treated as a primitive).** §7 specifies the verification *graph* (rooted, staked, multi-vouch, sampling audit) and §10/#1 above defer the reputation *engine* — but **how a verifier actually establishes that a human makes original work** (what proof, what process, what staff and peers inspect) is neither specified nor flagged, and it is the literal foundation of the trust moat and the §11 FTC-substantiation claim — the hard adversarial core in an AI-fake world. Still open: the **provenance-documentation standard** (studio evidence, work-in-progress, live demo?), the staff seed-set method, and what a peer verifier must attest. *Name it as load-bearing open work, not a solved primitive.*
|
||||
|
||||
7. **Support & dispute operations — the function and its cost.** §12 funds the reliability core (uptime, ledger, verification audit) but never a **support/ops function**: maker tickets (a broken sync at drop time), buyer-harm report intake, and the verification-revocation / appeal queues (§10). For a volunteer-built, money-adjacent, trust-critical platform this is a real recurring **F-term omission** and an operational-credibility question. *Still open: the support model, triage/SLAs for money-adjacent vs cosmetic issues, and its line in the §12 cost base.*
|
||||
|
||||
8. **Competitive engagement: creator-commerce tools.** The "no continuous (non-campaign) platform treats commit-then-make as first-class" claim (§2) is load-bearing and currently engages only Etsy/Shopify/Patreon/Kickstarter/Gamefound. The sharper counter-examples a skeptic raises are the **creator-commerce tools** — Gumroad, Payhip, Ko-fi Shop, Fourthwall, Lemon Squeezy — several of which already do drops + memberships + digital delivery at low fees. *Still open: a head-to-head that substantiates "first-class, not covered" against these specifically — the likely cut being that each does the storefront/transaction but none does the cross-maker reputation-staked referral network (the moat), and most treat the cadence as features rather than the spine; verify the claim rather than assert it.*
|
||||
|
||||
9. **Lower-priority flagged items (named, not yet developed).** (a) **cuttle.xyz / generative-tool integration** is cited as a differentiator (§2) without substance — develop what the integration is and why it's defensible, or demote it to a mere example. (b) **Catalog-sync reliability at scale** — N adapters against rented APIs (Shopify rate limits, webhook-delivery failure, deprecation cycles, reconciliation) is a chronic ops burden the moat surfaces depend on. (c) **Accessibility** (WCAG/ADA) for generated storefronts and the buyer feed — a compliance surface and an OHM-*dignity*-aligned one. (d) **Trademark / certification mark for "verified"** — a certification mark is the natural instrument to protect the badge from imitation (FTO/patent is covered in §11; this isn't). (e) **Platform wind-down plan** for the network asset — given volunteer-sustainability is the named #1 risk (§4), what becomes of the cross-maker graph, follows, buyer accounts, and outstanding wallet credits/payables if the org folds. (f) **Team / execution capacity** — the docs argue the model is *affordable*; named team/board/recruiting capacity is a separate, unaddressed question (arguably a roadmap/ops-doc concern more than a strategy one).
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Choosing a beachhead vertical (and sequencing expansion)
|
||||
|
||||
The platform serves makers in general, but it must *launch* into one dense community. This is the selection method, with miniatures as the worked candidate.
|
||||
|
||||
**The filter.** Score candidate communities on: made-to-order/drop motion; non-fungible inventory; hybrid digital + physical; recurring drops; variant explosion; audience-having makers; authenticity grievance; scalper/counterfeit problem; a reachable community hub. The recurring finding across verticals: the storefront/build layer is already commoditized (Shopify, Fourthwall), so the prize is the **demand-aggregation / curation layer** (flow). The right target is a vertical where that layer is *unoccupied* and you can belong to the community.
|
||||
|
||||
**Worked example — miniatures as the beachhead.** Miniatures express every form of the commit-then-make primitive at once: recurring monthly STL/digital releases (Patreon / Cults3D model), digital delivery with licensing, *and* physical resin/metal casting that's pre-order or made-to-order, plus heavy variant explosions (scale, material, painted/unpainted) and army-builder bundles. That membership + digital + physical-preorder combination is exactly where Shopify is mediocre. The expansion scan:
|
||||
|
||||
| Vertical | Pros | Cons | Flow layer | Adjacent to minis? | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **Resin dice** | Drop culture is the *default* motion; large maker audiences; vivid authenticity grievance (cast-copies undercutting real casters); active scalper market makes queue/anti-flip valuable; drop/queue/commission infra **unserved**. | Lower price points; casting is labor-intensive; design-theft enforcement complexity. | **Open.** | **Yes — same tabletop buyer.** | **Top pick / first expansion.** Reuse minis' made-to-order/pre-order/variant/provenance primitives; add drop-scheduling, queue/raffle, anti-scalper as net-new. Compounds community density, no second cold-start. |
|
||||
| **STL / digital files** | Shares minis' digital gnarl (tiered licensing, membership drops, re-upload piracy where provenance defends); huge audience. | Most contested and **consolidating** — MyMiniFactory bought Thingiverse; Cults3D, Patreon, MakerWorld entrenched. | **Owned.** | Yes (minis' digital half). | **Partner/coexist; don't build.** Own the physical + storefront + cross-maker network it doesn't touch. |
|
||||
| **Hand-dyed yarn** | Non-fungible inventory (dye lots); dyed-to-order pre-orders; yarn clubs = subscription drops; variant explosion; tight community (Ravelry + IG); strong anti-mass ethos. | Shopify + apps cover the storefront; **a curated demand-aggregator already exists (Indie Untangled)**; non-adjacent buyer = full second cold-start. | **Partly owned.** | No. | **Strong-but-contested; validate first.** Best "wide" option, but probe whether the incumbent has *earned* switching-cost loyalty or merely lightly occupies the niche. |
|
||||
| **Custom knives** | High value/unit; waitlist/lottery/deposit by default; secondary market makes queue integrity valuable; collector culture. | **Entrenched decades-old curated marketplaces own the flow** (Arizona Custom Knives, Noblie); blade-shipping regulatory drag; non-adjacent buyer. | **Owned.** | No. | **Deprioritize.** Dislodging earned loyalty *plus* weapons-shipping compliance. |
|
||||
| **Enamel pins** | Large audiences; campaign/pre-order motion; LE secondary market; B-grade sub-market. | Largely **design+manufacture (POD), not craft**; grievance is art theft (originality), not handmade provenance; Fourthwall holds the creator-merch layer. | **Owned.** | No. | **Skip.** The product isn't really handmade, so a verified-*provenance* moat doesn't fit. |
|
||||
|
||||
**Strategic conclusion — deep-and-adjacent over wide.** Dice wins on every axis *and* shares the buyer, so the arc is **beachhead → dice → broad physical tabletop** — one buyer, one community, one compounding set of drop-and-provenance primitives. Yarn/knives are "wide" bets into scenes where the flow layer is already held by an embedded member — the "don't fight where someone's embedded" trap. Of the wide options, only yarn merits a validation probe before being ruled out.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — The commitment-commerce layer: incumbents & the open gap
|
||||
|
||||
"This feels Kickstarter-ish" is correct, and the incumbents are specific. The crowdfunding/pledge-management space is large, mature, and consolidating — but built for **episodic, project-scale campaigns**, not an individual maker's **continuous drop cadence.** That distinction is the entire opening — with one correction this appendix originally missed: there are **two** incumbent shapes, not one. The *episodic* campaign players below (Kickstarter / Gamefound / BackerKit), and — easy to miss because it doesn't look like crowdfunding — the *continuous-membership* incumbent, **Patreon**, which already serves the monthly-club primitive and sits *inside* the beachhead (its own treatment after the table). (The pattern for the campaign players: every one started as the tool managing the gap between committed demand and delivery — pledge management — then grew up into the funding layer.)
|
||||
|
||||
| Player | What it is | Built for | Relevance |
|
||||
|---|---|---|---|
|
||||
| **Kickstarter** | All-or-nothing campaign crowdfunding; no integrated pledge manager | Episodic, project-scale campaigns | The launchpad |
|
||||
| **Gamefound** | Tabletop-native; pledge-manager → full crowdfunding platform with late-pledge stores | Episodic tabletop campaigns + post-campaign stores | **Sitting in your adjacent vertical**; fast-growing, Kickstarter's biggest tabletop rival |
|
||||
| **BackerKit** | Pledge-manager (surveys/shipping/tax/add-ons) → also crowdfunding | Post-campaign fulfillment + campaigns | "Mission control" for fulfillment |
|
||||
| **Patreon** | Per-creator recurring memberships; platform is MoR, processes the charge (~8–12% all-in), pays out | **Continuous** creator membership (the monthly club) | **The recurring-club incumbent — and it's *inside* your beachhead** (the Patreon/Cults3D model in minis, Appendix A); a sharper competitor than Shopify for the club slice, on the wrong side of three invariants — treatment below |
|
||||
|
||||
**The open gap (where to play):** none of these *campaign* players serve the maker running a small drop every other Saturday or a 10-piece lottery — and the **monthly club**, the one continuous-cadence slice that *does* have an incumbent, is served by **Patreon** on the wrong side of the invariants (below). The unserved space is **continuous commitment-commerce cadence** — the recurring, relationship-driven, small-batch motion *between* Shopify (continuous but stock-only) and Kickstarter/Gamefound (commitment but episodic). The recurring strategic shape (the same as the rest of this memo): there's always an entrenched incumbent owning the *episodic/distribution* layer — Shopify (stock), MyMiniFactory (file distribution), Gamefound (campaigns) — and the open prize is the *continuous cross-maker relationship* layer they don't serve. **Coexist with the episodic incumbent; own the continuous demand-relationship network.**
|
||||
|
||||
**Patreon — the continuous-membership incumbent inside the beachhead.** Patreon is *not* §2's stock-then-sell foil the way Shopify is: a monthly club is already *commit-then-make* (patrons commit ahead; the creator produces against it), so Patreon is genuinely doing this category for the recurring-club/membership primitive (§2, §6, §7) — and in miniatures it's the de-facto infrastructure (the "Patreon / Cults3D model", Appendix A). That makes it a *sharper-edged* competitor than Shopify for the slice it touches, and an unavoidable one: your first community lives on it. But it sits on the wrong side of three invariants at once —
|
||||
|
||||
- **In the money flow.** Patreon is MoR, processes the recurring charge, takes ~8–12% all-in, and pays out — precisely the thing §7/§11 design out. A native replacement must keep the maker MoR on their own processor (Stripe Billing on a **Standard account + direct charges + `application_fee`**, §7). *Recurring billing is the sharpest test of that dial*, because Patreon's whole model is "platform is MoR for a subscription" — one config-flip away, and the flip would quietly rebuild Patreon.
|
||||
- **A walled garden on the buyer.** Run Patreon through Appendix D's three-surfaces gate and it scores like **Etsy, not Shopify**: you can't host a Curated-By block on a Patreon page, you can't attribute a referral through Patreon checkout, and Patreon owns the patron payment relationship. By the value rule it's an **invitation target, not an integration/destination** — "I'd feature your work the moment you own your commerce." The one federatable seam is **patron-email export → seed the maker's ESP** (§7, ESP-as-source-of-truth): billing and delivery you want native, the *list* you can lift.
|
||||
- **No moat, and structurally can't grow one.** Patreon is single-creator; its cross-creator discovery is platform-performed engagement-algo — the inverse of *maker-as-discovery-engine* (§7 buyer feed) — with zero reputation-staked, attributed cross-maker referral. It won't build that, for the same *shape* of reason Shopify won't (§7, "Why this is the defensible core") but a different specific one: its business *is* the captive recurring relationship and the algorithmic discovery surface. So it contributes nothing to your North Star (§12, cross-maker-referred GMV) — every patron on Patreon is a follow you never capture into the cross-maker identity graph.
|
||||
|
||||
**The play is two-sided, and already latent in the sequencing (§5).** *Act 1 (Tool) — out-tool it:* the engine already lists "recurring clubs/memberships" + "digital-file delivery + licensing" as primitives (§2, §6); for minis that *is* the Patreon feature set (monthly STL drop, tiered licensing, patron list). Build it **maker-MoR**, grant **usage-rights ownership of the patron** (§7 — the exact inversion Patreon doesn't offer), lower the all-in fee, and wire it into the network. (Appendix-A discipline holds: don't build a Cults3D/MyMiniFactory *file marketplace* — "partner/coexist; don't build" — but the club mechanic + storefront + network layer is yours.) *Act 2+ (Network) — out-flank it:* the cross-maker referral/feed is the durable reason a maker prefers you **even if Patreon matched the club features** — which it can't, without becoming a different company.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C — Composite multi-maker kits
|
||||
|
||||
A kit combining products from multiple makers, sold as one SKU on a maker's storefront — the deepest expression of maker collaboration, a natural Curated-By extension, a strong "kit drop" — but it presses hardest on the one line the architecture defends: **custody of funds.** Two governing rules. For the kit creator: **be a *principal reseller* of the kit, never a *conduit* aggregating others' sales** (principal is a product; conduit is money transmission). For the platform: **coordination and bookkeeping are free; custody — funds resting in an account you control — is the line.**
|
||||
|
||||
### C.1 The retail model
|
||||
|
||||
One charge means one merchant of record and one payout destination; "one SKU, N makers, money perfectly siloed" is a contradiction.
|
||||
|
||||
| Model | Mechanism | Verdict |
|
||||
|---|---|---|
|
||||
| **1. Lead maker = MoR, others = suppliers** | A sells the kit as A's own SKU on A's processor; A owes X/Y wholesale COGS. A is a principal reseller (legitimate drop-ship/wholesale, **not** transmission). | **Recommended.** Single charge, single MoR, out of the consumer flow. The virtual-kit/BOM pattern with components from other makers. |
|
||||
| **2. Curated kit, no unified checkout** | Themed Curated-By bundle; each component hands off to its own store. | Free but weak — N transactions in a bundle costume; not a true SKU. |
|
||||
| **3. Facilitated split-payment** | One checkout, auto-split to N connected accounts (Connect destination charges) — Shopify Collective's mechanism. | Cleanest *experience*, but you become facilitator / in the flow. Avoid until you choose to be marketplace-of-record for kits. |
|
||||
|
||||
Model 1 has two physical variants: **A assembles** (components ship to A, A builds + ships one box) or **A drop-ships** (X/Y ship directly; A is still MoR and still owes wholesale). C.6 is the decisive reason to prefer assembly.
|
||||
|
||||
### C.2 Supplier settlement — how A pays X and Y
|
||||
|
||||
**Custody is the line:** the moment funds rest in an account you control and you pay them onward, you're a transmitter. "Facilitate without being in the flow" means facilitating the *coordination* (or netting on your own account as principal), never custody.
|
||||
|
||||
**The default stance: the kit merchant (A) handles every supplier obligation; the platform moves no money at all.** A is MoR and a principal reseller, so A owes X and Y on whatever wholesale terms they agreed and **settles with them directly, off-platform**, the way any two businesses do. The platform's role is **transparency, not transfer**: it can *notify* a supplier when their item sells in someone else's kit ("your *Frostfang Drake* sold 3× in **Hearthforge**'s 'Winter Warband' kit"), so makers see their cross-maker pull — **without facilitating any payment between them.** Notification is pure coordination (free); routing a single dollar from A to X is custody (the line). This knowingly re-weighs the peer-to-peer friction below: accept the friction (notification softens it) to keep the platform *entirely* money-free. Models B and C are then **optional conveniences a maker may elect**, never the platform stepping into the flow.
|
||||
|
||||
| Model | Mechanism | In the flow? |
|
||||
|---|---|---|
|
||||
| **A. Bookkeeper / peer-to-peer (default)** | Platform records the payable and *notifies* the supplier; A→X money moves directly, off-platform. | No — the cleanest; notification offsets the "makers chasing each other" friction. |
|
||||
| **B. Net through the existing fee ledger** | X's receivable → credit in X's fee-offset wallet; A's payable → charge on A's next invoice. Principal on both sides. | No — *for the non-cashable portion.* **Optional, maker-elected** (still cashless: a fee credit, not a transfer). |
|
||||
| **C. Connect direct payout** | Buyer pays A; platform routes component cost to X/Y connected accounts; Stripe is the transmitter. | Yes — scoped re-entry. **B2B-first is the safest place to cross** (verified, KYC'd, mandate-on-file makers). |
|
||||
|
||||
**Supplier choice with a carrot:** beyond the money-free default (A), a supplier may elect **non-cashable wallet credit** (nets their own fees, out of flow) or **Connect payout** (real money, scoped in-flow), with a **bonus for wallet**. Caveat: wallet credit only helps a maker who *has fees to offset* — a **pure supplier** (supplies many kits, rarely sells) accrues trapped credit and needs Connect regardless. Plan for both. Phasing: Phase 1 = Models A/B (record + notify + net; settle the remainder peer-to-peer); Phase 2 = Model C for pure suppliers, on the shared cashable rail.
|
||||
|
||||
### C.3 Fee economics — taxed once
|
||||
|
||||
Components carry a referral fee (supplier bears it, as in Curated-By) — but **tax each kit dollar once.** Example: kit retails at R; wholesale $40 (X) + $30 (Y) = $70 COGS to A. X's $40 → 15% = $6 platform, X nets $34; Y's $30 → 15% = $4.50, Y nets $25.50; A pays the standard platform fee on **A's own margin (R − $70)**, not the full R. Charging A's full fee on R *and* referral fees on the components would double-tax the $70. All of it runs through the ledger off the order event, never the consumer payment.
|
||||
|
||||
### C.4 Where kits can be sold — inventory integrity, not fees
|
||||
|
||||
The fee/settlement machinery works on *any* storefront (computed from the order webhook + the kit BOM in your network service), so a **Shopify maker can sell a kit, be MoR, and have settlement run on the webhook.** What Shopify can't give is a **real-time cross-maker stock check at purchase** (you don't control its checkout), so finite-stock components risk a sync-lag oversell. Therefore: **finite-stock kits → favor your Medusa storefront** (you control checkout: atomic availability gate + atomic order+payable); **made-to-order kits → storefront-agnostic** (no finite-stock race; kit lead time = max of component leads = pre-order semantics). v1 scoping: launch kits as a Medusa-seller feature; optionally allow made-to-order kits for Shopify sellers; defer Shopify finite-stock kits.
|
||||
|
||||
### C.5 Wholesale bookkeeping — including volume tiers
|
||||
|
||||
Storing "X charges A $W/unit, tiered by volume" and computing settlement from units is **pure facilitation (out of flow).** Two details tiers force: **retroactive vs. prospective** (when the price drops at unit 11, do 1–10 reprice or only 11+? — a *term A and X agree to*, which the platform stores and applies); and **stateful settlement** (tiered pricing depends on cumulative units, so settlement is a running total per supplier-agreement, not per-order-independent — build the ledger for that from the start).
|
||||
|
||||
### C.6 Shipping & fulfillment — the decisive argument for assembly
|
||||
|
||||
A kit drop-shipped from N makers = **N shipments; shipping scales with maker count, not order** (≈3× for three makers). The premium lands somewhere and every option hurts: on the **buyer** (visible combined shipping — a conversion killer on a "deal" kit), **A** (uncontrollable margin variance), or **suppliers** (inflated COGS). Managing it, best to worst: **(1) assemble-and-consolidate (default)** — components ship to A, A packs one box; fixes three problems at once (single shipping cost, single-package experience, QC liability), at the cost of A doing micro-fulfillment (which *is* A's value, and justifies the margin); **(2) 3PL/hub consolidation** (defer — overkill at indie volume); **(3) honest drop-ship** ("ships in N packages," disclosed — reserve for when consolidation is impossible); **(4) shipping-smart kit construction** — surface estimated combined shipping *at kit-design time* and prefer same-region makers (your network sees cross-maker geography no single maker can).
|
||||
|
||||
| Components | Fulfillment | Result |
|
||||
|---|---|---|
|
||||
| In-stock | Consolidate | One box, fast, clean — **best case** |
|
||||
| Made-to-order | Consolidate | One box, but kit lead = max(component leads) + assembly hold |
|
||||
| Made-to-order | Drop-ship | N boxes, N arrival times, N charges — **worst; avoid** |
|
||||
|
||||
**Through-line:** shipping argues the kit creator should be a real **assembler-principal**, not a thin aggregator. The clean money model (principal reseller) and the clean shipping model (assemble-and-consolidate) point at the same role for A — the signal the design is coherent.
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Storefront control & network participation (why walled gardens are out)
|
||||
|
||||
The gating property is **control of the three surfaces the network must touch**: the **storefront page** (to host the verified merchant referral collection), **checkout** (to attribute referrals and run clean agentic purchase), and the **buyer relationship** (to own identity). Merchant-controlled storefronts grant all three; walled-garden marketplaces deny all three *by design* — intermediating the buyer is the marketplace's business model, so participation **degrades monotonically as a maker cedes control to a walled garden.**
|
||||
|
||||
Layered on top is a value rule stricter than the technical limits: **the network never routes buyers *into* a walled garden — not via Curated-By, the buyer feed, or the agent feed.** So walled-garden makers are **invitation targets, not destinations** — even where a marketplace's API would permit ingesting their catalog, the value rule forecloses surfacing it as a buyable destination.
|
||||
|
||||
Legend: ✓ supported · ◐ partial/limited · ✗ not supported.
|
||||
|
||||
| Provider | Catalog sync-in | Be a curator | Be a network destination | Clean referral-fee attribution | Owns buyer | Clean agentic checkout |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Your white-label (Medusa)** | ✓ native | ✓ you build the page | ✓ controllable + value-OK | ✓ you own checkout | ✓ maker owns relationship | ✓ you expose ACP |
|
||||
| **Self-hosted (Woo, Saleor, Vendure, custom)** | ✓ open APIs/plugins | ✓ full page control | ✓ controllable + value-OK | ✓ controls own checkout | ✓ owns buyer | ✓ their build exposes ACP |
|
||||
| **Shopify** | ✓ Admin API + webhooks | ✓ theme app-extension block | ✓ controllable + value-OK | ✓ cart attr → order webhook (any plan) | ✓ merchant owns customers | ✓ their processor |
|
||||
| **BigCommerce** | ✓ Catalog/Admin API | ✓ open storefront | ✓ controllable + value-OK | ✓ controls checkout | ✓ owns buyer | ✓ their processor |
|
||||
| **Squarespace / Wix** | ◐ commerce APIs (read) | ◐ code-injection, no clean app-block model | ✓ controllable, value-OK | ◐ checkout more closed — coupon/landing only | ✓ owns buyer | ◐ depends on platform |
|
||||
| **Etsy** | ◐ Open API v3 — *unused as a destination by value rule* | ✗ can't modify the page | ✗ **value rule** — invitation target only | ✗ Etsy owns checkout | ✗ Etsy owns the buyer | ✗ Etsy's decision |
|
||||
| **eBay** | ◐ APIs (read) — *unused* | ✗ can't modify the listing | ✗ value rule; invitation target | ✗ eBay owns checkout | ✗ eBay owns the buyer | ✗ eBay's decision |
|
||||
| **Amazon Handmade** | ✗ gated/restricted API | ✗ zero storefront control | ✗ value rule + Amazon owns all; invitation target | ✗ Amazon owns checkout | ✗ Amazon owns the buyer (most completely) | ✗ Amazon's own agents treat you as a competitor |
|
||||
| **Patreon** *(membership)* | ◐ API reads tiers/posts — *unused as destination by value rule* | ✗ can't host a Curated-By block on the page | ✗ value rule; invitation target | ✗ Patreon is MoR for the subscription | ✗ Patreon owns the patron — *one seam: email export* | ✗ Patreon's decision |
|
||||
|
||||
The table's shape *is* the thesis: **controllable storefronts ✓ across; walled gardens ✗ across.** Not a coverage gap — a restatement of who the network is *for* (makers who own their commerce, or will) and what it's an alternative *to*.
|
||||
|
||||
**The three-surfaces gate:** storefront-page control → be a curator; checkout control → attribution + agentic checkout; buyer-relationship control → identity/sovereignty.
|
||||
|
||||
**Walled gardens are who you're an alternative to — not a gap to cover.** A maker deep in Amazon Handmade who can't participate is the person the pitch is *aimed at*, who hasn't left yet. The right move is **invitation, not integration**: "I'd feature your work the moment you own your commerce" — the absence of a link is the recruiting signal, making membership the price of inclusion rather than subsidizing captivity. Notes: Etsy is the *most permissive* walled garden (Open API v3, models made-to-order, runs an affiliate program) but the value rule forecloses using it as a buyable destination anyway; a maker on *both* Etsy and a controllable storefront participates via the controllable one. Amazon Handmade is the most walled and the most hostile (restricted API, total buyer ownership, its own agentic-commerce ambitions). The **verification inversion** becomes a recruiting message: "we'd vouch for your work — verified independent of any marketplace's compromised badge — the moment you own your commerce."
|
||||
|
||||
**Patreon is the *membership-side* walled garden — the gap this table originally missed.** Every other walled garden above is a *transactional* marketplace (one-shot sales); Patreon applies the identical captive-commerce model to *recurring relationships*, and — unlike Etsy/Amazon for most makers — it is the **actual home of the beachhead audience** (Appendix A; the recurring-club incumbent, Appendix B). On the three-surfaces gate it scores like Etsy, not Shopify: no page control (no Curated-By block), no checkout control (no referral attribution — Patreon is MoR for the subscription), no buyer ownership (Patreon owns the patron) — the lone federatable seam is **patron-email export → seed the maker's ESP** (§7). So the Patreon posture is the same **recruit-out, don't integrate** rule, but materially more load-bearing than the Etsy one the memo otherwise leads with: the tension to face head-on is that *your first community lives on the very walled garden you are recruiting them off of* (§4). The answer is the two-sided play in Appendix B — **replace the club** (native, out-of-flow, maker-MoR) **+ lift the list + out-flank with the network.**
|
||||
|
||||
---
|
||||
|
||||
## Recurring principles (the spine)
|
||||
|
||||
Every decision in this memo reduces to a few invariants worth stating once, plainly:
|
||||
|
||||
- **The network is the moat; the storefront, hosting, and Medusa are fungible means in service of it.**
|
||||
- **Bind with structure, not promises** — non-cashable wallets, verification gates, sovereignty-by-policy, the non-profit entity, the no-walled-garden rule. The wrong path is foreclosed, not merely disavowed.
|
||||
- **Custody is the line; coordination and bookkeeping are free** — cross into the money flow once, knowingly, with rented infra (the Phase-2 cashable rail), never by accident.
|
||||
- **Maker-as-discovery-engine, platform-as-pipe** — discovery is delegated to makers the buyer chose, never platform-performed.
|
||||
- **Gate the demand, not the tool** — the storefront stays open to all; the network's *demand* surfaces (Curated-By, feed, referrals, agents) are what's gated, by **verification** (entry) and **reputation** (ongoing standing) alike. Makers chase trust rather than resent it.
|
||||
- **The unvalidated keystone is demand, not technology.** The components all exist; the combination is novel but unproven. Everything is gated on §8: whether enough audience-having, vouch-willing makers exist in one tight community.
|
||||
@@ -0,0 +1,814 @@
|
||||
---
|
||||
slug: maker-platform-pr-faq
|
||||
title: "Maker Platform — PR-FAQ"
|
||||
state: super-draft
|
||||
id: null
|
||||
repo: null
|
||||
proposed_by: ben.stull@wiggleverse.org
|
||||
proposed_at: '2026-06-15'
|
||||
graduated_at: null
|
||||
graduated_by: null
|
||||
owners:
|
||||
- ben.stull
|
||||
arbiters: []
|
||||
tags: []
|
||||
---
|
||||
|
||||
# Wiggleverse Maker Collective - A Platform for Makers to Connect — PR-FAQ
|
||||
|
||||
> **What this doc is.** An Amazon-style **PR-FAQ** ("working backwards") version of
|
||||
> [`maker-platform-strategy.md`](./maker-platform-strategy.md). It opens with a
|
||||
> future-dated *press release* written as if the product had already launched,
|
||||
> then answers the questions a smart skeptic would ask. It is a communication
|
||||
> artifact, not a new strategy — every claim traces to the memo, with section
|
||||
> citations (`§7`, `Appendix C`, …) into it. Where the two disagree, **the memo
|
||||
> wins** (and the memo in turn defers to the [Open Human Model](https://rfc.wiggleverse.org/p/ohm/c/default/) on load-bearing concepts).
|
||||
>
|
||||
> **Audience.** Technology experts who know Etsy/Shopify as users but aren't
|
||||
> commerce specialists — so commerce jargon (merchant of record, money
|
||||
> transmission, marketplace-facilitator tax, chargebacks, GMV) is defined inline;
|
||||
> architecture is not.
|
||||
>
|
||||
> **Product name.** "Wiggleverse Maker Collective"
|
||||
|
||||
---
|
||||
|
||||
## PRESS RELEASE
|
||||
|
||||
### Wiggleverse launches Maker Collective, a verified-maker network where makers keep their own customers — and far more of every sale than on Etsy
|
||||
|
||||
**A verified-maker network where independent makers run customized orders, drops, pre-orders, and
|
||||
clubs — not just ready-made inventory — and earn demand by vouching for each other, not by buying ads. The maker
|
||||
keeps their own storefront, checkout, customers, and far more of every sale — all-in
|
||||
fees around 4–7% versus Etsy's ~20%.**
|
||||
|
||||
**SEATTLE, WA — August 1, 2026** — Maker Collective today opened to
|
||||
its first community of independent makers — the tabletop-miniatures scene: a
|
||||
commerce platform built around the way makers actually sell. Where Shopify and Etsy assume *stock-then-sell* — make
|
||||
inventory, shelve it, wait for a buyer — many makers run on *commit-then-make*: collect
|
||||
committed demand first (a drop, a pre-order, a deposit-and-waitlist, a monthly club, a made-to-order
|
||||
commission), then produce against it. Maker Collective is built for that motion
|
||||
end to end, and adds something no storefront tool has: a **reputation-staked
|
||||
referral network** where makers send each other real buyers.
|
||||
|
||||
**The problem.** The tools makers rely on serve them poorly at exactly the moments
|
||||
that matter. Generic storefronts treat a scheduled drop or a 10-piece lottery as
|
||||
an afterthought, and marketplaces have drifted the other way: Etsy, founded on
|
||||
"handmade," is now flooded with mass-produced and AI-generated goods, so the
|
||||
buyer can no longer tell what's authentically created by a Maker. Makers are left choosing between a tool that
|
||||
doesn't fit and a marketplace that has stopped standing for anything — while
|
||||
paying marketplace fees that can approach 20% of each sale.
|
||||
|
||||
**The solution.** Maker Collective is two things at once. First, a
|
||||
**commitment-commerce engine** — scheduled drops, pre-orders and deposits,
|
||||
raffle/queue allocation, recurring clubs, made-to-order workflows, digital-file
|
||||
delivery — that runs on the maker's *own* storefront and *own* payment processor.
|
||||
Second, a **verified merchant referral network**: each maker's storefront (and, optionally, email marketing content) carries
|
||||
a "Curated By This Maker" section featuring other *verified* makers whose work
|
||||
they genuinely admire, with the curating maker's reputation on the line. Placement
|
||||
is *earned*, never sold — the opposite of pay-for-placement advertising. Every
|
||||
item carries a buyer-visible **provenance badge** (original / partly original /
|
||||
resale), so a buyer always knows what they're buying, and the platform never
|
||||
points a buyer at an Etsy or Amazon listing.
|
||||
|
||||
Critically, **Maker Collective never touches the buyer's money.** The maker is
|
||||
the merchant of record on their own processor; the platform sells optional storefront software (or Makers can bring their own existing storefront) and
|
||||
bills its fee separately. That single architectural choice keeps the platform out
|
||||
of the financial and regulatory machinery that sinks marketplaces, and lets it
|
||||
charge a fraction of Etsy's take.
|
||||
|
||||
> "Every 'Etsy but actually handmade' before us recruited angry makers and died
|
||||
> for lack of buyers, because curation and liquidity pull against each other," said
|
||||
> a spokesperson for the non-profit behind Maker Collective. "We didn't launch a
|
||||
> marketplace. We launched a great tool for one tight community, and let demand
|
||||
> *emerge* from makers vouching for makers. The network is the product; the
|
||||
> storefront is just how some makers choose to plug in."
|
||||
|
||||
**How it works.** A maker joins by invitation from an existing member who vouches
|
||||
that they make original work — a rooted trust graph, not an anonymous signup. They
|
||||
run their commitment-commerce events on a Maker Collective storefront *or* keep
|
||||
their existing Shopify store and connect it (the platform federates over both).
|
||||
Once verified, they can curate other makers and be curated; a signed referral
|
||||
token rides each "Curated By" link so the platform can credit the referrer and
|
||||
bill the referred maker — without ever sitting in the payment flow. Referral
|
||||
income draws down the maker's own future platform fees, so curating well literally
|
||||
erases your bill.
|
||||
|
||||
> "Honestly, I almost didn't bother — I already have a Shopify store and a following, and
|
||||
> I didn't want to migrate everything to Some New Platform," said a founding miniatures
|
||||
> maker. "I didn't have to move anything: I kept my store, connected it, and ran my Saturday
|
||||
> drop and my monthly club right through it. What sold me was the referrals — makers I
|
||||
> respect started sending me real buyers, because they actually like my work, not because
|
||||
> someone bought a slot. And nobody ever took a cut of money that wasn't theirs."
|
||||
|
||||
> "I follow maybe a dozen casters and painters and I live for their drops — but I
|
||||
> got burned twice buying recasts off a marketplace, and lately I can't tell what's
|
||||
> even real," said a tabletop hobbyist. "Here every piece tells me it's the
|
||||
> maker's own original work, and the makers I already trust point me to new ones I
|
||||
> end up loving. It's the people I follow — not an algorithm guessing."
|
||||
|
||||
**Availability.** Maker Collective is opening invitation-only inside one tight
|
||||
community — independent **miniatures makers** (resin/STL casters,
|
||||
sculptors, painters) — chosen because it expresses every commit-then-make motion at
|
||||
once and its makers already run drops, clubs, and made-to-order commissions. It
|
||||
expands along the adjacent-buyer arc — **miniatures → resin dice → broader tabletop**
|
||||
— as each community compounds. Makers on any controllable storefront — Wiggleverse,
|
||||
Shopify, or self-hosted — can be invited to verify and join. Learn more at makers.wiggleverse.org.
|
||||
|
||||
*Maker Collective is operated as a true non-profit: open books, no equity, no
|
||||
sale, engineered by volunteers with LLM-accelerated development. The structure
|
||||
exists so the promise — that "verified" stays incorruptible — is enforced by law,
|
||||
not by good intentions.*
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Part 1 — Customer questions (makers & buyers)
|
||||
|
||||
**What is "commitment commerce," and why is it so important to the pitch?**
|
||||
It's the inverse of normal retail. Stock commerce is *make it, shelve it, someone
|
||||
buys it* (Shopify's model). Commitment commerce is *collect committed demand, then
|
||||
make against it* — a drop, a pre-order, a deposit-and-waitlist, a monthly club, a
|
||||
made-to-order commission. Many makers live in this mode; generic tools treat it as a
|
||||
bolt-on. The memo's core claim (§2) is that the drop/pre-order/club/commission
|
||||
cadence isn't a feature of a storefront — it *is* the platform, and it's the part
|
||||
that's genuinely hard to build well (the "gnarly 20%"). The storefront itself is a
|
||||
commodity we build as little of as possible and rent the rest.
|
||||
|
||||
**I already use Etsy/Shopify. How is this actually different?**
|
||||
- **vs. Etsy:** Etsy is a marketplace that owns your buyer and takes a large cut,
|
||||
and its "handmade" guarantee has eroded. Here you own your buyer and your
|
||||
checkout, pay far less, and verification is real and reputation-staked.
|
||||
- **vs. Shopify:** Shopify is a great *stock* storefront but mediocre at the
|
||||
commit-then-make cadence, and it has no cross-merchant *referral* network where
|
||||
sellers vouch for each other (it has a *resell* network — Collective — which is
|
||||
a different thing; see below).
|
||||
- **vs. Shopify Collective / Carro:** those let merchants *resell* each other's
|
||||
products through one checkout, which forces the reseller to become merchant of
|
||||
record and handle payouts. Our network is **referral, not resale** — a vouch and
|
||||
a handoff, money stays siloed (§7, "the fork"). We deliberately don't compete on
|
||||
the plumbing, which is commoditized; we compete on *verified provenance* and
|
||||
*reputation-staked curation*, which a commission-optimized network structurally
|
||||
can't have (§3).
|
||||
|
||||
**Isn't this just Patreon, for makers?**
|
||||
Patreon is the closest comparison for *one* primitive — the monthly club — and it's
|
||||
worth being precise about why, because it's the de-facto club infrastructure in the
|
||||
beachhead (Appendix A/B). Unlike Etsy/Shopify, Patreon *isn't* the opposite motion: a
|
||||
membership is already commit-then-make (patrons commit ahead, the creator produces
|
||||
against it), so Patreon genuinely *is* doing this category for recurring clubs. But it
|
||||
sits on the wrong side of three things we treat as non-negotiable:
|
||||
- **It's in the money flow.** Patreon is merchant of record, processes the recurring
|
||||
charge, takes ~8–12% all-in, and pays out. We keep the maker as merchant of record
|
||||
on their *own* processor (recurring billing via Stripe on a Standard account) and
|
||||
bill our software fee separately — so makers keep more and get *usage-rights
|
||||
ownership of the patron*, which Patreon doesn't grant.
|
||||
- **It's a walled garden on the buyer.** You can't host a curation block on a Patreon
|
||||
page, attribute a referral through its checkout, or take the patron relationship
|
||||
with you — the same captive model as Etsy, applied to *recurring* relationships. So
|
||||
Patreon is an *invitation target*, not something we integrate into: "I'd feature
|
||||
your work the moment you own your commerce." (The one thing you can lift is your
|
||||
patron email list.)
|
||||
- **It can't build the network.** Patreon is single-creator with platform-run
|
||||
algorithmic discovery — the opposite of maker-vouches-for-maker. Every patron there
|
||||
is a follow that never enters the cross-maker graph (our North Star), and it won't
|
||||
add reputation-staked cross-maker referral for the same reason Shopify won't build
|
||||
shared identity: it would have to become a different company.
|
||||
|
||||
So the play is two-sided: **out-tool** Patreon's club with a native, out-of-flow,
|
||||
lower-fee version the maker owns (the Tool), and **out-flank** it with the cross-maker
|
||||
referral network it structurally can't grow (the Network).
|
||||
|
||||
**Isn't this just Kickstarter / Gamefound / BackerKit?**
|
||||
No — and the distinction *is* the opening: **campaign vs. cadence** (Appendix B).
|
||||
Kickstarter, **Gamefound** (tabletop-native, Kickstarter's biggest tabletop rival —
|
||||
sitting *in* the miniatures vertical), and BackerKit are built for **episodic,
|
||||
project-scale campaigns**: a big push that funds a project, then fulfillment. None of them
|
||||
serves the maker running a small drop **every other Saturday**, a 10-piece lottery, a
|
||||
monthly club, or a standing made-to-order queue — the **continuous** commitment-commerce
|
||||
cadence. That continuous, relationship-driven, small-batch motion is the unserved space
|
||||
*between* Shopify (continuous but stock-only) and Kickstarter/Gamefound (commitment but
|
||||
episodic), and it's where we play. So the posture is **coexist, not compete**: run your big
|
||||
annual campaign on Gamefound if that's the right tool for it — keep us for the continuous
|
||||
cadence *between* campaigns, plus the cross-maker referral network none of them have. Two
|
||||
structural cuts underline it: Kickstarter is itself **in the money flow** (it processes
|
||||
pledges, takes a cut, and pays out, disclaiming only *delivery* liability), where we keep
|
||||
the maker merchant-of-record on their own processor and shed both the flow and the
|
||||
delivery liability (§7/§11); and the campaign players are single-project tools with **no
|
||||
reputation-staked cross-maker referral graph** — the durable moat — which they won't build
|
||||
for the same reason the others won't.
|
||||
|
||||
**What does it cost, and what's the "~20%" claim?**
|
||||
Marketplaces like Etsy bundle everything into one fee that, all-in, can approach
|
||||
~20% of a sale (GMV = gross merchandise value, the total sold). We **unbundle**:
|
||||
you pay your own payment processor directly (their normal ~3%), and pay us a
|
||||
separate, modest software fee billed in arrears, on a tier that **auto-graduates by
|
||||
volume so you never overpay**: **Starter** at $0 + a small percentage (~2–4%) on
|
||||
captured orders (so a low-price-point maker isn't over-taxed), or **Pro** at a flat
|
||||
**$29–49/month + 0%** once your volume makes the flat fee cheaper (§7, "How the
|
||||
platform gets paid"). Worked through, all-in — *illustrative; real rates are set at
|
||||
launch*:
|
||||
- a **Starter maker doing ~$1,200/mo**: ~2–4% platform + ~3% processor ≈ **5–7%** all-in;
|
||||
- a **Pro maker doing ~$6,000/mo**: $39/mo + ~3% processor ≈ **~3.6%** all-in;
|
||||
- **Etsy, for either of them: ~20%.**
|
||||
|
||||
That's the wedge in the numbers the doc owes you, not a slogan: a maker keeps roughly
|
||||
**13–16 percentage points more of every sale** than on Etsy. We bill only on
|
||||
*captured/fulfilled* orders, never on pledges that never cleared.
|
||||
|
||||
**Do I have to abandon my Shopify store to join?**
|
||||
No — and de-risking that question is a deliberate design goal (§7, "Shopify makers:
|
||||
federate, don't migrate"). You keep Shopify as your merchant-of-record storefront
|
||||
and connect via two hooks: a **catalog sync** (Shopify's Admin API + product
|
||||
webhooks feed our verified index) and **referral attribution** (the Curated-By link
|
||||
carries a signed token that rides in as a Shopify cart attribute → order
|
||||
note attribute, read off the order webhook). No checkout customization, works on any
|
||||
plan. The "hybrid wedge": keep your evergreen catalog on Shopify, use us only for
|
||||
the drop/pre-order/club *events* Shopify handles badly. Migrate later only if you
|
||||
want to.
|
||||
|
||||
**What does "own the customer" mean here?**
|
||||
The headline meaning is **usage rights** (§7, validated in interviews): the buyer
|
||||
relationship is *yours to market to, on any channel, including off our network*.
|
||||
This is the exact inverse of Etsy/Amazon, who forbid you from marketing to "their"
|
||||
captive buyers. We can grant it unconditionally because we don't monetize the
|
||||
captive relationship — we don't have one. Separately and optionally, sovereignty-
|
||||
minded makers can keep their buyers *private from the cross-maker network graph*;
|
||||
that's an opt-out, not the core meaning.
|
||||
|
||||
**What does "verified" mean, and how do I get it?**
|
||||
Verification answers "is this a real maker of original work?" at the door, and it's
|
||||
the gate to the demand surfaces (referrals, Curated-By, the buyer feed, AI-agent
|
||||
feed). Early on, staff verify a seed set directly; at scale, **makers verify makers**
|
||||
("peer verification"), staking their own reputation — a rooted, multi-vouch trust
|
||||
graph with sampling audits, because it's the highest-stakes mechanism in the system
|
||||
(§7, "Verification"). The unverified tier still gets the full storefront tool — we
|
||||
**gate the demand, not the tool** — so verification is something makers are pulled
|
||||
toward, not blocked at.
|
||||
|
||||
**How do you know a specific *item* is original — not just that the maker is real?**
|
||||
Two different checks, and conflating them is the Etsy failure mode. **Verification** is
|
||||
about the *maker* ("a real maker of original work?"); **provenance** is per-*item* ("is
|
||||
*this product* their original work?"). A real maker's catalog is legitimately mixed — a
|
||||
potter sells their pots *and* resells pottery tools — so every item carries its own
|
||||
**provenance classification** (§7), **self-attested** by the maker, **audited** by the
|
||||
trust machinery, and **shown to the buyer**: *Original* (bought raw materials like clay
|
||||
are inputs to making, not other-sourced parts) · *Original + components* (primarily
|
||||
theirs, with identifiable parts from others attributed — the "partly original" kit case)
|
||||
· *Resale – fellow maker* (an in-network maker's original item, provenance tracing to the
|
||||
true maker — Curated-By as a catalog item) · *Resale – third-party* (commercial goods,
|
||||
tools, supplies — honest, allowed, clearly *not* original).
|
||||
|
||||
Two things make the badge a guarantee rather than a self-serve sticker. **Eligibility
|
||||
keys off it, per item:** only *original* and *original-+-in-network-components* surface as
|
||||
the maker's original work in Curated-By / the buyer feed / the agent feed; a fellow-maker
|
||||
resale surfaces only *attributed to the true maker*; **third-party resale never enters a
|
||||
trust surface** — surfacing it would launder non-original goods through a trusted face,
|
||||
the Etsy pollution failure mode from the inside. And **misclassification has teeth:**
|
||||
calling a resale "original" is a *provenance lie*, not a clerical slip — a
|
||||
**verification-revocation trigger** (§10), with self-attestation (cheap to classify)
|
||||
policed by **sampling audits plus buyer reporting** (risky to game). One useful
|
||||
consequence: because "handmade/original" are advertising claims the FTC can require you to
|
||||
substantiate, this system *is* the substantiation mechanism (§11) — the product-defining
|
||||
feature and the compliance obligation are the same build.
|
||||
|
||||
What's still open, deliberately: the precise, auditable line between *making* and
|
||||
*reselling* — purchased supplies don't taint "original," but assembling mostly-third-party
|
||||
parts isn't original either; finishing, assembling, and kitting sit in between. That
|
||||
standard is named as later work (§14 #2), not claimed as solved.
|
||||
|
||||
**What is "Curated By This Maker," and how do referrals pay?**
|
||||
Each storefront carries a section where the maker features other *verified* makers'
|
||||
products they genuinely admire — and **only with the featured maker's approval**: B opts in
|
||||
to being curated by A, per relationship, so no one is featured against their will. On a
|
||||
referred sale two things stack. **The platform's cut is fixed and never negotiated** — a
|
||||
**3–5% spread on the order** (it may scale with the sale and carry a $ cap), the network's
|
||||
one piece of referral revenue. **Everything above it is the makers' to set:** A and B define
|
||||
A's referral reward through platform tooling — a flat percentage, a tiered rate ("x% on
|
||||
orders over $y"), a max-$ cap — and can **renegotiate** it as the relationship evolves, with
|
||||
one floor, the platform spread. So B always pays *at least* the spread; if A and B set A's
|
||||
reward to zero, **no referral money changes hands and the platform still takes its spread.**
|
||||
The two legs stay independent — B pays on B's own invoice, A is *credited* separately — so
|
||||
the platform is never a conduit moving money B→A (which would be regulated money
|
||||
transmission). A's credit is **non-cashable** (it draws down A's own future platform fees,
|
||||
so curating well drives your bill toward zero). It works at **n=2** — two makers are enough
|
||||
for it to be useful, rare for a network feature (§7, "Referral economics").
|
||||
|
||||
**Won't paid referrals just become advertising in disguise?**
|
||||
That's the central risk, and the guardrail is to separate **placement** from **economics**
|
||||
(§3, §7). *Placement* is **reputation-staked vouching, never pay-for-placement**: you cannot
|
||||
buy your way into a maker's curation, the platform never sells a slot, featuring is capped
|
||||
per maker, visibly personal (name + face), gated by the featured maker's approval, and
|
||||
biased toward *complementary* makers, not rivals. The *economics* (A's referral reward) are
|
||||
a private term A and B negotiate, floored at the platform's fixed spread — but because
|
||||
placement itself is non-biddable and the curator stakes their **own audience's trust**
|
||||
(feature junk for the money and your conversions and standing erode), a richer split can't
|
||||
buy a feature it didn't earn.
|
||||
|
||||
The subtler version: among the items a maker curates, *the platform* decides which to
|
||||
surface to a given buyer — and we **do** use algorithms (personalization, popularity) to do
|
||||
it, because lifting conversions is the job. The guarantee is that **referral economics are
|
||||
never an input to that ranking** — and it's *structural*, not a pinky-swear: because the
|
||||
platform earns the **same fixed spread no matter which referred item sells**, it has **no
|
||||
incentive** to favor a higher-paying referral, so the ranking optimizes for the buyer's
|
||||
conversion, full stop. The name itself — *verified merchant referral network*, not *retail
|
||||
media* — is the backstop: the moment it starts selling slots, it's a self-evident lie.
|
||||
|
||||
**If I'm the maker being *featured*, do I have a say — and am I just paying to be advertised?**
|
||||
Full say — on both the placement and the price. **Nobody features you without your
|
||||
approval:** being curated by a specific maker is **opt-in, per relationship** (A can feature
|
||||
B only if B agrees), and *buyer-identity* participation is a separate opt-in on top, never
|
||||
bundled. **You and the curator set the terms** (above) — you're not handed a rate, you agree
|
||||
to one, floored at the platform's fixed spread. And you're **not paying for an ad:** you pay only on an
|
||||
*actual referred sale* — incremental business you wouldn't otherwise have had — and the
|
||||
placement itself can't be bought (a curator features you only on genuine merit, their own
|
||||
audience-trust on the line, capped, name-and-face). Being featured is a *vouch you both
|
||||
agreed to*, not a slot — which is exactly why it's worth more than an ad.
|
||||
|
||||
**Will you ever link a buyer to my Etsy/Amazon listing?**
|
||||
Never (§7; Appendix D). The network never routes a buyer *into* a walled garden —
|
||||
not via curation, the buyer feed, or the agent feed. A maker who's *only* on Etsy
|
||||
is an **invitation target, not a destination**: "I'd feature your work the moment you
|
||||
own your commerce." The absence of the link is the recruiting signal.
|
||||
|
||||
**Why invitation-only? That limits growth.**
|
||||
On purpose, at launch (§7, "The membership gate phases"). A trust network cold-
|
||||
starts on *density*, not breadth — one community tight enough that word-of-mouth
|
||||
replaces a marketing budget. Scarcity keeps the trust guarantee absolute while the
|
||||
verified web is small, and makes every early member high-intent. The gate loosens
|
||||
toward open signup once roots and community density exist.
|
||||
|
||||
**As a buyer, why should I care?**
|
||||
Three things, in the order they matter (the buyer value prop, memo §13):
|
||||
- **You can finally trust what you're buying** — every item shows a provenance
|
||||
badge (original / partly original / resale), the thing Etsy can no longer tell
|
||||
you, and worth *more* as AI-generated and recast fakes proliferate. That's the
|
||||
floor under everything.
|
||||
- **You're a fan, not a shopper** — you follow makers and live for their
|
||||
drop/club/commission cadence. That relationship is what brings you back; it's what
|
||||
"commitment commerce" feels like from your side.
|
||||
- **The makers you trust introduce you to new ones** — discovery comes from people
|
||||
*you* chose to follow and the makers *they* vouch for, never an algorithm pushing
|
||||
whatever converts (§7, "The buyer-facing feed").
|
||||
|
||||
**As a buyer, where do I actually shop — is there an app, a site, a login?**
|
||||
Deliberately almost nowhere at first, and that's the point (§13). **At launch the platform
|
||||
is invisible to you:** you buy on the *maker's own storefront*, as a guest, the way you
|
||||
already do — there's no "Maker Collective" destination to sign up for, and "come back for
|
||||
the next drop" runs through the maker's *own* channels (their email, Instagram, Discord).
|
||||
The one thing the platform quietly powers is **follow/notify**, so you hear about the drops
|
||||
you care about. Later — Phase 2+, opt-in — a light **"your makers, in one place" feed**
|
||||
appears: followed makers' drops, "buy it again," and the Curated-By picks of makers you
|
||||
follow, with a light identity you can return to. But it's a *retention upgrade* on top of
|
||||
the makers' own channels, **pointedly not** a marketplace you evangelize ("I shop on X") —
|
||||
every item still traces to a maker *you* chose to follow, and the platform never becomes
|
||||
your primary relationship; the maker does. The mental model: **you're a fan of makers, and
|
||||
the platform is the wiring that keeps you connected to them**, not a store you shop at.
|
||||
|
||||
**I pre-ordered, and the maker never delivered. What protects me?**
|
||||
This is the *signature* risk of commitment commerce, not an edge case — the model collects
|
||||
money before delivery, so "a verified maker takes pre-orders/deposits and ghosts" is the
|
||||
structurally most-likely scam, and a PR-FAQ that skipped it would be dishonest (§10). Two
|
||||
straight answers. **First, the platform is not a guarantor.** The same out-of-the-money-
|
||||
flow design that keeps fees low means the network never holds your funds — so it has
|
||||
nothing to refund *from*; escrow was declined deliberately (holding buyer funds is exactly
|
||||
what triggers money-transmitter licensing — §11). Your monetary recourse is a
|
||||
**chargeback against the maker's own payment processor** (the maker is merchant of
|
||||
record), and the platform's compliance-by-design checkout **eases the maker's FTC 30-Day
|
||||
Rule duty** — prompting the delay notice and one-click cancel/refund when a window slips (the
|
||||
duty is the maker's; the platform doesn't assume it) — which is your first recourse *before*
|
||||
a chargeback (§11). **Second, the platform's contribution is
|
||||
consequence, not insurance.** Non-delivery drops the maker's standing: a **low,
|
||||
buyer-visible reputation score** and **loss of all network amplification** (Curated-By, the
|
||||
buyer feed, referrals, the agent feed). They keep their storefront, but they fall out of
|
||||
every surface that sends them buyers, and you — and every future buyer — can see the score.
|
||||
That's *transparency as enforcement* (§10): the network doesn't promise nobody ever
|
||||
behaves badly; it makes bad behavior legible and costly, and keeps the trusted surfaces
|
||||
clean by construction, since a low-standing maker has already dropped out of them.
|
||||
|
||||
---
|
||||
|
||||
### Part 2 — Strategy & build questions (for the technically-minded skeptic)
|
||||
|
||||
**The single most important design choice: why "stay out of the money flow"?**
|
||||
Because touching the buyer's money detonates three regulatory regimes at once, and
|
||||
*not* touching it discharges all three (§7, §11):
|
||||
- **Money-transmitter licensing (MTL).** In the US, holding customer funds (escrow,
|
||||
a balance, a payout you control) triggers state-by-state money-transmitter
|
||||
licenses — the single most expensive regime a small org could wander into. We
|
||||
never hold buyer funds, so: none.
|
||||
- **Marketplace-facilitator sales tax.** Post-*Wayfair*, states can force a
|
||||
"marketplace facilitator" to collect and remit sales tax — but the test is
|
||||
*conjunctive*: you must both (1) facilitate the listing **and** (2) collect the
|
||||
buyer's payment. We fail prong 2 by design (the maker's processor collects), so
|
||||
the duty doesn't attach.
|
||||
- **Merchant-of-record (MoR) liability.** The MoR is the legal seller — it owns
|
||||
chargebacks, refunds, and delivery liability. We make the *maker* MoR on their own
|
||||
processor, so all of that sits with them, not us.
|
||||
|
||||
The cost of this stance is forgoing payment take-rate — but that's exactly the
|
||||
slice that *carries* the risk. The cross-maker identity moat doesn't need checkout
|
||||
anyway: the asset lives at the **follow**, captured at the account layer regardless
|
||||
of whose processor runs the charge.
|
||||
|
||||
**For the technically inclined: where exactly is the line?**
|
||||
Custody. *"Coordination and bookkeeping are free; custody — funds resting in an
|
||||
account you control — is the line"* (the spine). We can record that maker A owes
|
||||
maker B, and even notify B that their component sold in A's kit — but routing a
|
||||
single dollar from A to B is custody. When we *do* eventually need cashable payouts
|
||||
(Phase 2), we rent a licensed transmitter (Stripe Connect/Treasury, Dwolla) rather
|
||||
than becoming one. One subtlety worth flagging to an engineer who'll wire Stripe:
|
||||
Connect's *charge type* and *account type* are **legal-posture switches, not
|
||||
implementation details** — Stripe's own tutorials default to "destination charges,"
|
||||
which silently flip you to merchant-of-record and into facilitator-tax territory.
|
||||
The stance holds *only* at Standard accounts + direct charges + `application_fee`
|
||||
(§7, "Money flow").
|
||||
|
||||
**"You own the buyer" — doesn't that collide with GDPR and CAN-SPAM?**
|
||||
No, because "own" means **usage-rights to a *consented* relationship**, not a license to
|
||||
message anyone (§7, §11). The mechanics: the network captures **email + per-type marketing
|
||||
consent at checkout** and pushes the subscriber to the **maker's own ESP**, which is the
|
||||
source of truth and owns everything after — compliant unsubscribe, suppression,
|
||||
deliverability. So what the maker owns is the right to market — *on any channel, on or off
|
||||
the network* — to buyers **who consented**, with the unsubscribe machinery a real ESP
|
||||
already enforces. That's the precise inverse of Etsy/Amazon (who forbid off-platform
|
||||
marketing because the captive buyer is *their* asset), and it's lawful *because* it's
|
||||
consent-first. Two more structural points a privacy reviewer will want:
|
||||
- **Two consent domains, strictly separate.** *Maker-originating* marketing lives in the
|
||||
maker's ESP (the maker is controller); *network-originating* communications (the buyer
|
||||
feed, follow notifications, aggregate digests) run on **network-communication consent the
|
||||
network owns** — and the network **never borrows a maker's ESP list** for its own sends.
|
||||
That separation is what keeps every personal-data flow attributable to a lawful basis and
|
||||
a controller.
|
||||
- **The cross-maker identity graph is opt-in by design.** Being recognized across makers is
|
||||
a *buyer* opt-in, not a default-share — so the architecture is already aligned with the
|
||||
strictest "no sharing without affirmative consent" reading rather than retrofitting
|
||||
opt-outs. The posture is **build to the strictest law** (CCPA/CPRA plus the newer state
|
||||
laws, and **GDPR the moment EU buyers appear**), with access/deletion and
|
||||
opt-out-of-sale/sharing built in. (Per the handbook, "consent" defers to the OHM *consent*
|
||||
RFC, not a local definition.)
|
||||
|
||||
**Isn't the referral "wallet" itself a regulatory problem — stored value?**
|
||||
No, by deliberate design (§11). The fee-offset wallet credits a maker against their **own
|
||||
future platform fees** — a *discount / accounts-receivable entry*, not a balance the maker
|
||||
can withdraw or spend with third parties. So it's neither stored value, a prepaid-access
|
||||
instrument, nor transmittable money, and stays outside the money-transmission and
|
||||
stored-value regimes *by construction*. The line is **cashing out:** the moment a credit
|
||||
becomes withdrawable cash, that's the deliberate crossing that re-opens the door — which is
|
||||
exactly why cashable payouts are **gated to Phase 2 behind a rented licensed transmitter**
|
||||
(Stripe Connect/Treasury, Dwolla), never a toggle flipped early. (Flagged for counsel:
|
||||
verify the non-cashable design against each state's stored-value / prepaid-access
|
||||
definitions before launch — *flag and verify, not legal advice*.)
|
||||
|
||||
**What's the actual moat? Can't a competent team rebuild this in a week with LLMs?**
|
||||
The memo's organizing lens (§1): cheap production destroys **stock moats**
|
||||
(accumulated build/features) and rewards **flow moats** (data, network, switching
|
||||
cost) that compound with use. The honest competitive read (§11, "Novelty"): *every
|
||||
component already exists* — cross-merchant inclusion, drop/pre-order tooling,
|
||||
affiliate networks, verification badges, non-profit governance. The novelty is the
|
||||
**specific combination**, scoped to one dense vertical: verified per-item provenance
|
||||
+ reputation-staked cross-maker referral + native commitment-commerce + non-profit
|
||||
governance. **The moat is positioning and governance, not patents** — community
|
||||
standing, the rooted trust graph, the no-walled-garden value rule, and a 501(c)(3)
|
||||
structure a commission-optimized incumbent *cannot* copy without betraying its own
|
||||
customers (§7, "Why this is the defensible core": Shopify won't build cross-merchant
|
||||
shared identity because its DTC merchants would experience it as theft).
|
||||
|
||||
**What if Shopify just adds native drops and pre-orders — doesn't the tool wedge evaporate?**
|
||||
The answer depends on splitting two things both loosely called "the storefront" (§1, §3,
|
||||
§7). The **commodity surface** — cart, catalog, checkout, customer accounts — we
|
||||
deliberately *don't* build; we rent it ("build the 20%, rent the 80%," on a headless
|
||||
backend like Medusa, or federate over the maker's existing Shopify). The
|
||||
**commitment-commerce engine** — scheduled drops, raffle/queue allocation, deposits, clubs,
|
||||
made-to-order — is the part we *do* build, and the honest claim isn't that it's *hard*: it's
|
||||
that **no continuous (non-campaign) platform treats it as a first-class citizen.** Outside the
|
||||
Kickstarter-likes the table-stakes simply aren't covered first-class anywhere, so makers bolt
|
||||
the cadence onto tools that treat it as an afterthought. Being its first-class home is also the
|
||||
on-ramp to something nobody else is positioned for — wiring in the emerging generative/LLM
|
||||
maker tools (e.g. **cuttle.xyz**) that campaign platforms and stock storefronts have no reason
|
||||
to integrate, *because* they never made the cadence first-class. (It carries real
|
||||
financial/delivery liability too — taking money before delivery — which the
|
||||
out-of-the-money-flow architecture handles; but the claim is **first-class + integration
|
||||
headroom**, not difficulty.)
|
||||
|
||||
But here's the part the doc won't dodge: **the tool is the wedge, not the moat.** The memo
|
||||
is explicit that storefront hosting isn't durably defensible — Medusa makes it cheap for
|
||||
everyone, and federation means a maker doesn't even *need* our storefront to be in the
|
||||
network (§7). The tool earns the cold-start (a real business at zero network liquidity) and
|
||||
gets makers in the door; the **durable** moat is the layer Shopify structurally *won't*
|
||||
build — the cross-maker **verified-provenance + reputation-staked curation network**, which
|
||||
its own DTC merchants would experience as theft (§3, §7, "the defensible core"). So if
|
||||
Shopify shipped excellent drops tomorrow it would neutralize a *convenience*, not the moat
|
||||
— the reason a maker stays is the network it can't copy without becoming a different
|
||||
company.
|
||||
|
||||
**What's the architecture, in one breath?**
|
||||
Two layers, kept strictly separate (§7, "Storefront architecture"):
|
||||
- **Storefront layer (per maker)** — either our white-label storefront (built on
|
||||
**Medusa**, a headless Node/TS commerce backend; we add drops/pre-orders/clubs as
|
||||
custom modules) or the maker's existing Shopify store, federated.
|
||||
- **Shared network service (cross-tenant — the moat)** — the verification graph, a
|
||||
**canonical catalog index**, the cross-maker follow/identity graph, the
|
||||
referral/fee ledger, the buyer feed, and the agent feed. A standalone service with
|
||||
its own datastore that federates over heterogeneous storefronts.
|
||||
|
||||
The discipline a technical reader will appreciate: the network catalog is a
|
||||
read-optimized **index** (a normalized, verified *projection* of products that live
|
||||
and sell elsewhere), **not** a "mega-store." Pouring every maker into one Medusa
|
||||
instance would build "a thing shaped like a store that must never behave like one"
|
||||
and couple the neutral network to one engine. Each storefront stays system of
|
||||
record; the network holds the projection. Attribution is **stateless** — a signed
|
||||
token (origin maker + item + expiry + nonce) carries the referral path, so it works
|
||||
for guests in Phase 1 with no identity layer.
|
||||
|
||||
**Why a non-profit built by volunteers? Isn't that fragile?**
|
||||
The structure converts the trust position *from a promise into a guarantee* (§7,
|
||||
"Entity structure"): a 501(c)(3) legally cannot be sold or distribute profits, which
|
||||
is the strongest possible answer to "will you sell our trust for GMV the way Etsy
|
||||
did?" Open books let the community verify the incorruptibility of "verified" rather
|
||||
than take it on faith. And it's viable for a thematically exact reason: **the cost
|
||||
center that usually makes non-profit tech infeasible — engineering — is the one
|
||||
LLMs just collapsed.** The honest risk (§4, §12): the danger moved, it didn't vanish.
|
||||
The failure mode of volunteer orgs is *sustaining*, not *building*. So the critical
|
||||
core (network service, ledger, verification) must be **funded, documented, and more
|
||||
than one person deep** — not bus-factor-one. The fragile-perception risk with
|
||||
professional makers is real and is something discovery explicitly tests (§8).
|
||||
|
||||
**What happens to *my* drop if the platform goes down at 9am Saturday?**
|
||||
The honest answer has an architectural half and a staffing half. **Architecturally, the
|
||||
blast radius is small for most makers:** you are merchant of record on *your own* processor,
|
||||
and if you federate your existing Shopify store your drop and checkout run on *Shopify's*
|
||||
infrastructure — so if our cross-tenant network service is down, what degrades is *network
|
||||
features* (Curated-By, the buyer feed, the agent feed), **not your ability to take the
|
||||
order.** The sale doesn't ride on the moat layer. **Operationally,** for a maker on our
|
||||
white-label storefront the drop *does* run on our infrastructure — which is exactly why
|
||||
network-service uptime, the money-adjacent ledger, and verification are the **funded,
|
||||
documented, more-than-one-deep reliability core** (§12), explicitly *not* volunteer
|
||||
best-effort and *not* bus-factor-one. Drops are spiky by nature, and "a storefront falling
|
||||
over *during* a drop is the worst possible moment for maker trust" (§7, Hosting) — so the
|
||||
spike is designed for (autoscaling infrastructure, load-checked before a real drop), and
|
||||
the on-call reliability of those pieces is a **budget line, not a hope.** What the doc won't
|
||||
pretend: this is the single point of failure §4 names, so the reliability core is bound with
|
||||
structure (funded + redundant), and the fragile-*perception* risk with professional makers
|
||||
is something discovery explicitly tests (§8).
|
||||
|
||||
**Who decides what "verified" means — and who watches the watchers?**
|
||||
Two answers, by time horizon (memo §14 #1 — direction set, mechanics deliberately
|
||||
deferred):
|
||||
- **The core is protected by structure, not by trust.** The trust guarantee,
|
||||
non-extraction, the no-walled-garden rule, and the out-of-flow stance are held by
|
||||
the non-profit and *entrenched* — a 501(c)(3) can't sell or distribute them, and
|
||||
they aren't editable by a simple majority. So the first answer to "who watches the
|
||||
watchers" is *the structure does*, and open books make it checkable.
|
||||
- **Authority over maker issues is progressively delegated as the network scales.**
|
||||
The non-profit can't (and shouldn't) adjudicate every verification call or maker
|
||||
dispute at scale, so authority over maker-facing standards moves to
|
||||
**representatives of the network** as it grows beyond what the non-profit can
|
||||
manage — a vision of **maker self-governance modeled on a functioning democracy**:
|
||||
members *elected* to network roles (resolving disputes among them), where holding a
|
||||
role well *earns standing* in the network — the same reputation currency as making
|
||||
and vouching well. Phased in the same start-closed-open-as-earned way as
|
||||
verification itself.
|
||||
- **The mechanics are deliberately unspecified for now.** Standing up a full
|
||||
governance apparatus before the community exists would be premature; the
|
||||
*direction* (progressive delegation, phased, capture-resistant) is set — the
|
||||
machinery is later work.
|
||||
|
||||
**What actually stops collusion — a ring of fake makers vouching each other in, or weaponized reports?**
|
||||
The highest-stakes surface in the system, because one polluted "verified" item breaks the
|
||||
guarantee for every buyer and agent downstream (§7, §10). The defenses are structural, not
|
||||
best-effort:
|
||||
- **The trust graph is rooted, never flat.** It is emphatically *not* "anyone verified can
|
||||
verify anyone." Every maker enters by invitation and traces back, by a chain of vouches,
|
||||
to a seed set Wiggleverse staff verified directly — a **permanent topology** that
|
||||
persists even after open signup arrives, so a compromised subtree can be found and
|
||||
revoked at its root.
|
||||
- **Inviting pays nothing.** There is deliberately **no per-invite bounty** — a payout
|
||||
would manufacture the exact Sybil/farming incentive the rooted graph exists to resist.
|
||||
You invite people whose work you'd stake your standing on, because that is the only thing
|
||||
the edge means.
|
||||
- **The vouch is a slashable stake, and consequence flows uphill.** When a maker
|
||||
misbehaves, consequence propagates **back toward whoever vouched for them** — transitively,
|
||||
**decayed per hop, and hop-capped**: strong right next to the misbehavior (the inviter who
|
||||
can actually act), negligible by ~6 degrees out (a distant root isn't punished for a
|
||||
great-great-invitee's fraud). A bad vouch costs the voucher standing; a good one compounds
|
||||
it.
|
||||
- **The consequence is loss of standing, not expulsion.** A bad actor keeps the storefront
|
||||
tool (a paying customer; the tool was never gated) but loses a buyer-visible score and
|
||||
all amplification. Authority is layered: the **inviter** holds primary suspend authority
|
||||
over their sub-graph, a **platform floor** lets staff act directly on active buyer harm
|
||||
regardless, and a **governance appeal path** (§14 #1) protects the wrongly-penalized.
|
||||
- **Sampling audits + buyer reporting** sit underneath — and the abuse surface of the
|
||||
reporting system *itself* (false reports, retaliatory scores, collusion rings) is named
|
||||
as instrumented from day one alongside ring-detection.
|
||||
|
||||
What's deliberately deferred (and marked so): the *reputation engine's* concrete mechanics
|
||||
— the scoring math, the decay-coefficient and hop-cap *values*, the benefit-gating
|
||||
thresholds, and the false-report/collusion controls — are explicit OHM-guided open work
|
||||
(§10, §14 #1), not claimed as solved. The *shape* is settled; the *values* are later work,
|
||||
because n=2 can't calibrate them yet.
|
||||
|
||||
**Why now?**
|
||||
This is the org-level [Wiggleverse thesis](https://wiggleverse.org/about/) ("the era
|
||||
of infinite alternatives") applied to maker commerce: every era commoditizes something
|
||||
— the internet commoditized knowledge, the cloud commoditized IT and then SaaS, and
|
||||
**LLMs are now commoditizing platforms themselves.** As that happens, the three moats
|
||||
incumbents stood on each turn into anchors — which is the opening:
|
||||
- **Build-cost / scale → anchor.** The engineering to run a platform at scale was moat
|
||||
#1; LLMs deflate it, which is the only reason a no-equity non-profit can credibly
|
||||
*build and sustain* this (memo §7/§12). (Headless commerce — "build the 20%, rent the
|
||||
80%" — is the same force at the storefront layer.)
|
||||
- **Vendor lock-in → dissolving.** Commoditized custom software makes migrating between
|
||||
platforms cheap and fast; the network's federation and data portability ride this.
|
||||
- **Network effect → fragmenting — the one that matters most here.** Our *own* moat is
|
||||
a network, so the obvious objection is that incumbents' network effects make them
|
||||
unassailable. The answer: incumbents got greedy and extractive and **eroded their own
|
||||
network stickiness**, so a values-aligned alternative can now contest a network moat
|
||||
that used to be untouchable. Etsy's reckoning is that erosion made concrete — its
|
||||
active-seller base fell from ~9M (2023) to ~5.6M as it purged for quality, while AI-generated and
|
||||
recast fakes flood marketplaces, leaving verified provenance scarcer, more valuable,
|
||||
and surrounded by disaffected makers to recruit.
|
||||
|
||||
Two maker-specific accelerants sit on top: **AI shopping agents** are arriving and need
|
||||
trustworthy supply they can't scrape (the window to be their verified maker-supply
|
||||
rails is open now), and the platform's out-of-flow, never-GMV-fee, you-own-your-buyer
|
||||
stance *is* the org's non-extraction ethic in commerce form.
|
||||
|
||||
**Why is this the Wiggleverse's first product — its beachhead?**
|
||||
Mind the overloaded word: *within* this product the launch community is tabletop
|
||||
miniatures, but the product *itself* is the beachhead for the whole
|
||||
[Wiggleverse](https://wiggleverse.org/) — its first product and the proving ground for
|
||||
the org's mission (ethical, non-extractive alternatives to extractive platforms). It's
|
||||
first for four reasons:
|
||||
- **Fastest honest path to self-sustenance.** Commerce is where money moves, so
|
||||
building close to it is the quickest route to a non-profit standing on its own feet
|
||||
([why ecomm first](https://wiggleverse.org/ecomm/)) — and a self-sustaining beachhead
|
||||
funds the rest of the portfolio (apps, learn).
|
||||
- **The most complete test of the thesis.** It exercises every org bet at once:
|
||||
non-extraction (the out-of-flow stance *is* "take only what it takes to run"), the
|
||||
network moat against eroding incumbents, OHM ethics made concrete (verification,
|
||||
provenance, usage-rights), and the open-core partner ecosystem. Prove it here and the
|
||||
rest is de-risked.
|
||||
- **The ethic, legible in dollars.** "Our fee + your processor ≈ 4–7% vs Etsy's ~20%" —
|
||||
the mission is a number on every sale, not a slogan.
|
||||
- **"Small businesses are really just people."** Serving makers directly is the mission
|
||||
— treat humans as humans — applied where commerce most turned them into accounts.
|
||||
|
||||
**How big is the opportunity (TAM)?**
|
||||
The honest unit of TAM here is **makers, not the dollar size of the craft market** —
|
||||
because the platform earns per-maker subscription + referral spread, never a cut of
|
||||
GMV. (The ~$0.8–1.2T global handicrafts market is backdrop, not a revenue base.) Sized
|
||||
properly:
|
||||
- **TAM — every independent maker who runs commit-then-make.** Because the network
|
||||
*federates* over existing storefronts, the addressable supply is the whole
|
||||
controllable-storefront + marketplace install base, not just switchers: Shopify
|
||||
reports ~4.8M active merchants, Etsy ~5.6M active sellers. The true TAM is the
|
||||
commit-then-make subset — millions of makers, not thousands.
|
||||
- **SAM — commit-then-make-native makers, reached community-by-community.** The model
|
||||
only works where you show up as a member, so it's summed over verticals. The
|
||||
tabletop-miniatures beachhead is a ~$3.8–4.2B/yr market growing ~7–10%/yr; Patreon's
|
||||
~286k paying creators is a proxy for the commit-then-make creator population, of
|
||||
which tabletop is one slice.
|
||||
- **SOM — deliberately parametric, not a "capture X% of $Y" number.** That top-down
|
||||
fiction is exactly what the memo's discipline refuses. Obtainable near-term scale is
|
||||
governed by the §12 break-even (`N* ≈ F/(m−v)`): clear the dozen-maker validation
|
||||
gate in one community, reach break-even density (~150–300 makers under illustrative
|
||||
midpoints), then compound vertical by vertical. The story is "reach self-sustaining
|
||||
density in one scene, then repeat" — not a slice of a giant pie.
|
||||
|
||||
*(Figures from Shopify/Etsy/Patreon public reporting, Marketplace Pulse, and tabletop
|
||||
market-research reports; sourced links in memo §12.)*
|
||||
|
||||
**Is it sustainable? How does a no-take-rate non-profit cover costs?**
|
||||
"Non-profit" changes who keeps a surplus (no one), not the arithmetic that revenue
|
||||
must meet cost (§12). It's a **fixed-cost-coverage** problem, not a margin problem:
|
||||
`N* ≈ F / (m − v)` — where F is the fixed reliability floor, m is per-maker net
|
||||
contribution (subscription + referral spread − drawn credits), and v is marginal
|
||||
per-maker cost. Two consequences fall out without needing real numbers: (1)
|
||||
break-even is driven by keeping F lean (the LLM-deflated-cost bet) and by makers
|
||||
*graduating and referring*, not merely by adding low-GMV makers; (2) there's also a
|
||||
*ceiling* — earn too much, too commercially, and a non-profit risks **UBIT**
|
||||
(Unrelated Business Income Tax) or its exemption. An illustrative pass (explicitly
|
||||
*shape, not validated values*, §12) puts the fixed reliability floor at **F ≈
|
||||
$75–150k/yr** — funded core ops + the fee/wallet ledger + the verification audit +
|
||||
hosting — and break-even around **~150–300 makers**, where the revenue mix has flipped from thin
|
||||
Starter percentages to Pro flat fees + referral spread (≈$170k/yr at ~200 makers under
|
||||
those midpoints), well past the **dozen-maker** validation gate — and naming that gap is the
|
||||
point.
|
||||
|
||||
The number a CFO will press on is F, so the doc is blunt about it: **F is not the cloud
|
||||
bill.** The seductive error is to model F as the pilot's tens-of-dollars-a-month GCP
|
||||
invoice; the honest F is dominated by **compensated, documented, more-than-one-deep
|
||||
ownership** of the reliability core — network-service uptime, the money-adjacent ledger,
|
||||
the verification audit — none of which can be best-effort. Under-modeling F is exactly how
|
||||
an org clears break-even *on paper* and still dies of bus-factor (§4). The
|
||||
LLM-deflated-cost bet is that F can be kept **lean, not that it's near-zero** — and the
|
||||
numbers stay variables because n=2 can't calibrate per-maker GMV, churn, or graduation rate
|
||||
yet.
|
||||
|
||||
**How will you know if it's working?**
|
||||
One **North Star: the share of GMV that is cross-maker-referred** (§12). It's near-
|
||||
zero for a pile of disconnected storefronts and rises *only* as the referral network
|
||||
does real work — so a "great tool that never becomes a network" (the most-feared
|
||||
outcome) shows a low North Star and can't hide behind a vanity supply count.
|
||||
Leading indicators beneath it: follower growth → Curated-By activation → drop
|
||||
sell-through → cross-maker repeat-buyer rate. All of it computes "for free" from the
|
||||
cross-merchant order history the referral ledger already requires — and a Goodhart
|
||||
guard applies: the metric must measure *earned* referral, not manufactured slots.
|
||||
|
||||
**How do you actually acquire buyers — and what's still unproven?**
|
||||
This is the keystone, and the memo now grapples with it directly in **§13** (it used
|
||||
to be an admitted gap). The honest mechanics:
|
||||
- **Buyers don't arrive at the platform — they arrive at makers.** The platform
|
||||
acquires no one directly; that's "maker-as-discovery-engine, platform-as-pipe." The
|
||||
first ~100 buyers are *activation, not acquisition* — the founding makers'
|
||||
**existing** audiences transacting on the new rails.
|
||||
- **The first ~1,000 come from compounding + supply** — buyers who follow maker A
|
||||
start following A's vouched makers (the cross-maker repeat that lifts the North
|
||||
Star), plus more makers onboarding, each bringing an audience. Word-of-mouth inside
|
||||
one tight community is the multiplier — which is *why* the beachhead is dense, not
|
||||
broad.
|
||||
- **Net-new demand is deliberately deferred, not hidden.** Early on the network
|
||||
*reshuffles* existing maker audiences rather than creating net-new buyers — the
|
||||
correct cold-start move, but not to be mistaken for solving acquisition. The
|
||||
net-new engines arrive later: **verified taste-makers** (community voices who bring
|
||||
their own audiences, Phase 2) and **AI shopping agents** (Phase 2+).
|
||||
- **What's still unproven: essentially all of it.** The §8 discovery interviews
|
||||
talked only to makers — and to *greenfield* makers with no audience, who by
|
||||
definition can't test "will fans follow them here." So the demand moat is the
|
||||
**least-validated** part of the whole thesis. The next step is a §8 extension that
|
||||
recruits *audience-having* makers and tests buyer behavior **behaviorally** (a real
|
||||
instrumented drop; does a referral from A actually convert A's buyers into
|
||||
followers of B?), not by survey. Until that runs, treat the buyer side as a
|
||||
reasoned plan, not a validated result — which is exactly how the memo frames it.
|
||||
|
||||
**What's the biggest risk?**
|
||||
**Demand** (§4, §13). "Etsy but handmade" is a graveyard (Goimagine, Artisans
|
||||
Cooperative, Folksy, Amazon Handmade…) because *curation fights liquidity*: these
|
||||
platforms recruit angry makers (supply) and die for lack of buyers (demand). Demand
|
||||
at scale must be *earned*, not bought; paid acquisition against Etsy/Amazon is the
|
||||
losing game. The thesis bets that demand can *emerge* from makers bringing their own
|
||||
audiences and vouching for each other — but that's the **unvalidated keystone**, and
|
||||
it's gated on discovery (§8): do enough audience-having, vouch-willing makers exist
|
||||
in one tight community? Everything is downstream of that question.
|
||||
|
||||
**What's the falsifiable hypothesis here — and what would make you kill it?**
|
||||
The thesis rests on one keystone, stated to be falsifiable, not asserted: **demand can be
|
||||
*earned* — makers bring their own audiences and vouch for each other — rather than bought**
|
||||
(§4, §13). The go/no-go is concrete (§9 decision gates), and a failed gate *stops the build*,
|
||||
not just dings it:
|
||||
- **The dozen-maker gate.** Can you reach ~a dozen makers in one tight community who feel the
|
||||
*same* commit-then-make pain *and* refer you onward? Two makers justify a portable data
|
||||
model; they do **not** justify building the platform. If the dozen doesn't cohere, you
|
||||
don't build — full stop.
|
||||
- **The behavioral demand test (the decisive one).** Discovery so far talked only to makers —
|
||||
and *greenfield* ones with no audience. So before the platform is built, run a **real
|
||||
instrumented drop** and measure whether a referral from maker A actually converts A's
|
||||
buyers into followers of B. If that propagation doesn't happen, the network thesis is
|
||||
falsified at the cheapest possible point.
|
||||
- **The North Star as a live kill-signal.** Post-launch the one number is **the share of GMV
|
||||
that is cross-maker-referred** (§12) — near-zero for a pile of disconnected storefronts,
|
||||
rising *only* if the network does real work. A persistently low North Star is the explicit
|
||||
failure mode ("a great tool that never becomes a network"), and it can't hide behind a
|
||||
vanity supply count.
|
||||
The honest posture: **the tool is a real business even if the network never lights** — so
|
||||
failure is survivable, not ruinous — but the *network*, the actual moat, is gated on a
|
||||
falsifiable demand test the org commits to running *before* betting on it.
|
||||
|
||||
**What's the sequencing? You keep saying "don't launch a marketplace."**
|
||||
Four acts, each viable alone, each earning the next (§5): **Tool** (the
|
||||
commitment-commerce engine — a real business at zero network liquidity) → **Community**
|
||||
(narrow to one beachhead, add curated discovery) → **Marketplace** (light the
|
||||
referral network once supply density + community exist, so it *emerges* rather than
|
||||
launching cold into the graveyard) → **Infrastructure** (expose the verified-supply
|
||||
graph to AI shopping agents as trustworthy rails). The phasing of money is parallel:
|
||||
Phase 1 stays entirely out of the flow (stateless referrals, non-cashable wallet);
|
||||
Phase 2 adds shared identity and *one* rented cashable payout rail; Phase 3 (much
|
||||
later, opt-in) is the only point shared checkout — and the money-flow question —
|
||||
returns.
|
||||
|
||||
**Why is "agent-ready rails" in here?**
|
||||
Longer term, the verified-supply graph becomes the structured, real-time, *trustworthy*
|
||||
supply layer AI shopping agents need and can't manufacture by scraping (§3). That
|
||||
repositions the moat from "win consumer eyeballs" (unwinnable for a newcomer) to "be
|
||||
the verified maker-supply layer agents route through." Agent inclusion is gated on
|
||||
*verification* (not network membership) and defaults on for verified makers, because
|
||||
agent sales route back through the maker as MoR — net-new demand with no sovereignty
|
||||
cost. It's the hedge against the buyer feed's deliberate weakness at net-new reach.
|
||||
|
||||
**What got deliberately left out of this PR-FAQ?**
|
||||
The memo's full depth on trust-&-safety/accountability (§10), the complete
|
||||
legal/compliance analysis (§11), composite multi-maker kits (Appendix C), the
|
||||
beachhead-selection method and worked example — miniatures → dice → broad tabletop
|
||||
(Appendix A), and the crowdfunding-incumbent landscape (Appendix B). Also **honestly
|
||||
unfinished** and tracked as open work in the memo's backlog (§14): the **international
|
||||
tax / cross-border** posture (the legal analysis is US-only today, under a digital-heavy
|
||||
global beachhead), **information security & breach posture** for the cross-tenant graph,
|
||||
**content moderation beyond authenticity** (third-party IP / DMCA), the **verification
|
||||
*methodology*** (how a verifier actually confirms original work), **support/dispute
|
||||
operations** as a funded function, and a head-to-head against the **creator-commerce
|
||||
tools** (Gumroad/Payhip/Ko-fi/Fourthwall). A technical reader who wants the real
|
||||
architecture — and the honest open edges — should read the
|
||||
[strategy memo](./maker-platform-strategy.md) directly; this document is the
|
||||
elevator version, not a replacement.
|
||||
@@ -0,0 +1,823 @@
|
||||
---
|
||||
slug: maker-platform-strategy
|
||||
title: "Maker Platform — Strategy & Architecture"
|
||||
state: super-draft
|
||||
id: null
|
||||
repo: null
|
||||
proposed_by: ben.stull@wiggleverse.org
|
||||
proposed_at: '2026-06-15'
|
||||
graduated_at: null
|
||||
graduated_by: null
|
||||
owners:
|
||||
- ben.stull
|
||||
arbiters: []
|
||||
tags: []
|
||||
---
|
||||
|
||||
# Maker Platform — Strategy & Architecture
|
||||
|
||||
A working memo distilling the thinking so far. It serves makers in general, but launches into one dense community first (the *beachhead*, §2/§5). The aim is to keep two doors open — a paid consultancy that's real today, and an option on a larger network play — without over-committing to the second before the evidence justifies it.
|
||||
|
||||
> **Grounded in the Open Human Model.** Every principle in this memo — *trust, consent, reputation, harm, recourse, value, agency, dignity* — is meant to rest on the shared definitions in the [**Open Human Model (OHM)**](https://rfc.wiggleverse.org/p/ohm/c/default/), Wiggleverse's version-controlled dictionary for the words that systems acting on behalf of humans turn on. Where this memo and an OHM RFC disagree, **the RFC is canonical**; where a load-bearing concept here isn't yet defined in OHM, defining it is itself OHM work (not license to coin a local meaning). The reputation/trust model below is the first place this bites — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 1. The lens: stock moats vs. flow moats
|
||||
|
||||
The organizing idea behind everything below.
|
||||
|
||||
- **Stock moat** = accumulated build, features, or content — a one-time lead. As production cost collapses (cloud did this to infrastructure; LLMs are now doing it to software production), stock moats trend toward zero, and any *new* stock advantage is matched in weeks.
|
||||
- **Flow moat** = data, network, switching cost, regulation, embedded workflow. It compounds with every customer and every day of use, and cheap production can't shortcut its accumulation.
|
||||
|
||||
The diagnostic for any product: *if a competent small team could reproduce the functionality in a week with LLM assistance, what does the customer still pay for?* Whatever survives is the real moat; if nothing survives, build-cost was the whole moat — the exposed position.
|
||||
|
||||
Cheap production lowers cost **symmetrically** — for every entrant at once — so it never picks winners. It commoditizes the build layer and pushes competition *up* to the moats it can't erode. Don't pick a fight where build-cost was going to be your only moat.
|
||||
|
||||
---
|
||||
|
||||
## 2. The thesis
|
||||
|
||||
Build for **independent makers** — positioned as "the curated maker marketplace before resellers polluted it," but executed so the durable value is flow, not stock.
|
||||
|
||||
**The core primitive: commitment commerce.** The entire "gnarly 20%" that generic tools serve badly is one mechanism wearing many costumes. Pre-order, raffle, drop, monthly club, made-to-order commission, deposit-and-waitlist — every one is *collect committed demand before production, then make against it.* That's the inverse of Shopify's *stock-then-sell* (make it, shelve it, someone buys it), which is exactly why Shopify and generic tools serve makers badly: **many** makers run on *commit-then-make*. The category you're building is **commitment commerce**, not storefront commerce — the drop/pre-order/club/commission cadence is the engine, and the storefront is just its surface. (The platform still handles ordinary *stock-then-sell* too — it is a full storefront — so a maker can run their whole catalog on it; the positioning is *commit-then-make, not **just** stock-then-sell* — both modes, with commit-then-make the differentiator and the gnarly part nobody else serves, never the platform's only mode.)
|
||||
|
||||
Two halves, treated differently:
|
||||
|
||||
- **Commitment-commerce engine (the real product).** The maker-native cadence layer Shopify lacks: scheduled drops, pre-orders and deposits, raffle/queue allocation, made-to-order workflows, recurring clubs/memberships, digital-file delivery + licensing where relevant, and variant/bundle handling. This *is* the gnarly 20% and the reason vertical infrastructure here is defensible — emphatically *not* "the same as Shopify." The defensibility isn't that the cadence is *hard* to build — it's that **no continuous (non-campaign) platform treats it as a first-class citizen**, so the table-stakes go uncovered everywhere outside the Kickstarter-likes (Appendix B); being its first-class home is also the on-ramp to integrating emerging generative/LLM maker tools (e.g. **cuttle.xyz**) that campaign platforms and stock storefronts have no reason to touch. (The white-label storefront is the commodity surface, subsumed here: build the least of it you can, rent the rest.)
|
||||
- **Cross-maker demand network (the moat).** The flow asset, accruing as a *byproduct* of the engine. Every drop/pre-order captures a buyer who *follows* a maker and commits early; a base of drop-followers across many makers is the embryonic cross-merchant identity network — the niche-scoped Shop Pay equivalent. Stock commerce gives you people who bought once; commitment commerce gives you people who *wait for* makers and commit ahead — a far stronger flow asset. Reputation-staked cross-maker curation turns that into *earned* demand, not just relocated demand.
|
||||
|
||||
The reframe: commitment commerce is not a feature of the storefront — it *is* the platform, and the mechanism that builds the flow moat, rather than something engineered separately alongside it.
|
||||
|
||||
**The beachhead principle.** "Makers in general" is the eventual market, but a network cold-start needs *density* — so launch in one craft community tight enough that word-of-mouth substitutes for a marketing budget, and where you show up as a *member, not a vendor*. Expand to adjacent communities only after the first compounds. The vertical-selection method and a worked candidate (miniatures → dice → broad tabletop) are in Appendix A.
|
||||
|
||||
**Business model (detail in §7):** the **network is the product and the value capture** (referral take + network subscription, charged identically whether a maker is on your storefront or Shopify); the **storefront is an optional, SaaS-priced convenience — never GMV-fee'd, never sold as hosting.** You are a *vertical commitment-commerce product*, not a hosting company; a partner/consultant network onboards the high-touch tail.
|
||||
|
||||
**Why now — the Wiggleverse thesis, applied to maker commerce.** The timing argument here isn't particular to makers; it is the org-level [Wiggleverse thesis](https://wiggleverse.org/about/) — *"the era of infinite alternatives"* — specialized to one vertical. Every era commoditizes something: the internet commoditized knowledge, the cloud commoditized IT infrastructure and then small SaaS, and **LLMs are now commoditizing the platforms themselves.** As they do, the three moats incumbents were built on each turn into *anchors* — and that is precisely the opening:
|
||||
|
||||
- **Build-cost / scale → anchor.** The manpower and capital to run software at platform scale was the first moat; LLMs deflate it (the §1 stock-moat lens), which is exactly what makes a volunteer/non-profit build viable here (§7, §12) — the entity and the opportunity are the same bet. (Headless commerce maturing — *build the 20%, rent the 80%*, §6 — is the same force at the storefront layer.)
|
||||
- **Vendor lock-in → dissolving.** Commoditized custom software makes migrating off a platform cheap and fast; you no longer wait for a vendor's export tools. The memo's federation, data portability, and ESP-as-source-of-truth (§7) ride this directly.
|
||||
- **Network effect → fragmenting — and this is the one that matters most here.** The memo's *own* moat is a network (§2), so the obvious objection is that incumbents' network effects make them unassailable — the §4 graveyard. The Wiggleverse answer: incumbents got **greedy and extractive and eroded their own network stickiness**, so a values-aligned alternative can now contest a network moat that used to be untouchable. (No contradiction with §1, where flow moats compound: a flow moat compounds only while it is *stewarded* — the incumbents spent theirs.) Etsy's authenticity reckoning *is* that erosion made concrete — its active-seller base fell from ~9M to ~5.6M as it purged for quality (§12 sizing) while AI-generated and recast fakes flood the marketplaces, so verified provenance is at once scarcer, more valuable, and surrounded by freshly-disaffected, recruitable supply (§3).
|
||||
|
||||
Two maker-commerce-specific accelerants sit on top of the org thesis: **AI shopping agents are standing up** and need structured, trustworthy, real-time supply they cannot scrape — the narrow window to be the verified maker-supply rails agents route through opens now (§3, §7); and the same **non-extraction ethic** the org is built on (give value, don't extract — an OHM principle) is precisely what the out-of-the-money-flow, never-GMV-fee, usage-rights-ownership stance (§7) *is*, in maker-commerce form. Miss the window and the provenance grievance normalizes, the agent rails get built by someone *in* the flow, and the build-cost advantage commoditizes for everyone at once (§1).
|
||||
|
||||
**Why this product is the Wiggleverse's beachhead.** Two nested beachheads are in play and shouldn't be confused: within *this* product, the launch *community* is miniatures (§5, Appendix A); but the product *itself* is the **beachhead for the whole [Wiggleverse](https://wiggleverse.org/) portfolio** — its first product, the proving ground for the org-level mission of ethical, non-extractive alternatives to extractive platforms. It earns that role on four counts:
|
||||
|
||||
- **The fastest honest path to standing on its own feet.** Running a non-profit and the infrastructure under a platform takes money; commerce is where money moves most, so building close to commerce is the quickest route to a sustainable economic position ([Wiggleverse — why ecomm first](https://wiggleverse.org/ecomm/)). A self-sustaining beachhead is also what funds the rest of the portfolio (the *apps* and *learn* products to come) — the §12 economics read at org scale.
|
||||
- **The hardest, most complete test of the thesis.** This product exercises *every* load-bearing Wiggleverse bet at once: **non-extraction** (the out-of-flow, never-GMV-fee stance, §7/§11, *is* the org's "take only what it takes to run"), the **flow/network moat** against eroding incumbents (the §2/§4 thesis and the three moats above), **OHM ethics made concrete** (consent, agency, dignity, value, recourse show up as verification, provenance, and usage-rights — §7/§10/§11), and the **open-core partner ecosystem** (§7 partner network). Prove it here and the rest of the portfolio is de-risked.
|
||||
- **The most tangible "give value, not extract" demonstration.** Commerce makes the ethic legible in dollars — *our fee + your processor ≈ 4–7% vs Etsy's ~20%* (§7) — so the mission is a number on every sale, not a slogan ("what is enough? — enough to keep the lights on, and no more").
|
||||
- **"Small businesses are really just people."** Serving independent makers directly *is* the OHM mission — *treat humans as humans* — applied to the place commerce had most thoroughly turned them into accounts and line items.
|
||||
|
||||
---
|
||||
|
||||
## 3. Positioning: what's yours vs. what's commodity
|
||||
|
||||
The cross-merchant inclusion mechanism already exists and is commoditized — Shopify Collective, Carro, and Shopify's Product Network all let merchants sell each other's products. Feasibility is de-risked, but it means **you cannot win on the plumbing.** If the product is "Collective for makers," Shopify extends Collective and you're gone.
|
||||
|
||||
Your differentiation is the layer the commission-optimized networks structurally can't have:
|
||||
|
||||
- **Verified provenance.** "Actually handmade/made by this person" is the trust signal buyers and AI agents can't get elsewhere, and it *appreciates* as AI-generated and drop-shipped fakes proliferate (Etsy is being flooded now). This is flow; the aggregation tech is stock.
|
||||
- **Reputation-staked curation, not pay-for-placement.** The central danger: commission-driven inclusion drifts curation from "what's genuinely good" to "what converts and pays," rebuilding Etsy's pollution from inside, laundered through trusted faces. Inclusion must be an editorial/social act with the curating maker's standing on the line.
|
||||
- **Community standing.** The demand problem is a community problem, and community is founded, not acquired.
|
||||
|
||||
Longer term, the verified-supply graph becomes **agent-ready rails** — the trustworthy, structured, real-time-inventory supply layer AI shopping agents need and can't manufacture by scraping. That repositions the moat from "win consumer eyeballs" (unwinnable for a newcomer) to "be the verified maker-supply layer agents route through."
|
||||
|
||||
---
|
||||
|
||||
## 4. Honest risks and hard truths
|
||||
|
||||
- **The graveyard is real.** Goimagine, Artisans Cooperative, Folksy, Storenvy, Amazon Handmade — "Etsy but actually handmade" has been built many times and stays small. The reason: **curation fights liquidity**, and curation is a *seller-side* value proposition. These platforms recruit angry makers (supply) and die for lack of buyers (demand). Etsy's drift into mass-produced goods wasn't betrayal — it was the gravity of GMV growth.
|
||||
- **No demand advantage yet — the binding constraint.** Demand at marketplace scale must be *earned*, not bought; paid acquisition against Etsy, Amazon, and agents is the losing game the graveyard played. Early on the network *pools existing maker audiences* (reshuffle), it doesn't create net-new buyers — the correct cold-start move, but don't mistake it for solving acquisition.
|
||||
- **Buyer-ownership tension.** Some makers guard their customer lists and will resist shared cross-merchant identity. If they want the cross-promotion demand but not the shared identity, the network's conversion advantage is capped. Surface early (and see the opt-in design in §7).
|
||||
- **n = 2 is learning, not validation.** The engineer's trap is over-abstracting for two clients when the right abstraction only reveals itself around ten.
|
||||
- **Commitment commerce carries financial/delivery liability.** Taking money before delivery inherits structural delivery risk — chargebacks, non-delivery, makers who collect pre-orders and don't ship. This is *why* the 20% is gnarly (financial risk, not just UX). The design answer (§7): stay out of the money flow so the maker, as merchant of record, carries it.
|
||||
- **Raffle/lottery legality.** The raffle drop mechanic can be regulated as a lottery/gambling depending on jurisdiction — the one primitive with real compliance exposure. Understand it *before* building it as a headline feature.
|
||||
- **Campaign vs. cadence is a product fork — pick cadence.** Episodic, project-scale crowdfunding is owned by entrenched incumbents (Kickstarter, Gamefound, BackerKit — Appendix B). The unserved gap is the *continuous* drop cadence (the biweekly drop, the monthly club). Don't drift into competing with Gamefound; own the continuous-relationship layer they don't serve. (One continuous-cadence slice *does* have an incumbent — the monthly club, owned by **Patreon**; the next bullet faces it.)
|
||||
- **Your beachhead lives on a walled garden you're recruiting them off of.** In miniatures, the de-facto club infrastructure is **Patreon** — the *membership-side* walled garden (Appendix D) and the commitment-commerce incumbent for the recurring-club primitive (Appendix B). It is a *sharper* competitor than Shopify for that slice — a club is already commit-then-make, not Shopify's stock-then-sell — and unavoidable, because your first community is already on it. The posture it forces — **recruit-out + replace-the-club (native, out-of-flow, maker-MoR) + lift-the-patron-list** — is therefore materially more load-bearing than the memo's posture toward Etsy, which is otherwise the lead walled-garden example.
|
||||
- **Volunteer-core sustainability is the single point of failure** (given the non-profit/volunteer model, §7). LLMs make a smaller core go further but don't fix volunteer attrition or bus-factor. The parts touching money, catalog data, and verification need *reliability*, not best-effort — so the critical core (network service, ledger, verification) must not be bus-factor-one. The historical killer of volunteer orgs is *sustaining*, not building; transfer the rigor you'd spend on fundraising onto this.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing: earn into the marketplace, don't launch one
|
||||
|
||||
Four acts, each viable on its own, each earning the right to the next. No stage depends on demand you can't yet generate.
|
||||
|
||||
1. **Tool.** The verified-maker commitment-commerce engine with a white-label storefront as its surface — genuinely the best home for a real maker. Useful at zero liquidity. Accrues supply, a structured provenance-stamped catalog, *and* a base of drop-followers. *A real business even if the network never lights.*
|
||||
2. **Community.** Narrow to one craft scene tight enough that word-of-mouth substitutes for a marketing budget (the beachhead). Add cross-maker discovery with an editorial point of view — the demand-generating feature you build rather than buy.
|
||||
3. **Marketplace.** Light the curated cross-inclusion network once supply density and community exist — it *emerges* from accumulated follows (§7), rather than launching cold into the graveyard.
|
||||
4. **Infrastructure.** Expose the verified-supply graph to agents; become the rails.
|
||||
|
||||
**Selection criterion that decides everything:** pick the community where you show up as a *member, not a vendor* — where you already have, or can authentically build, real standing.
|
||||
|
||||
---
|
||||
|
||||
## 6. The immediate engagement (the first makers)
|
||||
|
||||
A good investment **if** you build the right common infra. The wasted version is a generic storefront engine ("the same as Shopify") — the stock layer, valueless in every branch.
|
||||
|
||||
The reusable assets worth owning, none of which is a storefront builder:
|
||||
|
||||
1. **A clean, portable, verified catalog data model that you own.** Cheap to do well now, expensive to retrofit later.
|
||||
2. **The commitment-commerce primitives (the engine)** — scheduled drops, pre-orders/deposits, raffle/queue allocation, recurring clubs/memberships, made-to-order workflows, digital delivery + licensing, variant/bundle handling. The portable, vertical-spanning core; the storefront is just its surface.
|
||||
3. **Standing in the beachhead community** and the first reference relationships.
|
||||
|
||||
**Operating rule: build the maker-shaped 20%; rent the 80%.** Use a template, headless setup, or even Shopify itself for the commodity storefront parts. Let this be paid consultancy whose real return is knowledge, references, and a portable data asset — a cheap option on the bigger play.
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
The money-flow stance and the first network feature turn out to be the same decision viewed twice; the rest of the section builds out from there.
|
||||
|
||||
### Money flow: stay out of it (the "no")
|
||||
|
||||
The white-label model enforces this: each storefront is the maker's brand on the maker's own payment processor, so **the maker is merchant of record by construction.** The money never touches you — you sell software, they run commerce. This is *cleaner than Kickstarter*, which is itself in the money flow (processes pledges, takes a cut, pays out) and only disclaims *delivery* liability; here you avoid both the flow and the delivery liability structurally.
|
||||
|
||||
**Why staying out is right:** no money-transmitter/escrow licensing (holding customer funds triggers state-by-state MTL in the US); no chargeback exposure (it hits the maker's processor, not you); no delivery-risk balance-sheet exposure; clean SaaS margins and a faster launch; and it's maker-aligned (their brand, processor, payouts, customer, data). The cost is forgoing payment take-rate — but that's exactly the slice that carries the risk you're declining, a fair, deliberate trade. The cross-maker identity moat doesn't need checkout anyway: **the asset lives at the *follow*** ("who follows which makers and commits early"), captured at the account / "notify me" / club layer you own regardless of whose processor runs the charge.
|
||||
|
||||
If you ever want transaction economics without the MTL burden, **Stripe Connect** is the middle path — but it is a *dial, not a single choice*, and only one setting preserves the stance above. Two independent knobs decide who is merchant of record and who eats the risk:
|
||||
|
||||
- **Charge type.** *Direct charges* keep the **maker** as MoR — disputes and chargebacks hit the maker's balance, the maker pays the processing fees. *Destination charges* and *separate-charges-and-transfers* make the **platform** MoR — your balance is auto-debited for disputes, and you are now indirectly collecting and transmitting the buyer's payment, i.e. a **marketplace facilitator** for sales-tax purposes (§11).
|
||||
- **Account type.** *Standard* accounts leave negative-balance and dispute liability with the maker. *Express/Custom* push it onto the **platform**, with Stripe holding reserves against *you* for connected-account shortfalls.
|
||||
|
||||
The stance holds **only at Standard accounts + direct charges + `application_fee`**: the maker stays MoR, disputes stay on the maker's balance, you skim a fee without entering the flow. The trap is that Stripe's own marketplace tutorials default to *destination* charges (the canonical "marketplace" pattern), which silently flips every one of those properties — the payments-layer cousin of the "buy through their store" creep this memo warns about elsewhere. So **the Connect configuration is a legal-posture constraint, not an implementation detail.** The default money model remains the out-of-flow ACH/invoice fee below; Connect is reserved for the deliberate later crossings (cashable wallets, kit/taste-maker payouts), and even there uses the arm's-length setting. *(Verify per account type and per state with a payments/SALT attorney before architecting. Not legal advice.)*
|
||||
|
||||
### Unified phasing
|
||||
|
||||
One phasing governs the whole build; everything below references these stages.
|
||||
|
||||
- **Phase 1 — tool + stateless referrals (out of money flow).** White-label storefront on the maker's own processor; Curated-By via stateless signed tokens; non-cashable fee-offset wallet. No shared identity, no consent complexity, no money movement. A real consultancy/tool business on its own.
|
||||
- **Phase 2 — shared identity + the cashable-payout rail.** Shared auth + follow relationships + the consent-gated cross-maker graph (the durable network effects: follows, cross-session credit, niche-scoped recognition/conversion lift). Phase 2 also lands **one** cashable payout rail (Connect / mass-pay) that simultaneously unlocks cashable maker wallets, kit pure-supplier payouts (Appendix C), and the verified taste-maker tier — three consumers of a single scoped crossing. Optional Connect `application_fee` for transaction economics here too.
|
||||
- **Phase 2+ — buyer-facing feed.** The consumer surface where the marketplace *emerges* from accumulated follows (below).
|
||||
- **Phase 3 (much later, optional, opt-in) — shared checkout.** The only point the money-flow question returns — chosen by makers because it converts better, never forced.
|
||||
|
||||
### "Curated By This Maker" — the first network feature
|
||||
|
||||
Each maker's storefront carries a **"Curated By This Maker"** section featuring other *verified* makers' products they genuinely admire. It's the demand network as a concrete, shippable surface — and it works at **n = 2**, the holy grail for a network feature.
|
||||
|
||||
**What this is — and the name matters: a verified merchant referral network, *not* retail media.** The two look similar (a curated set of products with outbound links and attribution) but encode *opposite governance*: retail media is **paid placement** (highest payer wins the slot), while a verified merchant referral network is **reputation-staked vouching** (placement is earned; the curating maker's standing is on the line). The name is a structural guardrail — a thing called a *verified merchant referral network* can't quietly start selling slots without becoming a self-evident lie. Never frame or price it as retail media; that framing's gravity is toward selling placement, rebuilding Etsy's pollution through trusted faces.
|
||||
|
||||
**Targets are verified makers on controllable storefronts ONLY — never walled-garden listings.** The network never points its curation, buyer feed, or agent feed *outward* at an Etsy/Amazon/eBay listing. This is a **value-system rule, not a technical limit**: every such link would lend the community's trust and a maker's audience to the captive-commerce model this enterprise is an alternative to. A walled-garden-only maker is an **invitation target, not a curation/destination target** — "I'd feature your work the moment you own your commerce" is the pitch, and the *absence* of a link is the recruiting signal. (It also sharpens the moat: when every target is a verified controllable-storefront member, the "sincere vouch vs. paid slot" gray zone disappears.) Full participation matrix in Appendix D.
|
||||
|
||||
**The fork — what "buy" means:**
|
||||
|
||||
- **(A) Referral / handoff — build this.** Checkout hands off to maker B's own store and processor. A vouches, B fulfills and is paid, A optionally earns a referral credit. Money stays siloed; A is never merchant of record for B's goods. Out of the money flow.
|
||||
- **(B) Consignment / resale — avoid.** The buyer checks out on A's store for B's product; A becomes MoR for B's goods and owes B a payout — recreating the delivery-and-payout liability you designed out (mechanically what Shopify Collective is, and why it needs Shopify Payments in the middle).
|
||||
|
||||
Model A is reputation-staked human curation (the anti-pollution, can't-be-gamed kind that *is* the moat), it rides each maker's existing traffic, and it's structurally aligned (A only features B if A rates B's work). Watch the phrase **"buy through their store"** in any spec — it's where (B) creeps back via a unified-cart "convenience" that drags you to merchant-of-record. Hold the line at handoff until a shared *opt-in* checkout is a deliberate Phase-3 decision.
|
||||
|
||||
**Guardrails:**
|
||||
|
||||
- **Attribution is stateless — no identity layer required.** A signed referral token (origin maker + item + expiry + nonce) rides the A→B handoff into B's order; you read it to bill B and credit A. It tracks the *referral path*, not the buyer, so it works in Phase 1 for guests and sovereign makers. Persistent identity is only for the *durable* effects (follows, cross-session credit).
|
||||
- **Commission-corruption trap.** When Curated-By pays well, curation drifts to "feature whoever converts/pays most." Keep it reputation-staked: **B must approve being curated by A** (per-relationship opt-in — placement is consented on both sides, distinct from the default-on *catalog presence* switch below), cap how much any maker can feature, make it visibly personal (name + face), prefer "A owns/uses this," and never sell slots. The sincere vouch is the whole value.
|
||||
- **Ranking neutrality — structural, not a promise.** Among the items a maker curates, the *platform* chooses which to surface to a given buyer, and it **will** use algorithms (personalization, popularity) to lift conversions — that's the platform's job. The guarantee is that **referral economics are never an input to that ranking**, and it holds *by construction*: the platform's spread is the **same percentage no matter which referred item sells** ("Referral economics" below), so the platform has no incentive to favor a higher-paying referral. Structural indifference is a *stronger* guarantee than a flat uniform rate — it survives makers negotiating their own rewards.
|
||||
- **Complementary, not substitute.** Nudge toward complementary makers (a maker surfaces an adjacent craft, not a direct rival) — complementary curation is generative; substitute curation is cannibalistic and makers won't do it.
|
||||
|
||||
### How the platform gets paid
|
||||
|
||||
Out of the money flow, your fee is a **platform fee billed in arrears via ACH/invoice**, explicitly separate from processing (the comparable is Checkout Page: $29/mo, 0% on top of the maker's own Stripe).
|
||||
|
||||
- **Tiered "graduate" pricing.** Starter: $0/low monthly + a modest **percentage** (≈2–4%) on captured orders (cold-start-friendly). Pro: flat **$29–49/mo + 0%** (predictable, easy to collect, at scale). Auto-graduate by GMV so makers don't overpay. Use a **percentage, not a per-order flat fee**, on low-AOV makers (a flat $0.50 over-taxes a $12 item).
|
||||
- **Charge on captured/fulfilled orders, not pledges** — don't bill money that never cleared.
|
||||
- **Market the all-in transparently:** "our fee + your own processor ≈ X% vs Etsy's ~20%." Not bundling processing is the wedge — say it in numbers. Illustratively (shape, not validated rates): a Starter maker at ~$1,200/mo runs ~2–4% platform + ~3% processor ≈ **5–7%** all-in; a Pro maker at ~$6,000/mo runs $39/mo + ~3% processor ≈ **~3.6%** all-in — versus Etsy's ~20% either way, i.e. the maker keeps roughly **13–16 percentage points more of every sale.**
|
||||
|
||||
**Referral economics.** On a referred order, two layers stack. **The platform's spread is fixed and never negotiated** — ≈3–5% of the order (it may scale with the sale and carry a $ cap), the network's one piece of referral revenue, and *the same percentage regardless of which referred item sells* — which is what makes the ranking-neutrality guarantee structural rather than a promise ("Curated By This Maker" above). **Maker A's reward sits above the spread and is the makers' to set:** A and B negotiate it through platform tooling — a flat %, a tiered rate ("x% over $y"), a max-$ cap — and may **renegotiate** as the relationship evolves, with one floor, the spread. So B always pays *at least* the spread; set A's reward to zero and no referral money changes hands while the platform still earns its spread. (This negotiability is **scoped to maker↔maker** referrals; the pure non-maker taste-maker tier keeps **uniform, non-biddable** rates — it lacks the peer-respect counterweight — see "Non-maker referrers" below.) Keep the legs as **two separate events** — B→Platform (a fee on B's invoice) and Platform→A (a credit you extend) — so you're **principal on both sides, never a conduit** moving money B→A. That independence keeps it out of money-transmission territory, and holds *only* while A's credit is non-cashable.
|
||||
|
||||
- The referred order's total **subsumes** the standard fee (one clean "this is the referred rate"), rather than stacking toward Etsy-like ~19%.
|
||||
- **The fixed spread is the network's referral revenue line** (≈3–5%, $-capped) — not negotiable, and the *same percentage whatever is referred*; A's negotiated reward is the layer on top, non-cashable (below). One of the few places the network itself generates revenue.
|
||||
- **Non-cashable fee-offset wallet.** A's credit draws down *future platform fees first* — driving the curation flywheel ("curate → wipe out your fees"), lowering collection risk, and cleaner on tax (a fee discount, not income). **Cashing out re-opens the money-flow door** (rent BaaS — Connect/Treasury/Dwolla); a deliberate Phase-2 crossing, not a toggle.
|
||||
- **Pending → cleared settlement.** Credit A as *pending* (not spendable) and hold B's fee pending; settle **both legs together** after B's refund window (≈14–30 days) closes, so in-window refunds void both atomically with zero clawback. Negative balances are a debit on a continuing account: reverse pending, then available, then push any shortfall to the next ACH invoice; keep the ACH mandate active through offboarding; a rolling reserve only if a few high-volume curators make it material.
|
||||
|
||||
### Identity, sovereignty & the opt-in network
|
||||
|
||||
**Separate authentication (infrastructure) from identity ownership (policy).** Run **one shared platform auth** under every white-label store; express sovereignty as a *data-visibility policy* on top — never by splitting auth per store.
|
||||
|
||||
- **Sovereign maker:** full accounts/logins (on your shared auth), customer data private to them, zero network exposure. Sovereignty means *your buyers are private to you* — **not** "run your own login" and **not** guest-only (guest-only is *less* ownership, not more).
|
||||
- **Collective maker:** opted-in buyers participate in the cross-maker graph.
|
||||
- **Centralize auth always** — it's commodity infrastructure *and* the network's foundation. If auth fragments per store, even opted-in makers have no shared substrate to network across. The maker's choice is a flag on one system, flippable later without re-platforming.
|
||||
- **Two consent layers:** the *maker* decides whether their store participates; the *buyer* decides whether they're recognized across the network (GDPR/CCPA). Build both from the start.
|
||||
|
||||
**What "own the customer" means — usage-rights is the headline** (validated, §8). Makers mean **usage rights**: the buyer relationship is *theirs to market to, on any channel, including off the network*. This is the precise inversion of the walled-garden restriction (Etsy/Amazon forbid direct off-platform marketing because the captive buyer is *their* asset), so it's the sharpest anti-walled-garden promise — **and you can grant it unconditionally**, because you don't monetize the captive relationship. It also *subsumes portability*. So: **usage-rights ownership is the headline; network-graph *privacy* (buyers hidden from the cross-maker graph) is a separate, optional opt-out** for the fortress-minded subset — not the core meaning. Operational machinery in the consent subsection below.
|
||||
|
||||
**Opt-in network model — carrot, not stick.** It self-selects the commons-minded makers the network works for. The benefit must be **concentrated and visibly fast** or opt-in becomes a ghost town. Keep the asymmetry **additive** (joiners get referral income, placement, cross-promotion, fee offset, feed surfacing) — never **punitive** (don't cripple the standalone storefront to force joining). Design for **granular** participation, **reciprocity** (surfacing proportional to participation), and a **social-proof opt-in moment** ("makers you respect sent each other 200 buyers last month — want in?").
|
||||
|
||||
**Don't bundle "on our storefront ⇒ on the network."** Forcing full network participation on storefront makers is the coercion the opt-in model rules out, and it weakens the storefront's standalone (n=1) value. Split two switches: **catalog presence** (your products *can* be discovered/curated/fed) is reasonable to **default-on (opt-out)** for storefront makers (low-friction, low-sensitivity) — though being *featured in a specific maker's Curated-By* (a vouch carrying referral economics) is a **per-relationship opt-in** the featured maker approves (§7, "Curated By This Maker"); **buyer-identity participation** (your *buyers* are recognized cross-maker) stays a **separate, consent-gated opt-in** (sovereignty-sensitive, and it's the buyer's data). Take the convenience (auto-catalog-sync); never the coercion (forced identity sharing).
|
||||
|
||||
**Why this is the defensible core.** Shopify *won't* build cross-merchant shared identity — its customers (sovereignty-seeking DTC merchants) would experience it as the platform claiming their buyers, betraying the exact promise Shopify sells. Shopify does the *resell* network (share *products*) but not the *referral* network (share *buyers* with attribution). Your bet: community-embedded makers relate to shared identity as *belonging*, not theft. The moat isn't the storefront, the commitment-commerce mechanics, or the resell network (Shopify has that) — it's the **cross-maker identity-and-attribution layer**, the one asset an island-based incumbent is fenced out of by its own positioning.
|
||||
|
||||
### Customer ownership, consent & martech
|
||||
|
||||
"Own the customer" creates an obligation: consent has to be managed somewhere, and *where the source of truth lives* decides whether the promise is real or a disguised lock-in. The governing test: **if this maker left tomorrow, could they fully and lawfully market to their consented customers without ever touching the network again?**
|
||||
|
||||
**Source of truth: the maker's own ESP — not the network.** For a maker's *own* marketing, the maker's ESP (Mailchimp, Klaviyo, …) is authoritative and the network is a thin integration layer. This is *more* sovereign and *simpler* than a network ledger, passes the leave-tomorrow test tautologically, and dissolves the orphaned-opt-out-link problem: the unsubscribe link resolves to the ESP's native hosted unsubscribe, which the maker keeps after departure.
|
||||
|
||||
- The network captures email + per-type marketing consent at checkout and **pushes the subscriber to the maker's ESP**; the ESP owns everything after (compliant unsubscribe, suppression, deliverability).
|
||||
- Opt-outs happen in the ESP; the network **reflects them via the ESP's unsubscribe *webhooks*** (prefer webhooks to polling). The network is a connector, never an ESP.
|
||||
- **Imported pre-existing lists** then need no special handling — they live in the maker's ESP, naturally arm's-length from the network's records.
|
||||
|
||||
**Two consent domains — only one belongs in the ESP.** *Maker-originating* marketing → the maker's ESP is authoritative. *Network-originating* communications (buyer feed, aggregate marketing emails below, follow notifications) are the network's own relationship with the buyer, governed by **network-communication consent** the network must own. Keep them strictly separate: **the network may never borrow a maker's ESP list for its own sends.** "Send A's curation to A's buyers" means *A's buyers who also opted into network email* — the overlap, never A's whole ESP list.
|
||||
|
||||
**The cross-maker buyer dashboard is a thin cache + fan-out, not a ledger.** With consent scattered across N ESPs, the unified dashboard ("manage all my subscriptions," "opt out of all promotional across all makers") needs a **read cache** (webhook-synced reflection — explicitly *not* the source of truth) and **fan-out writes** into each maker's ESP. Per-*maker* full unsubscribe is easy everywhere; **per-*type* across makers is the fiddly part** (each ESP models promotional-vs-drop differently — groups/tags/interests/lists — so your normalized type taxonomy must map onto each). Every dashboard control writes per-maker (per-type) records; a buyer-level standing preference ("never promotional from anyone") acts as a *default that generates per-maker records as new consents form*, not a send-time global override.
|
||||
|
||||
**Greenfield fork: no ESP → network light-sending is the temporary source of truth.** Greenfield makers (the two interviewed) have no ESP, so the network provides **optional light first-party sending**, where it *is* authoritative because no ESP exists. Keep it deliberately minimal (never a Klaviyo competitor) and **exportable to seed a real ESP later**. The graduation path is the right shape: greenfield → grows → adopts an ESP → the network steps *back* from authority to integration. Network involvement *shrinks* as the maker matures.
|
||||
|
||||
### The network marketing channel (aggregate emails & email-embedded curation)
|
||||
|
||||
Distinct from maker-originating ESP email, the network runs its **own** marketing channel — a discovery engine, provided it obeys the feed discipline.
|
||||
|
||||
- **Personalized aggregate emails.** The network emails buyers (on the **network's** consent list) digests aggregating content/deals from *multiple* makers. The hard rule that keeps this from becoming retail-media-by-email: **content traces to the buyer's own engagement graph** — drops/deals from makers they follow, plus those makers' Curated-By picks — and the network *aggregates, personalizes, and delivers* (pipe) but **never injects platform-chosen merchants or sells placement.** Maker-as-discovery-engine, platform-as-pipe.
|
||||
- **Two-sided opt-out, both network-owned:** buyers opt out of receiving network marketing; makers opt out of being *included* in it.
|
||||
- **Curation as living discovery.** A buyer who engaged with Maker A can receive A's *current* Curated-By picks in their network digest, and as A's curation evolves the fresh picks flow into emails to A's (network-consented) buyers — turning Curated-By into a *recurring* discovery surface for the makers A vouches for, through the channel the network legitimately owns.
|
||||
- **Curated-By as an email widget in the maker's own ESP.** Makers embed their live Curated-By block in their own newsletters, with links carrying the stateless referral token — so a maker's own newsletter becomes a referral-generating surface (they earn credit; attribution works without identity). Points only to verified makers on controllable storefronts; reputation-staked, not paid; and — since email can't run JS — the widget is a send-time-pulled HTML block or a server-rendered image (refreshed per send).
|
||||
|
||||
### Verification: the gate to the trust tier
|
||||
|
||||
Verification sits at the boundary between the commodity layer and the moat layer. Three states, not two:
|
||||
|
||||
- **Unverified → full SaaS, zero network.** Any maker gets the complete commitment-commerce storefront (drops, pre-orders, clubs, own processor) — a paying customer of a genuinely good tool, but in *nothing* trust-gated (no referrals, no Curated-By either direction, no buyer feed, no agent feed). The storefront is commodity; gating it would suppress the Phase-1 wedge. Let everyone in the front door — *once the membership gate has loosened to open signup* (the phasing note below; at launch the door itself is invitation-only).
|
||||
- **Verified → eligible** for referrals, Curated-By, buyer feed, and agent feed — eligibility, not automatic inclusion.
|
||||
- **One gate, then independent switches.** Verification is the single floor; the channel opt-ins (network, agent) are independent choices within it.
|
||||
|
||||
**Curated-By and referrals are verified-only in *both* directions** — a verified maker's stake can't vouch for unverified supply, so an unverified maker can be neither curator nor target. (A useful forcing function toward verifying.)
|
||||
|
||||
**Network opt-in and agent (ACP) opt-in are independent siblings over one shared verified catalog — don't gate agents behind network membership.** A sovereign maker may refuse the maker-to-maker network yet *want* agent reach (net-new, sovereignty-neutral demand). **Verification — not network membership — is the precondition for the agent feed**, so the agent feed inherits the differentiator automatically ("*verified* products for agents," the structured trustworthy supply agents can't scrape). **Default agent inclusion ON (opt-out) for verified makers**: it maximizes the supply density that is your leverage with agents, and it's safe because agent sales route back through the maker as MoR. The agent feed is the **net-new-demand hedge** against the buyer feed's deliberate reach-weakness — the feed *deepens* existing follow graphs, agents *reach* outside them.
|
||||
|
||||
**Verification is aspirational, not punitive — by design.** Because it gates the *demand* (referrals, feed, agents), not the *tool*, makers are pulled toward verifying rather than blocked at signup, and the unverified SaaS tier becomes a **verification funnel** (already paying, catalog in-system, upgrade = "verify → unlock demand"). It also keeps the trust guarantee **absolute**: both consumer-facing surfaces are hard-gated, so anything a buyer or agent sees is verified.
|
||||
|
||||
**The membership gate itself phases — start closed, loosen toward open.** The three states above and the "front door / funnel" framing describe the **loosened end-state**. At **launch the gate is invitation-only, end to end**: there is no self-signup even for the unverified tool — every maker enters by invitation from an existing member who vouches they make original art or craft, rooted in a seed set Wiggleverse staff verify directly (the *originating edge*; full mechanics in the trust-&-safety section). Early scarcity is a feature: it lets word-of-mouth carry the beachhead, keeps the trust guarantee absolute while the rooted web is small, and makes every early edge high-intentionality. **Loosen toward open signup once roots and beachhead density exist** — then the unverified-tool front door opens, verification still gates the demand, and the funnel above kicks in at scale. This is the membership-gate analog of the peer-verification sequencing below (verify-makers-yourself-early → peer-verify-once-roots-exist): both start closed and open only as the trust web earns it.
|
||||
|
||||
**Peer verification — the scaling mechanism.** Makers verify makers, staking reputation. It's *provenance verifying provenance* (real makers spot real makers), the web *is* a trust graph (harder to copy than a checkmark DB — it deepens the moat), and vouching doubles as community formation. **But it's the highest-stakes mechanism** — a Sybil/collusion surface that fails *catastrophically* (one polluted item in the "verified" feed breaks the guarantee). Build a *rooted, staked, multi-vouch trust web with a sampling audit*, never flat "anyone verified can verify anyone":
|
||||
|
||||
- **Real, slashable stake** — a bad vouch costs the voucher (authority revoked, status reviewed), or "staked reputation" is a farmable click.
|
||||
- **Earned authority / time-trust gradient** — newly-verified can't immediately verify others, breaking ring-bootstrapping.
|
||||
- **Rooted graph** — anchor early verifications to a seed set *you* verified; every verified maker traces back to a root, so a compromised subtree can be found and revoked.
|
||||
- **Multiple independent vouches** for full status (ideally not all from one cluster).
|
||||
- **Sampling audit** (random + risk-triggered) so abuse is expensive and detectable, and **ring-pattern instrumentation** from day one.
|
||||
|
||||
**Sequencing:** do *not* launch with peer verification. Verify makers yourself early (few enough for manual review, and you *need* to establish the root set). Enable peer verification once roots exist and volume makes manual review the bottleneck. Verification is now load-bearing in four places (referrals, Curated-By, buyer feed, agent feed), so throughput is a real growth governor — design the scaling path (tiered verification, community vouching as signal, provenance-documentation standards) early.
|
||||
|
||||
### Per-item provenance: the catalog's originality layer
|
||||
|
||||
Verification answers a question about the *maker* (is this a real maker of original work?); provenance answers it about the *item* (is *this product* their original work?). A real maker's catalog is mixed — a potter sells their pots **and** resells pottery tools — and that is fine. What matters is that the buyer, and the trust surfaces, can always tell which is which. So every catalog item carries a **provenance classification** (metadata, like the cross-maker component layer below), **self-attested** by the maker, **audited** by the trust machinery, and **shown to the buyer**:
|
||||
|
||||
- **Original** — the maker's own original art or craft (the pots). Purchased raw materials and supplies (clay, glaze, blanks) are inputs to *making*, not other-sourced components — a pot thrown from bought clay is original.
|
||||
- **Original + components (composite / kit)** — primarily the maker's work but incorporating identifiable parts from others; attributes each contributor (in-network Maker Y, or flagged where out-of-network). The kit / "partial" case, reusing the cross-maker component-metadata layer below.
|
||||
- **Resale — fellow Maker** — reselling another in-network maker's original item; provenance still traces to the true maker. (This is Curated-By / consignment expressed as a catalog item.)
|
||||
- **Resale — third-party / not original** — out-of-network commercial goods, tools, or supplies (the resold pottery tools). Honest, allowed, and clearly *not* original.
|
||||
|
||||
Two rules ride on the classification:
|
||||
|
||||
- **Buyer transparency is the point.** Every item shows its provenance badge — the consumer-facing expression of the moat: not merely "this maker is verified," but "this *item* is original / partly original / a resale / not original (and contains these makers' work)." It is the precise anti-Etsy signal — you always know what you're buying — and it *appreciates* as AI-generated and drop-shipped fakes proliferate.
|
||||
- **Trust-surface eligibility keys off it, per item.** Only **original** and **original-+-(in-network)-components** items are surfaced as the maker's original work in Curated-By / buyer feed / agent feed. A **fellow-Maker resale** may surface *attributed to the true maker* (that *is* Curated-By). **Third-party resale never enters a trust surface** — surfacing it would launder non-original goods through a trusted face (the Etsy-pollution failure mode, from the inside). A composite that contains any non-original component is flagged as such wherever it appears.
|
||||
|
||||
The hard, still-open part is the **line between making and reselling** — finishing, assembling, and kitting sit in between (the standard to write, §14 #2): purchased supplies don't taint "original," but assembling mostly-third-party parts isn't original either. Misclassifying a resale as "original" is a provenance lie → a verification-revocation trigger (trust & safety, §10). Self-attestation makes classifying cheap; the sampling audit plus buyer reporting make gaming it risky.
|
||||
|
||||
### Non-maker referrers: the verified taste-maker tier (Phase 2)
|
||||
|
||||
Any website can join as a *referrer* — a taste-maker/curator (a hobby YouTuber, blogger, podcaster) who isn't a maker but has audience and taste. Financially low-risk (pay-on-conversion, no custody, no MoR, no inventory). The risk is to the **verification moat**, handled by the *same tiering as makers*:
|
||||
|
||||
- **Unverified referrer (open tier).** Generates referral links to verified makers, earns on conversion, but is **not surfaced in any trust-dependent surface** — a traffic source, self-limiting (a bad one doesn't convert) and contained (can't touch trust surfaces).
|
||||
- **Verified taste-maker (trust tier).** A legitimate community voice verified as a *trusted curator* (not a maker), reputation-staked and slashable — badge, surfacing, loses status for shilling. Verification separates the genuine taste-maker (additive) from the affiliate-spam farm (corrosive).
|
||||
|
||||
**The load-bearing guardrail for this tier: uniform, non-biddable referral rates.** A maker-curator's commission pull is counterbalanced by peer respect (and the structural ranking-neutrality guarantee), which is why **maker↔maker rewards are negotiable above the fixed spread** (§7, "Referral economics"). A pure taste-maker's incentive is more purely the fee, so that same negotiability would let them chase the highest payer — retail media through the referrer door. **For non-maker taste-makers, therefore, rates stay uniform and non-biddable** — they feature on **taste, not who pays most.** Plus **disclosure/labeling** (distinguish a maker's peer vouch from a taste-maker's disclosed-affiliate pick; FTC-required anyway), **referrer-only/one-directional** (never a destination, never a maker-verifier), and the **no-walled-garden rule** still holds.
|
||||
|
||||
**Why it's worth doing:** taste-makers are the **net-new-demand engine** the maker-only network is structurally weak at — a third source alongside maker-curation (deepens) and agents (reach), and the most community-native (a trusted human voice, not an algorithm).
|
||||
|
||||
**Phase 2, and a marginal add — not a new crossing.** A pure referrer has no fees to offset, so they need **cashable payout** — the same Connect/mass-pay rail Phase 2 already builds for cashable maker wallets and kit pure-supplier payouts. Three consumers, one scoped crossing; the marginal cost is the verification tier + uniform rate, not new money plumbing. (Paying a taste-maker is a *payout* to an affiliate — principal-on-both-sides, two independent events — not consumer money transmission.) Reserve option: a marquee taste-maker can accrue a pending cashable balance in Phase 1, paid on rail launch — don't open generally.
|
||||
|
||||
### The buyer-facing feed (Phase 2+) — how the marketplace emerges
|
||||
|
||||
A consumer surface (site/app + email) where buyers see followed makers' drops, "buy it again," and Curated-By from makers they follow. This is **how the marketplace *emerges*** from accumulated follows rather than launching cold into the curation-vs-liquidity graveyard (§5).
|
||||
|
||||
**Core principle — discovery is delegated to makers the buyer chose, never performed by the platform.** Every recommendation traces to a follow the buyer initiated. The platform never originates a recommendation — no "you might also like," no algorithmic cross-maker surfacing, no house-promoted placement. **Maker-as-discovery-engine, platform-as-pipe** — the *inverse* of the Shop app. Three payoffs: it keeps you **out of the discovery war** (you never compete with Amazon/Google/agents on recommendation quality); it keeps the feed **non-threatening to sovereign makers** (no buyer sees a maker they didn't choose or that a followed maker didn't vouch for); and it preserves **curation integrity** (follow-gated, reputation-staked curation can't be gamed toward pollution like an engagement algorithm).
|
||||
|
||||
**The deliberate tradeoff:** pure follow-gated discovery is intentionally *weaker at net-new demand* — it deepens the graph buyers already know but doesn't introduce makers outside it. Growth stays permanently "makers bring audiences and vouch," never "the platform surfaces new reach" (agents and taste-makers are the net-new hedges). The correct trade for this positioning, as long as it's chosen knowingly. Disciplines: **buyer-opt-in by nature** (doubling as cross-merchant data consent); lead with the safe end (buy-it-again → followed drops → curated-by); any move toward platform-originated/algorithmic discovery is a deliberate, opt-in-by-everyone, much-later decision.
|
||||
|
||||
### Storefront architecture & Shopify coexistence
|
||||
|
||||
**Two layers, kept separate — the core architectural decision.** A *storefront layer* (per maker) sits under a *shared cross-maker network service* (the moat). Conflating them couples the moat to one storefront engine and to single-tenant boundaries.
|
||||
|
||||
- **Storefront layer (per maker).** Either your **white-label storefront** (headless backend + themed frontend) or the maker's **existing Shopify store** (federated). Handles that maker's catalog, cart, checkout, orders, and own payment processor (MoR).
|
||||
- **Shared network service (cross-tenant — the moat).** Verification graph; **canonical catalog index** — *with a cross-maker component-metadata layer* ("this product contains a component made by Maker Y," the kit/BOM relationship expressed in the catalog itself); cross-maker identity/follow graph; **a complete per-maker order history** (every order since the maker joined — captured because referral/fee computation must see orders, and reused for the transparency below); referral/Curated-By attribution + fee/wallet ledger; buyer feed; agent/ACP feed. A standalone service with its own datastore, spanning *all* makers and federating over heterogeneous storefronts — deliberately **not** part of any storefront engine.
|
||||
|
||||
**The canonical catalog index lives in the network service — not a Medusa "mega-store."** Every maker's catalog (Shopify via Admin API, your Medusa storefronts via their API, anything else via adapters) is transformed into one normalized, verified index that powers Curated-By, the feed, and agents. A Medusa instance is a *storefront* (cart, checkout, one MoR, sellable inventory); the network catalog is a read-optimized *index* of products that live and sell elsewhere. Pouring all makers into one Medusa instance would make a thing shaped like a store that must never behave like one, and couple the neutral network to one engine. Keep each storefront as the system of record; the network holds a normalized verified *projection* — which also keeps Shopify and Medusa products *co-equal sources*, not Shopify imports into a competitor-shaped container.
|
||||
|
||||
**Cross-merchant order transparency — the positive-sum twin of the money-flow "no."** Because the network already sees every maker's orders (it must, to compute referrals) and knows which products contain other makers' components (the catalog metadata layer), it can hand each maker something no single-store tool can: **visibility into every order that touches their work anywhere in the network** — their item sold inside another maker's kit, a referral they sent that converted, a component of theirs moving through a partner's store. This is the grocery **scan-based / Direct-Store-Delivery** pattern: the supplier sees the sell-through and knows when to restock or re-engage, *without being the store*. It costs the network nothing to give (it already holds the data), it is **uniquely the network's to give** (only the cross-tenant vantage sees across stores — which deepens the moat), and it stays firmly on the right side of the line: **transparency and coordination are free; custody is not** (the kit-supplier notification in Appendix C.2 is one instance). One asset, three uses — the same order history powers the referral ledger, the network-health metrics (§12), and this reporting.
|
||||
|
||||
**Build the white-label storefront on a headless backend (Medusa recommended).** "Build the 20%, rent the 80%" in code: the headless backend (Medusa — Node/TS, modular, payment-agnostic, no per-order revenue share; alternatives Saleor, Vendure, Spree) supplies cart, catalog, orders, customers, fulfillment, BYO payment, and you add the commitment-commerce engine — **drops, pre-orders, clubs, raffle/queue — as custom backend modules.** Mental model: in Medusa, *modules are backend domain logic; the storefront is a separate frontend app* consuming the Store API. So **the storefront is not a module** — the commitment-commerce *features* are modules, the storefront is their client, and the network service above is neither (standalone). Multi-tenancy (one shared instance vs. per-maker instances) is decoupled from the moat because the network service is separate either way; for two pilots, a single instance + shared theme is plenty.
|
||||
|
||||
**Shopify makers: federate, don't migrate.** The network is storefront-agnostic, so a Shopify maker keeps Shopify (their MoR/processor) and joins via two hooks: **catalog sync** (Admin API + product webhooks into the verified index → eligible for Curated-By/feed/agents) and **referral handoff + attribution** (the Curated-By link carries a signed token that rides in as a **cart attribute → order note_attribute**, read from the order webhook to bill B / credit A — no checkout customization, any plan). **Hybrid wedge:** evergreen catalog stays on Shopify; the maker uses your platform only for the commitment-commerce *events* Shopify handles badly. Migrate later only if earned.
|
||||
|
||||
**Curated-By on Shopify** rides **one installed app** built on **theme app extensions** (not legacy ScriptTag): a draggable **app *block*** for the curator role (renders verified picks from your network service, links carry the token) and an **app *embed*** for the recipient role (reads `?ref=` on landing, writes it as a cart attribute → order note_attribute → `orders/create` webhook). The app is a thin client — curation, verification, and the ledger live in your network service. Optional app proxy for crawlable server-rendered sections. Because it rides cart attributes through native checkout, the *entire* loop — display and attribution — works on a stock Shopify store with no checkout access and no theme surgery.
|
||||
|
||||
**Strategic payoff:** the storefront-agnostic network makes your addressable makers the *entire* Shopify/Etsy/standalone install base — reachable via federation, not just makers willing to switch storefronts. The storefront is the wedge for makers who want a better tool; the network is open to anyone who verifies and syncs a catalog. This de-risks the scariest adoption question ("will makers switch storefronts?") — they don't have to. (Caveats: Shopify APIs are partly rented land, but you're not dependent on them — one source among several; Shopify takes its cut on Shopify sales, fine, since you bill your fee separately via ACH.)
|
||||
|
||||
**Admin surfaces — three, not two.** The *network* is one product with one admin for everyone; the *storefront* admin is Medusa's (your makers) or Shopify's (theirs).
|
||||
|
||||
- **Storefront admin** — only for your Medusa makers (products, drops, orders, fulfillment); a Shopify maker uses Shopify's admin.
|
||||
- **Network admin (bespoke, universal)** — curation, referral earnings + wallet, verification status + peer-vouching, follow/feed participation, network + agent opt-ins. No home in either storefront engine because it's the cross-tenant moat layer.
|
||||
- **Billing/account (bespoke, universal)** — ACH mandate, fee tier, invoices, payment history.
|
||||
|
||||
So everyone uses the bespoke network + billing admin; Medusa makers *additionally* get Medusa's storefront admin; Shopify makers get theirs from Shopify. Notes: **ACH is universal, not referral-specific** — every maker gives a debit mandate at onboarding (gate mandate-on-file as a precondition for referral eligibility, so a referral-only maker can't accrue an uncollectable fee). And **Shopify makers get a read-mostly catalog/sync/analytics view** — a trust surface: a mirror (not a second editor; Shopify stays system of record), sync health (a silently-broken sync makes products vanish from the feed), and **network analytics** uniquely yours to provide because only you see across stores ("Curated-By sent me $800 last month" does quiet retention work). **Build-ordering:** the bespoke network + billing + analytics admin is foundational and on the critical path for both populations from day one — even the two Medusa pilots need it the moment Curated-By and referrals exist.
|
||||
|
||||
### Business model: network is the product, storefront is an optional convenience
|
||||
|
||||
Now that Medusa exists and the network federates over any storefront, *centering* on storefront hosting has weakened — three erosions: the storefront is the commodity layer; Medusa makes it cheap for everyone; federation means a maker doesn't *need* your storefront to be in the network.
|
||||
|
||||
- **The network is the product and the value capture.** Charge for the network — referral take + subscription — **uniformly, whether a maker is on your storefront or Shopify.** Revenue must not depend on storefront adoption; that keeps you credibly neutral and makes TAM = *every* maker.
|
||||
- **The storefront is an optional, opt-in convenience — SaaS-priced, never GMV.** GMV-take would recreate channel conflict, tax makers for using your rails (making your storefront *worse* than self-hosted Medusa), and pull you back toward money-flow entanglements. Price it as a flat SaaS fee that covers cost.
|
||||
- **Three postures — reject the first.** *Don't* be a storefront company with a network (GMV fees, competing with Shopify on hosting — wrong center of gravity). *Do* be a network company that offers a storefront — the best-in-class native commitment-commerce experience for makers who want it or have nowhere else to be — as a wedge, not the business.
|
||||
|
||||
**"Why not just point makers to Medusa Cloud?"** Because Medusa Cloud sells *hosted infrastructure to developers*; it doesn't give a maker a working drops-and-clubs storefront. You sell a **vertical, maker-ready commitment-commerce product** where Medusa is *invisible plumbing*. The entire gap between raw infrastructure and a working maker storefront is your product — pointing a maker to Medusa Cloud is like pointing them to AWS. Corollary: **don't position or price as a hosting company** ("Medusa Cloud + modules" drags you into competing on infra margins). Hosting is a cost you absorb; the vertical experience (and the network) is what you sell.
|
||||
|
||||
### Partner / consultant network: onboarding the high-touch tail
|
||||
|
||||
Implementation consultants (community-embedded especially) onboard the **high-touch tail** without the network becoming a services business, doubling as community-aligned distribution and mirroring the partner ecosystems that grew Shopify and Medusa — a second flywheel you *enable* (certification, a directory, the referral-income share below) but don't *staff*. Guardrails: keep the product **genuinely self-serve for the median maker** (if makers *need* a consultant for a basic store, the product failed and partners are masking it), and structure partners as **referral/implementation partners, not white-label resellers**, so they don't become the relationship-owner and disintermediate you.
|
||||
|
||||
**How partners are paid — a negotiated, tapering share of the maker's *earned* referral income.** A maker may opt to bring on a partner to stand up their storefront and onboard them onto the referral network, and pay for that help out of the upside it creates: the partner earns a **share of the referral income the maker earns *as a curator*** (the negotiated Curated-By referral reward in "Referral economics" above) — not a fee on the maker's sales, and not a cut of the platform's spread. The natural shape is **front-loaded and tapering** — e.g. 100% of the maker's first $X in referral earnings to the partner, then a declining share as the maker's curation takes off — a *help-me-start, earn-out* deal, not a perpetual tax. The two negotiate and structure the schedule through **platform tooling**; the network stores the agreed terms and settles the split. This is the *one* partner-compensation leg the network touches, and it rides the same invariants as the rest of the money model, so it adds posture, not exposure:
|
||||
|
||||
- **Out of the flow, principal on both sides.** The platform never routes the maker's money to the partner (that is custody / transmission — Appendix C.2's line); it independently *reduces* the maker's referral credit and *extends* the partner a credit or payout — two events, principal on each, never a conduit (the §7 referral-economics pattern).
|
||||
- **A fourth consumer of the one Phase-2 cashable rail.** The maker's referral credit is non-cashable (fee-offset); a partner who isn't itself a maker has no platform fees to offset, so — exactly like the verified taste-maker and the kit pure-supplier — it needs *cashable* payout over the same scoped Connect/mass-pay crossing Phase 2 already builds. (A partner who *is* a maker can take non-cashable wallet credit instead.) One more rider on that crossing, not new money plumbing; Phase 1 can accrue the partner's split as *pending* and pay it on rail launch.
|
||||
- **The negotiation tooling is the ledger pattern already required.** "Partner P takes schedule S of maker A's referral earnings, tiered by cumulative $" is **stateful settlement on a running total** — the same machinery as the wholesale volume tiers (Appendix C.5): the schedule is a stored term, settlement is cumulative (not per-order-independent), and the retroactive-vs-prospective question at a tier boundary is a term the two agree and the platform applies. Build the ledger for cumulative partner splits from the start.
|
||||
- **The curation-integrity guardrail still binds.** The partner shares the maker's *earned* income but gets **no say in *whom* the maker vouches for** — otherwise a partner would push the maker toward whatever converts, reintroducing the commission-corruption drift the whole referral design resists (§3; "Curated By This Maker"). Disclosure applies where a partner relationship would read to buyers as paid placement (FTC, §11). The deal is comp for *activation*, not influence over *taste*.
|
||||
|
||||
**Cash, payment terms, and any other agreement live *outside* the network — by the same money-flow rule.** The referral-income split above is the only money leg the network handles, precisely *because* it can be done principal-on-both-sides without entering the flow. Everything else a partner and maker might agree — an **upfront cash payment, a setup fee, a retainer or hourly, milestone payments, or any other lawful arrangement** — is a **direct business agreement between the two, settled off-platform**, exactly as a maker pays any other vendor it hires. The network neither processes nor takes custody of that money, because doing so would put it squarely back in the money flow it is built to stay out of (custody is the line — "Money flow: stay out of it"; Appendix C.2; the spine). So the division is clean: **the network settles the one leg it can keep out of the flow (the referral-income share, via the cashable rail), and stays entirely clear of every direct-cash leg, which the parties arrange and settle themselves.** The tooling may *record* an off-network term for the parties' own clarity (recording is coordination, which is free) — but it never *moves* that money.
|
||||
|
||||
### Entity structure & trust positioning: non-profit, transparent, volunteer-built
|
||||
|
||||
The org is a **true non-profit (501(c)(3) or equivalent), radically transparent (open books), engineered by volunteers with LLM-accelerated development, not raising money or selling equity.** This converts the trust position from a *promise* into a *guarantee*, and it's funded-viable for a reason that's thematically exact:
|
||||
|
||||
- **The cost structure that usually makes non-profit tech infeasible is the one LLMs just collapsed.** The primary cost center — engineering — has been deflated by the same force the venture exploits. The entity and the opportunity are the same bet pointed two ways.
|
||||
- **No fundraising removes the only strong argument against true non-profit** (a PBC/B-Corp hedge exists only to preserve VC/equity optionality — which here is the door makers fear, so foreclosing it is the point).
|
||||
- **The structure *enforces* the anti-Etsy promise.** A 501(c)(3) legally can't be sold or distribute profits — the strongest answer to "will you sell our trust for GMV?" "Bind with structure, not promises," realized in the entity itself.
|
||||
- **Radical transparency is the substrate of the verification moat.** Open books/governance let the community *verify the incorruptibility* of "verified handmade" rather than take it on faith — the mechanism, not a nice-to-have.
|
||||
|
||||
**The risk moved, it didn't vanish:** apply the rigor you'd have spent on fundraising to **volunteer sustainability.** LLMs change the math but not the human dynamics (attrition, bus-factor). Separate what needs reliability (network service, ledger, verification) from what tolerates volunteer cadence (themes, nice-to-haves), and keep the critical core off bus-factor-one. Also watch the **"fragile" perception** with professional makers — non-profit + volunteer reads as *aligned* to commons-minded makers but possibly *might-fold* to those building a livelihood; transparency is the counter, and §8 should test whether it reads **safe** or **nervous**.
|
||||
|
||||
### Hosting: managed vs. self-host (and the pilot)
|
||||
|
||||
**What you sell and where you host are independent** — makers never see the infrastructure, so hosting is a pure cost/ops choice, *reversible and invisible* because Medusa is portable. **Managed (e.g., Medusa Cloud) now** (your scarce resource is attention on product + network, not infra savings); **self-host at scale** (unit economics; don't couple margins to one vendor). Multi-tenancy interacts with hosting price (per-maker instances multiply managed per-instance pricing; a shared instance is different math) — negotiate as a *multi-instance platform customer*. **The network service is hosted separately, always** — that separation is what makes the hosting choice switchable.
|
||||
|
||||
**The two-maker pilot:** self-host on **GCP — Cloud Run + Cloud SQL (Postgres)**. Cloud Run scales toward zero when idle (suits drop-spiky traffic) and avoids babysitting a cluster at n=2; the shared-instance cost is low tens of dollars/month. **Watch the drop-traffic spike** — a scheduled drop is a burst of concurrent buyers, exactly the load that embarrasses an under-provisioned instance, and a storefront falling over *during a drop* is the worst moment for maker trust; load-check before a real drop. On cost: pass-through or a flat fee is fine, but **treat cost recovery as trivial** — two happy pilot makers (your first verification roots and references) are worth far more than reconciling a small GCP invoice. Frame any charge as "covering pilot costs," not the product's pricing model.
|
||||
|
||||
---
|
||||
|
||||
## 8. Next step: maker discovery (do this before building the platform)
|
||||
|
||||
Goal: verify whether the cross-merchant-identity conversion advantage (Shop Pay's lift) is a *real moat for these makers*, and whether you can build your own version — and, more broadly, whether the demand side exists.
|
||||
|
||||
**Avoid the measurement trap.** Don't ask "how much value does Shop Pay give you" — makers can't see the conversion counterfactual, so answers are vibes. Shop Pay's lift comes from **cross-merchant identity recognition removing first-purchase friction**, so measure the driver: the revenue split between **repeat fans vs. first-time strangers**, and whether buyers **already have Shop Pay**. (Repeat-fan-heavy makers capture little of the network effect; viral/stranger-traffic makers capture a lot.)
|
||||
|
||||
**Don't concede a false tradeoff.** "Lose conversion to gain alignment" assumes you can't have the lift — but a curated cross-promotion network *structurally generates* cross-merchant identity as a byproduct (your own niche-scoped Shop Pay equivalent). You can't offer it day one (cold-start), but it's a **year-three asset, not a launch feature.** Concede the *timeline*, not the moat.
|
||||
|
||||
**Listen for (beyond what you ask):** where buyers come from today (traffic-mix tell); what they pay Shopify **all-in** vs. what they *think* (most underestimate — the gap is your opening); whether the value-alignment grievance is real willingness-to-switch or venting; whether they'd want shared buyer recognition or guard their list; and — the asset they'll least volunteer — **whether they have an audience they'd bring** (a maker with no audience is a cost, not an asset). Also test whether the **non-profit/volunteer/transparent** framing reads as *safe* or *fragile*.
|
||||
|
||||
**Weight what makers *do* over what they *say*.** "I'd switch for alignment and lower fees" is cheap and constantly contradicted by behavior (people stay on Etsy they openly resent). The makers worth building for already maintain a second channel, already *moved* on something, already bring their own buyers.
|
||||
|
||||
**Interview log — early signal (2 greenfield makers, no existing site).** Liked *quickly spinning up an in-network storefront* (a network subdomain, not a dedicated domain) over a standalone site — confirming the greenfield on-ramp and the in-network address as a *preferred default*. Both conditioned it on *owning the customer*, which probing clarified means **usage-rights** (market to them directly, use/export the list, on or off the network) — the sovereignty principle reinvented unprompted. **Two cautions:** (1) the weakest evidence tier (liking an idea ≠ adopting ≠ paying ≠ staying) — chase the *behavioral* next step (real catalog in, a real drop, a referral); (2) it validates the *supply/storefront-convenience* (commodity) layer only — the **demand/curation moat is still untested** (do they have audiences, would they vouch, would they want to be vouched-for?). The network layer remains the load-bearing unknown.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decision gates
|
||||
|
||||
Gate the real platform build on evidence, not enthusiasm:
|
||||
|
||||
- Do the first makers **refer you** to others?
|
||||
- Is the **pain consistent** across makers (the same 20%)?
|
||||
- Do target makers **have audiences**?
|
||||
- Can you reach roughly a **dozen makers** who want the same thing?
|
||||
|
||||
Two makers justify a thoughtful, portable data model. They do not justify a platform. Build the data layer and the vertical primitives now; gate everything else on the dozen.
|
||||
|
||||
These gates are stated qualitatively here; §12 gives each a quantitative instrument (and a North Star) computed from the cross-merchant order history, so the dozen-maker gate can be *measured* rather than felt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Trust & safety & maker accountability
|
||||
|
||||
Verification (§7) is only an *entry* gate — it answers "is this a real maker of original work?" at the door. This section is the other half: **what happens when a verified maker behaves badly afterward.** Commitment commerce makes this the *signature* failure mode, not an edge case — "a verified maker collects pre-orders/deposits and ghosts" is the structurally most-likely scam, because the model takes money before delivery (§4). The money-flow stance solved the *financial* exposure (the maker is MoR, so the platform never holds the funds at risk — §7), but it did **nothing** for the *reputational* exposure — and the reputational one is the exact thing the moat rests on. A single polluted "verified" item breaks the guarantee for every buyer and every agent downstream.
|
||||
|
||||
The **shape** below is settled; the **reputation engine itself is explicitly OHM-guided work** (handbook §4.4, [OHM](https://rfc.wiggleverse.org/p/ohm/c/default/)) and is gathered in the last subsection. This whole section turns on OHM concepts — **reputation, trust, harm, recourse, value, dignity** — so where it names one, the canonical RFC governs: cite it, propose it where undefined, don't coin a local meaning.
|
||||
|
||||
### The originating edge — the trust topology
|
||||
|
||||
- **Every maker enters by invitation, and the invitation *is* a vouch** that the invitee makes original art or craft — rooted in a staff-verified seed set (§7 verification). The invitation graph is a **permanent topology**: every maker traces back, by some chain of vouches, to a root the platform verified directly. (The membership gate starts invitation-only and loosens toward open signup over time — §7 — but the originating edge persists as graph structure even after the front door opens.)
|
||||
- **Inviting doesn't pay.** The incentive to invite is the commercial relationship the network creates, **never a bounty** — a per-invite payout would manufacture exactly the Sybil/farming incentive the rooted graph exists to resist. You invite people whose work you'd stake your standing on, because that is what the edge means.
|
||||
|
||||
### How reputation flows along the edge
|
||||
|
||||
- **Reputation flows *up* the originating edge** — when a maker misbehaves, consequence propagates back toward whoever vouched for them — but **transitively, decayed (a per-hop coefficient), and hop-capped.** So the impact is **strong right next to the misbehavior** (a prompt-to-act for the inviter who actually can act) and **negligible by ~6 degrees out** (a distant root isn't punished for a great-great-invitee's fraud).
|
||||
- **Framed as positive reinforcement, not punishment.** The primary direction is *earning and growing standing* by vouching well and making well; the up-the-edge consequence is the downside tail of that same mechanism, not a separate penalty system. Good vouching compounds standing; a bad vouch costs the voucher (this is the §7 verification "slashable stake," seen from the accountability side).
|
||||
|
||||
### Consequence: loss of standing, not expulsion
|
||||
|
||||
- **The consequence is a low, buyer-visible reputation score + loss of all network benefit** (Curated-By, the buyer feed, referrals, the agent feed) — **not expulsion.** The misbehaving maker **keeps the storefront and the tool** (they are a paying SaaS customer, and the tool was never the thing gated — "gate the demand, not the tool"), but **loses amplification** and **wears a score buyers can act on.** This is *transparency as enforcement*: rather than policing every maker centrally, the network makes standing legible and lets buyers route around bad actors — which keeps the trust surfaces clean *by construction*, since a low-standing maker has already fallen out of Curated-By / feed / agents.
|
||||
- **This is a "member in bad standing," not the rejected model "a."** It is emphatically **not** flat "anyone can verify anyone" (§7 warns against that). At launch every storefront holder was *invited*, so even a maker in bad standing entered through the rooted graph — they are a member who lost standing, not an anonymous bad actor who slipped the gate.
|
||||
|
||||
### Authority & appeal
|
||||
|
||||
- **The inviter holds primary suspend/expel authority** over their own sub-graph — the person who vouched is the person best placed, and most motivated (their standing is on the line), to act. Layered on top: a **platform floor for active buyer harm** (the platform can act directly when buyers are being harmed, regardless of what an inviter does), and a **governance appeal path** (§14 #1) for the maker who believes a consequence was unjust. **Expulsion is the rare extreme**, reserved for active harm — the default consequence is loss of standing, above.
|
||||
- **Provenance lies are trust violations.** Misclassifying a resale as "original" (§7 per-item provenance) is not a clerical error — it is a deception that pollutes the trust surfaces, and so it is a verification-revocation trigger handled by this machinery.
|
||||
- **The legal spine of the ghosting case is in §11.** Non-delivery isn't only a reputation event: the FTC 30-Day Rule (§11, "Consumer protection / FTC") is what a ghosting maker is *violating*, and the platform's compliance-by-design notice/refund UX is the buyer's first recourse *before* a chargeback against the maker's processor. Reputation consequence and legal recourse are two responses to the same act.
|
||||
|
||||
### Still open — the OHM-guided reputation engine (and adjacent frameworks)
|
||||
|
||||
The **shape** above is settled; the **system** that implements it is the open work, and it is OHM-guided by nature:
|
||||
|
||||
- **The reputation engine itself** — scoring, the decay-coefficient and hop-cap *values*, how good standing accrues over time, how the score is *displayed* to buyers, and the benefit-gating thresholds (what score loses Curated-By vs. the feed vs. agents) — all turn on operationalizing OHM **reputation, trust, harm, recourse, value, dignity**. Defer to the relevant RFCs; propose them where undefined (a load-bearing concept OHM hasn't defined is itself OHM work, not license to improvise — top-of-file note).
|
||||
- **Verification-revocation triggers and process** — the concrete patterns that drop a maker's standing or revoke verification (non-delivery pattern, inauthentic/AI-generated goods passed as handmade, sustained non-response), and the process around each.
|
||||
- **The buyer-harm / dispute framework** — how a buyer reports harm, how it's adjudicated, and the recourse on each side.
|
||||
- **Non-delivery handling, given the platform is out of the flow** — the platform is **not a guarantor** (it holds no funds to refund from); the buyer's monetary recourse is a chargeback against the maker's own processor (maker is MoR), and the platform's contribution is *transparency* (the score, the public consequence) plus the §11 compliance UX — not an escrow it deliberately declined to hold.
|
||||
- **False-report and collusion controls** — the abuse surface of the accountability system itself (weaponized reports, retaliatory low scores, collusion rings), instrumented from day one alongside the §7 verification ring-detection.
|
||||
|
||||
---
|
||||
|
||||
## 11. Legal & compliance
|
||||
|
||||
The memo's legal strategy is not a list of compliance chores bolted on at the end — **it is one architectural choice, viewed through a legal lens.** Staying out of the money flow (§7, "Money flow: stay out of it") was justified there as a financial-risk decision; its larger payoff is *legal*: a single structural fact — **the maker is merchant of record on the maker's own processor, and the network never touches buyer funds** — discharges obligations across three otherwise-separate regulatory categories at once. **No money transmission. No marketplace-facilitator tax duty. No merchant-of-record liability.** Where a competitor that sits in the flow must license, collect, remit, and indemnify, the network simply isn't the regulated party. The genuine open exposures are exactly the ones that *aren't* dissolved by the money-flow stance — and the discipline of this section is to separate the two: what the architecture already answers (recap, hold the line) from what it doesn't (engineer down, or flag for counsel).
|
||||
|
||||
**Convention: flag and verify — not legal advice.** Everything below is the *engineering posture* — the structural choices that keep the regulatory surface small, and the questions worth putting to specialist counsel. It is not legal advice; each item names what to verify and with whom. Where a principle turns on a load-bearing concept — *consent, harm, recourse, value, dignity* — it defers to the [Open Human Model](https://rfc.wiggleverse.org/p/ohm/c/default/) per the top-of-file note (handbook §4.4): cite the RFC, don't coin a local meaning.
|
||||
|
||||
### Money transmission (MTL) — recap from §7
|
||||
|
||||
Out of the flow → **none.** Holding customer funds (escrow, a balance, a payout you control) triggers state-by-state money-transmitter licensing in the US — the single most expensive licensing regime a small org could wander into. The network never holds buyer funds: the maker is MoR on the maker's own processor, and the platform fee is billed in arrears via ACH/invoice (§7, "How the platform gets paid"), which is the network charging *its own customer for software*, not transmitting a third party's money.
|
||||
|
||||
- **The cashable Phase-2 rail rents a licensed transmitter — it doesn't become one.** When cashable payouts arrive (maker wallet cash-out, kit pure-supplier settlement, taste-maker affiliate payout — §7), the money moves over a BaaS rail (Stripe Connect/Treasury, Dwolla, a mass-pay provider) whose operator *is* the licensed money transmitter. The network is a platform user of that rail, not the regulated transmitter — the "rent the licensed infra, don't build it" move applied to compliance.
|
||||
- **The non-cashable wallet is not stored value.** The fee-offset wallet (§7) credits a maker against *future platform fees* — a discount / accounts-receivable entry, not a balance the maker can withdraw or spend with third parties. It is therefore neither stored value, a prepaid-access instrument, nor transmittable money, and stays outside the MT and stored-value regimes by construction. **Cashing out is the deliberate crossing** that re-opens the door — which is precisely why it is gated to Phase 2 behind a rented transmitter, never a toggle. *(Verify the wallet's non-cashable design against your states' stored-value / prepaid-access definitions before launch.)*
|
||||
|
||||
### Marketplace-facilitator sales tax — the centerpiece
|
||||
|
||||
This is the high-consequence area the memo had entirely missing, and the one most likely to surprise. Post-*Wayfair*, every US state with a sales tax has enacted **marketplace-facilitator statutes** that can attach a *tax-collection-and-remittance duty* to a "marketplace facilitator" **even when it is not the merchant of record** — the whole point of these statutes was to make the platform, not thousands of small sellers, the collection point. So "the maker is MoR" does **not**, by itself, settle it; facilitator status turns on a separate test.
|
||||
|
||||
**The two-part conjunctive test.** In most states (modeled on the SSUTA / MTC definition) you are a marketplace facilitator only if you do **both**: **(prong 1)** list, advertise, or otherwise facilitate the sale of a marketplace seller's products, **and** **(prong 2)** directly or indirectly **collect the payment** from the purchaser and transmit it to the seller. Both prongs are required — it is conjunctive, not "either."
|
||||
|
||||
- **The referral-handoff model (§7, Model A) fails prong 2 — by design.** Checkout happens entirely on Maker B's own store and processor; the network passes a signed referral token, then bills B a fee and credits A — money on which the network is *principal* (its own fee revenue, its own credit), never buyer payment it collects and forwards. It arguably satisfies prong 1 (it facilitates discovery) but **cannot satisfy prong 2**, so it is not a facilitator. Many statutes reinforce this with an explicit **advertising-only / referral exclusion**: a person who merely advertises or lists products and refers purchasers to the seller, *without processing the payment*, is carved out by name. The referral network sits squarely inside that exclusion — which is *why* it is a referral network and not a checkout.
|
||||
- **The fragile edge is "indirectly collects payment."** Prong 2's "indirectly" is the word that does the damage: some statutes read it broadly enough to sweep in arrangements where the platform touches the payment flow even lightly (routing, splitting, holding briefly). The referral handoff is safe because it touches **zero** payment flow — but the margin of safety is "we never touch the money," not "we're small." The moment any feature lets the network touch buyer payment, the edge moves.
|
||||
- **Danger zones — where prong 2 attaches and facilitator status flips on:**
|
||||
- **Phase-3 shared checkout (§7).** A unified cart where the *network* collects the buyer's payment and routes/splits it to makers directly collects payment — prong 2 satisfied, facilitator duty attaches. This is exactly the "much later, optional, opt-in" crossing the phasing reserves, and **it carries a sales-tax-collection obligation as a first-class consequence**, not an afterthought.
|
||||
- **Split-payment kits (Appendix C, Model 3 — Connect destination charges).** One checkout auto-splitting to N connected accounts has the platform *indirectly collecting and transmitting* — facilitator. One more reason Model 3 is "avoid until you choose to be marketplace-of-record for kits."
|
||||
- **Connect destination/separate charges or `application_fee` where the platform is MoR.** The §7 charge-type dial that flips MoR to the platform *also* flips facilitator status, because platform-MoR means the platform is indirectly collecting the buyer's payment. The Connect setting is, again, a legal-posture decision (next).
|
||||
- **Tax calculation belongs to the maker's storefront layer — never the network.** Sales-tax *calculation, collection, and remittance* is the MoR's job, and the MoR is the maker. Both storefront engines do it natively (Shopify Tax; Medusa's tax module) and both integrate the specialist engines (Avalara, TaxJar) for nexus-aware rates and filing. The network must **not** build tax-calc — doing so would quietly adopt the very duty the architecture declines, and would invite the argument that a thing computing sales tax across makers is behaving like a facilitator. The network computes *its own* fees; the storefront computes the buyer's tax. *(Confirm facilitator status per state with a SALT attorney before any feature that touches buyer payment — especially before Phase 3.)*
|
||||
|
||||
### Stripe Connect configuration as legal posture — see §7
|
||||
|
||||
The Connect configuration is the hinge on which MTL, facilitator status, *and* merchant-of-record liability all turn, and it is **already specified in §7** ("Money flow"): the stance holds **only at Standard accounts + direct charges + `application_fee`** — maker stays MoR, disputes stay on the maker's balance, the network skims a fee without entering the flow. Destination / separate-charges-and-transfers and Express/Custom accounts each flip one or more of those properties (MoR → platform, dispute liability → platform, indirectly-collecting → true → facilitator). This section only records *why* §7's setting is a legal constraint and not an implementation detail; the dial itself is governed there. **Do not re-decide it here — cross-reference and hold the line.**
|
||||
|
||||
### Raffle / lottery — engineer out the consideration
|
||||
|
||||
The raffle drop is the one primitive (§4) with direct **gambling-law** exposure. A lottery is three elements, **conjunctive: prize + chance + consideration.** Remove any one and it is not a lottery — so the design removes the controllable one.
|
||||
|
||||
- **Free-entry allocation removes *consideration*.** The default raffle/queue mechanic allocates a *right to buy* a scarce drop by random draw, with **free entry** — no payment to enter the draw; the winner then purchases at the normal price like any other buyer. With no consideration paid for the *chance*, there is no lottery: it is allocation of limited inventory by lot, not a paid gamble (the same shape as a "no purchase necessary" sweepstakes — the chance must be free). The OHM frame is *fairness/dignity* in allocation: a transparent, equal-chance draw for scarce goods, not pay-to-play.
|
||||
- **Paid-entry raffle is off by default, pending per-state clearance.** A raffle where the buyer *pays to enter a draw for a prize* re-adds consideration and is a regulated raffle/lottery — generally unlawful for a commercial entity, lawful only for licensed nonprofits in some states, banned or tightly restricted (registration, caps, reporting) in others. It ships **disabled by default**, gated behind explicit per-state legal clearance. *Note the cross-current:* the 501(c)(3) entity (§7) may actually *open* charitable-raffle paths a commercial operator can't use — but that is a flag for counsel and a deliberate per-state opt-in, **not** a green light to turn paid raffles on. *(Verify per state — gambling / charitable-gaming law is among the most locally variable regimes there is.)*
|
||||
|
||||
### Consumer protection / FTC
|
||||
|
||||
Three distinct FTC-adjacent duties: two fall on the maker (the seller / MoR) with the platform easing compliance, one the platform's own architecture already discharges.
|
||||
|
||||
- **Affiliate disclosure (maker's + referrer's duty; platform surfaces it).** The verified-taste-maker tier and any compensated curation must carry clear, conspicuous affiliate disclosure (FTC endorsement guides, 16 CFR 255) — already required by the §7 taste-maker guardrails. The platform's job is to **make disclosure automatic in the UX**: label a disclosed-affiliate pick distinctly from a maker's reputation-staked peer vouch (reciprocal/reputational, not cash-compensated), so the buyer can tell sincere vouch from paid referral. Honest labeling here is a moat property, not just a rule — the whole anti-retail-media stance depends on the buyer seeing the difference.
|
||||
- **The pre-order / 30-day delivery rule (maker's duty; platform UX eases it).** Commitment commerce *is* taking money before delivery, which squarely triggers the FTC **Mail, Internet, or Telephone Order Merchandise Rule** ("the 30-Day Rule"): the seller must ship within the stated time — or within 30 days if none is stated — and on delay must give the buyer notice and a right to cancel for a prompt refund. This is the **maker's** obligation (maker is seller / MoR), but the commitment-commerce engine is uniquely positioned to make compliance the path of least resistance: **require a stated fulfillment window** at drop/pre-order creation, surface it to the buyer at commit time, **prompt delay notices** when a window slips, and support one-click cancel/refund. Compliance-by-design as a product feature — the platform eases the duty without assuming it. (This is also the legal spine of the §10 "verified maker collects pre-orders and ghosts" failure mode: the 30-Day Rule is what a ghosting maker *violates*, and the platform's notice/refund machinery is the buyer's first recourse before any chargeback.)
|
||||
- **Handmade-claim substantiation (platform architecture already answers it).** "Handmade," "original," "made by this person" are advertising claims subject to FTC substantiation; an unsubstantiated claim is a deceptive practice. The **per-item provenance system (§7) *is* the substantiation mechanism** — self-attested classification, audited by the sampling audit + buyer reporting, shown as a buyer-visible badge, with misclassification a verification-revocation trigger. The trust architecture the moat already requires doubles, exactly, as claim-substantiation infrastructure — a place where the product-defining feature and the compliance obligation are the same build. (OHM *value* / *dignity*: the truthfulness of "handmade" is a value claim about the maker's work, which is why it is load-bearing both legally and morally.)
|
||||
|
||||
### Privacy
|
||||
|
||||
Recap the §7 consent architecture, now read as the privacy-law posture.
|
||||
|
||||
- **Two consent domains, two consent layers, source-of-truth in the maker's ESP.** Maker-originating marketing lives in the maker's ESP (authoritative); network-originating communication consent the network owns; the maker decides store participation and the buyer decides cross-network recognition (§7, "Customer ownership, consent & martech"). This separation isn't only good product design — it is what keeps each personal-data flow attributable to a lawful basis and a controller.
|
||||
- **DPAs.** Where the network processes personal data on a maker's behalf (catalog sync, ESP push, the dashboard cache, the buyer feed) it acts as a **processor** and needs **Data Processing Agreements** with makers, plus back-to-back DPAs with its own sub-processors (ESP, hosting, the Connect/BaaS rail). Stand the DPA chain up at onboarding, not after a buyer asks.
|
||||
- **Build to the strictest state law, not a 50-state matrix.** Rather than tracking divergent regimes feature-by-feature, build to the **strictest common denominator** — CCPA/CPRA plus the newer comprehensive state laws (and GDPR the moment EU buyers appear) — and apply it everywhere: real consent capture, access/deletion rights, opt-out of "sale/sharing." The cross-maker identity graph is the privacy-sensitive surface, and it is **opt-in by design** (§7) — so the architecture is *already* aligned with the strictest "no sharing without affirmative consent" reading, rather than retrofitting opt-outs onto a default-share. The OHM *consent* RFC is canonical for what "consent" must mean across these surfaces — cite it; don't re-derive a local definition.
|
||||
|
||||
### Freedom-to-operate / patent
|
||||
|
||||
- **Low-novelty mechanics → low patent-thicket risk.** The commitment-commerce primitives (scheduled drops, pre-orders/deposits, raffle-as-allocation, clubs/memberships, referral links, cross-merchant attribution) are widely practiced, decades-deep mechanics with abundant prior art. The novelty is in the *combination and positioning*, not in any patentable mechanism — so the risk of a blocking patent thicket over the core build is low.
|
||||
- **Do a basic FTO check anyway.** Before building a headline feature, run a basic freedom-to-operate search — particularly for anything resembling a specifically-patented method (one-click-style checkout flows, specific loyalty/attribution or pledge-management methods). Cheap insurance against the rare narrow patent; not a reason for alarm.
|
||||
- **Transparency doubles as defensive prior art.** The radically-transparent / open-books posture (§7 entity) means the design is *published as it is built* — which is **defensive publication**: dated prior art that makes it harder for anyone (including a well-funded incumbent) to later patent the combination and assert it against you. The transparency that serves the verification moat pays a second dividend in patent defense — one posture, two protections.
|
||||
|
||||
### Novelty / competitive finding
|
||||
|
||||
*(Restored — this finding didn't survive the generalization from the miniatures memo and belongs here.)* The honest competitive read: **the components all already exist** — cross-merchant inclusion (Shopify Collective, Carro), pre-order/drop tooling, affiliate/referral networks, verification badges, non-profit governance — but the **specific combination is novel and unproven**: verified per-item provenance + reputation-staked cross-maker referral + native commitment-commerce + non-profit/transparent governance, scoped to one dense vertical. **The moat is positioning and governance, not patents.** Do not expect IP to defend the position; expect the defense to be community standing, the rooted trust graph (§7 verification), the no-walled-garden value rule (Appendix D), and a 501(c)(3) structure a commission-optimized incumbent structurally cannot copy without betraying its own customers (§7, "Why this is the defensible core"). This is the same truth the spine states — *the components exist; the combination is novel but unproven* — carried into the legal lens: novelty here buys no monopoly, so the work is to make the *combination* hard to replicate by other means.
|
||||
|
||||
### Entity structure / UBIT
|
||||
|
||||
- **The 501(c)(3) path** (§7, "Entity structure & trust positioning"): a true non-profit that legally cannot be sold or distribute profits — which is what converts the anti-Etsy promise from a pledge into a structural guarantee. The legal work here is *formation*: exempt-purpose drafting, governance documents, state charitable registration.
|
||||
- **The UBIT tension — flag for nonprofit counsel.** A 501(c)(3) that earns **platform fees and referral fees** is earning income from a trade or business regularly carried on — which raises **Unrelated Business Income Tax (UBIT)**. The crux is whether that fee income is *substantially related* to the exempt purpose (e.g. sustaining independent makers / a charitable-educational mission) or is merely a commercial activity that happens to fund it. Get it wrong and the exposure ranges from UBIT liability on the fee revenue to — if commercial activity comes to *dominate* — jeopardy to the exemption itself. This is the **one place the non-profit choice creates legal complexity rather than dissolving it** (the mirror image of the money-flow stance, which dissolves complexity), and it is genuinely unsettled at this memo's altitude — so it is an early, explicit **flag for specialist nonprofit/tax counsel**, who may reshape the exempt-purpose framing, structure the fee-earning activity to stay "related," or recommend a taxable subsidiary for the commercial layer. Surface it in §8/§9 diligence rather than discovering it post-formation.
|
||||
|
||||
**The through-line, restated.** The pattern of this whole section is one shape: **where the architecture stays out of the money flow, the legal burden largely dissolves** — MTL, marketplace-facilitator tax, and MoR liability are all discharged by the single choice that the maker, on the maker's own processor, is merchant of record. What remains are the exposures that *aren't* a money-flow question — and each gets handled in kind: **engineer it down** (consideration out of the raffle; compliance-by-design pre-order UX; provenance-as-substantiation; opt-in privacy aligned to the strictest law) or **flag it for counsel** (UBIT, paid-entry raffles, per-state facilitator confirmation before Phase 3). The legal strategy is not separate from the architecture — **it is the architecture, audited.** *Flag and verify — not legal advice.*
|
||||
|
||||
---
|
||||
|
||||
## 12. Sustainability economics & health metrics
|
||||
|
||||
The memo is deep on architecture and silent on whether the architecture pays for itself. That silence is the dangerous kind: ventures rarely die of a bad money-flow diagram, they die of a cost base no one modeled. Two questions sit under "is this sustainable," and they are different questions. **Does the fee model cover the cost base, and at what scale?** — the unit-economics question. And **is the network actually working?** — the health/liquidity question that turns the §9 gates from enthusiasm-readings into instruments. Both rest on the same asset: the cross-merchant order history the network already holds to compute referrals (§7, "Storefront architecture & Shopify coexistence"), which is *uniquely* able to see across stores. The *unit-economics* numbers here are deliberately left as variables — n = 2 can't calibrate them (§4) — because the contribution this section makes is the *model and the instruments*, not invented values; the **market-sizing anchors** added below are the one deliberate exception, since external market structure is publicly knowable rather than an uncalibrated internal variable. This whole section turns on OHM **value** (the metric must track *earned* value, not gamed volume) and **trust/recourse** (the trust guarantee is only as good as the funded reliability behind it); where it names one, the canonical RFC governs (top-of-file note).
|
||||
|
||||
### "Non-profit" is not "needn't cover costs"
|
||||
|
||||
The 501(c)(3)/volunteer/transparent structure (§7, "Entity structure & trust positioning") changes *who keeps any surplus* — no one; it cannot be distributed — but it does **not** change the arithmetic that revenue must meet cost or the org folds. Exemption is not exemption from a budget. The structure's funding-viability rested on one specific claim: the dominant historical cost center, engineering, has been deflated by LLMs (§7) — the entity and the opportunity are the same bet. But "deflated" is not "zero." The reliability core (network service, ledger, verification) needs *sustained* funding, not best-effort volunteer cadence (§4), and that floor is a real recurring cost the fee model must carry.
|
||||
|
||||
- **There is a ceiling as well as a floor — the §11 UBIT band.** The mirror of "must cover costs" is "must not look like a commercial marketplace wearing a non-profit badge." A 501(c)(3) earning platform and referral fees raises Unrelated Business Income Tax exposure unless that income is *substantially related* to the exempt purpose, and if commercial activity comes to *dominate*, the exemption itself is in jeopardy (§11, "Entity structure / UBIT"). So the sustainable target is a **narrow band**: enough fee revenue to fund the reliability core off bus-factor-one, framed as sustaining the exempt mission — not so much, or so commercially-shaped, that the org reads as a marketplace that incorporated as a charity. Cost-coverage and UBIT pull from opposite sides onto the same number.
|
||||
- **The discipline §7 names, applied to the numbers.** "Transfer the rigor you'd spend on fundraising onto volunteer sustainability" (§7) has a quantitative edge: a non-profit that cannot model its own break-even is already exhibiting the volunteer-org failure mode — dying of *sustaining*, not *building* (§4). Modeling it is the rigor; the model below is the minimum form of it.
|
||||
|
||||
### Unit economics — does the fee model cover the cost base?
|
||||
|
||||
Per the decision above, this is a **parametric** model: the fee *rates* are design parameters fixed in §7 ("How the platform gets paid"); the volumes and costs are named variables, because the honest values don't exist yet (§8 discovery and the first dozen makers are what populate them).
|
||||
|
||||
**Two revenue lines, both from §7.**
|
||||
|
||||
- **Subscription.** Cold-start makers pay Starter (≈2–4% of captured GMV, no/low monthly); at scale they auto-graduate to Pro (flat **$29–49/mo**, 0%). The flat Pro fee is the *predictable* margin; the Starter percentage is cold-start-friendly but thin on low-GMV makers.
|
||||
- **Referral spread.** On a referred order the platform takes a **fixed ≈3–5% spread** (the network's referral revenue line), and Maker A earns a **negotiated reward above it** (§7, "Referral economics"). The spread is the platform margin modeled here; A's reward is a *non-cashable draw against A's own future platform fees* — so it is **foregone future fee revenue**, not free money. The model must net it: referral activity generates spread *and* erodes subscription/fee revenue as credits are drawn. Treat the credit as a cost line, not a wash.
|
||||
|
||||
**A note on partner-network splits.** Where a maker pays a partner out of its earned referral income (§7, "Partner / consultant network"), the split **redistributes the maker's curator income, not the platform's spread** — the platform stays principal on both legs and its 3–5% margin is unchanged. So partner comp does not move the platform's break-even directly; it is **maker-borne activation cost, paid from the upside**, that lowers the friction of turning a maker into an *active referrer*. Model it as a driver of the referral-activation rate (and thus of the North Star, below), not as a platform cost line.
|
||||
|
||||
**The cost base — three buckets** (the §4 / launch-prompt decomposition).
|
||||
|
||||
1. **Network-service hosting.** The cross-tenant moat service is hosted *separately, always* (§7, "Hosting"); the per-maker storefront cost is largely the maker's own (their processor, their Medusa/Shopify). Mostly a fixed base with mild per-maker/per-order scaling. The §7 pilot (GCP Cloud Run + Cloud SQL, low tens of dollars/month) is the *floor* of this term — and the trap is mistaking that floor for the whole of it (below).
|
||||
2. **Verification labor.** The human cost of the trust gate — staff-verifying the seed/root set early, then the sampling audit, ring-detection, and revocation handling as peer verification scales (§7, "Verification"; §10). Scales with maker inflow and audit volume; the part that needs reliability cannot be best-effort.
|
||||
3. **Reliability core.** The funded floor that keeps buckets 1 and 2 *plus the ledger* off bus-factor-one (its own subsection below). This is the line item the cloud invoice does not show, and the one most likely to be under-modeled.
|
||||
|
||||
**The break-even shape.** Let N be active makers, with per-maker net contribution m (subscription, plus referral spread, *minus* drawn credits, netted across the Starter/Pro mix), and a cost base C = F + v·N where F is the fixed reliability-and-base term and v the marginal per-maker cost (incremental verification + hosting). Break-even is the familiar fixed-cost-coverage form:
|
||||
|
||||
```
|
||||
N* ≈ F / (m − v)
|
||||
```
|
||||
|
||||
Two things fall out of the *shape*, no values required:
|
||||
|
||||
- **This is a fixed-cost-coverage problem, not a margin problem.** Each maker contributes a small but positive m − v (mostly the subscription; the referral spread is thin and partly self-cancelling via credits). The question is therefore not "is a maker profitable" (yes, modestly) but "**how many modest contributions fund the reliability floor F**." That directly *reframes the §9 dozen-maker gate*: a dozen makers validates **demand**; break-even is a larger N governed by how lean F is kept (the §7 LLM-deflated-cost bet is precisely the bet that F is small) and how much per-maker margin the tier mix yields.
|
||||
- **N\* falls as makers *grow* and *refer*, not merely as they're *added*.** A network stuck on cold-start Starter percentages at low GMV barely moves m; break-even improves as makers graduate to flat Pro (margin firms up) and as referral activity lights (spread revenue). So the two levers that move N\* most are **F** (keep the reliability core lean — the §7 bet) and the **Starter→Pro graduation + referral-activation mix** (raise m). Adding low-GMV, non-referring makers moves break-even the least.
|
||||
|
||||
**An illustrative pass — the *shape*, not validated numbers.** To see what the formula implies, fix the variables at plausible midpoints — average active-maker GMV `G` = $30k/yr, Starter take 3%, Pro $39/mo, referral spread 4% — and let the Pro-tier mix and referred share `ρ` mature as the network lights:
|
||||
|
||||
| Makers `N` | on Pro | referred `ρ` | Pro fees | Starter % | Referral spread | **≈ revenue/yr** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2 (pilot) | 0% | 0% | — | $1.8k | — | **$1.8k** |
|
||||
| 12 (§9 gate) | 10% | 5% | $0.6k | $9.2k | $0.7k | **$10.5k** |
|
||||
| 50 | 25% | 10% | $5.9k | $30k | $6.0k | **$42k** |
|
||||
| 200 (density) | 40% | 20% | $37k | $86k | $48k | **$172k** |
|
||||
| 1,000 | 50% | 25% | $234k | $337k | $300k | **$872k** |
|
||||
|
||||
Two readings fall out, both reinforcing the parametric conclusions above. The revenue **mix flips with maturity** — ~90% thin Starter percentage at the §9 gate, but the Pro flat fee and the referral spread carry it by density and beyond (the *graduate-and-refer*, not merely *add*, point). And against a lean reliability floor `F` ≈ $75–150k/yr (the funded core ops + ledger + verification audit + hosting), break-even lands somewhere around **~150–300 makers** under these midpoints — **well past the dozen-maker §9 *demand* gate.** The dozen validates demand; sustainability is a later, larger N, and that gap is exactly the thing this section exists to name.
|
||||
|
||||
**The honest caveat (memo voice).** These are the variables, not values: n = 2 cannot calibrate per-maker GMV, the referred-GMV share, churn, or graduation rate. Naming the model is the point — and the §9 gate should start **instrumenting** the inputs (per-maker GMV, referred share) so that break-even stops being unknown by the time the dozen-maker gate is cleared. The same order-history asset that powers the metrics below makes every one of these variables measurable per maker (§7) — the model and the instruments are the same build.
|
||||
|
||||
### Market size — count makers, not craft-market GMV
|
||||
|
||||
The memo asserts *TAM = every maker* (§7, "Business model") but never sizes it. The sizing discipline that matters: **the unit of TAM is makers, not the dollar size of the craft market** — because revenue is per-maker subscription + referral spread, never a GMV take (§7). A roughly **$0.8–1.2 trillion** global handicrafts market ([Fortune Business Insights](https://www.fortunebusinessinsights.com/handicraft-market-108435)) is a category-scale backdrop, *not* our revenue base; counting *makers who run commit-then-make* is the honest denominator. And note the category distinction from the parametric caveat above: external market structure is publicly knowable, so unlike the *unit-economics* variables (per-maker GMV, churn — calibrated only by discovery), these are **researched anchors** — but anchors are proxies and ranges, not point truths.
|
||||
|
||||
- **TAM — every independent maker who runs commit-then-make.** Federation makes the addressable supply *the entire controllable-storefront + marketplace install base* (§7, "Strategic payoff"), not just makers willing to switch: Shopify reports ~4.8M active merchants (~2.5M live storefronts — [cropink](https://cropink.com/how-many-shopify-stores-are-there)), and Etsy ~5.6M active sellers — *down from ~9M in 2023* as it purged for quality ([Marketplace Pulse](https://www.marketplacepulse.com/stats/etsy-number-of-active-sellers)), which is the authenticity reckoning this thesis rides on, expressed as a number. Not all of these run commit-then-make; the true TAM is that subset — unknowable precisely, but **millions of makers, not thousands**.
|
||||
- **SAM — commit-then-make-native makers, reachable community-by-community.** The model only works where you show up as a member (§5), so the serviceable market is *summed over verticals*, not addressed at once. The beachhead (tabletop minis/dice, Appendix A) sits in a **~$3.8–4.2B/yr tabletop-miniatures market growing ~7–10%/yr** ([DataIntelo](https://dataintelo.com/report/tabletop-miniatures-game-market)); the relevant maker population is the independent casters/sculptors/dice-makers running drops and clubs. Patreon's **~286k paying creators** ([Backlinko](https://backlinko.com/patreon-users)) is a usable proxy for the *commit-then-make creator* population across all verticals — tabletop is one slice, and SAM grows dice → broad tabletop → adjacent craft scenes.
|
||||
- **SOM — left parametric, by design.** This is deliberately *not* a top-down "capture X% of a $Y market" number — that is exactly the fiction the "variables not values" discipline refuses. The obtainable near-term market is governed by the break-even `N* ≈ F/(m−v)` above: clear the **dozen-maker §9 demand gate** in one community, reach **break-even density (~150–300 makers under the illustrative midpoints)**, then compound vertical by vertical. The honest SOM story is *reach self-sustaining density in one scene, then repeat* — not a share of a giant pie.
|
||||
|
||||
### Network-health & liquidity metrics — instrumenting the §9 gates
|
||||
|
||||
The §9 gates are all qualitative — *do makers refer you? is the pain consistent? do they have audiences? can you reach a dozen?* Those are the right questions, but the network's success is fundamentally a **liquidity** outcome (§4: curation fights liquidity, and the graveyard is full of platforms that had supply and no demand). Liquidity needs liquidity instruments.
|
||||
|
||||
**North Star: the share of GMV that is cross-maker-referred.** This is the one number that is near-zero for a pile of disconnected storefronts and rises *only* as the referral network actually does work. A storefront-only success — a genuinely good commitment-commerce tool that never becomes a network — shows a **low North Star**, which is exactly the most-feared failure mode (a good tool that never lights the moat, §4/§5). It measures "the network is the product" (§7, "Business model") directly, in a single figure, in a way no vanity supply count can fake.
|
||||
|
||||
**The leading indicators beneath it** — the funnel that *predicts* the North Star, earliest-first:
|
||||
|
||||
- **Follower growth** — the §2 flow asset (drop-followers accumulating across makers, the embryonic cross-merchant identity graph). The leading-most signal: follows precede referred GMV by definition.
|
||||
- **Curated-By activation** — the share of verified makers who actually *vouch*, and the breadth of their curation. Referred GMV cannot exist without curators curating; a network rich in follows but where makers don't vouch is still dead.
|
||||
- **Drop sell-through** — the commitment-commerce engine's own vitality (do drops clear?). The tool-layer health the network rides on; a stalling engine starves the network upstream.
|
||||
- **Repeat-buyer rate, especially cross-maker repeat** — demand durability, and whether the buyer-side keystone (§13; §8) is real. A buyer who returns *across* makers is the network effect made visible at the buyer level.
|
||||
|
||||
**Mapped onto the §9 gates, making each quantitative:**
|
||||
|
||||
- *"Do makers refer you?"* → Curated-By activation + referred-GMV share (the North Star itself).
|
||||
- *"Do target makers have audiences?"* → follower growth (and the §8 "an audience they'd bring" question, now a number).
|
||||
- *"Consistent pain / a dozen who want the same thing?"* → drop sell-through + Starter→Pro graduation rate.
|
||||
|
||||
**The order-history asset computes all of this for free.** §7 already commits it: *"One asset, three uses — the same order history powers the referral ledger, the network-health metrics, and this reporting."* The cross-tenant vantage is the only place cross-maker-referred GMV and cross-maker repeat *can* be computed — no single-store tool sees across stores (which is itself moat-deepening, §7). So these metrics are not new instrumentation to fund; they fall out of the ledger the referral system already requires.
|
||||
|
||||
**Goodhart caution — the metric must measure *earned* referral.** A North Star is a target, and a target invites gaming. The corrupt way to lift "cross-maker-referred GMV" is to *manufacture* referrals — pay for placement, juice the slots — which is precisely the retail-media drift §3 and §7 ("Curated By This Maker") exist to forbid. The North Star is only valid as a measure of **reputation-staked, earned** cross-referral; optimized the wrong way it rebuilds Etsy's pollution from the inside, through the dashboard. This is the OHM **value** point in metric form: the number must track real value to buyers and makers, not gamed volume — so pair the North Star with the §7 anti-corruption guardrails (structural ranking-neutrality — the platform's spread is constant, so its algorithms ignore referral economics; capped per-maker featuring; per-relationship approval; reputation on the line; uniform rates for non-maker taste-makers) rather than reading it naked.
|
||||
|
||||
### Volunteer-sustainability economics — funding the critical core off bus-factor-one
|
||||
|
||||
§4 names volunteer-core sustainability as **the single point of failure**, and §7 sharpens it: "the risk moved, it didn't vanish" — LLMs make a smaller core go further but do nothing for attrition or bus-factor. The economic question this section must answer is therefore not "what does it cost to *build*" but "**what must the fee model *fund* to keep the critical core reliable when any one volunteer leaves.**"
|
||||
|
||||
Start from §7's own partition of what needs reliability versus what tolerates volunteer cadence:
|
||||
|
||||
- **Must be funded (the reliability core).** *Network-service uptime* — drops are spiky and a storefront falling over *during a drop* is the worst possible moment for maker trust (§7, "Hosting"). The *fee/wallet ledger* — money-adjacent correctness: pending→cleared settlement, clawback, negative-balance handling (§7, "Referral economics"). And *verification* — the root set, sampling audit, ring-detection, and revocation that hold the trust guarantee, where a single polluted "verified" item breaks the guarantee for everyone downstream (§7; §10). None of these can be best-effort.
|
||||
- **Tolerates volunteer cadence.** Themes, storefront nice-to-haves, non-critical features (§7). These can wait on a volunteer's Saturday; the core above cannot.
|
||||
|
||||
So the model's job is to fund *enough* of that reliability core that it **survives any single departure** — concretely, that the fixed term **F is not modeled as pure cloud cost.** The seductive error is to set F to the §7 pilot's tens-of-dollars-a-month GCP bill; the honest F also includes **compensated, documented, more-than-one-deep ownership** of network-service ops, the ledger, and the verification audit. Under-modeling F is exactly how an org clears break-even *on paper* and still dies of bus-factor — the volunteer-org killer §4 warns about, expressed as an accounting omission. "Bind with structure, not promises" (the spine) applied here means the reliability core is **funded, documented, and redundant by design** — not hoped for.
|
||||
|
||||
- **The UBIT mirror is favorable here (§11).** Paying core maintainers from fee revenue is ordinary non-profit operation (reasonable compensation for exempt-purpose work), and funding the *mission's reliability* is far more defensibly "substantially related" to the exempt purpose than accumulating surplus would be. So the sustainability framing actually *helps* the §11 UBIT posture rather than straining it — fees fund the exempt-purpose reliability core, which is the cleanest story to tell counsel. (Still a flag for specialist nonprofit/tax counsel, per §11 — but a flag that points toward "related," not away.)
|
||||
- **This is the trust moat's operating budget — OHM *trust/recourse* made economic.** The buyer's trust guarantee (verification and the §10 accountability machinery) is only ever as strong as the funded reliability behind it; an under-funded verification audit is a trust promise the org *cannot keep*, and a ledger run on best-effort is recourse the org cannot honor. Sustainability economics is therefore not a separate concern bolted onto the moat — it *is* the moat's budget line. Defer to the relevant OHM RFCs for *trust*, *value*, and *recourse*; where a load-bearing one is undefined, defining it is itself OHM work (top-of-file note).
|
||||
|
||||
### The through-line
|
||||
|
||||
"Non-profit" raises the bar on cost discipline rather than lowering it: the org must cover a cost base whose **dominant term is the reliability floor under the trust moat**, fund it off bus-factor-one, and do so inside the §11 UBIT band (related, not dominant-commercial). The unit economics is a **fixed-cost-coverage** problem — `N* ≈ F / (m − v)` — governed by how lean the LLM-deflated core stays and by makers *graduating and referring*, not by per-maker margin or by adding low-GMV makers. The health metrics turn §9's qualitative gates into instruments around a single North Star — **the share of GMV that is cross-maker-referred** — fed by follower growth, Curated-By activation, drop sell-through, and cross-maker repeat, and they fall out of the §7 order-history asset for free. The numbers are still variables (n = 2 cannot calibrate them); the deliverable is the **model and the instruments**, so the §9 gate can start populating them. And the line a sustainable non-profit under-funds at its peril is the one the trust moat rests on — so bind it with structure: **the reliability core is funded, documented, and more than one person deep.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Demand strategy & buyer-side go-to-market
|
||||
|
||||
The memo names demand as the unvalidated keystone everywhere — *the binding constraint* (§4), *earn into the marketplace* (§5), *the unvalidated keystone is demand, not technology* (the spine) — but never attempts a plan; everything concrete is supply-side. This section is that plan, carried to the depth the memo's own logic supports: a buyer-side value proposition stated as its own thing, where buyers actually come from, the content/community posture, and the §8 extension that tests *demand* rather than supply. It is the most consequential section because it is the keystone — and it remains, by construction, the **least validated**: the §8 interviews tested the supply/storefront-convenience layer, not this. The deliverable is the strategy and its test, not a claim that demand is proven. The section turns on OHM **trust** and **value** (the buyer's reason to return must track *earned* value, not manufactured engagement); where it names one, the canonical RFC governs (top-of-file note).
|
||||
|
||||
### The buyer value proposition — a three-pillar stack, not three claims
|
||||
|
||||
The buyer-side "why" is not one reason but three, each doing a different job in the funnel; the discipline is to say what each is *for*, never to lead with all three flatly (a value prop that leads with everything leads with nothing):
|
||||
|
||||
- **Authenticity / provenance — the precondition, not the magnet.** The floor that makes everything else safe. The per-item provenance badge and verification (§7) are the thing a buyer *cannot* get on a polluted Etsy, and the signal **appreciates** as AI-generated and recast fakes proliferate (§3). It is the advertised trust guarantee and the grievance-content angle — but trust is a tiebreaker/enabler, not a thing buyers wake up wanting. In the dice/miniatures beachhead (Appendix A) it runs *hottest*, because recasting is literal piracy there; the weighting across the three pillars is therefore community-dependent.
|
||||
- **The fan relationship — the acquisition-and-retention engine.** Commitment commerce, from the buyer's side, is *being a fan*: following a maker, waiting for the drop, joining the club, holding insider status. This is why the buyer is present at all (they follow a maker) and why they return (the cadence) — the §2 asset of "people who *wait for* makers and commit ahead," seen from the buyer. It is the pillar word-of-mouth carries and the one the beachhead's density compounds.
|
||||
- **Trusted discovery — the compounding mechanism.** "Makers I trust point me to makers I'll love" — Curated-By (§7) as the buyer experiences it. This is the §12 North Star (cross-maker-referred GMV) made human, and it is the *growth lever, not the entry hook*. Hard rule inherited from §7: it is framed as *emerging from makers the buyer chose to follow*, **never** as the platform performing discovery. Leading with discovery-as-destination is the curation-vs-liquidity graveyard (§4/§5) and violates *maker-as-discovery-engine, platform-as-pipe* (the spine).
|
||||
|
||||
### Buyer surface & the "come back" mechanic — phased like the rest
|
||||
|
||||
Mirror the §7 unified phasing — the buyer surface appears late and stays deliberately thin:
|
||||
|
||||
- **Phase 1 — invisible / maker-fronted.** Buyers transact as guests on the maker's own storefront; attribution is stateless (§7), so no buyer identity exists yet. "Come back" runs entirely through the *maker's own* channels — the maker's ESP / IG / Discord "next drop" notice (§7, martech). The platform's only retention role is powering follow/notify; it has no buyer-facing surface and needs none.
|
||||
- **Phase 2+ — the soft destination.** The buyer-facing feed (§7) becomes an *opt-in* surface — "your makers, in one place" — with a light identity buyers return to, every item still traced to a follow. This is a retention **upgrade** layered on the maker's own channels, not a replacement, and pointedly **not** a buyer-facing *brand* a buyer evangelizes ("I shop on X") — that is the marketplace-destination posture §5/§7 reject. The platform earns a thin buyer-facing presence; it never becomes the buyer's primary relationship.
|
||||
|
||||
### Where buyers actually come from
|
||||
|
||||
The conclusion forced by *platform-as-pipe* (§7) and *demand must be earned, not bought* (§4): **buyers do not arrive at the platform; they arrive at makers, and the network compounds them.**
|
||||
|
||||
- **The first ~100 are activation, not acquisition.** They are the founding (invited — §7) makers' *existing* audiences, transacting on the new rails. The platform acquires no one; the test is whether a maker's existing fans will follow them into a drop here. This is precisely why an audience-having maker is an asset and an audienceless one is a cost (§8) — restated as a buyer-acquisition fact.
|
||||
- **The first ~1,000 come from compounding plus supply.** Two engines: cross-maker propagation (a buyer who follows A discovers and follows A's Curated-By makers — the §12 cross-maker-repeat indicator), and more makers onboarding, each bringing an audience. Word-of-mouth inside the one tight beachhead (§5) is the multiplier — the reason density, not breadth, is the correct cold-start move.
|
||||
- **Net-new demand is deliberately deferred — and named, not hidden.** Everything above is *reshuffle and deepening* of audiences that already exist; the buyer feed is **by design** weak at net-new reach (§7). The two net-new hedges are scheduled, not early: **verified taste-makers (Phase 2)** are the genuine net-new-demand engine — community voices who bring *their* audiences (§7) — and **AI shopping agents (Phase 2+)** are net-new reach (§3, §7). Early demand is therefore honestly a reshuffle; pretending otherwise is the graveyard's mistake (§4). *Concede the timeline, not the moat* (§8).
|
||||
|
||||
### Content, community & SEO posture
|
||||
|
||||
The §5 *member, not vendor* principle applied to demand — and an explicit rejection of the marketplace-SEO play:
|
||||
|
||||
- **Embed in the beachhead's existing hubs; don't broadcast.** Show up inside the tabletop Discords, subreddits, painting forums, and conventions as a member of the scene (§5) — the same standing that recruits makers recruits buyers.
|
||||
- **Maker-amplified, not platform-voiced.** The platform's owned content surface is the network digest (§7, network marketing channel): followed makers' drops plus their Curated-By picks, aggregated and personalized but never platform-injected. Buyer-side SEO accrues to *makers' own* verified-provenance product pages, not a marketplace landing page — competing with Etsy/Amazon on generic "shop handmade" search is unwinnable and off-strategy (the discovery war §7 declines to fight).
|
||||
- **One native editorial voice: the authenticity grievance.** "How to spot a real cast," provenance explainers, the anti-recast / anti-AI-slop story — community-native in the beachhead, doubling as SEO and values signaling. Plus the **verified badge as a portable trust mark** makers display wherever they already are (their IG, their leaving-Etsy posts), pulling buyer awareness back to the verified graph.
|
||||
|
||||
### The §8 extension — test demand, not supply
|
||||
|
||||
§8 discovery talked only to makers, and the two interviewed were *greenfield, with no audience* (§8) — so it tested the supply/storefront-convenience layer and **could not test the demand moat at all** (you cannot measure "do a maker's fans follow them here" with makers who have no fans). The buyer-side extension must therefore:
|
||||
|
||||
- **Recruit audience-having makers specifically** — a different discovery target from the greenfield on-ramp. The demand moat can only be probed where an audience exists to move.
|
||||
- **Test behaviorally, not by survey** (§8's *weight what makers do over what they say*, carried to buyers): run a real instrumented drop; run a Curated-By referral between two makers and measure click → follow → buy **propagation** (the keystone network assumption, made measurable); measure provenance's effect on willingness-to-pay / switch; track repeat and cross-maker-repeat (§12).
|
||||
- **Avoid the Shop Pay measurement trap (§8).** Don't ask buyers what they'd value — measure the driver: the repeat-fan vs. first-time-stranger revenue mix, and whether buyers already carry a recognized cross-merchant identity.
|
||||
- **Feed the §9 gates and §12 instruments.** These tests populate the very inputs §12 said the dozen-maker gate should start instrumenting (per-maker GMV, referred share, repeat rate) — the demand strategy and the health metrics are the same build, validated together.
|
||||
|
||||
### The through-line
|
||||
|
||||
The keystone, finally grappled with rather than admitted: **buyers arrive at makers, and the network compounds them** — so the value prop is a three-pillar stack (authenticity the precondition, the fan relationship the engine, trusted discovery the compounding), the surface stays maker-fronted and only *softly* a destination, net-new demand is honestly deferred to taste-makers and agents, and the §8 extension tests it **behaviorally, with audience-having makers, before the platform is built.** It is the most consequential section and remains the least validated — by design, that is the next thing to *earn*, not assume.
|
||||
|
||||
---
|
||||
|
||||
## 14. Open sections to develop (backlog)
|
||||
|
||||
This memo is deep on the architectural/strategic axes (money flow, network mechanics, moat theory, consent) and thin on several operational ones that matter as much or more. The gaps cluster on the un-fun, operational side — which is usually where ventures actually die. Each below is a separate future session. (Trust & safety, legal & compliance, sustainability economics & health metrics, and demand strategy & buyer-side go-to-market were written up in prior sessions — now §10, §11, §12, and §13 — and have left the backlog. Concrete MVP scope, the build roadmap, and the data-model sketch are intentionally *not* tracked here: this is a **strategic memo, not a roadmap/rollout doc** — those belong in the implementation plan the strategy feeds, not in the strategy itself.) Rough priority order; with the demand keystone now drafted (§13, still to be *validated* via its §8 extension), the **live threads** are §10's open reputation-engine work (gathered in that section's last subsection) and the governance mechanics below (#1) — whose *direction* (progressive delegation to network representatives; §7/§10) is now set, but whose machinery is deliberately deferred.
|
||||
|
||||
1. **Governance — progressive delegation (direction set; mechanics deferred).** *Direction set:* a **layered hybrid**. The non-profit (its board, with fiduciary duty) retains the **legal floor and the entrenched core** — the trust guarantee, non-extraction, the no-walled-garden value rule, the out-of-flow stance — which *bind with structure*, not a changeable majority (a 501(c)(3) cannot sell or distribute them — §7). Authority over **maker issues** (verification standards, the provenance line, the §10 standing thresholds — §7, §10, §14 #2) is **progressively delegated to representatives of the network as it scales beyond what the non-profit can manage**, on the same *start-closed, open-as-the-trust-web-earns-it* phasing as the membership gate and peer verification (§7): founder/staff-led at launch, delegated as density demands. The vision is **network self-governance for maker issues, modeled on a functioning democracy**: members *elected* to network roles — dispute-resolution among them — where **holding and discharging a role well is itself a way to earn standing**, the same reputation currency as making and vouching well (§10's positive-reinforcement model). It is also how the org *scales* without the volunteer core adjudicating every dispute (§12). *Still open — and deliberately not fleshed out now:* what those representative bodies are and how they're constituted, how delegation resists capture (the §7 verification concern applied to governance itself), and the concrete appeal/dispute machinery (the §10 governance-appeal-path home). Over-specifying a governance apparatus before the community exists would be premature; the direction is set, the machinery is later work. (Also the body that stewards the §12 UBIT band.)
|
||||
|
||||
2. **Hybrid makers — the making-vs-reselling line (standard).** *Direction set:* provenance attaches **per-item, not per-maker**, via a self-attested, buyer-facing catalog classification (Original / Original + components / Resale – fellow Maker / Resale – third-party), with trust-surface eligibility keyed to it — see §7 "Per-item provenance: the catalog's originality layer." *Still open:* the precise, **auditable line between making and reselling** — purchased supplies don't taint "original," but where exactly do finishing, assembling, and kitting fall? — plus the enforcement/audit hook (ties to §10 accountability) and the exact buyer-facing label wording. Interacts with the consignment/resale "avoid" fork (§7) and the no-walled-garden value rule (Appendix D).
|
||||
|
||||
3. **International tax & cross-border operation (US-only today — flag for specialist counsel).** §11's analysis is entirely US (the *Wayfair* facilitator test, MTL, FTC). But the beachhead is **STL/digital-file-heavy and globally distributed** (UK/EU/AUS casters), so this is a *live* hole, not a someday: **EU/UK VAT on digital goods** (OSS/IOSS — VAT owed in the buyer's country from the first unit) and the EU **"deemed-supplier"** marketplace rule, which can pull a *facilitating* platform into VAT collection on logic that **does not mirror** the US "we fail prong 2" defense — so the out-of-flow stance does not automatically transfer abroad. Adjacent: **PSD2/SCA** on EU recurring club billing (the maker's processor must handle it; a "Standard account" doesn't discharge it), multi-currency display/settlement, and **KYC/AML/OFAC** onboarding for non-US makers/taste-makers on the Phase-2 cashable rail. *Direction: scope which jurisdictions Phase 1 actually serves, and put the VAT/deemed-supplier question to specialist cross-border counsel before international participants are first-class. Flag and verify — not legal advice.*
|
||||
|
||||
4. **Information security & breach posture for the cross-tenant network service.** §7/§11 cover privacy *consent* thoroughly; neither covers *security*. The shared network service holds the single most attractive breach target in the design — **every maker's full order history + buyer PII + the cross-maker identity graph** — and for a *trust* brand a breach is existential, not merely costly. Still open: encryption at rest/in transit + key management, tenant-isolation and least-privilege access to the cross-tenant store, secrets handling, and an incident-response / breach-notification plan (state laws + the GDPR 72-hour clock). *Direction set here: infosec of the network service belongs in the §12 **funded reliability core** alongside the ledger and the verification audit — a trust-moat budget line, not a volunteer-cadence nice-to-have; the concrete controls are later work.*
|
||||
|
||||
5. **Content moderation beyond authenticity (third-party IP / DMCA / prohibited goods).** Provenance verifies *handmade*, not *lawful to sell*. The minis/tabletop beachhead carries a heavy **third-party-IP/DMCA** load (fan-sculpts of others' IP; the Games Workshop takedown culture), plus counterfeit, regulated, and offensive-content surfaces — and the network *amplifies* whatever it surfaces (Curated-By, the buyer feed, the agent feed), so it inherits **amplification / contributory liability** distinct from "is it handmade." Still open: a DMCA §512 notice-and-takedown posture + designated agent, an IP-complaint / repeat-infringer policy, and the line between *verified original craft* and *originality of the depicted IP* (a verified maker can still infringe). Interacts with verification (§7) and accountability (§10). *Flag for counsel; design the takedown path before the agent feed amplifies at scale.*
|
||||
|
||||
6. **Verification methodology — the evidentiary act (currently treated as a primitive).** §7 specifies the verification *graph* (rooted, staked, multi-vouch, sampling audit) and §10/#1 above defer the reputation *engine* — but **how a verifier actually establishes that a human makes original work** (what proof, what process, what staff and peers inspect) is neither specified nor flagged, and it is the literal foundation of the trust moat and the §11 FTC-substantiation claim — the hard adversarial core in an AI-fake world. Still open: the **provenance-documentation standard** (studio evidence, work-in-progress, live demo?), the staff seed-set method, and what a peer verifier must attest. *Name it as load-bearing open work, not a solved primitive.*
|
||||
|
||||
7. **Support & dispute operations — the function and its cost.** §12 funds the reliability core (uptime, ledger, verification audit) but never a **support/ops function**: maker tickets (a broken sync at drop time), buyer-harm report intake, and the verification-revocation / appeal queues (§10). For a volunteer-built, money-adjacent, trust-critical platform this is a real recurring **F-term omission** and an operational-credibility question. *Still open: the support model, triage/SLAs for money-adjacent vs cosmetic issues, and its line in the §12 cost base.*
|
||||
|
||||
8. **Competitive engagement: creator-commerce tools.** The "no continuous (non-campaign) platform treats commit-then-make as first-class" claim (§2) is load-bearing and currently engages only Etsy/Shopify/Patreon/Kickstarter/Gamefound. The sharper counter-examples a skeptic raises are the **creator-commerce tools** — Gumroad, Payhip, Ko-fi Shop, Fourthwall, Lemon Squeezy — several of which already do drops + memberships + digital delivery at low fees. *Still open: a head-to-head that substantiates "first-class, not covered" against these specifically — the likely cut being that each does the storefront/transaction but none does the cross-maker reputation-staked referral network (the moat), and most treat the cadence as features rather than the spine; verify the claim rather than assert it.*
|
||||
|
||||
9. **Lower-priority flagged items (named, not yet developed).** (a) **cuttle.xyz / generative-tool integration** is cited as a differentiator (§2) without substance — develop what the integration is and why it's defensible, or demote it to a mere example. (b) **Catalog-sync reliability at scale** — N adapters against rented APIs (Shopify rate limits, webhook-delivery failure, deprecation cycles, reconciliation) is a chronic ops burden the moat surfaces depend on. (c) **Accessibility** (WCAG/ADA) for generated storefronts and the buyer feed — a compliance surface and an OHM-*dignity*-aligned one. (d) **Trademark / certification mark for "verified"** — a certification mark is the natural instrument to protect the badge from imitation (FTO/patent is covered in §11; this isn't). (e) **Platform wind-down plan** for the network asset — given volunteer-sustainability is the named #1 risk (§4), what becomes of the cross-maker graph, follows, buyer accounts, and outstanding wallet credits/payables if the org folds. (f) **Team / execution capacity** — the docs argue the model is *affordable*; named team/board/recruiting capacity is a separate, unaddressed question (arguably a roadmap/ops-doc concern more than a strategy one).
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Choosing a beachhead vertical (and sequencing expansion)
|
||||
|
||||
The platform serves makers in general, but it must *launch* into one dense community. This is the selection method, with miniatures as the worked candidate.
|
||||
|
||||
**The filter.** Score candidate communities on: made-to-order/drop motion; non-fungible inventory; hybrid digital + physical; recurring drops; variant explosion; audience-having makers; authenticity grievance; scalper/counterfeit problem; a reachable community hub. The recurring finding across verticals: the storefront/build layer is already commoditized (Shopify, Fourthwall), so the prize is the **demand-aggregation / curation layer** (flow). The right target is a vertical where that layer is *unoccupied* and you can belong to the community.
|
||||
|
||||
**Worked example — miniatures as the beachhead.** Miniatures express every form of the commit-then-make primitive at once: recurring monthly STL/digital releases (Patreon / Cults3D model), digital delivery with licensing, *and* physical resin/metal casting that's pre-order or made-to-order, plus heavy variant explosions (scale, material, painted/unpainted) and army-builder bundles. That membership + digital + physical-preorder combination is exactly where Shopify is mediocre. The expansion scan:
|
||||
|
||||
| Vertical | Pros | Cons | Flow layer | Adjacent to minis? | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **Resin dice** | Drop culture is the *default* motion; large maker audiences; vivid authenticity grievance (cast-copies undercutting real casters); active scalper market makes queue/anti-flip valuable; drop/queue/commission infra **unserved**. | Lower price points; casting is labor-intensive; design-theft enforcement complexity. | **Open.** | **Yes — same tabletop buyer.** | **Top pick / first expansion.** Reuse minis' made-to-order/pre-order/variant/provenance primitives; add drop-scheduling, queue/raffle, anti-scalper as net-new. Compounds community density, no second cold-start. |
|
||||
| **STL / digital files** | Shares minis' digital gnarl (tiered licensing, membership drops, re-upload piracy where provenance defends); huge audience. | Most contested and **consolidating** — MyMiniFactory bought Thingiverse; Cults3D, Patreon, MakerWorld entrenched. | **Owned.** | Yes (minis' digital half). | **Partner/coexist; don't build.** Own the physical + storefront + cross-maker network it doesn't touch. |
|
||||
| **Hand-dyed yarn** | Non-fungible inventory (dye lots); dyed-to-order pre-orders; yarn clubs = subscription drops; variant explosion; tight community (Ravelry + IG); strong anti-mass ethos. | Shopify + apps cover the storefront; **a curated demand-aggregator already exists (Indie Untangled)**; non-adjacent buyer = full second cold-start. | **Partly owned.** | No. | **Strong-but-contested; validate first.** Best "wide" option, but probe whether the incumbent has *earned* switching-cost loyalty or merely lightly occupies the niche. |
|
||||
| **Custom knives** | High value/unit; waitlist/lottery/deposit by default; secondary market makes queue integrity valuable; collector culture. | **Entrenched decades-old curated marketplaces own the flow** (Arizona Custom Knives, Noblie); blade-shipping regulatory drag; non-adjacent buyer. | **Owned.** | No. | **Deprioritize.** Dislodging earned loyalty *plus* weapons-shipping compliance. |
|
||||
| **Enamel pins** | Large audiences; campaign/pre-order motion; LE secondary market; B-grade sub-market. | Largely **design+manufacture (POD), not craft**; grievance is art theft (originality), not handmade provenance; Fourthwall holds the creator-merch layer. | **Owned.** | No. | **Skip.** The product isn't really handmade, so a verified-*provenance* moat doesn't fit. |
|
||||
|
||||
**Strategic conclusion — deep-and-adjacent over wide.** Dice wins on every axis *and* shares the buyer, so the arc is **beachhead → dice → broad physical tabletop** — one buyer, one community, one compounding set of drop-and-provenance primitives. Yarn/knives are "wide" bets into scenes where the flow layer is already held by an embedded member — the "don't fight where someone's embedded" trap. Of the wide options, only yarn merits a validation probe before being ruled out.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — The commitment-commerce layer: incumbents & the open gap
|
||||
|
||||
"This feels Kickstarter-ish" is correct, and the incumbents are specific. The crowdfunding/pledge-management space is large, mature, and consolidating — but built for **episodic, project-scale campaigns**, not an individual maker's **continuous drop cadence.** That distinction is the entire opening — with one correction this appendix originally missed: there are **two** incumbent shapes, not one. The *episodic* campaign players below (Kickstarter / Gamefound / BackerKit), and — easy to miss because it doesn't look like crowdfunding — the *continuous-membership* incumbent, **Patreon**, which already serves the monthly-club primitive and sits *inside* the beachhead (its own treatment after the table). (The pattern for the campaign players: every one started as the tool managing the gap between committed demand and delivery — pledge management — then grew up into the funding layer.)
|
||||
|
||||
| Player | What it is | Built for | Relevance |
|
||||
|---|---|---|---|
|
||||
| **Kickstarter** | All-or-nothing campaign crowdfunding; no integrated pledge manager | Episodic, project-scale campaigns | The launchpad |
|
||||
| **Gamefound** | Tabletop-native; pledge-manager → full crowdfunding platform with late-pledge stores | Episodic tabletop campaigns + post-campaign stores | **Sitting in your adjacent vertical**; fast-growing, Kickstarter's biggest tabletop rival |
|
||||
| **BackerKit** | Pledge-manager (surveys/shipping/tax/add-ons) → also crowdfunding | Post-campaign fulfillment + campaigns | "Mission control" for fulfillment |
|
||||
| **Patreon** | Per-creator recurring memberships; platform is MoR, processes the charge (~8–12% all-in), pays out | **Continuous** creator membership (the monthly club) | **The recurring-club incumbent — and it's *inside* your beachhead** (the Patreon/Cults3D model in minis, Appendix A); a sharper competitor than Shopify for the club slice, on the wrong side of three invariants — treatment below |
|
||||
|
||||
**The open gap (where to play):** none of these *campaign* players serve the maker running a small drop every other Saturday or a 10-piece lottery — and the **monthly club**, the one continuous-cadence slice that *does* have an incumbent, is served by **Patreon** on the wrong side of the invariants (below). The unserved space is **continuous commitment-commerce cadence** — the recurring, relationship-driven, small-batch motion *between* Shopify (continuous but stock-only) and Kickstarter/Gamefound (commitment but episodic). The recurring strategic shape (the same as the rest of this memo): there's always an entrenched incumbent owning the *episodic/distribution* layer — Shopify (stock), MyMiniFactory (file distribution), Gamefound (campaigns) — and the open prize is the *continuous cross-maker relationship* layer they don't serve. **Coexist with the episodic incumbent; own the continuous demand-relationship network.**
|
||||
|
||||
**Patreon — the continuous-membership incumbent inside the beachhead.** Patreon is *not* §2's stock-then-sell foil the way Shopify is: a monthly club is already *commit-then-make* (patrons commit ahead; the creator produces against it), so Patreon is genuinely doing this category for the recurring-club/membership primitive (§2, §6, §7) — and in miniatures it's the de-facto infrastructure (the "Patreon / Cults3D model", Appendix A). That makes it a *sharper-edged* competitor than Shopify for the slice it touches, and an unavoidable one: your first community lives on it. But it sits on the wrong side of three invariants at once —
|
||||
|
||||
- **In the money flow.** Patreon is MoR, processes the recurring charge, takes ~8–12% all-in, and pays out — precisely the thing §7/§11 design out. A native replacement must keep the maker MoR on their own processor (Stripe Billing on a **Standard account + direct charges + `application_fee`**, §7). *Recurring billing is the sharpest test of that dial*, because Patreon's whole model is "platform is MoR for a subscription" — one config-flip away, and the flip would quietly rebuild Patreon.
|
||||
- **A walled garden on the buyer.** Run Patreon through Appendix D's three-surfaces gate and it scores like **Etsy, not Shopify**: you can't host a Curated-By block on a Patreon page, you can't attribute a referral through Patreon checkout, and Patreon owns the patron payment relationship. By the value rule it's an **invitation target, not an integration/destination** — "I'd feature your work the moment you own your commerce." The one federatable seam is **patron-email export → seed the maker's ESP** (§7, ESP-as-source-of-truth): billing and delivery you want native, the *list* you can lift.
|
||||
- **No moat, and structurally can't grow one.** Patreon is single-creator; its cross-creator discovery is platform-performed engagement-algo — the inverse of *maker-as-discovery-engine* (§7 buyer feed) — with zero reputation-staked, attributed cross-maker referral. It won't build that, for the same *shape* of reason Shopify won't (§7, "Why this is the defensible core") but a different specific one: its business *is* the captive recurring relationship and the algorithmic discovery surface. So it contributes nothing to your North Star (§12, cross-maker-referred GMV) — every patron on Patreon is a follow you never capture into the cross-maker identity graph.
|
||||
|
||||
**The play is two-sided, and already latent in the sequencing (§5).** *Act 1 (Tool) — out-tool it:* the engine already lists "recurring clubs/memberships" + "digital-file delivery + licensing" as primitives (§2, §6); for minis that *is* the Patreon feature set (monthly STL drop, tiered licensing, patron list). Build it **maker-MoR**, grant **usage-rights ownership of the patron** (§7 — the exact inversion Patreon doesn't offer), lower the all-in fee, and wire it into the network. (Appendix-A discipline holds: don't build a Cults3D/MyMiniFactory *file marketplace* — "partner/coexist; don't build" — but the club mechanic + storefront + network layer is yours.) *Act 2+ (Network) — out-flank it:* the cross-maker referral/feed is the durable reason a maker prefers you **even if Patreon matched the club features** — which it can't, without becoming a different company.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C — Composite multi-maker kits
|
||||
|
||||
A kit combining products from multiple makers, sold as one SKU on a maker's storefront — the deepest expression of maker collaboration, a natural Curated-By extension, a strong "kit drop" — but it presses hardest on the one line the architecture defends: **custody of funds.** Two governing rules. For the kit creator: **be a *principal reseller* of the kit, never a *conduit* aggregating others' sales** (principal is a product; conduit is money transmission). For the platform: **coordination and bookkeeping are free; custody — funds resting in an account you control — is the line.**
|
||||
|
||||
### C.1 The retail model
|
||||
|
||||
One charge means one merchant of record and one payout destination; "one SKU, N makers, money perfectly siloed" is a contradiction.
|
||||
|
||||
| Model | Mechanism | Verdict |
|
||||
|---|---|---|
|
||||
| **1. Lead maker = MoR, others = suppliers** | A sells the kit as A's own SKU on A's processor; A owes X/Y wholesale COGS. A is a principal reseller (legitimate drop-ship/wholesale, **not** transmission). | **Recommended.** Single charge, single MoR, out of the consumer flow. The virtual-kit/BOM pattern with components from other makers. |
|
||||
| **2. Curated kit, no unified checkout** | Themed Curated-By bundle; each component hands off to its own store. | Free but weak — N transactions in a bundle costume; not a true SKU. |
|
||||
| **3. Facilitated split-payment** | One checkout, auto-split to N connected accounts (Connect destination charges) — Shopify Collective's mechanism. | Cleanest *experience*, but you become facilitator / in the flow. Avoid until you choose to be marketplace-of-record for kits. |
|
||||
|
||||
Model 1 has two physical variants: **A assembles** (components ship to A, A builds + ships one box) or **A drop-ships** (X/Y ship directly; A is still MoR and still owes wholesale). C.6 is the decisive reason to prefer assembly.
|
||||
|
||||
### C.2 Supplier settlement — how A pays X and Y
|
||||
|
||||
**Custody is the line:** the moment funds rest in an account you control and you pay them onward, you're a transmitter. "Facilitate without being in the flow" means facilitating the *coordination* (or netting on your own account as principal), never custody.
|
||||
|
||||
**The default stance: the kit merchant (A) handles every supplier obligation; the platform moves no money at all.** A is MoR and a principal reseller, so A owes X and Y on whatever wholesale terms they agreed and **settles with them directly, off-platform**, the way any two businesses do. The platform's role is **transparency, not transfer**: it can *notify* a supplier when their item sells in someone else's kit ("your *Frostfang Drake* sold 3× in **Hearthforge**'s 'Winter Warband' kit"), so makers see their cross-maker pull — **without facilitating any payment between them.** Notification is pure coordination (free); routing a single dollar from A to X is custody (the line). This knowingly re-weighs the peer-to-peer friction below: accept the friction (notification softens it) to keep the platform *entirely* money-free. Models B and C are then **optional conveniences a maker may elect**, never the platform stepping into the flow.
|
||||
|
||||
| Model | Mechanism | In the flow? |
|
||||
|---|---|---|
|
||||
| **A. Bookkeeper / peer-to-peer (default)** | Platform records the payable and *notifies* the supplier; A→X money moves directly, off-platform. | No — the cleanest; notification offsets the "makers chasing each other" friction. |
|
||||
| **B. Net through the existing fee ledger** | X's receivable → credit in X's fee-offset wallet; A's payable → charge on A's next invoice. Principal on both sides. | No — *for the non-cashable portion.* **Optional, maker-elected** (still cashless: a fee credit, not a transfer). |
|
||||
| **C. Connect direct payout** | Buyer pays A; platform routes component cost to X/Y connected accounts; Stripe is the transmitter. | Yes — scoped re-entry. **B2B-first is the safest place to cross** (verified, KYC'd, mandate-on-file makers). |
|
||||
|
||||
**Supplier choice with a carrot:** beyond the money-free default (A), a supplier may elect **non-cashable wallet credit** (nets their own fees, out of flow) or **Connect payout** (real money, scoped in-flow), with a **bonus for wallet**. Caveat: wallet credit only helps a maker who *has fees to offset* — a **pure supplier** (supplies many kits, rarely sells) accrues trapped credit and needs Connect regardless. Plan for both. Phasing: Phase 1 = Models A/B (record + notify + net; settle the remainder peer-to-peer); Phase 2 = Model C for pure suppliers, on the shared cashable rail.
|
||||
|
||||
### C.3 Fee economics — taxed once
|
||||
|
||||
Components carry a referral fee (supplier bears it, as in Curated-By) — but **tax each kit dollar once.** Example: kit retails at R; wholesale $40 (X) + $30 (Y) = $70 COGS to A. X's $40 → 15% = $6 platform, X nets $34; Y's $30 → 15% = $4.50, Y nets $25.50; A pays the standard platform fee on **A's own margin (R − $70)**, not the full R. Charging A's full fee on R *and* referral fees on the components would double-tax the $70. All of it runs through the ledger off the order event, never the consumer payment.
|
||||
|
||||
### C.4 Where kits can be sold — inventory integrity, not fees
|
||||
|
||||
The fee/settlement machinery works on *any* storefront (computed from the order webhook + the kit BOM in your network service), so a **Shopify maker can sell a kit, be MoR, and have settlement run on the webhook.** What Shopify can't give is a **real-time cross-maker stock check at purchase** (you don't control its checkout), so finite-stock components risk a sync-lag oversell. Therefore: **finite-stock kits → favor your Medusa storefront** (you control checkout: atomic availability gate + atomic order+payable); **made-to-order kits → storefront-agnostic** (no finite-stock race; kit lead time = max of component leads = pre-order semantics). v1 scoping: launch kits as a Medusa-seller feature; optionally allow made-to-order kits for Shopify sellers; defer Shopify finite-stock kits.
|
||||
|
||||
### C.5 Wholesale bookkeeping — including volume tiers
|
||||
|
||||
Storing "X charges A $W/unit, tiered by volume" and computing settlement from units is **pure facilitation (out of flow).** Two details tiers force: **retroactive vs. prospective** (when the price drops at unit 11, do 1–10 reprice or only 11+? — a *term A and X agree to*, which the platform stores and applies); and **stateful settlement** (tiered pricing depends on cumulative units, so settlement is a running total per supplier-agreement, not per-order-independent — build the ledger for that from the start).
|
||||
|
||||
### C.6 Shipping & fulfillment — the decisive argument for assembly
|
||||
|
||||
A kit drop-shipped from N makers = **N shipments; shipping scales with maker count, not order** (≈3× for three makers). The premium lands somewhere and every option hurts: on the **buyer** (visible combined shipping — a conversion killer on a "deal" kit), **A** (uncontrollable margin variance), or **suppliers** (inflated COGS). Managing it, best to worst: **(1) assemble-and-consolidate (default)** — components ship to A, A packs one box; fixes three problems at once (single shipping cost, single-package experience, QC liability), at the cost of A doing micro-fulfillment (which *is* A's value, and justifies the margin); **(2) 3PL/hub consolidation** (defer — overkill at indie volume); **(3) honest drop-ship** ("ships in N packages," disclosed — reserve for when consolidation is impossible); **(4) shipping-smart kit construction** — surface estimated combined shipping *at kit-design time* and prefer same-region makers (your network sees cross-maker geography no single maker can).
|
||||
|
||||
| Components | Fulfillment | Result |
|
||||
|---|---|---|
|
||||
| In-stock | Consolidate | One box, fast, clean — **best case** |
|
||||
| Made-to-order | Consolidate | One box, but kit lead = max(component leads) + assembly hold |
|
||||
| Made-to-order | Drop-ship | N boxes, N arrival times, N charges — **worst; avoid** |
|
||||
|
||||
**Through-line:** shipping argues the kit creator should be a real **assembler-principal**, not a thin aggregator. The clean money model (principal reseller) and the clean shipping model (assemble-and-consolidate) point at the same role for A — the signal the design is coherent.
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Storefront control & network participation (why walled gardens are out)
|
||||
|
||||
The gating property is **control of the three surfaces the network must touch**: the **storefront page** (to host the verified merchant referral collection), **checkout** (to attribute referrals and run clean agentic purchase), and the **buyer relationship** (to own identity). Merchant-controlled storefronts grant all three; walled-garden marketplaces deny all three *by design* — intermediating the buyer is the marketplace's business model, so participation **degrades monotonically as a maker cedes control to a walled garden.**
|
||||
|
||||
Layered on top is a value rule stricter than the technical limits: **the network never routes buyers *into* a walled garden — not via Curated-By, the buyer feed, or the agent feed.** So walled-garden makers are **invitation targets, not destinations** — even where a marketplace's API would permit ingesting their catalog, the value rule forecloses surfacing it as a buyable destination.
|
||||
|
||||
Legend: ✓ supported · ◐ partial/limited · ✗ not supported.
|
||||
|
||||
| Provider | Catalog sync-in | Be a curator | Be a network destination | Clean referral-fee attribution | Owns buyer | Clean agentic checkout |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Your white-label (Medusa)** | ✓ native | ✓ you build the page | ✓ controllable + value-OK | ✓ you own checkout | ✓ maker owns relationship | ✓ you expose ACP |
|
||||
| **Self-hosted (Woo, Saleor, Vendure, custom)** | ✓ open APIs/plugins | ✓ full page control | ✓ controllable + value-OK | ✓ controls own checkout | ✓ owns buyer | ✓ their build exposes ACP |
|
||||
| **Shopify** | ✓ Admin API + webhooks | ✓ theme app-extension block | ✓ controllable + value-OK | ✓ cart attr → order webhook (any plan) | ✓ merchant owns customers | ✓ their processor |
|
||||
| **BigCommerce** | ✓ Catalog/Admin API | ✓ open storefront | ✓ controllable + value-OK | ✓ controls checkout | ✓ owns buyer | ✓ their processor |
|
||||
| **Squarespace / Wix** | ◐ commerce APIs (read) | ◐ code-injection, no clean app-block model | ✓ controllable, value-OK | ◐ checkout more closed — coupon/landing only | ✓ owns buyer | ◐ depends on platform |
|
||||
| **Etsy** | ◐ Open API v3 — *unused as a destination by value rule* | ✗ can't modify the page | ✗ **value rule** — invitation target only | ✗ Etsy owns checkout | ✗ Etsy owns the buyer | ✗ Etsy's decision |
|
||||
| **eBay** | ◐ APIs (read) — *unused* | ✗ can't modify the listing | ✗ value rule; invitation target | ✗ eBay owns checkout | ✗ eBay owns the buyer | ✗ eBay's decision |
|
||||
| **Amazon Handmade** | ✗ gated/restricted API | ✗ zero storefront control | ✗ value rule + Amazon owns all; invitation target | ✗ Amazon owns checkout | ✗ Amazon owns the buyer (most completely) | ✗ Amazon's own agents treat you as a competitor |
|
||||
| **Patreon** *(membership)* | ◐ API reads tiers/posts — *unused as destination by value rule* | ✗ can't host a Curated-By block on the page | ✗ value rule; invitation target | ✗ Patreon is MoR for the subscription | ✗ Patreon owns the patron — *one seam: email export* | ✗ Patreon's decision |
|
||||
|
||||
The table's shape *is* the thesis: **controllable storefronts ✓ across; walled gardens ✗ across.** Not a coverage gap — a restatement of who the network is *for* (makers who own their commerce, or will) and what it's an alternative *to*.
|
||||
|
||||
**The three-surfaces gate:** storefront-page control → be a curator; checkout control → attribution + agentic checkout; buyer-relationship control → identity/sovereignty.
|
||||
|
||||
**Walled gardens are who you're an alternative to — not a gap to cover.** A maker deep in Amazon Handmade who can't participate is the person the pitch is *aimed at*, who hasn't left yet. The right move is **invitation, not integration**: "I'd feature your work the moment you own your commerce" — the absence of a link is the recruiting signal, making membership the price of inclusion rather than subsidizing captivity. Notes: Etsy is the *most permissive* walled garden (Open API v3, models made-to-order, runs an affiliate program) but the value rule forecloses using it as a buyable destination anyway; a maker on *both* Etsy and a controllable storefront participates via the controllable one. Amazon Handmade is the most walled and the most hostile (restricted API, total buyer ownership, its own agentic-commerce ambitions). The **verification inversion** becomes a recruiting message: "we'd vouch for your work — verified independent of any marketplace's compromised badge — the moment you own your commerce."
|
||||
|
||||
**Patreon is the *membership-side* walled garden — the gap this table originally missed.** Every other walled garden above is a *transactional* marketplace (one-shot sales); Patreon applies the identical captive-commerce model to *recurring relationships*, and — unlike Etsy/Amazon for most makers — it is the **actual home of the beachhead audience** (Appendix A; the recurring-club incumbent, Appendix B). On the three-surfaces gate it scores like Etsy, not Shopify: no page control (no Curated-By block), no checkout control (no referral attribution — Patreon is MoR for the subscription), no buyer ownership (Patreon owns the patron) — the lone federatable seam is **patron-email export → seed the maker's ESP** (§7). So the Patreon posture is the same **recruit-out, don't integrate** rule, but materially more load-bearing than the Etsy one the memo otherwise leads with: the tension to face head-on is that *your first community lives on the very walled garden you are recruiting them off of* (§4). The answer is the two-sided play in Appendix B — **replace the club** (native, out-of-flow, maker-MoR) **+ lift the list + out-flank with the network.**
|
||||
|
||||
---
|
||||
|
||||
## Recurring principles (the spine)
|
||||
|
||||
Every decision in this memo reduces to a few invariants worth stating once, plainly:
|
||||
|
||||
- **The network is the moat; the storefront, hosting, and Medusa are fungible means in service of it.**
|
||||
- **Bind with structure, not promises** — non-cashable wallets, verification gates, sovereignty-by-policy, the non-profit entity, the no-walled-garden rule. The wrong path is foreclosed, not merely disavowed.
|
||||
- **Custody is the line; coordination and bookkeeping are free** — cross into the money flow once, knowingly, with rented infra (the Phase-2 cashable rail), never by accident.
|
||||
- **Maker-as-discovery-engine, platform-as-pipe** — discovery is delegated to makers the buyer chose, never platform-performed.
|
||||
- **Gate the demand, not the tool** — the storefront stays open to all; the network's *demand* surfaces (Curated-By, feed, referrals, agents) are what's gated, by **verification** (entry) and **reputation** (ongoing standing) alike. Makers chase trust rather than resent it.
|
||||
- **The unvalidated keystone is demand, not technology.** The components all exist; the combination is novel but unproven. Everything is gated on §8: whether enough audience-having, vouch-willing makers exist in one tight community.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
# SLICE-4 (part 1) — Deploy-Contract Code Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
|
||||
|
||||
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
|
||||
|
||||
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError` → `accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
|
||||
|
||||
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: VERSION + versioned /healthz
|
||||
|
||||
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
|
||||
|
||||
- [ ] Write `VERSION` containing `0.4.0`.
|
||||
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
|
||||
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
|
||||
|
||||
### Task 2: backend serves the SPA (deploy phase-8 contract)
|
||||
|
||||
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
|
||||
|
||||
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
|
||||
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
|
||||
|
||||
### Task 3: SmtpMailer + config surface (INV-8)
|
||||
|
||||
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
|
||||
|
||||
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
|
||||
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
|
||||
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
|
||||
|
||||
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
|
||||
|
||||
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
|
||||
|
||||
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
|
||||
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
|
||||
|
||||
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
|
||||
|
||||
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
|
||||
|
||||
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
|
||||
- [ ] README status: deploy contract in place; frontend package 0.4.0.
|
||||
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
|
||||
@@ -0,0 +1,118 @@
|
||||
# ui/designs Content-Repo Collection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
|
||||
|
||||
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
|
||||
|
||||
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 10–14)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the collection README**
|
||||
|
||||
`ui/designs/README.md`:
|
||||
|
||||
```markdown
|
||||
# ui/designs — UI-design artifacts
|
||||
|
||||
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
|
||||
app's UI-design artifacts — primarily Claude Design outputs generated from a
|
||||
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
|
||||
|
||||
A Solution Design with a UX-involving slice references its design artifact here
|
||||
by path. The spec-linkage gate and the backfill gesture for adding that
|
||||
reference once a design exists are tracked in
|
||||
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
Suggested layout: one subfolder per design, named for the spec/slice it serves,
|
||||
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the layout-table row**
|
||||
|
||||
In the top-level `README.md`, extend the table:
|
||||
|
||||
```markdown
|
||||
| Path | Holds |
|
||||
| --- | --- |
|
||||
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
|
||||
| `plans/` | archived implementation plans |
|
||||
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
|
||||
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
|
||||
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
|
||||
|
||||
### Task 2: Standardize centrally in engineering schemas docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Schema description strings + enum**
|
||||
|
||||
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]` → `["1.0", "1.1", "1.2", "1.3"]`
|
||||
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
|
||||
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
|
||||
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
|
||||
|
||||
- [ ] **Step 3: schemas/README.md**
|
||||
|
||||
Changelog entry above 1.2:
|
||||
|
||||
```markdown
|
||||
- **1.3** — docs-only: the content-repo collection convention gains a third
|
||||
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
|
||||
outputs generated from a Solution Design), referenced from specs. Like
|
||||
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
|
||||
validation change (the spec-linkage gate/backfill tooling is
|
||||
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
|
||||
```
|
||||
|
||||
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
|
||||
|
||||
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
|
||||
git -C …/engineering add schemas/app.schema.json schemas/README.md
|
||||
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
|
||||
git -C …/engineering push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR + merge (default token for `/pulls`).
|
||||
|
||||
### Task 3: Cross-link and close out
|
||||
|
||||
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
|
||||
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
|
||||
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
# SLICE-8 — Shopify dialect + public format docs — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Auto-detect a Shopify product-CSV export by its header set, map it to the canonical model at the codec boundary (INV-17), warn (not fail) on Shopify columns with no canonical home, and publish the public column reference (DOC-2) + finalized sample CSV (DOC-3) — completing PUC-6 (a Shopify export imports directly) and PUC-11 (learning the format).
|
||||
|
||||
**Architecture:** A Shopify file is recognized by *signature columns* it carries that canonical never does (`Body (HTML)`, `Variant Grams`, `Cost per item`, `Google Shopping / *`, …). Detected Shopify headers are rewritten to canonical names *before* the existing known/unknown split in `parse_csv`, so every downstream stage (validate → diff → apply) is already dialect-agnostic and unchanged (INV-17). Shopify columns with no canonical home — including two name-collision overrides (`Type` is Shopify's *free-text* type vs canonical's *structural* type; `Variant Weight Unit` is superseded by the grams→weight transform) — flow into the existing `unknown_columns` warning, which the preview UI already renders. The only value transform is `Variant Grams` → canonical `Variant Weight` (+ synthesized unit `g`). Frontend is already built for dialect display and the not-imported banner; SLICE-8 only sharpens the label and the upload-screen format help, and adds DOC-2.
|
||||
|
||||
**Tech Stack:** Python 3.13 / FastAPI backend (`backend/app/domains/products/`), Vitest + React/TS SPA (`frontend/src/`), Playwright E2E (`e2e/`). Tests: `pytest` (backend), `npm test` (frontend), `scripts/e2e.sh` (browser).
|
||||
|
||||
---
|
||||
|
||||
## The exhaustive Shopify → canonical mapping (the §6.5.1 fixture)
|
||||
|
||||
This table is the contract. It is pinned as a Python literal in Task 1 and as a CSV fixture in Task 3.
|
||||
|
||||
**Mapped — renamed** (Shopify header → canonical header):
|
||||
|
||||
| Shopify column | Canonical column | Note |
|
||||
| --- | --- | --- |
|
||||
| `Body (HTML)` | `Description` | HTML, sanitized downstream (INV-15) |
|
||||
| `Product Category` | `Google Product Category` | taxonomy string |
|
||||
| `Cost per item` | `Variant Cost` | decimal |
|
||||
| `Variant Grams` | `Variant Weight` | value transform: grams numeric; unit `g` synthesized |
|
||||
|
||||
**Mapped — direct** (same name, already in `KNOWN_COLUMNS`, pass through): `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, `Option1 Name`, `Option2 Name`, `Option3 Name`, `Option1 Value`, `Option2 Value`, `Option3 Value`, `Variant SKU`, `Variant Barcode`, `Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, `Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
|
||||
|
||||
**Not imported — warned** (no canonical home; surfaced in `unknown_columns`):
|
||||
|
||||
- **Name-collision overrides (must NOT pass through):**
|
||||
- `Type` — Shopify free-text type; canonical `Type` is structural (`standalone`/kits, #15). Per decision D (spec §11): warned, never folded into canonical `Type` or `Tags`.
|
||||
- `Variant Weight Unit` — superseded by the `Variant Grams` → weight transform (unit forced to `g`).
|
||||
- **Shopify-only columns:** `Variant Compare At Price` (a.k.a. `Compare At Price`), `Variant Inventory Policy`, `Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, `Variant Tax Code`, `Gift Card`, `SEO Title`, `SEO Description`, every `Google Shopping / *` column, and any market/region column (`Included / *`, `Price / *`, `Compare At Price / *`, `Price / International`, …).
|
||||
|
||||
**Algorithm (handles the unbounded market columns without enumerating them):** for each header column, in order —
|
||||
1. in `SHOPIFY_RENAME` → its canonical name (mapped);
|
||||
2. in `SHOPIFY_DROP` overrides (`{"Type", "Variant Weight Unit"}`) → not imported;
|
||||
3. else in `KNOWN_COLUMNS` → pass through (mapped);
|
||||
4. else → not imported.
|
||||
|
||||
**Detection signature** — a header is Shopify iff it contains ≥1 column canonical never has:
|
||||
`{"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price", "Variant Inventory Policy", "Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable", "Gift Card", "SEO Title", "SEO Description"}` **or** any column starting `"Google Shopping / "`. Otherwise canonical (the safe default — shared columns map identically, so an ambiguous file never misparses; §7.4 risk row).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- **Create** `backend/app/domains/products/dialect_shopify.py` — detection + header mapping + the pinned table. One responsibility: the Shopify adapter.
|
||||
- **Modify** `backend/app/domains/products/codec.py` — `detect_dialect()` calls the adapter; `parse_csv()` rewrites the header before the known/unknown split and synthesizes the weight unit.
|
||||
- **Create** `backend/tests/test_products_dialect_shopify.py` — adapter unit tests + the exhaustive-mapping fixture test.
|
||||
- **Modify** `backend/tests/test_products_codec.py` — detection + end-to-end parse-as-shopify tests.
|
||||
- **Modify** `backend/tests/test_products_invariants.py` — INV-10 over a partial Shopify file.
|
||||
- **Create** `backend/app/domains/products/columns.md` — DOC-2 source (column reference, every column + dialect notes).
|
||||
- **Modify** `backend/app/main.py` — add `GET /api/products/columns.md`.
|
||||
- **Modify** `backend/tests/test_products_api.py` (or the existing API test module) — route test for DOC-2.
|
||||
- **Modify** `frontend/src/productsApi.ts` — `dialectLabel("shopify")` → `"Shopify product CSV — mapped"`.
|
||||
- **Modify** `frontend/src/screens/products/ImportUpload.tsx` — format help mentions Shopify + links DOC-2.
|
||||
- **Modify** `frontend/src/screens/products/ProductsPage.tsx` — link DOC-2 beside the sample CSV.
|
||||
- **Modify** `frontend/src/productsApi.test.ts` (or create) — `dialectLabel` cases.
|
||||
- **Create** `e2e/fixtures/shopify-export.csv` — an unmodified-shape Shopify export fixture.
|
||||
- **Create** `e2e/tests/import-shopify-dialect.spec.ts` — `e2e_import_shopify_dialect`.
|
||||
- **Modify** `docs/products-domain.md` — DOC-4 dialect-adapter notes.
|
||||
- **Modify** `backend/app/__init__.py` (or wherever `__version__`/`app.json` version lives) + `CHANGELOG`/version bump to v0.8.0.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shopify dialect adapter module
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/domains/products/dialect_shopify.py`
|
||||
- Test: `backend/tests/test_products_dialect_shopify.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# backend/tests/test_products_dialect_shopify.py
|
||||
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
|
||||
|
||||
|
||||
def test_detects_shopify_by_signature_column():
|
||||
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
|
||||
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
|
||||
|
||||
|
||||
def test_canonical_header_is_not_shopify():
|
||||
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
|
||||
|
||||
|
||||
def test_ambiguous_shared_only_header_defaults_canonical():
|
||||
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
|
||||
assert is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) is False
|
||||
|
||||
|
||||
def test_map_renames_and_passes_through():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
|
||||
)
|
||||
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
|
||||
assert not_imported == []
|
||||
|
||||
|
||||
def test_map_drops_type_and_weight_unit_overrides():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
|
||||
)
|
||||
assert mapped == ["Handle", None, "Variant Weight", None]
|
||||
assert not_imported == ["Type", "Variant Weight Unit"]
|
||||
|
||||
|
||||
def test_map_drops_shopify_only_and_market_columns():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
)
|
||||
assert mapped == ["Handle", None, None, None]
|
||||
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: app.domains.products.dialect_shopify`
|
||||
|
||||
- [ ] **Step 3: Implement the adapter**
|
||||
|
||||
```python
|
||||
# backend/app/domains/products/dialect_shopify.py
|
||||
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
|
||||
|
||||
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
|
||||
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import KNOWN_COLUMNS
|
||||
|
||||
# Shopify header -> canonical header (renames only; direct same-name columns pass
|
||||
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
|
||||
SHOPIFY_RENAME: dict[str, str] = {
|
||||
"Body (HTML)": "Description",
|
||||
"Product Category": "Google Product Category",
|
||||
"Cost per item": "Variant Cost",
|
||||
"Variant Grams": "Variant Weight",
|
||||
}
|
||||
|
||||
# Canonical-named columns Shopify uses differently — must NOT pass through.
|
||||
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
|
||||
|
||||
# Columns whose presence proves the file is a Shopify export (canonical never has them).
|
||||
_SIGNATURE: frozenset[str] = frozenset({
|
||||
"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price",
|
||||
"Variant Inventory Policy", "Variant Fulfillment Service",
|
||||
"Variant Requires Shipping", "Variant Taxable", "Gift Card",
|
||||
"SEO Title", "SEO Description",
|
||||
})
|
||||
|
||||
|
||||
def is_shopify_header(header: list[str]) -> bool:
|
||||
cols = {h.strip() for h in header}
|
||||
if cols & _SIGNATURE:
|
||||
return True
|
||||
return any(c.startswith("Google Shopping / ") for c in cols)
|
||||
|
||||
|
||||
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
|
||||
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
|
||||
or None when that Shopify column has no canonical home; not_imported lists the
|
||||
original Shopify names (in order) that were dropped — the preview warning."""
|
||||
mapped: list[str | None] = []
|
||||
not_imported: list[str] = []
|
||||
for raw in header:
|
||||
col = raw.strip()
|
||||
if col in SHOPIFY_RENAME:
|
||||
mapped.append(SHOPIFY_RENAME[col])
|
||||
elif col in SHOPIFY_DROP:
|
||||
mapped.append(None)
|
||||
not_imported.append(col)
|
||||
elif col in KNOWN_COLUMNS:
|
||||
mapped.append(col)
|
||||
else:
|
||||
mapped.append(None)
|
||||
if col:
|
||||
not_imported.append(col)
|
||||
return mapped, not_imported
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
|
||||
Expected: PASS (6 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/dialect_shopify.py backend/tests/test_products_dialect_shopify.py
|
||||
git commit -m "feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire the adapter into the codec
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/domains/products/codec.py:15-66`
|
||||
- Test: `backend/tests/test_products_codec.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (append to `test_products_codec.py`)
|
||||
|
||||
```python
|
||||
def test_parse_detects_and_maps_shopify():
|
||||
data = (
|
||||
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
|
||||
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
|
||||
).encode("utf-8")
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
cells = parsed.rows[0].cells
|
||||
assert cells["Description"] == "<p>Grey</p>"
|
||||
assert cells["Variant Cost"] == "9.50"
|
||||
assert cells["Variant Weight"] == "300"
|
||||
assert cells["Variant Weight Unit"] == "g" # synthesized
|
||||
# Type (free-text) and Gift Card warned, never mapped:
|
||||
assert "Type" in parsed.unknown_columns
|
||||
assert "Gift Card" in parsed.unknown_columns
|
||||
assert "product_type" not in str(cells) # Type never reached canonical
|
||||
|
||||
|
||||
def test_parse_canonical_unchanged():
|
||||
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.rows[0].cells["Description"] == "Grey"
|
||||
assert parsed.unknown_columns == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_codec.py -k "shopify or canonical_unchanged" -v`
|
||||
Expected: FAIL — `parsed.dialect == "canonical"` (detection still stubbed) / `KeyError: 'Description'`
|
||||
|
||||
- [ ] **Step 3: Rewrite `detect_dialect` + `parse_csv`** in `codec.py`
|
||||
|
||||
Replace the `detect_dialect` stub and the header/cell-building block. New `codec.py` (lines from the import down through `parse_csv`):
|
||||
|
||||
```python
|
||||
from .dialect_shopify import is_shopify_header, map_shopify_header
|
||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||
|
||||
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||
|
||||
|
||||
def detect_dialect(header: list[str]) -> str:
|
||||
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
|
||||
return "shopify" if is_shopify_header(header) else "canonical"
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
|
||||
try:
|
||||
text = data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
try:
|
||||
try:
|
||||
raw_header = next(reader)
|
||||
except StopIteration:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
header = [h.strip() for h in raw_header]
|
||||
dialect = detect_dialect(header)
|
||||
|
||||
# INV-17: normalize the header to canonical names at the boundary. For Shopify,
|
||||
# mapped[i] is the canonical name (or None when dropped); not_imported is the
|
||||
# warning list. Canonical files map to themselves, unknowns warned as before.
|
||||
if dialect == "shopify":
|
||||
mapped, unknown = map_shopify_header(header)
|
||||
else:
|
||||
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
|
||||
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
|
||||
|
||||
for col in _REQUIRED_HEADER_COLUMNS:
|
||||
if col not in (mapped):
|
||||
raise FileRejected(
|
||||
"missing_required_column",
|
||||
f"This file is missing the required column '{col}'.",
|
||||
)
|
||||
|
||||
# First occurrence of a duplicated canonical column wins.
|
||||
col_index: dict[str, int] = {}
|
||||
for i, name in enumerate(mapped):
|
||||
if name and name not in col_index:
|
||||
col_index[name] = i
|
||||
# De-dup the warning list, order-preserving.
|
||||
seen: set[str] = set()
|
||||
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
|
||||
|
||||
rows: list[Row] = []
|
||||
for raw in reader:
|
||||
if not any(cell.strip() for cell in raw):
|
||||
continue
|
||||
if len(rows) >= MAX_DATA_ROWS:
|
||||
raise FileRejected(
|
||||
"too_many_rows",
|
||||
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
|
||||
)
|
||||
cells = {
|
||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||
for c in col_index
|
||||
}
|
||||
# Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1).
|
||||
if dialect == "shopify" and cells.get("Variant Weight"):
|
||||
cells["Variant Weight Unit"] = "g"
|
||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||
except csv.Error:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
|
||||
```
|
||||
|
||||
Note: the required-column check now reads `mapped` (canonical names) so a Shopify file whose `Title`/`Handle` are present passes, and a file genuinely missing them still fails honestly. `Handle` and `Title` are direct pass-through names in both dialects, so `mapped` contains them when present.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_codec.py -v`
|
||||
Expected: PASS (existing canonical tests + 2 new)
|
||||
|
||||
- [ ] **Step 5: Run the whole products suite (no regressions)**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/ -q`
|
||||
Expected: PASS — validate/diff/invariants unchanged (they consume canonical cells; INV-17 holds).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/codec.py backend/tests/test_products_codec.py
|
||||
git commit -m "feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Exhaustive-mapping fixture test (the §6.5.1 pin)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/fixtures/shopify-export.csv`
|
||||
- Test: `backend/tests/test_products_dialect_shopify.py` (append)
|
||||
|
||||
- [ ] **Step 1: Create a realistic full-width Shopify export fixture**
|
||||
|
||||
`backend/tests/fixtures/shopify-export.csv` — one product, two variants, one image-only row, exercising every mapped + several not-imported columns:
|
||||
|
||||
```csv
|
||||
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
|
||||
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
|
||||
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
|
||||
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
```python
|
||||
import csv as _csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
|
||||
|
||||
|
||||
def test_shopify_fixture_maps_exhaustively():
|
||||
parsed = parse_csv(_FIXTURE.read_bytes())
|
||||
assert parsed.dialect == "shopify"
|
||||
first = parsed.rows[0].cells
|
||||
# renamed
|
||||
assert first["Description"] == "<p>Soft cotton tee.</p>"
|
||||
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
|
||||
assert first["Variant Cost"] == "11.00"
|
||||
assert first["Variant Weight"] == "180"
|
||||
assert first["Variant Weight Unit"] == "g"
|
||||
# direct
|
||||
assert first["Variant SKU"] == "WG-TEE-S"
|
||||
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
|
||||
# not imported — warned, none leaked into canonical cells
|
||||
for col in ("Type", "Variant Compare At Price", "Gift Card", "SEO Title",
|
||||
"Google Shopping / MPN", "Variant Weight Unit", "Variant Inventory Policy",
|
||||
"Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable"):
|
||||
assert col in parsed.unknown_columns, col
|
||||
assert "Variant Compare At Price" not in first
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run — expect PASS** (codec from Task 2 already maps correctly)
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py::test_shopify_fixture_maps_exhaustively -v`
|
||||
Expected: PASS. If a column assertion fails, fix the mapping table in `dialect_shopify.py` to match this fixture — the fixture and table must agree.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/fixtures/shopify-export.csv backend/tests/test_products_dialect_shopify.py
|
||||
git commit -m "test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: INV-10 over a partial Shopify import (never deletes)
|
||||
|
||||
**Files:**
|
||||
- Test: `backend/tests/test_products_invariants.py` (append)
|
||||
|
||||
- [ ] **Step 1: Inspect the existing INV-10 test** to reuse its catalog/diff helpers
|
||||
|
||||
Run: `cd backend && grep -n "def test_inv10\|compute_diff\|build_products\|catalog" tests/test_products_invariants.py | head`
|
||||
Expected: shows the helper pattern (catalog fixture → parse → build_products → compute_diff → assert no deletes in plan).
|
||||
|
||||
- [ ] **Step 2: Write the failing test** (mirror the existing INV-10 test, swapping in a Shopify file that omits an existing product)
|
||||
|
||||
```python
|
||||
def test_inv10_shopify_partial_never_deletes():
|
||||
# An existing catalog of two products; a Shopify file mentioning only one.
|
||||
catalog = _catalog_with(["star-tee", "moon-mug"]) # existing helper
|
||||
data = (
|
||||
"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
|
||||
"star-tee,Star Tee,<p>x</p>,24.00,180\n"
|
||||
).encode("utf-8")
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
products = build_products(parsed)
|
||||
result = compute_diff(catalog, products)
|
||||
# INV-10: nothing in the plan deletes; moon-mug (unmentioned) is untouched.
|
||||
assert all(op.kind != "delete" for op in result.plan)
|
||||
assert "moon-mug" not in {p.handle for p in products}
|
||||
```
|
||||
|
||||
Adapt `_catalog_with` / `op.kind` / `result.plan` to the actual symbols found in Step 1.
|
||||
|
||||
- [ ] **Step 3: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_invariants.py -k inv10_shopify -v`
|
||||
Expected: PASS (INV-10 is dialect-agnostic; this proves it over Shopify).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_products_invariants.py
|
||||
git commit -m "test(products): INV-10 holds over a partial Shopify import (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Shopify label + format help
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/productsApi.ts:52-54`
|
||||
- Modify: `frontend/src/screens/products/ImportUpload.tsx:53`
|
||||
- Modify: `frontend/src/screens/products/ProductsPage.tsx:114`
|
||||
- Test: `frontend/src/productsApi.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (create or append `frontend/src/productsApi.test.ts`)
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dialectLabel } from "./productsApi";
|
||||
|
||||
describe("dialectLabel", () => {
|
||||
it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format"));
|
||||
it("labels shopify as mapped", () =>
|
||||
expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd frontend && npm test -- productsApi`
|
||||
Expected: FAIL — receives `"shopify"`, expected `"Shopify product CSV — mapped"`.
|
||||
|
||||
- [ ] **Step 3: Update `dialectLabel`**
|
||||
|
||||
```ts
|
||||
// frontend/src/productsApi.ts
|
||||
export function dialectLabel(d: string): string {
|
||||
if (d === "canonical") return "Canonical format";
|
||||
if (d === "shopify") return "Shopify product CSV — mapped";
|
||||
return d;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd frontend && npm test -- productsApi`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Update the upload-screen format help** — `ImportUpload.tsx:53`
|
||||
|
||||
Replace the line `Works with the canonical format.{" "}` and the following sample link block so it reads (PUC-11, §5.4 wireframe text):
|
||||
|
||||
```tsx
|
||||
Works with the canonical format or a Shopify product CSV.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download a sample
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the column-reference link beside the sample on the Products page** — `ProductsPage.tsx:114`
|
||||
|
||||
After the existing `<a href="/api/products/sample.csv" download>` element, add:
|
||||
|
||||
```tsx
|
||||
{" · "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build + typecheck + test**
|
||||
|
||||
Run: `cd frontend && npm run build && npm test`
|
||||
Expected: PASS (no TS errors, vitest green).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/productsApi.ts frontend/src/productsApi.test.ts frontend/src/screens/products/ImportUpload.tsx frontend/src/screens/products/ProductsPage.tsx
|
||||
git commit -m "feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DOC-2 column reference + route; finalize DOC-3
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/domains/products/columns.md`
|
||||
- Modify: `backend/app/main.py` (after the `sample.csv` route, ~line 451)
|
||||
- Modify: `backend/app/domains/products/__init__.py` (export `COLUMNS_MD_PATH` next to `SAMPLE_CSV_PATH`)
|
||||
- Test: the existing products API test module (find it in Step 4)
|
||||
|
||||
- [ ] **Step 1: Write the DOC-2 column reference** — `backend/app/domains/products/columns.md`
|
||||
|
||||
A complete merchant-facing reference: a short intro (canonical = Shopify-flavored superset; auto-detection; blank-vs-absent semantics), then a table of **every canonical column** (name · level · required · accepted values), then a **Shopify dialect notes** section reproducing the Task-1 mapping table (renamed, direct, not-imported). Source the canonical column rows from spec §6.5.1 (the table at lines 852-873) and the dialect notes from §6.5.1 (lines 880-889). Keep it plain Markdown — no app-specific build step.
|
||||
|
||||
- [ ] **Step 2: Export the path** — `backend/app/domains/products/__init__.py`
|
||||
|
||||
Add beside the existing `SAMPLE_CSV_PATH`:
|
||||
|
||||
```python
|
||||
COLUMNS_MD_PATH = _HERE / "columns.md"
|
||||
```
|
||||
|
||||
(Use the same `_HERE` / `Path(__file__).parent` idiom already present for `SAMPLE_CSV_PATH`.)
|
||||
|
||||
- [ ] **Step 3: Add the route** — `backend/app/main.py`, immediately after `products_sample_csv`
|
||||
|
||||
```python
|
||||
@app.get("/api/products/columns.md")
|
||||
def products_columns_md():
|
||||
"""The DOC-2 column reference. Documentation, so no auth gate (§6.4)."""
|
||||
return PlainTextResponse(
|
||||
products.COLUMNS_MD_PATH.read_text(),
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the failing route test** — find the API test module first
|
||||
|
||||
Run: `cd backend && ls tests/ | grep -i "api\|main"`
|
||||
Append to the module that tests the other unauthenticated doc routes:
|
||||
|
||||
```python
|
||||
def test_columns_md_served(client):
|
||||
resp = client.get("/api/products/columns.md")
|
||||
assert resp.status_code == 200
|
||||
assert "text/markdown" in resp.headers["content-type"]
|
||||
body = resp.text
|
||||
assert "Body (HTML)" in body # dialect notes present
|
||||
assert "Handle" in body # canonical columns present
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/ -k "columns_md or sample" -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Verify DOC-3 sample.csv is final** — it already carries a variant product (`star-tee`, S/M/L) and image rows. Confirm it parses clean as canonical:
|
||||
|
||||
Run: `cd backend && python -c "from app.domains.products.codec import parse_csv; from app.domains.products import SAMPLE_CSV_PATH; p=parse_csv(SAMPLE_CSV_PATH.read_bytes()); print(p.dialect, len(p.rows), p.unknown_columns)"`
|
||||
Expected: `canonical 5 []` (no unknown columns; 5 data rows). If `unknown_columns` is non-empty, fix the sample header to canonical names.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/columns.md backend/app/domains/products/__init__.py backend/app/main.py backend/tests/
|
||||
git commit -m "docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DOC-4 dialect-adapter dev notes
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/products-domain.md`
|
||||
|
||||
- [ ] **Step 1: Add a "Dialects (INV-17)" subsection** documenting: detection by signature columns; the `dialect_shopify` adapter; the rename/drop/passthrough algorithm; the grams→weight transform; that all downstream stages are dialect-agnostic. Point to `dialect_shopify.py` and the fixture as the source of truth (documentation-leads-automation, §4.1).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/products-domain.md
|
||||
git commit -m "docs(products): DOC-4 dialect-adapter notes (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: E2E — `e2e_import_shopify_dialect`
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/fixtures/shopify-export.csv` (copy the Task-3 fixture)
|
||||
- Create: `e2e/tests/import-shopify-dialect.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Add the fixture** — same content as `backend/tests/fixtures/shopify-export.csv`.
|
||||
|
||||
- [ ] **Step 2: Read an existing import E2E spec** to reuse the sign-up + upload helpers
|
||||
|
||||
Run: `sed -n '1,60p' e2e/tests/import-preview-confirm.spec.ts`
|
||||
Expected: shows the page-object / sign-up helper and the upload→preview→confirm flow to mirror.
|
||||
|
||||
- [ ] **Step 3: Write `e2e/tests/import-shopify-dialect.spec.ts`** (mirroring that helper)
|
||||
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { signUpAndOpenImport } from "./helpers"; // use the actual helper from Step 2
|
||||
|
||||
test("e2e_import_shopify_dialect: a Shopify export imports directly (PUC-6)", async ({ page }) => {
|
||||
await signUpAndOpenImport(page);
|
||||
await page.getByLabel(/choose|upload|file/i).setInputFiles("fixtures/shopify-export.csv");
|
||||
// Preview shows the mapped banner + not-imported warning
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
await expect(page.getByText(/not imported/i)).toBeVisible();
|
||||
await expect(page.getByText("Type")).toBeVisible(); // a warned column
|
||||
// Confirm and land on the run detail
|
||||
await page.getByRole("button", { name: /^Import \d/ }).click();
|
||||
await expect(page).toHaveURL(/\/products\/imports\/runs\/\d+/);
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
Adapt selectors to the real upload control + helper names found in Step 2.
|
||||
|
||||
- [ ] **Step 4: Run the E2E suite**
|
||||
|
||||
Run: `./scripts/e2e.sh`
|
||||
Expected: all specs PASS including `import-shopify-dialect` (7 prior + 1 new = 8).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/fixtures/shopify-export.csv e2e/tests/import-shopify-dialect.spec.ts
|
||||
git commit -m "test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Traceability audit, version bump, full green
|
||||
|
||||
**Files:**
|
||||
- Modify: version source (`backend/app/__init__.py` or `app.json`) + any `CHANGELOG`
|
||||
- Read-only: spec §12 traceability matrix
|
||||
|
||||
- [ ] **Step 1: Audit §12 traceability** — confirm PUC-6, PUC-11, BUC-1 (Shopify), DOC-2, DOC-3 now have shipping code + tests. Note any gap in the transcript `## Deferred decisions`. (Spec audit is read-only; do not edit the content repo here — finalize submits the plan.)
|
||||
|
||||
- [ ] **Step 2: Bump the version to v0.8.0**
|
||||
|
||||
Run: `cd backend && grep -rn "0.7.0" app/__init__.py app.json 2>/dev/null`
|
||||
Update the version string to `0.8.0` wherever the prior slice set it (mirror the SLICE-7 v0.7.0 bump).
|
||||
|
||||
- [ ] **Step 3: Full local gate**
|
||||
|
||||
Run: `./scripts/check.sh && ./scripts/e2e.sh` (or the repo's canonical localhost gate)
|
||||
Expected: backend pytest, frontend build+vitest, import-linter, and Playwright all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-plan (session flow, not subagent tasks)
|
||||
|
||||
After all tasks are green on the branch:
|
||||
1. **PR → merge to `main`** (branch→PR→merge; §5.4).
|
||||
2. **PPE deploy** via flotilla + E2E green on `https://ecomm-ppe.wiggleverse.org`; stamp the `release/<ts>` tag (§9.1). Prod promotion stays the operator's gate.
|
||||
3. **Close story #13** (SLICE-5/6/7/8 all done) with a pointer to the merge + PPE release tag.
|
||||
4. **Finalize** the session (`wgl-session-finalize`) — archives this plan to the content repo `plans/`, updates memory, publishes the transcript.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (SLICE-8 §7.2 DoD):**
|
||||
- Header-set dialect detection → Task 1 (`is_shopify_header`) + Task 2 (`detect_dialect`). ✓
|
||||
- Shopify→canonical mapping + exhaustive fixture → Task 1 (table) + Task 3 (fixture pin). ✓
|
||||
- Preview "mapped" banner + not-imported warnings → Task 5 (label) + already-built `unknown_columns` banner (`ImportPreview.tsx:288`). ✓
|
||||
- DOC-2 column reference (published) → Task 6. ✓
|
||||
- DOC-3 final sample CSV → Task 6 Step 6 (verify; already has variants+images). ✓
|
||||
- `e2e_import_shopify_dialect` green → Task 8. ✓
|
||||
- BUC-1 acceptance for an unmodified Shopify export → Task 3 fixture + Task 8 E2E. ✓
|
||||
- §12 traceability audited → Task 9. ✓
|
||||
- INV-10 over Shopify → Task 4. ✓
|
||||
|
||||
**Placeholder scan:** every code step shows concrete code; selectors/helpers in Tasks 4/6/8 are explicitly flagged to adapt to symbols discovered in a named inspection step (not silent TBDs).
|
||||
|
||||
**Type consistency:** `is_shopify_header` / `map_shopify_header` signatures match between Task 1 (def), Task 2 (import + call), and Task 3 (test). `dialectLabel("shopify")` string is identical in Task 5 def, Task 5 test, and Task 8 E2E assertion (`"Shopify product CSV — mapped"`). `COLUMNS_MD_PATH` defined (Task 6 Step 2) before use (Task 6 Step 3).
|
||||
|
||||
**Deferred decisions (logged to transcript):**
|
||||
- `Variant Grams` → `Variant Weight` value with unit forced to `g`; Shopify's own `Variant Weight Unit` display column is *not imported* (warned). Lossless in mass (grams is exact); chosen over carrying a possibly-inconsistent unit. Revisit if merchants need kg/lb display fidelity.
|
||||
- DOC-2 served as raw Markdown (`text/markdown`), mirroring how DOC-3 `sample.csv` is served — not a styled HTML page. Satisfies "app-served, source in framework repo"; styled rendering is a future enhancement.
|
||||
- Ambiguous header (only shared columns) defaults to **canonical** rather than a distinct `unknown_dialect` state — shared columns map identically so it never misparses (§7.4 intent); avoids a third dialect state the model doesn't carry.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
# Miniatures Maker Platform — Strategy & Next Steps
|
||||
|
||||
A working memo distilling the thinking so far. The aim is to keep both doors open — a paid consultancy that's real today, and an option on a larger network play — without over-committing to the second before the evidence justifies it.
|
||||
|
||||
---
|
||||
|
||||
## 1. The lens: stock moats vs. flow moats
|
||||
|
||||
The organizing idea behind everything below.
|
||||
|
||||
- **Stock moat** = accumulated build, features, or content. A one-time lead. As production cost collapses (cloud did this to infrastructure; LLMs are now doing it to software production itself), stock moats trend toward zero, and any *new* stock advantage is matched in weeks.
|
||||
- **Flow moat** = data, network, switching cost, regulation, embedded workflow. It compounds with every customer and every day of use, and cheap production cannot shortcut its accumulation.
|
||||
|
||||
The diagnostic question for any product: *if a competent small team could reproduce the functionality in a week with LLM assistance, what does the customer still pay for?* Whatever survives that question is the real moat. If nothing survives, build-cost was the whole moat — and that's the exposed position.
|
||||
|
||||
Cheap production lowers cost **symmetrically** — for every entrant at once — so it never picks winners. It commoditizes the build layer and pushes competition *up* to the moats it can't erode. Plan accordingly: don't pick a fight where build-cost was going to be your only moat.
|
||||
|
||||
---
|
||||
|
||||
## 2. The thesis
|
||||
|
||||
Build for **miniatures makers** — positioned as "the curated maker marketplace before resellers polluted it," but executed so the durable value is flow, not stock.
|
||||
|
||||
**The core primitive: commitment commerce.** The unifying insight is that the entire "gnarly 20%" across these maker verticals is one mechanism wearing many costumes. Pre-order, raffle, drop, monthly club, made-to-order commission, deposit-and-waitlist — every one of them is *collect committed demand before production, then make against it.* That is the inverse of Shopify's model (*stock-then-sell*: make it, shelve it, someone buys it), which is exactly why Shopify and generic tools serve makers badly: makers run on *commit-then-make*. The product category you're building is **commitment commerce**, not storefront commerce. Naming that defines the platform: the drop/pre-order/club/commission cadence is the engine, and the storefront is just its surface.
|
||||
|
||||
Two halves, deliberately treated differently:
|
||||
|
||||
- **Commitment-commerce engine (the real product).** The maker-native cadence layer Shopify lacks: scheduled drops, pre-orders and deposits, raffle/queue allocation, made-to-order workflows, recurring clubs/memberships, variant and bundle handling. This *is* the gnarly 20% and the reason vertical infrastructure here is defensible — it is emphatically *not* "the same as Shopify." (The white-label storefront is subsumed here as the commodity surface: build the least of it you can, rent the rest.)
|
||||
- **Cross-maker demand network (the moat).** The flow asset, and it accumulates as a *byproduct* of the engine. Every drop/pre-order is an occasion to capture a buyer who *follows* a maker and commits early. A base of drop-followers across many makers is the embryonic cross-merchant identity network — the niche-scoped Shop Pay equivalent. Stock commerce gives you people who bought once; commitment commerce gives you people who *wait for* makers and commit ahead — a far stronger flow asset. Curated cross-maker inclusion (makers featuring each other, reputation-staked) is how that network turns into earned demand rather than relocated demand.
|
||||
|
||||
The key reframe vs. earlier drafts: commitment commerce is not a feature of the storefront — it *is* the platform, and it's the mechanism that builds the flow moat instead of something engineered separately alongside it.
|
||||
|
||||
**Business model (detail in §7):** the **network is the product and the value capture** (referral take + network subscription, charged identically whether a maker is on your storefront or Shopify); the **storefront is an optional, SaaS-priced convenience — never GMV-fee'd, never sold as hosting.** You are a *vertical commitment-commerce product*, not a hosting company; a partner/consultant network onboards the high-touch tail.
|
||||
|
||||
**Why miniatures is a good vertical:** it expresses every form of the commit-then-make primitive at once — recurring monthly STL/digital releases (the Patreon / Tribes / Cults3D model), digital file delivery with licensing, *and* physical resin/metal casting that's pre-order or made-to-order, plus heavy variant explosions (scale, material, painted/unpainted) and army-builder bundles. That membership + digital + physical-preorder combination is precisely where Shopify is mediocre and where commitment-commerce infrastructure earns its keep.
|
||||
|
||||
---
|
||||
|
||||
## 3. Positioning: what's yours vs. what's commodity
|
||||
|
||||
The cross-merchant inclusion mechanism already exists and is commoditized — Shopify Collective, Carro, and Shopify's newer Product Network all let merchants sell each other's products. Feasibility is fully de-risked, but it also means **you cannot win on the plumbing.** If the product is "Collective for makers," Shopify extends Collective and you're gone.
|
||||
|
||||
Your differentiation must be the layer the commission-optimized networks structurally can't have:
|
||||
|
||||
- **Verified provenance.** Authenticity verification — "actually handmade/sculpted by this person" — is the trust signal buyers and AI agents can't get elsewhere. It *appreciates* as AI-generated and drop-shipped fakes proliferate (Etsy is being flooded right now). This is the flow asset; the aggregation tech is stock.
|
||||
- **Reputation-staked curation, not pay-for-placement.** The central danger: margin/commission-driven inclusion drifts curation from "what's genuinely good" to "what converts and pays" — which rebuilds Etsy's pollution *from inside*, laundered through trusted faces. Inclusion has to be an editorial/social act with the curating maker's standing on the line, not a high-margin SKU import.
|
||||
- **Community standing.** The demand problem is fundamentally a community problem, and community is founded, not acquired.
|
||||
|
||||
Longer term, the verified-supply graph becomes **agent-ready rails** — the trustworthy, structured, real-time-inventory supply layer that AI shopping agents need and cannot manufacture by scraping. That repositions the moat from "win consumer eyeballs" (unwinnable for a newcomer) to "be the verified maker-supply layer agents route through."
|
||||
|
||||
---
|
||||
|
||||
## 4. Honest risks and hard truths
|
||||
|
||||
- **The graveyard is real.** Goimagine, Artisans Cooperative, Folksy, My Community Made, Storenvy, Amazon Handmade — "Etsy but actually handmade" has been built many times and every version stays small. The reason: **curation fights liquidity**, and curation is a *seller-side* value proposition, not a buyer-side one. These platforms recruit angry makers (supply) and die for lack of buyers (demand). Etsy's 2013 drift into mass-produced goods wasn't a betrayal — it was the gravity of GMV growth.
|
||||
- **No demand advantage yet.** This is the binding constraint. Demand at a new marketplace's scale must be *earned*, not bought — paid acquisition against Etsy, Amazon, and agents is the losing game the whole graveyard played.
|
||||
- **Early-stage, the network pools existing maker audiences (reshuffle), it doesn't create net-new buyers.** That's the correct cold-start move, but don't mistake it for solving acquisition.
|
||||
- **Buyer-ownership tension.** Some makers guard their customer lists and will resist shared cross-merchant identity. If they want the cross-promotion demand but not the shared identity, your network's conversion advantage is capped. Surface this early.
|
||||
- **n = 2 is learning, not validation.** The trap for an engineer is over-abstracting for two clients when the right abstraction only reveals itself around ten.
|
||||
- **Commitment commerce carries financial/delivery liability.** Taking money before delivery means inheriting structural delivery risk — chargebacks, non-delivery, makers who collect pre-orders and don't ship. This is *why* the 20% is gnarly (it's financial risk, not just UX), and it echoes Shopify's payments-loss exposure. Decide early whether you sit in the money flow and carry that risk, or stay payment-adjacent and let the maker carry it (all-or-nothing gates, delayed payouts, and maker vetting are the standard mitigations).
|
||||
- **Raffle/lottery legality.** The raffle drop mechanic can be regulated as a lottery or gambling depending on jurisdiction — the one primitive in the set with real compliance exposure. Understand it *before* building it as a headline feature.
|
||||
- **Campaign vs. cadence is a product fork — pick cadence.** Episodic, project-scale crowdfunding (a maker funding one big run a year) is owned by entrenched incumbents (Kickstarter, Gamefound, BackerKit — see Appendix B). The unserved gap is the *continuous* maker drop cadence (the biweekly drop, the monthly club). Don't drift into competing with Gamefound; own the continuous-relationship layer they don't serve and let big campaigns happen elsewhere.
|
||||
- **Volunteer-core sustainability is the new single point of failure** (given the non-profit/volunteer model — see §7). LLMs make a smaller core go further but don't fix volunteer attrition or bus-factor. The parts touching money, catalog data, and verification need *reliability*, not best-effort — so the critical core (network service, ledger, verification) must not be bus-factor-one. The historical killer of volunteer orgs is sustaining, not building; transfer the rigor you'd spend on fundraising onto this.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing: earn into the marketplace, don't launch one
|
||||
|
||||
Four acts, each viable on its own, each earning the right to the next. No stage depends on demand you can't yet generate.
|
||||
|
||||
1. **Tool.** The verified-maker commitment-commerce engine (drops, pre-orders, clubs, made-to-order) with a white-label storefront as its surface — genuinely the best home for a real miniatures maker. Useful at zero liquidity. Accrues supply, structured provenance-stamped catalog, *and* a base of drop-followers. *Real business even if the network never lights.*
|
||||
2. **Community.** Narrow to one craft scene tight enough that word-of-mouth substitutes for a marketing budget. Add cross-maker discovery with an editorial point of view — the demand-generating feature you build rather than buy.
|
||||
3. **Marketplace.** Light the curated cross-inclusion network once supply density and community exist.
|
||||
4. **Infrastructure.** Expose the verified-supply graph to agents; become the rails.
|
||||
|
||||
**Selection criterion that decides everything:** pick the community where you show up as a *member, not a vendor* — where you already have, or can authentically build, real standing.
|
||||
|
||||
---
|
||||
|
||||
## 6. The immediate engagement (the two makers)
|
||||
|
||||
Good investment **if** you build the right common infra. The wasted version is a generic storefront engine ("the same as Shopify") — that's the stock layer, valueless in every branch.
|
||||
|
||||
The reusable assets worth owning, none of which is a storefront builder:
|
||||
|
||||
1. **A clean, portable, verified catalog data model that you own.** Cheap to do well now, expensive to retrofit later.
|
||||
2. **The commitment-commerce primitives (the engine)** — scheduled drops, pre-orders/deposits, raffle/queue allocation, recurring clubs/memberships, made-to-order workflows, digital file delivery + licensing, and variant/bundle handling. This is the portable, vertical-spanning core; the storefront is just its surface.
|
||||
3. **Standing in the miniatures scene** and two reference relationships.
|
||||
|
||||
**Operating rule: build the miniatures-shaped 20%; rent the 80%.** Use a template, headless setup, or even Shopify itself for the commodity storefront parts. Let this be paid consultancy whose real return is knowledge, references, and a portable data asset — a cheap option on the bigger play.
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture: money-flow stance & the first network feature
|
||||
|
||||
These are two decisions that turn out to be the same decision viewed twice.
|
||||
|
||||
### Money flow: stay out of it (the "no")
|
||||
|
||||
The white-label model already enforces this: because each storefront is the maker's brand on the maker's own payment processor, **the maker is merchant of record by construction.** The money never touches you — you sell software, they run commerce. This is *cleaner than Kickstarter*, which is itself in the money flow (it processes pledges, takes a cut, pays out) and only disclaims *delivery* liability; here you avoid both the flow and the delivery liability structurally.
|
||||
|
||||
**Pros of staying out of the flow:**
|
||||
|
||||
- No money-transmitter / escrow licensing burden (holding customer funds triggers state-by-state MTL in the US).
|
||||
- No chargeback exposure — chargebacks hit the merchant of record (the maker's processor), not you.
|
||||
- No delivery-risk balance-sheet exposure — the Shopify "loan-and-transaction-losses up 84%" risk simply isn't yours.
|
||||
- Clean SaaS margins; faster launch (no payments/underwriting/compliance build).
|
||||
- Maker-aligned: their brand, their processor, their payouts, their customer, their data.
|
||||
|
||||
**Cons (real — the reason to phase rather than refuse forever):**
|
||||
|
||||
- You forgo payment take-rate, which the Shopify analysis showed is the actual profit engine. Pure-SaaS is a smaller model. (But the take-rate you give up is exactly the slice that carries the risk you're declining — a fair, deliberate trade.)
|
||||
- The cross-maker identity moat can't come *from* the payment layer, because you don't own checkout.
|
||||
|
||||
**The reconciliation — you surrender neither economics nor the moat:**
|
||||
|
||||
- **Stripe Connect threads the revenue needle.** With connected accounts, the *maker* stays merchant of record (their bank, brand, chargeback + delivery liability), Stripe is the money transmitter, and you take an `application_fee` per transaction *without holding funds or the MTL burden* — plus transaction-level visibility. The middle path between pure-SaaS and full merchant-of-record. *(Verify the exact liability split across standard/express/custom account types with Stripe's docs and a payments attorney before architecting — platform liability genuinely varies by type. Not legal advice.)*
|
||||
- **The identity moat lives at the *follow*, not at checkout.** The asset is "who follows which makers and commits early to drops" — captured at the account / "notify me" / club-membership layer you own *regardless* of whose processor runs the charge. It never needed the money flow.
|
||||
|
||||
**Phasing:**
|
||||
|
||||
1. **Phase 1 — white-label + their processor.** No liability, no network yet. Pure tool / consultancy.
|
||||
2. **Phase 2 — shared identity/follow layer** threaded through all the white-label sites so the network can form. Optionally add Connect `application_fee` for transaction economics. Still no money-flow liability.
|
||||
3. **Phase 3 (much later, optional, opt-in) — a shared checkout** makers *choose* because it converts better (the Shopify Payments move). The only point the money-flow question returns — on your terms, never forced.
|
||||
|
||||
### "Curated By This Maker" — the first network feature
|
||||
|
||||
Each maker's storefront carries a **"Curated By This Maker"** section featuring other *verified* makers' products they genuinely admire. It's the cross-maker demand network expressed as a concrete, shippable product surface — and it works at **n = 2**, the holy grail for a network feature (almost nothing is useful at two nodes).
|
||||
|
||||
**What this is — and the name matters.** This is a **verified merchant referral network**, *not* retail media. The two are mechanically similar (a curated set of products with outbound links and attribution) but encode *opposite governance*: retail media is **paid placement** (the advertiser who pays most wins the slot; relevance is for sale), while a verified merchant referral network is **reputation-staked vouching** (placement is earned by being worth featuring; the curating maker's standing is on the line). Same shape, inverted soul. The name is a structural guardrail: a thing called a *verified merchant referral network* can't quietly start selling slots without becoming a self-evident lie. Never frame or price it as retail media — that framing's gravity is toward selling placement, which rebuilds Etsy's pollution through trusted faces.
|
||||
|
||||
**Targets are verified makers on controllable storefronts ONLY — never walled-garden listings.** The network never points its curation (or its buyer feed, or its agent feed) *outward* at an Etsy/Amazon/eBay listing. This is a **value-system rule, not a technical limitation**: every outbound link into a walled garden would lend the community's trust and a maker's audience to the exact captive-commerce model this enterprise is an alternative to — sending makers' buyers *into* the gardens you exist to get makers *out* of. Mechanically possible, philosophically self-defeating. Consequence: a walled-garden-only maker is an **invitation target, not a curation/destination target** — "I'd feature your work the moment you own your commerce" is the pitch, and the *absence* of a link is the recruiting signal toward sovereignty. (This also strictly sharpens the moat: when every target must be a verified, controllable-storefront member, the "sincere vouch vs. paid slot" gray zone disappears entirely.) See Appendix D for the full participation matrix.
|
||||
|
||||
**The fork that determines everything — what "buy" means:**
|
||||
|
||||
- **(A) Referral / handoff — build this.** Checkout hands off to maker B's own store and processor. A vouches, B fulfills and gets paid, A optionally earns a referral credit. Money stays cleanly siloed; A is never merchant of record for B's goods. Keeps you out of the money flow — *consistent with the "no" above.*
|
||||
- **(B) Consignment / resale — avoid (for now).** The buyer checks out on A's store, through A's processor, for B's product. A becomes merchant of record for B's goods and owes B a payout — recreating the delivery-and-payout liability you just designed out. This is mechanically what Shopify Collective is, and why it needs Shopify Payments in the middle. Re-opens the money flow.
|
||||
|
||||
**Why A is the elegant choice:** it's reputation-staked human curation (A puts their standing behind B) — the anti-pollution, can't-be-gamed curation that *is* the moat; it rides each maker's existing traffic (the mesh that pools demand without you buying it); and it's structurally aligned (A only features B if A rates B's work).
|
||||
|
||||
**How the two halves connect:** A (handoff) *is* the money-flow "no" applied to the network feature. The referral attribution that makes A work is powered by the **Phase-2 shared identity layer** — a buyer recognized across A's and B's white-label sites is what makes "A drove this sale to B" tractable without anyone being merchant of record for anyone else. A referral *credit* can be a platform-side ledger entry, not a money-movement event — keeping you out of the flow even while rewarding curation. The feature, the identity layer, and the money-flow stance are one coherent bet.
|
||||
|
||||
**Guardrails (A degrades into the failure modes if done carelessly):**
|
||||
|
||||
- **Attribution is stateless — it does *not* require the identity layer.** A signed referral token (origin maker + item + expiry + nonce) rides the A→B handoff into B's order; you read it at checkout to bill B and credit A. This tracks the *referral path*, not the buyer, so it works in Phase 1 for guests and fully sovereign makers alike. Persistent identity is only needed for the *durable* network effects (follows, cross-session credit, recognition lift) — see the identity subsection below.
|
||||
- **Commission-corruption trap.** The moment "Curated By" pays well, curation drifts from *vouch for work I admire* to *feature whoever converts/pays most* — Etsy's pollution rebuilt through trusted faces. Keep it reputation-staked: cap how much any maker can feature, make it visibly personal (name + face on it), prefer "A owns/uses this," and resist turning slots into paid placement no matter how tempting. The sincere vouch is the whole value.
|
||||
- **Competitive-adjacency rule.** Nudge toward *complementary* makers, not substitutes (the dice maker surfaces the dice-bag sewist or terrain sculptor, not a rival caster). Complementary curation is generative; substitute curation is cannibalistic, and makers won't do it.
|
||||
|
||||
**The phrase to interrogate in your own spec: "buy through their store."** It's the soft spot where (B) creeps back in — someone will ask for unified-cart "so buyers check out once across makers," and that convenience is exactly what drags you back to merchant-of-record and the money flow. Hold the line at handoff until a shared *opt-in* checkout is a deliberate Phase-3 decision.
|
||||
|
||||
### How the platform gets paid
|
||||
|
||||
Because you're out of the money flow (maker is merchant of record on their own processor), your fee is a **platform fee billed in arrears via the ACH/invoice model**, explicitly separate from processing — you can't skim a sale you don't touch. The comparable that matches this architecture is Checkout Page ($29/mo, 0% on top of the maker's own Stripe). The structure:
|
||||
|
||||
- **Tiered "graduate" pricing.** Starter tier: $0 or low monthly + a modest **percentage** (≈2–4%) on captured orders — adoption-friendly for cold start. Pro tier: flat **$29–49/mo + 0%** — predictable and easy to collect, for makers at scale. Auto-graduate by GMV threshold so makers aren't accidentally overpaying. (Industry norm: start transaction-fee-only to validate, switch to subscription around $3–5k/mo.)
|
||||
- **Use a percentage, not a per-order flat fee**, on low-AOV makers (dice, single minis) — a flat $0.50 disproportionately taxes a $12 item.
|
||||
- **Charge on captured/fulfilled orders, not pledges** — pre-orders get cancelled/refunded; don't bill on money that never cleared.
|
||||
- **Market the all-in transparently:** "our fee + your own processor ≈ X% vs Etsy's ~20%." Not bundling processing is the wedge — say it in numbers.
|
||||
|
||||
**Referral economics (Curated-By).** On a referred order, charge Maker B a **referral fee** (≈15%, marketplace convention à la Faire/Amazon Handmade) and *independently* credit Maker A. Keep these as **two separate events** — B→Platform (a fee on B's invoice) and Platform→A (a credit you extend) — so you act as **principal on both sides, never as a conduit** moving money B→A. That two-step independence is what keeps this out of money-transmission territory, and it holds *only* while A's credit is non-cashable.
|
||||
|
||||
- **The referral fee subsumes the standard fee on referred orders** (one clean "15% because referred" number), rather than stacking 15% on top of your normal cut and drifting toward Etsy-like ~19%.
|
||||
- **B's fee and A's credit are independent numbers; the spread is platform margin** (e.g., charge B 15%, credit A 10–12%, keep 3–5%). One of the few places the network itself generates revenue, not just fee-offset.
|
||||
- **Non-cashable fee-offset wallet.** A's credit accrues to a wallet that draws down *future platform fees first*. This drives the curation flywheel ("curate → wipe out your fees"), reduces your collection risk (referral-active makers self-offset their invoices), and — being a fee discount, not income — is cleaner on tax (no 1099 event). **Cashing out re-opens the money-flow/transmission door** and would require renting BaaS money-movement infra (Stripe Treasury/Connect/Dwolla); treat it as a deliberate later crossing, not a toggle.
|
||||
- **Settlement on a pending → cleared lifecycle.** Credit A as *pending* immediately (engagement signal) but not spendable; hold B's fee pending too; settle **both legs together** after B's refund window closes. In-window refunds void both atomically — zero clawback, nothing goes negative. Clear on the *refund/return* window (≈14–30 days), not the ~120-day chargeback window. Rare post-clearance reversals fall to the next mechanism.
|
||||
- **Negative balances are just a debit on a continuing account.** Reverse pending first, then available, then push any shortfall to the maker's next ACH invoice; floor the displayed wallet at zero and show "owed next invoice." The monthly billing cadence *is* the settlement job — include only window-closed referral events per invoice. Only real bad-debt case: a maker simultaneously churning, having spent cleared credit, then eating a late reversal — keep the ACH mandate active through offboarding; accept minor write-off; consider a rolling reserve only if a few high-volume curators make it material.
|
||||
|
||||
### Identity, sovereignty & the opt-in network
|
||||
|
||||
**Separate authentication (infrastructure) from identity ownership (policy).** Run **one shared platform auth** under every white-label store; express sovereignty as a *data-visibility policy* on top — never by splitting auth per store.
|
||||
|
||||
- **Sovereign maker:** full accounts/logins on their store (run by your shared auth), customer data private to them, zero network exposure. Sovereignty means *your buyers are private to you* — **not** "run your own login" and **not** guest-only (guest-only is *less* ownership, not more).
|
||||
- **Collective maker:** opted-in buyers participate in the cross-maker graph (recognition, referral, Curated-By).
|
||||
- **Centralize auth always** — it's commodity infrastructure *and* the foundation of the network. If auth fragments per store, even opted-in makers have no shared substrate to network across, destroying the keystone identity asset. The maker's choice is a flag on one system, flippable later without re-platforming (which is what makes the opt-in upgrade path work).
|
||||
- **Two consent layers:** the *maker* decides whether their store participates; the *buyer* decides whether they're recognized across the network (GDPR/CCPA). Build both flags from the start — far cheaper than retrofitting.
|
||||
|
||||
**Opt-in network model.** Joining the collective is opt-in — carrot, not stick, and it self-selects the commons-minded makers the network works for. Critical execution rule: the benefit must be **concentrated and visibly fast**, or opt-in collapses into a ghost town. Make the asymmetry **additive** (joiners get referral income, Curated-By placement, cross-promotion, fee offset, feed surfacing) — never **punitive** (do not cripple the standalone storefront to force joining; that breaks the Phase-1 promise and poisons trust). Design for: **granular** participation (e.g., outbound curation without inbound identity-sharing), **reciprocity** (surfacing proportional to participation, so no free-riding), and a **social-proof opt-in moment** ("makers you respect sent each other 200 buyers last month — want in?").
|
||||
|
||||
**"On our storefront ⇒ on the network" — split this into two switches; don't bundle.** Being a storefront maker should *not* force full network participation — that's the coercion the opt-in model rules out, it reintroduces the sovereignty problem for the maker who wants your drops-storefront without sharing buyers, and it weakens the storefront's standalone (n=1) value. The clean version separates two switches:
|
||||
|
||||
- **Catalog presence in the network** (your products *can* be discovered/curated/fed): reasonable to **default-on (opt-out)** for storefront makers — they're verified-eligible and their catalog is already in your system, so syncing it into the index is low-friction, high-value, low-sensitivity.
|
||||
- **Buyer-identity participation** (your *buyers* are recognized cross-maker): must stay a **separate, consent-gated opt-in** — it's the sovereignty-sensitive part *and* it's the buyer's data, not just the maker's.
|
||||
|
||||
So storefront onboarding can default *catalog* into the network as a fair convenience, while *buyer-identity sharing* remains an explicit, separate choice. Bundling the two would trade a hard-won neutrality/trust position (what wins fortress-leaning makers and differentiates you from Etsy/Shop-app) for a small onboarding convenience — a bad trade. Take the convenience (auto-catalog-sync); never the coercion (forced identity participation).
|
||||
|
||||
**Why this is the defensible core.** Shopify *won't* build cross-merchant shared identity — not because it's technically hard, but because its customers (sovereignty-seeking DTC merchants) would experience it as the platform claiming their buyers, betraying the exact promise Shopify sells. It does the *resell* network (Collective/Carro/Product Network — share *products*) but not the *referral* network (share *buyers* with attribution). Your bet: community-embedded makers relate to shared identity as *belonging*, not theft — so you can serve the buyer-relationship norm Shopify's base would revolt against. The moat isn't the storefront, the commitment-commerce mechanics, or the resell network (Shopify has that). It's the **cross-maker identity-and-attribution layer** — the one asset an island-based incumbent is fenced out of by its own positioning.
|
||||
|
||||
### Verification: the gate to the trust tier
|
||||
|
||||
Verification sits exactly at the boundary between the commodity layer and the moat layer. Everything below the line is the SaaS tool (anyone); everything above requires verification (the trust-gated network). There are three states, not two.
|
||||
|
||||
**Tiering:**
|
||||
|
||||
- **Unverified → full SaaS, zero network.** Any maker signs up and gets the complete commitment-commerce storefront (drops, pre-orders, clubs, their own processor). They're a paying customer of a genuinely good tool. They are simply not in *anything* trust-gated: no referrals (send or receive), no Curated-By (as curator *or* target), no buyer-feed surfacing, no agent feed. Rationale: the storefront is the commodity layer; gating it would suppress the Phase-1 adoption wedge. Let everyone in the front door.
|
||||
- **Verified → eligible for the trust tier.** Verification unlocks *eligibility* for referrals, Curated-By, buyer feed, and agent feed — not automatic inclusion.
|
||||
- **One gate, then independent switches.** Verification is the single floor to the entire trust tier; the channel opt-ins (network, agent) are independent choices *within* it.
|
||||
|
||||
**Curated-By and referrals are verified-only in *both* directions.** A verified maker's reputation-stake can't vouch for unverified supply, so Curated-By can feature *only* verified makers, and an unverified maker can't be the *target* of a referral either. Fully outside the graph both ways until verified. (Useful forcing function: wanting to be curated by your friends is a reason to verify.)
|
||||
|
||||
**Network opt-in and agent (ACP) opt-in are independent siblings over one shared verified catalog — do not gate agent inclusion behind network membership.**
|
||||
|
||||
- **Network opt-in** (maker-to-maker): identity sharing, Curated-By, referrals, buyer feed.
|
||||
- **Agent/ACP opt-in** (maker-to-machine): verified catalog exposed to external AI shopping agents.
|
||||
- A sovereign, fortress-minded maker may refuse the maker-to-maker network but *want* agent reach — because agents bring **net-new, sovereignty-neutral demand** from outside any maker's audience. Gating agents behind network membership would exile exactly the makers who most want agents.
|
||||
- **Verification — not network membership — is the precondition for the agent feed.** Agents need trustworthy, structured, verified-provenance supply (what OpenAI's Instant Checkout died for lack of). The agent feed thus inherits the core differentiator automatically: "*verified-handmade* products for agents."
|
||||
- **Default agent inclusion ON (opt-out) for verified makers.** Net-new sovereignty-neutral demand that most will want; default-on maximizes supply density, which is your leverage with the agents. Safe because agent sales route back through the maker as merchant of record (their processor, their fulfillment, your fee).
|
||||
- **The agent feed is the net-new-demand hedge** against the buyer feed's deliberate reach-weakness: the buyer feed *deepens* existing follow graphs, the agent feed *reaches* outside them. Complementary by design — another reason to want the widest verified participation in the reach engine.
|
||||
|
||||
**Verification is aspirational, not punitive — by design.** Because it gates the *demand* (referrals, feed, agents) and not the *tool*, makers are pulled toward verifying rather than blocked at signup. The unverified SaaS tier becomes a **verification funnel**: makers already paying, already with catalog in-system, upgrade path = "verify → unlock demand." And it keeps the trust guarantee **absolute** — both consumer-facing surfaces (buyer feed and agent feed) are hard-gated, so anything a buyer or agent sees through the network is verified.
|
||||
|
||||
**Peer verification — the scaling mechanism for the gate.** Makers verify other makers, staking their reputation. This is the right way to scale because it's *provenance verifying provenance* (real makers spot real makers better than any central reviewer), the verification web *is* a trust graph (harder to replicate than a checkmark database — it deepens the moat, not just scales it), and vouching/being-vouched-for doubles as community formation. **But it's the highest-stakes mechanism in the design** — a Sybil/collusion attack surface that fails *catastrophically*, because one polluted item reaching the "verified" feed breaks the entire guarantee. Build it as a *rooted, staked, multi-vouch trust web with a sampling audit*, never a flat "anyone verified can verify anyone":
|
||||
|
||||
- **Real, slashable stake.** If A verifies C and C is a reseller, A suffers a concrete consequence (verification authority revoked, own status reviewed, privileges suspended). The stake must *hurt*, or "staked reputation" is just a farmable click.
|
||||
- **Earned authority / time-trust gradient.** Newly-verified makers can't immediately verify others; authority accrues with tenure, clean record, and volume. This breaks ring-bootstrapping (you can't spin up accounts that instantly cross-verify).
|
||||
- **Rooted graph.** Anchor early verifications to a seed set *you* verified directly (community pillars); peer verification extends outward from trusted roots, so every verified maker traces back through a path to a root — and a compromised subtree can be found and revoked. A flat graph has no roots and no way to contain a breach.
|
||||
- **Multiple independent vouches** for full status (N verified makers, ideally not all from one cluster — independence matters more than count).
|
||||
- **Sampling audit on top**, sized to risk: random plus risk-triggered (new clusters, rapid cross-verification, mass-produced-*looking* products, buyer/agent complaints). You don't review everyone; you make abuse expensive and detectable.
|
||||
- **Instrument for ring patterns from day one** — cross-verification clusters, reciprocal-vouch loops, bursts, makers who only verify each other are detectable graph signatures.
|
||||
|
||||
**Sequencing: do *not* launch with peer verification.** Verify makers yourself early — you'll have few enough that manual review is feasible, and you *need* to personally establish the root set the web later anchors to. Turn on peer verification only once trusted roots exist and volume makes manual review the bottleneck. Seed manually first; enable deliberately. Note that verification is now load-bearing in four places (referrals, Curated-By, buyer feed, agent feed), so verification throughput is a real growth governor — worth designing the scaling path (tiered verification, community vouching as signal, provenance-documentation standards) early.
|
||||
|
||||
### Non-maker referrers: the verified taste-maker tier (Phase 2)
|
||||
|
||||
Any website can join as a *referrer* — a taste-maker/curator (painting YouTuber, tabletop blogger, hobby podcaster) who isn't a maker but has audience and taste. It's financially low-risk (pay-on-conversion, no custody, no MoR, no inventory — mature affiliate plumbing you already have). The risk is to the **verification moat**, and it's handled by the *same verification tiering as makers*:
|
||||
|
||||
- **Unverified referrer (open tier).** Anyone generates referral links to verified makers and earns on conversion, but is **not surfaced in any trust-dependent surface** (no trusted-curator badge, no buyer-feed placement, no appearance in others' Curated-By). Just a traffic source pointing at your makers — self-limiting (a bad referrer doesn't convert) and contained (can't touch trust surfaces, so can't pollute them).
|
||||
- **Verified taste-maker (trust tier).** A legitimate community voice, verified as a *trusted curator* (not as a maker), reputation-staked and slashable — gets the badge/surfacing/"vouched taste" treatment and loses it for shilling garbage. The same reputation-stake principle that makes maker curation pollution-resistant. Verification is what separates the genuine taste-maker (additive) from the affiliate-spam farm (corrosive).
|
||||
|
||||
**The load-bearing guardrail: uniform, non-biddable referral rates.** A maker-curator has a dual incentive (fee + peer respect + their own maker reputation) that counterbalances commission pull; a pure taste-maker's incentive is more purely the fee. If makers could set *different* rates, taste-makers would chase the highest payer — bidding-for-placement, i.e., retail media through the referrer door. So the rate is **uniform across makers (not biddable for placement)**: the taste-maker earns the same regardless of *which* verified maker they feature, so they feature on **taste, not who pays most.** This is what keeps the verified merchant referral network from sliding into retail media even with pure-commission referrers in the system.
|
||||
|
||||
Smaller rules: **disclosure/labeling** — distinguish a maker's reputation-staked peer vouch ("Maker A uses & vouches for this") from a taste-maker's disclosed-affiliate pick ("Curator X recommends this — earns a referral"); FTC-required anyway, and it protects a maker's vouch from dilution. **Referrer-only, one-directional** — a taste-maker is never a *destination* (nothing to sell) and never a *maker-verifier* (provenance verification stays maker-to-maker); they point traffic *at* verified makers, full stop. **No-walled-garden rule still holds** — they refer *to* verified makers on controllable storefronts, *from* their own controllable property; nothing routed into a garden.
|
||||
|
||||
**Why it's worth doing, not just safe:** taste-makers are the **net-new-demand engine** the maker-only network is structurally weak at. The buyer feed only deepens existing follow graphs; the agent feed was the lone net-new hedge. A respected hobby creator brings *genuinely new buyers* (who follow no maker yet) — a third demand source alongside maker-curation (deepens) and agents (reach), and the most community-native of the three (a trusted human voice, not an algorithm).
|
||||
|
||||
**Why Phase 2 — and why it's a marginal add, not a new crossing.** A pure referrer has no platform fees to offset, so the non-cashable wallet (what kept Phase 1 out of money movement) is *trapped value* for them — they need **cashable payout**, exactly the rail Phase 1 deliberately omits. But it's the *same* cashable Connect/mass-pay rail Phase 2 already needs for **cashable maker wallets** and **kit pure-supplier payouts** (Appendix C) — so taste-makers are a *third consumer* of one rail, not a new crossing. The marginal cost is the verification tier + uniform-rate guardrail, not new money plumbing. (The crossing's nature: paying a taste-maker is a *payout* to an affiliate/contractor — principal-on-both-sides, maker→platform fee and platform→taste-maker commission as two independent events — not consumer money transmission; rentable via Connect / PayPal mass-pay / Dwolla.) **Reserve option:** a marquee taste-maker who wants in before the rail exists can refer and *accrue a pending cashable balance in Phase 1, paid on rail launch* — captures the relationship early without building the rail early; reserve for a marquee curator (you're booking obligations you can't yet settle), don't open generally.
|
||||
|
||||
### Phasing the network (stateless → identity → feed)
|
||||
|
||||
1. **Phase 1 — stateless referrals.** Curated-By with signed tokens only. No auth dependency, no consent complexity; works for guests and sovereign makers. Ships early; buys *attribution*, not *recognition*. Maker-to-maker only, non-cashable wallet — fully out of money movement.
|
||||
2. **Phase 2 — persistent identity + the cashable-payout rail.** Shared auth + follow relationships + the cross-maker graph (consent-gated): the durable network effects (follows, cross-session credit, niche-scoped recognition/conversion lift). Phase 2 also lands the **cashable payout rail** (Connect/mass-pay), which is a single build that simultaneously unlocks **cashable maker wallets**, **kit pure-supplier payouts** (Appendix C), and the **verified taste-maker tier** (above) — three consumers of one scoped, rented crossing.
|
||||
3. **Phase 2+ — buyer-facing feed.** A consumer surface (site/app, plus email) where buyers see followed makers' drops, "buy it again," and Curated-By from makers they follow. This is **how the marketplace *emerges*** from accumulated follows rather than being launched cold into the curation-vs-liquidity graveyard (§5's "earn into the marketplace").
|
||||
|
||||
**Core principle — discovery is delegated to makers the buyer chose, never performed by the platform.** Every recommendation traces to a follow the buyer initiated: their followed makers' drops, buy-it-again from those makers, and Curated-By *from those same makers*. The platform never originates a recommendation — no "you might also like," no algorithmic cross-maker surfacing, no house-promoted placement. **Maker-as-discovery-engine, platform-as-pipe.** This is the *inverse* of the Shop app (platform-as-discovery-engine with algorithmic feeds and sponsored placement), and it's the line that keeps the feed from sliding into the model this whole strategy avoids. Three payoffs: it keeps you **out of the discovery war** (you never compete with Amazon/Google/agents on recommendation quality); it keeps the feed **non-threatening to sovereign makers** (no buyer is shown a maker they didn't choose to follow or that a followed maker didn't vouch for); and it preserves **curation integrity** (human, follow-gated, reputation-staked curation can't be gamed toward pollution the way an engagement algorithm can).
|
||||
|
||||
**The deliberate tradeoff to name:** pure follow-gated discovery is intentionally *weaker at net-new demand* — it deepens and cross-pollinates the graph buyers already know, but does not introduce buyers to makers outside their follow/curation graph. The growth engine stays permanently "makers bring audiences and vouch for each other," never "the platform surfaces new reach." That's the correct trade for this positioning (it's what protects sovereignty and trust), but it puts the entire demand burden on makers and their curation by design — a feature for this model, not a bug, as long as it's chosen knowingly.
|
||||
|
||||
Additional disciplines: **buyer-opt-in by nature** (which doubles as cross-merchant data consent); lead with the safe end (buy-it-again → followed drops → curated-by); respect maker-side policy on appearing in others' recommendations. Any future move toward platform-originated/algorithmic discovery is a deliberate, opt-in-by-everyone, much-later decision — not a default.
|
||||
|
||||
### Storefront architecture & Shopify coexistence
|
||||
|
||||
**Two layers, kept separate — this is the core architectural decision.** The system is a *storefront layer* (per maker) sitting under a *shared cross-maker network service* (the moat). Conflating them couples the moat to one storefront engine and to single-tenant boundaries; keep them apart.
|
||||
|
||||
- **Storefront layer (per maker).** Either your **white-label storefront** (headless backend + themed frontend) or the maker's **existing Shopify store** (federated — below). Handles that maker's catalog, cart, checkout, orders, and their own payment processor (merchant of record).
|
||||
- **Shared network service (cross-tenant — the moat).** Verification graph, **canonical catalog index**, cross-maker identity/follow graph, referral/Curated-By attribution + fee/wallet ledger, buyer-facing feed, agent/ACP feed. A standalone service with its own datastore. It spans *all* makers and federates over *heterogeneous* storefronts (Medusa and Shopify alike), so it is deliberately **not** part of any storefront engine.
|
||||
|
||||
**The canonical catalog index lives in the network service — not in a Medusa "mega-store."** Every network maker's catalog (Shopify via Admin API, your own Medusa storefronts via their API, anything else via adapters) is *transformed into one normalized, verified catalog index* that powers Curated-By, the buyer feed, and the agent feed. Do **not** pour all makers' products into a single Medusa instance: a Medusa instance is a *storefront* (cart, checkout, pricing, one merchant-of-record, sellable inventory), but the network catalog is a read-optimized *index* of products that live and sell elsewhere — it has no checkout and no single MoR, and its inventory is authoritative on each maker's own storefront. Routing it through Medusa would make a thing shaped like a store that must never behave like one, and would quietly couple the neutral network to one storefront engine. Keep each storefront (Medusa or Shopify) as the system of record for sellable catalog/inventory; the network holds a normalized verified *projection*. Landing the index in your own schema (not Medusa) is also what keeps Shopify and Medusa products *co-equal sources* feeding one neutral index, rather than Shopify products being second-class imports into a competitor-shaped container.
|
||||
|
||||
**Build the white-label storefront on a headless backend (Medusa recommended).** "Build the 20%, rent the 80%" in code. The headless backend (Medusa — Node/TS, modular, payment-agnostic, no per-order revenue share; alternatives Saleor (Python/GraphQL), Vendure (NestJS/TS), Spree (Ruby, natively multi-tenant white-label)) supplies cart, catalog, orders, customers, fulfillment, and BYO payment. You add the commitment-commerce engine — **drops, pre-orders/deposits, clubs/memberships, raffle/queue — as custom backend modules + workflows.**
|
||||
|
||||
- **Mental model (important):** in Medusa, *modules are backend domain logic; the storefront is a separate frontend app* (Next.js/Astro) consuming the Store API; the admin is separately extensible. So **the storefront is not a module** — the commitment-commerce *features* are modules, the storefront is their client, and the network service above is neither (it's standalone).
|
||||
- **Multi-tenancy:** one shared Medusa instance with app-layer tenant isolation (simpler, fine for early scale) *or* per-maker instances (stronger isolation, the "self-host per-instance" model). The network service is separate either way, so this choice is decoupled from the moat and can be deferred. For the two pilots: single instance + shared theme + minimal per-maker config; don't over-build theming for n=2.
|
||||
|
||||
**Shopify makers: federate, don't migrate.** Because the network layer is storefront-agnostic, a Shopify maker keeps Shopify as storefront + checkout (their MoR/processor) and joins the network via two hooks:
|
||||
|
||||
- **Catalog sync (inbound):** pull their catalog via Shopify Admin API + product webhooks into the verified-supply graph; once verified, their products are eligible for Curated-By, buyer feed, and agent feed — without leaving Shopify.
|
||||
- **Referral handoff + attribution:** the Curated-By link hands off to their Shopify product page with a signed referral token, which rides in as a **cart attribute → order note_attribute**, read back from the order webhook to bill B / credit A. Needs no checkout customization, so it works on *any* Shopify plan.
|
||||
- **Hybrid wedge:** evergreen catalog stays on Shopify; the maker uses your platform only for the commitment-commerce *events* (drops/pre-orders/clubs) Shopify handles badly. Lowest-friction entry; migrate the evergreen catalog later only if earned.
|
||||
|
||||
**Curated-By on Shopify — the concrete integration.** A Shopify maker plays two roles, both delivered through **one installed app** built on **theme app extensions** (not legacy ScriptTag — required for OS 2.0 themes and App Store submission; keep ScriptTag only as a fallback for vintage themes):
|
||||
|
||||
- **Curator role → an app *block*.** A draggable "Curated By This Maker" block the maker drops onto a product/home page in the theme editor (no code; auto-removed on uninstall). It fetches the maker's *verified* curated picks from your network service (the source of truth) and renders cards that hand off to the target maker's store with the signed referral token. Works on product/collection/cart/home/blog pages on any plan — only checkout is off-limits without Plus, which you don't need.
|
||||
- **Recipient role → an app *embed*.** A script (toggled in the theme editor's "App embeds" panel) reads the inbound `?ref=` token on landing, persists it, and writes it as a **cart attribute** on add-to-cart. Cart attributes flow natively into the order as **note_attributes** through standard checkout; you read them from the `orders/create` webhook to bill B / credit A. No checkout customization, any plan.
|
||||
- **One app = joining.** A maker who both curates and receives installs the single app (block + embed); installing it *is* how a Shopify maker joins Curated-By. The app is a thin client — curation choices, verification, and the referral ledger all live in your network service; the maker manages picks in your dashboard or an embedded App Bridge admin.
|
||||
- **Optional:** an **app proxy** (`makerstore.com/apps/curated` → your backend) for server-rendered, crawlable curated sections under the maker's own domain. **Attribution discipline:** persist the ref on landing and re-attach on every add-to-cart; last-click single-hop, matching the stateless model.
|
||||
|
||||
The headline: because it rides cart attributes through native checkout, the *entire* Curated-By loop — display and attribution — works on a completely standard Shopify store with no checkout access and no theme surgery.
|
||||
|
||||
**Strategic payoff:** the storefront-agnostic network makes your addressable makers the *entire* Shopify/Etsy/standalone install base — reachable as network participants via federation, not just makers willing to switch storefronts. The storefront is the wedge for makers who want a better tool; the network is open to anyone who verifies and syncs a catalog. This de-risks the scariest adoption question ("will makers switch storefronts?") — they don't have to. Caveats: Shopify API integration is partly rented land (rate limits, API changes, possible competition), but you're not dependent on it (one source among several); Shopify takes its cut on Shopify-store sales (fine — you meter the order webhook and bill your fee separately via ACH).
|
||||
|
||||
**Admin surfaces — three, not two.** The admin layer falls along the same seam as the architecture: the *network* is one product with one admin for *everyone*; the *storefront* is a separate product whose admin is Medusa's (your makers) or Shopify's (theirs).
|
||||
|
||||
- **Storefront admin — only for makers on your platform (Medusa).** Products, drops, pre-orders, clubs, orders, fulfillment, inventory for their own store. Use/extend Medusa's admin for the commitment-commerce modules. A Shopify maker gets *none* of this from you — Shopify is their storefront, so they manage all of it in Shopify's admin.
|
||||
- **Network admin — universal (every participating maker, Medusa or Shopify).** Curation/Curated-By management, referral earnings + fee-offset wallet, verification status + peer-vouching, follow/feed participation, network opt-in toggles, agent/ACP inclusion. **Bespoke build** — it has no home in either Medusa's or Shopify's admin because it's the cross-tenant moat layer above both.
|
||||
- **Billing/account — universal.** ACH mandate, fee tier, invoices, payment history. Bespoke, tied to the network service.
|
||||
|
||||
Mental model: **everyone uses your bespoke network + billing admin; Medusa makers *additionally* get Medusa's storefront admin; Shopify makers get their storefront admin from Shopify.**
|
||||
|
||||
- **ACH is universal, not referral-specific.** Every maker gives an ACH debit mandate at onboarding — it's how the platform collects all fees (subscription, per-order/%, *and* referral). Shopify makers (who have no account on your storefront) attach it through the network admin or the app's embedded setup, via GoCardless / Stripe-Billing-ACH. **Make mandate-on-file a precondition for activating referral eligibility** (gate it where you gate verification) — otherwise a referral-only maker accrues a fee you can't collect, and you avoid the negative-balance edge case for makers with no other invoice to net against.
|
||||
- **Shopify makers get a read-mostly catalog/sync/analytics view — a trust surface, not a convenience.** (1) **Synced catalog as a mirror, not a second editor** — Shopify stays system of record; your view shows the ingested catalog annotated with network metadata (verified? in agent feed? curated by whom?). Two editing surfaces = sync-conflict nightmare; keep it read-mostly. (2) **Sync health** — last sync, what came through, what failed, webhook status; when a sync silently breaks, a maker whose products vanish from the feed loses faith fast, so surface this prominently. (3) **Network analytics** — referral traffic/revenue (in/out), Curated-By performance, feed/agent-attributed sales, wallet/earnings. This is *uniquely yours to provide* because only you see across stores, and it does quiet retention work: the maker who can see "Curated-By sent me $800 last month" stays and participates more.
|
||||
|
||||
**Build-ordering implication:** the bespoke network + billing + analytics admin is **foundational and on the critical path for both maker populations from day one** — even the two Medusa pilots need it the moment Curated-By and referrals exist. Medusa's admin extension is the storefront-specific layer on top, not the other way around.
|
||||
|
||||
### Business model: network is the product, storefront is an optional convenience
|
||||
|
||||
Now that Medusa exists and the network federates over any storefront, the case for *centering* on storefront hosting has weakened — three independent erosions: the storefront is the commodity layer; Medusa makes that layer cheap for everyone; and federation means a maker doesn't *need* your storefront to be in the network. The resolution is a deliberate posture about role and pricing:
|
||||
|
||||
- **The network is the product and the value capture.** Charge for the network — referral take + network subscription — **uniformly, whether a maker is on your storefront or on Shopify.** Revenue must not depend on storefront adoption; that's what keeps you credibly neutral and feeds the moat (TAM = *every* maker, not just storefront adopters).
|
||||
- **The storefront is an optional, opt-in convenience — SaaS-priced, never GMV.** Drop the GMV storefront fee: GMV-take would recreate channel conflict, tax makers for using your rails (making your storefront *worse* than self-hosted Medusa), and pull you back toward the money-flow/MoR entanglements you escaped. Price the storefront as a flat SaaS fee that covers its cost; capture value in the network. A maker's storefront choice then doesn't change what they pay for the network.
|
||||
- **Three postures — reject the first.** *Don't* be a storefront company with a network (GMV fees, competing with Shopify on hosting — wrong center of gravity). *Do* be a network company that offers a storefront, where the storefront is the best-in-class native commitment-commerce experience for makers who want it or have nowhere else to be (the two pilots; greenfield makers), and a wedge — not the business.
|
||||
|
||||
**"Why not just point makers to Medusa Cloud?"** Because Medusa Cloud sells *hosted infrastructure to developers*; it does not give a maker a working drops-and-clubs storefront, and never will. You sell a **vertical, maker-ready commitment-commerce product** (drops, pre-orders, clubs, made-to-order, verified provenance, turnkey setup) where Medusa is *invisible plumbing*. The entire gap between raw infrastructure and a working maker storefront is your product — pointing a maker to Medusa Cloud is like pointing them to AWS. Corollary discipline: **don't position or price as a hosting company.** Framing yourself as "Medusa Cloud + modules" drags you into competing on hosting/infra margins. Hosting is a cost you absorb; the vertical experience (and the network) is what you sell.
|
||||
|
||||
**Partner / consultant network.** A channel of implementation consultants (community-embedded ones especially) onboards the **high-touch tail** — makers who want bespoke theming, migration, or "someone do it for me" — without you becoming a services business. It doubles as community-aligned distribution (trusted insiders bring makers) and mirrors the partner ecosystems that grew Shopify and Medusa. It's a second flywheel you *enable* (certification, partner referral fees, a directory) but don't *staff*. Two guardrails: keep the product **genuinely self-serve for the median maker** (partners serve those who *want* help; if makers *need* a consultant for a basic store, the product has failed and partners are masking it); and structure partners as **referral/implementation partners, not white-label resellers**, so consultants don't become the relationship-owner and disintermediate you from the maker (the buyer-ownership tension, one level up).
|
||||
|
||||
### Entity structure & trust positioning: non-profit, transparent, volunteer-built
|
||||
|
||||
The org is a **true non-profit (501(c)(3) or equivalent), radically transparent (open books), engineered by volunteers with LLM-accelerated development, not raising money or selling equity.** This is not a hedge or a branding choice — it's the structure that converts the trust position from a *promise* into a *guarantee*, and it's funded-viable for a reason that is thematically exact rather than incidental.
|
||||
|
||||
- **The cost structure that usually makes non-profit tech infeasible is the exact one LLMs just collapsed.** The primary cost center — engineering — has been deflated by the same force the venture exists to exploit. The entity structure and the market opportunity are *the same bet pointed in two directions*; the thesis (LLMs collapse build cost) applied to the org itself is what makes a volunteer-built non-profit viable.
|
||||
- **No fundraising removes the only strong argument against true non-profit.** (An earlier draft considered a PBC/B-Corp hedge — its entire purpose was preserving the ability to raise VC and grant equity. With fundraising off the table, that optionality is optionality you *don't want*, because it's the door — take growth capital, get acquired, chase GMV — that makers fear. The non-profit forecloses it.)
|
||||
- **The structure *enforces* the anti-Etsy promise rather than signaling it.** A 501(c)(3) legally cannot be sold or distribute profits to owners — the strongest possible structural answer to "will you get big, let resellers in, and sell our trust for GMV?" This is "bind with structure, not promises" realized in the entity itself: the extractive path is legally foreclosed, not merely disavowed.
|
||||
- **Radical transparency is the substrate of the verification moat.** Trust is the whole moat, and open books / open governance / an auditable verification process let the community *verify the incorruptibility* of "verified handmade" rather than take it on faith. Transparency makes the verification trust-guarantee *legible* — it's the mechanism, not a nice-to-have. (It's also the Wiggleverse ethos — intentional, non-extractive, transparent — expressed as an institution.)
|
||||
|
||||
**The risk moved; it didn't vanish — apply the rigor you'd have spent on fundraising to volunteer sustainability.**
|
||||
|
||||
- **Volunteer engineering is now the single point of failure.** LLMs change the *math* (a smaller core does what five engineers used to) but not the *human dynamics* of volunteer commitment — attrition when day jobs spike, bus-factor risk, best-effort cadence. A network touching makers' money, catalog data, and verification needs *reliability*, not best-effort. **Separate what must have reliability guarantees (the network service, the money/ledger, verification) from what can tolerate volunteer cadence (themes, nice-to-haves), and ensure the critical core is not bus-factor-one.** LLMs accelerate *building*; the historical killer of volunteer orgs is *sustaining*.
|
||||
- **Watch the "fragile" perception with professional makers.** Non-profit + volunteer + transparent reads as *trustworthy/aligned* to commons-minded makers, but possibly *fragile/might-fold* to professional, audience-having makers building a livelihood. Radical transparency (open books showing a sustainable operation) is the strongest counter — test in the §8 interviews whether it reads as **safe** (won't sell out) or **nervous** (might disappear).
|
||||
|
||||
|
||||
|
||||
### Hosting: managed vs. self-host (and the GCP pilot)
|
||||
|
||||
**What you sell and where you host are independent decisions** — makers never see the infrastructure, so hosting is a pure cost/ops optimization, and it's *reversible and invisible* because Medusa is open-source and portable. Start managed for speed, migrate to self-host when volume makes the economics worth the ops burden, without makers noticing.
|
||||
|
||||
- **Managed (e.g., Medusa Cloud) now; self-host at scale.** Early, your scarce resource is attention on product + network, not infra savings — managed avoids DevOps/upgrades/on-call. At scale, self-host wins on unit economics and avoids coupling margins to one vendor's pricing.
|
||||
- **Multi-tenancy interacts with hosting price.** Per-maker instances multiply managed per-instance pricing by maker count; a shared multi-tenant instance is far fewer (larger) instances with very different math. Negotiate volume pricing knowing your instance *count*, and pitch as a *multi-instance platform customer* (a better rate card than a single-store merchant).
|
||||
- **The network service is hosted separately, always.** The cross-tenant moat layer (verification, referral ledger, identity, feed) runs as its own service + database, untouched by any storefront-hosting choice. That separation is what makes the hosting decision switchable.
|
||||
|
||||
**The two-maker pilot:** self-host on **GCP — Cloud Run + Cloud SQL (Postgres)**. Cloud Run scales toward zero when idle (suits drop-spiky traffic) and avoids babysitting a GKE cluster you don't need at n=2; the shared-instance cost for two makers is low tens of dollars/month. **Watch the drop-traffic spike** — a scheduled drop is a sudden burst of concurrent buyers, exactly the load that embarrasses an under-provisioned instance; a storefront falling over *during a drop* is the worst possible moment for maker trust, so load-check before a real drop. On cost recovery: pass-through or a flat hosting fee is fine, but **treat cost recovery as the trivial part** — the bill is small, while two happy, well-treated pilot makers (your first verification roots and reference relationships) are worth far more. Optimize the pilot for learning the commitment-commerce 20% and goodwill, not for reconciling a GCP invoice. And frame any hosting charge as "covering pilot costs," not as the product's pricing model — don't anchor on cost-plus-hosting, which you've already decided against.
|
||||
|
||||
---
|
||||
|
||||
## 8. Next step: maker discovery (do this before building the platform)
|
||||
|
||||
Goal: verify whether Shop Pay's conversion advantage is a *real moat for these specific makers*, and whether you can build your own version of it.
|
||||
|
||||
**Avoid the measurement trap.** Don't ask "how much value does Shop Pay give you" — makers can't see the conversion counterfactual (they see the sales they got, never the carts they'd have lost), so answers are pure vibes. Shop Pay's lift comes from **cross-merchant identity recognition removing first-purchase friction.** So measure the thing that drives it:
|
||||
|
||||
- What's your revenue split between **repeat fans vs. first-time strangers**?
|
||||
- Do your buyers tend to **already have Shop Pay**?
|
||||
- (Repeat-fan-heavy makers capture little of the network effect — those buyers convert anyway. Viral/stranger-traffic makers capture a lot.)
|
||||
|
||||
**Don't concede a false tradeoff.** "Lose conversion to gain alignment" assumes you can't have the lift. But a curated cross-promotion network *structurally generates* cross-merchant identity as a byproduct — your own Shop Pay-equivalent, scoped to the niche. You can't offer it on day one (cold-start), but it's a **year-three asset, not a launch feature.** Concede the *timeline*, not the moat.
|
||||
|
||||
**Listen for (beyond what you ask directly):**
|
||||
|
||||
- Where their buyers come from today — the traffic-mix tell.
|
||||
- What they pay Shopify **all-in** vs. what they *think* they pay — most underestimate, and the gap is your opening.
|
||||
- Whether the value-alignment grievance is real willingness-to-switch or just venting.
|
||||
- Whether they'd want **shared buyer recognition** across a network, or guard their list.
|
||||
- **Whether they have an audience they'd bring** — the asset they'll least volunteer. A maker with no audience joining the network is a cost, not an asset.
|
||||
|
||||
**Weight what makers *do* over what they *say*.** "I'd switch for alignment and lower fees" is cheap and constantly contradicted by behavior (people stay on Etsy they openly resent). The makers worth building for are the ones who already maintain a second channel, already *moved* on something, already bring their own buyers. Find whether those exist in miniatures — and whether you are one of them.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decision gates
|
||||
|
||||
Gate the real platform build on evidence, not enthusiasm:
|
||||
|
||||
- Do the first two makers **refer you** to others?
|
||||
- Is the **pain consistent** across makers (same 20%)?
|
||||
- Do target makers **have audiences**?
|
||||
- Can you get to roughly a **dozen makers** who want the same thing?
|
||||
|
||||
Two makers justify a thoughtful, portable data model. They do not justify a platform. Build the data layer and the vertical primitives now; gate everything else on the dozen.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Adjacent maker verticals: pros, cons & expansion strategy
|
||||
|
||||
Competitive scan of candidate verticals beyond miniatures. The recurring finding: in every vertical the storefront/build layer is already commoditized (Shopify, Fourthwall), so the prize is the **demand-aggregation / curation layer** (flow). The right target is a vertical where that layer is *unoccupied* and you can belong to the community. Scored on the transferable filter — made-to-order/drop motion, non-fungible inventory, hybrid digital+physical, recurring drops, variant explosion, audience-having makers, authenticity grievance, scalper/counterfeit problem, reachable community hub.
|
||||
|
||||
| Vertical | Pros | Cons | Flow layer (curation/demand) | Adjacent to minis? | Expansion strategy / verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **Resin dice** | Drop culture is the *default* motion; large maker audiences (20K+ IG); vivid authenticity grievance (China cast-copies undercutting real casters); active scalper secondary market makes queue/anti-flip mechanics valuable; drop/queue/commission infra is genuinely **unserved**. | Lower price points than knives; casting is labor-intensive (maker-side throughput limits); some design-theft enforcement complexity. | **Open** — no vertical platform owns drops or maker-following. | **Yes — same tabletop buyer.** | **Top pick / first expansion.** Treat as expansion of one community, not a new vertical. Reuse minis' made-to-order, pre-order, variant, and provenance primitives; add drop-scheduling, queue/raffle, and anti-scalper mechanics as the net-new infra. Compounds community density rather than forcing a second cold-start. |
|
||||
| **STL / digital files** | Shares minis' digital gnarl almost exactly (tiered licensing, membership drops, re-upload piracy where provenance is the defense); huge engaged audience. | Most contested space and **actively consolidating** — MyMiniFactory bought Thingiverse (Feb 2026), ~1M paying customers, $100M+ to creators; Cults3D, Patreon, Printables/MakerWorld all entrenched. | **Owned** (MMF / Cults3D / Patreon). | Yes (it's minis' digital half). | **Partner/coexist; do not build.** This is the file-distribution layer and it's owned. Sit alongside it: own the physical + branded storefront + cross-maker network it doesn't touch. Don't try to out-repository the incumbents. |
|
||||
| **Hand-dyed yarn** | Genuinely non-fungible inventory (dye lots can't be restocked identically); dyed-to-order pre-order motion; yarn clubs = subscription drops; variant explosion (colorway × base); intense, tightly networked community (Ravelry + IG); strong anti-mass-produced ethos. | Shopify + apps already cover the storefront acceptably; **a curated demand-aggregator already exists (Indie Untangled — marketplace + newsletter + events) with a proven demand engine**; non-adjacent buyer = full second cold-start. | **Partly owned** (Indie Untangled). | No. | **Strong-but-contested; validate before committing.** Best "wide" option on gnarl and community, but the flow layer you'd want is partly occupied. Probe whether Indie Untangled has earned switching-cost loyalty or is merely lightly occupying the niche — "an incumbent exists" ≠ "an incumbent is loved." Only enter if there's a wedge it isn't serving. |
|
||||
| **Custom knives** | High value per unit ($500–$800+, heirloom pieces into 4–5 figures); waitlist/lottery/deposit motion by default; finished pieces flip on a secondary market, so queue integrity and buyer verification have direct dollar value; strong collector culture. | **Entrenched decades-old curated marketplaces own the flow** (Arizona Custom Knives consignment + maker-following alerts; Noblie); blade-shipping **regulatory drag**; non-adjacent buyer. | **Owned** (consignment dealers). | No. | **Hard to displace; deprioritize.** The demand engine (maker-following, drop alerts) is already built and trusted by 25-year incumbents. Entering means dislodging earned loyalty *and* handling weapons-shipping compliance. Not a first or second move. |
|
||||
| **Enamel pins** | Large audiences; campaign/pre-order (Kickstarter) motion; collectible secondary market with LE runs; B-grade/"seconds" grading is an established sub-market. | Largely a **design+manufacture (POD/dropship) business, not a craft one** — low true gnarl, factory AI pin-design tools exist; grievance is **art theft (originality), not handmade provenance** — a different, harder moat; all-in-one creator-merch layer already held by Fourthwall. | **Owned** (Fourthwall + POD). | No. | **Skip.** Weakest fit for a verified-*provenance* moat because the product isn't really handmade. The defensible problem here (originality/anti-theft) is not the one your platform is built to solve. |
|
||||
|
||||
### Strategic conclusion (deep vs. wide)
|
||||
|
||||
The scan favors **deep-and-adjacent over wide.** Dice wins on every axis *and* shares the miniatures buyer, so the arc is **miniatures → dice → broad physical tabletop** — one buyer, one community, one compounding set of drop-and-provenance primitives. Yarn and knives are "wide" bets into scenes where the exact flow layer you'd want to own is already held by an embedded member (Indie Untangled; Arizona Custom Knives) — the precise "don't fight where someone's already embedded" trap. Of the wide options, only yarn merits a validation probe before being ruled out, and the probe is specifically whether its incumbent has *earned switching-cost loyalty* or just *lightly occupies* the niche.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — The commitment-commerce layer: incumbents & the open gap
|
||||
|
||||
"This feels Kickstarter-ish" is correct, and the incumbents are specific. The crowdfunding / pledge-management space is large, mature, and consolidating — but it is built for **episodic, project-scale campaigns**, not for an individual maker's **continuous drop cadence**. That distinction is the entire opening.
|
||||
|
||||
**The pattern:** every incumbent started exactly where this platform would — as the tool managing the gap between committed demand and delivery (pledge management: surveys, shipping, taxes, add-ons, late pledges) — then grew up into the funding layer. Kickstarter is all-or-nothing with *no* integrated pledge manager, which is the gap the others filled.
|
||||
|
||||
| Player | What it is | Built for | Relevance |
|
||||
|---|---|---|---|
|
||||
| **Kickstarter** | All-or-nothing campaign crowdfunding; ~5% + 3–5% processing; no integrated pledge manager; no installments | Episodic, project-scale campaigns | The launchpad; ~$216.6M tabletop in 2024 |
|
||||
| **Gamefound** | Tabletop-native; founded by publisher Awaken Realms; evolved pledge-manager → full crowdfunding platform with late-pledge stores | Episodic tabletop campaigns + post-campaign stores | **Sitting in your adjacent vertical.** ~$84.5M tabletop in 2024, up from ~$22M in 2021 — fast-growing, Kickstarter's biggest tabletop rival |
|
||||
| **BackerKit** | Pledge-manager (surveys/shipping/tax/add-ons) → also a crowdfunding platform | Post-campaign fulfillment + campaigns | ~$23.1M tabletop in 2024; "mission control" for fulfillment |
|
||||
|
||||
**The open gap (where to play):** none of these serve the maker running a $2k drop every other Saturday, a monthly dyed-to-order club, or a 10-piece knife lottery. Nobody spins up a 30-day all-or-nothing campaign on a biweekly rhythm. The unserved space is **continuous commitment-commerce cadence** — the recurring, relationship-driven, small-batch motion that sits *between* Shopify (continuous but stock-only) and Kickstarter/Gamefound (commitment but episodic and project-scale).
|
||||
|
||||
**The recurring strategic shape (same as the rest of this memo):** there is always an entrenched incumbent owning the *episodic / distribution* layer — Shopify (stock commerce), MyMiniFactory (file distribution), Gamefound (campaign crowdfunding) — and the open prize is always the *continuous cross-maker relationship* layer they don't serve. The move is identical every time: **coexist with the episodic incumbent, own the continuous demand-relationship network.** A maker can run their big annual campaign on Gamefound *and* live here for the between-campaign drop cadence — exactly as they keep STLs on MMF and physical drops here.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C — Composite multi-maker kits
|
||||
|
||||
A kit combining products from multiple makers (dice from X, minis from Y) sold as one SKU on a maker's storefront. Powerful and on-theme — the deepest expression of maker collaboration, a natural Curated-By extension, a strong commitment-commerce "kit drop" — but it presses hardest on the one line the whole architecture defends: **custody of funds.** Two rules govern everything below. For the kit creator: **be a *principal reseller* of the kit, never a *conduit* aggregating other makers' sales** (principal is a product; conduit is money transmission). For the platform: **coordination and bookkeeping are free; custody — funds resting in an account you control — is the line.**
|
||||
|
||||
### C.1 The retail model — how the kit is sold
|
||||
|
||||
One charge means one merchant of record and one payout destination; "one SKU, N makers, money perfectly siloed" is a contradiction. Three real models:
|
||||
|
||||
| Model | Mechanism | Clean? | Verdict |
|
||||
|---|---|---|---|
|
||||
| **1. Lead maker = MoR, others = suppliers** | A sells the kit as A's own SKU on A's own processor; A owes X/Y wholesale COGS. A is a principal reseller (bought + resold a bundle, owes the buyer the kit) — legitimate drop-ship/wholesale, **not** transmission. | Yes — single charge, single MoR, out of consumer flow | **Recommended.** The virtual-kit/BOM pattern with components sourced from other makers. |
|
||||
| **2. Curated kit, no unified checkout** | Themed Curated-By bundle; each component hands off to its own store. N purchases. | Yes — fully siloed | Free but weak — N transactions in a bundle costume; buyer feels the seams. Not a true SKU. |
|
||||
| **3. Facilitated split-payment** | One checkout, auto-split to N connected accounts (Connect destination charges). Shopify Collective's mechanism. | No — you become facilitator / in the flow | Cleanest *experience*; a deliberate, scoped money-flow re-entry. Avoid until you choose to be marketplace-of-record for kits. |
|
||||
|
||||
Model 1 has two physical variants: **A assembles** (components ship to A, A builds + ships one box — simplest, cleanest) or **A drop-ships** (X/Y ship directly; A never touches them but is still MoR and still owes wholesale). C.6 is the decisive reason to prefer assembly.
|
||||
|
||||
### C.2 Supplier settlement — how A pays X and Y
|
||||
|
||||
**Custody is the line:** the moment funds rest in an account you control and you pay them onward, you're a transmitter. "Facilitate without being in the flow" means facilitating the *coordination* of a payment that moves directly between parties (or nets on your own account as principal), never custody.
|
||||
|
||||
| Model | Mechanism | In the flow? |
|
||||
|---|---|---|
|
||||
| **A. Bookkeeper / peer-to-peer** | Platform records the payable and surfaces it; A→X money moves directly via their own arrangement. | No — cleanest, but makers chasing each other is friction that can poison collaboration. |
|
||||
| **B. Net through the existing fee ledger** | Component cost becomes a ledger entry: X's receivable → credit in X's fee-offset wallet; A's payable → charge on A's next invoice. Principal on both sides (two independent events) — the same structure that kept the referral wallet clean. | No — *for the netting / non-cashable portion.* **Recommended default.** |
|
||||
| **C. Connect direct payout** | Buyer pays A; platform routes component cost to X/Y connected accounts (destination charges/transfers); Stripe is the transmitter. | Yes — scoped facilitator re-entry. Cleanest experience; **B2B-first is the safest place to cross** (verified, KYC'd, mandate-on-file makers; small fraud surface). |
|
||||
|
||||
**Supplier choice with a carrot:** X and Y each choose **non-cashable wallet credit** (nets their own platform fees, keeps you out of the flow) or **Connect payout** (real money, you're scoped in-flow), with a **bonus for choosing wallet** — the "incentivize the clean rail" move. Caveat: wallet credit only helps a maker who *has fees to offset.* A **pure supplier** (supplies many kits, rarely sells) accrues trapped credit and needs Connect payout regardless of bonus. Plan for both; don't expect to keep every supplier non-cashable.
|
||||
|
||||
**Phasing:** Phase 1 = Model A/B (record + net, settle the remainder peer-to-peer), fully out of flow. Later/optional = Model C (Connect payout) for suppliers who can't absorb wallet credit — B2B-first because it's the safest crossing.
|
||||
|
||||
### C.3 Fee economics — referral fee on components, taxed once
|
||||
|
||||
X and Y's components carry a referral fee (supplier bears it, as in Curated-By) — but **tax each kit dollar once.** Worked example: kit retails at R; agreed wholesale $40 (X dice) + $30 (Y minis) = $70 COGS to A.
|
||||
|
||||
- X's $40 → 15% referral fee = $6 platform; X nets $34 (wallet+bonus or Connect).
|
||||
- Y's $30 → 15% = $4.50 platform; Y nets $25.50.
|
||||
- A pays the standard platform fee on **A's own margin (R − $70)**, *not* on the full R.
|
||||
|
||||
Charging A's full platform fee on R *and* referral fees on the components would double-tax the $70 (the same "don't stack to ~19%" trap as the referral discussion). All of it runs through the ledger off the order event — never through the consumer payment, since A is MoR for the retail sale.
|
||||
|
||||
### C.4 Where kits can be sold — the constraint is inventory integrity, not fees
|
||||
|
||||
The fee-and-settlement machinery works on *any* storefront (computed from the order webhook + the kit BOM in your network service), so a **Shopify maker can sell a kit, be MoR, and have settlement run on the `orders/create` webhook.** Shopify doesn't block the money model. What it can't give you is a **real-time cross-maker stock check at purchase** (you don't control its checkout), so finite-stock components have a sync-lag oversell window. Therefore:
|
||||
|
||||
- **Finite-stock multi-merchant kits → favor your Medusa storefront** (you control checkout: atomic component-availability gate + atomic order+payable creation).
|
||||
- **Made-to-order kits → storefront-agnostic, including Shopify** (no finite-stock race; kit lead time = max of component leads = pre-order semantics you already have). Dice/minis are frequently made-to-order, so this is the common case, not a corner.
|
||||
- **v1 scoping:** launch kits as a Medusa-seller feature (native BOM, real-time inventory, atomic settlement); optionally allow made-to-order kits for Shopify sellers; defer Shopify-seller *finite-stock* kits as the hard later extension.
|
||||
|
||||
### C.5 Wholesale bookkeeping — including volume tiers
|
||||
|
||||
Storing "X charges A $W/unit, tiered by volume" and computing settlement from units sold is **pure facilitation (out of flow)** — virtual-BOM/COGS with a cross-maker settlement twist. Two design details volume tiers force:
|
||||
|
||||
- **Retroactive vs. prospective tiers:** when X's price drops at unit 11, do units 1–10 reprice or only 11+? This is a *term A and X agree to*; the platform stores and applies it.
|
||||
- **Stateful settlement:** tiered pricing depends on cumulative units over a window, so settlement is a running total per supplier-agreement, not per-order-independent. Build the ledger for stateful supplier agreements from the start.
|
||||
|
||||
### C.6 Shipping & fulfillment — the decisive argument for assembly
|
||||
|
||||
A kit drop-shipped from N makers = **N shipments; shipping scales with maker count, not order** (≈3× for three makers). The premium lands somewhere, and every option hurts: on the **buyer** (visible combined shipping — a conversion killer, especially jarring on a "deal" kit), on **A** (absorbed into price — uncontrollable margin variance), or on **suppliers** (baked into wholesale — inflates COGS, shrinks A's margin). For low-AOV components, per-parcel shipping can rival the component cost. No free placement — it attacks the "kit is a deal" proposition directly.
|
||||
|
||||
Managing it, best to worst:
|
||||
|
||||
1. **Assemble-and-consolidate (default).** Components ship to A; A packs one box; one shipping cost. The stock variant of Model 1 — and it fixes **three** problems at once: single shipping cost, single-package buyer experience, and QC (A inspects components → resolves the "who owns a miscast mini" liability). Cost: A handles inventory + assembly (a micro-fulfillment role) — but that *is* the value A adds, and it justifies A's margin.
|
||||
2. **3PL / hub consolidation.** A partner consolidates; cleaner for A but adds a third party and cost — overkill at indie-maker volumes. Defer.
|
||||
3. **Honest drop-ship.** "Ships in N packages," higher combined shipping disclosed up front. Reserve for when consolidation is genuinely impossible; accept the conversion hit and multi-package experience.
|
||||
4. **Shipping-smart kit construction (a lever regardless of model).** The platform surfaces estimated combined shipping *at kit-design time*, prefers same-region makers, and warns when a kit's combined shipping blows its value prop. Your network sees cross-maker shipping geography no single maker can — turning a hidden checkout cost into a visible design constraint.
|
||||
|
||||
**Consolidation vs. made-to-order is a real knot:**
|
||||
|
||||
| Components | Fulfillment | Result |
|
||||
|---|---|---|
|
||||
| In-stock | Consolidate | One box, fast, clean — **best case** |
|
||||
| Made-to-order | Consolidate | One box, but kit lead = max(component leads) + assembly hold; A runs a multi-supplier pre-order with an assembly stage |
|
||||
| Made-to-order | Drop-ship | N boxes, N arrival times, N shipping charges — **worst case; avoid unless unavoidable** |
|
||||
|
||||
**Through-line:** shipping doesn't argue against kits — it argues that the kit creator should be a real **assembler-principal**, not a thin aggregator of others' drop-shipments. A's justified value and margin *is* that A sources, settles, consolidates, QCs, and one-boxes a thing no single component maker offers. The clean money model (principal reseller) and the clean shipping model (assemble-and-consolidate) point at the **same role for A** — the signal the design is coherent.
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Storefront control & network participation (why walled gardens are out)
|
||||
|
||||
The gating property for network participation is **control of the three surfaces the network must touch**: the **storefront page** (to host the verified merchant referral collection), **checkout** (to attribute referrals and run clean agentic purchase), and the **buyer relationship** (to own identity/sovereignty). Merchant-controlled storefronts grant all three; walled-garden marketplaces deny all three *by design* — the intermediation of the buyer is the marketplace's business model (retail media, ads, the take on a captive relationship), so a marketplace that let an external network embed, attribute, and own the buyer would be dismantling its own asset. Participation therefore **degrades monotonically as a maker cedes control to a walled garden.**
|
||||
|
||||
**Layered on top is a value-system rule that is stricter than the technical limits: the network never routes buyers *into* a walled garden — not via Curated-By, not via the buyer feed, not via the agent feed.** Every such link would lend the community's trust to the captive-commerce model this enterprise is an alternative to. So walled-garden makers are **invitation targets, not network destinations** — even where a marketplace's API would *technically* permit ingesting their catalog, the value rule forecloses surfacing it as a buyable destination. (This supersedes an earlier draft that treated Etsy sellers as inbound "discovery receivers"; sending buyers to an Etsy listing is exactly the outward-pointing pattern now ruled out.)
|
||||
|
||||
### Participation matrix (ordered most-control → least)
|
||||
|
||||
Legend: ✓ supported · ◐ partial/limited · ✗ not supported.
|
||||
|
||||
| Provider | Catalog sync-in | Be a curator (host the collection) | Be a network destination (Curated-By / feed / agent) | Clean referral-fee attribution | Owns buyer (sovereignty) | Clean agentic checkout |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Your white-label storefront (Medusa)** | ✓ native | ✓ you build the page | ✓ controllable + value-OK | ✓ you own checkout | ✓ shared auth, maker owns relationship | ✓ you expose ACP endpoints |
|
||||
| **Self-hosted (WooCommerce, Saleor, Vendure, custom)** | ✓ open APIs/plugins | ✓ full page control | ✓ controllable + value-OK | ✓ controls own checkout | ✓ owns buyer | ✓ their build exposes ACP |
|
||||
| **Shopify** | ✓ Admin API + webhooks | ✓ theme app-extension app block | ✓ controllable + value-OK | ✓ cart attr → order webhook (any plan) | ✓ merchant owns customers | ✓ their processor (deep checkout = Plus) |
|
||||
| **BigCommerce** | ✓ Catalog/Admin API | ✓ open storefront (widgets/headless) | ✓ controllable + value-OK | ✓ controls checkout (Open Checkout) | ✓ owns buyer | ✓ their processor |
|
||||
| **Squarespace / Wix** | ◐ commerce APIs (read) | ◐ code-injection / Velo, no clean app-block model | ✓ controllable storefront, value-OK | ◐ checkout more closed — coupon/landing only | ✓ owns buyer | ◐ depends on platform support |
|
||||
| **Etsy** | ◐ Open API v3 (OAuth, commercial access) — *but unused as a destination by value rule* | ✗ can't modify the Etsy page | ✗ **value rule** — would route buyers into a walled garden; invitation target only | ✗ Etsy owns checkout (coupon-code at best; moot) | ✗ Etsy owns the buyer | ✗ Etsy's decision; routes into Etsy |
|
||||
| **eBay** | ◐ Sell/Trading APIs (read) — *unused, as above* | ✗ can't modify the listing page | ✗ value rule (walled garden); invitation target | ✗ eBay owns checkout | ✗ eBay owns the buyer | ✗ eBay's decision |
|
||||
| **Amazon Handmade / Amazon** | ✗ SP-API is gated/restricted for this | ✗ zero storefront control | ✗ value rule + Amazon owns everything; invitation target | ✗ Amazon owns checkout | ✗ Amazon owns the buyer (most completely) | ✗ Amazon's own agents (Rufus / "Buy for Me") treat you as a competitor |
|
||||
|
||||
The table's shape *is* the thesis: **controllable storefronts are ✓ across; walled gardens are ✗ across.** That's not a coverage gap — it's a restatement of who the network is *for* (makers who own their commerce, or will) and what it's an alternative *to* (captive marketplaces).
|
||||
|
||||
### The three-surfaces gate, explained
|
||||
|
||||
- **Storefront-page control → be a curator.** Hosting the verified merchant referral collection requires injecting a UI block into the maker's own page. Controllable storefronts allow it (native, app block, widget, code); walled gardens don't expose the page.
|
||||
- **Checkout control → attribution + agentic checkout.** Billing a referral and running a clean agent purchase require instrumenting the cart/checkout. Controllable storefronts let you (cart attributes, your own processor); walled gardens own checkout and strip your attribution.
|
||||
- **Buyer-relationship control → sovereignty/identity.** Owning the buyer (the basis of the cross-maker identity layer) requires the maker not be intermediated. Walled gardens own the buyer by definition.
|
||||
|
||||
### Walled gardens are who you're an alternative to — not a gap to cover
|
||||
|
||||
A maker deep in Amazon Handmade who can't participate isn't missing coverage — they're the person the pitch is *aimed at*, who hasn't left the garden yet. The model not working *inside* the walls is consistent with the model being the way *out* of them. So the right move toward walled-garden makers is **invitation, not integration**: "I'd feature your work in the network the moment you own your commerce." The *absence* of a link is the recruiting signal — it makes membership (on a controllable storefront) the price of inclusion rather than subsidizing captivity with free audience.
|
||||
|
||||
### Notes on the marketplaces
|
||||
|
||||
- **Etsy is the most permissive walled garden** — Open API v3 (OAuth 2.0, `listings_r`/`transactions_r`, commercial access subject to review) can read listings and receipts, it natively models made-to-order (`readiness_state`), and it even runs an affiliate program that blesses inbound links. None of that changes the conclusion: the value rule forecloses using Etsy as a buyable destination, so its API permissiveness is moot for participation. (A maker on *both* Etsy and a controllable storefront participates via the controllable storefront; their Etsy presence is simply ignored.)
|
||||
- **Amazon Handmade is the most walled and the most hostile** — restricted/gated API access, zero storefront control, the most complete buyer ownership, and Amazon's own agentic-commerce ambitions (Rufus, "Buy for Me") mean it would view a verified-supply network as a competitor to route around, not a partner to ingest. Your assumption is correct: this model can't run on Amazon Handmade, and shouldn't try.
|
||||
|
||||
### The verification inversion (as a recruiting angle)
|
||||
|
||||
Walled gardens are the polluted marketplaces this strategy reacts against, so you verify makers **independently of any marketplace's compromised "handmade" badge.** That inversion is now a *recruiting* message rather than a participation tier: "we'd vouch for your work — verified independent of Etsy's badge — the moment you own your commerce and can be a real member." You become the trust layer the marketplaces stopped being, *for the makers who leave them.*
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Products — Bulk CSV Import / Export</title>
|
||||
|
||||
<!-- Wiggleverse design system: tokens + webfonts (one entry point @imports the rest) -->
|
||||
<link rel="stylesheet" href="_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css" />
|
||||
|
||||
<style>
|
||||
/* ---- paper-canvas + status tokens (status hues defined in oklch-adjacent
|
||||
muted tones that harmonize with the violet/ink palette; never color-only) ---- */
|
||||
:root {
|
||||
--paper-line: rgba(60, 47, 122, .12);
|
||||
--paper-line-strong: rgba(60, 47, 122, .22);
|
||||
--violet-tint: rgba(124, 111, 224, .10);
|
||||
--violet-line: rgba(124, 111, 224, .28);
|
||||
--add-tint: rgba(31, 138, 91, .10);
|
||||
|
||||
--st-add: #1F8A5B; --st-add-tint: rgba(31,138,91,.10); --st-add-line: rgba(31,138,91,.30);
|
||||
--st-update: #B5830F; --st-update-tint: rgba(181,131,15,.12); --st-update-line: rgba(181,131,15,.32);
|
||||
--st-error: #C2513E; --st-error-tint: rgba(194,81,62,.10); --st-error-line: rgba(194,81,62,.30);
|
||||
--st-muted: #6A5E94; --st-muted-tint: rgba(106,94,148,.10); --st-muted-line: rgba(106,94,148,.24);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--surface-paper);
|
||||
color: var(--text-on-light);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
::selection { background: var(--wv-lilac-32); }
|
||||
|
||||
@keyframes ie-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes ie-toast { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
|
||||
/* hover affordances */
|
||||
.ie-hrow:hover { background: var(--violet-tint); }
|
||||
.ie-samplefile:hover { border-color: var(--violet-line) !important; transform: translateY(-1px); }
|
||||
.ie-prow:hover { background: var(--violet-tint) !important; }
|
||||
.ie-stat[data-active="0"]:hover { transform: translateY(-1px); border-color: var(--paper-line-strong) !important; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible {
|
||||
outline: 3px solid var(--focus-ring); outline-offset: 2px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* density */
|
||||
[data-density="compact"] td { padding-top: .55rem !important; padding-bottom: .55rem !important; }
|
||||
[data-density="compact"] .ie-prow { padding-top: .6rem !important; padding-bottom: .6rem !important; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
|
||||
}
|
||||
|
||||
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ie-root"></div>
|
||||
|
||||
<script>
|
||||
// Benign, non-actionable browser notification emitted by ResizeObserver-based
|
||||
// layout (the Tweaks panel). Swallow only this exact message.
|
||||
window.addEventListener('error', function (e) {
|
||||
if (e && e.message && e.message.indexOf('ResizeObserver loop') !== -1) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- React + Babel (pinned) -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Wiggleverse component bundle (plain compiled JS — needs window.React) -->
|
||||
<script src="_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_bundle.js"></script>
|
||||
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- app (order: data → ui → shell/products/importflow → tweaks → app) -->
|
||||
<script type="text/babel" src="app/data.jsx"></script>
|
||||
<script type="text/babel" src="app/ui.jsx"></script>
|
||||
<script type="text/babel" src="app/shell.jsx"></script>
|
||||
<script type="text/babel" src="app/products.jsx"></script>
|
||||
<script type="text/babel" src="app/importflow.jsx"></script>
|
||||
<script type="text/babel" src="app/tweaks-panel.jsx"></script>
|
||||
<script type="text/babel" src="app/app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+378
@@ -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": "<BrandLockup> 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": "<BrandLockup> 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": "<BuildCard> 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": "<Button> doesn't accept that prop. Declared props: children, variant, href, onLight, disabled, type, onClick."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|ghost)$/]",
|
||||
"message": "<Button> variant must be one of 'primary' | 'ghost'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
|
||||
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Callout'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|key|ref|className|style|children)$/]",
|
||||
"message": "<Callout> doesn't accept that prop. Declared props: children, onLight."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Eyebrow'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|as|key|ref|className|style|children)$/]",
|
||||
"message": "<Eyebrow> doesn't accept that prop. Declared props: children, onLight, as."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Notice'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|key|ref|className|style|children)$/]",
|
||||
"message": "<Notice> doesn't accept that prop. Declared props: children."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='PathCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|children|href|first|key|ref|className|style|children)$/]",
|
||||
"message": "<PathCard> doesn't accept that prop. Declared props: icon, title, children, href, first."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='FooterColumn'] > JSXAttribute > JSXIdentifier[name!=/^(?:heading|links|key|ref|className|style|children)$/]",
|
||||
"message": "<FooterColumn> doesn't accept that prop. Declared props: heading, links."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavLink'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|href|cta|key|ref|className|style|children)$/]",
|
||||
"message": "<NavLink> doesn't accept that prop. Declared props: label, href, cta."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|tone|onLight|as|key|ref|className|style|children)$/]",
|
||||
"message": "<Soul> doesn't accept that prop. Declared props: children, size, tone, onLight, as."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<Soul> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:gold|plain|lilac)$/]",
|
||||
"message": "<Soul> tone must be one of 'gold' | 'plain' | 'lilac'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|onLight|key|ref|className|style|children)$/]",
|
||||
"message": "<Tag> doesn't accept that prop. Declared props: children, variant, onLight."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:live|soon)$/]",
|
||||
"message": "<Tag> variant must be one of 'live' | 'soon'."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {
|
||||
"BrandLockup": {
|
||||
"replaces": []
|
||||
},
|
||||
"BuildCard": {
|
||||
"replaces": []
|
||||
},
|
||||
"Button": {
|
||||
"replaces": []
|
||||
},
|
||||
"Callout": {
|
||||
"replaces": []
|
||||
},
|
||||
"Eyebrow": {
|
||||
"replaces": []
|
||||
},
|
||||
"Notice": {
|
||||
"replaces": []
|
||||
},
|
||||
"PathCard": {
|
||||
"replaces": []
|
||||
},
|
||||
"FooterColumn": {
|
||||
"replaces": []
|
||||
},
|
||||
"NavLink": {
|
||||
"replaces": []
|
||||
},
|
||||
"Soul": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tag": {
|
||||
"replaces": []
|
||||
}
|
||||
},
|
||||
"tokens": [
|
||||
"--accent",
|
||||
"--accent-on-light",
|
||||
"--border-card",
|
||||
"--border-card-w",
|
||||
"--border-hair",
|
||||
"--border-soft",
|
||||
"--border-strong",
|
||||
"--btn-border-w",
|
||||
"--cta",
|
||||
"--cta-hover",
|
||||
"--cta-text",
|
||||
"--dur-fast",
|
||||
"--dur-mid",
|
||||
"--ease",
|
||||
"--focus-ring",
|
||||
"--font-body",
|
||||
"--font-display",
|
||||
"--font-soul",
|
||||
"--glass-blur",
|
||||
"--glass-sky",
|
||||
"--leading-body",
|
||||
"--leading-tight",
|
||||
"--lift-1",
|
||||
"--lift-3",
|
||||
"--measure-lead",
|
||||
"--measure-prose",
|
||||
"--page-hero-pad",
|
||||
"--radius-card",
|
||||
"--radius-mark",
|
||||
"--radius-panel",
|
||||
"--radius-pill",
|
||||
"--radius-sm",
|
||||
"--section-pad",
|
||||
"--shadow-none",
|
||||
"--shadow-soft",
|
||||
"--space-0",
|
||||
"--space-1",
|
||||
"--space-10",
|
||||
"--space-12",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-5",
|
||||
"--space-6",
|
||||
"--space-8",
|
||||
"--surface-card-light",
|
||||
"--surface-footer",
|
||||
"--surface-paper",
|
||||
"--surface-raised",
|
||||
"--surface-raised-hi",
|
||||
"--surface-sky",
|
||||
"--text-body",
|
||||
"--text-eyebrow",
|
||||
"--text-fine",
|
||||
"--text-h1",
|
||||
"--text-h2",
|
||||
"--text-h3",
|
||||
"--text-lead",
|
||||
"--text-on-dark",
|
||||
"--text-on-dark-mute",
|
||||
"--text-on-dark-soft",
|
||||
"--text-on-light",
|
||||
"--text-on-light-soft",
|
||||
"--text-small",
|
||||
"--text-soul",
|
||||
"--text-tag",
|
||||
"--tracking-display",
|
||||
"--tracking-eyebrow",
|
||||
"--tracking-notice",
|
||||
"--tracking-tag",
|
||||
"--weight-bold",
|
||||
"--weight-medium",
|
||||
"--weight-regular",
|
||||
"--weight-semibold",
|
||||
"--weight-soul",
|
||||
"--wrap-gutter",
|
||||
"--wrap-max",
|
||||
"--wv-font-body",
|
||||
"--wv-font-display",
|
||||
"--wv-font-human",
|
||||
"--wv-gold",
|
||||
"--wv-gold-28",
|
||||
"--wv-gold-40",
|
||||
"--wv-gold-hi",
|
||||
"--wv-gold-ink",
|
||||
"--wv-gold-ink-soft",
|
||||
"--wv-indigo",
|
||||
"--wv-indigo-2",
|
||||
"--wv-ink",
|
||||
"--wv-lilac",
|
||||
"--wv-lilac-08",
|
||||
"--wv-lilac-12",
|
||||
"--wv-lilac-16",
|
||||
"--wv-lilac-18",
|
||||
"--wv-lilac-32",
|
||||
"--wv-midnight",
|
||||
"--wv-night",
|
||||
"--wv-paper",
|
||||
"--wv-starlight",
|
||||
"--wv-starlight-55",
|
||||
"--wv-starlight-60",
|
||||
"--wv-starlight-78",
|
||||
"--wv-starlight-85",
|
||||
"--wv-violet"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--wv-midnight": "color",
|
||||
"--wv-indigo": "color",
|
||||
"--wv-indigo-2": "color",
|
||||
"--wv-lilac": "color",
|
||||
"--wv-violet": "color",
|
||||
"--wv-gold": "color",
|
||||
"--wv-gold-hi": "color",
|
||||
"--wv-starlight": "color",
|
||||
"--wv-paper": "color",
|
||||
"--wv-ink": "color",
|
||||
"--wv-night": "color",
|
||||
"--wv-gold-ink": "color",
|
||||
"--wv-gold-ink-soft": "color",
|
||||
"--wv-lilac-08": "color",
|
||||
"--wv-lilac-12": "color",
|
||||
"--wv-lilac-16": "color",
|
||||
"--wv-lilac-18": "color",
|
||||
"--wv-lilac-32": "color",
|
||||
"--wv-gold-28": "color",
|
||||
"--wv-gold-40": "color",
|
||||
"--wv-starlight-85": "color",
|
||||
"--wv-starlight-78": "color",
|
||||
"--wv-starlight-60": "color",
|
||||
"--wv-starlight-55": "color",
|
||||
"--surface-sky": "color",
|
||||
"--surface-raised": "color",
|
||||
"--surface-raised-hi": "color",
|
||||
"--surface-paper": "color",
|
||||
"--surface-card-light": "color",
|
||||
"--surface-footer": "color",
|
||||
"--text-on-dark": "font",
|
||||
"--text-on-dark-soft": "font",
|
||||
"--text-on-dark-mute": "font",
|
||||
"--text-on-light": "font",
|
||||
"--text-on-light-soft": "font",
|
||||
"--accent": "color",
|
||||
"--accent-on-light": "color",
|
||||
"--cta": "color",
|
||||
"--cta-hover": "color",
|
||||
"--cta-text": "font",
|
||||
"--border-soft": "color",
|
||||
"--border-card": "color",
|
||||
"--border-strong": "color",
|
||||
"--focus-ring": "color",
|
||||
"--wv-font-display": "font",
|
||||
"--wv-font-body": "font",
|
||||
"--wv-font-human": "font",
|
||||
"--font-display": "font",
|
||||
"--font-body": "font",
|
||||
"--font-soul": "font",
|
||||
"--weight-regular": "font",
|
||||
"--weight-medium": "font",
|
||||
"--weight-semibold": "font",
|
||||
"--weight-bold": "font",
|
||||
"--weight-soul": "font",
|
||||
"--text-h1": "font",
|
||||
"--text-h2": "font",
|
||||
"--text-h3": "font",
|
||||
"--text-lead": "font",
|
||||
"--text-soul": "font",
|
||||
"--text-body": "font",
|
||||
"--text-small": "font",
|
||||
"--text-fine": "font",
|
||||
"--text-eyebrow": "font",
|
||||
"--text-tag": "font",
|
||||
"--leading-tight": "font",
|
||||
"--leading-body": "font",
|
||||
"--tracking-display": "font",
|
||||
"--tracking-eyebrow": "font",
|
||||
"--tracking-tag": "font",
|
||||
"--tracking-notice": "font",
|
||||
"--measure-lead": "spacing",
|
||||
"--measure-prose": "spacing",
|
||||
"--space-0": "spacing",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-5": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--space-10": "spacing",
|
||||
"--space-12": "spacing",
|
||||
"--section-pad": "spacing",
|
||||
"--page-hero-pad": "spacing",
|
||||
"--wrap-max": "spacing",
|
||||
"--wrap-gutter": "spacing",
|
||||
"--radius-card": "radius",
|
||||
"--radius-panel": "radius",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-pill": "radius",
|
||||
"--radius-mark": "radius",
|
||||
"--border-hair": "spacing",
|
||||
"--border-card-w": "spacing",
|
||||
"--btn-border-w": "spacing",
|
||||
"--shadow-none": "shadow",
|
||||
"--shadow-soft": "shadow",
|
||||
"--lift-1": "other",
|
||||
"--lift-3": "other",
|
||||
"--glass-sky": "color",
|
||||
"--glass-blur": "spacing",
|
||||
"--ease": "other",
|
||||
"--dur-fast": "other",
|
||||
"--dur-mid": "other"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Fraunces",
|
||||
"Inter",
|
||||
"Space Grotesk"
|
||||
]
|
||||
}
|
||||
}
|
||||
+959
@@ -0,0 +1,959 @@
|
||||
/* @ds-bundle: {"format":3,"namespace":"WiggleverseDesignSystem_94cd80","components":[{"name":"BrandLockup","sourcePath":"components/brand/BrandLockup.jsx"},{"name":"BuildCard","sourcePath":"components/cards/BuildCard.jsx"},{"name":"PathCard","sourcePath":"components/cards/PathCard.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Callout","sourcePath":"components/core/Callout.jsx"},{"name":"Eyebrow","sourcePath":"components/core/Eyebrow.jsx"},{"name":"Notice","sourcePath":"components/core/Notice.jsx"},{"name":"Soul","sourcePath":"components/core/Soul.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"SiteFooter","sourcePath":"components/navigation/SiteFooter.jsx"},{"name":"SiteHeader","sourcePath":"components/navigation/SiteHeader.jsx"}],"sourceHashes":{"components/brand/BrandLockup.jsx":"70ef76a92b84","components/cards/BuildCard.jsx":"f4588d77bd79","components/cards/PathCard.jsx":"f8779016b751","components/core/Button.jsx":"58601f356762","components/core/Callout.jsx":"4347df19558c","components/core/Eyebrow.jsx":"38c0374f060f","components/core/Notice.jsx":"471101cf1c07","components/core/Soul.jsx":"d1b2ddeea071","components/core/Tag.jsx":"192ca2a7e57d","components/navigation/SiteFooter.jsx":"61fbdd9cf145","components/navigation/SiteHeader.jsx":"6541bfe8e43a","ui_kits/wiggleverse-www/app.jsx":"f0cea1954d57"},"inlinedExternals":[],"unexposedExports":[]} */
|
||||
|
||||
(() => {
|
||||
|
||||
const __ds_ns = (window.WiggleverseDesignSystem_94cd80 = window.WiggleverseDesignSystem_94cd80 || {});
|
||||
|
||||
const __ds_scope = {};
|
||||
|
||||
(__ds_ns.__errors = __ds_ns.__errors || []);
|
||||
|
||||
// components/brand/BrandLockup.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse BrandLockup — the mark + "Wiggleverse" wordmark, linked home.
|
||||
* 'primary' uses the gradient mark on dark; 'footer' uses the mono-gold mark;
|
||||
* 'onLight' uses the ink mark for paper. Wordmark is Space Grotesk 700, never italic.
|
||||
*/
|
||||
function BrandLockup({
|
||||
variant = 'primary',
|
||||
size = 34,
|
||||
href = '/',
|
||||
showWordmark = true,
|
||||
assetBase = '/assets',
|
||||
...rest
|
||||
}) {
|
||||
const marks = {
|
||||
primary: 'wiggleverse-mark.svg',
|
||||
footer: 'mark-mono-gold.svg',
|
||||
onLight: 'mark-on-light.svg'
|
||||
};
|
||||
const wordColor = variant === 'onLight' ? 'var(--wv-ink)' : 'var(--wv-starlight)';
|
||||
// Resolve asset relative to the given base so the lockup works from any depth.
|
||||
const src = `${assetBase}/${marks[variant] || marks.primary}`;
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
"aria-label": "Wiggleverse home",
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '.55rem',
|
||||
textDecoration: 'none'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("img", {
|
||||
src: src,
|
||||
alt: "",
|
||||
width: size,
|
||||
height: size,
|
||||
style: {
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}), showWordmark && /*#__PURE__*/React.createElement("span", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: `${Math.round(size * 0.0338 * 10) / 10}rem`,
|
||||
letterSpacing: 'var(--tracking-display)',
|
||||
color: wordColor
|
||||
}
|
||||
}, "Wiggleverse"));
|
||||
}
|
||||
Object.assign(__ds_scope, { BrandLockup });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/brand/BrandLockup.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/cards/BuildCard.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse BuildCard — a portfolio item on a paper section. White card with
|
||||
* a violet hairline, a status Tag, title, and copy. Built for light backgrounds.
|
||||
*/
|
||||
function BuildCard({
|
||||
tag,
|
||||
title,
|
||||
children,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("div", _extends({
|
||||
style: {
|
||||
padding: '1.4rem',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid rgba(124,111,224,.3)',
|
||||
background: 'var(--surface-card-light)'
|
||||
}
|
||||
}, rest), tag && /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
marginBottom: '.6rem'
|
||||
}
|
||||
}, tag), /*#__PURE__*/React.createElement("h3", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: 'var(--text-h3)',
|
||||
margin: '0 0 .35rem',
|
||||
color: 'var(--wv-ink)',
|
||||
letterSpacing: 'var(--tracking-display)'
|
||||
}
|
||||
}, title), /*#__PURE__*/React.createElement("p", {
|
||||
style: {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-small)',
|
||||
lineHeight: 1.55,
|
||||
color: 'var(--text-on-light-soft)'
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { BuildCard });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/BuildCard.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/cards/PathCard.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse PathCard — the audience-router doorway. A raised indigo card with
|
||||
* a glyph icon, title, and one line of copy. Hover lifts -3px and the border
|
||||
* warms to lilac. 'first' gives it a gold border (the recommended door).
|
||||
*/
|
||||
function PathCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
href = '#',
|
||||
first = false,
|
||||
...rest
|
||||
}) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
onMouseEnter: () => setHover(true),
|
||||
onMouseLeave: () => setHover(false),
|
||||
style: {
|
||||
display: 'block',
|
||||
padding: '1.2rem',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: hover ? 'var(--surface-raised-hi)' : 'var(--surface-raised)',
|
||||
border: `1px solid ${first ? 'var(--wv-gold)' : hover ? 'var(--wv-lilac)' : 'var(--border-card)'}`,
|
||||
color: 'var(--wv-starlight)',
|
||||
textDecoration: 'none',
|
||||
transform: hover ? 'var(--lift-3)' : 'none',
|
||||
transition: 'transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease)'
|
||||
}
|
||||
}, rest), icon && /*#__PURE__*/React.createElement("div", {
|
||||
"aria-hidden": "true",
|
||||
style: {
|
||||
fontSize: '1.5rem'
|
||||
}
|
||||
}, icon), /*#__PURE__*/React.createElement("h3", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: 'var(--text-h3)',
|
||||
margin: '.5rem 0 .25rem',
|
||||
color: 'var(--wv-starlight)',
|
||||
letterSpacing: 'var(--tracking-display)'
|
||||
}
|
||||
}, title), /*#__PURE__*/React.createElement("p", {
|
||||
style: {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-fine)',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--wv-starlight-78)'
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { PathCard });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/PathCard.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Button.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Button — pill action in the machine register (Space Grotesk 500).
|
||||
* Primary = gold horizon CTA; ghost = lilac-outlined. Hover lifts -1px (never a shadow).
|
||||
*/
|
||||
function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
href,
|
||||
onLight = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
onClick,
|
||||
...rest
|
||||
}) {
|
||||
const base = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '.4em',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: 'var(--text-body)',
|
||||
lineHeight: 1,
|
||||
padding: '.7rem 1.25rem',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: 'var(--btn-border-w) solid transparent',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
textDecoration: 'none',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: 'transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)'
|
||||
};
|
||||
const variants = {
|
||||
primary: {
|
||||
background: 'var(--cta)',
|
||||
color: 'var(--cta-text)'
|
||||
},
|
||||
ghost: {
|
||||
background: 'transparent',
|
||||
borderColor: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
|
||||
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
|
||||
}
|
||||
};
|
||||
const style = {
|
||||
...base,
|
||||
...(variants[variant] || variants.primary)
|
||||
};
|
||||
const handleEnter = e => {
|
||||
if (!disabled) e.currentTarget.style.transform = 'var(--lift-1)';
|
||||
};
|
||||
const handleLeave = e => {
|
||||
e.currentTarget.style.transform = 'none';
|
||||
};
|
||||
const common = {
|
||||
style,
|
||||
onMouseEnter: handleEnter,
|
||||
onMouseLeave: handleLeave,
|
||||
...rest
|
||||
};
|
||||
if (href && !disabled) {
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
onClick: onClick
|
||||
}, common), children);
|
||||
}
|
||||
return /*#__PURE__*/React.createElement("button", _extends({
|
||||
type: type,
|
||||
disabled: disabled,
|
||||
onClick: onClick
|
||||
}, common), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Button });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Button.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Eyebrow.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Eyebrow — uppercase, letter-spaced kicker in the machine register
|
||||
* that sits above a section heading. Lilac on dark, violet on paper.
|
||||
*/
|
||||
function Eyebrow({
|
||||
children,
|
||||
onLight = false,
|
||||
as = 'p',
|
||||
...rest
|
||||
}) {
|
||||
const Tag = as;
|
||||
return /*#__PURE__*/React.createElement(Tag, _extends({
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: 'var(--text-eyebrow)',
|
||||
letterSpacing: 'var(--tracking-eyebrow)',
|
||||
textTransform: 'uppercase',
|
||||
color: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
|
||||
margin: '0 0 1rem'
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Eyebrow });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Eyebrow.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Notice.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Notice — a hollow gold pill set inline beside a heading to mark
|
||||
* roadmap status ("In build", "Coming"). Quieter than a filled Tag.
|
||||
*/
|
||||
function Notice({
|
||||
children,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("span", _extends({
|
||||
style: {
|
||||
display: 'inline-block',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: '.82rem',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
letterSpacing: 'var(--tracking-notice)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--wv-gold)',
|
||||
border: '1px solid var(--wv-gold-40)',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: '.25rem .8rem',
|
||||
verticalAlign: 'middle'
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Notice });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Notice.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Soul.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Soul — the human register. Fraunces ITALIC ONLY, used sparingly
|
||||
* for the lines that carry conscience (taglines, pull-quotes). Gold by default
|
||||
* (hero/page-hero) or starlight/ink when 'plain'.
|
||||
*/
|
||||
function Soul({
|
||||
children,
|
||||
size = 'md',
|
||||
tone = 'gold',
|
||||
onLight = false,
|
||||
as = 'span',
|
||||
...rest
|
||||
}) {
|
||||
const Tag = as;
|
||||
const sizes = {
|
||||
sm: '1.1rem',
|
||||
md: 'var(--text-soul)',
|
||||
/* clamp(1.2rem, 2.4vw, 1.7rem) */
|
||||
lg: 'clamp(1.4rem, 3vw, 2rem)'
|
||||
};
|
||||
const tones = {
|
||||
gold: 'var(--wv-gold)',
|
||||
plain: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)',
|
||||
lilac: 'var(--wv-lilac)'
|
||||
};
|
||||
return /*#__PURE__*/React.createElement(Tag, _extends({
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-human)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 'var(--weight-soul)',
|
||||
fontSize: sizes[size] || sizes.md,
|
||||
lineHeight: 1.3,
|
||||
color: tones[tone] || tones.gold
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Soul });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Soul.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Callout.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Callout — a gold left-rule framing a single conscience line.
|
||||
* No box, no fill: just a 3px gold border and a Fraunces italic quote.
|
||||
*/
|
||||
function Callout({
|
||||
children,
|
||||
onLight = false,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("div", _extends({
|
||||
style: {
|
||||
borderLeft: '3px solid var(--wv-gold)',
|
||||
padding: '.4rem 0 .4rem 1.1rem',
|
||||
margin: '1.5rem 0'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
|
||||
size: "lg",
|
||||
tone: "plain",
|
||||
onLight: onLight,
|
||||
as: "p",
|
||||
style: {
|
||||
margin: 0
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { Callout });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Callout.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Tag.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Tag — small filled pill marking a product's status on cards.
|
||||
* 'live' = lilac tint (first product); 'soon' = gold tint (coming).
|
||||
*/
|
||||
function Tag({
|
||||
children,
|
||||
variant = 'live',
|
||||
onLight = false,
|
||||
...rest
|
||||
}) {
|
||||
const base = {
|
||||
display: 'inline-block',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: 'var(--text-tag)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
letterSpacing: 'var(--tracking-tag)',
|
||||
textTransform: 'uppercase',
|
||||
padding: '.15rem .55rem',
|
||||
borderRadius: 'var(--radius-pill)'
|
||||
};
|
||||
const variants = {
|
||||
live: {
|
||||
background: 'var(--wv-lilac-16)',
|
||||
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
|
||||
},
|
||||
soon: {
|
||||
background: 'var(--wv-gold-28)',
|
||||
color: 'var(--wv-gold-ink-soft)'
|
||||
}
|
||||
};
|
||||
return /*#__PURE__*/React.createElement("span", _extends({
|
||||
style: {
|
||||
...base,
|
||||
...(variants[variant] || variants.live)
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Tag });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Tag.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/navigation/SiteFooter.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
const DEFAULT_COLUMNS = [{
|
||||
heading: 'Explore',
|
||||
links: [{
|
||||
label: 'Build',
|
||||
href: '/building/'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '/partner/'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '/learn/'
|
||||
}]
|
||||
}, {
|
||||
heading: 'The org',
|
||||
links: [{
|
||||
label: 'About',
|
||||
href: '/about/'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '/give/'
|
||||
}, {
|
||||
label: 'Finances',
|
||||
href: '/finances/'
|
||||
}, {
|
||||
label: 'Code of Conduct',
|
||||
href: '/code-of-conduct/'
|
||||
}]
|
||||
}];
|
||||
const DEFAULT_LEGAL = "Wiggleverse.org, Inc. — a Washington 501(c)(3) nonprofit creating art and software that supports humanity on the path to world peace. Dedicated to Aaron Swartz, who believed knowledge belongs to everyone.";
|
||||
|
||||
/**
|
||||
* Wiggleverse SiteFooter — deepest ground (night). Mono-gold lockup, a lilac
|
||||
* Soul tagline, link columns, and the dedication legal line.
|
||||
*/
|
||||
function SiteFooter({
|
||||
columns = DEFAULT_COLUMNS,
|
||||
tagline = 'We are verbs, not nouns.',
|
||||
legal = DEFAULT_LEGAL,
|
||||
assetBase = '/assets',
|
||||
...rest
|
||||
}) {
|
||||
const wrap = {
|
||||
width: 'min(var(--wrap-max), 92vw)',
|
||||
margin: '0 auto'
|
||||
};
|
||||
return /*#__PURE__*/React.createElement("footer", _extends({
|
||||
style: {
|
||||
background: 'var(--surface-footer)',
|
||||
borderTop: '1px solid var(--border-soft)',
|
||||
padding: '3rem 0 2.5rem',
|
||||
fontSize: '.92rem'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
...wrap,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2rem 3rem',
|
||||
justifyContent: 'space-between'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
maxWidth: '22rem'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
|
||||
variant: "footer",
|
||||
size: 30,
|
||||
assetBase: assetBase
|
||||
}), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
|
||||
size: "sm",
|
||||
tone: "lilac",
|
||||
as: "span",
|
||||
style: {
|
||||
display: 'block',
|
||||
marginTop: '.6rem'
|
||||
}
|
||||
}, tagline)), columns.map(col => /*#__PURE__*/React.createElement("div", {
|
||||
key: col.heading
|
||||
}, /*#__PURE__*/React.createElement("h4", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: '.8rem',
|
||||
letterSpacing: '.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--wv-lilac)',
|
||||
margin: '0 0 .7rem'
|
||||
}
|
||||
}, col.heading), /*#__PURE__*/React.createElement("ul", {
|
||||
style: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}
|
||||
}, col.links.map(l => /*#__PURE__*/React.createElement("li", {
|
||||
key: l.href,
|
||||
style: {
|
||||
marginBottom: '.45rem'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("a", {
|
||||
href: l.href,
|
||||
style: {
|
||||
color: 'var(--wv-starlight-85)',
|
||||
textDecoration: 'none'
|
||||
}
|
||||
}, l.label))))))), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
...wrap,
|
||||
marginTop: '2.2rem',
|
||||
paddingTop: '1.3rem',
|
||||
borderTop: '1px solid var(--wv-lilac-12)',
|
||||
color: 'var(--wv-starlight-60)',
|
||||
fontSize: '.85rem'
|
||||
}
|
||||
}, legal));
|
||||
}
|
||||
Object.assign(__ds_scope, { SiteFooter });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteFooter.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/navigation/SiteHeader.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
const DEFAULT_LINKS = [{
|
||||
label: 'About',
|
||||
href: '/about/'
|
||||
}, {
|
||||
label: 'Build',
|
||||
href: '/building/'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '/partner/'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '/learn/'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '/give/'
|
||||
}];
|
||||
|
||||
/**
|
||||
* Wiggleverse SiteHeader — sticky glass nav: brand lockup + machine-register
|
||||
* links. Translucent midnight with a blur and a lilac hairline underneath.
|
||||
*/
|
||||
function SiteHeader({
|
||||
links = DEFAULT_LINKS,
|
||||
current,
|
||||
assetBase = '/assets',
|
||||
homeHref = '/',
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("header", _extends({
|
||||
style: {
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
background: 'var(--glass-sky)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderBottom: '1px solid var(--border-soft)'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
width: 'min(var(--wrap-max), 92vw)',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '.7rem 0'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
|
||||
variant: "primary",
|
||||
assetBase: assetBase,
|
||||
href: homeHref
|
||||
}), /*#__PURE__*/React.createElement("nav", null, /*#__PURE__*/React.createElement("ul", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.2rem',
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}
|
||||
}, links.map(l => {
|
||||
const isCurrent = current === l.href || current === l.label;
|
||||
return /*#__PURE__*/React.createElement("li", {
|
||||
key: l.href
|
||||
}, /*#__PURE__*/React.createElement("a", {
|
||||
href: l.href,
|
||||
"aria-current": isCurrent ? 'page' : undefined,
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: '.95rem',
|
||||
color: l.cta ? 'var(--wv-gold)' : 'var(--wv-starlight)',
|
||||
textDecoration: isCurrent ? 'underline' : 'none',
|
||||
textUnderlineOffset: '4px',
|
||||
textDecorationColor: 'var(--wv-lilac)'
|
||||
}
|
||||
}, l.label));
|
||||
})))));
|
||||
}
|
||||
Object.assign(__ds_scope, { SiteHeader });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteHeader.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// ui_kits/wiggleverse-www/app.jsx
|
||||
try { (() => {
|
||||
/* Wiggleverse marketing-site recreation. Composes the DS components and routes
|
||||
between screens by intercepting in-app anchor clicks. */
|
||||
const {
|
||||
Button,
|
||||
Tag,
|
||||
Notice,
|
||||
Eyebrow,
|
||||
Soul,
|
||||
Callout,
|
||||
PathCard,
|
||||
BuildCard,
|
||||
SiteHeader,
|
||||
SiteFooter
|
||||
} = window.WiggleverseDesignSystem_94cd80;
|
||||
const ASSET_BASE = '../../assets';
|
||||
const NAV = [{
|
||||
label: 'About',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Build',
|
||||
href: '#/ecomm'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '#/finances',
|
||||
cta: true
|
||||
}];
|
||||
const FOOTER_COLS = [{
|
||||
heading: 'Explore',
|
||||
links: [{
|
||||
label: 'Build',
|
||||
href: '#/ecomm'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '#/finances'
|
||||
}]
|
||||
}, {
|
||||
heading: 'The org',
|
||||
links: [{
|
||||
label: 'About',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Finances',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Code of Conduct',
|
||||
href: '#/about'
|
||||
}]
|
||||
}];
|
||||
|
||||
/* ---------- Home ---------- */
|
||||
function HomeScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__sky",
|
||||
"aria-hidden": "true"
|
||||
}, /*#__PURE__*/React.createElement("img", {
|
||||
className: "hero__mark",
|
||||
src: ASSET_BASE + '/wiggleverse-mark.svg',
|
||||
alt: ""
|
||||
})), /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__inner"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "A 501(c)(3) nonprofit \xB7 art & software for a world at peace"), /*#__PURE__*/React.createElement("h1", null, "Ethical alternatives to the software that connects you to other humans."), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "span",
|
||||
className: "hero__soul"
|
||||
}, "Treat humans as humans \u2014 everything else follows."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "A wise world where people and machines build together on one shared set of ethics. Low-cost commerce for small businesses. Tools for moving toward agreement on shared meaning. A place where, if you're learning, you're succeeding."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__cta"
|
||||
}, /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "primary",
|
||||
href: "#/about"
|
||||
}, "Why Wiggleverse \u2192"), /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "ghost",
|
||||
href: "#/ecomm"
|
||||
}, "See what we're building"))))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Find your way in"), /*#__PURE__*/React.createElement("h2", null, "I'm here as a\u2026"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Wiggleverse is an umbrella. Pick the door that fits \u2014 each leads to its own part of the world."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "router__grid"
|
||||
}, /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u2727",
|
||||
title: "Curious human",
|
||||
href: "#/about",
|
||||
first: true
|
||||
}, "What is this, and why does it matter? Start with the story."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25C7",
|
||||
title: "Small business",
|
||||
href: "#/ecomm"
|
||||
}, "People and families who join, sell, and contribute. Near-zero-cost commerce."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25C8",
|
||||
title: "Builder",
|
||||
href: "#/about"
|
||||
}, "A developer who wants to build ethical software? We'd love your help."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u274D",
|
||||
title: "Funder",
|
||||
href: "#/finances"
|
||||
}, "Donors, foundations, and grantmakers supporting a 501(c)(3) at work.")))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section section--paper"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, {
|
||||
onLight: true
|
||||
}, "One set of principles, many alternatives"), /*#__PURE__*/React.createElement("h2", null, "What we're building"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Ethical alternatives to the platforms people rely on every day \u2014 every one built on the same shared set of principles."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "build__grid"
|
||||
}, /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "live",
|
||||
onLight: true
|
||||
}, "First product"),
|
||||
title: "Ecomm"
|
||||
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. ", /*#__PURE__*/React.createElement("a", {
|
||||
href: "#/ecomm"
|
||||
}, "Why ecomm first \u2192")), /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "soon"
|
||||
}, "Coming"),
|
||||
title: "Apps"
|
||||
}, "A branded mobile app of their own for every small business \u2014 so they meet their people directly, instead of renting space inside someone else's platform."), /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "soon"
|
||||
}, "Coming"),
|
||||
title: "Learn"
|
||||
}, "A place where anyone \u2014 including small businesses who share our ethics \u2014 can post educational content that's true, right-sized, and actually teaches.")))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Open Core \xB7 a partner ecosystem"), /*#__PURE__*/React.createElement("h2", null, "Built on an open core"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "The platform's core is open \u2014 anyone can inspect it, extend it, and build on it. Because no two businesses run the same way, we don't sell one-size-fits-all \u2014 we grow a partner ecosystem that builds software shaped to the work it's actually for."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "router__grid"
|
||||
}, /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25D0",
|
||||
title: "Partner",
|
||||
href: "#/about",
|
||||
first: true
|
||||
}, "Build custom software on an open platform \u2014 value flowing to the builders and the served, never extracted by a platform in the middle. Why custom-on-open is the future \u2192")))));
|
||||
}
|
||||
|
||||
/* ---------- About ---------- */
|
||||
function AboutScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "About"), /*#__PURE__*/React.createElement("h1", null, "The era of infinite alternatives"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "Welcome to the Wiggleverse."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "We've entered an era of alternatives \u2014 infinite alternatives. Wiggleverse exists to build the humane ones: ethical alternatives to the platforms people use every day, kept low-cost and high-value, in service of human flourishing."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "Now it's the platforms"), /*#__PURE__*/React.createElement("p", null, "With large language models, we've entered the era where the platforms themselves are being commoditized \u2014 and with them comes a world of infinite alternatives for software. One genuinely skilled developer can now do what would have taken ten of them a year ago. The things everyone assumed were permanent fixtures are suddenly open to alternatives."), /*#__PURE__*/React.createElement("h2", null, "The moats are becoming anchors"), /*#__PURE__*/React.createElement("p", null, "The large platforms have thrived on three moats: the manpower and capital to run software at scale, vendor lock-in, and the network effect. My hypothesis is that the first is turning into an anchor, the second is dissolving as custom migration code gets cheap, and the third is already fragmented."), /*#__PURE__*/React.createElement("h2", null, "What Wiggleverse is for"), /*#__PURE__*/React.createElement("p", null, "None of this is about taking anyone's place. Wiggleverse exists to offer ethical alternatives to platforms like Shopify, YouTube, Facebook, and Instagram \u2014 rooted in ethics, built to give people value rather than to extract it."), /*#__PURE__*/React.createElement(Callout, null, "The intention isn't to take as much as we can from people \u2014 it's to give as much value as we can to everyone."), /*#__PURE__*/React.createElement("p", null, "Welcome to the Wiggleverse, and welcome to the era of infinite alternatives \u2014 even to the platforms everyone assumed were permanent."), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "sm",
|
||||
tone: "plain",
|
||||
as: "p",
|
||||
style: {
|
||||
marginTop: '1.5rem'
|
||||
}
|
||||
}, "\u2014 Ben Stull, Founder"))));
|
||||
}
|
||||
|
||||
/* ---------- Ecomm ---------- */
|
||||
function EcommScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "The portfolio \xB7 first product"), /*#__PURE__*/React.createElement("h1", null, "Ecomm"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "We take only what it takes to run."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. It's the first product to move from principle into the world, and there's a reason it's first."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "Why commerce first"), /*#__PURE__*/React.createElement("p", null, "Running an organization \u2014 and the infrastructure under a platform \u2014 takes money. Commerce is where money moves most, so by building close to commerce we reach a sustainable position quickest. Ecomm is first because it's the fastest honest path to standing on our own feet."), /*#__PURE__*/React.createElement(Callout, null, "Enough to keep the lights on, and no more."), /*#__PURE__*/React.createElement("h2", null, "What we commit to"), /*#__PURE__*/React.createElement("p", null, "The mission for Ecomm is to keep transaction fees and retail-media fees as low as they can possibly go. While we're still in beta, here is what we're committing to:"), /*#__PURE__*/React.createElement("ul", null, /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Retail media, free."), " Earned through platform engagement rather than paid for."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Marketplace storefronts: low or no transaction fees."), " Selling in the shared marketplace costs little or nothing."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "White-label storefronts: low transaction fees."), " Your own branded storefront stays low-cost to run.")), /*#__PURE__*/React.createElement("p", null, /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "ghost",
|
||||
href: "#/finances"
|
||||
}, "See the open book \u2192")))));
|
||||
}
|
||||
|
||||
/* ---------- Finances (open book) ---------- */
|
||||
function FinancesScreen() {
|
||||
const lines = [{
|
||||
item: 'Google Cloud (GCP)',
|
||||
for: 'Servers, databases, and the infrastructure the platform runs on — on startup credits today',
|
||||
current: 32,
|
||||
ant: 75,
|
||||
antFrom: 'July 2026'
|
||||
}, {
|
||||
item: 'Google Workspace',
|
||||
for: 'Email and documents — one seat',
|
||||
current: 7,
|
||||
ant: 14,
|
||||
antFrom: 'Sept 2026'
|
||||
}, {
|
||||
item: 'Anthropic',
|
||||
for: 'Claude — one Max plan, the AI we build alongside every day',
|
||||
current: 100,
|
||||
ant: 100,
|
||||
antFrom: null
|
||||
}];
|
||||
const sumCur = lines.reduce((a, l) => a + l.current, 0);
|
||||
const sumAnt = lines.reduce((a, l) => a + l.ant, 0);
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Radical transparency \xB7 open book"), /*#__PURE__*/React.createElement("h1", null, "Open book"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "We show you the receipts."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "We take only what it takes to run this organization and the platform \u2014 and the only way to mean that is to show our work. Here is what it actually costs to run Wiggleverse, line by line."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "What it costs to run Wiggleverse"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "ledger-stamp"
|
||||
}, "Last updated 8 June 2026 \xB7 hand-kept estimates, refreshed monthly for now."), /*#__PURE__*/React.createElement("table", {
|
||||
className: "ledger"
|
||||
}, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col"
|
||||
}, "Line item"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col"
|
||||
}, "What it's for"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col",
|
||||
className: "num"
|
||||
}, "Current / mo"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col",
|
||||
className: "num"
|
||||
}, "Anticipated / mo"))), /*#__PURE__*/React.createElement("tbody", null, lines.map(l => /*#__PURE__*/React.createElement("tr", {
|
||||
key: l.item
|
||||
}, /*#__PURE__*/React.createElement("td", null, l.item), /*#__PURE__*/React.createElement("td", null, l.for), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", l.current), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", l.ant, l.antFrom && /*#__PURE__*/React.createElement("span", {
|
||||
className: "when"
|
||||
}, "from ", l.antFrom))))), /*#__PURE__*/React.createElement("tfoot", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("td", null, "Total"), /*#__PURE__*/React.createElement("td", null, "per month, plus tax"), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", sumCur), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", sumAnt, /*#__PURE__*/React.createElement("span", {
|
||||
className: "when"
|
||||
}, "by Sept 2026"))))), /*#__PURE__*/React.createElement("p", null, "Revenue today is ", /*#__PURE__*/React.createElement("strong", null, "$0"), " \u2014 we haven't launched commerce yet. Covering these costs ourselves is the whole point of ", /*#__PURE__*/React.createElement("a", {
|
||||
href: "#/ecomm"
|
||||
}, "building commerce first"), "."), /*#__PURE__*/React.createElement(Callout, null, "Ethics that cannot be checked are just claims."))));
|
||||
}
|
||||
const SCREENS = {
|
||||
'#/': HomeScreen,
|
||||
'#/about': AboutScreen,
|
||||
'#/ecomm': EcommScreen,
|
||||
'#/finances': FinancesScreen
|
||||
};
|
||||
function App() {
|
||||
const [route, setRoute] = React.useState(window.location.hash || '#/');
|
||||
React.useEffect(() => {
|
||||
const onHash = () => {
|
||||
setRoute(window.location.hash || '#/');
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
const Screen = SCREENS[route] || HomeScreen;
|
||||
const current = route === '#/about' ? 'About' : route === '#/ecomm' ? 'Build' : route === '#/finances' ? 'Learn' : undefined;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
className: "wv-app"
|
||||
}, /*#__PURE__*/React.createElement(SiteHeader, {
|
||||
links: NAV,
|
||||
current: current,
|
||||
assetBase: ASSET_BASE,
|
||||
homeHref: "#/"
|
||||
}), /*#__PURE__*/React.createElement("main", null, /*#__PURE__*/React.createElement(Screen, null)), /*#__PURE__*/React.createElement(SiteFooter, {
|
||||
columns: FOOTER_COLS,
|
||||
assetBase: ASSET_BASE
|
||||
}));
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(/*#__PURE__*/React.createElement(App, null));
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/wiggleverse-www/app.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
__ds_ns.BrandLockup = __ds_scope.BrandLockup;
|
||||
|
||||
__ds_ns.BuildCard = __ds_scope.BuildCard;
|
||||
|
||||
__ds_ns.PathCard = __ds_scope.PathCard;
|
||||
|
||||
__ds_ns.Button = __ds_scope.Button;
|
||||
|
||||
__ds_ns.Callout = __ds_scope.Callout;
|
||||
|
||||
__ds_ns.Eyebrow = __ds_scope.Eyebrow;
|
||||
|
||||
__ds_ns.Notice = __ds_scope.Notice;
|
||||
|
||||
__ds_ns.Soul = __ds_scope.Soul;
|
||||
|
||||
__ds_ns.Tag = __ds_scope.Tag;
|
||||
|
||||
__ds_ns.SiteFooter = __ds_scope.SiteFooter;
|
||||
|
||||
__ds_ns.SiteHeader = __ds_scope.SiteHeader;
|
||||
|
||||
})();
|
||||
+1
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
/* Space Grotesk — machine register: headings, wordmark */
|
||||
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:500;font-display:swap;src:url('./space-grotesk-v22-latin-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:700;font-display:swap;src:url('./space-grotesk-v22-latin-700.woff2') format('woff2');}
|
||||
/* Inter — machine register: body / UI */
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:400;font-display:swap;src:url('./inter-v20-latin-regular.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:500;font-display:swap;src:url('./inter-v20-latin-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:600;font-display:swap;src:url('./inter-v20-latin-600.woff2') format('woff2');}
|
||||
/* Fraunces — human register: pull-quotes (italic only) */
|
||||
@font-face{font-family:'Fraunces';font-style:italic;font-weight:400;font-display:swap;src:url('./fraunces-v38-latin-italic.woff2') format('woff2');}
|
||||
@font-face{font-family:'Fraunces';font-style:italic;font-weight:500;font-display:swap;src:url('./fraunces-v38-latin-500italic.woff2') format('woff2');}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+170
@@ -0,0 +1,170 @@
|
||||
# Wiggleverse Design System
|
||||
|
||||
The core design system for **Wiggleverse.org** — a Washington 501(c)(3)
|
||||
nonprofit building art and software for a world at peace, on one shared set of
|
||||
ethics. Its first product is *Ecomm* (Shopify-class commerce at near-zero cost);
|
||||
the portfolio also names *Apps* and *Learn*, all built on an **Open Core**.
|
||||
|
||||
> *Treat humans as humans — everything else follows.*
|
||||
|
||||
This system captures the brand exactly as it ships on the marketing site so that
|
||||
agents and designers can produce on-brand interfaces, decks, and assets.
|
||||
|
||||
## Sources
|
||||
|
||||
Built from the live marketing-site codebase, the source of truth:
|
||||
|
||||
- **Codebase:** `wiggleverse-www/` — static site (plain HTML/CSS/JS, no build
|
||||
step), deployed to Cloudflare Pages. Brand tokens live in
|
||||
`wiggleverse-www/assets/tokens.css`; the design system CSS is
|
||||
`wiggleverse-www/styles.css`.
|
||||
- **Brand source of truth (referenced, not attached):** `wiggleverse-org/corp` →
|
||||
`brand/BRAND.md` (§5–10). The site's `assets/` kit is a vendored copy. The
|
||||
site design spec is `corp/docs/superpowers/specs/2026-06-04-wiggleverse-www-design.md`.
|
||||
- **Fonts** are the real vendored woff2 files (Space Grotesk, Inter, Fraunces),
|
||||
copied into `assets/fonts/`. No substitutions were made.
|
||||
|
||||
---
|
||||
|
||||
## Content fundamentals
|
||||
|
||||
How Wiggleverse writes. The voice is **two registers passing through one
|
||||
review** — the same idea the product is built on.
|
||||
|
||||
- **Machine register (default).** Precise, structural, plain-spoken. Most copy
|
||||
lives here: headings, UI, body prose. Calm and declarative, never hypey.
|
||||
*"We take only what it takes to run."*
|
||||
- **Human register ("the soul").** Warm, literary, used **sparingly** for the
|
||||
lines that carry conscience — taglines and pull-quotes only. Always set in
|
||||
**Fraunces italic**. *"We are verbs, not nouns." · "Ethics that cannot be
|
||||
checked are just claims."*
|
||||
|
||||
Specifics:
|
||||
|
||||
- **Person.** Mostly **we / our** (the org speaking plainly and accountably).
|
||||
Slips to **first-person singular** for the founder's voice in the About essay
|
||||
("I felt that one firsthand… — Ben Stull, Founder"). Addresses the reader as
|
||||
**you** in product/partner copy.
|
||||
- **Tone.** Honest, humble, anti-extractive, quietly ambitious. Leads with
|
||||
*what is enough?* rather than *how much can we get?* Admits limits openly
|
||||
("hand-kept estimates, refreshed monthly for now").
|
||||
- **Casing.** Sentence case everywhere — headings, buttons, nav. **Eyebrows**
|
||||
and small labels are UPPERCASE with wide tracking. Product names are casual
|
||||
and lowercase-ish in prose ("ecomm"), Title Case as proper nouns ("Ecomm").
|
||||
- **Punctuation.** Em dashes for asides. Arrows (`→`) end calls to action
|
||||
("Why Wiggleverse →"). Italic emphasis on the load-bearing question
|
||||
(*what is enough?*). Bold for the load-bearing noun phrases in a list.
|
||||
- **No emoji.** None, anywhere. Status and emphasis come from type and a small
|
||||
set of geometric glyphs (see Iconography).
|
||||
- **Recurring phrases.** "Treat humans as humans." · "We take only what it takes
|
||||
to run." · "Radical transparency / we show you the receipts." · "Build the
|
||||
dictionary first." · "If you're learning, you're succeeding." · "We are verbs,
|
||||
not nouns." · the dedication to Aaron Swartz in every footer.
|
||||
- **The vibe.** A standards document (RFC/IETF) with a conscience — exact where
|
||||
it matters, tender where it counts.
|
||||
|
||||
---
|
||||
|
||||
## Visual foundations
|
||||
|
||||
The whole system answers to one motif: a **Circle of Equals** — one living,
|
||||
wiggling line through six equal points, closed into a ring, with a **deliberately
|
||||
empty center** ("no center, no hub, no sun"). The connecting line *is* the ethic.
|
||||
|
||||
- **Ground.** **Dark "sky" is primary** (`--wv-midnight #0E1230`). Long-reading
|
||||
passages flip to **Paper** (`--wv-paper #F6F4FB`) sections. Most of the system
|
||||
lives on dark.
|
||||
- **Color.** Indigo (`#1C2150`) for raised surfaces; **lilac** (`#9B8CFF`) is the
|
||||
accent — links, nodes, "the bonds between us"; **violet** (`#7C6FE0`) is its
|
||||
on-paper counterpart; **gold** (`#F4C76B`) is warmth / horizon / the single
|
||||
primary CTA color; **starlight** (`#EDEAFF`) is text on dark; **ink**
|
||||
(`#3B2F7A`) is text on paper. A near-black **night** (`#090C22`) grounds the
|
||||
footer.
|
||||
- **Type.** Two machine faces + one human face. **Space Grotesk** 500/700 for
|
||||
display & wordmark (tracked in −0.015em). **Inter** 400/500/600 for body & UI.
|
||||
**Fraunces italic** 400/500 for the soul — *italic only, never upright, never
|
||||
body copy.* Headings use fluid `clamp()` sizes.
|
||||
- **Backgrounds.** No photography in the system. The hero uses a **scattered
|
||||
starfield** (faint multi-radial-gradient nodes, intentionally off-center) and a
|
||||
large, very low-opacity (≈0.14) mark bleeding off the right edge. Otherwise:
|
||||
flat color fields. No big gradients as surfaces (the only gradient is *inside*
|
||||
the mark, lilac→gold).
|
||||
- **Borders & cards.** Depth is carried by **surface color + hairline borders**,
|
||||
not shadows. Cards are indigo (on dark) or white (on paper) with a 1px
|
||||
lilac/violet hairline at low alpha, **14px** radius. The system is
|
||||
**near-shadowless** — a soft shadow token exists but is rarely used.
|
||||
- **Radii.** 14px cards, 12px panels, 4px focus, **999px pills** (buttons, tags,
|
||||
notices), 26px app-tile.
|
||||
- **Animation.** Restrained. Transitions ≈ `.15s ease`. Hover = **translateY
|
||||
lift** (−1px buttons, −3px cards), *not* a shadow or scale. Mobile nav reveals
|
||||
via `max-height .25s`. Everything is killed under
|
||||
`prefers-reduced-motion: reduce`. No bounces, no loops, no parallax.
|
||||
- **Hover / press.** Links underline (3px offset, lilac). Buttons lift and
|
||||
lighten (gold → `#F7D488`). Cards lift and warm their border to lilac, surface
|
||||
to `#232A63`. No color-darkening press states; no shrink.
|
||||
- **Transparency & blur.** The **sticky header** is the one glass surface:
|
||||
`rgba(14,18,48,.82)` + `blur(10px)` + a lilac hairline. Alpha lilac/gold/
|
||||
starlight washes (`.08`–`.40`) do tints, dividers, and focus rings.
|
||||
- **Focus.** Always visible: **3px gold outline**, 2px offset, 4px rounding.
|
||||
Accessibility is load-bearing (skip links, `aria-current`, reduced-motion).
|
||||
- **Imagery vibe.** Cool, dark, cosmic, calm — starlight on midnight, warmed by a
|
||||
single gold horizon. Never busy, never loud.
|
||||
- **Layout.** Content column is `min(1080px, 92vw)`, gutter-aligned (not
|
||||
auto-centered text). Prose blocks cap at a 68ch measure. Sections breathe with
|
||||
fluid `clamp(3rem, 7vw, 5.5rem)` vertical padding.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
Wiggleverse is **iconography-light by design**. There is **no icon font and no
|
||||
icon set** in the codebase.
|
||||
|
||||
- **The mark** is the one true graphic: the *Circle of Equals*, shipped as SVG in
|
||||
four cuts — `wiggleverse-mark.svg` (lilac→gold gradient line, starlight nodes),
|
||||
`mark-mono-gold.svg` (footer), `mark-on-light.svg` (ink on paper), and
|
||||
`favicon.svg` (gold line + starlight nodes on a rounded-26px midnight tile).
|
||||
Raster favicons (`favicon-32.png`, `favicon-180.png`) are provided for tabs and
|
||||
iOS. All are in `assets/`.
|
||||
- **"Icons" are geometric unicode glyphs.** The audience router uses
|
||||
**✧ ◇ ◈ ❍ ◐** as quiet, abstract door markers — never pictographic icons,
|
||||
never emoji. Treat these as the sanctioned glyph set when a small mark is
|
||||
needed. Arrows are literal `→` characters in link/button text.
|
||||
- **No emoji, ever.** (Reconfirmed under Content fundamentals.)
|
||||
- **If you need UI icons** (e.g. a richer product surface that the marketing site
|
||||
doesn't cover), there is no house set to match — keep them to a thin,
|
||||
geometric, single-weight line style consistent with the mark, and **flag the
|
||||
addition** so it can be folded into the brand properly. Do not hand-draw new
|
||||
brand illustrations.
|
||||
|
||||
---
|
||||
|
||||
## Index / manifest
|
||||
|
||||
Root files:
|
||||
|
||||
- `styles.css` — the global entry point (consumers link this one file). `@import`
|
||||
manifest only.
|
||||
- `tokens/colors.css` · `tokens/typography.css` · `tokens/spacing.css` — CSS
|
||||
custom properties (base values + semantic aliases).
|
||||
- `assets/` — the Circle-of-Equals marks (4 cuts), favicons, and the three
|
||||
vendored webfont families in `assets/fonts/` (+ `fonts.css` `@font-face`).
|
||||
- `guidelines/` — foundation specimen cards (Colors, Type, Spacing, Brand).
|
||||
- `SKILL.md` — Agent-Skills-compatible entry point.
|
||||
|
||||
Components (`window.WiggleverseDesignSystem_*`):
|
||||
|
||||
- **core/** — `Button`, `Tag`, `Notice`, `Eyebrow`, `Soul`, `Callout`
|
||||
- **cards/** — `PathCard`, `BuildCard`
|
||||
- **brand/** — `BrandLockup`
|
||||
- **navigation/** — `SiteHeader`, `SiteFooter`
|
||||
|
||||
UI kits:
|
||||
|
||||
- **ui_kits/wiggleverse-www/** — high-fidelity recreation of the marketing site
|
||||
(Home, About, Ecomm, Finances/open-book), composing the components above.
|
||||
|
||||
Each component directory carries `<Name>.jsx`, `<Name>.d.ts`, `<Name>.prompt.md`,
|
||||
and one `@dsCard` HTML thumbnail. Mount components in card/kit HTML via
|
||||
`const { X } = window.WiggleverseDesignSystem_94cd80` after loading
|
||||
`_ds_bundle.js` (generated automatically — do not edit).
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/* Wiggleverse Design System — global entry point.
|
||||
Consumers link THIS one file. It is an @import manifest only — no rules here.
|
||||
Everything reachable from here ships to consumers (tokens + @font-face webfonts). */
|
||||
|
||||
@import url("./assets/fonts/fonts.css"); /* Space Grotesk · Inter · Fraunces (@font-face) */
|
||||
@import url("./tokens/colors.css"); /* palette + semantic color aliases */
|
||||
@import url("./tokens/typography.css"); /* families, scale, weights, tracking */
|
||||
@import url("./tokens/spacing.css"); /* spacing, radii, borders, motion, layout */
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
|
||||
Derived from the marketing-site CSS. The brand has almost no shadow system —
|
||||
depth is carried by surface color + hairline borders, not drop shadows. */
|
||||
|
||||
:root {
|
||||
/* ---- Spacing scale (rem) ---- */
|
||||
--space-0: 0;
|
||||
--space-1: .25rem;
|
||||
--space-2: .5rem;
|
||||
--space-3: .7rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.2rem;
|
||||
--space-6: 1.6rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Section rhythm — fluid vertical padding for page bands */
|
||||
--section-pad: clamp(3rem, 7vw, 5.5rem);
|
||||
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--wrap-max: 1080px; /* content column */
|
||||
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
|
||||
--measure-prose: 68ch; /* reading measure for prose blocks */
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-card: 14px; /* path-cards, build-cards */
|
||||
--radius-panel: 12px; /* principle tiles */
|
||||
--radius-sm: 4px; /* focus ring rounding */
|
||||
--radius-pill: 999px; /* buttons, tags, notices */
|
||||
--radius-mark: 26px; /* favicon tile rounding */
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-hair: 1px; /* default hairline */
|
||||
--border-card-w: 1px;
|
||||
--btn-border-w: 1.5px; /* ghost button / focus weight */
|
||||
|
||||
/* ---- Elevation ---- *
|
||||
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
|
||||
* not a shadow. These tokens exist for the rare card that needs real elevation. */
|
||||
--shadow-none: none;
|
||||
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
|
||||
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
|
||||
--lift-3: translateY(-3px); /* @kind other */ /* cards */
|
||||
|
||||
/* ---- Glass (sticky header) ---- */
|
||||
--glass-sky: rgba(14, 18, 48, .82);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ---- Motion ---- */
|
||||
--ease: ease; /* @kind other */
|
||||
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
|
||||
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/* Wiggleverse — Typography tokens
|
||||
Two registers, one meaning (BRAND.md):
|
||||
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
|
||||
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
|
||||
for the lines that carry conscience ("the soul").
|
||||
@font-face rules live in assets/fonts/fonts.css. */
|
||||
|
||||
:root {
|
||||
/* ---- Families ---- */
|
||||
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
|
||||
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
|
||||
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
|
||||
|
||||
/* semantic aliases */
|
||||
--font-display: var(--wv-font-display);
|
||||
--font-body: var(--wv-font-body);
|
||||
--font-soul: var(--wv-font-human);
|
||||
|
||||
/* ---- Weights ---- */
|
||||
--weight-regular: 400; /* Inter body */
|
||||
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
|
||||
--weight-semibold:600; /* Inter emphasis, ledger totals */
|
||||
--weight-bold: 700; /* Space Grotesk headings, wordmark */
|
||||
--weight-soul: 500; /* Fraunces italic pull-quotes */
|
||||
|
||||
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
|
||||
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
|
||||
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
--text-h3: 1.2rem;
|
||||
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
|
||||
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
|
||||
|
||||
/* ---- Body / UI scale ---- */
|
||||
--text-body: 1rem; /* 16px base */
|
||||
--text-small: .95rem;
|
||||
--text-fine: .92rem;
|
||||
--text-eyebrow: .8rem; /* uppercase label */
|
||||
--text-tag: .72rem; /* pill tags */
|
||||
|
||||
/* ---- Line heights ---- */
|
||||
--leading-tight: 1.12; /* headings */
|
||||
--leading-body: 1.6; /* paragraphs */
|
||||
|
||||
/* ---- Letter spacing ---- */
|
||||
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
|
||||
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
|
||||
--tracking-tag: 0.08em;
|
||||
--tracking-notice: 0.06em;
|
||||
|
||||
/* ---- Measure ---- */
|
||||
--measure-lead: 60ch;
|
||||
--measure-prose:68ch;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* app.jsx — wires the state machine, Tweaks, and mounts. */
|
||||
|
||||
/* Pull every cross-file dependency off window with `var` (safe under both the
|
||||
shared-global and isolated babel-script models — no redeclaration error). */
|
||||
var { AdminShell, ProductsPage, UploadScreen, PreviewScreen, RunDetailScreen } = window;
|
||||
var { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakButton, useTweaks } = window;
|
||||
const { useState: _aState, useEffect: _aEffect, useRef: _aRef } = React;
|
||||
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"catalog": "populated",
|
||||
"density": "comfortable",
|
||||
"scenario": "canonical"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
/* ---- synthesize image problems / errors for historical runs ---- */
|
||||
function synthProblems(rejected, failed) {
|
||||
const out = [];
|
||||
const handles = ['oxford-button-down', 'weekender-duffel', 'camp-mug-enamel', 'aspen-knit-sweater', 'lantern-flannel', 'river-stone-belt', 'cedar-beanie', 'driftwood-henley'];
|
||||
for (let i = 0; i < rejected; i++) out.push({ handle: handles[i % handles.length], variant: i % 2 ? 'M / Slate' : '—', url: `https://cdn.northfield.example/${handles[i % handles.length]}-${i}.jpg`, outcome: 'rejected', reason: 'Below the resolution bar — needs at least 800 × 800.' });
|
||||
for (let i = 0; i < failed; i++) out.push({ handle: handles[(i + rejected) % handles.length], variant: '—', url: `https://oldhost.example/${handles[(i + rejected) % handles.length]}.jpg`, outcome: 'failed', reason: 'Unreachable — host timed out.' });
|
||||
return out;
|
||||
}
|
||||
function synthErrors(n) {
|
||||
const base = [
|
||||
{ line: 88, column: 'Type', title: 'Gift Card', message: "Shopify 'Gift Card' products aren't catalog products here — skipped." },
|
||||
{ line: 1042, column: 'Type', title: 'Essentials Bundle', message: 'Looks like a bundle — kits arrive in a coming release.' },
|
||||
{ line: 1188, column: 'Variant Price', title: 'Trail Mug', message: "'9,00' is not a price — use a dot for decimals." },
|
||||
{ line: 1455, column: 'Handle', title: '(row 1455)', message: 'Empty Handle — every row needs one.' },
|
||||
];
|
||||
return base.slice(0, n);
|
||||
}
|
||||
|
||||
function buildRunView(run, data) {
|
||||
if (run.id === 'run-0007') {
|
||||
return { scenario: data.SCENARIOS.canonical, images: data.IMAGE_OUTCOMES, run };
|
||||
}
|
||||
const isShopify = run.dialect.includes('Shopify');
|
||||
const scenario = {
|
||||
file: run.file,
|
||||
dialect: run.dialect + (isShopify ? ' — mapped to canonical' : ' format'),
|
||||
dialectKind: isShopify ? 'shopify' : 'canonical',
|
||||
summary: { add: run.added, update: run.updated, unchanged: 0, error: run.errors },
|
||||
records: { error: synthErrors(run.errors), add: [], update: [], unchanged: [] },
|
||||
extra: {},
|
||||
unknownColumns: [],
|
||||
};
|
||||
const total = run.images.fetched + run.images.rejected + run.images.failed;
|
||||
const images = { total, fetched: run.images.fetched, problems: synthProblems(run.images.rejected, run.images.failed) };
|
||||
return { scenario, images, run };
|
||||
}
|
||||
|
||||
function Toast({ toast }) {
|
||||
if (!toast) return null;
|
||||
const color = toast.kind === 'success' ? 'var(--st-add)' : 'var(--wv-violet)';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', zIndex: 9000,
|
||||
display: 'flex', alignItems: 'center', gap: '.55rem', padding: '.7rem 1.2rem',
|
||||
background: 'var(--wv-midnight)', color: 'var(--wv-starlight)', borderRadius: 999,
|
||||
border: '1px solid var(--wv-lilac-32)', boxShadow: '0 10px 30px rgba(9,12,34,.4)',
|
||||
fontSize: '.9rem', fontFamily: 'var(--font-body)', animation: 'ie-toast .25s ease',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: color }} />
|
||||
{toast.msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
const [screen, setScreen] = _aState('products');
|
||||
const [view, setView] = _aState(null);
|
||||
const [toast, setToast] = _aState(null);
|
||||
const toastTimer = _aRef(null);
|
||||
const data = window.IEData;
|
||||
const catalogEmpty = t.catalog === 'empty';
|
||||
|
||||
_aEffect(() => { document.documentElement.dataset.density = t.density; }, [t.density]);
|
||||
|
||||
const showToast = (msg, kind) => {
|
||||
setToast({ msg, kind, id: Date.now() });
|
||||
clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(null), 2600);
|
||||
};
|
||||
|
||||
const getImages = (scenario) => {
|
||||
const m = {
|
||||
canonical: data.IMAGE_OUTCOMES,
|
||||
shopify: { total: 519, fetched: 511, problems: synthProblems(6, 2) },
|
||||
roundtrip: { total: 0, fetched: 0, problems: [] },
|
||||
};
|
||||
return m[scenario.key] || { total: 0, fetched: 0, problems: [] };
|
||||
};
|
||||
|
||||
const goProducts = () => { setScreen('products'); setView(null); };
|
||||
const goImport = () => setScreen('upload');
|
||||
const onValidated = (key) => { setView({ scenario: data.SCENARIOS[key] }); setScreen('preview'); };
|
||||
const onConfirm = (scenario) => {
|
||||
setView({ scenario, images: getImages(scenario), run: null, live: true });
|
||||
setScreen('rundetail');
|
||||
if (catalogEmpty) setTweak('catalog', 'populated');
|
||||
};
|
||||
const onOpenRun = (runId) => {
|
||||
const run = data.HISTORY.find((r) => r.id === runId);
|
||||
if (!run) return;
|
||||
setView({ ...buildRunView(run, data), live: false });
|
||||
setScreen('rundetail');
|
||||
};
|
||||
|
||||
// tweak jump helpers
|
||||
const jumpPreview = () => { setView({ scenario: data.SCENARIOS[t.scenario] }); setScreen('preview'); };
|
||||
const jumpRun = () => { const sc = data.SCENARIOS[t.scenario === 'roundtrip' ? 'canonical' : t.scenario]; onConfirm(sc); };
|
||||
|
||||
let body;
|
||||
if (screen === 'products') {
|
||||
body = <ProductsPage data={data} catalogEmpty={catalogEmpty} onImport={goImport} onOpenRun={onOpenRun} toast={showToast} />;
|
||||
} else if (screen === 'upload') {
|
||||
body = <UploadScreen onBack={goProducts} onValidated={onValidated} toast={showToast} />;
|
||||
} else if (screen === 'preview' && view) {
|
||||
body = <PreviewScreen scenario={view.scenario} onCancel={goProducts} onConfirm={onConfirm} />;
|
||||
} else if (screen === 'rundetail' && view) {
|
||||
body = <RunDetailScreen run={view.run} scenario={view.scenario} images={view.images} onBack={goProducts} live={view.live} />;
|
||||
} else {
|
||||
body = <ProductsPage data={data} catalogEmpty={catalogEmpty} onImport={goImport} onOpenRun={onOpenRun} toast={showToast} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<AdminShell current="products" onNav={() => goProducts()} storefront={data.STOREFRONT}>
|
||||
{body}
|
||||
</AdminShell>
|
||||
<Toast toast={toast} />
|
||||
|
||||
<TweaksPanel>
|
||||
<TweakSection label="Prototype state" />
|
||||
<TweakRadio label="Catalog" value={t.catalog} options={['populated', 'empty']} onChange={(v) => { setTweak('catalog', v); setScreen('products'); setView(null); }} />
|
||||
<TweakRadio label="Density" value={t.density} options={['comfortable', 'compact']} onChange={(v) => setTweak('density', v)} />
|
||||
|
||||
<TweakSection label="Jump to a step" />
|
||||
<TweakSelect label="Import file" value={t.scenario}
|
||||
options={[['canonical', 'Canonical — seasonal pricing'], ['shopify', 'Shopify export — mapped'], ['roundtrip', 'Round-trip — no-op']].map(([v, l]) => ({ value: v, label: l }))}
|
||||
onChange={(v) => setTweak('scenario', v)} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '.4rem', marginTop: '.2rem' }}>
|
||||
<TweakButton label="Products" onClick={goProducts} />
|
||||
<TweakButton label="Upload" onClick={goImport} />
|
||||
<TweakButton label="Preview" onClick={jumpPreview} />
|
||||
<TweakButton label="Run detail" onClick={jumpRun} />
|
||||
</div>
|
||||
</TweaksPanel>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/* Mount once everything is on window (guards against script-eval ordering). */
|
||||
function ieMount() {
|
||||
const ready = window.AdminShell && window.ProductsPage && window.UploadScreen &&
|
||||
window.PreviewScreen && window.RunDetailScreen && window.TweaksPanel &&
|
||||
window.TweakSection && window.TweakRadio && window.TweakSelect && window.TweakButton &&
|
||||
window.useTweaks && window.IEData;
|
||||
if (!ready) { setTimeout(ieMount, 20); return; }
|
||||
// refresh local bindings in case they were read before the producer ran
|
||||
AdminShell = window.AdminShell; ProductsPage = window.ProductsPage;
|
||||
UploadScreen = window.UploadScreen; PreviewScreen = window.PreviewScreen;
|
||||
RunDetailScreen = window.RunDetailScreen; TweaksPanel = window.TweaksPanel;
|
||||
TweakSection = window.TweakSection; TweakRadio = window.TweakRadio;
|
||||
TweakSelect = window.TweakSelect; TweakButton = window.TweakButton; useTweaks = window.useTweaks;
|
||||
if (!window.__ieRoot) window.__ieRoot = ReactDOM.createRoot(document.getElementById('ie-root'));
|
||||
window.__ieRoot.render(<App />);
|
||||
}
|
||||
ieMount();
|
||||
@@ -0,0 +1,435 @@
|
||||
/* data.jsx — sample catalog + import scenarios for the bulk CSV import/export prototype.
|
||||
A boutique apparel store, "Wander & Wool". Numbers chosen so every count is backed
|
||||
by a concrete row the merchant can drill into. Exported to window.IEData. */
|
||||
|
||||
const STOREFRONT = {
|
||||
name: 'Wander & Wool',
|
||||
handle: 'wander-and-wool',
|
||||
email: 'mara@wanderandwool.com',
|
||||
productCount: 412,
|
||||
imageProblemCount: 9,
|
||||
latestRunId: 'run-0007',
|
||||
};
|
||||
|
||||
/* ---- Import history (newest first). Statuses mirror §5.2 ---- */
|
||||
const HISTORY = [
|
||||
{
|
||||
id: 'run-0007', date: '2026-06-11T12:40:00', rel: 'just now',
|
||||
file: 'spring-2026-pricing.csv', dialect: 'Canonical',
|
||||
added: 8, updated: 14, errors: 3,
|
||||
images: { fetched: 18, rejected: 2, failed: 1 },
|
||||
status: 'complete_with_problems',
|
||||
},
|
||||
{
|
||||
id: 'run-0006', date: '2026-05-02T09:12:00', rel: '5 weeks ago',
|
||||
file: 'shopify-store-export.csv', dialect: 'Shopify product CSV',
|
||||
added: 286, updated: 0, errors: 4,
|
||||
images: { fetched: 511, rejected: 6, failed: 2 },
|
||||
status: 'complete_with_problems',
|
||||
},
|
||||
{
|
||||
id: 'run-0005', date: '2026-04-18T16:30:00', rel: '8 weeks ago',
|
||||
file: 'restock-april.csv', dialect: 'Canonical',
|
||||
added: 0, updated: 63, errors: 0,
|
||||
images: { fetched: 0, rejected: 0, failed: 0 },
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
id: 'run-0004', date: '2026-03-30T11:05:00', rel: '10 weeks ago',
|
||||
file: 'initial-catalog.csv', dialect: 'Canonical',
|
||||
added: 118, updated: 0, errors: 0,
|
||||
images: { fetched: 402, rejected: 0, failed: 0 },
|
||||
status: 'complete',
|
||||
},
|
||||
];
|
||||
|
||||
/* =====================================================================
|
||||
SCENARIO A — canonical seasonal pricing file (the default happy path).
|
||||
8 to add · 14 to update · 26 unchanged · 3 errors. (48 product rows.)
|
||||
===================================================================== */
|
||||
|
||||
const A_ADDS = [
|
||||
{
|
||||
handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'add', variants: 8,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Alpine Fleece Pullover'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'outerwear, fleece, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Size (S·M·L·XL) × Color (Heather Grey · Forest)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['S / Heather Grey', 'SKU AFP-S-HG', '$88.00'],
|
||||
['M / Heather Grey', 'SKU AFP-M-HG', '$88.00'],
|
||||
['L / Forest', 'SKU AFP-L-FO', '$88.00'],
|
||||
],
|
||||
images: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'coastal-linen-shirt', title: 'Coastal Linen Shirt', kind: 'add', variants: 6,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Coastal Linen Shirt'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'shirts, linen, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Size (S·M·L) × Color (Sand · Sky)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['S / Sand', 'SKU CLS-S-SA', '$72.00'],
|
||||
['M / Sky', 'SKU CLS-M-SK', '$72.00'],
|
||||
],
|
||||
images: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'trailhead-cap', title: 'Trailhead Cap', kind: 'add', variants: 3,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Trailhead Cap'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'accessories, hats, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Color (Black · Olive · Rust)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['Black', 'SKU THC-BK', '$34.00'],
|
||||
['Olive', 'SKU THC-OL', '$34.00'],
|
||||
['Rust', 'SKU THC-RU', '$34.00'],
|
||||
],
|
||||
images: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'merino-base-tee', title: 'Merino Base Tee', kind: 'add', variants: 8,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Merino Base Tee'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'base-layer, merino, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Size (XS·S·M·L) × Color (Charcoal · Bone)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['XS / Charcoal', 'SKU MBT-XS-CH', '$58.00'],
|
||||
['M / Bone', 'SKU MBT-M-BO', '$58.00'],
|
||||
],
|
||||
images: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'dune-chino-short', title: 'Dune Chino Short', kind: 'add', variants: 5,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Dune Chino Short'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'shorts, spring-26'],
|
||||
['Status', 'draft'],
|
||||
['Options', 'Size (28·30·32·34·36)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['28', 'SKU DCS-28', '$64.00'],
|
||||
['34', 'SKU DCS-34', '$64.00'],
|
||||
],
|
||||
images: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'harbor-tote', title: 'Harbor Canvas Tote', kind: 'add', variants: 1,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Harbor Canvas Tote'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'bags, accessories, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', '— (single variant)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['Default', 'SKU HCT-01', '$48.00'],
|
||||
],
|
||||
images: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'cirrus-rain-shell', title: 'Cirrus Rain Shell', kind: 'add', variants: 6,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Cirrus Rain Shell'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'outerwear, rain, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Size (S·M·L) × Color (Slate · Marigold)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['S / Slate', 'SKU CRS-S-SL', '$186.00'],
|
||||
['L / Marigold', 'SKU CRS-L-MA', '$186.00'],
|
||||
],
|
||||
images: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'wildflower-scarf', title: 'Wildflower Wool Scarf', kind: 'add', variants: 2,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Wildflower Wool Scarf'],
|
||||
['Vendor', 'Wander & Wool'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'accessories, wool, spring-26'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Color (Meadow · Dusk)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['Meadow', 'SKU WWS-ME', '$42.00'],
|
||||
['Dusk', 'SKU WWS-DU', '$42.00'],
|
||||
],
|
||||
images: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const A_UPDATES = [
|
||||
{
|
||||
handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'update', variants: 6,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Price', scope: 'all 6 variants', before: '$118.00', after: '$98.00' },
|
||||
{ field: 'Tags', scope: 'product', before: 'dresses, summer', after: 'dresses, summer, sale' },
|
||||
{ field: 'Status', scope: 'product', before: 'active', after: 'active', noop: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'update', variants: 9,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Price', scope: 'all 9 variants', before: '$240.00', after: '$264.00' },
|
||||
{ field: 'Variant Cost', scope: 'all 9 variants', before: '$96.00', after: '$112.00' },
|
||||
{ field: 'Variant Inventory Qty', scope: 'S / Slate', before: '4', after: '22' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'field-canvas-pant', title: 'Field Canvas Pant', kind: 'update', variants: 10,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Price', scope: 'all 10 variants', before: '$92.00', after: '$84.00' },
|
||||
{ field: 'Description', scope: 'product', before: 'Durable canvas work pant.', after: 'Durable organic-canvas work pant, garment-dyed.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'aspen-knit-sweater', title: 'Aspen Knit Sweater', kind: 'update', variants: 8,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Price', scope: 'all 8 variants', before: '$134.00', after: '$118.00' },
|
||||
{ field: 'Status', scope: 'product', before: 'draft', after: 'active' },
|
||||
{ field: 'Tags', scope: 'product', before: 'knitwear, fall', after: 'knitwear, fall, spring-26' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'river-stone-belt', title: 'River Stone Belt', kind: 'update', variants: 4,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Cost', scope: 'all 4 variants', before: '$11.00', after: '$14.50' },
|
||||
{ field: 'Variant Barcode', scope: 'M / Tan', before: '— (empty)', after: '7 290 1148 22 901' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'lantern-flannel', title: 'Lantern Flannel', kind: 'update', variants: 12,
|
||||
detail: {
|
||||
changes: [
|
||||
{ field: 'Variant Price', scope: 'all 12 variants', before: '$78.00', after: '$68.00' },
|
||||
{ field: 'Tags', scope: 'product', before: 'shirts, flannel', after: 'shirts, flannel, sale' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const A_UPDATES_EXTRA = 8; // 14 total updates; 6 shown above, 8 more collapsed into the count
|
||||
|
||||
const A_UNCHANGED = [
|
||||
{ handle: 'sandpiper-sock-3pk', title: 'Sandpiper Sock 3-Pack', kind: 'unchanged', variants: 3 },
|
||||
{ handle: 'cedar-beanie', title: 'Cedar Ribbed Beanie', kind: 'unchanged', variants: 4 },
|
||||
{ handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
|
||||
];
|
||||
const A_UNCHANGED_EXTRA = 23; // 26 total
|
||||
|
||||
const A_ERRORS = [
|
||||
{
|
||||
handle: 'gardenia-sundress', title: 'Gardenia Sundress', kind: 'error', variants: 5,
|
||||
line: 214, column: 'Variant Price',
|
||||
message: "'12,50' is not a price — use a dot for decimals (12.50), no thousands separators.",
|
||||
},
|
||||
{
|
||||
handle: 'pathfinder-vest', title: 'Pathfinder Vest', kind: 'error', variants: 6,
|
||||
line: 327, column: 'Option1 Value',
|
||||
message: "Variant has value 'Medium' but the product never declares 'Option1 Name'. Add the option name, or remove the value.",
|
||||
},
|
||||
{
|
||||
handle: 'trail-kit-starter', title: 'Trail Starter Kit', kind: 'error', variants: 1,
|
||||
line: 402, column: 'Type',
|
||||
message: "'kit_assembled' is reserved — bundled kits arrive in a coming release. Use 'standalone' for now.",
|
||||
},
|
||||
];
|
||||
|
||||
const SCENARIO_CANONICAL = {
|
||||
key: 'canonical',
|
||||
file: 'spring-2026-pricing.csv',
|
||||
dialect: 'Canonical format',
|
||||
dialectKind: 'canonical',
|
||||
rows: 48,
|
||||
summary: { add: 8, update: 14, unchanged: 26, error: 3 },
|
||||
unknownColumns: ['Season Note', 'Internal Ref'],
|
||||
records: { add: A_ADDS, update: A_UPDATES, unchanged: A_UNCHANGED, error: A_ERRORS },
|
||||
extra: { update: A_UPDATES_EXTRA, unchanged: A_UNCHANGED_EXTRA },
|
||||
noop: false,
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
SCENARIO B — an unmodified Shopify product export (the migration path).
|
||||
Recognized + mapped. Big add, many not-imported columns warned.
|
||||
===================================================================== */
|
||||
|
||||
const B_ADDS = [
|
||||
{
|
||||
handle: 'oxford-button-down', title: 'Oxford Button-Down', kind: 'add', variants: 8, mapped: true,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Oxford Button-Down'],
|
||||
['Vendor', 'Northfield Supply'],
|
||||
['Type', 'standalone', 'mapped from Shopify "Shirts" → warned, see below'],
|
||||
['Tags', 'shirts, oxford'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Size (S·M·L·XL) × Fit (Slim · Classic)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['S / Slim', 'SKU OBD-S-SL', '$74.00'],
|
||||
['L / Classic', 'SKU OBD-L-CL', '$74.00'],
|
||||
],
|
||||
images: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'weekender-duffel', title: 'Weekender Duffel', kind: 'add', variants: 2, mapped: true,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Weekender Duffel'],
|
||||
['Vendor', 'Northfield Supply'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'bags, travel'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Color (Tan · Charcoal)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['Tan', 'SKU WKD-TN', '$148.00'],
|
||||
['Charcoal', 'SKU WKD-CH', '$148.00'],
|
||||
],
|
||||
images: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
handle: 'camp-mug-enamel', title: 'Enamel Camp Mug', kind: 'add', variants: 3, mapped: true,
|
||||
detail: {
|
||||
product: [
|
||||
['Title', 'Enamel Camp Mug'],
|
||||
['Vendor', 'Northfield Supply'],
|
||||
['Type', 'standalone'],
|
||||
['Tags', 'home, camp'],
|
||||
['Status', 'active'],
|
||||
['Options', 'Color (Red · Navy · Cream)'],
|
||||
],
|
||||
variantsPreview: [
|
||||
['Red', 'SKU ECM-RD', '$18.00'],
|
||||
['Navy', 'SKU ECM-NV', '$18.00'],
|
||||
],
|
||||
images: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
const B_ADDS_EXTRA = 283; // 286 total adds
|
||||
|
||||
const B_ERRORS = [
|
||||
{
|
||||
handle: 'gift-card', title: 'Gift Card', kind: 'error', variants: 4,
|
||||
line: 88, column: 'Type',
|
||||
message: "Shopify 'Gift Card' products aren't catalog products here — this row is skipped, not imported.",
|
||||
},
|
||||
{
|
||||
handle: 'bundle-essentials', title: 'Essentials Bundle', kind: 'error', variants: 1,
|
||||
line: 1042, column: 'Type',
|
||||
message: "Looks like a Shopify bundle — bundled kits arrive in a coming release. Skipped for now.",
|
||||
},
|
||||
];
|
||||
const B_ERRORS_EXTRA = 2; // 4 total
|
||||
|
||||
const SCENARIO_SHOPIFY = {
|
||||
key: 'shopify',
|
||||
file: 'shopify-store-export.csv',
|
||||
dialect: 'Shopify product CSV — mapped to canonical',
|
||||
dialectKind: 'shopify',
|
||||
rows: 1894,
|
||||
summary: { add: 286, update: 0, unchanged: 0, error: 4 },
|
||||
unknownColumns: [
|
||||
'Compare At Price', 'SEO Title', 'SEO Description', 'Google Shopping / Condition',
|
||||
'Google Shopping / Gender', 'Variant Fulfillment Service', 'Variant Tax Code', 'Gift Card',
|
||||
],
|
||||
records: { add: B_ADDS, update: [], unchanged: [], error: B_ERRORS },
|
||||
extra: { add: B_ADDS_EXTRA, error: B_ERRORS_EXTRA },
|
||||
noop: false,
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
SCENARIO C — re-importing our own export (the round-trip no-op).
|
||||
Everything unchanged → the import action is disabled (PUC-10).
|
||||
===================================================================== */
|
||||
|
||||
const C_UNCHANGED = [
|
||||
{ handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'unchanged', variants: 8 },
|
||||
{ handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'unchanged', variants: 6 },
|
||||
{ handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'unchanged', variants: 9 },
|
||||
{ handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
|
||||
];
|
||||
const C_UNCHANGED_EXTRA = 408; // 412 total
|
||||
|
||||
const SCENARIO_ROUNDTRIP = {
|
||||
key: 'roundtrip',
|
||||
file: 'wander-wool-catalog-export.csv',
|
||||
dialect: 'Canonical format',
|
||||
dialectKind: 'canonical',
|
||||
rows: 1620,
|
||||
summary: { add: 0, update: 0, unchanged: 412, error: 0 },
|
||||
unknownColumns: [],
|
||||
records: { add: [], update: [], unchanged: C_UNCHANGED, error: [] },
|
||||
extra: { unchanged: C_UNCHANGED_EXTRA },
|
||||
noop: true,
|
||||
};
|
||||
|
||||
/* ---- Image outcomes for the canonical run's post-commit phase (PUC-7) ---- */
|
||||
const IMAGE_OUTCOMES = {
|
||||
total: 21,
|
||||
fetched: 18,
|
||||
problems: [
|
||||
{ handle: 'cirrus-rain-shell', variant: 'L / Marigold', url: 'https://cdn.northfield.example/cirrus-marigold-detail.jpg', outcome: 'rejected', reason: 'Below the resolution bar (320 × 480) — needs at least 800 × 800.' },
|
||||
{ handle: 'coastal-linen-shirt', variant: 'M / Sky', url: 'https://images.example.org/linen/sky.gif', outcome: 'rejected', reason: 'Not an image we can host (animated GIF).' },
|
||||
{ handle: 'harbor-tote', variant: '—', url: 'https://oldhost.example/harbor-tote-2.jpg', outcome: 'failed', reason: 'Unreachable — host returned 404.' },
|
||||
],
|
||||
};
|
||||
|
||||
const SCENARIOS = {
|
||||
canonical: SCENARIO_CANONICAL,
|
||||
shopify: SCENARIO_SHOPIFY,
|
||||
roundtrip: SCENARIO_ROUNDTRIP,
|
||||
};
|
||||
|
||||
window.IEData = { STOREFRONT, HISTORY, SCENARIOS, IMAGE_OUTCOMES };
|
||||
@@ -0,0 +1,556 @@
|
||||
/* importflow.jsx — the three import screens.
|
||||
UploadScreen (§5.3) · PreviewScreen (§5.4, the consent gate) · RunDetailScreen (§5.5).
|
||||
Exported to window. */
|
||||
|
||||
var { Button, Eyebrow } = window.WiggleverseDesignSystem_94cd80 || {};
|
||||
|
||||
const { useState: _ifState, useEffect: _ifEffect, useRef: _ifRef } = React;
|
||||
|
||||
/* ============================ small shared bits ============================ */
|
||||
|
||||
function ScreenHeader({ eyebrow, title, onBack, backLabel = 'Products', right }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.6rem' }}>
|
||||
<button onClick={onBack} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '.35em', font: 'inherit',
|
||||
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.85rem',
|
||||
color: 'var(--wv-violet)', background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
padding: 0, marginBottom: '.9rem',
|
||||
}}>
|
||||
<Icon name="arrowLeft" size={15} /> Back to {backLabel}
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{eyebrow && <Eyebrow onLight>{eyebrow}</Eyebrow>}
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.9rem', letterSpacing: '-0.015em', color: 'var(--text-on-light)', margin: '.35rem 0 0', overflowWrap: 'anywhere' }}>{title}</h1>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================ 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 (
|
||||
<div>
|
||||
<ScreenHeader eyebrow="Import" title="Import products" onBack={onBack} />
|
||||
|
||||
{phase === 'validating' ? (
|
||||
<Card style={{ padding: '2.4rem 2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', marginBottom: '1rem' }}>
|
||||
<Spinner size={20} />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.1rem', color: 'var(--text-on-light)' }}>Validating…</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginBottom: '.7rem' }}>
|
||||
{progress.file} — {progress.done.toLocaleString()} of {progress.total.toLocaleString()} rows
|
||||
</div>
|
||||
<ProgressBar value={progress.done} max={progress.total} />
|
||||
<div style={{ marginTop: '1.4rem', display: 'flex', alignItems: 'center', gap: '.6rem' }}>
|
||||
<Button variant="ghost" onLight onClick={() => { clearInterval(timer.current); setPhase('idle'); }}>Cancel</Button>
|
||||
<span style={{ fontSize: '.85rem', color: 'var(--text-on-light-soft)' }}>Canceling is free — nothing has been written.</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { 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)',
|
||||
}}>
|
||||
<input ref={fileInput} type="file" accept=".csv" style={{ display: 'none' }}
|
||||
onChange={() => { pick('canonical', 'spring-2026-pricing.csv'); }} />
|
||||
<div style={{ width: 54, height: 54, margin: '0 auto 1rem', borderRadius: 14, background: 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="upload" size={26} style={{ color: 'var(--wv-violet)' }} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.15rem', color: 'var(--text-on-light)' }}>Drop a CSV here, or click to choose</div>
|
||||
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.4rem' }}>CSV, up to 5,000 rows · 10 MB. Selecting a file starts validation right away.</div>
|
||||
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '1.1rem', borderTop: '1px solid var(--paper-line)', paddingTop: '1.1rem', maxWidth: 460, margin: '1.1rem auto 0' }}>
|
||||
Works with the <strong style={{ color: 'var(--text-on-light)' }}>canonical format</strong> or a <strong style={{ color: 'var(--text-on-light)' }}>Shopify product export</strong> — we detect which.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1.4rem', justifyContent: 'center', marginTop: '.9rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
<a onClick={(e) => { e.preventDefault(); IE_downloadText('wander-wool-sample.csv', IE_SAMPLE_CSV); toast('Sample CSV downloaded', 'success'); }} href="#" style={linkStyle}><Icon name="download" size={14} /> Sample CSV</a>
|
||||
<a onClick={(e) => e.preventDefault()} href="#" style={linkStyle}><Icon name="link" size={14} /> Column reference</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* file-level rejection */}
|
||||
{phase === 'rejected' && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '.7rem', marginTop: '1.2rem', padding: '1rem 1.2rem', borderRadius: '12px', background: 'var(--st-error-tint)', border: '1px solid var(--st-error-line)' }}>
|
||||
<Icon name="alert" size={19} style={{ color: 'var(--st-error)', marginTop: '.1rem' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-on-light)' }}>This file can't be imported as a catalog.</div>
|
||||
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.2rem' }}>It's missing the required column <code style={codeStyle}>Title</code> and matches no known dialect. Nothing was imported and no import run was recorded. Fix the file and try again.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sample files */}
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--text-on-light-soft)', marginBottom: '.8rem' }}>Or try a sample file</div>
|
||||
<div style={{ display: 'grid', gap: '.7rem' }}>
|
||||
{[...SAMPLE_FILES, REJECT_FILE].map((f) => (
|
||||
<button key={f.key} onClick={() => start({ ...f, rows: ROWS[f.key] })} className="ie-samplefile" style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', width: '100%', textAlign: 'left',
|
||||
background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)',
|
||||
borderRadius: '12px', padding: '.9rem 1.1rem', cursor: 'pointer', font: 'inherit',
|
||||
transition: 'border-color var(--dur-fast), transform var(--dur-fast)',
|
||||
}}>
|
||||
<span style={{ width: 38, height: 38, flexShrink: 0, borderRadius: 10, background: f.key === 'reject' ? 'var(--st-error-tint)' : 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name={f.icon} size={18} style={{ color: f.key === 'reject' ? 'var(--st-error)' : 'var(--wv-violet)' }} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: '.6rem', minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.96rem', color: 'var(--text-on-light)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<span style={{ fontSize: '.78rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap', flexShrink: 0 }}>{f.size}</span>
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: '.88rem', color: 'var(--text-on-light-soft)', marginTop: '.15rem' }}>{f.desc}</span>
|
||||
</span>
|
||||
<Icon name="arrowRight" size={16} style={{ color: 'var(--wv-violet)' }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================ PREVIEW ================================= */
|
||||
|
||||
function FieldRow({ label, value, note }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '1rem', padding: '.4rem 0', borderBottom: '1px solid var(--paper-line)' }}>
|
||||
<div style={{ width: 130, flexShrink: 0, fontSize: '.82rem', color: 'var(--text-on-light-soft)', fontWeight: 500 }}>{label}</div>
|
||||
<div style={{ flex: 1, fontSize: '.9rem', color: 'var(--text-on-light)' }}>
|
||||
{value}
|
||||
{note && <span style={{ display: 'block', fontSize: '.78rem', color: 'var(--st-update)', marginTop: '.2rem' }}>⚠ {note}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetail({ rec }) {
|
||||
const d = rec.detail || {};
|
||||
if (rec.kind === 'add') {
|
||||
return (
|
||||
<div style={{ padding: '.4rem 0 .2rem' }}>
|
||||
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--st-add)', marginBottom: '.5rem' }}>New product — fields to be set</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
{d.product.map((row, i) => <FieldRow key={i} label={row[0]} value={row[1]} note={row[2]} />)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1.4rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 280px' }}>
|
||||
<div style={{ fontSize: '.78rem', fontWeight: 600, color: 'var(--text-on-light-soft)', marginBottom: '.4rem' }}>Variants ({rec.variants}) · sample</div>
|
||||
<div style={{ display: 'grid', gap: '.3rem' }}>
|
||||
{d.variantsPreview.map((v, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '.6rem', fontSize: '.86rem', color: 'var(--text-on-light)' }}>
|
||||
<span style={{ minWidth: 130, fontWeight: 500 }}>{v[0]}</span>
|
||||
<span style={{ color: 'var(--text-on-light-soft)', flex: 1 }}>{v[1]}</span>
|
||||
<span style={{ fontWeight: 600 }}>{v[2]}</span>
|
||||
</div>
|
||||
))}
|
||||
{rec.variants > d.variantsPreview.length && <div style={{ fontSize: '.82rem', color: 'var(--text-on-light-soft)' }}>+ {rec.variants - d.variantsPreview.length} more variants</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: '.45em', color: 'var(--text-on-light-soft)', fontSize: '.86rem' }}>
|
||||
<Icon name="image" size={15} /> {d.images} image{d.images === 1 ? '' : 's'} to fetch
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rec.kind === 'update') {
|
||||
return (
|
||||
<div style={{ padding: '.4rem 0 .2rem' }}>
|
||||
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--st-update)', marginBottom: '.5rem' }}>Changes — before → after</div>
|
||||
<div style={{ display: 'grid', gap: '.1rem' }}>
|
||||
{d.changes.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '1rem', padding: '.55rem 0', borderBottom: '1px solid var(--paper-line)', alignItems: 'baseline', opacity: c.noop ? .6 : 1 }}>
|
||||
<div style={{ width: 170, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '.88rem', fontWeight: 600, color: 'var(--text-on-light)' }}>{c.field}</div>
|
||||
<div style={{ fontSize: '.76rem', color: 'var(--text-on-light-soft)' }}>{c.scope}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}><DiffPair before={c.before} after={c.after} noop={c.noop} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rec.kind === 'error') {
|
||||
return (
|
||||
<div style={{ padding: '.5rem 0 .2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '.6rem', padding: '.8rem 1rem', borderRadius: 10, background: 'var(--st-error-tint)', border: '1px solid var(--st-error-line)' }}>
|
||||
<Icon name="alert" size={17} style={{ color: 'var(--st-error)', marginTop: '.1rem' }} />
|
||||
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light)' }}>
|
||||
<span style={{ fontWeight: 600 }}>Row {rec.line} · {rec.column}</span>
|
||||
<div style={{ color: 'var(--text-on-light-soft)', marginTop: '.2rem' }}>{rec.message}</div>
|
||||
<div style={{ marginTop: '.4rem', fontSize: '.82rem', color: 'var(--text-on-light-soft)' }}>This product will not be imported. Correct the row and re-import — the valid products still go through.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div style={{ padding: '.6rem 0', fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>This product matches your catalog exactly — nothing to change.</div>;
|
||||
}
|
||||
|
||||
function PreviewRow({ rec, expanded, onToggle }) {
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid var(--paper-line)' }}>
|
||||
<button onClick={onToggle} className="ie-prow" style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', width: '100%', textAlign: 'left',
|
||||
padding: '.85rem 1.2rem', background: expanded ? 'var(--violet-tint)' : 'transparent',
|
||||
border: 'none', cursor: 'pointer', font: 'inherit', transition: 'background var(--dur-fast)',
|
||||
}}>
|
||||
<Icon name="chevron" size={15} style={{ color: 'var(--text-on-light-soft)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.96rem', color: 'var(--text-on-light)' }}>{rec.title}</div>
|
||||
<div style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', fontFamily: 'var(--font-body)' }}>{rec.handle}</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '.82rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>{rec.variants} variant{rec.variants === 1 ? '' : 's'}</span>
|
||||
<KindBadge kind={rec.kind} />
|
||||
</button>
|
||||
{expanded && <div style={{ padding: '0 1.2rem 1.1rem 2.4rem' }}><RecordDetail rec={rec} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorTable({ errors }) {
|
||||
return (
|
||||
<Card pad="0" style={{ overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text-on-light-soft)', fontSize: '.72rem', textTransform: 'uppercase', letterSpacing: '.06em' }}>
|
||||
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600, width: 70 }}>Line</th>
|
||||
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600, width: 150 }}>Column</th>
|
||||
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600 }}>What's wrong</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.map((e, i) => (
|
||||
<tr key={i} style={{ borderTop: '1px solid var(--paper-line)' }}>
|
||||
<td style={{ padding: '.8rem 1.2rem', fontVariantNumeric: 'tabular-nums', color: 'var(--st-error)', fontWeight: 600 }}>{e.line}</td>
|
||||
<td style={{ padding: '.8rem 1.2rem' }}><code style={codeStyle}>{e.column}</code></td>
|
||||
<td style={{ padding: '.8rem 1.2rem', color: 'var(--text-on-light)' }}>
|
||||
<span style={{ fontWeight: 600 }}>{e.title}</span> — {e.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ paddingBottom: 90 }}>
|
||||
<ScreenHeader
|
||||
eyebrow="Import preview"
|
||||
title={scenario.file}
|
||||
onBack={onCancel}
|
||||
right={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '.45em', padding: '.4rem .85rem', borderRadius: 999, background: scenario.dialectKind === 'shopify' ? 'var(--st-update-tint)' : 'var(--violet-tint)', border: `1px solid ${scenario.dialectKind === 'shopify' ? 'var(--st-update-line)' : 'var(--violet-line)'}` }}>
|
||||
<Icon name={scenario.dialectKind === 'shopify' ? 'refresh' : 'check'} size={14} style={{ color: scenario.dialectKind === 'shopify' ? 'var(--st-update)' : 'var(--wv-violet)' }} />
|
||||
<span style={{ fontSize: '.84rem', fontWeight: 600, color: 'var(--text-on-light)' }}>Recognized: {scenario.dialect}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* nothing-written reassurance */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.5em', fontSize: '.86rem', color: 'var(--text-on-light-soft)', marginBottom: '1.3rem', marginTop: '-.6rem' }}>
|
||||
<Icon name="check" size={14} style={{ color: 'var(--st-add)' }} /> Nothing has been written to your catalog yet. This is a preview.
|
||||
</div>
|
||||
|
||||
{/* warnings band */}
|
||||
{scenario.unknownColumns.length > 0 && (
|
||||
<div style={{ marginBottom: '1.3rem', borderRadius: '12px', background: 'var(--st-update-tint)', border: '1px solid var(--st-update-line)', overflow: 'hidden' }}>
|
||||
<button onClick={() => setWarnOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: '.6rem', width: '100%', padding: '.8rem 1.1rem', background: 'transparent', border: 'none', cursor: 'pointer', font: 'inherit', textAlign: 'left' }}>
|
||||
<Icon name="alert" size={17} style={{ color: 'var(--st-update)' }} />
|
||||
<span style={{ flex: 1, fontSize: '.92rem', color: 'var(--text-on-light)' }}>
|
||||
<strong style={{ fontWeight: 600 }}>{scenario.unknownColumns.length} columns won't be imported.</strong>
|
||||
<span style={{ color: 'var(--text-on-light-soft)' }}> They're recognized but have no home in your catalog — warned, never silently dropped.</span>
|
||||
</span>
|
||||
<Icon name="chevronDown" size={15} style={{ color: 'var(--text-on-light-soft)', transform: warnOpen ? 'none' : 'rotate(-90deg)', transition: 'transform var(--dur-fast)' }} />
|
||||
</button>
|
||||
{warnOpen && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.4rem', padding: '0 1.1rem 1rem 2.7rem' }}>
|
||||
{scenario.unknownColumns.map((c) => (
|
||||
<span key={c} style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', background: 'var(--surface-card-light)', border: '1px solid var(--paper-line-strong)', borderRadius: 999, padding: '.18rem .6rem', fontFamily: 'var(--font-body)', whiteSpace: 'nowrap' }}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* summary tiles */}
|
||||
<div style={{ display: 'flex', gap: '.8rem', marginBottom: '1.6rem', flexWrap: 'wrap' }}>
|
||||
<StatTile n={scenario.summary.add} label="To add" kind="add" active={active === 'add'} onClick={() => setActive('add')} disabled={scenario.summary.add === 0} />
|
||||
<StatTile n={scenario.summary.update} label="To update" kind="update" active={active === 'update'} onClick={() => setActive('update')} disabled={scenario.summary.update === 0} />
|
||||
<StatTile n={scenario.summary.unchanged} label="Unchanged" kind="unchanged" active={active === 'unchanged'} onClick={() => setActive('unchanged')} disabled={scenario.summary.unchanged === 0} />
|
||||
<StatTile n={scenario.summary.error} label="In error" kind="error" active={active === 'error'} onClick={() => setActive('error')} disabled={scenario.summary.error === 0} />
|
||||
</div>
|
||||
|
||||
{/* detail list / error table */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem', marginBottom: '.7rem' }}>
|
||||
<KindBadge kind={active} size="sm" />
|
||||
<span style={{ fontSize: '.86rem', color: 'var(--text-on-light-soft)' }}>
|
||||
{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.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{active === 'error'
|
||||
? <ErrorTable errors={records} />
|
||||
: (
|
||||
<Card pad="0" style={{ overflow: 'hidden' }}>
|
||||
{records.length === 0
|
||||
? <div style={{ padding: '1.6rem', textAlign: 'center', color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>Nothing in this group.</div>
|
||||
: records.map((rec) => <PreviewRow key={rec.handle} rec={rec} expanded={expanded.has(rec.handle)} onToggle={() => toggle(rec.handle)} />)}
|
||||
{extra > 0 && (
|
||||
<div style={{ padding: '.85rem 1.2rem', fontSize: '.86rem', color: 'var(--text-on-light-soft)', borderTop: '1px solid var(--paper-line)', textAlign: 'center' }}>
|
||||
+ {extra.toLocaleString()} more {active} {extra === 1 ? 'product' : 'products'} in this file
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* sticky footer */}
|
||||
<div style={{
|
||||
position: 'sticky', bottom: 0, marginTop: '1.6rem', marginLeft: -8, marginRight: -8,
|
||||
padding: '1rem 1.2rem', borderRadius: '14px',
|
||||
background: 'rgba(255,255,255,.9)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--paper-line-strong)', boxShadow: '0 -6px 20px rgba(14,18,48,.06)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light-soft)' }}>
|
||||
{scenario.noop
|
||||
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: '.4em' }}><Icon name="check" size={15} style={{ color: 'var(--st-add)' }} /> Nothing to change — your catalog already matches this file.</span>
|
||||
: <span><strong style={{ color: 'var(--text-on-light)', fontWeight: 600 }}>{applyCount.toLocaleString()} products</strong> will be added or updated{scenario.summary.error ? <span>, <strong style={{ color: 'var(--st-error)' }}>{scenario.summary.error} rows</strong> left in error</span> : ''}.</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.7rem' }}>
|
||||
<Button variant="ghost" onLight onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="primary" disabled={scenario.noop || applyCount === 0} onClick={() => onConfirm(scenario)}>
|
||||
<Icon name="check" size={16} /> Import {applyCount.toLocaleString()} products
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================== 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 (
|
||||
<div>
|
||||
<ScreenHeader
|
||||
eyebrow="Import run"
|
||||
title={scenario.file}
|
||||
onBack={onBack}
|
||||
right={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.5em', fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.95rem', color: statusColor }}>
|
||||
{phase === 'done' ? <span style={{ width: 9, height: 9, borderRadius: 999, background: statusColor }} /> : <Spinner size={15} />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)', marginTop: '-.7rem', marginBottom: '1.4rem' }}>
|
||||
Imported {run ? run.rel : 'just now'} by {window.IEData.STOREFRONT.email} · {scenario.dialect}
|
||||
</div>
|
||||
|
||||
{/* result summary */}
|
||||
<div style={{ display: 'flex', gap: '.8rem', marginBottom: '1.8rem', flexWrap: 'wrap' }}>
|
||||
<ResultTile n={counts.added} label="Products added" kind="add" />
|
||||
<ResultTile n={counts.updated} label="Products updated" kind="update" />
|
||||
<ResultTile n={counts.errors} label="Rows in error" kind="error" />
|
||||
</div>
|
||||
|
||||
{/* images section */}
|
||||
<section style={{ marginBottom: '1.8rem' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Images</h2>
|
||||
<Card>
|
||||
{phase !== 'done' && phase === 'applying' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', color: 'var(--text-on-light-soft)', fontSize: '.92rem' }}>
|
||||
<Spinner size={16} /> Applying catalog rows — the image fetch begins once products commit.
|
||||
</div>
|
||||
) : phase === 'fetching' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.7rem' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '.5em', fontWeight: 600, color: 'var(--text-on-light)' }}><Spinner size={15} /> Fetching images</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>{fetched.toLocaleString()} of {total.toLocaleString()}</span>
|
||||
</div>
|
||||
<ProgressBar value={fetched} max={total} />
|
||||
<div style={{ fontSize: '.84rem', color: 'var(--text-on-light-soft)', marginTop: '.7rem' }}>Running in the background — your catalog is already live. You can leave this page and come back.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '1.4rem', flexWrap: 'wrap', marginBottom: images.problems.length ? '1.2rem' : 0 }}>
|
||||
<SummaryStat label="Fetched" n={images.fetched} color="var(--st-add)" glyph="check" />
|
||||
<SummaryStat label="Rejected" n={images.problems.filter((p) => p.outcome === 'rejected').length} color="var(--st-error)" glyph="x" />
|
||||
<SummaryStat label="Failed" n={images.problems.filter((p) => p.outcome === 'failed').length} color="var(--st-error)" glyph="alert" />
|
||||
</div>
|
||||
{images.problems.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '.82rem', fontWeight: 600, color: 'var(--text-on-light-soft)', marginBottom: '.5rem' }}>Only problems are listed — these products show a placeholder until you re-import a corrected URL.</div>
|
||||
<div style={{ border: '1px solid var(--paper-line)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{images.problems.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '1rem', padding: '.8rem 1rem', borderTop: i ? '1px solid var(--paper-line)' : 'none', alignItems: 'flex-start' }}>
|
||||
<span style={{ marginTop: '.1rem' }}><KindBadge kind="error" size="sm" /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light)', fontWeight: 600 }}>{p.handle} <span style={{ color: 'var(--text-on-light-soft)', fontWeight: 400 }}>· {p.variant}</span></div>
|
||||
<div style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', fontFamily: 'var(--font-body)', wordBreak: 'break-all', margin: '.1rem 0' }}>{p.url}</div>
|
||||
<div style={{ fontSize: '.84rem', color: 'var(--st-error)' }}>{p.outcome === 'rejected' ? 'Rejected' : 'Failed'} — {p.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div style={{ fontSize: '.9rem', color: 'var(--st-add)' }}>All images fetched cleanly.</div>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* error table */}
|
||||
{scenario.records.error.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Rows in error <span style={{ fontWeight: 500, color: 'var(--text-on-light-soft)', fontSize: '1rem' }}>· unapplied</span></h2>
|
||||
<ErrorTable errors={scenario.records.error} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTile({ n, label, kind }) {
|
||||
const m = KIND_META[kind];
|
||||
return (
|
||||
<div style={{ flex: '1 1 0', minWidth: 120, background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)', borderRadius: 12, padding: '1rem 1.2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.4em', marginBottom: '.3rem' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: `var(--st-${m.cssvar})` }} />
|
||||
<span style={{ fontSize: '.74rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.8rem', color: kind === 'error' && n === 0 ? 'var(--text-on-light)' : `var(--st-${m.cssvar})` }}>{n.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStat({ label, n, color, glyph }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.55rem' }}>
|
||||
<span style={{ width: 30, height: 30, borderRadius: 8, background: color, opacity: .12, position: 'absolute' }} />
|
||||
<span style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', background: 'color-mix(in srgb, ' + color + ' 12%, transparent)' }}>
|
||||
<Icon name={glyph} size={16} style={{ color }} />
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ display: 'block', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', lineHeight: 1 }}>{n.toLocaleString()}</span>
|
||||
<span style={{ fontSize: '.78rem', color: 'var(--text-on-light-soft)' }}>{label}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 });
|
||||
@@ -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,"<p>Brushed recycled fleece, cut for layering.</p>",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,"<p>Heavyweight organic-canvas tote.</p>",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 (
|
||||
<div ref={ref} style={{
|
||||
position: 'absolute', top: 'calc(100% + .5rem)', right: 0, zIndex: 40, width: 246,
|
||||
background: 'var(--surface-card-light)', border: '1px solid var(--paper-line-strong)',
|
||||
borderRadius: '12px', boxShadow: '0 12px 32px rgba(14,18,48,.16)', padding: '.7rem',
|
||||
}}>
|
||||
<div style={{ fontSize: '.72rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)', padding: '.3rem .4rem .5rem' }}>Export — filter by status</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '.1rem' }}>
|
||||
{opts.map(([v, label]) => (
|
||||
<button key={v} onClick={() => setStatus(v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '.6rem', padding: '.5rem .5rem', borderRadius: 8,
|
||||
border: 'none', background: status === v ? 'var(--violet-tint)' : 'transparent', cursor: 'pointer',
|
||||
font: 'inherit', fontSize: '.92rem', color: 'var(--text-on-light)', textAlign: 'left',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 999, flexShrink: 0,
|
||||
border: `1.5px solid ${status === v ? 'var(--wv-violet)' : 'var(--paper-line-strong)'}`,
|
||||
display: 'grid', placeItems: 'center',
|
||||
}}>
|
||||
{status === v && <span style={{ width: 8, height: 8, borderRadius: 999, background: 'var(--wv-violet)' }} />}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--paper-line)', marginTop: '.5rem', paddingTop: '.6rem' }}>
|
||||
<button onClick={() => onExport(status)} style={{
|
||||
width: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '.45em',
|
||||
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.92rem', color: 'var(--cta-text)',
|
||||
background: 'var(--cta)', border: 'none', borderRadius: 999, padding: '.55rem 1rem', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<Icon name="download" size={15} /> Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.4em', fontSize: '.84rem', fontWeight: 600, color }}>
|
||||
{spin ? <Spinner size={13} /> : <span style={{ width: 7, height: 7, borderRadius: 999, background: color }} />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportHistory({ runs, onOpenRun }) {
|
||||
return (
|
||||
<section style={{ marginTop: '2.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '.7rem', marginBottom: '.9rem' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.25rem', color: 'var(--text-on-light)', margin: 0 }}>Import history</h2>
|
||||
<span style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>{runs.length} runs · newest first</span>
|
||||
</div>
|
||||
<Card pad="0" style={{ overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text-on-light-soft)', fontSize: '.74rem', textTransform: 'uppercase', letterSpacing: '.06em' }}>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>When</th>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>File</th>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Dialect</th>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600, textAlign: 'right' }}>Added / Updated / Errors</th>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Images</th>
|
||||
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r, i) => (
|
||||
<tr key={r.id} onClick={() => onOpenRun(r.id)} className="ie-hrow" style={{
|
||||
borderTop: '1px solid var(--paper-line)', cursor: 'pointer',
|
||||
}}>
|
||||
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>{r.rel}</td>
|
||||
<td style={{ padding: '.95rem 1.2rem' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.45em', color: 'var(--text-on-light)', fontWeight: 500 }}>
|
||||
<Icon name="file" size={15} style={{ color: 'var(--wv-violet)' }} /> {r.file}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)' }}>{r.dialect}</td>
|
||||
<td style={{ padding: '.95rem 1.2rem', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<span style={{ color: 'var(--st-add)', fontWeight: 600 }}>+{r.added}</span>
|
||||
<span style={{ color: 'var(--text-on-light-soft)', margin: '0 .35em' }}>·</span>
|
||||
<span style={{ color: 'var(--st-update)', fontWeight: 600 }}>±{r.updated}</span>
|
||||
<span style={{ color: 'var(--text-on-light-soft)', margin: '0 .35em' }}>·</span>
|
||||
<span style={{ color: r.errors ? 'var(--st-error)' : 'var(--text-on-light-soft)', fontWeight: 600 }}>!{r.errors}</span>
|
||||
</td>
|
||||
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>
|
||||
{(r.images.fetched + r.images.rejected + r.images.failed) > 0
|
||||
? <span><span style={{ color: 'var(--st-add)' }}>{r.images.fetched} ✓</span> · <span style={{ color: 'var(--st-error)' }}>{r.images.rejected + r.images.failed} ✗</span></span>
|
||||
: <span style={{ opacity: .6 }}>—</span>}
|
||||
</td>
|
||||
<td style={{ padding: '.95rem 1.2rem' }}><HistoryStatus status={r.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{/* header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Eyebrow onLight>Catalog</Eyebrow>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '2.1rem', letterSpacing: '-0.015em', color: 'var(--text-on-light)', margin: '.35rem 0 0' }}>
|
||||
Products <span style={{ color: 'var(--text-on-light-soft)', fontWeight: 500 }}>· {count.toLocaleString()}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.7rem', position: 'relative' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => !catalogEmpty && setMenuOpen((o) => !o)} disabled={catalogEmpty} title={catalogEmpty ? 'Nothing to export yet' : ''} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '.45em', fontFamily: 'var(--font-display)',
|
||||
fontWeight: 500, fontSize: '.92rem', color: 'var(--text-on-light)', background: 'var(--surface-card-light)',
|
||||
border: '1.5px solid var(--paper-line-strong)', borderRadius: 999, padding: '.55rem 1.1rem',
|
||||
cursor: catalogEmpty ? 'not-allowed' : 'pointer', opacity: catalogEmpty ? .5 : 1,
|
||||
}}>
|
||||
<Icon name="download" size={16} /> Export <Icon name="chevronDown" size={14} />
|
||||
</button>
|
||||
{menuOpen && <StatusFilterMenu onExport={doExport} onClose={() => setMenuOpen(false)} />}
|
||||
</div>
|
||||
<Button variant="primary" onClick={onImport}>
|
||||
<Icon name="upload" size={16} /> Import products
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{catalogEmpty
|
||||
? <span style={{ display: 'block', fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '.5rem' }}>An honest empty shell — nothing to manage yet.</span>
|
||||
: <span style={{ display: 'block', fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '.5rem' }}>One canonical CSV in, your whole catalog out — any time.</span>}
|
||||
|
||||
{/* notices band */}
|
||||
{!catalogEmpty && STOREFRONT.imageProblemCount > 0 && (
|
||||
<button onClick={() => onOpenRun(STOREFRONT.latestRunId)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%', textAlign: 'left',
|
||||
marginTop: '1.5rem', padding: '.85rem 1.1rem', borderRadius: '12px', cursor: 'pointer',
|
||||
background: 'var(--st-update-tint)', border: '1px solid var(--st-update-line)', font: 'inherit',
|
||||
}}>
|
||||
<Icon name="image" size={18} style={{ color: 'var(--st-update)' }} />
|
||||
<span style={{ flex: 1, color: 'var(--text-on-light)', fontSize: '.92rem' }}>
|
||||
<strong style={{ fontWeight: 600 }}>{STOREFRONT.imageProblemCount} products have image problems.</strong>
|
||||
<span style={{ color: 'var(--text-on-light-soft)' }}> Some images were rejected or unreachable in your latest import.</span>
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.3em', color: 'var(--st-update)', fontWeight: 600, fontSize: '.88rem' }}>
|
||||
View run <Icon name="arrowRight" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* primary content */}
|
||||
{catalogEmpty ? (
|
||||
<Card style={{ marginTop: '1.8rem', padding: '3rem 2rem', textAlign: 'center' }}>
|
||||
<div style={{ width: 56, height: 56, margin: '0 auto 1.1rem', borderRadius: 16, background: 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="products" size={26} style={{ color: 'var(--wv-violet)' }} />
|
||||
</div>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.4rem', color: 'var(--text-on-light)', margin: '0 0 .4rem' }}>No products yet</h2>
|
||||
<p style={{ fontSize: '1rem', color: 'var(--text-on-light-soft)', maxWidth: '40ch', margin: '0 auto 1.5rem', lineHeight: 1.55 }}>
|
||||
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.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onImport}>
|
||||
<Icon name="upload" size={16} /> Import products
|
||||
</Button>
|
||||
<div style={{ display: 'flex', gap: '1.6rem', justifyContent: 'center', marginTop: '1.5rem' }}>
|
||||
<a onClick={(e) => { e.preventDefault(); downloadText('wander-wool-sample.csv', SAMPLE_CSV); toast('Sample CSV downloaded', 'success'); }} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
|
||||
<Icon name="download" size={14} /> Download sample CSV
|
||||
</a>
|
||||
<a onClick={(e) => e.preventDefault()} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
|
||||
<Icon name="link" size={14} /> Column reference
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ marginTop: '1.8rem', display: 'flex', alignItems: 'center', gap: '1.2rem', padding: '1.4rem 1.6rem' }}>
|
||||
<div style={{ width: 46, height: 46, flexShrink: 0, borderRadius: 12, background: 'var(--add-tint)', display: 'grid', placeItems: 'center' }}>
|
||||
<Icon name="check" size={22} style={{ color: 'var(--st-add)' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.05rem', color: 'var(--text-on-light)' }}>Your catalog is loaded — {count.toLocaleString()} products.</div>
|
||||
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.15rem' }}>The browsable product list arrives with an upcoming release. For now, manage it in bulk: import to change it, export to take it.</div>
|
||||
</div>
|
||||
<span style={{ whiteSpace: 'nowrap' }}><Notice>List · soon</Notice></span>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* history */}
|
||||
{runs.length > 0
|
||||
? <ImportHistory runs={runs} onOpenRun={onOpenRun} />
|
||||
: !catalogEmpty ? null : (
|
||||
<section style={{ marginTop: '2.4rem' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.25rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Import history</h2>
|
||||
<Card style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-on-light-soft)' }}>No imports yet.</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ProductsPage = ProductsPage;
|
||||
window.IE_downloadText = downloadText;
|
||||
window.IE_SAMPLE_CSV = SAMPLE_CSV;
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => !item.soon && onClick(item.id)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
disabled={item.soon}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%',
|
||||
padding: '.6rem .8rem', borderRadius: '10px', border: '1px solid transparent',
|
||||
font: 'inherit', textAlign: 'left', cursor: item.soon ? 'default' : 'pointer',
|
||||
fontFamily: 'var(--font-body)', fontSize: '.95rem', fontWeight: active ? 600 : 500,
|
||||
color: active ? 'var(--wv-starlight)' : item.soon ? 'var(--wv-starlight-55)' : 'var(--wv-starlight-78)',
|
||||
background: active ? 'var(--wv-lilac-16)' : hover && !item.soon ? 'var(--wv-lilac-08)' : 'transparent',
|
||||
borderColor: active ? 'var(--wv-lilac-32)' : 'transparent',
|
||||
transition: 'background var(--dur-fast), color var(--dur-fast)',
|
||||
}}>
|
||||
<Icon name={item.icon} size={18} stroke={active ? 1.9 : 1.6}
|
||||
style={{ color: active ? 'var(--wv-lilac)' : 'inherit' }} />
|
||||
<span style={{ flex: 1 }}>{item.label}</span>
|
||||
{item.soon && <Notice>Soon</Notice>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminShell({ current, onNav, storefront, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--surface-paper)' }}>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<aside style={{
|
||||
width: 248, flexShrink: 0, position: 'sticky', top: 0, alignSelf: 'flex-start',
|
||||
height: '100vh', display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--wv-midnight)',
|
||||
backgroundImage:
|
||||
'radial-gradient(1px 1px at 38px 60px, rgba(237,234,255,.5), transparent),' +
|
||||
'radial-gradient(1px 1px at 180px 130px, rgba(237,234,255,.35), transparent),' +
|
||||
'radial-gradient(1.4px 1.4px at 90px 320px, rgba(244,199,107,.45), transparent),' +
|
||||
'radial-gradient(1px 1px at 200px 460px, rgba(237,234,255,.4), transparent),' +
|
||||
'radial-gradient(1px 1px at 50px 540px, rgba(155,140,255,.5), transparent)',
|
||||
borderRight: '1px solid var(--wv-lilac-16)',
|
||||
}}>
|
||||
{/* brand lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', padding: '1.25rem 1.25rem 1.1rem' }}>
|
||||
<CircleMark size={30} />
|
||||
<div style={{ lineHeight: 1.15 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '.98rem', color: 'var(--wv-starlight)', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{storefront.name}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '.6rem', fontWeight: 500, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--wv-starlight-55)', marginTop: '.12rem' }}>ecomm · admin</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '.2rem', padding: '0 .7rem', marginTop: '.3rem' }}>
|
||||
{NAV_ITEMS.map((it) => (
|
||||
<SidebarItem key={it.id} item={it} active={current === it.id} onClick={onNav} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ marginTop: 'auto', padding: '1.1rem 1.25rem 1.25rem', borderTop: '1px solid var(--wv-lilac-12)' }}>
|
||||
<Soul size="sm">Your catalog is yours — it can leave whole, any time.</Soul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Main column ---- */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* top header */}
|
||||
<header style={{
|
||||
position: 'sticky', top: 0, zIndex: 20,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '.85rem 2rem', background: 'rgba(246,244,251,.86)', backdropFilter: 'blur(10px)',
|
||||
borderBottom: '1px solid var(--paper-line)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', minWidth: 0 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 999, background: 'var(--st-add)', boxShadow: '0 0 0 3px rgba(31,138,91,.15)' }} />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.98rem', color: 'var(--text-on-light)', whiteSpace: 'nowrap' }}>{storefront.name}</span>
|
||||
<span style={{ fontSize: '.85rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>· live storefront</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<span style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>{storefront.email}</span>
|
||||
<button style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '.4em', font: 'inherit',
|
||||
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.85rem',
|
||||
color: 'var(--text-on-light)', background: 'transparent',
|
||||
border: '1.5px solid var(--paper-line-strong)', borderRadius: 999,
|
||||
padding: '.4rem .9rem', cursor: 'pointer',
|
||||
}}>
|
||||
<Icon name="signout" size={15} /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style={{ flex: 1, padding: '2.2rem 2rem 4rem' }}>
|
||||
<div style={{ maxWidth: 1080, margin: '0 auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { useState: _shUseState } = React;
|
||||
window.AdminShell = AdminShell;
|
||||
@@ -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 <input type="range">, 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 (
|
||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||
// Hello
|
||||
// <TweaksPanel>
|
||||
// <TweakSection label="Typography" />
|
||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||
// onChange={(v) => setTweak('fontSize', v)} />
|
||||
// <TweakRadio label="Density" value={t.density}
|
||||
// options={['compact', 'regular', 'comfy']}
|
||||
// onChange={(v) => setTweak('density', v)} />
|
||||
// <TweakSection label="Theme" />
|
||||
// <TweakColor label="Primary" value={t.primaryColor}
|
||||
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||
// <TweakColor label="Palette" value={t.palette}
|
||||
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||
// onChange={(v) => setTweak('palette', v)} />
|
||||
// <TweakToggle label="Dark mode" value={t.dark}
|
||||
// onChange={(v) => setTweak('dark', v)} />
|
||||
// </TweaksPanel>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||
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 (
|
||||
<>
|
||||
<style>{__TWEAKS_STYLE}</style>
|
||||
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||
<b>{title}</b>
|
||||
<button className="twk-x" aria-label="Close tweaks"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={dismiss}>✕</button>
|
||||
</div>
|
||||
<div className="twk-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSection({ label, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="twk-sect">{label}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRow({ label, value, children, inline = false }) {
|
||||
return (
|
||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||
<div className="twk-lbl">
|
||||
<span>{label}</span>
|
||||
{value != null && <span className="twk-val">{value}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||
return (
|
||||
<TweakRow label={label} value={`${value}${unit}`}>
|
||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||
role="switch" aria-checked={!!value}
|
||||
onClick={() => onChange(!value)}><i /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
// <select> emits strings — map back to the original option value so the
|
||||
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||
const resolve = (s) => {
|
||||
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||
};
|
||||
return <TweakSelect label={label} value={value} options={options}
|
||||
onChange={(s) => onChange(resolve(s))} />;
|
||||
}
|
||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||
const n = opts.length;
|
||||
|
||||
const segAt = (clientX) => {
|
||||
const r = trackRef.current.getBoundingClientRect();
|
||||
const inner = r.width - 4;
|
||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
setDragging(true);
|
||||
const v0 = segAt(e.clientX);
|
||||
if (v0 !== valueRef.current) onChange(v0);
|
||||
const move = (ev) => {
|
||||
if (!trackRef.current) return;
|
||||
const v = segAt(ev.clientX);
|
||||
if (v !== valueRef.current) onChange(v);
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||
<div className="twk-seg-thumb"
|
||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||
width: `calc((100% - 4px) / ${n})` }} />
|
||||
{opts.map((o) => (
|
||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'object' ? o.value : o;
|
||||
const l = typeof o === 'object' ? o.label : o;
|
||||
return <option key={v} value={v}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakText({ label, value, placeholder, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="twk-num">
|
||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||
<input type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }) => (
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<input type="color" className="twk-swatch" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Native <input type=color> 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 (
|
||||
<TweakRow label={label}>
|
||||
<div className="twk-chips" role="radiogroup">
|
||||
{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 (
|
||||
<button key={i} type="button" className="twk-chip" role="radio"
|
||||
aria-checked={on} data-on={on ? '1' : '0'}
|
||||
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||
style={{ background: hero }}
|
||||
onClick={() => onChange(o)}>
|
||||
{sup.length > 0 && (
|
||||
<span>
|
||||
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||
</span>
|
||||
)}
|
||||
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakButton({ label, onClick, secondary = false }) {
|
||||
return (
|
||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||
onClick={onClick}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||
});
|
||||
@@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={stroke} strokeLinecap="round"
|
||||
strokeLinejoin="round" style={{ flexShrink: 0, display: 'block', ...style }} {...rest}>
|
||||
<path d={ICON_PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 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 (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36" fill="none" style={{ display: 'block' }}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="4" y1="6" x2="32" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#9B8CFF" />
|
||||
<stop offset="1" stopColor="#F4C76B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={d} stroke={`url(#${id})`} strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{pts.map((p, i) => <circle key={i} cx={p[0]} cy={p[1]} r="2.1" fill="#EDEAFF" />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 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 (
|
||||
<span className="ie-kind" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '.34em',
|
||||
fontFamily: 'var(--font-display)', fontWeight: 600,
|
||||
fontSize: size === 'sm' ? '.72rem' : '.78rem',
|
||||
letterSpacing: '.01em', padding: pad, borderRadius: '999px',
|
||||
color: `var(--st-${m.cssvar})`, background: `var(--st-${m.cssvar}-tint)`,
|
||||
border: `1px solid var(--st-${m.cssvar}-line)`, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<Icon name={m.glyph} size={size === 'sm' ? 11 : 12} stroke={2.1} />
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Diff value: before → after ---------- */
|
||||
function DiffPair({ before, after, noop }) {
|
||||
if (noop) {
|
||||
return <span style={{ color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>{after} <span style={{ opacity: .6 }}>(unchanged)</span></span>;
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.5em', flexWrap: 'wrap', fontSize: '.9rem' }}>
|
||||
<span style={{ color: 'var(--text-on-light-soft)', textDecoration: 'line-through', textDecorationColor: 'var(--st-error)', textDecorationThickness: '1px', opacity: .8 }}>{before}</span>
|
||||
<Icon name="arrowRight" size={13} stroke={1.8} style={{ color: 'var(--text-on-light-soft)', opacity: .7 }} />
|
||||
<span style={{ color: 'var(--st-update)', fontWeight: 600 }}>{after}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Stat tile (summary filter) ---------- */
|
||||
function StatTile({ n, label, kind, active, onClick, disabled }) {
|
||||
const m = KIND_META[kind];
|
||||
const accent = `var(--st-${m.cssvar})`;
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className="ie-stat" data-active={active ? '1' : '0'} style={{
|
||||
flex: '1 1 0', minWidth: 0, textAlign: 'left', cursor: disabled ? 'default' : 'pointer',
|
||||
background: active ? `var(--st-${m.cssvar}-tint)` : 'var(--surface-card-light)',
|
||||
border: `1.5px solid ${active ? `var(--st-${m.cssvar}-line)` : 'var(--paper-line)'}`,
|
||||
borderRadius: '12px', padding: '.85rem 1rem .9rem', position: 'relative',
|
||||
transition: 'transform var(--dur-fast) var(--ease), border-color var(--dur-fast), background var(--dur-fast)',
|
||||
opacity: disabled ? .55 : 1, font: 'inherit',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.4em' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: accent, display: 'inline-block' }} />
|
||||
<span style={{ fontSize: '.74rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.9rem', lineHeight: 1.05, marginTop: '.3rem', color: kind === 'unchanged' ? 'var(--text-on-light)' : accent }}>
|
||||
{n.toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Progress bar ---------- */
|
||||
function ProgressBar({ value, max, accent = 'var(--wv-violet)', height = 8 }) {
|
||||
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||
return (
|
||||
<div style={{ background: 'var(--paper-line)', borderRadius: 999, height, overflow: 'hidden', width: '100%' }}>
|
||||
<div style={{ width: pct + '%', height: '100%', background: accent, borderRadius: 999, transition: 'width .3s ease' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Generic paper card ---------- */
|
||||
function Card({ children, style, pad = '1.5rem', ...rest }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)', borderRadius: '14px', padding: pad, ...style }} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Spinner (thin, geometric) ---------- */
|
||||
function Spinner({ size = 16, stroke = 2 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ display: 'block', animation: 'ie-spin .8s linear infinite' }}>
|
||||
<circle cx="12" cy="12" r="9" stroke="var(--paper-line)" strokeWidth={stroke} />
|
||||
<path d="M12 3a9 9 0 0 1 9 9" stroke="var(--wv-violet)" strokeWidth={stroke} strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
Icon, CircleMark, KindBadge, DiffPair, StatTile, ProgressBar, Card, Spinner, KIND_META,
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+180
File diff suppressed because one or more lines are too long
+378
@@ -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": "<BrandLockup> 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": "<BrandLockup> 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": "<BuildCard> 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": "<Button> doesn't accept that prop. Declared props: children, variant, href, onLight, disabled, type, onClick."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|ghost)$/]",
|
||||
"message": "<Button> variant must be one of 'primary' | 'ghost'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
|
||||
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Callout'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|key|ref|className|style|children)$/]",
|
||||
"message": "<Callout> doesn't accept that prop. Declared props: children, onLight."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Eyebrow'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|as|key|ref|className|style|children)$/]",
|
||||
"message": "<Eyebrow> doesn't accept that prop. Declared props: children, onLight, as."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Notice'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|key|ref|className|style|children)$/]",
|
||||
"message": "<Notice> doesn't accept that prop. Declared props: children."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='PathCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|children|href|first|key|ref|className|style|children)$/]",
|
||||
"message": "<PathCard> doesn't accept that prop. Declared props: icon, title, children, href, first."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='FooterColumn'] > JSXAttribute > JSXIdentifier[name!=/^(?:heading|links|key|ref|className|style|children)$/]",
|
||||
"message": "<FooterColumn> doesn't accept that prop. Declared props: heading, links."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavLink'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|href|cta|key|ref|className|style|children)$/]",
|
||||
"message": "<NavLink> doesn't accept that prop. Declared props: label, href, cta."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|tone|onLight|as|key|ref|className|style|children)$/]",
|
||||
"message": "<Soul> doesn't accept that prop. Declared props: children, size, tone, onLight, as."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<Soul> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:gold|plain|lilac)$/]",
|
||||
"message": "<Soul> tone must be one of 'gold' | 'plain' | 'lilac'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|onLight|key|ref|className|style|children)$/]",
|
||||
"message": "<Tag> doesn't accept that prop. Declared props: children, variant, onLight."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:live|soon)$/]",
|
||||
"message": "<Tag> variant must be one of 'live' | 'soon'."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {
|
||||
"BrandLockup": {
|
||||
"replaces": []
|
||||
},
|
||||
"BuildCard": {
|
||||
"replaces": []
|
||||
},
|
||||
"Button": {
|
||||
"replaces": []
|
||||
},
|
||||
"Callout": {
|
||||
"replaces": []
|
||||
},
|
||||
"Eyebrow": {
|
||||
"replaces": []
|
||||
},
|
||||
"Notice": {
|
||||
"replaces": []
|
||||
},
|
||||
"PathCard": {
|
||||
"replaces": []
|
||||
},
|
||||
"FooterColumn": {
|
||||
"replaces": []
|
||||
},
|
||||
"NavLink": {
|
||||
"replaces": []
|
||||
},
|
||||
"Soul": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tag": {
|
||||
"replaces": []
|
||||
}
|
||||
},
|
||||
"tokens": [
|
||||
"--accent",
|
||||
"--accent-on-light",
|
||||
"--border-card",
|
||||
"--border-card-w",
|
||||
"--border-hair",
|
||||
"--border-soft",
|
||||
"--border-strong",
|
||||
"--btn-border-w",
|
||||
"--cta",
|
||||
"--cta-hover",
|
||||
"--cta-text",
|
||||
"--dur-fast",
|
||||
"--dur-mid",
|
||||
"--ease",
|
||||
"--focus-ring",
|
||||
"--font-body",
|
||||
"--font-display",
|
||||
"--font-soul",
|
||||
"--glass-blur",
|
||||
"--glass-sky",
|
||||
"--leading-body",
|
||||
"--leading-tight",
|
||||
"--lift-1",
|
||||
"--lift-3",
|
||||
"--measure-lead",
|
||||
"--measure-prose",
|
||||
"--page-hero-pad",
|
||||
"--radius-card",
|
||||
"--radius-mark",
|
||||
"--radius-panel",
|
||||
"--radius-pill",
|
||||
"--radius-sm",
|
||||
"--section-pad",
|
||||
"--shadow-none",
|
||||
"--shadow-soft",
|
||||
"--space-0",
|
||||
"--space-1",
|
||||
"--space-10",
|
||||
"--space-12",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-5",
|
||||
"--space-6",
|
||||
"--space-8",
|
||||
"--surface-card-light",
|
||||
"--surface-footer",
|
||||
"--surface-paper",
|
||||
"--surface-raised",
|
||||
"--surface-raised-hi",
|
||||
"--surface-sky",
|
||||
"--text-body",
|
||||
"--text-eyebrow",
|
||||
"--text-fine",
|
||||
"--text-h1",
|
||||
"--text-h2",
|
||||
"--text-h3",
|
||||
"--text-lead",
|
||||
"--text-on-dark",
|
||||
"--text-on-dark-mute",
|
||||
"--text-on-dark-soft",
|
||||
"--text-on-light",
|
||||
"--text-on-light-soft",
|
||||
"--text-small",
|
||||
"--text-soul",
|
||||
"--text-tag",
|
||||
"--tracking-display",
|
||||
"--tracking-eyebrow",
|
||||
"--tracking-notice",
|
||||
"--tracking-tag",
|
||||
"--weight-bold",
|
||||
"--weight-medium",
|
||||
"--weight-regular",
|
||||
"--weight-semibold",
|
||||
"--weight-soul",
|
||||
"--wrap-gutter",
|
||||
"--wrap-max",
|
||||
"--wv-font-body",
|
||||
"--wv-font-display",
|
||||
"--wv-font-human",
|
||||
"--wv-gold",
|
||||
"--wv-gold-28",
|
||||
"--wv-gold-40",
|
||||
"--wv-gold-hi",
|
||||
"--wv-gold-ink",
|
||||
"--wv-gold-ink-soft",
|
||||
"--wv-indigo",
|
||||
"--wv-indigo-2",
|
||||
"--wv-ink",
|
||||
"--wv-lilac",
|
||||
"--wv-lilac-08",
|
||||
"--wv-lilac-12",
|
||||
"--wv-lilac-16",
|
||||
"--wv-lilac-18",
|
||||
"--wv-lilac-32",
|
||||
"--wv-midnight",
|
||||
"--wv-night",
|
||||
"--wv-paper",
|
||||
"--wv-starlight",
|
||||
"--wv-starlight-55",
|
||||
"--wv-starlight-60",
|
||||
"--wv-starlight-78",
|
||||
"--wv-starlight-85",
|
||||
"--wv-violet"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--wv-midnight": "color",
|
||||
"--wv-indigo": "color",
|
||||
"--wv-indigo-2": "color",
|
||||
"--wv-lilac": "color",
|
||||
"--wv-violet": "color",
|
||||
"--wv-gold": "color",
|
||||
"--wv-gold-hi": "color",
|
||||
"--wv-starlight": "color",
|
||||
"--wv-paper": "color",
|
||||
"--wv-ink": "color",
|
||||
"--wv-night": "color",
|
||||
"--wv-gold-ink": "color",
|
||||
"--wv-gold-ink-soft": "color",
|
||||
"--wv-lilac-08": "color",
|
||||
"--wv-lilac-12": "color",
|
||||
"--wv-lilac-16": "color",
|
||||
"--wv-lilac-18": "color",
|
||||
"--wv-lilac-32": "color",
|
||||
"--wv-gold-28": "color",
|
||||
"--wv-gold-40": "color",
|
||||
"--wv-starlight-85": "color",
|
||||
"--wv-starlight-78": "color",
|
||||
"--wv-starlight-60": "color",
|
||||
"--wv-starlight-55": "color",
|
||||
"--surface-sky": "color",
|
||||
"--surface-raised": "color",
|
||||
"--surface-raised-hi": "color",
|
||||
"--surface-paper": "color",
|
||||
"--surface-card-light": "color",
|
||||
"--surface-footer": "color",
|
||||
"--text-on-dark": "font",
|
||||
"--text-on-dark-soft": "font",
|
||||
"--text-on-dark-mute": "font",
|
||||
"--text-on-light": "font",
|
||||
"--text-on-light-soft": "font",
|
||||
"--accent": "color",
|
||||
"--accent-on-light": "color",
|
||||
"--cta": "color",
|
||||
"--cta-hover": "color",
|
||||
"--cta-text": "font",
|
||||
"--border-soft": "color",
|
||||
"--border-card": "color",
|
||||
"--border-strong": "color",
|
||||
"--focus-ring": "color",
|
||||
"--wv-font-display": "font",
|
||||
"--wv-font-body": "font",
|
||||
"--wv-font-human": "font",
|
||||
"--font-display": "font",
|
||||
"--font-body": "font",
|
||||
"--font-soul": "font",
|
||||
"--weight-regular": "font",
|
||||
"--weight-medium": "font",
|
||||
"--weight-semibold": "font",
|
||||
"--weight-bold": "font",
|
||||
"--weight-soul": "font",
|
||||
"--text-h1": "font",
|
||||
"--text-h2": "font",
|
||||
"--text-h3": "font",
|
||||
"--text-lead": "font",
|
||||
"--text-soul": "font",
|
||||
"--text-body": "font",
|
||||
"--text-small": "font",
|
||||
"--text-fine": "font",
|
||||
"--text-eyebrow": "font",
|
||||
"--text-tag": "font",
|
||||
"--leading-tight": "font",
|
||||
"--leading-body": "font",
|
||||
"--tracking-display": "font",
|
||||
"--tracking-eyebrow": "font",
|
||||
"--tracking-tag": "font",
|
||||
"--tracking-notice": "font",
|
||||
"--measure-lead": "spacing",
|
||||
"--measure-prose": "spacing",
|
||||
"--space-0": "spacing",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-5": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--space-10": "spacing",
|
||||
"--space-12": "spacing",
|
||||
"--section-pad": "spacing",
|
||||
"--page-hero-pad": "spacing",
|
||||
"--wrap-max": "spacing",
|
||||
"--wrap-gutter": "spacing",
|
||||
"--radius-card": "radius",
|
||||
"--radius-panel": "radius",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-pill": "radius",
|
||||
"--radius-mark": "radius",
|
||||
"--border-hair": "spacing",
|
||||
"--border-card-w": "spacing",
|
||||
"--btn-border-w": "spacing",
|
||||
"--shadow-none": "shadow",
|
||||
"--shadow-soft": "shadow",
|
||||
"--lift-1": "other",
|
||||
"--lift-3": "other",
|
||||
"--glass-sky": "color",
|
||||
"--glass-blur": "spacing",
|
||||
"--ease": "other",
|
||||
"--dur-fast": "other",
|
||||
"--dur-mid": "other"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Fraunces",
|
||||
"Inter",
|
||||
"Space Grotesk"
|
||||
]
|
||||
}
|
||||
}
|
||||
+959
@@ -0,0 +1,959 @@
|
||||
/* @ds-bundle: {"format":3,"namespace":"WiggleverseDesignSystem_94cd80","components":[{"name":"BrandLockup","sourcePath":"components/brand/BrandLockup.jsx"},{"name":"BuildCard","sourcePath":"components/cards/BuildCard.jsx"},{"name":"PathCard","sourcePath":"components/cards/PathCard.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Callout","sourcePath":"components/core/Callout.jsx"},{"name":"Eyebrow","sourcePath":"components/core/Eyebrow.jsx"},{"name":"Notice","sourcePath":"components/core/Notice.jsx"},{"name":"Soul","sourcePath":"components/core/Soul.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"SiteFooter","sourcePath":"components/navigation/SiteFooter.jsx"},{"name":"SiteHeader","sourcePath":"components/navigation/SiteHeader.jsx"}],"sourceHashes":{"components/brand/BrandLockup.jsx":"70ef76a92b84","components/cards/BuildCard.jsx":"f4588d77bd79","components/cards/PathCard.jsx":"f8779016b751","components/core/Button.jsx":"58601f356762","components/core/Callout.jsx":"4347df19558c","components/core/Eyebrow.jsx":"38c0374f060f","components/core/Notice.jsx":"471101cf1c07","components/core/Soul.jsx":"d1b2ddeea071","components/core/Tag.jsx":"192ca2a7e57d","components/navigation/SiteFooter.jsx":"61fbdd9cf145","components/navigation/SiteHeader.jsx":"6541bfe8e43a","ui_kits/wiggleverse-www/app.jsx":"f0cea1954d57"},"inlinedExternals":[],"unexposedExports":[]} */
|
||||
|
||||
(() => {
|
||||
|
||||
const __ds_ns = (window.WiggleverseDesignSystem_94cd80 = window.WiggleverseDesignSystem_94cd80 || {});
|
||||
|
||||
const __ds_scope = {};
|
||||
|
||||
(__ds_ns.__errors = __ds_ns.__errors || []);
|
||||
|
||||
// components/brand/BrandLockup.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse BrandLockup — the mark + "Wiggleverse" wordmark, linked home.
|
||||
* 'primary' uses the gradient mark on dark; 'footer' uses the mono-gold mark;
|
||||
* 'onLight' uses the ink mark for paper. Wordmark is Space Grotesk 700, never italic.
|
||||
*/
|
||||
function BrandLockup({
|
||||
variant = 'primary',
|
||||
size = 34,
|
||||
href = '/',
|
||||
showWordmark = true,
|
||||
assetBase = '/assets',
|
||||
...rest
|
||||
}) {
|
||||
const marks = {
|
||||
primary: 'wiggleverse-mark.svg',
|
||||
footer: 'mark-mono-gold.svg',
|
||||
onLight: 'mark-on-light.svg'
|
||||
};
|
||||
const wordColor = variant === 'onLight' ? 'var(--wv-ink)' : 'var(--wv-starlight)';
|
||||
// Resolve asset relative to the given base so the lockup works from any depth.
|
||||
const src = `${assetBase}/${marks[variant] || marks.primary}`;
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
"aria-label": "Wiggleverse home",
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '.55rem',
|
||||
textDecoration: 'none'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("img", {
|
||||
src: src,
|
||||
alt: "",
|
||||
width: size,
|
||||
height: size,
|
||||
style: {
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}), showWordmark && /*#__PURE__*/React.createElement("span", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: `${Math.round(size * 0.0338 * 10) / 10}rem`,
|
||||
letterSpacing: 'var(--tracking-display)',
|
||||
color: wordColor
|
||||
}
|
||||
}, "Wiggleverse"));
|
||||
}
|
||||
Object.assign(__ds_scope, { BrandLockup });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/brand/BrandLockup.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/cards/BuildCard.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse BuildCard — a portfolio item on a paper section. White card with
|
||||
* a violet hairline, a status Tag, title, and copy. Built for light backgrounds.
|
||||
*/
|
||||
function BuildCard({
|
||||
tag,
|
||||
title,
|
||||
children,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("div", _extends({
|
||||
style: {
|
||||
padding: '1.4rem',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid rgba(124,111,224,.3)',
|
||||
background: 'var(--surface-card-light)'
|
||||
}
|
||||
}, rest), tag && /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
marginBottom: '.6rem'
|
||||
}
|
||||
}, tag), /*#__PURE__*/React.createElement("h3", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: 'var(--text-h3)',
|
||||
margin: '0 0 .35rem',
|
||||
color: 'var(--wv-ink)',
|
||||
letterSpacing: 'var(--tracking-display)'
|
||||
}
|
||||
}, title), /*#__PURE__*/React.createElement("p", {
|
||||
style: {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-small)',
|
||||
lineHeight: 1.55,
|
||||
color: 'var(--text-on-light-soft)'
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { BuildCard });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/BuildCard.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/cards/PathCard.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse PathCard — the audience-router doorway. A raised indigo card with
|
||||
* a glyph icon, title, and one line of copy. Hover lifts -3px and the border
|
||||
* warms to lilac. 'first' gives it a gold border (the recommended door).
|
||||
*/
|
||||
function PathCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
href = '#',
|
||||
first = false,
|
||||
...rest
|
||||
}) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
onMouseEnter: () => setHover(true),
|
||||
onMouseLeave: () => setHover(false),
|
||||
style: {
|
||||
display: 'block',
|
||||
padding: '1.2rem',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: hover ? 'var(--surface-raised-hi)' : 'var(--surface-raised)',
|
||||
border: `1px solid ${first ? 'var(--wv-gold)' : hover ? 'var(--wv-lilac)' : 'var(--border-card)'}`,
|
||||
color: 'var(--wv-starlight)',
|
||||
textDecoration: 'none',
|
||||
transform: hover ? 'var(--lift-3)' : 'none',
|
||||
transition: 'transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease)'
|
||||
}
|
||||
}, rest), icon && /*#__PURE__*/React.createElement("div", {
|
||||
"aria-hidden": "true",
|
||||
style: {
|
||||
fontSize: '1.5rem'
|
||||
}
|
||||
}, icon), /*#__PURE__*/React.createElement("h3", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-bold)',
|
||||
fontSize: 'var(--text-h3)',
|
||||
margin: '.5rem 0 .25rem',
|
||||
color: 'var(--wv-starlight)',
|
||||
letterSpacing: 'var(--tracking-display)'
|
||||
}
|
||||
}, title), /*#__PURE__*/React.createElement("p", {
|
||||
style: {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-fine)',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--wv-starlight-78)'
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { PathCard });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/PathCard.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Button.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Button — pill action in the machine register (Space Grotesk 500).
|
||||
* Primary = gold horizon CTA; ghost = lilac-outlined. Hover lifts -1px (never a shadow).
|
||||
*/
|
||||
function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
href,
|
||||
onLight = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
onClick,
|
||||
...rest
|
||||
}) {
|
||||
const base = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '.4em',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: 'var(--text-body)',
|
||||
lineHeight: 1,
|
||||
padding: '.7rem 1.25rem',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: 'var(--btn-border-w) solid transparent',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
textDecoration: 'none',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: 'transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)'
|
||||
};
|
||||
const variants = {
|
||||
primary: {
|
||||
background: 'var(--cta)',
|
||||
color: 'var(--cta-text)'
|
||||
},
|
||||
ghost: {
|
||||
background: 'transparent',
|
||||
borderColor: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
|
||||
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
|
||||
}
|
||||
};
|
||||
const style = {
|
||||
...base,
|
||||
...(variants[variant] || variants.primary)
|
||||
};
|
||||
const handleEnter = e => {
|
||||
if (!disabled) e.currentTarget.style.transform = 'var(--lift-1)';
|
||||
};
|
||||
const handleLeave = e => {
|
||||
e.currentTarget.style.transform = 'none';
|
||||
};
|
||||
const common = {
|
||||
style,
|
||||
onMouseEnter: handleEnter,
|
||||
onMouseLeave: handleLeave,
|
||||
...rest
|
||||
};
|
||||
if (href && !disabled) {
|
||||
return /*#__PURE__*/React.createElement("a", _extends({
|
||||
href: href,
|
||||
onClick: onClick
|
||||
}, common), children);
|
||||
}
|
||||
return /*#__PURE__*/React.createElement("button", _extends({
|
||||
type: type,
|
||||
disabled: disabled,
|
||||
onClick: onClick
|
||||
}, common), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Button });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Button.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Eyebrow.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Eyebrow — uppercase, letter-spaced kicker in the machine register
|
||||
* that sits above a section heading. Lilac on dark, violet on paper.
|
||||
*/
|
||||
function Eyebrow({
|
||||
children,
|
||||
onLight = false,
|
||||
as = 'p',
|
||||
...rest
|
||||
}) {
|
||||
const Tag = as;
|
||||
return /*#__PURE__*/React.createElement(Tag, _extends({
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: 'var(--text-eyebrow)',
|
||||
letterSpacing: 'var(--tracking-eyebrow)',
|
||||
textTransform: 'uppercase',
|
||||
color: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
|
||||
margin: '0 0 1rem'
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Eyebrow });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Eyebrow.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Notice.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Notice — a hollow gold pill set inline beside a heading to mark
|
||||
* roadmap status ("In build", "Coming"). Quieter than a filled Tag.
|
||||
*/
|
||||
function Notice({
|
||||
children,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("span", _extends({
|
||||
style: {
|
||||
display: 'inline-block',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: '.82rem',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
letterSpacing: 'var(--tracking-notice)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--wv-gold)',
|
||||
border: '1px solid var(--wv-gold-40)',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: '.25rem .8rem',
|
||||
verticalAlign: 'middle'
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Notice });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Notice.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Soul.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Soul — the human register. Fraunces ITALIC ONLY, used sparingly
|
||||
* for the lines that carry conscience (taglines, pull-quotes). Gold by default
|
||||
* (hero/page-hero) or starlight/ink when 'plain'.
|
||||
*/
|
||||
function Soul({
|
||||
children,
|
||||
size = 'md',
|
||||
tone = 'gold',
|
||||
onLight = false,
|
||||
as = 'span',
|
||||
...rest
|
||||
}) {
|
||||
const Tag = as;
|
||||
const sizes = {
|
||||
sm: '1.1rem',
|
||||
md: 'var(--text-soul)',
|
||||
/* clamp(1.2rem, 2.4vw, 1.7rem) */
|
||||
lg: 'clamp(1.4rem, 3vw, 2rem)'
|
||||
};
|
||||
const tones = {
|
||||
gold: 'var(--wv-gold)',
|
||||
plain: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)',
|
||||
lilac: 'var(--wv-lilac)'
|
||||
};
|
||||
return /*#__PURE__*/React.createElement(Tag, _extends({
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-human)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 'var(--weight-soul)',
|
||||
fontSize: sizes[size] || sizes.md,
|
||||
lineHeight: 1.3,
|
||||
color: tones[tone] || tones.gold
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Soul });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Soul.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Callout.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Callout — a gold left-rule framing a single conscience line.
|
||||
* No box, no fill: just a 3px gold border and a Fraunces italic quote.
|
||||
*/
|
||||
function Callout({
|
||||
children,
|
||||
onLight = false,
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("div", _extends({
|
||||
style: {
|
||||
borderLeft: '3px solid var(--wv-gold)',
|
||||
padding: '.4rem 0 .4rem 1.1rem',
|
||||
margin: '1.5rem 0'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
|
||||
size: "lg",
|
||||
tone: "plain",
|
||||
onLight: onLight,
|
||||
as: "p",
|
||||
style: {
|
||||
margin: 0
|
||||
}
|
||||
}, children));
|
||||
}
|
||||
Object.assign(__ds_scope, { Callout });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Callout.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/core/Tag.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
/**
|
||||
* Wiggleverse Tag — small filled pill marking a product's status on cards.
|
||||
* 'live' = lilac tint (first product); 'soon' = gold tint (coming).
|
||||
*/
|
||||
function Tag({
|
||||
children,
|
||||
variant = 'live',
|
||||
onLight = false,
|
||||
...rest
|
||||
}) {
|
||||
const base = {
|
||||
display: 'inline-block',
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: 'var(--text-tag)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
letterSpacing: 'var(--tracking-tag)',
|
||||
textTransform: 'uppercase',
|
||||
padding: '.15rem .55rem',
|
||||
borderRadius: 'var(--radius-pill)'
|
||||
};
|
||||
const variants = {
|
||||
live: {
|
||||
background: 'var(--wv-lilac-16)',
|
||||
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
|
||||
},
|
||||
soon: {
|
||||
background: 'var(--wv-gold-28)',
|
||||
color: 'var(--wv-gold-ink-soft)'
|
||||
}
|
||||
};
|
||||
return /*#__PURE__*/React.createElement("span", _extends({
|
||||
style: {
|
||||
...base,
|
||||
...(variants[variant] || variants.live)
|
||||
}
|
||||
}, rest), children);
|
||||
}
|
||||
Object.assign(__ds_scope, { Tag });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Tag.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/navigation/SiteFooter.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
const DEFAULT_COLUMNS = [{
|
||||
heading: 'Explore',
|
||||
links: [{
|
||||
label: 'Build',
|
||||
href: '/building/'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '/partner/'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '/learn/'
|
||||
}]
|
||||
}, {
|
||||
heading: 'The org',
|
||||
links: [{
|
||||
label: 'About',
|
||||
href: '/about/'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '/give/'
|
||||
}, {
|
||||
label: 'Finances',
|
||||
href: '/finances/'
|
||||
}, {
|
||||
label: 'Code of Conduct',
|
||||
href: '/code-of-conduct/'
|
||||
}]
|
||||
}];
|
||||
const DEFAULT_LEGAL = "Wiggleverse.org, Inc. — a Washington 501(c)(3) nonprofit creating art and software that supports humanity on the path to world peace. Dedicated to Aaron Swartz, who believed knowledge belongs to everyone.";
|
||||
|
||||
/**
|
||||
* Wiggleverse SiteFooter — deepest ground (night). Mono-gold lockup, a lilac
|
||||
* Soul tagline, link columns, and the dedication legal line.
|
||||
*/
|
||||
function SiteFooter({
|
||||
columns = DEFAULT_COLUMNS,
|
||||
tagline = 'We are verbs, not nouns.',
|
||||
legal = DEFAULT_LEGAL,
|
||||
assetBase = '/assets',
|
||||
...rest
|
||||
}) {
|
||||
const wrap = {
|
||||
width: 'min(var(--wrap-max), 92vw)',
|
||||
margin: '0 auto'
|
||||
};
|
||||
return /*#__PURE__*/React.createElement("footer", _extends({
|
||||
style: {
|
||||
background: 'var(--surface-footer)',
|
||||
borderTop: '1px solid var(--border-soft)',
|
||||
padding: '3rem 0 2.5rem',
|
||||
fontSize: '.92rem'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
...wrap,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2rem 3rem',
|
||||
justifyContent: 'space-between'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
maxWidth: '22rem'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
|
||||
variant: "footer",
|
||||
size: 30,
|
||||
assetBase: assetBase
|
||||
}), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
|
||||
size: "sm",
|
||||
tone: "lilac",
|
||||
as: "span",
|
||||
style: {
|
||||
display: 'block',
|
||||
marginTop: '.6rem'
|
||||
}
|
||||
}, tagline)), columns.map(col => /*#__PURE__*/React.createElement("div", {
|
||||
key: col.heading
|
||||
}, /*#__PURE__*/React.createElement("h4", {
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontSize: '.8rem',
|
||||
letterSpacing: '.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--wv-lilac)',
|
||||
margin: '0 0 .7rem'
|
||||
}
|
||||
}, col.heading), /*#__PURE__*/React.createElement("ul", {
|
||||
style: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}
|
||||
}, col.links.map(l => /*#__PURE__*/React.createElement("li", {
|
||||
key: l.href,
|
||||
style: {
|
||||
marginBottom: '.45rem'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("a", {
|
||||
href: l.href,
|
||||
style: {
|
||||
color: 'var(--wv-starlight-85)',
|
||||
textDecoration: 'none'
|
||||
}
|
||||
}, l.label))))))), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
...wrap,
|
||||
marginTop: '2.2rem',
|
||||
paddingTop: '1.3rem',
|
||||
borderTop: '1px solid var(--wv-lilac-12)',
|
||||
color: 'var(--wv-starlight-60)',
|
||||
fontSize: '.85rem'
|
||||
}
|
||||
}, legal));
|
||||
}
|
||||
Object.assign(__ds_scope, { SiteFooter });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteFooter.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// components/navigation/SiteHeader.jsx
|
||||
try { (() => {
|
||||
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
||||
const DEFAULT_LINKS = [{
|
||||
label: 'About',
|
||||
href: '/about/'
|
||||
}, {
|
||||
label: 'Build',
|
||||
href: '/building/'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '/partner/'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '/learn/'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '/give/'
|
||||
}];
|
||||
|
||||
/**
|
||||
* Wiggleverse SiteHeader — sticky glass nav: brand lockup + machine-register
|
||||
* links. Translucent midnight with a blur and a lilac hairline underneath.
|
||||
*/
|
||||
function SiteHeader({
|
||||
links = DEFAULT_LINKS,
|
||||
current,
|
||||
assetBase = '/assets',
|
||||
homeHref = '/',
|
||||
...rest
|
||||
}) {
|
||||
return /*#__PURE__*/React.createElement("header", _extends({
|
||||
style: {
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
background: 'var(--glass-sky)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderBottom: '1px solid var(--border-soft)'
|
||||
}
|
||||
}, rest), /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
width: 'min(var(--wrap-max), 92vw)',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '.7rem 0'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
|
||||
variant: "primary",
|
||||
assetBase: assetBase,
|
||||
href: homeHref
|
||||
}), /*#__PURE__*/React.createElement("nav", null, /*#__PURE__*/React.createElement("ul", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.2rem',
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}
|
||||
}, links.map(l => {
|
||||
const isCurrent = current === l.href || current === l.label;
|
||||
return /*#__PURE__*/React.createElement("li", {
|
||||
key: l.href
|
||||
}, /*#__PURE__*/React.createElement("a", {
|
||||
href: l.href,
|
||||
"aria-current": isCurrent ? 'page' : undefined,
|
||||
style: {
|
||||
fontFamily: 'var(--wv-font-display)',
|
||||
fontWeight: 'var(--weight-medium)',
|
||||
fontSize: '.95rem',
|
||||
color: l.cta ? 'var(--wv-gold)' : 'var(--wv-starlight)',
|
||||
textDecoration: isCurrent ? 'underline' : 'none',
|
||||
textUnderlineOffset: '4px',
|
||||
textDecorationColor: 'var(--wv-lilac)'
|
||||
}
|
||||
}, l.label));
|
||||
})))));
|
||||
}
|
||||
Object.assign(__ds_scope, { SiteHeader });
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteHeader.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
// ui_kits/wiggleverse-www/app.jsx
|
||||
try { (() => {
|
||||
/* Wiggleverse marketing-site recreation. Composes the DS components and routes
|
||||
between screens by intercepting in-app anchor clicks. */
|
||||
const {
|
||||
Button,
|
||||
Tag,
|
||||
Notice,
|
||||
Eyebrow,
|
||||
Soul,
|
||||
Callout,
|
||||
PathCard,
|
||||
BuildCard,
|
||||
SiteHeader,
|
||||
SiteFooter
|
||||
} = window.WiggleverseDesignSystem_94cd80;
|
||||
const ASSET_BASE = '../../assets';
|
||||
const NAV = [{
|
||||
label: 'About',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Build',
|
||||
href: '#/ecomm'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '#/finances',
|
||||
cta: true
|
||||
}];
|
||||
const FOOTER_COLS = [{
|
||||
heading: 'Explore',
|
||||
links: [{
|
||||
label: 'Build',
|
||||
href: '#/ecomm'
|
||||
}, {
|
||||
label: 'Partner',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Learn',
|
||||
href: '#/finances'
|
||||
}]
|
||||
}, {
|
||||
heading: 'The org',
|
||||
links: [{
|
||||
label: 'About',
|
||||
href: '#/about'
|
||||
}, {
|
||||
label: 'Give',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Finances',
|
||||
href: '#/finances'
|
||||
}, {
|
||||
label: 'Code of Conduct',
|
||||
href: '#/about'
|
||||
}]
|
||||
}];
|
||||
|
||||
/* ---------- Home ---------- */
|
||||
function HomeScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__sky",
|
||||
"aria-hidden": "true"
|
||||
}, /*#__PURE__*/React.createElement("img", {
|
||||
className: "hero__mark",
|
||||
src: ASSET_BASE + '/wiggleverse-mark.svg',
|
||||
alt: ""
|
||||
})), /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__inner"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "A 501(c)(3) nonprofit \xB7 art & software for a world at peace"), /*#__PURE__*/React.createElement("h1", null, "Ethical alternatives to the software that connects you to other humans."), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "span",
|
||||
className: "hero__soul"
|
||||
}, "Treat humans as humans \u2014 everything else follows."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "A wise world where people and machines build together on one shared set of ethics. Low-cost commerce for small businesses. Tools for moving toward agreement on shared meaning. A place where, if you're learning, you're succeeding."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "hero__cta"
|
||||
}, /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "primary",
|
||||
href: "#/about"
|
||||
}, "Why Wiggleverse \u2192"), /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "ghost",
|
||||
href: "#/ecomm"
|
||||
}, "See what we're building"))))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Find your way in"), /*#__PURE__*/React.createElement("h2", null, "I'm here as a\u2026"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Wiggleverse is an umbrella. Pick the door that fits \u2014 each leads to its own part of the world."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "router__grid"
|
||||
}, /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u2727",
|
||||
title: "Curious human",
|
||||
href: "#/about",
|
||||
first: true
|
||||
}, "What is this, and why does it matter? Start with the story."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25C7",
|
||||
title: "Small business",
|
||||
href: "#/ecomm"
|
||||
}, "People and families who join, sell, and contribute. Near-zero-cost commerce."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25C8",
|
||||
title: "Builder",
|
||||
href: "#/about"
|
||||
}, "A developer who wants to build ethical software? We'd love your help."), /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u274D",
|
||||
title: "Funder",
|
||||
href: "#/finances"
|
||||
}, "Donors, foundations, and grantmakers supporting a 501(c)(3) at work.")))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section section--paper"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, {
|
||||
onLight: true
|
||||
}, "One set of principles, many alternatives"), /*#__PURE__*/React.createElement("h2", null, "What we're building"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Ethical alternatives to the platforms people rely on every day \u2014 every one built on the same shared set of principles."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "build__grid"
|
||||
}, /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "live",
|
||||
onLight: true
|
||||
}, "First product"),
|
||||
title: "Ecomm"
|
||||
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. ", /*#__PURE__*/React.createElement("a", {
|
||||
href: "#/ecomm"
|
||||
}, "Why ecomm first \u2192")), /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "soon"
|
||||
}, "Coming"),
|
||||
title: "Apps"
|
||||
}, "A branded mobile app of their own for every small business \u2014 so they meet their people directly, instead of renting space inside someone else's platform."), /*#__PURE__*/React.createElement(BuildCard, {
|
||||
tag: /*#__PURE__*/React.createElement(Tag, {
|
||||
variant: "soon"
|
||||
}, "Coming"),
|
||||
title: "Learn"
|
||||
}, "A place where anyone \u2014 including small businesses who share our ethics \u2014 can post educational content that's true, right-sized, and actually teaches.")))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Open Core \xB7 a partner ecosystem"), /*#__PURE__*/React.createElement("h2", null, "Built on an open core"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "The platform's core is open \u2014 anyone can inspect it, extend it, and build on it. Because no two businesses run the same way, we don't sell one-size-fits-all \u2014 we grow a partner ecosystem that builds software shaped to the work it's actually for."), /*#__PURE__*/React.createElement("div", {
|
||||
className: "router__grid"
|
||||
}, /*#__PURE__*/React.createElement(PathCard, {
|
||||
icon: "\u25D0",
|
||||
title: "Partner",
|
||||
href: "#/about",
|
||||
first: true
|
||||
}, "Build custom software on an open platform \u2014 value flowing to the builders and the served, never extracted by a platform in the middle. Why custom-on-open is the future \u2192")))));
|
||||
}
|
||||
|
||||
/* ---------- About ---------- */
|
||||
function AboutScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "About"), /*#__PURE__*/React.createElement("h1", null, "The era of infinite alternatives"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "Welcome to the Wiggleverse."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "We've entered an era of alternatives \u2014 infinite alternatives. Wiggleverse exists to build the humane ones: ethical alternatives to the platforms people use every day, kept low-cost and high-value, in service of human flourishing."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "Now it's the platforms"), /*#__PURE__*/React.createElement("p", null, "With large language models, we've entered the era where the platforms themselves are being commoditized \u2014 and with them comes a world of infinite alternatives for software. One genuinely skilled developer can now do what would have taken ten of them a year ago. The things everyone assumed were permanent fixtures are suddenly open to alternatives."), /*#__PURE__*/React.createElement("h2", null, "The moats are becoming anchors"), /*#__PURE__*/React.createElement("p", null, "The large platforms have thrived on three moats: the manpower and capital to run software at scale, vendor lock-in, and the network effect. My hypothesis is that the first is turning into an anchor, the second is dissolving as custom migration code gets cheap, and the third is already fragmented."), /*#__PURE__*/React.createElement("h2", null, "What Wiggleverse is for"), /*#__PURE__*/React.createElement("p", null, "None of this is about taking anyone's place. Wiggleverse exists to offer ethical alternatives to platforms like Shopify, YouTube, Facebook, and Instagram \u2014 rooted in ethics, built to give people value rather than to extract it."), /*#__PURE__*/React.createElement(Callout, null, "The intention isn't to take as much as we can from people \u2014 it's to give as much value as we can to everyone."), /*#__PURE__*/React.createElement("p", null, "Welcome to the Wiggleverse, and welcome to the era of infinite alternatives \u2014 even to the platforms everyone assumed were permanent."), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "sm",
|
||||
tone: "plain",
|
||||
as: "p",
|
||||
style: {
|
||||
marginTop: '1.5rem'
|
||||
}
|
||||
}, "\u2014 Ben Stull, Founder"))));
|
||||
}
|
||||
|
||||
/* ---------- Ecomm ---------- */
|
||||
function EcommScreen() {
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "The portfolio \xB7 first product"), /*#__PURE__*/React.createElement("h1", null, "Ecomm"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "We take only what it takes to run."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. It's the first product to move from principle into the world, and there's a reason it's first."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "Why commerce first"), /*#__PURE__*/React.createElement("p", null, "Running an organization \u2014 and the infrastructure under a platform \u2014 takes money. Commerce is where money moves most, so by building close to commerce we reach a sustainable position quickest. Ecomm is first because it's the fastest honest path to standing on our own feet."), /*#__PURE__*/React.createElement(Callout, null, "Enough to keep the lights on, and no more."), /*#__PURE__*/React.createElement("h2", null, "What we commit to"), /*#__PURE__*/React.createElement("p", null, "The mission for Ecomm is to keep transaction fees and retail-media fees as low as they can possibly go. While we're still in beta, here is what we're committing to:"), /*#__PURE__*/React.createElement("ul", null, /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Retail media, free."), " Earned through platform engagement rather than paid for."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Marketplace storefronts: low or no transaction fees."), " Selling in the shared marketplace costs little or nothing."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "White-label storefronts: low transaction fees."), " Your own branded storefront stays low-cost to run.")), /*#__PURE__*/React.createElement("p", null, /*#__PURE__*/React.createElement(Button, {
|
||||
variant: "ghost",
|
||||
href: "#/finances"
|
||||
}, "See the open book \u2192")))));
|
||||
}
|
||||
|
||||
/* ---------- Finances (open book) ---------- */
|
||||
function FinancesScreen() {
|
||||
const lines = [{
|
||||
item: 'Google Cloud (GCP)',
|
||||
for: 'Servers, databases, and the infrastructure the platform runs on — on startup credits today',
|
||||
current: 32,
|
||||
ant: 75,
|
||||
antFrom: 'July 2026'
|
||||
}, {
|
||||
item: 'Google Workspace',
|
||||
for: 'Email and documents — one seat',
|
||||
current: 7,
|
||||
ant: 14,
|
||||
antFrom: 'Sept 2026'
|
||||
}, {
|
||||
item: 'Anthropic',
|
||||
for: 'Claude — one Max plan, the AI we build alongside every day',
|
||||
current: 100,
|
||||
ant: 100,
|
||||
antFrom: null
|
||||
}];
|
||||
const sumCur = lines.reduce((a, l) => a + l.current, 0);
|
||||
const sumAnt = lines.reduce((a, l) => a + l.ant, 0);
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
|
||||
className: "page-hero"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap"
|
||||
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Radical transparency \xB7 open book"), /*#__PURE__*/React.createElement("h1", null, "Open book"), /*#__PURE__*/React.createElement(Soul, {
|
||||
size: "md",
|
||||
as: "p",
|
||||
className: "page-hero__soul"
|
||||
}, "We show you the receipts."), /*#__PURE__*/React.createElement("p", {
|
||||
className: "lead"
|
||||
}, "We take only what it takes to run this organization and the platform \u2014 and the only way to mean that is to show our work. Here is what it actually costs to run Wiggleverse, line by line."))), /*#__PURE__*/React.createElement("section", {
|
||||
className: "section"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "wrap prose"
|
||||
}, /*#__PURE__*/React.createElement("h2", null, "What it costs to run Wiggleverse"), /*#__PURE__*/React.createElement("p", {
|
||||
className: "ledger-stamp"
|
||||
}, "Last updated 8 June 2026 \xB7 hand-kept estimates, refreshed monthly for now."), /*#__PURE__*/React.createElement("table", {
|
||||
className: "ledger"
|
||||
}, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col"
|
||||
}, "Line item"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col"
|
||||
}, "What it's for"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col",
|
||||
className: "num"
|
||||
}, "Current / mo"), /*#__PURE__*/React.createElement("th", {
|
||||
scope: "col",
|
||||
className: "num"
|
||||
}, "Anticipated / mo"))), /*#__PURE__*/React.createElement("tbody", null, lines.map(l => /*#__PURE__*/React.createElement("tr", {
|
||||
key: l.item
|
||||
}, /*#__PURE__*/React.createElement("td", null, l.item), /*#__PURE__*/React.createElement("td", null, l.for), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", l.current), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", l.ant, l.antFrom && /*#__PURE__*/React.createElement("span", {
|
||||
className: "when"
|
||||
}, "from ", l.antFrom))))), /*#__PURE__*/React.createElement("tfoot", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("td", null, "Total"), /*#__PURE__*/React.createElement("td", null, "per month, plus tax"), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", sumCur), /*#__PURE__*/React.createElement("td", {
|
||||
className: "num"
|
||||
}, "$", sumAnt, /*#__PURE__*/React.createElement("span", {
|
||||
className: "when"
|
||||
}, "by Sept 2026"))))), /*#__PURE__*/React.createElement("p", null, "Revenue today is ", /*#__PURE__*/React.createElement("strong", null, "$0"), " \u2014 we haven't launched commerce yet. Covering these costs ourselves is the whole point of ", /*#__PURE__*/React.createElement("a", {
|
||||
href: "#/ecomm"
|
||||
}, "building commerce first"), "."), /*#__PURE__*/React.createElement(Callout, null, "Ethics that cannot be checked are just claims."))));
|
||||
}
|
||||
const SCREENS = {
|
||||
'#/': HomeScreen,
|
||||
'#/about': AboutScreen,
|
||||
'#/ecomm': EcommScreen,
|
||||
'#/finances': FinancesScreen
|
||||
};
|
||||
function App() {
|
||||
const [route, setRoute] = React.useState(window.location.hash || '#/');
|
||||
React.useEffect(() => {
|
||||
const onHash = () => {
|
||||
setRoute(window.location.hash || '#/');
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
const Screen = SCREENS[route] || HomeScreen;
|
||||
const current = route === '#/about' ? 'About' : route === '#/ecomm' ? 'Build' : route === '#/finances' ? 'Learn' : undefined;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
className: "wv-app"
|
||||
}, /*#__PURE__*/React.createElement(SiteHeader, {
|
||||
links: NAV,
|
||||
current: current,
|
||||
assetBase: ASSET_BASE,
|
||||
homeHref: "#/"
|
||||
}), /*#__PURE__*/React.createElement("main", null, /*#__PURE__*/React.createElement(Screen, null)), /*#__PURE__*/React.createElement(SiteFooter, {
|
||||
columns: FOOTER_COLS,
|
||||
assetBase: ASSET_BASE
|
||||
}));
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(/*#__PURE__*/React.createElement(App, null));
|
||||
})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/wiggleverse-www/app.jsx", error: String((e && e.message) || e) }); }
|
||||
|
||||
__ds_ns.BrandLockup = __ds_scope.BrandLockup;
|
||||
|
||||
__ds_ns.BuildCard = __ds_scope.BuildCard;
|
||||
|
||||
__ds_ns.PathCard = __ds_scope.PathCard;
|
||||
|
||||
__ds_ns.Button = __ds_scope.Button;
|
||||
|
||||
__ds_ns.Callout = __ds_scope.Callout;
|
||||
|
||||
__ds_ns.Eyebrow = __ds_scope.Eyebrow;
|
||||
|
||||
__ds_ns.Notice = __ds_scope.Notice;
|
||||
|
||||
__ds_ns.Soul = __ds_scope.Soul;
|
||||
|
||||
__ds_ns.Tag = __ds_scope.Tag;
|
||||
|
||||
__ds_ns.SiteFooter = __ds_scope.SiteFooter;
|
||||
|
||||
__ds_ns.SiteHeader = __ds_scope.SiteHeader;
|
||||
|
||||
})();
|
||||
+1
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
/* Space Grotesk — machine register: headings, wordmark */
|
||||
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:500;font-display:swap;src:url('./space-grotesk-v22-latin-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:700;font-display:swap;src:url('./space-grotesk-v22-latin-700.woff2') format('woff2');}
|
||||
/* Inter — machine register: body / UI */
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:400;font-display:swap;src:url('./inter-v20-latin-regular.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:500;font-display:swap;src:url('./inter-v20-latin-500.woff2') format('woff2');}
|
||||
@font-face{font-family:'Inter';font-style:normal;font-weight:600;font-display:swap;src:url('./inter-v20-latin-600.woff2') format('woff2');}
|
||||
/* Fraunces — human register: pull-quotes (italic only) */
|
||||
@font-face{font-family:'Fraunces';font-style:italic;font-weight:400;font-display:swap;src:url('./fraunces-v38-latin-italic.woff2') format('woff2');}
|
||||
@font-face{font-family:'Fraunces';font-style:italic;font-weight:500;font-display:swap;src:url('./fraunces-v38-latin-500italic.woff2') format('woff2');}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+170
@@ -0,0 +1,170 @@
|
||||
# Wiggleverse Design System
|
||||
|
||||
The core design system for **Wiggleverse.org** — a Washington 501(c)(3)
|
||||
nonprofit building art and software for a world at peace, on one shared set of
|
||||
ethics. Its first product is *Ecomm* (Shopify-class commerce at near-zero cost);
|
||||
the portfolio also names *Apps* and *Learn*, all built on an **Open Core**.
|
||||
|
||||
> *Treat humans as humans — everything else follows.*
|
||||
|
||||
This system captures the brand exactly as it ships on the marketing site so that
|
||||
agents and designers can produce on-brand interfaces, decks, and assets.
|
||||
|
||||
## Sources
|
||||
|
||||
Built from the live marketing-site codebase, the source of truth:
|
||||
|
||||
- **Codebase:** `wiggleverse-www/` — static site (plain HTML/CSS/JS, no build
|
||||
step), deployed to Cloudflare Pages. Brand tokens live in
|
||||
`wiggleverse-www/assets/tokens.css`; the design system CSS is
|
||||
`wiggleverse-www/styles.css`.
|
||||
- **Brand source of truth (referenced, not attached):** `wiggleverse-org/corp` →
|
||||
`brand/BRAND.md` (§5–10). The site's `assets/` kit is a vendored copy. The
|
||||
site design spec is `corp/docs/superpowers/specs/2026-06-04-wiggleverse-www-design.md`.
|
||||
- **Fonts** are the real vendored woff2 files (Space Grotesk, Inter, Fraunces),
|
||||
copied into `assets/fonts/`. No substitutions were made.
|
||||
|
||||
---
|
||||
|
||||
## Content fundamentals
|
||||
|
||||
How Wiggleverse writes. The voice is **two registers passing through one
|
||||
review** — the same idea the product is built on.
|
||||
|
||||
- **Machine register (default).** Precise, structural, plain-spoken. Most copy
|
||||
lives here: headings, UI, body prose. Calm and declarative, never hypey.
|
||||
*"We take only what it takes to run."*
|
||||
- **Human register ("the soul").** Warm, literary, used **sparingly** for the
|
||||
lines that carry conscience — taglines and pull-quotes only. Always set in
|
||||
**Fraunces italic**. *"We are verbs, not nouns." · "Ethics that cannot be
|
||||
checked are just claims."*
|
||||
|
||||
Specifics:
|
||||
|
||||
- **Person.** Mostly **we / our** (the org speaking plainly and accountably).
|
||||
Slips to **first-person singular** for the founder's voice in the About essay
|
||||
("I felt that one firsthand… — Ben Stull, Founder"). Addresses the reader as
|
||||
**you** in product/partner copy.
|
||||
- **Tone.** Honest, humble, anti-extractive, quietly ambitious. Leads with
|
||||
*what is enough?* rather than *how much can we get?* Admits limits openly
|
||||
("hand-kept estimates, refreshed monthly for now").
|
||||
- **Casing.** Sentence case everywhere — headings, buttons, nav. **Eyebrows**
|
||||
and small labels are UPPERCASE with wide tracking. Product names are casual
|
||||
and lowercase-ish in prose ("ecomm"), Title Case as proper nouns ("Ecomm").
|
||||
- **Punctuation.** Em dashes for asides. Arrows (`→`) end calls to action
|
||||
("Why Wiggleverse →"). Italic emphasis on the load-bearing question
|
||||
(*what is enough?*). Bold for the load-bearing noun phrases in a list.
|
||||
- **No emoji.** None, anywhere. Status and emphasis come from type and a small
|
||||
set of geometric glyphs (see Iconography).
|
||||
- **Recurring phrases.** "Treat humans as humans." · "We take only what it takes
|
||||
to run." · "Radical transparency / we show you the receipts." · "Build the
|
||||
dictionary first." · "If you're learning, you're succeeding." · "We are verbs,
|
||||
not nouns." · the dedication to Aaron Swartz in every footer.
|
||||
- **The vibe.** A standards document (RFC/IETF) with a conscience — exact where
|
||||
it matters, tender where it counts.
|
||||
|
||||
---
|
||||
|
||||
## Visual foundations
|
||||
|
||||
The whole system answers to one motif: a **Circle of Equals** — one living,
|
||||
wiggling line through six equal points, closed into a ring, with a **deliberately
|
||||
empty center** ("no center, no hub, no sun"). The connecting line *is* the ethic.
|
||||
|
||||
- **Ground.** **Dark "sky" is primary** (`--wv-midnight #0E1230`). Long-reading
|
||||
passages flip to **Paper** (`--wv-paper #F6F4FB`) sections. Most of the system
|
||||
lives on dark.
|
||||
- **Color.** Indigo (`#1C2150`) for raised surfaces; **lilac** (`#9B8CFF`) is the
|
||||
accent — links, nodes, "the bonds between us"; **violet** (`#7C6FE0`) is its
|
||||
on-paper counterpart; **gold** (`#F4C76B`) is warmth / horizon / the single
|
||||
primary CTA color; **starlight** (`#EDEAFF`) is text on dark; **ink**
|
||||
(`#3B2F7A`) is text on paper. A near-black **night** (`#090C22`) grounds the
|
||||
footer.
|
||||
- **Type.** Two machine faces + one human face. **Space Grotesk** 500/700 for
|
||||
display & wordmark (tracked in −0.015em). **Inter** 400/500/600 for body & UI.
|
||||
**Fraunces italic** 400/500 for the soul — *italic only, never upright, never
|
||||
body copy.* Headings use fluid `clamp()` sizes.
|
||||
- **Backgrounds.** No photography in the system. The hero uses a **scattered
|
||||
starfield** (faint multi-radial-gradient nodes, intentionally off-center) and a
|
||||
large, very low-opacity (≈0.14) mark bleeding off the right edge. Otherwise:
|
||||
flat color fields. No big gradients as surfaces (the only gradient is *inside*
|
||||
the mark, lilac→gold).
|
||||
- **Borders & cards.** Depth is carried by **surface color + hairline borders**,
|
||||
not shadows. Cards are indigo (on dark) or white (on paper) with a 1px
|
||||
lilac/violet hairline at low alpha, **14px** radius. The system is
|
||||
**near-shadowless** — a soft shadow token exists but is rarely used.
|
||||
- **Radii.** 14px cards, 12px panels, 4px focus, **999px pills** (buttons, tags,
|
||||
notices), 26px app-tile.
|
||||
- **Animation.** Restrained. Transitions ≈ `.15s ease`. Hover = **translateY
|
||||
lift** (−1px buttons, −3px cards), *not* a shadow or scale. Mobile nav reveals
|
||||
via `max-height .25s`. Everything is killed under
|
||||
`prefers-reduced-motion: reduce`. No bounces, no loops, no parallax.
|
||||
- **Hover / press.** Links underline (3px offset, lilac). Buttons lift and
|
||||
lighten (gold → `#F7D488`). Cards lift and warm their border to lilac, surface
|
||||
to `#232A63`. No color-darkening press states; no shrink.
|
||||
- **Transparency & blur.** The **sticky header** is the one glass surface:
|
||||
`rgba(14,18,48,.82)` + `blur(10px)` + a lilac hairline. Alpha lilac/gold/
|
||||
starlight washes (`.08`–`.40`) do tints, dividers, and focus rings.
|
||||
- **Focus.** Always visible: **3px gold outline**, 2px offset, 4px rounding.
|
||||
Accessibility is load-bearing (skip links, `aria-current`, reduced-motion).
|
||||
- **Imagery vibe.** Cool, dark, cosmic, calm — starlight on midnight, warmed by a
|
||||
single gold horizon. Never busy, never loud.
|
||||
- **Layout.** Content column is `min(1080px, 92vw)`, gutter-aligned (not
|
||||
auto-centered text). Prose blocks cap at a 68ch measure. Sections breathe with
|
||||
fluid `clamp(3rem, 7vw, 5.5rem)` vertical padding.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
Wiggleverse is **iconography-light by design**. There is **no icon font and no
|
||||
icon set** in the codebase.
|
||||
|
||||
- **The mark** is the one true graphic: the *Circle of Equals*, shipped as SVG in
|
||||
four cuts — `wiggleverse-mark.svg` (lilac→gold gradient line, starlight nodes),
|
||||
`mark-mono-gold.svg` (footer), `mark-on-light.svg` (ink on paper), and
|
||||
`favicon.svg` (gold line + starlight nodes on a rounded-26px midnight tile).
|
||||
Raster favicons (`favicon-32.png`, `favicon-180.png`) are provided for tabs and
|
||||
iOS. All are in `assets/`.
|
||||
- **"Icons" are geometric unicode glyphs.** The audience router uses
|
||||
**✧ ◇ ◈ ❍ ◐** as quiet, abstract door markers — never pictographic icons,
|
||||
never emoji. Treat these as the sanctioned glyph set when a small mark is
|
||||
needed. Arrows are literal `→` characters in link/button text.
|
||||
- **No emoji, ever.** (Reconfirmed under Content fundamentals.)
|
||||
- **If you need UI icons** (e.g. a richer product surface that the marketing site
|
||||
doesn't cover), there is no house set to match — keep them to a thin,
|
||||
geometric, single-weight line style consistent with the mark, and **flag the
|
||||
addition** so it can be folded into the brand properly. Do not hand-draw new
|
||||
brand illustrations.
|
||||
|
||||
---
|
||||
|
||||
## Index / manifest
|
||||
|
||||
Root files:
|
||||
|
||||
- `styles.css` — the global entry point (consumers link this one file). `@import`
|
||||
manifest only.
|
||||
- `tokens/colors.css` · `tokens/typography.css` · `tokens/spacing.css` — CSS
|
||||
custom properties (base values + semantic aliases).
|
||||
- `assets/` — the Circle-of-Equals marks (4 cuts), favicons, and the three
|
||||
vendored webfont families in `assets/fonts/` (+ `fonts.css` `@font-face`).
|
||||
- `guidelines/` — foundation specimen cards (Colors, Type, Spacing, Brand).
|
||||
- `SKILL.md` — Agent-Skills-compatible entry point.
|
||||
|
||||
Components (`window.WiggleverseDesignSystem_*`):
|
||||
|
||||
- **core/** — `Button`, `Tag`, `Notice`, `Eyebrow`, `Soul`, `Callout`
|
||||
- **cards/** — `PathCard`, `BuildCard`
|
||||
- **brand/** — `BrandLockup`
|
||||
- **navigation/** — `SiteHeader`, `SiteFooter`
|
||||
|
||||
UI kits:
|
||||
|
||||
- **ui_kits/wiggleverse-www/** — high-fidelity recreation of the marketing site
|
||||
(Home, About, Ecomm, Finances/open-book), composing the components above.
|
||||
|
||||
Each component directory carries `<Name>.jsx`, `<Name>.d.ts`, `<Name>.prompt.md`,
|
||||
and one `@dsCard` HTML thumbnail. Mount components in card/kit HTML via
|
||||
`const { X } = window.WiggleverseDesignSystem_94cd80` after loading
|
||||
`_ds_bundle.js` (generated automatically — do not edit).
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/* Wiggleverse Design System — global entry point.
|
||||
Consumers link THIS one file. It is an @import manifest only — no rules here.
|
||||
Everything reachable from here ships to consumers (tokens + @font-face webfonts). */
|
||||
|
||||
@import url("./assets/fonts/fonts.css"); /* Space Grotesk · Inter · Fraunces (@font-face) */
|
||||
@import url("./tokens/colors.css"); /* palette + semantic color aliases */
|
||||
@import url("./tokens/typography.css"); /* families, scale, weights, tracking */
|
||||
@import url("./tokens/spacing.css"); /* spacing, radii, borders, motion, layout */
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
|
||||
Derived from the marketing-site CSS. The brand has almost no shadow system —
|
||||
depth is carried by surface color + hairline borders, not drop shadows. */
|
||||
|
||||
:root {
|
||||
/* ---- Spacing scale (rem) ---- */
|
||||
--space-0: 0;
|
||||
--space-1: .25rem;
|
||||
--space-2: .5rem;
|
||||
--space-3: .7rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.2rem;
|
||||
--space-6: 1.6rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Section rhythm — fluid vertical padding for page bands */
|
||||
--section-pad: clamp(3rem, 7vw, 5.5rem);
|
||||
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--wrap-max: 1080px; /* content column */
|
||||
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
|
||||
--measure-prose: 68ch; /* reading measure for prose blocks */
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-card: 14px; /* path-cards, build-cards */
|
||||
--radius-panel: 12px; /* principle tiles */
|
||||
--radius-sm: 4px; /* focus ring rounding */
|
||||
--radius-pill: 999px; /* buttons, tags, notices */
|
||||
--radius-mark: 26px; /* favicon tile rounding */
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-hair: 1px; /* default hairline */
|
||||
--border-card-w: 1px;
|
||||
--btn-border-w: 1.5px; /* ghost button / focus weight */
|
||||
|
||||
/* ---- Elevation ---- *
|
||||
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
|
||||
* not a shadow. These tokens exist for the rare card that needs real elevation. */
|
||||
--shadow-none: none;
|
||||
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
|
||||
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
|
||||
--lift-3: translateY(-3px); /* @kind other */ /* cards */
|
||||
|
||||
/* ---- Glass (sticky header) ---- */
|
||||
--glass-sky: rgba(14, 18, 48, .82);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ---- Motion ---- */
|
||||
--ease: ease; /* @kind other */
|
||||
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
|
||||
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/* Wiggleverse — Typography tokens
|
||||
Two registers, one meaning (BRAND.md):
|
||||
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
|
||||
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
|
||||
for the lines that carry conscience ("the soul").
|
||||
@font-face rules live in assets/fonts/fonts.css. */
|
||||
|
||||
:root {
|
||||
/* ---- Families ---- */
|
||||
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
|
||||
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
|
||||
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
|
||||
|
||||
/* semantic aliases */
|
||||
--font-display: var(--wv-font-display);
|
||||
--font-body: var(--wv-font-body);
|
||||
--font-soul: var(--wv-font-human);
|
||||
|
||||
/* ---- Weights ---- */
|
||||
--weight-regular: 400; /* Inter body */
|
||||
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
|
||||
--weight-semibold:600; /* Inter emphasis, ledger totals */
|
||||
--weight-bold: 700; /* Space Grotesk headings, wordmark */
|
||||
--weight-soul: 500; /* Fraunces italic pull-quotes */
|
||||
|
||||
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
|
||||
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
|
||||
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
--text-h3: 1.2rem;
|
||||
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
|
||||
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
|
||||
|
||||
/* ---- Body / UI scale ---- */
|
||||
--text-body: 1rem; /* 16px base */
|
||||
--text-small: .95rem;
|
||||
--text-fine: .92rem;
|
||||
--text-eyebrow: .8rem; /* uppercase label */
|
||||
--text-tag: .72rem; /* pill tags */
|
||||
|
||||
/* ---- Line heights ---- */
|
||||
--leading-tight: 1.12; /* headings */
|
||||
--leading-body: 1.6; /* paragraphs */
|
||||
|
||||
/* ---- Letter spacing ---- */
|
||||
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
|
||||
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
|
||||
--tracking-tag: 0.08em;
|
||||
--tracking-notice: 0.06em;
|
||||
|
||||
/* ---- Measure ---- */
|
||||
--measure-lead: 60ch;
|
||||
--measure-prose:68ch;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
+19
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — Circle of Equals</title>
|
||||
<defs>
|
||||
<linearGradient id="wv-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#9B8CFF"></stop>
|
||||
<stop offset="1" stop-color="#F4C76B"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="url(#wv-grad)" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="4.5"></circle>
|
||||
<circle cx="79" cy="93" r="4.5"></circle>
|
||||
<circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle>
|
||||
<circle cx="41" cy="27" r="4.5"></circle>
|
||||
<circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 891 B |
@@ -0,0 +1,974 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
|
||||
/* BEGIN USAGE */
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
//
|
||||
// Artboards are static design frames, not scroll regions — never use
|
||||
// height: 100% + overflow: auto/scroll on inner elements; size each artboard
|
||||
// to fit its content (explicit pixel height, or let it grow).
|
||||
/* END USAGE */
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// right. Single flex row; when the artboard's on-screen width is too
|
||||
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||
// ~4ch via the container query) and the buttons stay on the row.
|
||||
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||
' display:flex;align-items:center;container-type:inline-size}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||
// until the card is moused.
|
||||
'@container (max-width: 110px){',
|
||||
' .dc-labeltext{display:none}',
|
||||
' .dc-grip{opacity:0}',
|
||||
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||
'}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||
' font:inherit;transition:background .12s,color .12s}',
|
||||
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||
// DCViewport on every transform update and inherits to all descendants —
|
||||
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||
// it the same way.
|
||||
//
|
||||
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||
// after counter-scaling its on-screen width exactly matches the card's —
|
||||
// that's what lets the container query + text-overflow behave against the
|
||||
// card's visible edge at every zoom level.
|
||||
//
|
||||
// The section head uses CSS zoom instead of transform so its layout box
|
||||
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||
// constant-screen-size title would overflow into the (shrinking) world-
|
||||
// space gap and overlap the artboard headers at low zoom.
|
||||
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
|
||||
// DCSection/DCArtboard children from the type-based walks below.
|
||||
function dcFlatten(children) {
|
||||
const out = [];
|
||||
React.Children.forEach(children, (c) => {
|
||||
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
|
||||
else out.push(c);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||
// .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Fragments are flattened; wrapping in other
|
||||
// elements still opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
dcFlatten(children).forEach((sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const abs = [];
|
||||
dcFlatten(sec.props.children).forEach((ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (aid) abs.push([aid, ab]);
|
||||
});
|
||||
// hidden is scoped to one source revision — when the agent regenerates
|
||||
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||
const srcIds = [];
|
||||
abs.forEach(([aid, ab]) => {
|
||||
if (hidden.includes(aid)) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
// Persist viewport across reloads so the user lands back where they were
|
||||
// after an agent edit or browser refresh. The sandbox origin is already
|
||||
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||
const tfKey = 'dc-viewport:' + location.pathname;
|
||||
const saveT = React.useRef(0);
|
||||
|
||||
const lastPostedScale = React.useRef();
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (!el) return;
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||
if (lastPostedScale.current !== scale) {
|
||||
lastPostedScale.current = scale;
|
||||
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||
}
|
||||
clearTimeout(saveT.current);
|
||||
saveT.current = setTimeout(() => {
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
}, 200);
|
||||
}, [tfKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const flush = () => {
|
||||
clearTimeout(saveT.current);
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
};
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||
apply();
|
||||
}
|
||||
} catch {}
|
||||
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||
// window doesn't drop the last pan/zoom.
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
|
||||
// marginBottom) reflow on every scale change, vertically shifting the
|
||||
// world layout — so a world point mathematically pinned under the cursor
|
||||
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
|
||||
// Anchor the DOM element under the cursor instead: record its screen Y,
|
||||
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
|
||||
// drift the reflow introduced so it stays put on screen.
|
||||
let marker = null, markerY0 = 0;
|
||||
if (k !== 1) {
|
||||
const hit = document.elementFromPoint(cx, cy);
|
||||
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
||||
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
||||
}
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
if (marker) {
|
||||
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
|
||||
// departure after the --dc-inv-zoom reflow is the layout drift.
|
||||
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
||||
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||
const onHostMsg = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||
const r = vp.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||
} else if (d && d.type === '__dc_probe') {
|
||||
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||
// fires on the iframe's native 'load', which for canvases with
|
||||
// images/fonts is after our mount-time announce, so re-announce.
|
||||
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||
// even if it's unchanged — the host just reset dcScale to 1.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onHostMsg);
|
||||
// Announce canvas mode so the host toolbar proxies its % control here
|
||||
// instead of scaling the iframe element (which would just shrink the
|
||||
// viewport window of an infinite canvas). The apply() that follows emits
|
||||
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||
// effect's restore-path apply() may already have posted the restored
|
||||
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(dcFlatten(children));
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||
const srcKey = allIds.join('\x1f');
|
||||
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||
// belonging to the section above. paddingBottom below is just enough for
|
||||
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||
// the title sits tight against its own row at every zoom.
|
||||
return (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||
srcKey,
|
||||
}))}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
const cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
const n = sectionOrder.length;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// hf-admin.jsx — Admin shell (PUC-8 / PUC-9) hi-fi. Honestly empty + states.
|
||||
const { HFScreen: AScreen, HFTopBar: ATopBar, HFAccountChip: AChip, HFBanner: ABanner,
|
||||
HFStateCard: AStateCard, HFStates: AStates, HF_H: A, HF_STORE: ASTORE, HF_DSC: ADS } = window;
|
||||
const { Eyebrow: AEyebrow, Soul: ASoul } = ADS;
|
||||
|
||||
// storefront identity lockup — the admin's anchor
|
||||
function StoreId({ size = 17 }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 11 }}>
|
||||
<img src={window.HF_ASSET('markTile', 'assets/mark-tile.svg')} width={size + 7} height={size + 7} style={{ borderRadius: 7, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.12 }}>
|
||||
<span style={{ fontFamily: A.disp, fontWeight: 700, fontSize: size, color: A.star, letterSpacing: '-0.01em' }}>{ASTORE}</span>
|
||||
<span style={{ fontFamily: A.body, fontSize: 11, color: A.mute }}>ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// the honest-empty centerpiece — intentional, not sparse
|
||||
function EmptyHome({ compact = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
textAlign: 'center', padding: compact ? '40px 24px' : '0 64px', position: 'relative',
|
||||
}}>
|
||||
{/* faint constellation seal — one decorative mark, low alpha */}
|
||||
<div style={{ width: compact ? 64 : 76, height: compact ? 64 : 76, borderRadius: '50%', border: `1px solid ${A.hairCard}`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 26, background: 'var(--wv-lilac-08)' }}>
|
||||
<img src={window.HF_ASSET('markGold', 'assets/mark-mono-gold.svg')} width={compact ? 30 : 36} height={compact ? 30 : 36} alt="" style={{ opacity: 0.9 }} />
|
||||
</div>
|
||||
<AEyebrow style={{ margin: '0 0 12px' }}>Your storefront</AEyebrow>
|
||||
<h1 style={{ fontFamily: A.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.04, fontSize: compact ? 30 : 42, color: A.star, margin: '0 0 16px' }}>{ASTORE}</h1>
|
||||
<p style={{ fontFamily: A.body, fontSize: compact ? 15 : 17, lineHeight: 1.6, color: A.soft, maxWidth: 460, margin: '0 0 28px', textWrap: 'pretty' }}>
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAdmin({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<AScreen stars={false}>
|
||||
<ATopBar pad={desktop ? '0 36px' : '0 20px'} left={<StoreId size={desktop ? 17 : 15} />}
|
||||
right={desktop ? <AChip /> : <button className="signout">Sign out</button>} />
|
||||
<EmptyHome compact={!desktop} />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAdminStates() {
|
||||
return (
|
||||
<AStates>
|
||||
<AStateCard label="Loading" note="skeleton while /me resolves">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 13 }}>
|
||||
<Skel w={56} h={56} r={28} />
|
||||
<Skel w={90} h={11} />
|
||||
<Skel w={180} h={26} />
|
||||
<Skel w={300} h={11} />
|
||||
<Skel w={260} h={11} />
|
||||
</div>
|
||||
</AStateCard>
|
||||
<AStateCard label="Session expired" note="PUC-9 · redirect to sign-in">
|
||||
<ABanner tone="info" title="Your session ended">
|
||||
For your security you've been signed out. <a className="lk" href="#">Sign in again →</a>
|
||||
</ABanner>
|
||||
</AStateCard>
|
||||
<AStateCard label="Account menu" note="PUC-9 · sign out">
|
||||
<div style={{ position: 'relative', height: 150, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<AChip menu />
|
||||
</div>
|
||||
</AStateCard>
|
||||
</AStates>
|
||||
);
|
||||
}
|
||||
function Skel({ w, h, r = 6 }) {
|
||||
return <div style={{ width: w, height: h, borderRadius: r, background: 'linear-gradient(90deg, rgba(237,234,255,.06), rgba(237,234,255,.12), rgba(237,234,255,.06))' }} />;
|
||||
}
|
||||
|
||||
Object.assign(window, { HFAdmin, HFAdminStates });
|
||||
@@ -0,0 +1,205 @@
|
||||
// hf-bootstrap.jsx — Bootstrap (PUC-10 / PUC-11) hi-fi. Terminals + runbook.
|
||||
const { HF_H: B, HF_STARS: B_STARS, HF_DSC: BDS } = window;
|
||||
const { Eyebrow: BEyebrow, Soul: BSoul, Callout: BCallout } = BDS;
|
||||
const BMONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
|
||||
|
||||
function BBay({ children, pad = 32 }) {
|
||||
return <div className="hf" style={{ height: '100%', background: `${B_STARS}, ${B.sky}`, padding: pad, boxSizing: 'border-box', overflow: 'hidden', fontFamily: B.body, display: 'flex', flexDirection: 'column' }}>{children}</div>;
|
||||
}
|
||||
function BHead({ kicker, title, sub }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<BEyebrow style={{ margin: '0 0 9px' }}>{kicker}</BEyebrow>
|
||||
<h2 style={{ fontFamily: B.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 23, color: B.star, margin: '0 0 7px', lineHeight: 1.1 }}>{title}</h2>
|
||||
{sub && <p style={{ fontFamily: B.body, fontSize: 13.5, lineHeight: 1.5, color: B.soft, margin: 0, maxWidth: 580 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BTerminal({ title, lines, style = {} }) {
|
||||
return (
|
||||
<div style={{ background: B.night, border: `1px solid ${B.hairCard}`, borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: B.shadow, ...style }}>
|
||||
<div style={{ flex: '0 0 auto', height: 42, padding: '0 15px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: `1px solid ${B.hair}`, background: 'rgba(155,140,255,.05)' }}>
|
||||
<span style={{ display: 'flex', gap: 7 }}>
|
||||
{['rgba(155,140,255,.55)', 'rgba(244,199,107,.55)', 'rgba(237,234,255,.32)'].map((c, i) => (
|
||||
<span key={i} style={{ width: 10, height: 10, borderRadius: 5, background: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 12, color: B.mute, marginLeft: 5 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '17px 19px', fontFamily: BMONO, fontSize: 12.5, lineHeight: 1.8, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
||||
{lines.map((ln, i) => <BLine key={i} ln={ln} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BLine({ ln }) {
|
||||
const [k, t] = ln;
|
||||
if (k === 'sp') return <div style={{ height: 10 }} />;
|
||||
if (k === 'cmd') return <div><span style={{ color: B.lilac }}>$ </span><span style={{ color: B.star }}>{t}</span></div>;
|
||||
if (k === 'step') return <div><span style={{ color: B.lilac }}>→ </span><span style={{ color: B.soft }}>{t}</span></div>;
|
||||
if (k === 'ok') return <div><span style={{ color: B.gold }}>✓ </span><span style={{ color: B.star }}>{t}</span></div>;
|
||||
if (k === 'hl') return <div style={{ color: B.gold, background: 'rgba(244,199,107,.09)', margin: '0 -9px', padding: '2px 9px', borderRadius: 5 }}>{t}</div>;
|
||||
if (k === 'note') return <div style={{ color: B.faint, fontStyle: 'italic' }}>{t}</div>;
|
||||
return <div style={{ color: B.mute }}>{t}</div>;
|
||||
}
|
||||
|
||||
// 1 · principle ----------------------------------------------------------------
|
||||
function HFBootPrinciple() {
|
||||
const steps = [
|
||||
['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
|
||||
['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
|
||||
['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
|
||||
['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
|
||||
['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
|
||||
];
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="INV-1 · the bootstrap property" title="Empty is a working state"
|
||||
sub="No seed step, ever. A fresh, empty database is a first-class state the product brings to life itself — which is what makes launching anywhere an ordinary, rehearsed act." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{steps.map(([t, d, c], i) => {
|
||||
const col = c === 'gold' ? B.gold : B.lilac;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 16, paddingBottom: i < steps.length - 1 ? 18 : 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '0 0 auto' }}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 14, border: `1.5px solid ${col}`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: B.disp, fontWeight: 700, fontSize: 13.5 }}>{i + 1}</span>
|
||||
{i < steps.length - 1 && <span style={{ width: 1.5, flex: 1, minHeight: 22, background: B.hair }} />}
|
||||
</div>
|
||||
<div style={{ paddingTop: 3 }}>
|
||||
<div style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 15, color: B.star }}>{t}</div>
|
||||
<div style={{ fontFamily: BMONO, fontSize: 11.5, color: B.mute, marginTop: 3 }}>{d}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 · runbook (Paper) ----------------------------------------------------------
|
||||
function HFBootRunbook() {
|
||||
const envs = [
|
||||
['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
|
||||
['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
|
||||
['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
|
||||
];
|
||||
return (
|
||||
<div style={{ height: '100%', background: 'var(--wv-paper)', padding: '36px 40px', boxSizing: 'border-box', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 12, color: 'var(--wv-violet)', background: 'rgba(124,111,224,.12)', padding: '4px 11px', borderRadius: 999 }}>docs/BOOTSTRAP.md</span>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 11.5, color: '#8A7FB0' }}>the documented gesture</span>
|
||||
</div>
|
||||
<h2 style={{ fontFamily: B.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, color: 'var(--wv-ink)', margin: '0 0 8px', lineHeight: 1.08 }}>Bringing ecomm up from empty</h2>
|
||||
<p style={{ fontFamily: B.body, fontSize: 14, lineHeight: 1.55, color: '#4B4170', margin: '0 0 24px', maxWidth: 560 }}>
|
||||
One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{envs.map(([env, cmd, note]) => (
|
||||
<div key={env} style={{ display: 'grid', gridTemplateColumns: '132px 1fr', gap: 18, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 14, color: 'var(--wv-ink)' }}>{env}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: BMONO, fontSize: 12.5, color: 'var(--wv-ink)', background: '#ECE8F7', border: '1px solid #DCD5F0', borderRadius: 8, padding: '8px 12px', display: 'inline-block' }}>{cmd}</div>
|
||||
<div style={{ fontFamily: B.body, fontSize: 12.5, lineHeight: 1.5, color: '#6A5F90', marginTop: 7 }}>{note}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<BCallout onLight>Empty is a working state — there is no seed.</BCallout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 · localhost terminal -------------------------------------------------------
|
||||
function HFBootDev() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-10 · localhost" title="One command from clean checkout"
|
||||
sub="A developer reaches “first merchant, first storefront” with Docker as the only prerequisite — the app migrates an empty database itself." />
|
||||
<BTerminal title="scripts/dev.sh" style={{ flex: 1 }} lines={[
|
||||
['cmd', 'git clone …/wiggleverse-ecomm && cd wiggleverse-ecomm'],
|
||||
['cmd', './scripts/dev.sh'],
|
||||
['sp'],
|
||||
['step', 'datastore: starting postgres (docker compose up)…'],
|
||||
['ok', 'postgres healthy · ecomm_dev · :5432'],
|
||||
['step', 'migrations: applying pending…'],
|
||||
['ok', '0001_init applied · schema current'],
|
||||
['step', 'backend: uvicorn on :8000'],
|
||||
['step', 'web: vite on :5173 (proxying /api)'],
|
||||
['ok', '/healthz 200 {status:"ok", migrations:"current"}'],
|
||||
['sp'],
|
||||
['out', ' ecomm is up against an empty database.'],
|
||||
['hl', ' open http://localhost:5173'],
|
||||
['sp'],
|
||||
['note', 'one-time codes print to this log (LogMailer) — no real mail in dev.'],
|
||||
]} />
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
// 4 · the OTC channel ----------------------------------------------------------
|
||||
function HFBootChannel() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-10 / PUC-11 · the OTC channel" title="Where the one-time code lands"
|
||||
sub="Same issue/verify path everywhere — only the mailer differs by configuration (INV-8)." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
<div>
|
||||
<ChanLabel tone="lilac">Dev — the code prints to the backend log</ChanLabel>
|
||||
<BTerminal title="backend · LogMailer" lines={[
|
||||
['out', '[mailer] adapter=LogMailer'],
|
||||
['out', ' to: ben@bensbets.com'],
|
||||
['hl', ' subject: Your ecomm code: 419 204'],
|
||||
['out', ' code valid 10m · single-use · 5 tries'],
|
||||
]} />
|
||||
</div>
|
||||
<div>
|
||||
<ChanLabel tone="gold">Deployed — the code arrives as real email</ChanLabel>
|
||||
<div style={{ background: B.raised, border: `1px solid ${B.hairCard}`, borderRadius: 12, padding: 17 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: B.body, fontSize: 12, color: B.mute, marginBottom: 9, borderBottom: `1px solid ${B.hair}`, paddingBottom: 9 }}>
|
||||
<span>ecomm <no-reply@ecomm.wiggleverse.org></span><span>now</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 15.5, color: B.star, marginBottom: 7 }}>Your ecomm code: 419 204</div>
|
||||
<div style={{ fontFamily: B.body, fontSize: 12.5, lineHeight: 1.5, color: B.soft }}>
|
||||
Enter this code to sign in. It's good for 10 minutes. If you didn't request it, you can ignore this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
function ChanLabel({ children, tone }) {
|
||||
const c = tone === 'gold' ? B.gold : B.lilac;
|
||||
return <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, fontFamily: B.body, fontSize: 12.5, color: B.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c }}>{tone === 'gold' ? '◆' : '◇'}</span>{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// 5 · deploy terminal ----------------------------------------------------------
|
||||
function HFBootDeploy() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-11 · pre-prod, then prod" title="The same gesture, deployed"
|
||||
sub="The operator runs flotilla; PPE rehearses Prod with real email (BUC-5a). No environment is “later.”" />
|
||||
<BTerminal title="flotilla — operator front door" style={{ flex: 1 }} lines={[
|
||||
['cmd', 'flotilla deploy ecomm --env ppe'],
|
||||
['sp'],
|
||||
['step', 'provision: Cloud SQL (postgres) · backups + PITR…'],
|
||||
['ok', 'database ready · secrets resolved from Secret Manager'],
|
||||
['step', 'deploy: image → single VM · fail-stop'],
|
||||
['step', 'migrations: applying at startup…'],
|
||||
['ok', '0001_init applied'],
|
||||
['ok', '/healthz 200 — ppe live'],
|
||||
['out', ' rehearsal: real sign-up (real email) → storefront → admin ✓'],
|
||||
['sp'],
|
||||
['cmd', 'flotilla deploy ecomm --env prod'],
|
||||
['ok', 'same gesture — day-one prod is the bootstrap state'],
|
||||
]} />
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFBootPrinciple, HFBootRunbook, HFBootDev, HFBootChannel, HFBootDeploy });
|
||||
@@ -0,0 +1,87 @@
|
||||
// hf-intro.jsx — Hi-fi cover: the design language Direction A locks in.
|
||||
const { HFScreen: IScreen, HFWordmark: IWordmark, HF_H: I, HF_DSC: IDS } = window;
|
||||
const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
|
||||
|
||||
function HFCover() {
|
||||
const langs = [
|
||||
['Ground', 'Midnight sky with a barely-there starfield; a gold horizon glow warms the bottom of entry screens.'],
|
||||
['Depth', 'Carried by layered surfaces and hairlines — not drop shadows. Only the auth card earns real elevation.'],
|
||||
['Type', 'Space Grotesk for structure, Inter for body — calm and precise, never hypey. Sentence case throughout; uppercase only for small eyebrows.'],
|
||||
['Action', 'One gold CTA per screen. Lilac for links and nodes. Hover lifts 1px; focus is a gold ring.'],
|
||||
['Attention', 'Gold, always — the palette carries no red. Honest failure, never a faked success.'],
|
||||
['Emptiness', 'A finished state with its own composition — no zeroed tiles, no locked-feature teasers.'],
|
||||
];
|
||||
return (
|
||||
<IScreen horizon style={{ padding: '44px 46px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={28} sub />
|
||||
<ITag variant="live">SD-0001 · Hi-fi · Direction A</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 30 }}>
|
||||
<IEyebrow style={{ margin: '0 0 14px' }}>Quiet centered — the full design</IEyebrow>
|
||||
<h1 style={{ fontFamily: I.disp, fontWeight: 700, letterSpacing: '-0.02em', fontSize: 40, lineHeight: 1.03, color: I.star, margin: '0 0 12px' }}>
|
||||
The language, before<br />the hundred screens
|
||||
</h1>
|
||||
<p style={{ fontFamily: I.body, fontSize: 15.5, lineHeight: 1.6, color: I.soft, maxWidth: 620, margin: 0, textWrap: 'pretty' }}>
|
||||
Every surface below is the same six decisions, applied. Get these right and the next ninety-six
|
||||
screens inherit a calm, honest house style for free.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 30 }}>
|
||||
{langs.map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', gap: 13, padding: '15px 17px', borderRadius: 12, background: I.raised, border: `1px solid ${I.hairCard}` }}>
|
||||
<span style={{ flex: '0 0 auto', width: 7, height: 7, borderRadius: 4, background: I.gold, marginTop: 6 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: I.disp, fontWeight: 500, fontSize: 14.5, color: I.star, marginBottom: 3 }}>{k}</div>
|
||||
<div style={{ fontFamily: I.body, fontSize: 13, lineHeight: 1.5, color: I.mute }}>{v}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// Entry routing — the shared spine, hi-fi
|
||||
function HFRouting() {
|
||||
const lanes = [
|
||||
{ sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
|
||||
{ sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
|
||||
{ sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
|
||||
];
|
||||
return (
|
||||
<IScreen style={{ padding: '44px 40px' }}>
|
||||
<IEyebrow style={{ margin: '0 0 14px' }}>The shared spine</IEyebrow>
|
||||
<h1 style={{ fontFamily: I.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 28, lineHeight: 1.08, color: I.star, margin: '0 0 10px' }}>Entry routing</h1>
|
||||
<p style={{ fontFamily: I.body, fontSize: 14, lineHeight: 1.55, color: I.soft, margin: '0 0 8px' }}>
|
||||
One server answer — <code style={{ fontFamily: 'ui-monospace,monospace', fontSize: 12.5, color: I.lilac, background: 'var(--wv-lilac-08)', padding: '1px 6px', borderRadius: 5 }}>GET /api/auth/me</code> — decides where every arrival lands.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, marginTop: 26 }}>
|
||||
{lanes.map((l, i) => {
|
||||
const c = l.tone === 'gold' ? I.gold : l.tone === 'lilac' ? I.lilac : I.mute;
|
||||
return (
|
||||
<div key={i} className="row-lift" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '17px 19px', borderRadius: 12, background: I.raised, border: `1px solid ${I.hairCard}` }}>
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto' }}>
|
||||
<Cond>{l.sess}</Cond><Cond>{l.store}</Cond>
|
||||
</div>
|
||||
<span aria-hidden="true" style={{ color: c, fontSize: 18, flex: '0 0 auto' }}>→</span>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontFamily: I.disp, fontWeight: 700, fontSize: 17, color: c, letterSpacing: '-0.01em' }}>{l.arrow}</span>
|
||||
<span style={{ fontFamily: I.body, fontSize: 11.5, color: I.mute }}>{l.puc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontFamily: I.body, fontSize: 13, lineHeight: 1.55, color: I.mute, margin: '24px 0 0', display: 'flex', gap: 9 }}>
|
||||
<span aria-hidden="true" style={{ color: I.lilac }}>◇</span>
|
||||
Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
|
||||
</p>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
function Cond({ children }) {
|
||||
return <span style={{ fontFamily: I.body, fontSize: 13, color: I.soft, padding: '6px 12px', borderRadius: 999, background: 'rgba(237,234,255,.05)', border: `1px solid ${I.hair}`, whiteSpace: 'nowrap' }}>{children}</span>;
|
||||
}
|
||||
|
||||
Object.assign(window, { HFCover, HFRouting });
|
||||
@@ -0,0 +1,267 @@
|
||||
// hf-kit.jsx — Wiggleverse ecomm HI-FI primitives (Direction A · Quiet centered).
|
||||
// Depth = layered surfaces + hairlines (the brand avoids shadows). Hover = 1–3px lift.
|
||||
// Merchant persona: Ben's Bets · ben@bensbets.com
|
||||
|
||||
const HFDS = window.WiggleverseDesignSystem_94cd80;
|
||||
const { Button: HFButton, Soul: HFSoul, Eyebrow: HFEyebrow, Tag: HFTag } = HFDS;
|
||||
|
||||
// Resolve a brand asset: blob URL when bundled offline (window.__resources), else relative path.
|
||||
const HF_ASSET = (id, path) => (window.__resources && window.__resources[id]) || path;
|
||||
window.HF_ASSET = HF_ASSET;
|
||||
|
||||
// ---- token shorthands ----------------------------------------------------
|
||||
const H = {
|
||||
sky: 'var(--wv-midnight)', night: 'var(--wv-night)',
|
||||
raised: 'var(--wv-indigo)', raisedHi: 'var(--wv-indigo-2)',
|
||||
star: 'var(--wv-starlight)', soft: 'var(--wv-starlight-78)',
|
||||
mute: 'var(--wv-starlight-60)', faint: 'var(--wv-starlight-55)',
|
||||
lilac: 'var(--wv-lilac)', violet: 'var(--wv-violet)',
|
||||
gold: 'var(--wv-gold)', goldHi: 'var(--wv-gold-hi)', goldInk: 'var(--wv-gold-ink)',
|
||||
hair: 'var(--border-soft)', hairCard: 'var(--border-card)', hairStrong: 'var(--border-strong)',
|
||||
disp: 'var(--wv-font-display)', body: 'var(--wv-font-body)', soul: 'var(--wv-font-human)',
|
||||
shadow: 'var(--shadow-soft)',
|
||||
};
|
||||
const MENU_EMAIL = 'ben@bensbets.com';
|
||||
const STORE = "Ben's Bets";
|
||||
|
||||
// barely-there starfield (Direction A: restrained) — a few asymmetric nodes
|
||||
const HF_STARS =
|
||||
'radial-gradient(1.3px 1.3px at 16% 22%, rgba(237,234,255,.34), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 78% 14%, rgba(237,234,255,.22), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 88% 46%, rgba(155,140,255,.30), transparent),' +
|
||||
'radial-gradient(1.3px 1.3px at 30% 72%, rgba(237,234,255,.24), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 62% 86%, rgba(237,234,255,.18), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 7% 56%, rgba(155,140,255,.26), transparent)';
|
||||
// gold horizon — the brand's warmth, a low glow at the bottom of the sky
|
||||
const HF_HORIZON = 'radial-gradient(120% 70% at 50% 142%, rgba(244,199,107,.16) 0%, rgba(244,199,107,.05) 38%, transparent 64%)';
|
||||
|
||||
// One-time injected stylesheet: hover + focus affordances (subtle motion only).
|
||||
function HFStyles() {
|
||||
return (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.hf { --r: var(--radius-card); }
|
||||
.hf a.lk { color: var(--wv-lilac); text-decoration: none; transition: color .15s ease; }
|
||||
.hf a.lk:hover { color: var(--wv-gold); }
|
||||
.hf .row-lift { transition: transform .15s ease, border-color .15s ease, background .15s ease; }
|
||||
.hf .row-lift:hover { transform: translateY(-1px); border-color: var(--border-strong); background: var(--wv-indigo-2); }
|
||||
.hf .field { transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; }
|
||||
.hf .field:hover { border-color: var(--border-strong); }
|
||||
.hf .field.is-focus { border-color: var(--wv-gold); box-shadow: 0 0 0 3px var(--wv-gold-28); background: rgba(237,234,255,.06); }
|
||||
.hf .field.is-attn { border-color: var(--wv-gold); }
|
||||
.hf .caret { display:inline-block; width:1.5px; height:1.1em; background: var(--wv-gold); margin-left:1px; vertical-align:-2px; animation: hfblink 1.1s steps(1) infinite; }
|
||||
@keyframes hfblink { 50% { opacity: 0; } }
|
||||
.hf .signout { font-family: var(--wv-font-display); font-weight:500; font-size:14px; color: var(--wv-starlight); background:transparent; border:none; cursor:pointer; padding:0; transition: color .15s ease; }
|
||||
.hf .signout:hover { color: var(--wv-gold); }
|
||||
.hf .navitem { transition: background .15s ease, color .15s ease; }
|
||||
` }} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ground ──────────────────────────────────────────────────────────
|
||||
function HFScreen({ children, horizon = false, stars = true, bg = H.sky, style = {} }) {
|
||||
const layers = [];
|
||||
if (stars) layers.push(HF_STARS);
|
||||
if (horizon) layers.unshift(HF_HORIZON);
|
||||
const background = layers.length ? `${layers.join(',')}, ${bg}` : bg;
|
||||
return (
|
||||
<div className="hf" style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', background,
|
||||
color: H.star, fontFamily: H.body, display: 'flex', flexDirection: 'column',
|
||||
position: 'relative', overflow: 'hidden', ...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wordmark — mark tile + "ecomm" in Space Grotesk ───────────────────────
|
||||
function HFWordmark({ size = 26, sub = false }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size * 0.42 }}>
|
||||
<img src={HF_ASSET('markTile', 'assets/mark-tile.svg')} width={size} height={size}
|
||||
style={{ borderRadius: size * 0.22, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: size * 0.3 }}>
|
||||
<span style={{ fontFamily: H.disp, fontWeight: 700, fontSize: size * 0.86, letterSpacing: '-0.015em', color: H.star }}>ecomm</span>
|
||||
{sub && <span style={{ fontFamily: H.body, fontSize: size * 0.42, color: H.mute, letterSpacing: '0.02em' }}>by Wiggleverse</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TopBar — glass chrome ──────────────────────────────────────────────────
|
||||
function HFTopBar({ left, right, pad = '0 36px', height = 68 }) {
|
||||
return (
|
||||
<header style={{
|
||||
flex: '0 0 auto', height, padding: pad, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', borderBottom: `1px solid ${H.hair}`,
|
||||
background: 'var(--glass-sky)', backdropFilter: 'blur(var(--glass-blur))',
|
||||
position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>{left}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAccountChip({ email = MENU_EMAIL, menu = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, position: 'relative' }}>
|
||||
<span style={{ width: 30, height: 30, borderRadius: 15, background: 'var(--wv-lilac-16)', border: `1px solid ${H.hairCard}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: H.disp, fontWeight: 700, fontSize: 13, color: H.star }}>B</span>
|
||||
<span style={{ fontFamily: H.body, fontSize: 14, color: H.soft }}>{email}</span>
|
||||
<span style={{ width: 1, height: 18, background: H.hair }} />
|
||||
<button className="signout">Sign out</button>
|
||||
{menu && (
|
||||
<div style={{ position: 'absolute', top: 40, right: 0, minWidth: 216, zIndex: 9, background: H.raised, border: `1px solid ${H.hairCard}`, borderRadius: 12, padding: 7, boxShadow: H.shadow }}>
|
||||
<div style={{ padding: '9px 12px' }}>
|
||||
<div style={{ fontFamily: H.disp, fontWeight: 500, fontSize: 13.5, color: H.star }}>Ben</div>
|
||||
<div style={{ fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{email}</div>
|
||||
</div>
|
||||
<div style={{ borderTop: `1px solid ${H.hair}`, margin: '6px 4px' }} />
|
||||
<div className="navitem" style={{ padding: '9px 12px', fontFamily: H.body, fontSize: 13.5, color: H.soft, borderRadius: 8, cursor: 'pointer' }}>Account settings</div>
|
||||
<div className="navitem" style={{ padding: '9px 12px', fontFamily: H.body, fontSize: 13.5, color: H.gold, fontWeight: 600, borderRadius: 8, cursor: 'pointer' }}>Sign out</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field ──────────────────────────────────────────────────────────────────
|
||||
function HFField({ label, placeholder, value, helper, error, focus = false, optional = false, hint }) {
|
||||
const cls = `field${focus ? ' is-focus' : ''}${error ? ' is-attn' : ''}`;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ fontFamily: H.body, fontWeight: 500, fontSize: 13.5, color: H.soft, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ fontSize: 12.5, color: H.mute, fontWeight: 400 }}>optional</span>}
|
||||
</label>
|
||||
<div className={cls} style={{
|
||||
height: 52, borderRadius: 10, padding: '0 16px', display: 'flex', alignItems: 'center',
|
||||
background: 'rgba(237,234,255,.035)', border: `1.5px solid ${H.hair}`,
|
||||
fontFamily: H.body, fontSize: 16, color: value ? H.star : H.mute,
|
||||
}}>
|
||||
{value || placeholder}
|
||||
{focus && !value && <span className="caret" />}
|
||||
{hint && <span style={{ marginLeft: 'auto', fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{hint}</span>}
|
||||
</div>
|
||||
{error && <HFNote tone="attn">{error}</HFNote>}
|
||||
{helper && !error && <HFNote>{helper}</HFNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HFNote({ children, tone }) {
|
||||
return <span style={{ fontFamily: H.body, fontSize: 13, lineHeight: 1.45, color: tone === 'attn' ? H.gold : H.mute, display: 'flex', gap: 7, alignItems: 'baseline' }}>
|
||||
{tone === 'attn' && <span aria-hidden="true" style={{ color: H.gold, fontSize: 11 }}>◆</span>}
|
||||
<span>{children}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
// ── CodeField — 6 cells ─────────────────────────────────────────────────────
|
||||
function HFCodeField({ filled = 0, error = false, vals = ['4', '1', '9', '2', '0', '4'] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 11 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => {
|
||||
const active = i === filled && !error;
|
||||
const has = i < filled;
|
||||
return (
|
||||
<div key={i} className={`field${active ? ' is-focus' : ''}${error ? ' is-attn' : ''}`} style={{
|
||||
width: 52, height: 64, borderRadius: 11, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(237,234,255,.035)', border: `1.5px solid ${H.hair}`,
|
||||
fontFamily: H.disp, fontWeight: 500, fontSize: 26, color: H.star,
|
||||
}}>
|
||||
{has ? vals[i] : (active ? <span className="caret" style={{ height: 28 }} /> : '')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner — gold attention / lilac info (no red in palette) ───────────────
|
||||
function HFBanner({ children, tone = 'attn', title }) {
|
||||
const attn = tone === 'attn';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, padding: '14px 16px', borderRadius: 12,
|
||||
background: attn ? 'rgba(244,199,107,.10)' : 'var(--wv-lilac-08)',
|
||||
border: `1px solid ${attn ? 'var(--wv-gold-40)' : 'var(--wv-lilac-18)'}`,
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ color: attn ? H.gold : H.lilac, fontSize: 13, lineHeight: '22px' }}>{attn ? '◆' : '◇'}</span>
|
||||
<div style={{ fontFamily: H.body, fontSize: 14, lineHeight: 1.5, color: H.soft }}>
|
||||
{title && <div style={{ color: H.star, fontWeight: 600, marginBottom: 2 }}>{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AuthCard — the one surface that earns a real shadow ────────────────────
|
||||
function HFAuthCard({ children, width = 440 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, maxWidth: '100%', boxSizing: 'border-box', background: H.raised,
|
||||
border: `1px solid ${H.hairCard}`, borderRadius: 'var(--radius-card)',
|
||||
padding: '40px 40px 34px', display: 'flex', flexDirection: 'column', gap: 22,
|
||||
boxShadow: H.shadow, position: 'relative', zIndex: 2,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Primary action wrapper (DS Button, full-width option) ──────────────────
|
||||
function HFPrimary({ children, full = true, disabled = false }) {
|
||||
return (
|
||||
<HFButton variant="primary" href={disabled ? undefined : '#'} disabled={disabled}
|
||||
style={{ justifyContent: 'center', width: full ? '100%' : 'auto', padding: '.85rem 1.4rem', fontSize: 15.5, boxSizing: 'border-box' }}>
|
||||
{children}
|
||||
</HFButton>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────────────
|
||||
function HFFooter({ pad = '20px 36px' }) {
|
||||
return (
|
||||
<footer style={{ flex: '0 0 auto', padding: pad, borderTop: `1px solid ${H.hair}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, fontFamily: H.body, fontSize: 12.5, color: H.mute, background: 'rgba(9,12,34,.4)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9 }}>
|
||||
<img src={HF_ASSET('markGold', 'assets/mark-mono-gold.svg')} width={16} height={16} alt="" style={{ opacity: 0.85 }} />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', gap: 18 }}>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Privacy</a>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Open Core</a>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Terms</a>
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heading helper ───────────────────────────────────────────────────────────
|
||||
function HFHeading({ children, sub, size = 27 }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: H.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: size, color: H.star, margin: '0 0 9px', lineHeight: 1.08 }}>{children}</h1>
|
||||
{sub && <p style={{ fontFamily: H.body, fontSize: 14.5, lineHeight: 1.55, color: H.soft, margin: 0 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hfHonest = { fontFamily: H.body, fontSize: 13, color: H.mute, lineHeight: 1.5, margin: 0 };
|
||||
|
||||
// ── StateCard — frames a single state in the states stacks ─────────────────
|
||||
function HFStateCard({ label, note, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
<HFTag variant="soon">{label}</HFTag>
|
||||
{note && <span style={{ fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{note}</span>}
|
||||
</div>
|
||||
<div style={{ background: H.raised, border: `1px solid ${H.hairCard}`, borderRadius: 12, padding: 22 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HFStates({ children }) {
|
||||
return <div className="hf" style={{ height: '100%', background: `${HF_STARS}, ${H.sky}`, padding: 26, boxSizing: 'border-box', overflow: 'hidden', fontFamily: H.body, display: 'flex', flexDirection: 'column', gap: 20 }}>{children}</div>;
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
HF_H: H, HF_STARS, HF_HORIZON, HF_STORE: STORE, HF_EMAIL: MENU_EMAIL, HF_DSC: HFDS,
|
||||
HFStyles, HFScreen, HFWordmark, HFTopBar, HFAccountChip, HFField, HFNote, HFCodeField,
|
||||
HFBanner, HFAuthCard, HFPrimary, HFFooter, HFHeading, HFStateCard, HFStates,
|
||||
hfHonest,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// hf-landing.jsx — Landing (PUC-1), Direction A hi-fi. Desktop + mobile.
|
||||
const { HFScreen: LScreen, HFWordmark: LWordmark, HFTopBar: LTopBar, HFFooter: LFooter,
|
||||
HFPrimary: LPrimary, HF_H: L, HF_DSC: LDS } = window;
|
||||
const { Soul: LSoul, Eyebrow: LEyebrow } = LDS;
|
||||
|
||||
function NavLinks({ mobile = false }) {
|
||||
if (mobile) return <a className="lk" href="#" style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14, color: L.star }}>Log in</a>;
|
||||
return (
|
||||
<nav style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14, color: L.soft }}>How it works</a>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14, color: L.soft }}>Open Core</a>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14, color: L.star }}>Log in</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// trust row — three honest promises, hairline-separated (no icons-as-slop)
|
||||
function Promises({ device }) {
|
||||
const items = [
|
||||
['No trial clock', 'Your storefront never expires or locks.'],
|
||||
['No plan wall', 'We take only what it takes to run.'],
|
||||
['Your data, yours', "Nothing collected you didn't agree to give."],
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: device === 'desktop' ? 'repeat(3, 1fr)' : '1fr',
|
||||
gap: device === 'desktop' ? 0 : 14, marginTop: device === 'desktop' ? 44 : 28,
|
||||
borderTop: `1px solid ${L.hair}`, paddingTop: device === 'desktop' ? 24 : 20,
|
||||
}}>
|
||||
{items.map(([t, d], i) => (
|
||||
<div key={t} style={{
|
||||
padding: device === 'desktop' ? '0 22px' : 0,
|
||||
borderLeft: device === 'desktop' && i > 0 ? `1px solid ${L.hair}` : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 5 }}>
|
||||
<span aria-hidden="true" style={{ color: L.gold, fontSize: 10 }}>◆</span>
|
||||
<span style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14.5, color: L.star }}>{t}</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: L.body, fontSize: 13, lineHeight: 1.5, color: L.mute, margin: 0 }}>{d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HFLanding({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<LScreen horizon>
|
||||
<LTopBar pad={desktop ? '0 44px' : '0 22px'}
|
||||
left={<LWordmark size={desktop ? 26 : 22} />} right={<NavLinks mobile={!desktop} />} />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
|
||||
padding: desktop ? '0 44px' : '0 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: desktop ? 680 : '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<LEyebrow style={{ margin: '0 0 20px' }}>One storefront, fully yours</LEyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: L.disp, fontWeight: 700, letterSpacing: '-0.02em', lineHeight: 1.02,
|
||||
fontSize: desktop ? 62 : 36, color: L.star, margin: '0 0 22px',
|
||||
}}>
|
||||
Sell online,<br /><span style={{ color: L.gold }}>honestly.</span>
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: L.body, fontSize: desktop ? 18.5 : 15.5, lineHeight: 1.6, color: L.soft,
|
||||
maxWidth: 520, margin: '0 0 32px', textWrap: 'pretty',
|
||||
}}>
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: desktop ? 'row' : 'column', gap: 14, alignItems: 'center', width: desktop ? 'auto' : '100%' }}>
|
||||
<div style={{ width: desktop ? 'auto' : '100%' }}><LPrimary full={!desktop}>Create your storefront →</LPrimary></div>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14.5, color: L.mute }}>
|
||||
Already selling with us? <span style={{ color: L.lilac }}>Log in</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ width: '100%', marginTop: desktop ? 8 : 4 }}><Promises device={device} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<LFooter pad={desktop ? '20px 44px' : '16px 22px'} />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFLanding });
|
||||
@@ -0,0 +1,116 @@
|
||||
// hf-signin.jsx — Sign in (PUC-2 / PUC-3) hi-fi. Email → one-time code + states.
|
||||
const { HFScreen: SScreen, HFWordmark: SWordmark, HFTopBar: STopBar, HFFooter: SFooter,
|
||||
HFAuthCard: SAuthCard, HFField: SField, HFCodeField: SCodeField, HFBanner: SBanner,
|
||||
HFPrimary: SPrimary, HFHeading: SHeading, HFStateCard: SStateCard, HFStates: SStates,
|
||||
HF_H: S, hfHonest: sHonest } = window;
|
||||
|
||||
function SBack({ label = 'Back' }) {
|
||||
return <a className="lk" href="#" style={{ fontFamily: S.body, fontSize: 14, color: S.mute }}>← {label}</a>;
|
||||
}
|
||||
|
||||
// ── Step 1 · email ──────────────────────────────────────────────────────────
|
||||
function EmailStep({ framing = 'signup', s = {} }) {
|
||||
const login = framing === 'login';
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 2 }}><SWordmark size={24} /></div>
|
||||
<SHeading sub={login ? 'Enter your email and we\u2019ll send a one-time code to sign in.' : 'Enter your email to begin. There\u2019s no password to create.'}>
|
||||
{login ? 'Log in' : 'Create your storefront'}
|
||||
</SHeading>
|
||||
{s.deliveryFail && <SBanner title="We couldn't send the code">The email didn't go out. Try again in a moment — nothing was lost.</SBanner>}
|
||||
<SField label="Email" placeholder="you@example.com" value={s.email}
|
||||
focus={!s.emailError && !s.email} error={s.emailError} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SPrimary disabled={!!s.cooldown}>{s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}</SPrimary>
|
||||
<p style={sHonest}>We'll email you a one-time code. That's all we need — no password, ever.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2 · code ────────────────────────────────────────────────────────────
|
||||
function CodeStep({ s = {} }) {
|
||||
const email = s.email || 'ben@bensbets.com';
|
||||
let err = null;
|
||||
if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
|
||||
if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 2 }}><SWordmark size={24} /></div>
|
||||
<SHeading sub={<>We sent a 6-digit code to <span style={{ color: S.star }}>{email}</span>. <a className="lk" href="#">Wrong address?</a></>}>
|
||||
Check your email
|
||||
</SHeading>
|
||||
{s.welcome && <SBanner tone="info" title={s.welcome === 'new' ? 'Welcome to ecomm' : 'Welcome back'}>
|
||||
{s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
|
||||
</SBanner>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
|
||||
<SCodeField filled={s.filled ?? 3} error={!!(s.codeWrong || s.codeExpired)} />
|
||||
{err && <span style={{ fontFamily: S.body, fontSize: 13, color: S.gold, display: 'flex', gap: 6, alignSelf: 'flex-start' }}><span aria-hidden="true" style={{ fontSize: 11 }}>◆</span>{err}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SPrimary>Continue</SPrimary>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p style={sHonest}>Good for 10 minutes.</p>
|
||||
<a className="lk" href="#" style={{ fontFamily: S.disp, fontWeight: 500, fontSize: 13.5, color: s.cooldown ? S.mute : S.lilac }}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Resend code'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HFSignIn({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<SScreen horizon>
|
||||
<STopBar pad={desktop ? '0 36px' : '0 22px'} left={<SWordmark size={desktop ? 22 : 20} />} right={<SBack />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<SAuthCard width={desktop ? 432 : '100%'}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</SAuthCard>
|
||||
</div>
|
||||
{desktop && <SFooter />}
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── States stack ─────────────────────────────────────────────────────────────
|
||||
function codeNote(text) {
|
||||
return <span style={{ fontFamily: S.body, fontSize: 13, color: S.gold, display: 'flex', gap: 6, marginTop: 10 }}><span aria-hidden="true" style={{ fontSize: 11 }}>◆</span>{text}</span>;
|
||||
}
|
||||
function HFSignInStates() {
|
||||
return (
|
||||
<SStates>
|
||||
<SStateCard label="Invalid email" note="inline field error">
|
||||
<SField label="Email" value="ben@bensbets" error="That doesn't look like an email address." />
|
||||
</SStateCard>
|
||||
<SStateCard label="Resend cooldown" note="PUC-2c · 60s per email">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
||||
<SPrimary disabled>Resend in 47s</SPrimary>
|
||||
<span style={sHonest}>One code per minute keeps inboxes — and abuse — in check.</span>
|
||||
</div>
|
||||
</SStateCard>
|
||||
<SStateCard label="Delivery failed" note="502 · honest failure (INV-9)">
|
||||
<SBanner title="We couldn't send the code">The email didn't go out — try again. We'd never fake a success.</SBanner>
|
||||
</SStateCard>
|
||||
<SStateCard label="Wrong code" note="PUC-2a">
|
||||
<SCodeField filled={6} error />{codeNote("That code didn't match — 2 attempts left.")}
|
||||
</SStateCard>
|
||||
<SStateCard label="Expired code" note="PUC-2b · 10-min window">
|
||||
<SCodeField filled={6} error />{codeNote('That code expired. Send a fresh one below.')}
|
||||
</SStateCard>
|
||||
<SStateCard label="Attempts exhausted" note="5 tries · code invalidated (INV-3)">
|
||||
<SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>
|
||||
</SStateCard>
|
||||
<SStateCard label="Honest welcome" note="PUC-3 · same flow, true label">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SBanner tone="info" title="Welcome to ecomm">A new account was created for this email.</SBanner>
|
||||
<SBanner tone="info" title="Welcome back">Signed in to your existing account.</SBanner>
|
||||
</div>
|
||||
</SStateCard>
|
||||
</SStates>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFSignIn, HFSignInStates });
|
||||
@@ -0,0 +1,79 @@
|
||||
// hf-storefront.jsx — Create storefront (PUC-4/5/7) hi-fi + states.
|
||||
const { HFScreen: FScreen, HFWordmark: FWordmark, HFTopBar: FTopBar, HFAccountChip: FChip,
|
||||
HFFooter: FFooter, HFAuthCard: FAuthCard, HFField: FField, HFBanner: FBanner,
|
||||
HFPrimary: FPrimary, HFStateCard: FStateCard, HFStates: FStates, HF_H: F, HF_DSC: FDS } = window;
|
||||
const { Eyebrow: FEyebrow } = FDS;
|
||||
|
||||
function CreateForm({ s = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<FEyebrow style={{ margin: '0 0 12px' }}>One storefront, fully yours</FEyebrow>
|
||||
<h1 style={{ fontFamily: F.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, color: F.star, margin: '0 0 9px', lineHeight: 1.08 }}>
|
||||
Create your storefront
|
||||
</h1>
|
||||
<p style={{ fontFamily: F.body, fontSize: 14.5, lineHeight: 1.55, color: F.soft, margin: 0 }}>
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{s.alreadyOwns && <FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a className="lk" href="#">Go to your admin →</a>
|
||||
</FBanner>}
|
||||
<FField label="Storefront name" optional placeholder="e.g. Ben's Bets" value={s.name}
|
||||
focus={!s.name && !s.blankPreview} error={s.nameError}
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time." />
|
||||
{s.blankPreview && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginTop: -8, fontFamily: F.body, fontSize: 13, color: F.mute }}>
|
||||
<span aria-hidden="true" style={{ color: F.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: F.soft, fontStyle: 'italic' }}>“ben's storefront”</span> — change it later.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FPrimary disabled={s.creating}>{s.creating ? 'Creating…' : 'Create storefront'}</FPrimary>
|
||||
<p style={{ fontFamily: F.body, fontSize: 13, color: F.mute, lineHeight: 1.5, margin: 0 }}>
|
||||
You can rename, configure, or close your storefront whenever you like.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HFCreate({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<FScreen horizon>
|
||||
<FTopBar pad={desktop ? '0 36px' : '0 20px'} left={<FWordmark size={desktop ? 22 : 20} />}
|
||||
right={desktop ? <FChip /> : <button className="signout">Sign out</button>} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<FAuthCard width={desktop ? 448 : '100%'}><CreateForm s={s} /></FAuthCard>
|
||||
</div>
|
||||
{desktop && <FFooter />}
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function HFCreateStates() {
|
||||
return (
|
||||
<FStates>
|
||||
<FStateCard label="Blank name allowed" note="PUC-4 · default generated">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FField label="Storefront name" optional placeholder="e.g. Ben's Bets" helper="Leave blank and we'll name it for you." />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontFamily: F.body, fontSize: 13, color: F.mute }}>
|
||||
<span aria-hidden="true" style={{ color: F.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: F.soft, fontStyle: 'italic' }}>“ben's storefront”</span>
|
||||
</div>
|
||||
</div>
|
||||
</FStateCard>
|
||||
<FStateCard label="Creating" note="button busy, single submit">
|
||||
<FPrimary disabled>Creating…</FPrimary>
|
||||
</FStateCard>
|
||||
<FStateCard label="Already owns one" note="409 · defense in depth (PUC-7)">
|
||||
<FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a className="lk" href="#">Go to your admin →</a>
|
||||
</FBanner>
|
||||
</FStateCard>
|
||||
</FStates>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFCreate, HFCreateStates });
|
||||
@@ -0,0 +1,128 @@
|
||||
// wf-admin.jsx — Screen 4: Admin shell (PUC-8, PUC-9). A/B + states.
|
||||
const { Screen: AScreen, Wordmark: AWordmark, TopBar: ATopBar, AccountChip: AAccountChip,
|
||||
Banner: ABanner, EmptyAdmin: AEmptyAdmin, StateBlock: AStateBlock, WF_T: AT, WF_DS: ADS } = window;
|
||||
const { Eyebrow: AEyebrow } = ADS;
|
||||
|
||||
const STORE = 'Studio Fern';
|
||||
|
||||
// storefront identity lockup — the admin's anchor (left of top bar in A)
|
||||
function StoreId({ size = 17 }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 11 }}>
|
||||
<img src="assets/mark-tile.svg" width={size + 6} height={size + 6} style={{ borderRadius: 6, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.1 }}>
|
||||
<span style={{ fontFamily: AT.disp, fontWeight: 700, fontSize: size, color: AT.star, letterSpacing: '-0.01em' }}>{STORE}</span>
|
||||
<span style={{ fontFamily: AT.body, fontSize: 11, color: AT.mute }}>ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — top bar + centered honest-empty ──────────────────────────
|
||||
function AdminA({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<AScreen>
|
||||
<ATopBar pad={desktop ? '0 32px' : '0 18px'} left={<StoreId size={desktop ? 17 : 15} />}
|
||||
right={desktop ? <AAccountChip /> : <button style={signOutBtn}>Sign out</button>} />
|
||||
<AEmptyAdmin storefront={STORE} compact={!desktop} />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — durable app shell (sidebar + content) ────────────────────
|
||||
function AdminB({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<AScreen>
|
||||
<ATopBar pad="0 18px" left={<StoreId size={15} />} right={<button style={signOutBtn}>Sign out</button>} />
|
||||
<div style={{ flex: '0 0 auto', padding: '12px 18px', borderBottom: `1px solid ${AT.hair}`, display: 'flex', gap: 8 }}>
|
||||
<NavItem active>Home</NavItem>
|
||||
</div>
|
||||
<AEmptyAdmin storefront={STORE} compact />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AScreen style={{ flexDirection: 'row' }}>
|
||||
<Sidebar />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<header style={{ flex: '0 0 auto', height: 60, padding: '0 36px', display: 'flex', alignItems: 'center', borderBottom: `1px solid ${AT.hair}` }}>
|
||||
<span style={{ fontFamily: AT.disp, fontWeight: 500, fontSize: 15, color: AT.soft }}>Home</span>
|
||||
</header>
|
||||
<div style={{ flex: 1, display: 'flex' }}>
|
||||
<AEmptyAdmin storefront={STORE} />
|
||||
</div>
|
||||
</div>
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside style={{ flex: '0 0 258px', background: 'var(--wv-night)', borderRight: `1px solid ${AT.hair}`, display: 'flex', flexDirection: 'column', padding: '22px 16px' }}>
|
||||
<div style={{ padding: '0 8px 18px' }}><StoreId /></div>
|
||||
<div style={{ borderTop: `1px solid ${AT.hair}`, margin: '0 8px 16px' }} />
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<NavItem active full>Home</NavItem>
|
||||
</nav>
|
||||
<div style={{ flex: 1 }} />
|
||||
<p style={{ fontFamily: AT.body, fontSize: 11.5, lineHeight: 1.5, color: 'var(--wv-starlight-55)', padding: '0 10px', margin: '0 0 16px' }}>
|
||||
Catalog, orders & settings join this rail as ecomm grows. Nothing is shown here before it exists.
|
||||
</p>
|
||||
<div style={{ borderTop: `1px solid ${AT.hair}`, padding: '14px 10px 2px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<span style={{ fontFamily: AT.body, fontSize: 13, color: AT.soft }}>mara@studiofern.com</span>
|
||||
<button style={{ ...signOutBtn, fontSize: 13, textAlign: 'left' }}>Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ children, active, full }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 9, padding: full ? '9px 12px' : '7px 13px',
|
||||
borderRadius: 9, fontFamily: AT.disp, fontWeight: 500, fontSize: 14,
|
||||
color: active ? AT.star : AT.mute,
|
||||
background: active ? 'var(--wv-lilac-12)' : 'transparent',
|
||||
border: full ? `1px solid ${active ? 'var(--wv-lilac-18)' : 'transparent'}` : 'none',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ width: 6, height: 6, borderRadius: 3, background: active ? AT.lilac : 'transparent' }} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const signOutBtn = { fontFamily: AT.disp, fontWeight: 500, fontSize: 14, color: AT.star, background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 };
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
function AdminStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<AStateBlock label="Loading" note="minimal skeleton while /me resolves">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Skel w={90} h={12} />
|
||||
<Skel w={200} h={28} />
|
||||
<Skel w={280} h={12} />
|
||||
<Skel w={240} h={12} />
|
||||
</div>
|
||||
</AStateBlock>
|
||||
<AStateBlock label="Session expired" note="redirect to sign-in">
|
||||
<ABanner tone="info" title="Your session ended">
|
||||
For your security you've been signed out. <a href="#" style={{ color: AT.lilac, textDecoration: 'none' }}>Sign in again →</a>
|
||||
</ABanner>
|
||||
</AStateBlock>
|
||||
<AStateBlock label="Sign out" note="account menu open (PUC-9)">
|
||||
<div style={{ position: 'relative', height: 96 }}>
|
||||
<AAccountChip menu />
|
||||
</div>
|
||||
</AStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Skel({ w, h }) {
|
||||
return <div style={{ width: w, height: h, borderRadius: 6, background: 'rgba(237,234,255,.08)' }} />;
|
||||
}
|
||||
|
||||
Object.assign(window, { AdminA, AdminB, AdminStates });
|
||||
@@ -0,0 +1,214 @@
|
||||
// wf-bootstrap.jsx — Section 5: Bootstrap (PUC-10 / PUC-11, INV-1/INV-7).
|
||||
// Operator & developer surfaces — terminals and a runbook, not app screens.
|
||||
const { Screen: BScreen, Wordmark: BWordmark, WF_T: BT, WF_DS: BDS } = window;
|
||||
const { Eyebrow: BEyebrow, Soul: BSoul, Tag: BTag, Callout: BCallout } = BDS;
|
||||
|
||||
const MONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
|
||||
|
||||
// ── Terminal window ────────────────────────────────────────────────────────
|
||||
function Terminal({ title, lines, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--wv-night)', border: `1px solid ${BT.hair}`, borderRadius: 12,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column', ...style,
|
||||
}}>
|
||||
<div style={{ flex: '0 0 auto', height: 40, padding: '0 14px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: `1px solid ${BT.hair}`, background: 'rgba(155,140,255,.05)' }}>
|
||||
<span style={{ display: 'flex', gap: 6 }}>
|
||||
{['rgba(155,140,255,.5)', 'rgba(244,199,107,.5)', 'rgba(237,234,255,.3)'].map((c, i) => (
|
||||
<span key={i} style={{ width: 9, height: 9, borderRadius: 5, background: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: BT.mute, marginLeft: 4 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '16px 18px', fontFamily: MONO, fontSize: 12.5, lineHeight: 1.75, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
||||
{lines.map((ln, i) => <TLine key={i} ln={ln} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function TLine({ ln }) {
|
||||
const [k, t] = ln;
|
||||
if (k === 'sp') return <div style={{ height: 9 }} />;
|
||||
if (k === 'cmd') return <div><span style={{ color: BT.lilac }}>$ </span><span style={{ color: BT.star }}>{t}</span></div>;
|
||||
if (k === 'step') return <div><span style={{ color: BT.lilac }}>→ </span><span style={{ color: BT.soft }}>{t}</span></div>;
|
||||
if (k === 'ok') return <div><span style={{ color: BT.gold }}>✓ </span><span style={{ color: BT.star }}>{t}</span></div>;
|
||||
if (k === 'hl') return <div style={{ color: BT.gold, background: 'rgba(244,199,107,.08)', margin: '0 -8px', padding: '1px 8px', borderRadius: 4 }}>{t}</div>;
|
||||
if (k === 'note') return <div style={{ color: 'var(--wv-starlight-55)', fontStyle: 'italic' }}>{t}</div>;
|
||||
return <div style={{ color: BT.mute }}>{t}</div>;
|
||||
}
|
||||
|
||||
// ── Wrap a section artboard on the sky ─────────────────────────────────────
|
||||
function Bay({ children, pad = 30 }) {
|
||||
return <div style={{ height: '100%', background: BT.sky, padding: pad, boxSizing: 'border-box', overflow: 'hidden', fontFamily: BT.body, display: 'flex', flexDirection: 'column' }}>{children}</div>;
|
||||
}
|
||||
function BayHead({ kicker, title, sub }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<BEyebrow style={{ margin: '0 0 8px' }}>{kicker}</BEyebrow>
|
||||
<h2 style={{ fontFamily: BT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 22, color: BT.star, margin: '0 0 6px', lineHeight: 1.1 }}>{title}</h2>
|
||||
{sub && <p style={{ fontFamily: BT.body, fontSize: 13.5, lineHeight: 1.5, color: BT.soft, margin: 0, maxWidth: 560 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1 · The principle — empty is a working state (INV-1) ───────────────────
|
||||
function BootstrapPrinciple() {
|
||||
const steps = [
|
||||
['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
|
||||
['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
|
||||
['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
|
||||
['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
|
||||
['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="INV-1 · the bootstrap property" title="Empty is a working state"
|
||||
sub="No seed step, ever. A fresh, empty database is a first-class state the product brings to life itself — which is what makes launching anywhere an ordinary, rehearsed act." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, position: 'relative' }}>
|
||||
{steps.map(([t, d, c], i) => {
|
||||
const col = c === 'gold' ? BT.gold : BT.lilac;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 16, paddingBottom: i < steps.length - 1 ? 18 : 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '0 0 auto' }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 13, border: `1.5px solid ${col}`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: BT.disp, fontWeight: 700, fontSize: 13 }}>{i + 1}</span>
|
||||
{i < steps.length - 1 && <span style={{ width: 1.5, flex: 1, minHeight: 22, background: BT.hair }} />}
|
||||
</div>
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
<div style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 15, color: BT.star }}>{t}</div>
|
||||
<div style={{ fontFamily: MONO, fontSize: 11.5, color: BT.mute, marginTop: 3 }}>{d}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2 · The runbook — docs/BOOTSTRAP.md (paper) ────────────────────────────
|
||||
function RunbookDoc() {
|
||||
const envs = [
|
||||
['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
|
||||
['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
|
||||
['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
|
||||
];
|
||||
return (
|
||||
<div style={{ height: '100%', background: 'var(--wv-paper)', padding: '34px 38px', boxSizing: 'border-box', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: 'var(--wv-violet)', background: 'rgba(124,111,224,.12)', padding: '3px 10px', borderRadius: 999 }}>docs/BOOTSTRAP.md</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 11.5, color: '#8A7FB0' }}>the documented gesture</span>
|
||||
</div>
|
||||
<h2 style={{ fontFamily: BT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: 'var(--wv-ink)', margin: '0 0 8px', lineHeight: 1.08 }}>Bringing ecomm up from empty</h2>
|
||||
<p style={{ fontFamily: BT.body, fontSize: 14, lineHeight: 1.55, color: '#4B4170', margin: '0 0 22px', maxWidth: 560 }}>
|
||||
One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{envs.map(([env, cmd, note], i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '128px 1fr', gap: 18, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 14, color: 'var(--wv-ink)' }}>{env}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO, fontSize: 12.5, color: 'var(--wv-ink)', background: '#ECE8F7', border: '1px solid #DCD5F0', borderRadius: 8, padding: '7px 11px', display: 'inline-block' }}>{cmd}</div>
|
||||
<div style={{ fontFamily: BT.body, fontSize: 12.5, lineHeight: 1.5, color: '#6A5F90', marginTop: 6 }}>{note}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<BCallout onLight>Empty is a working state — there is no seed.</BCallout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3 · localhost — scripts/dev.sh ─────────────────────────────────────────
|
||||
function DevTerminal() {
|
||||
const lines = [
|
||||
['cmd', 'git clone …/wiggleverse-ecomm && cd wiggleverse-ecomm'],
|
||||
['cmd', './scripts/dev.sh'],
|
||||
['sp'],
|
||||
['step', 'datastore: starting postgres (docker compose up)…'],
|
||||
['ok', 'postgres healthy · ecomm_dev · :5432'],
|
||||
['step', 'migrations: applying pending…'],
|
||||
['ok', '0001_init applied · schema current'],
|
||||
['step', 'backend: uvicorn on :8000'],
|
||||
['step', 'web: vite on :5173 (proxying /api)'],
|
||||
['ok', '/healthz 200 {status:"ok", migrations:"current"}'],
|
||||
['sp'],
|
||||
['out', ' ecomm is up against an empty database.'],
|
||||
['hl', ' open http://localhost:5173'],
|
||||
['sp'],
|
||||
['note', 'one-time codes print to this log (LogMailer) — no real mail in dev.'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-10 · localhost" title="One command from clean checkout"
|
||||
sub="A developer reaches “first merchant, first storefront” with Docker as the only prerequisite — the app migrates an empty database itself." />
|
||||
<Terminal title="scripts/dev.sh" lines={lines} style={{ flex: 1 }} />
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4 · The code channel — dev log vs real email ───────────────────────────
|
||||
function DevChannel() {
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-10 / PUC-11 · the OTC channel" title="Where the one-time code lands"
|
||||
sub="Same issue/verify path everywhere — only the mailer differs by configuration (INV-8)." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
<div>
|
||||
<ChanLabel tone="lilac">Dev — the code prints to the backend log</ChanLabel>
|
||||
<Terminal title="backend · LogMailer" lines={[
|
||||
['out', '[mailer] adapter=LogMailer'],
|
||||
['out', ' to: mara@studiofern.com'],
|
||||
['hl', ' subject: Your ecomm code: 419 204'],
|
||||
['out', ' code valid 10m · single-use · 5 tries'],
|
||||
]} />
|
||||
</div>
|
||||
<div>
|
||||
<ChanLabel tone="gold">Deployed — the code arrives as real email</ChanLabel>
|
||||
<div style={{ background: BT.raised, border: `1px solid ${BT.hairCard}`, borderRadius: 12, padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: BT.body, fontSize: 12, color: BT.mute, marginBottom: 8, borderBottom: `1px solid ${BT.hair}`, paddingBottom: 8 }}>
|
||||
<span>ecomm <no-reply@ecomm.wiggleverse.org></span><span>now</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 15, color: BT.star, marginBottom: 6 }}>Your ecomm code: 419 204</div>
|
||||
<div style={{ fontFamily: BT.body, fontSize: 12.5, lineHeight: 1.5, color: BT.soft }}>
|
||||
Enter this code to sign in. It's good for 10 minutes. If you didn't request it, ignore this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
function ChanLabel({ children, tone }) {
|
||||
const c = tone === 'gold' ? BT.gold : BT.lilac;
|
||||
return <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 9, fontFamily: BT.body, fontSize: 12.5, color: BT.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c }}>{tone === 'gold' ? '◆' : '◇'}</span>{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// ── 5 · deployed — flotilla (PPE then Prod) ────────────────────────────────
|
||||
function DeployTerminal() {
|
||||
const lines = [
|
||||
['cmd', 'flotilla deploy ecomm --env ppe'],
|
||||
['sp'],
|
||||
['step', 'provision: Cloud SQL (postgres) · backups + PITR…'],
|
||||
['ok', 'database ready · secrets resolved from Secret Manager'],
|
||||
['step', 'deploy: image → single VM · fail-stop'],
|
||||
['step', 'migrations: applying at startup…'],
|
||||
['ok', '0001_init applied'],
|
||||
['ok', '/healthz 200 — ppe live'],
|
||||
['out', ' rehearsal: real sign-up (real email) → storefront → admin ✓'],
|
||||
['sp'],
|
||||
['cmd', 'flotilla deploy ecomm --env prod'],
|
||||
['ok', 'same gesture — day-one prod is the bootstrap state'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-11 · pre-prod, then prod" title="The same gesture, deployed"
|
||||
sub="The operator runs flotilla; PPE rehearses Prod with real email (BUC-5a). No environment is “later.”" />
|
||||
<Terminal title="flotilla — operator front door" lines={lines} style={{ flex: 1 }} />
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { BootstrapPrinciple, RunbookDoc, DevTerminal, DevChannel, DeployTerminal });
|
||||
@@ -0,0 +1,175 @@
|
||||
// wf-intro.jsx — System framing artboards: the two directions + entry-routing spine.
|
||||
const { Screen: IScreen, Wordmark: IWordmark, WF_T: IT, WF_DS: IDS } = window;
|
||||
const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
|
||||
|
||||
// ── The two directions, side by side ───────────────────────────────────────
|
||||
function TwoDirections() {
|
||||
return (
|
||||
<IScreen starfield style={{ padding: '40px 44px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={26} />
|
||||
<ITag variant="live">SD-0001 · MVP wireframes</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 26 }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>One A/B, run across all four screens</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 34, lineHeight: 1.05, color: IT.star, margin: '0 0 10px' }}>
|
||||
Two bets on the lasting frame
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 15, lineHeight: 1.6, color: IT.soft, maxWidth: 620, margin: 0 }}>
|
||||
These are the first screens of a product headed for hundreds. The variation isn't cosmetic — each
|
||||
direction is a different answer to <em>how much frame to commit to on day one</em>. Same content, same tokens,
|
||||
same honest copy; different skeleton.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 22, marginTop: 30 }}>
|
||||
<DirCard letter="A" name="Quiet centered" tone="lilac"
|
||||
bet="Keep chrome minimal until features earn it."
|
||||
lines={['Auth & onboarding are one centered column on the sky.',
|
||||
'Admin is a slim top bar over a centered empty state.',
|
||||
'Fewest moving parts — the calm voice carries it.']}
|
||||
best="Reads calmest now; less to maintain." cost="A bigger reframe when the nav finally arrives." />
|
||||
<DirCard letter="B" name="Durable frame" tone="gold"
|
||||
bet="Set the skeleton now; let screens slot in."
|
||||
lines={['Entry uses a split: a persistent brand/identity rail + a work column.',
|
||||
'Admin ships the real shell — sidebar + content region.',
|
||||
'Catalog, orders, settings drop into an existing frame later.']}
|
||||
best="No redesign when features land." cost="More frame than four screens strictly need." />
|
||||
</div>
|
||||
<div style={{ marginTop: 26, padding: '16px 20px', borderRadius: 12, background: 'var(--wv-lilac-08)', border: `1px solid var(--wv-lilac-18)`, display: 'flex', gap: 13, alignItems: 'flex-start' }}>
|
||||
<span aria-hidden="true" style={{ color: IT.lilac }}>◇</span>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.55, color: IT.soft, margin: 0 }}>
|
||||
<strong style={{ color: IT.star }}>Both honor the OHM bar.</strong> No trial clock, no plan wall, no fabricated metrics or
|
||||
locked-feature teasers. Empty is an honest, finished state — not a sales surface. Attention states use <span style={{ color: IT.gold }}>gold</span>,
|
||||
since the palette carries no red.
|
||||
</p>
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function DirCard({ letter, name, bet, lines, best, cost, tone }) {
|
||||
const c = tone === 'gold' ? IT.gold : IT.lilac;
|
||||
return (
|
||||
<div style={{ background: IT.raised, border: `1px solid ${IT.hairCard}`, borderRadius: 14, padding: '22px 22px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ width: 34, height: 34, borderRadius: 9, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: IT.disp, fontWeight: 700, fontSize: 17, color: tone === 'gold' ? IT.goldInk : '#0E1230', background: c }}>{letter}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: IT.disp, fontWeight: 700, fontSize: 18, color: IT.star, letterSpacing: '-0.01em' }}>{name}</div>
|
||||
<div style={{ fontFamily: IT.body, fontStyle: 'italic', fontSize: 13, color: c }}>{bet}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{lines.map((l, i) => (
|
||||
<li key={i} style={{ display: 'flex', gap: 9, fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.45, color: IT.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c, flex: '0 0 auto' }}>·</span>{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ borderTop: `1px solid ${IT.hair}`, paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<TradeRow k="Wins" v={best} c={IT.lilac} />
|
||||
<TradeRow k="Costs" v={cost} c={IT.gold} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function TradeRow({ k, v, c }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 10, fontFamily: IT.body, fontSize: 12.5, lineHeight: 1.4 }}>
|
||||
<span style={{ flex: '0 0 42px', color: c, fontWeight: 600 }}>{k}</span>
|
||||
<span style={{ color: IT.mute }}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Entry routing — the exhaustive arrival rule (§6.5) ─────────────────────
|
||||
function EntryRouting() {
|
||||
const lanes = [
|
||||
{ sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
|
||||
{ sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
|
||||
{ sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
|
||||
];
|
||||
return (
|
||||
<IScreen style={{ padding: '40px 38px' }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>The shared spine</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, lineHeight: 1.08, color: IT.star, margin: '0 0 10px' }}>
|
||||
Entry routing
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 14, lineHeight: 1.55, color: IT.soft, margin: '0 0 8px' }}>
|
||||
One server answer — <code style={{ fontFamily: 'ui-monospace,monospace', fontSize: 12.5, color: IT.lilac, background: 'var(--wv-lilac-08)', padding: '1px 6px', borderRadius: 5 }}>GET /api/auth/me</code> —
|
||||
decides where every arrival lands. Every screen here obeys it.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 24 }}>
|
||||
{lanes.map((l, i) => {
|
||||
const c = l.tone === 'gold' ? IT.gold : l.tone === 'lilac' ? IT.lilac : IT.mute;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', borderRadius: 12, background: IT.raised, border: `1px solid ${IT.hairCard}` }}>
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto' }}>
|
||||
<Cond>{l.sess}</Cond>
|
||||
<Cond>{l.store}</Cond>
|
||||
</div>
|
||||
<span aria-hidden="true" style={{ color: c, fontSize: 18, flex: '0 0 auto' }}>→</span>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontFamily: IT.disp, fontWeight: 700, fontSize: 17, color: c, letterSpacing: '-0.01em' }}>{l.arrow}</span>
|
||||
<span style={{ fontFamily: IT.body, fontSize: 11.5, color: IT.mute }}>{l.puc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13, lineHeight: 1.55, color: IT.mute, margin: '22px 0 0', display: 'flex', gap: 9 }}>
|
||||
<span aria-hidden="true" style={{ color: IT.lilac }}>◇</span>
|
||||
Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
|
||||
</p>
|
||||
<div style={{ flex: 1 }} />
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
function Cond({ children }) {
|
||||
return <span style={{ fontFamily: IT.body, fontSize: 13, color: IT.soft, padding: '5px 11px', borderRadius: 999, background: 'rgba(237,234,255,.05)', border: `1px solid ${IT.hair}`, whiteSpace: 'nowrap' }}>{children}</span>;
|
||||
}
|
||||
|
||||
// ── Direction A, chosen — the system this locks in ─────────────────────────
|
||||
function SystemA() {
|
||||
const patterns = [
|
||||
['Entry surfaces', 'One centered card on the midnight sky — landing, sign-in, create. Content-led, no side chrome.'],
|
||||
['Admin', 'A slim top bar over a centered state. The storefront name is the anchor; no sidebar yet.'],
|
||||
['Actions', 'One primary gold CTA per screen. Lilac for secondary links. Ghost buttons stay rare.'],
|
||||
['Attention', 'Errors and waits read in gold — the palette carries no red. Honest failure, never a fake success.'],
|
||||
['Emptiness', 'Honestly empty everywhere: no zeroed tiles, no locked-feature teasers, no manufactured aspiration.'],
|
||||
];
|
||||
return (
|
||||
<IScreen starfield style={{ padding: '40px 44px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={26} />
|
||||
<ITag variant="live">SD-0001 · MVP wireframes</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 26 }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>Chosen direction</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 34, lineHeight: 1.05, color: IT.star, margin: '0 0 10px' }}>
|
||||
Quiet centered
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 15, lineHeight: 1.6, color: IT.soft, maxWidth: 600, margin: 0 }}>
|
||||
A single calm column on the sky, with the least chrome the job allows. The honest voice carries the rest —
|
||||
and every screen here uses the same tokens, the same copy register, the same primary action.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 26, display: 'flex', flexDirection: 'column', gap: 0, border: `1px solid ${IT.hairCard}`, borderRadius: 14, overflow: 'hidden', background: IT.raised }}>
|
||||
{patterns.map(([k, v], i) => (
|
||||
<div key={k} style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 18, padding: '15px 20px', borderTop: i ? `1px solid ${IT.hair}` : 'none' }}>
|
||||
<span style={{ fontFamily: IT.disp, fontWeight: 500, fontSize: 14, color: IT.lilac }}>{k}</span>
|
||||
<span style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.5, color: IT.soft }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 22, padding: '15px 20px', borderRadius: 12, background: 'rgba(244,199,107,.08)', border: `1px solid var(--wv-gold-40)`, display: 'flex', gap: 13, alignItems: 'flex-start' }}>
|
||||
<span aria-hidden="true" style={{ color: IT.gold }}>◆</span>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.55, color: IT.soft, margin: 0 }}>
|
||||
<strong style={{ color: IT.star }}>Deferred, not forgotten.</strong> When the product grows past forms, a persistent
|
||||
nav frame gets introduced as its own deliberate step — designed then, against real sections, not guessed at now.
|
||||
</p>
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SystemA, TwoDirections, EntryRouting });
|
||||
@@ -0,0 +1,303 @@
|
||||
// wf-kit.jsx — Wiggleverse ecomm wireframe primitives.
|
||||
// Mid-fi: real Wiggleverse tokens + composed DS components (Button, Soul, Eyebrow, Tag, Notice).
|
||||
// Everything exported to window at the bottom so sibling babel scripts can use it.
|
||||
|
||||
const DS = window.WiggleverseDesignSystem_94cd80;
|
||||
const { Button, Soul, Eyebrow, Tag, Notice } = DS;
|
||||
|
||||
// ---- token shorthands (resolved from the linked token stylesheets) -------
|
||||
const T = {
|
||||
sky: 'var(--wv-midnight)',
|
||||
raised: 'var(--wv-indigo)',
|
||||
raisedHi: 'var(--wv-indigo-2)',
|
||||
night: 'var(--wv-night)',
|
||||
star: 'var(--wv-starlight)',
|
||||
soft: 'var(--wv-starlight-78)',
|
||||
mute: 'var(--wv-starlight-60)',
|
||||
lilac: 'var(--wv-lilac)',
|
||||
gold: 'var(--wv-gold)',
|
||||
goldInk: 'var(--wv-gold-ink)',
|
||||
hair: 'var(--border-soft)',
|
||||
hairCard: 'var(--border-card)',
|
||||
disp: 'var(--wv-font-display)',
|
||||
body: 'var(--wv-font-body)',
|
||||
};
|
||||
|
||||
// faint scattered starfield — the brand's one decorative texture (off-center, low alpha)
|
||||
const STARFIELD =
|
||||
'radial-gradient(1.4px 1.4px at 18% 24%, rgba(237,234,255,.5), transparent),' +
|
||||
'radial-gradient(1.4px 1.4px at 73% 16%, rgba(237,234,255,.35), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 88% 52%, rgba(155,140,255,.45), transparent),' +
|
||||
'radial-gradient(1.6px 1.6px at 32% 70%, rgba(237,234,255,.4), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 58% 84%, rgba(237,234,255,.3), transparent),' +
|
||||
'radial-gradient(1.3px 1.3px at 8% 58%, rgba(155,140,255,.4), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 46% 40%, rgba(237,234,255,.28), transparent)';
|
||||
|
||||
// ── Screen — fills the artboard, the midnight ground, column flow ──────────
|
||||
function Screen({ children, starfield = false, bg = T.sky, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box',
|
||||
background: starfield ? `${STARFIELD}, ${bg}` : bg,
|
||||
color: T.star, fontFamily: T.body,
|
||||
display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden',
|
||||
...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wordmark — the mark tile + "ecomm" (Space Grotesk) ─────────────────────
|
||||
function Wordmark({ size = 26, muted = false }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size * 0.42 }}>
|
||||
<img src="assets/mark-tile.svg" width={size} height={size}
|
||||
style={{ borderRadius: size * 0.22, display: 'block', opacity: muted ? 0.85 : 1 }} alt="" />
|
||||
<span style={{
|
||||
fontFamily: T.disp, fontWeight: 700, fontSize: size * 0.86,
|
||||
letterSpacing: '-0.015em', color: T.star,
|
||||
}}>ecomm</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TopBar — the chrome shared by every authenticated surface ──────────────
|
||||
function TopBar({ left, right, pad = '0 32px', height = 64 }) {
|
||||
return (
|
||||
<header style={{
|
||||
flex: '0 0 auto', height, padding: pad,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${T.hair}`,
|
||||
background: 'rgba(14,18,48,.6)', backdropFilter: 'blur(10px)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>{left}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// account chip used at top-right of authenticated surfaces
|
||||
function AccountChip({ email = 'mara@studiofern.com', menu = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, position: 'relative' }}>
|
||||
<span style={{ fontFamily: T.body, fontSize: 14, color: T.soft }}>{email}</span>
|
||||
<span style={{ width: 1, height: 18, background: T.hair }} />
|
||||
<button style={{
|
||||
fontFamily: T.disp, fontWeight: 500, fontSize: 14, color: T.star,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
|
||||
}}>Sign out</button>
|
||||
{menu && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 30, right: 0, minWidth: 200, zIndex: 9,
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 12,
|
||||
padding: 6, boxShadow: '0 8px 24px rgba(9,12,34,.4)',
|
||||
}}>
|
||||
<MenuRow>{email}</MenuRow>
|
||||
<div style={{ borderTop: `1px solid ${T.hair}`, margin: '6px 4px' }} />
|
||||
<MenuRow strong>Sign out</MenuRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MenuRow({ children, strong }) {
|
||||
return <div style={{
|
||||
padding: '8px 12px', fontFamily: T.body, fontSize: 13.5,
|
||||
color: strong ? T.star : T.mute, fontWeight: strong ? 600 : 400, borderRadius: 7,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
// ── Field — label + input + helper/error. Real token styling. ──────────────
|
||||
function Field({ label, placeholder, value, helper, error, focus = false, optional = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<label style={{
|
||||
fontFamily: T.body, fontWeight: 500, fontSize: 13.5, color: T.soft,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ fontSize: 12.5, color: T.mute, fontWeight: 400 }}>optional</span>}
|
||||
</label>
|
||||
<div style={{
|
||||
height: 50, borderRadius: 10, padding: '0 15px', display: 'flex', alignItems: 'center',
|
||||
background: 'rgba(237,234,255,.04)',
|
||||
border: `1.5px solid ${error ? T.gold : focus ? T.gold : T.hair}`,
|
||||
boxShadow: focus ? '0 0 0 3px var(--wv-gold-28)' : 'none',
|
||||
fontFamily: T.body, fontSize: 15.5,
|
||||
color: value ? T.star : T.mute,
|
||||
}}>
|
||||
{value || placeholder}
|
||||
{focus && !value && <span style={{
|
||||
width: 1.5, height: 20, marginLeft: 1, background: T.gold,
|
||||
}} />}
|
||||
</div>
|
||||
{error && <FieldNote tone="attn">{error}</FieldNote>}
|
||||
{helper && !error && <FieldNote>{helper}</FieldNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function FieldNote({ children, tone }) {
|
||||
return <span style={{
|
||||
fontFamily: T.body, fontSize: 13, lineHeight: 1.4,
|
||||
color: tone === 'attn' ? T.gold : T.mute,
|
||||
display: 'flex', gap: 6, alignItems: 'baseline',
|
||||
}}>
|
||||
{tone === 'attn' && <span aria-hidden="true" style={{ color: T.gold }}>◆</span>}
|
||||
<span>{children}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
// ── CodeField — one-time-code entry, 6 cells ───────────────────────────────
|
||||
function CodeField({ filled = 0, error = false }) {
|
||||
const cells = [0, 1, 2, 3, 4, 5];
|
||||
const vals = ['4', '1', '9', '', '', ''];
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{cells.map((i) => {
|
||||
const active = i === filled;
|
||||
const has = i < filled;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
width: 50, height: 60, borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(237,234,255,.04)',
|
||||
border: `1.5px solid ${error ? T.gold : active ? T.gold : T.hair}`,
|
||||
boxShadow: active ? '0 0 0 3px var(--wv-gold-28)' : 'none',
|
||||
fontFamily: T.disp, fontWeight: 500, fontSize: 24, color: T.star,
|
||||
}}>
|
||||
{has ? vals[i] : (active ? <span style={{ width: 1.5, height: 26, background: T.gold }} /> : '')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner — inline status. attn = gold (no red in palette); info = lilac ──
|
||||
function Banner({ children, tone = 'attn', title }) {
|
||||
const attn = tone === 'attn';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, padding: '13px 16px', borderRadius: 12,
|
||||
background: attn ? 'rgba(244,199,107,.10)' : 'var(--wv-lilac-08)',
|
||||
border: `1px solid ${attn ? 'var(--wv-gold-40)' : 'var(--wv-lilac-18)'}`,
|
||||
fontFamily: T.body, alignItems: 'flex-start',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ color: attn ? T.gold : T.lilac, fontSize: 14, lineHeight: '21px' }}>
|
||||
{attn ? '◆' : '◇'}
|
||||
</span>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, color: T.soft }}>
|
||||
{title && <div style={{ color: T.star, fontWeight: 600, marginBottom: 2 }}>{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AuthCard — the centered raised surface for Direction A ─────────────────
|
||||
function AuthCard({ children, width = 420 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, maxWidth: '100%', boxSizing: 'border-box',
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 14,
|
||||
padding: '38px 38px 34px',
|
||||
display: 'flex', flexDirection: 'column', gap: 22,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BrandRail — the persistent identity panel for Direction B ──────────────
|
||||
function BrandRail({ compact = false, soul = 'Your storefront is yours.' }) {
|
||||
return (
|
||||
<aside style={{
|
||||
flex: compact ? '0 0 auto' : '0 0 42%',
|
||||
background: `${STARFIELD}, ${T.night}`,
|
||||
borderRight: compact ? 'none' : `1px solid ${T.hair}`,
|
||||
borderBottom: compact ? `1px solid ${T.hair}` : 'none',
|
||||
padding: compact ? '26px 28px' : '44px 46px',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: compact ? 'flex-start' : 'space-between',
|
||||
position: 'relative', overflow: 'hidden', gap: compact ? 18 : 0,
|
||||
}}>
|
||||
<Wordmark size={compact ? 24 : 28} />
|
||||
{!compact && (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Soul size="lg" tone="gold" as="p">Treat humans as humans —<br />everything else follows.</Soul>
|
||||
<p style={{ fontFamily: T.body, fontSize: 14.5, lineHeight: 1.6, color: T.mute, marginTop: 20 }}>
|
||||
Honest commerce, built on one shared set of ethics. No trial clock, no plan wall — just your storefront.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{compact && <Soul size="sm" tone="gold" as="p">{soul}</Soul>}
|
||||
{!compact && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: T.body, fontSize: 12, color: T.mute }}>
|
||||
<span style={{ width: 22, height: 1, background: T.hair }} />
|
||||
<span>A Wiggleverse line · Open Core</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer — minimal project identity + the standing dedication ────────────
|
||||
function Footer({ onDark = true }) {
|
||||
return (
|
||||
<footer style={{
|
||||
flex: '0 0 auto', padding: '20px 32px', borderTop: `1px solid ${T.hair}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16,
|
||||
fontFamily: T.body, fontSize: 12.5, color: T.mute,
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9 }}>
|
||||
<img src="assets/mark-mono-gold.svg" width={16} height={16} alt="" style={{ opacity: 0.8 }} />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
<span>Privacy · Open Core</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section wrapper used in the canvas to title state stacks ───────────────
|
||||
function StateBlock({ label, note, children, h }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<Tag variant="soon">{label}</Tag>
|
||||
{note && <span style={{ fontFamily: T.body, fontSize: 12.5, color: T.mute }}>{note}</span>}
|
||||
</div>
|
||||
<div style={{
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 12,
|
||||
padding: 22, height: h, boxSizing: 'border-box',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Honest-empty admin body — the heart of PUC-8 ───────────────────────────
|
||||
function EmptyAdmin({ storefront = 'Studio Fern', compact = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start', justifyContent: 'center',
|
||||
padding: compact ? '32px 26px' : '0 64px', maxWidth: 720, margin: compact ? 0 : '0 auto',
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
}}>
|
||||
<Eyebrow style={{ margin: '0 0 14px' }}>Your storefront</Eyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: T.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.05,
|
||||
fontSize: compact ? 30 : 'var(--text-h1)', color: T.star, margin: '0 0 16px',
|
||||
}}>{storefront}</h1>
|
||||
<p style={{
|
||||
fontFamily: T.body, fontSize: compact ? 15 : 17, lineHeight: 1.6, color: T.soft,
|
||||
maxWidth: 480, margin: 0,
|
||||
}}>
|
||||
There's nothing to manage yet. Catalog, orders, and settings will appear
|
||||
here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
WF_T: T, WF_STARFIELD: STARFIELD,
|
||||
Screen, Wordmark, TopBar, AccountChip, MenuRow, Field, FieldNote, CodeField,
|
||||
Banner, AuthCard, BrandRail, Footer, StateBlock, EmptyAdmin,
|
||||
WF_DS: DS,
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// wf-landing.jsx — Screen 1: Landing (PUC-1). Two directions.
|
||||
const { Screen: LScreen, Wordmark: LWordmark, TopBar: LTopBar, Footer: LFooter, WF_T: LT, WF_DS: LDS } = window;
|
||||
const { Button: LButton, Soul: LSoul, Eyebrow: LEyebrow } = LDS;
|
||||
|
||||
function LogInLink({ size = 14 }) {
|
||||
return <a href="#" style={{
|
||||
fontFamily: LT.disp, fontWeight: 500, fontSize: size, color: LT.star,
|
||||
textDecoration: 'none', textUnderlineOffset: 3,
|
||||
}}>Log in</a>;
|
||||
}
|
||||
|
||||
function LandingValue({ device }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<>
|
||||
<LEyebrow style={{ margin: '0 0 18px' }}>One storefront, fully yours</LEyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: LT.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.04,
|
||||
fontSize: desktop ? 'var(--text-h1)' : 30, color: LT.star, margin: '0 0 20px',
|
||||
}}>Sell online,<br />honestly.</h1>
|
||||
<p style={{
|
||||
fontFamily: LT.body, fontSize: desktop ? 18 : 15.5, lineHeight: 1.6, color: LT.soft,
|
||||
maxWidth: 460, margin: '0 0 30px',
|
||||
}}>
|
||||
Claim your storefront on a platform that takes only what it takes to run —
|
||||
no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — Quiet centered ───────────────────────────────────────────
|
||||
function LandingA({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<LScreen starfield>
|
||||
<LTopBar pad={desktop ? '0 40px' : '0 22px'}
|
||||
left={<LWordmark size={desktop ? 26 : 22} />} right={<LogInLink />} />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: desktop ? 'center' : 'flex-start', justifyContent: 'center',
|
||||
textAlign: desktop ? 'center' : 'left',
|
||||
padding: desktop ? '0 40px' : '0 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', alignItems: desktop ? 'center' : 'flex-start' }}>
|
||||
<div style={{ display: 'contents' }}><LandingValue device={device} /></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: desktop ? 'center' : 'stretch', gap: 16, width: desktop ? 'auto' : '100%' }}>
|
||||
<LButton variant="primary" href="#">Create your storefront →</LButton>
|
||||
<a href="#" style={{ fontFamily: LT.body, fontSize: 14, color: LT.mute, textDecoration: 'none', textAlign: desktop ? 'center' : 'left' }}>
|
||||
Already selling with us? <span style={{ color: LT.lilac }}>Log in</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LFooter />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — Durable frame (split) ────────────────────────────────────
|
||||
function LandingB({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<LScreen>
|
||||
<BrandRailLite />
|
||||
<div style={{ flex: 1, padding: '28px 24px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<LandingValue device={device} />
|
||||
<Doors />
|
||||
</div>
|
||||
<LFooter />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{
|
||||
flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`,
|
||||
borderRight: `1px solid ${LT.hair}`, padding: '44px 46px',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden',
|
||||
}}>
|
||||
<LWordmark size={28} />
|
||||
<div style={{ maxWidth: 380 }}>
|
||||
<LSoul size="lg" tone="gold" as="p">Treat humans as humans —<br />everything else follows.</LSoul>
|
||||
<p style={{ fontFamily: LT.body, fontSize: 15, lineHeight: 1.6, color: LT.mute, marginTop: 22 }}>
|
||||
Honest commerce on one shared set of ethics. Your storefront is yours — to keep, to leave, to own.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: LT.body, fontSize: 12, color: LT.mute }}>
|
||||
<span style={{ width: 22, height: 1, background: LT.hair }} />
|
||||
<span>A Wiggleverse line · Open Core</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 70px' }}>
|
||||
<LandingValue device={device} />
|
||||
<Doors />
|
||||
</div>
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandRailLite() {
|
||||
return (
|
||||
<div style={{
|
||||
background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${LT.hair}`,
|
||||
padding: '24px 24px 26px', display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<LWordmark size={22} />
|
||||
<LSoul size="sm" tone="gold">Your storefront is yours.</LSoul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Doors() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 440 }}>
|
||||
<DoorRow primary title="Create your storefront"
|
||||
desc="New here? Set up the one storefront that's yours." cta="Get started →" />
|
||||
<DoorRow title="Log in"
|
||||
desc="Already selling with us? Pick up where you left off." cta="Log in →" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function DoorRow({ primary, title, desc, cta }) {
|
||||
return (
|
||||
<a href="#" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16,
|
||||
padding: '18px 20px', borderRadius: 14, textDecoration: 'none',
|
||||
background: primary ? 'rgba(244,199,107,.08)' : 'var(--wv-indigo)',
|
||||
border: `1px solid ${primary ? 'var(--wv-gold-40)' : LT.hairCard}`,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: LT.disp, fontWeight: 500, fontSize: 17, color: LT.star, marginBottom: 3 }}>{title}</div>
|
||||
<div style={{ fontFamily: LT.body, fontSize: 13.5, color: LT.mute, lineHeight: 1.45 }}>{desc}</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: LT.disp, fontWeight: 500, fontSize: 14, color: primary ? LT.gold : LT.lilac, whiteSpace: 'nowrap' }}>{cta}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { LandingA, LandingB });
|
||||
@@ -0,0 +1,161 @@
|
||||
// wf-signin.jsx — Screen 2: Sign in (PUC-2, PUC-3). Email → one-time code. A/B + states.
|
||||
const { Screen: SScreen, Wordmark: SWordmark, TopBar: STopBar, Field: SField, CodeField: SCodeField,
|
||||
Banner: SBanner, AuthCard: SAuthCard, BrandRail: SBrandRail, WF_T: ST, WF_DS: SDS } = window;
|
||||
const { Button: SButton, Soul: SSoul } = SDS;
|
||||
|
||||
function BackLink({ label = 'Back' }) {
|
||||
return <a href="#" style={{ fontFamily: ST.body, fontSize: 14, color: ST.mute, textDecoration: 'none' }}>← {label}</a>;
|
||||
}
|
||||
function Heading({ children, sub }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: ST.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: ST.star, margin: '0 0 8px', lineHeight: 1.1 }}>{children}</h1>
|
||||
{sub && <p style={{ fontFamily: ST.body, fontSize: 14.5, lineHeight: 1.5, color: ST.soft, margin: 0 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const honest = { fontFamily: ST.body, fontSize: 13, color: ST.mute, lineHeight: 1.5, margin: 0 };
|
||||
|
||||
// ── Step renderers (shared by A card and B column) ─────────────────────────
|
||||
function EmailStep({ framing = 'signup', s = {} }) {
|
||||
const title = framing === 'login' ? 'Log in' : 'Create your storefront';
|
||||
const sub = framing === 'login' ? 'Enter your email and we\u2019ll send you a code to sign in.' : 'Enter your email to begin. No password to create.';
|
||||
return (
|
||||
<>
|
||||
<Heading sub={sub}>{title}</Heading>
|
||||
{s.deliveryFail && <SBanner title="We couldn't send the code">The email didn't go out. Try again in a moment — nothing was lost.</SBanner>}
|
||||
<SField label="Email" placeholder="you@example.com"
|
||||
value={s.email} focus={!s.emailError && !s.value} error={s.emailError} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SButton variant="primary" href="#" disabled={!!s.cooldown}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}
|
||||
</SButton>
|
||||
<p style={honest}>We'll email you a one-time code. That's all we need.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeStep({ s = {} }) {
|
||||
const email = s.email || 'mara@studiofern.com';
|
||||
let err = null;
|
||||
if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
|
||||
if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
|
||||
return (
|
||||
<>
|
||||
<Heading sub={<>We sent a code to <span style={{ color: ST.star }}>{email}</span>. <a href="#" style={{ color: ST.lilac, textDecoration: 'none' }}>Wrong address?</a></>}>
|
||||
Check your email
|
||||
</Heading>
|
||||
{s.welcome && <SBanner tone="info" title={s.welcome === 'new' ? 'Welcome to ecomm' : 'Welcome back'}>
|
||||
{s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
|
||||
</SBanner>}
|
||||
{s.codeExhausted && <SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
<SCodeField filled={s.filled ?? 3} error={!!(s.codeWrong || s.codeExpired || s.codeExhausted)} />
|
||||
{err && <span style={{ fontFamily: ST.body, fontSize: 13, color: ST.gold, display: 'flex', gap: 6 }}><span aria-hidden="true">◆</span>{err}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<SButton variant="primary" href="#">Continue</SButton>
|
||||
<a href="#" style={{ fontFamily: ST.disp, fontWeight: 500, fontSize: 14, color: s.cooldown ? ST.mute : ST.lilac, textDecoration: 'none' }}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Resend code'}
|
||||
</a>
|
||||
</div>
|
||||
<p style={honest}>The code is good for 10 minutes. If you didn't request it, ignore this.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — centered card ────────────────────────────────────────────
|
||||
function SignInA({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<SScreen starfield>
|
||||
<STopBar pad={desktop ? '0 40px' : '0 22px'} left={<SWordmark size={desktop ? 24 : 22} />} right={<BackLink />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<SAuthCard width={desktop ? 430 : '100%'}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</SAuthCard>
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — split (brand rail + form column) ─────────────────────────
|
||||
function SignInB({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<SScreen>
|
||||
<div style={{ background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${ST.hair}`, padding: '22px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SWordmark size={22} />
|
||||
<SSoul size="sm" tone="gold">Your storefront is yours.</SSoul>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '26px 24px', display: 'flex', flexDirection: 'column', gap: 22, justifyContent: 'center' }}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{ flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`, borderRight: `1px solid ${ST.hair}`, padding: '40px 46px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
||||
<SWordmark size={26} />
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<SSoul size="lg" tone="gold" as="p">We are verbs,<br />not nouns.</SSoul>
|
||||
<p style={{ fontFamily: ST.body, fontSize: 14.5, lineHeight: 1.6, color: ST.mute, marginTop: 20 }}>
|
||||
One door, two framings. Whether you're signing up or coming back, it's the same honest step — your email, one code.
|
||||
</p>
|
||||
</div>
|
||||
<BackLink label="Back to start" />
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 64px' }}>
|
||||
<div style={{ width: '100%', maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</div>
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
const { StateBlock: SStateBlock } = window;
|
||||
function codeNote(text) {
|
||||
return <span style={{ fontFamily: ST.body, fontSize: 13, color: ST.gold, display: 'flex', gap: 6, marginTop: 9 }}><span aria-hidden="true">◆</span>{text}</span>;
|
||||
}
|
||||
function SignInStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<SStateBlock label="Invalid email" note="inline field error">
|
||||
<SField label="Email" value="mara@studiofern" error="That doesn't look like an email address." />
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Resend cooldown" note="PUC-2c · 60s per email">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<SButton variant="primary" href="#" disabled>Resend in 47s</SButton>
|
||||
<span style={honest}>One code per minute keeps inboxes (and abuse) in check.</span>
|
||||
</div>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Delivery failed" note="502 · honest failure (INV-9)">
|
||||
<SBanner title="We couldn't send the code">The email didn't go out — try again. We'd never fake a success.</SBanner>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Wrong code" note="PUC-2a">
|
||||
<SCodeField filled={6} error />
|
||||
{codeNote("That code didn't match — 2 attempts left.")}
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Expired code" note="PUC-2b">
|
||||
<SCodeField filled={6} error />
|
||||
{codeNote('That code expired. Send a fresh one below.')}
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Attempts exhausted" note="5 tries · code invalidated (INV-3)">
|
||||
<SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Honest welcome" note="PUC-3 · same flow, true label">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SBanner tone="info" title="Welcome to ecomm">A new account was created for this email.</SBanner>
|
||||
<SBanner tone="info" title="Welcome back">Signed in to your existing account.</SBanner>
|
||||
</div>
|
||||
</SStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SignInA, SignInB, EmailStep, CodeStep, SignInStates });
|
||||
@@ -0,0 +1,117 @@
|
||||
// wf-storefront.jsx — Screen 3: Create storefront (PUC-4/5/7). A/B + states.
|
||||
const { Screen: FScreen, Wordmark: FWordmark, TopBar: FTopBar, AccountChip: FAccountChip,
|
||||
Field: FField, Banner: FBanner, AuthCard: FAuthCard, StateBlock: FStateBlock,
|
||||
WF_T: FT, WF_DS: FDS } = window;
|
||||
const { Button: FButton, Soul: FSoul, Eyebrow: FEyebrow } = FDS;
|
||||
|
||||
function CreateForm({ s = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<FEyebrow style={{ margin: '0 0 12px' }}>One storefront, fully yours</FEyebrow>
|
||||
<h1 style={{ fontFamily: FT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: FT.star, margin: '0 0 8px', lineHeight: 1.1 }}>Create your storefront</h1>
|
||||
<p style={{ fontFamily: FT.body, fontSize: 14.5, lineHeight: 1.5, color: FT.soft, margin: 0 }}>
|
||||
This is the one thing to do right now. Nothing here costs anything or commits you to anything.
|
||||
</p>
|
||||
</div>
|
||||
{s.alreadyOwns && <FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a href="#" style={{ color: FT.lilac, textDecoration: 'none' }}>Go to your admin →</a>
|
||||
</FBanner>}
|
||||
<FField label="Storefront name" optional
|
||||
placeholder="e.g. Studio Fern" value={s.name}
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change later." />
|
||||
{s.blankPreview && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginTop: -6, fontFamily: FT.body, fontSize: 13, color: FT.mute }}>
|
||||
<span aria-hidden="true" style={{ color: FT.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: FT.soft, fontStyle: 'italic', fontFamily: FT.body }}>“mara's storefront”</span> — change it any time.
|
||||
</div>
|
||||
)}
|
||||
<FButton variant="primary" href="#" disabled={s.creating}>
|
||||
{s.creating ? 'Creating…' : 'Create storefront'}
|
||||
</FButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — centered card ────────────────────────────────────────────
|
||||
function CreateA({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<FScreen starfield>
|
||||
<FTopBar pad={desktop ? '0 32px' : '0 18px'} left={<FWordmark size={desktop ? 24 : 21} />}
|
||||
right={desktop ? <FAccountChip /> : <SignOutMini />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<FAuthCard width={desktop ? 440 : '100%'}><CreateForm s={s} /></FAuthCard>
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — split (the frame begins to form) ─────────────────────────
|
||||
function CreateB({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<FScreen>
|
||||
<div style={{ background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${FT.hair}`, padding: '20px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<FWordmark size={21} />
|
||||
<SignOutMini />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '24px 22px', display: 'flex', flexDirection: 'column', gap: 20, justifyContent: 'center' }}>
|
||||
<CreateForm s={s} />
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{ flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`, borderRight: `1px solid ${FT.hair}`, padding: '40px 46px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
||||
<FWordmark size={26} />
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<FSoul size="lg" tone="gold" as="p">Build the<br />dictionary first.</FSoul>
|
||||
<p style={{ fontFamily: FT.body, fontSize: 14.5, lineHeight: 1.6, color: FT.mute, marginTop: 20 }}>
|
||||
One storefront, claimed by you. The frame you see now is the one your catalog, orders and settings will live in.
|
||||
</p>
|
||||
</div>
|
||||
<FAccountChip />
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 64px' }}>
|
||||
<div style={{ width: '100%', maxWidth: 420, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<CreateForm s={s} />
|
||||
</div>
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutMini() {
|
||||
return <button style={{ fontFamily: FT.disp, fontWeight: 500, fontSize: 13, color: FT.star, background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}>Sign out</button>;
|
||||
}
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
function CreateStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<FStateBlock label="Blank name" note="allowed → default generated">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FField label="Storefront name" optional placeholder="e.g. Studio Fern"
|
||||
helper="Leave blank and we'll name it for you." />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontFamily: FT.body, fontSize: 13, color: FT.mute }}>
|
||||
<span aria-hidden="true" style={{ color: FT.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: FT.soft, fontStyle: 'italic' }}>“mara's storefront”</span>
|
||||
</div>
|
||||
</div>
|
||||
</FStateBlock>
|
||||
<FStateBlock label="Creating" note="button busy">
|
||||
<FButton variant="primary" href="#" disabled>Creating…</FButton>
|
||||
</FStateBlock>
|
||||
<FStateBlock label="Already owns one" note="409 · defense in depth (PUC-7)">
|
||||
<FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a href="#" style={{ color: FT.lilac, textDecoration: 'none' }}>Go to your admin →</a>
|
||||
</FBanner>
|
||||
</FStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { CreateA, CreateB, CreateStates });
|
||||
Reference in New Issue
Block a user