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.
- package/LICENSE +662 -0
- package/README.md +737 -0
- package/THIRD_PARTY_NOTICES.md +268 -0
- package/bin/rovecode.ts +21 -0
- package/package.json +56 -0
- package/src/acp/server.ts +374 -0
- package/src/cli/auth-login.ts +122 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +82 -0
- package/src/cli/doctor.ts +362 -0
- package/src/cli/export.ts +276 -0
- package/src/cli/help.ts +293 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +583 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-login.ts +141 -0
- package/src/cli/mcp-market-cmd.ts +302 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +250 -0
- package/src/cli/repomap-root.ts +14 -0
- package/src/cli/resume.ts +57 -0
- package/src/cli/run-flags.ts +43 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +931 -0
- package/src/cli/session-arg.ts +30 -0
- package/src/cli/sessions-cmd.ts +145 -0
- package/src/cli/setup.ts +153 -0
- package/src/cli/skills-cmd.ts +194 -0
- package/src/cli/start-chat.ts +65 -0
- package/src/cli/trust-cmd.ts +52 -0
- package/src/coding/bash.ts +148 -0
- package/src/coding/checkpoints.ts +327 -0
- package/src/coding/diff.ts +138 -0
- package/src/coding/files.ts +341 -0
- package/src/coding/hashline.ts +274 -0
- package/src/coding/lsp-gate.ts +254 -0
- package/src/coding/lsp-servers.ts +147 -0
- package/src/coding/lsp.ts +283 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +192 -0
- package/src/coding/repomap.ts +481 -0
- package/src/core/agents.ts +255 -0
- package/src/core/compaction.ts +259 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +454 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +411 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +266 -0
- package/src/core/loop.ts +567 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +245 -0
- package/src/core/proc-group.ts +48 -0
- package/src/core/project-trust.ts +98 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +186 -0
- package/src/core/session-id.ts +24 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session-ops.ts +183 -0
- package/src/core/session-text.ts +29 -0
- package/src/core/session.ts +469 -0
- package/src/core/settings.ts +170 -0
- package/src/core/tasks.ts +646 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tools.ts +309 -0
- package/src/core/trust.ts +104 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +236 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/core/workspace.ts +184 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +215 -0
- package/src/eval/gauntlet-support.ts +84 -0
- package/src/eval/gauntlet-wave3.ts +269 -0
- package/src/eval/gauntlet-wave4.ts +217 -0
- package/src/eval/gauntlet.ts +253 -0
- package/src/index.ts +17 -0
- package/src/lanes/agy.ts +95 -0
- package/src/lanes/approval.ts +24 -0
- package/src/lanes/claude.ts +129 -0
- package/src/lanes/codex.ts +127 -0
- package/src/lanes/events.ts +130 -0
- package/src/lanes/job.ts +142 -0
- package/src/lanes/opencode.ts +122 -0
- package/src/lanes/process.ts +184 -0
- package/src/lanes/progress.ts +183 -0
- package/src/lanes/registry.ts +178 -0
- package/src/lanes/runner.ts +124 -0
- package/src/lanes/types.ts +112 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +449 -0
- package/src/mcp/config.ts +252 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +362 -0
- package/src/mcp/oauth.ts +251 -0
- package/src/mcp/prompts-resources.ts +249 -0
- package/src/mcp/shared.ts +149 -0
- package/src/mcp/status.ts +67 -0
- package/src/mcp/tools.ts +275 -0
- package/src/mcp/transport.ts +122 -0
- package/src/mcp/trust.ts +25 -0
- package/src/memory/blocks.ts +278 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/scope.ts +182 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +119 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +124 -0
- package/src/plugins/manifest.ts +92 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +408 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +421 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/model-list.ts +23 -0
- package/src/providers/models-index.json +1 -0
- package/src/providers/oauth/common.ts +105 -0
- package/src/providers/oauth/device-code.ts +107 -0
- package/src/providers/oauth/github-copilot.ts +146 -0
- package/src/providers/oauth/loopback.ts +158 -0
- package/src/providers/oauth/openai.ts +163 -0
- package/src/providers/oauth/openrouter.ts +89 -0
- package/src/providers/oauth/pkce.ts +45 -0
- package/src/providers/oauth/registry.ts +39 -0
- package/src/providers/oauth/seam.ts +89 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +333 -0
- package/src/providers/responses.ts +209 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +566 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/providers/wire-responses.ts +79 -0
- package/src/providers/wire-select.ts +53 -0
- package/src/server/http.ts +291 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/crew-cards.ts +350 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +187 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/draw-wizard.ts +378 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +521 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +169 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +200 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +456 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +335 -0
- package/src/sextant/sextant-renderer.ts +574 -0
- package/src/sextant/sextant-repo.ts +140 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +493 -0
- package/src/skills/index.ts +387 -0
- package/src/skills/pack.ts +220 -0
- package/src/skills/spec.ts +162 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel-export.ts +122 -0
- package/src/telemetry/otel-lanes.ts +89 -0
- package/src/telemetry/otel-logs.ts +131 -0
- package/src/telemetry/otel-metrics.ts +136 -0
- package/src/telemetry/otel.ts +397 -0
- package/src/telemetry/otlp.ts +76 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/bash-bg.ts +94 -0
- package/src/tools/bash-jobs.ts +237 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +250 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +332 -0
- package/src/tools/websearch.ts +359 -0
- package/src/tui/agents-cmd.ts +41 -0
- package/src/tui/app.ts +749 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/boot-notes.ts +41 -0
- package/src/tui/builtin-prompts.ts +59 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/clipboard.ts +78 -0
- package/src/tui/commands.ts +283 -0
- package/src/tui/config-view.ts +53 -0
- package/src/tui/context-cmds.ts +282 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/crash-guard.ts +173 -0
- package/src/tui/focus-terminal.ts +34 -0
- package/src/tui/git-cmds.ts +273 -0
- package/src/tui/git-plain.ts +58 -0
- package/src/tui/info-cmd.ts +150 -0
- package/src/tui/input-plain.ts +76 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/memory-note.ts +77 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/notify-seq.ts +100 -0
- package/src/tui/notify.ts +318 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +428 -0
- package/src/tui/providers-cmd.ts +377 -0
- package/src/tui/reasoning-view.ts +56 -0
- package/src/tui/renderer.ts +128 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +148 -0
- package/src/tui/session-manage.ts +95 -0
- package/src/tui/sextant-attach.ts +102 -0
- package/src/tui/sextant-io.ts +202 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/shell-cmd.ts +158 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/staged-terminal.ts +50 -0
- package/src/tui/startup.ts +12 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/tui/trust-card.ts +107 -0
- package/src/tui/tui-commands.ts +87 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- 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
|
+
}
|