@usequeek/theme-kit 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +187 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-switcher.css +131 -0
  8. package/components/language-switcher.tsx +144 -0
  9. package/components/markdown.tsx +1 -1
  10. package/components/powered-by-queek.tsx +4 -4
  11. package/components/product-media-frame.tsx +1 -1
  12. package/components/product-metafields.tsx +2 -3
  13. package/components/variant-picker.tsx +6 -9
  14. package/demo.ts +10 -14
  15. package/design-token-preview.tsx +0 -2
  16. package/edit-mode-active.tsx +3 -4
  17. package/hooks/use-announcement.ts +1 -1
  18. package/hooks/use-auth-flow.tsx +1 -1
  19. package/hooks/use-locales.ts +107 -0
  20. package/hooks/use-posts.ts +1 -1
  21. package/hooks/use-product-detail.ts +1 -2
  22. package/hooks/use-product-sections.ts +4 -6
  23. package/hooks/use-products.ts +5 -8
  24. package/index.ts +13 -16
  25. package/locales/en.default.json +4 -0
  26. package/next-reference.d.ts +2 -4
  27. package/package.json +3 -2
  28. package/page-renderer.tsx +5 -6
  29. package/provider.tsx +22 -2
  30. package/scripts/verify-package.ts +6 -8
  31. package/sdk/client.ts +13 -12
  32. package/sdk/error-handler.ts +1 -1
  33. package/sdk/platform.ts +6 -8
  34. package/sdk/queek-client.ts +11 -12
  35. package/sdk/server-store-client.ts +13 -17
  36. package/shared-blocks/metaobjects.tsx +1 -1
  37. package/stores/cart-store.ts +2 -2
  38. package/stores/checkout-store.ts +1 -1
  39. package/stores/edit-preview-store.ts +5 -6
  40. package/stores/user-store.ts +4 -5
  41. package/strings/pseudo-locale.ts +3 -4
  42. package/strings/theme-strings.ts +9 -7
  43. package/tests/auth-email-errors.test.tsx +2 -2
  44. package/tests/currency-contract.test.ts +2 -3
  45. package/tests/display-format.test.ts +17 -25
  46. package/tests/fixtures/external-theme.tsx +18 -2
  47. package/tests/language-switcher.test.tsx +350 -0
  48. package/tests/list-pagination.test.tsx +8 -8
  49. package/tests/locale-switch.test.ts +197 -0
  50. package/tests/locale-transport.test.tsx +3 -3
  51. package/tests/markdown-render.test.ts +5 -6
  52. package/tests/orders-walk.test.ts +5 -5
  53. package/tests/page-renderer-signature.test.tsx +1 -1
  54. package/tests/powered-by-queek.test.tsx +3 -3
  55. package/tests/powered-by-url.test.ts +2 -2
  56. package/tests/public-surface.test.ts +1 -4
  57. package/tests/shopper-chrome-strings.test.tsx +5 -6
  58. package/tests/storefront-client-scope.test.ts +5 -5
  59. package/tests/theme-hook-placement.test.tsx +2 -2
  60. package/tests/theme-strings-client.test.tsx +5 -5
  61. package/tests/theme-strings-pseudo.test.tsx +1 -1
  62. package/tests/theme-strings-typed.test.tsx +1 -1
  63. package/tests/theme-strings.test.tsx +8 -11
  64. package/tests/use-shop-prefetch.test.tsx +1 -1
  65. package/theme-mount.tsx +6 -11
  66. package/types/block.ts +7 -7
  67. package/types/media.ts +8 -9
  68. package/types/product.ts +8 -12
  69. package/types/theme.ts +26 -25
  70. package/types/vendor.ts +10 -12
  71. package/utils/announcement.ts +3 -3
  72. package/utils/benefit-icon.tsx +9 -11
  73. package/utils/brand.ts +7 -10
  74. package/utils/format.ts +5 -7
  75. package/utils/image-srcset.ts +2 -2
  76. package/utils/locale-switch.ts +208 -0
  77. package/utils/locale.ts +10 -11
  78. package/utils/menu-link.ts +1 -1
  79. package/utils/product-media.ts +1 -1
  80. package/utils/product-normalizer.ts +2 -2
  81. package/utils/vendor-host.ts +7 -11
