raptor-aios 0.9.0 → 0.11.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/_core/dist/agents/prompt-builder.js +10 -8
  3. package/dist/_core/dist/audit/schema.js +1 -0
  4. package/dist/_core/dist/design/asset-normalize.js +37 -0
  5. package/dist/_core/dist/design/asset-paths.js +23 -0
  6. package/dist/_core/dist/design/downloader.js +140 -0
  7. package/dist/_core/dist/design/fetcher.js +203 -0
  8. package/dist/_core/dist/design/figma-client.js +198 -0
  9. package/dist/_core/dist/design/figma-credentials.js +41 -0
  10. package/dist/_core/dist/design/figma-dialects.js +75 -0
  11. package/dist/_core/dist/design/figma-oauth.js +71 -0
  12. package/dist/_core/dist/design/figma-rest.js +277 -0
  13. package/dist/_core/dist/design/font-resolver.js +298 -0
  14. package/dist/_core/dist/design/index.js +12 -0
  15. package/dist/_core/dist/design/mapper.js +22 -2
  16. package/dist/_core/dist/design/scaffold.js +58 -2
  17. package/dist/_core/dist/design/screens.js +202 -0
  18. package/dist/_core/dist/design/slug.js +9 -0
  19. package/dist/_core/dist/design/tokens.js +2 -1
  20. package/dist/_core/dist/gates/design-gates.js +1 -9
  21. package/dist/_core/dist/jira/dialects.js +2 -2
  22. package/dist/_core/dist/jira/index.js +1 -1
  23. package/dist/_core/dist/jira/mapper.js +152 -13
  24. package/dist/_core/dist/jira/mcp-client.js +16 -131
  25. package/dist/_core/dist/jira/oauth.js +1 -79
  26. package/dist/_core/dist/transport/index.js +2 -0
  27. package/dist/_core/dist/transport/mcp.js +134 -0
  28. package/dist/_core/dist/transport/oauth.js +83 -0
  29. package/dist/_core/package.json +1 -1
  30. package/dist/_core/templates/spec.md.hbs +32 -5
  31. package/dist/commands/design/connect.js +105 -0
  32. package/dist/commands/design/disconnect.js +19 -0
  33. package/dist/commands/design/pull.js +65 -0
  34. package/dist/commands/design/status.js +76 -0
  35. package/dist/commands/design/sync.js +55 -0
  36. package/dist/commands/jira/pull.js +7 -1
  37. package/dist/commands/new.js +36 -2
  38. package/dist/shared/design.js +93 -0
  39. package/dist/shared/jira.js +18 -7
  40. package/package.json +1 -1
  41. package/scripts/prepare-npm.mjs +1 -1
  42. package/templates/commands/plan.md +5 -3
  43. package/templates/commands/specify.md +9 -5
  44. package/templates/commands/tasks.md +5 -3
