@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
package/README.md CHANGED
@@ -1,30 +1,35 @@
1
1
  # @usequeek/theme-kit
2
2
 
3
3
  The framework a Queek storefront theme is built against: hooks, stores, headless
4
- flows (auth, cart, checkout), types and the block renderer.
5
-
6
- **Ships TypeScript source, not a build.** Most of its modules carry a
7
- `'use client'` directive, and that directive is the whole contract with the React
8
- Server Components boundary — bundlers routinely strip or hoist it when a library
9
- is pre-compiled, which turns a client module into a server one silently. The
10
- consumer transpiles instead (`transpilePackages: ['@usequeek/theme-kit']`), so the
11
- directives survive to the bundler that actually enforces them.
12
-
13
- Deep subpaths resolve off the filesystem — there is deliberately NO `exports`
14
- map. One was tried: with source-shipped `.ts`/`.tsx` an extensionless wildcard
15
- target does not resolve, and spelling out extensions cannot cover both. Legacy
16
- resolution under `moduleResolution: bundler` handles it. The cost is that the
17
- package has no subpath encapsulation, so what a theme may import is enforced by
18
- convention plus tests: `tests/public-surface.test.ts` here (barrels declare
19
- only modules that exist) and `core-public-surface.test.ts` in the storefront
20
- repo (barrels cover every module the themes actually import).
21
-
22
- Every module that annotates a return as `JSX.Element` does
23
- `import type { JSX } from 'react'` — React 19 removed the global `JSX`
24
- namespace, and 0.1.0 shipped broken for every external consumer by relying on
25
- the storefront app's `next-env.d.ts` to provide it. `scripts/verify-package.ts`
26
- packs the current source and typechecks a real theme against it from a sandbox,
27
- so that class of breakage fails here instead of after publishing.
4
+ flows (auth, cart, checkout), types and the block renderer. See the
5
+ [theme documentation](https://docs.usequeek.com/docs/themes) for how themes
6
+ work end to end.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @usequeek/theme-kit
12
+ ```
13
+
14
+ Peer dependencies: `next` (>=15), `react` and `react-dom` (>=19) and
15
+ `@queekai/client-sdk`.
16
+
17
+ The package ships TypeScript source, not a build. Most modules carry a
18
+ `'use client'` directive, and that directive is the whole contract with the
19
+ React Server Components boundary; bundlers routinely strip or hoist it when a
20
+ library is pre-compiled, which silently turns a client module into a server
21
+ one. The consumer transpiles the package instead, so the directives reach the
22
+ bundler that enforces them:
23
+
24
+ ```ts
25
+ // next.config.ts
26
+ export default { transpilePackages: ['@usequeek/theme-kit'] };
27
+ ```
28
+
29
+ Compiling a theme against it needs `moduleResolution: bundler` and
30
+ `jsx: react-jsx`. A vitest setup that renders kit components needs the package
31
+ inlined (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
32
+ source is transformed like first-party code.
28
33
 
29
34
  ## Use
30
35
 
@@ -33,10 +38,16 @@ import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
33
38
  import type { ThemeModule } from '@usequeek/theme-kit/types/theme';
34
39
  ```
35
40
 
36
- Deep paths, mirroring the old `@/lib/core/*`. Barrels exist (`.` and `./client`)
37
- to DECLARE the public surface — what a theme is allowed to build against — but
38
- importing through them pulls every client module into one chunk and was measured
39
- to break the app's build. Import the module you want.
41
+ Import the module you need by its deep path. The barrels (`.` and `./client`)
42
+ declare the public surface, meaning what a theme is allowed to build against,
43
+ but importing through them pulls every client module into one chunk and can
44
+ break an app's build.
45
+
46
+ There is no `exports` map: with source-shipped `.ts`/`.tsx`, an extensionless
47
+ wildcard target does not resolve, so subpaths resolve off the filesystem under
48
+ `moduleResolution: bundler`. The package therefore has no subpath
49
+ encapsulation; what a theme may import is the surface the barrels declare,
50
+ checked by `tests/public-surface.test.ts`.
40
51
 
41
52
  Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks.css`
42
53
  (framework-owned blocks), plus per-component CSS next to its component
@@ -44,9 +55,9 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
44
55
 
45
56
  ## Theme strings
46
57
 
47
- The kit's Shopify-shaped UI-string mechanism (`strings/theme-strings.ts`,
48
- pure: no `next/*`, no React). Additive and backward-compatible — a theme or
49
- provider that passes nothing renders exactly as today, in English.
58
+ The kit's UI-string mechanism (`strings/theme-strings.ts`; pure, with no
59
+ `next/*` and no React). A theme or provider that passes nothing renders in
60
+ English. See also [Translating your theme](https://docs.usequeek.com/docs/themes/translations).
50
61
 
51
62
  - **Authoring contract.** `t(dictionaries, key, vars?, locale?)`: dot-path
52
63
  lookup (`cart.title`), `{var}` interpolation (`{{`/`}}` escape to literal
@@ -56,9 +67,9 @@ provider that passes nothing renders exactly as today, in English.
56
67
  `vars.count`, defaulting to `other`. Locale-aware `formatThemeNumber`,
57
68
  `formatThemeDate` and `formatThemeMoney` replace hardcoded `en-GB`
58
69
  formatting; an unknown locale falls back to English. Keys are dotted
59
- lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**:
60
- the backend overlay stores `<theme-slug>.<key>` in a varchar(64), so the
61
- key budget leaves 23 chars for the slug plus the dot.
70
+ lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**.
71
+ Merchant overrides are stored as `<theme-slug>.<key>` in a 64-character
72
+ field, so the key budget leaves 23 chars for the slug plus the dot.
62
73
  - **Two surfaces.** Server (provider-free): the host supplies per-locale
63
74
  loaders and calls
64
75
  `createThemeStrings({ locale, loaders })` — `{ override, theme, core }`,
@@ -90,10 +101,9 @@ provider that passes nothing renders exactly as today, in English.
90
101
  provider — other `{lang}.json` files never enter the client bundle.
91
102
  Only `locales/en.default.json` may be statically imported (by
92
103
  `strings/theme-strings.ts` itself); every other locale arrives via the
93
- host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
94
- gates this: any other static locale import fails the suite.
104
+ host's dynamic `import()` per locale.
95
105
  - **Typed keys (optional, compile-time).** A theme opts in per call site —
96
- existing untyped `t(key)` callers keep compiling unchanged.
106
+ untyped `t(key)` callers keep compiling unchanged.
97
107
  `ThemeStringKey<typeof strings>` derives the key union from the theme's
98
108
  English dictionary object (nested objects flatten to dotted keys, a
99
109
  plural map counts as ONE key, flat literal dotted keys stay one key),
@@ -110,9 +120,6 @@ provider that passes nothing renders exactly as today, in English.
110
120
  // tt('cart.titl'); // type error — typo keys fail, valid keys pass
111
121
  ```
112
122
 
113
- (`tests/theme-typed-keys.fixture.ts` proves this under
114
- `npm run typecheck`: valid keys pass, typo/unknown/plural-form keys are
115
- `@ts-expect-error` errors.)
116
123
  - **Pseudo-locale QA (dev/test only, never shipped).**
117
124
  `pseudoLocalize(dictionary, options?)`
118
125
  (`strings/pseudo-locale.ts`, pure: no `next/*`, no React) returns a
@@ -123,9 +130,7 @@ provider that passes nothing renders exactly as today, in English.
123
130
  pseudo dictionary as the `strings` prop: everything that went through
124
131
  `t()` renders pseudo, so any text that remains plain English next to
125
132
  pseudo text is a hard-coded (un-wrapped) string. In tests, render with
126
- the pseudo dictionary and run the English-leak scan over the HTML —
127
- the reusable primitive theme tests import (the storefront and
128
- theme-tools import it from the same pure module):
133
+ the pseudo dictionary and run the English-leak scan over the HTML:
129
134
 
130
135
  ```tsx
131
136
  import { pseudoLocalize, assertNoEnglishLeak } from '@usequeek/theme-kit/strings/pseudo-locale';
@@ -147,53 +152,39 @@ provider that passes nothing renders exactly as today, in English.
147
152
 
148
153
  ## Locale-aware dates, numbers and relative time
149
154
 
150
- Dates, counts, money and relative time follow the SHOPPER's locale (founder
151
- 2/10/26). English renders in the British/Nigerian style — `2 October 2026`,
152
- never US `October 2, 2026` — so English stores look exactly as before.
155
+ Dates, counts, money and relative time follow the SHOPPER's locale. English
156
+ renders in the British/Nigerian style — `2 October 2026`, never US
157
+ `October 2, 2026`.
153
158
 
154
159
  - **One mapping.** `resolveIntlLocale(locale)` (`utils/locale.ts`, pure: no
155
160
  `next/*`, no React) is the single canonical storefront-code → Intl-locale
156
161
  mapping every formatter goes through. Missing/invalid codes and every
157
162
  English variant (`en`, `en-GB`, `en-NG`, `en-US`, …) become `en-NG`;
158
163
  everything else passes through (`fr`, `ar`, `pt-BR`, `zh-CN`, `yo`, `ha`,
159
- `sw`, …). `en-NG` — not `en-GB` — because only it reproduces current
160
- output byte for byte: both print `2 October 2026`, but `en-GB` renders NGN
161
- as `NGN 12,500` where the stores show `₦12,500` (verified on Node 22, full
162
- ICU). `resolveSupportedLocale` falls back to English when the runtime's
163
- ICU lacks a locale — formatting never throws.
164
+ `sw`, …). `en-NG` — not `en-GB` — because it renders NGN as `₦12,500`
165
+ where `en-GB` renders `NGN 12,500` (both print dates as `2 October 2026`;
166
+ checked on Node 22 with full ICU). `resolveSupportedLocale` falls back to
167
+ English when the runtime's ICU lacks a locale — formatting never throws.
164
168
  - **What themes call.** `formatThemeDate(value, locale?, options?)`
165
169
  (`strings/theme-strings.ts`) instead of a hardcoded-`'en-GB'`
166
170
  `toLocaleDateString`: pass the storefront locale — client components via
167
171
  `useStorefrontLocale()`, server components via the request locale the host
168
172
  already holds. Siblings: `formatThemeNumber`, `formatThemeMoney`, and
169
173
  `formatThemeRelativeTime(-1, 'day', locale)` (`hier`, `أمس`, `ontem`, …,
170
- `numeric: 'auto'`). `formatThemeMoney` keeps the K0 currency-default
174
+ `numeric: 'auto'`). `formatThemeMoney` uses the currency's default
171
175
  decimals (`₦5,000.00`); `formatMoney` shows decimals only when present
172
176
  (`₦5,000`, `₦0.28`). The fixed-zone display path is `formatShopperDate`
173
- (`utils/format.ts`, Africa/Lagos, `''` for invalid dates as before);
174
- `formatCount`/`formatMoney` take an optional locale, defaulting to the
175
- pinned English output. `PostMeta` (`components/blog/post-meta.tsx`) already
176
- renders its byline through the provider locale.
177
+ (`utils/format.ts`, Africa/Lagos, `''` for invalid dates);
178
+ `formatCount`/`formatMoney` take an optional locale, defaulting to English
179
+ output. `PostMeta` (`components/blog/post-meta.tsx`) renders its byline
180
+ through the provider locale.
177
181
  - **Fallbacks.** Unknown locale → English style; invalid date → `''`;
178
182
  minimal-ICU runtimes render English rather than throwing.
179
183
  - **Plural selection stays separate.** `canonicalThemeLocale`
180
184
  (`strings/theme-strings.ts`) is for `t()` plural selection ONLY: it returns
181
185
  a bare `'en'` tag, which is a US-order Intl tag. Never pass it to a date,
182
186
  number, money or relative-time formatter — formatters use
183
- `resolveIntlLocale` (en* → `en-NG`) instead. A test fails the suite if any
184
- formatter body ever calls it.
185
- - **Shopify comparison.** Shopify's Liquid
186
- [`date` filter](https://shopify.dev/docs/api/liquid/filters/date) formats
187
- with explicit strftime strings, or locale-aware via named `format:` options
188
- (`abbreviated_date`, `basic`, `date`, `date_at_time`, `default`, `on_date`)
189
- whose patterns live in the theme's locale files (`date_formats`); the
190
- [`money` filter](https://shopify.dev/docs/api/liquid/filters/money) renders
191
- per the store's currency-formatting setting. This slice differs
192
- intentionally: one code-level mapping (`resolveIntlLocale`) instead of
193
- per-locale format files, every English variant pinned to `en-NG`
194
- (British/Nigerian order, never US), and an English fallback when the
195
- runtime's ICU lacks the locale. Number/relative-time have no Liquid
196
- equivalent consulted — UNVERIFIED remainder, no parity claim made.
187
+ `resolveIntlLocale` (en* → `en-NG`) instead.
197
188
 
198
189
  ```tsx
199
190
  import { useStorefrontLocale } from '@usequeek/theme-kit/provider';
@@ -204,23 +195,14 @@ formatThemeDate(post.published_at, locale, { year: 'numeric', month: 'long', day
204
195
  // en → "2 October 2026" · fr → "2 octobre 2026" · ar → "2 أكتوبر 2026"
205
196
  ```
206
197
 
207
- `tests/display-format.test.ts` pins this: every existing English string is
208
- byte-identical with or without a locale, `fr`/`ar`/`pt-BR`/`zh-CN`/`yo`/
209
- `ha`/`sw` outputs are asserted against real `Intl` output (with the
210
- English fallback when ICU lacks a locale), and a scan fails the suite if a
211
- hardcoded English locale ever reaches a formatter again.
212
-
213
198
  ## Consumers
214
199
 
215
- - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
216
- from the npm registry (`"@usequeek/theme-kit": "^x.y.z"`) and transpiles it
217
- via `transpilePackages`. Its vitest config inlines the package
218
- (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
219
- source is transformed like first-party code.
220
- - **External theme developers** start from `packages/theme-starter` in the
221
- storefront repo, which builds standalone against a packed tarball of this
222
- source. Anyone testing a theme with vitest needs the same `inline` line;
223
- anyone compiling one needs `moduleResolution: bundler` + `jsx: react-jsx`.
200
+ - **Queek storefronts** install this package from npm
201
+ (`"@usequeek/theme-kit": "^x.y.z"`) and transpile it via
202
+ `transpilePackages`.
203
+ - **Theme developers** start from
204
+ [`usequeek/theme-starter`](https://github.com/usequeek/theme-starter), which
205
+ builds against this package.
224
206
 
225
207
  ## Custom data: metafields & metaobjects
226
208
 
@@ -275,7 +257,7 @@ export default function ProductPage({ product }: ProductPageProps) {
275
257
  ```
276
258
 
277
259
  - MUST: place `<AppBlocks target="product" productId={...} />` on the
278
- product page. `target` is product-only in phase 1 — no other slot exists.
260
+ product page. `target` is product-only: no other slot exists.
279
261
  `productId` scopes availability reads and deep links to the shown product;
280
262
  omit `vendorSlug` (the host resolves the store itself).
281
263
  - The host registers its placement component once through `AppBlocksProvider`
@@ -292,8 +274,7 @@ export default function ProductPage({ product }: ProductPageProps) {
292
274
 
293
275
  ## Attribution: Powered by Queek
294
276
 
295
- Every storefront is a billboard — every footer variant of every theme renders
296
- the core-owned attribution link:
277
+ Every theme footer renders the core-owned attribution link:
297
278
 
298
279
  ```tsx
299
280
  import { PoweredByQueek } from '@usequeek/theme-kit/components/powered-by-queek';
@@ -311,7 +292,8 @@ export default function Footer() {
311
292
  - MUST: place `<PoweredByQueek />` on every footer variant (`footers/*.tsx`,
312
293
  or `footer.tsx` where a theme has no `footers/` dir). The placement and
313
294
  spacing are the theme's — the component is the content, the footer owns
314
- where it sits. `theme-check` rule `theme/footer-shows-powered-by` enforces
295
+ where it sits. The theme check rule `theme/footer-shows-powered-by`
296
+ ([checks reference](https://docs.usequeek.com/docs/themes/checks)) enforces
315
297
  this, so a theme cannot ship without it.
316
298
  - Style only: the link emits `core-powered-by` with a
317
299
  `core-powered-by__brand` hook on the `Queek` wordmark; an optional
@@ -324,9 +306,8 @@ export default function Footer() {
324
306
  (`utm_source=powered_by&utm_medium=storefront&utm_campaign=<store slug>`,
325
307
  campaign omitted when the store has no slug); the component reads the slug
326
308
  from the storefront context, so themes pass nothing.
327
- - `hidden` (default false) takes the component's visibility from a prop as
328
- the seam for a possible future plan perk. There is no merchant-facing
329
- toggle for it in v1 — always render it visible.
309
+ - `hidden` (default false) takes the component's visibility from a prop.
310
+ There is no merchant-facing toggle for it — always render it visible.
330
311
 
331
312
  ## Locales
332
313
 
@@ -337,7 +318,7 @@ request locale on every catalogue/content transport, server and browser:
337
318
  - Server: `createServerStoreClient().get()` reads the incoming request's
338
319
  `x-queek-locale` header (set by the storefront proxy for published
339
320
  non-primary locales only — absent on the primary), appends `locale=<code>`
340
- to the backend URL, and suffixes every cache tag with `:locale:<code>` so
321
+ to the API URL, and suffixes every cache tag with `:locale:<code>` so
341
322
  locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
342
323
  `fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
343
324
  an optional explicit override. No header → byte-identical requests and tags.
@@ -348,11 +329,202 @@ request locale on every catalogue/content transport, server and browser:
348
329
  `useProductQuestions` and `useProductDetail` send `?locale=` when it is set
349
330
  and key their client caches by it. No prop → unchanged.
350
331
 
351
- What this means for themes: nothing to handle. Never read the header, never
352
- append `locale` yourself, and never build a language switcher — locale
353
- routing, `basePath` prefixing and the switcher are core-owned in the
354
- storefront. Links built from `basePath` keep their locale prefix with zero
355
- theme changes.
332
+ What this means for themes: nothing to handle for the content itself. Never
333
+ read the header and never append `locale` yourself — locale routing and
334
+ `basePath` prefixing are core-owned in the storefront, and links built from
335
+ `basePath` keep their locale prefix with zero theme changes. To let shoppers
336
+ change language, place the kit's `LanguageSwitcher` (next section); do not
337
+ build your own URL logic.
338
+
339
+ ## Language switcher
340
+
341
+ A store can serve several languages. Its default language lives at the store
342
+ root; every other language, English included when it is not the default, lives
343
+ under its bare lower-case code (`/fr`, `/en`, `/zh-cn`). Changing language
344
+ keeps the page, the query string and the hash.
345
+
346
+ ```tsx
347
+ import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
348
+
349
+ <LanguageSwitcher variant="menu" /> // button + popover (sheet on phones) — recommended
350
+ <LanguageSwitcher variant="menu" side="top" /> // a footer: opens upward
351
+ <LanguageSwitcher variant="inline-list" /> // every language as a link (footer, up to four)
352
+ <LanguageSwitcher /> // native <select> — legacy
353
+ ```
354
+
355
+ Place it unconditionally, in the header or the footer: it renders nothing on a
356
+ single-language store, outside a storefront, and until the host passes the
357
+ store's languages.
358
+
359
+ | Prop | Default | |
360
+ | --- | --- | --- |
361
+ | `className` | | Extra class on the root, to position or restyle it. |
362
+ | `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works and is unchanged, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
363
+ | `side` | `'bottom'` | `menu` only. `'top'` opens above the trigger, for footers. Flips by itself when the preferred side does not fit. |
364
+ | `align` | `'end'` | `menu` only. Line the popover up with the trigger's inline `'end'` or `'start'` edge (mirrors in right-to-left). Flips and shifts to stay inside the viewport. |
365
+ | `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
366
+ | `showCode` | `false` | Show the language code (`FR`) next to the name. |
367
+ | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)`, `displayName(locale)`, `open` and `setOpen`. |
368
+
369
+ ### The `menu` variant
370
+
371
+ A real `<button>` opens a list of language **links** (`<a href>` built from the
372
+ current page, so open-in-new-tab and copy-link work). On screens wider than
373
+ 640px it is a popover under the trigger; below 640px it becomes a bottom sheet
374
+ with a scrim, safe-area padding, and page scroll locked while it is open. The
375
+ wide/narrow layout switch is CSS only (one media query), so the markup is the
376
+ same on both and server and client agree; JavaScript only consults the media
377
+ query while the menu opens, to lock scroll and to place the popover.
378
+ With more than four languages in a footer, use `menu` with `side="top"` rather
379
+ than `inline-list`. 2 to 20 languages are fine: the list scrolls past about
380
+ eight rows, and a long list can use two columns on wide screens (below).
381
+
382
+ It is the *disclosure* pattern, not a listbox, because choosing an item
383
+ navigates: the button has `aria-expanded` and `aria-controls` and the name
384
+ "Language: English"; the list is a `<ul>` labelled by its title; the current
385
+ language's link has `aria-current="true"`; there are no `option` roles.
386
+
387
+ | Key | |
388
+ | --- | --- |
389
+ | Enter, Space, ArrowDown on the button | Open, focus on the current language |
390
+ | ArrowUp on the button | Open, focus on the last language |
391
+ | ArrowDown / ArrowUp in the list | Next / previous link (wraps) |
392
+ | Home / End | First / last link |
393
+ | Typing letters | Jump to the language whose name or code starts with them (500 ms buffer) |
394
+ | Enter on a link | Follow it (native); Space does the same |
395
+ | Escape | Close and return focus to the button |
396
+ | Tab | Close the menu |
397
+ | Click outside, scrim tap | Close |
398
+
399
+ Only the focused link is in the tab order (roving tabindex), so Tab leaves the
400
+ list and closes it. Choosing a language closes the menu, returns focus to the
401
+ button and navigates (a modified click — new tab, copy link — is left to the
402
+ browser). Motion respects `prefers-reduced-motion` (an 80 ms fade only).
403
+
404
+ **Styling surface.** Every part has a class and a `data-part` attribute; the
405
+ root also carries state attributes:
406
+
407
+ | Part | Class | `data-part` |
408
+ | --- | --- | --- |
409
+ | Root | `qn-lang qn-lang--menu` | `data-qn-language-switcher`, `data-variant="menu"` |
410
+ | Trigger button | `qn-lang__trigger` | `trigger` |
411
+ | Trigger name / code / chevron | `qn-lang__trigger-label` / `__trigger-code` / `__chevron` | `trigger-label` / `trigger-code` / `chevron` |
412
+ | Scrim (sheet) | `qn-lang__scrim` | `scrim` |
413
+ | Popover / sheet | `qn-lang__popover` | `popover` |
414
+ | Sheet grabber | `qn-lang__handle` | `handle` |
415
+ | Group label | `qn-lang__title` | `title` |
416
+ | List / item | `qn-lang__options` / `__item` | `list` / `item` |
417
+ | Link (current: `aria-current="true"`, `data-current="true"`) | `qn-lang__option` | `option` |
418
+ | Name / code / current marker | `qn-lang__name` / `__code` / `__mark` | `name` / `code` / `mark` |
419
+
420
+ Root attributes: `data-state="open|closed"`, `data-side="bottom|top"`,
421
+ `data-align="start|end"`, `data-mode="popover|sheet"` (while open),
422
+ `data-count="<n>"`, `data-many="true"` (more than ten languages),
423
+ `data-show-code="true"`. The code spans are always rendered and hidden by
424
+ default, so a theme can show a code-only trigger with
425
+ `.my-header [data-part='trigger-label'] { display: none }` plus
426
+ `.my-header [data-part='trigger-code'] { display: inline }`.
427
+
428
+ | Variable | Default | Controls |
429
+ | --- | --- | --- |
430
+ | `--qn-lang-surface` | `--qn-surface` | popover / sheet background |
431
+ | `--qn-lang-ink` | `--qn-text` | text colour |
432
+ | `--qn-lang-muted` | `--qn-text-muted` | title and code colour |
433
+ | `--qn-lang-border` | `--qn-border` | hairlines |
434
+ | `--qn-lang-radius` | `--qn-radius-md` (8px) | trigger / popover corners |
435
+ | `--qn-lang-item-radius` | `--qn-radius-sm` (6px) | link corners |
436
+ | `--qn-lang-item-hover` / `--qn-lang-current-bg` | 6% ink / none | hover and current-link backgrounds |
437
+ | `--qn-lang-accent` | `--qn-accent` | current marker colour |
438
+ | `--qn-lang-focus` | `--qn-focus` | focus ring |
439
+ | `--qn-lang-shadow` | subtle | popover shadow |
440
+ | `--qn-lang-row-height` / `--qn-lang-sheet-row-height` | 44px / 52px | link height (popover / sheet) |
441
+ | `--qn-lang-gap` / `--qn-lang-pad` | 0 / 4px | space between links / inside the popover |
442
+ | `--qn-lang-offset` | 8px | gap between trigger and popover |
443
+ | `--qn-lang-popover-width` | 240px | popover width |
444
+ | `--qn-lang-many-width` / `--qn-lang-many-columns` | popover width / 1 | width and columns when `data-many="true"` (set `--qn-lang-many-columns: 2` and a wider width for two columns) |
445
+ | `--qn-lang-list-max` | 8.5 rows | height at which the list scrolls |
446
+ | `--qn-lang-sheet-radius` | 16px | sheet top corners |
447
+ | `--qn-lang-scrim` | 40% black | scrim colour |
448
+ | `--qn-lang-z` | 50 | z-index |
449
+ | `--qn-lang-duration` / `--qn-lang-ease` | 160ms / ease-out curve | open motion |
450
+
451
+ Each variable falls back to the matching `--qn-*` token, then a neutral
452
+ value, and is never set by the kit itself — set it on the root, on an
453
+ ancestor, or on `:root`. The sheet breakpoint (640px) is fixed.
454
+
455
+ Restyling recipe — no override of internals needed:
456
+
457
+ ```css
458
+ .site-header [data-qn-language-switcher] {
459
+ --qn-lang-surface: #f3f0e9;
460
+ --qn-lang-ink: #1b2029;
461
+ --qn-lang-radius: 0;
462
+ --qn-lang-item-radius: 0;
463
+ --qn-lang-popover-width: 280px;
464
+ --qn-lang-sheet-radius: 0;
465
+ --qn-lang-duration: 120ms;
466
+ }
467
+ .site-header [data-part='trigger'] { border: 0; letter-spacing: 0.12em; text-transform: uppercase; }
468
+ .site-header [data-part='option'][aria-current='true'] { font-style: italic; }
469
+ ```
470
+
471
+ **Tell the storefront the theme places it.** Set `languageSwitcher: 'theme'` in
472
+ the theme manifest. Without it the storefront keeps rendering its own floating
473
+ language control next to yours; with it the storefront renders none and the
474
+ shopper sees exactly one.
475
+
476
+ ```ts
477
+ // in the theme module
478
+ manifest: { /* … */ languageSwitcher: 'theme' } // ThemeManifest.languageSwitcher
479
+ ```
480
+
481
+ **Fully custom markup.** `useLocales()` (from
482
+ `@usequeek/theme-kit/hooks/use-locales`) returns:
483
+
484
+ | Field | |
485
+ | --- | --- |
486
+ | `locales` | The store's languages, default first. One entry, or none, on a single-language store. |
487
+ | `active` | The language the page renders in. |
488
+ | `defaultLocale` | The language served at the store root. |
489
+ | `hasMultiple` | `locales.length > 1`. |
490
+ | `hrefFor(locale)` | The current page in another language (takes a row or a code), e.g. `/fr/shop?sort=new`. The first render carries no query string or hash, so server and client markup match; they are added right after mount. |
491
+ | `switchTo(locale)` | Navigate to it, reading the live query string and hash. Use it for `onClick`/`onChange`. |
492
+
493
+ Each language is `{ locale, name, native_name, is_primary, is_default,
494
+ path_prefix, hreflang, html_lang, dir }`. Use `lang={l.html_lang}` and
495
+ `dir={l.dir}` on a label written in that language. The pure URL rules are also
496
+ exported (`localeHref`, `normalizeStoreLocales`, `defaultStoreLocale`,
497
+ `resolveActiveLocale` from `@usequeek/theme-kit/utils/locale-switch`).
498
+
499
+ **Strings.** `language.label` ("Language") names the control and
500
+ `language.named` ("Language: {name}") names the select and the current
501
+ language. Both are kit core strings with English defaults; a theme can
502
+ override them in its manifest `strings`, and merchants and translators in
503
+ their locale packs.
504
+
505
+ **Styling.** The default control reads only these tokens, each with a neutral
506
+ fallback, and uses logical properties, so it mirrors in right-to-left
507
+ storefronts and sits in a header or footer unstyled:
508
+
509
+ | Token | Used for | Fallback |
510
+ | --- | --- | --- |
511
+ | `--qn-text` | text and icon colour | inherited |
512
+ | `--qn-border` | select border | 22% of the text colour |
513
+ | `--qn-border-strong` | select border on hover | the text colour |
514
+ | `--qn-surface` | select background | transparent |
515
+ | `--qn-radius-sm` | corner radius | `6px` |
516
+ | `--qn-focus` | focus ring | the text colour |
517
+
518
+ Class names for deeper overrides: `qn-lang`, `qn-lang--select`,
519
+ `qn-lang--inline-list`, `qn-lang__select`, `qn-lang__list`, `qn-lang__link`.
520
+ Targets are at least 44px. The root carries `data-qn-language-switcher`.
521
+
522
+ **Host side.** The host passes the store's languages to
523
+ `StorefrontProvider` as the optional `locales` prop (the rows as it already
524
+ holds them; extra fields are ignored, and a row without `is_default` falls
525
+ back to `is_primary`). `basePath` must already carry the active language
526
+ prefix, as in the Locales section. No prop, or one language: no switcher, and
527
+ everything behaves as before.
356
528
 
357
529
  ## Links and navigation
358
530
 
@@ -361,42 +533,46 @@ import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
361
533
  ```
