rovecode 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +737 -0
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/bin/rovecode.ts +21 -0
  5. package/package.json +56 -0
  6. package/src/acp/server.ts +374 -0
  7. package/src/cli/auth-login.ts +122 -0
  8. package/src/cli/connect.ts +244 -0
  9. package/src/cli/context-cmd.ts +199 -0
  10. package/src/cli/dispatch.ts +82 -0
  11. package/src/cli/doctor.ts +362 -0
  12. package/src/cli/export.ts +276 -0
  13. package/src/cli/help.ts +293 -0
  14. package/src/cli/is-tui-invocation.ts +8 -0
  15. package/src/cli/main.ts +583 -0
  16. package/src/cli/market-cmd.ts +658 -0
  17. package/src/cli/mcp-login.ts +141 -0
  18. package/src/cli/mcp-market-cmd.ts +302 -0
  19. package/src/cli/output.ts +382 -0
  20. package/src/cli/repl.ts +250 -0
  21. package/src/cli/repomap-root.ts +14 -0
  22. package/src/cli/resume.ts +57 -0
  23. package/src/cli/run-flags.ts +43 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +931 -0
  26. package/src/cli/session-arg.ts +30 -0
  27. package/src/cli/sessions-cmd.ts +145 -0
  28. package/src/cli/setup.ts +153 -0
  29. package/src/cli/skills-cmd.ts +194 -0
  30. package/src/cli/start-chat.ts +65 -0
  31. package/src/cli/trust-cmd.ts +52 -0
  32. package/src/coding/bash.ts +148 -0
  33. package/src/coding/checkpoints.ts +327 -0
  34. package/src/coding/diff.ts +138 -0
  35. package/src/coding/files.ts +341 -0
  36. package/src/coding/hashline.ts +274 -0
  37. package/src/coding/lsp-gate.ts +254 -0
  38. package/src/coding/lsp-servers.ts +147 -0
  39. package/src/coding/lsp.ts +283 -0
  40. package/src/coding/repomap-cache.ts +99 -0
  41. package/src/coding/repomap-files.ts +192 -0
  42. package/src/coding/repomap.ts +481 -0
  43. package/src/core/agents.ts +255 -0
  44. package/src/core/compaction.ts +259 -0
  45. package/src/core/config.ts +289 -0
  46. package/src/core/context-report.ts +228 -0
  47. package/src/core/context.ts +60 -0
  48. package/src/core/count-remote.ts +107 -0
  49. package/src/core/execpolicy-rules.ts +196 -0
  50. package/src/core/execpolicy.ts +385 -0
  51. package/src/core/executor.ts +454 -0
  52. package/src/core/guardrails.ts +400 -0
  53. package/src/core/hooks.ts +411 -0
  54. package/src/core/images.ts +230 -0
  55. package/src/core/intro.ts +266 -0
  56. package/src/core/loop.ts +567 -0
  57. package/src/core/modes.ts +372 -0
  58. package/src/core/orchestrator.ts +245 -0
  59. package/src/core/proc-group.ts +48 -0
  60. package/src/core/project-trust.ts +98 -0
  61. package/src/core/reflection.ts +165 -0
  62. package/src/core/sandbox-config.ts +186 -0
  63. package/src/core/session-id.ts +24 -0
  64. package/src/core/session-images.ts +73 -0
  65. package/src/core/session-ops.ts +183 -0
  66. package/src/core/session-text.ts +29 -0
  67. package/src/core/session.ts +469 -0
  68. package/src/core/settings.ts +170 -0
  69. package/src/core/tasks.ts +646 -0
  70. package/src/core/token-scale.ts +108 -0
  71. package/src/core/tools.ts +309 -0
  72. package/src/core/trust.ts +104 -0
  73. package/src/core/types.ts +330 -0
  74. package/src/core/update-check.ts +171 -0
  75. package/src/core/usage.ts +204 -0
  76. package/src/core/validate.ts +121 -0
  77. package/src/core/verify-gate.ts +159 -0
  78. package/src/core/verify.ts +236 -0
  79. package/src/core/voice.ts +158 -0
  80. package/src/core/win-job.ts +183 -0
  81. package/src/core/workspace.ts +184 -0
  82. package/src/design/audit.ts +797 -0
  83. package/src/design/direction.ts +190 -0
  84. package/src/design/rules.ts +157 -0
  85. package/src/eval/bench.ts +150 -0
  86. package/src/eval/gauntlet-runner.ts +215 -0
  87. package/src/eval/gauntlet-support.ts +84 -0
  88. package/src/eval/gauntlet-wave3.ts +269 -0
  89. package/src/eval/gauntlet-wave4.ts +217 -0
  90. package/src/eval/gauntlet.ts +253 -0
  91. package/src/index.ts +17 -0
  92. package/src/lanes/agy.ts +95 -0
  93. package/src/lanes/approval.ts +24 -0
  94. package/src/lanes/claude.ts +129 -0
  95. package/src/lanes/codex.ts +127 -0
  96. package/src/lanes/events.ts +130 -0
  97. package/src/lanes/job.ts +142 -0
  98. package/src/lanes/opencode.ts +122 -0
  99. package/src/lanes/process.ts +184 -0
  100. package/src/lanes/progress.ts +183 -0
  101. package/src/lanes/registry.ts +178 -0
  102. package/src/lanes/runner.ts +124 -0
  103. package/src/lanes/types.ts +112 -0
  104. package/src/market/catalogs/mcp-docs.json +111 -0
  105. package/src/market/catalogs/plugins.json +111 -0
  106. package/src/market/catalogs/skills.json +478 -0
  107. package/src/market/clone.ts +72 -0
  108. package/src/market/context-cost.ts +121 -0
  109. package/src/market/digest.ts +106 -0
  110. package/src/market/index.ts +22 -0
  111. package/src/market/install.ts +578 -0
  112. package/src/market/manifest.ts +187 -0
  113. package/src/market/prereq.ts +145 -0
  114. package/src/market/registry.ts +363 -0
  115. package/src/market/resolve.ts +111 -0
  116. package/src/market/types.ts +236 -0
  117. package/src/market/validate.ts +227 -0
  118. package/src/mcp/client.ts +449 -0
  119. package/src/mcp/config.ts +252 -0
  120. package/src/mcp/local-package.ts +211 -0
  121. package/src/mcp/market-catalog.ts +84 -0
  122. package/src/mcp/market-install.ts +289 -0
  123. package/src/mcp/market.ts +362 -0
  124. package/src/mcp/oauth.ts +251 -0
  125. package/src/mcp/prompts-resources.ts +249 -0
  126. package/src/mcp/shared.ts +149 -0
  127. package/src/mcp/status.ts +67 -0
  128. package/src/mcp/tools.ts +275 -0
  129. package/src/mcp/transport.ts +122 -0
  130. package/src/mcp/trust.ts +25 -0
  131. package/src/memory/blocks.ts +278 -0
  132. package/src/memory/recall.ts +355 -0
  133. package/src/memory/scope.ts +182 -0
  134. package/src/memory/store.ts +105 -0
  135. package/src/memory/tools.ts +99 -0
  136. package/src/plugins/cli.ts +119 -0
  137. package/src/plugins/discover.ts +108 -0
  138. package/src/plugins/index.ts +50 -0
  139. package/src/plugins/install.ts +184 -0
  140. package/src/plugins/load.ts +124 -0
  141. package/src/plugins/manifest.ts +92 -0
  142. package/src/plugins/state.ts +83 -0
  143. package/src/providers/auth.ts +408 -0
  144. package/src/providers/cache.ts +223 -0
  145. package/src/providers/catalog-local.ts +160 -0
  146. package/src/providers/catalog.ts +421 -0
  147. package/src/providers/middleware-context.ts +86 -0
  148. package/src/providers/middleware.ts +373 -0
  149. package/src/providers/model-list.ts +23 -0
  150. package/src/providers/models-index.json +1 -0
  151. package/src/providers/oauth/common.ts +105 -0
  152. package/src/providers/oauth/device-code.ts +107 -0
  153. package/src/providers/oauth/github-copilot.ts +146 -0
  154. package/src/providers/oauth/loopback.ts +158 -0
  155. package/src/providers/oauth/openai.ts +163 -0
  156. package/src/providers/oauth/openrouter.ts +89 -0
  157. package/src/providers/oauth/pkce.ts +45 -0
  158. package/src/providers/oauth/registry.ts +39 -0
  159. package/src/providers/oauth/seam.ts +89 -0
  160. package/src/providers/profile-glm53.ts +111 -0
  161. package/src/providers/profile-sonnet5-persona.ts +65 -0
  162. package/src/providers/profile-sonnet5-voice.ts +23 -0
  163. package/src/providers/profiles.ts +156 -0
  164. package/src/providers/provider-config.ts +311 -0
  165. package/src/providers/registry.ts +333 -0
  166. package/src/providers/responses.ts +209 -0
  167. package/src/providers/retry.ts +234 -0
  168. package/src/providers/router.ts +294 -0
  169. package/src/providers/sse.ts +26 -0
  170. package/src/providers/stream-errors.ts +117 -0
  171. package/src/providers/stream.ts +566 -0
  172. package/src/providers/thinking.ts +189 -0
  173. package/src/providers/wire-messages.ts +129 -0
  174. package/src/providers/wire-responses.ts +79 -0
  175. package/src/providers/wire-select.ts +53 -0
  176. package/src/server/http.ts +291 -0
  177. package/src/server/openapi.ts +246 -0
  178. package/src/sextant/card-hits.ts +102 -0
  179. package/src/sextant/card-keys.ts +55 -0
  180. package/src/sextant/context-source.ts +157 -0
  181. package/src/sextant/crew-cards.ts +350 -0
  182. package/src/sextant/draw-agents.ts +273 -0
  183. package/src/sextant/draw-code.ts +388 -0
  184. package/src/sextant/draw-context.ts +222 -0
  185. package/src/sextant/draw-frame.ts +164 -0
  186. package/src/sextant/draw-market.ts +573 -0
  187. package/src/sextant/draw-messages.ts +386 -0
  188. package/src/sextant/draw-pet.ts +230 -0
  189. package/src/sextant/draw-plan.ts +187 -0
  190. package/src/sextant/draw-tabs.ts +85 -0
  191. package/src/sextant/draw-util.ts +65 -0
  192. package/src/sextant/draw-wizard.ts +378 -0
  193. package/src/sextant/engine.ts +230 -0
  194. package/src/sextant/frame-hits.ts +25 -0
  195. package/src/sextant/frame.ts +101 -0
  196. package/src/sextant/git-status.ts +197 -0
  197. package/src/sextant/grid.ts +59 -0
  198. package/src/sextant/input.ts +119 -0
  199. package/src/sextant/keys.ts +521 -0
  200. package/src/sextant/layout.ts +86 -0
  201. package/src/sextant/local-commands.ts +169 -0
  202. package/src/sextant/market-source.ts +287 -0
  203. package/src/sextant/mentions.ts +200 -0
  204. package/src/sextant/message-hits.ts +26 -0
  205. package/src/sextant/model.ts +387 -0
  206. package/src/sextant/overlays.ts +456 -0
  207. package/src/sextant/panel-hits.ts +38 -0
  208. package/src/sextant/pet.ts +399 -0
  209. package/src/sextant/screen.ts +324 -0
  210. package/src/sextant/scroll-hits.ts +66 -0
  211. package/src/sextant/scrollbar.ts +82 -0
  212. package/src/sextant/sextant-bridge.ts +174 -0
  213. package/src/sextant/sextant-cards.ts +142 -0
  214. package/src/sextant/sextant-diff-base.ts +63 -0
  215. package/src/sextant/sextant-files.ts +154 -0
  216. package/src/sextant/sextant-frame-loop.ts +335 -0
  217. package/src/sextant/sextant-renderer.ts +574 -0
  218. package/src/sextant/sextant-repo.ts +140 -0
  219. package/src/sextant/theme.ts +66 -0
  220. package/src/sextant/tool-rows.ts +189 -0
  221. package/src/sextant/types.ts +493 -0
  222. package/src/skills/index.ts +387 -0
  223. package/src/skills/pack.ts +220 -0
  224. package/src/skills/spec.ts +162 -0
  225. package/src/skills/tools.ts +69 -0
  226. package/src/skills/versioned.ts +227 -0
  227. package/src/telemetry/otel-export.ts +122 -0
  228. package/src/telemetry/otel-lanes.ts +89 -0
  229. package/src/telemetry/otel-logs.ts +131 -0
  230. package/src/telemetry/otel-metrics.ts +136 -0
  231. package/src/telemetry/otel.ts +397 -0
  232. package/src/telemetry/otlp.ts +76 -0
  233. package/src/tools/ask-user.ts +156 -0
  234. package/src/tools/bash-bg.ts +94 -0
  235. package/src/tools/bash-jobs.ts +237 -0
  236. package/src/tools/design.ts +151 -0
  237. package/src/tools/evalcell.ts +338 -0
  238. package/src/tools/html-text.ts +139 -0
  239. package/src/tools/provider.ts +149 -0
  240. package/src/tools/task.ts +250 -0
  241. package/src/tools/todo.ts +320 -0
  242. package/src/tools/webfetch.ts +332 -0
  243. package/src/tools/websearch.ts +359 -0
  244. package/src/tui/agents-cmd.ts +41 -0
  245. package/src/tui/app.ts +749 -0
  246. package/src/tui/attach.ts +127 -0
  247. package/src/tui/boot-notes.ts +41 -0
  248. package/src/tui/builtin-prompts.ts +59 -0
  249. package/src/tui/checkpoints-cmd.ts +70 -0
  250. package/src/tui/clipboard-image.ts +81 -0
  251. package/src/tui/clipboard.ts +78 -0
  252. package/src/tui/commands.ts +283 -0
  253. package/src/tui/config-view.ts +53 -0
  254. package/src/tui/context-cmds.ts +282 -0
  255. package/src/tui/cost.ts +108 -0
  256. package/src/tui/crash-guard.ts +173 -0
  257. package/src/tui/focus-terminal.ts +34 -0
  258. package/src/tui/git-cmds.ts +273 -0
  259. package/src/tui/git-plain.ts +58 -0
  260. package/src/tui/info-cmd.ts +150 -0
  261. package/src/tui/input-plain.ts +76 -0
  262. package/src/tui/mcp-cmd.ts +128 -0
  263. package/src/tui/memory-note.ts +77 -0
  264. package/src/tui/modes-cmd.ts +45 -0
  265. package/src/tui/notify-seq.ts +100 -0
  266. package/src/tui/notify.ts +318 -0
  267. package/src/tui/overlays.ts +97 -0
  268. package/src/tui/pi-renderer.ts +428 -0
  269. package/src/tui/providers-cmd.ts +377 -0
  270. package/src/tui/reasoning-view.ts +56 -0
  271. package/src/tui/renderer.ts +128 -0
  272. package/src/tui/replay-marker.ts +29 -0
  273. package/src/tui/session-cmd.ts +148 -0
  274. package/src/tui/session-manage.ts +95 -0
  275. package/src/tui/sextant-attach.ts +102 -0
  276. package/src/tui/sextant-io.ts +202 -0
  277. package/src/tui/sextant-smoke.ts +110 -0
  278. package/src/tui/shell-cmd.ts +158 -0
  279. package/src/tui/smoke.ts +72 -0
  280. package/src/tui/staged-terminal.ts +50 -0
  281. package/src/tui/startup.ts +12 -0
  282. package/src/tui/theme.ts +59 -0
  283. package/src/tui/todo-label.ts +7 -0
  284. package/src/tui/trust-card.ts +107 -0
  285. package/src/tui/tui-commands.ts +87 -0
  286. package/tsconfig.json +30 -0
  287. package/vendor/pi-tui/LICENSE +21 -0
  288. package/vendor/pi-tui/PATCHES.md +12 -0
  289. package/vendor/pi-tui/PROVENANCE.md +12 -0
  290. package/vendor/pi-tui/README.upstream.md +854 -0
  291. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  292. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  293. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  294. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  295. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  296. package/vendor/pi-tui/src/components/box.ts +138 -0
  297. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  298. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  299. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  300. package/vendor/pi-tui/src/components/image.ts +128 -0
  301. package/vendor/pi-tui/src/components/input.ts +448 -0
  302. package/vendor/pi-tui/src/components/loader.ts +93 -0
  303. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  304. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  305. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  306. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  307. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  308. package/vendor/pi-tui/src/components/stack.ts +155 -0
  309. package/vendor/pi-tui/src/components/text.ts +108 -0
  310. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  311. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  312. package/vendor/pi-tui/src/editor-component.ts +75 -0
  313. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  314. package/vendor/pi-tui/src/index.ts +149 -0
  315. package/vendor/pi-tui/src/keybindings.ts +321 -0
  316. package/vendor/pi-tui/src/keys.ts +1402 -0
  317. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  318. package/vendor/pi-tui/src/latex.ts +1381 -0
  319. package/vendor/pi-tui/src/layout-node.ts +52 -0
  320. package/vendor/pi-tui/src/layout.ts +411 -0
  321. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  322. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  323. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  324. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  325. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  326. package/vendor/pi-tui/src/terminal.ts +554 -0
  327. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  328. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  329. package/vendor/pi-tui/src/tui.ts +1264 -0
  330. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  331. package/vendor/pi-tui/src/utils.ts +1327 -0
  332. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  333. package/vendor/pi-tui/test/test-themes.ts +39 -0
  334. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
