@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -0,0 +1,291 @@
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
+ /**
157
+ * Assemble a view's derived `index.tsx` and `registry.tsx` from the view's
158
+ * single ordered composition (E3 task 5.1).
159
+ *
160
+ * Each ordered entry is either a registered presentation slot
161
+ * (`{ id: "slot:<slot>", type: "<slot>" }`) — composed from the selected
162
+ * per-variant fragment — or an editorial section instance
163
+ * (`{ id, type, variant? }`) — composed from the view assembly's
164
+ * `meta.sections[type]` editorial fragment. Array order is render order, so a
165
+ * cross-boundary reorder changes the emitted element/route order. When
166
+ * `sections` is omitted (or null) the assembly falls back to `meta.slots` order,
167
+ * which is byte-identical to the committed default template.
168
+ *
169
+ * A slot marked `nested: true` (a presentation slot rendered inside its host
170
+ * element) stays in the ordered composition but is skipped at the top level, so
171
+ * it contributes no element, catalog entry, registry binding, or route id — the
172
+ * category `productCard` nested in `CategoryFilter2`. The default category
173
+ * composition therefore renders exactly the single `category.filter` element.
174
+ *
175
+ * Returns the raw source (placeholders intact); callers run
176
+ * `replacePlaceholders` and write the files.
177
+ */
178
+ export function assembleView(templateDir, selection = {}, sections = null) {
179
+ const { meta, selection: resolved } = resolveViewSelection(templateDir, selection)
180
+ const slotsDir = path.join(assemblyDir(templateDir), 'slots')
181
+ const slots = Array.isArray(meta.slots) ? meta.slots : []
182
+ const slotMeta = new Map(slots.map((slot) => [slot.slot, slot]))
183
+ const sectionMeta =
184
+ meta.sections && typeof meta.sections === 'object' && !Array.isArray(meta.sections)
185
+ ? meta.sections
186
+ : {}
187
+
188
+ const ordered = Array.isArray(sections)
189
+ ? sections
190
+ : slots
191
+ .filter((slot) => slot.nested !== true)
192
+ .map((slot) => ({ id: `slot:${slot.slot}`, type: slot.slot }))
193
+
194
+ const elements = []
195
+ const catalog = []
196
+ const registry = []
197
+ const bindings = []
198
+ const slotIds = []
199
+ const sectionPropsModules = new Set()
200
+ const sectionRegistryImports = new Map()
201
+ for (const entry of ordered) {
202
+ if (isSlotSectionEntry(entry)) {
203
+ const slot = slotMeta.get(entry.type)
204
+ if (!slot) {
205
+ throw new Error(
206
+ `View "${meta.view}" has no assembly fragment for registered slot "${entry.type}".`,
207
+ )
208
+ }
209
+ // A nested slot is rendered inside its host element (its own fragment is
210
+ // authoring-only), so it contributes no top-level output. It stays in the
211
+ // ordered composition and is validated as a core registered slot.
212
+ if (slot.nested === true) continue
213
+ const variant = resolved[slot.slot]
214
+ const fragmentDir = path.join(slotsDir, slot.slot, variant)
215
+ elements.push(readPart(fragmentDir, 'element.part'))
216
+ catalog.push(readPart(fragmentDir, 'catalog.part'))
217
+ registry.push(readPart(fragmentDir, 'registry.part'))
218
+ // A fragment may declare no per-variant binding when its block import is
219
+ // static in the skeleton (a single-variant slot whose import also carries
220
+ // types the binding format cannot express). Such a fragment adds no import.
221
+ const bindingPath = path.join(fragmentDir, 'binding.json')
222
+ if (existsSync(bindingPath)) {
223
+ bindings.push(JSON.parse(readFileSync(bindingPath, 'utf8')))
224
+ }
225
+ slotIds.push(`'${slot.id}'`)
226
+ continue
227
+ }
228
+ const def = sectionMeta[entry.type]
229
+ if (!def || typeof def.elementType !== 'string' || typeof def.registryExport !== 'string') {
230
+ throw new Error(
231
+ `View "${meta.view}" has no editorial fragment for section "${entry.type}".`,
232
+ )
233
+ }
234
+ elements.push(renderEditorialElement(entry, def))
235
+ registry.push(renderEditorialRegistry(entry, def))
236
+ slotIds.push(`'${entry.id}'`)
237
+ sectionPropsModules.add(def.propsModule ?? './content')
238
+ sectionRegistryImports.set(def.registryModule, def.registryExport)
239
+ }
240
+
241
+ const indexSkeleton = readFileSync(path.join(assemblyDir(templateDir), 'index.skeleton.tsx'), 'utf8')
242
+ const registrySkeleton = readFileSync(
243
+ path.join(assemblyDir(templateDir), 'registry.skeleton.tsx'),
244
+ 'utf8',
245
+ )
246
+
247
+ const index = indexSkeleton
248
+ .replace('@@SECTION_IMPORTS@@', () => renderSectionImports(sectionPropsModules))
249
+ .replace('@@ELEMENTS@@', () => elements.join('\n'))
250
+ .replace('@@CATALOG@@', () => catalog.join('\n'))
251
+ .replace('@@SLOT_IDS@@', () => slotIds.join(', '))
252
+ const registrySource = registrySkeleton
253
+ .replace('@@BLOCK_IMPORTS@@', () => renderBlockImports(bindings))
254
+ .replace('@@SECTION_REGISTRY_IMPORTS@@', () => renderSectionRegistryImports(sectionRegistryImports))
255
+ .replace('@@REGISTRY@@', () => registry.join('\n'))
256
+
257
+ return { index, registry: registrySource, selection: resolved, meta, sections: ordered }
258
+ }
259
+
260
+ /**
261
+ * Assemble and write a view's derived `index.tsx` and `registry.tsx` into
262
+ * `viewDir`. `render` applies placeholder substitution (the caller owns the
263
+ * subs). Returns the absolute paths that changed; with `dryRun` it reports them
264
+ * without writing. Idempotent: identical output leaves the files untouched.
265
+ */
266
+ export async function writeViewAssembly({
267
+ viewDir,
268
+ templateDir,
269
+ selection = {},
270
+ sections = null,
271
+ render = (source) => source,
272
+ dryRun = false,
273
+ } = {}) {
274
+ const { index, registry } = assembleView(templateDir, selection, sections)
275
+ const changed = []
276
+ for (const [name, source] of [
277
+ ['index.tsx', index],
278
+ ['registry.tsx', registry],
279
+ ]) {
280
+ const dest = path.join(viewDir, name)
281
+ const next = render(source)
282
+ const prev = existsSync(dest) ? readFileSync(dest, 'utf8') : null
283
+ if (prev === next) continue
284
+ if (!dryRun) {
285
+ await mkdir(path.dirname(dest), { recursive: true })
286
+ await writeFile(dest, next)
287
+ }
288
+ changed.push(dest)
289
+ }
290
+ return changed
291
+ }
@@ -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,67 @@
1
+ ---
2
+ name: sf-feature
3
+ description: Add or remove a Thor Commerce storefront feature bundle. Use when the user runs /sf-feature or asks to add/remove a feature such as checkout. Delegates every structural write to the `storefront` CLI.
4
+ ---
5
+
6
+ # sf-feature
7
+
8
+ A **feature** is a capability bundle that owns views plus its state slices,
9
+ handlers, and declared adapter needs, and may declare an owned route and one or
10
+ more instance mounts into a host view's slot. Adding it installs those views,
11
+ routes, and mounts; removing it takes them away. The CLI writes every derived
12
+ file; agents never do.
13
+
14
+ ## Read the registry through the CLI
15
+
16
+ Run from the storefront project root (no directory argument):
17
+
18
+ ```bash
19
+ npx @thorprovider/storefront features --json
20
+ npx @thorprovider/storefront recipes --json
21
+ ```
22
+
23
+ ## Curated MVP set
24
+
25
+ The supported (MVP) features are `checkout` (owns the `/checkout` route) and
26
+ `benefits` (mounts at the home `benefits` slot, config selects the
27
+ presentation). Held features (for example `recommendations`) are rejected before
28
+ any write; do not try to install them.
29
+
30
+ ## Add a feature
31
+
32
+ ```bash
33
+ npx @thorprovider/storefront feature add <name>
34
+ ```
35
+
36
+ This installs the feature and every view it owns (e.g. `checkout` installs the
37
+ `checkout` view), and reconciles the state/handlers/adapter wiring. If the CLI
38
+ exits non-zero, show its message; do not hand-edit derived files.
39
+
40
+ ## Remove a feature
41
+
42
+ ```bash
43
+ npx @thorprovider/storefront feature remove <name>
44
+ ```
45
+
46
+ This removes the bundle, any view no other installed feature owns, its owned
47
+ route, and its mounts. A mount's host view stays installed, so its customized
48
+ content is preserved; if the CLI prints a preserved customized file, relay that
49
+ path to the user.
50
+
51
+ ## Stack constraints
52
+
53
+ - UI only `@thorprovider/blocks`; data only `@thorprovider/adapters` +
54
+ `@thorprovider/runtime/commerce`.
55
+ - The `checkout` feature's purchase logic is owned by the separate
56
+ `single-tenant-commerce-mvp` change; this command only installs its structural
57
+ wiring.
58
+
59
+ ## Workflow model
60
+
61
+ Read `../storefront-workflow/SKILL.md` for the four phases and the CLI/agent
62
+ authority.
63
+
64
+ ## Completion criteria
65
+
66
+ - The manifest lists (or no longer lists) the feature and its owned views.
67
+ - `npx @thorprovider/storefront sync` reports no drift.
@@ -9,6 +9,11 @@ Customize a storefront's theme and identity. This skill ships with everything it
9
9
 
