@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,337 @@
1
+ #!/usr/bin/env node
2
+ // Per-slot / per-variant view assembly (E1 task 2.1).
3
+ //
4
+ // A view template is split into one source fragment per executable slot variant
5
+ // under `assembly/slots/<slot>/<variant>/`, plus two skeletons with markers
6
+ // (`assembly/index.skeleton.tsx`, `assembly/registry.skeleton.tsx`) and an
7
+ // `assembly/assembly.json` declaring slot order and each slot's default.
8
+ //
9
+ // `assembleView` composes the derived `index.tsx` and `registry.tsx` source from
10
+ // the selected variant's fragments. Task 2.1 calls it with the default selection
11
+ // so the fresh scaffold keeps the default composition's identity; task 2.2 will
12
+ // pass the manifest's intent/variant selection through the same function. The
13
+ // assembler is pure (no file writes, no placeholder substitution) so callers own
14
+ // the render + write step.
15
+ //
16
+ // Held variants have no fragment directory, so they can never be assembled: the
17
+ // selectable set is exactly the fragment set, which a parity test pins to the
18
+ // semantic catalog's `executable` entries.
19
+
20
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
21
+ import { mkdir, writeFile } from 'node:fs/promises'
22
+ import path from 'node:path'
23
+ import { isSlotSectionEntry } from './section-compat.mjs'
24
+
25
+ export const ASSEMBLY_DIRNAME = 'assembly'
26
+
27
+ // Skip predicate for the recursive template copy: the assembly sources are
28
+ // authoring input, never project output, so they must not be copied verbatim.
29
+ export function isAssemblyRel(rel) {
30
+ return rel.split(path.sep)[0] === ASSEMBLY_DIRNAME || rel.split('/')[0] === ASSEMBLY_DIRNAME
31
+ }
32
+
33
+ export function assemblyDir(templateDir) {
34
+ return path.join(templateDir, ASSEMBLY_DIRNAME)
35
+ }
36
+
37
+ export function hasViewAssembly(templateDir) {
38
+ return Boolean(templateDir) && existsSync(path.join(assemblyDir(templateDir), 'assembly.json'))
39
+ }
40
+
41
+ export function loadViewAssembly(templateDir) {
42
+ const file = path.join(assemblyDir(templateDir), 'assembly.json')
43
+ return JSON.parse(readFileSync(file, 'utf8'))
44
+ }
45
+
46
+ // The executable variants for a slot, in deterministic order. A variant is
47
+ // assemblable exactly when its fragment directory exists.
48
+ export function fragmentVariants(templateDir, slot) {
49
+ const dir = path.join(assemblyDir(templateDir), 'slots', slot)
50
+ if (!existsSync(dir)) return []
51
+ return readdirSync(dir, { withFileTypes: true })
52
+ .filter((entry) => entry.isDirectory())
53
+ .map((entry) => entry.name)
54
+ .sort()
55
+ }
56
+
57
+ // Resolve a `{ slot: variant }` selection against the assembly metadata. An
58
+ // omitted slot falls back to its declared default; an unknown variant throws so
59
+ // a caller never silently renders the wrong (or no) fragment.
60
+ //
61
+ // A slot marked `nested: true` is a presentation slot that is rendered inside
62
+ // its host element (for example the category `productCard` inside the single
63
+ // `CategoryFilter2` listing), so it has no top-level fragment and is skipped
64
+ // here: it stays in the ordered composition (it is a core registered slot) but
65
+ // contributes no top-level element, catalog entry, or route id.
66
+ export function resolveViewSelection(templateDir, selection = {}) {
67
+ const meta = loadViewAssembly(templateDir)
68
+ const slots = Array.isArray(meta.slots) ? meta.slots : []
69
+ const resolved = {}
70
+ for (const slot of slots) {
71
+ if (slot.nested === true) continue
72
+ const chosen = selection[slot.slot] ?? slot.default
73
+ const variants = fragmentVariants(templateDir, slot.slot)
74
+ if (!variants.includes(chosen)) {
75
+ throw new Error(
76
+ `Variant "${chosen}" has no fragment for slot "${slot.slot}" in view "${meta.view}". Available: ${variants.join(', ') || '(none)'}.`,
77
+ )
78
+ }
79
+ resolved[slot.slot] = chosen
80
+ }
81
+ return { meta, selection: resolved }
82
+ }
83
+
84
+ function readPart(fragmentDir, name) {
85
+ const file = path.join(fragmentDir, name)
86
+ if (!existsSync(file)) {
87
+ throw new Error(`View fragment is missing "${name}": ${fragmentDir}`)
88
+ }
89
+ // A fragment is a source snippet; its single trailing newline is provided by
90
+ // the skeleton, so strip it before joining.
91
+ return readFileSync(file, 'utf8').replace(/\n$/, '')
92
+ }
93
+
94
+ // Merge the per-variant block bindings into one import statement per module,
95
+ // sorted by imported name so the output is deterministic. This is what makes
96
+ // swapping a variant (Feature1 -> Feature2) change exactly one binding.
97
+ function renderBlockImports(bindings) {
98
+ const byModule = new Map()
99
+ for (const binding of bindings) {
100
+ if (!byModule.has(binding.module)) byModule.set(binding.module, [])
101
+ byModule.get(binding.module).push(binding)
102
+ }
103
+ const lines = []
104
+ for (const [moduleName, list] of [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b))) {
105
+ const sorted = [...list].sort((a, b) => a.import.localeCompare(b.import))
106
+ if (sorted.length === 1) {
107
+ lines.push(`import { ${sorted[0].import} as ${sorted[0].as} } from '${moduleName}'`)
108
+ } else {
109
+ lines.push('import {')
110
+ for (const binding of sorted) lines.push(` ${binding.import} as ${binding.as},`)
111
+ lines.push(`} from '${moduleName}'`)
112
+ }
113
+ }
114
+ return lines.join('\n')
115
+ }
116
+
117
+ // The import line that lets an editorial element read its user-owned instance
118
+ // content from the view's `content.ts` (`sectionContent[id]`). Emitted only when
119
+ // the ordered composition contains at least one editorial instance, so the
120
+ // default (slot-only) composition keeps its exact output.
121
+ function renderSectionImports(modules) {
122
+ const lines = [...modules]
123
+ .sort((a, b) => a.localeCompare(b))
124
+ .map((moduleName) => `import { ${SECTION_CONTENT_EXPORT} } from '${moduleName}'`)
125
+ return lines.length === 0 ? '' : `\n${lines.join('\n')}`
126
+ }
127
+
128
+ // The client-registry import for an editorial section's presentation bridge,
129
+ // one per distinct `views/sections/<type>.client` module, sorted by module.
130
+ function renderSectionRegistryImports(byModule) {
131
+ const lines = [...byModule.entries()]
132
+ .sort(([a], [b]) => a.localeCompare(b))
133
+ .map(([moduleName, exportName]) => `import { ${exportName} } from '${moduleName}'`)
134
+ return lines.length === 0 ? '' : `\n${lines.join('\n')}`
135
+ }
136
+
137
+ const SECTION_CONTENT_EXPORT = 'sectionContent'
138
+
139
+ // One editorial instance's element: a persisted instance id, the section's
140
+ // registered element type, and its props read from the view's user-owned
141
+ // `content.ts` by id (falling back to the section fragment's declared neutral
142
+ // default so the element is always a valid tree node).
143
+ function renderEditorialElement(entry, def) {
144
+ const fallback =
145
+ typeof def.defaultProps === 'string' && def.defaultProps.length > 0 ? def.defaultProps : '{}'
146
+ const props = `${SECTION_CONTENT_EXPORT}['${entry.id}'] ?? ${fallback}`
147
+ return ` '${entry.id}': {\n type: '${def.elementType}',\n props: ${props},\n },`
148
+ }
149
+
150
+ // One editorial instance's client registry binding: the section element type
151
+ // maps to the shared section fragment's registry component.
152
+ function renderEditorialRegistry(entry, def) {
153
+ return ` ${def.elementType}: ${def.registryExport}.${def.elementType},`
154
+ }
155
+
156
+ // One data-bound instance's element: like an editorial element, but its props
157
+ // merge the user-owned editorial content (`sectionContent[id]`) with the
158
+ // resolved per-instance selection delivered through the view state. The
159
+ // `variant` selects the verified presentation; `products`/`status` are dynamic
160
+ // state refs resolved by json-render, so the server module never fetches or
161
+ // imports the presentation components.
162
+ function renderDataBoundElement(entry, def) {
163
+ const variant =
164
+ typeof entry.variant === 'string' && entry.variant.length > 0
165
+ ? entry.variant
166
+ : def.defaultVariant
167
+ if (typeof variant !== 'string' || variant.length === 0) {
168
+ throw new Error(
169
+ `Data-bound section "${entry.type}" instance "${entry.id}" has no variant and no declared defaultVariant.`,
170
+ )
171
+ }
172
+ return [
173
+ ` '${entry.id}': {`,
174
+ ` type: '${def.elementType}',`,
175
+ ` props: {`,
176
+ ` ...(${SECTION_CONTENT_EXPORT}['${entry.id}'] ?? {}),`,
177
+ ` sectionId: '${entry.id}',`,
178
+ ` sectionType: '${entry.type}',`,
179
+ ` variant: '${variant}',`,
180
+ ` products: { $state: '/sections/${entry.id}/products' },`,
181
+ ` status: { $state: '/sections/${entry.id}/status' },`,
182
+ ` },`,
183
+ ` on: { addClickedToCart: { action: 'addClickedToCart' }, openProduct: { action: 'openProduct' } },`,
184
+ ` },`,
185
+ ].join('\n')
186
+ }
187
+
188
+ /**
189
+ * Assemble a view's derived `index.tsx` and `registry.tsx` from the view's
190
+ * single ordered composition (E3 task 5.1).
191
+ *
192
+ * Each ordered entry is either a registered presentation slot
193
+ * (`{ id: "slot:<slot>", type: "<slot>" }`) — composed from the selected
194
+ * per-variant fragment — or an editorial section instance
195
+ * (`{ id, type, variant? }`) — composed from the view assembly's
196
+ * `meta.sections[type]` editorial fragment. Array order is render order, so a
197
+ * cross-boundary reorder changes the emitted element/route order. When
198
+ * `sections` is omitted (or null) the assembly falls back to `meta.slots` order,
199
+ * which is byte-identical to the committed default template.
200
+ *
201
+ * A slot marked `nested: true` (a presentation slot rendered inside its host
202
+ * element) stays in the ordered composition but is skipped at the top level, so
203
+ * it contributes no element, catalog entry, registry binding, or route id — the
204
+ * category `productCard` nested in `CategoryFilter2`. The default category
205
+ * composition therefore renders exactly the single `category.filter` element.
206
+ *
207
+ * Returns the raw source (placeholders intact); callers run
208
+ * `replacePlaceholders` and write the files.
209
+ */
210
+ export function assembleView(templateDir, selection = {}, sections = null) {
211
+ const { meta, selection: resolved } = resolveViewSelection(templateDir, selection)
212
+ const slotsDir = path.join(assemblyDir(templateDir), 'slots')
213
+ const slots = Array.isArray(meta.slots) ? meta.slots : []
214
+ const slotMeta = new Map(slots.map((slot) => [slot.slot, slot]))
215
+ const sectionMeta =
216
+ meta.sections && typeof meta.sections === 'object' && !Array.isArray(meta.sections)
217
+ ? meta.sections
218
+ : {}
219
+
220
+ const ordered = Array.isArray(sections)
221
+ ? sections
222
+ : slots
223
+ .filter((slot) => slot.nested !== true)
224
+ .map((slot) => ({ id: `slot:${slot.slot}`, type: slot.slot }))
225
+
226
+ const elements = []
227
+ const catalog = []
228
+ const registry = []
229
+ const registryTypes = new Set()
230
+ const bindings = []
231
+ const slotIds = []
232
+ const dataBoundIds = []
233
+ const sectionPropsModules = new Set()
234
+ const sectionRegistryImports = new Map()
235
+ for (const entry of ordered) {
236
+ if (isSlotSectionEntry(entry)) {
237
+ const slot = slotMeta.get(entry.type)
238
+ if (!slot) {
239
+ throw new Error(
240
+ `View "${meta.view}" has no assembly fragment for registered slot "${entry.type}".`,
241
+ )
242
+ }
243
+ // A nested slot is rendered inside its host element (its own fragment is
244
+ // authoring-only), so it contributes no top-level output. It stays in the
245
+ // ordered composition and is validated as a core registered slot.
246
+ if (slot.nested === true) continue
247
+ const variant = resolved[slot.slot]
248
+ const fragmentDir = path.join(slotsDir, slot.slot, variant)
249
+ elements.push(readPart(fragmentDir, 'element.part'))
250
+ catalog.push(readPart(fragmentDir, 'catalog.part'))
251
+ registry.push(readPart(fragmentDir, 'registry.part'))
252
+ // A fragment may declare no per-variant binding when its block import is
253
+ // static in the skeleton (a single-variant slot whose import also carries
254
+ // types the binding format cannot express). Such a fragment adds no import.
255
+ const bindingPath = path.join(fragmentDir, 'binding.json')
256
+ if (existsSync(bindingPath)) {
257
+ bindings.push(JSON.parse(readFileSync(bindingPath, 'utf8')))
258
+ }
259
+ slotIds.push(`'${slot.id}'`)
260
+ continue
261
+ }
262
+ const def = sectionMeta[entry.type]
263
+ if (!def || typeof def.elementType !== 'string' || typeof def.registryExport !== 'string') {
264
+ throw new Error(
265
+ `View "${meta.view}" has no editorial fragment for section "${entry.type}".`,
266
+ )
267
+ }
268
+ elements.push(
269
+ def.dataBound === true
270
+ ? renderDataBoundElement(entry, def)
271
+ : renderEditorialElement(entry, def),
272
+ )
273
+ registry.push(renderEditorialRegistry(entry, def))
274
+ // A registry binding is keyed by the element type, so several instances of
275
+ // the same section type (for example two `products` sections on one view)
276
+ // share a single registry entry. Emitting one per instance would produce
277
+ // duplicate object keys in the derived `registry.tsx`.
278
+ if (registryTypes.has(def.elementType)) registry.pop()
279
+ else registryTypes.add(def.elementType)
280
+ slotIds.push(`'${entry.id}'`)
281
+ sectionPropsModules.add(def.propsModule ?? './content')
282
+ sectionRegistryImports.set(def.registryModule, def.registryExport)
283
+ if (def.dataBound === true) dataBoundIds.push(`'${entry.id}'`)
284
+ }
285
+
286
+ const indexSkeleton = readFileSync(path.join(assemblyDir(templateDir), 'index.skeleton.tsx'), 'utf8')
287
+ const registrySkeleton = readFileSync(
288
+ path.join(assemblyDir(templateDir), 'registry.skeleton.tsx'),
289
+ 'utf8',
290
+ )
291
+
292
+ const index = indexSkeleton
293
+ .replace('@@SECTION_IMPORTS@@', () => renderSectionImports(sectionPropsModules))
294
+ .replace('@@SECTION_LOADERS@@', () => `[${dataBoundIds.join(', ')}]`)
295
+ .replace('@@ELEMENTS@@', () => elements.join('\n'))
296
+ .replace('@@CATALOG@@', () => catalog.join('\n'))
297
+ .replace('@@SLOT_IDS@@', () => slotIds.join(', '))
298
+ const registrySource = registrySkeleton
299
+ .replace('@@BLOCK_IMPORTS@@', () => renderBlockImports(bindings))
300
+ .replace('@@SECTION_REGISTRY_IMPORTS@@', () => renderSectionRegistryImports(sectionRegistryImports))
301
+ .replace('@@REGISTRY@@', () => registry.join('\n'))
302
+
303
+ return { index, registry: registrySource, selection: resolved, meta, sections: ordered }
304
+ }
305
+
306
+ /**
307
+ * Assemble and write a view's derived `index.tsx` and `registry.tsx` into
308
+ * `viewDir`. `render` applies placeholder substitution (the caller owns the
309
+ * subs). Returns the absolute paths that changed; with `dryRun` it reports them
310
+ * without writing. Idempotent: identical output leaves the files untouched.
311
+ */
312
+ export async function writeViewAssembly({
313
+ viewDir,
314
+ templateDir,
315
+ selection = {},
316
+ sections = null,
317
+ render = (source) => source,
318
+ dryRun = false,
319
+ } = {}) {
320
+ const { index, registry } = assembleView(templateDir, selection, sections)
321
+ const changed = []
322
+ for (const [name, source] of [
323
+ ['index.tsx', index],
324
+ ['registry.tsx', registry],
325
+ ]) {
326
+ const dest = path.join(viewDir, name)
327
+ const next = render(source)
328
+ const prev = existsSync(dest) ? readFileSync(dest, 'utf8') : null
329
+ if (prev === next) continue
330
+ if (!dryRun) {
331
+ await mkdir(path.dirname(dest), { recursive: true })
332
+ await writeFile(dest, next)
333
+ }
334
+ changed.push(dest)
335
+ }
336
+ return changed
337
+ }
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // Manifest view selection -> per-slot fragment selection (E1 task 2.2).
3
+ //
4
+ // A view's manifest entry may carry `intents` ({ slot: intentName }) and/or
5
+ // `variants` ({ slot: variantName }). This module is the single resolver that
6
+ // turns either into the `{ slot: variant }` selection the view assembler
7
+ // consumes, so a fresh scaffold and a later reconcile both read the manifest's
8
+ // explicit decisions through one code path.
9
+ //
10
+ // Authority is the single semantic catalog (via `recipes/validate.mjs`): an
11
+ // intent is resolved through the archetype layer and must land on a
12
+ // catalog-executable variant; an explicit variant must itself be
13
+ // catalog-executable. A slot selected by both an intent and a variant has two
14
+ // competing truths and is rejected before any write, matching the `view.add`
15
+ // operation contract. An omitted slot is left out of the selection so the
16
+ // assembler applies the view's declared default.
17
+
18
+ import {
19
+ getCatalogVariant,
20
+ getFeature,
21
+ isSupportedVariant,
22
+ normalizeFeatureConfig,
23
+ resolveIntent,
24
+ resolveMountClaims,
25
+ } from '../recipes/validate.mjs'
26
+ import { logicalControlFor } from './operation-registry.mjs'
27
+ import { CATALOG_LOGICAL_CONTROL_REGISTRY, logicalControlForMount } from './logical-controls.mjs'
28
+ import { normalizeViewSectionsForView } from './section-compat.mjs'
29
+
30
+ function isPlainObject(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
32
+ }
33
+
34
+ /**
35
+ * Resolve a manifest view entry's explicit selections to a `{ slot: variant }`
36
+ * map. Returns `{ ok, selection, errors }`; `selection` contains only the slots
37
+ * the entry selected (omitted slots fall back to the view's defaults).
38
+ */
39
+ export function resolveViewSelection(viewName, entry = {}) {
40
+ const errors = []
41
+ const intents = isPlainObject(entry.intents) ? entry.intents : {}
42
+ const variants = isPlainObject(entry.variants) ? entry.variants : {}
43
+ const selection = {}
44
+
45
+ for (const slot of Object.keys(intents)) {
46
+ if (Object.prototype.hasOwnProperty.call(variants, slot)) {
47
+ errors.push(
48
+ `Slot "${slot}" in view "${viewName}" is selected by both an intent and a variant; choose exactly one.`,
49
+ )
50
+ }
51
+ }
52
+
53
+ for (const [slot, intentName] of Object.entries(intents)) {
54
+ const resolved = resolveIntent(viewName, slot, intentName)
55
+ if (!resolved.ok) {
56
+ errors.push(...resolved.errors)
57
+ continue
58
+ }
59
+ selection[slot] = resolved.variant
60
+ }
61
+
62
+ // An explicit variant is used directly; it never goes through the intent
63
+ // layer. A slot that also appears in `intents` is a conflict reported above.
64
+ for (const [slot, variantName] of Object.entries(variants)) {
65
+ selection[slot] = variantName
66
+ }
67
+
68
+ for (const [slot, variantName] of Object.entries(selection)) {
69
+ if (isSupportedVariant(viewName, slot, variantName)) continue
70
+ const held = getCatalogVariant(viewName, slot, variantName)
71
+ const reason =
72
+ held?.status === 'held' && typeof held.reason === 'string'
73
+ ? ` Held reason: ${held.reason}.`
74
+ : ''
75
+ errors.push(
76
+ `Variant "${variantName}" is not executable for ${viewName}.${slot} in the curated MVP set.${reason}`,
77
+ )
78
+ }
79
+
80
+ return { ok: errors.length === 0, selection, errors }
81
+ }
82
+
83
+ /**
84
+ * Resolve the generation-time slot presentation each installed feature instance
85
+ * mount imposes on its host view (E1 task 3.3). A registry feature declares
86
+ * `mounts: [{ id, host, slot }]` and a `presentations` map; generation owns the
87
+ * block wiring by mapping the feature's `config.presentation` through that map
88
+ * (`compact` -> `Feature1`, `detailed` -> `Feature2`). A mount never carries an
89
+ * element. Returns
90
+ * `{ ok, byView: Map<host, { [slot]: { variant, mountId, featureName } }>, errors }`.
91
+ *
92
+ * The overrides are generation-only and are never written back to the manifest,
93
+ * so the view's own stored selection stays dormant and is restored when the
94
+ * feature is removed. Host-slot claim rules (`E_MOUNT_CONFLICT`,
95
+ * `E_DUPLICATE_MOUNT_ID`) are resolved through the single registry authority
96
+ * before any override is built; `options.getFeatureSpec` is the injection seam a
97
+ * hermetic test uses to exercise a second claim without a generic registry
98
+ * feature.
99
+ */
100
+ export function resolveFeatureMountOverrides(manifest, options = {}) {
101
+ const byView = new Map()
102
+ const errors = []
103
+ const featureList = Array.isArray(manifest?.features) ? manifest.features : []
104
+ const resolve =
105
+ typeof options.getFeatureSpec === 'function' ? options.getFeatureSpec : getFeature
106
+ const claims = resolveMountClaims(featureList, { getFeatureSpec: resolve })
107
+ if (!claims.ok) return { ok: false, byView, errors: claims.errors }
108
+ for (const feature of featureList) {
109
+ if (!isPlainObject(feature) || typeof feature.name !== 'string') continue
110
+ const spec = resolve(feature.name)
111
+ const mounts = Array.isArray(spec?.mounts) ? spec.mounts : []
112
+ if (mounts.length === 0) continue
113
+ // Seed declared defaults so an omitted config resolves to the feature's
114
+ // default presentation, matching the `feature.add` contract (omitted config
115
+ // -> the feature's declared defaults).
116
+ const normalized = normalizeFeatureConfig(
117
+ feature.name,
118
+ isPlainObject(feature.config) ? feature.config : {},
119
+ )
120
+ if (!normalized.ok) {
121
+ errors.push(...normalized.errors)
122
+ continue
123
+ }
124
+ const presentations = isPlainObject(spec.presentations) ? spec.presentations : {}
125
+ const config = normalized.config
126
+ const presentationKey = config.presentation
127
+ const variant =
128
+ typeof presentationKey === 'string' ? presentations[presentationKey] : undefined
129
+ // E2 task 6.1 (M2 resolution): a mount registered as a logical control is a
130
+ // contribution to the host's single already-rendered presentation, not a
131
+ // variant swap. It carries no `presentations` map and no catalog variant, so
132
+ // the presentation/variant requirement is skipped for exactly those mounts.
133
+ // The control's visibility is derived at generation time from the installed
134
+ // manifest (`mountedFeatures`/`mountedFeatureConfigs`), never by replacing
135
+ // the host slot's selection.
136
+ const registry = options.logicalControlRegistry ?? CATALOG_LOGICAL_CONTROL_REGISTRY
137
+ for (const mount of mounts) {
138
+ if (typeof mount?.host !== 'string' || typeof mount?.slot !== 'string') continue
139
+ const control =
140
+ registry === CATALOG_LOGICAL_CONTROL_REGISTRY
141
+ ? logicalControlForMount(mount.host, mount.slot)
142
+ : logicalControlFor(registry, mount.host, mount.slot)
143
+ if (control && control.feature === feature.name) continue
144
+ if (typeof variant !== 'string') {
145
+ errors.push(
146
+ `Feature "${feature.name}" mount "${mount.id}" has no presentation for config.presentation "${presentationKey}".`,
147
+ )
148
+ continue
149
+ }
150
+ if (!isSupportedVariant(mount.host, mount.slot, variant)) {
151
+ errors.push(
152
+ `Feature "${feature.name}" mount "${mount.id}" maps config.presentation "${presentationKey}" to variant "${variant}", which is not executable for ${mount.host}.${mount.slot}.`,
153
+ )
154
+ continue
155
+ }
156
+ if (!byView.has(mount.host)) byView.set(mount.host, {})
157
+ byView.get(mount.host)[mount.slot] = {
158
+ variant,
159
+ mountId: mount.id,
160
+ featureName: feature.name,
161
+ }
162
+ }
163
+ }
164
+ return { ok: errors.length === 0, byView, errors }
165
+ }
166
+
167
+ /**
168
+ * Resolve a view's effective generation selection: its own stored selection
169
+ * overridden by any installed feature instance mount that claims one of its
170
+ * slots, plus the view's single normalized ordered composition (E3 task 5.1).
171
+ * `dormant` is the view's stored selection, returned untouched so a later
172
+ * feature removal restores it. `sections` is the ordered `{ id, type, variant? }`
173
+ * list from `section-compat.mjs` — the single order truth the view assembler
174
+ * renders. Returns
175
+ * `{ ok, selection, dormant, overrides, sections, errors }`.
176
+ */
177
+ export function resolveViewGenerationSelection(view, byView = new Map()) {
178
+ const viewName = typeof view === 'string' ? view : view?.name
179
+ const entry = typeof view === 'string' ? {} : view ?? {}
180
+ const base = resolveViewSelection(viewName, entry)
181
+ const overrides = byView.get(viewName) ?? {}
182
+ // The ordered composition is normalized through the same authority every
183
+ // section operation uses, so a hand-edited or legacy manifest is either
184
+ // adapted deterministically or refused (with no output) here, before any
185
+ // derived file is written.
186
+ const normalized = normalizeViewSectionsForView(viewName, entry.sections ?? [])
187
+ if (!base.ok || !normalized.ok) {
188
+ return {
189
+ ok: false,
190
+ selection: {},
191
+ dormant: {},
192
+ overrides,
193
+ sections: null,
194
+ errors: [...base.errors, ...normalized.diagnostics.map((diagnostic) => diagnostic.message)],
195
+ }
196
+ }
197
+ return {
198
+ ok: true,
199
+ selection: { ...base.selection, ...mapOverrideVariants(overrides) },
200
+ dormant: base.selection,
201
+ overrides,
202
+ sections: normalized.sections,
203
+ errors: [],
204
+ }
205
+ }
206
+
207
+ function mapOverrideVariants(overrides) {
208
+ const out = {}
209
+ for (const [slot, override] of Object.entries(overrides)) out[slot] = override.variant
210
+ return out
211
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: sf-feature
3
+ description: Add or remove a Thor Commerce storefront feature bundle, and explain how a feature owns views, routes, and state versus mounting into a host view's slot. Use when the user asks to add or remove a feature (for example checkout, customer auth, or a category filter), or asks why a view cannot be added directly. Delegates every structural write to the `storefront` CLI.
4
+ ---
5
+
6
+ # sf-feature
7
+
8
+ A **feature** is a capability bundle. It may own views plus its state slices,
9
+ handlers, and declared adapter needs, and it may declare an owned route and one
10
+ or more instance mounts into a host view's slot. The CLI writes every derived
11
+ file; agents never do.
12
+
13
+ ## Read the curated feature set through the CLI (never from memory)
14
+
15
+ Run from the storefront project root (the CLI resolves the project from the
16
+ current directory; do not pass a directory):
17
+
18
+ ```bash
19
+ npx @thorprovider/storefront features --json
20
+ npx @thorprovider/storefront recipes --json
21
+ ```
22
+
23
+ `features --json` is the registry the CLI validates against, annotated with
24
+ `installed`, `mvp`, `route`, `views`, `state`, `handlers`, `adapter`, `presentations`,
25
+ `mounts`, and `surfaces` per feature. There is **no fixed feature list in this
26
+ skill**: whatever the registry reports is the authoritative set, so a feature
27
+ added to or removed from the registry is reflected without editing this text. Do
28
+ not read the JSON files directly; they are an implementation detail.
29
+
30
+ ## Feature-owned vs mount-only (read this before routing a request)
31
+
32
+ - **Feature-owned view** — `views` is non-empty (for example a `checkout` feature
33
+ that owns the `checkout` view). Install and remove the whole bundle through the
34
+ feature; a direct `view add <owned-view>` is rejected. The CLI installs the
35
+ owned views, route, and mounts together.
36
+ - **Mount-only feature** — `views` is empty and `mounts` (or `surfaces`) is
37
+ non-empty. It injects into a host view's declared slot without owning a view.
38
+ Removing it leaves the host view installed and preserves the host's user-owned
39
+ content.
40
+ - **Held feature** — `mvp: false`. The CLI rejects it before any write; report it
41
+ as not available rather than trying to install it.
42
+
43
+ ## Add a feature
44
+
45
+ ```bash
46
+ npx @thorprovider/storefront feature add <name>
47
+ ```
48
+
49
+ This installs the feature, every view it owns, its owned route, and its mounts,
50
+ and reconciles the state/handlers/adapter wiring. If the CLI exits non-zero,
51
+ relay its message and the supported options; do not hand-edit derived files to
52
+ work around it.
53
+
54
+ ## Remove a feature
55
+
56
+ ```bash
57
+ npx @thorprovider/storefront feature remove <name>
58
+ ```
59
+
60
+ This removes the bundle, any view no other installed feature owns, its owned
61
+ route, and its mounts. A mount's host view stays installed, so its customized
62
+ content is preserved; if the CLI prints a preserved customized file, relay that
63
+ path to the user.
64
+
65
+ ## Stack constraints
66
+
67
+ - UI only `@thorprovider/blocks`; data only `@thorprovider/adapters` +
68
+ `@thorprovider/runtime/commerce`.
69
+ - A missing capability is a gap to report, not a reason to install another UI
70
+ framework or hand-edit a derived file.
71
+
72
+ ## Workflow model
73
+
74
+ Read `../storefront-workflow/SKILL.md` for the four phases and the CLI/agent
75
+ authority.
76
+
77
+ ## Completion criteria
78
+
79
+ - The manifest lists (or no longer lists) the feature and its owned views.
80
+ - `npx @thorprovider/storefront sync` reports no drift.