create-ailk 0.7.0 → 0.9.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 (310) hide show
  1. package/README.md +112 -10
  2. package/component-catalog.json +798 -49
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module.d.ts +20 -0
  5. package/dist/add-module.js +43 -7
  6. package/dist/add-route.d.ts +56 -0
  7. package/dist/add-route.js +286 -0
  8. package/dist/apply-design-md.d.ts +7 -0
  9. package/dist/apply-design-md.js +50 -0
  10. package/dist/apply-design.d.ts +64 -0
  11. package/dist/apply-design.js +273 -0
  12. package/dist/claude-md.d.ts +111 -0
  13. package/dist/claude-md.js +334 -0
  14. package/dist/cli.js +114 -28
  15. package/dist/derive-architecture.js +41 -36
  16. package/dist/design-compile.d.ts +71 -0
  17. package/dist/design-compile.js +98 -0
  18. package/dist/design-package.d.ts +83 -0
  19. package/dist/design-package.js +468 -0
  20. package/dist/design-presets.d.ts +86 -0
  21. package/dist/design-presets.js +642 -0
  22. package/dist/design-scaffold.d.ts +76 -0
  23. package/dist/design-scaffold.js +220 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +4 -1
  26. package/dist/legacy-nav-renderers.d.ts +45 -0
  27. package/dist/legacy-nav-renderers.js +545 -0
  28. package/dist/lib/auth-secret-file.d.ts +19 -0
  29. package/dist/lib/auth-secret-file.js +112 -0
  30. package/dist/lib/design-checks.d.ts +78 -0
  31. package/dist/lib/design-checks.js +174 -0
  32. package/dist/lib/extract-module-bundle.js +5 -1
  33. package/dist/lib/license-key-files.d.ts +9 -0
  34. package/dist/lib/license-key-files.js +2 -2
  35. package/dist/lib/module-license-gate.d.ts +89 -0
  36. package/dist/lib/module-license-gate.js +171 -12
  37. package/dist/lib/module-tier.d.ts +45 -16
  38. package/dist/lib/module-tier.js +76 -0
  39. package/dist/lib/pro-callout.js +10 -2
  40. package/dist/lib/render-verdict.d.ts +52 -0
  41. package/dist/lib/render-verdict.js +77 -0
  42. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  43. package/dist/lib/scaffold-render-sweep.js +236 -0
  44. package/dist/module-architecture.d.ts +19 -16
  45. package/dist/module-architecture.js +64 -21
  46. package/dist/parse-args.d.ts +88 -10
  47. package/dist/parse-args.js +272 -21
  48. package/dist/programmatic.d.ts +66 -12
  49. package/dist/programmatic.js +361 -34
  50. package/dist/site-chrome.d.ts +19 -9
  51. package/dist/site-chrome.js +23 -13
  52. package/dist/site-manifest.d.ts +25 -0
  53. package/dist/site-manifest.js +254 -0
  54. package/dist/site-roster.d.ts +41 -0
  55. package/dist/site-roster.js +149 -11
  56. package/dist/starter-grid.d.ts +232 -0
  57. package/dist/starter-grid.js +545 -0
  58. package/dist/surfaces.d.ts +41 -9
  59. package/dist/surfaces.js +268 -92
  60. package/dist/template-manifest.d.ts +65 -29
  61. package/dist/template-manifest.js +161 -382
  62. package/dist/template-names.d.ts +14 -12
  63. package/dist/template-names.js +13 -17
  64. package/package.json +5 -4
  65. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  66. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  67. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  68. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  69. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  70. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  71. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  72. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  73. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  74. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  75. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  76. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  77. package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
  78. package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
  79. package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
  80. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  81. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  82. package/site-templates/designs/minimalism/sample/README.md +48 -0
  83. package/site-templates/designs/standard/DESIGN.md +103 -6
  84. package/site-templates/marketing-site/DESIGN.md +103 -6
  85. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
  86. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
  87. package/site-templates/marketing-site-free/DESIGN.md +103 -6
  88. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
  89. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
  90. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  91. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  92. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  93. package/site-templates/saas-product/DESIGN.md +103 -6
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
  97. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  98. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  99. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  100. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  101. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  102. package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  103. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  104. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  105. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  106. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  107. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  108. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
  110. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  111. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  112. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  113. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  114. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  115. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  116. package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
  117. package/site-templates/shapes/organization-service/provenance.json +4 -0
  118. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
  119. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
  120. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  121. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  122. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  123. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  124. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  125. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
  126. package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
  127. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  128. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  129. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  130. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  131. package/site-templates/shapes/person-service/provenance.json +4 -0
  132. package/templates/.claude/rules/architecture.md +7 -0
  133. package/templates/.claude/skills/README.md +5 -3
  134. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  135. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  136. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  137. package/templates/.claude/skills/add-page/references/page-types.md +85 -72
  138. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  139. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  140. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  141. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  142. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  143. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  144. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  145. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  146. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  147. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  148. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  149. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  150. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
  151. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  152. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  153. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  154. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  155. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  156. package/templates/.github/workflows/ci.yml +4 -0
  157. package/templates/.prettierignore +1 -0
  158. package/templates/CLAUDE.md +146 -103
  159. package/templates/DESIGN.md +103 -6
  160. package/templates/README.md +63 -43
  161. package/templates/apps/api/.env.example +2 -1
  162. package/templates/apps/api/CLAUDE.md +3 -2
  163. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  164. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  165. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  166. package/templates/apps/api/src/config/index.ts +35 -16
  167. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  168. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  169. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  170. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  171. package/templates/apps/api/src/lib/stripe.ts +9 -0
  172. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  173. package/templates/apps/api/src/middleware/auth.ts +54 -0
  174. package/templates/apps/api/src/openapi/spec.ts +63 -12
  175. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  176. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  177. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  178. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  179. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  180. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  181. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  182. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  183. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  184. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  185. package/templates/apps/api/src/server.ts +35 -0
  186. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  187. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  188. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  189. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  190. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  191. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  192. package/templates/apps/api/src/services/revalidation.ts +60 -15
  193. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  194. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  197. package/templates/apps/web/.env.example +13 -0
  198. package/templates/apps/web/CLAUDE.md +22 -28
  199. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  200. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  201. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  202. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  203. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  205. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  206. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  207. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  208. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  209. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  210. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  211. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  212. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  213. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  214. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  215. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  216. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  217. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  218. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
  219. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  220. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  221. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  222. package/templates/apps/web/app/[locale]/layout.tsx +65 -17
  223. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  224. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  225. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  226. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  227. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  228. package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
  229. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  230. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  232. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  233. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  234. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  235. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  236. package/templates/apps/web/lib/locale-guard.ts +18 -0
  237. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  238. package/templates/apps/web/lib/nav-config.ts +50 -37
  239. package/templates/apps/web/lib/site-brand.tsx +13 -9
  240. package/templates/apps/web/lib/site-identity.ts +14 -0
  241. package/templates/apps/web/lib/site-logo.tsx +36 -0
  242. package/templates/apps/web/lib/site-theme.ts +46 -26
  243. package/templates/apps/web/messages/en.json +45 -5
  244. package/templates/apps/web/middleware.ts +28 -4
  245. package/templates/content/_site.mdx +4 -2
  246. package/templates/content/en/BlogList/blog.mdx +37 -16
  247. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  248. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  249. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  250. package/templates/content/en/BookCall/start-project.mdx +56 -0
  251. package/templates/content/en/ContactPage/contact.mdx +43 -34
  252. package/templates/content/en/HomePage/home.mdx +10 -22
  253. package/templates/content/en/LandingPage/meet.mdx +59 -0
  254. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  255. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  256. package/templates/database/CHANGELOG.md +14 -0
  257. package/templates/database/package.json +1 -1
  258. package/templates/package.json +2 -1
  259. package/templates/tools/check-claude-md.ts +128 -0
  260. package/versions.json +33 -33
  261. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
  262. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  263. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  264. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  265. package/site-templates/minimalism-site/DESIGN.md +0 -162
  266. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
  267. package/site-templates/minimalism-site/ailk-template.json +0 -23
  268. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  269. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
  270. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  271. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  272. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  273. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  274. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  275. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  276. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  277. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  278. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  279. package/site-templates/minimalism-site/provenance.json +0 -4
  280. package/site-templates/simple-service/DESIGN.md +0 -162
  281. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  282. package/site-templates/simple-service/ailk-template.json +0 -11
  283. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  284. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  285. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  286. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  287. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  288. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  289. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  290. package/site-templates/simple-service/provenance.json +0 -4
  291. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  292. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  293. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  294. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  295. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
  296. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  297. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  298. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  299. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  300. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  301. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  302. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  303. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  304. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  305. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  306. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  307. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
  308. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
  309. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
  310. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