362
534
 
363
535
  A theme links and navigates through these, never through `next/link` or
364
- `next/navigation`. Which framework runs the storefront is Queek's decision: the
365
- kit is the one place that knows it, so a change there is a kit release, not an
366
- update to every theme. `Link` is an `<a>` that moves inside the store without a
367
- full page load (`href`, `prefetch`, `replace`, plus any anchor attribute);
368
- `useRouter()` gives `push`, `replace`, `back` and `refresh`; `usePathname()`
369
- returns the current path. The theme check rejects a theme that imports `next`.
536
+ `next/navigation`. Which framework runs the storefront is Queek's decision, so
537
+ the kit is the one place that knows it, and a framework change is a kit
538
+ release, not an update to every theme. `Link` is an `<a>` that moves inside the
539
+ store without a full page load (`href`, `prefetch`, `replace`, plus any anchor
540
+ attribute); `useRouter()` gives `push`, `replace`, `back` and `refresh`;
541
+ `usePathname()` returns the current path. The theme check rejects a theme that
542
+ imports `next`.
370
543
 
371
544
  ## Boundary
372
545
 
373
- The kit must never import the storefront app, its themes, or `lib/storefront`.
374
- That one-way rule is what makes this package extractable at all. The reverse
375
- also holds for the framework: the kit may import `next`, a theme may not.
546
+ The kit never imports a host app or any theme. The reverse rule holds for the
547
+ framework: the kit may import `next`, a theme may not.
376
548
 
