raptor-aios 0.13.2 → 0.14.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/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.0] - 2026-06-21
9
+
10
+ ### Added
11
+
12
+ - **Loop de fidelidade visual (Figma → React Native) — nota por micro-região.**
13
+ Transforma "a tela tem os tokens" em "a tela replica o Figma". Cada tela ganha
14
+ uma seção `## Anatomy` em `design/screens/<slug>.md` (regiões→elementos amarrados
15
+ a token) — o **contrato** de fidelidade. Um agente `design-fidelity` (read-only,
16
+ adversarial) pontua cada micro-região 60–100 (nota = a dimensão mais fraca; mínimo
17
+ default 95) e escreve `design/visual-review/<slug>.fidelity.json` (schema
18
+ `raptor.fidelity/v1`). Peças:
19
+ - `gate.design.fidelity` (estático, `required`): lê o scorecard e trava o
20
+ `raptor verify` se alguma região < mínimo, hex hardcoded, screenshot de evidência
21
+ ausente/duplicada, ou divergência `design-decision` sem aprovação — nunca roda o
22
+ app (como os gates M7 leem `impl-log.md`). Piso de threshold: o scorecard pode
23
+ endurecer, nunca afrouxar.
24
+ - `raptor verify fidelity [feature]`: lente read-only que pontua os scorecards.
25
+ - `raptor design fidelity <feature>`: driver do loop — reporta a fidelidade e emite
26
+ o prompt do scorer; o loop é dirigido pelo agente iterando até convergir.
27
+ - Backbone determinístico (anti-gaming, medido pelo Raptor): `hex-scan` (hex +
28
+ `rgb()/rgba()/hsl()` + cores nomeadas em prop de cor), `token-diff` (drift
29
+ Figma×impl) e `scripts/app/screenshot.sh` (captura de evidência iOS/Android).
30
+ - Evento de auditoria `design.fidelity.scored` registra cada ciclo.
31
+ - Doc: [docs/visual-fidelity-loop.md](docs/visual-fidelity-loop.md).
32
+
8
33
  ## [0.13.2] - 2026-06-19
9
34
 
10
35
  ### Fixed
package/README.md CHANGED
@@ -454,6 +454,7 @@ Declarar não é cumprir. O Raptor **mede** e compara com o declarado:
454
454
  | `raptor verify stores <feat>` | M1 | Compara `spec.stores` com `AndroidManifest.xml`/`Info.plist`; `--fix` adiciona faltantes |
455
455
  | `raptor verify os-matrix <feat>` | M4 | Compara `plan.os_matrix` com Gradle/Podfile; `--fix` alinha |
456
456
  | `raptor verify architecture <feat>` | M8 (exp.) | Checagem estática de arquitetura limpa sobre o grafo de imports |
457
+ | `raptor verify fidelity <feat>` | M3 | Pontua os scorecards de fidelidade visual por micro-região (60–100, mín. 95) vs Figma |
457
458
  | `raptor verify <feat>` | — | Roda **todos** os gates da feature de uma vez |
458
459
 
459
460
  > 🧪 **Exemplo real:** `verify stores` detectou que `ACCESS_FINE_LOCATION` e `POST_NOTIFICATIONS` estavam na spec mas **ausentes** do `AndroidManifest.xml` — e `--fix` corrigiu. `verify perf` mediu TTFI mediano de **957ms** contra orçamento de 2000ms.
@@ -472,7 +473,7 @@ Declarar não é cumprir. O Raptor **mede** e compara com o declarado:
472
473
  | 🪝 **Hooks** | Scripts disparados em **34 pontos** do ciclo (`before_*`/`after_*` de 17 comandos). | `raptor hook list · run` |
473
474
  | 📦 **Extensões** | Pacotes self-contained (templates/gates/hooks/workflows). | `raptor extension add · list · info · remove` |
474
475
  | 🎟️ **Jira** | Puxa issues do Jira (somente leitura) para semear specs. | `raptor jira connect · status · pull · disconnect` |
