Skip to content

Multi-axis Token Bundle

What merchants see as a “color scheme” is internally a Bundle — a multi-axis token package composed of 5 independent axes:

axes = { color, icon, typography, shape, motion }

Each axis is independently optional and independently consumed — when a merchant picks a bundle, it might affect only color, or color + icon together.

v1 ships with only color + icon axes implemented. typography / shape / motion are spec-reserved slots with no runtime projection.

This boundary matters: the more complete the spec looks, the easier it is to assume all 5 axes are live. The architecture is open-ended — adding a new axis is purely additive (add apply-axis/<name>.js projection + bundle field, zero existing code changes), but adding any axis requires ≥3 real merchant use cases.

Axisv1 statusTrigger to ship
color✅ Implemented
icon✅ Implemented
typography🔴 ReservedMerchant brand-font import demand
shape🔴 ReservedTheme shape derivation (radius / shadow) maturity
motion🔴 ReservedBranded animation requests
Layer 1: Bundle (editing-time)
Sources: 5 internal preset JSONs / theme-derived
Storage: packages/widget-presets/src/styles/*.json
+ widgets.editor_state_json.style.bundle (per-widget snapshot)
Evolution: free to evolve (merchant can't see, breaking changes only affect internal code)
Layer 2: Widget Config (persistence)
Form: resolved flat values (hex strings, Fill objects, iconStyle enum)
Storage: D1 widgets.config_json + Shopify metafield + __TTB_BLOCKS__
Evolution: strict additive-only (see 7 contracts below)
Layer 3: Renderer (runtime)
Code: packages/widget-ui storefront engine
Rules: reads only Layer 2; unknown-safe (every switch needs a default)

Bundle ↔ Widget Config is a one-way projection — never reverse-derive a Bundle from Widget Config. Layer 1’s freedom of evolution depends on Layer 2’s stability; the projection pipeline is the firewall.

Layer 2 + Layer 3 hard rules — every new feature PR review checks against these:

#ContractMeaning
1schemaVersion strictly additiveNew version: new fields must be optional with sensible defaults; never remove fields, change types, or promote optional → required
2Polymorphic values use kindFill (solid/gradient/tricolor/pattern), icon source (phosphor/brand/custom), animation kind, font kind — new kind values are additive
3New fields optional + default fallbackAll new fields must have sensible defaults; renderer never reads undefined and crashes
4Renderer unknown-safeUnknown component type → console.warn + skip; unknown kind → switch default → fallback to v1 known; unknown field → ignore
5extensions: {} experimental bagEvery component reserves extensions: {} field; v1 renderer ignores, v2/v3 use to test new features
6Bundle → Config one-wayNever reverse-derive bundle from persisted config
7Schema evolution auditwidget-schema-evolution-audit.mjs snapshots v1 field tree; any PR change must be additive, else CI fails
SourceCountBehavior
Internal presets5 JSON files (packages/widget-presets/src/styles/)Merchant picks from a dropdown in wizard / Design tab
Theme-derived2-4 per shopSee Theme-derived Bundle

Switching “color scheme” in the Design tab is actually switching bundles:

  • Pick bundle → applyStyle(widget, bundle) projection
  • Projection writes to widget config’s flat fields (fill / iconStyle / etc.)
  • Merchant-overridden fields are snapshot-locked and not overwritten
  • Bundle is the Layer 1 editing-time form of the Data shape; projected into Layer 2
  • Theme-derived bundles are described in Theme-derived Bundle
  • Bundle projection timing and editor interaction in Design tab