package/CHANGELOG.md CHANGED
@@ -3,6 +3,42 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [0.11.0] - 2026-06-12
9
+
10
+ ### Added
11
+
12
+ - **Integração Figma/Design — captura determinística + seed + gate (`packages/core/src/design/`).** Um link do Figma em `raptor new --figma <url>` deixa de ser só uma URL na spec: o Raptor CAPTURA o design de forma determinística e SEMEIA `design/` com os artefatos reais (espelhando o modelo do enriquecimento Jira). A captura usa o provider **`figma-rest`** (REST API + Personal Access Token, determinístico e roda em CI), selecionável via `design.provider` no `raptor.yml`.
13
+ - **Tokens** via `GET /v1/files/:key/variables/local` (com fallback para `/styles` quando o endpoint dá `403` em planos não-Enterprise ou volta vazio), normalizados em `design/tokens.json` (colors/typography/spacing/radii) por uma classificação robusta a nomes inconsistentes do mundo real (o valor decide antes do nome; tipografia decomposta em family/size/weight/lineHeight/letterSpacing — um número nunca vira família).
14
+ - **Telas** via `GET /v1/files/:key/nodes` (nó linkado) ou `?depth=2` (arquivo inteiro) → `design/screens/<slug>.md`.
15
+ - **Assets** escopados ao frame: o render do nó (`/v1/images`) + os image fills que a subárvore realmente pinta (`collectImageRefs` sobre `/nodes`, filtrando o `/v1/files/:key/images` que devolve o arquivo inteiro) — baixados com `sha256`, guarda anti-SSRF e `max_bytes`, catalogados em `design/assets-manifest.json`.
16
+ - **Fontes** (o Figma não serve o binário) resolvidas por precedência repo/brand → Google Fonts → OS (`fc-match`) → `pending`.
17
+ - **Comandos** `raptor design connect | status | pull | sync | disconnect` (o `sync` re-semeia idempotente — nunca sobrescreve refino — e emite `design.synced`).
18
+ - **Flip REDISTRIBUIR**: `spec.md.hbs`, os prompts de comando (`specify`/`plan`/`tasks`) e o prompt canônico bifurcam — quando o design foi semeado, instruem o agente a **REFINAR e redistribuir** os artefatos em disco (referenciar por `[screen: X]`/`[asset: Y]`, refinar os `screens/*.md`, verificar os tokens, declarar as libs); sem captura, mantêm a instrução de **ENXERGAR** via Figma MCP. O `spec.md.hbs` lista o que foi semeado (contagem de tokens, telas, assets, libs).
19
+ - **`gate.design.ready`** cobra a incorporação na promoção da spec (telas/assets referenciados existem e estão em disco; tokens não-vazios).
20
+ - **Auditoria** enriquecida (`feature.created`/`design.imported` com contagens de tokens/telas/assets; novo evento `design.synced`).
21
+ - Validado E2E contra o arquivo Check-in real (`figma-rest`/PAT) e endurecido por revisão adversarial multi-dimensão: escopo de assets (1357 imagens espúrias → 0), classificação de tokens (63 → 93 bem distribuídos, 0 famílias falsas), memoização de `/nodes`. Doc: [`docs/design-figma-sync.md`](docs/design-figma-sync.md).
22
+
23
+ ## [0.10.0] - 2026-06-11
24
+
25
+ ### Added
26
+
27
+ - **Jira → spec: nomes de campo resolvidos + filtro de relevância (`packages/core/src/jira/`).** Um card de Jira corporativo carrega centenas de custom fields de configuração (o `CDPOS-6532` real: 1408 campos, 515 não-nulos) que antes inundavam a `## Problem Statement` com blocos `### customfield_NNNNN` ilegíveis. Agora:
28
+ - **Nomes resolvidos.** O dialeto Rovo (hospedado, OAuth) passa a enviar `expand: "names"`, então cada `customfield_NNNNN` ganha seu rótulo humano ("Critérios de Aceite"). O `mcp-atlassian` (≥ 0.11.2) tem o rótulo desembrulhado do wrapper `{value, name}` (ele nunca ecoa o mapa `names`); `renderedFields` deixou de ser pedido (o servidor o descarta e a combinação suprime custom fields em algumas versões).
29
+ - **Filtro de relevância (`filterCustomFields`).** Camadas determinísticas (PT/EN, singular+plural): valor-ruído (enums, datas, placeholders) e rótulos de config (Responsável, Relator, Categorias, Branch, Repositório, EI/EO/EQ, CMDB, Nova Data…) caem; allowlist de história (Requisitos, Cenários, Critérios de Aceite, Ressalva de testes, Figma, Traduções, Documentação, Anexo…) e links de ferramenta de design (figma/miro/zeplin/notion/confluence) ficam — avaliados ANTES do denylist. Desconhecidos só ficam com prosa substantiva (rede *lose-nothing*).
30
+ - **`jira.custom_fields` ganha `exclude` e force-keep** (chaves case-insensitive): `<campo>: exclude` derruba; `<campo>: <bucket>` força a manter.
31
+ - **Anexos de imagem** (`image/*`) entram como `### Attachments (images)` (links, não baixados); outros tipos ficam no card.
32
+ - **Critérios de Aceite agrupados por cenário** (`acceptanceLines`): blocos "Cenário N …" viram um `[AC-#]` cada (não um por linha de Gherkin).
33
+
34
+ ### Changed
35
+
36
+ - **`flattenAdf` rende `taskItem` e `table`.** Uma matriz de aplicabilidade do Jira (grade de checkboxes) agora vira texto legível: **só os itens marcados `[x]` sobrevivem** (os desmarcados são ruído) e tabelas viram uma linha por row com células separadas por `|`. No `CDPOS-6532`, "Cenários aplicáveis" encolheu de 2528 → 607 chars, mostrando o escopo real.
37
+ - **URL do card browsable.** `resolveUrl` prefere o `base_url` do `raptor.yml` ao `self` do gateway `api.atlassian.com` (que não abre no navegador); `jira.base_url` é encanado do `raptor.yml` ao cliente OAuth.
38
+ - **`raptor jira pull`** imprime quantos custom fields foram filtrados (descoberta para o escape hatch); `--json` segue despejando tudo sem filtro. O `jira-refresh.md` (clarify) usa o mesmo filtro — os três consumidores (`new`, `pull`, `jira-refresh`) mostram o mesmo card.
39
+
40
+ Doc: [`docs/jira-spec-enrichment.md`](docs/jira-spec-enrichment.md) (seções F1–F4 + F3½ + §4b card corporativo). Validado E2E contra o `CDPOS-6532` real via OAuth.
41
+
6
42
  ## [0.9.0] - 2026-06-11