@@ -0,0 +1,797 @@
1
+ /** design_audit's engine: the checks that read generated markup and styles and count the things a
2
+ * prompt rule forgets by turn six.
3
+ *
4
+ * REWORKED against docs/design-audit-calibration.md, which measured the previous version over 1,502
5
+ * files in 20 repos and 43 live sites. The headline result: the old slop checks did not separate good
6
+ * design from template slop. `cliche-font` fired on 86% of GOOD repos, `cliche-accent-amber` inverted
7
+ * (80% of sober good sites vs 35% slop), `rule-line-density` was within noise of itself at every
8
+ * threshold and every element floor (2% good vs 3% slop), and `all-square` fired on 330 files in repos
9
+ * that all use rounded corners somewhere. Meanwhile the four template repos the checker called CLEAN
10
+ * were textbook slop. Every threshold below cites the number that set it.
11
+ *
12
+ * Two structural changes carry most of the improvement:
13
+ *
14
+ * 1. SCOPE. Density and centring are properties of a PAGE, not of a component file. A bezel drawn with
15
+ * six borders is one figure on a page of 300 elements (0.02), not a 0.60 violation; the same
16
+ * vendored `components/ui/scroll-area.tsx` fired identically in three different repos. So those
17
+ * checks now sum a route file with the components it imports, and `all-square` is project-level:
18
+ * "no radius anywhere in the audited set", never per file.
19
+ *
20
+ * 2. KIND. Kent C. Dodds' yellow, Paco Coursey's Inter and Aristide Benoist's square corners are the
21
+ * SAME TOKENS as a template's yellow, Inter and squares. No count tells them apart; only the record
22
+ * does. So every check is either SLOP — meaning "no decision was made", which can only be asserted
23
+ * when .rovecode/design.json is absent — or DEVIATION, meaning "this contradicts what the human
24
+ * chose". A check that keeps firing after the human has decided is the failure this file's previous
25
+ * header called fatal, and it was committing it.
26
+ *
27
+ * What the calibration found actually discriminates, now checked here: `(md|lg):grid-cols-3` (12/13
28
+ * slop repos, 0/7 good — the strongest single number in the study), font LOAD sites rather than font
29
+ * mentions (`next/font/google` 6/13 slop, 0/7 good), and `lucide-react` (6/13 slop, 0/7 good). All
30
+ * three detect the ABSENCE of a decision and go silent the moment one is recorded. None of them names
31
+ * a colour, a face or a layout, so none of them is a default in disguise.
32
+ *
33
+ * Deliberately textual — it greps source, it does not parse a DOM or run a browser. It reports what is
34
+ * WRITTEN, misses what is computed at runtime, and can be fooled by indirection. Run it on a source
35
+ * tree; a fetched page measures the framework's build output, not the design (calibration §5: 7 of 43
36
+ * live sites arrived as SPA shells with under 60 elements). It is a smoke alarm, not a fire marshal;
37
+ * every finding names its evidence so a human can overrule. */
38
+
39
+ import { readFileSync } from "node:fs";
40
+ import type { DesignDirection } from "./direction.ts";
41
+
42
+ export type Severity = "high" | "med" | "low";
43
+
44
+ /** SLOP = "nobody decided this", assertable only with no direction recorded. DEVIATION = "this
45
+ * contradicts the recorded direction". The distinction is the whole rework: see the header. */
46
+ export type FindingKind = "slop" | "deviation";
47
+
48
+ export interface Finding {
49
+ /** stable kebab-case id, so a project can silence one check by name */
50
+ rule: string;
51
+ kind: FindingKind;
52
+ severity: Severity;
53
+ /** what is wrong, in one sentence */
54
+ message: string;
55
+ /** what was actually counted or matched — the reason a human can disagree */
56
+ evidence: string;
57
+ /** the file, the page (for page-scoped checks), or absent for project-scoped ones */
58
+ file?: string;
59
+ }
60
+
61
+ export interface AuditOptions {
62
+ file?: string;
63
+ direction?: DesignDirection | null;
64
+ /** rule ids to skip (the project decided the check does not apply) */
65
+ ignore?: readonly string[];
66
+ }
67
+
68
+ /** One source file for the project-scoped pass. */
69
+ export interface SourceFile {
70
+ path: string;
71
+ text: string;
72
+ }
73
+
74
+ // ---------- colour ----------
75
+
76
+ /** #rgb / #rrggbb -> {h,s,l} in degrees/percent, or null when it is not a hex colour. */
77
+ export function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
78
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
79
+ if (m === null) return null;
80
+ let h6 = m[1] as string;
81
+ if (h6.length === 3) h6 = h6.split("").map((c) => c + c).join("");
82
+ const r = parseInt(h6.slice(0, 2), 16) / 255;
83
+ const g = parseInt(h6.slice(2, 4), 16) / 255;
84
+ const b = parseInt(h6.slice(4, 6), 16) / 255;
85
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
86
+ const l = (max + min) / 2;
87
+ const d = max - min;
88
+ if (d === 0) return { h: 0, s: 0, l: l * 100 };
89
+ const s = d / (1 - Math.abs(2 * l - 1));
90
+ let h: number;
91
+ if (max === r) h = 60 * (((g - b) / d) % 6);
92
+ else if (max === g) h = 60 * ((b - r) / d + 2);
93
+ else h = 60 * ((r - g) / d + 4);
94
+ if (h < 0) h += 360;
95
+ return { h, s: s * 100, l: l * 100 };
96
+ }
97
+
98
+ const srgbToLinear = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
99
+
100
+ /** #rgb / #rrggbb -> OKLCH {l 0-1, c, h degrees}, or null. Perceptual, so a "hue family" means what the
101
+ * eye means by it: calibration §6.3 asks for palette membership in OKLCH precisely because exact-hex
102
+ * membership calls the chosen brand's own ramp off-palette. */
103
+ export function hexToOklch(hex: string): { l: number; c: number; h: number } | null {
104
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
105
+ if (m === null) return null;
106
+ let h6 = m[1] as string;
107
+ if (h6.length === 3) h6 = h6.split("").map((ch) => ch + ch).join("");
108
+ const r = srgbToLinear(parseInt(h6.slice(0, 2), 16) / 255);
109
+ const g = srgbToLinear(parseInt(h6.slice(2, 4), 16) / 255);
110
+ const b = srgbToLinear(parseInt(h6.slice(4, 6), 16) / 255);
111
+ const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
112
+ const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
113
+ const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
114
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
115
+ const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
116
+ const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
117
+ const c = Math.sqrt(a * a + bb * bb);
118
+ let h = (Math.atan2(bb, a) * 180) / Math.PI;
119
+ if (h < 0) h += 360;
120
+ return { l: L, c, h };
121
+ }
122
+
123
+ /** Below this OKLCH chroma a colour is doing neutral duty whatever its hue: greys, inks, papers and
124
+ * every tinted neutral ramp. Calibration §6.8: exact-saturation neutrality misfiled Tailwind's
125
+ * `gray-700 #374151` (HSL s 19) as a chromatic off-palette colour on every page that sets body text.
126
+ *
127
+ * Measured before choosing the number. Tinted neutrals: zinc-800 0.006, gray-500 0.023, gray-700 0.031,
128
+ * gray-900 0.032, slate-800 0.037, slate-600 0.037. Real colours: muted plum 0.043, navy #0b1a2e 0.045,
129
+ * brown 0.074, teal 0.096, green-800 0.108. The two bands genuinely OVERLAP between 0.037 and 0.045, so
130
+ * this is a judgement inside a grey zone, not a discovered boundary: 0.042 keeps Tailwind's slate ramp
131
+ * neutral while leaving site/'s own navy ink chromatic. Being wrong is cheap either way — a misfiled
132
+ * neutral contributes no hue family, and a misfiled colour contributes one the budget of 1 absorbs. */
133
+ export const NEUTRAL_CHROMA = 0.042;
134
+
135
+ /** 30-degree bins. Twelve families across the wheel: wide enough that a brand's tints, shades and
136
+ * hover state land in one family, narrow enough that a second brand colour lands in another. */
137
+ export function hueFamily(hex: string): number | null {
138
+ const c = hexToOklch(hex);
139
+ if (c === null || c.c < NEUTRAL_CHROMA) return null;
140
+ return Math.floor(c.h / 30) % 12;
141
+ }
142
+
143
+ const familyLabel = (f: number): string => `${f * 30}-${f * 30 + 30} deg`;
144
+
145
+ /** The amber/orange/gold band AI-generated sites reach for by reflex. Saturated and mid-light: a
146
+ * dark brown or a pale cream in the same hue range is not the cliche and is not flagged.
147
+ *
148
+ * The 20 deg floor is a decision, not an accident (nimbus-96 raised it on #b4431d, 2026-09-04).
149
+ * Measured: rust and terracotta sit at h 12-19 (#b4431d h 15, #c2410c h 17, #9a3412 h 15) and the
150
+ * reflex amber ramp at h 21-38 (#ea580c 21, #b45309 26, #d97706 32, #f59e0b 38). A rust is a colour
151
+ * someone reaches for on purpose — it is nobody's default — so the floor stays at 20 and all three
152
+ * rusts go unflagged, verified above. The cost is honest and accepted: an amber at exactly h 19 also
153
+ * escapes. This rule only ever fires when NO direction is recorded, and one wrong slop finding on a
154
+ * deliberate palette costs more trust than one missed cliche costs quality. Widening the floor to
155
+ * catch h 15-19 would flag every terracotta brand there is. */
156
+ export function isAmberish(hex: string): boolean {
157
+ const c = hexToHsl(hex);
158
+ if (c === null) return false;
159
+ return c.h >= 20 && c.h <= 55 && c.s >= 45 && c.l >= 35 && c.l <= 80;
160
+ }
161
+
162
+ const HEX_RE = /#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g;
163
+
164
+ /** HSL back to a hex string, so one colour pipeline serves every syntax a stylesheet writes. */
165
+ export function hslToHex(h: number, s: number, l: number): string {
166
+ const sn = Math.min(100, Math.max(0, s)) / 100;
167
+ const ln = Math.min(100, Math.max(0, l)) / 100;
168
+ const c = (1 - Math.abs(2 * ln - 1)) * sn;
169
+ const hp = (((h % 360) + 360) % 360) / 60;
170
+ const x = c * (1 - Math.abs((hp % 2) - 1));
171
+ const [r1, g1, b1] = hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
172
+ : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
173
+ const m = ln - c / 2;
174
+ const to = (v: number): string => Math.round((v + m) * 255).toString(16).padStart(2, "0");
175
+ return `#${to(r1!)}${to(g1!)}${to(b1!)}`;
176
+ }
177
+
178
+ /** Every colour a stylesheet DECLARES, normalised to hex.
179
+ *
180
+ * Hex alone is not enough. Measured on shadcn-ui/taxonomy (2026-09-04): a current Next.js + Tailwind
181
+ * project declares its entire palette as bare HSL triplets on custom properties — `--primary: 222.2
182
+ * 47.4% 11.2%` — and contains ZERO hex literals, so every colour rule here saw an empty document and
183
+ * `off-palette` could not fire at all. That convention is most of the ecosystem rovecode's users build
184
+ * in, so reading only `#rrggbb` made the colour half of the audit blind exactly where it is needed.
185
+ *
186
+ * Three forms are read: a hex literal, a custom property holding a bare `H S% L%` triplet (Tailwind's
187
+ * `hsl(var(--x))` convention), and a written `hsl()` / `hsla()` in either the comma or the space
188
+ * syntax. An alpha component is dropped — transparency is not a hue decision. Anything else (a
189
+ * `color-mix`, an `oklch()` literal, a value behind another variable) is still unread, and that is the
190
+ * documented limit rather than a silent one. */
191
+ export function declaredColours(text: string): string[] {
192
+ const out = new Set<string>();
193
+ for (const h of text.match(HEX_RE) ?? []) out.add(h.toLowerCase());
194
+ // `--token: 222.2 47.4% 11.2%` — the percent signs are what tell a colour from any other triplet
195
+ for (const m of text.matchAll(/--[\w-]+\s*:\s*(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%/g)) {
196
+ out.add(hslToHex(Number(m[1]), Number(m[2]), Number(m[3])));
197
+ }
198
+ // `hsl(222 47% 11%)`, `hsl(222, 47%, 11%)`, with or without an alpha
199
+ for (const m of text.matchAll(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%/gi)) {
200
+ out.add(hslToHex(Number(m[1]), Number(m[2]), Number(m[3])));
201
+ }
202
+ return [...out];
203
+ }
204
+
205
+ /** A colour that is doing neutral duty — grey, ink, paper. Never counted as an accent.
206
+ *
207
+ * The mid-tone bar is s < 20, widened from s < 12 on calibration §3.8: at 12, every Tailwind body-text
208
+ * colour between l 20 and l 92 (gray-700 #374151 is l 27 s 19) read as a chromatic off-palette colour.
209
+ * Very dark and very light colours keep the looser chroma bar they always had, because the standard
210
+ * neutral ramps are deliberately tinted (gray-900 #111827 is a blue-leaning near-black at s 39). */
211
+ export function isNeutral(hex: string): boolean {
212
+ const c = hexToHsl(hex);
213
+ if (c === null) return false;
214
+ if (c.s < 20 || c.l < 8 || c.l > 95) return true;
215
+ if (c.l < 20 && c.s < 45) return true; // tinted ink
216
+ return c.l > 92 && c.s < 30; // tinted paper
217
+ }
218
+
219
+
220
+ // ---------- typefaces ----------
221
+
222
+ /** Named webfonts that arrive when nobody chose a face. System stacks are NOT here: calibration §3.1
223
+ * found 100% of the good-site false positives involved one of `system-ui, -apple-system, Segoe UI,
224
+ * Arial, Helvetica, Helvetica Neue`, because every reset (Tailwind preflight, normalize) puts one in a
225
+ * fallback position or on a form control. They survive only as DEVIATION evidence, where a direction
226
+ * names a face and a file overrides it. */
227
+ export const CLICHE_FONTS: readonly string[] = [
228
+ "Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Poppins", "Nunito", "Source Sans Pro", "Raleway",
229
+ ];
230
+
231
+ /** The stacks that are fallbacks, not decisions. Deviation-only (see CLICHE_FONTS). */
232
+ export const SYSTEM_STACK_FONTS: readonly string[] = [
233
+ "system-ui", "-apple-system", "Segoe UI", "Arial", "Helvetica Neue", "Helvetica",
234
+ ];
235
+
236
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
237
+
238
+ /** Where a face is LOADED, not merely mentioned. Calibration §3.1: kentcdodds fired because a blog post
239
+ * mentions Inter in prose, shud.in because the OG-image renderer loads it for a social card, 11ty
240
+ * because of a fallback in `code.css`. A mention, a fallback position and a stack behind a webfont are
241
+ * all "not a decision"; an import or an @font-face is one. */
242
+ export function fontLoadSites(text: string): string[] {
243
+ const hits = new Set<string>();
244
+ for (const f of CLICHE_FONTS) {
245
+ const name = esc(f);
246
+ const spaced = name.replace(/\\?\s/g, "[\\s_+-]");
247
+ // 1. next/font/google: `import { Inter } from "next/font/google"` — 6/13 slop repos, 0/7 good
248
+ if (new RegExp("\\{[^}]*\\b" + name.replace(/\s/g, "_") + "\\b[^}]*\\}\\s*from\\s*[\"']next/font/google", "i").test(text)) hits.add(f);
249
+ // 2. @fontsource/inter, @fontsource-variable/inter
250
+ if (new RegExp("@fontsource(?:-variable)?/" + spaced.toLowerCase().replace(/\\s/g, "-"), "i").test(text)) hits.add(f);
251
+ // 3. a Google Fonts URL that asks for the family
252
+ if (new RegExp("family=" + spaced, "i").test(text)) hits.add(f);
253
+ // 4. @font-face { ... font-family: "X" ... } — the file itself defines the face
254
+ for (const block of text.match(/@font-face\s*\{[^}]*\}/gi) ?? []) {
255
+ if (new RegExp("font-family\\s*:\\s*[\"']?" + spaced, "i").test(block)) hits.add(f);
256
+ }
257
+ }
258
+ return [...hits];
259
+ }
260
+
261
+ /** CSS generic families and the keywords a font-family can legally lead with. None of these is a face,
262
+ * so none of them can be "named but not loaded". */
263
+ const GENERIC_FAMILIES: readonly string[] = [
264
+ "sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif",
265
+ "ui-monospace", "ui-rounded", "math", "emoji", "fangsong", "inherit", "initial", "unset", "revert",
266
+ "revert-layer", "none", "currentcolor",
267
+ ];
268
+
269
+ /** Every face this text actually LOADS, by name — the general form of fontLoadSites, which answers the
270
+ * same question for the cliché list only. Four load sites: a next/font/google import, an @fontsource
271
+ * package, a Google-Fonts `family=` URL, and an @font-face block that defines the face here. */
272
+ export function loadedFaceNames(text: string): string[] {
273
+ const hits = new Set<string>();
274
+ const put = (raw: string): void => {
275
+ const name = raw.trim().replace(/^["']|["']$/g, "").replace(/[_+-]+/g, " ").trim();
276
+ if (name.length > 0) hits.add(name.toLowerCase());
277
+ };
278
+ for (const m of text.matchAll(/\{([^}]*)\}\s*from\s*["']next\/font\/google/gi)) {
279
+ for (const ident of (m[1] ?? "").split(",")) put(ident.split(" as ")[0] ?? "");
280
+ }
281
+ for (const m of text.matchAll(/@fontsource(?:-variable)?\/([a-z0-9-]+)/gi)) put(m[1] ?? "");
282
+ for (const m of text.matchAll(/family=([^&"'`\s:;)]+)/gi)) put(m[1] ?? "");
283
+ for (const block of text.match(/@font-face\s*\{[^}]*\}/gi) ?? []) {
284
+ const m = /font-family\s*:\s*([^;}\n]+)/i.exec(block);
285
+ if (m) put((m[1] ?? "").split(",")[0] ?? "");
286
+ }
287
+ // next/font/local and a bare `src: url(...)` outside @font-face cannot name their face reliably;
288
+ // that is this rule's known blind spot, recorded in docs/design.md rather than guessed at here.
289
+ return [...hits];
290
+ }
291
+
292
+ /** Tailwind's `font-[…]` is overloaded: `font-[Sohne]` is a family but `font-[450]`, `font-[bold]`
293
+ * and `font-[italic]` are a WEIGHT or a style — Tailwind picks by data type. Measured on site/ during
294
+ * the 2026-09-04 review, where `font-[450]` on an accordion trigger was reported as an unloaded face
295
+ * called "450". A number, a weight keyword or a style keyword is never a family, in the shorthand or
296
+ * in a declaration. */
297
+ const NOT_A_FACE = /^(?:[\d.]+%?|bolder|lighter|bold|normal|medium|light|thin|black|heavy|semibold|extrabold|ultrabold|extralight|ultralight|book|regular|italic|oblique)$/i;
298
+
299
+ /** Faces this text NAMES: the leading family of each font-family declaration, plus Tailwind's
300
+ * `font-[Family_Name]` arbitrary value. Generic keywords and var()/theme() indirection are dropped —
301
+ * a family behind a custom property is not a name this rule can check. */
302
+ export function namedFaces(text: string): string[] {
303
+ const out: string[] = [];
304
+ const push = (raw: string): void => {
305
+ const name = raw.trim().replace(/^["']|["']$/g, "").replace(/_/g, " ").trim();
306
+ if (name.length === 0) return;
307
+ if (/^(?:var|theme|calc)\s*\(/i.test(name) || name.startsWith("--") || name.includes("$")) return;
308
+ if (GENERIC_FAMILIES.includes(name.toLowerCase())) return;
309
+ if (NOT_A_FACE.test(name)) return;
310
+ out.push(name);
311
+ };
312
+ for (const m of text.matchAll(/font-family\s*:\s*([^;}\n]+)/gi)) push((m[1] ?? "").split(",")[0] ?? "");
313
+ for (const m of text.matchAll(/\bfont-\[([^\]]+)\]/g)) push((m[1] ?? "").split(",")[0] ?? "");
314
+ return out;
315
+ }
316
+
317
+ /** The leading family of every `font-family:` declaration, plus Tailwind `font-\[...\]` arbitrary
318
+ * values. Used for the DEVIATION check only: with a face recorded, a file that sets a different
319
+ * leading family on its own text is contradicting the record. */
320
+ export function leadingFamilies(text: string): string[] {
321
+ const out: string[] = [];
322
+ for (const m of text.matchAll(/font-family\s*:\s*([^;}\n]+)/gi)) {
323
+ const first = (m[1] ?? "").split(",")[0]?.trim().replace(/^["']|["']$/g, "");
324
+ if (first !== undefined && first.length > 0) out.push(first);
325
+ }
326
+ return out;
327
+ }
328
+
329
+ // ---------- counting helpers ----------
330
+
331
+ const count = (text: string, re: RegExp): number => (text.match(re) ?? []).length;
332
+
333
+ /** Rough element count: opening HTML/JSX tags. The denominator for every density check. */
334
+ export function elementCount(text: string): number {
335
+ return count(text, /<[a-zA-Z][\w.:-]*/g);
336
+ }
337
+
338
+ const RULE_LINE_RE = /\bborder(?:-[trbl])?(?:-\d+)?\b(?!-(?:none|0|transparent))|\bdivide-[xy]\b|<hr\b|border-(?:top|bottom|left|right)\s*:(?!\s*(?:none|0))/g;
339
+ const RADIUS_RE = /\brounded(?:-(?:sm|md|lg|xl|2xl|3xl|full|t|b|l|r|tl|tr|bl|br))?\b|border-radius\s*:\s*(?!0)/g;
340
+ const CENTER_RE = /\btext-center\b|\bitems-center\b|\bjustify-center\b|\bmx-auto\b|\bplace-items-center\b|text-align\s*:\s*center|margin\s*:\s*0\s+auto/g;
341
+ const VIEWPORT_RE = /\bmin-h-screen\b|\bh-screen\b|(?:min-)?height\s*:\s*100[dsl]?vh/;
342
+ /** The three-up feature grid, only at a breakpoint — a plain `grid-cols-3` is a layout primitive, the
343
+ * responsive form is the template idiom. 12/13 slop repos, 0/7 good (calibration §2). */
344
+ const TEMPLATE_GRID_RE = /\b(?:md|lg):grid-cols-3\b/;
345
+ const DECOR_RE = /\b(?:linear|radial|conic)-gradient\b|\bbg-gradient-to\b|background-image\s*:|\bbg-\[url\(|\btransition-(?:all|colors|transform|opacity)\b|\banimate-[a-z]|@keyframes\b|\banimation\s*:|\bbefore:|\bafter:|::(?:before|after)|\bbackdrop-blur\b|\bdrop-shadow\b/g;
346
+
347
+ // ---------- file kinds ----------
348
+
349
+ const norm = (p: string): string => p.replace(/\\/g, "/").toLowerCase();
350
+
351
+ /** Route files: a page is what a reader loads. Next app/pages routers, Astro/Nuxt/SvelteKit pages,
352
+ * plain HTML. These are the units density and centring are scored over (calibration §3.4, §3.6). */
353
+ export function isRouteFile(path: string): boolean {
354
+ const p = norm(path);
355
+ return /(?:^|\/)app\/.*\/page\.[jt]sx?$/.test(p)
356
+ || /(?:^|\/)app\/page\.[jt]sx?$/.test(p)
357
+ || /(?:^|\/)pages\/(?!api\/)/.test(p)
358
+ || /(?:^|\/)routes\/.*\+page\.svelte$/.test(p)
359
+ || /\.html?$/.test(p);
360
+ }
361
+
362
+ /** Sections are the page's own blocks — the other place the template grid shows up. */
363
+ export function isPageOrSection(path: string): boolean {
364
+ const p = norm(path);
365
+ return isRouteFile(path) || /(?:^|\/)(?:sections?|blocks?)\//.test(p) || /(?:hero|features?|pricing|testimonial|cta|footer|header)[^/]*\.(?:[jt]sx|astro|vue|svelte)$/.test(p);
366
+ }
367
+
368
+ /** Vendored primitives. Calibration §3.4.2: 4 of 12 slop density hits were `components/ui/**` and none
369
+ * of them was a design decision — the identical shadcn `scroll-area.tsx` fired in three repos. */
370
+ export function isUiPrimitive(path: string): boolean {
371
+ return /(?:^|\/)components\/ui\//.test(norm(path));
372
+ }
373
+
374
+ /** Page types where centring and a full-height wrapper are CORRECT. Calibration §3.6.1: exempting these
375
+ * removes 17 of 24 centring fires, 14 of them in the slop corpus, i.e. it costs recall the rule never
376
+ * had. §3.7: 9 of 10 `reflex-hero` fires were the sticky-footer wrapper on exactly these files. */
377
+ export function isCentringExempt(path: string): boolean {
378
+ // ANY segment, not just the last: the exempt name is `login` in `app/login/page.tsx`, where the final
379
+ // segment is the router's own `page`. Matching only the tail missed every Next app-router auth route.
380
+ const segments = norm(path).replace(/\.[a-z]+$/, "").split("/");
381
+ return segments.some((s) => /(?:^|-|_)(?:login|register|signin|signup|auth|404|not-found|error|loading|empty|placeholder|tooltip|dialog|modal|toast|announcement|layout)(?:$|-|_)/.test(s));
382
+ }
383
+
384
+ /** Prose and generated-image files: a face named here is not the site's face. Calibration §3.1.3 —
385
+ * 3 of 9 good-repo font hits were exactly an .mdx post and an opengraph-image renderer. */
386
+ export function isProseOrGenerated(path: string): boolean {
387
+ const p = norm(path);
388
+ return /\.mdx?$/.test(p) || /opengraph-image|twitter-image|(?:^|\/)og\/route\./.test(p);
389
+ }
390
+
391
+ // ---------- accent positions ----------
392
+
393
+ /** Strip the places a colour is not an accent: SVG payloads (Stripe's Google logo), data URIs, and
394
+ * syntax-highlight scopes. Calibration §3.2.3, §3.3. */
395
+ function stripNonAccent(text: string): string {
396
+ return text
397
+ .replace(/<svg[\s\S]*?<\/svg>/gi, " ")
398
+ .replace(/data:image\/[^"')\s]+/gi, " ")
399
+ .replace(/(?:\.hljs|\.shiki|\.token|pre|code)\s*[^{]*\{[^}]*\}/gi, " ");
400
+ }
401
+
402
+ const WARNING_CTX = /warn|warning|caution|alert|danger|error|status|badge|pending|highlight|mark|star|rating/i;
403
+
404
+ /** Amber in a position that MEANS accent: a brand/accent token, a button or link background, a heading
405
+ * colour, a hero gradient stop. Calibration §3.2: the old absolute count over a whole document made the
406
+ * rule measure stylesheet size, inverting it to 80% of sober good sites vs 35% of slop. */
407
+ export function amberAccentPositions(text: string): string[] {
408
+ const src = stripNonAccent(text);
409
+ const hits: string[] = [];
410
+ const near = (i: number): string => src.slice(Math.max(0, i - 90), i + 90);
411
+ // ROLE tokens only: `--accent`, `--brand`, `--color-primary`. A token named after the colour itself
412
+ // (`--color-team-yellow`) is a palette entry, not an accent assignment — kentcdodds.com declares
413
+ // exactly that for a brand yellow he has kept for years (calibration §3.2), and reading it as "the
414
+ // accent" is the checker guessing at intent it cannot see.
415
+ for (const m of src.matchAll(/--(?:color-)?(?:primary|accent|brand)\b\s*:\s*([^;}\n]+)/gi)) {
416
+ const val = m[1] ?? "";
417
+ const hex = declaredColours(val).find(isAmberish);
418
+ if (hex !== undefined && !WARNING_CTX.test(m[0])) hits.push(`${m[0].split(":")[0]?.trim()}: ${hex}`);
419
+ }
420
+ // Tailwind utilities in accent positions. The 300-700 band is the same window isAmberish applies to a
421
+ // hex (l 35-80): amber-800/900 are dark browns, and counting a class the hex path would reject made
422
+ // the two halves of this rule disagree — sindresorhus's `bg-amber-900` warning box was the case.
423
+ for (const m of src.matchAll(/\b(?:bg|text|from|border)-(?:amber|orange|yellow)-(?:[3-7]00)\b/gi)) {
424
+ const ctx = near(m.index);
425
+ if (WARNING_CTX.test(ctx)) continue;
426
+ if (/\b(?:button|btn|<a\b|link|cta|hero|h1|h2)\b/i.test(ctx) || /^(?:bg|from)-/i.test(m[0])) hits.push(m[0]);
427
+ }
428
+ return [...new Set(hits)];
429
+ }
430
+
431
+ /** How many distinct saturated hue families the document declares. Calibration §3.2.2: a document that
432
+ * ships a whole palette (Vercel, tailwindcss.com's colour page, Sentry, fly.io) has amber as one swatch
433
+ * among many, not as the accent. */
434
+ export function saturatedFamilies(text: string): number {
435
+ const fams = new Set<number>();
436
+ for (const h of declaredColours(stripNonAccent(text))) {
437
+ const f = hueFamily(h);
438
+ if (f !== null) fams.add(f);
439
+ }
440
+ return fams.size;
441
+ }
442
+
443
+ // ---------- file-scope checks ----------
444
+
445
+ /** Audit one source file's text. Pure: no disk, no network.
446
+ *
447
+ * FILE SCOPE ONLY. Density, centring and all-square are page- and project-scoped after the calibration
448
+ * (see the header) and live in auditProject; calling this on one file will not produce them. */
449
+ export function auditSource(text: string, opts: AuditOptions = {}): Finding[] {
450
+ const { direction = null, ignore = [], file } = opts;
451
+ const out: Finding[] = [];
452
+ const path = file ?? "";
453
+ const add = (rule: string, kind: FindingKind, severity: Severity, message: string, evidence: string): void => {
454
+ if (ignore.includes(rule)) return;
455
+ out.push({ rule, kind, severity, message, evidence, ...(file !== undefined ? { file } : {}) });
456
+ };
457
+ const decided = direction !== null;
458
+
459
+ // ---- f1 fonts: a face that was LOADED without being chosen ----
460
+ if (!isProseOrGenerated(path)) {
461
+ const loaded = fontLoadSites(text);
462
+ const chosen = Object.values(direction?.typeface ?? {}).map((f) => f.toLowerCase());
463
+ const unchosen = loaded.filter((f) => !chosen.includes(f.toLowerCase()));
464
+ if (unchosen.length > 0) {
465
+ if (!decided) {
466
+ // med, not high: 6 of 7 good repos would otherwise open with a high (calibration §3.1.4)
467
+ add("cliche-font", "slop", "med",
468
+ "A default webfont is loaded and nothing records that anyone chose it. The typeface is half the personality of a page; pick one for a reason you can state, then record it.",
469
+ `loaded at a font import or @font-face: ${unchosen.join(", ")}`);
470
+ } else if (direction?.typeface !== undefined) {
471
+ add("font-deviation", "deviation", "med",
472
+ "A typeface is loaded that is not the one this project recorded.",
473
+ `loaded: ${unchosen.join(", ")}; recorded: ${Object.values(direction.typeface).join(", ")}`);
474
+ }
475
+ }
476
+ // system stacks are deviation-only: they are fallbacks everywhere, and a decision nowhere
477
+ if (direction?.typeface !== undefined) {
478
+ const sys = leadingFamilies(text).filter((f) => SYSTEM_STACK_FONTS.some((s) => s.toLowerCase() === f.toLowerCase()));
479
+ if (sys.length > 0 && !chosen.some((c) => sys.some((s) => s.toLowerCase() === c))) {
480
+ add("font-deviation", "deviation", "low",
481
+ "A font-family declaration leads with a system stack while this project records a chosen face.",
482
+ `${[...new Set(sys)].join(", ")} leads a font-family here; recorded: ${Object.values(direction.typeface).join(", ")}`);
483
+ }
484
+ }
485
+ }
486
+
487
+ // ---- f2 amber in accent positions ----
488
+ const warmChosen = Object.values(direction?.palette ?? {}).some(isAmberish);
489
+ if (!warmChosen) {
490
+ const positions = amberAccentPositions(text);
491
+ // a full palette makes amber one swatch among many, not the accent (calibration §3.2.2)
492
+ if (positions.length > 0 && saturatedFamilies(text) < 5) {
493
+ const shown = positions.slice(0, 5).join(", ");
494
+ if (!decided) {
495
+ add("cliche-accent-amber", "slop", "med",
496
+ "Amber/orange is doing accent duty and nothing records that it was chosen. It is the reflex accent of generated interfaces; pick one that belongs to this product.",
497
+ `${positions.length} accent position${positions.length === 1 ? "" : "s"} (${shown})`);
498
+ } else {
499
+ add("accent-deviation", "deviation", "med",
500
+ "Amber/orange is used as an accent and it is not in this project's recorded palette.",
501
+ `${positions.length} accent position${positions.length === 1 ? "" : "s"} (${shown})`);
502
+ }
503
+ }
504
+ }
505
+
506
+ // ---- f3 the template grid: three equal cards ----
507
+ // 12/13 slop repos, 0/7 good — the strongest single signal in the calibration (§2). Silent the moment
508
+ // a layout is recorded, because then the grid is a choice and off-layout work is a deviation question.
509
+ if (direction?.layout === undefined && isPageOrSection(path) && TEMPLATE_GRID_RE.test(text)) {
510
+ add("template-grid", "slop", "low",
511
+ "Three equal cards at a breakpoint is the feature grid every template ships. Is this the layout the content wants, or the one the starter had?",
512
+ "md|lg:grid-cols-3 in a page/section file");
513
+ }
514
+
515
+ // ---- f4 the icon-per-card template marker ----
516
+ // 6/13 slop repos, 0/7 good (calibration §2). A marker, not a fault: lowest severity, and silent once
517
+ // anything is recorded, because a project that decided its look may legitimately use an icon set.
518
+ if (!decided && /from\s*["']lucide-react["']/.test(text)) {
519
+ add("template-icons", "slop", "low",
520
+ "lucide-react is imported and nothing records a design direction. It is the icon set of the shadcn landing-page template; it is fine as a choice and a tell as a default.",
521
+ "import from \"lucide-react\" with no design.json");
522
+ }
523
+
524
+ // ---- f5 the reflex hero ----
525
+ // KEPT rather than deleted (calibration §3.7 offered either), but only in its specific form: 9 of its
526
+ // 10 fires were a sticky-footer wrapper on a login/404 page, and all three added conditions —
527
+ // centring in the same element's class list, an h1 in the window, a page/section file that is not an
528
+ // exempt name — are exactly what separated those from the one true hero.
529
+ if (isPageOrSection(path) && !isCentringExempt(path)) {
530
+ const vp = VIEWPORT_RE.exec(text);
531
+ if (vp !== null) {
532
+ // a fresh non-global copy: CENTER_RE carries /g, and .test() on a /g regex advances lastIndex, so
533
+ // sharing it here made the answer depend on whatever the previous call happened to match
534
+ const attr = text.slice(Math.max(0, vp.index - 200), vp.index + 200);
535
+ const centredHere = new RegExp(CENTER_RE.source, "i").test(attr);
536
+ if (centredHere && /<h1\b/i.test(text.slice(vp.index, vp.index + 1500))) {
537
+ add("reflex-hero", direction?.heroPattern === undefined ? "slop" : "deviation", "low",
538
+ "A full-viewport centred first screen with a headline in it. A hero costs the reader a whole screen; keep the height only when an image or an idea earns it.",
539
+ `${vp[0]} centred in the same element, with an <h1> within 1500 characters`);
540
+ }
541
+ }
542
+ }
543
+
544
+ // ---- f6 the purple-to-blue gradient ----
545
+ // The SIGNATURE is the pair, not a violet stop somewhere: the old hex form fired on 53% of sober good
546
+ // sites (syntax themes, a dark-mode glow, a progress bar) vs 12% of slop (calibration §3.3).
547
+ const clean = stripNonAccent(text);
548
+ const gradTw = /from-(?:purple|violet|indigo|fuchsia)-\d00[\s\S]{0,80}?to-(?:blue|pink|cyan|indigo|purple)-\d00/.test(clean);
549
+ const gradCss = (clean.match(/linear-gradient\([^)]*\)/g) ?? []).some((g) => {
550
+ const stops = declaredColours(g).map(hexToHsl).filter((c): c is { h: number; s: number; l: number } => c !== null && c.s >= 40);
551
+ return stops.some((a) => a.h >= 250 && a.h <= 290) && stops.some((b) => b.h >= 180 && b.h <= 330) && stops.length >= 2;
552
+ });
553
+ if (gradTw || gradCss) {
554
+ add("cliche-gradient", decided ? "deviation" : "slop", "low",
555
+ "A violet-to-blue gradient pair. It is the most recognisable generated-template signature there is.",
556
+ gradTw ? "tailwind from-violet/to-blue pair" : "linear-gradient with a violet stop and a second saturated stop");
557
+ }
558
+
559
+ // ---- f7 off-palette, in OKLCH hue families ----
560
+ // Exact-hex membership called the chosen brand's own ramp off-palette (nimbus-ed's probe 1: six of the
561
+ // six "off-palette" colours WERE the recorded brand's tints). Families let a tint, a shade and a hover
562
+ // state belong to the colour they came from. Budget of 1: semantic states (a red, a green) are not a
563
+ // second brand (design-slop-research §6.2).
564
+ if (direction?.palette !== undefined) {
565
+ const chosen = new Set<number>();
566
+ for (const v of Object.values(direction.palette)) {
567
+ // the human may have recorded "hsl(210 40% 96%)" as readily as a hex
568
+ for (const c of declaredColours(v)) { const f = hueFamily(c); if (f !== null) chosen.add(f); }
569
+ }
570
+ const seen = new Map<number, string[]>();
571
+ for (const h of declaredColours(stripNonAccent(text))) {
572
+ const f = hueFamily(h);
573
+ if (f === null || chosen.has(f)) continue;
574
+ seen.set(f, [...(seen.get(f) ?? []), h]);
575
+ }
576
+ if (seen.size > 1) {
577
+ const shown = [...seen.entries()].map(([f, hs]) => `${familyLabel(f)} (${hs.slice(0, 3).join(", ")})`).join("; ");
578
+ add("off-palette", "deviation", "med",
579
+ "Colours from hue families outside the recorded palette. One extra family is a semantic state; more than one is a second palette.",
580
+ `${seen.size} extra hue families: ${shown}`);
581
+ }
582
+ }
583
+
584
+ // ---- f9 decoration density against a record that asked for restraint ----
585
+ // The one rule here with NO corpus number behind it: it comes from eight rounds of rejections, not
586
+ // from the calibration sweep. It is therefore gated twice — it needs a recorded direction AND that
587
+ // record must ask for restraint in its own words — so it cannot fire on a project that did not ask.
588
+ if (direction !== null && wantsRestraint(direction)) {
589
+ const els = elementCount(text);
590
+ const decor = count(text, DECOR_RE);
591
+ if (els >= 10 && decor / els > 0.6) {
592
+ add("decoration-density", "deviation", "low",
593
+ "More decoration than the recorded direction asks for: gradients, background images, ornament and motion, counted against the elements that carry them.",
594
+ `${decor} decorative declarations across ~${els} elements (${(decor / els).toFixed(2)} per element); the record asks for restraint`);
595
+ }
596
+ }
597
+
598
+ return out;
599
+ }
600
+
601
+ /** Does the recorded direction ask for restraint, in its own words? Read from `notes` and `rationale`
602
+ * because those are where a human says it; English and Turkish, since this project is written in both. */
603
+ export function wantsRestraint(d: DesignDirection): boolean {
604
+ const said = `${d.notes ?? ""} ${d.rationale ?? ""}`.toLowerCase();
605
+ return /restrain|minimal|sober|quiet|austere|understated|plain|calm|spare|no decoration|sade|yal[ıi]n|sakin|az\b|g[öo]sterissiz/.test(said);
606
+ }
607
+
608
+ // ---------- page and project scope ----------
609
+
610
+ /** The layout files a route is WRAPPED in, which it never imports.
611
+ *
612
+ * Next.js and the routers that copy it nest `layout.tsx` implicitly: `app/(docs)/guides/page.tsx` renders
613
+ * inside `app/(docs)/guides/layout.tsx`, then `app/(docs)/layout.tsx`, then `app/layout.tsx`, and imports
614
+ * none of them. Measured on shadcn-ui/taxonomy (2026-09-04): the chain carries 8-24 elements per route and
615
+ * on four of its fourteen routes it is LARGER than the page file — the settings page is 4 elements of its
616
+ * own inside 17 of layout. That is where a site's nav, footer and section rules live, so scoring "the page"
617
+ * without it measured the smaller and quieter half and called it the page.
618
+ *
619
+ * Astro, SvelteKit and the rest import their layouts explicitly, so `importsOf` already has them; this only
620
+ * adds what the convention hides. Only files actually passed to the audit are used — nothing is read from
621
+ * disk here. */
622
+ function layoutChain(page: SourceFile, byPath: ReadonlyMap<string, SourceFile>): SourceFile[] {
623
+ const out: SourceFile[] = [];
624
+ let dir = norm(page.path).replace(/\/[^/]*$/, "");
625
+ for (;;) {
626
+ for (const ext of ["tsx", "jsx", "ts", "js"]) {
627
+ const f = byPath.get(dir === "" ? `layout.${ext}` : `${dir}/layout.${ext}`);
628
+ if (f !== undefined && f.path !== page.path) { out.push(f); break; }
629
+ }
630
+ if (dir === "") break;
631
+ dir = dir.includes("/") ? dir.replace(/\/[^/]*$/, "") : "";
632
+ }
633
+ return out;
634
+ }
635
+
636
+ /** Which audited files a page pulls in. Relative specifiers are resolved against the importer; alias
637
+ * forms (`@/x`, `~/x`, `src/x`) are matched by path suffix. One level deep, which is what the
638
+ * calibration's page-scoping recommendation needs (§3.4.1) and keeps this from walking a whole graph. */
639
+ function importsOf(file: SourceFile, byPath: ReadonlyMap<string, SourceFile>): SourceFile[] {
640
+ const dir = norm(file.path).replace(/\/[^/]*$/, "");
641
+ const out: SourceFile[] = [];
642
+ for (const m of file.text.matchAll(/\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']/g)) {
643
+ const spec = (m[1] ?? m[2] ?? "").trim();
644
+ if (spec === "" || /^[a-z@][^/]*$/i.test(spec)) continue; // bare package
645
+ let base = spec.replace(/^[@~]\//, "").replace(/^\.\//, dir === "" ? "" : dir + "/");
646
+ if (spec.startsWith("../")) {
647
+ const up = spec.match(/^(?:\.\.\/)+/)?.[0] ?? "";
648
+ const levels = up.split("../").length - 1;
649
+ base = dir.split("/").slice(0, Math.max(0, dir.split("/").length - levels)).concat(spec.slice(up.length)).join("/");
650
+ }
651
+ const want = norm(base).replace(/\.[a-z]+$/, "");
652
+ for (const [p, f] of byPath) {
653
+ const stem = p.replace(/\.[a-z]+$/, "").replace(/\/index$/, "");
654
+ if (stem === want || stem.endsWith("/" + want) || p.replace(/\.[a-z]+$/, "").endsWith("/" + want)) { out.push(f); break; }
655
+ }
656
+ }
657
+ return out;
658
+ }
659
+
660
+ /** Audit a whole set of files: file-scope checks per file, density and centring per PAGE, all-square
661
+ * once for the project. This is what design_audit runs; auditSource alone cannot produce the scoped
662
+ * findings, by design (see the header). */
663
+ export function auditProject(files: readonly SourceFile[], opts: Omit<AuditOptions, "file"> = {}): Finding[] {
664
+ const { direction = null, ignore = [] } = opts;
665
+ const out: Finding[] = [];
666
+ const add = (rule: string, kind: FindingKind, severity: Severity, message: string, evidence: string, file?: string): void => {
667
+ if (ignore.includes(rule)) return;
668
+ out.push({ rule, kind, severity, message, evidence, ...(file !== undefined ? { file } : {}) });
669
+ };
670
+
671
+ for (const f of files) out.push(...auditSource(f.text, { ...opts, file: f.path }));
672
+
673
+ const byPath = new Map(files.map((f) => [norm(f.path), f]));
674
+ // A single PATHLESS input (design_audit's `source` mode passes the synthetic name "source") is one
675
+ // page, so pasted markup still gets the page-scoped checks. A lone file WITH a directory in its path
676
+ // is a component and is not promoted to a page: that promotion is exactly the per-file scoring the
677
+ // calibration removed (§3.4 — a bezel scored 0.60 alone and 0.02 inside the page it belongs to).
678
+ const pages = files.filter((f) => isRouteFile(f.path));
679
+ const asPages = pages.length > 0 ? pages
680
+ : files.length === 1 && !norm(files[0]!.path).includes("/") ? files
681
+ : [];
682
+
683
+ for (const page of asPages) {
684
+ if (isCentringExempt(page.path)) continue;
685
+ const chain = layoutChain(page, byPath);
686
+ const parts = [page, ...chain, ...importsOf(page, byPath), ...chain.flatMap((l) => importsOf(l, byPath))]
687
+ .filter((f, i, a) => a.findIndex((x) => x.path === f.path) === i && !isUiPrimitive(f.path));
688
+ const text = parts.map((f) => f.text).join("\n");
689
+ const els = elementCount(text);
690
+ if (els < 10) continue;
691
+ const others = parts.length - 1;
692
+ const scope = others > 0
693
+ ? `${page.path} + ${others} file${others === 1 ? "" : "s"} it renders inside or imports${chain.length > 0 ? ` (incl. ${chain.length} layout${chain.length === 1 ? "" : "s"})` : ""}`
694
+ : page.path;
695
+
696
+ // Threshold stays 0.4. The calibration swept it per FILE and found good and slop within noise at
697
+ // every value (2% vs 3%, §3.4) — the fix was scope, not the number, and at page scope a framed
698
+ // component is diluted by the page around it instead of scored on its own.
699
+ const lines = count(text, RULE_LINE_RE);
700
+ if (lines / els > 0.4) {
701
+ add("rule-line-density", direction === null ? "slop" : "deviation", "med",
702
+ "Across this page almost everything is separated by a drawn line. Separation reads better from spacing, weight and background than from hairlines.",
703
+ `${lines} border/divider declarations across ~${els} elements (${(lines / els).toFixed(2)} per element) over ${scope}`, page.path);
704
+ }
705
+
706
+ // 0.45 as before, now over a page and with the exempt names removed: on the corpus those two changes
707
+ // took the rule from 24 fires (17 of them correct centring) to the 4 marketing pages that are the
708
+ // actual complaint (calibration §3.6).
709
+ if (direction?.layout === undefined) {
710
+ const centred = count(text, CENTER_RE);
711
+ if (centred / els > 0.45) {
712
+ add("everything-centered", "slop", "med",
713
+ "Nearly every block on this page is centred. Centring everything removes the alignment edge the eye follows down the page and flattens the hierarchy.",
714
+ `${centred} centring declarations across ~${els} elements (${(centred / els).toFixed(2)} per element) over ${scope}`, page.path);
715
+ }
716
+ }
717
+ }
718
+
719
+ // Project scope, and deterministic: a face named in a font-family that nothing in the audited set
720
+ // LOADS renders as its fallback. That is not a taste call — the page does not look the way the code
721
+ // says it does, and if the name is the recorded face the record is describing a page that is not
722
+ // there. Project-scoped because the load site is usually a layout or a global stylesheet, not the
723
+ // file that names the face; pass those in or this rule cannot see them (docs/design.md).
724
+ {
725
+ const loaded = new Set(files.flatMap((f) => loadedFaceNames(f.text)));
726
+ const chosen = Object.values(direction?.typeface ?? {}).map((f) => f.toLowerCase());
727
+ const seen = new Set<string>();
728
+ for (const f of files) {
729
+ if (isProseOrGenerated(f.path)) continue;
730
+ for (const face of namedFaces(f.text)) {
731
+ const key = face.toLowerCase();
732
+ if (seen.has(key)) continue;
733
+ if (loaded.has(key)) continue;
734
+ // a system stack is a fallback by definition — it needs no load site and never fires here
735
+ if (SYSTEM_STACK_FONTS.some((sys) => sys.toLowerCase() === key)) continue;
736
+ seen.add(key);
737
+ const isChosen = chosen.includes(key);
738
+ add("font-named-not-loaded", isChosen ? "deviation" : "slop", "low",
739
+ isChosen
740
+ ? `"${face}" is the face this project recorded, but nothing in the audited set loads it — the page renders its fallback, so the recorded direction is not what a reader sees.`
741
+ : `"${face}" is named in a font-family but nothing in the audited set loads it (no @font-face, next/font import, @fontsource package or Google-Fonts URL). It renders as the fallback, which is nobody's decision. Load it, or drop the name.`,
742
+ `named in ${f.path}; no load site across ${files.length} audited file${files.length === 1 ? "" : "s"}`, f.path);
743
+ }
744
+ }
745
+ }
746
+
747
+ // Project scope. Per file this fired on 330 files across repos that ALL use rounded corners somewhere
748
+ // (calibration §3.5) — a component with no radius is not a design statement, a whole project with none
749
+ // is. Recording corners "sharp" silences it, which is the point.
750
+ if (direction?.corners !== "sharp" && files.length > 0) {
751
+ const totalEls = files.reduce((n, f) => n + elementCount(f.text), 0);
752
+ const anyRadius = files.some((f) => count(f.text, RADIUS_RE) > 0);
753
+ if (totalEls >= 40 && !anyRadius) {
754
+ add("all-square", "slop", "low",
755
+ "Not one rounded corner anywhere in the audited set. Square everything is a legitimate choice; if it was chosen, record corners \"sharp\" so this stops being a finding.",
756
+ `0 radius declarations across ${files.length} files (~${totalEls} elements)`);
757
+ }
758
+ }
759
+
760
+ return out;
761
+ }
762
+
763
+ /** Audit files from disk. An unreadable file becomes a finding rather than an exception, so one bad
764
+ * path never costs the caller the other results. */
765
+ export function auditFiles(paths: readonly string[], opts: Omit<AuditOptions, "file"> = {}): Finding[] {
766
+ const out: Finding[] = [];
767
+ const files: SourceFile[] = [];
768
+ for (const p of paths) {
769
+ try { files.push({ path: p, text: readFileSync(p, "utf8") }); }
770
+ catch (e) {
771
+ out.push({ rule: "unreadable", kind: "slop", severity: "low", message: "could not read the file", evidence: (e as Error).message, file: p });
772
+ }
773
+ }
774
+ out.push(...auditProject(files, opts));
775
+ return out;
776
+ }
777
+
778
+ const ORDER: Record<Severity, number> = { high: 0, med: 1, low: 2 };
779
+
780
+ /** A provisional direction is checked exactly like a chosen one — but "consistent with the direction"
781
+ * must not read as "the human approved this". The suffix says which of the two it is, every time. */
782
+ function provisionalNote(d: DesignDirection): string {
783
+ return d.provisional === true ? " (provisional direction — recorded by the agent, not yet confirmed by a human)" : "";
784
+ }
785
+
786
+ /** Findings as the model reads them: worst first, evidence attached, no finding without a reason. */
787
+ export function formatFindings(findings: readonly Finding[], direction: DesignDirection | null): string {
788
+ if (findings.length === 0) {
789
+ return direction === null
790
+ ? "No design findings. Note: this project has recorded no design direction, so only the \"nobody decided\" checks ran — once the human has chosen a direction, record it with design_direction and later screens get consistency checks too."
791
+ : `No design findings; consistent with the recorded direction "${direction.name}"${provisionalNote(direction)}.`;
792
+ }
793
+ const sorted = [...findings].sort((a, b) => ORDER[a.severity] - ORDER[b.severity]);
794
+ const head = `${findings.length} design finding${findings.length === 1 ? "" : "s"}${direction === null ? " (no direction recorded — \"nobody decided\" checks only, no consistency checks)" : ` against "${direction.name}"${provisionalNote(direction)}`}:`;
795
+ const body = sorted.map((f) => `- [${f.severity}] ${f.rule}${f.kind === "deviation" ? " (deviation)" : ""}${f.file !== undefined ? ` (${f.file})` : ""}: ${f.message}\n evidence: ${f.evidence}`);
796
+ return [head, ...body].join("\n");
797
+ }