@stylecrib/cli 0.3.1 → 0.3.3

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 (314) hide show
  1. package/README.md +94 -9
  2. package/agents/MANIFEST.yml +136 -0
  3. package/agents/README.md +75 -0
  4. package/agents/art-director.md +168 -0
  5. package/agents/audacity-director.md +109 -0
  6. package/agents/brand-identity.md +179 -0
  7. package/agents/brand-precedent.md +119 -0
  8. package/agents/brief-enforcer.md +136 -0
  9. package/agents/component-qa.md +167 -0
  10. package/agents/cto.md +49 -0
  11. package/agents/design-director.md +184 -0
  12. package/agents/design-systems-engineer.md +162 -0
  13. package/agents/direction-curator.md +133 -0
  14. package/agents/homogeneity-detector.md +104 -0
  15. package/agents/logo-designer.md +240 -0
  16. package/agents/marketing-designer.md +156 -0
  17. package/agents/media-curator.md +165 -0
  18. package/agents/motion-engineer.md +270 -0
  19. package/agents/qa-conductor.md +143 -0
  20. package/agents/render-qa.md +133 -0
  21. package/agents/upgrade-simulator.md +123 -0
  22. package/agents/vp-architecture.md +49 -0
  23. package/agents/vp-engineering.md +49 -0
  24. package/dist/index.js +1606 -467
  25. package/package.json +12 -4
  26. package/themes/README.md +70 -0
  27. package/themes/acrylic-soft/THEME.md +142 -0
  28. package/themes/acrylic-soft/tokens.ts +180 -0
  29. package/themes/agency-creative/THEME.md +91 -0
  30. package/themes/agency-creative/tokens.ts +202 -0
  31. package/themes/agency-portfolio/THEME.md +109 -0
  32. package/themes/agency-portfolio/tokens.ts +159 -0
  33. package/themes/ai-startup/THEME.md +95 -0
  34. package/themes/ai-startup/tokens.ts +162 -0
  35. package/themes/airline-modern/THEME.md +104 -0
  36. package/themes/airline-modern/tokens.ts +179 -0
  37. package/themes/arc-stack/THEME.md +125 -0
  38. package/themes/arc-stack/tailwind.snippet.ts +17 -0
  39. package/themes/arc-stack/theme.yml +153 -0
  40. package/themes/arc-stack/tokens.ts +29 -0
  41. package/themes/architectural/THEME.md +102 -0
  42. package/themes/architectural/tokens.ts +159 -0
  43. package/themes/atelier-radiance/THEME.md +93 -0
  44. package/themes/atelier-radiance/tailwind.snippet.ts +15 -0
  45. package/themes/atelier-radiance/theme.yml +130 -0
  46. package/themes/atelier-radiance/tokens.ts +29 -0
  47. package/themes/automotive-ev/THEME.md +87 -0
  48. package/themes/automotive-ev/tokens.ts +183 -0
  49. package/themes/automotive-luxury/THEME.md +88 -0
  50. package/themes/automotive-luxury/tokens.ts +175 -0
  51. package/themes/baby-modern/THEME.md +79 -0
  52. package/themes/baby-modern/tokens.ts +184 -0
  53. package/themes/banking-gravity/THEME.md +171 -0
  54. package/themes/banking-gravity/tokens.ts +169 -0
  55. package/themes/beauty-clean/THEME.md +92 -0
  56. package/themes/beauty-clean/tokens.ts +176 -0
  57. package/themes/beauty-luxury/THEME.md +96 -0
  58. package/themes/beauty-luxury/tokens.ts +178 -0
  59. package/themes/bento-modular/BUILD_BRIEF.md +171 -0
  60. package/themes/bento-modular/THEME.md +80 -0
  61. package/themes/bento-modular/tailwind.snippet.ts +17 -0
  62. package/themes/bento-modular/theme.yml +83 -0
  63. package/themes/bento-modular/tokens.ts +27 -0
  64. package/themes/bookstore-indie/THEME.md +78 -0
  65. package/themes/bookstore-indie/tokens.ts +147 -0
  66. package/themes/brutalist/THEME.md +96 -0
  67. package/themes/brutalist/tokens.ts +151 -0
  68. package/themes/business-tiles/THEME.md +141 -0
  69. package/themes/business-tiles/tokens.ts +178 -0
  70. package/themes/cabana-poster/THEME.md +158 -0
  71. package/themes/cabana-poster/tailwind.snippet.ts +16 -0
  72. package/themes/cabana-poster/theme.yml +221 -0
  73. package/themes/cabana-poster/tokens.ts +34 -0
  74. package/themes/case-panel/THEME.md +126 -0
  75. package/themes/case-panel/tailwind.snippet.ts +15 -0
  76. package/themes/case-panel/theme.yml +153 -0
  77. package/themes/case-panel/tokens.ts +28 -0
  78. package/themes/cinematic-dark/THEME.md +97 -0
  79. package/themes/cinematic-dark/tokens.ts +195 -0
  80. package/themes/classic-car/THEME.md +86 -0
  81. package/themes/classic-car/tokens.ts +173 -0
  82. package/themes/cloud-friendly/THEME.md +152 -0
  83. package/themes/cloud-friendly/tokens.ts +181 -0
  84. package/themes/community-niche/THEME.md +102 -0
  85. package/themes/community-niche/tokens.ts +177 -0
  86. package/themes/corporate-grid/THEME.md +154 -0
  87. package/themes/corporate-grid/tokens.ts +182 -0
  88. package/themes/couture-luxury/THEME.md +85 -0
  89. package/themes/couture-luxury/tokens.ts +146 -0
  90. package/themes/creator-social/THEME.md +131 -0
  91. package/themes/creator-social/tokens.ts +249 -0
  92. package/themes/crypto-onchain/THEME.md +100 -0
  93. package/themes/crypto-onchain/tokens.ts +207 -0
  94. package/themes/cycling-brand/THEME.md +78 -0
  95. package/themes/cycling-brand/tokens.ts +185 -0
  96. package/themes/dating-friendly/THEME.md +102 -0
  97. package/themes/dating-friendly/tokens.ts +178 -0
  98. package/themes/dense-utility/THEME.md +85 -0
  99. package/themes/dense-utility/tokens.ts +155 -0
  100. package/themes/devtool-cli/THEME.md +100 -0
  101. package/themes/devtool-cli/tokens.ts +195 -0
  102. package/themes/devtool-docs/THEME.md +105 -0
  103. package/themes/devtool-docs/tokens.ts +177 -0
  104. package/themes/editorial/THEME.md +129 -0
  105. package/themes/editorial/tailwind.snippet.ts +15 -0
  106. package/themes/editorial/theme.yml +173 -0
  107. package/themes/editorial/tokens.ts +29 -0
  108. package/themes/education-formal/THEME.md +154 -0
  109. package/themes/education-formal/tailwind.snippet.ts +15 -0
  110. package/themes/education-formal/theme.yml +186 -0
  111. package/themes/education-formal/tokens.ts +29 -0
  112. package/themes/education-warm/THEME.md +91 -0
  113. package/themes/education-warm/tokens.ts +185 -0
  114. package/themes/ember-poster/README.md +79 -0
  115. package/themes/ember-poster/THEME.md +101 -0
  116. package/themes/ember-poster/package.json +34 -0
  117. package/themes/ember-poster/tailwind.snippet.ts +17 -0
  118. package/themes/ember-poster/theme.yml +109 -0
  119. package/themes/ember-poster/tokens.ts +27 -0
  120. package/themes/enterprise-cloud/THEME.md +139 -0
  121. package/themes/enterprise-cloud/tokens.ts +179 -0
  122. package/themes/enterprise-tech/THEME.md +167 -0
  123. package/themes/enterprise-tech/tailwind.snippet.ts +15 -0
  124. package/themes/enterprise-tech/theme.yml +202 -0
  125. package/themes/enterprise-tech/tokens.ts +29 -0
  126. package/themes/ev-charging/THEME.md +94 -0
  127. package/themes/ev-charging/tokens.ts +185 -0
  128. package/themes/farm-direct/THEME.md +92 -0
  129. package/themes/farm-direct/tokens.ts +180 -0
  130. package/themes/fintech-card/THEME.md +96 -0
  131. package/themes/fintech-card/tokens.ts +191 -0
  132. package/themes/fintech-trust/THEME.md +94 -0
  133. package/themes/fintech-trust/tokens.ts +192 -0
  134. package/themes/food-blog/THEME.md +98 -0
  135. package/themes/food-blog/tokens.ts +183 -0
  136. package/themes/forum-classic/THEME.md +100 -0
  137. package/themes/forum-classic/tokens.ts +168 -0
  138. package/themes/fresh-plate/THEME.md +144 -0
  139. package/themes/fresh-plate/tailwind.snippet.ts +16 -0
  140. package/themes/fresh-plate/theme.yml +189 -0
  141. package/themes/fresh-plate/tokens.ts +30 -0
  142. package/themes/furniture-modern/THEME.md +94 -0
  143. package/themes/furniture-modern/tokens.ts +176 -0
  144. package/themes/gaming-esports/THEME.md +146 -0
  145. package/themes/gaming-esports/tokens.ts +199 -0
  146. package/themes/gaming-streamer/THEME.md +139 -0
  147. package/themes/gaming-streamer/tokens.ts +201 -0
  148. package/themes/glassmorphic-modern/THEME.md +119 -0
  149. package/themes/glassmorphic-modern/tokens.ts +174 -0
  150. package/themes/government-civic/THEME.md +138 -0
  151. package/themes/government-civic/tokens.ts +164 -0
  152. package/themes/gym-chain/THEME.md +79 -0
  153. package/themes/gym-chain/tokens.ts +196 -0
  154. package/themes/healthcare-bright/THEME.md +95 -0
  155. package/themes/healthcare-bright/tokens.ts +184 -0
  156. package/themes/home-decor/THEME.md +94 -0
  157. package/themes/home-decor/tokens.ts +177 -0
  158. package/themes/hotel-boutique/THEME.md +110 -0
  159. package/themes/hotel-boutique/tokens.ts +179 -0
  160. package/themes/hyperminimal-mono/THEME.md +91 -0
  161. package/themes/hyperminimal-mono/tokens.ts +150 -0
  162. package/themes/indie-art-game/THEME.md +107 -0
  163. package/themes/indie-art-game/tokens.ts +216 -0
  164. package/themes/insurance-legacy/THEME.md +101 -0
  165. package/themes/insurance-legacy/tokens.ts +182 -0
  166. package/themes/insurance-modern/THEME.md +103 -0
  167. package/themes/insurance-modern/tokens.ts +192 -0
  168. package/themes/kaiju-reveal/THEME.md +97 -0
  169. package/themes/kaiju-reveal/tokens.ts +185 -0
  170. package/themes/kinetic-data/THEME.md +172 -0
  171. package/themes/kinetic-data/tailwind.snippet.ts +16 -0
  172. package/themes/kinetic-data/theme.yml +207 -0
  173. package/themes/kinetic-data/tokens.ts +30 -0
  174. package/themes/kinetic-headline/BUILD_BRIEF.md +156 -0
  175. package/themes/kinetic-headline/THEME.md +78 -0
  176. package/themes/kinetic-headline/tailwind.snippet.ts +16 -0
  177. package/themes/kinetic-headline/theme.yml +81 -0
  178. package/themes/kinetic-headline/tokens.ts +26 -0
  179. package/themes/law-firm/THEME.md +108 -0
  180. package/themes/law-firm/tokens.ts +158 -0
  181. package/themes/lawtech-modern/THEME.md +107 -0
  182. package/themes/lawtech-modern/tokens.ts +192 -0
  183. package/themes/linkedin-pro/THEME.md +102 -0
  184. package/themes/linkedin-pro/tokens.ts +174 -0
  185. package/themes/logistics-modern/THEME.md +94 -0
  186. package/themes/logistics-modern/tokens.ts +187 -0
  187. package/themes/magazine-glossy/THEME.md +74 -0
  188. package/themes/magazine-glossy/tokens.ts +158 -0
  189. package/themes/marker-mag/THEME.md +128 -0
  190. package/themes/marker-mag/tailwind.snippet.ts +15 -0
  191. package/themes/marker-mag/theme.yml +159 -0
  192. package/themes/marker-mag/tokens.ts +27 -0
  193. package/themes/marketplace-modern/THEME.md +135 -0
  194. package/themes/marketplace-modern/tokens.ts +186 -0
  195. package/themes/marketplace-vintage/THEME.md +110 -0
  196. package/themes/marketplace-vintage/tokens.ts +185 -0
  197. package/themes/maximalist/THEME.md +96 -0
  198. package/themes/maximalist/tokens.ts +168 -0
  199. package/themes/mid-century/THEME.md +109 -0
  200. package/themes/mid-century/tokens.ts +162 -0
  201. package/themes/motorcycle-brand/THEME.md +86 -0
  202. package/themes/motorcycle-brand/tokens.ts +174 -0
  203. package/themes/museum/THEME.md +83 -0
  204. package/themes/museum/tokens.ts +147 -0
  205. package/themes/music-streaming/THEME.md +101 -0
  206. package/themes/music-streaming/tokens.ts +187 -0
  207. package/themes/music-vinyl/THEME.md +98 -0
  208. package/themes/music-vinyl/tokens.ts +167 -0
  209. package/themes/nature-distilled/BUILD_BRIEF.md +158 -0
  210. package/themes/nature-distilled/THEME.md +81 -0
  211. package/themes/nature-distilled/tailwind.snippet.ts +16 -0
  212. package/themes/nature-distilled/theme.yml +91 -0
  213. package/themes/nature-distilled/tokens.ts +26 -0
  214. package/themes/news-tabloid/THEME.md +72 -0
  215. package/themes/news-tabloid/tokens.ts +158 -0
  216. package/themes/news-tech/THEME.md +75 -0
  217. package/themes/news-tech/tokens.ts +149 -0
  218. package/themes/newsletter/THEME.md +82 -0
  219. package/themes/newsletter/tokens.ts +138 -0
  220. package/themes/newspaper-classic/THEME.md +74 -0
  221. package/themes/newspaper-classic/tokens.ts +159 -0
  222. package/themes/nonprofit-arts/THEME.md +78 -0
  223. package/themes/nonprofit-arts/tokens.ts +157 -0
  224. package/themes/nonprofit-warm/THEME.md +105 -0
  225. package/themes/nonprofit-warm/tokens.ts +188 -0
  226. package/themes/outdoor-adventure/THEME.md +113 -0
  227. package/themes/outdoor-adventure/tokens.ts +181 -0
  228. package/themes/overland-broadside/THEME.md +119 -0
  229. package/themes/overland-broadside/tailwind.snippet.ts +17 -0
  230. package/themes/overland-broadside/theme.yml +138 -0
  231. package/themes/overland-broadside/tokens.ts +27 -0
  232. package/themes/performance-engineered/THEME.md +162 -0
  233. package/themes/performance-engineered/tailwind.snippet.ts +15 -0
  234. package/themes/performance-engineered/theme.yml +193 -0
  235. package/themes/performance-engineered/tokens.ts +29 -0
  236. package/themes/pet-food/THEME.md +78 -0
  237. package/themes/pet-food/tokens.ts +185 -0
  238. package/themes/pharma-clinical/THEME.md +164 -0
  239. package/themes/pharma-clinical/tailwind.snippet.ts +15 -0
  240. package/themes/pharma-clinical/theme.yml +201 -0
  241. package/themes/pharma-clinical/tokens.ts +29 -0
  242. package/themes/pill-editorial/THEME.md +125 -0
  243. package/themes/pill-editorial/tailwind.snippet.ts +15 -0
  244. package/themes/pill-editorial/theme.yml +168 -0
  245. package/themes/pill-editorial/tokens.ts +31 -0
  246. package/themes/podcast-warm/THEME.md +103 -0
  247. package/themes/podcast-warm/tokens.ts +192 -0
  248. package/themes/publisher-indie/THEME.md +78 -0
  249. package/themes/publisher-indie/tokens.ts +148 -0
  250. package/themes/raw-concrete/BUILD_BRIEF.md +157 -0
  251. package/themes/raw-concrete/THEME.md +76 -0
  252. package/themes/raw-concrete/tailwind.snippet.ts +16 -0
  253. package/themes/raw-concrete/theme.yml +88 -0
  254. package/themes/raw-concrete/tokens.ts +27 -0
  255. package/themes/realestate-luxury/THEME.md +94 -0
  256. package/themes/realestate-luxury/tokens.ts +169 -0
  257. package/themes/realestate-marketplace/THEME.md +106 -0
  258. package/themes/realestate-marketplace/tokens.ts +183 -0
  259. package/themes/restaurant-modern/THEME.md +109 -0
  260. package/themes/restaurant-modern/tokens.ts +172 -0
  261. package/themes/runway-fashion/THEME.md +99 -0
  262. package/themes/runway-fashion/tokens.ts +167 -0
  263. package/themes/saturday-morning/THEME.md +117 -0
  264. package/themes/saturday-morning/tokens.ts +281 -0
  265. package/themes/schema.ts +235 -0
  266. package/themes/signal-dark/BUILD_BRIEF.md +168 -0
  267. package/themes/signal-dark/THEME.md +107 -0
  268. package/themes/signal-dark/tokens.ts +181 -0
  269. package/themes/soft-modern/THEME.md +91 -0
  270. package/themes/soft-modern/tokens.ts +166 -0
  271. package/themes/solar-residential/THEME.md +92 -0
  272. package/themes/solar-residential/tokens.ts +176 -0
  273. package/themes/sports-league/THEME.md +180 -0
  274. package/themes/sports-league/tailwind.snippet.ts +15 -0
  275. package/themes/sports-league/theme.yml +213 -0
  276. package/themes/sports-league/tokens.ts +32 -0
  277. package/themes/stadium-sport/THEME.md +114 -0
  278. package/themes/stadium-sport/tokens.ts +187 -0
  279. package/themes/streetwear-hype/THEME.md +100 -0
  280. package/themes/streetwear-hype/tokens.ts +174 -0
  281. package/themes/studio-architect/THEME.md +108 -0
  282. package/themes/studio-architect/tokens.ts +162 -0
  283. package/themes/studio-photographer/THEME.md +88 -0
  284. package/themes/studio-photographer/tokens.ts +164 -0
  285. package/themes/team-kanban/THEME.md +167 -0
  286. package/themes/team-kanban/tailwind.snippet.ts +15 -0
  287. package/themes/team-kanban/theme.yml +209 -0
  288. package/themes/team-kanban/tokens.ts +35 -0
  289. package/themes/telehealth-modern/THEME.md +95 -0
  290. package/themes/telehealth-modern/tokens.ts +197 -0
  291. package/themes/tourism-board/THEME.md +111 -0
  292. package/themes/tourism-board/tokens.ts +173 -0
  293. package/themes/toys-modern/THEME.md +79 -0
  294. package/themes/toys-modern/tokens.ts +200 -0
  295. package/themes/travel-luxury/THEME.md +95 -0
  296. package/themes/travel-luxury/tokens.ts +162 -0
  297. package/themes/vacation-rental/THEME.md +105 -0
  298. package/themes/vacation-rental/tokens.ts +174 -0
  299. package/themes/vault-reel/THEME.md +134 -0
  300. package/themes/vault-reel/tailwind.snippet.ts +16 -0
  301. package/themes/vault-reel/theme.yml +167 -0
  302. package/themes/vault-reel/tokens.ts +30 -0
  303. package/themes/vet-modern/THEME.md +79 -0
  304. package/themes/vet-modern/tokens.ts +194 -0
  305. package/themes/web3-restraint/THEME.md +87 -0
  306. package/themes/web3-restraint/tokens.ts +154 -0
  307. package/themes/wellness-active/THEME.md +102 -0
  308. package/themes/wellness-active/tokens.ts +203 -0
  309. package/themes/wellness-spa/THEME.md +93 -0
  310. package/themes/wellness-spa/tokens.ts +173 -0
  311. package/themes/y2k-chrome/THEME.md +118 -0
  312. package/themes/y2k-chrome/tokens.ts +249 -0
  313. package/themes/yoga-studio/THEME.md +76 -0
  314. package/themes/yoga-studio/tokens.ts +163 -0
