@lugom.io/hefesto 0.2.0 → 0.3.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 (52) hide show
  1. package/agents/hefesto-argos.md +279 -0
  2. package/agents/hefesto-athena.md +379 -0
  3. package/agents/hefesto-hermes.md +128 -0
  4. package/bin/install.js +54 -20
  5. package/package.json +3 -3
  6. package/skills/hefesto-context/SKILL.md +28 -8
  7. package/skills/hefesto-design/SKILL.md +194 -0
  8. package/skills/hefesto-design/data/animations.csv +21 -0
  9. package/skills/hefesto-design/data/anti-patterns.csv +41 -0
  10. package/skills/hefesto-design/data/charts.csv +26 -0
  11. package/skills/hefesto-design/data/colors.csv +108 -0
  12. package/skills/hefesto-design/data/components.csv +31 -0
  13. package/skills/hefesto-design/data/google-fonts.csv +56 -0
  14. package/skills/hefesto-design/data/icons.csv +23 -0
  15. package/skills/hefesto-design/data/landing-pages.csv +28 -0
  16. package/skills/hefesto-design/data/products.csv +46 -0
  17. package/skills/hefesto-design/data/spacing.csv +16 -0
  18. package/skills/hefesto-design/data/styles.csv +53 -0
  19. package/skills/hefesto-design/data/typography.csv +41 -0
  20. package/skills/hefesto-design/data/ux-rules.csv +61 -0
  21. package/skills/hefesto-design/references/accessibility.md +335 -0
  22. package/skills/hefesto-design/references/aesthetics.md +343 -0
  23. package/skills/hefesto-design/references/anti-patterns.md +107 -0
  24. package/skills/hefesto-design/references/checklist.md +66 -0
  25. package/skills/hefesto-design/references/color-psychology.md +203 -0
  26. package/skills/hefesto-design/references/component-specs.md +318 -0
  27. package/skills/hefesto-design/references/polish.md +339 -0
  28. package/skills/hefesto-design/references/token-architecture.md +394 -0
  29. package/skills/hefesto-design/references/ux-rules.md +349 -0
  30. package/skills/hefesto-design/scripts/__pycache__/audit.cpython-314.pyc +0 -0
  31. package/skills/hefesto-design/scripts/__pycache__/contrast.cpython-314.pyc +0 -0
  32. package/skills/hefesto-design/scripts/__pycache__/core.cpython-314.pyc +0 -0
  33. package/skills/hefesto-design/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
  34. package/skills/hefesto-design/scripts/__pycache__/search.cpython-314.pyc +0 -0
  35. package/skills/hefesto-design/scripts/__pycache__/validate_tokens.cpython-314.pyc +0 -0
  36. package/skills/hefesto-design/scripts/audit.py +450 -0
  37. package/skills/hefesto-design/scripts/contrast.py +195 -0
  38. package/skills/hefesto-design/scripts/core.py +155 -0
  39. package/skills/hefesto-design/scripts/design_system.py +311 -0
  40. package/skills/hefesto-design/scripts/search.py +235 -0
  41. package/skills/hefesto-design/scripts/validate_tokens.py +274 -0
  42. package/{commands/hefesto/init.md → skills/hefesto-init/SKILL.md} +5 -2
  43. package/{commands/hefesto/new-feature.md → skills/hefesto-new-feature/SKILL.md} +5 -2
  44. package/{commands/hefesto/update.md → skills/hefesto-update/SKILL.md} +6 -3
  45. package/templates/DESIGN.md +137 -0
  46. package/templates/RECON.md +54 -0
  47. package/templates/RESEARCH.md +22 -25
  48. package/templates/STATE.md +1 -1
  49. package/templates/VERDICT.md +52 -0
  50. package/agents/.gitkeep +0 -0
  51. package/agents/hefesto-researcher.md +0 -180
  52. package/commands/hefesto/status.md +0 -46
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env python3
2
+ """Scanner de violações do contrato de design para o Hefesto.
3
+
4
+ Varre código-fonte contra o DESIGN.md e reporta cores não autorizadas,
5
+ fontes não contratadas, espaçamentos fora da escala e CTAs genéricos.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+
15
+ SCANNABLE_EXTENSIONS = {
16
+ ".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss",
17
+ ".html", ".astro", ".ts", ".js",
18
+ }
19
+
20
+ GENERIC_CTAS = {
21
+ "submit", "click here", "clique aqui", "ok", "cancel", "cancelar",
22
+ "send", "enviar", "close", "fechar", "yes", "sim", "no", "não",
23
+ "next", "próximo", "proximo", "previous", "anterior", "back", "voltar",
24
+ "continue", "continuar", "accept", "aceitar", "confirm", "confirmar",
25
+ "delete", "deletar", "remove", "remover", "save", "salvar",
26
+ }
27
+
28
+
29
+ def _is_binary(filepath):
30
+ """Verifica se o arquivo parece ser binário."""
31
+ try:
32
+ with open(filepath, "rb") as f:
33
+ chunk = f.read(1024)
34
+ return b"\x00" in chunk
35
+ except (IOError, OSError):
36
+ return True
37
+
38
+
39
+ def parse_design_md(design_path):
40
+ """Extrai contratos do DESIGN.md.
41
+
42
+ Retorna dict com:
43
+ colors: set de cores hex normalizadas (lowercase)
44
+ fonts: set de nomes de fonte (lowercase)
45
+ spacing_base: int (unidade base de espaçamento, padrão 4)
46
+ cta_texts: set de textos de CTA aprovados (lowercase)
47
+ """
48
+ try:
49
+ with open(design_path, "r", encoding="utf-8") as f:
50
+ content = f.read()
51
+ except FileNotFoundError:
52
+ print(f"Erro: arquivo não encontrado: {design_path}", file=sys.stderr)
53
+ sys.exit(1)
54
+
55
+ contract = {
56
+ "colors": set(),
57
+ "fonts": set(),
58
+ "spacing_base": 4,
59
+ "cta_texts": set(),
60
+ }
61
+
62
+ sections = _split_sections(content)
63
+
64
+ # Cores
65
+ colors_text = sections.get("contrato de cores", "")
66
+ for match in re.finditer(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b", colors_text):
67
+ contract["colors"].add(match.group(0).lower())
68
+
69
+ # Tipografia
70
+ typo_text = sections.get("contrato de tipografia", "")
71
+ for match in re.finditer(r"font-family[:\s]*[\"']?([^\"';,\n]+)", typo_text, re.IGNORECASE):
72
+ contract["fonts"].add(match.group(1).strip().lower())
73
+ # Também captura nomes entre aspas ou backticks
74
+ for match in re.finditer(r"[`\"']([A-Z][a-zA-Z\s]+(?:Sans|Serif|Mono|Display|Text)?)[`\"']", typo_text):
75
+ contract["fonts"].add(match.group(1).strip().lower())
76
+
77
+ # Espaçamento
78
+ spacing_text = sections.get("contrato de espacamento", sections.get("contrato de espaçamento", ""))
79
+ base_match = re.search(r"base[:\s]*(\d+)", spacing_text, re.IGNORECASE)
80
+ if base_match:
81
+ contract["spacing_base"] = int(base_match.group(1))
82
+
83
+ # Copywriting
84
+ copy_text = sections.get("copywriting", "")
85
+ for match in re.finditer(r"[`\"']([^`\"']+)[`\"']", copy_text):
86
+ contract["cta_texts"].add(match.group(1).strip().lower())
87
+
88
+ return contract
89
+
90
+
91
+ def _split_sections(content):
92
+ """Divide conteúdo markdown em seções por heading."""
93
+ sections = {}
94
+ current_key = ""
95
+ current_lines = []
96
+
97
+ for line in content.split("\n"):
98
+ heading_match = re.match(r"^#{1,4}\s+(.+)", line)
99
+ if heading_match:
100
+ if current_key:
101
+ sections[current_key] = "\n".join(current_lines)
102
+ raw_key = heading_match.group(1).strip().lower()
103
+ # Normaliza removendo acentos simples para matching
104
+ current_key = raw_key.replace("ã", "a").replace("é", "e").replace("ç", "c").replace("á", "a").replace("ó", "o").replace("ú", "u")
105
+ current_lines = []
106
+ else:
107
+ current_lines.append(line)
108
+
109
+ if current_key:
110
+ sections[current_key] = "\n".join(current_lines)
111
+
112
+ return sections
113
+
114
+
115
+ def scan_colors(filepath, lines, contract_colors):
116
+ """Encontra cores hex não contratadas."""
117
+ violations = []
118
+ hex_pattern = re.compile(r"#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b")
119
+
120
+ var_pattern = re.compile(r"var\([^)]*\)")
121
+
122
+ for lineno, line in enumerate(lines, 1):
123
+ # Build set of positions covered by var() expressions to skip
124
+ var_spans = set()
125
+ for vm in var_pattern.finditer(line):
126
+ for pos in range(vm.start(), vm.end()):
127
+ var_spans.add(pos)
128
+
129
+ for match in hex_pattern.finditer(line):
130
+ # Skip hex colors inside var() — e.g., var(--color, #abc123)
131
+ if match.start() in var_spans:
132
+ continue
133
+ found = match.group(0).lower()
134
+ # Normaliza #RGB para #RRGGBB para comparação
135
+ if len(found) == 4:
136
+ expanded = "#" + "".join(c * 2 for c in found[1:])
137
+ if expanded not in contract_colors and found not in contract_colors:
138
+ violations.append({
139
+ "file": filepath,
140
+ "line": lineno,
141
+ "type": "color",
142
+ "found": found,
143
+ "suggestion": "Use uma cor do contrato de cores do DESIGN.md",
144
+ })
145
+ elif found not in contract_colors:
146
+ violations.append({
147
+ "file": filepath,
148
+ "line": lineno,
149
+ "type": "color",
150
+ "found": found,
151
+ "suggestion": "Use uma cor do contrato de cores do DESIGN.md",
152
+ })
153
+
154
+ return violations
155
+
156
+
157
+ def scan_typography(filepath, lines, contract_fonts):
158
+ """Encontra font-family não contratadas."""
159
+ violations = []
160
+ font_pattern = re.compile(r"font-family\s*:\s*([^;}\n]+)", re.IGNORECASE)
161
+ font_js_pattern = re.compile(r"fontFamily\s*:\s*['\"]([^'\"]+)['\"]")
162
+
163
+ for lineno, line in enumerate(lines, 1):
164
+ # CSS-in-JS: fontFamily: "Inter" / fontFamily: 'Inter'
165
+ for match in font_js_pattern.finditer(line):
166
+ font_name = match.group(1).strip()
167
+ normalized = font_name.lower()
168
+ if normalized not in {"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "inherit", "initial", "unset"}:
169
+ if normalized not in contract_fonts:
170
+ violations.append({
171
+ "file": filepath,
172
+ "line": lineno,
173
+ "type": "typography",
174
+ "found": font_name,
175
+ "suggestion": f"Use uma fonte do contrato: {', '.join(contract_fonts) or '(nenhuma definida)'}",
176
+ })
177
+
178
+ for match in font_pattern.finditer(line):
179
+ fonts_str = match.group(1)
180
+ # Extrai nomes de fontes individuais
181
+ font_names = re.findall(r"[\"']?([a-zA-Z][a-zA-Z\s-]+)[\"']?", fonts_str)
182
+ for font_name in font_names:
183
+ normalized = font_name.strip().lower()
184
+ # Ignora fontes genéricas CSS
185
+ if normalized in {"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "inherit", "initial", "unset"}:
186
+ continue
187
+ if normalized not in contract_fonts:
188
+ violations.append({
189
+ "file": filepath,
190
+ "line": lineno,
191
+ "type": "typography",
192
+ "found": font_name.strip(),
193
+ "suggestion": f"Use uma fonte do contrato: {', '.join(contract_fonts) or '(nenhuma definida)'}",
194
+ })
195
+
196
+ return violations
197
+
198
+
199
+ def scan_spacing(filepath, lines, base_unit):
200
+ """Encontra valores de espaçamento que não são múltiplos da unidade base."""
201
+ violations = []
202
+ # CSS inline: padding: 13px, margin: 7px 5px, gap: 9px
203
+ spacing_pattern = re.compile(
204
+ r"(?:padding|margin|gap|top|right|bottom|left)\s*:\s*(\d+)\s*px",
205
+ re.IGNORECASE,
206
+ )
207
+ # CSS shorthand: padding: 10px 15px (captures each value)
208
+ spacing_shorthand = re.compile(
209
+ r"(?:padding|margin)\s*:\s*((?:\d+px\s*)+)",
210
+ re.IGNORECASE,
211
+ )
212
+ # CSS-in-JS: padding: 13, margin: 7 (numeric values without px)
213
+ spacing_js_pattern = re.compile(
214
+ r"(?:padding|margin|gap|top|right|bottom|left)\s*:\s*(\d+)\s*[,}\n]",
215
+ )
216
+ # Tailwind: p-3, m-5, gap-7 (scale * 4 = px)
217
+ tailwind_pattern = re.compile(r"\b(?:p|m|gap)-(\d+)\b")
218
+
219
+ for lineno, line in enumerate(lines, 1):
220
+ checked_values = set()
221
+
222
+ for match in spacing_pattern.finditer(line):
223
+ value = int(match.group(1))
224
+ if value > 0 and value % base_unit != 0 and value not in checked_values:
225
+ checked_values.add(value)
226
+ violations.append({
227
+ "file": filepath,
228
+ "line": lineno,
229
+ "type": "spacing",
230
+ "found": f"{value}px",
231
+ "suggestion": f"Use múltiplo de {base_unit}px (ex: {(value // base_unit) * base_unit}px ou {((value // base_unit) + 1) * base_unit}px)",
232
+ })
233
+
234
+ # CSS shorthand: extract each px value
235
+ for match in spacing_shorthand.finditer(line):
236
+ for val_match in re.finditer(r"(\d+)px", match.group(1)):
237
+ value = int(val_match.group(1))
238
+ if value > 0 and value % base_unit != 0 and value not in checked_values:
239
+ checked_values.add(value)
240
+ violations.append({
241
+ "file": filepath,
242
+ "line": lineno,
243
+ "type": "spacing",
244
+ "found": f"{value}px",
245
+ "suggestion": f"Use múltiplo de {base_unit}px (ex: {(value // base_unit) * base_unit}px ou {((value // base_unit) + 1) * base_unit}px)",
246
+ })
247
+
248
+ # CSS-in-JS numeric values
249
+ for match in spacing_js_pattern.finditer(line):
250
+ value = int(match.group(1))
251
+ if value > 0 and value % base_unit != 0 and value not in checked_values:
252
+ checked_values.add(value)
253
+ violations.append({
254
+ "file": filepath,
255
+ "line": lineno,
256
+ "type": "spacing",
257
+ "found": f"{value} (CSS-in-JS)",
258
+ "suggestion": f"Use múltiplo de {base_unit} (ex: {(value // base_unit) * base_unit} ou {((value // base_unit) + 1) * base_unit})",
259
+ })
260
+
261
+ # Tailwind: scale value * 4 = px
262
+ for match in tailwind_pattern.finditer(line):
263
+ scale = int(match.group(1))
264
+ px_value = scale * 4
265
+ if px_value > 0 and px_value % base_unit != 0:
266
+ violations.append({
267
+ "file": filepath,
268
+ "line": lineno,
269
+ "type": "spacing",
270
+ "found": f"{match.group(0)} ({px_value}px)",
271
+ "suggestion": f"Use múltiplo de {base_unit}px (ex: {(px_value // base_unit) * base_unit}px ou {((px_value // base_unit) + 1) * base_unit}px)",
272
+ })
273
+
274
+ return violations
275
+
276
+
277
+ def scan_copywriting(filepath, lines):
278
+ """Encontra CTAs genéricos."""
279
+ violations = []
280
+ # Build pattern for matching generic CTAs in JS strings
281
+ ctas_pattern = "|".join(re.escape(cta) for cta in GENERIC_CTAS)
282
+ # React props: label="Submit", label='Submit'
283
+ react_prop_pattern = re.compile(
284
+ r"""(?:label|buttonText|title|placeholder|aria-label)\s*=\s*['"](%s)['"]""" % ctas_pattern,
285
+ re.IGNORECASE,
286
+ )
287
+
288
+ for lineno, line in enumerate(lines, 1):
289
+ # Busca em conteúdo de texto entre tags ou em atributos
290
+ text_matches = re.findall(r">([^<]+)<", line)
291
+ attr_matches = re.findall(r"(?:label|title|placeholder|aria-label)\s*=\s*[\"']([^\"']+)[\"']", line, re.IGNORECASE)
292
+
293
+ for text in text_matches + attr_matches:
294
+ text_lower = text.strip().lower()
295
+ if text_lower in GENERIC_CTAS:
296
+ violations.append({
297
+ "file": filepath,
298
+ "line": lineno,
299
+ "type": "copywriting",
300
+ "found": text.strip(),
301
+ "suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
302
+ })
303
+
304
+ # JSX text: >Submit< (no spaces around, single word)
305
+ for match in re.finditer(r">\s*(%s)\s*<" % ctas_pattern, line, re.IGNORECASE):
306
+ found = match.group(1).strip()
307
+ # Avoid duplicate if already caught by text_matches above
308
+ if found.lower() not in {t.strip().lower() for t in text_matches}:
309
+ violations.append({
310
+ "file": filepath,
311
+ "line": lineno,
312
+ "type": "copywriting",
313
+ "found": found,
314
+ "suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
315
+ })
316
+
317
+ # React props: label="Submit"
318
+ for match in react_prop_pattern.finditer(line):
319
+ found = match.group(1).strip()
320
+ # Avoid duplicate if already caught by attr_matches above
321
+ if found.lower() not in {t.strip().lower() for t in attr_matches}:
322
+ violations.append({
323
+ "file": filepath,
324
+ "line": lineno,
325
+ "type": "copywriting",
326
+ "found": found,
327
+ "suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
328
+ })
329
+
330
+ return violations
331
+
332
+
333
+ def walk_source(src_dir):
334
+ """Percorre diretório fonte e retorna arquivos escaneáveis."""
335
+ files = []
336
+ for root, _dirs, filenames in os.walk(src_dir):
337
+ # Ignora node_modules, .git, dist, build
338
+ parts = root.split(os.sep)
339
+ if any(p in {"node_modules", ".git", "dist", "build", ".next", "__pycache__"} for p in parts):
340
+ continue
341
+ for fname in filenames:
342
+ ext = os.path.splitext(fname)[1].lower()
343
+ if ext in SCANNABLE_EXTENSIONS:
344
+ files.append(os.path.join(root, fname))
345
+ return files
346
+
347
+
348
+ def run_audit(design_path, src_dir, checks=None):
349
+ """Executa auditoria completa."""
350
+ contract = parse_design_md(design_path)
351
+ all_checks = {"colors", "typography", "spacing", "copywriting"}
352
+ active_checks = {checks} if checks else all_checks
353
+
354
+ files = walk_source(src_dir)
355
+ if not files:
356
+ print(f"Aviso: nenhum arquivo escaneável encontrado em {src_dir}", file=sys.stderr)
357
+ return []
358
+
359
+ all_violations = []
360
+ for filepath in files:
361
+ if _is_binary(filepath):
362
+ continue
363
+ try:
364
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
365
+ lines = f.readlines()
366
+ except (IOError, OSError):
367
+ continue
368
+
369
+ if "colors" in active_checks and contract["colors"]:
370
+ all_violations.extend(scan_colors(filepath, lines, contract["colors"]))
371
+ if "typography" in active_checks and contract["fonts"]:
372
+ all_violations.extend(scan_typography(filepath, lines, contract["fonts"]))
373
+ if "spacing" in active_checks:
374
+ all_violations.extend(scan_spacing(filepath, lines, contract["spacing_base"]))
375
+ if "copywriting" in active_checks:
376
+ all_violations.extend(scan_copywriting(filepath, lines))
377
+
378
+ return all_violations
379
+
380
+
381
+ def format_violations(violations, fmt="table"):
382
+ """Formata e imprime violações."""
383
+ if not violations:
384
+ print("\n Nenhuma violação encontrada. Contrato de design respeitado!\n")
385
+ return
386
+
387
+ if fmt == "json":
388
+ print(json.dumps(violations, indent=2, ensure_ascii=False))
389
+ return
390
+
391
+ print(f"\n {len(violations)} violação(ões) encontrada(s):\n")
392
+
393
+ # Agrupa por tipo
394
+ by_type = {}
395
+ for v in violations:
396
+ by_type.setdefault(v["type"], []).append(v)
397
+
398
+ for vtype, items in by_type.items():
399
+ print(f" [{vtype.upper()}] ({len(items)} violações)")
400
+ for item in items:
401
+ print(f" {item['file']}:{item['line']}")
402
+ print(f" Encontrado: {item['found']}")
403
+ print(f" Sugestão: {item['suggestion']}")
404
+ print()
405
+
406
+
407
+ def main():
408
+ parser = argparse.ArgumentParser(
409
+ description="Scanner de violações do contrato de design.",
410
+ epilog="""Exemplos:
411
+ python3 audit.py --design .hefesto/DESIGN.md --src src/
412
+ python3 audit.py --design .hefesto/DESIGN.md --src src/ --check colors
413
+ python3 audit.py --design .hefesto/DESIGN.md --src src/ -f json
414
+ """,
415
+ )
416
+ parser.add_argument("--design", required=True, help="Caminho para DESIGN.md")
417
+ parser.add_argument("--src", required=True, help="Diretório fonte para escanear")
418
+ parser.add_argument(
419
+ "--check",
420
+ choices=["colors", "typography", "spacing", "copywriting"],
421
+ default=None,
422
+ help="Verificar apenas um tipo específico",
423
+ )
424
+ parser.add_argument(
425
+ "-f", "--format",
426
+ choices=["table", "json"],
427
+ default="table",
428
+ help="Formato de saída (padrão: table)",
429
+ )
430
+
431
+ args = parser.parse_args()
432
+
433
+ if not os.path.isfile(args.design):
434
+ print(f"Erro: DESIGN.md não encontrado: {args.design}", file=sys.stderr)
435
+ sys.exit(1)
436
+
437
+ if not os.path.isdir(args.src):
438
+ print(f"Erro: diretório fonte não encontrado: {args.src}", file=sys.stderr)
439
+ sys.exit(1)
440
+
441
+ violations = run_audit(args.design, args.src, args.check)
442
+ format_violations(violations, args.format)
443
+
444
+ # Exit code não-zero se há violações
445
+ if violations:
446
+ sys.exit(1)
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """Verificador de contraste WCAG 2.1 para o Hefesto.
3
+
4
+ Calcula ratio de contraste entre cores e valida conformidade com WCAG AA/AAA.
5
+ Pode verificar pares individuais ou extrair cores de um DESIGN.md.
6
+ """
7
+
8
+ import argparse
9
+ import re
10
+ import sys
11
+
12
+
13
+ def parse_hex(color):
14
+ """Converte cor hexadecimal para (R, G, B) em 0-255.
15
+
16
+ Aceita formatos: #RGB, #RRGGBB, #RRGGBBAA (ignora alpha).
17
+ """
18
+ color = color.strip().lstrip("#")
19
+ if len(color) == 3:
20
+ color = "".join(c * 2 for c in color)
21
+ elif len(color) == 8:
22
+ color = color[:6] # ignora alpha
23
+ elif len(color) != 6:
24
+ raise ValueError(f"Cor hexadecimal inválida: #{color}")
25
+
26
+ try:
27
+ r = int(color[0:2], 16)
28
+ g = int(color[2:4], 16)
29
+ b = int(color[4:6], 16)
30
+ except ValueError:
31
+ raise ValueError(f"Cor hexadecimal inválida: #{color}")
32
+
33
+ return r, g, b
34
+
35
+
36
+ def relative_luminance(r, g, b):
37
+ """Calcula luminância relativa conforme WCAG 2.1.
38
+
39
+ sRGB para linear: if c <= 0.04045: c/12.92 else: ((c+0.055)/1.055)^2.4
40
+ L = 0.2126*R + 0.7152*G + 0.0722*B
41
+ """
42
+ def linearize(c):
43
+ c = c / 255.0
44
+ if c <= 0.04045:
45
+ return c / 12.92
46
+ return ((c + 0.055) / 1.055) ** 2.4
47
+
48
+ rl = linearize(r)
49
+ gl = linearize(g)
50
+ bl = linearize(b)
51
+ return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
52
+
53
+
54
+ def contrast_ratio(color1, color2):
55
+ """Calcula ratio de contraste entre duas cores hex.
56
+
57
+ Retorna ratio como float (ex: 4.5).
58
+ """
59
+ r1, g1, b1 = parse_hex(color1)
60
+ r2, g2, b2 = parse_hex(color2)
61
+
62
+ l1 = relative_luminance(r1, g1, b1)
63
+ l2 = relative_luminance(r2, g2, b2)
64
+
65
+ lighter = max(l1, l2)
66
+ darker = min(l1, l2)
67
+
68
+ return (lighter + 0.05) / (darker + 0.05)
69
+
70
+
71
+ def check_wcag(ratio):
72
+ """Verifica conformidade WCAG para um ratio."""
73
+ return {
74
+ "aa_normal": ratio >= 4.5,
75
+ "aa_large": ratio >= 3.0,
76
+ "aaa": ratio >= 7.0,
77
+ }
78
+
79
+
80
+ def _pass_fail(ok):
81
+ return "PASSA" if ok else "FALHA"
82
+
83
+
84
+ def format_result(fg, bg, ratio, wcag):
85
+ """Formata resultado de verificação."""
86
+ return (
87
+ f" {fg} sobre {bg}\n"
88
+ f" Ratio: {ratio:.1f}:1 | "
89
+ f"AA normal: {_pass_fail(wcag['aa_normal'])} | "
90
+ f"AA grande: {_pass_fail(wcag['aa_large'])} | "
91
+ f"AAA: {_pass_fail(wcag['aaa'])}"
92
+ )
93
+
94
+
95
+ def check_pair(fg, bg):
96
+ """Verifica um par de cores e imprime resultado."""
97
+ ratio = contrast_ratio(fg, bg)
98
+ wcag = check_wcag(ratio)
99
+ print(format_result(fg, bg, ratio, wcag))
100
+ return wcag
101
+
102
+
103
+ def extract_design_colors(design_path):
104
+ """Extrai cores da seção 'Contrato de Cores' do DESIGN.md.
105
+
106
+ Retorna dict com listas de cores foreground e background.
107
+ """
108
+ try:
109
+ with open(design_path, "r", encoding="utf-8") as f:
110
+ content = f.read()
111
+ except FileNotFoundError:
112
+ print(f"Erro: arquivo não encontrado: {design_path}", file=sys.stderr)
113
+ sys.exit(1)
114
+
115
+ # Encontra seção de cores
116
+ colors_section = ""
117
+ in_section = False
118
+ for line in content.split("\n"):
119
+ if re.match(r"^#{1,6}\s+.*(?:contrato\s+de\s+cores|cores|paleta|colors)", line, re.IGNORECASE):
120
+ in_section = True
121
+ continue
122
+ if in_section and re.match(r"^#{1,6}\s+", line) and "cor" not in line.lower() and "color" not in line.lower() and "paleta" not in line.lower():
123
+ break
124
+ if in_section:
125
+ colors_section += line + "\n"
126
+
127
+ # Extrai todas as cores hex
128
+ hex_colors = re.findall(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b", colors_section)
129
+ hex_colors = list(dict.fromkeys(hex_colors)) # remove duplicatas mantendo ordem
130
+
131
+ if not hex_colors:
132
+ print("Aviso: nenhuma cor hexadecimal encontrada na seção 'Contrato de Cores'.", file=sys.stderr)
133
+
134
+ return hex_colors
135
+
136
+
137
+ def main():
138
+ parser = argparse.ArgumentParser(
139
+ description="Verificador de contraste WCAG 2.1.",
140
+ epilog="""Exemplos:
141
+ python3 contrast.py "#1a1a2e" "#ffffff"
142
+ python3 contrast.py --fg "#6366f1" --bg "#ffffff" --bg-dark "#1a1a2e"
143
+ python3 contrast.py --design .hefesto/DESIGN.md
144
+ """,
145
+ )
146
+ parser.add_argument("color1", nargs="?", help="Cor de primeiro plano (hex)")
147
+ parser.add_argument("color2", nargs="?", help="Cor de fundo (hex)")
148
+ parser.add_argument("--fg", help="Cor de primeiro plano (hex)")
149
+ parser.add_argument("--bg", help="Cor de fundo claro (hex)")
150
+ parser.add_argument("--bg-dark", help="Cor de fundo escuro (hex)")
151
+ parser.add_argument("--design", help="Caminho para DESIGN.md para verificar todos os pares")
152
+
153
+ args = parser.parse_args()
154
+
155
+ print()
156
+
157
+ if args.design:
158
+ colors = extract_design_colors(args.design)
159
+ if len(colors) < 2:
160
+ print("Erro: pelo menos 2 cores são necessárias para verificar contraste.", file=sys.stderr)
161
+ sys.exit(1)
162
+
163
+ print(f" Verificando {len(colors)} cores de {args.design}\n")
164
+ failures = 0
165
+ total = 0
166
+ for i, fg in enumerate(colors):
167
+ for bg in colors[i + 1:]:
168
+ ratio = contrast_ratio(fg, bg)
169
+ wcag = check_wcag(ratio)
170
+ total += 1
171
+ if not wcag["aa_normal"]:
172
+ failures += 1
173
+ print(format_result(fg, bg, ratio, wcag))
174
+
175
+ print(f"\n Resumo: {total} pares verificados, {total - failures} passam AA normal, {failures} falham.\n")
176
+
177
+ elif args.fg:
178
+ if not args.bg:
179
+ parser.error("--bg é obrigatório quando --fg é usado.")
180
+
181
+ check_pair(args.fg, args.bg)
182
+ if args.bg_dark:
183
+ check_pair(args.fg, args.bg_dark)
184
+ print()
185
+
186
+ elif args.color1 and args.color2:
187
+ check_pair(args.color1, args.color2)
188
+ print()
189
+
190
+ else:
191
+ parser.error("Informe duas cores, use --fg/--bg, ou --design.")
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()