@usequeek/theme-kit 0.1.21 → 0.1.23

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 (86) hide show
  1. package/README.md +293 -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-menu.css +349 -0
  8. package/components/language-menu.tsx +371 -0
  9. package/components/language-switcher.css +131 -0
  10. package/components/language-switcher.tsx +175 -0
  11. package/components/markdown.tsx +1 -1
  12. package/components/powered-by-queek.tsx +4 -4
  13. package/components/product-media-frame.tsx +1 -1
  14. package/components/product-metafields.tsx +2 -3
  15. package/components/variant-picker.tsx +6 -9
  16. package/demo.ts +10 -14
  17. package/design-token-preview.tsx +0 -2
  18. package/edit-mode-active.tsx +3 -4
  19. package/hooks/use-announcement.ts +1 -1
  20. package/hooks/use-auth-flow.tsx +1 -1
  21. package/hooks/use-locales.ts +107 -0
  22. package/hooks/use-posts.ts +1 -1
  23. package/hooks/use-product-detail.ts +1 -2
  24. package/hooks/use-product-sections.ts +4 -6
  25. package/hooks/use-products.ts +5 -8
  26. package/index.ts +13 -16
  27. package/locales/en.default.json +4 -0
  28. package/next-reference.d.ts +2 -4
  29. package/package.json +3 -2
  30. package/page-renderer.tsx +5 -6
  31. package/provider.tsx +22 -2
  32. package/scripts/verify-package.ts +6 -8
  33. package/sdk/client.ts +13 -12
  34. package/sdk/error-handler.ts +1 -1
  35. package/sdk/platform.ts +6 -8
  36. package/sdk/queek-client.ts +11 -12
  37. package/sdk/server-store-client.ts +13 -17
  38. package/shared-blocks/metaobjects.tsx +1 -1
  39. package/stores/cart-store.ts +2 -2
  40. package/stores/checkout-store.ts +1 -1
  41. package/stores/edit-preview-store.ts +5 -6
  42. package/stores/user-store.ts +4 -5
  43. package/strings/pseudo-locale.ts +3 -4
  44. package/strings/theme-strings.ts +9 -7
  45. package/tests/auth-email-errors.test.tsx +2 -2
  46. package/tests/currency-contract.test.ts +2 -3
  47. package/tests/display-format.test.ts +17 -25
  48. package/tests/fixtures/external-theme.tsx +23 -2
  49. package/tests/language-menu.test.tsx +598 -0
  50. package/tests/language-switcher.test.tsx +350 -0
  51. package/tests/list-pagination.test.tsx +8 -8
  52. package/tests/locale-switch.test.ts +197 -0
  53. package/tests/locale-transport.test.tsx +3 -3
  54. package/tests/markdown-render.test.ts +5 -6
  55. package/tests/menu-placement.test.ts +63 -0
  56. package/tests/orders-walk.test.ts +5 -5
  57. package/tests/page-renderer-signature.test.tsx +1 -1
  58. package/tests/powered-by-queek.test.tsx +3 -3
  59. package/tests/powered-by-url.test.ts +2 -2
  60. package/tests/public-surface.test.ts +1 -4
  61. package/tests/shopper-chrome-strings.test.tsx +5 -6
  62. package/tests/storefront-client-scope.test.ts +5 -5
  63. package/tests/theme-hook-placement.test.tsx +2 -2
  64. package/tests/theme-strings-client.test.tsx +5 -5
  65. package/tests/theme-strings-pseudo.test.tsx +1 -1
  66. package/tests/theme-strings-typed.test.tsx +1 -1
  67. package/tests/theme-strings.test.tsx +8 -11
  68. package/tests/use-shop-prefetch.test.tsx +1 -1
  69. package/theme-mount.tsx +6 -11
  70. package/types/block.ts +7 -7
  71. package/types/media.ts +8 -9
  72. package/types/product.ts +8 -12
  73. package/types/theme.ts +26 -25
  74. package/types/vendor.ts +10 -12
  75. package/utils/announcement.ts +3 -3
  76. package/utils/benefit-icon.tsx +9 -11
  77. package/utils/brand.ts +7 -10
  78. package/utils/format.ts +5 -7
  79. package/utils/image-srcset.ts +2 -2
  80. package/utils/locale-switch.ts +208 -0
  81. package/utils/locale.ts +10 -11
  82. package/utils/menu-link.ts +1 -1
  83. package/utils/menu-placement.ts +91 -0
  84. package/utils/product-media.ts +1 -1
  85. package/utils/product-normalizer.ts +2 -2
  86. package/utils/vendor-host.ts +7 -11
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computePlacement, type PlacementInput } from '../utils/menu-placement';
3
+
4
+ const VIEW = { width: 1000, height: 800 };
5
+ const POP = { width: 240, height: 300 };
6
+
7
+ function place(over: Partial<PlacementInput> & Pick<PlacementInput, 'trigger'>) {
8
+ return computePlacement({ popover: POP, viewport: VIEW, side: 'bottom', align: 'end', dir: 'ltr', ...over });
9
+ }
10
+
11
+ /** A 100x44 trigger whose top-left is (left, top). */
12
+ const box = (left: number, top: number) => ({ left, top, right: left + 100, bottom: top + 44 });
13
+
14
+ describe('computePlacement', () => {
15
+ it('keeps the preferred placement when it fits', () => {
16
+ expect(place({ trigger: box(700, 10) })).toMatchObject({ side: 'bottom', align: 'end', shift: 0 });
17
+ });
18
+
19
+ it('flips up when there is no room below and more above', () => {
20
+ const p = place({ trigger: box(700, 600) });
21
+ expect(p.side).toBe('top');
22
+ expect(p.available).toBe(600 - 8 - 8);
23
+ });
24
+
25
+ it('footers opening up flip down when there is no room above', () => {
26
+ expect(place({ trigger: box(700, 20), side: 'top' }).side).toBe('bottom');
27
+ expect(place({ trigger: box(700, 600), side: 'top' }).side).toBe('top');
28
+ });
29
+
30
+ it('keeps the side with more room (and caps the height) when neither fits', () => {
31
+ const p = place({ trigger: box(700, 300), popover: { width: 240, height: 700 } });
32
+ expect(p.side).toBe('bottom');
33
+ expect(p.available).toBe(800 - 344 - 8 - 8);
34
+ });
35
+
36
+ it('aligns to the trigger end (right edge in ltr) and flips alignment on overflow', () => {
37
+ // end: left = 800 - 240 = 560, fits.
38
+ expect(place({ trigger: box(700, 10) })).toMatchObject({ align: 'end', shift: 0 });
39
+ // trigger near the left edge: end would start at 40 - 140 < 16; start fits.
40
+ expect(place({ trigger: box(40, 10) })).toMatchObject({ align: 'start', shift: 0 });
41
+ });
42
+
43
+ it('mirrors in rtl: end is the left edge', () => {
44
+ // rtl end: left = trigger.left = 40 fits.
45
+ expect(place({ trigger: box(40, 10), dir: 'rtl' })).toMatchObject({ align: 'end', shift: 0 });
46
+ // trigger at the right edge: rtl end would overflow; start (right edge aligned) fits.
47
+ expect(place({ trigger: box(880, 10), dir: 'rtl' })).toMatchObject({ align: 'start', shift: 0 });
48
+ });
49
+
50
+ it('shifts into the gutter when neither alignment fits', () => {
51
+ // 240 wide in a 260 viewport, trigger in the middle: both overflow somewhere.
52
+ const p = computePlacement({
53
+ trigger: box(80, 10),
54
+ popover: { width: 240, height: 100 },
55
+ viewport: { width: 260, height: 800 },
56
+ side: 'bottom',
57
+ align: 'end',
58
+ dir: 'ltr',
59
+ });
60
+ // end: left edge at 180 - 240 = -60, shifted +76 to the 16px gutter.
61
+ expect(p).toMatchObject({ align: 'end', shift: 76 });
62
+ });
63
+ });
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { fetchOrders } from '../api/orders';
3
3
 