475
- | 🎨 **Figma / Design** | Captura **determinística** de tokens/telas/assets/fontes do Figma (provider `figma-rest` + PAT) e semeia `design/`. | `raptor design connect · status · pull · sync · disconnect` |
476
+ | 🎨 **Figma / Design** | Captura **determinística** de tokens/telas/assets/fontes do Figma (provider `figma-rest` + PAT) e semeia `design/`; **loop de fidelidade visual** com nota por micro-região. | `raptor design connect · status · pull · sync · fidelity · disconnect` |
476
477
  | 📜 **Auditoria & trace** | Consulta a trilha de eventos e a rastreabilidade. | `raptor audit query · show` · `raptor trace` |
477
478
 
478
479
  ### 🎟️ Integração com Jira
@@ -497,6 +498,18 @@ raptor design sync home # re-captura idempotente (nunca sobrescreve
497
498
 
498
499
  O provider **`figma-rest`** usa a REST API da Figma com um PAT (`${FIGMA_TOKEN}` no `raptor.yml`), é determinístico e roda em CI. A captura gera `design/tokens.json` (colors/typography/spacing/radii), `design/screens/<slug>.md` (frames de topo) e `design/assets-manifest.json` (assets baixados com SHA-256, guarda anti-SSRF). Quando há captura real, os prompts fazem o **flip REDISTRIBUIR**: o agente passa a *refinar e redistribuir* os artefatos em disco em vez de "olhar o Figma" — cobrado pelo `gate.design.ready`.
499
500
 
501
+ #### 🎯 Loop de fidelidade visual (Figma → React Native)
502
+
503
+ Para garantir que a tela implementada **replica o Figma** (e não só "tem os tokens"), o Raptor adota anatomia + nota por micro-região:
504
+
505
+ ```bash
506
+ raptor design fidelity home-entregador # driver do loop: reporta a fidelidade e emite o prompt do scorer
507
+ raptor verify fidelity home-entregador # lente read-only: nota por micro-região (60–100, mín. 95)
508
+ raptor verify home-entregador # gate.design.fidelity (estático) trava se alguma região < mínimo
509
+ ```
510
+
511
+ Cada tela ganha uma seção `## Anatomy` em `design/screens/<slug>.md` (regiões→elementos amarrados a token) — o **contrato** de fidelidade. O agente `design-fidelity` (read-only, adversarial — "na dúvida, reprova") extrai os valores reais do Figma, captura o app rodando (`scripts/app/screenshot.sh`), pontua cada micro-região e escreve `design/visual-review/<slug>.fidelity.json` (schema `raptor.fidelity/v1`). O **backbone determinístico** que o Raptor mede sozinho — hex hardcoded (`scripts/review/hex-scan.mjs`), drift de token (`scripts/review/diff-tokens.mjs`), screenshots de evidência — impede inflar a nota. O `gate.design.fidelity` é **estático** (lê o scorecard, nunca roda o app), como os gates M7 leem o `impl-log.md`. Detalhes: [docs/visual-fidelity-loop.md](docs/visual-fidelity-loop.md).
512
+
500
513
  > 📖 Detalhes, providers e limitações: [docs/design-figma-sync.md](docs/design-figma-sync.md) e [docs/design-gate.md](docs/design-gate.md).
501
514
 
502
515
  ---
@@ -37,12 +37,68 @@ const PHASE_MAP = {
37
37
  " artefatos semeados (`design/tokens.json` populado, `design/screens/*.md`), REFINE e",
38
38
  " redistribua esses artefatos em vez de re-buscar; senão use o Figma MCP para ENXERGAR o",
39
39
  " design. Derive telas/fluxos/critérios das telas reais e refine (ou crie) `design/screens/<nome>.md`.",
40
+ "9. Para cada tela, AUTORE a anatomia: uma seção `## Anatomy` em `design/screens/<nome>.md` decompondo",
41
+ " a tela em regiões→elementos, cada valor amarrado a token (`{color.*}`/`{space.*}`/`{radius.*}`/`{type.*}`).",
42
+ " Gramática: `- region: <id> — <notas>` e itens indentados `- <elemento> — <spec>`. É o CONTRATO de",
43
+ " fidelidade pontuado pós-implement por `raptor design fidelity` + `gate.design.fidelity`.",
40
44
  "",
41
45
  "### Feature a especificar:",
42
46
  "",
43
47
  "{{DESCRIPTION}}",
44
48
  ].join("\n"),
45
49
  },
