opencode-skills-collection 4.1.18 → 4.1.20
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/bundled-skills/.antigravity-install-manifest.json +20 -1
- package/bundled-skills/ai-headshot-studio/SKILL.md +107 -99
- package/bundled-skills/ai-image-generation-studio/SKILL.md +108 -93
- package/bundled-skills/ai-logo-maker/SKILL.md +108 -93
- package/bundled-skills/ai-multilingual-dubbing/SKILL.md +108 -93
- package/bundled-skills/ai-photo-restyler/SKILL.md +108 -93
- package/bundled-skills/ai-podcast-voiceover/SKILL.md +108 -93
- package/bundled-skills/aident-loadout/SKILL.md +88 -0
- package/bundled-skills/antigravity-maintainer-batch-release/SKILL.md +27 -254
- package/bundled-skills/antigravity-maintainer-batch-release/agents/openai.yaml +1 -1
- package/bundled-skills/antigravity-maintainer-batch-release/references/component-validation.md +18 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/core-preview.md +20 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/maintenance-contract.md +83 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/maintenance-cycle.md +149 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/public-surfaces.md +22 -0
- package/bundled-skills/antigravity-maintainer-batch-release/references/skillspector.md +40 -0
- package/bundled-skills/apify-facebook-ads-library-scraper/SKILL.md +197 -0
- package/bundled-skills/apify-facebook-ads-library-scraper/references/gotchas.md +45 -0
- package/bundled-skills/apify-linkedin-jobs-scraper/SKILL.md +181 -0
- package/bundled-skills/apify-linkedin-jobs-scraper/references/gotchas.md +57 -0
- package/bundled-skills/apify-tiktok-scraper/SKILL.md +199 -0
- package/bundled-skills/apify-tiktok-scraper/references/gotchas.md +45 -0
- package/bundled-skills/arcmira-find-quotes/SKILL.md +5 -0
- package/bundled-skills/beatra/SKILL.md +109 -90
- package/bundled-skills/beatra-ai-video-studio/SKILL.md +110 -90
- package/bundled-skills/curlshot/SKILL.md +139 -0
- package/bundled-skills/datacircle/SKILL.md +159 -98
- package/bundled-skills/deed-page/SKILL.md +161 -0
- package/bundled-skills/design-token-contrast/SKILL.md +128 -0
- package/bundled-skills/design-token-contrast/references/contraste-checker.md +330 -0
- package/bundled-skills/ecommerce-listing-image-set/SKILL.md +109 -90
- package/bundled-skills/goal-instruction-writer/LICENSE +21 -0
- package/bundled-skills/goal-instruction-writer/SKILL.md +275 -0
- package/bundled-skills/goal-instruction-writer/references/contribution-walkthrough.md +9 -0
- package/bundled-skills/goal-instruction-writer/references/drift-control.md +160 -0
- package/bundled-skills/goal-instruction-writer/references/examples.md +220 -0
- package/bundled-skills/goal-instruction-writer/references/goal-contract.md +169 -0
- package/bundled-skills/goal-instruction-writer/references/loop-preview.md +113 -0
- package/bundled-skills/keenable-search-mcp/SKILL.md +176 -0
- package/bundled-skills/krexa/SKILL.md +221 -0
- package/bundled-skills/krexa/references/limits.md +42 -0
- package/bundled-skills/krexa/references/setup.md +60 -0
- package/bundled-skills/krexa/references/tools.md +59 -0
- package/bundled-skills/llm-cost-optimization/SKILL.md +1 -1
- package/bundled-skills/llmops-platform-engineering/SKILL.md +1 -1
- package/bundled-skills/multi-tenant-llm-hosting/SKILL.md +1 -1
- package/bundled-skills/music-generation-studio/SKILL.md +109 -90
- package/bundled-skills/outreachgenie-list-audit/LICENSE +21 -0
- package/bundled-skills/outreachgenie-list-audit/SKILL.md +60 -0
- package/bundled-skills/outreachgenie-list-audit/assets/fictional-snapshot.json +256 -0
- package/bundled-skills/outreachgenie-list-audit/references/capture.md +53 -0
- package/bundled-skills/outreachgenie-list-audit/references/checks.md +35 -0
- package/bundled-skills/outreachgenie-list-audit/references/setup.md +25 -0
- package/bundled-skills/outreachgenie-list-audit/scripts/audit.py +766 -0
- package/bundled-skills/outreachgenie-list-audit/scripts/test_audit.py +360 -0
- package/bundled-skills/pangolinfo-amazon-data-research/SKILL.md +170 -0
- package/bundled-skills/poster-design-studio/SKILL.md +109 -90
- package/bundled-skills/ppl-personal-crm/SKILL.md +118 -0
- package/bundled-skills/product-photo-studio/SKILL.md +109 -90
- package/bundled-skills/project-memory-log/LICENSE +21 -0
- package/bundled-skills/project-memory-log/SKILL.md +110 -0
- package/bundled-skills/project-memory-log/references/example-document-catalog.md +22 -0
- package/bundled-skills/project-memory-log/references/example-notes.md +16 -0
- package/bundled-skills/project-memory-log/references/example-operation-log.md +46 -0
- package/bundled-skills/project-memory-log/references/record-formats.md +77 -0
- package/bundled-skills/solidping/SKILL.md +87 -0
- package/bundled-skills/story-bible-continuity/SKILL.md +149 -0
- package/bundled-skills/story-bible-continuity/references/sbc-checker.md +367 -0
- package/bundled-skills/story-bible-continuity/references/story-bible-schema.md +91 -0
- package/bundled-skills/suno-lyrics-to-song/SKILL.md +109 -90
- package/bundled-skills/taasky/SKILL.md +376 -0
- package/bundled-skills/talking-avatar-video/SKILL.md +109 -90
- package/bundled-skills/uiverify-triage-visual-changes/SKILL.md +304 -0
- package/bundled-skills/unpaid-intern/SKILL.md +159 -0
- package/bundled-skills/viral-video-teardown-remake/SKILL.md +109 -90
- package/bundled-skills/voice-cloning-studio/SKILL.md +109 -90
- package/bundled-skills/voiceover-narration-studio/SKILL.md +109 -90
- package/package.json +1 -1
- package/skills_index.json +654 -24
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: design-token-contrast
|
|
3
|
+
description: "Checks WCAG 2.2 contrast between the color tokens of a project (light and dark theme) and suggests the closest passing color that keeps the hue."
|
|
4
|
+
category: frontend
|
|
5
|
+
risk: safe
|
|
6
|
+
source: community
|
|
7
|
+
source_repo: Clagoss/claude-code-para-disenadores
|
|
8
|
+
source_type: community
|
|
9
|
+
date_added: "2026-10-19"
|
|
10
|
+
author: Clagoss
|
|
11
|
+
tags: [accessibility, wcag, contrast, design-tokens, css, dark-mode]
|
|
12
|
+
tools: [claude, cursor, gemini]
|
|
13
|
+
license: "MIT"
|
|
14
|
+
license_source: "https://github.com/Clagoss/claude-code-para-disenadores/blob/main/LICENSE"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Design Token Contrast
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
Measures contrast on the **tokens**, not on screens: a problem fixed in a token is fixed
|
|
22
|
+
everywhere it is used. A Node checker, shipped as source in
|
|
23
|
+
[references/contraste-checker.md](references/contraste-checker.md), reads the CSS custom properties
|
|
24
|
+
of the project, pairs text tokens with background tokens, measures each pair in the light theme
|
|
25
|
+
and in dark mode, and, for every pair that fails, proposes the color with the same hue and the
|
|
26
|
+
nearest lightness that passes. It also proposes **one value per token** that passes against every
|
|
27
|
+
background it is measured on, so the fix is a single change.
|
|
28
|
+
|
|
29
|
+
WCAG 2.2 AA minimums it applies: normal text **4.5:1** (1.4.3), large text **3:1**, and UI
|
|
30
|
+
components and graphics such as input borders, icons and focus rings **3:1** (1.4.11).
|
|
31
|
+
|
|
32
|
+
## When to Use This Skill
|
|
33
|
+
|
|
34
|
+
- Use when the palette changes, a color is added, or a light or dark theme is about to be approved.
|
|
35
|
+
- Use when the user asks whether a text color is readable on its background.
|
|
36
|
+
- Use when an accessibility audit flags contrast and you want the fix at the token level.
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
### Step 1: Run the measurement
|
|
41
|
+
|
|
42
|
+
The checker ships as source, not as a committed script: save the JavaScript block in
|
|
43
|
+
[references/contraste-checker.md](references/contraste-checker.md) as `contraste.mjs` in a
|
|
44
|
+
scratch directory (no dependencies, Node 18+), then run it:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node contraste.mjs --tokens src/styles/tokens.css
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Without `--tokens` it looks for `src/styles/tokens.css` and other common locations. The script:
|
|
51
|
+
|
|
52
|
+
- Reads `--color-*` variables in hex, `rgb()`, `hsl()` or `oklch()`, resolves `var(--other)`, and
|
|
53
|
+
separates the default theme from dark mode (`prefers-color-scheme: dark`, `.dark` or
|
|
54
|
+
`[data-theme=dark]`).
|
|
55
|
+
- Pairs tokens by name: text tokens (`text`, `foreground`, `link`, `focus`…) over background tokens
|
|
56
|
+
(`background`, `bg`, `surface`, `card`, `canvas`…). Spanish token names (`texto`, `fondo`,
|
|
57
|
+
`superficie`, `tarjeta`) are recognized too.
|
|
58
|
+
- Prints `OK` or `NO` per pair, with the ratio, the minimum and the suggestion.
|
|
59
|
+
|
|
60
|
+
If token names do not follow that pattern, write the real pairs to a JSON file and pass it with
|
|
61
|
+
`--pares` (the keys are `texto`, `fondo` and `tipo`, where `tipo` is `texto` or `ui`):
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
[{"texto": "--color-gray-500", "fondo": "--color-white", "tipo": "texto"},
|
|
65
|
+
{"texto": "--color-input-border", "fondo": "--color-white", "tipo": "ui"}]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To explore a new palette, `--todos` measures every color against every other. `--json out.json`
|
|
69
|
+
writes the results for further processing. The script's output is in Spanish; ratios and token
|
|
70
|
+
names read the same in any language.
|
|
71
|
+
|
|
72
|
+
### Step 2: Check what the script cannot see
|
|
73
|
+
|
|
74
|
+
- Text over **images or gradients**: capture the section and check the worst spot.
|
|
75
|
+
- Text with **opacity**, or colors hard-coded outside the tokens (search for `#[0-9a-fA-F]{3,8}`,
|
|
76
|
+
`rgb(`, `text-[#`).
|
|
77
|
+
- States: `hover`, `disabled` (exempt, but still distinguishable), and placeholders that carry
|
|
78
|
+
information.
|
|
79
|
+
|
|
80
|
+
### Step 3: Report and propose
|
|
81
|
+
|
|
82
|
+
| Pair | Now | Minimum | Suggestion | Where it is used |
|
|
83
|
+
|---|---|---|---|---|
|
|
84
|
+
|
|
85
|
+
Search for the classes and `var(--color-...)` references to fill "where it is used".
|
|
86
|
+
|
|
87
|
+
## Examples
|
|
88
|
+
|
|
89
|
+
### Example 1: A gray that fails on white
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
== tema claro (por defecto): 4 colores leídos ==
|
|
93
|
+
NO 3.54:1 (mín. 4.5) --color-text #888888 sobre --color-background #ffffff
|
|
94
|
+
sugerencia para --color-text: oklch(56.2% 0.000 89.9) (#757575) → 4.62:1
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Propose `#767676` for `--color-text` (the single value that passes on all its backgrounds), apply
|
|
98
|
+
it only after the user agrees, and run the script again to show it passes.
|
|
99
|
+
|
|
100
|
+
## Best Practices
|
|
101
|
+
|
|
102
|
+
- ✅ Fix the token once instead of patching each screen.
|
|
103
|
+
- ✅ Offer options: darken the token, keep the color only for large text, or add a new token
|
|
104
|
+
(`--color-accent-text`) for small text and keep the original for fills and borders.
|
|
105
|
+
- ❌ Don't change brand colors without asking.
|
|
106
|
+
- ❌ Don't report a theme as passing without checking dark mode too.
|
|
107
|
+
|
|
108
|
+
## Limitations
|
|
109
|
+
|
|
110
|
+
- It measures tokens, not rendered pixels: images, gradients and opacity need a visual check.
|
|
111
|
+
- Pairing by name is a heuristic; use `--pares` when names are unconventional.
|
|
112
|
+
- This skill does not replace environment-specific validation, testing, or expert review.
|
|
113
|
+
|
|
114
|
+
## Security & Safety Notes
|
|
115
|
+
|
|
116
|
+
- The script only reads the CSS or JSON file you point it to and writes a file only with `--json`.
|
|
117
|
+
It makes no network requests and installs nothing. Requires Node 18+.
|
|
118
|
+
- It never edits tokens by itself; changes are proposed and applied after the user approves.
|
|
119
|
+
|
|
120
|
+
## Common Pitfalls
|
|
121
|
+
|
|
122
|
+
- **Problem:** A dark-mode pair shows `OK` against the light background.
|
|
123
|
+
**Solution:** Make sure dark-mode variables live in a `prefers-color-scheme: dark`, `.dark` or
|
|
124
|
+
`[data-theme=dark]` block so the script can separate the themes.
|
|
125
|
+
|
|
126
|
+
## Related Skills
|
|
127
|
+
|
|
128
|
+
- `@wcag-audit-patterns` - For a full WCAG 2.2 audit beyond color contrast.
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Checker source
|
|
2
|
+
|
|
3
|
+
`design-token-contrast` ships its checker as source so the review stays auditable and
|
|
4
|
+
dependency-free. Save the block below as `contraste.mjs` somewhere you can run Node 18+
|
|
5
|
+
from, then invoke it with `node contraste.mjs`. No dependencies.
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
#!/usr/bin/env node
|
|
9
|
+
// Calcula el contraste WCAG 2.x entre los colores de tus tokens y sugiere un ajuste cuando un par
|
|
10
|
+
// no llega al mínimo. Lee variables CSS (--color-*) en hex, rgb(), hsl() u oklch(), y resuelve
|
|
11
|
+
// alias var(--otro-token). No necesita dependencias.
|
|
12
|
+
//
|
|
13
|
+
// Uso:
|
|
14
|
+
// node contraste.mjs # busca src/styles/tokens.css
|
|
15
|
+
// node contraste.mjs --tokens ruta/tokens.css
|
|
16
|
+
// node contraste.mjs --pares .kit/contraste.json # pares explícitos
|
|
17
|
+
// node contraste.mjs --todos # matriz con todas las combinaciones
|
|
18
|
+
// node contraste.mjs --json informe.json
|
|
19
|
+
//
|
|
20
|
+
// Formato de --pares: [{"texto": "--color-texto", "fondo": "--color-fondo", "tipo": "texto"}]
|
|
21
|
+
// tipo: "texto" (4,5:1), "texto-grande" (3:1, desde 24 px o 18,66 px en negrita), "ui" (3:1,
|
|
22
|
+
// bordes de campos, íconos, indicador de foco: criterio 1.4.11).
|
|
23
|
+
// Sale con código 1 si algún par falla, y con código 2 si no pudo medir nada o algún par quedó sin
|
|
24
|
+
// medir (un token que no existe o no es un color): un informe incompleto nunca dice «todo cumple».
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const op = { tokens: null, pares: null, todos: false, json: null };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (a === '--tokens') op.tokens = argv[++i];
|
|
34
|
+
else if (a === '--pares') op.pares = argv[++i];
|
|
35
|
+
else if (a === '--todos') op.todos = true;
|
|
36
|
+
else if (a === '--json') op.json = argv[++i];
|
|
37
|
+
else if (a === '--ayuda' || a === '-h') { console.log('Uso: node contraste.mjs [--tokens archivo.css] [--pares pares.json] [--todos] [--json salida.json]'); process.exit(0); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buscarTokens() {
|
|
41
|
+
const candidatos = ['src/styles/tokens.css', 'src/styles/global.css', 'src/app/globals.css', 'app/globals.css', 'src/index.css', 'styles/tokens.css'];
|
|
42
|
+
for (const c of candidatos) if (fs.existsSync(c)) return c;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const archivo = op.tokens || buscarTokens();
|
|
46
|
+
if (!archivo || !fs.existsSync(archivo)) {
|
|
47
|
+
console.error('No encontré el archivo de tokens. Indícalo con --tokens ruta/al/archivo.css');
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
const css = fs.readFileSync(archivo, 'utf8');
|
|
51
|
+
|
|
52
|
+
// ---------- Lectura de variables por bloque ----------
|
|
53
|
+
// Separamos las variables del tema claro (por defecto) de las de modo oscuro.
|
|
54
|
+
const DECLARACION = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
|
|
55
|
+
// Un selector es de modo oscuro si nombra .dark o [data-theme="dark"] en cualquier posición
|
|
56
|
+
// (.dark, .dark:root, :root.dark, html[data-theme=dark]), salvo dentro de :not(...).
|
|
57
|
+
const esSelectorOscuro = (sel) => /\[data-theme=["']?dark["']?\]|\.dark(?![\w-])/.test(sel.replace(/:not\([^)]*\)/g, ''));
|
|
58
|
+
function leerVariables(texto) {
|
|
59
|
+
const claro = new Map();
|
|
60
|
+
const oscuro = new Map();
|
|
61
|
+
let resto = texto.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
62
|
+
// 1) @media (prefers-color-scheme: dark) { ... } completo, con todos sus bloques internos.
|
|
63
|
+
for (const m of resto.matchAll(/@media[^{]*prefers-color-scheme:\s*dark[^{]*\{((?:[^{}]*\{[^{}]*\})*[^{}]*)\}/g)) {
|
|
64
|
+
for (const v of m[1].matchAll(DECLARACION)) oscuro.set(v[1], v[2].trim());
|
|
65
|
+
resto = resto.replace(m[0], '');
|
|
66
|
+
}
|
|
67
|
+
// 2) Bloques cuyo selector es de modo oscuro.
|
|
68
|
+
for (const m of resto.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
|
|
69
|
+
if (!esSelectorOscuro(m[1])) continue;
|
|
70
|
+
for (const v of m[2].matchAll(DECLARACION)) oscuro.set(v[1], v[2].trim());
|
|
71
|
+
resto = resto.replace(m[0], '');
|
|
72
|
+
}
|
|
73
|
+
for (const v of resto.matchAll(DECLARACION)) claro.set(v[1], v[2].trim());
|
|
74
|
+
return { claro, oscuro };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- Conversión de color ----------
|
|
78
|
+
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
|
|
79
|
+
const srgbALineal = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
80
|
+
const linealASrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
81
|
+
|
|
82
|
+
function oklabARgb(L, a, b) {
|
|
83
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
84
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
85
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
86
|
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
|
87
|
+
return [
|
|
88
|
+
linealASrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
89
|
+
linealASrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
90
|
+
linealASrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
function rgbAOklab(r, g, b) {
|
|
94
|
+
const R = srgbALineal(r), G = srgbALineal(g), B = srgbALineal(b);
|
|
95
|
+
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
|
96
|
+
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
|
|
97
|
+
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
|
|
98
|
+
return [
|
|
99
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
100
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
101
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
function hslARgb(h, s, l) {
|
|
105
|
+
const k = (n) => (n + h / 30) % 12;
|
|
106
|
+
const a = s * Math.min(l, 1 - l);
|
|
107
|
+
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
108
|
+
return [f(0), f(8), f(4)];
|
|
109
|
+
}
|
|
110
|
+
const num = (t, escala = 1) => (t.trim().endsWith('%') ? parseFloat(t) / 100 : parseFloat(t) / escala);
|
|
111
|
+
// Ángulos de CSS en grados: deg (o sin unidad), rad, grad y turn. «none» vale 0.
|
|
112
|
+
function angulo(t) {
|
|
113
|
+
const x = parseFloat(t);
|
|
114
|
+
if (Number.isNaN(x)) return 0;
|
|
115
|
+
if (/grad$/.test(t)) return x * 0.9;
|
|
116
|
+
if (/rad$/.test(t)) return x * 180 / Math.PI;
|
|
117
|
+
if (/turn$/.test(t)) return x * 360;
|
|
118
|
+
return x;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Devuelve { r, g, b, a } en 0..1 o null si no se puede interpretar.
|
|
122
|
+
function parsearColor(valor, vars, profundidad = 0) {
|
|
123
|
+
if (!valor || profundidad > 10) return null;
|
|
124
|
+
// El nombre dentro de var() distingue mayúsculas, como en el navegador: se busca tal cual.
|
|
125
|
+
const alias = valor.trim().match(/^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/i);
|
|
126
|
+
if (alias) {
|
|
127
|
+
const destino = vars.get(alias[1]);
|
|
128
|
+
return parsearColor(destino ?? alias[2], vars, profundidad + 1);
|
|
129
|
+
}
|
|
130
|
+
const v = valor.trim().toLowerCase();
|
|
131
|
+
if (v === 'white') return { r: 1, g: 1, b: 1, a: 1 };
|
|
132
|
+
if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
|
|
133
|
+
if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
|
|
134
|
+
let m = v.match(/^#([0-9a-f]{3,8})$/);
|
|
135
|
+
if (m) {
|
|
136
|
+
let h = m[1];
|
|
137
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
|
|
138
|
+
const n = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
139
|
+
return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
|
|
140
|
+
}
|
|
141
|
+
m = v.match(/^rgba?\(([^)]+)\)$/);
|
|
142
|
+
if (m) {
|
|
143
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
144
|
+
return { r: num(p[0], 255), g: num(p[1], 255), b: num(p[2], 255), a: p[3] ? num(p[3]) : 1 };
|
|
145
|
+
}
|
|
146
|
+
m = v.match(/^hsla?\(([^)]+)\)$/);
|
|
147
|
+
if (m) {
|
|
148
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
149
|
+
// Saturación y luminosidad: con % o como número de 0 a 100 (sintaxis moderna).
|
|
150
|
+
const [r, g, b] = hslARgb(angulo(p[0]), num(p[1], 100), num(p[2], 100));
|
|
151
|
+
return { r, g, b, a: p[3] ? num(p[3]) : 1 };
|
|
152
|
+
}
|
|
153
|
+
m = v.match(/^oklch\(([^)]+)\)$/);
|
|
154
|
+
if (m) {
|
|
155
|
+
const p = m[1].split(/[\s/]+/).filter(Boolean);
|
|
156
|
+
const L = num(p[0]);
|
|
157
|
+
const C = p[1].trim().endsWith('%') ? parseFloat(p[1]) / 100 * 0.4 : (parseFloat(p[1]) || 0); // 100% = 0,4
|
|
158
|
+
const H = angulo(p[2]) * Math.PI / 180;
|
|
159
|
+
const [r, g, b] = oklabARgb(L, C * Math.cos(H), C * Math.sin(H));
|
|
160
|
+
return { r: clamp(r), g: clamp(g), b: clamp(b), a: p[3] ? num(p[3]) : 1, fueraDeGama: [r, g, b].some((x) => x < -0.001 || x > 1.001) };
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const luminancia = ({ r, g, b }) => 0.2126 * srgbALineal(r) + 0.7152 * srgbALineal(g) + 0.0722 * srgbALineal(b);
|
|
166
|
+
function componer(frente, fondo) {
|
|
167
|
+
const a = frente.a ?? 1;
|
|
168
|
+
return { r: frente.r * a + fondo.r * (1 - a), g: frente.g * a + fondo.g * (1 - a), b: frente.b * a + fondo.b * (1 - a), a: 1 };
|
|
169
|
+
}
|
|
170
|
+
function contraste(c1, c2) {
|
|
171
|
+
const fondo = c2.a < 1 ? componer(c2, { r: 1, g: 1, b: 1 }) : c2;
|
|
172
|
+
const frente = c1.a < 1 ? componer(c1, fondo) : c1;
|
|
173
|
+
const L1 = luminancia(frente), L2 = luminancia(fondo);
|
|
174
|
+
return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
|
|
175
|
+
}
|
|
176
|
+
const aHex = ({ r, g, b }) => '#' + [r, g, b].map((x) => Math.round(clamp(x) * 255).toString(16).padStart(2, '0')).join('');
|
|
177
|
+
|
|
178
|
+
// Busca la luminosidad OKLCH más cercana a la original que cumpla el mínimo contra TODOS los fondos
|
|
179
|
+
// dados, manteniendo tono y croma.
|
|
180
|
+
function sugerirTodos(frente, fondos, minimo) {
|
|
181
|
+
const [L, a, b] = rgbAOklab(frente.r, frente.g, frente.b);
|
|
182
|
+
const C = Math.hypot(a, b);
|
|
183
|
+
const H = Math.atan2(b, a);
|
|
184
|
+
let mejor = null;
|
|
185
|
+
for (const dir of [-1, 1]) {
|
|
186
|
+
for (let paso = 1; ; paso++) { // recorre todo el rango hasta 0 o 1
|
|
187
|
+
const L2 = L + dir * paso * 0.0025;
|
|
188
|
+
if (L2 < 0 || L2 > 1) break;
|
|
189
|
+
let c = C;
|
|
190
|
+
let rgb = oklabARgb(L2, c * Math.cos(H), c * Math.sin(H));
|
|
191
|
+
while (rgb.some((x) => x < 0 || x > 1) && c > 0) { c -= 0.005; rgb = oklabARgb(L2, c * Math.cos(H), c * Math.sin(H)); }
|
|
192
|
+
const cand = { r: clamp(rgb[0]), g: clamp(rgb[1]), b: clamp(rgb[2]), a: 1 };
|
|
193
|
+
if (fondos.every((f) => contraste(cand, f) >= minimo + 0.05)) {
|
|
194
|
+
if (!mejor || paso < mejor.paso) mejor = { paso, color: cand, oklch: `oklch(${(L2 * 100).toFixed(1)}% ${c.toFixed(3)} ${((H * 180 / Math.PI + 360) % 360).toFixed(1)})` };
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return mejor;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Busca la luminosidad OKLCH más cercana a la original que cumpla el mínimo, manteniendo tono y croma.
|
|
203
|
+
function sugerir(frente, fondo, minimo) {
|
|
204
|
+
const [L, a, b] = rgbAOklab(frente.r, frente.g, frente.b);
|
|
205
|
+
const C = Math.hypot(a, b);
|
|
206
|
+
const H = Math.atan2(b, a);
|
|
207
|
+
let mejor = null;
|
|
208
|
+
for (const dir of [-1, 1]) {
|
|
209
|
+
for (let paso = 1; ; paso++) { // recorre todo el rango hasta 0 o 1
|
|
210
|
+
const L2 = L + dir * paso * 0.005;
|
|
211
|
+
if (L2 < 0 || L2 > 1) break;
|
|
212
|
+
let c = C;
|
|
213
|
+
let rgb = oklabARgb(L2, c * Math.cos(H), c * Math.sin(H));
|
|
214
|
+
while (rgb.some((x) => x < 0 || x > 1) && c > 0) { c -= 0.005; rgb = oklabARgb(L2, c * Math.cos(H), c * Math.sin(H)); }
|
|
215
|
+
const candidato = { r: clamp(rgb[0]), g: clamp(rgb[1]), b: clamp(rgb[2]), a: 1 };
|
|
216
|
+
if (contraste(candidato, fondo) >= minimo + 0.05) {
|
|
217
|
+
if (!mejor || paso < mejor.paso) mejor = { paso, color: candidato, oklch: `oklch(${(L2 * 100).toFixed(1)}% ${c.toFixed(3)} ${((H * 180 / Math.PI + 360) % 360).toFixed(1)})` };
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return mejor;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------- Pares a revisar ----------
|
|
226
|
+
const MINIMOS = { texto: 4.5, 'texto-grande': 3, ui: 3 };
|
|
227
|
+
const esFrente = (n) => /texto|text|foreground|\bfg\b|-fg|contraste?|on-|tinta|ink|titulo|heading|foco|focus|borde-(campo|fuerte|control|input)|border-(input|strong)|icono|icon|enlace|link/.test(n);
|
|
228
|
+
const esFondo = (n) => /fondo|background|\bbg\b|-bg|superficie|surface|base|lienzo|canvas|tarjeta|card|(acento|accent|primario|primary|marca|brand)-(suave|tinte|weak|subtle|light|50|100)/.test(n);
|
|
229
|
+
|
|
230
|
+
// Un nombre que dice explícitamente que va encima (-foreground, -fg, on-, -texto, -contraste) es
|
|
231
|
+
// frente aunque también nombre un fondo: --color-card-foreground, --color-on-surface.
|
|
232
|
+
const esFrenteExplicito = (n) => /foreground$|-fg$|(^--|-)on-|-(texto|text)$|-contraste?$|-contrast$/.test(n);
|
|
233
|
+
|
|
234
|
+
function paresPorNombre(colores) {
|
|
235
|
+
const nombres = [...colores.keys()];
|
|
236
|
+
const frentes = nombres.filter((n) => esFrente(n) && (!esFondo(n) || esFrenteExplicito(n)));
|
|
237
|
+
const fondos = nombres.filter((n) => esFondo(n) && !frentes.includes(n));
|
|
238
|
+
const pares = [];
|
|
239
|
+
for (const f of frentes) {
|
|
240
|
+
const tipo = /foco|focus|borde-|border-|icono|icon/.test(f) ? 'ui' : 'texto';
|
|
241
|
+
if (/-contraste?$|-on-|-(foreground|fg)$/.test(f)) {
|
|
242
|
+
// --color-acento-contraste (o --color-accent-contrast) va sobre --color-acento; --color-on-primary,
|
|
243
|
+
// sobre --color-primary; --color-card-foreground, sobre --color-card.
|
|
244
|
+
const candidatos = [f.replace(/-contraste?$/, ''), f.replace('-on-', '-'), f.replace(/-(foreground|fg)$/, '')].filter((n) => colores.has(n) && n !== f);
|
|
245
|
+
if (candidatos.length) { pares.push({ texto: f, fondo: candidatos[0], tipo }); continue; }
|
|
246
|
+
}
|
|
247
|
+
for (const b of fondos) pares.push({ texto: f, fondo: b, tipo });
|
|
248
|
+
}
|
|
249
|
+
return pares;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function evaluar(variables, etiqueta) {
|
|
253
|
+
const colores = new Map();
|
|
254
|
+
const noLeidos = [];
|
|
255
|
+
for (const [nombre, valor] of variables) {
|
|
256
|
+
if (!/^--colou?r-/.test(nombre)) continue;
|
|
257
|
+
const c = parsearColor(valor, variables);
|
|
258
|
+
if (c) colores.set(nombre, c); else noLeidos.push(`${nombre}: ${valor}`);
|
|
259
|
+
}
|
|
260
|
+
if (colores.size === 0) return null;
|
|
261
|
+
let pares;
|
|
262
|
+
if (op.pares) pares = PARES_ARCHIVO;
|
|
263
|
+
else if (op.todos) {
|
|
264
|
+
pares = [];
|
|
265
|
+
const lista = [...colores.keys()];
|
|
266
|
+
for (const a of lista) for (const b of lista) if (a !== b) pares.push({ texto: a, fondo: b, tipo: 'texto' });
|
|
267
|
+
} else pares = paresPorNombre(colores);
|
|
268
|
+
|
|
269
|
+
const filas = [];
|
|
270
|
+
for (const p of pares) {
|
|
271
|
+
const f = colores.get(p.texto), b = colores.get(p.fondo);
|
|
272
|
+
if (!f || !b) { filas.push({ ...p, error: 'token no encontrado o no es un color' }); continue; }
|
|
273
|
+
const ratio = contraste(f, b);
|
|
274
|
+
const minimo = MINIMOS[p.tipo] ?? 4.5;
|
|
275
|
+
const fila = { ...p, ratio: Math.round(ratio * 100) / 100, minimo, pasa: ratio >= minimo, frente: aHex(f), fondoHex: aHex(b) };
|
|
276
|
+
if (!fila.pasa) {
|
|
277
|
+
const s = sugerir(f, b.a < 1 ? componer(b, { r: 1, g: 1, b: 1 }) : b, minimo);
|
|
278
|
+
if (s) fila.sugerencia = `${s.oklch} (${aHex(s.color)}) → ${contraste(s.color, b).toFixed(2)}:1`;
|
|
279
|
+
}
|
|
280
|
+
if (f.fueraDeGama || b.fueraDeGama) fila.nota = 'algún color oklch() queda fuera de sRGB y se recortó';
|
|
281
|
+
filas.push(fila);
|
|
282
|
+
}
|
|
283
|
+
// Una sugerencia por token que falla, que pase contra todos los fondos con los que se mide.
|
|
284
|
+
const unicas = [];
|
|
285
|
+
const porToken = new Map();
|
|
286
|
+
for (const f of filas) if (f.pasa === false) porToken.set(f.texto, [...(porToken.get(f.texto) || []), f]);
|
|
287
|
+
for (const [token, fallas] of porToken) {
|
|
288
|
+
const todas = filas.filter((x) => x.texto === token && !x.error);
|
|
289
|
+
const fondos = todas.map((x) => { const b = colores.get(x.fondo); return b.a < 1 ? componer(b, { r: 1, g: 1, b: 1 }) : b; });
|
|
290
|
+
const minimo = Math.max(...todas.map((x) => x.minimo));
|
|
291
|
+
const s = sugerirTodos(colores.get(token), fondos, minimo);
|
|
292
|
+
if (s) unicas.push({ token, sugerencia: `${s.oklch} (${aHex(s.color)})`, peor: Math.min(...fondos.map((f) => contraste(s.color, f))).toFixed(2), fondos: todas.length });
|
|
293
|
+
}
|
|
294
|
+
return { etiqueta, colores: colores.size, noLeidos, filas, unicas };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// El archivo de pares se lee una sola vez (así también funciona con <(...) en la terminal).
|
|
298
|
+
const PARES_ARCHIVO = op.pares ? JSON.parse(fs.readFileSync(op.pares, 'utf8')) : null;
|
|
299
|
+
const { claro, oscuro } = leerVariables(css);
|
|
300
|
+
const resultados = [evaluar(claro, 'tema claro (por defecto)')];
|
|
301
|
+
if (oscuro.size) resultados.push(evaluar(new Map([...claro, ...oscuro]), 'modo oscuro'));
|
|
302
|
+
|
|
303
|
+
let fallas = 0, incompletos = 0, medidos = 0;
|
|
304
|
+
console.log(`Archivo: ${archivo}`);
|
|
305
|
+
if (!resultados.some(Boolean)) console.log('\nNo encontré ninguna variable --color-* que pueda leer como color.');
|
|
306
|
+
for (const r of resultados.filter(Boolean)) {
|
|
307
|
+
console.log(`\n== ${r.etiqueta}: ${r.colores} colores leídos ==`);
|
|
308
|
+
if (r.filas.length === 0) console.log('No pude armar pares por nombre. Usa --pares o --todos.');
|
|
309
|
+
const ordenadas = r.filas;
|
|
310
|
+
for (const f of ordenadas) {
|
|
311
|
+
if (f.error) { console.log(` ? ${f.texto} sobre ${f.fondo}: ${f.error}`); incompletos++; continue; }
|
|
312
|
+
medidos++;
|
|
313
|
+
if (op.todos && f.pasa) continue;
|
|
314
|
+
const marca = f.pasa ? 'OK ' : 'NO ';
|
|
315
|
+
console.log(` ${marca} ${f.ratio.toFixed(2)}:1 (mín. ${f.minimo}) ${f.texto} ${f.frente} sobre ${f.fondo} ${f.fondoHex}${f.nota ? ' [' + f.nota + ']' : ''}`);
|
|
316
|
+
if (f.sugerencia) console.log(` sugerencia para ${f.texto}: ${f.sugerencia}`);
|
|
317
|
+
if (!f.pasa) fallas++;
|
|
318
|
+
}
|
|
319
|
+
if (op.todos) console.log(` (con --todos solo se listan las combinaciones que no pasan 4,5:1; ${r.filas.filter((f) => f.pasa).length} sí pasan)`);
|
|
320
|
+
for (const u of r.unicas || []) console.log(` → Un solo valor para ${u.token} que pasa en sus ${u.fondos} fondos: ${u.sugerencia} (peor caso ${u.peor}:1)`);
|
|
321
|
+
if (r.noLeidos.length) console.log(` No interpretados: ${r.noLeidos.join(' · ')}`);
|
|
322
|
+
}
|
|
323
|
+
if (fallas) console.log(`\n${fallas} pares no llegan al mínimo de WCAG 2.2 AA.`);
|
|
324
|
+
if (incompletos) console.log(`\n${incompletos} pares no se pudieron medir: revisa que esos tokens existan y sean colores.`);
|
|
325
|
+
if (!medidos) console.log('\nNo se midió ningún par: el informe no dice nada sobre tu contraste.');
|
|
326
|
+
else if (!fallas && !incompletos) console.log('\nTodos los pares revisados cumplen WCAG 2.2 AA.');
|
|
327
|
+
console.log('Ojo: esto mide los tokens. Un texto sobre una imagen o un degradado hay que revisarlo en pantalla.');
|
|
328
|
+
if (op.json) fs.writeFileSync(op.json, JSON.stringify(resultados, null, 2));
|
|
329
|
+
process.exit(fallas && !op.todos ? 1 : (incompletos || !medidos ? 2 : 0));
|
|
330
|
+
```
|