@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,185 @@
1
+ // Color parsing, conversion and contrast for the theme controls.
2
+ //
3
+ // Dependency rationale (design decision 3): no CSS/color parser is present in
4
+ // this package or the workspace, and the storefront CLI pins an exact candidate
5
+ // toolset for generated consumers. Adding a color dependency would churn that
6
+ // pinned candidate for a boundary the CLI already owns, so the small, fully
7
+ // specified OKLCH <-> sRGB math lives here instead. It is deterministic,
8
+ // network-free and has no runtime dependencies.
9
+ //
10
+ // Conversions follow the standard OKLab formulation (Björn Ottosson) with the
11
+ // sRGB transfer function, so a value round-trips and contrast ratios match what
12
+ // a browser computes.
13
+
14
+ const clamp01 = (value) => Math.min(1, Math.max(0, value))
15
+ const toDeg = (rad) => (rad * 180) / Math.PI
16
+ const toRad = (deg) => (deg * Math.PI) / 180
17
+
18
+ // sRGB transfer functions (gamma <-> linear).
19
+ function srgbToLinear(channel) {
20
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
21
+ }
22
+ function linearToSrgb(channel) {
23
+ return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055
24
+ }
25
+
26
+ // OKLCH -> linear sRGB (unclamped; out-of-gamut channels are reported as-is).
27
+ export function oklchToLinearSrgb({ l, c, h }) {
28
+ const hRad = toRad(h)
29
+ const a = c * Math.cos(hRad)
30
+ const b = c * Math.sin(hRad)
31
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b
32
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b
33
+ const s_ = l - 0.0894841775 * a - 1.291485548 * b
34
+ const lc = l_ ** 3
35
+ const mc = m_ ** 3
36
+ const sc = s_ ** 3
37
+ return {
38
+ r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
39
+ g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
40
+ b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,
41
+ }
42
+ }
43
+
44
+ export function linearSrgbToOklch({ r, g, b }) {
45
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
46
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
47
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
48
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
49
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
50
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
51
+ const c = Math.sqrt(A * A + B * B)
52
+ let h = toDeg(Math.atan2(B, A))
53
+ if (h < 0) h += 360
54
+ return { l: L, c, h }
55
+ }
56
+
57
+ export function oklchToSrgb({ l, c, h }) {
58
+ const lin = oklchToLinearSrgb({ l, c, h })
59
+ return { r: linearToSrgb(lin.r), g: linearToSrgb(lin.g), b: linearToSrgb(lin.b) }
60
+ }
61
+
62
+ export function srgbToOklch({ r, g, b }) {
63
+ return linearSrgbToOklch({ r: srgbToLinear(r), g: srgbToLinear(g), b: srgbToLinear(b) })
64
+ }
65
+
66
+ // Relative luminance (WCAG) from an OKLCH color, computed in linear sRGB.
67
+ export function relativeLuminance({ l, c, h }) {
68
+ const lin = oklchToLinearSrgb({ l, c, h })
69
+ return 0.2126 * clamp01(lin.r) + 0.7152 * clamp01(lin.g) + 0.0722 * clamp01(lin.b)
70
+ }
71
+
72
+ // WCAG contrast ratio between two OKLCH colors (>= 1).
73
+ export function contrastRatio(a, b) {
74
+ const la = relativeLuminance(a)
75
+ const lb = relativeLuminance(b)
76
+ const hi = Math.max(la, lb)
77
+ const lo = Math.min(la, lb)
78
+ return (hi + 0.05) / (lo + 0.05)
79
+ }
80
+
81
+ const WHITE = { l: 1, c: 0, h: 0 }
82
+ const BLACK = { l: 0, c: 0, h: 0 }
83
+
84
+ // Pick the readable foreground (black or white) for a background by measured
85
+ // contrast rather than an OKLCH lightness threshold. Returns the chosen color
86
+ // and the ratio it achieves.
87
+ export function bestForeground(background) {
88
+ const onWhite = contrastRatio(background, WHITE)
89
+ const onBlack = contrastRatio(background, BLACK)
90
+ return onWhite >= onBlack
91
+ ? { color: { ...WHITE }, ratio: onWhite }
92
+ : { color: { ...BLACK }, ratio: onBlack }
93
+ }
94
+
95
+ // A slightly darker (light mode) / lighter (dark mode) variant for hover states.
96
+ export function shiftLightness({ l, c, h }, delta) {
97
+ return { l: clamp01(l + delta), c, h }
98
+ }
99
+
100
+ function round(value, digits) {
101
+ const factor = 10 ** digits
102
+ return Math.round(value * factor) / factor
103
+ }
104
+
105
+ // Canonical CSS output. L keeps 4 significant decimals, C 3, H 2 (no trailing
106
+ // zeros beyond the rounding), matching the hand-authored token style.
107
+ export function formatOklch({ l, c, h }) {
108
+ const L = round(clamp01(l), 4)
109
+ const C = round(Math.max(0, c), 4)
110
+ let H = round(((h % 360) + 360) % 360, 2)
111
+ if (H === 360) H = 0
112
+ return `oklch(${L} ${C} ${H})`
113
+ }
114
+
115
+ // Parse `oklch(L C H [/ A])`. Accepts L as a fraction (0-1) or a percentage.
116
+ export function parseOklch(input) {
117
+ const match = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(
118
+ input.trim(),
119
+ )
120
+ if (!match) return null
121
+ const parseL = (token) => (token.endsWith('%') ? parseFloat(token) / 100 : parseFloat(token))
122
+ const l = parseL(match[1])
123
+ const c = match[2].endsWith('%') ? (parseFloat(match[2]) / 100) * 0.4 : parseFloat(match[2])
124
+ const h = parseFloat(match[3])
125
+ if ([l, c, h].some((n) => Number.isNaN(n))) return null
126
+ return { l, c, h }
127
+ }
128
+
129
+ export function parseHex(input) {
130
+ const match = /^#([0-9a-f]{3,8})$/i.exec(input.trim())
131
+ if (!match) return null
132
+ let hex = match[1]
133
+ if (hex.length === 3 || hex.length === 4) {
134
+ hex = hex
135
+ .split('')
136
+ .map((ch) => ch + ch)
137
+ .join('')
138
+ }
139
+ if (hex.length !== 6 && hex.length !== 8) return null
140
+ const r = parseInt(hex.slice(0, 2), 16) / 255
141
+ const g = parseInt(hex.slice(2, 4), 16) / 255
142
+ const b = parseInt(hex.slice(4, 6), 16) / 255
143
+ return srgbToOklch({ r, g, b })
144
+ }
145
+
146
+ // Named brand colors resolve to a vivid, mid-lightness OKLCH value so a request
147
+ // for "orange" yields a genuinely chromatic primary even when the preset token
148
+ // was achromatic (chroma 0). Hues mirror the runtime ACCENT_OKLCH palette.
149
+ export const NAMED_COLORS = {
150
+ red: { l: 0.58, c: 0.22, h: 27 },
151
+ orange: { l: 0.62, c: 0.19, h: 55 },
152
+ amber: { l: 0.7, c: 0.17, h: 75 },
153
+ yellow: { l: 0.8, c: 0.16, h: 95 },
154
+ lime: { l: 0.72, c: 0.19, h: 130 },
155
+ green: { l: 0.6, c: 0.17, h: 155 },
156
+ teal: { l: 0.6, c: 0.13, h: 185 },
157
+ cyan: { l: 0.62, c: 0.13, h: 210 },
158
+ blue: { l: 0.58, c: 0.2, h: 232 },
159
+ indigo: { l: 0.55, c: 0.22, h: 264 },
160
+ violet: { l: 0.55, c: 0.22, h: 290 },
161
+ purple: { l: 0.55, c: 0.22, h: 310 },
162
+ pink: { l: 0.62, c: 0.2, h: 350 },
163
+ slate: { l: 0.45, c: 0.04, h: 240 },
164
+ gray: { l: 0.55, c: 0.02, h: 240 },
165
+ neutral: { l: 0.55, c: 0.0, h: 0 },
166
+ stone: { l: 0.5, c: 0.02, h: 60 },
167
+ zinc: { l: 0.5, c: 0.01, h: 240 },
168
+ }
169
+
170
+ // Parse any accepted color input into an OKLCH object, or null when unsupported.
171
+ export function parseColor(input) {
172
+ if (typeof input !== 'string') return null
173
+ const value = input.trim()
174
+ if (value.startsWith('#') || value.startsWith('#')) return parseHex(value)
175
+ if (/^oklch\(/i.test(value)) return parseOklch(value)
176
+ const named = NAMED_COLORS[value.toLowerCase()]
177
+ if (named) return { ...named }
178
+ return null
179
+ }
180
+
181
+ export function isNamedColor(input) {
182
+ return typeof input === 'string' && Object.prototype.hasOwnProperty.call(NAMED_COLORS, input.trim().toLowerCase())
183
+ }
184
+
185
+ export const NAMED_COLOR_NAMES = Object.keys(NAMED_COLORS)
@@ -0,0 +1,233 @@
1
+ // Bounded CSS theme extraction and merge.
2
+ //
3
+ // The storefront's visual tokens live in `:root` (light) and `.dark` blocks in
4
+ // app/globals.css. This module reads those declarations and writes new values
5
+ // back *in place*, preserving every unrelated byte — the Tailwind imports, the
6
+ // `@theme inline` bridge, comments, and any token the CLI does not manage. It
7
+ // never rewrites the whole stylesheet and never flattens `var()` aliases.
8
+
9
+ // Scan `css` from the `{` at/after `from` to its matching `}`, ignoring braces
10
+ // inside strings and comments. Returns the index just past the closing brace.
11
+ function matchBrace(css, from) {
12
+ let depth = 0
13
+ let quote = null
14
+ for (let i = from; i < css.length; i++) {
15
+ const ch = css[i]
16
+ if (quote) {
17
+ if (ch === '\\') i++
18
+ else if (ch === quote) quote = null
19
+ continue
20
+ }
21
+ if (ch === '/' && css[i + 1] === '*') {
22
+ const end = css.indexOf('*/', i + 2)
23
+ i = end === -1 ? css.length : end + 1
24
+ continue
25
+ }
26
+ if (ch === '"' || ch === "'") {
27
+ quote = ch
28
+ continue
29
+ }
30
+ if (ch === '{') depth++
31
+ else if (ch === '}') {
32
+ depth--
33
+ if (depth === 0) return i + 1
34
+ }
35
+ }
36
+ return -1
37
+ }
38
+
39
+ // Find the first top-level block whose selector is exactly `selector`. A block is
40
+ // `selector { ... }`; we require the selector to be immediately followed (after
41
+ // whitespace) by `{` so `.dark-something` never matches `.dark`.
42
+ export function findBlock(css, selector) {
43
+ const re = new RegExp(`(^|[}\\s;])${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`, 'g')
44
+ let match
45
+ while ((match = re.exec(css)) !== null) {
46
+ const braceIdx = css.indexOf('{', match.index)
47
+ const end = matchBrace(css, braceIdx)
48
+ if (end === -1) continue
49
+ const bodyStart = braceIdx + 1
50
+ return { start: match.index + (match[1] ? match[1].length : 0), bodyStart, bodyEnd: end - 1, end }
51
+ }
52
+ return null
53
+ }
54
+
55
+ // Split a block body into `--name: value` declarations, tracking their exact
56
+ // offsets so callers can splice a single value without disturbing the rest.
57
+ export function parseDeclarations(body, baseOffset = 0) {
58
+ const decls = []
59
+ let i = 0
60
+ while (i < body.length) {
61
+ const semi = indexOfTopLevel(body, ';', i)
62
+ const chunkEnd = semi === -1 ? body.length : semi
63
+ const chunk = body.slice(i, chunkEnd)
64
+ const colon = chunk.indexOf(':')
65
+ if (colon !== -1) {
66
+ const name = chunk.slice(0, colon).trim()
67
+ if (name.startsWith('--')) {
68
+ const valueStartInChunk = chunk.indexOf(':', colon) + 1
69
+ const rawValue = chunk.slice(valueStartInChunk)
70
+ const leading = rawValue.length - rawValue.trimStart().length
71
+ const trailing = rawValue.length - rawValue.trimEnd().length
72
+ const valueStart = i + valueStartInChunk + leading
73
+ const valueEnd = chunkEnd - trailing
74
+ decls.push({ name, value: body.slice(valueStart, valueEnd), valueStart: baseOffset + valueStart, valueEnd: baseOffset + valueEnd })
75
+ }
76
+ }
77
+ if (semi === -1) break
78
+ i = semi + 1
79
+ }
80
+ return decls
81
+ }
82
+
83
+ // Index of `ch` at paren/quote depth zero, or -1.
84
+ function indexOfTopLevel(text, ch, from) {
85
+ let depth = 0
86
+ let quote = null
87
+ for (let i = from; i < text.length; i++) {
88
+ const c = text[i]
89
+ if (quote) {
90
+ if (c === '\\') i++
91
+ else if (c === quote) quote = null
92
+ continue
93
+ }
94
+ if (c === '"' || c === "'") quote = c
95
+ else if (c === '(') depth++
96
+ else if (c === ')') depth--
97
+ else if (c === ch && depth === 0) return i
98
+ }
99
+ return -1
100
+ }
101
+
102
+ // Read the theme declarations of a stylesheet into light/dark maps.
103
+ export function readThemeCss(css) {
104
+ const read = (selector) => {
105
+ const block = findBlock(css, selector)
106
+ if (!block) return {}
107
+ const body = css.slice(block.bodyStart, block.bodyEnd)
108
+ const map = {}
109
+ for (const decl of parseDeclarations(body)) map[decl.name] = decl.value
110
+ return map
111
+ }
112
+ return { light: read(':root'), dark: read('.dark') }
113
+ }
114
+
115
+ // Apply a list of `{ scope, name, value }` edits, replacing existing
116
+ // declarations in place and appending missing ones to the end of their block.
117
+ // `scope` is 'light' (→ :root) or 'dark' (→ .dark). Returns the new CSS.
118
+ export function applyDeclarations(css, edits) {
119
+ const groups = { light: [], dark: [] }
120
+ for (const edit of edits) {
121
+ if (!groups[edit.scope]) throw new Error(`Unknown theme scope: ${edit.scope}`)
122
+ groups[edit.scope].push(edit)
123
+ }
124
+ let out = css
125
+ for (const scope of ['light', 'dark']) {
126
+ if (groups[scope].length === 0) continue
127
+ out = applyBlockDeclarations(out, scope === 'light' ? ':root' : '.dark', groups[scope])
128
+ }
129
+ return out
130
+ }
131
+
132
+ // Apply `{ name, value }` edits to any block by selector, replacing existing
133
+ // declarations in place and appending missing ones before the closing brace.
134
+ // When the block does not exist it is created at the end of the file. Used for
135
+ // the `@theme inline` font mapping, which is not a `:root` token block.
136
+ export function applyBlockDeclarations(css, selector, edits) {
137
+ let block = findBlock(css, selector)
138
+ if (!block) {
139
+ // No block yet: create one at the end of the file, preserving everything.
140
+ const body = edits.map((e) => ` ${e.name}: ${e.value};`).join('\n')
141
+ const prefix = css.replace(/\s*$/, '')
142
+ return `${prefix}\n\n${selector} {\n${body}\n}\n`
143
+ }
144
+ const body = css.slice(block.bodyStart, block.bodyEnd)
145
+ const decls = parseDeclarations(body, block.bodyStart)
146
+ const byName = new Map(decls.map((d) => [d.name, d]))
147
+ // Collapse multiple edits to the same declaration to the last one, so
148
+ // precedence is "later wins" and each name is spliced exactly once.
149
+ const collapsed = []
150
+ const indexByName = new Map()
151
+ for (const edit of edits) {
152
+ if (indexByName.has(edit.name)) collapsed[indexByName.get(edit.name)] = edit
153
+ else {
154
+ indexByName.set(edit.name, collapsed.length)
155
+ collapsed.push(edit)
156
+ }
157
+ }
158
+ const inserts = []
159
+ // Apply from the end so earlier offsets stay valid as we splice.
160
+ const replacements = []
161
+ for (const edit of collapsed) {
162
+ const existing = byName.get(edit.name)
163
+ if (existing) replacements.push({ start: existing.valueStart, end: existing.valueEnd, value: edit.value })
164
+ else inserts.push(edit)
165
+ }
166
+ replacements.sort((a, b) => b.start - a.start)
167
+ let next = css
168
+ for (const rep of replacements) next = next.slice(0, rep.start) + rep.value + next.slice(rep.end)
169
+ if (inserts.length > 0) {
170
+ // Re-locate after replacements, then insert before the block's closing brace.
171
+ block = findBlock(next, selector)
172
+ const insertText = '\n' + inserts.map((e) => ` ${e.name}: ${e.value};`).join('\n')
173
+ next = next.slice(0, block.bodyEnd) + insertText + next.slice(block.bodyEnd)
174
+ }
175
+ return next
176
+ }
177
+
178
+ // Fill only the named tokens that are absent from each scope, leaving any value
179
+ // a developer already set untouched. `defaults` is `{ light, dark }`.
180
+ export function fillMissingTokens(css, tokens, defaults) {
181
+ const current = readThemeCss(css)
182
+ const edits = []
183
+ for (const scope of ['light', 'dark']) {
184
+ for (const token of tokens) {
185
+ const name = `--${token}`
186
+ if (current[scope][name] === undefined && defaults[scope][token] !== undefined) {
187
+ edits.push({ scope, name, value: defaults[scope][token] })
188
+ }
189
+ }
190
+ }
191
+ return applyDeclarations(css, edits)
192
+ }
193
+
194
+ // Collect `var(--x)` references in a declaration value.
195
+ export function varReferences(value) {
196
+ const refs = []
197
+ const re = /var\(\s*(--[\w-]+)/g
198
+ let match
199
+ while ((match = re.exec(value)) !== null) refs.push(match[1])
200
+ return refs
201
+ }
202
+
203
+ // Detect references to undefined theme variables and reference cycles among the
204
+ // declarations of a stylesheet. Returns `{ undefined: [...], cycles: [[...]] }`.
205
+ export function validateVarReferences(css) {
206
+ const { light, dark } = readThemeCss(css)
207
+ const defined = new Set([...Object.keys(light), ...Object.keys(dark)])
208
+ const undefinedRefs = new Set()
209
+ const graph = new Map()
210
+ for (const scope of [light, dark]) {
211
+ for (const [name, value] of Object.entries(scope)) {
212
+ const refs = varReferences(value)
213
+ graph.set(name, refs)
214
+ for (const ref of refs) if (!defined.has(ref)) undefinedRefs.add(ref)
215
+ }
216
+ }
217
+ const cycles = []
218
+ const visiting = new Set()
219
+ const visited = new Set()
220
+ const walk = (node, path) => {
221
+ if (visiting.has(node)) {
222
+ cycles.push([...path.slice(path.indexOf(node)), node])
223
+ return
224
+ }
225
+ if (visited.has(node)) return
226
+ visiting.add(node)
227
+ for (const next of graph.get(node) ?? []) if (graph.has(next)) walk(next, [...path, node])
228
+ visiting.delete(node)
229
+ visited.add(node)
230
+ }
231
+ for (const node of graph.keys()) walk(node, [])
232
+ return { undefined: [...undefinedRefs], cycles }
233
+ }
@@ -0,0 +1,123 @@
1
+ // Font wiring for the bounded external layout (task 3.3).
2
+ //
3
+ // A font selection is either a hosted Google family (`google:<Family>`, loaded
4
+ // with `next/font/google`) or a CSS system stack (`system:<stack>`, no load).
5
+ // Hosted fonts get a real `next/font` import + variable + `<html>` class so the
6
+ // glyphs actually load; a bare family name is never treated as loaded. Only the
7
+ // selected slots are touched, so an unselected local font wiring is preserved.
8
+
9
+ function slug(family) {
10
+ return family
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '')
15
+ }
16
+
17
+ function camel(family) {
18
+ return family
19
+ .trim()
20
+ .replace(/[^A-Za-z0-9]+(.)/g, (_, c) => c.toUpperCase())
21
+ .replace(/^./, (c) => c.toLowerCase())
22
+ }
23
+
24
+ // The identifier `next/font/google` exports for a family: each word capitalized
25
+ // and joined with `_` (`Open Sans` -> `Open_Sans`, `inter` -> `Inter`). A family
26
+ // stored as a lowercase slug (`inter`) still yields the correct export name.
27
+ function importName(family) {
28
+ return family
29
+ .trim()
30
+ .split(/[^A-Za-z0-9]+/)
31
+ .filter(Boolean)
32
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
33
+ .join('_')
34
+ }
35
+
36
+ const FONT_IMPORT_RE = /^import\s*\{([^}]*)\}\s*from\s*'next\/font\/google'\s*$/m
37
+
38
+ /**
39
+ * Apply hosted font selections to app/layout.tsx. `fonts` is
40
+ * `{ sans?, mono?, serif? }` where each value is `{ kind: 'google'|'system',
41
+ * value }` or null. Returns `{ content, loaderVars, changed }`; `loaderVars`
42
+ * maps slot → the CSS variable next/font will inject (`--font-<slug>-<slot>`),
43
+ * so the stylesheet can point the Tailwind token at it.
44
+ */
45
+ export function applyLayoutFonts(content, fonts = {}) {
46
+ const loaderVars = {}
47
+ let next = content
48
+ let changed = false
49
+
50
+ for (const slot of ['sans', 'mono', 'serif']) {
51
+ const selection = fonts[slot]
52
+ if (!selection || selection.kind !== 'google') continue
53
+ const family = selection.value
54
+ const varName = `--font-${slug(family)}-${slot}`
55
+ const binding = camel(family)
56
+ const exported = importName(family)
57
+ loaderVars[slot] = varName
58
+
59
+ // 1. Ensure the named import from next/font/google.
60
+ const existingImport = next.match(FONT_IMPORT_RE)
61
+ if (existingImport) {
62
+ const names = existingImport[1]
63
+ .split(',')
64
+ .map((s) => s.trim())
65
+ .filter(Boolean)
66
+ if (!names.includes(exported)) {
67
+ names.push(exported)
68
+ next = next.replace(FONT_IMPORT_RE, `import { ${names.join(', ')} } from 'next/font/google'`)
69
+ }
70
+ } else {
71
+ const anchor = next.match(/^import[^\n]*$/m)
72
+ const importLine = `import { ${exported} } from 'next/font/google'`
73
+ next = anchor ? next.replace(anchor[0], `${anchor[0]}\n${importLine}`) : `${importLine}\n${next}`
74
+ }
75
+
76
+ // 2. Ensure the loader const.
77
+ const constRe = new RegExp(`const\\s+${binding}\\s*=\\s*${exported}\\(`)
78
+ if (!constRe.test(next)) {
79
+ const loader = `const ${binding} = ${exported}({ variable: '${varName}', subsets: ['latin'] })`
80
+ const lines = next.split('\n')
81
+ let lastImport = -1
82
+ for (let i = 0; i < lines.length; i++) if (lines[i].trimStart().startsWith('import ')) lastImport = i
83
+ if (lastImport >= 0) lines.splice(lastImport + 1, 0, '', loader)
84
+ else lines.unshift(loader, '')
85
+ next = lines.join('\n')
86
+ }
87
+
88
+ // 3. Ensure the variable is on the <html> className.
89
+ const htmlMatch = next.match(/<html\b([^>]*)>/)
90
+ if (htmlMatch) {
91
+ const attrs = htmlMatch[1]
92
+ if (!attrs.includes(`${binding}.variable`)) {
93
+ const classMatch = attrs.match(/className=\{([^}]*)\}/)
94
+ let newAttrs
95
+ if (classMatch) {
96
+ newAttrs = attrs.replace(classMatch[0], `className={[${classMatch[1]}, ${binding}.variable].join(' ')}`)
97
+ } else {
98
+ newAttrs = `${attrs} className={${binding}.variable}`
99
+ }
100
+ next = next.replace(htmlMatch[0], `<html${newAttrs}>`)
101
+ }
102
+ }
103
+ }
104
+
105
+ changed = next !== content
106
+ return { content: next, loaderVars, changed }
107
+ }
108
+
109
+ /**
110
+ * The stylesheet declarations a font selection implies. Hosted fonts map the
111
+ * Tailwind token to the variable next/font injects; system stacks are written
112
+ * directly. Returns a map of `--font-<slot>` → value.
113
+ */
114
+ export function fontCssVars(fonts = {}, loaderVars = {}) {
115
+ const out = {}
116
+ for (const slot of ['sans', 'mono', 'serif']) {
117
+ const selection = fonts[slot]
118
+ if (!selection) continue
119
+ if (selection.kind === 'google' && loaderVars[slot]) out[`--font-${slot}`] = `var(${loaderVars[slot]})`
120
+ else if (selection.kind === 'system') out[`--font-${slot}`] = selection.value
121
+ }
122
+ return out
123
+ }
@@ -0,0 +1,125 @@
1
+ // Portable theme import/export (tasks 4.2, 4.3).
2
+ //
3
+ // External theme sources are normalized through one data-only boundary:
4
+ // - a shadcn `registry:theme` JSON (`{ cssVars: { theme, light, dark } }`)
5
+ // - a local CSS export (a `:root` / `.dark` snippet from a generator)
6
+ // - a versioned storefront export written by `theme --export`
7
+ // The normalized result is always `{ tokens: { light, dark }, fonts? }`, never a
8
+ // second project config and never executable payloads.
9
+
10
+ import { readThemeCss } from './css-theme.mjs'
11
+ import { THEME_COLOR_TOKENS, FONT_VARS } from './tokens.mjs'
12
+
13
+ export const EXPORT_FORMAT = 'storefront-theme'
14
+ export const EXPORT_VERSION = 1
15
+
16
+ function fail(code, message, extra = {}) {
17
+ return { ok: false, diagnostics: [{ code, severity: 'error', message, ...extra }] }
18
+ }
19
+
20
+ const THEME_VAR = /^--(?:[a-z0-9-]+)$/
21
+
22
+ // Accept only theme-token and font declarations; drop anything else (component
23
+ // files, scripts, arbitrary payloads) with a diagnostic rather than applying it.
24
+ function filterDeclarations(raw) {
25
+ const kept = {}
26
+ const dropped = []
27
+ for (const [name, value] of Object.entries(raw ?? {})) {
28
+ if (typeof value !== 'string') {
29
+ dropped.push(name)
30
+ continue
31
+ }
32
+ const key = name.replace(/^--/, '')
33
+ if (THEME_COLOR_TOKENS.includes(key) || FONT_VARS.includes(name)) kept[name] = value
34
+ else dropped.push(name)
35
+ }
36
+ return { kept, dropped }
37
+ }
38
+
39
+ // A shadcn `registry:theme` item: cssVars.theme (shared) + cssVars.light/.dark.
40
+ export function parseRegistryTheme(item) {
41
+ if (!item || typeof item !== 'object' || typeof item.cssVars !== 'object' || item.cssVars === null) {
42
+ return fail('E_IMPORT_SHAPE', 'registry:theme JSON must have a `cssVars` object')
43
+ }
44
+ const { theme = {}, light = {}, dark = {} } = item.cssVars
45
+ const dropped = []
46
+ const collect = (scope, raw) => {
47
+ const { kept, dropped: d } = filterDeclarations(raw)
48
+ dropped.push(...d)
49
+ return kept
50
+ }
51
+ const shared = collect('theme', theme)
52
+ const merged = { light: { ...shared, ...collect('light', light) }, dark: { ...shared, ...collect('dark', dark) } }
53
+ if (dropped.length) {
54
+ // Non-theme declarations are ignored, not an error: the theme tokens still apply.
55
+ }
56
+ return { ok: true, tokens: merged, fonts: {}, dropped }
57
+ }
58
+
59
+ // A local CSS export: a plain stylesheet snippet with `:root` / `.dark` blocks.
60
+ export function parseCssExport(css) {
61
+ if (typeof css !== 'string' || !css.trim()) return fail('E_IMPORT_SHAPE', 'CSS theme export is empty')
62
+ const tokens = readThemeCss(css)
63
+ if (!Object.keys(tokens.light).length && !Object.keys(tokens.dark).length) {
64
+ return fail('E_IMPORT_SHAPE', 'CSS theme export has no `:root` or `.dark` token block')
65
+ }
66
+ return { ok: true, tokens, fonts: {} }
67
+ }
68
+
69
+ // Versioned storefront export written by `theme --export`.
70
+ export function parseThemeExport(json) {
71
+ let parsed
72
+ try {
73
+ parsed = typeof json === 'string' ? JSON.parse(json) : json
74
+ } catch (error) {
75
+ return fail('E_IMPORT_JSON', `theme export is not valid JSON: ${error.message}`)
76
+ }
77
+ if (!parsed || typeof parsed !== 'object') return fail('E_IMPORT_SHAPE', 'theme export must be an object')
78
+ if (parsed.format !== EXPORT_FORMAT) {
79
+ return fail('E_IMPORT_FORMAT', `expected format "${EXPORT_FORMAT}" (got "${parsed.format}")`)
80
+ }
81
+ if (parsed.version !== EXPORT_VERSION) {
82
+ return fail('E_IMPORT_VERSION', `unsupported theme export version ${parsed.version} (supported: ${EXPORT_VERSION})`)
83
+ }
84
+ const tokens = parsed.tokens ?? {}
85
+ if (typeof tokens.light !== 'object' || typeof tokens.dark !== 'object') {
86
+ return fail('E_IMPORT_SHAPE', 'theme export must carry `tokens.light` and `tokens.dark`')
87
+ }
88
+ return { ok: true, tokens: { light: tokens.light, dark: tokens.dark }, fonts: parsed.fonts ?? {}, mode: parsed.mode ?? null }
89
+ }
90
+
91
+ // Build a portable, versioned export from the resolved palette. Declarative
92
+ // font intent only (kind + value); no machine-specific module paths, secrets or
93
+ // executable code.
94
+ export function buildThemeExport({ tokens, mode, fonts = {} }) {
95
+ const fontSelections = {}
96
+ for (const slot of ['sans', 'mono', 'serif']) {
97
+ if (fonts[slot]) fontSelections[slot] = { kind: fonts[slot].kind, value: fonts[slot].value }
98
+ }
99
+ return {
100
+ format: EXPORT_FORMAT,
101
+ version: EXPORT_VERSION,
102
+ mode: mode ?? null,
103
+ tokens: { light: tokens.light ?? {}, dark: tokens.dark ?? {} },
104
+ fonts: fontSelections,
105
+ }
106
+ }
107
+
108
+ // Detect the concrete input type of an import source so a URL/format mismatch
109
+ // produces an actionable diagnostic instead of a confusing parse failure.
110
+ export function classifyImportSource(raw) {
111
+ const text = String(raw).trim()
112
+ if (/^https?:\/\//.test(text)) {
113
+ if (/\/r\/|registry|\.json(\?|$)/.test(text) && !/ui\.shadcn\.com\/create/.test(text)) {
114
+ return 'registry-url'
115
+ }
116
+ return 'preset-url'
117
+ }
118
+ if (text.startsWith('{')) return 'json'
119
+ if (text.includes('{')) return 'css'
120
+ // A path is classified by extension so a stylesheet that has no block on the
121
+ // command line (the common case: `--import ./brand.css`) still resolves.
122
+ if (/\.json(\?|$)/i.test(text)) return 'json'
123
+ if (/\.css(\?|$)/i.test(text)) return 'css'
124
+ return 'unknown'
125
+ }