@codewithjuber/forgekit 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- package/templates/project-layer/CLAUDE.md +40 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
// forge uifingerprint — the generated-UI quality gate (P6,
|
|
2
|
+
// docs/plans/substrate-v2/07-ui-quality-gate.md). AI-generated UI converges on the
|
|
3
|
+
// max-likelihood template — the same statistical failure as M5 over-engineering, here
|
|
4
|
+
// favoring the *median* design. The taste layer (global/taste/*.md) is prose, and
|
|
5
|
+
// prose loses to gradients; this module makes taste MEASURABLE: a deterministic
|
|
6
|
+
// design fingerprint (pure static CSS/Tailwind-class parsing — no LLM, no
|
|
7
|
+
// screenshots, same discipline as uicheck's WCAG math) and two distances over it:
|
|
8
|
+
// slop(v) — too CLOSE to a shipped generic-template signature → fail
|
|
9
|
+
// conform(v) — too FAR from the project's own stored design system → fail
|
|
10
|
+
// Good output is far from generic and close to home; both are geometry once UI is a
|
|
11
|
+
// feature vector. The subjective residue (beauty) stays with the human reviewer —
|
|
12
|
+
// the gate's job is to stop the template from ever reaching them.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { isAbsolute, join } from "node:path";
|
|
15
|
+
import { BRAND } from "./brand.js";
|
|
16
|
+
import { mintClaim } from "./ledger.js";
|
|
17
|
+
import { loadClaims, putClaim, reindex, repoLedger } from "./ledger_store.js";
|
|
18
|
+
import { gitAuthor } from "./util.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Color parsing — everything normalizes to integer {h,s,l} so hue geometry (the
|
|
22
|
+
// "looks like the default framework palette" signal) is comparable across syntaxes.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** sRGB (0..255) → {h,s,l} with h in 0..359 degrees, s/l in 0..100 percent. */
|
|
26
|
+
export function rgbToHsl(r, g, b) {
|
|
27
|
+
const rn = r / 255;
|
|
28
|
+
const gn = g / 255;
|
|
29
|
+
const bn = b / 255;
|
|
30
|
+
const max = Math.max(rn, gn, bn);
|
|
31
|
+
const min = Math.min(rn, gn, bn);
|
|
32
|
+
const d = max - min;
|
|
33
|
+
const l = (max + min) / 2;
|
|
34
|
+
let h = 0;
|
|
35
|
+
let s = 0;
|
|
36
|
+
if (d > 0) {
|
|
37
|
+
s = d / (1 - Math.abs(2 * l - 1));
|
|
38
|
+
if (max === rn) h = ((gn - bn) / d + 6) % 6;
|
|
39
|
+
else if (max === gn) h = (bn - rn) / d + 2;
|
|
40
|
+
else h = (rn - gn) / d + 4;
|
|
41
|
+
h *= 60;
|
|
42
|
+
}
|
|
43
|
+
return { h: Math.round(h) % 360, s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Representative [hue, saturation] per Tailwind color family (the 500 shade — the one
|
|
47
|
+
// every starter reaches for). Lightness comes from the shade number. Neutral families
|
|
48
|
+
// get s≤5 by fiat so an all-slate UI doesn't register as "chromatic blue": their tint
|
|
49
|
+
// is a background choice, not an accent.
|
|
50
|
+
const TW_FAMILY_HS = {
|
|
51
|
+
red: [0, 84],
|
|
52
|
+
orange: [25, 95],
|
|
53
|
+
amber: [38, 92],
|
|
54
|
+
yellow: [45, 93],
|
|
55
|
+
lime: [84, 81],
|
|
56
|
+
green: [142, 71],
|
|
57
|
+
emerald: [160, 84],
|
|
58
|
+
teal: [173, 80],
|
|
59
|
+
cyan: [189, 94],
|
|
60
|
+
sky: [199, 89],
|
|
61
|
+
blue: [217, 91],
|
|
62
|
+
indigo: [239, 84],
|
|
63
|
+
violet: [258, 90],
|
|
64
|
+
purple: [271, 91],
|
|
65
|
+
fuchsia: [292, 84],
|
|
66
|
+
pink: [330, 81],
|
|
67
|
+
rose: [350, 89],
|
|
68
|
+
slate: [215, 5],
|
|
69
|
+
gray: [220, 5],
|
|
70
|
+
zinc: [240, 4],
|
|
71
|
+
neutral: [0, 0],
|
|
72
|
+
stone: [25, 5],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const HEX_RE = /#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})\b/gi;
|
|
76
|
+
const RGB_RE = /rgba?\(\s*(\d{1,3})[,\s]+(\d{1,3})[,\s]+(\d{1,3})/gi;
|
|
77
|
+
const HSL_RE = /hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%/gi;
|
|
78
|
+
const TW_COLOR_RE = new RegExp(
|
|
79
|
+
`\\b(?:bg|text|border|from|via|to|ring|outline|fill|stroke|accent|caret|decoration|divide|shadow)-(${Object.keys(TW_FAMILY_HS).join("|")})-(50|100|200|300|400|500|600|700|800|900|950)\\b`,
|
|
80
|
+
"g",
|
|
81
|
+
);
|
|
82
|
+
const TW_BW_RE = /\b(?:bg|text|border|from|via|to|ring|fill|stroke)-(white|black)\b/g;
|
|
83
|
+
|
|
84
|
+
function parseColors(text) {
|
|
85
|
+
/** @type {{h:number,s:number,l:number}[]} */
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const [, hex] of text.matchAll(HEX_RE)) {
|
|
88
|
+
// 3/4-digit shorthand expands per CSS; a trailing alpha channel is ignored — the
|
|
89
|
+
// fingerprint cares about hue identity, not opacity.
|
|
90
|
+
const full =
|
|
91
|
+
hex.length <= 4 ? [...hex.slice(0, 3)].map((c) => c + c).join("") : hex.slice(0, 6);
|
|
92
|
+
out.push(
|
|
93
|
+
rgbToHsl(
|
|
94
|
+
parseInt(full.slice(0, 2), 16),
|
|
95
|
+
parseInt(full.slice(2, 4), 16),
|
|
96
|
+
parseInt(full.slice(4, 6), 16),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
for (const [, r, g, b] of text.matchAll(RGB_RE)) out.push(rgbToHsl(+r, +g, +b));
|
|
101
|
+
for (const [, h, s, l] of text.matchAll(HSL_RE))
|
|
102
|
+
out.push({ h: Math.round(+h) % 360, s: Math.round(+s), l: Math.round(+l) });
|
|
103
|
+
for (const [, family, shade] of text.matchAll(TW_COLOR_RE)) {
|
|
104
|
+
const [h, s] = TW_FAMILY_HS[family];
|
|
105
|
+
// Lightness from the shade number: 50→95, 500→50, 950→5 — coarse but monotone,
|
|
106
|
+
// and hue (what the slop signatures key on) is exact.
|
|
107
|
+
out.push({ h, s, l: Math.min(96, Math.max(4, Math.round(100 - +shade / 10))) });
|
|
108
|
+
}
|
|
109
|
+
for (const [, bw] of text.matchAll(TW_BW_RE))
|
|
110
|
+
out.push({ h: 0, s: 0, l: bw === "white" ? 100 : 0 });
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Custom-property resolution — a token-driven stylesheet (`--s4: 16px` consumed as
|
|
116
|
+
// `padding: var(--s4)`) carries its whole design system in var() indirections; the
|
|
117
|
+
// extractors below only see literals, so var() must be substituted FIRST or the
|
|
118
|
+
// fingerprint reads a 6-value scale as one value.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// `--name: value` declarations (value stops at `;`/`}` like every prop regex here).
|
|
122
|
+
const VAR_DECL_RE = /(--[\w-]+)\s*:\s*([^;}]+)/g;
|
|
123
|
+
// `var(--name)` / `var(--name, fallback)`; the fallback may contain ONE paren level
|
|
124
|
+
// (rgba(...), nested var(...)) — deeper nesting stays unmatched and thus untouched.
|
|
125
|
+
const VAR_USE_RE = /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\s*\)/g;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Substitute `var(--name[, fallback])` with the declared custom-property value
|
|
129
|
+
* (fallback when undeclared; left as-is when neither exists — the extractors ignore
|
|
130
|
+
* unresolved `var(` just as before). One level of nesting (a custom property whose
|
|
131
|
+
* value is itself a var()) resolves via a BOUNDED pass count, so declaration cycles
|
|
132
|
+
* terminate instead of recursing: cyclic values simply keep their `var(` text and
|
|
133
|
+
* stay invisible to the extractors.
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function resolveCssVars(text) {
|
|
138
|
+
const t = String(text);
|
|
139
|
+
/** @type {Map<string,string>} */
|
|
140
|
+
const decls = new Map();
|
|
141
|
+
for (const [, name, value] of t.matchAll(VAR_DECL_RE)) decls.set(name, value.trim());
|
|
142
|
+
if (!decls.size && !t.includes("var(")) return t;
|
|
143
|
+
const substitute = (/** @type {string} */ s) =>
|
|
144
|
+
s.replace(VAR_USE_RE, (whole, name, fallback) => {
|
|
145
|
+
const v = decls.get(name);
|
|
146
|
+
if (v !== undefined) return v;
|
|
147
|
+
return fallback !== undefined ? String(fallback).trim() : whole;
|
|
148
|
+
});
|
|
149
|
+
// Resolve the declarations themselves first (--a: var(--b)); 4 passes covers the
|
|
150
|
+
// sane nesting depths and bounds a --a↔--b cycle to a fixed cost.
|
|
151
|
+
for (let i = 0; i < 4; i++) {
|
|
152
|
+
let changed = false;
|
|
153
|
+
for (const [name, value] of decls) {
|
|
154
|
+
if (!value.includes("var(")) continue;
|
|
155
|
+
const next = substitute(value);
|
|
156
|
+
if (next !== value) {
|
|
157
|
+
decls.set(name, next);
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!changed) break;
|
|
162
|
+
}
|
|
163
|
+
// Then the whole text; extra passes let a fallback that is itself a var() land.
|
|
164
|
+
let out = t;
|
|
165
|
+
for (let i = 0; i < 3 && out.includes("var("); i++) {
|
|
166
|
+
const next = substitute(out);
|
|
167
|
+
if (next === out) break;
|
|
168
|
+
out = next;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Length parsing — spacing, radii. Everything lands in px (rem/em at the 16px root)
|
|
175
|
+
// so the base-unit inference sees one scale.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const LEN_RE = /(-?\d*\.?\d+)(px|rem|em)\b/g;
|
|
179
|
+
|
|
180
|
+
/** Absolute px lengths inside one CSS value string (zero and non-lengths skipped). */
|
|
181
|
+
function parseLengths(value) {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const [, n, unit] of String(value).matchAll(LEN_RE)) {
|
|
184
|
+
const px = Math.abs(+n) * (unit === "px" ? 1 : 16);
|
|
185
|
+
if (px > 0) out.push(Math.round(px * 100) / 100);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cssValues = (text, propRe) => [...text.matchAll(propRe)].map((m) => m[1]);
|
|
191
|
+
|
|
192
|
+
// The leading class keeps `scroll-padding`, `--m-4` etc. from matching.
|
|
193
|
+
const SPACING_PROP_RE = /(?:^|[;{\s"'])(?:margin|padding)(?:-[a-z-]+)?\s*:\s*([^;}]+)/gi;
|
|
194
|
+
const GAP_PROP_RE = /(?:^|[;{\s"'])(?:row-gap|column-gap|gap)\s*:\s*([^;}]+)/gi;
|
|
195
|
+
const RADIUS_PROP_RE =
|
|
196
|
+
/(?:^|[;{\s"'])border(?:-(?:top|bottom|start|end)-(?:left|right)?)?-radius\s*:\s*([^;}]+)/gi;
|
|
197
|
+
const SHADOW_PROP_RE = /(?:^|[;{\s"'])box-shadow\s*:\s*([^;}]+)/gi;
|
|
198
|
+
const FONT_PROP_RE = /(?:^|[;{\s"'])font-family\s*:\s*([^;}]+)/gi;
|
|
199
|
+
|
|
200
|
+
// Tailwind spacing utilities: p-4/mx-2/gap-6/space-y-8 → n×4 px (p-px → 1px). The
|
|
201
|
+
// lookbehind stops `top-4` matching as `p-4`.
|
|
202
|
+
const TW_SPACE_RE =
|
|
203
|
+
/(?<![\w-])-?(?:[pm][trblxyse]?|gap(?:-[xy])?|space-[xy])-(\d+(?:\.\d+)?|px)(?![\w-])/g;
|
|
204
|
+
// rounded[-side][-size]; side alternatives are ordered two-letter-first so `-tl`
|
|
205
|
+
// never half-matches as `-t`+garbage.
|
|
206
|
+
const TW_ROUNDED_RE =
|
|
207
|
+
/(?<![\w-])rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?(?:-(none|sm|md|lg|xl|2xl|3xl|full))?(?![\w-])/g;
|
|
208
|
+
const TW_ROUNDED_PX = { none: 0, sm: 2, md: 6, lg: 8, xl: 12, "2xl": 16, "3xl": 24, full: 9999 };
|
|
209
|
+
const TW_SHADOW_RE = /(?<![\w-])shadow(?:-(sm|md|lg|xl|2xl|inner|none))?(?![\w-])/g;
|
|
210
|
+
const TW_FONT_RE = /(?<![\w-])font-(sans|serif|mono)(?![\w-])/g;
|
|
211
|
+
const TW_FONT_STACK = { sans: "sans-serif", serif: "serif", mono: "monospace" };
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Base-unit inference — approximate GCD by residual minimization.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const offBase = (s, u) => Math.abs(s - Math.round(s / u) * u);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Infer the spacing base unit: residual(u) = Σ distance-to-nearest-multiple-of-u.
|
|
221
|
+
* Residuals are monotone in divisibility (every multiple of 8 is one of 4 and 2), so
|
|
222
|
+
* a bare argmin always degenerates to the smallest candidate — instead take the
|
|
223
|
+
* LARGEST base that explains the data within half a pixel per value, falling back to
|
|
224
|
+
* the true argmin when nothing fits cleanly.
|
|
225
|
+
* @param {number[]} values @param {number[]} [candidates]
|
|
226
|
+
* @returns {number|null} null when there are no values to infer from
|
|
227
|
+
*/
|
|
228
|
+
export function inferSpacingBase(values, candidates = [2, 4, 8]) {
|
|
229
|
+
if (!values.length) return null;
|
|
230
|
+
const residual = (u) => values.reduce((sum, s) => sum + offBase(s, u), 0);
|
|
231
|
+
let best = null;
|
|
232
|
+
for (const u of [...candidates].sort((a, b) => a - b))
|
|
233
|
+
if (residual(u) <= 0.5 * values.length) best = u;
|
|
234
|
+
if (best !== null) return best;
|
|
235
|
+
return candidates.reduce((arg, u) => (residual(u) < residual(arg) ? u : arg), candidates[0]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Fraction of values sitting on multiples of `base` within ε (vacuously 1). */
|
|
239
|
+
export function onScaleFraction(values, base, epsilon = 0.5) {
|
|
240
|
+
if (!values.length || !base) return 1;
|
|
241
|
+
return values.filter((s) => offBase(s, base) <= epsilon).length / values.length;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// The fingerprint.
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @typedef {{h:number,s:number,l:number}} Hsl
|
|
250
|
+
* @typedef {{palette:Hsl[], paletteSize:number, hueBuckets:number[], spacing:number[],
|
|
251
|
+
* spacingBase:number|null, spacingOnScale:number, fontFamilies:string[],
|
|
252
|
+
* radii:number[], radiusLevels:number, shadowLevels:number}} Fingerprint
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
// Below this saturation a color is a neutral: it carries no hue identity, so it must
|
|
256
|
+
// not vote in the hue histogram (an ink-and-paper UI is not "red" at h=0).
|
|
257
|
+
const NEUTRAL_S = 10;
|
|
258
|
+
|
|
259
|
+
const sortNum = (a, b) => a - b;
|
|
260
|
+
const uniqSorted = (arr) => [...new Set(arr)].sort(sortNum);
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract the design fingerprint from raw CSS / JSX / Tailwind-class text. Pure and
|
|
264
|
+
* deterministic — the same text always yields the same vector (it becomes a
|
|
265
|
+
* content-addressed ledger claim, so this is a protocol requirement, not a nicety).
|
|
266
|
+
* @param {string} text
|
|
267
|
+
* @returns {Fingerprint}
|
|
268
|
+
*/
|
|
269
|
+
export function fingerprintText(text) {
|
|
270
|
+
const t = resolveCssVars(String(text));
|
|
271
|
+
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
/** @type {Hsl[]} */
|
|
274
|
+
const palette = [];
|
|
275
|
+
for (const c of parseColors(t)) {
|
|
276
|
+
const key = `${c.h},${c.s},${c.l}`;
|
|
277
|
+
if (!seen.has(key)) {
|
|
278
|
+
seen.add(key);
|
|
279
|
+
palette.push(c);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
palette.sort((a, b) => a.h - b.h || a.s - b.s || a.l - b.l);
|
|
283
|
+
const hueBuckets = new Array(12).fill(0);
|
|
284
|
+
for (const c of palette) if (c.s >= NEUTRAL_S) hueBuckets[Math.floor((c.h % 360) / 30)]++;
|
|
285
|
+
|
|
286
|
+
const spacingRaw = [
|
|
287
|
+
...cssValues(t, SPACING_PROP_RE).flatMap(parseLengths),
|
|
288
|
+
...cssValues(t, GAP_PROP_RE).flatMap(parseLengths),
|
|
289
|
+
...[...t.matchAll(TW_SPACE_RE)].map(([, n]) => (n === "px" ? 1 : +n * 4)).filter(Boolean),
|
|
290
|
+
];
|
|
291
|
+
const spacing = uniqSorted(spacingRaw);
|
|
292
|
+
const spacingBase = inferSpacingBase(spacing);
|
|
293
|
+
const spacingOnScale = onScaleFraction(spacing, spacingBase);
|
|
294
|
+
|
|
295
|
+
const fontFamilies = [
|
|
296
|
+
...new Set([
|
|
297
|
+
// Only the FIRST family in a stack — the intended face; the rest are fallbacks.
|
|
298
|
+
...cssValues(t, FONT_PROP_RE)
|
|
299
|
+
.map((v) =>
|
|
300
|
+
String(v.split(",")[0])
|
|
301
|
+
.trim()
|
|
302
|
+
.replace(/^["']|["']$/g, "")
|
|
303
|
+
.toLowerCase(),
|
|
304
|
+
)
|
|
305
|
+
.filter((f) => f && !f.includes("(")), // var()/env() indirections carry no face
|
|
306
|
+
...[...t.matchAll(TW_FONT_RE)].map(([, k]) => TW_FONT_STACK[k]),
|
|
307
|
+
]),
|
|
308
|
+
].sort();
|
|
309
|
+
|
|
310
|
+
const radii = uniqSorted([
|
|
311
|
+
// 999+px pill radii normalize to one "full" level — 9999 vs 99999 is not a choice.
|
|
312
|
+
...cssValues(t, RADIUS_PROP_RE)
|
|
313
|
+
.flatMap(parseLengths)
|
|
314
|
+
.map((r) => (r >= 999 ? 9999 : r)),
|
|
315
|
+
...[...t.matchAll(TW_ROUNDED_RE)]
|
|
316
|
+
.map(([, size]) => (size === undefined ? 4 : TW_ROUNDED_PX[size]))
|
|
317
|
+
.filter((r) => r > 0),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const shadows = new Set([
|
|
321
|
+
...cssValues(t, SHADOW_PROP_RE)
|
|
322
|
+
.map((v) => v.trim().replace(/\s+/g, " "))
|
|
323
|
+
.filter((v) => v !== "none"),
|
|
324
|
+
...[...t.matchAll(TW_SHADOW_RE)]
|
|
325
|
+
.map(([, size]) => `tw:${size ?? "base"}`)
|
|
326
|
+
.filter((s) => s !== "tw:none"),
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
palette,
|
|
331
|
+
paletteSize: palette.length,
|
|
332
|
+
hueBuckets,
|
|
333
|
+
spacing,
|
|
334
|
+
spacingBase,
|
|
335
|
+
spacingOnScale,
|
|
336
|
+
fontFamilies,
|
|
337
|
+
radii,
|
|
338
|
+
radiusLevels: radii.length,
|
|
339
|
+
shadowLevels: shadows.size,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Fingerprint a set of files as ONE vector (a design system is a property of the
|
|
345
|
+
* whole surface, not any single file). Unreadable files are skipped; the file list
|
|
346
|
+
* is sorted first so argument order can never change the vector.
|
|
347
|
+
* @param {string} root @param {string[]} files
|
|
348
|
+
* @returns {Fingerprint}
|
|
349
|
+
*/
|
|
350
|
+
export function fingerprintFiles(root, files) {
|
|
351
|
+
const texts = [];
|
|
352
|
+
for (const f of [...files].sort()) {
|
|
353
|
+
try {
|
|
354
|
+
texts.push(readFileSync(isAbsolute(f) ? f : join(root, f), "utf8"));
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
return fingerprintText(texts.join("\n"));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Slop distance — the shipped generic-template signature set. Each entry is the
|
|
362
|
+
// measurable footprint of a recognizable "AI default" look; being NEAR one of these
|
|
363
|
+
// is the failure. Curated, versioned, extensible (spec §2).
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @typedef {{id:string, why:string, hues:number[], spacingBase:number,
|
|
368
|
+
* spacingCount:number, fontCount:number, radii:number[], radiusLevels:number,
|
|
369
|
+
* shadowLevels:number}} GenericSignature
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
/** @type {GenericSignature[]} */
|
|
373
|
+
export const GENERIC_SIGNATURES = [
|
|
374
|
+
{
|
|
375
|
+
id: "tailwind-default",
|
|
376
|
+
why: "the untouched Tailwind starter: blue-500/indigo-500 accents, flat 8px-everything spacing (the p-2/p-4/p-8 trio), one sans stack, rounded-xl on every card, one soft shadow",
|
|
377
|
+
hues: [217, 239], // blue-500 #3b82f6 ≈ h217, indigo-500 #6366f1 ≈ h239
|
|
378
|
+
spacingBase: 8,
|
|
379
|
+
spacingCount: 3, // 8/16/32 — a flat scale with no rhythm
|
|
380
|
+
fontCount: 1,
|
|
381
|
+
radii: [12], // rounded-xl
|
|
382
|
+
radiusLevels: 1,
|
|
383
|
+
shadowLevels: 1,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "bootstrap-default",
|
|
387
|
+
why: "stock Bootstrap: the #0d6efd primary (h≈216), 1rem spacers halving to 8px steps, one system stack, uniform 6px --bs-border-radius, one shadow",
|
|
388
|
+
hues: [216],
|
|
389
|
+
spacingBase: 8,
|
|
390
|
+
spacingCount: 4, // the $spacer/2 ladder: 8/16/24/48
|
|
391
|
+
fontCount: 1,
|
|
392
|
+
radii: [6],
|
|
393
|
+
radiusLevels: 1,
|
|
394
|
+
shadowLevels: 1,
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
id: "ai-landing-gradient",
|
|
398
|
+
why: "the canonical AI landing page: violet→purple gradient hero (h 258–271), airy uniform 8px spacing, rounded-2xl cards, two layered soft shadows",
|
|
399
|
+
hues: [258, 271], // violet-500 #8b5cf6, purple-500 #a855f7
|
|
400
|
+
spacingBase: 8,
|
|
401
|
+
spacingCount: 3,
|
|
402
|
+
fontCount: 1,
|
|
403
|
+
radii: [16], // rounded-2xl
|
|
404
|
+
radiusLevels: 1,
|
|
405
|
+
shadowLevels: 2,
|
|
406
|
+
},
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
const round3 = (x) => Math.round(x * 1000) / 1000;
|
|
410
|
+
const hueDist = (a, b) => {
|
|
411
|
+
const d = Math.abs(a - b) % 360;
|
|
412
|
+
return Math.min(d, 360 - d);
|
|
413
|
+
};
|
|
414
|
+
const chromaticHues = (fp) => fp.palette.filter((c) => c.s >= NEUTRAL_S).map((c) => c.h);
|
|
415
|
+
|
|
416
|
+
/** Tolerate a raw ledger body (or partial vector) anywhere a fingerprint is read. */
|
|
417
|
+
const asFp = (fp) => ({
|
|
418
|
+
palette: fp?.palette ?? [],
|
|
419
|
+
paletteSize: fp?.paletteSize ?? 0,
|
|
420
|
+
hueBuckets: fp?.hueBuckets ?? new Array(12).fill(0),
|
|
421
|
+
spacing: fp?.spacing ?? [],
|
|
422
|
+
spacingBase: fp?.spacingBase ?? null,
|
|
423
|
+
spacingOnScale: fp?.spacingOnScale ?? 1,
|
|
424
|
+
fontFamilies: fp?.fontFamilies ?? [],
|
|
425
|
+
radii: fp?.radii ?? [],
|
|
426
|
+
radiusLevels: fp?.radiusLevels ?? 0,
|
|
427
|
+
shadowLevels: fp?.shadowLevels ?? 0,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Per-feature distances (each in [0,1]) from a fingerprint to one generic signature.
|
|
431
|
+
// Only features the input actually exhibits are compared — an all-CSS-variables file
|
|
432
|
+
// with no radii must not be judged on radii it doesn't have.
|
|
433
|
+
function sigFeatures(fp, sig) {
|
|
434
|
+
const out = [];
|
|
435
|
+
if (fp.paletteSize > 0) {
|
|
436
|
+
const hues = chromaticHues(fp);
|
|
437
|
+
// A neutral-only palette is maximally far from a blue-band template (grayscale is
|
|
438
|
+
// a deliberate stance, e.g. brutalist), hence d=1 rather than "incomparable".
|
|
439
|
+
const d = hues.length
|
|
440
|
+
? hues.reduce((s, h) => s + Math.min(...sig.hues.map((z) => hueDist(h, z))), 0) /
|
|
441
|
+
hues.length /
|
|
442
|
+
180
|
|
443
|
+
: 1;
|
|
444
|
+
out.push({ feature: "palette", d: round3(d) });
|
|
445
|
+
}
|
|
446
|
+
if (fp.spacing.length) {
|
|
447
|
+
const baseD = fp.spacingBase === sig.spacingBase ? 0 : 1;
|
|
448
|
+
const divD = Math.min(1, Math.abs(fp.spacing.length - sig.spacingCount) / 8);
|
|
449
|
+
out.push({ feature: "spacing", d: round3((baseD + divD) / 2) });
|
|
450
|
+
}
|
|
451
|
+
if (fp.fontFamilies.length)
|
|
452
|
+
out.push({
|
|
453
|
+
feature: "type",
|
|
454
|
+
d: round3(Math.min(1, Math.abs(fp.fontFamilies.length - sig.fontCount) / 2)),
|
|
455
|
+
});
|
|
456
|
+
if (fp.radii.length) {
|
|
457
|
+
const near = Math.min(
|
|
458
|
+
...fp.radii.map((r) => Math.min(...sig.radii.map((z) => Math.min(1, Math.abs(r - z) / 16)))),
|
|
459
|
+
);
|
|
460
|
+
const lev = Math.min(1, Math.abs(fp.radiusLevels - sig.radiusLevels) / 3);
|
|
461
|
+
out.push({ feature: "shape", d: round3((near + lev) / 2) });
|
|
462
|
+
}
|
|
463
|
+
// Zero shadows on a UI that exhibits other features IS signal (flat ≠ template
|
|
464
|
+
// soft-shadow); but on an empty vector it would be judging nothing, so gate on
|
|
465
|
+
// having at least one other measurable feature.
|
|
466
|
+
if (out.length || fp.shadowLevels > 0)
|
|
467
|
+
out.push({
|
|
468
|
+
feature: "elevation",
|
|
469
|
+
d: round3(Math.min(1, Math.abs(fp.shadowLevels - sig.shadowLevels) / 3)),
|
|
470
|
+
});
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Nearest generic signature with its per-feature breakdown (what uiGate turns into
|
|
476
|
+
* actionable violations). Null when the input had nothing measurable.
|
|
477
|
+
* @param {Fingerprint} fingerprint
|
|
478
|
+
* @returns {{id:string, why:string, distance:number, features:{feature:string,d:number}[]}|null}
|
|
479
|
+
*/
|
|
480
|
+
export function nearestGeneric(fingerprint) {
|
|
481
|
+
const fp = asFp(fingerprint);
|
|
482
|
+
let best = null;
|
|
483
|
+
for (const sig of GENERIC_SIGNATURES) {
|
|
484
|
+
const features = sigFeatures(fp, sig);
|
|
485
|
+
if (!features.length) continue;
|
|
486
|
+
const distance = round3(features.reduce((s, f) => s + f.d, 0) / features.length);
|
|
487
|
+
if (!best || distance < best.distance) best = { id: sig.id, why: sig.why, distance, features };
|
|
488
|
+
}
|
|
489
|
+
return best;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Normalized distance (0..1) to the NEAREST generic-template signature. Low = slop.
|
|
493
|
+
* An empty/unmeasurable vector returns 1 — nothing measurable is not generic. */
|
|
494
|
+
export function slopDistance(fingerprint) {
|
|
495
|
+
return nearestGeneric(fingerprint)?.distance ?? 1;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Conformance — distance from the project's own design system.
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
const jaccardDist = (a, b) => {
|
|
503
|
+
const A = new Set(a);
|
|
504
|
+
const B = new Set(b);
|
|
505
|
+
const uni = new Set([...A, ...B]).size;
|
|
506
|
+
if (!uni) return 0;
|
|
507
|
+
return 1 - [...A].filter((x) => B.has(x)).length / uni;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Per-feature distances between two fingerprints; comparable features only.
|
|
511
|
+
function conformFeatures(a, b) {
|
|
512
|
+
const out = [];
|
|
513
|
+
if (a.paletteSize || b.paletteSize) {
|
|
514
|
+
const ha = chromaticHues(a);
|
|
515
|
+
const hb = chromaticHues(b);
|
|
516
|
+
let d;
|
|
517
|
+
if (!ha.length && !hb.length)
|
|
518
|
+
d = 0; // two neutral-only systems agree
|
|
519
|
+
else if (!ha.length || !hb.length)
|
|
520
|
+
d = 1; // chromatic vs grayscale — different worlds
|
|
521
|
+
else {
|
|
522
|
+
// Symmetric mean nearest-hue distance (an averaged Hausdorff): every hue in
|
|
523
|
+
// each palette must have a home in the other.
|
|
524
|
+
const dir = (xs, ys) =>
|
|
525
|
+
xs.reduce((s, h) => s + Math.min(...ys.map((z) => hueDist(h, z))), 0) / xs.length;
|
|
526
|
+
d = (dir(ha, hb) + dir(hb, ha)) / 2 / 180;
|
|
527
|
+
}
|
|
528
|
+
out.push({ feature: "palette", d: round3(d) });
|
|
529
|
+
}
|
|
530
|
+
if (a.spacing.length && b.spacing.length) {
|
|
531
|
+
// Base disagreement in octaves (2 vs 8 is worse than 4 vs 8) blended with how
|
|
532
|
+
// much of the OUTPUT sits off the PROJECT's base — the actionable half.
|
|
533
|
+
const baseD = Math.min(1, Math.abs(Math.log2((a.spacingBase || 1) / (b.spacingBase || 1))) / 2);
|
|
534
|
+
const scaleD = 1 - onScaleFraction(a.spacing, b.spacingBase ?? undefined);
|
|
535
|
+
out.push({ feature: "spacing", d: round3((baseD + scaleD) / 2) });
|
|
536
|
+
}
|
|
537
|
+
if (a.fontFamilies.length || b.fontFamilies.length)
|
|
538
|
+
out.push({ feature: "type", d: round3(jaccardDist(a.fontFamilies, b.fontFamilies)) });
|
|
539
|
+
if (a.radii.length || b.radii.length) {
|
|
540
|
+
const lev = Math.min(1, Math.abs(a.radiusLevels - b.radiusLevels) / 3);
|
|
541
|
+
out.push({ feature: "shape", d: round3((jaccardDist(a.radii, b.radii) + lev) / 2) });
|
|
542
|
+
}
|
|
543
|
+
out.push({
|
|
544
|
+
feature: "elevation",
|
|
545
|
+
d: round3(Math.min(1, Math.abs(a.shadowLevels - b.shadowLevels) / 3)),
|
|
546
|
+
});
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Normalized distance (0..1) between a fingerprint and the project's. High = the
|
|
552
|
+
* output ignored the system the codebase already has.
|
|
553
|
+
* @param {Fingerprint} fingerprint @param {Fingerprint} projectFp
|
|
554
|
+
*/
|
|
555
|
+
export function conformance(fingerprint, projectFp) {
|
|
556
|
+
const feats = conformFeatures(asFp(fingerprint), asFp(projectFp));
|
|
557
|
+
return feats.length ? round3(feats.reduce((s, f) => s + f.d, 0) / feats.length) : 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// The gate — actionable violations, never a bare score (spec §2: each failing
|
|
562
|
+
// feature maps to a concrete edit).
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
/** Default thresholds; taste profiles override these in P8 once fixtures show separation. */
|
|
566
|
+
export const UI_GATE_DEFAULTS = { tauSlop: 0.25, tauConform: 0.5 };
|
|
567
|
+
|
|
568
|
+
const SLOP_HINTS = {
|
|
569
|
+
palette: (_fp, near) =>
|
|
570
|
+
`palette hues sit in the ${near.id} band — pick a brand hue (or the project's accent) outside it`,
|
|
571
|
+
spacing: (fp) =>
|
|
572
|
+
`spacing is the uniform ${fp.spacingBase ?? 8}px template rhythm — use a deliberate scale (e.g. the project's 4-based scale with real jumps)`,
|
|
573
|
+
type: () =>
|
|
574
|
+
"a single default font stack is the strongest template tell — use the project's faces or a deliberate pairing",
|
|
575
|
+
shape: () =>
|
|
576
|
+
"one uniform large radius on everything reads as template — commit to few deliberate radii (0 counts)",
|
|
577
|
+
elevation: () =>
|
|
578
|
+
"the one-soft-shadow-on-every-card look is generic — flatten, or define explicit elevation steps",
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const CONFORM_HINTS = {
|
|
582
|
+
palette: (fp, proj) =>
|
|
583
|
+
`output hues [${chromaticHues(proj).join(", ") || "neutral-only"}] are the project's — reuse those accents instead of [${chromaticHues(fp).join(", ") || "none"}]`,
|
|
584
|
+
spacing: (fp, proj) =>
|
|
585
|
+
`the project spacing base is ${proj.spacingBase}px — put values on that scale (output inferred base ${fp.spacingBase}px)`,
|
|
586
|
+
type: (fp, proj) =>
|
|
587
|
+
`output fonts [${fp.fontFamilies.join(", ")}] don't match the project's [${proj.fontFamilies.join(", ")}] — use the project stacks`,
|
|
588
|
+
shape: (fp, proj) =>
|
|
589
|
+
`project radii are [${proj.radii.join(", ")}] — use those levels, not [${fp.radii.join(", ")}]`,
|
|
590
|
+
elevation: (fp, proj) =>
|
|
591
|
+
`project uses ${proj.shadowLevels} shadow level(s), output uses ${fp.shadowLevels} — match the project's elevation system`,
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The two-sided quality gate: PASS iff slop ≥ tauSlop AND (when a project
|
|
596
|
+
* fingerprint exists) conform ≤ tauConform. Violations name the driving feature and
|
|
597
|
+
* a concrete edit; because each per-feature distance is in [0,1], a failing mean
|
|
598
|
+
* always has at least one failing feature — a FAIL can never arrive hint-less.
|
|
599
|
+
* @param {Fingerprint} fingerprint
|
|
600
|
+
* @param {{projectFp?:Fingerprint|null, tauSlop?:number, tauConform?:number}} [opts]
|
|
601
|
+
* @returns {{pass:boolean, slop:number, conform:number|null,
|
|
602
|
+
* violations:{feature:string, detail:string, hint:string}[]}}
|
|
603
|
+
*/
|
|
604
|
+
export function uiGate(fingerprint, opts = {}) {
|
|
605
|
+
const { projectFp = null, tauSlop, tauConform } = { ...UI_GATE_DEFAULTS, ...opts };
|
|
606
|
+
const fp = asFp(fingerprint);
|
|
607
|
+
const violations = [];
|
|
608
|
+
const near = nearestGeneric(fp);
|
|
609
|
+
const slop = near?.distance ?? 1;
|
|
610
|
+
if (near && slop < tauSlop) {
|
|
611
|
+
for (const f of near.features)
|
|
612
|
+
if (f.d < tauSlop)
|
|
613
|
+
violations.push({
|
|
614
|
+
feature: f.feature,
|
|
615
|
+
detail: `${f.feature} is Δ${f.d} from the "${near.id}" template (need ≥ ${tauSlop} overall)`,
|
|
616
|
+
hint: SLOP_HINTS[f.feature](fp, near),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
let conform = null;
|
|
620
|
+
if (projectFp) {
|
|
621
|
+
const proj = asFp(projectFp);
|
|
622
|
+
const feats = conformFeatures(fp, proj);
|
|
623
|
+
conform = feats.length ? round3(feats.reduce((s, f) => s + f.d, 0) / feats.length) : 0;
|
|
624
|
+
if (conform > tauConform) {
|
|
625
|
+
for (const f of feats)
|
|
626
|
+
if (f.d > tauConform)
|
|
627
|
+
violations.push({
|
|
628
|
+
feature: f.feature,
|
|
629
|
+
detail: `${f.feature} is Δ${f.d} from the project fingerprint (need ≤ ${tauConform} overall)`,
|
|
630
|
+
hint: CONFORM_HINTS[f.feature](fp, proj),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { pass: violations.length === 0, slop, conform, violations };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Scale-conformance checks — the ASSERTABLE_CHECKS extension (spec §4). Same shape
|
|
639
|
+
// as uicheck's list: deterministic, per-fingerprint, pass|fail + fix hint. The ids
|
|
640
|
+
// mirror the entries added to uicheck.ASSERTABLE_CHECKS (drift-tested).
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
/** A design system uses FEW levels, deliberately — these caps encode that. */
|
|
644
|
+
export const SCALE_CHECK_DEFAULTS = {
|
|
645
|
+
epsilon: 0.5, // px tolerance for "on the base scale" (sub-pixel = rounding noise)
|
|
646
|
+
minOnScale: 0.9, // ≥90% of spacing values must sit on the base
|
|
647
|
+
maxRadiusLevels: 3,
|
|
648
|
+
maxShadowLevels: 3,
|
|
649
|
+
maxPalette: 8, // distinct normalized colors — beyond this it's pixel-soup, not a palette
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Run the deterministic scale checks over a fingerprint.
|
|
654
|
+
* @param {Fingerprint} fingerprint
|
|
655
|
+
* @param {{base?:number|null, epsilon?:number, minOnScale?:number, maxRadiusLevels?:number,
|
|
656
|
+
* maxShadowLevels?:number, maxPalette?:number}} [opts]
|
|
657
|
+
* `base` = the DECLARED design-system base; defaults to the inferred one.
|
|
658
|
+
* @returns {{id:string, pass:boolean, detail:string, hint:string}[]}
|
|
659
|
+
*/
|
|
660
|
+
export function scaleChecks(fingerprint, opts = {}) {
|
|
661
|
+
const fp = asFp(fingerprint);
|
|
662
|
+
const o = { ...SCALE_CHECK_DEFAULTS, base: fp.spacingBase, ...opts };
|
|
663
|
+
const off = o.base ? fp.spacing.filter((s) => offBase(s, o.base) > o.epsilon) : [];
|
|
664
|
+
const onFrac = fp.spacing.length ? 1 - off.length / fp.spacing.length : 1;
|
|
665
|
+
return [
|
|
666
|
+
{
|
|
667
|
+
id: "spacing-scale",
|
|
668
|
+
pass: onFrac >= o.minOnScale,
|
|
669
|
+
detail: `${Math.round(onFrac * 100)}% of ${fp.spacing.length} spacing value(s) on the ${o.base ?? "(none)"}px base (ε ${o.epsilon}px)`,
|
|
670
|
+
hint: off.length ? `move ${off.join(", ")}px onto the ${o.base}px scale` : "",
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
id: "radius-levels",
|
|
674
|
+
pass: fp.radiusLevels <= o.maxRadiusLevels,
|
|
675
|
+
detail: `${fp.radiusLevels} distinct radius level(s) (max ${o.maxRadiusLevels})`,
|
|
676
|
+
hint:
|
|
677
|
+
fp.radiusLevels > o.maxRadiusLevels
|
|
678
|
+
? `collapse [${fp.radii.join(", ")}] to ≤${o.maxRadiusLevels} deliberate levels`
|
|
679
|
+
: "",
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
id: "shadow-levels",
|
|
683
|
+
pass: fp.shadowLevels <= o.maxShadowLevels,
|
|
684
|
+
detail: `${fp.shadowLevels} distinct shadow level(s) (max ${o.maxShadowLevels})`,
|
|
685
|
+
hint:
|
|
686
|
+
fp.shadowLevels > o.maxShadowLevels
|
|
687
|
+
? `define ≤${o.maxShadowLevels} elevation steps and reuse them`
|
|
688
|
+
: "",
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
id: "palette-size",
|
|
692
|
+
pass: fp.paletteSize <= o.maxPalette,
|
|
693
|
+
detail: `${fp.paletteSize} distinct color(s) (max ${o.maxPalette})`,
|
|
694
|
+
hint:
|
|
695
|
+
fp.paletteSize > o.maxPalette
|
|
696
|
+
? "consolidate to design tokens — a palette is a decision, not an accumulation"
|
|
697
|
+
: "",
|
|
698
|
+
},
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Taste profiles as constraint sets (spec §3) — each prose global/taste/<name>.md
|
|
704
|
+
// keeps the *why* and steers generation; its JSON sibling is what the gate CHECKS.
|
|
705
|
+
// `chroma` in the JSON is an HSL saturation/100 proxy in [0,1] (documented in each
|
|
706
|
+
// profile's "why"); `ratio`/`chroma` bands steer generation but are not yet gated —
|
|
707
|
+
// only the checks below are, until P8 fixtures show separation.
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* @typedef {{name:string, why:string,
|
|
712
|
+
* palette:{max_hues:number, chroma:[number,number], neutrals:string},
|
|
713
|
+
* space:{scale:string, ratio:[number,number], base:number[]},
|
|
714
|
+
* type:{max_families:number},
|
|
715
|
+
* shape:{radius_levels:[number,number], shadow_levels:[number,number]},
|
|
716
|
+
* gate:{tau_slop:number, tau_conform:number}}} TasteProfile
|
|
717
|
+
*/
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Load a taste-profile constraint set from global/taste/<name>.json (located
|
|
721
|
+
* relative to the installed module via BRAND.root, same as `forge taste` finds the
|
|
722
|
+
* prose files). Null when the name has no JSON sibling or it doesn't parse.
|
|
723
|
+
* @param {string} name
|
|
724
|
+
* @returns {TasteProfile|null}
|
|
725
|
+
*/
|
|
726
|
+
export function loadTasteProfile(name) {
|
|
727
|
+
if (!/^[\w-]+$/.test(String(name))) return null; // a name, never a path
|
|
728
|
+
try {
|
|
729
|
+
const p = JSON.parse(readFileSync(join(BRAND.root, "global", "taste", `${name}.json`), "utf8"));
|
|
730
|
+
return p && typeof p === "object" && p.gate ? p : null;
|
|
731
|
+
} catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The repo's pinned taste style, read from a `forge taste`-managed DESIGN.md (its
|
|
738
|
+
* header: `<!-- Forge taste: <style> — … forge:sync:<hash> -->`). Null when there is
|
|
739
|
+
* no DESIGN.md, it isn't Forge-managed, or it was written by hand.
|
|
740
|
+
* @param {string} root
|
|
741
|
+
* @returns {string|null}
|
|
742
|
+
*/
|
|
743
|
+
export function activeTasteStyle(root) {
|
|
744
|
+
try {
|
|
745
|
+
const head = readFileSync(join(root, "DESIGN.md"), "utf8").slice(0, 200);
|
|
746
|
+
const m = head.match(/<!--\s*Forge taste:\s*([\w-]+)\s.*forge:sync:/);
|
|
747
|
+
return m ? m[1] : null;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Deterministic profile-constraint checks — same {id, pass, detail, hint} shape as
|
|
755
|
+
* scaleChecks so the CLI/hooks render them identically. Hue count is OCCUPIED
|
|
756
|
+
* 30°-hue-bins (a warm palette of 8 near-identical h≈30 tints is ONE hue decision,
|
|
757
|
+
* not eight); features the input doesn't exhibit pass vacuously, mirroring
|
|
758
|
+
* sigFeatures' "don't judge what isn't there".
|
|
759
|
+
* @param {Fingerprint} fingerprint @param {TasteProfile} profile
|
|
760
|
+
* @returns {{id:string, pass:boolean, detail:string, hint:string}[]}
|
|
761
|
+
*/
|
|
762
|
+
export function profileChecks(fingerprint, profile) {
|
|
763
|
+
const fp = asFp(fingerprint);
|
|
764
|
+
const name = profile.name ?? "taste";
|
|
765
|
+
const hues = fp.hueBuckets.filter((n) => n > 0).length;
|
|
766
|
+
const [rLo, rHi] = profile.shape?.radius_levels ?? [0, Infinity];
|
|
767
|
+
const [sLo, sHi] = profile.shape?.shadow_levels ?? [0, Infinity];
|
|
768
|
+
const bases = profile.space?.base ?? [];
|
|
769
|
+
const maxHues = profile.palette?.max_hues ?? Infinity;
|
|
770
|
+
const maxFam = profile.type?.max_families ?? Infinity;
|
|
771
|
+
return [
|
|
772
|
+
{
|
|
773
|
+
id: "taste-palette",
|
|
774
|
+
pass: fp.paletteSize === 0 || hues <= maxHues,
|
|
775
|
+
detail: `${hues} distinct hue bin(s) of ${fp.paletteSize} color(s) (${name} allows ≤ ${maxHues})`,
|
|
776
|
+
hint: hues > maxHues ? `collapse accents to ${maxHues} hue(s) — ${name} palettes commit` : "",
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
id: "taste-type",
|
|
780
|
+
pass: fp.fontFamilies.length <= maxFam,
|
|
781
|
+
detail: `${fp.fontFamilies.length} font family(ies) (${name} allows ≤ ${maxFam})`,
|
|
782
|
+
hint:
|
|
783
|
+
fp.fontFamilies.length > maxFam
|
|
784
|
+
? `drop to ≤${maxFam} families — [${fp.fontFamilies.join(", ")}] is more voices than ${name} speaks in`
|
|
785
|
+
: "",
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
id: "taste-radius",
|
|
789
|
+
pass: fp.radiusLevels >= rLo && fp.radiusLevels <= rHi,
|
|
790
|
+
detail: `${fp.radiusLevels} radius level(s) (${name} wants ${rLo}–${rHi})`,
|
|
791
|
+
hint:
|
|
792
|
+
fp.radiusLevels > rHi
|
|
793
|
+
? `flatten [${fp.radii.join(", ")}] to ≤${rHi} level(s)${rHi === 0 ? " — square corners are the style" : ""}`
|
|
794
|
+
: fp.radiusLevels < rLo
|
|
795
|
+
? `add rounding — ${name} expects at least ${rLo} radius level(s)`
|
|
796
|
+
: "",
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
id: "taste-shadow",
|
|
800
|
+
pass: fp.shadowLevels >= sLo && fp.shadowLevels <= sHi,
|
|
801
|
+
detail: `${fp.shadowLevels} shadow level(s) (${name} wants ${sLo}–${sHi})`,
|
|
802
|
+
hint:
|
|
803
|
+
fp.shadowLevels > sHi
|
|
804
|
+
? `flatten elevation to ≤${sHi} level(s)`
|
|
805
|
+
: fp.shadowLevels < sLo
|
|
806
|
+
? `add soft elevation — ${name} expects at least ${sLo} shadow level(s)`
|
|
807
|
+
: "",
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: "taste-spacing-base",
|
|
811
|
+
pass: !fp.spacing.length || fp.spacingBase === null || bases.includes(fp.spacingBase),
|
|
812
|
+
detail: `inferred spacing base ${fp.spacingBase ?? "(none)"}px (${name} allows [${bases.join(", ")}])`,
|
|
813
|
+
hint:
|
|
814
|
+
fp.spacing.length && fp.spacingBase !== null && !bases.includes(fp.spacingBase)
|
|
815
|
+
? `re-grid spacing onto a ${bases.join("- or ")}px base`
|
|
816
|
+
: "",
|
|
817
|
+
},
|
|
818
|
+
];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
// The project fingerprint claim — v_proj lives in the PCM ledger so it is shared
|
|
823
|
+
// with the team and updated by the same evidence rules as everything else.
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Extract the project fingerprint from `files` and store it as a `fingerprint`
|
|
828
|
+
* claim. Content-addressed: the same UI surface mints the same id on every machine,
|
|
829
|
+
* so teammates converge on one claim instead of duplicating.
|
|
830
|
+
* @param {string} root @param {string[]} files @param {{t?:number}} [opts]
|
|
831
|
+
* @returns {{ok:true, id:string, existed:boolean, fingerprint:Fingerprint}|{ok:false, reason:string}}
|
|
832
|
+
*/
|
|
833
|
+
export function mintProjectFingerprint(root, files, { t = 0 } = {}) {
|
|
834
|
+
const fingerprint = fingerprintFiles(root, files);
|
|
835
|
+
const minted = mintClaim({
|
|
836
|
+
kind: "fingerprint",
|
|
837
|
+
body: fingerprint,
|
|
838
|
+
scope: { level: "repo" },
|
|
839
|
+
provenance: { agent: "uicheck", author: gitAuthor() },
|
|
840
|
+
t,
|
|
841
|
+
});
|
|
842
|
+
if (!minted.ok) return { ok: false, reason: "reason" in minted ? minted.reason : "mint failed" };
|
|
843
|
+
const dir = repoLedger(root);
|
|
844
|
+
const put = putClaim(dir, minted.claim);
|
|
845
|
+
if (!put.ok) return { ok: false, reason: put.reason ?? "putClaim failed" };
|
|
846
|
+
reindex(dir, t);
|
|
847
|
+
return { ok: true, id: minted.claim.id, existed: Boolean(put.existed), fingerprint };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The stored project fingerprint (latest live `fingerprint` claim), or null on a
|
|
852
|
+
* greenfield repo — the gate then runs slop-only (spec §2).
|
|
853
|
+
* @param {string} root
|
|
854
|
+
* @returns {Fingerprint|null}
|
|
855
|
+
*/
|
|
856
|
+
export function loadProjectFingerprint(root) {
|
|
857
|
+
const live = loadClaims(repoLedger(root)).filter((c) => c.kind === "fingerprint" && !c.tombstone);
|
|
858
|
+
if (!live.length) return null;
|
|
859
|
+
live.sort((a, b) => (b.provenance?.t ?? 0) - (a.provenance?.t ?? 0) || (a.id < b.id ? -1 : 1));
|
|
860
|
+
return live[0].body;
|
|
861
|
+
}
|