377
549
  ## Develop
378
550
 
379
551
  ```bash
380
552
  npm install
381
- npm run typecheck # tsc --noEmit, standalone — no storefront files
382
- npm test # vitest run
553
+ npm run typecheck # tsc --noEmit
554
+ npm test # public-text check, then vitest run
383
555
  npm run verify-package # pack current source, typecheck a real theme against it
384
556
  ```
385
557
 
558
+ `verify-package` packs the current source and typechecks a real theme against
559
+ it from a sandbox, so a break that only external consumers would see (for
560
+ example a module relying on the global `JSX` namespace, which React 19 removed)
561
+ fails here instead of after publishing.
562
+
386
563
  ## Release
387
564
 
388
- The package is public on npm; the repo is private. Publishing runs on tags via
389
- `.github/workflows/publish.yml` (needs the `NPM_TOKEN` secret):
565
+ Publishing runs on tags via `.github/workflows/publish.yml`, which needs the
566
+ `NPM_TOKEN` repository secret:
390
567
 
391
568
  ```bash
392
569
  # 1. Bump version in package.json and commit
393
- # 2. Tag and push the tag — the workflow typechecks, tests, verify-packages,
570
+ # 2. Tag and push the tag — the workflow typechecks, tests, runs verify-package,
394
571
  # then `npm publish --access public`:
395
- git tag v0.1.6
572
+ git tag vX.Y.Z
396
573
  git push origin main --tags
397
574
  ```