4
4
  /**
5
- * `GET store/orders` answers the ONE storefront list envelope (pagination
6
- * Slice 4b, founder 28/9/26): `has_more` + `next_cursor` top-level, no
7
- * `links`, no paginator `meta`. The kit reads the page it asked for.
5
+ * `GET store/orders` answers the ONE storefront list envelope: `has_more` +
6
+ * `next_cursor` top-level, no `links`, no paginator `meta`. The kit reads the
7
+ * page it asked for.
8
8
  */
9
9
  describe('fetchOrders walk state', () => {
10
10
  afterEach(() => vi.unstubAllGlobals());
@@ -23,7 +23,7 @@ describe('fetchOrders walk state', () => {
23
23
  }));
24
24
  vi.stubGlobal('fetch', fetchMock);
25
25
 
26
- const page = await fetchOrders('kili-foods', 2, 'ongoing');
26
+ const page = await fetchOrders('demo-store', 2, 'ongoing');
27
27
 
28
28
  const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
29
29
  expect(url).toContain('/client/store/orders');
@@ -39,7 +39,7 @@ describe('fetchOrders walk state', () => {
39
39
  const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
40
40
  vi.stubGlobal('fetch', fetchMock);
41
41
 
42
- const page = await fetchOrders('kili-foods', 1, 'all');
42
+ const page = await fetchOrders('demo-store', 1, 'all');
43
43
 
44
44
  expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
45
45
  expect(page.has_more).toBe(false);
@@ -27,7 +27,7 @@ describe('PageRenderer', () => {
27
27
  { type: 'content', data: { markdown: 'Fresh meals, delivered' }, productsPromise: circularThenable(), metaobjectsPromise: circularThenable() },
28
28
  ] as unknown as Block[];
29
29
  const theme = { getBlock: () => () => null } as unknown as ThemeModule;
30
- const store = { vendor: { id: 'v1', slug: 'kili-foods' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
30
+ const store = { vendor: { id: 'v1', slug: 'demo-store' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
31
31
 
32
32
  const html = renderToStaticMarkup(
33
33
  <StorefrontContext.Provider value={store as never}>
@@ -27,9 +27,9 @@ describe('PoweredByQueek', () => {
27
27
  });
28
28
 
29
29
  it('tags the link with the store slug as utm_campaign', () => {
30
- const html = render(undefined, { id: 'uuid-1', slug: 'kili-foods' });
30
+ const html = render(undefined, { id: 'uuid-1', slug: 'demo-store' });
31
31
 
32
- expect(html).toContain(`href="${BASE}&amp;utm_campaign=kili-foods"`);
32
+ expect(html).toContain(`href="${BASE}&amp;utm_campaign=demo-store"`);
33
33
  expect(html).toContain('rel="noopener noreferrer"');
34
34
  });
35
35
 
@@ -60,6 +60,6 @@ describe('PoweredByQueek', () => {
60
60
 
61
61
  it('renders nothing when hidden', () => {
62
62
  expect(render({ hidden: true })).toBe('');
63
- expect(render({ hidden: true }, { id: 'uuid-1', slug: 'kili-foods' })).toBe('');
63
+ expect(render({ hidden: true }, { id: 'uuid-1', slug: 'demo-store' })).toBe('');
64
64
  });
65
65
  });
@@ -7,13 +7,13 @@ function params(url: string): Record<string, string> {
7
7
 
8
8
  describe('poweredByQueekUrl', () => {
9
9
  it('keeps the business-page destination and adds source, medium and campaign', () => {
10
- const url = poweredByQueekUrl('kili-foods');
10
+ const url = poweredByQueekUrl('demo-store');
11
11
 
12
12
  expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
13
13
  expect(params(url)).toEqual({
14
14
  utm_source: 'powered_by',
15
15
  utm_medium: 'storefront',
16
- utm_campaign: 'kili-foods',
16
+ utm_campaign: 'demo-store',
17
17
  });
18
18
  });
19
19
 
@@ -20,10 +20,7 @@ function barrelModules(file: string): Set<string> {
20
20
  * `index.ts` + `client.ts` declare the surface a theme may build against — the
21
21
  * set that ships as `@usequeek/theme-kit`.
22
22
  *
23
- * This is the kit-internal half of the contract: every module the barrels
24
- * declare must exist. (The storefront repo holds the other half —
25
- * `core-public-surface.test.ts` there asserts the barrels cover every module
26
- * the themes actually import.)
23
+ * Every module the barrels declare must exist.
27
24
  */
28
25
  describe('core public surface (kit-internal)', () => {
29
26
  const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
3
3
  *
4
- * Stage A: every state below was rendered against the PRE-migration code and
5
- * stored in `tests/fixtures/shopper-chrome-baseline.json`. Each case asserts
6
- * `.toBe` equality against that stored OLD markup, so ANY English change
7
- * (value, markup, attribute, plural) fails here. S4 drift guard + S7 proof.
4
+ * Each state below is rendered and compared with the markup stored in
5
+ * `tests/fixtures/shopper-chrome-baseline.json` (`.toBe` equality), so ANY
6
+ * English change (value, markup, attribute, plural) fails here.
8
7
  */
9
8
  import { readFileSync } from 'node:fs';
10
9
  import { join, dirname } from 'node:path';
@@ -112,7 +111,7 @@ const AVATAR_USER = {
112
111
  function render(children: ReactNode): string {
113
112
  return renderToStaticMarkup(
114
113
  <StorefrontProvider
115
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
114
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
116
115
  config={{} as never}
117
116
  menus={[]}
118
117
  >
@@ -262,7 +261,7 @@ describe('shopper chrome English safety (checkout/auth/blog)', () => {
262
261
  });
263
262
  }
264
263
 
265
- // S4 value guard: ShareButtons renders its copied branch only after a
264
+ // Value guard: ShareButtons renders its copied branch only after a
266
265
  // client-side clipboard write (internal useState, no prop), so no static
267
266
  // baseline can capture it. Pin the English through the real `t` instead —
268
267
  // any change to blog.share.copied fails here.
@@ -4,11 +4,11 @@ import { join } from 'node:path';
4
4
  import { request } from '../sdk/client';
5
5
 
6
6
  /**
7
- * The storefront must ONLY ever call the `/v1/client/*` backend scope. That scope's
8
- * ResolveClientContext middleware resolves the vendor AND forces the platform
9
- * (storefront/instore_qr) — so prices are always the storefront's, never the
10
- * marketplace commission-inclusive price. Legacy v1 routes (/products, /vendors/*,
11
- * /categories, ...) bypass that middleware → wrong platform → wrong price.
7
+ * The storefront must ONLY ever call the `/v1/client/*` API scope. That scope
8
+ * resolves the vendor AND forces the platform (storefront/instore_qr) — so prices
9
+ * are always the storefront's, never the marketplace commission-inclusive price.
10
+ * Legacy v1 routes (/products, /vendors/*, /categories, ...) skip that
11
+ * resolution → wrong platform → wrong price.
12
12
  *
13
13
  * This guard prevents that whole class of bug from ever returning.
14
14
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hook-placement rule (checkout / auth / blog slice) — S2 enforcement.
2
+ * Hook-placement rule for the checkout / auth / blog components.
3
3
  *
4
4
  * Rule: every `useThemeStrings()` call must sit at the top level of a
5
5
  * PascalCase component or a `use*` hook: no enclosing if / ternary / loop /
@@ -15,7 +15,7 @@ import ts from 'typescript';
15
15
 
16
16
  const kitRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
17
17
 
18
- // Files this slice migrated: each must call useThemeStrings() exactly once.
18
+ // Files that use theme strings: each must call useThemeStrings() exactly once.
19
19
  const EXPECTED_FILES = [
20
20
  'components/checkout/checkout-contact.tsx',
21
21
  'components/checkout/default-checkout-shell.tsx',
@@ -23,7 +23,7 @@ function render(
23
23
  ): string {
24
24
  return renderToStaticMarkup(
25
25
  <StorefrontProvider
26
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
26
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
27
27
  config={{} as never}
28
28
  menus={[]}
29
29
  {...(options?.locale !== undefined ? { locale: options.locale } : {})}
@@ -39,7 +39,7 @@ function Title(): JSX.Element {
39
39
  return <h1>{t('cart.title')}</h1>;
40
40
  }
41
41
 
42
- describe('client surface: StorefrontProvider strings + useThemeStrings (decision 4b)', () => {
42
+ describe('client surface: StorefrontProvider strings + useThemeStrings', () => {
43
43
  it('renders the active locale through the hook', () => {
44
44
  expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
45
45
  });
@@ -105,7 +105,7 @@ describe('client surface through ThemeMount', () => {
105
105
  });
106
106
  });
107
107
 
108
- describe('manifest.strings travels with the theme (decision 15)', () => {
108
+ describe('manifest.strings travels with the theme', () => {
109
109
  const MANIFEST_EN: ThemeStringsDictionary = {
110
110
  theme: {
111
111
  hero: { title: 'Fresh from the farm' },
@@ -216,10 +216,10 @@ describe('manifest.strings travels with the theme (decision 15)', () => {
216
216
  // No `strings` key at all: this literal must satisfy StorefrontContextValue
217
217
  // (it failed to compile while the field was required in 0.1.18).
218
218
  const value = {
219
- vendor: { id: 'v1', slug: 'kili-foods', name: 'Kili Foods', logo: null } as VendorProfile,
219
+ vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store', logo: null } as VendorProfile,
220
220
  config: { theme: 'stub', brand: {} } as StorefrontConfig,
221
221
  menus: [],
222
- basePath: '/kili-foods',
222
+ basePath: '/demo-store',
223
223
  pagesChrome: {},
224
224
  locale: null,
225
225
  };
@@ -17,7 +17,7 @@ const EN = enDefault as unknown as ThemeStringsDictionary;
17
17
  function render(children: ReactNode, strings: ThemeStringsDictionary | null): string {
18
18
  return renderToStaticMarkup(
19
19
  <StorefrontProvider
20
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
20
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
21
21
  config={{} as never}
22
22
  menus={[]}
23
23
  {...(strings ? { strings } : {})}
@@ -79,7 +79,7 @@ describe('typed keys: createThemeT (additive, opt-in)', () => {
79
79
  });
80
80
 
81
81
  it('drifts with the dictionary: the union is exactly the flattened keys', () => {
82
- // Compile-time proof (positive direction; the negative direction — typos
82
+ // Compile-time check (positive direction; the negative direction — typos
83
83
  // fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
84
84
  // so `npm run typecheck` stays green).
85
85
  const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
@@ -43,7 +43,7 @@ const FR_THEME = {
43
43
  'cart.empty.browse': 'Voir les produits',
44
44
  } as unknown as ThemeStringsDictionary;
45
45
 
46
- describe('t() fallback order (decision 3)', () => {
46
+ describe('t() fallback order', () => {
47
47
  it('returns the first hit across override, theme, core, default', async () => {
48
48
  const bound = await createThemeStrings({
49
49
  locale: 'fr',
@@ -223,7 +223,7 @@ describe('RTL dir (locale catalogue)', () => {
223
223
  });
224
224
  });
225
225
 
226
- describe('default English dictionary budget (backend varchar(64))', () => {
226
+ describe('default English dictionary budget (64-character stored key)', () => {
227
227
  function flatten(node: unknown, prefix: string, out: Array<[string, string]>): void {
228
228
  if (typeof node === 'string') {
229
229
  out.push([prefix, node]);
@@ -240,12 +240,9 @@ describe('default English dictionary budget (backend varchar(64))', () => {
240
240
  flatten(enDefault, '', pairs);
241
241
 
242
242
  it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
243
- // Dictionary budget: the checkout/auth/blog shopper-chrome slice added 31
244
- // keys and removed 1 orphaned pre-existing key (auth.code.sent, 0 usages,
245
- // superseded by auth.code.lead): 76 -> 106, commit 5cb67dd + orphan removal.
246
- // The ceiling stays tight (+9 headroom): any further growth must bump it
247
- // here deliberately with its own justification, which keeps guarding
248
- // against uncontrolled growth (backend varchar(64) per-key budget below).
243
+ // Dictionary budget: the ceiling stays tight (+9 headroom): any further
244
+ // growth must bump it here deliberately with its own justification, which
245
+ // guards against uncontrolled growth (64-character stored-key budget below).
249
246
  expect(pairs.length).toBeGreaterThanOrEqual(70);
250
247
  expect(pairs.length).toBeLessThanOrEqual(115);
251
248
  });
@@ -257,7 +254,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
257
254
  expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
258
255
  expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
259
256
  }
260
- // `<theme-slug>.<key>` must fit varchar(64): 40 + 1 + 23.
257
+ // `<theme-slug>.<key>` must fit the 64-character stored key: 40 + 1 + 23.
261
258
  expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
262
259
  });
263
260
 
@@ -272,7 +269,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
272
269
  });
273
270
  });
274
271
 
275
- describe('per-locale loading gate (decision 5)', () => {
272
+ describe('per-locale loading gate', () => {
276
273
  function sourceFiles(dir: string, out: string[] = []): string[] {
277
274
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
278
275
  if (entry.name === 'node_modules') continue;
@@ -335,7 +332,7 @@ describe('per-locale loading gate (decision 5)', () => {
335
332
  });
336
333
  });
337
334
 
338
- describe('server surface: bound t with NO provider (decision 4a)', () => {
335
+ describe('server surface: bound t with NO provider', () => {
339
336
  function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
340
337
  return (
341
338
  <main>
@@ -34,7 +34,7 @@ function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
34
34
 
35
35
  function render(children: ReactNode, opts: { prefetch?: ShopPrefetch | null; preview?: Product[] } = {}): string {
36
36
  const store = {
37
- vendor: { id: 'v1', slug: 'kili-foods', name: 'Kili Foods' },
37
+ vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store' },
38
38
  config: {},
39
39
  menus: [],
40
40
  previewData: opts.preview ? { products: opts.preview } : undefined,
package/theme-mount.tsx CHANGED
@@ -7,18 +7,13 @@ import { ThemeProvider } from './theme-context';
7
7
  /**
8
8
  * Renders a theme. THE MINIMUM A THEME NEEDS TO BE SEEN.
9
9
  *
10
- * Without this, installing `@usequeek/theme-kit` gave a developer every hook,
11
- * store, type and shared block — and no way to actually mount what they built.
12
- * The component that does it, `VendorShell`, lives in the storefront app, so it
13
- * was never part of what shipped. A theme could be written and typechecked from
14
- * outside this repo but never rendered.
10
+ * It mounts a theme's header, page and footer inside the kit's provider, so a
11
+ * theme written and typechecked outside a Queek storefront can still be rendered.
15
12
  *
16
- * DELIBERATELY SMALLER THAN VendorShell. That composes the same three pieces and
17
- * then layers on the Queek storefront's own chrome — checkout header, cart and
18
- * auth modals, toasts, WhatsApp chat, analytics, the merchant edit-mode bridge.
19
- * None of that is a theme's concern, and pulling it in would drag the whole app
20
- * into the package. What is left is the actual contract: a Layout wrapping a
21
- * header, the page, and a footer.
13
+ * DELIBERATELY SMALL. It leaves out the Queek storefront's own chrome — checkout
14
+ * header, cart and auth modals, toasts, WhatsApp chat, analytics, the merchant
15
+ * edit-mode bridge. None of that is a theme's concern. What is left is the
16
+ * actual contract: a Layout wrapping a header, the page, and a footer.
22
17
  *
23
18
  * Themes render inside a boundary because a theme's header and footer are
24
19
  * SIBLINGS of the page, so a crash in either escapes any boundary the page sets
package/types/block.ts CHANGED
@@ -42,7 +42,7 @@ export interface GalleryBlockData {
42
42
  heading?: string | null;
43
43
  description?: string | null;
44
44
  /** Block-level (not per-slide) hero display controls — currently only
45
- * glow's 'slider'/'thumb-rail' variants read these; harmless no-op
45
+ * some themes' 'slider'/'thumb-rail' variants read these; harmless no-op
46
46
  * elsewhere. */
47
47
  zoom_effect?: boolean | null;
48
48
  overlay?: 'none' | 'light' | 'medium' | 'strong' | null;
@@ -62,7 +62,7 @@ export interface GalleryBlockData {
62
62
  /** YouTube/Vimeo/direct file URL — takes over from `url` as the slide's
63
63
  * media when set, with `url` still used as the poster/fallback. Only
64
64
  * takes effect on themes/variants that render a single full-bleed slide
65
- * (e.g. glow's `slider`/`thumb-rail` with exactly 1 image) — a
65
+ * (e.g. a `slider`/`thumb-rail` variant with exactly 1 image) — a
66
66
  * multi-slide carousel would autoplay every slide's video concurrently
67
67
  * since inactive slides stay mounted, not unmounted. */
68
68
  video?: string | null;
@@ -258,7 +258,7 @@ export interface MetaobjectsBlockData {
258
258
  * Server-seeded resolution for a `metaobjects` block — the entries of
259
259
  * `data.type` plus the definition meta needed to decide whether each card
260
260
  * links out (`definition.has_pages`). Mirrors `MetaobjectsResolved` shape
261
- * used by `hydrateProductBlocks` in the storefront.
261
+ * used by `hydrateProductBlocks` in the host app.
262
262
  */
263
263
  export interface MetaobjectsResolved {
264
264
  entries: MetaobjectEntry[];
@@ -293,17 +293,17 @@ export type Block<T extends BlockType = BlockType> = {
293
293
  /**
294
294
  * Section-scoped design-token overrides — only the keys set here are
295
295
  * applied; everything else inherits the global tokens via the CSS
296
- * cascade. See `expandDesignTokensPartial` in `lib/core/utils/brand.ts`.
296
+ * cascade. See `expandDesignTokensPartial` in `utils/brand.ts`.
297
297
  */
298
298
  tokens?: Partial<DesignTokens>;
299
299
  /**
300
300
  * Stable in-page-scroll target — a menu item's `meta.anchor` links here.
301
- * Lowercase-kebab, enforced by the backend (ValidStorefrontBlocks).
301
+ * Lowercase-kebab, enforced by the API.
302
302
  */
303
303
  anchor_id?: string;
304
304
  /**
305
305
  * Server-seeded product data for `products` blocks — set by
306
- * `hydrateProductBlocks` (lib/core/api/products.ts) before blocks reach
306
+ * the host's `hydrateProductBlocks` before blocks reach
307
307
  * `PageRenderer`, which awaits it via `use()` inside a per-block
308
308
  * `<Suspense>` boundary. Lives on the block, not inside `data`, so it never
309
309
  * gets spread into theme components as a prop. Absent for every other
@@ -312,7 +312,7 @@ export type Block<T extends BlockType = BlockType> = {
312
312
  productsPromise?: Promise<Product[]>;
313
313
  /**
314
314
  * Server-seeded entries + definition meta for `metaobjects` blocks — set
315
- * by `hydrateProductBlocks` alongside `productsPromise`. Same contract:
315
+ * by the host's `hydrateProductBlocks` alongside `productsPromise`. Same rule:
316
316
  * absent for every other block type and for any route that hasn't been
317
317
  * wired to hydrate; the renderer falls back to rendering nothing (themes
318
318
  * never fetch metaobjects client-side).
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
  /**