7
43
 
8
44
  ### Added
@@ -33,9 +33,10 @@ const PHASE_MAP = {
33
33
  " e todo id de AC é espelhado no frontmatter `acceptance.ids`.",
34
34
  "6. Success Criteria mensuráveis e tecnologia-agnósticos (foco no usuário).",
35
35
  "7. Auto-valide contra o 'Review & Acceptance Checklist' do template antes de entregar.",
36
- "8. Se a feature tiver design anexado (pasta `design/` presente ou seção `## Design Reference`",
37
- " na spec), use suas ferramentas de design (Figma MCP) para ENXERGAR o design: derive",
38
- " telas, fluxos e critérios das telas reais e crie `design/screens/<nome>.md` por tela.",
36
+ "8. Se a feature tiver design (pasta `design/` ou seção `## Design Reference`): se já houver",
37
+ " artefatos semeados (`design/tokens.json` populado, `design/screens/*.md`), REFINE e",
38
+ " redistribua esses artefatos em vez de re-buscar; senão use o Figma MCP para ENXERGAR o",
39
+ " design. Derive telas/fluxos/critérios das telas reais e refine (ou crie) `design/screens/<nome>.md`.",
39
40
  "",
40
41
  "### Feature a especificar:",
41
42
  "",
@@ -67,8 +68,9 @@ const PHASE_MAP = {
67
68
  "3. Inclua Data Model, Contracts e Research quando aplicável.",
68
69
  "4. Gere Complexity Tracking se algum gate for violado.",
69
70
  "5. Não invente onde a spec tem `[NEEDS CLARIFICATION]`.",
70
- "6. Se a feature tiver design (pasta `design/`), use o Figma MCP para extrair tokens em",
71
- " `design/tokens.json` e declarar as bibliotecas/design-system necessárias no plan.",
71
+ "6. Se a feature tiver design (pasta `design/`): se `design/tokens.json` estiver populado,",
72
+ " VERIFIQUE/corrija os tokens semeados; senão extraia via Figma MCP. Declare as",
73
+ " bibliotecas/design-system necessárias no plan.",
72
74
  ].join("\n"),
73
75
  },
74
76
  tasks: {
@@ -97,9 +99,9 @@ const PHASE_MAP = {
97
99
  "3. Inclua critério de aceite técnico por task.",
98
100
  "4. Ordene pela dependência natural (build-order).",
99
101
  "5. Toda task deve ser completável em ≤4h.",
100
- "6. Se a feature tiver design (pasta `design/`), gere tasks que baixem os assets via Figma",
101
- " MCP para `assets/{images,icons,fonts}`, os cataloguem em `design/assets-manifest.json`",
102
- " e os referenciem por caminho concreto (tags `[screen: X]` / `[asset: X]`).",
102
+ "6. Se a feature tiver design (pasta `design/`): consuma os assets catalogados em",
103
+ " `design/assets-manifest.json` (baixados em `assets/{images,icons,fonts}`) e baixe via",
104
+ " Figma MCP o que faltar; referencie por caminho concreto (tags `[screen: X]` / `[asset: X]`).",
103
105
  ].join("\n"),
104
106
  },