10
10
  ## Source of truth
11
11
 
12
+ The manifest (`storefront.config.json`) is the theme authority: `theme --mode`
13
+ updates it and reconciles, so a later `sync` cannot revert the change. Read
14
+ `../storefront-workflow/SKILL.md` for the four phases (bootstrap → compose →
15
+ customize → verify) and the CLI/agent authority.
16
+
12
17
  The workflow uses only the assets in this skill directory and the vendored `../shadcn-theming/` skill next to it. When a required choice is not determined there, ask the user.
13
18
 
14
19
  ## Inputs (the only target-project facts this skill reads)
@@ -43,16 +48,18 @@ Follow `../shadcn-theming/SKILL.md` for OKLCH pair rules and contrast. Paths are
43
48
  ## Workflow
44
49
 
45
50
  1. Use the `question` tool to ask what to customize: colors, typography, identity, SEO, Open Graph.
46
- 2. **Colors**: follow `../shadcn-theming/SKILL.md`; update BOTH `:root` (light) and `.dark` blocks in `app/globals.css`, keeping the `@theme inline` mapping intact.
47
- 3. **Pairs**: every changed color gets its foreground pair in both modes; verify the lightness rule.
48
- 4. **Typography**: load the font with `next/font` in `app/layout.tsx`; point `--font-sans` in `globals.css` at the new variable; keep body >= 1rem / line-height 1.5.
49
- 5. **Identity**: update the `metadata` object in `app/layout.tsx` (title, description) and add Open Graph + Twitter Card fields. Research the current Next.js Metadata API before writing.
50
- 6. **Verify**: run the dev server; confirm both color modes, font, and head metadata. `yarn typecheck` passes.
51
+ 2. **Mode (light/dark/system)**: this is a deterministic option, not a hand edit. Run `npx @thorprovider/storefront theme --mode light|dark|system` to change the site color mode; it rewrites the `ThemeProvider` wiring in `app/layout.tsx` idempotently. Do NOT edit `app/layout.tsx` for the mode yourself. There is no visitor-facing toggle by design — the mode is a site setting.
52
+ 3. **Colors**: follow `../shadcn-theming/SKILL.md`; update BOTH `:root` (light) and `.dark` blocks in `app/globals.css`, keeping the `@theme inline` mapping intact.
53
+ 4. **Pairs**: every changed color gets its foreground pair in both modes; verify the lightness rule.
54
+ 5. **Typography**: load the font with `next/font` in `app/layout.tsx`; point `--font-sans` in `globals.css` at the new variable; keep body >= 1rem / line-height 1.5.
55
+ 6. **Identity**: update the `metadata` object in `app/layout.tsx` (title, description) and add Open Graph + Twitter Card fields. Research the current Next.js Metadata API before writing.
56
+ 7. **Verify**: run the dev server; confirm both color modes, font, and head metadata. `yarn typecheck` passes.
51
57
 
52
58
  ## Error handling
53
59
  - Invalid color input → re-prompt (accept `<name>`, `#hex`, `oklch(...)`).
54
60
  - No `lib/<name>/` → run `npx @thorprovider/storefront create`, stop.
55
61
  - Unknown token key → check `themeTokenKeys` in `@thorprovider/runtime/contracts` first.
62
+ - Mode change → always via `npx @thorprovider/storefront theme --mode ...`; never edit the layout by hand.
56
63
 
57
64
  ## Generation headers
58
65
  ```