50
+ "design-fidelity": {
51
+ command: "design-fidelity",
52
+ inputs: [
53
+ { filename: "spec.md", role: "spec" },
54
+ { filename: "constitution.md", role: "constitution" },
55
+ ],
56
+ expectedOutput: "specs/{feature}/design/visual-review/*.fidelity.json",
57
+ outputSchema: "raptor.fidelity/v1",
58
+ scope: {
59
+ read: ["specs/{feature}/**", "src/**", "assets/**", ".raptor/constitution.md"],
60
+ write: ["specs/{feature}/design/visual-review/**"],
61
+ forbidden: [
62
+ "src/**",
63
+ ".raptor/audit.jsonl",
64
+ "specs/{feature}/spec.md",
65
+ "specs/{feature}/plan.md",
66
+ "specs/{feature}/tasks.md",
67
+ ],
68
+ },
69
+ bodyTemplate: [
70
+ "## Instrução",
71
+ "",
72
+ "Você é o agente de FIDELIDADE VISUAL do Raptor — read-only no código. Seu papel é",
73
+ "PONTUAR a fidelidade da tela implementada contra o Figma, com rigor adversarial:",
74
+ "na dúvida, REPROVE. Você produz EVIDÊNCIA; não edita `src/` enquanto pontua",
75
+ "(a correção é etapa separada do loop).",
76
+ "",
77
+ "Para CADA tela que declara `## Anatomy` (em `design/screens/<tela>.md`) e cada estado",
78
+ "relevante (disponível, loading, erro, vazio…):",
79
+ "1. Leia a ANATOMIA da tela (seção `## Anatomy`): regiões→elementos→tokens.",
80
+ "2. Extraia do Figma os valores REAIS do nó com `get_design_context` / `get_variable_defs` /",
81
+ " `get_screenshot`. REGRA DE OURO: o nó vence a percepção — nunca chute px/cor/raio/peso.",
82
+ "3. Capture o app RODANDO (evidência, não invente): rode",
83
+ " `scripts/app/screenshot.sh design/visual-review/_shots/<tela>.<estado>.app.png` e salve o",
84
+ " screenshot do Figma como `…<estado>.figma.png`.",
85
+ "4. Compare Figma×app POR MICRO-REGIÃO e pontue 60–100 em cada dimensão",
86
+ " (layout, spacing, color, typography, dimensions, icon, states). A nota da região = a",
87
+ " dimensão MAIS FRACA (o elo). Mínimo de aprovação = 95 (ou o threshold do preset).",
88
+ "5. Backbone determinístico (não falsifique): rode `node scripts/review/hex-scan.mjs src --json`",
89
+ " e preencha `deterministic.hex_hardcoded` com o resultado REAL; rode",
90
+ " `node scripts/review/diff-tokens.mjs <figma-tokens.json> design/tokens.json --json` p/ o drift.",
91
+ "6. Registre cada divergência `{ dimension, figma, rn, fix, severity, status }`. `status`:",
92
+ " `resolved` (corrigida), `open` (ainda falha) ou `design-decision` (desvio semântico —",
93
+ " troca de ícone/cor/copy — que EXIGE aprovação humana antes de passar o gate).",
94
+ "7. Escreva `design/visual-review/<tela>.fidelity.json` (schema `raptor.fidelity/v1`):",
95
+ " `states[].regions[]` com `score`+`dimensions`+`divergences`, `screenshot_figma`/`screenshot_app`,",
96
+ " `deterministic`, e `learnings[]` (aprendizados reutilizáveis). TODA região da anatomia deve",
97
+ " ser pontuada.",
98
+ "8. NÃO edite `src/` aqui. `gate.design.fidelity` trava o `raptor verify` até toda região ≥ mínimo,",
99
+ " sem hex hardcoded e com screenshots de evidência presentes em disco.",
100
+ ].join("\n"),
101
+ },
46
102
  plan: {
47
103
  command: "plan",
48
104
  inputs: [
@@ -65,6 +65,7 @@ export const auditEventSchema = {
65
65
  "jira.issues.created",
66
66
  "design.imported",
67
67
  "design.synced",
68
+ "design.fidelity.scored",
68
69
  "branch.created",
69
70
  "branch.switched",
70
71
  "commit.guarded",
@@ -0,0 +1,82 @@
1
+ import { slugify } from "./slug.js";
2
+ const LIST_ITEM = /^(\s*)[-*]\s+(.*\S)\s*$/;
3
+ const REGION_PREFIX = /^(?:region|regi[aã]o)\s*:\s*(.+)$/i;
4
+ const DELIM = /\s+[—–-]\s+|\s*:\s+/;
5
+ function splitHead(text) {
6
+ const m = DELIM.exec(text);
7
+ if (!m || m.index <= 0)
8
+ return { head: text.trim() };
9
+ return {
10
+ head: text.slice(0, m.index).trim(),
11
+ tail: text.slice(m.index + m[0].length).trim() || undefined,
12
+ };
13
+ }
14
+ function sliceAnatomy(content) {
15
+ const lines = content.split(/\r?\n/);
16
+ let start = -1;
17
+ for (let i = 0; i < lines.length; i++) {
18
+ if (/^##\s+anatomy\b/i.test(lines[i].trim())) {
19
+ start = i + 1;
20
+ break;
21
+ }
22
+ }
23
+ if (start === -1)
24
+ return null;
25
+ const out = [];
26
+ for (let i = start; i < lines.length; i++) {
27
+ if (/^#{1,6}\s+\S/.test(lines[i]))
28
+ break;
29
+ out.push(lines[i]);
30
+ }
31
+ return out;
32
+ }
33
+ export function parseAnatomy(content) {
34
+ const body = sliceAnatomy(content);
35
+ if (body === null) {
36
+ return { regions: [], problems: ["no `## Anatomy` section found"] };
37
+ }
38
+ const regions = [];
39
+ const problems = [];
40
+ const seenIds = new Set();
41
+ let current = null;
42
+ let inFence = false;
43
+ for (const line of body) {
44
+ if (/^\s*(?:```|~~~)/.test(line)) {
45
+ inFence = !inFence;
46
+ continue;
47
+ }
48
+ if (inFence)
49
+ continue;
50
+ const item = LIST_ITEM.exec(line);
51
+ if (!item)
52
+ continue;
53
+ const text = item[2].trim();
54
+ const region = REGION_PREFIX.exec(text);
55
+ if (region) {
56
+ const { head, tail } = splitHead(region[1].trim());
57
+ const id = slugify(head);
58
+ if (!id) {
59
+ problems.push(`region with empty label: "${text}"`);
60
+ current = null;
61
+ continue;
62
+ }
63
+ if (seenIds.has(id)) {
64
+ problems.push(`duplicate region "${id}"`);
65
+ }
66
+ seenIds.add(id);
67
+ current = { id, label: head, ...(tail ? { notes: tail } : {}), elements: [] };
68
+ regions.push(current);
69
+ continue;
70
+ }
71
+ if (!current) {
72
+ problems.push(`element "${text}" appears before any region`);
73
+ continue;
74
+ }
75
+ const { head, tail } = splitHead(text);
76
+ current.elements.push({ name: head, ...(tail ? { spec: tail } : {}) });
77
+ }
78
+ if (regions.length === 0 && problems.length === 0) {
79
+ problems.push("`## Anatomy` section has no regions");
80
+ }
81
+ return { regions, problems };
82
+ }
@@ -0,0 +1,258 @@
1
+ export const FIDELITY_SCORECARD_SCHEMA = "raptor.fidelity/v1";
2
+ export const FIDELITY_DIMENSIONS = [
3
+ "layout",
4
+ "spacing",
5
+ "color",
6
+ "typography",
7
+ "dimensions",
8
+ "icon",
9
+ "states",
10
+ ];
11
+ export const DEFAULT_FIDELITY_THRESHOLD = 95;
12
+ export const DIVERGENCE_SEVERITIES = ["minor", "major", "blocker"];
13
+ export const DIVERGENCE_STATUSES = ["resolved", "open", "design-decision"];
14
+ function isRecord(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ function isScore(v) {
18
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 100;
19
+ }
20
+ function validateDivergence(d, label, problems) {
21
+ if (!isRecord(d)) {
22
+ problems.push(`${label}: divergence must be an object`);
23
+ return null;
24
+ }
25
+ const dim = d["dimension"];
26
+ if (typeof dim !== "string" || !FIDELITY_DIMENSIONS.includes(dim)) {
27
+ problems.push(`${label}: divergence "dimension" must be one of ${FIDELITY_DIMENSIONS.join("|")}`);
28
+ }
29
+ const sev = d["severity"];
30
+ if (typeof sev !== "string" || !DIVERGENCE_SEVERITIES.includes(sev)) {
31
+ problems.push(`${label}: divergence "severity" must be one of ${DIVERGENCE_SEVERITIES.join("|")}`);
32
+ }
33
+ const status = d["status"];
34
+ if (typeof status !== "string" || !DIVERGENCE_STATUSES.includes(status)) {
35
+ problems.push(`${label}: divergence "status" must be one of ${DIVERGENCE_STATUSES.join("|")}`);
36
+ }
37
+ return {
38
+ dimension: dim,
39
+ figma: typeof d["figma"] === "string" ? d["figma"] : "",
40
+ rn: typeof d["rn"] === "string" ? d["rn"] : "",
41
+ ...(typeof d["fix"] === "string" ? { fix: d["fix"] } : {}),
42
+ severity: sev,
43
+ status: status,
44
+ };
45
+ }
46
+ function validateRegion(r, label, problems) {
47
+ if (!isRecord(r)) {
48
+ problems.push(`${label} must be an object`);
49
+ return null;
50
+ }
51
+ const id = r["id"];
52
+ if (typeof id !== "string" || !id.trim()) {
53
+ problems.push(`${label}: missing "id"`);
54
+ }
55
+ if (!isScore(r["score"])) {
56
+ problems.push(`${label}: "score" must be a number 0–100`);
57
+ }
58
+ let dimensions;
59
+ if (r["dimensions"] !== undefined) {
60
+ if (!isRecord(r["dimensions"])) {
61
+ problems.push(`${label}: "dimensions" must be an object`);
62
+ }
63
+ else {
64
+ dimensions = {};
65
+ for (const [k, v] of Object.entries(r["dimensions"])) {
66
+ if (!FIDELITY_DIMENSIONS.includes(k)) {
67
+ problems.push(`${label}: unknown dimension "${k}"`);
68
+ continue;
69
+ }
70
+ if (!isScore(v)) {
71
+ problems.push(`${label}: dimension "${k}" must be a number 0–100`);
72
+ continue;
73
+ }
74
+ dimensions[k] = v;
75
+ }
76
+ }
77
+ }
78
+ let divergences;
79
+ if (r["divergences"] !== undefined) {
80
+ if (!Array.isArray(r["divergences"])) {
81
+ problems.push(`${label}: "divergences" must be an array`);
82
+ }
83
+ else {
84
+ divergences = [];
85
+ r["divergences"].forEach((d, i) => {
86
+ const parsed = validateDivergence(d, `${label}.divergences[${i}]`, problems);
87
+ if (parsed)
88
+ divergences.push(parsed);
89
+ });
90
+ }
91
+ }
92
+ return {
93
+ id: typeof id === "string" ? id : "",
94
+ ...(typeof r["label"] === "string" ? { label: r["label"] } : {}),
95
+ score: isScore(r["score"]) ? r["score"] : 0,
96
+ ...(dimensions ? { dimensions } : {}),
97
+ ...(divergences ? { divergences } : {}),
98
+ };
99
+ }
100
+ export function parseFidelityScorecard(raw) {
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ }
105
+ catch (err) {
106
+ return {
107
+ scorecard: null,
108
+ problems: [`fidelity scorecard is not valid JSON: ${err instanceof Error ? err.message : String(err)}`],
109
+ };
110
+ }
111
+ if (!isRecord(parsed)) {
112
+ return { scorecard: null, problems: ["fidelity scorecard must be a JSON object"] };
113
+ }
114
+ const problems = [];
115
+ if (parsed["schema"] !== FIDELITY_SCORECARD_SCHEMA) {
116
+ problems.push(`fidelity scorecard schema must be "${FIDELITY_SCORECARD_SCHEMA}" (got ${JSON.stringify(parsed["schema"])})`);
117
+ return { scorecard: null, problems };
118
+ }
119
+ if (typeof parsed["screen"] !== "string" || !parsed["screen"].trim()) {
120
+ problems.push('fidelity scorecard: missing "screen"');
121
+ }
122
+ if (parsed["threshold"] !== undefined && !isScore(parsed["threshold"])) {
123
+ problems.push('fidelity scorecard: "threshold" must be a number 0–100');
124
+ }
125
+ const states = [];
126
+ const rawStates = parsed["states"];
127
+ if (!Array.isArray(rawStates)) {
128
+ problems.push('fidelity scorecard: "states" must be an array');
129
+ }
130
+ else {
131
+ rawStates.forEach((st, si) => {
132
+ if (!isRecord(st)) {
133
+ problems.push(`state[${si}] must be an object`);
134
+ return;
135
+ }
136
+ const stateName = typeof st["state"] === "string" && st["state"].trim() ? st["state"] : `state[${si}]`;
137
+ if (typeof st["state"] !== "string" || !st["state"].trim()) {
138
+ problems.push(`state[${si}]: missing "state"`);
139
+ }
140
+ const regions = [];
141
+ if (!Array.isArray(st["regions"])) {
142
+ problems.push(`state "${stateName}": "regions" must be an array`);
143
+ }
144
+ else {
145
+ st["regions"].forEach((r, ri) => {
146
+ const region = validateRegion(r, `state "${stateName}" region[${ri}]`, problems);
147
+ if (region)
148
+ regions.push(region);
149
+ });
150
+ }
151
+ states.push({
152
+ state: typeof st["state"] === "string" ? st["state"] : stateName,
153
+ ...(typeof st["screenshot_figma"] === "string" ? { screenshot_figma: st["screenshot_figma"] } : {}),
154
+ ...(typeof st["screenshot_app"] === "string" ? { screenshot_app: st["screenshot_app"] } : {}),
155
+ regions,
156
+ });
157
+ });
158
+ }
159
+ let deterministic;
160
+ if (parsed["deterministic"] !== undefined) {
161
+ if (!isRecord(parsed["deterministic"])) {
162
+ problems.push('fidelity scorecard: "deterministic" must be an object');
163
+ }
164
+ else {
165
+ const det = parsed["deterministic"];
166
+ deterministic = {
167
+ ...(Array.isArray(det["hex_hardcoded"]) ? { hex_hardcoded: det["hex_hardcoded"].map(String) } : {}),
168
+ ...(typeof det["screenshots_present"] === "boolean" ? { screenshots_present: det["screenshots_present"] } : {}),
169
+ ...(typeof det["token_diff"] === "string" ? { token_diff: det["token_diff"] } : {}),
170
+ };
171
+ }
172
+ }
173
+ const scorecard = {
174
+ schema: FIDELITY_SCORECARD_SCHEMA,
175
+ screen: typeof parsed["screen"] === "string" ? parsed["screen"] : "",
176
+ ...(typeof parsed["node_id"] === "string" ? { node_id: parsed["node_id"] } : {}),
177
+ ...(isScore(parsed["threshold"]) ? { threshold: parsed["threshold"] } : {}),
178
+ states,
179
+ ...(deterministic ? { deterministic } : {}),
180
+ ...(isScore(parsed["min_score"]) ? { min_score: parsed["min_score"] } : {}),
181
+ ...(typeof parsed["passed"] === "boolean" ? { passed: parsed["passed"] } : {}),
182
+ ...(Array.isArray(parsed["learnings"]) ? { learnings: parsed["learnings"].map(String) } : {}),
183
+ };
184
+ return { scorecard, problems };
185
+ }
186
+ export function regionScore(region) {
187
+ const dims = region.dimensions
188
+ ? Object.values(region.dimensions).filter((n) => typeof n === "number" && Number.isFinite(n))
189
+ : [];
190
+ if (dims.length > 0)
191
+ return Math.min(...dims);
192
+ return typeof region.score === "number" ? region.score : 0;
193
+ }
194
+ export function evaluateScorecard(card, opts) {
195
+ const threshold = opts?.threshold ?? card.threshold ?? DEFAULT_FIDELITY_THRESHOLD;
196
+ const regions = [];
197
+ const pendingDesignDecisions = [];
198
+ for (const st of card.states ?? []) {
199
+ for (const r of st.regions ?? []) {
200
+ const reasons = [];
201
+ const score = regionScore(r);
202
+ if (score < threshold)
203
+ reasons.push(`score ${score} < threshold ${threshold}`);
204
+ const dimVals = r.dimensions
205
+ ? Object.values(r.dimensions).filter((n) => typeof n === "number" && Number.isFinite(n))
206
+ : [];
207
+ if (r.dimensions !== undefined) {
208
+ if (dimVals.length === 0) {
209
+ reasons.push("declared dimensions breakdown is empty");
210
+ }
211
+ else if (typeof r.score === "number") {
212
+ const weakest = Math.min(...dimVals);
213
+ if (r.score !== weakest) {
214
+ reasons.push(`declared score ${r.score} must equal the weakest dimension ${weakest}`);
215
+ }
216
+ }
217
+ }
218
+ for (const d of r.divergences ?? []) {
219
+ if (d.status === "open")
220
+ reasons.push(`open divergence in ${d.dimension}`);
221
+ if (d.status === "design-decision") {
222
+ pendingDesignDecisions.push({ state: st.state, region: r.id, dimension: d.dimension });
223
+ }
224
+ }
225
+ regions.push({
226
+ state: st.state,
227
+ region: r.id,
228
+ score,
229
+ threshold,
230
+ pass: reasons.length === 0,
231
+ reasons,
232
+ });
233
+ }
234
+ }
235
+ const hexHardcoded = card.deterministic?.hex_hardcoded ?? [];
236
+ const screenshotsPresent = card.deterministic?.screenshots_present ?? false;
237
+ const failing = regions.filter((r) => !r.pass);
238
+ const reasons = [];
239
+ if (regions.length === 0)
240
+ reasons.push("scorecard scored no regions");
241
+ for (const st of card.states ?? []) {
242
+ if ((st.regions?.length ?? 0) === 0)
243
+ reasons.push(`state "${st.state}" has no regions`);
244
+ }
245
+ if (hexHardcoded.length > 0)
246
+ reasons.push(`${hexHardcoded.length} hardcoded hex colour(s)`);
247
+ const ok = reasons.length === 0 && failing.length === 0;
248
+ return {
249
+ threshold,
250
+ regions,
251
+ failing,
252
+ hexHardcoded,
253
+ screenshotsPresent,
254
+ pendingDesignDecisions,
255
+ reasons,
256
+ ok,
257
+ };
258
+ }
@@ -0,0 +1,62 @@
1
+ const TOKEN_DIR = /(?:^|\/)(?:design-system|design_system|tokens?|theme|palette|colou?rs?)\//i;
2
+ const HEX = /(?<![\w#&])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
3
+ const COLOR_FUNC = /(?<!\w)(?:rgba?|hsla?)\s*\([^)]*\)/gi;
4
+ const NAMED_COLOR_ASSIGN = /\b\w*(?:[Cc]olor|[Ff]ill|[Ss]troke|[Tt]int|[Ss]hadow)\b\s*[:=]\s*["']([A-Za-z]+)["']/g;
5
+ const CSS_NAMED_COLORS = new Set([
6
+ "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond",
7
+ "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue",
8
+ "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey",
9
+ "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon",
10
+ "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink",
11
+ "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia",
12
+ "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", "grey", "honeydew", "hotpink",
13
+ "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue",
14
+ "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink",
15
+ "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue",
16
+ "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue",
17
+ "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise",
18
+ "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace",
19
+ "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise",
20
+ "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",
21
+ "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna",
22
+ "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal",
23
+ "thistle", "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen",
24
+ ]);
25
+ function isCommentLine(trimmed) {
26
+ return (trimmed.startsWith("//") ||
27
+ trimmed.startsWith("*") ||
28
+ trimmed.startsWith("/*") ||
29
+ trimmed.startsWith("{/*"));
30
+ }
31
+ export function scanHardcodedHex(files, opts) {
32
+ const findings = [];
33
+ for (const { path, content } of files) {
34
+ if (TOKEN_DIR.test(path))
35
+ continue;
36
+ if (opts?.allowPaths?.some((p) => path.includes(p)))
37
+ continue;
38
+ const lines = content.split(/\r?\n/);
39
+ lines.forEach((line, i) => {
40
+ const trimmed = line.trim();
41
+ if (isCommentLine(trimmed))
42
+ return;
43
+ const push = (hex) => findings.push({ file: path, line: i + 1, hex, snippet: trimmed.slice(0, 120) });
44
+ for (const re of [HEX, COLOR_FUNC]) {
45
+ re.lastIndex = 0;
46
+ let m;
47
+ while ((m = re.exec(line)))
48
+ push(m[0]);
49
+ }
50
+ NAMED_COLOR_ASSIGN.lastIndex = 0;
51
+ let m;
52
+ while ((m = NAMED_COLOR_ASSIGN.exec(line))) {
53
+ if (CSS_NAMED_COLORS.has(m[1].toLowerCase()))
54
+ push(m[1]);
55
+ }
56
+ });
57
+ }
58
+ return findings;
59
+ }
60
+ export function hexFindingsToScorecard(findings) {
61
+ return findings.map((f) => `${f.hex} at ${f.file}:${f.line}`);
62
+ }
@@ -9,6 +9,10 @@ export * from "./slug.js";
9
9
  export * from "./asset-paths.js";
10
10
  export * from "./asset-normalize.js";
11
11
  export * from "./screens.js";
12
+ export * from "./anatomy.js";
13
+ export * from "./fidelity.js";
14
+ export * from "./hex-scan.js";
15
+ export * from "./token-diff.js";
12
16
  export * from "./downloader.js";
13
17
  export * from "./font-resolver.js";
14
18
  export * from "./figma-dialects.js";
@@ -0,0 +1,64 @@
1
+ import { slugify } from "./slug.js";
2
+ function normColor(v) {
3
+ const s = v.trim().toUpperCase();
4
+ const m = /^#([0-9A-F])([0-9A-F])([0-9A-F])$/.exec(s);
5
+ return m ? `#${m[1]}${m[1]}${m[2]}${m[2]}${m[3]}${m[3]}` : s;
6
+ }
7
+ function normDim(v) {
8
+ return v.trim().replace(/px$/i, "").trim();
9
+ }
10
+ function normType(t) {
11
+ return [
12
+ t.fontFamily?.trim() ?? "",
13
+ t.fontSize ? normDim(t.fontSize) : "",
14
+ t.fontWeight?.toString().trim() ?? "",
15
+ t.lineHeight ? normDim(t.lineHeight) : "",
16
+ t.letterSpacing ? normDim(t.letterSpacing) : "",
17
+ ].join("|");
18
+ }
19
+ function diffMaps(category, figma, impl) {
20
+ const entries = [];
21
+ for (const [key, f] of figma) {
22
+ const i = impl.get(key);
23
+ if (!i) {
24
+ entries.push({ category, name: f.name, figma: f.value, status: "missing" });
25
+ }
26
+ else {
27
+ entries.push({
28
+ category,
29
+ name: f.name,
30
+ figma: f.value,
31
+ impl: i.value,
32
+ status: f.value === i.value ? "match" : "mismatch",
33
+ });
34
+ }
35
+ }
36
+ for (const [key, i] of impl) {
37
+ if (!figma.has(key)) {
38
+ entries.push({ category, name: i.name, impl: i.value, status: "extra" });
39
+ }
40
+ }
41
+ return entries;
42
+ }
43
+ function mapColors(list) {
44
+ return new Map(list.map((c) => [slugify(c.name), { name: c.name, value: normColor(c.value) }]));
45
+ }
46
+ function mapDims(list) {
47
+ return new Map(list.map((s) => [slugify(s.name), { name: s.name, value: normDim(s.value) }]));
48
+ }
49
+ function mapType(list) {
50
+ return new Map(list.map((t) => [slugify(t.name), { name: t.name, value: normType(t) }]));
51
+ }
52
+ export function diffDesignTokens(figma, impl) {
53
+ const entries = [
54
+ ...diffMaps("colors", mapColors(figma.colors), mapColors(impl.colors)),
55
+ ...diffMaps("typography", mapType(figma.typography), mapType(impl.typography)),
56
+ ...diffMaps("spacing", mapDims(figma.spacing), mapDims(impl.spacing)),
57
+ ...diffMaps("radii", mapDims(figma.radii ?? []), mapDims(impl.radii ?? [])),
58
+ ];
59
+ const matched = entries.filter((e) => e.status === "match").length;
60
+ const mismatched = entries.filter((e) => e.status === "mismatch").length;
61
+ const missing = entries.filter((e) => e.status === "missing").length;
62
+ const extra = entries.filter((e) => e.status === "extra").length;
63
+ return { entries, matched, mismatched, missing, extra, ok: mismatched === 0 && missing === 0 };
64
+ }