105
107
  implement: {
@@ -64,6 +64,7 @@ export const auditEventSchema = {
64
64
  "hotfix.reconciled",
65
65
  "jira.issues.created",
66
66
  "design.imported",
67
+ "design.synced",
67
68
  "branch.created",
68
69
  "branch.switched",
69
70
  "commit.guarded",
@@ -0,0 +1,37 @@
1
+ import { slugify } from "./slug.js";
2
+ import { assetDirFor, classifyAssetType, extForFormat, extFromUrl } from "./asset-paths.js";
3
+ export function normalizeImagesToAssets(renders, fills, opts = {}) {
4
+ const out = [];
5
+ const seen = new Set();
6
+ const fmt = opts.format ?? "png";
7
+ const renderType = classifyAssetType(fmt);
8
+ const renderExt = extForFormat(fmt);
9
+ const renderDir = assetDirFor(renderType);
10
+ for (const [nodeId, url] of Object.entries(renders)) {
11
+ if (!url)
12
+ continue;
13
+ const human = opts.names?.[nodeId];
14
+ let base = slugify(human || `node ${nodeId}`) || slugify(`node ${nodeId}`);
15
+ let path = `${renderDir}/${base}.${renderExt}`;
16
+ if (seen.has(path)) {
17
+ base = `${base}-${slugify(nodeId)}`;
18
+ path = `${renderDir}/${base}.${renderExt}`;
19
+ }
20
+ seen.add(path);
21
+ out.push({ name: human || base, type: renderType, path, url, figma_node_id: nodeId });
22
+ }
23
+ for (const [ref, url] of Object.entries(fills)) {
24
+ if (!url)
25
+ continue;
26
+ let base = slugify(`fill ${ref}`).slice(0, 40) || slugify(`fill ${ref}`);
27
+ const ext = extFromUrl(url) ?? "png";
28
+ let path = `assets/images/${base}.${ext}`;
29
+ if (seen.has(path)) {
30
+ base = `${base}-${slugify(ref)}`.slice(0, 60);
31
+ path = `assets/images/${base}.${ext}`;
32
+ }
33
+ seen.add(path);
34
+ out.push({ name: base, type: "image", path, url });
35
+ }
36
+ return out;
37
+ }
@@ -0,0 +1,23 @@
1
+ export function classifyAssetType(format) {
2
+ return format.trim().toLowerCase() === "svg" ? "svg" : "image";
3
+ }
4
+ export function assetDirFor(type) {
5
+ if (type === "svg" || type === "icon")
6
+ return "assets/icons";
7
+ if (type === "font")
8
+ return "assets/fonts";
9
+ return "assets/images";
10
+ }
11
+ export function extForFormat(format) {
12
+ const f = format.trim().toLowerCase();
13
+ if (!/^[a-z0-9]{2,5}$/.test(f))
14
+ return "png";
15
+ return f === "jpeg" ? "jpg" : f;
16
+ }
17
+ export function extFromUrl(url) {
18
+ const m = url.match(/\.([A-Za-z0-9]{2,5})(?:[?#]|$)/);
19
+ if (!m)
20
+ return null;
21
+ const ext = m[1].toLowerCase();
22
+ return ext === "jpeg" ? "jpg" : ext;
23
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { normalizePath } from "./slug.js";
5
+ export class DownloadError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = "DownloadError";
11
+ }
12
+ }
13
+ export async function downloadAsset(url, destAbs, opts = {}) {
14
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
15
+ const max = opts.maxBytes && opts.maxBytes > 0 ? opts.maxBytes : 0;
16
+ assertSafeAssetUrl(url, opts.allowHttp ?? false);
17
+ let res;
18
+ try {
19
+ res = await doFetch(url, opts.headers ? { headers: opts.headers } : {});
20
+ }
21
+ catch (err) {
22
+ throw new DownloadError(`download failed for ${url}: ${err instanceof Error ? err.message : String(err)}`, 0);
23
+ }
24
+ if (!res.ok) {
25
+ throw new DownloadError(`download ${url} → ${res.status} ${res.statusText}`, res.status);
26
+ }
27
+ const contentType = res.headers.get("content-type") ?? undefined;
28
+ const declared = Number(res.headers.get("content-length") ?? "");
29
+ if (max && Number.isFinite(declared) && declared > max) {
30
+ throw new DownloadError(`download ${url} exceeds max_bytes (${declared} > ${max})`, res.status);
31
+ }
32
+ const buf = Buffer.from(await res.arrayBuffer());
33
+ if (max && buf.length > max) {
34
+ throw new DownloadError(`download ${url} exceeds max_bytes (${buf.length} > ${max})`, res.status);
35
+ }
36
+ mkdirSync(dirname(destAbs), { recursive: true });
37
+ writeFileSync(destAbs, buf);
38
+ const hash = createHash("sha256").update(buf).digest("hex");
39
+ return { hash, bytes: buf.length, ...(contentType ? { contentType } : {}) };
40
+ }
41
+ export async function downloadAll(pending, root, opts = {}) {
42
+ const concurrency = Math.max(1, opts.concurrency ?? 4);
43
+ const results = new Array(pending.length);
44
+ let next = 0;
45
+ const worker = async () => {
46
+ for (;;) {
47
+ const i = next++;
48
+ if (i >= pending.length)
49
+ return;
50
+ const a = pending[i];
51
+ if (!a)
52
+ return;
53
+ const base = {
54
+ name: a.name,
55
+ type: a.type,
56
+ path: a.path,
57
+ ...(a.figma_node_id ? { figma_node_id: a.figma_node_id } : {}),
58
+ ...(a.screen ? { screen: a.screen } : {}),
59
+ };
60
+ try {
61
+ const rel = normalizePath(a.path);
62
+ const rootAbs = resolve(root);
63
+ const within = resolve(rootAbs, rel);
64
+ if (isAbsolute(rel) || (within !== rootAbs && !within.startsWith(rootAbs + sep))) {
65
+ throw new DownloadError(`asset path escapes project root: ${a.path}`, 0);
66
+ }
67
+ const destAbs = join(root, rel);
68
+ const { hash } = await downloadAsset(a.url, destAbs, opts);
69
+ results[i] = { ...base, hash, status: "downloaded" };
70
+ }
71
+ catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ if (opts.onWarn)
74
+ opts.onWarn(`asset "${a.name}" not downloaded: ${msg}`);
75
+ else
76
+ process.stderr.write(`Warning: asset "${a.name}" not downloaded: ${msg}\n`);
77
+ results[i] = { ...base, status: "pending" };
78
+ }
79
+ }
80
+ };
81
+ await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
82
+ return results;
83
+ }
84
+ function assertSafeAssetUrl(url, allowHttp) {
85
+ let u;
86
+ try {
87
+ u = new URL(url);
88
+ }
89
+ catch {
90
+ throw new DownloadError(`invalid asset URL: ${url}`, 0);
91
+ }
92
+ if (u.protocol !== "https:" && !(allowHttp && u.protocol === "http:")) {
93
+ throw new DownloadError(`refusing non-https asset URL (${u.protocol}//…)`, 0);
94
+ }
95
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
96
+ if (isInternalHost(host)) {
97
+ throw new DownloadError(`refusing asset URL to internal host: ${host}`, 0);
98
+ }
99
+ }
100
+ function isInternalHost(host) {
101
+ if (host === "localhost" || host.endsWith(".localhost"))
102
+ return true;
103
+ if (host === "metadata.google.internal")
104
+ return true;
105
+ if (host.includes(":")) {
106
+ if (host === "::1" || host === "::")
107
+ return true;
108
+ if (host.startsWith("fe80:"))
109
+ return true;
110
+ if (/^f[cd][0-9a-f]{2}:/.test(host))
111
+ return true;
112
+ if (host.startsWith("::ffff:")) {
113
+ const rest = host.slice("::ffff:".length);
114
+ if (rest.includes("."))
115
+ return isInternalHost(rest);
116
+ const hx = rest.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
117
+ if (hx) {
118
+ const hi = parseInt(hx[1], 16);
119
+ const lo = parseInt(hx[2], 16);
120
+ return isInternalHost(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
126
+ if (m) {
127
+ const a = Number(m[1]);
128
+ const b = Number(m[2]);
129
+ if (a === 0 || a === 10 || a === 127)
130
+ return true;
131
+ if (a === 169 && b === 254)
132
+ return true;
133
+ if (a === 172 && b >= 16 && b <= 31)
134
+ return true;
135
+ if (a === 192 && b === 168)
136
+ return true;
137
+ return false;
138
+ }
139
+ return false;
140
+ }
@@ -0,0 +1,203 @@
1
+ import { downloadAll } from "./downloader.js";
2
+ import { resolveFonts } from "./font-resolver.js";
3
+ function warn(opts, message) {
4
+ if (opts.onWarn)
5
+ opts.onWarn(message);
6
+ else
7
+ process.stderr.write(`Warning: ${message}\n`);
8
+ }
9
+ export async function fetchDesignData(refs, client, opts = {}) {
10
+ const varMap = {};
11
+ for (const ref of refs) {
12
+ try {
13
+ const m = await client.getVariableDefs(ref.fileKey, ref.nodeId);
14
+ Object.assign(varMap, m);
15
+ }
16
+ catch (err) {
17
+ warn(opts, `Figma variables read failed for ${ref.url}: ${errMsg(err)}`);
18
+ }
19
+ }
20
+ const libraries = new Set();
21
+ if (client.getLibraries) {
22
+ for (const ref of refs) {
23
+ try {
24
+ for (const lib of await client.getLibraries(ref.fileKey))
25
+ libraries.add(lib);
26
+ }
27
+ catch (err) {
28
+ warn(opts, `Figma libraries read failed for ${ref.url}: ${errMsg(err)}`);
29
+ }
30
+ }
31
+ }
32
+ const tokens = normalizeVariablesToTokens(varMap, opts.source);
33
+ const screens = await captureScreens(refs, client, opts);
34
+ const assets = [];
35
+ if (opts.root) {
36
+ assets.push(...(await captureAssets(refs, client, tokens.typography, opts)));
37
+ }
38
+ return {
39
+ tokens,
40
+ screens,
41
+ assets,
42
+ libraries: [...libraries],
43
+ };
44
+ }
45
+ async function captureScreens(refs, client, opts) {
46
+ if (!client.captureScreens)
47
+ return [];
48
+ const out = [];
49
+ const seen = new Set();
50
+ for (const ref of refs) {
51
+ try {
52
+ for (const s of await client.captureScreens(ref.fileKey, ref.nodeId)) {
53
+ const key = s.nodeId
54
+ ? `id:${s.file ?? ""}#${s.nodeId}`
55
+ : `nm:${s.file ?? ""}#${s.name}#${s.summary ?? ""}`;
56
+ if (seen.has(key))
57
+ continue;
58
+ seen.add(key);
59
+ out.push(s);
60
+ }
61
+ }
62
+ catch (err) {
63
+ warn(opts, `Figma screens read failed for ${ref.url}: ${errMsg(err)}`);
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ async function captureAssets(refs, client, typography, opts) {
69
+ const root = opts.root;
70
+ const format = opts.assets?.format ?? "svg";
71
+ const scale = opts.assets?.scale ?? 2;
72
+ const maxBytes = opts.assets?.maxBytes ?? 5_000_000;
73
+ const fetchImpl = opts.deps?.fetchImpl;
74
+ const pending = [];
75
+ const seenPaths = new Set();
76
+ if (client.captureAssets) {
77
+ for (const ref of refs) {
78
+ try {
79
+ const found = await client.captureAssets(ref.fileKey, ref.nodeId, { format, scale });
80
+ for (const p of found) {
81
+ if (seenPaths.has(p.path))
82
+ continue;
83
+ seenPaths.add(p.path);
84
+ pending.push(p);
85
+ }
86
+ }
87
+ catch (err) {
88
+ warn(opts, `Figma asset read failed for ${ref.url}: ${errMsg(err)}`);
89
+ }
90
+ }
91
+ }
92
+ const out = [];
93
+ if (pending.length) {
94
+ const entries = await downloadAll(pending, root, {
95
+ ...(fetchImpl ? { fetchImpl } : {}),
96
+ maxBytes,
97
+ ...(opts.concurrency ? { concurrency: opts.concurrency } : {}),
98
+ ...(opts.onWarn ? { onWarn: opts.onWarn } : {}),
99
+ });
100
+ for (const e of entries) {
101
+ if (e.status === "downloaded")
102
+ out.push(e);
103
+ else
104
+ warn(opts, `asset "${e.name}" could not be downloaded — left pending (not catalogued)`);
105
+ }
106
+ }
107
+ const fontEntries = await resolveFonts(typography, {
108
+ root,
109
+ brandDir: opts.fonts?.brandDir ?? "assets/fonts",
110
+ google: opts.fonts?.google !== false,
111
+ os: opts.fonts?.os !== false,
112
+ maxBytes,
113
+ deps: {
114
+ ...(fetchImpl ? { fetchImpl } : {}),
115
+ ...(opts.deps?.fcMatch ? { fcMatch: opts.deps.fcMatch } : {}),
116
+ ...(opts.deps?.osFontDirs ? { osFontDirs: opts.deps.osFontDirs } : {}),
117
+ },
118
+ ...(opts.onWarn ? { onWarn: opts.onWarn } : {}),
119
+ });
120
+ out.push(...fontEntries);
121
+ return out;
122
+ }
123
+ export function isColorValue(value) {
124
+ const v = value.trim().toLowerCase();
125
+ return /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(v) || /^rgba?\(/.test(v);
126
+ }
127
+ export function isNumericValue(value) {
128
+ return /^-?\d+(\.\d+)?(px|rem|em|pt|%)?$/.test(value.trim());
129
+ }
130
+ export function classifyToken(name, value) {
131
+ const lname = name.toLowerCase();
132
+ if (isColorValue(value))
133
+ return "color";
134
+ if (value === "true" || value === "false")
135
+ return "unknown";
136
+ if (/(radius|radii|corner|rounded)/.test(lname))
137
+ return "radius";
138
+ if (/(font|typograph|leading|line-?height|letter-?spacing|\btext\b|\btype\b)/.test(lname)) {
139
+ return "typography";
140
+ }
141
+ if (!isNumericValue(value) && /(colou?r|fill|bg|background|surface|border|stroke|brand|shadow)/.test(lname)) {
142
+ return "color";
143
+ }
144
+ if (/(spac|sizing|gap|pad|margin|inset|\bsize\b|dimension|width|height)/.test(lname)) {
145
+ return "spacing";
146
+ }
147
+ return "unknown";
148
+ }
149
+ function typographyField(name, value) {
150
+ const ln = name.toLowerCase();
151
+ if (/weight/.test(ln))
152
+ return { fontWeight: value };
153
+ if (/(line-?height|leading)/.test(ln))
154
+ return { lineHeight: value };
155
+ if (/(letter-?spacing|tracking)/.test(ln))
156
+ return { letterSpacing: value };
157
+ if (/size/.test(ln) || isNumericValue(value))
158
+ return { fontSize: value };
159
+ return { fontFamily: value };
160
+ }
161
+ export function normalizeVariablesToTokens(map, source) {
162
+ const colors = [];
163
+ const spacing = [];
164
+ const radii = [];
165
+ const typography = [];
166
+ for (const [rawName, rawValue] of Object.entries(map)) {
167
+ const name = rawName.trim();
168
+ const value = String(rawValue).trim();
169
+ if (!name || !value)
170
+ continue;
171
+ switch (classifyToken(name, value)) {
172
+ case "color":
173
+ colors.push({ name, value });
174
+ break;
175
+ case "radius":
176
+ radii.push({ name, value });
177
+ break;
178
+ case "spacing":
179
+ spacing.push({ name, value });
180
+ break;
181
+ case "typography":
182
+ typography.push({ name, ...typographyField(name, value) });
183
+ break;
184
+ }
185
+ }
186
+ const tokens = { colors, typography, spacing };
187
+ if (radii.length)
188
+ tokens.radii = radii;
189
+ if (source) {
190
+ tokens.source = {
191
+ provider: source.provider,
192
+ ...(source.fileKey ? { fileKey: source.fileKey } : {}),
193
+ ...(source.generatedAt ? { generatedAt: source.generatedAt } : {}),
194
+ };
195
+ }
196
+ return tokens;
197
+ }
198
+ export function seededTokensCount(t) {
199
+ return t.colors.length + t.typography.length + t.spacing.length + (t.radii?.length ?? 0);
200
+ }
201
+ function errMsg(err) {
202
+ return err instanceof Error ? err.message : String(err);
203
+ }