package/dist/index.js CHANGED
@@ -281,10 +281,10 @@ var init_slop_rules = __esm({
281
281
  },
282
282
  {
283
283
  id: "R-SECOND-ACCENT",
284
- description: "Component introduces a second saturated accent hue on a theme whose theme.yml declares single_accent (e.g. signal-dark, enterprise-tech, pharma-clinical, kinetic-data). Alarms/status must use ink + hairline + mono glyph, not a second hue.",
284
+ description: "Component introduces a second saturated accent hue on a theme whose policy permits exactly one (signal-dark, enterprise-tech, pharma-clinical, kinetic-data). Alarms/status must use ink + hairline + mono glyph, not a second hue. Theme-aware: resolves the active theme from .stylecrib/config.json and its permitted accent from the bundled themes/, then compares hue distance. No-ops when the theme isn't resolvable or has no single-accent policy.",
285
285
  severity: "block",
286
286
  category: "palette",
287
- specSource: "theme.yml single_accent field",
287
+ specSource: "slop-blocklist.md",
288
288
  detectorAt: "check.ts"
289
289
  },
290
290
  {
@@ -371,19 +371,19 @@ var init_slop_rules = __esm({
371
371
  },
372
372
  {
373
373
  id: "R-MONO-CAPS-SECTION-EYEBROW",
374
- description: "Section source renders `SECTION \xB7 X` / `CHAPTER \xB7 X` / `PART <N>` / `WHAT WE X` mono-caps section eyebrow above content \u2014 universal slop.",
374
+ description: "Renders `SECTION \xB7 X` / `CHAPTER \xB7 X` / `PART <N>` / `WHAT WE X` mono-caps section eyebrow above content \u2014 universal slop. Detected at both the component level (check.ts, anchored to quoted/JSX text) and at theme materialization (slop-blocklist.ts).",
375
375
  severity: "block",
376
376
  category: "typography",
377
- specSource: "theme-harness/gates/slop-blocklist.ts",
378
- detectorAt: "slop-blocklist.ts"
377
+ specSource: "slop-blocklist.md",
378
+ detectorAt: "check.ts"
379
379
  },
380
380
  {
381
381
  id: "R-CORNER-TAG-ABBREVIATION",
382
- description: "Small mono-caps corner tags with period abbreviations (`NO. 03`, `EST. 2019`, `VOL. 2`, `CH. 4`, `ESSAY NO. 04`) \u2014 editorial-ornament defaults, always slop.",
382
+ description: "Small mono-caps corner tags with period abbreviations (`NO. 03`, `EST. 2019`, `VOL. 2`, `CH. 4`, `ESSAY NO. 04`) \u2014 editorial-ornament defaults, always slop. Detected at both the component level (check.ts, digit-suffixed only) and at theme materialization (slop-blocklist.ts, which also matches interpolated values).",
383
383
  severity: "block",
384
384
  category: "content",
385
- specSource: "theme-harness/gates/slop-blocklist.ts",
386
- detectorAt: "slop-blocklist.ts"
385
+ specSource: "slop-blocklist.md",
386
+ detectorAt: "check.ts"
387
387
  },
388
388
  {
389
389
  id: "R-READ-TIME-META",
@@ -403,19 +403,19 @@ var init_slop_rules = __esm({
403
403
  },
404
404
  {
405
405
  id: "R-BYLINE-MONO-CAPS",
406
- description: "Bylines rendered in mono-caps (`BY R. IWASAKI`, `WORDS BY X`, `PHOTOGRAPHY BY Y`) \u2014 real editorial bylines are set in body type; mono-caps bylines are editorial cosplay.",
406
+ description: "Bylines rendered in mono-caps (`BY R. IWASAKI`, `WORDS BY X`, `PHOTOGRAPHY BY Y`) \u2014 real editorial bylines are set in body type; mono-caps bylines are editorial cosplay. Detected at both the component level (check.ts) and at theme materialization (slop-blocklist.ts).",
407
407
  severity: "block",
408
408
  category: "typography",
409
- specSource: "theme-harness/gates/slop-blocklist.ts",
410
- detectorAt: "slop-blocklist.ts"
409
+ specSource: "slop-blocklist.md",
410
+ detectorAt: "check.ts"
411
411
  },
412
412
  {
413
413
  id: "R-TAG-ROW-ABOVE-BODY",
414
- description: "`\u25B8 TAG` / `\u25C6 TAG` / `\xBB TAG` / `\u25CF TAG` mono-caps prefix labels used as category tags next to body copy \u2014 the eyebrow relocated, still slop.",
414
+ description: "`\u25B8 TAG` / `\u25C6 TAG` / `\xBB TAG` / `\u25CF TAG` mono-caps prefix labels used as category tags next to body copy \u2014 the eyebrow relocated, still slop. Detected at both the component level (check.ts) and at theme materialization (slop-blocklist.ts).",
415
415
  severity: "block",
416
416
  category: "typography",
417
- specSource: "theme-harness/gates/slop-blocklist.ts",
418
- detectorAt: "slop-blocklist.ts"
417
+ specSource: "slop-blocklist.md",
418
+ detectorAt: "check.ts"
419
419
  },
420
420
  {
421
421
  id: "R-TRUSTED-BY-EMPTY-ROW",
@@ -477,9 +477,8 @@ import { Command } from "commander";
477
477
 
478
478
  // src/commands/init.ts
479
479
  init_esm_shims();
480
- import { resolve as resolve6, basename, dirname as dirname6 } from "path";
480
+ import { resolve as resolve5, join as join8, basename } from "path";
481
481
  import { readFile as readFile6 } from "fs/promises";
482
- import { fileURLToPath as fileURLToPath4 } from "url";
483
482
  import { confirm as confirm2 } from "@inquirer/prompts";
484
483
 
485
484
  // src/lib/colors.ts
@@ -508,6 +507,74 @@ function step(n, total, label) {
508
507
  // src/wizard.ts
509
508
  init_esm_shims();
510
509
  import { select, input, confirm, editor } from "@inquirer/prompts";
510
+
511
+ // src/lib/errors.ts
512
+ init_esm_shims();
513
+ var CliError = class extends Error {
514
+ constructor(message, options) {
515
+ super(message, options);
516
+ this.name = "CliError";
517
+ }
518
+ };
519
+ var MissingApiKeyError = class extends CliError {
520
+ constructor() {
521
+ super(
522
+ "ANTHROPIC_API_KEY not set.\n\nThe key is needed for two things: synthesizing a direction from your own\nreferences, and the deep review the pre-commit hook runs. Installing a\ncanonical theme as-is needs no key and makes no API call:\n\n npx stylecrib init --style soft-modern --yes\n\nTo use references, get a key at https://console.anthropic.com/ and run:\n\n export ANTHROPIC_API_KEY=sk-ant-...\n\nOr pass it inline for one run:\n\n ANTHROPIC_API_KEY=sk-ant-... npx stylecrib init"
523
+ );
524
+ this.name = "MissingApiKeyError";
525
+ }
526
+ };
527
+ var NotAGitRepoError = class extends CliError {
528
+ constructor(target) {
529
+ super(
530
+ `Not a git repository: ${target}
531
+
532
+ The enforcement layer is a git pre-commit hook, so there has to be a repo
533
+ to hook into. Run \`git init\` there first, then \`stylecrib install-hooks\`.`
534
+ );
535
+ this.name = "NotAGitRepoError";
536
+ }
537
+ };
538
+ var HookCollisionError = class extends CliError {
539
+ paths;
540
+ wrote;
541
+ /**
542
+ * @param paths files skipped because they exist with different content
543
+ * @param wrote how many files DID land before this was thrown
544
+ *
545
+ * The count matters. installHooks writes every non-colliding file and only
546
+ * then throws, so the install is partial — saying "nothing was written"
547
+ * (as this did) sends the customer looking for a clean slate that isn't
548
+ * there.
549
+ */
550
+ constructor(paths, wrote = 0) {
551
+ super(
552
+ `${paths.length} file(s) already exist with different content and were left alone.
553
+
554
+ ` + paths.map((p) => ` \xB7 ${p}`).join("\n") + (wrote > 0 ? `
555
+
556
+ ${wrote} other file(s) WERE written, so this install is partial \u2014 the enforcement layer is not fully wired yet.` : "") + "\n\nRe-run with --force to overwrite the files above (each is backed up to\n<name>.pre-stylecrib.bak first), or merge them by hand and re-run."
557
+ );
558
+ this.name = "HookCollisionError";
559
+ this.paths = paths;
560
+ this.wrote = wrote;
561
+ }
562
+ };
563
+ var StackMismatchError = class extends CliError {
564
+ constructor(detail) {
565
+ super(
566
+ `Project stack not supported in v1.
567
+
568
+ ${detail}
569
+
570
+ v1 targets React + Tailwind + Vite + TS. Vue, Svelte, Next, styled-components, CSS modules are v2.
571
+ If you're sure the project matches and detection misfired, pass --target with the correct path.`
572
+ );
573
+ this.name = "StackMismatchError";
574
+ }
575
+ };
576
+
577
+ // src/wizard.ts
511
578
  var DIRECTION_CHOICES = [
512
579
  // reading-first
513
580
  {
@@ -650,11 +717,29 @@ var DIRECTION_CHOICES = [
650
717
  }
651
718
  ];
652
719
  async function runWizard(opts) {
720
+ assertValidDirection(opts.skippedStyle);
721
+ if (opts.nonInteractive) {
722
+ const out = {
723
+ baseDirection: opts.skippedStyle ?? "soft-modern",
724
+ projectName: opts.defaultProjectName,
725
+ references: [],
726
+ antiReferences: [],
727
+ vibe: "",
728
+ urls: [],
729
+ imagePaths: []
730
+ };
731
+ console.log(header("StyleCrib \u2014 non-interactive install"));
732
+ printSummary(out);
733
+ console.log(
734
+ c.dim(" Refine any of this later with `npx stylecrib init` from a terminal.\n")
735
+ );
736
+ return out;
737
+ }
653
738
  console.log(header("StyleCrib \u2014 design upgrade wizard"));
654
739
  console.log(
655
740
  "Answer a few questions and the Director will synthesize a design direction\nfor your project. Reference brands beat hex codes; vivid words beat specs.\n"
656
741
  );
657
- const baseDirection = opts.skippedStyle && isValidDirection(opts.skippedStyle) ? opts.skippedStyle : await select({
742
+ const baseDirection = opts.skippedStyle ? opts.skippedStyle : await select({
658
743
  message: "Pick a starting direction (you can refine it with references next):",
659
744
  choices: DIRECTION_CHOICES,
660
745
  default: "soft-modern"
@@ -717,14 +802,14 @@ async function runWizard(opts) {
717
802
  }
718
803
  }
719
804
  console.log(header("Summary"));
720
- console.log(" " + c.dim("Base direction: ") + c.bold(baseDirection));
721
- console.log(" " + c.dim("Project: ") + projectName);
722
- console.log(" " + c.dim("References: ") + (references.length ? references.join(", ") : c.dim("(none)")));
723
- console.log(" " + c.dim("Avoid: ") + (antiReferences.length ? antiReferences.join(", ") : c.dim("(none)")));
724
- console.log(" " + c.dim("Vibe: ") + (vibe || c.dim("(none)")));
725
- console.log(" " + c.dim("URLs: ") + (urls.length ? urls.join(", ") : c.dim("(none)")));
726
- console.log(" " + c.dim("Images: ") + (imagePaths.length ? imagePaths.join(", ") : c.dim("(none)")));
727
- console.log("");
805
+ printSummary({ baseDirection, projectName, references, antiReferences, vibe, urls, imagePaths });
806
+ const willCallDirector = baseDirection === "custom" || references.length > 0 || antiReferences.length > 0 || !!vibe || urls.length > 0 || imagePaths.length > 0;
807
+ if (willCallDirector && !process.env.ANTHROPIC_API_KEY) {
808
+ console.log(
809
+ c.warn(" \u26A0 ANTHROPIC_API_KEY is not set, and these answers need the Director.\n") + c.dim(" Set the key and re-run, or clear the references above to install the\n") + c.dim(` canonical "${baseDirection}" theme verbatim with no API call.
810
+ `)
811
+ );
812
+ }
728
813
  const proceed = await confirm({
729
814
  message: "Send this to the Director and write outputs?",
730
815
  default: true
@@ -746,73 +831,36 @@ async function runWizard(opts) {
746
831
  function parseList(raw) {
747
832
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
748
833
  }
834
+ function printSummary(w) {
835
+ console.log(" " + c.dim("Base direction: ") + c.bold(w.baseDirection));
836
+ console.log(" " + c.dim("Project: ") + w.projectName);
837
+ console.log(" " + c.dim("References: ") + (w.references.length ? w.references.join(", ") : c.dim("(none)")));
838
+ console.log(" " + c.dim("Avoid: ") + (w.antiReferences.length ? w.antiReferences.join(", ") : c.dim("(none)")));
839
+ console.log(" " + c.dim("Vibe: ") + (w.vibe || c.dim("(none)")));
840
+ console.log(" " + c.dim("URLs: ") + (w.urls.length ? w.urls.join(", ") : c.dim("(none)")));
841
+ console.log(" " + c.dim("Images: ") + (w.imagePaths.length ? w.imagePaths.join(", ") : c.dim("(none)")));
842
+ console.log("");
843
+ }
844
+ var VALID_DIRECTIONS = DIRECTION_CHOICES.map((d) => d.value);
749
845
  function isValidDirection(s) {
750
- return [
751
- "editorial",
752
- "newsletter",
753
- "soft-modern",
754
- "dense-utility",
755
- "glassmorphic-modern",
756
- "signal-dark",
757
- "cinematic-dark",
758
- "outdoor-adventure",
759
- "brutalist",
760
- "hyperminimal-mono",
761
- "mid-century",
762
- "maximalist",
763
- "y2k-chrome",
764
- "couture-luxury",
765
- "stadium-sport",
766
- "performance-engineered",
767
- "saturday-morning",
768
- "enterprise-tech",
769
- "creator-social",
770
- "kaiju-reveal",
771
- "runway-fashion",
772
- "streetwear-hype",
773
- "architectural",
774
- "kinetic-data",
775
- "indie-art-game",
776
- "custom"
777
- ].includes(s);
846
+ return VALID_DIRECTIONS.includes(s);
847
+ }
848
+ function assertValidDirection(slug) {
849
+ if (slug === void 0 || isValidDirection(slug)) return;
850
+ throw new CliError(
851
+ `Unknown --style "${slug}".
852
+
853
+ Valid directions:
854
+ ${VALID_DIRECTIONS.map((v) => ` \xB7 ${v}`).join("\n")}
855
+
856
+ Browse them at https://github.com/frischermf/stylecrib/tree/main/themes`
857
+ );
778
858
  }
779
859
 
780
860
  // src/ingest.ts
781
861
  init_esm_shims();
782
862
  import { readFile } from "fs/promises";
783
863
  import { extname } from "path";
784
-
785
- // src/lib/errors.ts
786
- init_esm_shims();
787
- var CliError = class extends Error {
788
- constructor(message, options) {
789
- super(message, options);
790
- this.name = "CliError";
791
- }
792
- };
793
- var MissingApiKeyError = class extends CliError {
794
- constructor() {
795
- super(
796
- "ANTHROPIC_API_KEY not set.\n\nStyleCrib calls the Anthropic API to synthesize your design direction.\nGet a key at https://console.anthropic.com/, then run:\n\n export ANTHROPIC_API_KEY=sk-ant-...\n\nOr pass it inline for one run:\n\n ANTHROPIC_API_KEY=sk-ant-... npx stylecrib init"
797
- );
798
- this.name = "MissingApiKeyError";
799
- }
800
- };
801
- var StackMismatchError = class extends CliError {
802
- constructor(detail) {
803
- super(
804
- `Project stack not supported in v1.
805
-
806
- ${detail}
807
-
808
- v1 targets React + Tailwind + Vite + TS. Vue, Svelte, Next, styled-components, CSS modules are v2.
809
- If you're sure the project matches and detection misfired, pass --target with the correct path.`
810
- );
811
- this.name = "StackMismatchError";
812
- }
813
- };
814
-
815
- // src/ingest.ts
816
864
  async function ingestUrl(url) {
817
865
  const res = await fetch(url, {
818
866
  headers: {
@@ -835,16 +883,16 @@ async function ingestUrl(url) {
835
883
  );
836
884
  }
837
885
  const html = await res.text();
838
- const text = htmlToReadableText(html);
886
+ const text2 = htmlToReadableText(html);
839
887
  console.log(
840
888
  c.dim(
841
- ` \xB7 fetched ${url} (${(html.length / 1024).toFixed(1)} KB HTML \u2192 ${(text.length / 1024).toFixed(1)} KB text)`
889
+ ` \xB7 fetched ${url} (${(html.length / 1024).toFixed(1)} KB HTML \u2192 ${(text2.length / 1024).toFixed(1)} KB text)`
842
890
  )
843
891
  );
844
892
  return {
845
893
  type: "text",
846
894
  text: `=== URL reference: ${url} ===
847
- ` + text.slice(0, 12e3) + (text.length > 12e3 ? "\n[...truncated]" : "")
895
+ ` + text2.slice(0, 12e3) + (text2.length > 12e3 ? "\n[...truncated]" : "")
848
896
  };
849
897
  }
850
898
  async function ingestImage(path2) {
@@ -1026,32 +1074,62 @@ Now produce the JSON object exactly as your system prompt specifies. Synthesize,
1026
1074
  // src/writer.ts
1027
1075
  init_esm_shims();
1028
1076
  import { mkdir, writeFile, readFile as readFile2, access, cp, readdir, stat } from "fs/promises";
1029
- import { join, dirname, resolve, relative } from "path";
1077
+ import { join as join2, dirname as dirname2, relative } from "path";
1078
+
1079
+ // src/lib/assets.ts
1080
+ init_esm_shims();
1081
+ import { existsSync, readdirSync } from "fs";
1082
+ import { dirname, join, resolve } from "path";
1030
1083
  import { fileURLToPath as fileURLToPath2 } from "url";
1031
1084
  var __dirname2 = dirname(fileURLToPath2(import.meta.url));
1032
- function findAgentsDir() {
1085
+ var cache = /* @__PURE__ */ new Map();
1086
+ function resolveAssetDir(kind) {
1087
+ const hit = cache.get(kind);
1088
+ if (hit !== void 0) return hit;
1033
1089
  const candidates = [
1034
- resolve(__dirname2, "..", "agents"),
1035
- resolve(__dirname2, "..", "..", "agents")
1090
+ resolve(__dirname2, "..", kind),
1091
+ // npm install: <pkg>/dist/../<kind>
1092
+ resolve(__dirname2, "..", "..", kind),
1093
+ // dev monorepo: cli/dist/../../<kind>
1094
+ resolve(__dirname2, "..", "..", "..", kind)
1036
1095
  ];
1037
- return candidates[0];
1096
+ const found = candidates.find((p) => existsSync(p) && looksLikeOurs(p, kind)) ?? null;
1097
+ cache.set(kind, found);
1098
+ return found;
1099
+ }
1100
+ function looksLikeOurs(dir, kind) {
1101
+ try {
1102
+ if (kind === "agents") {
1103
+ return existsSync(join(dir, "MANIFEST.yml"));
1104
+ }
1105
+ const entries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).slice(0, 40);
1106
+ return entries.some(
1107
+ (e) => existsSync(join(dir, e.name, "THEME.md")) || existsSync(join(dir, e.name, "tokens.ts"))
1108
+ );
1109
+ } catch {
1110
+ return false;
1111
+ }
1112
+ }
1113
+ function missingAssetMessage(kind) {
1114
+ return `The CLI install is missing its bundled ${kind}/ directory. Reinstall with \`npm install -D @stylecrib/cli\`, or report this at https://github.com/frischermf/stylecrib/issues.`;
1038
1115
  }
1116
+
1117
+ // src/writer.ts
1039
1118
  async function stageOutputs(opts) {
1040
1119
  const { targetRoot, director, emitted } = opts;
1041
- const stagingRoot = join(targetRoot, ".design-upgrade");
1120
+ const stagingRoot = join2(targetRoot, ".design-upgrade");
1042
1121
  const filesStaged = [];
1043
1122
  const filesSkipped = [];
1044
1123
  await mkdir(stagingRoot, { recursive: true });
1045
1124
  await stage(
1046
- join(targetRoot, "design-system", "THEME.md"),
1125
+ join2(targetRoot, "design-system", "THEME.md"),
1047
1126
  director.directionMarkdown,
1048
1127
  stagingRoot,
1049
1128
  targetRoot,
1050
1129
  filesStaged,
1051
1130
  filesSkipped
1052
1131
  );
1053
- const agentsDir = findAgentsDir();
1054
- for (const name of [
1132
+ const agentNames = [
1055
1133
  "design-director.md",
1056
1134
  "design-systems-engineer.md",
1057
1135
  "component-qa.md",
@@ -1060,22 +1138,31 @@ async function stageOutputs(opts) {
1060
1138
  "logo-designer.md",
1061
1139
  "brand-identity.md",
1062
1140
  "marketing-designer.md"
1063
- ]) {
1064
- try {
1065
- const content = await readFile2(join(agentsDir, name), "utf8");
1066
- await stage(
1067
- join(targetRoot, ".claude", "agents", name),
1068
- content,
1069
- stagingRoot,
1070
- targetRoot,
1071
- filesStaged,
1072
- filesSkipped
1073
- );
1074
- } catch (e) {
1075
- filesSkipped.push({
1076
- targetPath: join(targetRoot, ".claude", "agents", name),
1077
- reason: `source missing \u2014 ${e.message}`
1078
- });
1141
+ ];
1142
+ const agentsDir = resolveAssetDir("agents");
1143
+ if (!agentsDir) {
1144
+ filesSkipped.push({
1145
+ targetPath: join2(targetRoot, ".claude", "agents"),
1146
+ reason: missingAssetMessage("agents")
1147
+ });
1148
+ } else {
1149
+ for (const name of agentNames) {
1150
+ try {
1151
+ const content = await readFile2(join2(agentsDir, name), "utf8");
1152
+ await stage(
1153
+ join2(targetRoot, ".claude", "agents", name),
1154
+ content,
1155
+ stagingRoot,
1156
+ targetRoot,
1157
+ filesStaged,
1158
+ filesSkipped
1159
+ );
1160
+ } catch (e) {
1161
+ filesSkipped.push({
1162
+ targetPath: join2(targetRoot, ".claude", "agents", name),
1163
+ reason: `source missing \u2014 ${e.message}`
1164
+ });
1165
+ }
1079
1166
  }
1080
1167
  }
1081
1168
  await stage(
@@ -1099,11 +1186,11 @@ async function stageOutputs(opts) {
1099
1186
  }
1100
1187
  async function stage(targetPath, content, stagingRoot, targetRoot, filesStaged, _filesSkipped) {
1101
1188
  const rel = relative(targetRoot, targetPath);
1102
- const stagedPath = join(stagingRoot, rel);
1103
- await mkdir(dirname(stagedPath), { recursive: true });
1189
+ const stagedPath = join2(stagingRoot, rel);
1190
+ await mkdir(dirname2(stagedPath), { recursive: true });
1104
1191
  await writeFile(stagedPath, content, "utf8");
1105
1192
  filesStaged.push({ stagedPath, targetPath });
1106
- console.log(c.ok(" + ") + c.dim(relPath(stagedPath)));
1193
+ console.log(c.ok(" + ") + c.dim(relPath(stagedPath, targetRoot)));
1107
1194
  }
1108
1195
  var ROUTING_MARK_START = "<!-- stylecrib:routing:start -->";
1109
1196
  var ROUTING_MARK_END = "<!-- stylecrib:routing:end -->";
@@ -1129,7 +1216,7 @@ Source of truth for design: \`design-system/THEME.md\`.
1129
1216
  ${ROUTING_MARK_END}
1130
1217
  `;
1131
1218
  async function writePatchInstruction(targetRoot, stagingRoot, filesStaged) {
1132
- const claudeMdPath = join(targetRoot, "CLAUDE.md");
1219
+ const claudeMdPath = join2(targetRoot, "CLAUDE.md");
1133
1220
  const exists = await fileExists(claudeMdPath);
1134
1221
  let stagedContent;
1135
1222
  if (!exists) {
@@ -1147,23 +1234,23 @@ See \`design-system/THEME.md\` and \`.claude/agents/\` for the design team.
1147
1234
  }
1148
1235
  stagedContent = current.replace(/\s*$/, "") + "\n\n" + ROUTING_BLOCK;
1149
1236
  }
1150
- const stagedPath = join(stagingRoot, "CLAUDE.md");
1151
- await mkdir(dirname(stagedPath), { recursive: true });
1237
+ const stagedPath = join2(stagingRoot, "CLAUDE.md");
1238
+ await mkdir(dirname2(stagedPath), { recursive: true });
1152
1239
  await writeFile(stagedPath, stagedContent, "utf8");
1153
1240
  filesStaged.push({ stagedPath, targetPath: claudeMdPath });
1154
- console.log(c.ok(" + ") + c.dim(relPath(stagedPath)));
1241
+ console.log(c.ok(" + ") + c.dim(relPath(stagedPath, targetRoot)));
1155
1242
  }
1156
1243
  async function* walk(dir) {
1157
1244
  const entries = await readdir(dir);
1158
1245
  for (const e of entries) {
1159
- const p = join(dir, e);
1246
+ const p = join2(dir, e);
1160
1247
  const s = await stat(p);
1161
1248
  if (s.isDirectory()) yield* walk(p);
1162
1249
  else yield p;
1163
1250
  }
1164
1251
  }
1165
1252
  async function copyFile(src, dest) {
1166
- await mkdir(dirname(dest), { recursive: true });
1253
+ await mkdir(dirname2(dest), { recursive: true });
1167
1254
  await cp(src, dest);
1168
1255
  }
1169
1256
  async function fileExists(p) {
@@ -1174,22 +1261,21 @@ async function fileExists(p) {
1174
1261
  return false;
1175
1262
  }
1176
1263
  }
1177
- function relPath(p) {
1178
- const cwd = process.cwd();
1179
- return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
1264
+ function relPath(p, base) {
1265
+ return relative(base, p) || p;
1180
1266
  }
1181
1267
 
1182
1268
  // src/codemod/detect.ts
1183
1269
  init_esm_shims();
1184
1270
  import { readFile as readFile3, access as access2 } from "fs/promises";
1185
- import { join as join2 } from "path";
1271
+ import { join as join3 } from "path";
1186
1272
  var REQUIRED_DEPS = {
1187
1273
  react: { name: "react", minMajor: 18 },
1188
1274
  vite: { name: "vite", minMajor: 5 },
1189
- tailwind: { name: "tailwindcss", minMajor: 3 }
1275
+ tailwind: { name: "tailwindcss", minMajor: 3, maxMajor: 3 }
1190
1276
  };
1191
1277
  async function detectStack(rootPath) {
1192
- const pkgPath = join2(rootPath, "package.json");
1278
+ const pkgPath = join3(rootPath, "package.json");
1193
1279
  let pkg;
1194
1280
  try {
1195
1281
  pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
@@ -1202,6 +1288,7 @@ async function detectStack(rootPath) {
1202
1288
  const versions = {};
1203
1289
  const missing = [];
1204
1290
  const wrongVersion = [];
1291
+ const unparseable = [];
1205
1292
  for (const [key, req] of Object.entries(REQUIRED_DEPS)) {
1206
1293
  const raw = allDeps[req.name];
1207
1294
  if (!raw) {
@@ -1210,11 +1297,30 @@ async function detectStack(rootPath) {
1210
1297
  }
1211
1298
  const major = parseMajor(raw);
1212
1299
  versions[key] = { raw, major };
1300
+ if (major === 0) {
1301
+ unparseable.push(
1302
+ `${req.name}@"${raw}" \u2014 can't read a version out of that. Pin an explicit range (e.g. "^${req.minMajor}.0.0") in package.json, or pass --target at the workspace member that has one.`
1303
+ );
1304
+ continue;
1305
+ }
1213
1306
  if (major < req.minMajor) {
1214
1307
  wrongVersion.push(
1215
1308
  `${req.name}@${raw} \u2014 v1 requires ${req.name} >= ${req.minMajor}.x`
1216
1309
  );
1217
1310
  }
1311
+ if (req.maxMajor !== void 0 && major > req.maxMajor) {
1312
+ wrongVersion.push(
1313
+ `${req.name}@${raw} \u2014 v1 requires ${req.name} <= ${req.maxMajor}.x.
1314
+ Taste emits a Tailwind 3 config (tailwind.config.ts + @tailwind directives
1315
+ in src/index.css). Tailwind 4 ignores the JS config and uses CSS-first
1316
+ @theme instead, so the generated tokens would silently do nothing.
1317
+ Downgrade with \`npm install -D tailwindcss@^3.4.17\`, or run with
1318
+ --no-apply and port the emitted tokens to @theme by hand.`
1319
+ );
1320
+ }
1321
+ }
1322
+ if (unparseable.length) {
1323
+ throw new StackMismatchError(unparseable.join("\n"));
1218
1324
  }
1219
1325
  if (missing.length) {
1220
1326
  throw new StackMismatchError(
@@ -1232,13 +1338,13 @@ async function detectStack(rootPath) {
1232
1338
  ];
1233
1339
  let tailwindConfigPath = null;
1234
1340
  for (const c2 of tailwindConfigCandidates) {
1235
- if (await fileExists2(join2(rootPath, c2))) {
1236
- tailwindConfigPath = join2(rootPath, c2);
1341
+ if (await fileExists2(join3(rootPath, c2))) {
1342
+ tailwindConfigPath = join3(rootPath, c2);
1237
1343
  break;
1238
1344
  }
1239
1345
  }
1240
- const ts = await fileExists2(join2(rootPath, "tsconfig.json"));
1241
- const srcPath = join2(rootPath, "src");
1346
+ const ts = await fileExists2(join3(rootPath, "tsconfig.json"));
1347
+ const srcPath = join3(rootPath, "src");
1242
1348
  const hasSrcDir = await fileExists2(srcPath);
1243
1349
  if (!hasSrcDir) {
1244
1350
  throw new StackMismatchError(
@@ -1285,19 +1391,20 @@ async function fileExists2(p) {
1285
1391
 
1286
1392
  // src/codemod/emit-tokens.ts
1287
1393
  init_esm_shims();
1288
- import { join as join3, dirname as dirname2, resolve as resolve2 } from "path";
1394
+ import { join as join4 } from "path";
1289
1395
  import { readFile as readFile4 } from "fs/promises";
1290
- import { fileURLToPath as fileURLToPath3 } from "url";
1291
- var __dirname3 = dirname2(fileURLToPath3(import.meta.url));
1292
1396
  async function emitTokens(opts) {
1293
1397
  const { direction, stack } = opts;
1294
1398
  const tokensTs = await loadDirectionTokensSource(direction);
1295
1399
  const directionMd = await loadDirectionMarkdown(direction);
1296
1400
  const parsed = parseDirectionTokens(tokensTs, direction);
1401
+ if (Object.keys(parsed.colors).length === 0) {
1402
+ throw unemittableError(direction, tokensTs);
1403
+ }
1297
1404
  const tailwindConfig = renderTailwindConfig(parsed, stack);
1298
1405
  const indexCss = renderIndexCss(parsed, directionMd);
1299
- const tailwindPath = stack.tailwindConfigPath ? stack.tailwindConfigPath : join3(stack.rootPath, stack.ts ? "tailwind.config.ts" : "tailwind.config.js");
1300
- const indexCssPath = join3(stack.srcPath, "index.css");
1406
+ const tailwindPath = stack.tailwindConfigPath ? stack.tailwindConfigPath : join4(stack.rootPath, stack.ts ? "tailwind.config.ts" : "tailwind.config.js");
1407
+ const indexCssPath = join4(stack.srcPath, "index.css");
1301
1408
  const notes = [];
1302
1409
  if (stack.hasTailwindConfig) {
1303
1410
  notes.push(
@@ -1310,33 +1417,55 @@ async function emitTokens(opts) {
1310
1417
  notes
1311
1418
  };
1312
1419
  }
1420
+ async function assertDirectionEmittable(slug) {
1421
+ const src = await loadDirectionTokensSource(slug);
1422
+ const parsed = parseDirectionTokens(src, slug);
1423
+ if (Object.keys(parsed.colors).length === 0) {
1424
+ throw unemittableError(slug, src);
1425
+ }
1426
+ }
1427
+ function unemittableError(slug, src) {
1428
+ const shape = /^\s*palette:\s*\{/m.test(src) ? `"${slug}" ships the newer theme.yml palette format, which the token
1429
+ emitter can't consume yet.` : `"${slug}" has no readable colors block in its tokens.ts.`;
1430
+ return new CliError(
1431
+ `Can't generate tokens for direction "${slug}".
1432
+
1433
+ ${shape}
1434
+
1435
+ Directions that emit cleanly today include:
1436
+ soft-modern \xB7 signal-dark \xB7 brutalist \xB7 newsletter \xB7 cinematic-dark
1437
+ dense-utility \xB7 mid-century \xB7 maximalist \xB7 couture-luxury \xB7 architectural
1438
+
1439
+ Nothing has been written \u2014 pick another --style and re-run.`
1440
+ );
1441
+ }
1313
1442
  async function loadDirectionTokensSource(slug) {
1314
- const candidates = [
1315
- resolve2(__dirname3, "..", "..", "themes", slug, "tokens.ts"),
1316
- resolve2(__dirname3, "..", "..", "..", "themes", slug, "tokens.ts")
1317
- ];
1318
- for (const p of candidates) {
1319
- try {
1320
- return await readFile4(p, "utf8");
1321
- } catch {
1322
- continue;
1323
- }
1443
+ const themesDir = resolveAssetDir("themes");
1444
+ if (!themesDir) {
1445
+ throw new CliError(
1446
+ `Canonical tokens.ts for direction "${slug}" not found.
1447
+
1448
+ ${missingAssetMessage("themes")}`
1449
+ );
1450
+ }
1451
+ try {
1452
+ return await readFile4(join4(themesDir, slug, "tokens.ts"), "utf8");
1453
+ } catch {
1454
+ throw new CliError(
1455
+ `Canonical tokens.ts for direction "${slug}" not found at ${join4(themesDir, slug, "tokens.ts")}.
1456
+
1457
+ Pass a different --style, or report this at https://github.com/frischermf/taste/issues.`
1458
+ );
1324
1459
  }
1325
- throw new CliError(`Canonical tokens.ts for direction "${slug}" not found.`);
1326
1460
  }
1327
1461
  async function loadDirectionMarkdown(slug) {
1328
- const candidates = [
1329
- resolve2(__dirname3, "..", "..", "themes", slug, "THEME.md"),
1330
- resolve2(__dirname3, "..", "..", "..", "themes", slug, "THEME.md")
1331
- ];
1332
- for (const p of candidates) {
1333
- try {
1334
- return await readFile4(p, "utf8");
1335
- } catch {
1336
- continue;
1337
- }
1462
+ const themesDir = resolveAssetDir("themes");
1463
+ if (!themesDir) return "";
1464
+ try {
1465
+ return await readFile4(join4(themesDir, slug, "THEME.md"), "utf8");
1466
+ } catch {
1467
+ return "";
1338
1468
  }
1339
- return "";
1340
1469
  }
1341
1470
  function parseDirectionTokens(src, slug) {
1342
1471
  const ethos = matchString(src, /ethos:\s*"([^"]+)"/) ?? `(no ethos for ${slug})`;
@@ -1524,6 +1653,11 @@ ${indent(k.css, 4)}
1524
1653
  box-shadow: ${value};
1525
1654
  }`
1526
1655
  ).join("\n\n");
1656
+ const borderReset = d.colors.border ? ` * {
1657
+ @apply border-border;
1658
+ }
1659
+
1660
+ ` : "";
1527
1661
  return `${headerComment}${fontImport}@tailwind base;
1528
1662
  @tailwind components;
1529
1663
  @tailwind utilities;
@@ -1535,11 +1669,7 @@ ${tokenLines}
1535
1669
  }
1536
1670
 
1537
1671
  @layer base {
1538
- * {
1539
- @apply border-border;
1540
- }
1541
-
1542
- html {
1672
+ ${borderReset} html {
1543
1673
  scroll-behavior: smooth;
1544
1674
  }
1545
1675
 
@@ -1623,12 +1753,12 @@ function indent(s, n) {
1623
1753
 
1624
1754
  // src/commands/apply.ts
1625
1755
  init_esm_shims();
1626
- import { join as join4, relative as relative2, resolve as resolve3 } from "path";
1756
+ import { join as join5, relative as relative2, resolve as resolve2 } from "path";
1627
1757
  import { access as access3, mkdir as mkdir2, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
1628
1758
  import { execSync } from "child_process";
1629
1759
  var ROUTING_MARK_START2 = "<!-- stylecrib:routing:start -->";
1630
1760
  async function apply(opts = {}) {
1631
- const targetRoot = resolve3(opts.target ?? process.cwd());
1761
+ const targetRoot = resolve2(opts.target ?? process.cwd());
1632
1762
  await applyFromStaging({
1633
1763
  targetRoot,
1634
1764
  branchName: opts.branch,
@@ -1637,13 +1767,13 @@ async function apply(opts = {}) {
1637
1767
  }
1638
1768
  async function applyFromStaging(opts) {
1639
1769
  const { targetRoot, noBranch } = opts;
1640
- const stagingRoot = join4(targetRoot, ".design-upgrade");
1770
+ const stagingRoot = join5(targetRoot, ".design-upgrade");
1641
1771
  if (!await dirExists(stagingRoot)) {
1642
1772
  throw new CliError(
1643
1773
  `No staged changes at ${stagingRoot}. Run \`stylecrib init\` first.`
1644
1774
  );
1645
1775
  }
1646
- const isGit = await dirExists(join4(targetRoot, ".git"));
1776
+ const isGit = await dirExists(join5(targetRoot, ".git"));
1647
1777
  if (isGit && !noBranch) {
1648
1778
  const branchName = opts.branchName ?? defaultBranchName();
1649
1779
  try {
@@ -1672,7 +1802,7 @@ Pass --no-branch to skip branch creation.`,
1672
1802
  const filesCopied = [];
1673
1803
  for await (const stagedPath of walk(stagingRoot)) {
1674
1804
  const rel = relative2(stagingRoot, stagedPath);
1675
- const targetPath = join4(targetRoot, rel);
1805
+ const targetPath = join5(targetRoot, rel);
1676
1806
  if (rel === "CLAUDE.md" && await fileExists3(targetPath)) {
1677
1807
  const existing = await readFile5(targetPath, "utf8");
1678
1808
  if (existing.includes(ROUTING_MARK_START2)) {
@@ -1734,8 +1864,8 @@ function dirname3(p) {
1734
1864
  // src/commands/install-hooks.ts
1735
1865
  init_esm_shims();
1736
1866
  var import_picocolors2 = __toESM(require_picocolors(), 1);
1737
- import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "fs";
1738
- import { resolve as resolve4, join as join5, dirname as dirname4 } from "path";
1867
+ import { existsSync as existsSync2, mkdirSync, writeFileSync, chmodSync, readFileSync } from "fs";
1868
+ import { resolve as resolve3, join as join6, dirname as dirname4 } from "path";
1739
1869
 
1740
1870
  // src/lib/bundled-agents.ts
1741
1871
  init_esm_shims();
@@ -1833,11 +1963,11 @@ var BUNDLED_AGENTS = {
1833
1963
  };
1834
1964
  function loadAgentDoctrine(target, agentName) {
1835
1965
  try {
1836
- const { existsSync: existsSync16, readFileSync: readFileSync17 } = __require("fs");
1837
- const { join: join22 } = __require("path");
1838
- const localPath = join22(target, ".claude", "agents", `${agentName}.md`);
1839
- if (existsSync16(localPath)) {
1840
- return readFileSync17(localPath, "utf8");
1966
+ const { existsSync: existsSync20, readFileSync: readFileSync20 } = __require("fs");
1967
+ const { join: join27 } = __require("path");
1968
+ const localPath = join27(target, ".claude", "agents", `${agentName}.md`);
1969
+ if (existsSync20(localPath)) {
1970
+ return readFileSync20(localPath, "utf8");
1841
1971
  }
1842
1972
  } catch {
1843
1973
  }
@@ -1846,73 +1976,77 @@ function loadAgentDoctrine(target, agentName) {
1846
1976
 
1847
1977
  // src/commands/install-hooks.ts
1848
1978
  async function installHooks(opts = {}) {
1849
- const target = resolve4(opts.target ?? process.cwd());
1850
- if (!existsSync(join5(target, ".git"))) {
1851
- console.error(import_picocolors2.default.red("\u2717 ") + "Not a git repository.");
1852
- console.error(import_picocolors2.default.dim(" Run `git init` first, then `stylecrib install-hooks`."));
1853
- process.exit(1);
1979
+ const target = resolve3(opts.target ?? process.cwd());
1980
+ if (!existsSync2(join6(target, ".git"))) {
1981
+ throw new NotAGitRepoError(target);
1854
1982
  }
1855
1983
  const writes = [];
1856
- const huskyDir = join5(target, ".husky");
1857
- const useHusky = existsSync(huskyDir);
1858
- const hookPath = useHusky ? join5(huskyDir, "pre-commit") : join5(target, ".git/hooks/pre-commit");
1984
+ const huskyDir = join6(target, ".husky");
1985
+ const useHusky = existsSync2(huskyDir);
1986
+ const hookPath = useHusky ? join6(huskyDir, "pre-commit") : join6(target, ".git/hooks/pre-commit");
1859
1987
  const hookBody = PRE_COMMIT_HOOK;
1860
1988
  writes.push({ path: hookPath, body: hookBody, chmod: 493 });
1861
1989
  if (opts.cursorrules !== false) {
1862
1990
  writes.push({
1863
- path: join5(target, ".cursorrules"),
1991
+ path: join6(target, ".cursorrules"),
1864
1992
  body: CURSORRULES
1865
1993
  });
1866
1994
  }
1867
1995
  writes.push({
1868
- path: join5(target, ".github/workflows/stylecrib-review.yml"),
1996
+ path: join6(target, ".github/workflows/stylecrib-review.yml"),
1869
1997
  body: GITHUB_ACTION
1870
1998
  });
1871
1999
  writes.push({
1872
- path: join5(target, ".claude/settings.json"),
2000
+ path: join6(target, ".claude/settings.json"),
1873
2001
  body: CLAUDE_SETTINGS
1874
2002
  });
1875
2003
  writes.push({
1876
- path: join5(target, ".stylecrib/config.json"),
1877
- body: TASTE_CONFIG
2004
+ path: join6(target, ".stylecrib/config.json"),
2005
+ body: mergeTasteConfig(join6(target, ".stylecrib/config.json"), opts.theme),
2006
+ merge: true
1878
2007
  });
1879
2008
  writes.push({
1880
- path: join5(target, ".stylecrib/RULEBOOK.md"),
2009
+ path: join6(target, ".stylecrib/RULEBOOK.md"),
1881
2010
  body: renderRulebook()
1882
2011
  });
1883
2012
  for (const [name, doctrine] of Object.entries(BUNDLED_AGENTS)) {
1884
2013
  writes.push({
1885
- path: join5(target, ".claude/agents", `${name}.md`),
2014
+ path: join6(target, ".claude/agents", `${name}.md`),
1886
2015
  body: doctrine
1887
2016
  });
1888
2017
  }
1889
2018
  console.log(import_picocolors2.default.bold("\u25C7 StyleCrib enforcement layer\n"));
1890
2019
  const collisions = [];
1891
2020
  let wroteAny = false;
2021
+ let wroteCount = 0;
1892
2022
  for (const w of writes) {
1893
- const exists = existsSync(w.path);
2023
+ const exists = existsSync2(w.path);
1894
2024
  if (exists) {
1895
2025
  const existing = readFileSync(w.path, "utf8");
1896
2026
  if (existing === w.body) {
1897
2027
  console.log(import_picocolors2.default.dim(" \xB7 " + relPath2(w.path, target) + " (unchanged)"));
1898
2028
  continue;
1899
2029
  }
1900
- if (!opts.force) {
2030
+ if (w.merge) {
2031
+ console.log(import_picocolors2.default.dim(" \xB7 " + relPath2(w.path, target) + " (merged)"));
2032
+ } else if (!opts.force) {
1901
2033
  console.log(
1902
2034
  import_picocolors2.default.yellow(" \u26A0 ") + relPath2(w.path, target) + import_picocolors2.default.dim(" exists with different content \u2014 see summary below")
1903
2035
  );
1904
2036
  collisions.push({ path: w.path, existing });
1905
2037
  continue;
2038
+ } else {
2039
+ const backup = w.path + ".pre-stylecrib.bak";
2040
+ writeFileSync(backup, existing);
2041
+ console.log(import_picocolors2.default.dim(" \u21AA backed up existing content to " + relPath2(backup, target)));
1906
2042
  }
1907
- const backup = w.path + ".pre-taste.bak";
1908
- writeFileSync(backup, existing);
1909
- console.log(import_picocolors2.default.dim(" \u21AA backed up existing content to " + relPath2(backup, target)));
1910
2043
  }
1911
2044
  mkdirSync(dirname4(w.path), { recursive: true });
1912
2045
  writeFileSync(w.path, w.body);
1913
2046
  if (w.chmod) chmodSync(w.path, w.chmod);
1914
2047
  console.log(import_picocolors2.default.green(" \u2713 ") + relPath2(w.path, target));
1915
2048
  wroteAny = true;
2049
+ wroteCount++;
1916
2050
  }
1917
2051
  if (collisions.length > 0) {
1918
2052
  console.error("");
@@ -1921,7 +2055,7 @@ async function installHooks(opts = {}) {
1921
2055
  console.error(import_picocolors2.default.bold(" What to do:"));
1922
2056
  console.error("");
1923
2057
  console.error(" Option 1 \u2014 pass --force to overwrite. The existing content of each file");
1924
- console.error(" will be backed up to `<path>.pre-taste.bak` before the new content lands.");
2058
+ console.error(" will be backed up to `<path>.pre-stylecrib.bak` before the new content lands.");
1925
2059
  console.error("");
1926
2060
  console.error(" Option 2 \u2014 inspect each collision, merge by hand, and re-run.");
1927
2061
  console.error("");
@@ -1932,7 +2066,7 @@ async function installHooks(opts = {}) {
1932
2066
  console.error("");
1933
2067
  console.error(import_picocolors2.default.dim(" Half-installed enforcement is worse than none \u2014 the customer would"));
1934
2068
  console.error(import_picocolors2.default.dim(" assume the gate is active. Refusing to print the success banner."));
1935
- process.exit(1);
2069
+ throw new HookCollisionError(collisions.map((x) => relPath2(x.path, target)), wroteCount);
1936
2070
  }
1937
2071
  if (!wroteAny) {
1938
2072
  console.log("");
@@ -1957,7 +2091,7 @@ async function installHooks(opts = {}) {
1957
2091
  console.log(" AI agents should read this at session start; humans should read it in code review.");
1958
2092
  console.log("");
1959
2093
  console.log(import_picocolors2.default.bold("The agents:"));
1960
- console.log(" \xB7 " + import_picocolors2.default.cyan(".claude/agents/") + " \u2014 qa-conductor + 15 specialist agents, ready for immediate use.");
2094
+ console.log(" \xB7 " + import_picocolors2.default.cyan(".claude/agents/") + " \u2014 qa-conductor + 18 specialist agents, ready for immediate use.");
1961
2095
  console.log("");
1962
2096
  console.log(import_picocolors2.default.dim("Config: .stylecrib/config.json (enforce: 'hard' by default; downgrade to 'warn' for gradual migration)."));
1963
2097
  console.log(import_picocolors2.default.dim('Bypass: `git commit --no-verify` or `STYLECRIB_SKIP_QA="reason" git commit` (logged for review).'));
@@ -1994,15 +2128,46 @@ var CLAUDE_SETTINGS = `{
1994
2128
  }
1995
2129
  }
1996
2130
  `;
1997
- var TASTE_CONFIG = `{
1998
- "enforce": "hard",
1999
- "notes": [
2131
+ function mergeTasteConfig(path2, theme) {
2132
+ let existing = null;
2133
+ if (existsSync2(path2)) {
2134
+ try {
2135
+ const parsed = JSON.parse(readFileSync(path2, "utf8"));
2136
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2137
+ existing = parsed;
2138
+ }
2139
+ } catch {
2140
+ try {
2141
+ writeFileSync(path2 + ".pre-stylecrib.bak", readFileSync(path2, "utf8"));
2142
+ } catch {
2143
+ }
2144
+ }
2145
+ }
2146
+ if (!existing) return tasteConfig(theme);
2147
+ const merged = { ...existing };
2148
+ if (typeof merged.enforce !== "string") merged.enforce = "hard";
2149
+ if (theme && typeof merged.theme !== "string") merged.theme = theme;
2150
+ merged.notes = JSON.parse(tasteConfig(typeof merged.theme === "string" ? merged.theme : theme)).notes;
2151
+ return JSON.stringify(merged, null, 2) + "\n";
2152
+ }
2153
+ function tasteConfig(theme) {
2154
+ const notes = [
2000
2155
  "enforce: 'hard' (default) \u2014 pre-commit BLOCKs on block-severity slop hits AND requires qa-conductor auth on every UI-touching commit.",
2001
- "enforce: 'warn' \u2014 downgrades every gate to advisory. Reserved for gradual-migration mode when the pack was installed on top of a large existing codebase.",
2156
+ "enforce: 'warn' \u2014 downgrades every gate to advisory. Reserved for gradual-migration mode when the pack was installed on top of a large existing codebase."
2157
+ ];
2158
+ if (theme) {
2159
+ notes.push(
2160
+ "theme: the active StyleCrib theme slug. Read by 'stylecrib check' to enforce per-theme rules such as R-SECOND-ACCENT. Change it if you switch themes."
2161
+ );
2162
+ }
2163
+ notes.push(
2002
2164
  "See https://github.com/frischermf/stylecrib/blob/main/README.md for the full rule catalog and each rule's escape hatch."
2003
- ]
2165
+ );
2166
+ const config = { enforce: "hard" };
2167
+ if (theme) config.theme = theme;
2168
+ config.notes = notes;
2169
+ return JSON.stringify(config, null, 2) + "\n";
2004
2170
  }
2005
- `;
2006
2171
  function renderRulebook() {
2007
2172
  const { SLOP_RULES: SLOP_RULES2 } = (init_slop_rules(), __toCommonJS(slop_rules_exports));
2008
2173
  const lines = [];
@@ -2122,10 +2287,10 @@ jobs:
2122
2287
  with:
2123
2288
  node-version: "20"
2124
2289
  cache: npm
2125
- # Install the project's own dependencies so \`stylecrib\` resolves from
2126
- # node_modules. Without this, \`npx stylecrib\` on a CI runner would
2127
- # fetch whatever package owns that name on the registry and run it
2128
- # with ANTHROPIC_API_KEY in the environment.
2290
+ # Install the repo's own pinned dependencies first. Without this, a bare
2291
+ # \`npx stylecrib\` on a fresh runner would fetch whatever package owns
2292
+ # that name on the registry and run it with ANTHROPIC_API_KEY in the
2293
+ # environment.
2129
2294
  - run: npm ci
2130
2295
  - name: Diff against base
2131
2296
  run: |
@@ -2142,11 +2307,13 @@ jobs:
2142
2307
  init_esm_shims();
2143
2308
  var import_picocolors3 = __toESM(require_picocolors(), 1);
2144
2309
  import { execSync as execSync2 } from "child_process";
2145
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2146
- import { dirname as dirname5, join as join6, relative as relative3, resolve as resolve5 } from "path";
2310
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2311
+ import { dirname as dirname5, join as join7, relative as relative3, resolve as resolve4 } from "path";
2312
+ var RUNTIME_PKG = "@stylecrib/react";
2313
+ var RUNTIME_PKG_RANGE = ">=0.2.2 <0.3.0";
2147
2314
  function readDeps(target) {
2148
- const path2 = join6(target, "package.json");
2149
- if (!existsSync2(path2)) return null;
2315
+ const path2 = join7(target, "package.json");
2316
+ if (!existsSync3(path2)) return null;
2150
2317
  try {
2151
2318
  const pkg = JSON.parse(readFileSync2(path2, "utf8"));
2152
2319
  return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
@@ -2161,14 +2328,55 @@ function hasReactRouter(deps) {
2161
2328
  return "react-router-dom" in deps;
2162
2329
  }
2163
2330
  function resolveWireFilePath(target) {
2164
- const srcLib = join6(target, "src", "lib");
2165
- const src = join6(target, "src");
2166
- if (existsSync2(srcLib)) return join6(srcLib, "stylecrib-wire.tsx");
2167
- if (existsSync2(src)) return join6(src, "lib", "stylecrib-wire.tsx");
2168
- return join6(target, "src", "lib", "stylecrib-wire.tsx");
2331
+ const srcLib = join7(target, "src", "lib");
2332
+ const src = join7(target, "src");
2333
+ if (existsSync3(srcLib)) return join7(srcLib, "stylecrib-wire.tsx");
2334
+ if (existsSync3(src)) return join7(src, "lib", "stylecrib-wire.tsx");
2335
+ return join7(target, "src", "lib", "stylecrib-wire.tsx");
2336
+ }
2337
+ function runtimeAtLeast(target, major, minor, patch = 0) {
2338
+ try {
2339
+ const pkg = JSON.parse(
2340
+ readFileSync2(join7(target, "node_modules", RUNTIME_PKG, "package.json"), "utf8")
2341
+ );
2342
+ const m = String(pkg.version ?? "").match(/^(\d+)\.(\d+)\.(\d+)/);
2343
+ if (!m) return false;
2344
+ const [maj, min, pat] = [m[1], m[2], m[3]].map((n) => parseInt(n, 10));
2345
+ if (maj !== major) return maj > major;
2346
+ if (min !== minor) return min > minor;
2347
+ return pat >= patch;
2348
+ } catch {
2349
+ return false;
2350
+ }
2169
2351
  }
2170
- function buildStyleCribWireTemplate(opts) {
2352
+ function resolveThemeFonts(themeSlug) {
2353
+ const themesDir = resolveAssetDir("themes");
2354
+ if (!themesDir) return null;
2355
+ const tokensPath = join7(themesDir, themeSlug, "tokens.ts");
2356
+ if (!existsSync3(tokensPath)) return null;
2357
+ let src;
2358
+ try {
2359
+ src = readFileSync2(tokensPath, "utf8");
2360
+ } catch {
2361
+ return null;
2362
+ }
2363
+ const pick = (role) => {
2364
+ const block = src.match(new RegExp(`${role}:\\s*\\{([\\s\\S]{0,600}?)\\}`));
2365
+ if (!block) return [];
2366
+ const fam = block[1].match(/family:\s*"([^"]+)"/);
2367
+ return fam ? [fam[1]] : [];
2368
+ };
2369
+ const fonts = { display: pick("display"), body: pick("body"), mono: pick("mono") };
2370
+ if (!fonts.display.length && !fonts.body.length && !fonts.mono.length) return null;
2371
+ return fonts;
2372
+ }
2373
+ function buildTasteWireTemplate(opts) {
2171
2374
  const { themeSlug, themeName, themeSwatch, scaffoldedAt } = opts;
2375
+ const fonts = opts.emitFonts ? resolveThemeFonts(themeSlug) : null;
2376
+ const themeFontsBlock = fonts ? `
2377
+ fonts: {
2378
+ ` + ["display", "body", "mono"].filter((role) => fonts[role].length > 0).map((role) => ` ${role}: ${JSON.stringify(fonts[role])},`).join("\n") + `
2379
+ },` : "";
2172
2380
  return `// StyleCrib \u2014 customer runtime wire-up
2173
2381
  // Scaffolded by \`stylecrib init\` on ${scaffoldedAt}.
2174
2382
  //
@@ -2208,7 +2416,7 @@ const INSTALLED_THEMES: RegisteredTheme[] = [
2208
2416
  name: "${themeName}",
2209
2417
  swatch: "${themeSwatch}",
2210
2418
  installed: true,
2211
- briefUrl: "https://github.com/frischermf/stylecrib/tree/main/themes/${themeSlug}",
2419
+ briefUrl: "https://github.com/frischermf/stylecrib/tree/main/themes/${themeSlug}",${themeFontsBlock}
2212
2420
  },
2213
2421
  ];
2214
2422
 
@@ -2242,9 +2450,10 @@ export function StyleCribWire({ children }: { children: ReactNode }) {
2242
2450
  }
2243
2451
  function runNpmInstall(target, silent) {
2244
2452
  try {
2245
- const hasYarnLock = existsSync2(join6(target, "yarn.lock"));
2246
- const hasPnpmLock = existsSync2(join6(target, "pnpm-lock.yaml"));
2247
- const cmd = hasPnpmLock ? "pnpm add @stylecrib/react" : hasYarnLock ? "yarn add @stylecrib/react" : "npm install @stylecrib/react";
2453
+ const hasYarnLock = existsSync3(join7(target, "yarn.lock"));
2454
+ const hasPnpmLock = existsSync3(join7(target, "pnpm-lock.yaml"));
2455
+ const spec = `"${RUNTIME_PKG}@${RUNTIME_PKG_RANGE}"`;
2456
+ const cmd = hasPnpmLock ? `pnpm add ${spec}` : hasYarnLock ? `yarn add ${spec}` : `npm install ${spec}`;
2248
2457
  if (!silent) console.log(import_picocolors3.default.dim(" running: " + cmd));
2249
2458
  execSync2(cmd, { cwd: target, stdio: silent ? "ignore" : "inherit" });
2250
2459
  return true;
@@ -2253,7 +2462,7 @@ function runNpmInstall(target, silent) {
2253
2462
  }
2254
2463
  }
2255
2464
  async function installRuntimeScaffold(opts) {
2256
- const target = resolve5(opts.target);
2465
+ const target = resolve4(opts.target);
2257
2466
  const themeSwatch = opts.themeSwatch ?? "#FFDF29";
2258
2467
  const silent = opts.silent ?? false;
2259
2468
  if (!silent) console.log(import_picocolors3.default.bold("\u25C7 Runtime pack\n"));
@@ -2290,29 +2499,30 @@ async function installRuntimeScaffold(opts) {
2290
2499
  }
2291
2500
  const scaffoldPath = resolveWireFilePath(target);
2292
2501
  const scaffoldRel = relative3(target, scaffoldPath);
2293
- if (existsSync2(scaffoldPath) && !opts.force) {
2502
+ if (existsSync3(scaffoldPath) && !opts.force) {
2294
2503
  if (!silent) {
2295
2504
  console.log("");
2296
2505
  console.log(import_picocolors3.default.red(" \u2717 ") + scaffoldRel + " already exists.");
2297
2506
  console.log(import_picocolors3.default.dim(" Refusing to overwrite. Options:"));
2298
2507
  console.log(import_picocolors3.default.dim(" \xB7 Rename / archive the existing file and re-run `stylecrib init`."));
2299
- console.log(import_picocolors3.default.dim(" \xB7 Pass `--force-runtime` to overwrite (backs up existing to `.pre-taste.bak` first)."));
2508
+ console.log(import_picocolors3.default.dim(" \xB7 Pass `--force-runtime` to overwrite (backs up existing to `.pre-stylecrib.bak` first)."));
2300
2509
  console.log(import_picocolors3.default.dim(" \xB7 Skip this scaffold with `--no-runtime` \u2014 copy the wire-up manually per docs/STYLECRIB_ADMIN.md."));
2301
2510
  }
2302
2511
  return { kind: "collision", scaffoldPath };
2303
2512
  }
2304
- if (existsSync2(scaffoldPath) && opts.force) {
2305
- const backup = scaffoldPath + ".pre-taste.bak";
2513
+ if (existsSync3(scaffoldPath) && opts.force) {
2514
+ const backup = scaffoldPath + ".pre-stylecrib.bak";
2306
2515
  writeFileSync2(backup, readFileSync2(scaffoldPath, "utf8"));
2307
2516
  if (!silent) console.log(import_picocolors3.default.dim(" \u21AA backed up existing " + scaffoldRel + " to " + relative3(target, backup)));
2308
2517
  }
2309
2518
  mkdirSync2(dirname5(scaffoldPath), { recursive: true });
2310
2519
  writeFileSync2(
2311
2520
  scaffoldPath,
2312
- buildStyleCribWireTemplate({
2521
+ buildTasteWireTemplate({
2313
2522
  themeSlug: opts.themeSlug,
2314
2523
  themeName: opts.themeName,
2315
2524
  themeSwatch,
2525
+ emitFonts: runtimeAtLeast(target, 0, 2, 2),
2316
2526
  scaffoldedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
2317
2527
  })
2318
2528
  );
@@ -2337,12 +2547,36 @@ async function installRuntimeScaffold(opts) {
2337
2547
  return { kind: "installed", scaffoldPath, ranNpmInstall };
2338
2548
  }
2339
2549
 
2550
+ // src/lib/tty.ts
2551
+ init_esm_shims();
2552
+ function isInteractive() {
2553
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
2554
+ }
2555
+ function assertInteractive(what) {
2556
+ if (isInteractive()) return;
2557
+ throw new CliError(
2558
+ `${what} needs a terminal, and none is attached.
2559
+
2560
+ Re-run one of these ways:
2561
+ \u2022 --yes accept canonical defaults, no prompts
2562
+ \u2022 --style <slug> pick the theme directly, no prompts
2563
+ \u2022 from an interactive shell (not piped, not CI)`
2564
+ );
2565
+ }
2566
+
2567
+ // src/lib/version.ts
2568
+ init_esm_shims();
2569
+ var VERSION = true ? "0.3.3" : "0.0.0-dev";
2570
+
2340
2571
  // src/commands/init.ts
2341
- var __dirname4 = dirname6(fileURLToPath4(import.meta.url));
2342
2572
  async function init(opts) {
2343
- const target = resolve6(opts.target ?? process.cwd());
2344
- if (!process.env.ANTHROPIC_API_KEY) throw new MissingApiKeyError();
2345
- console.log(c.brand("StyleCrib") + c.dim(" \u2014 design upgrade CLI \xB7 v0.1.0"));
2573
+ const target = resolve5(opts.target ?? process.cwd());
2574
+ assertValidDirection(opts.style);
2575
+ if (opts.style && opts.style !== "custom") {
2576
+ await assertDirectionEmittable(opts.style);
2577
+ }
2578
+ const nonInteractive = opts.yes === true || !isInteractive();
2579
+ console.log(c.brand("StyleCrib") + c.dim(` \u2014 design upgrade CLI \xB7 v${VERSION}`));
2346
2580
  console.log(c.dim("target: ") + target);
2347
2581
  if (opts.dryRun) console.log(c.warn("DRY RUN \u2014 no files will be written\n"));
2348
2582
  console.log(step(1, 5, "Detect stack"));
@@ -2352,7 +2586,8 @@ async function init(opts) {
2352
2586
  console.log(header(step(2, 5, "Collect direction")));
2353
2587
  const wizard = await runWizard({
2354
2588
  defaultProjectName: basename(target),
2355
- skippedStyle: opts.style
2589
+ skippedStyle: opts.style,
2590
+ nonInteractive
2356
2591
  });
2357
2592
  console.log(header(step(3, 5, "Ingest references")));
2358
2593
  const contextBlocks = [];
@@ -2403,6 +2638,7 @@ async function init(opts) {
2403
2638
  rationale: `Pure-canonical "${wizard.baseDirection}" install \u2014 Director call skipped to save tokens.`
2404
2639
  };
2405
2640
  } else {
2641
+ if (!process.env.ANTHROPIC_API_KEY) throw new MissingApiKeyError();
2406
2642
  console.log(c.dim(" calling Anthropic API (this can take 20-60s)..."));
2407
2643
  const baseMd = wizard.baseDirection === "custom" ? "" : await loadCanonicalDirectionMarkdown(wizard.baseDirection);
2408
2644
  directorOutput = await callDirector({
@@ -2450,10 +2686,17 @@ async function init(opts) {
2450
2686
  console.log(
2451
2687
  c.dim(` ${result.filesStaged.length} file(s) staged at ${result.stagingRoot}`)
2452
2688
  );
2453
- const apply2 = await confirm2({
2454
- message: "Apply now? This creates a new git branch and copies staged files into your project.",
2455
- default: true
2456
- });
2689
+ let apply2;
2690
+ if (opts.apply !== void 0) {
2691
+ apply2 = opts.apply;
2692
+ } else if (nonInteractive) {
2693
+ apply2 = false;
2694
+ } else {
2695
+ apply2 = await confirm2({
2696
+ message: "Apply now? This creates a new git branch and copies staged files into your project.",
2697
+ default: true
2698
+ });
2699
+ }
2457
2700
  if (apply2) {
2458
2701
  await applyFromStaging({ targetRoot: target });
2459
2702
  } else {
@@ -2461,6 +2704,11 @@ async function init(opts) {
2461
2704
  console.log(c.dim("Next:"));
2462
2705
  console.log(c.dim(" \u2022 Review .design-upgrade/ in your editor."));
2463
2706
  console.log(c.dim(" \u2022 Run `stylecrib apply` when ready to commit the changes to a new branch."));
2707
+ if (nonInteractive && opts.apply === void 0) {
2708
+ console.log(
2709
+ c.dim(" (staged rather than applied because there was no terminal to confirm with \u2014\n") + c.dim(" pass --apply to skip this step next time.)")
2710
+ );
2711
+ }
2464
2712
  }
2465
2713
  if (opts.runtime !== false && !opts.dryRun) {
2466
2714
  console.log("");
@@ -2478,6 +2726,16 @@ async function init(opts) {
2478
2726
  " \u26A0 Runtime scaffold skipped \u2014 src/lib/stylecrib-wire.tsx already exists. Re-run with --force-runtime to overwrite (backup taken), or --no-runtime to suppress this step."
2479
2727
  )
2480
2728
  );
2729
+ } else if (result2.kind === "skipped-no-react-router") {
2730
+ console.log(
2731
+ c.warn(
2732
+ " \u26A0 Runtime pack skipped \u2014 react-router-dom is not a dependency.\n The <StyleCribAdmin> drawer uses useLocation for its route-scoped section\n registry, so it can't mount without a router. Install it and re-run:\n npm install react-router-dom && npx stylecrib init --no-hooks"
2733
+ )
2734
+ );
2735
+ } else if (result2.kind === "skipped-no-package-json") {
2736
+ console.log(
2737
+ c.warn(" \u26A0 Runtime pack skipped \u2014 no package.json at the target.")
2738
+ );
2481
2739
  }
2482
2740
  } catch (err) {
2483
2741
  console.log(
@@ -2496,7 +2754,10 @@ async function init(opts) {
2496
2754
  )
2497
2755
  );
2498
2756
  try {
2499
- await installHooks({ target });
2757
+ await installHooks({
2758
+ target,
2759
+ theme: wizard.baseDirection === "custom" ? void 0 : wizard.baseDirection
2760
+ });
2500
2761
  } catch (err) {
2501
2762
  console.log(
2502
2763
  c.warn(
@@ -2569,23 +2830,24 @@ async function runSelfTest(target) {
2569
2830
  }
2570
2831
  }
2571
2832
  async function loadCanonicalDirectionMarkdown(slug) {
2572
- const candidates = [
2573
- resolve6(__dirname4, "..", "themes", slug, "THEME.md"),
2574
- resolve6(__dirname4, "..", "..", "themes", slug, "THEME.md")
2575
- ];
2576
- for (const p of candidates) {
2833
+ const themesDir = resolveAssetDir("themes");
2834
+ if (themesDir) {
2577
2835
  try {
2578
- return await readFile6(p, "utf8");
2836
+ return await readFile6(join8(themesDir, slug, "THEME.md"), "utf8");
2579
2837
  } catch {
2580
- continue;
2838
+ console.log(
2839
+ c.warn(` ! No THEME.md for ${slug} at ${join8(themesDir, slug)} \u2014 falling back to a stub.`)
2840
+ );
2841
+ return `# Design Direction \u2014 ${slug}
2842
+
2843
+ (Canonical THEME.md not bundled \u2014 re-install stylecrib.)
2844
+ `;
2581
2845
  }
2582
2846
  }
2583
- console.log(
2584
- c.warn(` ! Couldn't locate canonical THEME.md for ${slug} \u2014 falling back to a stub.`)
2585
- );
2847
+ console.log(c.warn(` ! ${missingAssetMessage("themes")}`));
2586
2848
  return `# Design Direction \u2014 ${slug}
2587
2849
 
2588
- (Canonical THEME.md not bundled \u2014 re-install taste.)
2850
+ (Canonical THEME.md not bundled \u2014 re-install stylecrib.)
2589
2851
  `;
2590
2852
  }
2591
2853
  function extractEthos(md) {
@@ -2597,13 +2859,61 @@ function extractEthos(md) {
2597
2859
  init_esm_shims();
2598
2860
  var import_picocolors4 = __toESM(require_picocolors(), 1);
2599
2861
  import { execSync as execSync3, spawnSync } from "child_process";
2600
- import { readFileSync as readFileSync3, existsSync as existsSync3, mkdirSync as mkdirSync3, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
2601
- import { resolve as resolve7, join as join7 } from "path";
2862
+ import { readFileSync as readFileSync4, existsSync as existsSync5, mkdirSync as mkdirSync3, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
2863
+ import { resolve as resolve6, join as join10 } from "path";
2602
2864
  import { homedir } from "os";
2603
2865
  import Anthropic2 from "@anthropic-ai/sdk";
2604
2866
 
2605
2867
  // src/lib/slop-detectors.ts
2606
2868
  init_esm_shims();
2869
+
2870
+ // src/lib/color.ts
2871
+ init_esm_shims();
2872
+ function hexToHsl(hex) {
2873
+ let m = hex.match(/^#([0-9a-f]{3})$/i);
2874
+ let r, g, b;
2875
+ if (m) {
2876
+ const [a, c2, d] = m[1].split("");
2877
+ r = parseInt(a + a, 16);
2878
+ g = parseInt(c2 + c2, 16);
2879
+ b = parseInt(d + d, 16);
2880
+ } else {
2881
+ m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
2882
+ if (!m) return null;
2883
+ r = parseInt(m[1], 16);
2884
+ g = parseInt(m[2], 16);
2885
+ b = parseInt(m[3], 16);
2886
+ }
2887
+ return rgbToHsl(r, g, b);
2888
+ }
2889
+ function rgbToHsl(r8, g8, b8) {
2890
+ const r = r8 / 255, g = g8 / 255, b = b8 / 255;
2891
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
2892
+ const l = (max + min) / 2;
2893
+ const d = max - min;
2894
+ if (d === 0) return { h: 0, s: 0, l: l * 100 };
2895
+ const s = d / (1 - Math.abs(2 * l - 1));
2896
+ let h;
2897
+ if (max === r) h = 60 * ((g - b) / d % 6);
2898
+ else if (max === g) h = 60 * ((b - r) / d + 2);
2899
+ else h = 60 * ((r - g) / d + 4);
2900
+ if (h < 0) h += 360;
2901
+ return { h, s: s * 100, l: l * 100 };
2902
+ }
2903
+ function parseHslTriplet(raw) {
2904
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
2905
+ if (!m) return null;
2906
+ return { h: parseFloat(m[1]), s: parseFloat(m[2]), l: parseFloat(m[3]) };
2907
+ }
2908
+ function hueDistance(a, b) {
2909
+ const d = Math.abs((a % 360 + 360) % 360 - (b % 360 + 360) % 360);
2910
+ return d > 180 ? 360 - d : d;
2911
+ }
2912
+ function isAccentLike(c2) {
2913
+ return c2.s >= 45 && c2.l >= 20 && c2.l <= 80;
2914
+ }
2915
+
2916
+ // src/lib/slop-detectors.ts
2607
2917
  function extractOverrides(body) {
2608
2918
  const local = /* @__PURE__ */ new Map();
2609
2919
  const fileScope = /* @__PURE__ */ new Set();
@@ -2664,7 +2974,7 @@ function detectMonoDecorativeEyebrow(file, body) {
2664
2974
  if (/<kbd\b|<code\b|<pre\b/i.test(window)) continue;
2665
2975
  if (/⌘|⌃|⌥|⇧|⏎|⇥|←|→|↑|↓/.test(window)) continue;
2666
2976
  if (/\$\s|\bLEAD-\d|\bAC-\d|\bID-\d|\bDEA-\d|\bCDN-\d|\bPT-\d/.test(window)) continue;
2667
- if (/[a-z]+\/[a-z\-]+\/?[a-z\-]*/.test(window) && /taste\/|routes?\.|route:/i.test(window)) continue;
2977
+ if (/[a-z]+\/[a-z\-]+\/?[a-z\-]*/.test(window) && /stylecrib\/|routes?\.|route:/i.test(window)) continue;
2668
2978
  if (/terminal|CLI|specimen|\$\s*[a-z\-]+ /i.test(window)) continue;
2669
2979
  if (/tabular-nums/i.test(line)) continue;
2670
2980
  hits.push({ ruleId: "R-MONO-DECORATIVE-EYEBROW", file, line: i + 1, sample: line.trim().slice(0, 120) });
@@ -2722,11 +3032,13 @@ function detectMonoCapsCTA(file, body) {
2722
3032
  function detectDotMetadataRow(file, body) {
2723
3033
  const hits = [];
2724
3034
  const lines = body.split("\n");
2725
- const dotToken = String.raw`[A-Za-z0-9][\w./\-]{1,20}(?:\s[A-Za-z0-9][\w./\-]{1,20})?`;
3035
+ const dotToken = String.raw`[A-Z0-9][A-Z0-9./\-]{0,20}(?:\s[A-Z0-9][A-Z0-9./\-]{0,20}){0,2}`;
2726
3036
  const dotTokens = new RegExp(`(?:${dotToken}\\s\xB7\\s){2,}${dotToken}`);
2727
3037
  const smallMarker = /fontSize\s*:\s*(?:['"]?)(?:[1-9]|1[0-4])(?:px)?['"]?|text-xs|text-\[0\.\d+rem\]|font-mono/;
2728
3038
  for (let i = 0; i < lines.length; i++) {
2729
- if (!dotTokens.test(lines[i])) continue;
3039
+ const dotHit = lines[i].match(dotTokens);
3040
+ if (!dotHit) continue;
3041
+ if (!/[A-Z]/.test(dotHit[0])) continue;
2730
3042
  const window = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 4)).join("\n");
2731
3043
  if (!smallMarker.test(window)) continue;
2732
3044
  if (/<p\b[^>]*(?:lede|prose|body)/i.test(window)) continue;
@@ -3248,14 +3560,113 @@ function detectTechBroGradient(file, body) {
3248
3560
  }
3249
3561
  return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3250
3562
  }
3251
- function runAllTextDetectors(file, body) {
3252
- const overrides = extractOverrides(body);
3253
- const cleaned = stripJsxExpressionBraces(file, stripBlockComments(body));
3254
- const rawHits = [
3255
- ...detectMonoDecorativeEyebrow(file, cleaned),
3256
- ...detectMonoInCssApply(file, cleaned),
3257
- ...detectArrowEyebrowPrefix(file, cleaned),
3258
- ...detectMonoCapsCTA(file, cleaned),
3563
+ function detectMonoCapsSectionEyebrow(file, body) {
3564
+ const hits = [];
3565
+ const lines = body.split("\n");
3566
+ const eyebrowRe = /["'`>]\s*(SECTION|CHAPTER|PART|WHAT WE)\s*[·•]?\s*[A-Z][A-Z']/;
3567
+ for (let i = 0; i < lines.length; i++) {
3568
+ if (isCommentOrRuleDoc(lines[i], "R-MONO-CAPS-SECTION-EYEBROW")) continue;
3569
+ if (!eyebrowRe.test(lines[i])) continue;
3570
+ hits.push({
3571
+ ruleId: "R-MONO-CAPS-SECTION-EYEBROW",
3572
+ file,
3573
+ line: i + 1,
3574
+ sample: lines[i].trim().slice(0, 120)
3575
+ });
3576
+ }
3577
+ return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3578
+ }
3579
+ function detectCornerTagAbbreviation(file, body) {
3580
+ const hits = [];
3581
+ const lines = body.split("\n");
3582
+ const cornerRe = /\b(NO|EST|VOL|CH|PT|SEC|EP|PG)\.\s*\d/;
3583
+ for (let i = 0; i < lines.length; i++) {
3584
+ if (isCommentOrRuleDoc(lines[i], "R-CORNER-TAG-ABBREVIATION")) continue;
3585
+ const m = lines[i].match(cornerRe);
3586
+ if (!m) continue;
3587
+ hits.push({
3588
+ ruleId: "R-CORNER-TAG-ABBREVIATION",
3589
+ file,
3590
+ line: i + 1,
3591
+ sample: lines[i].trim().slice(0, 120)
3592
+ });
3593
+ }
3594
+ return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3595
+ }
3596
+ function detectBylineMonoCaps(file, body) {
3597
+ const hits = [];
3598
+ const lines = body.split("\n");
3599
+ const bylineRe = /["'`>]\s*(BY|WORDS BY|WRITTEN BY|PHOTOGRAPHY BY|PHOTOS BY|EDITED BY)\s+[A-Z]/;
3600
+ for (let i = 0; i < lines.length; i++) {
3601
+ if (isCommentOrRuleDoc(lines[i], "R-BYLINE-MONO-CAPS")) continue;
3602
+ if (!bylineRe.test(lines[i])) continue;
3603
+ hits.push({
3604
+ ruleId: "R-BYLINE-MONO-CAPS",
3605
+ file,
3606
+ line: i + 1,
3607
+ sample: lines[i].trim().slice(0, 120)
3608
+ });
3609
+ }
3610
+ return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3611
+ }
3612
+ function detectTagRowAboveBody(file, body) {
3613
+ const hits = [];
3614
+ const lines = body.split("\n");
3615
+ const tagRe = /[▸◆»●]\s+[A-Z][A-Z]{2,}/;
3616
+ for (let i = 0; i < lines.length; i++) {
3617
+ if (isCommentOrRuleDoc(lines[i], "R-TAG-ROW-ABOVE-BODY")) continue;
3618
+ const m = lines[i].match(tagRe);
3619
+ if (!m) continue;
3620
+ hits.push({
3621
+ ruleId: "R-TAG-ROW-ABOVE-BODY",
3622
+ file,
3623
+ line: i + 1,
3624
+ sample: lines[i].trim().slice(0, 120)
3625
+ });
3626
+ }
3627
+ return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3628
+ }
3629
+ function detectSecondAccent(file, body, theme) {
3630
+ if (!theme || !theme.singleAccentOnly || !theme.accent) return [];
3631
+ const hits = [];
3632
+ const lines = body.split("\n");
3633
+ const HUE_TOLERANCE = 40;
3634
+ for (let i = 0; i < lines.length; i++) {
3635
+ const line = lines[i];
3636
+ if (isCommentOrRuleDoc(line, "R-SECOND-ACCENT")) continue;
3637
+ const candidates = [];
3638
+ for (const m of line.matchAll(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g)) {
3639
+ candidates.push({ raw: m[0], hsl: hexToHsl(m[0]) });
3640
+ }
3641
+ for (const m of line.matchAll(/hsl\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)%\s*,?\s*(\d+(?:\.\d+)?)%/g)) {
3642
+ candidates.push({
3643
+ raw: m[0],
3644
+ hsl: { h: parseFloat(m[1]), s: parseFloat(m[2]), l: parseFloat(m[3]) }
3645
+ });
3646
+ }
3647
+ for (const c2 of candidates) {
3648
+ if (!c2.hsl || !isAccentLike(c2.hsl)) continue;
3649
+ const dist = hueDistance(c2.hsl.h, theme.accent.h);
3650
+ if (dist <= HUE_TOLERANCE) continue;
3651
+ hits.push({
3652
+ ruleId: "R-SECOND-ACCENT",
3653
+ file,
3654
+ line: i + 1,
3655
+ sample: `${c2.raw} (hue ${c2.hsl.h.toFixed(0)}\xB0 vs ${theme.slug} accent ${theme.accent.h.toFixed(0)}\xB0) ${line.trim().slice(0, 70)}`
3656
+ });
3657
+ break;
3658
+ }
3659
+ }
3660
+ return hits.filter((h) => !isOverridden(lines, h.line - 1, h.ruleId));
3661
+ }
3662
+ function runAllTextDetectors(file, body, theme = null) {
3663
+ const overrides = extractOverrides(body);
3664
+ const cleaned = stripJsxExpressionBraces(file, stripBlockComments(body));
3665
+ const rawHits = [
3666
+ ...detectMonoDecorativeEyebrow(file, cleaned),
3667
+ ...detectMonoInCssApply(file, cleaned),
3668
+ ...detectArrowEyebrowPrefix(file, cleaned),
3669
+ ...detectMonoCapsCTA(file, cleaned),
3259
3670
  ...detectDotMetadataRow(file, cleaned),
3260
3671
  ...detectStaticGlowBloom(file, cleaned),
3261
3672
  ...detectTrustedByRow(file, cleaned),
@@ -3272,11 +3683,97 @@ function runAllTextDetectors(file, body) {
3272
3683
  ...detectMotionNoReducedMotion(file, cleaned),
3273
3684
  ...detectClaudeCream(file, cleaned),
3274
3685
  ...detectVoidAcid(file, cleaned),
3275
- ...detectTechBroGradient(file, cleaned)
3686
+ ...detectTechBroGradient(file, cleaned),
3687
+ ...detectMonoCapsSectionEyebrow(file, cleaned),
3688
+ ...detectCornerTagAbbreviation(file, cleaned),
3689
+ ...detectBylineMonoCaps(file, cleaned),
3690
+ ...detectTagRowAboveBody(file, cleaned),
3691
+ ...detectSecondAccent(file, cleaned, theme)
3276
3692
  ];
3277
3693
  return rawHits.filter((h) => !isOverriddenByMap(h.line - 1, h.ruleId, overrides));
3278
3694
  }
3279
3695
 
3696
+ // src/lib/theme-policy.ts
3697
+ init_esm_shims();
3698
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
3699
+ import { join as join9 } from "path";
3700
+ var SINGLE_ACCENT_THEMES = [
3701
+ "signal-dark",
3702
+ "enterprise-tech",
3703
+ "pharma-clinical",
3704
+ "kinetic-data"
3705
+ ];
3706
+ function resolveActiveThemeSlug(target) {
3707
+ const cfgPath = join9(target, ".stylecrib", "config.json");
3708
+ if (existsSync4(cfgPath)) {
3709
+ try {
3710
+ const cfg = JSON.parse(readFileSync3(cfgPath, "utf8"));
3711
+ if (typeof cfg.theme === "string" && cfg.theme.trim()) return cfg.theme.trim();
3712
+ } catch {
3713
+ }
3714
+ }
3715
+ const wirePath = join9(target, "src", "lib", "stylecrib-wire.tsx");
3716
+ if (existsSync4(wirePath)) {
3717
+ try {
3718
+ const m = readFileSync3(wirePath, "utf8").match(/defaultTheme=["']([a-z0-9-]+)["']/);
3719
+ if (m) return m[1];
3720
+ } catch {
3721
+ }
3722
+ }
3723
+ return null;
3724
+ }
3725
+ function themePermitsSingleAccentOnly(slug, themeYmlSrc) {
3726
+ if (SINGLE_ACCENT_THEMES.includes(slug)) return true;
3727
+ if (themeYmlSrc && /^\s*single_accent:\s*true\s*$/m.test(themeYmlSrc)) return true;
3728
+ return false;
3729
+ }
3730
+ function resolveThemeContext(slug) {
3731
+ const themesDir = resolveAssetDir("themes");
3732
+ if (!themesDir) return null;
3733
+ const dir = join9(themesDir, slug);
3734
+ if (!existsSync4(dir)) return null;
3735
+ let accent = null;
3736
+ let accentSource = "unresolved";
3737
+ let ymlSrc;
3738
+ const ymlPath = join9(dir, "theme.yml");
3739
+ if (existsSync4(ymlPath)) {
3740
+ try {
3741
+ ymlSrc = readFileSync3(ymlPath, "utf8");
3742
+ const m = ymlSrc.match(/^\s*accent:\s*["']?(#[0-9a-fA-F]{3,6})["']?/m);
3743
+ if (m) {
3744
+ accent = hexToHsl(m[1]);
3745
+ accentSource = `theme.yml palette.accent ${m[1]}`;
3746
+ }
3747
+ } catch {
3748
+ }
3749
+ }
3750
+ if (!accent) {
3751
+ const tokPath = join9(dir, "tokens.ts");
3752
+ if (existsSync4(tokPath)) {
3753
+ try {
3754
+ const src = readFileSync3(tokPath, "utf8");
3755
+ const m = src.match(/\bprimary:\s*\{\s*hsl:\s*["']([^"']+)["']/);
3756
+ if (m) {
3757
+ accent = parseHslTriplet(m[1]);
3758
+ accentSource = `tokens.ts colors.primary "${m[1]}"`;
3759
+ }
3760
+ } catch {
3761
+ }
3762
+ }
3763
+ }
3764
+ return {
3765
+ slug,
3766
+ accent,
3767
+ accentSource,
3768
+ singleAccentOnly: themePermitsSingleAccentOnly(slug, ymlSrc)
3769
+ };
3770
+ }
3771
+ function themeContextForTarget(target) {
3772
+ const slug = resolveActiveThemeSlug(target);
3773
+ if (!slug) return null;
3774
+ return resolveThemeContext(slug);
3775
+ }
3776
+
3280
3777
  // src/lib/regression-detectors.ts
3281
3778
  init_esm_shims();
3282
3779
  function firstLineWith(body, needle) {
@@ -3349,12 +3846,35 @@ function runAllRegressionDetectors(file, oldBody, newBody) {
3349
3846
 
3350
3847
  // src/commands/check.ts
3351
3848
  init_slop_rules();
3849
+
3850
+ // src/lib/verdict.ts
3851
+ init_esm_shims();
3852
+ var VERDICT_LINE = /^[\s#>*_`-]*(verdict)[\s*_`]*:[\s*_`]*(pass|block)\b/i;
3853
+ function parseVerdict(text2) {
3854
+ const lines = text2.split(/\r?\n/);
3855
+ let lastAnyCase = null;
3856
+ for (let i = lines.length - 1; i >= 0; i--) {
3857
+ const m = VERDICT_LINE.exec(lines[i]);
3858
+ if (!m) continue;
3859
+ const hit = { verdict: m[2].toUpperCase(), line: lines[i].trim() };
3860
+ if (m[1] === "VERDICT") return { ...hit, found: true };
3861
+ lastAnyCase ??= hit;
3862
+ }
3863
+ if (lastAnyCase) return { ...lastAnyCase, found: true };
3864
+ return {
3865
+ verdict: "BLOCK",
3866
+ found: false,
3867
+ reason: 'no final "VERDICT: PASS" or "VERDICT: BLOCK" line in the reply'
3868
+ };
3869
+ }
3870
+
3871
+ // src/commands/check.ts
3352
3872
  function existsInHead(target, rel) {
3353
3873
  const r = spawnSync("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: target, stdio: "ignore" });
3354
3874
  return r.status === 0;
3355
3875
  }
3356
3876
  async function check(opts = {}) {
3357
- const target = resolve7(opts.target ?? process.cwd());
3877
+ const target = resolve6(opts.target ?? process.cwd());
3358
3878
  const apiKey = process.env.ANTHROPIC_API_KEY;
3359
3879
  const changed = detectChangedFiles(target, opts);
3360
3880
  const changedApp = detectChangedAppFiles(target, opts);
@@ -3367,7 +3887,7 @@ async function check(opts = {}) {
3367
3887
  const precedentMisses = [];
3368
3888
  for (const rel of changed) {
3369
3889
  if (!isComponentFile(rel)) continue;
3370
- const body = readSafe(join7(target, rel));
3890
+ const body = readSafe(join10(target, rel));
3371
3891
  if (!body) continue;
3372
3892
  const hasDeclaration = /\/\/\s*Composition study:/i.test(body);
3373
3893
  if (!hasDeclaration) {
@@ -3413,7 +3933,7 @@ async function check(opts = {}) {
3413
3933
  const fullScan = process.env.STYLECRIB_LINT_FULL === "1";
3414
3934
  for (const rel of changed) {
3415
3935
  if (!isComponentFile(rel)) continue;
3416
- const body = readSafe(join7(target, rel));
3936
+ const body = readSafe(join10(target, rel));
3417
3937
  if (!body) continue;
3418
3938
  const newHits = runMonoTrackedCapsCheck(rel, body);
3419
3939
  if (fullScan) {
@@ -3444,22 +3964,23 @@ async function check(opts = {}) {
3444
3964
  );
3445
3965
  process.exit(1);
3446
3966
  }
3967
+ const theme = themeContextForTarget(target);
3447
3968
  const allHits = [];
3448
3969
  for (const rel of changed) {
3449
3970
  if (!looksLikeUiFile(rel)) continue;
3450
- const body = readSafe(join7(target, rel));
3971
+ const body = readSafe(join10(target, rel));
3451
3972
  if (!body) continue;
3452
- const newHits = runAllTextDetectors(rel, body);
3973
+ const newHits = runAllTextDetectors(rel, body, theme);
3453
3974
  if (fullScan) {
3454
3975
  allHits.push(...newHits);
3455
3976
  continue;
3456
3977
  }
3457
3978
  const oldBody = readHeadBody(target, rel);
3458
- const oldHits = oldBody ? runAllTextDetectors(rel, oldBody) : [];
3979
+ const oldHits = oldBody ? runAllTextDetectors(rel, oldBody, theme) : [];
3459
3980
  allHits.push(...subtractHitsBySample(newHits, oldHits));
3460
3981
  }
3461
3982
  for (const rel of changedApp) {
3462
- const body = readSafe(join7(target, rel));
3983
+ const body = readSafe(join10(target, rel));
3463
3984
  if (!body) continue;
3464
3985
  const oldBody = readHeadBody(target, rel);
3465
3986
  if (oldBody === null) continue;
@@ -3538,7 +4059,7 @@ async function check(opts = {}) {
3538
4059
  console.error(import_picocolors4.default.dim(' STYLECRIB_SKIP_QA="<one-line reason>" git commit'));
3539
4060
  process.exit(1);
3540
4061
  }
3541
- const conductor = readSafe(join7(target, ".claude/agents/qa-conductor.md"));
4062
+ const conductor = readSafe(join10(target, ".claude/agents/qa-conductor.md"));
3542
4063
  if (!conductor) {
3543
4064
  console.error(import_picocolors4.default.red("\u2717 ") + "QA Conductor agent not installed.");
3544
4065
  console.error(
@@ -3548,8 +4069,8 @@ async function check(opts = {}) {
3548
4069
  );
3549
4070
  process.exit(1);
3550
4071
  }
3551
- const direction = readSafe(join7(target, "design-system/DIRECTION.md")) || readSafe(join7(target, "design-system/THEME.md")) || "(No design-system/DIRECTION.md or THEME.md found \u2014 review will rely on the QA Conductor's general principles.)";
3552
- const feedback = readSafe(join7(target, ".stylecrib/feedback.md"));
4072
+ const direction = readSafe(join10(target, "design-system/DIRECTION.md")) || readSafe(join10(target, "design-system/THEME.md")) || "(No design-system/DIRECTION.md or THEME.md found \u2014 review will rely on the QA Conductor's general principles.)";
4073
+ const feedback = readSafe(join10(target, ".stylecrib/feedback.md"));
3553
4074
  if (!opts.silent) {
3554
4075
  const route = apiKey ? "Anthropic API" : "Claude Code CLI";
3555
4076
  console.log(
@@ -3560,7 +4081,7 @@ async function check(opts = {}) {
3560
4081
  for (const f of changed) console.log(import_picocolors4.default.dim(" \xB7 " + f));
3561
4082
  }
3562
4083
  const fileContents = changed.map((rel) => {
3563
- const path2 = join7(target, rel);
4084
+ const path2 = join10(target, rel);
3564
4085
  const body = readSafe(path2);
3565
4086
  return body ? `## ${rel}
3566
4087
 
@@ -3628,14 +4149,17 @@ ${userText}`;
3628
4149
  process.exit(2);
3629
4150
  }
3630
4151
  const elapsed = ((Date.now() - t0) / 1e3).toFixed(1);
3631
- const verdictLine = verdictText.match(/VERDICT:\s*(PASS|BLOCK)/i);
3632
- const verdict = verdictLine ? verdictLine[1].toUpperCase() : "BLOCK";
4152
+ const parsed = parseVerdict(verdictText);
4153
+ const verdict = parsed.verdict;
3633
4154
  if (!opts.silent) {
3634
4155
  console.log("");
3635
4156
  console.log(verdictText);
3636
4157
  console.log("");
3637
4158
  console.log(import_picocolors4.default.dim(`\u25C7 Loop completed in ${elapsed}s`));
3638
4159
  }
4160
+ if (!parsed.found) {
4161
+ console.error(import_picocolors4.default.red("\u2717 ") + `Conductor reply had ${parsed.reason} \u2014 treating as BLOCK.`);
4162
+ }
3639
4163
  if (verdict === "PASS") {
3640
4164
  if (!opts.silent) console.log(import_picocolors4.default.green("\u2713 PASS \u2014 cleared to commit."));
3641
4165
  process.exit(0);
@@ -3690,7 +4214,7 @@ function looksLikeAppFile(path2) {
3690
4214
  }
3691
4215
  function logQaBypass(target, files, kind, reason) {
3692
4216
  try {
3693
- const dir = join7(target, "reports");
4217
+ const dir = join10(target, "reports");
3694
4218
  mkdirSync3(dir, { recursive: true });
3695
4219
  ensureReportsReadme(dir);
3696
4220
  const record = {
@@ -3706,13 +4230,13 @@ function logQaBypass(target, files, kind, reason) {
3706
4230
  }
3707
4231
  })()
3708
4232
  };
3709
- appendFileSync(join7(dir, "qa-bypasses.jsonl"), JSON.stringify(record) + "\n");
4233
+ appendFileSync(join10(dir, "qa-bypasses.jsonl"), JSON.stringify(record) + "\n");
3710
4234
  } catch {
3711
4235
  }
3712
4236
  }
3713
4237
  function ensureReportsReadme(reportsDir) {
3714
- const path2 = join7(reportsDir, "README.md");
3715
- if (existsSync3(path2)) return;
4238
+ const path2 = join10(reportsDir, "README.md");
4239
+ if (existsSync5(path2)) return;
3716
4240
  const body = `# reports/
3717
4241
 
3718
4242
  This directory is written by the StyleCrib harness. Files here are not
@@ -3817,8 +4341,8 @@ function isComponentFile(path2) {
3817
4341
  }
3818
4342
  function readSafe(path2) {
3819
4343
  try {
3820
- if (!existsSync3(path2)) return null;
3821
- return readFileSync3(path2, "utf8");
4344
+ if (!existsSync5(path2)) return null;
4345
+ return readFileSync4(path2, "utf8");
3822
4346
  } catch {
3823
4347
  return null;
3824
4348
  }
@@ -3839,7 +4363,7 @@ function findClaudeCli() {
3839
4363
  "/opt/homebrew/bin/claude"
3840
4364
  ];
3841
4365
  for (const p of candidates) {
3842
- if (existsSync3(p)) return p;
4366
+ if (existsSync5(p)) return p;
3843
4367
  }
3844
4368
  return null;
3845
4369
  }
@@ -3847,21 +4371,21 @@ function findClaudeCli() {
3847
4371
  // src/commands/review-site.ts
3848
4372
  init_esm_shims();
3849
4373
  var import_picocolors5 = __toESM(require_picocolors(), 1);
3850
- import { readFileSync as readFileSync4, readdirSync, statSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
3851
- import { resolve as resolve8, join as join8, relative as relative4 } from "path";
4374
+ import { readFileSync as readFileSync5, readdirSync as readdirSync2, statSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
4375
+ import { resolve as resolve7, join as join11, relative as relative4 } from "path";
3852
4376
  init_slop_rules();
3853
4377
  function walkUiFiles(siteRoot) {
3854
4378
  const out = [];
3855
4379
  const visit = (dir) => {
3856
4380
  let entries;
3857
4381
  try {
3858
- entries = readdirSync(dir);
4382
+ entries = readdirSync2(dir);
3859
4383
  } catch {
3860
4384
  return;
3861
4385
  }
3862
4386
  for (const name of entries) {
3863
4387
  if (name.startsWith(".") || name === "node_modules" || name === "dist" || name === "build") continue;
3864
- const p = join8(dir, name);
4388
+ const p = join11(dir, name);
3865
4389
  let s;
3866
4390
  try {
3867
4391
  s = statSync(p);
@@ -3882,22 +4406,23 @@ function walkUiFiles(siteRoot) {
3882
4406
  return out;
3883
4407
  }
3884
4408
  async function reviewSite(opts = {}) {
3885
- const target = resolve8(opts.target ?? process.cwd());
3886
- const siteRoot = resolve8(opts.siteRoot ?? join8(target, "site/src"));
3887
- const outDir = resolve8(opts.outDir ?? join8(target, "reports"));
4409
+ const target = resolve7(opts.target ?? process.cwd());
4410
+ const siteRoot = resolve7(opts.siteRoot ?? join11(target, "site/src"));
4411
+ const outDir = resolve7(opts.outDir ?? join11(target, "reports"));
3888
4412
  console.log(import_picocolors5.default.dim(`Walking ${siteRoot} for UI files\u2026`));
3889
4413
  const files = walkUiFiles(siteRoot);
3890
4414
  console.log(import_picocolors5.default.dim(` ${files.length} files found. Running structural slop detectors\u2026`));
4415
+ const sweepTheme = themeContextForTarget(resolve7(target));
3891
4416
  const allHits = [];
3892
4417
  for (const abs of files) {
3893
4418
  let body;
3894
4419
  try {
3895
- body = readFileSync4(abs, "utf8");
4420
+ body = readFileSync5(abs, "utf8");
3896
4421
  } catch {
3897
4422
  continue;
3898
4423
  }
3899
- const rel = relative4(resolve8(target), abs);
3900
- allHits.push(...runAllTextDetectors(rel, body));
4424
+ const rel = relative4(resolve7(target), abs);
4425
+ allHits.push(...runAllTextDetectors(rel, body, sweepTheme));
3901
4426
  }
3902
4427
  const byRule = /* @__PURE__ */ new Map();
3903
4428
  const byFile = /* @__PURE__ */ new Map();
@@ -3919,8 +4444,14 @@ async function reviewSite(opts = {}) {
3919
4444
  lines.push(`Block-severity hits: **${blocks}**`);
3920
4445
  lines.push(`Warn-severity hits: **${warns}**`);
3921
4446
  lines.push("");
3922
- if (blocks === 0 && warns === 0) {
3923
- lines.push("**No slop detected.** Every UI file in the tree passes the structural gate.");
4447
+ if (files.length === 0) {
4448
+ lines.push(
4449
+ "**Scanned nothing \u2014 this sweep proves nothing.** No UI files were found under the scan root, so the result below is vacuous rather than clean. Check that the sweep is pointed at a directory that actually contains `src/components` / `src/pages`."
4450
+ );
4451
+ } else if (blocks === 0 && warns === 0) {
4452
+ lines.push(
4453
+ `**No slop detected.** All ${files.length} UI file(s) under the scan root pass the structural gate.`
4454
+ );
3924
4455
  } else {
3925
4456
  lines.push("## Hits by rule");
3926
4457
  lines.push("");
@@ -3947,9 +4478,9 @@ async function reviewSite(opts = {}) {
3947
4478
  }
3948
4479
  }
3949
4480
  mkdirSync4(outDir, { recursive: true });
3950
- const outPath = join8(outDir, `nightly-slop-${today}.md`);
4481
+ const outPath = join11(outDir, `nightly-slop-${today}.md`);
3951
4482
  writeFileSync4(outPath, lines.join("\n") + "\n");
3952
- writeFileSync4(join8(outDir, "nightly-slop-latest.md"), lines.join("\n") + "\n");
4483
+ writeFileSync4(join11(outDir, "nightly-slop-latest.md"), lines.join("\n") + "\n");
3953
4484
  console.log("");
3954
4485
  console.log(import_picocolors5.default.dim(`Wrote ${outPath}`));
3955
4486
  console.log(import_picocolors5.default.dim(` ${blocks} block-severity, ${warns} warn-severity`));
@@ -3958,8 +4489,8 @@ async function reviewSite(opts = {}) {
3958
4489
  // src/commands/rule.ts
3959
4490
  init_esm_shims();
3960
4491
  var import_picocolors6 = __toESM(require_picocolors(), 1);
3961
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync4, mkdirSync as mkdirSync5 } from "fs";
3962
- import { resolve as resolve9, join as join9 } from "path";
4492
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6, mkdirSync as mkdirSync5 } from "fs";
4493
+ import { resolve as resolve8, join as join12 } from "path";
3963
4494
  import { input as input2, select as select2 } from "@inquirer/prompts";
3964
4495
  var CATEGORIES = [
3965
4496
  "chrome",
@@ -3973,7 +4504,8 @@ var CATEGORIES = [
3973
4504
  "provenance"
3974
4505
  ];
3975
4506
  async function ruleAdd(opts = {}) {
3976
- const target = resolve9(opts.target ?? process.cwd());
4507
+ const target = resolve8(opts.target ?? process.cwd());
4508
+ assertInteractive("`stylecrib rule add`");
3977
4509
  console.log(import_picocolors6.default.bold("Add a slop rule to this repo's custom rulebook."));
3978
4510
  console.log(import_picocolors6.default.dim(" Custom rules live at .stylecrib/custom-rules.ts and load automatically."));
3979
4511
  console.log("");
@@ -4022,10 +4554,10 @@ async function ruleAdd(opts = {}) {
4022
4554
  message: "Sample fix \u2014 what to write instead:",
4023
4555
  validate: (v) => v.length > 0 || "Write the corrected version so authors know the right pattern."
4024
4556
  });
4025
- const dir = join9(target, ".stylecrib");
4557
+ const dir = join12(target, ".stylecrib");
4026
4558
  mkdirSync5(dir, { recursive: true });
4027
- const path2 = join9(dir, "custom-rules.ts");
4028
- const existing = existsSync4(path2) ? readFileSync5(path2, "utf8") : SEED_CUSTOM_RULES;
4559
+ const path2 = join12(dir, "custom-rules.ts");
4560
+ const existing = existsSync6(path2) ? readFileSync6(path2, "utf8") : SEED_CUSTOM_RULES;
4029
4561
  const insertion = `
4030
4562
  // \u2500\u2500\u2500 ${id} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4031
4563
  // ${description}
@@ -4059,13 +4591,13 @@ async function ruleAdd(opts = {}) {
4059
4591
  console.log(import_picocolors6.default.dim(` To share with the community: stylecrib rule share ${id}`));
4060
4592
  }
4061
4593
  async function ruleShare(id, opts = {}) {
4062
- const target = resolve9(opts.target ?? process.cwd());
4063
- const path2 = join9(target, ".stylecrib", "custom-rules.ts");
4064
- if (!existsSync4(path2)) {
4594
+ const target = resolve8(opts.target ?? process.cwd());
4595
+ const path2 = join12(target, ".stylecrib", "custom-rules.ts");
4596
+ if (!existsSync6(path2)) {
4065
4597
  console.error(import_picocolors6.default.red("\u2717 ") + ".stylecrib/custom-rules.ts not found. Run `stylecrib rule add` first.");
4066
4598
  process.exit(1);
4067
4599
  }
4068
- const body = readFileSync5(path2, "utf8");
4600
+ const body = readFileSync6(path2, "utf8");
4069
4601
  const start = body.indexOf(`id: "${id}"`);
4070
4602
  if (start < 0) {
4071
4603
  console.error(import_picocolors6.default.red("\u2717 ") + `Rule ${id} not found in .stylecrib/custom-rules.ts.`);
@@ -4163,11 +4695,11 @@ function extractFilePaths(event) {
4163
4695
  // src/commands/audit.ts
4164
4696
  init_esm_shims();
4165
4697
  var import_picocolors7 = __toESM(require_picocolors(), 1);
4166
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
4167
- import { resolve as resolve10, join as join10, basename as basename2 } from "path";
4698
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
4699
+ import { resolve as resolve9, join as join13, basename as basename2 } from "path";
4168
4700
  import Anthropic3 from "@anthropic-ai/sdk";
4169
4701
  async function audit(opts = {}) {
4170
- const target = resolve10(opts.target ?? process.cwd());
4702
+ const target = resolve9(opts.target ?? process.cwd());
4171
4703
  const apiKey = process.env.ANTHROPIC_API_KEY;
4172
4704
  if (!apiKey) {
4173
4705
  console.error(import_picocolors7.default.red("\u2717 ") + "ANTHROPIC_API_KEY not set.");
@@ -4187,8 +4719,8 @@ async function audit(opts = {}) {
4187
4719
  );
4188
4720
  process.exit(1);
4189
4721
  }
4190
- const usingBundled = !existsSync5(
4191
- join10(target, ".claude/agents/homogeneity-detector.md")
4722
+ const usingBundled = !existsSync7(
4723
+ join13(target, ".claude/agents/homogeneity-detector.md")
4192
4724
  );
4193
4725
  if (usingBundled && !opts.silent) {
4194
4726
  console.log(
@@ -4197,7 +4729,7 @@ async function audit(opts = {}) {
4197
4729
  )
4198
4730
  );
4199
4731
  }
4200
- const directionDoctrine = readSafe2(join10(target, "design-system/THEME.md")) ?? "";
4732
+ const directionDoctrine = readSafe2(join13(target, "design-system/THEME.md")) ?? "";
4201
4733
  if (!opts.silent) {
4202
4734
  console.log(import_picocolors7.default.bold("\u25C7 StyleCrib audit \u2014 whole-project drift report"));
4203
4735
  console.log("");
@@ -4225,7 +4757,7 @@ async function audit(opts = {}) {
4225
4757
  );
4226
4758
  }
4227
4759
  const filesBody = fam.files.map((rel) => {
4228
- const body = readSafe2(join10(target, rel));
4760
+ const body = readSafe2(join13(target, rel));
4229
4761
  return body ? `## ${rel}
4230
4762
 
4231
4763
  \`\`\`tsx
@@ -4263,8 +4795,7 @@ Apply your Homogeneity Detector doctrine \u2014 build the 5-axis matrix, score e
4263
4795
  ]
4264
4796
  });
4265
4797
  const report = resp.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
4266
- const verdictLine = report.match(/VERDICT:\s*(PASS|BLOCK)/i);
4267
- const verdict = verdictLine ? verdictLine[1].toUpperCase() : "BLOCK";
4798
+ const verdict = parseVerdict(report).verdict;
4268
4799
  verdicts.push({ family: fam.name, verdict, report });
4269
4800
  if (verdict === "BLOCK") anyBlock = true;
4270
4801
  if (!opts.silent) {
@@ -4315,18 +4846,18 @@ function discoverFamilies(target, only) {
4315
4846
  const out = [];
4316
4847
  for (const c2 of candidates) {
4317
4848
  if (only && c2.name !== only) continue;
4318
- const abs = join10(target, c2.dir);
4319
- if (!existsSync5(abs)) continue;
4849
+ const abs = join13(target, c2.dir);
4850
+ if (!existsSync7(abs)) continue;
4320
4851
  if (!statSync2(abs).isDirectory()) continue;
4321
- const files = readdirSync2(abs).filter((f) => f.endsWith(".tsx")).map((f) => join10(c2.dir, f));
4852
+ const files = readdirSync3(abs).filter((f) => f.endsWith(".tsx")).map((f) => join13(c2.dir, f));
4322
4853
  if (files.length > 0) out.push({ name: c2.name, files });
4323
4854
  }
4324
4855
  return out;
4325
4856
  }
4326
4857
  function readSafe2(path2) {
4327
4858
  try {
4328
- if (!existsSync5(path2)) return null;
4329
- return readFileSync6(path2, "utf8");
4859
+ if (!existsSync7(path2)) return null;
4860
+ return readFileSync7(path2, "utf8");
4330
4861
  } catch {
4331
4862
  return null;
4332
4863
  }
@@ -4335,11 +4866,11 @@ function readSafe2(path2) {
4335
4866
  // src/commands/reject.ts
4336
4867
  init_esm_shims();
4337
4868
  var import_picocolors8 = __toESM(require_picocolors(), 1);
4338
- import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
4339
- import { resolve as resolve11, join as join11, dirname as dirname7 } from "path";
4869
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
4870
+ import { resolve as resolve10, join as join14, dirname as dirname6 } from "path";
4340
4871
  async function reject(opts = {}) {
4341
- const target = resolve11(opts.target ?? process.cwd());
4342
- const path2 = join11(target, ".stylecrib", "feedback.md");
4872
+ const target = resolve10(opts.target ?? process.cwd());
4873
+ const path2 = join14(target, ".stylecrib", "feedback.md");
4343
4874
  const pattern = opts.pattern?.trim();
4344
4875
  const replacement = opts.replacement?.trim();
4345
4876
  if (!pattern) {
@@ -4354,7 +4885,7 @@ async function reject(opts = {}) {
4354
4885
  );
4355
4886
  process.exit(1);
4356
4887
  }
4357
- const now = process.env.TASTE_NOW ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4888
+ const now = process.env.STYLECRIB_NOW ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4358
4889
  const entry = `
4359
4890
  ## Rejection \u2014 ${now}
4360
4891
 
@@ -4364,9 +4895,9 @@ async function reject(opts = {}) {
4364
4895
  ` : `
4365
4896
  **Replacement:** _(not specified \u2014 propose alternatives that don't repeat this pattern)_
4366
4897
  `);
4367
- const existing = existsSync6(path2) ? readFileSync7(path2, "utf8") : HEADER_TEMPLATE;
4898
+ const existing = existsSync8(path2) ? readFileSync8(path2, "utf8") : HEADER_TEMPLATE;
4368
4899
  const body = existing.trimEnd() + "\n" + entry;
4369
- mkdirSync6(dirname7(path2), { recursive: true });
4900
+ mkdirSync6(dirname6(path2), { recursive: true });
4370
4901
  writeFileSync6(path2, body);
4371
4902
  console.log(import_picocolors8.default.green("\u2713 ") + "Rejection recorded in " + import_picocolors8.default.cyan(".stylecrib/feedback.md"));
4372
4903
  console.log("");
@@ -4397,19 +4928,19 @@ that gap. It's plain markdown \u2014 readable by humans, loaded by agents.
4397
4928
  // src/commands/status.ts
4398
4929
  init_esm_shims();
4399
4930
  var import_picocolors9 = __toESM(require_picocolors(), 1);
4400
- import { existsSync as existsSync7, readFileSync as readFileSync8, statSync as statSync3 } from "fs";
4401
- import { resolve as resolve12, join as join12 } from "path";
4931
+ import { existsSync as existsSync9, readFileSync as readFileSync9, statSync as statSync3 } from "fs";
4932
+ import { resolve as resolve11, join as join15 } from "path";
4402
4933
  async function status(opts = {}) {
4403
- const target = resolve12(opts.target ?? process.cwd());
4934
+ const target = resolve11(opts.target ?? process.cwd());
4404
4935
  console.log(import_picocolors9.default.bold("\u25C7 StyleCrib enforcement status"));
4405
4936
  console.log(import_picocolors9.default.dim(" " + target));
4406
4937
  console.log("");
4407
4938
  let anyFail = false;
4408
- const huskyPath = join12(target, ".husky", "pre-commit");
4409
- const gitHookPath = join12(target, ".git", "hooks", "pre-commit");
4410
- const hookPath = existsSync7(huskyPath) ? huskyPath : gitHookPath;
4411
- const hookExists = existsSync7(hookPath);
4412
- const hookCallsCheck = hookExists ? readFileSync8(hookPath, "utf8").includes("stylecrib check") : false;
4939
+ const huskyPath = join15(target, ".husky", "pre-commit");
4940
+ const gitHookPath = join15(target, ".git", "hooks", "pre-commit");
4941
+ const hookPath = existsSync9(huskyPath) ? huskyPath : gitHookPath;
4942
+ const hookExists = existsSync9(hookPath);
4943
+ const hookCallsCheck = hookExists ? readFileSync9(hookPath, "utf8").includes("stylecrib check") : false;
4413
4944
  const hookExec = hookExists ? (statSync3(hookPath).mode & 73) !== 0 : false;
4414
4945
  const hookOk = hookExists && hookCallsCheck && hookExec;
4415
4946
  printLine(
@@ -4418,7 +4949,7 @@ async function status(opts = {}) {
4418
4949
  hookOk ? "pass" : "fail"
4419
4950
  );
4420
4951
  if (!hookOk) anyFail = true;
4421
- const agentsDir = join12(target, ".claude", "agents");
4952
+ const agentsDir = join15(target, ".claude", "agents");
4422
4953
  const requiredAgents = [
4423
4954
  "design-director.md",
4424
4955
  "design-systems-engineer.md",
@@ -4429,7 +4960,7 @@ async function status(opts = {}) {
4429
4960
  "brand-precedent.md"
4430
4961
  ];
4431
4962
  const presentAgents = requiredAgents.filter(
4432
- (f) => existsSync7(join12(agentsDir, f))
4963
+ (f) => existsSync9(join15(agentsDir, f))
4433
4964
  );
4434
4965
  const agentsOk = presentAgents.length === requiredAgents.length;
4435
4966
  printLine(
@@ -4437,24 +4968,24 @@ async function status(opts = {}) {
4437
4968
  agentsOk ? `PASS (${presentAgents.length}/${requiredAgents.length})` : `WARN (${presentAgents.length}/${requiredAgents.length}) \u2014 missing: ` + requiredAgents.filter((f) => !presentAgents.includes(f)).join(", "),
4438
4969
  agentsOk ? "pass" : "warn"
4439
4970
  );
4440
- const cursorrulesOk = existsSync7(join12(target, ".cursorrules"));
4971
+ const cursorrulesOk = existsSync9(join15(target, ".cursorrules"));
4441
4972
  printLine(
4442
4973
  ".cursorrules",
4443
4974
  cursorrulesOk ? "PASS (Cursor users routed through agents)" : "WARN: missing \u2014 Cursor users won't route through agents on this repo",
4444
4975
  cursorrulesOk ? "pass" : "warn"
4445
4976
  );
4446
- const actionPath = join12(target, ".github", "workflows", "stylecrib-review.yml");
4447
- const actionOk = existsSync7(actionPath);
4977
+ const actionPath = join15(target, ".github", "workflows", "stylecrib-review.yml");
4978
+ const actionOk = existsSync9(actionPath);
4448
4979
  printLine(
4449
4980
  "GitHub Action (PR gating)",
4450
4981
  actionOk ? "PASS" : "WARN: not installed \u2014 PRs won't be gated by the QA loop in CI",
4451
4982
  actionOk ? "pass" : "warn"
4452
4983
  );
4453
- const feedbackPath = join12(target, ".stylecrib", "feedback.md");
4454
- const feedbackOk = existsSync7(feedbackPath);
4984
+ const feedbackPath = join15(target, ".stylecrib", "feedback.md");
4985
+ const feedbackOk = existsSync9(feedbackPath);
4455
4986
  let rejectionCount = 0;
4456
4987
  if (feedbackOk) {
4457
- const body = readFileSync8(feedbackPath, "utf8");
4988
+ const body = readFileSync9(feedbackPath, "utf8");
4458
4989
  rejectionCount = (body.match(/^## Rejection — /gm) || []).length;
4459
4990
  }
4460
4991
  printLine(
@@ -4462,7 +4993,7 @@ async function status(opts = {}) {
4462
4993
  feedbackOk ? `PASS (${rejectionCount} rejection${rejectionCount === 1 ? "" : "s"} recorded)` : 'INFO: no rejections recorded yet \u2014 add one with `stylecrib reject "<pattern>"`',
4463
4994
  feedbackOk ? "pass" : "info"
4464
4995
  );
4465
- const directionOk = existsSync7(join12(target, "design-system", "THEME.md"));
4996
+ const directionOk = existsSync9(join15(target, "design-system", "THEME.md"));
4466
4997
  printLine(
4467
4998
  "design-system/THEME.md",
4468
4999
  directionOk ? "PASS" : "FAIL: missing \u2014 agents have no project direction to pivot reviews on. Run `stylecrib init`",
@@ -4475,9 +5006,9 @@ async function status(opts = {}) {
4475
5006
  apiKey ? "PASS" : "WARN: not set \u2014 `stylecrib check` will exit early; hook will warn & no-op",
4476
5007
  apiKey ? "pass" : "warn"
4477
5008
  );
4478
- const bypassPath = join12(target, "reports", "qa-bypasses.jsonl");
4479
- if (existsSync7(bypassPath)) {
4480
- const lines = readFileSync8(bypassPath, "utf8").split("\n").filter((l) => l.trim());
5009
+ const bypassPath = join15(target, "reports", "qa-bypasses.jsonl");
5010
+ if (existsSync9(bypassPath)) {
5011
+ const lines = readFileSync9(bypassPath, "utf8").split("\n").filter((l) => l.trim());
4481
5012
  printLine(
4482
5013
  "qa-bypass log",
4483
5014
  `INFO: ${lines.length} bypass${lines.length === 1 ? "" : "es"} recorded \u2192 reports/qa-bypasses.jsonl`,
@@ -4504,20 +5035,574 @@ function printLine(label, state, level) {
4504
5035
  console.log(" " + tag + import_picocolors9.default.bold(label.padEnd(28)) + import_picocolors9.default.dim(" " + state));
4505
5036
  }
4506
5037
 
4507
- // src/commands/harness.ts
5038
+ // src/commands/verify.ts
4508
5039
  init_esm_shims();
4509
5040
  var import_picocolors10 = __toESM(require_picocolors(), 1);
4510
- import { existsSync as existsSync15 } from "fs";
4511
- import { join as join21, resolve as resolve13 } from "path";
5041
+ import { existsSync as existsSync10, readFileSync as readFileSync10, mkdirSync as mkdirSync7 } from "fs";
5042
+ import { resolve as resolve12, join as join16, dirname as dirname7 } from "path";
5043
+ import { spawn, execSync as execSync4, execFileSync } from "child_process";
5044
+ async function verify(opts = {}) {
5045
+ const target = resolve12(opts.target ?? process.cwd());
5046
+ const bootTimeoutMs = Math.max(5e3, parseInt(opts.timeout ?? "60000", 10) || 6e4);
5047
+ console.log(import_picocolors10.default.bold("\u25C7 StyleCrib verify") + import_picocolors10.default.dim(" \u2014 runtime smoke gate"));
5048
+ console.log(import_picocolors10.default.dim(" " + target));
5049
+ console.log("");
5050
+ const results = [];
5051
+ if (opts.typecheck === false) {
5052
+ results.push({ name: "typecheck", status: "skip", detail: "--no-typecheck" });
5053
+ } else {
5054
+ results.push(runTypecheck(target));
5055
+ }
5056
+ if (opts.boot === false) {
5057
+ results.push({ name: "dev-server boot", status: "skip", detail: "--no-boot" });
5058
+ } else {
5059
+ results.push(await runBoot(target, opts.route ?? "/", opts.screenshot, bootTimeoutMs));
5060
+ }
5061
+ console.log("");
5062
+ for (const r of results) {
5063
+ const tag = r.status === "pass" ? import_picocolors10.default.green("PASS") : r.status === "fail" ? import_picocolors10.default.red("FAIL") : import_picocolors10.default.dim("SKIP");
5064
+ console.log(` ${tag} ${r.name}${r.detail ? import_picocolors10.default.dim(" \u2014 " + r.detail) : ""}`);
5065
+ }
5066
+ const failed = results.filter((r) => r.status === "fail");
5067
+ if (failed.length > 0) {
5068
+ console.log("");
5069
+ for (const f of failed) {
5070
+ if (!f.output) continue;
5071
+ console.log(import_picocolors10.default.red(`\u2500\u2500 ${f.name} output \u2500\u2500`));
5072
+ console.log(indent2(trimOutput(f.output), 2));
5073
+ console.log("");
5074
+ }
5075
+ console.log(import_picocolors10.default.red(`\u2717 verify failed \u2014 ${failed.length} of ${results.length} step(s).`));
5076
+ console.log(import_picocolors10.default.dim(" A sweep that passes `stylecrib check` can still break the app."));
5077
+ console.log(import_picocolors10.default.dim(" Fix the above before pushing."));
5078
+ process.exitCode = 1;
5079
+ return;
5080
+ }
5081
+ const ran = results.filter((r) => r.status === "pass").length;
5082
+ console.log("");
5083
+ if (ran === 0) {
5084
+ console.log(import_picocolors10.default.yellow("\u26A0 verify ran no checks \u2014 nothing was verified."));
5085
+ for (const r of results) {
5086
+ console.log(import_picocolors10.default.dim(` ${r.name}: ${r.detail}`));
5087
+ }
5088
+ process.exitCode = 1;
5089
+ return;
5090
+ }
5091
+ console.log(import_picocolors10.default.green(`\u2713 verify passed \u2014 ${ran} step(s), 0 failures.`));
5092
+ }
5093
+ function runTypecheck(target) {
5094
+ const pkgPath = join16(target, "package.json");
5095
+ if (!existsSync10(pkgPath)) {
5096
+ return { name: "typecheck", status: "skip", detail: "no package.json" };
5097
+ }
5098
+ let scripts = {};
5099
+ try {
5100
+ scripts = JSON.parse(readFileSync10(pkgPath, "utf8")).scripts ?? {};
5101
+ } catch {
5102
+ return { name: "typecheck", status: "skip", detail: "unreadable package.json" };
5103
+ }
5104
+ const hasTsconfig = existsSync10(join16(target, "tsconfig.json"));
5105
+ let cmd;
5106
+ if (scripts.typecheck) {
5107
+ cmd = "npm run typecheck --silent";
5108
+ } else if (hasTsconfig) {
5109
+ cmd = "npx --no-install tsc --noEmit";
5110
+ } else {
5111
+ return { name: "typecheck", status: "skip", detail: "no typecheck script and no tsconfig.json" };
5112
+ }
5113
+ process.stdout.write(import_picocolors10.default.dim(` \xB7 typecheck (${cmd}) \u2026 `));
5114
+ try {
5115
+ execSync4(cmd, {
5116
+ cwd: target,
5117
+ stdio: ["ignore", "pipe", "pipe"],
5118
+ timeout: 3e5,
5119
+ encoding: "utf8"
5120
+ });
5121
+ console.log(import_picocolors10.default.green("ok"));
5122
+ return { name: "typecheck", status: "pass", detail: cmd };
5123
+ } catch (err) {
5124
+ console.log(import_picocolors10.default.red("failed"));
5125
+ const e = err;
5126
+ return {
5127
+ name: "typecheck",
5128
+ status: "fail",
5129
+ detail: cmd,
5130
+ output: [e.stdout, e.stderr].filter(Boolean).join("\n") || e.message || "(no output)"
5131
+ };
5132
+ }
5133
+ }
5134
+ async function runBoot(target, route, screenshotPath, timeoutMs) {
5135
+ const pkgPath = join16(target, "package.json");
5136
+ if (!existsSync10(pkgPath)) {
5137
+ return { name: "dev-server boot", status: "skip", detail: "no package.json" };
5138
+ }
5139
+ let scripts = {};
5140
+ try {
5141
+ scripts = JSON.parse(readFileSync10(pkgPath, "utf8")).scripts ?? {};
5142
+ } catch {
5143
+ return { name: "dev-server boot", status: "skip", detail: "unreadable package.json" };
5144
+ }
5145
+ if (!scripts.dev) {
5146
+ return { name: "dev-server boot", status: "skip", detail: "no `dev` script" };
5147
+ }
5148
+ if (!existsSync10(join16(target, "node_modules"))) {
5149
+ return { name: "dev-server boot", status: "skip", detail: "node_modules missing \u2014 run npm install" };
5150
+ }
5151
+ process.stdout.write(import_picocolors10.default.dim(" \xB7 booting dev server \u2026 "));
5152
+ const child = spawn("npm", ["run", "dev"], {
5153
+ cwd: target,
5154
+ detached: true,
5155
+ stdio: ["ignore", "pipe", "pipe"]
5156
+ });
5157
+ let log = "";
5158
+ const onData = (b) => {
5159
+ log += b.toString();
5160
+ };
5161
+ child.stdout?.on("data", onData);
5162
+ child.stderr?.on("data", onData);
5163
+ const killGroup = () => {
5164
+ if (child.pid === void 0) return;
5165
+ try {
5166
+ process.kill(-child.pid, "SIGTERM");
5167
+ } catch {
5168
+ }
5169
+ try {
5170
+ process.kill(-child.pid, "SIGKILL");
5171
+ } catch {
5172
+ }
5173
+ try {
5174
+ child.kill("SIGKILL");
5175
+ } catch {
5176
+ }
5177
+ };
5178
+ const onSignal = (sig) => {
5179
+ killGroup();
5180
+ process.exitCode = 130;
5181
+ process.removeListener("SIGINT", onSignal);
5182
+ process.removeListener("SIGTERM", onSignal);
5183
+ process.kill(process.pid, sig);
5184
+ };
5185
+ process.once("SIGINT", onSignal);
5186
+ process.once("SIGTERM", onSignal);
5187
+ try {
5188
+ const url = await waitForUrl(child, () => log, timeoutMs);
5189
+ if (!url) {
5190
+ console.log(import_picocolors10.default.red("failed"));
5191
+ return {
5192
+ name: "dev-server boot",
5193
+ status: "fail",
5194
+ detail: `no server URL within ${Math.round(timeoutMs / 1e3)}s`,
5195
+ output: log
5196
+ };
5197
+ }
5198
+ const pageUrl = url.replace(/\/$/, "") + (route.startsWith("/") ? route : "/" + route);
5199
+ const res = await fetchWithRetry(pageUrl, 10, 500);
5200
+ if (!res) {
5201
+ console.log(import_picocolors10.default.red("failed"));
5202
+ return { name: "dev-server boot", status: "fail", detail: `no response from ${pageUrl}`, output: log };
5203
+ }
5204
+ if (!res.ok) {
5205
+ console.log(import_picocolors10.default.red("failed"));
5206
+ return {
5207
+ name: "dev-server boot",
5208
+ status: "fail",
5209
+ detail: `${pageUrl} returned HTTP ${res.status}`,
5210
+ output: res.body.slice(0, 2e3) + "\n---\n" + log
5211
+ };
5212
+ }
5213
+ const dom = renderDom(pageUrl);
5214
+ if (!dom) {
5215
+ console.log(import_picocolors10.default.yellow("skipped"));
5216
+ return {
5217
+ name: "dev-server boot",
5218
+ status: "skip",
5219
+ detail: `server came up at ${pageUrl}, but no headless Chrome was usable, so the render could not be verified \u2014 install Chrome, or re-run where it is available`
5220
+ };
5221
+ }
5222
+ const inspected = dom;
5223
+ const method = "rendered DOM";
5224
+ const bodyProblem = describeBodyProblem(inspected, true);
5225
+ if (bodyProblem) {
5226
+ console.log(import_picocolors10.default.red("failed"));
5227
+ return {
5228
+ name: "dev-server boot",
5229
+ status: "fail",
5230
+ detail: `${bodyProblem} (checked ${dom ? "rendered DOM" : "HTTP body"})`,
5231
+ output: inspected.slice(0, 3e3)
5232
+ };
5233
+ }
5234
+ let shotNote = "";
5235
+ if (screenshotPath) {
5236
+ const abs = resolve12(screenshotPath);
5237
+ mkdirSync7(dirname7(abs), { recursive: true });
5238
+ try {
5239
+ captureScreenshot(pageUrl, abs);
5240
+ shotNote = ` \xB7 screenshot \u2192 ${screenshotPath}`;
5241
+ } catch (e) {
5242
+ shotNote = ` \xB7 screenshot skipped (${e.message})`;
5243
+ }
5244
+ }
5245
+ console.log(import_picocolors10.default.green("ok"));
5246
+ return {
5247
+ name: "dev-server boot",
5248
+ status: "pass",
5249
+ detail: `${pageUrl} \xB7 ${method}, ${inspected.length} bytes${shotNote}`
5250
+ };
5251
+ } finally {
5252
+ process.removeListener("SIGINT", onSignal);
5253
+ process.removeListener("SIGTERM", onSignal);
5254
+ killGroup();
5255
+ }
5256
+ }
5257
+ function waitForUrl(child, getLog, timeoutMs) {
5258
+ return new Promise((res) => {
5259
+ const started = Date.now();
5260
+ let settled = false;
5261
+ const done = (v) => {
5262
+ if (!settled) {
5263
+ settled = true;
5264
+ clearInterval(t);
5265
+ res(v);
5266
+ }
5267
+ };
5268
+ child.once("exit", () => done(null));
5269
+ child.once("error", () => done(null));
5270
+ const t = setInterval(() => {
5271
+ const m = getLog().match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/?/);
5272
+ if (m) return done(m[0]);
5273
+ if (Date.now() - started > timeoutMs) done(null);
5274
+ }, 250);
5275
+ });
5276
+ }
5277
+ async function fetchWithRetry(url, attempts, delayMs) {
5278
+ for (let i = 0; i < attempts; i++) {
5279
+ try {
5280
+ const r = await fetch(url, { redirect: "follow" });
5281
+ return { ok: r.ok, status: r.status, body: await r.text() };
5282
+ } catch {
5283
+ await sleep(delayMs);
5284
+ }
5285
+ }
5286
+ return null;
5287
+ }
5288
+ function renderDom(url) {
5289
+ for (const c2 of chromeCandidates()) {
5290
+ try {
5291
+ return execFileSync(c2, [
5292
+ "--headless",
5293
+ "--disable-gpu",
5294
+ "--hide-scrollbars",
5295
+ "--virtual-time-budget=5000",
5296
+ "--dump-dom",
5297
+ url
5298
+ ], { stdio: ["ignore", "pipe", "ignore"], timeout: 3e4, encoding: "utf8" });
5299
+ } catch {
5300
+ continue;
5301
+ }
5302
+ }
5303
+ return null;
5304
+ }
5305
+ function describeBodyProblem(body, isDom) {
5306
+ if (/vite-error-overlay/i.test(body)) {
5307
+ return "page served a Vite error overlay";
5308
+ }
5309
+ if (/Internal Server Error|Cannot GET |Failed to resolve import/i.test(body)) {
5310
+ return "page contains a server error";
5311
+ }
5312
+ if (isDom) {
5313
+ const mount = body.match(/<div[^>]+id=["']root["'][^>]*>([\s\S]*?)<\/div>/i);
5314
+ if (mount && mount[1].trim().length === 0) {
5315
+ return "mount node #root is empty after JS ran \u2014 the app threw before rendering";
5316
+ }
5317
+ if (!mount && !/<div[^>]+id=["']app["']/i.test(body)) {
5318
+ return "no #root / #app mount node in the rendered DOM";
5319
+ }
5320
+ } else if (body.trim().length < 80) {
5321
+ return `page body is only ${body.trim().length} bytes \u2014 likely not a real render`;
5322
+ }
5323
+ return null;
5324
+ }
5325
+ function chromeCandidates() {
5326
+ return [
5327
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
5328
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
5329
+ "google-chrome",
5330
+ "chromium"
5331
+ ];
5332
+ }
5333
+ function captureScreenshot(url, outPath) {
5334
+ for (const c2 of chromeCandidates()) {
5335
+ try {
5336
+ execFileSync(c2, [
5337
+ "--headless",
5338
+ "--disable-gpu",
5339
+ "--hide-scrollbars",
5340
+ `--screenshot=${outPath}`,
5341
+ "--window-size=1440,1600",
5342
+ url
5343
+ ], { stdio: ["ignore", "ignore", "ignore"], timeout: 3e4 });
5344
+ return;
5345
+ } catch {
5346
+ continue;
5347
+ }
5348
+ }
5349
+ throw new Error("no headless Chrome found");
5350
+ }
5351
+ function sleep(ms) {
5352
+ return new Promise((r) => setTimeout(r, ms));
5353
+ }
5354
+ function trimOutput(s, maxLines = 40) {
5355
+ const lines = s.split("\n").filter((l) => l.trim() !== "");
5356
+ if (lines.length <= maxLines) return lines.join("\n");
5357
+ return [...lines.slice(0, maxLines), `\u2026 ${lines.length - maxLines} more line(s)`].join("\n");
5358
+ }
5359
+ function indent2(s, n) {
5360
+ const pad = " ".repeat(n);
5361
+ return s.split("\n").map((l) => pad + l).join("\n");
5362
+ }
5363
+
5364
+ // src/commands/mcp.ts
5365
+ init_esm_shims();
5366
+ import { readFileSync as readFileSync11, existsSync as existsSync11 } from "fs";
5367
+ import { join as join17 } from "path";
5368
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
5369
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5370
+ import { z } from "zod";
5371
+ init_slop_rules();
5372
+ async function mcp(_opts = {}) {
5373
+ const toStderr = (...args) => console.error(...args);
5374
+ console.log = toStderr;
5375
+ console.info = toStderr;
5376
+ console.warn = toStderr;
5377
+ const server = new McpServer({ name: "stylecrib", version: VERSION });
5378
+ server.registerTool(
5379
+ "list_themes",
5380
+ {
5381
+ title: "List StyleCrib themes",
5382
+ description: "The 25 canonical StyleCrib directions: slug, display name, one-line description, and whether `emit_theme_tokens` can currently generate a Tailwind config for it. Four themes use a newer token format the emitter can't read yet and are marked emittable: false \u2014 pick another or use get_theme for the brief alone.",
5383
+ inputSchema: {}
5384
+ },
5385
+ async () => {
5386
+ const themes = DIRECTION_CHOICES.filter((d) => d.value !== "custom").map((d) => ({
5387
+ slug: d.value,
5388
+ name: d.name,
5389
+ description: d.description,
5390
+ emittable: isEmittable(d.value)
5391
+ }));
5392
+ return text(JSON.stringify({ count: themes.length, themes }, null, 2));
5393
+ }
5394
+ );
5395
+ server.registerTool(
5396
+ "get_theme",
5397
+ {
5398
+ title: "Get a StyleCrib theme",
5399
+ description: "The full THEME.md design brief for one theme \u2014 ethos, palette, typography, motion, and the per-theme refusals. Use this to brief an agent before it writes UI for a project on that theme.",
5400
+ inputSchema: {
5401
+ slug: z.string().describe("Theme slug from list_themes, e.g. 'signal-dark'")
5402
+ }
5403
+ },
5404
+ async ({ slug }) => {
5405
+ if (!VALID_DIRECTIONS.includes(slug) || slug === "custom") {
5406
+ return fail(`Unknown theme "${slug}". Call list_themes for valid slugs.`);
5407
+ }
5408
+ const themesDir = resolveAssetDir("themes");
5409
+ if (!themesDir) return fail(missingAssetMessage("themes"));
5410
+ const mdPath = join17(themesDir, slug, "THEME.md");
5411
+ if (!existsSync11(mdPath)) return fail(`No THEME.md for "${slug}" at ${mdPath}.`);
5412
+ const header2 = `# ${slug}
5413
+
5414
+ emittable: ${isEmittable(slug)}
5415
+ tokens: themes/${slug}/tokens.ts
5416
+
5417
+ ---
5418
+
5419
+ `;
5420
+ return text(header2 + readFileSync11(mdPath, "utf8"));
5421
+ }
5422
+ );
5423
+ server.registerTool(
5424
+ "emit_theme_tokens",
5425
+ {
5426
+ title: "Generate Tailwind tokens for a theme",
5427
+ description: "Generate tailwind.config.ts (or .js) and src/index.css for a theme \u2014 the same files `stylecrib init` writes. Returns both as text. The config references `tailwindcss-animate`, so that package must be installed alongside Tailwind 3. Fails cleanly for the four themes whose token format isn't readable yet; list_themes marks those.",
5428
+ inputSchema: {
5429
+ slug: z.string().describe("Theme slug from list_themes"),
5430
+ typescript: z.boolean().optional().describe("Emit tailwind.config.ts (default true) or tailwind.config.js")
5431
+ }
5432
+ },
5433
+ async ({ slug, typescript }) => {
5434
+ if (!VALID_DIRECTIONS.includes(slug) || slug === "custom") {
5435
+ return fail(`Unknown theme "${slug}". Call list_themes for valid slugs.`);
5436
+ }
5437
+ try {
5438
+ const direction = slug;
5439
+ await assertDirectionEmittable(direction);
5440
+ const emitted = await emitTokens({ direction, stack: syntheticStack(typescript ?? true) });
5441
+ return text(
5442
+ `// ===== ${emitted.tailwindConfig.path} =====
5443
+ ` + emitted.tailwindConfig.content + `
5444
+
5445
+ /* ===== ${emitted.indexCss.path} ===== */
5446
+ ` + emitted.indexCss.content
5447
+ );
5448
+ } catch (err) {
5449
+ return fail(err instanceof Error ? err.message : String(err));
5450
+ }
5451
+ }
5452
+ );
5453
+ server.registerTool(
5454
+ "list_rules",
5455
+ {
5456
+ title: "List slop rules",
5457
+ description: "The 39-rule catalog StyleCrib enforces \u2014 what each refuses, severity, category, and where it's detected (commit time vs theme materialization). Filter by any field.",
5458
+ inputSchema: {
5459
+ category: z.string().optional().describe("e.g. typography, palette, layout, content, motion"),
5460
+ severity: z.enum(["block", "warn"]).optional(),
5461
+ detectorAt: z.string().optional().describe("check.ts | slop-blocklist.ts | qa-conductor")
5462
+ }
5463
+ },
5464
+ async ({ category, severity, detectorAt }) => {
5465
+ const rules = SLOP_RULES.filter(
5466
+ (r) => (!category || r.category === category) && (!severity || r.severity === severity) && (!detectorAt || r.detectorAt === detectorAt)
5467
+ );
5468
+ return text(JSON.stringify({ count: rules.length, rules }, null, 2));
5469
+ }
5470
+ );
5471
+ server.registerTool(
5472
+ "check_slop",
5473
+ {
5474
+ title: "Check a file for AI-default slop",
5475
+ description: "Run StyleCrib's text-pattern slop detectors against a file's contents and return every hit with its rule, line, and the offending sample. Covers 26 of the 32 commit-time rules \u2014 everything in the pre-commit hook except the two composition-study provenance checks, R-MONO-CAPS-TRACKED, the three diff-signature regression detectors (which need a diff, not a file), and the LLM review. Whole-file, not diff-scoped: it reports every hit present, not only ones a change introduced. Pass `theme` to also enforce per-theme rules such as R-SECOND-ACCENT.",
5476
+ inputSchema: {
5477
+ path: z.string().describe("File path, used for rule scoping (JSX brace-stripping applies to .tsx/.jsx)"),
5478
+ content: z.string().describe("The file's full contents"),
5479
+ theme: z.string().optional().describe("Active theme slug, enables theme-aware rules (e.g. 'signal-dark')")
5480
+ }
5481
+ },
5482
+ async ({ path: path2, content, theme }) => {
5483
+ let themeCtx = null;
5484
+ if (theme) {
5485
+ if (!VALID_DIRECTIONS.includes(theme)) {
5486
+ return fail(`Unknown theme "${theme}". Call list_themes for valid slugs.`);
5487
+ }
5488
+ themeCtx = resolveThemeContext(theme);
5489
+ }
5490
+ const hits = runAllTextDetectors(path2, content, themeCtx);
5491
+ return text(JSON.stringify(summarise(path2, hits), null, 2));
5492
+ }
5493
+ );
5494
+ server.registerResource(
5495
+ "rules",
5496
+ "stylecrib://rules",
5497
+ {
5498
+ title: "StyleCrib slop-rule catalog",
5499
+ description: "All 39 rules as JSON. Same data as list_rules with no filter.",
5500
+ mimeType: "application/json"
5501
+ },
5502
+ async (uri) => ({
5503
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(SLOP_RULES, null, 2) }]
5504
+ })
5505
+ );
5506
+ server.registerResource(
5507
+ "agents",
5508
+ new ResourceTemplate("stylecrib://agents/{name}", {
5509
+ list: async () => ({
5510
+ resources: Object.keys(BUNDLED_AGENTS).map((name) => ({
5511
+ uri: `stylecrib://agents/${name}`,
5512
+ name,
5513
+ mimeType: "text/markdown"
5514
+ }))
5515
+ })
5516
+ }),
5517
+ {
5518
+ title: "StyleCrib agent doctrines",
5519
+ description: "The 19 subagent prompts that ship with the pack, by name.",
5520
+ mimeType: "text/markdown"
5521
+ },
5522
+ async (uri, { name }) => {
5523
+ const key = String(name);
5524
+ const doc = BUNDLED_AGENTS[key];
5525
+ if (!doc) throw new CliError(`No agent named "${key}". Known: ${Object.keys(BUNDLED_AGENTS).join(", ")}`);
5526
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: doc }] };
5527
+ }
5528
+ );
5529
+ const transport = new StdioServerTransport();
5530
+ await server.connect(transport);
5531
+ console.error(`stylecrib mcp ${VERSION} \u2014 serving over stdio`);
5532
+ }
5533
+ function text(body) {
5534
+ return { content: [{ type: "text", text: body }] };
5535
+ }
5536
+ function fail(message) {
5537
+ return { content: [{ type: "text", text: message }], isError: true };
5538
+ }
5539
+ var emittableCache = /* @__PURE__ */ new Map();
5540
+ function isEmittable(slug) {
5541
+ const cached = emittableCache.get(slug);
5542
+ if (cached !== void 0) return cached;
5543
+ let ok = true;
5544
+ try {
5545
+ const themesDir = resolveAssetDir("themes");
5546
+ const src = themesDir ? readFileSync11(join17(themesDir, slug, "tokens.ts"), "utf8") : "";
5547
+ ok = /^\s*colors:\s*\{/m.test(src);
5548
+ } catch {
5549
+ ok = false;
5550
+ }
5551
+ emittableCache.set(slug, ok);
5552
+ return ok;
5553
+ }
5554
+ function syntheticStack(ts) {
5555
+ return {
5556
+ rootPath: "",
5557
+ packageJsonPath: "",
5558
+ ts,
5559
+ react: { version: "n/a", major: 0 },
5560
+ vite: { version: "n/a", major: 0 },
5561
+ tailwind: { version: "n/a", major: 0 },
5562
+ hasTailwindConfig: false,
5563
+ tailwindConfigPath: null,
5564
+ hasSrcDir: true,
5565
+ srcPath: "src",
5566
+ notes: []
5567
+ };
5568
+ }
5569
+ function summarise(path2, hits) {
5570
+ const enriched = hits.map((h) => {
5571
+ const rule2 = ruleById(h.ruleId);
5572
+ return {
5573
+ rule: h.ruleId,
5574
+ severity: rule2?.severity ?? "block",
5575
+ category: rule2?.category ?? "unknown",
5576
+ line: h.line,
5577
+ sample: h.sample,
5578
+ description: rule2?.description ?? ""
5579
+ };
5580
+ });
5581
+ const blocks = enriched.filter((h) => h.severity === "block").length;
5582
+ return {
5583
+ path: path2,
5584
+ verdict: blocks > 0 ? "BLOCK" : enriched.length > 0 ? "WARN" : "PASS",
5585
+ blockHits: blocks,
5586
+ warnHits: enriched.length - blocks,
5587
+ hits: enriched,
5588
+ override: enriched.length > 0 ? "Silence a legitimate hit with `// slop-override: R-XXX \u2014 <reason>` within 3 lines above or 8 below." : void 0
5589
+ };
5590
+ }
5591
+
5592
+ // src/commands/harness.ts
5593
+ init_esm_shims();
5594
+ var import_picocolors11 = __toESM(require_picocolors(), 1);
5595
+ import { existsSync as existsSync19 } from "fs";
5596
+ import { join as join26, resolve as resolve13 } from "path";
4512
5597
 
4513
5598
  // src/theme-harness/materialize.ts
4514
5599
  init_esm_shims();
4515
5600
  import { writeFileSync as writeFileSync7 } from "fs";
4516
- import { join as join13 } from "path";
5601
+ import { join as join18 } from "path";
4517
5602
 
4518
5603
  // src/theme-harness/lib/theme-yml.ts
4519
5604
  init_esm_shims();
4520
- import { readFileSync as readFileSync9 } from "fs";
5605
+ import { readFileSync as readFileSync12 } from "fs";
4521
5606
  import { parse as parseYaml } from "yaml";
4522
5607
  var REQUIRED_TOP = [
4523
5608
  "name",
@@ -4543,7 +5628,7 @@ var HEX_RE = /^#[0-9A-Fa-f]{6}$/;
4543
5628
  function loadTheme(path2) {
4544
5629
  let raw;
4545
5630
  try {
4546
- raw = parseYaml(readFileSync9(path2, "utf8"));
5631
+ raw = parseYaml(readFileSync12(path2, "utf8"));
4547
5632
  } catch (e) {
4548
5633
  return { ok: false, errors: [`YAML parse error: ${e.message}`] };
4549
5634
  }
@@ -4629,7 +5714,7 @@ function validateTheme(raw) {
4629
5714
 
4630
5715
  // src/theme-harness/materialize.ts
4631
5716
  function materializeTheme(slug, themeDir) {
4632
- const ymlPath = join13(themeDir, "theme.yml");
5717
+ const ymlPath = join18(themeDir, "theme.yml");
4633
5718
  const parsed = loadTheme(ymlPath);
4634
5719
  if (!parsed.ok) return { ok: false, errors: parsed.errors };
4635
5720
  const theme = parsed.theme;
@@ -4637,11 +5722,11 @@ function materializeTheme(slug, themeDir) {
4637
5722
  const tokensTs = renderTokensTs(slug, theme);
4638
5723
  const tailwindSnippet = renderTailwindSnippet(slug, theme);
4639
5724
  const wrote = [];
4640
- writeFileSync7(join13(themeDir, "THEME.md"), themeMd);
5725
+ writeFileSync7(join18(themeDir, "THEME.md"), themeMd);
4641
5726
  wrote.push("THEME.md");
4642
- writeFileSync7(join13(themeDir, "tokens.ts"), tokensTs);
5727
+ writeFileSync7(join18(themeDir, "tokens.ts"), tokensTs);
4643
5728
  wrote.push("tokens.ts");
4644
- writeFileSync7(join13(themeDir, "tailwind.snippet.ts"), tailwindSnippet);
5729
+ writeFileSync7(join18(themeDir, "tailwind.snippet.ts"), tailwindSnippet);
4645
5730
  wrote.push("tailwind.snippet.ts");
4646
5731
  return { ok: true, wrote };
4647
5732
  }
@@ -4726,9 +5811,27 @@ function renderTokensTs(slug, t) {
4726
5811
  ].join("\n");
4727
5812
  }
4728
5813
  function renderTailwindSnippet(slug, t) {
5814
+ const display = extractFontStack(t.typography.display);
5815
+ const body = extractFontStack(t.typography.body);
5816
+ const mono = extractFontStack(t.typography.mono);
5817
+ const fontLines = [];
5818
+ if (display) fontLines.push(` display: ${JSON.stringify(display)},`);
5819
+ if (body) fontLines.push(` body: ${JSON.stringify(body)},`);
5820
+ if (mono) fontLines.push(` mono: ${JSON.stringify(mono)},`);
5821
+ const fontBlock = fontLines.length ? [` fontFamily: {`, ...fontLines, ` },`] : [
5822
+ ` // fontFamily omitted \u2014 theme.yml's typography brief doesn't state a`,
5823
+ ` // machine-readable fallback stack. Read the Typography section of`,
5824
+ ` // THEME.md and set the stack by hand.`
5825
+ ];
5826
+ const unresolved = [
5827
+ !display ? "display" : "",
5828
+ !body ? "body" : "",
5829
+ !mono ? "mono" : ""
5830
+ ].filter(Boolean);
4729
5831
  return [
4730
5832
  `// Generated from theme.yml. Merge into your tailwind.config.ts under theme.extend.`,
4731
5833
  `// slug: ${slug}`,
5834
+ unresolved.length ? `// Unresolved font stack(s): ${unresolved.join(", ")} \u2014 see THEME.md \xA7 Typography.` : "",
4732
5835
  ``,
4733
5836
  `export const ${camelCase(slug)}TailwindExtend = {`,
4734
5837
  ` colors: {`,
@@ -4739,15 +5842,21 @@ function renderTailwindSnippet(slug, t) {
4739
5842
  t.palette.secondary ? ` secondary: ${JSON.stringify(t.palette.secondary)},` : "",
4740
5843
  t.palette.signal ? ` signal: ${JSON.stringify(t.palette.signal)},` : "",
4741
5844
  ` },`,
4742
- ` fontFamily: {`,
4743
- ` display: ${JSON.stringify([t.typography.display])},`,
4744
- ` body: ${JSON.stringify([t.typography.body])},`,
4745
- ` mono: ${JSON.stringify([t.typography.mono])},`,
4746
- ` },`,
5845
+ ...fontBlock,
4747
5846
  `};`,
4748
5847
  ``
4749
5848
  ].filter((l) => l !== "").join("\n");
4750
5849
  }
5850
+ function extractFontStack(brief) {
5851
+ if (!brief) return null;
5852
+ const text2 = String(brief).trim();
5853
+ if (/^none\b/i.test(text2)) return null;
5854
+ const m = text2.match(/fallback stack\s*[—–-]\s*([^.]+)\./i);
5855
+ if (!m) return null;
5856
+ const families = m[1].split(",").map((f) => f.trim().replace(/^["'“”]|["'“”]$/g, "").trim()).filter((f) => f.length > 0 && f.length < 40 && !/\s{2,}/.test(f));
5857
+ if (families.length < 2) return null;
5858
+ return families;
5859
+ }
4751
5860
  function camelCase(slug) {
4752
5861
  return slug.split("-").map((p, i) => i === 0 ? p : p[0].toUpperCase() + p.slice(1)).join("");
4753
5862
  }
@@ -4763,8 +5872,8 @@ function inferFamily(t) {
4763
5872
 
4764
5873
  // src/theme-harness/grade.ts
4765
5874
  init_esm_shims();
4766
- import { appendFileSync as appendFileSync3, readFileSync as readFileSync14, existsSync as existsSync12 } from "fs";
4767
- import { join as join18 } from "path";
5875
+ import { appendFileSync as appendFileSync3, readFileSync as readFileSync17, existsSync as existsSync16 } from "fs";
5876
+ import { join as join23 } from "path";
4768
5877
 
4769
5878
  // src/theme-harness/gates/slop-blocklist.ts
4770
5879
  init_esm_shims();
@@ -4884,14 +5993,14 @@ function runSourceRules(src, theme, failures) {
4884
5993
  const capsCssRe2 = /letterSpacing:\s*["']?(0?\.\d+)em[\s\S]{0,200}?textTransform:\s*["']uppercase["']/g;
4885
5994
  const fontSizeRe = /fontSize:\s*(\d+)/;
4886
5995
  const capsHits = [];
4887
- const scanCaps = (re, text) => {
5996
+ const scanCaps = (re, text2) => {
4888
5997
  let m;
4889
- while ((m = re.exec(text)) !== null) {
5998
+ while ((m = re.exec(text2)) !== null) {
4890
5999
  const spacing = parseFloat(m[1]);
4891
6000
  if (spacing < 0.18) continue;
4892
6001
  const start = Math.max(0, m.index - 240);
4893
- const end = Math.min(text.length, m.index + m[0].length + 240);
4894
- const window = text.slice(start, end);
6002
+ const end = Math.min(text2.length, m.index + m[0].length + 240);
6003
+ const window = text2.slice(start, end);
4895
6004
  const fs = window.match(fontSizeRe);
4896
6005
  const fontSize = fs ? parseInt(fs[1], 10) : 12;
4897
6006
  if (fontSize < 20) capsHits.push(`${m[1]}em @ ~${fontSize}px`);
@@ -5026,8 +6135,8 @@ function normalizeFont(f) {
5026
6135
 
5027
6136
  // src/theme-harness/gates/avoid-rubric.ts
5028
6137
  init_esm_shims();
5029
- import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5030
- import { join as join14 } from "path";
6138
+ import { readFileSync as readFileSync13, existsSync as existsSync12 } from "fs";
6139
+ import { join as join19 } from "path";
5031
6140
  import Anthropic4 from "@anthropic-ai/sdk";
5032
6141
  var RUBRIC_TOOL = {
5033
6142
  name: "report_verdict",
@@ -5141,25 +6250,25 @@ async function runAvoidRubric(theme, themeDir) {
5141
6250
  return { passed: failures.length === 0, failures };
5142
6251
  }
5143
6252
  function collectEvidence(themeDir) {
5144
- const readOr = (p) => existsSync8(p) ? readFileSync10(p, "utf8") : "<file not present>";
6253
+ const readOr = (p) => existsSync12(p) ? readFileSync13(p, "utf8") : "<file not present>";
5145
6254
  return {
5146
- themeMd: readOr(join14(themeDir, "THEME.md")),
5147
- tokensTs: readOr(join14(themeDir, "tokens.ts")),
5148
- tailwindSnippet: readOr(join14(themeDir, "tailwind.snippet.ts"))
6255
+ themeMd: readOr(join19(themeDir, "THEME.md")),
6256
+ tokensTs: readOr(join19(themeDir, "tokens.ts")),
6257
+ tailwindSnippet: readOr(join19(themeDir, "tailwind.snippet.ts"))
5149
6258
  };
5150
6259
  }
5151
6260
 
5152
6261
  // src/theme-harness/gates/regression-scan.ts
5153
6262
  init_esm_shims();
5154
- import { readFileSync as readFileSync11, existsSync as existsSync9 } from "fs";
5155
- import { join as join15 } from "path";
6263
+ import { readFileSync as readFileSync14, existsSync as existsSync13 } from "fs";
6264
+ import { join as join20 } from "path";
5156
6265
  function resolveRegressionSet(themeDir) {
5157
- return join15(themeDir, "..", "..", "site", "design-system", "regression-set.jsonl");
6266
+ return join20(themeDir, "..", "..", "site", "design-system", "regression-set.jsonl");
5158
6267
  }
5159
6268
  function loadRegressionRules(themeDir) {
5160
6269
  const p = resolveRegressionSet(themeDir);
5161
- if (!existsSync9(p)) return [];
5162
- const raw = readFileSync11(p, "utf8").trim();
6270
+ if (!existsSync13(p)) return [];
6271
+ const raw = readFileSync14(p, "utf8").trim();
5163
6272
  if (!raw) return [];
5164
6273
  const rules = [];
5165
6274
  for (const line of raw.split("\n")) {
@@ -5175,9 +6284,9 @@ function loadRegressionRules(themeDir) {
5175
6284
  function runRegressionScan(theme, themeDir) {
5176
6285
  const rules = loadRegressionRules(themeDir);
5177
6286
  if (rules.length === 0) return { passed: true, failures: [] };
5178
- const tokensSrc = safeRead(join15(themeDir, "tokens.ts"));
5179
- const twSrc = safeRead(join15(themeDir, "tailwind.snippet.ts"));
5180
- const themeMdSrc = safeRead(join15(themeDir, "THEME.md"));
6287
+ const tokensSrc = safeRead(join20(themeDir, "tokens.ts"));
6288
+ const twSrc = safeRead(join20(themeDir, "tailwind.snippet.ts"));
6289
+ const themeMdSrc = safeRead(join20(themeDir, "THEME.md"));
5181
6290
  const combined = tokensSrc + "\n" + twSrc;
5182
6291
  const failures = [];
5183
6292
  for (const rule2 of rules) {
@@ -5233,7 +6342,7 @@ function checkRule(rule2, theme, materializedCode, themeMdSrc) {
5233
6342
  return null;
5234
6343
  }
5235
6344
  function safeRead(p) {
5236
- return existsSync9(p) ? readFileSync11(p, "utf8") : "";
6345
+ return existsSync13(p) ? readFileSync14(p, "utf8") : "";
5237
6346
  }
5238
6347
  function hexToRgb2(hex) {
5239
6348
  const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
@@ -5249,9 +6358,9 @@ function rgbDistance2(a, b) {
5249
6358
 
5250
6359
  // src/theme-harness/gates/anchor-compare.ts
5251
6360
  init_esm_shims();
5252
- import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
5253
- import { execSync as execSync4 } from "child_process";
5254
- import { join as join16 } from "path";
6361
+ import { existsSync as existsSync14, readFileSync as readFileSync15 } from "fs";
6362
+ import { execSync as execSync5 } from "child_process";
6363
+ import { join as join21 } from "path";
5255
6364
  import Anthropic5 from "@anthropic-ai/sdk";
5256
6365
  var RUBRIC_TOOL2 = {
5257
6366
  name: "report_anchor_verdict",
@@ -5318,16 +6427,16 @@ async function runAnchorCompare(theme, themeDir, repoRoot) {
5318
6427
  skipped: `Family ${family} has no golden-set anchor yet \u2014 anchor-compare skipped (per Matt's Path B decision, families A+G bootstrap from their own first-authored theme).`
5319
6428
  };
5320
6429
  }
5321
- const familyDir = join16(repoRoot, "site", "design-system", "golden-set", family);
5322
- const rubricPath = join16(familyDir, "rubric.md");
5323
- if (!existsSync10(rubricPath)) {
6430
+ const familyDir = join21(repoRoot, "site", "design-system", "golden-set", family);
6431
+ const rubricPath = join21(familyDir, "rubric.md");
6432
+ if (!existsSync14(rubricPath)) {
5324
6433
  return {
5325
6434
  passed: true,
5326
6435
  failures: [],
5327
6436
  skipped: `Family ${family} rubric.md missing at ${rubricPath} \u2014 anchor-compare skipped.`
5328
6437
  };
5329
6438
  }
5330
- const rubric = readFileSync12(rubricPath, "utf8");
6439
+ const rubric = readFileSync15(rubricPath, "utf8");
5331
6440
  const anchorPngs = findAnchorPngs(familyDir);
5332
6441
  if (anchorPngs.length === 0) {
5333
6442
  return {
@@ -5343,7 +6452,7 @@ async function runAnchorCompare(theme, themeDir, repoRoot) {
5343
6452
  skipped: `theme.yml has no render_url \u2014 anchor-compare skipped. Add render_url to enable.`
5344
6453
  };
5345
6454
  }
5346
- const renderPath = join16(themeDir, ".anchor-render.png");
6455
+ const renderPath = join21(themeDir, ".anchor-render.png");
5347
6456
  try {
5348
6457
  captureRender(theme.render_url, renderPath);
5349
6458
  } catch (e) {
@@ -5353,7 +6462,7 @@ async function runAnchorCompare(theme, themeDir, repoRoot) {
5353
6462
  skipped: `Render capture failed: ${e.message}. Verify dev server is running at ${theme.render_url}. Gate skipped.`
5354
6463
  };
5355
6464
  }
5356
- if (!existsSync10(renderPath)) {
6465
+ if (!existsSync14(renderPath)) {
5357
6466
  return {
5358
6467
  passed: true,
5359
6468
  failures: [],
@@ -5374,7 +6483,7 @@ async function runAnchorCompare(theme, themeDir, repoRoot) {
5374
6483
  source: {
5375
6484
  type: "base64",
5376
6485
  media_type: "image/png",
5377
- data: readFileSync12(p).toString("base64")
6486
+ data: readFileSync15(p).toString("base64")
5378
6487
  }
5379
6488
  }));
5380
6489
  const renderBlock = {
@@ -5382,7 +6491,7 @@ async function runAnchorCompare(theme, themeDir, repoRoot) {
5382
6491
  source: {
5383
6492
  type: "base64",
5384
6493
  media_type: "image/png",
5385
- data: readFileSync12(renderPath).toString("base64")
6494
+ data: readFileSync15(renderPath).toString("base64")
5386
6495
  }
5387
6496
  };
5388
6497
  const userContent = [
@@ -5466,20 +6575,20 @@ Grade the render against each rubric axis. Return via report_anchor_verdict.`
5466
6575
  return { passed: failures.length === 0, failures };
5467
6576
  }
5468
6577
  function findAnchorPngs(familyDir) {
5469
- if (!existsSync10(familyDir)) return [];
5470
- const dirents = execSync4(`ls -1 "${familyDir}" 2>/dev/null`).toString().split("\n").filter(Boolean);
5471
- return dirents.filter((n) => /^[0-9]+-[a-z0-9-]+\.png$/.test(n)).map((n) => join16(familyDir, n));
6578
+ if (!existsSync14(familyDir)) return [];
6579
+ const dirents = execSync5(`ls -1 "${familyDir}" 2>/dev/null`).toString().split("\n").filter(Boolean);
6580
+ return dirents.filter((n) => /^[0-9]+-[a-z0-9-]+\.png$/.test(n)).map((n) => join21(familyDir, n));
5472
6581
  }
5473
6582
  function captureRender(url, outPath) {
5474
- const chromeCandidates = [
6583
+ const chromeCandidates2 = [
5475
6584
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
5476
6585
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
5477
6586
  "google-chrome",
5478
6587
  "chromium"
5479
6588
  ];
5480
- for (const c2 of chromeCandidates) {
6589
+ for (const c2 of chromeCandidates2) {
5481
6590
  try {
5482
- execSync4(`"${c2}" --headless --disable-gpu --hide-scrollbars --screenshot="${outPath}" --window-size=1440,1600 "${url}"`, {
6591
+ execSync5(`"${c2}" --headless --disable-gpu --hide-scrollbars --screenshot="${outPath}" --window-size=1440,1600 "${url}"`, {
5483
6592
  stdio: ["ignore", "ignore", "ignore"],
5484
6593
  timeout: 3e4
5485
6594
  });
@@ -5488,13 +6597,13 @@ function captureRender(url, outPath) {
5488
6597
  continue;
5489
6598
  }
5490
6599
  }
5491
- throw new Error(`No headless Chrome found. Tried: ${chromeCandidates.join(", ")}`);
6600
+ throw new Error(`No headless Chrome found. Tried: ${chromeCandidates2.join(", ")}`);
5492
6601
  }
5493
6602
 
5494
6603
  // src/theme-harness/dismiss.ts
5495
6604
  init_esm_shims();
5496
- import { existsSync as existsSync11, readFileSync as readFileSync13, writeFileSync as writeFileSync8, appendFileSync as appendFileSync2 } from "fs";
5497
- import { join as join17 } from "path";
6605
+ import { existsSync as existsSync15, readFileSync as readFileSync16, writeFileSync as writeFileSync8, appendFileSync as appendFileSync2 } from "fs";
6606
+ import { join as join22 } from "path";
5498
6607
  var DISMISSALS_FILE = "dismissals.jsonl";
5499
6608
  function writeDismissal(themeDir, finding_id, reason, scope, now) {
5500
6609
  const d = {
@@ -5504,18 +6613,18 @@ function writeDismissal(themeDir, finding_id, reason, scope, now) {
5504
6613
  dismissed_at: now,
5505
6614
  applied: false
5506
6615
  };
5507
- appendFileSync2(join17(themeDir, DISMISSALS_FILE), JSON.stringify(d) + "\n");
6616
+ appendFileSync2(join22(themeDir, DISMISSALS_FILE), JSON.stringify(d) + "\n");
5508
6617
  return d;
5509
6618
  }
5510
6619
  function loadDismissals(themeDir) {
5511
- const p = join17(themeDir, DISMISSALS_FILE);
5512
- if (!existsSync11(p)) return [];
5513
- const raw = readFileSync13(p, "utf8").trim();
6620
+ const p = join22(themeDir, DISMISSALS_FILE);
6621
+ if (!existsSync15(p)) return [];
6622
+ const raw = readFileSync16(p, "utf8").trim();
5514
6623
  if (!raw) return [];
5515
6624
  return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line));
5516
6625
  }
5517
6626
  function persistDismissals(themeDir, ds) {
5518
- const p = join17(themeDir, DISMISSALS_FILE);
6627
+ const p = join22(themeDir, DISMISSALS_FILE);
5519
6628
  const body = ds.map((d) => JSON.stringify(d)).join("\n") + (ds.length ? "\n" : "");
5520
6629
  writeFileSync8(p, body);
5521
6630
  }
@@ -5537,7 +6646,7 @@ function claimDismissal(ds, finding_id, runTimestamp) {
5537
6646
 
5538
6647
  // src/theme-harness/grade.ts
5539
6648
  async function gradeTheme(slug, themeDir, now, repoRoot = process.cwd()) {
5540
- const ymlPath = join18(themeDir, "theme.yml");
6649
+ const ymlPath = join23(themeDir, "theme.yml");
5541
6650
  const parsed = loadTheme(ymlPath);
5542
6651
  if (!parsed.ok) return { error: parsed.errors };
5543
6652
  const theme = parsed.theme;
@@ -5595,18 +6704,18 @@ async function gradeTheme(slug, themeDir, now, repoRoot = process.cwd()) {
5595
6704
  }
5596
6705
  const overall = gates.some((g) => !g.verdict.passed) ? "BLOCK" : "PASS";
5597
6706
  const report = { slug, gates, overall, ran_at: now };
5598
- const logPath = join18(themeDir, "audit.log.jsonl");
6707
+ const logPath = join23(themeDir, "audit.log.jsonl");
5599
6708
  appendFileSync3(logPath, JSON.stringify(report) + "\n");
5600
6709
  if (dismissalsMutated) persistDismissals(themeDir, dismissals);
5601
6710
  return report;
5602
6711
  }
5603
6712
  function readSectionSource(slug, repoRoot) {
5604
6713
  const candidates = [
5605
- join18(repoRoot, "site", "src", "components", "sections", `${slug}.tsx`),
5606
- join18(repoRoot, "src", "components", "sections", `${slug}.tsx`)
6714
+ join23(repoRoot, "site", "src", "components", "sections", `${slug}.tsx`),
6715
+ join23(repoRoot, "src", "components", "sections", `${slug}.tsx`)
5607
6716
  ];
5608
6717
  for (const p of candidates) {
5609
- if (existsSync12(p)) return readFileSync14(p, "utf8");
6718
+ if (existsSync16(p)) return readFileSync17(p, "utf8");
5610
6719
  }
5611
6720
  return void 0;
5612
6721
  }
@@ -5680,15 +6789,15 @@ function formatReport(r) {
5680
6789
 
5681
6790
  // src/theme-harness/promote.ts
5682
6791
  init_esm_shims();
5683
- import { readFileSync as readFileSync15, writeFileSync as writeFileSync9, existsSync as existsSync13, appendFileSync as appendFileSync4 } from "fs";
5684
- import { join as join19 } from "path";
6792
+ import { readFileSync as readFileSync18, writeFileSync as writeFileSync9, existsSync as existsSync17, appendFileSync as appendFileSync4 } from "fs";
6793
+ import { join as join24 } from "path";
5685
6794
  function regressionSetPath(themeDir) {
5686
- return join19(themeDir, "..", "..", "site", "design-system", "regression-set.jsonl");
6795
+ return join24(themeDir, "..", "..", "site", "design-system", "regression-set.jsonl");
5687
6796
  }
5688
6797
  function nextRegressionId(themeDir) {
5689
6798
  const p = regressionSetPath(themeDir);
5690
- if (!existsSync13(p)) return "REG-0001";
5691
- const raw = readFileSync15(p, "utf8");
6799
+ if (!existsSync17(p)) return "REG-0001";
6800
+ const raw = readFileSync18(p, "utf8");
5692
6801
  let max = 0;
5693
6802
  for (const line of raw.split("\n")) {
5694
6803
  const m = line.match(/"id":\s*"REG-(\d+)"/);
@@ -5710,7 +6819,7 @@ function promoteToRegression(themeDir, input3) {
5710
6819
  scope: "global"
5711
6820
  };
5712
6821
  const p = regressionSetPath(themeDir);
5713
- if (!existsSync13(p)) {
6822
+ if (!existsSync17(p)) {
5714
6823
  writeFileSync9(p, "// Regression matchers \u2014 permanent lessons from caught defects.\n// One JSON object per line. See site/design-system/HARNESS_DESIGN.md.\n");
5715
6824
  }
5716
6825
  appendFileSync4(p, JSON.stringify(rule2) + "\n");
@@ -5719,8 +6828,8 @@ function promoteToRegression(themeDir, input3) {
5719
6828
 
5720
6829
  // src/theme-harness/waivers.ts
5721
6830
  init_esm_shims();
5722
- import { existsSync as existsSync14, mkdirSync as mkdirSync7, readFileSync as readFileSync16, readdirSync as readdirSync3, statSync as statSync4, writeFileSync as writeFileSync10 } from "fs";
5723
- import { join as join20, relative as relative5 } from "path";
6831
+ import { existsSync as existsSync18, mkdirSync as mkdirSync8, readFileSync as readFileSync19, readdirSync as readdirSync4, statSync as statSync4, writeFileSync as writeFileSync10 } from "fs";
6832
+ import { join as join25, relative as relative5 } from "path";
5724
6833
  var SKIP_DIRS = /* @__PURE__ */ new Set([
5725
6834
  "node_modules",
5726
6835
  ".git",
@@ -5735,9 +6844,9 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
5735
6844
  ]);
5736
6845
  var CODE_SUFFIXES = /\.(?:tsx?|jsx?|mjs|cjs|css|scss|md|ya?ml)$/;
5737
6846
  function walk2(root, acc) {
5738
- for (const entry of readdirSync3(root)) {
6847
+ for (const entry of readdirSync4(root)) {
5739
6848
  if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
5740
- const full = join20(root, entry);
6849
+ const full = join25(root, entry);
5741
6850
  let st;
5742
6851
  try {
5743
6852
  st = statSync4(full);
@@ -5775,7 +6884,7 @@ function collectWaivers(target) {
5775
6884
  for (const abs of files) {
5776
6885
  let body;
5777
6886
  try {
5778
- body = readFileSync16(abs, "utf8");
6887
+ body = readFileSync19(abs, "utf8");
5779
6888
  } catch {
5780
6889
  continue;
5781
6890
  }
@@ -5816,15 +6925,15 @@ function formatWaiverDigest(waivers, today) {
5816
6925
  return lines.join("\n") + "\n";
5817
6926
  }
5818
6927
  function writeDigest(target, waivers, today) {
5819
- const dir = join20(target, "reports");
5820
- if (!existsSync14(dir)) {
6928
+ const dir = join25(target, "reports");
6929
+ if (!existsSync18(dir)) {
5821
6930
  if (waivers.length === 0) return null;
5822
- mkdirSync7(dir, { recursive: true });
6931
+ mkdirSync8(dir, { recursive: true });
5823
6932
  }
5824
6933
  const digest = formatWaiverDigest(waivers, today);
5825
- const outPath = join20(dir, `qa-waivers-${today}.md`);
6934
+ const outPath = join25(dir, `qa-waivers-${today}.md`);
5826
6935
  writeFileSync10(outPath, digest);
5827
- writeFileSync10(join20(dir, "qa-waivers-latest.md"), digest);
6936
+ writeFileSync10(join25(dir, "qa-waivers-latest.md"), digest);
5828
6937
  return outPath;
5829
6938
  }
5830
6939
 
@@ -5841,30 +6950,30 @@ function registerHarnessCommand(program2) {
5841
6950
  const harness = program2.command("harness").description("v2 theme-authoring harness \u2014 materialize + grade + (later) dismiss.");
5842
6951
  harness.command("materialize <slug>").description("Read themes/<slug>/theme.yml \u2192 write THEME.md + tokens.ts + tailwind.snippet.ts.").option("--themes-dir <path>", "Root directory containing per-theme folders", DEFAULT_THEMES_DIR).action((slug, opts) => {
5843
6952
  const dir = resolveThemeDir(slug, opts);
5844
- if (!existsSync15(join21(dir, "theme.yml"))) {
5845
- console.error(import_picocolors10.default.red(`\u2717 No theme.yml at ${join21(dir, "theme.yml")}`));
6953
+ if (!existsSync19(join26(dir, "theme.yml"))) {
6954
+ console.error(import_picocolors11.default.red(`\u2717 No theme.yml at ${join26(dir, "theme.yml")}`));
5846
6955
  process.exit(1);
5847
6956
  }
5848
6957
  const result = materializeTheme(slug, dir);
5849
6958
  if (!result.ok) {
5850
- console.error(import_picocolors10.default.red(`\u2717 theme.yml validation failed:`));
5851
- for (const e of result.errors) console.error(import_picocolors10.default.red(` \xB7 ${e}`));
6959
+ console.error(import_picocolors11.default.red(`\u2717 theme.yml validation failed:`));
6960
+ for (const e of result.errors) console.error(import_picocolors11.default.red(` \xB7 ${e}`));
5852
6961
  process.exit(1);
5853
6962
  }
5854
- console.log(import_picocolors10.default.green(`\u2713 Materialized ${slug}:`));
6963
+ console.log(import_picocolors11.default.green(`\u2713 Materialized ${slug}:`));
5855
6964
  for (const f of result.wrote) console.log(` \xB7 ${f}`);
5856
6965
  });
5857
6966
  harness.command("grade <slug>").description("Run harness gates against themes/<slug>. Appends audit.log.jsonl and exits 1 on BLOCK.").option("--themes-dir <path>", "Root directory containing per-theme folders", DEFAULT_THEMES_DIR).action(async (slug, opts) => {
5858
6967
  const dir = resolveThemeDir(slug, opts);
5859
- if (!existsSync15(join21(dir, "theme.yml"))) {
5860
- console.error(import_picocolors10.default.red(`\u2717 No theme.yml at ${join21(dir, "theme.yml")}`));
6968
+ if (!existsSync19(join26(dir, "theme.yml"))) {
6969
+ console.error(import_picocolors11.default.red(`\u2717 No theme.yml at ${join26(dir, "theme.yml")}`));
5861
6970
  process.exit(1);
5862
6971
  }
5863
6972
  const repoRoot = resolve13(opts.themesDir ?? DEFAULT_THEMES_DIR, "..");
5864
6973
  const report = await gradeTheme(slug, dir, isoNow(), repoRoot);
5865
6974
  if ("error" in report) {
5866
- console.error(import_picocolors10.default.red(`\u2717 theme.yml validation failed:`));
5867
- for (const e of report.error) console.error(import_picocolors10.default.red(` \xB7 ${e}`));
6975
+ console.error(import_picocolors11.default.red(`\u2717 theme.yml validation failed:`));
6976
+ for (const e of report.error) console.error(import_picocolors11.default.red(` \xB7 ${e}`));
5868
6977
  process.exit(1);
5869
6978
  }
5870
6979
  console.log(formatReport(report));
@@ -5874,14 +6983,14 @@ function registerHarnessCommand(program2) {
5874
6983
  "Turn a caught defect into a permanent regression matcher \u2014 appends to site/design-system/regression-set.jsonl. Rules are tracked in git so the whole team inherits the lesson."
5875
6984
  ).requiredOption("--matcher-type <type>", "hex_distance | class_pattern | inline_style").requiredOption("--target <value>", "For hex_distance: a hex color like #D97757. For class_pattern / inline_style: a regex.").option("--threshold <n>", "For hex_distance: RGB distance threshold (default: 12).", "12").requiredOption("-r, --reason <text>", "Human-readable explanation of the defect and why it's a permanent rule.").option("--themes-dir <path>", "Root directory containing per-theme folders", DEFAULT_THEMES_DIR).action((slug, opts) => {
5876
6985
  const dir = resolveThemeDir(slug, opts);
5877
- if (!existsSync15(join21(dir, "theme.yml"))) {
5878
- console.error(import_picocolors10.default.red(`\u2717 No theme.yml at ${join21(dir, "theme.yml")}`));
6986
+ if (!existsSync19(join26(dir, "theme.yml"))) {
6987
+ console.error(import_picocolors11.default.red(`\u2717 No theme.yml at ${join26(dir, "theme.yml")}`));
5879
6988
  process.exit(1);
5880
6989
  }
5881
6990
  let matcher;
5882
6991
  if (opts.matcherType === "hex_distance") {
5883
6992
  if (!/^#[0-9A-Fa-f]{6}$/.test(opts.target)) {
5884
- console.error(import_picocolors10.default.red(`\u2717 --target must be a 6-digit hex for hex_distance matcher, got: ${opts.target}`));
6993
+ console.error(import_picocolors11.default.red(`\u2717 --target must be a 6-digit hex for hex_distance matcher, got: ${opts.target}`));
5885
6994
  process.exit(1);
5886
6995
  }
5887
6996
  matcher = { type: "hex_distance", target: opts.target, threshold: parseInt(opts.threshold, 10) };
@@ -5890,7 +6999,7 @@ function registerHarnessCommand(program2) {
5890
6999
  } else if (opts.matcherType === "inline_style") {
5891
7000
  matcher = { type: "inline_style", pattern: opts.target };
5892
7001
  } else {
5893
- console.error(import_picocolors10.default.red(`\u2717 --matcher-type must be hex_distance | class_pattern | inline_style, got: ${opts.matcherType}`));
7002
+ console.error(import_picocolors11.default.red(`\u2717 --matcher-type must be hex_distance | class_pattern | inline_style, got: ${opts.matcherType}`));
5894
7003
  process.exit(1);
5895
7004
  }
5896
7005
  const rule2 = promoteToRegression(dir, {
@@ -5900,30 +7009,30 @@ function registerHarnessCommand(program2) {
5900
7009
  matcher,
5901
7010
  now: isoNow()
5902
7011
  });
5903
- console.log(import_picocolors10.default.green(`\u2713 Promoted to regression-set as ${rule2.id}`));
7012
+ console.log(import_picocolors11.default.green(`\u2713 Promoted to regression-set as ${rule2.id}`));
5904
7013
  console.log(` matcher: ${JSON.stringify(matcher)}`);
5905
7014
  console.log(` reason: ${rule2.reason}`);
5906
- console.log(import_picocolors10.default.dim(` Rule now runs against every future grade. Delete from regression-set.jsonl to reverse.`));
7015
+ console.log(import_picocolors11.default.dim(` Rule now runs against every future grade. Delete from regression-set.jsonl to reverse.`));
5907
7016
  });
5908
7017
  harness.command("dismiss <slug> <finding-id>").description(
5909
7018
  "Dismiss a finding from the theme's next grade run (this_run) or permanently for this theme (this_theme). Default scope: this_run \u2014 dismissal applies once, then is consumed. Nothing regresses silently."
5910
7019
  ).requiredOption("-r, --reason <text>", "Why this finding is not a real defect. Persisted to dismissals.jsonl and shown in every future run's report.").option("-s, --scope <scope>", "this_run | this_theme (default: this_run)", "this_run").option("--themes-dir <path>", "Root directory containing per-theme folders", DEFAULT_THEMES_DIR).action((slug, findingId, opts) => {
5911
7020
  const dir = resolveThemeDir(slug, opts);
5912
- if (!existsSync15(join21(dir, "theme.yml"))) {
5913
- console.error(import_picocolors10.default.red(`\u2717 No theme.yml at ${join21(dir, "theme.yml")}`));
7021
+ if (!existsSync19(join26(dir, "theme.yml"))) {
7022
+ console.error(import_picocolors11.default.red(`\u2717 No theme.yml at ${join26(dir, "theme.yml")}`));
5914
7023
  process.exit(1);
5915
7024
  }
5916
7025
  if (opts.scope !== "this_run" && opts.scope !== "this_theme") {
5917
- console.error(import_picocolors10.default.red(`\u2717 --scope must be "this_run" or "this_theme", got: ${opts.scope}`));
7026
+ console.error(import_picocolors11.default.red(`\u2717 --scope must be "this_run" or "this_theme", got: ${opts.scope}`));
5918
7027
  process.exit(1);
5919
7028
  }
5920
7029
  const d = writeDismissal(dir, findingId, opts.reason, opts.scope, isoNow());
5921
- console.log(import_picocolors10.default.green(`\u2713 Dismissed ${findingId} for ${slug} (scope: ${d.scope})`));
7030
+ console.log(import_picocolors11.default.green(`\u2713 Dismissed ${findingId} for ${slug} (scope: ${d.scope})`));
5922
7031
  console.log(` reason: ${d.reason}`);
5923
7032
  if (d.scope === "this_run") {
5924
- console.log(import_picocolors10.default.dim(` Applies to the next grade run only. If ${findingId} fires again after that, it will BLOCK.`));
7033
+ console.log(import_picocolors11.default.dim(` Applies to the next grade run only. If ${findingId} fires again after that, it will BLOCK.`));
5925
7034
  } else {
5926
- console.log(import_picocolors10.default.dim(` Applies to every future grade run for this theme. Delete the entry from dismissals.jsonl to reverse.`));
7035
+ console.log(import_picocolors11.default.dim(` Applies to every future grade run for this theme. Delete the entry from dismissals.jsonl to reverse.`));
5927
7036
  }
5928
7037
  });
5929
7038
  harness.command("waivers").description(
@@ -5933,10 +7042,10 @@ function registerHarnessCommand(program2) {
5933
7042
  const waivers = collectWaivers(target);
5934
7043
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5935
7044
  if (waivers.length === 0) {
5936
- console.log(import_picocolors10.default.green(`\u2713 No slop-override waivers found in ${target}.`));
7045
+ console.log(import_picocolors11.default.green(`\u2713 No slop-override waivers found in ${target}.`));
5937
7046
  return;
5938
7047
  }
5939
- console.log(import_picocolors10.default.bold(`\u25C7 Waivers \u2014 ${waivers.length} static override${waivers.length === 1 ? "" : "s"} across the tree`));
7048
+ console.log(import_picocolors11.default.bold(`\u25C7 Waivers \u2014 ${waivers.length} static override${waivers.length === 1 ? "" : "s"} across the tree`));
5940
7049
  console.log("");
5941
7050
  const byRule = /* @__PURE__ */ new Map();
5942
7051
  for (const w of waivers) {
@@ -5944,37 +7053,43 @@ function registerHarnessCommand(program2) {
5944
7053
  byRule.get(w.ruleId).push(w);
5945
7054
  }
5946
7055
  for (const [ruleId, ws] of byRule) {
5947
- console.log(import_picocolors10.default.yellow(` ${ruleId}`) + import_picocolors10.default.dim(` \u2014 ${ws.length} waiver${ws.length === 1 ? "" : "s"}`));
7056
+ console.log(import_picocolors11.default.yellow(` ${ruleId}`) + import_picocolors11.default.dim(` \u2014 ${ws.length} waiver${ws.length === 1 ? "" : "s"}`));
5948
7057
  for (const w of ws.slice(0, 8)) {
5949
- const scope = w.scope === "file" ? import_picocolors10.default.magenta("[file]") : import_picocolors10.default.cyan("[local]");
5950
- const reason = w.reason || import_picocolors10.default.dim("(no reason given)");
5951
- console.log(` ${scope} ${import_picocolors10.default.cyan(w.file + ":" + w.line)}`);
7058
+ const scope = w.scope === "file" ? import_picocolors11.default.magenta("[file]") : import_picocolors11.default.cyan("[local]");
7059
+ const reason = w.reason || import_picocolors11.default.dim("(no reason given)");
7060
+ console.log(` ${scope} ${import_picocolors11.default.cyan(w.file + ":" + w.line)}`);
5952
7061
  console.log(` ${reason}`);
5953
7062
  }
5954
- if (ws.length > 8) console.log(import_picocolors10.default.dim(` \u2026 +${ws.length - 8} more`));
7063
+ if (ws.length > 8) console.log(import_picocolors11.default.dim(` \u2026 +${ws.length - 8} more`));
5955
7064
  }
5956
7065
  console.log("");
5957
7066
  if (opts.write) {
5958
7067
  const path2 = writeDigest(target, waivers, today);
5959
- if (path2) console.log(import_picocolors10.default.dim(` Digest written to ${path2.replace(target + "/", "")}`));
7068
+ if (path2) console.log(import_picocolors11.default.dim(` Digest written to ${path2.replace(target + "/", "")}`));
5960
7069
  }
5961
7070
  });
5962
7071
  }
5963
7072
 
5964
7073
  // src/index.ts
5965
- var import_picocolors11 = __toESM(require_picocolors(), 1);
7074
+ var import_picocolors12 = __toESM(require_picocolors(), 1);
5966
7075
  var program = new Command();
5967
7076
  program.name("stylecrib").description(
5968
7077
  "Design upgrade for vibe-coded React apps. Installs 19 Claude Code subagents (craft reviewers, enforcer, exec auditors) + a chosen theme from a 118-theme library into your repo. See https://github.com/frischermf/stylecrib for the mandate."
5969
- ).version("0.2.0");
7078
+ ).version(VERSION);
5970
7079
  program.command("init").description(
5971
7080
  "Interactive wizard: pick a direction, describe references, drop the agents into your repo, and install the enforcement layer (pre-commit hook + .cursorrules + GitHub Action). The hook is what makes the agents actually fire \u2014 installed by default. Pass --no-hooks to skip."
5972
7081
  ).option(
5973
7082
  "-s, --style <direction>",
5974
- "Skip the wizard, use a canonical direction (editorial | soft-modern | dense-utility)"
7083
+ "Skip the direction picker and use a canonical theme (soft-modern, brutalist, signal-dark, newsletter, \u2026 \u2014 an unknown slug is an error, not a fallback). Run `stylecrib init --help` or browse themes/ for the full list."
5975
7084
  ).option(
5976
7085
  "-y, --yes",
5977
- "Use defaults wherever possible (still prompts for required inputs)"
7086
+ "Non-interactive: no prompts at all. Takes --style (or soft-modern), the directory name as the project name, and no references \u2014 which keeps the run on the canonical path, so it makes no Anthropic call and needs no API key. Implied when there's no terminal attached."
7087
+ ).option(
7088
+ "--apply",
7089
+ "Apply staged files without asking. Overwrites tailwind.config.* and src/index.css on a new git branch."
7090
+ ).option(
7091
+ "--no-apply",
7092
+ "Stage into .design-upgrade/ and stop. Review, then run `stylecrib apply` yourself. This is the default when running non-interactively against a project that already exists."
5978
7093
  ).option(
5979
7094
  "--target <path>",
5980
7095
  "Target project root (defaults to current working directory)"
@@ -5989,7 +7104,7 @@ program.command("init").description(
5989
7104
  "Skip installing @stylecrib/react + scaffolding src/lib/stylecrib-wire.tsx. Default-on: init installs the runtime pack and writes a wire-up file the customer imports into their App.tsx. Opt out for repos that have the runtime wired already."
5990
7105
  ).option(
5991
7106
  "--force-runtime",
5992
- "Overwrite src/lib/stylecrib-wire.tsx if it exists. Existing content is backed up to `src/lib/stylecrib-wire.tsx.pre-taste.bak` first."
7107
+ "Overwrite src/lib/stylecrib-wire.tsx if it exists. Existing content is backed up to `src/lib/stylecrib-wire.tsx.pre-stylecrib.bak` first."
5993
7108
  ).action(init);
5994
7109
  program.command("apply").description(
5995
7110
  "Apply staged changes from .design-upgrade/ to your project (on a new git branch)."
@@ -6027,6 +7142,27 @@ program.command("install-hooks").description(
6027
7142
  program.command("hook-check", { hidden: true }).description(
6028
7143
  "Internal \u2014 reads Claude Code PostToolUse hook JSON from stdin, forwards to `stylecrib check` on the touched file. Wired by install-hooks. Not intended for direct invocation."
6029
7144
  ).action(hookCheck);
7145
+ program.command("verify").description(
7146
+ "Runtime smoke gate \u2014 typecheck the project, then boot its dev server and fetch a route to prove the app still renders. Catches the class of regression a diff-scoped detector cannot see: a sweep that leaves the source looking plausible but the app broken (a type error, a bad import, an empty render). Run it after an AI sweep and before a push \u2014 NOT on every commit, it takes tens of seconds."
7147
+ ).option(
7148
+ "--target <path>",
7149
+ "Project root (defaults to current working directory)"
7150
+ ).option(
7151
+ "--no-typecheck",
7152
+ "Skip the typecheck step."
7153
+ ).option(
7154
+ "--no-boot",
7155
+ "Skip the dev-server boot. Leaves only the typecheck \u2014 useful in CI without a browser."
7156
+ ).option(
7157
+ "--route <path>",
7158
+ "Route to fetch once the server is up (default: /)"
7159
+ ).option(
7160
+ "--screenshot <file>",
7161
+ "Also capture a headless-Chrome screenshot of the route to this path."
7162
+ ).option(
7163
+ "--timeout <ms>",
7164
+ "How long to wait for the dev server to announce a URL (default: 60000)"
7165
+ ).action(verify);
6030
7166
  program.command("status").description(
6031
7167
  "Print which enforcement layers are active in this repo. Verifies the hook is installed + executable + wired, agents are present, .cursorrules exists, GH Action wired, feedback memory initialized, THEME.md present, and ANTHROPIC_API_KEY set. Use any time to confirm the loop will actually fire."
6032
7168
  ).option(
@@ -6057,12 +7193,15 @@ program.command("reject").description(
6057
7193
  target: opts.target
6058
7194
  });
6059
7195
  });
7196
+ program.command("mcp").description(
7197
+ "Run StyleCrib as an MCP server over stdio, so Cursor, Windsurf, Claude Desktop, Zed, or any other MCP client can list themes, fetch a theme's brief, generate its Tailwind tokens, read the rule catalog, and run the slop detectors on a file \u2014 without Claude Code or the pre-commit hook. Point your client's MCP config at `npx -y @stylecrib/cli mcp`."
7198
+ ).action(mcp);
6060
7199
  registerHarnessCommand(program);
6061
7200
  program.parseAsync(process.argv).catch((err) => {
6062
7201
  console.error("");
6063
- console.error(import_picocolors11.default.red("\u2717 ") + (err instanceof Error ? err.message : String(err)));
7202
+ console.error(import_picocolors12.default.red("\u2717 ") + (err instanceof Error ? err.message : String(err)));
6064
7203
  if (err instanceof Error && err.cause) {
6065
- console.error(import_picocolors11.default.dim(" caused by: " + String(err.cause)));
7204
+ console.error(import_picocolors12.default.dim(" caused by: " + String(err.cause)));
6066
7205
  }
6067
7206
  process.exit(1);
6068
7207
  });