@thorprovider/storefront 0.4.0-mvp.0 → 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 (166) hide show
  1. package/README.md +173 -153
  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 +42 -1
  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 +1 -5
  9. package/assets/templates/app/app/robots.ts +9 -3
  10. package/assets/templates/app/app/sitemap.ts +64 -5
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
  13. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
  16. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
  17. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
  19. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
  22. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
  26. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
  27. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  30. package/assets/templates/features/checkout/handlers.ts +6 -0
  31. package/assets/templates/features/customer-auth/state.ts +10 -1
  32. package/assets/templates/views/account/index.tsx +1 -1
  33. package/assets/templates/views/account/registry.tsx +80 -19
  34. package/assets/templates/views/account/schema.ts +1 -0
  35. package/assets/templates/views/cart/content.ts +11 -0
  36. package/assets/templates/views/cart/data.ts +23 -0
  37. package/assets/templates/views/cart/index.tsx +53 -0
  38. package/assets/templates/views/cart/props.ts +12 -0
  39. package/assets/templates/views/cart/registry.tsx +81 -0
  40. package/assets/templates/views/cart/schema.ts +52 -0
  41. package/assets/templates/views/category/assembly/assembly.json +1 -0
  42. package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
  43. package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
  44. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  45. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  46. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  47. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  48. package/assets/templates/views/category/index.tsx +76 -7
  49. package/assets/templates/views/category/props.ts +7 -0
  50. package/assets/templates/views/category/registry.tsx +19 -0
  51. package/assets/templates/views/category/schema.ts +13 -0
  52. package/assets/templates/views/home/assembly/assembly.json +9 -0
  53. package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
  54. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
  55. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
  56. package/assets/templates/views/home/data.ts +9 -0
  57. package/assets/templates/views/home/index.tsx +23 -5
  58. package/assets/templates/views/home/registry.tsx +2 -2
  59. package/assets/templates/views/product/data.ts +4 -2
  60. package/assets/templates/views/product/index.tsx +49 -5
  61. package/assets/templates/views/product/props.ts +7 -0
  62. package/assets/templates/views/product/registry.tsx +25 -0
  63. package/assets/templates/views/product/schema.ts +13 -0
  64. package/assets/templates/views/static/content.ts +4 -4
  65. package/assets/templates/views/static/index.tsx +1 -1
  66. package/bin/storefront.js +1231 -154
  67. package/examples/merchandising/category-led.json +37 -0
  68. package/examples/merchandising/collection-led.json +37 -0
  69. package/package.json +11 -7
  70. package/recipes/archetypes.json +9 -22
  71. package/recipes/catalog.json +40 -1
  72. package/recipes/features.json +25 -2
  73. package/recipes/recipe.schema.json +1 -1
  74. package/recipes/recipes.json +32 -30
  75. package/recipes/sections.json +10 -0
  76. package/recipes/validate.mjs +54 -0
  77. package/scripts/candidate.mjs +4 -4
  78. package/scripts/composers.mjs +25 -5
  79. package/scripts/env-file.mjs +86 -0
  80. package/scripts/file-classes.mjs +5 -0
  81. package/scripts/migrate-english-routes.mjs +276 -0
  82. package/scripts/pinning.mjs +74 -5
  83. package/scripts/plan-apply.mjs +149 -5
  84. package/scripts/reconcile.mjs +49 -11
  85. package/scripts/render-templates.mjs +97 -44
  86. package/scripts/section-config.mjs +253 -0
  87. package/scripts/starter.mjs +17 -0
  88. package/scripts/theme/color.mjs +185 -0
  89. package/scripts/theme/css-theme.mjs +233 -0
  90. package/scripts/theme/fonts.mjs +123 -0
  91. package/scripts/theme/import-export.mjs +125 -0
  92. package/scripts/theme/preset.mjs +93 -0
  93. package/scripts/theme/theme-plan.mjs +292 -0
  94. package/scripts/theme/tokens.mjs +137 -0
  95. package/scripts/view-assembly.mjs +47 -1
  96. package/skills/sf-feature/SKILL.md +32 -19
  97. package/skills/sf-setup/SKILL.md +79 -0
  98. package/skills/sf-theme-gen/SKILL.md +99 -36
  99. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  100. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  101. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  102. package/skills/sf-view-gen/SKILL.md +63 -40
  103. package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
  104. package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
  105. package/skills/sf-view-gen/assets/recipes/features.json +25 -2
  106. package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
  107. package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
  108. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  109. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  110. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  111. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  112. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  113. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  114. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  115. package/skills/sf-view-gen/references/views.md +28 -37
  116. package/skills/sf-view-gen/scripts/validate.mjs +54 -0
  117. package/skills/storefront-workflow/SKILL.md +94 -31
  118. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  119. package/CHANGELOG.md +0 -84
  120. package/assets/templates/views/user-panel/index.tsx +0 -106
  121. package/assets/templates/views/user-panel/registry.tsx +0 -54
  122. package/assets/templates/views/user-panel/schema.ts +0 -79
  123. package/commands/sf-add-view.md +0 -57
  124. package/commands/sf-feature.md +0 -28
  125. package/commands/sf-theme.md +0 -26
  126. package/commands/sf-view.md +0 -67
  127. package/skills/building-storefronts/SKILL.md +0 -178
  128. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  129. package/skills/json-render-remotion/SKILL.md +0 -111
  130. package/skills/json-render-solid/SKILL.md +0 -204
  131. package/skills/nextjs-shadcn/SKILL.md +0 -367
  132. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  133. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  134. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  135. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  136. package/skills/nextjs-shadcn/references/styling.md +0 -555
  137. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  138. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  139. package/skills/shadcn-theming/SKILL.md +0 -104
  140. package/skills/storefront-best-practices/SKILL.md +0 -421
  141. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  142. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  143. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  144. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  145. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  146. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  147. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  148. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  149. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  150. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  151. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  152. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  153. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  154. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  155. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  156. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  157. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  158. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  159. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  160. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  161. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  162. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  163. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  164. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  165. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  166. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,93 @@