@@ -20,16 +20,21 @@
20
20
  * true of any declared palette. Deleting the `theme:` block still fails, on the
21
21
  * `toBeDefined()` below.
22
22
  */
23
- import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { join } from "node:path";
26
26
 
27
- import { parse as parseYaml } from "yaml";
27
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
28
28
 
29
- import { PartialColorPaletteSchema } from "@working-theory/site-config/schema";
30
- import { siteColorTokenMap, themeToCssVarsBothModes } from "@working-theory/theme";
29
+ import { PartialColorPaletteSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
30
+ import {
31
+ siteColorTokenMap,
32
+ siteThemeShapeCss,
33
+ siteThemeToBrandTokens,
34
+ themeToCssVarsBothModes,
35
+ } from "@working-theory/theme";
31
36
 
32
- import { loadSiteBrandTokens } from "../site-theme";
37
+ import { loadSiteBrandTokens, loadSiteShapeCss } from "../site-theme";
33
38
 
34
39
  const REPO_CONTENT_ROOT = join(__dirname, "..", "..", "..", "..", "content");
35
40
 
@@ -46,19 +51,28 @@ function darkBlock(css: string): string {
46
51
  /** The two modes, as the palette and the emitted CSS both name them. */
47
52
  const MODES = ["light", "dark"] as const;
48
53
 
54
+ type ModeColors = Partial<Record<(typeof MODES)[number], Record<string, string>>>;
55
+
49
56
  /**
50
- * The `theme.colors` block exactly as the `_site.mdx` under test declares it —
51
- * raw SLOT names (`text`, `primary`), not token keys. Read with the same
57
+ * The colours the `_site.mdx` under test declares — `theme.colors` with any
58
+ * `brand.colors` (the brand triad, #6820) layered over it per mode — as raw
59
+ * SLOT names (`text`, `primary`), not token keys. Read with the same
52
60
  * frontmatter parse `site-theme.ts` uses, so the test cannot pass against a
53
- * file the implementation reads differently.
61
+ * file the implementation reads differently, and derived from the layered
62
+ * result so a scaffold whose owner sets a triad stays green.
54
63
  */
55
- function declaredColors(): Partial<Record<(typeof MODES)[number], Record<string, string>>> {
64
+ function declaredColors(): ModeColors {
56
65
  const raw = readFileSync(join(REPO_CONTENT_ROOT, "_site.mdx"), "utf8");
57
66
  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
58
67
  const parsed = parseYaml(frontmatter?.[1] ?? "") as
59
- | { theme?: { colors?: Record<string, Record<string, string>> } }
68
+ | { theme?: { colors?: ModeColors }; brand?: { colors?: ModeColors } }
60
69
  | null;
61
- return parsed?.theme?.colors ?? {};
70
+ const out: ModeColors = {};
71
+ for (const mode of MODES) {
72
+ const layered = { ...parsed?.theme?.colors?.[mode], ...parsed?.brand?.colors?.[mode] };
73
+ if (Object.keys(layered).length > 0) out[mode] = layered;
74
+ }
75
+ return out;
62
76
  }
63
77
 
64
78
  /**
@@ -210,6 +224,85 @@ describe("loadSiteBrandTokens — the real content/_site.mdx", () => {
210
224
  });
211
225
  });
212
226
 
227
+ /**
228
+ * The brand triad (#6820) — `brand.colors` / `brand.fonts` layered over the
229
+ * theme. The golden: a site with a theme and no triad emits exactly the CSS
230
+ * the pre-triad formula emitted; a site with a triad carries its fonts and
231
+ * primary into both mode blocks.
232
+ */
233
+ describe("loadSiteBrandTokens — the brand triad", () => {
234
+ const originalRoot = process.env.AILK_CONTENT_ROOT;
235
+ const tempRoots: string[] = [];
236
+
237
+ afterEach(() => {
238
+ if (originalRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
239
+ else process.env.AILK_CONTENT_ROOT = originalRoot;
240
+ for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
241
+ });
242
+
243
+ /** The repo's own `theme:` block, as YAML text and as parsed. */
244
+ function repoTheme(): { yaml: string; parsed: unknown } {
245
+ const raw = readFileSync(join(REPO_CONTENT_ROOT, "_site.mdx"), "utf8");
246
+ const parsed = (
247
+ parseYaml(/^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? "") as { theme?: unknown }
248
+ ).theme;
249
+ return { yaml: stringifyYaml({ theme: parsed }), parsed };
250
+ }
251
+
252
+ function useSiteMdx(frontmatter: string): void {
253
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-triad-"));
254
+ tempRoots.push(root);
255
+ writeFileSync(join(root, "_site.mdx"), `---\nsiteName: Test\n${frontmatter}---\n`, "utf8");
256
+ process.env.AILK_CONTENT_ROOT = root;
257
+ }
258
+
259
+ /** The layout's emission, verbatim (`app/[locale]/layout.tsx`). */
260
+ function layoutCss(): string {
261
+ const brand = loadSiteBrandTokens();
262
+ return themeToCssVarsBothModes(brand ? { brand } : {}) + loadSiteShapeCss();
263
+ }
264
+
265
+ it("golden: the repo's theme with no triad emits the pre-triad CSS byte for byte", () => {
266
+ const { yaml, parsed } = repoTheme();
267
+ useSiteMdx(`${yaml}brand:\n logomark: { light: /logomark.svg, dark: /logomark.svg, alt: Test }\n`);
268
+
269
+ const theme = SiteThemeSchema.parse(parsed);
270
+ const before = siteThemeToBrandTokens(theme);
271
+ expect(layoutCss()).toBe(
272
+ themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme),
273
+ );
274
+ });
275
+
276
+ it("golden: no _site.mdx emits the default CSS", () => {
277
+ process.env.AILK_CONTENT_ROOT = join(__dirname, "no-such-content-root");
278
+ expect(layoutCss()).toBe(themeToCssVarsBothModes({}));
279
+ });
280
+
281
+ it("a declared triad reaches --font-display / --font-body and --color-brand in both blocks", () => {
282
+ const { yaml } = repoTheme();
283
+ useSiteMdx(
284
+ `${yaml}brand:\n` +
285
+ ` colors:\n light: { primary: "#0a3d62" }\n dark: { primary: "#82ccdd" }\n` +
286
+ ` fonts:\n display: "'Fraunces', Georgia, serif"\n body: "Inter, sans-serif"\n`,
287
+ );
288
+
289
+ const css = layoutCss();
290
+ for (const [mode, block] of [
291
+ ["light", lightBlock(css)],
292
+ ["dark", darkBlock(css)],
293
+ ] as const) {
294
+ expect(block).toContain("--font-display: 'Fraunces', Georgia, serif;");
295
+ expect(block).toContain("--font-body: Inter, sans-serif;");
296
+ expect(block).toContain(`--color-brand: ${mode === "light" ? "#0a3d62" : "#82ccdd"};`);
297
+ }
298
+ });
299
+
300
+ it("refuses an unknown brand key by name", () => {
301
+ useSiteMdx("brand:\n corners: square\n");
302
+ expect(() => loadSiteBrandTokens()).toThrow(/corners/);
303
+ });
304
+ });
305
+
213
306
  /**
214
307
  * The twelve customer-facing colour slots live in `@working-theory/site-config`
215
308
  * and the map from them to token names lives in `@working-theory/theme`;
@@ -53,6 +53,8 @@ import {
53
53
  type SchemaOrgType,
54
54
  type ValidationError,
55
55
  } from "@working-theory/schema-validator";
56
+
57
+ import { MEET_PLACEHOLDER_HUB } from "../meet-placeholder";
56
58
  import type { ReactElement } from "react";
57
59
  import { parse as parseYaml } from "yaml";
58
60
 
@@ -447,19 +449,31 @@ function routeIdentity(
447
449
 
448
450
  type PageModule = {
449
451
  default: (props: {
450
- params: Promise<{ locale: string; slug?: string }>;
452
+ params: Promise<{ locale: string; slug?: string; detail?: string }>;
451
453
  }) => Promise<ReactElement>;
452
454
  };
453
455
 
454
456
  async function renderRoute(
455
457
  file: string,
458
+ route: string,
456
459
  slug: string | undefined,
457
460
  ): Promise<HTMLElement> {
458
461
  // Resolved at test time from the walk above — a static import would dangle
459
462
  // in a scaffold that gated the surface off.
460
463
  const mod = require(file) as PageModule;
461
- const params: { locale: string; slug?: string } = { locale: DEFAULT_LOCALE };
462
- if (slug !== undefined) params.slug = slug;
464
+ const params: { locale: string; slug?: string; detail?: string } = {
465
+ locale: DEFAULT_LOCALE,
466
+ };
467
+ // #6897 D2 — params are built from the route's OWN dynamic segments: the
468
+ // `[slug]/[detail]` placeholder route's `slug` param is fixed to the meet
469
+ // hub, and its content slug rides `detail` instead — never a copied
470
+ // literal for the hub name.
471
+ if (route === "[slug]/[detail]") {
472
+ params.slug = MEET_PLACEHOLDER_HUB;
473
+ if (slug !== undefined) params.detail = slug;
474
+ } else if (slug !== undefined) {
475
+ params.slug = slug;
476
+ }
463
477
  const element = await mod.default({ params: Promise.resolve(params) });
464
478
  return render(element).container;
465
479
  }
@@ -531,12 +545,15 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
531
545
  for (const slug of slugs) {
532
546
  const container = await renderRoute(
533
547
  file,
548
+ route,
534
549
  identity.slug === null ? slug : undefined,
535
550
  );
536
551
  const blocks = extractJsonLdBlocks(container);
537
552
  const pageLabel =
538
553
  identity.slug === null
539
- ? `${label.replace(/\[slug\]$/, "")}${slug}`
554
+ ? route === "[slug]/[detail]"
555
+ ? `/${DEFAULT_LOCALE}/${MEET_PLACEHOLDER_HUB}/${slug}`
556
+ : `${label.replace(/\[slug\]$/, "")}${slug}`
540
557
  : label;
541
558
  if (blocks.length === 0) {
542
559
  failures.push(
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The `[locale]` layout's first check (#6107 F5).
3
+ *
4
+ * A dotted path that is not a file — the `/.well-known` directory, `/foo.bar`
5
+ * — skips the middleware matcher and reaches `app/[locale]` with that segment
6
+ * as `locale`. Anything outside the enabled-locale list (the same list the
7
+ * footer switcher offers) is a 404, not the whole site rendered under a bogus
8
+ * locale.
9
+ */
10
+ import { readLocales } from "@working-theory/web-i18n";
11
+ import { notFound } from "next/navigation";
12
+
13
+ export function assertEnabledLocale(
14
+ locale: string,
15
+ locales: readonly string[] = readLocales(),
16
+ ): void {
17
+ if (!locales.includes(locale)) notFound();
18
+ }
@@ -0,0 +1,5 @@
1
+ // The Free `/meet/<slug>` placeholder hub (#6897 D2). `MEET_PLACEHOLDER_HUB`
2
+ // is the one fixed top-level slug the `[slug]/[detail]` route answers under —
3
+ // never a copied literal, so the route and the structured-data walk
4
+ // (`apps/web/lib/__tests__/structured-data.test.tsx`) can never drift.
5
+ export const MEET_PLACEHOLDER_HUB = "meet";
@@ -1,30 +1,14 @@
1
- // nav-config.ts — Nav item registry for apps/web (#1451, re-shaped by #5469,
2
- // #6508).
1
+ // nav-config.ts — site-nav registry.
3
2
  //
4
- // The Free marketing-site template's nav (the approved chrome, #5469 D5,
5
- // #6508 D5): a Product flyout (How it works · Features · FAQ) rendered as a
6
- // fixed-640px two-column panel (the Standard Issue direction — #6508), then
7
- // How it works, Pricing and About as top-level items. Paths are
8
- // locale-agnostic; Nav prefixes them with the active locale at render.
9
- // `footerColumns` carries the footer's column groups (#6468), keys only — the
10
- // layout resolves the labels.
3
+ // GENERATED by create-ailk from the declared route surfaces. Every entry here
4
+ // points at a route this site actually carries — the nav is regenerated with
5
+ // the surface set so it can never link a page that was not scaffolded.
11
6
  //
12
- // In a SCAFFOLD this file is regenerated by create-ailk from the declared
13
- // route surfaces (`renderNavConfig` in packages/create-ailk/src/surfaces.ts)
14
- // so the nav never links a page the scaffold does not carry — keep the two in
15
- // step when the base item set changes.
16
- //
17
- // SOFT_LOCK_OK: #6508 — Product's flyout is `width: "panel"` (D4/D5): a fixed
18
- // 640px, two-column grid, replacing the prior "list"/"trigger" one-column
19
- // menu. `presentation` is omitted — it defaults to "grid" (D5). The `docs`
20
- // action is dropped (it duplicated the first feature's /how-it-works
21
- // destination, § 1.4). This note lives here, not inside the `navItems` array
22
- // below, so this file's `navItems`/`footerColumns` blocks stay byte-identical
23
- // to `renderNavConfig`'s generated output — the mirror test (#6468) diffs the
24
- // arrays verbatim.
25
-
7
+ // Adding a link: scaffold the page (the `add-page` skill), then add an
8
+ // entry below with a `labelKey` that exists in the `nav` namespace of
9
+ // `apps/web/messages/<locale>.json`. Paths are locale-agnostic — Nav prefixes
10
+ // them with the active locale at render.
26
11
  import type { NavItem } from "@working-theory/ui";
27
- import { HelpCircle, Layers, Sparkles } from "lucide-react";
28
12
 
29
13
  export const navItems: NavItem[] = [
30
14
  {
@@ -32,19 +16,41 @@ export const navItems: NavItem[] = [
32
16
  flyout: {
33
17
  width: "panel",
34
18
  features: [
35
- { labelKey: "useCases", descKey: "useCasesDesc", icon: Layers, path: "/use-cases" },
36
- { labelKey: "features", descKey: "featuresDesc", icon: Sparkles, path: "/software/product" },
37
- { labelKey: "faq", descKey: "faqDesc", icon: HelpCircle, path: "/faq" },
19
+ { labelKey: "feature1", path: "/features/feature-1" },
20
+ { labelKey: "feature2", path: "/features/feature-2" },
21
+ { labelKey: "feature3", path: "/features/feature-3" },
22
+ { labelKey: "customer1", path: "/for/persona-1" },
23
+ { labelKey: "customer2", path: "/for/persona-2" },
24
+ { labelKey: "customer3", path: "/for/persona-3" },
25
+ ],
26
+ groups: [
27
+ {
28
+ headingKey: "flyoutFeaturesHeading",
29
+ items: [
30
+ { labelKey: "feature1", path: "/features/feature-1" },
31
+ { labelKey: "feature2", path: "/features/feature-2" },
32
+ { labelKey: "feature3", path: "/features/feature-3" },
33
+ ],
34
+ },
35
+ {
36
+ headingKey: "flyoutCustomersHeading",
37
+ items: [
38
+ { labelKey: "customer1", path: "/for/persona-1" },
39
+ { labelKey: "customer2", path: "/for/persona-2" },
40
+ { labelKey: "customer3", path: "/for/persona-3" },
41
+ ],
42
+ },
38
43
  ],
39
44
  actions: [],
40
45
  },
41
46
  },
42
- { labelKey: "useCases", path: "/use-cases" },
43
47
  { labelKey: "blog", path: "/blog" },
44
48
  { labelKey: "pricing", path: "/pricing" },
45
49
  { labelKey: "about", path: "/about" },
46
50
  ];
47
51
 
52
+ // Footer column groups. Keys only — the layout resolves each `headingKey`
53
+ // and `labelKey` through the same `nav` labels as the header.
48
54
  export interface FooterColumnConfig {
49
55
  headingKey: string;
50
56
  items: { labelKey: string; path: string }[];
@@ -54,23 +60,30 @@ export const footerColumns: FooterColumnConfig[] = [
54
60
  {
55
61
  headingKey: "footerSitePagesHeading",
56
62
  items: [
57
- { labelKey: "features", path: "/software/product" },
63
+ { labelKey: "features", path: "/features" },
58
64
  { labelKey: "pricing", path: "/pricing" },
59
- { labelKey: "useCases", path: "/use-cases" },
65
+ { labelKey: "portfolio", path: "/portfolio" },
60
66
  { labelKey: "about", path: "/about" },
61
67
  { labelKey: "contact", path: "/contact" },
62
- { labelKey: "bookACall", path: "/book-a-call" },
68
+ { labelKey: "bookACall", path: "/meet/book-a-call" },
63
69
  ],
64
70
  },
65
71
  {
66
72
  headingKey: "footerFeaturesHeading",
67
73
  items: [
68
- { labelKey: "feature1", path: "/software/product" },
69
- { labelKey: "feature2", path: "/software/product" },
70
- { labelKey: "feature3", path: "/software/product" },
71
- { labelKey: "feature4", path: "/software/product" },
72
- { labelKey: "feature5", path: "/software/product" },
73
- { labelKey: "allFeatures", path: "/software/product" },
74
+ { labelKey: "feature1", path: "/features/feature-1" },
75
+ { labelKey: "feature2", path: "/features/feature-2" },
76
+ { labelKey: "feature3", path: "/features/feature-3" },
77
+ { labelKey: "allFeatures", path: "/features" },
78
+ ],
79
+ },
80
+ {
81
+ headingKey: "footerCustomersHeading",
82
+ items: [
83
+ { labelKey: "customer1", path: "/for/persona-1" },
84
+ { labelKey: "customer2", path: "/for/persona-2" },
85
+ { labelKey: "customer3", path: "/for/persona-3" },
86
+ { labelKey: "allCustomers", path: "/for" },
74
87
  ],
75
88
  },
76
89
  {
@@ -20,7 +20,7 @@
20
20
  * rather than a dependency on `@working-theory/site-config`'s `loadSiteConfig`
21
21
  * (`packages/site-config/src/server/load.ts`): that package is async (this
22
22
  * module's exports are plain constants, read at many synchronous call
23
- * sites — Nav/Footer props, `siteLogo()`, edge-safe route defaults) and
23
+ * sites — Nav/Footer props, the raw logomark `siteLogo()` in `site-logo.tsx` renders, edge-safe route defaults) and
24
24
  * brings gray-matter + Zod validation + optimistic-locking writes built for
25
25
  * the agent-operator editing workflow — disproportionate for reading two
26
26
  * fields. See docs/specs/4157-scaffold-brand-defaults-spec.md § Design
@@ -30,7 +30,6 @@
30
30
  import { existsSync, readFileSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
 
33
- import type { ReactElement } from "react";
34
33
  import { parse as parseYaml } from "yaml";
35
34
 
36
35
  /** Neutral, non-vendor fallback — see module doc comment point 3. */
@@ -46,6 +45,8 @@ interface SiteFrontmatter {
46
45
  // `undefined` (a plain `?: string` would forbid that explicit assignment).
47
46
  siteName?: string | undefined;
48
47
  baseUrl?: string | undefined;
48
+ /** `brand.logomark`, raw — validated by `siteLogo()` when it renders. */
49
+ logomark?: unknown;
49
50
  }
50
51
 
51
52
  /**
@@ -83,10 +84,14 @@ function readSiteFrontmatter(): SiteFrontmatter | null {
83
84
  const parsed: unknown = parseYaml(match[1] ?? "");
84
85
  if (!parsed || typeof parsed !== "object") return null;
85
86
 
86
- const { siteName, baseUrl } = parsed as Record<string, unknown>;
87
+ const { siteName, baseUrl, brand } = parsed as Record<string, unknown>;
87
88
  return {
88
89
  siteName: typeof siteName === "string" ? siteName : undefined,
89
90
  baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
91
+ logomark:
92
+ brand && typeof brand === "object"
93
+ ? ((brand as Record<string, unknown>).logomark ?? undefined)
94
+ : undefined,
90
95
  };
91
96
  } catch {
92
97
  return null;
@@ -108,10 +113,9 @@ export const SITE_BASE_URL =
108
113
  process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
109
114
 
110
115
  /**
111
- * Shared brand mark — the square logomark (the central brand library's
112
- * `logomark/loop/tile-black.svg`). The Nav pairs it with `/wordmark.svg`.
113
- * RSC-safe (no hooks, no client state).
116
+ * The raw `brand.logomark` from `_site.mdx`, or `undefined` when none is
117
+ * declared. Unvalidated: `siteLogo()` (`site-logo.tsx`) parses it on the
118
+ * render that uses it, so a malformed mark never breaks `SITE_NAME` /
119
+ * `SITE_BASE_URL` for the routes that read only those.
114
120
  */
115
- export function siteLogo(): ReactElement {
116
- return <img src="/logomark.svg" alt={SITE_NAME} width={28} height={28} className="block" />;
117
- }
121
+ export const SITE_LOGOMARK: unknown = siteFrontmatter?.logomark;
@@ -85,6 +85,20 @@ function readFeatureList(contentRoot: string, locale: string): string[] {
85
85
  const frontmatter = readFrontmatter(join(productDir, name));
86
86
  if (frontmatter !== undefined) return featuresOf(frontmatter);
87
87
  }
88
+ // #6721 — the Free roster's feature PAGES (one `Feature` seed per feature,
89
+ // #6721) are the featureList when the site carries no product page: each
90
+ // page's title, in sorted file order.
91
+ const featureDir = join(contentRoot, locale, "Feature");
92
+ try {
93
+ const titles = readdirSync(featureDir)
94
+ .filter((name) => name.endsWith(".mdx"))
95
+ .sort()
96
+ .map((name) => readFrontmatter(join(featureDir, name))?.["title"])
97
+ .filter((title): title is string => typeof title === "string" && title.length > 0);
98
+ if (titles.length > 0) return titles;
99
+ } catch {
100
+ // No Feature directory — fall through to the legacy probe.
101
+ }
88
102
  let typeDirs: string[];
89
103
  try {
90
104
  typeDirs = readdirSync(join(contentRoot, locale)).sort();
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Site logo — the site's declared `brand.logomark` (#6820), else the square
3
+ * logomark (the central brand library's `logomark/loop/tile-black.svg`).
4
+ *
5
+ * Split from `site-brand.tsx`, which ten route and lib modules import for
6
+ * `SITE_NAME` / `SITE_BASE_URL`: the mark needs `@working-theory/ui`'s
7
+ * `Image` and site-config's schema, and those stay out of that wider graph
8
+ * (spec #6820 § 5.4). The two layouts are the only callers.
9
+ */
10
+
11
+ import { BrandLogomarkSchema } from "@working-theory/site-config/schema";
12
+ import { Image } from "@working-theory/ui/primitives";
13
+ import type { ReactElement } from "react";
14
+
15
+ import { SITE_LOGOMARK, SITE_NAME } from "./site-brand";
16
+
17
+ /**
18
+ * Shared brand mark. The Nav pairs it with `/wordmark.svg`. RSC-safe (no
19
+ * hooks, no client state).
20
+ *
21
+ * The logomark is validated here, on the render that uses it, not at module
22
+ * init: a malformed mark throws naming the field while `SITE_NAME` /
23
+ * `SITE_BASE_URL` stay readable by the routes that need only them. A mark
24
+ * whose dark source differs renders through the `Image` primitive's
25
+ * `darkSrc` swap, so the dark mark is not dropped.
26
+ */
27
+ export function siteLogo(): ReactElement {
28
+ if (SITE_LOGOMARK === undefined) {
29
+ return <img src="/logomark.svg" alt={SITE_NAME} width={28} height={28} className="block" />;
30
+ }
31
+ const { light, dark, alt } = BrandLogomarkSchema.parse(SITE_LOGOMARK);
32
+ if (light === dark) {
33
+ return <img src={light} alt={alt} width={28} height={28} className="block" />;
34
+ }
35
+ return <Image src={light} darkSrc={dark} alt={alt} width={28} height={28} />;
36
+ }
@@ -15,21 +15,26 @@
15
15
  * The translation itself lives in `@working-theory/theme`
16
16
  * (`siteThemeToBrandTokens`) — the token vocabulary is that package's own.
17
17
  *
18
- * Returns `undefined` when the site declares no theme, so the layout passes
19
- * no `brand` at all and the resolver emits byte-for-byte the CSS it emitted
20
- * before this wiring existed.
18
+ * The brand triad (#6820) — `brand.colors` and `brand.fonts`, a sibling of
19
+ * `theme:` that applying a design never rewrites — is read in the same parse
20
+ * and layered over the theme by `siteBrandToBrandTokens`.
21
+ *
22
+ * Returns `undefined` when the site declares no theme and no triad, so the
23
+ * layout passes no `brand` at all and the resolver emits byte-for-byte the
24
+ * CSS it emitted before this wiring existed.
21
25
  */
22
26
 
23
27
  import { readFileSync } from "node:fs";
24
28
  import { join } from "node:path";
25
29
 
26
- import { SiteThemeSchema } from "@working-theory/site-config/schema";
30
+ import { BrandSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
27
31
  import {
32
+ siteBrandToBrandTokens,
28
33
  siteThemeShapeCss,
29
- siteThemeToBrandTokens,
30
34
  type BrandTokenOverrides,
31
35
  } from "@working-theory/theme";
32
36
  import { parse as parseYaml } from "yaml";
37
+ import { z } from "zod";
33
38
 
34
39
  import { resolveContentRoot } from "./site-brand";
35
40
 
@@ -38,40 +43,55 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
38
43
  const SOURCE = "content/_site.mdx theme";
39
44
 
40
45
  /**
41
- * The `theme:` value as it appears in the frontmatter, or `undefined` when
42
- * `_site.mdx` is absent, unreadable, or declares no theme. A site with no
43
- * readable config has no theme to apply, and the default palette is the
44
- * correct result — the same fallback `site-brand.tsx` takes.
46
+ * `brand:` as this loader validates it: every key checked by name, but the
47
+ * `logomark` left to `siteLogo()` (`site-brand.tsx`), which parses it on the
48
+ * render that uses it — one throw site per field.
49
+ */
50
+ const BrandTriadSchema = BrandSchema.extend({
51
+ logomark: z.unknown().optional(),
52
+ }).strict();
53
+
54
+ /**
55
+ * The `theme:` and `brand:` values as they appear in the frontmatter, each
56
+ * `undefined` when `_site.mdx` is absent, unreadable, or does not declare it.
57
+ * A site with no readable config has no theme to apply, and the default
58
+ * palette is the correct result — the same fallback `site-brand.tsx` takes.
45
59
  *
46
- * "Declares no theme" covers a bare `theme:` key with nothing under it, which
47
- * YAML parses to `null`. That is the shape the most natural edit produces —
48
- * commenting out the block's children leaves the key dangling — and it means
49
- * exactly what an absent key means, so it takes the same path rather than
50
- * reaching the schema and throwing out of a Server Component.
60
+ * "Does not declare it" covers a bare `theme:` key with nothing under it,
61
+ * which YAML parses to `null`. That is the shape the most natural edit
62
+ * produces — commenting out the block's children leaves the key dangling —
63
+ * and it means exactly what an absent key means, so it takes the same path
64
+ * rather than reaching the schema and throwing out of a Server Component.
51
65
  */
52
- function readThemeBlock(): unknown {
66
+ function readSiteBlocks(): { theme: unknown; brand: unknown } {
53
67
  try {
54
68
  const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
55
69
  const match = FRONTMATTER_RE.exec(raw);
56
- if (!match) return undefined;
70
+ if (!match) return { theme: undefined, brand: undefined };
57
71
 
58
72
  const parsed: unknown = parseYaml(match[1] ?? "");
59
- if (!parsed || typeof parsed !== "object") return undefined;
73
+ if (!parsed || typeof parsed !== "object") {
74
+ return { theme: undefined, brand: undefined };
75
+ }
60
76
 
61
- return (parsed as Record<string, unknown>).theme ?? undefined;
77
+ const { theme, brand } = parsed as Record<string, unknown>;
78
+ return { theme: theme ?? undefined, brand: brand ?? undefined };
62
79
  } catch {
63
- return undefined;
80
+ return { theme: undefined, brand: undefined };
64
81
  }
65
82
  }
66
83
 
67
84
  export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
68
- const block = readThemeBlock();
69
- if (block === undefined) return undefined;
85
+ const blocks = readSiteBlocks();
86
+ if (blocks.theme === undefined && blocks.brand === undefined) return undefined;
70
87
 
71
88
  // Validation failures and unsupported colours BOTH throw, deliberately: a
72
89
  // customer who set a colour and saw no change would have been lied to.
73
- const theme = SiteThemeSchema.parse(block);
74
- return siteThemeToBrandTokens(theme, SOURCE);
90
+ const theme =
91
+ blocks.theme === undefined ? undefined : SiteThemeSchema.parse(blocks.theme);
92
+ const brand =
93
+ blocks.brand === undefined ? undefined : BrandTriadSchema.parse(blocks.brand);
94
+ return siteBrandToBrandTokens(theme, brand, SOURCE);
75
95
  }
76
96
 
77
97
  /**
@@ -79,7 +99,7 @@ export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
79
99
  * override block for `:root`. "" when the site sets neither.
80
100
  */
81
101
  export function loadSiteShapeCss(): string {
82
- const block = readThemeBlock();
83
- if (block === undefined) return "";
84
- return siteThemeShapeCss(SiteThemeSchema.parse(block));
102
+ const { theme } = readSiteBlocks();
103
+ if (theme === undefined) return "";
104
+ return siteThemeShapeCss(SiteThemeSchema.parse(theme));
85
105
  }