package/types/media.ts CHANGED
@@ -1,21 +1,20 @@
1
1
  /**
2
- * The srcset-ready tier map emitted by the backend's MediaVariantMap
3
- * (queek_backend: app/Services/Media/MediaVariantMap.php). Sits BESIDE the
4
- * existing collapsed url fields (media.image, vendor.logo, collection.image, …),
5
- * which are unchanged — this is purely additive.
2
+ * The srcset-ready tier map emitted by the API. Sits BESIDE the existing
3
+ * collapsed url fields (media.image, vendor.logo, collection.image, …), which
4
+ * are unchanged — this is purely additive.
6
5
  *
7
- * Contract (enforced backend-side, relied on here):
6
+ * Contract (enforced API-side, relied on here):
8
7
  * 1. All 5 keys are ALWAYS present when the map itself is non-null.
9
8
  * 2. A tier that doesn't exist is the WHOLE VALUE null — never a partial
10
9
  * object, never a fallback url. Skip it; never synthesize a filename.
11
10
  * 3. `original` is never null when the map is non-null. Modeled as
12
11
  * `ImageVariantTier | null` anyway rather than a type-level invariant:
13
- * making it non-nullable would buy zero runtime safety against a backend
12
+ * making it non-nullable would buy zero runtime safety against an API
14
13
  * regression while forcing every consumer through a `!` or a cast. The
15
14
  * invariant is documented, not encoded.
16
15
  * 4. `w`/`h` are `min(tier_cap, original.width)` and the proportional height —
17
16
  * so on a small original SEVERAL TIERS LEGITIMATELY SHARE A WIDTH. That is
18
- * correct backend behavior, and the reason resolveImageSources() dedupes
17
+ * correct API behavior, and the reason resolveImageSources() dedupes
19
18
  * srcset candidates by width (duplicate `w` descriptors make a srcset
20
19
  * non-deterministic).
21
20
  * 5. `thumb` ALWAYS has `w: null, h: null` — it's a legacy 150→240px tier with
@@ -25,7 +24,7 @@
25
24
  * url, SVG, some legacy uploads). Consumers fall back to the existing
26
25
  * collapsed field, exactly as today.
27
26
  *
28
- * Tier caps (queek_backend config/media.php `variant_widths`):
27
+ * Tier caps (px):
29
28
  * thumb ~240 (uncapped/legacy) · card 480 · card2x 800 · view 1600 · original raw
30
29
  */