1
+ // Ecosystem preset resolution (task 4.1).
2
+ //
3
+ // A shadcn preset (name, code, or builder URL) is resolved by the *pinned*
4
+ // shadcn CLI inside an isolated staging directory seeded with the consumer's own
5
+ // configuration, using `apply <preset> --only theme,font`. Only the resulting
6
+ // theme tokens and font wiring are read back and merged; the consumer's
7
+ // component files, base/structural style, icon library and import aliases are
8
+ // never touched, and a full `apply` is never run against the live project.
9
+
10
+ import { cp, mkdtemp, readFile, rm } from 'node:fs/promises'
11
+ import { existsSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { spawnSync } from 'node:child_process'
15
+ import { readThemeCss } from './css-theme.mjs'
16
+ import { FONT_VARS, THEME_COLOR_TOKENS } from './tokens.mjs'
17
+
18
+ // Files copied into staging so shadcn sees the real consumer config. Never the
19
+ // component source — only what theme/font resolution needs.
20
+ const STAGING_FILES = ['package.json', 'components.json', 'tsconfig.json', 'app/globals.css', 'app/layout.tsx']
21
+
22
+ function slugify(value) {
23
+ return value
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, '-')
27
+ .replace(/^-+|-+$/g, '')
28
+ }
29
+
30
+ // Default runner: stage the consumer config, run the pinned shadcn theme/font
31
+ // application, and let the caller read the staged stylesheet back.
32
+ export function makeShadcnRunner({ shadcnVersion, projectDir, run }) {
33
+ return async function runPreset(presetRef, stagingDir) {
34
+ for (const rel of STAGING_FILES) {
35
+ const from = join(projectDir, rel)
36
+ if (existsSync(from)) await cp(from, join(stagingDir, rel))
37
+ }
38
+ const args = ['dlx', `shadcn@${shadcnVersion}`, 'apply', presetRef, '--only', 'theme,font', '--yes']
39
+ return run('yarn', args, stagingDir)
40
+ }
41
+ }
42
+
43
+ function detectFonts(layout) {
44
+ const fonts = {}
45
+ const slots = { sans: 'sans', mono: 'mono', serif: 'serif' }
46
+ for (const [slot, word] of Object.entries(slots)) {
47
+ const m = layout.match(new RegExp(`variable:\\s*'--font-([a-z0-9-]+)-${word}'`))
48
+ if (m) fonts[slot] = { kind: 'google', value: m[1] }
49
+ }
50
+ return fonts
51
+ }
52
+
53
+ /**
54
+ * Resolve a preset to normalized theme data. Returns
55
+ * `{ ok: true, tokens, fonts, raw }` or `{ ok: false, diagnostics }`. `runPreset`
56
+ * is injected so the resolution is testable without network access.
57
+ */
58
+ export async function resolvePreset(presetRef, { projectDir, runPreset, keepStaging = false }) {
59
+ const stagingDir = await mkdtemp(join(tmpdir(), `cs-preset-${slugify(presetRef) || 'x'}-`))
60
+ try {
61
+ const result = await runPreset(presetRef, stagingDir)
62
+ if (result && result.status !== 0) {
63
+ return {
64
+ ok: false,
65
+ diagnostics: [
66
+ {
67
+ code: 'E_PRESET',
68
+ severity: 'error',
69
+ message: `shadcn could not resolve preset "${presetRef}" (exit ${result.status ?? 'unknown'}). Check the name/code/URL and your network.`,
70
+ },
71
+ ],
72
+ }
73
+ }
74
+ const globalsPath = join(stagingDir, 'app', 'globals.css')
75
+ if (!existsSync(globalsPath)) {
76
+ return {
77
+ ok: false,
78
+ diagnostics: [
79
+ { code: 'E_PRESET', severity: 'error', message: `preset "${presetRef}" produced no app/globals.css in staging` },
80
+ ],
81
+ }
82
+ }
83
+ const css = await readFile(globalsPath, 'utf8')
84
+ const tokens = readThemeCss(css)
85
+ const layoutPath = join(stagingDir, 'app', 'layout.tsx')
86
+ const fonts = existsSync(layoutPath) ? detectFonts(await readFile(layoutPath, 'utf8')) : {}
87
+ return { ok: true, tokens, fonts, raw: css }
88
+ } finally {
89
+ if (!keepStaging) await rm(stagingDir, { recursive: true, force: true })
90
+ }
91
+ }
92
+
93
+ export { spawnSync }
@@ -0,0 +1,292 @@
1
+ // Theme operation resolution: turn CLI inputs into a validated, deterministic
2
+ // change plan over app/globals.css (visual tokens) and app/layout.tsx (font
3
+ // wiring), then render the resulting files.
4
+ //
5
+ // One operation is resolved in a fixed precedence order (design decision 2):
6
+ // current state -> imported source -> brand -> primary/accent -> radius/font
7
+ // shortcuts -> explicit `--set` setters.
8
+ // Later steps win. The plan is computed entirely in memory so a dry-run and a
9
+ // real run share one code path, and a failure leaves the project untouched.
10
+
11
+ import {
12
+ bestForeground,
13
+ contrastRatio,
14
+ formatOklch,
15
+ isNamedColor,
16
+ parseColor,
17
+ shiftLightness,
18
+ NAMED_COLOR_NAMES,
19
+ } from './color.mjs'
20
+ import { applyDeclarations, readThemeCss, fillMissingTokens } from './css-theme.mjs'
21
+ import { BRAND_TOKENS, ACCENT_TOKENS, RUNTIME_ONLY_TOKENS, RUNTIME_TOKEN_DEFAULTS, THEME_COLOR_TOKENS } from './tokens.mjs'
22
+
23
+ const SCOPES = ['light', 'dark', 'shared']
24
+
25
+ function diagnostic(code, message, extra = {}) {
26
+ return { code, severity: extra.severity ?? 'error', message, ...extra }
27
+ }
28
+
29
+ // A `--set` entry: `light.primary=value`, `dark.accent-foreground=value`, or
30
+ // `shared.radius=value`. The token name is written without the leading `--`.
31
+ export function parseSetter(entry) {
32
+ const eq = entry.indexOf('=')
33
+ if (eq === -1) return { error: `--set expects <scope>.<token>=<value> (got "${entry}")` }
34
+ const target = entry.slice(0, eq)
35
+ const value = entry.slice(eq + 1)
36
+ const dot = target.indexOf('.')
37
+ if (dot === -1) return { error: `--set target must be <scope>.<token> (got "${target}")` }
38
+ const scope = target.slice(0, dot)
39
+ const name = target.slice(dot + 1)
40
+ if (!SCOPES.includes(scope)) return { error: `--set scope must be one of ${SCOPES.join(', ')} (got "${scope}")` }
41
+ if (!name) return { error: `--set is missing a token name (got "${target}")` }
42
+ if (value === '') return { error: `--set is missing a value (got "${entry}")` }
43
+ return { scope, name, value }
44
+ }
45
+
46
+ // Resolve and validate the whole operation before any write. Returns
47
+ // `{ operation, diagnostics }`; a non-empty error diagnostic list means the
48
+ // caller must abort without touching the project.
49
+ export function normalizeOperation(input = {}) {
50
+ const diagnostics = []
51
+ const operation = {
52
+ mode: input.mode ?? null,
53
+ brand: input.brand ?? null,
54
+ primary: input.primary ?? null,
55
+ accent: input.accent ?? null,
56
+ radius: input.radius ?? null,
57
+ fontSans: input.fontSans ?? null,
58
+ fontMono: input.fontMono ?? null,
59
+ fontSerif: input.fontSerif ?? null,
60
+ setters: [],
61
+ preset: input.preset ?? null,
62
+ presetOnly: input.presetOnly ?? null,
63
+ importSource: input.importSource ?? null,
64
+ exportPath: input.exportPath ?? null,
65
+ dryRun: Boolean(input.dryRun),
66
+ }
67
+
68
+ if (operation.mode !== null && !['light', 'dark', 'system'].includes(operation.mode)) {
69
+ diagnostics.push(diagnostic('E_MODE', `--mode must be one of light, dark, system (got "${operation.mode}")`))
70
+ }
71
+
72
+ const resolveColor = (label, value) => {
73
+ if (value === null) return null
74
+ const parsed = parseColor(value)
75
+ if (!parsed) {
76
+ diagnostics.push(
77
+ diagnostic(
78
+ 'E_COLOR',
79
+ `--${label} expects a color name (${NAMED_COLOR_NAMES.join(', ')}), a #hex, or an oklch(...) value (got "${value}")`,
80
+ ),
81
+ )
82
+ return null
83
+ }
84
+ return parsed
85
+ }
86
+ operation.brandColor = resolveColor('brand', operation.brand)
87
+ operation.primaryColor = resolveColor('primary', operation.primary)
88
+ operation.accentColor = resolveColor('accent', operation.accent)
89
+
90
+ if (operation.radius !== null && !/^(0|\d*\.?\d+)(rem|px|em)$/.test(String(operation.radius))) {
91
+ diagnostics.push(diagnostic('E_LENGTH', `--radius expects a CSS length such as 0.75rem or 8px (got "${operation.radius}")`))
92
+ }
93
+
94
+ const resolveFont = (label, value) => {
95
+ if (value === null) return null
96
+ if (typeof value === 'string' && (value.startsWith('google:') || value.startsWith('system:'))) {
97
+ const [kind, rest] = [value.slice(0, value.indexOf(':')), value.slice(value.indexOf(':') + 1)]
98
+ if (!rest.trim()) {
99
+ diagnostics.push(diagnostic('E_FONT', `--${label} "${kind}:" is missing a ${kind === 'google' ? 'family' : 'stack'}`))
100
+ return null
101
+ }
102
+ return { kind, value: rest.trim() }
103
+ }
104
+ diagnostics.push(
105
+ diagnostic('E_FONT', `--${label} must be google:<Family> or system:<stack> (got "${value}")`),
106
+ )
107
+ return null
108
+ }
109
+ operation.fonts = {
110
+ sans: resolveFont('font-sans', operation.fontSans),
111
+ mono: resolveFont('font-mono', operation.fontMono),
112
+ serif: resolveFont('font-serif', operation.fontSerif),
113
+ }
114
+
115
+ const seen = new Set()
116
+ for (const entry of input.sets ?? []) {
117
+ const parsed = parseSetter(entry)
118
+ if (parsed.error) {
119
+ diagnostics.push(diagnostic('E_SET', parsed.error))
120
+ continue
121
+ }
122
+ const key = `${parsed.scope}.${parsed.name}`
123
+ if (seen.has(key)) {
124
+ diagnostics.push(diagnostic('E_SET_DUP', `--set assigns ${key} more than once`))
125
+ continue
126
+ }
127
+ seen.add(key)
128
+ operation.setters.push(parsed)
129
+ }
130
+
131
+ if (operation.presetOnly !== null && operation.preset === null) {
132
+ diagnostics.push(diagnostic('E_ONLY', '--only requires --preset'))
133
+ }
134
+
135
+ if (operation.exportPath && hasMutation(operation)) {
136
+ diagnostics.push(diagnostic('E_EXPORT_MIX', '--export cannot be combined with theme mutations; export writes only the export file'))
137
+ }
138
+
139
+ return { operation, diagnostics }
140
+ }
141
+
142
+ function hasMutation(operation) {
143
+ return Boolean(
144
+ operation.mode ||
145
+ operation.brand ||
146
+ operation.primary ||
147
+ operation.accent ||
148
+ operation.radius ||
149
+ operation.fontSans ||
150
+ operation.fontMono ||
151
+ operation.fontSerif ||
152
+ operation.setters.length ||
153
+ operation.preset ||
154
+ operation.importSource,
155
+ )
156
+ }
157
+
158
+ // The primary/brand family derived from one color, per mode.
159
+ export function brandFamily(color, mode) {
160
+ const fg = bestForeground(color).color
161
+ const hover = shiftLightness(color, mode === 'light' ? -0.08 : 0.1)
162
+ const soft = { l: mode === 'light' ? 0.93 : 0.28, c: Math.min(color.c, 0.06), h: color.h }
163
+ return {
164
+ primary: formatOklch(color),
165
+ 'primary-foreground': formatOklch(fg),
166
+ 'primary-hover': formatOklch(hover),
167
+ 'primary-soft': formatOklch(soft),
168
+ ring: formatOklch(color),
169
+ nav: formatOklch(color),
170
+ }
171
+ }
172
+
173
+ export function accentFamily(color) {
174
+ return {
175
+ accent: formatOklch(color),
176
+ 'accent-foreground': formatOklch(bestForeground(color).color),
177
+ }
178
+ }
179
+
180
+ // Text/background pairs whose contrast the operation should report on.
181
+ const CONTRAST_PAIRS = [
182
+ ['primary', 'primary-foreground'],
183
+ ['accent', 'accent-foreground'],
184
+ ['background', 'foreground'],
185
+ ['card', 'card-foreground'],
186
+ ['popover', 'popover-foreground'],
187
+ ['secondary', 'secondary-foreground'],
188
+ ['muted', 'muted-foreground'],
189
+ ['destructive', 'destructive-foreground'],
190
+ ['promo', 'promo-foreground'],
191
+ ]
192
+
193
+ // Build the change plan for `css` (globals.css content). Returns
194
+ // `{ css, changed, diagnostics, values }`. `changed` lists `scope.token` keys
195
+ // whose value actually changed; `values` is the resolved palette.
196
+ export function planCss(css, operation) {
197
+ const diagnostics = []
198
+ const edits = []
199
+ const push = (scope, name, value) => edits.push({ scope, name, value })
200
+
201
+ const modes = ['light', 'dark']
202
+
203
+ // Imported source (local CSS export, registry theme or a prior storefront
204
+ // export) is applied first so brand/primary/accent and explicit setters can
205
+ // override it.
206
+ if (operation.importSource) {
207
+ let imported = null
208
+ if (operation.importSource.type === 'css') imported = readThemeCss(operation.importSource.css)
209
+ else if (operation.importSource.type === 'tokens') imported = operation.importSource.tokens
210
+ if (imported) {
211
+ for (const mode of modes) {
212
+ for (const [rawName, value] of Object.entries(imported[mode] ?? {})) {
213
+ const name = rawName.startsWith('--') ? rawName : `--${rawName}`
214
+ push(mode, name, value)
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // Brand / primary: the same color in both modes.
221
+ const primarySource = operation.brandColor ?? operation.primaryColor
222
+ if (primarySource) {
223
+ for (const mode of modes) {
224
+ const family = brandFamily(primarySource, mode)
225
+ for (const token of BRAND_TOKENS) push(mode, `--${token}`, family[token])
226
+ }
227
+ }
228
+
229
+ if (operation.accentColor) {
230
+ const family = accentFamily(operation.accentColor)
231
+ for (const mode of modes) for (const token of ACCENT_TOKENS) push(mode, `--${token}`, family[token])
232
+ }
233
+
234
+ if (operation.radius !== null) push('light', '--radius', String(operation.radius))
235
+
236
+ // Explicit setters last: they win over shortcuts and imports.
237
+ for (const setter of operation.setters) {
238
+ if (setter.scope === 'shared') push('light', `--${setter.name}`, setter.value)
239
+ else push(setter.scope, `--${setter.name}`, setter.value)
240
+ }
241
+
242
+ let next = applyDeclarations(css, edits)
243
+
244
+ // Completeness: the stylesheet owns every runtime token. Fill only what is
245
+ // absent so an existing (possibly branded) value is never replaced.
246
+ next = fillMissingTokens(next, [...THEME_COLOR_TOKENS, ...RUNTIME_ONLY_TOKENS], RUNTIME_TOKEN_DEFAULTS)
247
+
248
+ // Report measured contrast for the pairs that changed.
249
+ const before = readThemeCss(css)
250
+ const after = readThemeCss(next)
251
+ const changed = []
252
+ for (const edit of edits) {
253
+ const scope = edit.scope === 'shared' ? 'light' : edit.scope
254
+ if (before[scope][edit.name] !== edit.value) changed.push(`${scope}.${edit.name}`)
255
+ }
256
+ for (const mode of modes) {
257
+ for (const [bg, fg] of CONTRAST_PAIRS) {
258
+ const bgVal = after[mode][`--${bg}`]
259
+ const fgVal = after[mode][`--${fg}`]
260
+ if (!bgVal || !fgVal) continue
261
+ const touched = changed.includes(`${mode}.--${bg}`) || changed.includes(`${mode}.--${fg}`)
262
+ if (!touched) continue
263
+ const bgColor = parseColor(bgVal)
264
+ const fgColor = parseColor(fgVal)
265
+ if (!bgColor || !fgColor) continue
266
+ const ratio = contrastRatio(bgColor, fgColor)
267
+ if (ratio < 4.5) {
268
+ diagnostics.push(
269
+ diagnostic('W_CONTRAST', `${mode}: --${bg}/--${fg} contrast is ${ratio.toFixed(2)}:1 (below 4.5:1)`, {
270
+ severity: 'warning',
271
+ mode,
272
+ pair: [bg, fg],
273
+ ratio: Number(ratio.toFixed(2)),
274
+ }),
275
+ )
276
+ }
277
+ }
278
+ }
279
+
280
+ return { css: next, changed, diagnostics, values: after }
281
+ }
282
+
283
+ // High-level: compute the plan for a project's current file contents.
284
+ export function planTheme({ css, operation }) {
285
+ const { operation: op, diagnostics: opDiagnostics } = normalizeOperation(operation)
286
+ if (opDiagnostics.some((d) => d.severity === 'error')) {
287
+ return { ok: false, diagnostics: opDiagnostics }
288
+ }
289
+ const planned = planCss(css, op)
290
+ const diagnostics = [...opDiagnostics, ...planned.diagnostics]
291
+ return { ok: true, css: planned.css, changed: planned.changed, values: planned.values, diagnostics, operation: op }
292
+ }
@@ -0,0 +1,137 @@
1
+ // Token metadata for the theme controls: which tokens the CSS owns, which
2
+ // belong to the brand family, and the geometry/font variables the CLI can edit.
3
+ //
4
+ // RUNTIME_TOKEN_DEFAULTS mirrors @thorprovider/runtime/contracts
5
+ // (theme-tokens.ts). The CLI is a standalone Node program and cannot import the
6
+ // runtime TypeScript, so the values are embedded here exactly as the runtime
7
+ // declares them; `theme-tokens.parity.test.ts` parses the runtime source and
8
+ // fails if the two drift. These values are the *fallback* the scaffold writes
9
+ // when a generated stylesheet is missing a runtime-only token — never an
10
+ // override of a value a developer already set.
11
+
12
+ // The 29 tokens in the runtime theme contract, in contract order.
13
+ export const THEME_COLOR_TOKENS = [
14
+ 'background', 'foreground', 'card', 'card-foreground', 'popover', 'popover-foreground',
15
+ 'primary', 'primary-foreground', 'primary-hover', 'primary-soft', 'secondary',
16
+ 'secondary-foreground', 'muted', 'muted-foreground', 'accent', 'accent-foreground',
17
+ 'border', 'input', 'ring', 'destructive', 'destructive-foreground', 'nav', 'promo',
18
+ 'promo-foreground', 'stock', 'urgent', 'success', 'warning', 'error',
19
+ ]
20
+
21
+ // Tokens written by a brand/primary/accent change as one coherent family.
22
+ export const BRAND_TOKENS = ['primary', 'primary-foreground', 'primary-hover', 'primary-soft', 'ring', 'nav']
23
+ export const ACCENT_TOKENS = ['accent', 'accent-foreground']
24
+
25
+ // Geometry + font variables editable through the bounded external surface. The
26
+ // radius scale is derived from a single `--radius` base (shadcn convention).
27
+ export const RADIUS_BASE_VAR = '--radius'
28
+ export const RADIUS_SCALE = { sm: 'calc(var(--radius) - 4px)', md: 'calc(var(--radius) - 2px)', lg: 'var(--radius)', xl: 'calc(var(--radius) + 4px)' }
29
+ export const FONT_VARS = ['--font-sans', '--font-mono', '--font-serif']
30
+
31
+ export const RUNTIME_TOKEN_DEFAULTS = {
32
+ light: {
33
+ background: 'oklch(0.98 0.002 90)',
34
+ foreground: 'oklch(0.15 0 0)',
35
+ card: 'oklch(1 0 0)',
36
+ 'card-foreground': 'oklch(0.15 0 0)',
37
+ popover: 'oklch(1 0 0)',
38
+ 'popover-foreground': 'oklch(0.15 0 0)',
39
+ primary: 'oklch(0.15 0 0)',
40
+ 'primary-foreground': 'oklch(0.98 0.002 90)',
41
+ 'primary-hover': 'oklch(0.3 0.01 0)',
42
+ 'primary-soft': 'oklch(0.92 0.003 90)',
43
+ secondary: 'oklch(0.96 0.003 90)',
44
+ 'secondary-foreground': 'oklch(0.15 0 0)',
45
+ muted: 'oklch(0.94 0.003 90)',
46
+ 'muted-foreground': 'oklch(0.45 0 0)',
47
+ accent: 'oklch(0.65 0.15 25)',
48
+ 'accent-foreground': 'oklch(1 0 0)',
49
+ border: 'oklch(0.88 0.003 90)',
50
+ input: 'oklch(0.92 0.003 90)',
51
+ ring: 'oklch(0.15 0 0)',
52
+ destructive: 'oklch(0.55 0.2 25)',
53
+ 'destructive-foreground': 'oklch(1 0 0)',
54
+ nav: 'oklch(0.15 0 0)',
55
+ promo: 'oklch(0.8 0.15 45)',
56
+ 'promo-foreground': 'oklch(1 0 0)',
57
+ stock: 'oklch(0.55 0.18 155)',
58
+ urgent: 'oklch(0.55 0.2 27)',
59
+ success: 'oklch(0.55 0.18 155)',
60
+ warning: 'oklch(0.65 0.16 75)',
61
+ error: 'oklch(0.55 0.2 27)',
62
+ },
63
+ dark: {
64
+ background: 'oklch(0.145 0 0)',
65
+ foreground: 'oklch(0.985 0 0)',
66
+ card: 'oklch(0.145 0 0)',
67
+ 'card-foreground': 'oklch(0.985 0 0)',
68
+ popover: 'oklch(0.145 0 0)',
69
+ 'popover-foreground': 'oklch(0.985 0 0)',
70
+ primary: 'oklch(0.985 0 0)',
71
+ 'primary-foreground': 'oklch(0.205 0 0)',
72
+ 'primary-hover': 'oklch(0.85 0.01 0)',
73
+ 'primary-soft': 'oklch(0.269 0 0)',
74
+ secondary: 'oklch(0.269 0 0)',
75
+ 'secondary-foreground': 'oklch(0.985 0 0)',
76
+ muted: 'oklch(0.269 0 0)',
77
+ 'muted-foreground': 'oklch(0.708 0 0)',
78
+ accent: 'oklch(0.269 0 0)',
79
+ 'accent-foreground': 'oklch(0.985 0 0)',
80
+ border: 'oklch(0.269 0 0)',
81
+ input: 'oklch(0.269 0 0)',
82
+ ring: 'oklch(0.439 0 0)',
83
+ destructive: 'oklch(0.396 0.141 25.723)',
84
+ 'destructive-foreground': 'oklch(0.637 0.237 25.331)',
85
+ nav: 'oklch(0.985 0 0)',
86
+ promo: 'oklch(0.6 0.16 45)',
87
+ 'promo-foreground': 'oklch(1 0 0)',
88
+ stock: 'oklch(0.6 0.14 155)',
89
+ urgent: 'oklch(0.6 0.18 27)',
90
+ success: 'oklch(0.6 0.14 155)',
91
+ warning: 'oklch(0.7 0.14 75)',
92
+ error: 'oklch(0.6 0.18 27)',
93
+ },
94
+ }
95
+
96
+ // The subset that historically lived only in the runtime injection (not in the
97
+ // shadcn-generated stylesheet). The scaffold fills exactly these when absent.
98
+ export const RUNTIME_ONLY_TOKENS = [
99
+ 'primary-hover', 'primary-soft', 'nav', 'promo', 'promo-foreground', 'stock', 'urgent', 'error',
100
+ ]
101
+
102
+ // Every CSS declaration the generated stylesheet must own so the runtime palette
103
+ // injection can be removed without losing a token: the 29 runtime tokens plus the
104
+ // block contract's foreground pairs.
105
+ export const REQUIRED_CSS_TOKENS = [
106
+ ...THEME_COLOR_TOKENS,
107
+ 'success-foreground',
108
+ 'warning-foreground',
109
+ ]
110
+
111
+ // Fallback values the scaffold fills for a missing required token — never an
112
+ // override of a value already present. Success/warning use the block contract's
113
+ // AA-conformant pair (mirrored by assets/templates/app/app/globals.css), the rest
114
+ // mirror the runtime contract.
115
+ export const DEFAULT_GLOBALS_TOKENS = {
116
+ light: {
117
+ ...RUNTIME_TOKEN_DEFAULTS.light,
118
+ success: 'oklch(0.47 0.14 155)',
119
+ 'success-foreground': 'oklch(0.98 0.01 155)',
120
+ warning: 'oklch(0.55 0.18 75)',
121
+ 'warning-foreground': 'oklch(0.98 0.02 75)',
122
+ },
123
+ dark: {
124
+ ...RUNTIME_TOKEN_DEFAULTS.dark,
125
+ success: 'oklch(0.6 0.13 155)',
126
+ 'success-foreground': 'oklch(0.1 0.02 155)',
127
+ warning: 'oklch(0.7 0.15 75)',
128
+ 'warning-foreground': 'oklch(0.1 0.02 75)',
129
+ },
130
+ }
131
+
132
+ export function isThemeColorToken(name) {
133
+ return THEME_COLOR_TOKENS.includes(name)
134
+ }
135
+
136
+ export const THEME_MODES = ['light', 'dark', 'system']
137
+ export const DEFAULT_THEME_MODE = 'system'
@@ -153,6 +153,38 @@ function renderEditorialRegistry(entry, def) {
153
153
  return ` ${def.elementType}: ${def.registryExport}.${def.elementType},`
154
154
  }
155
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
+
156
188
  /**
157
189
  * Assemble a view's derived `index.tsx` and `registry.tsx` from the view's
158
190
  * single ordered composition (E3 task 5.1).
@@ -194,8 +226,10 @@ export function assembleView(templateDir, selection = {}, sections = null) {
194
226
  const elements = []
195
227
  const catalog = []
196
228
  const registry = []
229
+ const registryTypes = new Set()
197
230
  const bindings = []
198
231
  const slotIds = []
232
+ const dataBoundIds = []
199
233
  const sectionPropsModules = new Set()
200
234
  const sectionRegistryImports = new Map()
201
235
  for (const entry of ordered) {
@@ -231,11 +265,22 @@ export function assembleView(templateDir, selection = {}, sections = null) {
231
265
  `View "${meta.view}" has no editorial fragment for section "${entry.type}".`,
232
266
  )
233
267
  }
234
- elements.push(renderEditorialElement(entry, def))
268
+ elements.push(
269
+ def.dataBound === true
270
+ ? renderDataBoundElement(entry, def)
271
+ : renderEditorialElement(entry, def),
272
+ )
235
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)
236
280
  slotIds.push(`'${entry.id}'`)
237
281
  sectionPropsModules.add(def.propsModule ?? './content')
238
282
  sectionRegistryImports.set(def.registryModule, def.registryExport)
283
+ if (def.dataBound === true) dataBoundIds.push(`'${entry.id}'`)
239
284
  }
240
285
 
241
286
  const indexSkeleton = readFileSync(path.join(assemblyDir(templateDir), 'index.skeleton.tsx'), 'utf8')
@@ -246,6 +291,7 @@ export function assembleView(templateDir, selection = {}, sections = null) {
246
291
 
247
292
  const index = indexSkeleton
248
293
  .replace('@@SECTION_IMPORTS@@', () => renderSectionImports(sectionPropsModules))
294
+ .replace('@@SECTION_LOADERS@@', () => `[${dataBoundIds.join(', ')}]`)
249
295
  .replace('@@ELEMENTS@@', () => elements.join('\n'))
250
296
  .replace('@@CATALOG@@', () => catalog.join('\n'))
251
297
  .replace('@@SLOT_IDS@@', () => slotIds.join(', '))