398
575
 
399
- The storefront then bumps its dependency to match the published version
400
- (`yarn add @usequeek/theme-kit@^0.1.6` + lockfile) and pushes separately —
401
- never the other way round: the app must only ever depend on a version that
402
- actually exists on the registry.
576
+ ## License
577
+
578
+ MIT
@@ -4,8 +4,8 @@ import type { Collection, Pagination } from '../types/page';
4
4
  import type { ApiResponse } from '../types/product';
5
5
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
6
6
 
7
- /** Matches `VendorCollectionProductsRequest`'s validated `sort` values exactly
8
- * (queek_backend) — a value outside this list gets a 422 from the backend. */
7
+ /** The `sort` values the collection-products endpoint accepts; any other value
8
+ * gets a 422 from the API. */
9
9
  export type CollectionSort = 'newest' | 'popular' | 'price_low' | 'price_high';
10
10
 
11
11
  export interface FetchCollectionProductsOptions {
@@ -17,9 +17,9 @@ export interface FetchCollectionProductsOptions {
17
17
  locale?: string;
18
18
  }
19
19
 
20
- // SSR collection reads go through `/client/store/*` so ResolveClientContext forces
21
- // X-Platform=storefront (base prices, no marketplace commission) — never the
22
- // legacy v1 routes shared with the commission-bearing marketplace.
20
+ // SSR collection reads go through `/client/store/*`, which resolves the store and
21
+ // forces X-Platform=storefront (base prices, no marketplace commission) — never
22
+ // the legacy v1 routes shared with the marketplace.
23
23
  export async function fetchCollections(vendorSlug: string, storefrontOnly = true, locale?: string): Promise<Collection[]> {
24
24
  const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
25
25
 
package/api/shop.ts CHANGED
@@ -6,7 +6,7 @@ import { parseShopResponse, shopRequestParams, type ShopPrefetch, type ShopQuery
6
6
  * The shop page's first page of products, fetched while the server renders
7
7
  * `/shop`, for `ShopPrefetchProvider` to hand to `useShop` — so the product grid
8
8
  * is in the HTML instead of appearing after the browser has run the JS and
9
- * fetched it (kili-foods' `/shop` main content landed at 2.8 s that way).
9
+ * fetched it.
10
10
  *
11
11
  * Same request and the same parsing as the browser's `useShop` (both come from
12
12
  * utils/shop-query). Reads go through `/client/store/*` like every SSR read, so
package/client.ts CHANGED
@@ -16,6 +16,7 @@ export * from './components/blog';
16
16
  export * from './components/image';
17
17
  export * from './components/product-metafields';
18
18
  export * from './components/powered-by-queek';
19
+ export * from './components/language-switcher';
19
20
  export * from './components/default-metaobject-page';
20
21
  export * from './components/product-media-frame';
21
22
  export * from './components/variant-picker';
@@ -30,6 +31,7 @@ export * from './apps/subscribe-form';
30
31
  export * from './api/collections';
31
32
  export * from './api/orders';
32
33
  export * from './hooks/use-href';
34
+ export * from './hooks/use-locales';
33
35
  export * from './hooks/use-auth';
34
36
  export * from './hooks/use-cart';
35
37
  export * from './hooks/use-shop-cart';
@@ -8,9 +8,9 @@ import { Image } from '../image';
8
8
 
9
9
  /**
10
10
  * First section of the checkout form — shows who's checking out. Ordering
11
- * requires auth (`canCheckout` in checkout-controller.tsx), so unlike
12
- * Shopify's guest-friendly email field this is purely an identity
13
- * confirmation: avatar + email when signed in, a sign-in prompt otherwise.
11
+ * requires auth (`canCheckout` in checkout-controller.tsx), so this is purely
12
+ * an identity confirmation, not an email field: avatar + email when signed in,
13
+ * a sign-in prompt otherwise.
14
14
  */
15
15
  export function CheckoutContactSection(): JSX.Element {
16
16
  const { user, isAuthenticated, openAuthModal, logout } = useAuth();