@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.
- package/CHANGELOG.md +23 -0
- package/README.md +248 -99
- package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
- package/assets/templates/app/app/layout.tsx +18 -4
- package/assets/templates/app/app/robots.ts +13 -0
- package/assets/templates/app/app/sitemap.ts +12 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +153 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +36 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +378 -0
- package/assets/templates/views/account/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +28 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +230 -0
- package/assets/templates/views/category/props.ts +16 -0
- package/assets/templates/views/category/registry.tsx +103 -0
- package/assets/templates/views/category/schema.ts +78 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +24 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +15 -0
- package/assets/templates/views/home/index.tsx +38 -84
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +48 -0
- package/assets/templates/views/product/index.tsx +104 -0
- package/assets/templates/views/product/props.ts +20 -0
- package/assets/templates/views/product/registry.tsx +78 -0
- package/assets/templates/views/product/schema.ts +108 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
- package/assets/templates/views/user-panel/schema.ts +79 -0
- package/bin/storefront.js +1278 -79
- package/commands/sf-add-view.md +44 -13
- package/commands/sf-feature.md +28 -0
- package/commands/sf-theme.md +14 -8
- package/commands/sf-view.md +54 -12
- package/package.json +6 -4
- package/recipes/archetypes.json +28 -1
- package/recipes/catalog.json +361 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +139 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +60 -5
- package/recipes/sections.json +24 -5
- package/recipes/validate.mjs +784 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +140 -0
- package/scripts/file-classes.mjs +116 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +504 -0
- package/scripts/plan-apply.mjs +843 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +511 -0
- package/scripts/render-templates.mjs +245 -28
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/view-assembly.mjs +291 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +67 -0
- package/skills/sf-theme-gen/SKILL.md +12 -5
- package/skills/sf-view-gen/SKILL.md +123 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
- package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +139 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
- package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/sf-view-gen/references/views.md +44 -10
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +784 -32
- package/skills/storefront-workflow/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- 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. **
|
|
47
|
-
3. **
|
|
48
|
-
4. **
|
|
49
|
-
5. **
|
|
50
|
-
6. **
|
|
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
|
```
|