31
30
  export interface ImageVariantTier {
@@ -44,7 +43,7 @@ export type ImageVariants = {
44
43
 
45
44
  /**
46
45
  * The flat url→variants sidecar on a Page/Post response
47
- * (StorePageResource::toArray → 'media_index'). Keyed by the EXACT raw url
46
+ * (the `media_index` field). Keyed by the EXACT raw url
48
47
  * string as it appears in that same response's `content[]` block JSON —
49
48
  * block schemas were not changed, so a consumer looks up
50
49
  * `mediaIndex[url] ?? null` per raw url it finds in block content.
package/types/product.ts CHANGED
@@ -29,8 +29,7 @@ export interface ProductShop {
29
29
  }
30
30
 
31
31
  /**
32
- * One entry of the ordered `images` list (backend
33
- * `CustomerProductMedia::orderedImages()`): primary first,
32
+ * One entry of the ordered `images` list: primary first,
34
33
  * `{id, url, alt, variants}`. The listing payload caps the list
35
34
  * (`media.listing.images_limit`); detail/PDP carries the full list.
36
35
  */
@@ -53,11 +52,9 @@ export interface ProductMedia {
53
52
  primary_variant_image?: string | null;
54
53
  video_url?: string | null;
55
54
  /** Falls back to `image` server-side when the vendor hasn't set an explicit
56
- * poster (ProductResource::resolveVideoPosterUrl) — always a usable value
57
- * whenever video_url is set. */
55
+ * poster — always a usable value whenever video_url is set. */
58
56
  video_poster_url?: string | null;
59
- /** Srcset tier map for `image` (ProductResource::buildBaseMediaPayload /
60
- * buildDetailMediaPayload — present on BOTH list and detail responses).
57
+ /** Srcset tier map for `image` (present on BOTH list and detail responses).
61
58
  * null when the primary image isn't Media-backed. */
62
59
  image_variants?: ImageVariants | null;
63
60
  /** Sibling to `primary_variant_image`, same contract. */
@@ -268,9 +265,8 @@ export interface LegacyProductLike {
268
265
  vendor_id?: string;
269
266
  thumbnail?: string | null;
270
267
  image?: string | null;
271
- // Sibling top-level fields on the real backend payload (ProductResource::
272
- // buildBasePayload) — NOT nested under `media` server-side, unlike
273
- // image/thumbnail which are sent in both places.
268
+ // Sibling top-level fields on the real API payload — NOT nested under
269
+ // `media` server-side, unlike image/thumbnail which are sent in both places.
274
270
  video_url?: string | null;
275
271
  video_poster_url?: string | null;
276
272
  stock?: number;
@@ -296,9 +292,9 @@ export interface LegacyProductLike {
296
292
  // also send it as a {optionName: value} map (normalizeVariantOptionValues handles both) —
297
293
  // intersecting the two types directly collapses to an unsatisfiable type, hence the Omit.
298
294
  option_values?: ProductVariantOptionValue[] | Record<string, string>;
299
- // The backend sends a variant's own price/stock as FLAT fields (see
300
- // ProductResource::resolveShopVariants), never nested under pricing/inventory —
301
- // those nested keys only exist if some other caller pre-shapes the payload.
295
+ // The API sends a variant's own price/stock as FLAT fields, never nested
296
+ // under pricing/inventory — those nested keys only exist if some other
297
+ // caller pre-shapes the payload.
302
298
  price?: number;
303
299
  discount_price?: number;
304
300
  compare_at_price?: number | null;
package/types/theme.ts CHANGED
@@ -254,9 +254,8 @@ export interface ThemeManifestVariant {
254
254
  * in vendor.ts) this variant's rendered markup actually consumes via CSS
255
255
  * vars, i.e. which controls a per-section `block.tokens` override on THIS
256
256
  * variant will visibly change. Authored next to the component/CSS that
257
- * consumes the vars so it can't drift — flows verbatim through
258
- * generate-theme-registry.ts → registry.json → Laravel's Theme.variants →
259
- * ThemeManifestService (mirrors `fields`).
257
+ * consumes the vars so it can't drift — flows verbatim from the manifest
258
+ * through the theme registry to the API (mirrors `fields`).
260
259
  *
261
260
  * Granular per sub-control, NOT per parent dimension — `'shape'` is not a
262
261
  * valid entry; declare `'shape.radius'` and/or `'shape.border_weight'`
@@ -269,19 +268,17 @@ export interface ThemeManifestVariant {
269
268
  *
270
269
  * No back-compat default: an omitted `editable` (or an omitted leaf) means
271
270
  * NOT stylable for that control — every variant in every theme has an
272
- * explicit, audited array as of the 2026-08-03 granular pass.
271
+ * explicit, audited array.
273
272
  */
274
273
  editable?: DesignTokenAxisPath[];
275
274
  /**
276
- * Business buckets this variant is a strong fit for, as
277
- * `config/category_packs.php` keys ONLY (queek_backend): food | supermarket |
278
- * product | pharmacy | laundry | delivery | service | gas-refill |
279
- * local_market. Omitted = universal. Authored next to the component (same
280
- * anti-drift placement as `fields`/`editable`) and flows verbatim through
281
- * generate-theme-registry.ts -> registry.json -> Laravel's Theme.variants ->
282
- * ThemeManifestService, where BuildHomepageTool's scored resolver boosts a
283
- * bucket match when auto-building a store. The backend matches buckets with
284
- * `_`/`-` treated as equivalent.
275
+ * Business buckets this variant is a strong fit for, as category-pack keys
276
+ * ONLY: food | supermarket | product | pharmacy | laundry | delivery |
277
+ * service | gas-refill | local_market. Omitted = universal. Authored next to
278
+ * the component (same anti-drift placement as `fields`/`editable`) and flows
279
+ * verbatim through the theme registry to the API, where the AI store builder
280
+ * boosts a bucket match when auto-building a store. The API matches buckets
281
+ * with `_`/`-` treated as equivalent.
285
282
  */
286
283
  best_for?: string[];
287
284
  /**
@@ -342,13 +339,11 @@ export interface ThemeManifest {
342
339
  features?: string[];
343
340
  images?: Record<string, ThemeImageSpec>;
344
341
  /**
345
- * Theme's default semantic design-token set (design-token-contract.md,
346
- * queek_backend `.agent/.tmp/`) — the theme's current look expressed as
347
- * tokens. Surfaced via `ThemeManifestService`/registry.json as the
348
- * baseline the backend resolver layers vendor `tokens` overrides onto
349
- * (theme defaults ← legacy fields ← vendor overrides). Optional during
350
- * the pilot rollout — themes without it fall back to the storefront
351
- * injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
342
+ * Theme's default semantic design-token set — the theme's current look
343
+ * expressed as tokens. Surfaced through the theme registry as the baseline
344
+ * the API resolver layers vendor `tokens` overrides onto (theme defaults ←
345
+ * legacy fields ← vendor overrides). Optional — themes without it fall back
346
+ * to the token injector's own hardcoded defaults (`utils/brand.ts`).
352
347
  */
353
348
  tokens?: DesignTokens;
354
349
  /**
@@ -358,19 +353,25 @@ export interface ThemeManifest {
358
353
  * Same shape as the kit core English default
359
354
  * (nested objects by dot-path, or flat dotted keys; CLDR plural maps
360
355
  * allowed); keys follow the shared grammar (dotted lowercase, <= 40
361
- * chars) because the backend overlay stores `<theme-slug>.<key>` in a
362
- * varchar(64).
356
+ * chars) because merchant overrides are stored as `<theme-slug>.<key>` in a
357
+ * 64-character field.
363
358
  *
364
359
  * The kit layers it between the host-loaded locale dictionaries and the
365
360
  * kit core English, so ANY host that mounts the theme (vendor layout,
366
361
  * theme preview, CLI dev preview) renders the theme's English with zero
367
362
  * host cooperation — a host that passes nothing still shows English,
368
363
  * never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
369
- * per locale and are never bundled into the theme. Optional during the
370
- * migration — a manifest without it behaves exactly as before (kit core
371
- * English, then `''` for unknown keys).
364
+ * per locale and are never bundled into the theme. Optional — a manifest
365
+ * without it falls back to the kit core English, then `''` for unknown keys.
372
366
  */
373
367
  strings?: ThemeStringsDictionary;
368
+ /**
369
+ * Set to `'theme'` when the theme places the kit's `LanguageSwitcher`
370
+ * itself. The storefront then does not render its own floating language
371
+ * control, so the shopper sees exactly one. Optional — a manifest without
372
+ * it keeps the storefront's control for multi-language stores.
373
+ */
374
+ languageSwitcher?: 'theme';
374
375
  }
375
376
 
376
377
  /**
package/types/vendor.ts CHANGED
@@ -7,13 +7,11 @@ export interface BrandFont {
7
7
  }
8
8
 
9
9
  /**
10
- * Semantic design-token shapes — the FROZEN contract
11
- * (`.agent/.tmp/design-token-contract.md` in queek_backend). Backend resolves
12
- * theme defaults ← legacy vendor fields ← vendor `tokens` overrides and
13
- * returns this object under `StorefrontConfig.tokens`. Every field is
14
- * optional: during the transition many vendors have no `tokens` at all, and
15
- * the storefront injector (`lib/core/utils/brand.ts`) fills every gap with a
16
- * sane default so it can never crash or omit a CSS var.
10
+ * Semantic design-token shapes. The API resolves theme defaults ← legacy
11
+ * vendor fields ← vendor `tokens` overrides and returns this object under
12
+ * `StorefrontConfig.tokens`. Every field is optional: many vendors have no
13
+ * `tokens` at all, and the token injector (`utils/brand.ts`) fills every gap
14
+ * with a sane default so it can never crash or omit a CSS var.
17
15
  */
18
16
  export type DesignColorScheme = 'light' | 'dark';
19
17
  export type DesignScaleRatio = 1.125 | 1.2 | 1.25 | 1.333;
@@ -94,9 +92,9 @@ export type DesignTokenAxis = keyof DesignTokens;
94
92
  * container's radius is a hardcoded literal, or a button's border reads
95
93
  * `--border-weight` while its radius doesn't. Declaring only the dimension name
96
94
  * (e.g. `'shape'`) would show a dead sub-dial next to a live one — this is the
97
- * exact 2026-08-03 gap found in Allure's products/carousel section (Image radius
98
- * and Corner radius dead, Image fit/filter and Border weight live, all under the
99
- * same 'shape'/'image' bundle). `type`/`elevation`/`motion` have no UI sub-controls
95
+ * exact gap of a products/carousel section whose Image radius and Corner radius
96
+ * were dead while Image fit/filter and Border weight were live, all under the
97
+ * same 'shape'/'image' bundle. `type`/`elevation`/`motion` have no UI sub-controls
100
98
  * today, so they stay bare (no `.field` suffix) — add one here the day a sub-dial
101
99
  * is added for them.
102
100
  */
@@ -185,7 +183,7 @@ export interface StorefrontConfig {
185
183
  heading?: string;
186
184
  body?: string;
187
185
  };
188
- /** Resolved semantic design tokens (see design-token-contract.md). Absent for stores not yet migrated. */
186
+ /** Resolved semantic design tokens. Absent for stores that have none. */
189
187
  tokens?: DesignTokens | null;
190
188
  menus?: {
191
189
  header?: Menu;
@@ -235,7 +233,7 @@ export interface StorefrontConfig {
235
233
  heading?: string;
236
234
  tagline?: string;
237
235
  cta?: string;
238
- /** `modal` variant only — when it should pop up. Backend field pending; defaults to 'scroll' when unset. */
236
+ /** `modal` variant only — when it should pop up. Defaults to 'scroll' when unset. */
239
237
  trigger?: 'immediate' | 'delay' | 'scroll';
240
238
  };
241
239
  };
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The announcement bar is ONE text field on the backend
2
+ * The announcement bar is ONE text field on the API
3
3
  * (`header.announcement.text`). A vendor writes several messages into it
4
4
  * separated by `|` or a line break and the bar rotates them; a single
5
- * message renders static. Roast's masthead set the convention; the merchant
6
- * editor and Qee teach it. Split here, once, so every theme rotates the same
5
+ * message renders static. The merchant editor and Qee teach this convention.
6
+ * Split here, once, so every theme rotates the same
7
7
  * list — a theme-local split meant the other themes showed the literal pipes
8
8
  * after a switch.
9
9
  */
@@ -1,13 +1,12 @@
1
1
  import type { JSX } from 'react';
2
2
  /**
3
- * Shared icon-keyword vocabulary for "benefit row" content blocks (glow's
4
- * `trust-badges`, any theme's `features-columns`, and similar icon+title+text
5
- * item lists). qee generates a short keyword per item (e.g. "star", "truck"),
3
+ * Shared icon-keyword vocabulary for "benefit row" content blocks (a theme's
4
+ * `trust-badges` or `features-columns`, and similar icon+title+text item
5
+ * lists). Qee generates a short keyword per item (e.g. "star", "truck"),
6
6
  * never raw SVG markup — themes render that keyword through this resolver
7
- * instead of each theme re-declaring its own switch (glow and allure both
8
- * had one, independently, before this was extracted) or a theme trying to
9
- * `dangerouslySetInnerHTML` the keyword directly (renders as literal text,
10
- * not an icon — the bug that prompted this).
7
+ * instead of each theme re-declaring its own switch, or trying to
8
+ * `dangerouslySetInnerHTML` the keyword directly (that renders as literal
9
+ * text, not an icon).
11
10
  *
12
11
  * `currentColor` throughout — the caller controls color via CSS `color` on
13
12
  * an ancestor, same convention every existing usage already followed.
@@ -88,8 +87,8 @@ const PATHS: Record<BenefitIconKey, JSX.Element> = {
88
87
  ),
89
88
  };
90
89
 
91
- // Synonyms qee has actually generated in practice for a key in BENEFIT_ICON_KEYS
92
- // (confirmed live: "truck" for a shipping/delivery benefit) — the AI is given
90
+ // Synonyms Qee is known to generate for a key in BENEFIT_ICON_KEYS (for example
91
+ // "truck" for a shipping/delivery benefit) — the AI is given
93
92
  // the canonical keyword list in the manifest/prompt, but a close synonym
94
93
  // should still resolve to the right icon instead of silently falling back.
95
94
  const ALIASES: Record<string, BenefitIconKey> = {
@@ -103,8 +102,7 @@ const ALIASES: Record<string, BenefitIconKey> = {
103
102
  quality: 'star',
104
103
  payment: 'wallet',
105
104
  price: 'wallet',
106
- // Carried over from glow's pre-shared TrustIcon switch — real demo data
107
- // and, presumably, real qee-generated pages already use this word.
105
+ // Already used by existing demo data and generated pages.
108
106
  sustainability: 'leaf',
109
107
  };
110
108
 
package/utils/brand.ts CHANGED
@@ -20,10 +20,8 @@ import type {
20
20
 
21
21
  /**
22
22
  * Storefront design-token injector — turns the resolved semantic `tokens`
23
- * object (design-token-contract.md, queek_backend `.agent/.tmp/`) into the
24
- * full CSS custom-property set every theme reads. Replaces the old
25
- * `getBrandCssVariables`, which only ever emitted ~12 of the 24 color roles
26
- * (the bug this fixes).
23
+ * object into the full CSS custom-property set every theme reads, covering
24
+ * all 24 color roles.
27
25
  *
28
26
  * `expandDesignTokens` is the pure, unit-tested core: given ANY partial (or
29
27
  * absent) token object it fills every gap with a sane default and always
@@ -34,7 +32,7 @@ import type {
34
32
  * legacy `config.colors` / `brand.colors` / `brand.font` ← defaults) and
35
33
  * hands them to `expandDesignTokens`, then layers on the handful of
36
34
  * chrome vars (`--brand-footer-*`, explicit legacy button overrides) that
37
- * live outside the token contract so existing stores render identically.
35
+ * live outside the token set so existing stores render identically.
38
36
  */
39
37
 
40
38
  const DEFAULT_COLOR: Required<DesignTokenColor> = {
@@ -120,7 +118,7 @@ const CASE_MAP: Record<DesignHeadingCase, string> = {
120
118
 
121
119
  /**
122
120
  * Deliberately its OWN fixed scale, not a reference into RADIUS_SCALE/
123
- * shape.radius. Live-caught (15/8/26): when `--image-radius` pointed at
121
+ * shape.radius. When `--image-radius` pointed at
124
122
  * `var(--radius-sm/md/lg)`, a vendor on the (very common) 'sharp' global
125
123
  * Shape preset saw EVERY image-radius choice — sharp, soft, AND rounded —
126
124
  * collapse to the same 0px, since 'sharp' flattens the whole sm/md/lg
@@ -215,8 +213,7 @@ function contrastRatio(hexA: string, hexB: string): number | null {
215
213
  }
216
214
 
217
215
  /** WCAG AA body-text minimum. Below this, `accent`/`on_accent` reads as
218
- * the same color rather than text-on-a-surface (the Taylor/glow incident:
219
- * a vendor's legacy `colors.accent` override went to near-black while
216
+ * the same color rather than text-on-a-surface (for example, a vendor's legacy `colors.accent` override went to near-black while
220
217
  * `on_accent` silently kept the theme's own default — also near-black,
221
218
  * since it was tuned to pair with THAT theme's accent, not the vendor's). */
222
219
  const MIN_ON_ACCENT_CONTRAST = 4.5;
@@ -474,7 +471,7 @@ function legacyTokensFromConfig(brand?: Brand | null, config?: StorefrontConfig
474
471
  }
475
472
 
476
473
  /**
477
- * Resolution order per the contract: `config.tokens` (per-group) ←
474
+ * Resolution order: `config.tokens` (per-group) ←
478
475
  * legacy fields ← (handled by `expandDesignTokens`'s own defaults).
479
476
  */
480
477
  function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig | null): DesignTokens {
@@ -497,7 +494,7 @@ function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig
497
494
  /**
498
495
  * Resolve brand CSS variables for the `provider.tsx` wrapper. Emits the
499
496
  * full token-derived var set (see `expandDesignTokens`), then layers on
500
- * the legacy chrome/override vars that sit outside the token contract:
497
+ * the legacy chrome/override vars that sit outside the token set:
501
498
  * footer colors and an explicit `config.colors.button(_text)` override
502
499
  * (pre-tokens stores could set a button color independent of `primary`).
503
500
  */
package/utils/format.ts CHANGED
@@ -4,11 +4,10 @@
4
4
  * down to 0, so it silently truncated EVERY fractional-naira amount to a
5
5
  * whole number, everywhere in the storefront. A ₦0.28 balance (e.g. a tiny
6
6
  * residual left after a near-100% discount) displayed as "₦0" — the customer
7
- * saw a free checkout, then got asked to actually pay something. POS already
8
- * shows real money correctly (lib/currency.ts: minimumFractionDigits: 2,
9
- * maximumFractionDigits: 2, always).
7
+ * saw a free checkout, then got asked to actually pay something. The POS
8
+ * always shows two decimals.
10
9
  *
11
- * Deliberately NOT matching POS's always-2-decimals contract here: the
10
+ * Deliberately NOT matching the POS's always-2-decimals format here: the
12
11
  * overwhelming majority of storefront prices are whole naira, and putting
13
12
  * ".00" on every single one would be a large, unrequested visual change
14
13
  * across every theme. `minimumFractionDigits: 0, maximumFractionDigits: 2`
@@ -51,8 +50,7 @@ function displayTagFor(
51
50
  /**
52
51
  * A date for display (default "14 Feb 2026"), identical on server and browser.
53
52
  * '' if unparseable. The locale parameter is routed through the canonical
54
- * mapping (founder 2/10/26: English is British/Nigerian order, never US
55
- * order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
53
+ * mapping (English is British/Nigerian order, never US order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
56
54
  * exported formatter lets an 'en*' tag reach Intl unmapped.
57
55
  */
58
56
  export function formatDisplayDate(
@@ -137,7 +135,7 @@ export const PLATFORM_DEFAULT_CURRENCY = 'NGN';
137
135
  * The ONE currency-code resolver: trims/uppercases the item or vendor code,
138
136
  * accepts it when it is a 3-letter code, and returns the platform default
139
137
  * otherwise. List-level `meta.currency` is never consulted — it stamps the
140
- * platform default even on non-NGN stores (api-currency-contract-fix).
138
+ * platform default even on non-NGN stores.
141
139
  */
142
140
  export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFORM_DEFAULT_CURRENCY): string {
143
141
  const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
@@ -22,10 +22,10 @@ export interface ResolvedImageSources {
22
22
 
23
23
  const NONE: ResolvedImageSources = { src: undefined, srcSet: undefined, sizes: undefined };
24
24
 
25
- /** Ascending by cap. `thumb` is excluded by contract rule 5; `original` is
25
+ /** Ascending by cap. `thumb` is excluded because it has no width; `original` is
26
26
  * excluded on purpose — it is uncapped and unoptimized (raw jpg/png, possibly
27
27
  * 6000px), so letting a browser pick it off a srcset is the exact regression
28
- * this whole effort exists to remove. `original` survives only as a last-resort
28
+ * the tier map exists to prevent. `original` survives only as a last-resort
29
29
  * `src` for the `raw` intent and as the onError step-2 retry. */
30
30
  const SRCSET_TIERS = ['card', 'card2x', 'view'] as const;
31
31
  type SrcSetTier = (typeof SRCSET_TIERS)[number];
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Language-switch URL rules — pure (no `next/*`, no React) so the hook, the
3
+ * component and any node tooling share one implementation.
4
+ *
5
+ * Routing shape of a storefront with several languages:
6
+ *
7
+ * - The store's DEFAULT language is served at the store root (`path_prefix`
8
+ * is `''`).
9
+ * - Every other language — English included when it is not the default —
10
+ * lives under its bare lower-case code: `/fr`, `/en`, `/zh-cn`.
11
+ * - Slugs are never translated, so a page keeps the same path under every
12
+ * prefix.
13
+ * - `StorefrontProvider`'s `basePath` already carries the ACTIVE language
14
+ * prefix (`/store/fr` on a path-based host, `/fr` on a store domain), so
15
+ * links built from it stay in the shopper's language. `localeHref` takes
16
+ * that into account.
17
+ */
18
+
19
+ /** One language a store serves — the row shape the locales endpoint returns. */
20
+ export interface StoreLocale {
21
+ /** Language code, e.g. `en`, `fr`, `zh-CN`. */
22
+ locale: string;
23
+ /** English name, e.g. `French`. */
24
+ name: string;
25
+ /** The language's own name, e.g. `Français`. */
26
+ native_name: string;
27
+ /** The store's content (source) language. */
28
+ is_primary: boolean;
29
+ /** Served at the store root. Exactly one language is the default. */
30
+ is_default: boolean;
31
+ /** URL prefix: `''` on the default language, the bare lower-case code otherwise. */
32
+ path_prefix: string;
33
+ hreflang: string;
34
+ html_lang: string;
35
+ /** `'rtl'` or `'ltr'`. */
36
+ dir: string;
37
+ }
38
+
39
+ /**
40
+ * What `StorefrontProvider` accepts for `locales`: a `StoreLocale` list as
41
+ * the storefront already holds it, or an older payload that lacks
42
+ * `is_default` (then the primary language is the default). Extra fields are
43
+ * tolerated and dropped.
44
+ */
45
+ export interface StoreLocaleInput {
46
+ locale: string;
47
+ name?: string;
48
+ native_name?: string;
49
+ is_primary?: boolean;
50
+ is_default?: boolean;
51
+ path_prefix?: string;
52
+ hreflang?: string;
53
+ html_lang?: string;
54
+ dir?: string;
55
+ }
56
+
57
+ const FALLBACK_LOCALE_CODE = 'en';
58
+
59
+ /**
60
+ * Validates and normalises a locales payload into `StoreLocale` rows.
61
+ *
62
+ * - Rows without a string `locale` are dropped; a repeated code keeps its
63
+ * first row.
64
+ * - `is_default` missing (older backend) is derived from `is_primary`.
65
+ * - Exactly one row stays default: the first flagged one, else the primary,
66
+ * else the first row. It sorts first (others keep their order) and its
67
+ * prefix is forced to `''`; every other prefix is lower-cased, and an
68
+ * empty one becomes the bare lower-case code.
69
+ * - Nothing usable → `[]`, which the hook and component read as "single
70
+ * language: no switcher".
71
+ */
72
+ export function normalizeStoreLocales(input: readonly StoreLocaleInput[] | null | undefined): StoreLocale[] {
73
+ if (!Array.isArray(input)) return [];
74
+
75
+ const seen = new Set<string>();
76
+ const rows: StoreLocale[] = [];
77
+ for (const row of input as readonly StoreLocaleInput[]) {
78
+ if (!row || typeof row.locale !== 'string' || row.locale.length === 0) continue;
79
+ const key = row.locale.toLowerCase();
80
+ if (seen.has(key)) continue;
81
+ seen.add(key);
82
+
83
+ const isPrimary = row.is_primary === true;
84
+ rows.push({
85
+ locale: row.locale,
86
+ name: typeof row.name === 'string' && row.name ? row.name : row.locale,
87
+ native_name: typeof row.native_name === 'string' && row.native_name ? row.native_name : (typeof row.name === 'string' && row.name ? row.name : row.locale),
88
+ is_primary: isPrimary,
89
+ is_default: typeof row.is_default === 'boolean' ? row.is_default : isPrimary,
90
+ path_prefix: typeof row.path_prefix === 'string' ? row.path_prefix.toLowerCase() : '',
91
+ hreflang: typeof row.hreflang === 'string' && row.hreflang ? row.hreflang : row.locale,
92
+ html_lang: typeof row.html_lang === 'string' && row.html_lang ? row.html_lang : row.locale,
93
+ dir: row.dir === 'rtl' ? 'rtl' : 'ltr',
94
+ });
95
+ }
96
+ if (rows.length === 0) return [];
97
+
98
+ const defaultRow = rows.find((row) => row.is_default) ?? rows.find((row) => row.is_primary) ?? rows[0];
99
+ const normalized = rows.map((row) => {
100
+ const isDefault = row === defaultRow;
101
+ return {
102
+ ...row,
103
+ is_default: isDefault,
104
+ path_prefix: isDefault ? '' : row.path_prefix || row.locale.toLowerCase(),
105
+ };
106
+ });
107
+ return [...normalized.filter((row) => row.is_default), ...normalized.filter((row) => !row.is_default)];
108
+ }
109
+
110
+ /**
111
+ * The store's default language — the one served at the root. Falls back to
112
+ * the primary, then the first row, then English for an empty list.
113
+ */
114
+ export function defaultStoreLocale(locales: readonly StoreLocale[]): StoreLocale {
115
+ return (
116
+ locales.find((locale) => locale.is_default) ??
117
+ locales.find((locale) => locale.is_primary) ??
118
+ locales[0] ?? {
119
+ locale: FALLBACK_LOCALE_CODE,
120
+ name: 'English',
121
+ native_name: 'English',
122
+ is_primary: true,
123
+ is_default: true,
124
+ path_prefix: '',
125
+ hreflang: FALLBACK_LOCALE_CODE,
126
+ html_lang: FALLBACK_LOCALE_CODE,
127
+ dir: 'ltr',
128
+ }
129
+ );
130
+ }
131
+
132
+ /**
133
+ * The language a request renders in. `localeCode` is the request locale the
134
+ * provider holds (`null` on the default language). An unknown code resolves
135
+ * to the default — never an error.
136
+ */
137
+ export function resolveActiveLocale(locales: readonly StoreLocale[], localeCode: string | null | undefined): StoreLocale {
138
+ if (localeCode) {
139
+ const code = localeCode.toLowerCase();
140
+ const match = locales.find((locale) => locale.locale.toLowerCase() === code);
141
+ if (match) return match;
142
+ }
143
+ return defaultStoreLocale(locales);
144
+ }
145
+
146
+ /** `/a/b` starts with segment path `/a` (whole segments only). */
147
+ function hasSegmentPrefix(path: string, prefix: string): boolean {
148
+ return path === prefix || path.startsWith(`${prefix}/`);
149
+ }
150
+
151
+ function hasSegmentSuffix(path: string, segment: string): boolean {
152
+ return path === `/${segment}` || path.endsWith(`/${segment}`);
153
+ }
154
+
155
+ function withMark(value: string | undefined, mark: '?' | '#'): string {
156
+ if (!value || value === mark) return '';
157
+ return value.startsWith(mark) ? value : `${mark}${value}`;
158
+ }
159
+
160
+ export interface LocaleHrefInput {
161
+ /** The current path as the browser shows it, e.g. `/store/fr/shop`. */
162
+ pathname: string;
163
+ /** Query string, with or without the leading `?`. Kept as is. */
164
+ search?: string;
165
+ /** Hash, with or without the leading `#`. Kept as is. */
166
+ hash?: string;
167
+ /** The provider's `basePath` (already carries the active language prefix). */
168
+ basePath?: string;
169
+ /** The language the page renders in now. */
170
+ active: StoreLocale;
171
+ /** The language to switch to. */
172
+ target: StoreLocale;
173
+ }
174
+
175
+ /**
176
+ * The same page in another language: the path moves under the target's
177
+ * prefix (or to the root for the default language), the query string and
178
+ * hash are kept, and a trailing slash is kept.
179
+ *
180
+ * Only the ACTIVE prefix is removed from the current path, and only as a
181
+ * whole segment — `/french-press` is a page, not the `fr` language.
182
+ */
183
+ export function localeHref({ pathname, search, hash, basePath = '', active, target }: LocaleHrefInput): string {
184
+ const activePrefix = active.path_prefix.toLowerCase();
185
+ const current = pathname || '/';
186
+ const lowerBase = basePath.toLowerCase();
187
+
188
+ let vendorBase = basePath;
189
+ let bare = current;
190
+
191
+ if (activePrefix && hasSegmentSuffix(lowerBase, activePrefix)) {
192
+ // `basePath` is the localized one: removing it removes the language too.
193
+ vendorBase = basePath.slice(0, basePath.length - activePrefix.length - 1);
194
+ if (hasSegmentPrefix(current.toLowerCase(), lowerBase)) bare = current.slice(basePath.length) || '/';
195
+ } else {
196
+ if (basePath && hasSegmentPrefix(current, basePath)) bare = current.slice(basePath.length) || '/';
197
+ if (activePrefix && hasSegmentPrefix(bare.toLowerCase(), `/${activePrefix}`)) {
198
+ bare = bare.slice(activePrefix.length + 1) || '/';
199
+ }
200
+ }
201
+ if (!bare.startsWith('/')) bare = `/${bare}`;
202
+
203
+ const targetPrefix = target.is_default ? '' : target.path_prefix.toLowerCase() || target.locale.toLowerCase();
204
+ const rest = bare === '/' ? '' : bare;
205
+ const path = `${vendorBase}${targetPrefix ? `/${targetPrefix}` : ''}${rest}` || '/';
206
+
207
+ return `${path}${withMark(search, '?')}${withMark(hash, '#')}`;
208
+ }
package/utils/locale.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Storefront locale transport — the kit side of the translations plan.
2
+ * Storefront locale transport.
3
3
  *
4
- * Contract (translations-core.md, decisions closed): the backend accepts
5
- * `?locale=<code>` on every customer-facing read and answers translated text
6
- * (unknown/unpublished locale → source text, no error). The storefront proxy
7
- * sets `x-queek-locale` on the incoming request for published NON-primary
4
+ * The API accepts `?locale=<code>` on every customer-facing read and answers
5
+ * translated text (unknown/unpublished locale → source text, no error). The
6
+ * storefront proxy sets `x-queek-locale` on the incoming request for published NON-primary
8
7
  * locales only — the header is ABSENT on the primary locale — and suffixes
9
8
  * revalidation tags as `<tag>:locale:<code>`.
10
9
  *
@@ -23,17 +22,17 @@
23
22
  * English display locale — the ONE Intl tag every English shopper sees dates,
24
23
  * numbers and money in. `en-NG`, not `en-GB`: both print dates identically
25
24
  * ("2 October 2026", "14 Feb 2026"), but only `en-NG` renders NGN with the
26
- * naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500` —
27
- * verified on Node 22, full ICU, 2/10/26). `DISPLAY_LOCALE` in
28
- * `utils/format.ts` aliases this, so the value lives in exactly one place.
25
+ * naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500`, on
26
+ * Node 22 with full ICU). `DISPLAY_LOCALE` in `utils/format.ts` aliases this, so
27
+ * the value lives in exactly one place.
29
28
  */
30
29
  export const ENGLISH_DISPLAY_LOCALE = 'en-NG';
31
30
 
32
31
  /**
33
32
  * THE single canonical mapping from a storefront locale code to the Intl
34
- * locale dates/numbers/relative time are formatted in (founder 2/10/26:
35
- * shopper's locale, English in the British/Nigerian style so English stores
36
- * do not change visibly — "2 October 2026", never US "October 2, 2026").
33
+ * locale dates/numbers/relative time are formatted in: the shopper's locale,
34
+ * with English in the British/Nigerian style ("2 October 2026", never US
35
+ * "October 2, 2026").
37
36
  *
38
37
  * - Missing/invalid/unknown-structure codes and EVERY English variant (`en`,
39
38
  * `en-GB`, `en-NG`, `en-US`, …) become `ENGLISH_DISPLAY_LOCALE`. In