Revert "feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)"

This reverts commit 49c9f2109f.
This commit is contained in:
Reza Rezvani
2026-03-18 08:30:06 +01:00
parent 827027b19b
commit de7723036a
16 changed files with 18 additions and 2274 deletions

View File

@@ -82,7 +82,7 @@
{
"name": "engineering-skills",
"source": "./engineering-team",
"description": "27 engineering skills: architecture, frontend, backend, fullstack, QA, DevOps, security, AI/ML, data engineering, Playwright (9 sub-skills), self-improving agent, Stripe integration, TDD guide, tech stack evaluator, Google Workspace CLI. Added: review-fix-a11y (WCAG 2.2 a11y audit + fix). Added: free-llm-api (ChatAnywhere + provider rotation pool).",
"description": "25 engineering skills: architecture, frontend, backend, fullstack, QA, DevOps, security, AI/ML, data engineering, Playwright (9 sub-skills), self-improving agent, Stripe integration, TDD guide, tech stack evaluator, Google Workspace CLI.",
"version": "2.1.2",
"author": {
"name": "Alireza Rezvani"
@@ -102,12 +102,7 @@
"gws",
"gmail",
"google-drive",
"google-sheets",
"accessibility",
"a11y",
"llm",
"free-api",
"chatanywhere"
"google-sheets"
],
"category": "development"
},
@@ -252,7 +247,7 @@
{
"name": "autoresearch-agent",
"source": "./engineering/autoresearch-agent",
"description": "Autonomous experiment loop \u2014 optimize any file by a measurable metric. 5 slash commands (/ar:setup, /ar:run, /ar:loop, /ar:status, /ar:resume), 8 built-in evaluators, configurable loop intervals (10min to monthly).",
"description": "Autonomous experiment loop optimize any file by a measurable metric. 5 slash commands (/ar:setup, /ar:run, /ar:loop, /ar:status, /ar:resume), 8 built-in evaluators, configurable loop intervals (10min to monthly).",
"version": "2.1.2",
"author": {
"name": "Alireza Rezvani"
@@ -428,7 +423,7 @@
{
"name": "agenthub",
"source": "./engineering/agenthub",
"description": "Multi-agent collaboration \u2014 spawn N parallel subagents that compete on code optimization, content drafts, research approaches, or any task that benefits from diverse solutions. 7 slash commands (/hub:init, /hub:spawn, /hub:status, /hub:eval, /hub:merge, /hub:board, /hub:run), agent templates, DAG-based orchestration, LLM judge mode, message board coordination.",
"description": "Multi-agent collaboration spawn N parallel subagents that compete on code optimization, content drafts, research approaches, or any task that benefits from diverse solutions. 7 slash commands (/hub:init, /hub:spawn, /hub:status, /hub:eval, /hub:merge, /hub:board, /hub:run), agent templates, DAG-based orchestration, LLM judge mode, message board coordination.",
"version": "2.1.2",
"author": {
"name": "Alireza Rezvani"

View File

@@ -3,7 +3,7 @@
"name": "claude-code-skills",
"description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM",
"repository": "https://github.com/alirezarezvani/claude-skills",
"total_skills": 166,
"total_skills": 164,
"skills": [
{
"name": "contract-and-proposal-writer",

View File

@@ -1 +0,0 @@
../../engineering-team/free-llm-api

View File

@@ -1 +0,0 @@
../../engineering-team/review-fix-a11y

View File

@@ -1,7 +1,7 @@
{
"version": "1.0.0",
"name": "gemini-cli-skills",
"total_skills": 248,
"total_skills": 246,
"skills": [
{
"name": "README",
@@ -608,11 +608,6 @@
"category": "engineering",
"description": ">-"
},
{
"name": "review-fix-a11y",
"category": "engineering",
"description": "Audit and fix WCAG 2.2 accessibility issues in React, Next.js, Vue, Angular, and plain HTML. Use when asked about a11y, accessibility, WCAG, screen readers, keyboard navigation, ARIA, focus management, color contrast, or fixing accessibility in front-end code. Includes static file scanner and contrast ratio checker."
},
{
"name": "agent-designer",
"category": "engineering-advanced",
@@ -1237,11 +1232,6 @@
"name": "risk-management-specialist",
"category": "ra-qm",
"description": "Medical device risk management specialist implementing ISO 14971 throughout product lifecycle. Provides risk analysis, risk evaluation, risk control, and post-production information analysis. Use when user mentions risk management, ISO 14971, risk analysis, FMEA, fault tree analysis, hazard identification, risk control, risk matrix, benefit-risk analysis, residual risk, risk acceptability, or post-market risk."
},
{
"name": "free-llm-api",
"category": "engineering",
"description": "Set up free OpenAI-compatible LLM APIs: ChatAnywhere (GPT-4o free), Groq, Cerebras, OpenRouter, Mistral. Includes provider rotation pool, rate limit handling, and drop-in base_url replacement."
}
],
"categories": {

View File

@@ -1,22 +1,20 @@
# Engineering Team Skills - Claude Code Guidance
This guide covers the 25 production-ready engineering skills and their Python automation tools.
This guide covers the 24 production-ready engineering skills and their Python automation tools.
## Engineering Skills Overview
**Core Engineering (15 skills):**
**Core Engineering (14 skills):**
- senior-architect, senior-frontend, senior-backend, senior-fullstack
- senior-qa, senior-devops, senior-secops
- code-reviewer, senior-security
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator, epic-design
- **review-fix-a11y** — WCAG 2.2 accessibility audit and fix for React, Next.js, Vue, Angular, and HTML
- **free-llm-api** — Free/low-cost OpenAI-compatible API setup: ChatAnywhere, Groq, Cerebras, OpenRouter, provider rotation pool
**AI/ML/Data (5 skills):**
- senior-data-scientist, senior-data-engineer, senior-ml-engineer
- senior-prompt-engineer, senior-computer-vision
**Total Tools:** 34+ Python automation tools
**Total Tools:** 32+ Python automation tools
## Core Engineering Tools
@@ -289,48 +287,9 @@ services:
---
**Last Updated:** March 17, 2026
**Skills Deployed:** 27 engineering skills production-ready
**Total Tools:** 39+ Python automation tools across core + AI/ML/Data + epic-design + a11y
---
## review-fix-a11y
Audit and fix WCAG 2.2 accessibility issues in any front-end project. Covers React, Next.js, Vue, Angular, and plain HTML — including keyboard navigation, ARIA, focus management, form labels, color contrast, and screen reader support.
**Tools:**
**A11y Auditor** (`review-fix-a11y/scripts/a11y_audit.py`)
- Scans HTML, JSX, TSX, Vue, and CSS files for 17 common a11y violations
- Severity-ranked output: critical → serious → moderate → minor
- Exit code 1 when blocking (critical/serious) issues found — CI-friendly
```bash
# Audit a project directory
python review-fix-a11y/scripts/a11y_audit.py /path/to/project
# Only show critical and serious issues
python review-fix-a11y/scripts/a11y_audit.py /path/to/project --severity serious
# JSON output for CI integration
python review-fix-a11y/scripts/a11y_audit.py /path/to/project --json
```
**Contrast Checker** (`review-fix-a11y/scripts/contrast_checker.py`)
- Computes WCAG relative luminance and contrast ratio
- Reports AA/AAA pass/fail for normal text, large text, and UI components
- Suggests accessible background alternatives for a given foreground
```bash
# Check two colors
python review-fix-a11y/scripts/contrast_checker.py "#1a1a2e" "#ffffff"
# Suggest accessible backgrounds for a foreground color
python review-fix-a11y/scripts/contrast_checker.py "#777777" --suggest
```
**Use for:** Accessibility audits, pre-merge a11y reviews, WCAG compliance checks, fixing specific a11y issues in components
**Last Updated:** March 13, 2026
**Skills Deployed:** 25 engineering skills production-ready
**Total Tools:** 37+ Python automation tools across core + AI/ML/Data + epic-design
---

View File

@@ -1,549 +0,0 @@
---
name: "free-llm-api"
description: "Set up and use free or low-cost LLM API endpoints compatible with the OpenAI SDK. Use when the user wants to reduce API costs, access GPT/Claude/DeepSeek/Gemini for free, configure an OpenAI-compatible proxy, set up API key rotation, build a cloud provider fallback pool, manage LLM API keys centrally, or when they mention ChatAnywhere, Groq, Cerebras, OpenRouter, Mistral free tier, free ChatGPT API, llm-mux, one-api, or turning a Claude Pro/GitHub Copilot/Gemini subscription into a local API."
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: engineering
updated: 2026-03-17
---
# Free & Low-Cost LLM APIs
You are an expert in configuring cost-effective LLM API access. Your goal is to help developers get GPT-4o, DeepSeek, Claude, and other frontier models for free or near-free — using OpenAI-compatible endpoints that drop into any existing codebase with a one-line change.
## Before Starting
Gather this context (ask if not provided):
### 1. Current Setup
- What SDK/library are you using? (openai Python/Node, LangChain, LiteLLM, raw HTTP)
- Which models do you need? (GPT-4o, DeepSeek, Claude, Gemini, etc.)
- Location: inside China or outside? (affects which relay to use)
### 2. Goals
- Zero cost, or willing to pay small amounts?
- Need high rate limits or is 200 req/day sufficient?
- Production or development/research use?
---
## How This Skill Works
### Mode 1: Free Tier Setup
Get free API access with a GitHub account — zero credit card required.
### Mode 2: Provider Rotation Pool
Build a fault-tolerant pool that rotates across free providers on rate limit errors.
### Mode 3: Drop-in Replacement
Swap base URL in existing code — no other changes required.
---
## Free Providers at a Glance
| Provider | Free Tier | Models | Rate Limit | Location |
|----------|-----------|--------|------------|----------|
| **llm-mux** | Unlimited (uses your subscriptions) | Claude Pro, Copilot GPT-5, Gemini, Codex | Subscription quota | Local (`localhost:8317`) |
| **One API** | Self-hosted key manager | Any provider you configure | Your quotas | Local or remote (`localhost:3000`) |
| **Bytez** | Free tier + pay-per-use | 175k+ open-source models, GPT, Claude, Gemini | Free tier available | `api.bytez.com` |
| **ChatAnywhere** | 200 req/day (GitHub login) | GPT-4o-mini, GPT-4o, DeepSeek-v3, Claude, Gemini | 200/day/IP+Key | Global (CN relay available) |
| **Groq** | Free tier | Llama-3.3-70b, Mixtral, Gemma | ~30 RPM | Global |
| **Cerebras** | Free tier | Llama-3.1-8b, 70b | ~30 RPM | Global |
| **Mistral** | Free tier | mistral-small, mistral-7b | ~1 RPM | Global |
| **OpenRouter** | Free models (`:free` suffix) | Llama, Mistral, Gemma variants | Varies | Global |
| **Google AI Studio** | 15 RPM free | Gemini 1.5 Flash, Pro | 15 RPM | Global |
All providers use the OpenAI-compatible `/v1/chat/completions` endpoint.
---
## Setup: llm-mux (Best if You Have Existing Subscriptions)
llm-mux ([github.com/nghyane/llm-mux](https://github.com/nghyane/llm-mux)) turns existing Claude Pro, GitHub Copilot, and Gemini subscriptions into a local OpenAI-compatible API. No API keys — OAuth login only. Runs at `localhost:8317`.
**Supported subscriptions:**
| Provider | Login command | Models unlocked |
|----------|--------------|----------------|
| Claude Pro/Max | `llm-mux login claude` | claude-sonnet-4, claude-opus-4 |
| GitHub Copilot | `llm-mux login copilot` | gpt-4o, gpt-4.1, gpt-5, gpt-5.1, gpt-5.2 |
| Google Gemini | `llm-mux login antigravity` | gemini-2.5-pro, gemini-2.5-flash |
| ChatGPT Plus/Pro | `llm-mux login codex` | gpt-5 series |
| Alibaba Cloud | `llm-mux login qwen` | qwen models |
| AWS/Amazon Q | `llm-mux login kiro` | Amazon Q models |
### Install
```bash
curl -fsSL https://raw.githubusercontent.com/nghyane/llm-mux/main/install.sh | bash
```
### Login and Start
```bash
# Login to one or more providers
llm-mux login claude # Claude Pro subscription
llm-mux login copilot # GitHub Copilot subscription
llm-mux login antigravity # Google Gemini
# Start the gateway (runs on localhost:8317)
llm-mux
```
### Use in Code
```python
from openai import OpenAI
client = OpenAI(
api_key="unused", # llm-mux ignores API key
base_url="http://localhost:8317/v1",
)
response = client.chat.completions.create(
model="claude-sonnet-4-20250514", # or gpt-4o, gemini-2.5-pro, etc.
messages=[{"role": "user", "content": "Hello"}],
)
```
### Check Available Models
```bash
curl http://localhost:8317/v1/models
```
### Multi-Account Load Balancing
Login multiple accounts — llm-mux auto-rotates and handles quota limits:
```bash
llm-mux login claude # Account 1
llm-mux login claude # Account 2 (rotates automatically)
```
### Run as Background Service (macOS)
```bash
# Install as launchd service
llm-mux service install
llm-mux service start
# Check status
llm-mux service status
```
### Config File (`~/.config/llm-mux/config.yaml`)
```yaml
port: 8317
disable-auth: true # No API key required for local use
request-retry: 3
stream-timeout: 300
```
---
## Setup: One API (Best for Teams / Multi-Provider Management)
One API ([github.com/songquanpeng/one-api](https://github.com/songquanpeng/one-api), 30k+ stars) is a self-hosted LLM API gateway with a full web UI. Add all your provider API keys once, then hand out unified tokens to teammates or apps — with quota limits, usage tracking, and automatic load balancing across channels.
**When to use One API vs llm-mux:**
| | One API | llm-mux |
|-|---------|---------|
| Setup | Web UI + Docker | CLI binary |
| Auth | API key tokens you issue | OAuth subscription |
| Best for | Teams, multi-app, billing control | Personal, subscription-based |
| Web dashboard | Yes | No |
| User management | Yes | No |
### Quick Start (Docker)
```bash
docker run --name one-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v /data/one-api:/data \
justsong/one-api
```
Open `http://localhost:3000` — default credentials: `root` / `123456` (change immediately).
### Docker Compose (with MySQL for persistence)
```yaml
version: '3'
services:
one-api:
image: justsong/one-api
ports:
- "3000:3000"
environment:
- SQL_DSN=root:password@tcp(mysql:3306)/oneapi
- SESSION_SECRET=change_me
- INITIAL_ROOT_TOKEN=your-root-token
depends_on:
- mysql
restart: always
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: oneapi
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
```
### Configuration
1. **Add channels** (Channels page): Add your API keys for OpenAI, Azure, Claude, Gemini, DeepSeek, etc.
2. **Create tokens** (Tokens page): Generate tokens with optional quota limits and expiry.
3. **Use the token** as your API key — set base URL to your One API instance.
### Use in Code
```python
from openai import OpenAI
client = OpenAI(
api_key="your-one-api-token", # Token from One API Tokens page
base_url="http://localhost:3000/v1", # Or your remote One API URL
)
# Works with any model you've configured in channels
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
)
```
### Target a Specific Channel
```bash
# Append channel ID to token: TOKEN-CHANNEL_ID
Authorization: Bearer sk-your-token-123
```
### Key Environment Variables
| Variable | Purpose | Example |
|----------|---------|---------|
| `SQL_DSN` | MySQL instead of SQLite | `root:pass@tcp(localhost:3306)/oneapi` |
| `SESSION_SECRET` | Stable session across restarts | `random_string` |
| `INITIAL_ROOT_TOKEN` | Pre-set root token on first start | `sk-my-root-token` |
| `REDIS_CONN_STRING` | Redis for rate limiting | `redis://localhost:6379` |
| `RELAY_PROXY` | Outbound proxy for API calls | `http://proxy:8080` |
### Supported Providers
OpenAI, Azure OpenAI, Anthropic Claude, Google Gemini/PaLM, Baidu Wenxin, Alibaba Qwen, Zhipu ChatGLM, DeepSeek, and more — anything with an OpenAI-compatible endpoint can be added as a custom channel.
---
## Setup: ChatAnywhere (Best Free Option)
ChatAnywhere ([github.com/chatanywhere/GPT_API_free](https://github.com/chatanywhere/GPT_API_free)) provides free API keys backed by real OpenAI/DeepSeek/Claude accounts.
### 1. Get a Free Key
1. Visit: `https://api.chatanywhere.tech/v1/oauth/free/render`
2. Log in with GitHub
3. Copy your free API key (starts with `sk-`)
### 2. Configure
```bash
# .env
CHATANYWHERE_API_KEY=sk-your-key-here
# Base URLs
# Inside China (lower latency): https://api.chatanywhere.tech
# Outside China: https://api.chatanywhere.org
```
### 3. Use in Code
**Python (openai SDK)**
```python
from openai import OpenAI
client = OpenAI(
api_key="sk-your-key-here",
base_url="https://api.chatanywhere.tech/v1",
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
)
print(response.choices[0].message.content)
```
**Environment variable method**
```bash
export OPENAI_API_KEY=sk-your-key-here
export OPENAI_BASE_URL=https://api.chatanywhere.tech/v1
# Existing code using openai.OpenAI() now routes through ChatAnywhere
```
**Node.js**
```js
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.CHATANYWHERE_API_KEY,
baseURL: "https://api.chatanywhere.tech/v1",
});
```
### Supported Models (Free Tier)
- `gpt-4o-mini`, `gpt-3.5-turbo`, `gpt-4.1-mini` — 200/day
- `gpt-4o`, `gpt-5` — 5/day
- `deepseek-r1`, `deepseek-v3` — 30/day
- `text-embedding-3-small` — 200/day
---
## Setup: Bytez (175k+ Serverless Models)
Bytez ([bytez.com](https://bytez.com)) is the largest serverless model inference API — 1 API key for 175k+ open-source models plus closed-source (OpenAI, Claude, Gemini). No infra, no cold starts to manage.
**Get key:** `https://bytez.com/api`
**OpenAI-compatible base URL:** `https://api.bytez.com/models/v2/openai/v1`
### Open-Source Models (your Bytez key only)
```python
from openai import OpenAI
client = OpenAI(
api_key="YOUR_BYTEZ_KEY",
base_url="https://api.bytez.com/models/v2/openai/v1",
)
response = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "Hello"}],
)
```
### Closed-Source Models (Bytez key + provider key)
```python
import requests
requests.post(
"https://api.bytez.com/models/v2/openai/v1/chat/completions",
headers={
"Authorization": "YOUR_BYTEZ_KEY",
"provider-key": "YOUR_OPENAI_KEY", # pass-through, never stored by Bytez
"Content-Type": "application/json",
},
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]},
)
```
### Native SDK
```bash
pip install bytez
```
```python
from bytez import Bytez
sdk = Bytez("YOUR_BYTEZ_KEY")
model = sdk.model("meta-llama/Llama-3.1-8B-Instruct")
result = model.run("Once upon a time")
print(result.output)
```
### Supported Tasks (33 ML task types)
Chat, text-generation, image-to-text, text-to-image, text-to-speech, ASR, translation, summarization, object-detection, image-classification, video-text-to-text, and more.
### List Available Models
```python
result = sdk.list.models() # 175k+ models
result = sdk.list.tasks() # 33 task types
```
---
## Setup: Groq (Fastest Free Inference)
```python
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("GROQ_API_KEY"),
base_url="https://api.groq.com/openai/v1",
)
# Use: llama-3.3-70b-versatile, mixtral-8x7b-32768, gemma2-9b-it
```
Get key: `https://console.groq.com/keys`
---
## Setup: OpenRouter (100+ Free Models)
```python
client = OpenAI(
api_key=os.getenv("OPENROUTER_API_KEY"),
base_url="https://openrouter.ai/api/v1",
)
# Free models end with :free — e.g. "meta-llama/llama-3.3-70b-instruct:free"
```
Get key: `https://openrouter.ai/keys`
Free models list: `https://openrouter.ai/models?q=free`
---
## Provider Rotation Pool (Recommended)
Build a fault-tolerant pool that automatically rotates on 429 rate limit errors:
```python
import os, requests
_CLOUD_POOL = [
# (base_url, api_key, model)
("http://localhost:8317", "unused", "gpt-4o"), # llm-mux local — falls through if not running
(os.getenv("ONE_API_BASE","localhost:3000"), os.getenv("ONE_API_KEY",""), "gpt-4o"), # one-api self-hosted gateway
("https://api.groq.com/openai", os.getenv("GROQ_API_KEY", ""), "llama-3.3-70b-versatile"),
("https://api.cerebras.ai", os.getenv("CEREBRAS_API_KEY", ""), "llama3.1-8b"),
("https://api.mistral.ai", os.getenv("MISTRAL_API_KEY", ""), "mistral-small-latest"),
("https://api.chatanywhere.tech", os.getenv("CHATANYWHERE_API_KEY",""), "gpt-4o-mini"),
("https://openrouter.ai/api", os.getenv("OPENROUTER_API_KEY", ""), "meta-llama/llama-3.3-70b-instruct:free"),
]
def llm_call(prompt: str, max_tokens: int = 100, timeout: int = 30) -> str:
for base_url, api_key, model in _CLOUD_POOL:
if not api_key:
continue
try:
r = requests.post(
f"{base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": 0},
timeout=timeout,
)
if r.status_code == 429:
continue # rate limited — try next
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
except Exception:
continue
raise RuntimeError("All LLM providers failed or rate-limited")
```
---
## LangChain / LiteLLM Integration
**LangChain**
```python
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="gpt-4o-mini",
openai_api_key=os.getenv("CHATANYWHERE_API_KEY"),
openai_api_base="https://api.chatanywhere.tech/v1",
)
```
**LiteLLM (universal proxy)**
```python
import litellm
response = litellm.completion(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
api_key=os.getenv("CHATANYWHERE_API_KEY"),
api_base="https://api.chatanywhere.tech/v1",
)
```
---
## Rate Limit Strategy
| Situation | Strategy |
|-----------|----------|
| 429 from one provider | Rotate to next in pool immediately |
| All providers 429 | Exponential backoff: 2s, 4s, 8s |
| Daily limit reached | Fall back to local Ollama or next-day reset |
| Need more than 200/day | Use Groq (higher RPM) or buy paid ChatAnywhere key |
```python
import time
def llm_call_with_backoff(prompt: str, retries: int = 3) -> str:
for attempt in range(retries):
try:
return llm_call(prompt)
except RuntimeError:
if attempt < retries - 1:
time.sleep(2 ** attempt)
raise RuntimeError("All retries exhausted")
```
---
## Proactive Triggers
- **`OPENAI_API_KEY` in code with no `base_url`** → suggest ChatAnywhere or Groq to avoid billing
- **Single provider, no fallback** → suggest rotation pool to prevent downtime
- **Hardcoded API key in source** → flag as security issue, suggest env vars
- **`gpt-4` or `gpt-4-turbo` on a budget** → suggest `gpt-4o-mini` (98% cheaper, similar quality for most tasks)
- **Embedding costs** → suggest `text-embedding-3-small` via ChatAnywhere free tier
---
## Output Artifacts
| When you ask for... | You get... |
|---------------------|------------|
| Quick setup | `.env` template + one-file integration code |
| Rotation pool | Drop-in `llm_call()` function with all free providers |
| LangChain setup | ChatOpenAI config snippet for chosen provider |
| Cost estimate | Comparison of free vs paid tiers for your use case |
| Troubleshooting | Diagnostic checklist for 401/429/timeout errors |
---
## Troubleshooting
| Error | Cause | Fix |
|-------|-------|-----|
| `401 Unauthorized` | Wrong key or key not activated | Re-generate key at provider dashboard |
| `429 Too Many Requests` | Rate limit hit | Rotate provider or wait for reset |
| `404 Not Found` | Wrong base URL or model name | Check model list for that provider |
| No response / timeout | Network block or wrong endpoint | Try alternate base URL (`chatanywhere.org` vs `.tech`) |
| `model not found` | Model not available on free tier | Check provider's free model list |
---
## Related Skills
| Skill | Use instead when... |
|-------|---------------------|
| `claude-api` | Building production apps with Anthropic's Claude API directly |
| `senior-backend` | Full API integration architecture beyond just LLM calls |
| `env-secrets-manager` | Managing API keys securely across environments |
---
## Reference
→ [references/providers.md](references/providers.md) — full model lists, rate limits, and pricing for each provider

View File

@@ -1,7 +0,0 @@
{
"name": "free-llm-api",
"version": "1.0.0",
"description": "Set up free or low-cost OpenAI-compatible LLM APIs: ChatAnywhere, Groq, Cerebras, OpenRouter, llm-mux, One API",
"author": "Alireza Rezvani",
"category": "engineering"
}

View File

@@ -1,272 +0,0 @@
# Free LLM Provider Reference
## Bytez (Serverless Model API)
**Source:** [github.com/Bytez-com/docs](https://github.com/Bytez-com/docs)
**Get key:** `https://bytez.com/api`
**OpenAI-compatible base URL:** `https://api.bytez.com/models/v2/openai/v1`
**Scale:** 175k+ open-source models, 33 ML task types, plus closed-source pass-through (OpenAI, Claude, Gemini, Mistral, Cohere)
**SDKs:** Python (`pip install bytez`), JavaScript (`npm i bytez.js`), Julia, HTTP
**Free tier:** Available — apply for $200k AI grant at `https://docs.google.com/forms/d/e/1FAIpQLSfpm9hHTKRLTBrudOnikqM47etOhIhXiTbf0bBeFbhpqw9VZg/viewform`
**Key models (open-source, free tier):**
- `meta-llama/Llama-3.1-8B-Instruct`
- `meta-llama/Llama-3.3-70B-Instruct`
- `deepseek-ai/DeepSeek-R1`
- `mistralai/Mistral-7B-Instruct-v0.3`
- `microsoft/phi-4`
- Any of 175k+ HuggingFace-hosted models
**Auth for closed-source:** Pass `provider-key` header — Bytez routes it as a pass-through, never stored.
---
## One API (Self-Hosted Gateway)
**Source:** [github.com/songquanpeng/one-api](https://github.com/songquanpeng/one-api) (30k+ stars)
**What it is:** A full-featured LLM API management and key redistribution system. Add all your provider API keys once, issue unified tokens to apps/users with quota limits and expiry, get a web dashboard for usage stats.
**Default port:** `3000`
**Quick install:**
```bash
docker run --name one-api -d --restart always \
-p 3000:3000 -v /data/one-api:/data justsong/one-api
# Open http://localhost:3000 — login: root / 123456
```
**Supported providers (channels):**
| Provider | Notes |
|----------|-------|
| OpenAI | All models incl. GPT-5 |
| Azure OpenAI | API version configurable |
| Anthropic Claude | All Claude models |
| Google Gemini / PaLM | v1 and v1beta |
| DeepSeek | Chat + Coder |
| Baidu Wenxin (ERNIE) | Chinese models |
| Alibaba Qwen | Tongyi Qianwen |
| Zhipu ChatGLM | BigModel API |
| Custom OpenAI-compatible | Any base URL |
**Key features:**
- Web UI: channel management, token creation, user management, quota tracking
- Load balancing across multiple keys/channels for same model
- Per-token quota limits and expiry dates
- User groups with different rate multipliers
- Auto-channel health testing
- Usage logs per token/user/channel
**Key env vars:**
| Variable | Purpose |
|----------|---------|
| `SQL_DSN` | MySQL DSN (default: SQLite) |
| `SESSION_SECRET` | Stable sessions across restarts |
| `INITIAL_ROOT_TOKEN` | Pre-set root token on first start |
| `REDIS_CONN_STRING` | Redis for rate limiting |
| `RELAY_PROXY` | Outbound HTTP proxy |
---
## llm-mux (Local Gateway)
**Source:** [github.com/nghyane/llm-mux](https://github.com/nghyane/llm-mux)
**What it is:** A Go binary that turns existing AI subscriptions into a local OpenAI-compatible API server. No API keys — uses OAuth to authenticate with provider accounts you already pay for.
**Install:**
```bash
curl -fsSL https://raw.githubusercontent.com/nghyane/llm-mux/main/install.sh | bash
```
**Base URL:** `http://localhost:8317` (configurable via `LLM_MUX_PORT`)
**Supported providers and models:**
| Provider | Subscription needed | Login command | Key models |
|----------|-------------------|--------------|-----------|
| Claude | Claude Pro/Max | `llm-mux login claude` | claude-sonnet-4-20250514, claude-opus-4-5-20251101 |
| GitHub Copilot | Copilot subscription | `llm-mux login copilot` | gpt-4o, gpt-4.1, gpt-5, gpt-5.1, gpt-5.2 |
| Google Gemini | Google One AI Premium or free | `llm-mux login antigravity` | gemini-2.5-pro, gemini-2.5-flash |
| OpenAI Codex | ChatGPT Plus/Pro | `llm-mux login codex` | gpt-5 series |
| Qwen | Alibaba Cloud account | `llm-mux login qwen` | qwen models |
| Kiro | AWS/Amazon Q Developer | `llm-mux login kiro` | Amazon Q models |
| Cline | Cline subscription | `llm-mux login cline` | Cline models |
**Key features:**
- Multi-account load balancing — login multiple accounts, auto-rotates
- Auto-retry on quota limits across accounts
- Anthropic + Gemini + Ollama compatible endpoints (not just OpenAI)
- Run as a background service (`llm-mux service install`)
- Management API for usage stats
**Config file:** `~/.config/llm-mux/config.yaml`
**Token storage:** `~/.config/llm-mux/auth/`
---
## ChatAnywhere
**Source:** [github.com/chatanywhere/GPT_API_free](https://github.com/chatanywhere/GPT_API_free) (36k+ stars)
**Get key:** `https://api.chatanywhere.tech/v1/oauth/free/render` (GitHub login required)
**Base URLs:**
- `https://api.chatanywhere.tech` — China relay (lower latency inside CN)
- `https://api.chatanywhere.org` — Global endpoint
**Free tier limits:** 200 req/day per IP+Key combination
**Free models:**
| Model | Daily Limit |
|-------|------------|
| gpt-4o-mini | 200/day |
| gpt-3.5-turbo | 200/day |
| gpt-4.1-mini | 200/day |
| gpt-4.1-nano | 200/day |
| gpt-5-mini | 200/day |
| gpt-4o | 5/day |
| gpt-5 | 5/day |
| gpt-5.1 | 5/day |
| deepseek-r1 | 30/day |
| deepseek-v3 | 30/day |
| text-embedding-3-small | 200/day |
| text-embedding-3-large | 200/day |
**Notes:** Free key requires personal/educational/non-commercial use only. No commercial use.
---
## Groq
**Get key:** `https://console.groq.com/keys`
**Base URL:** `https://api.groq.com/openai/v1`
**Free tier:** Generous free tier, no credit card required
**Models (free):**
| Model | Context | Speed |
|-------|---------|-------|
| llama-3.3-70b-versatile | 128k | Very fast |
| llama-3.1-8b-instant | 128k | Fastest |
| mixtral-8x7b-32768 | 32k | Fast |
| gemma2-9b-it | 8k | Fast |
| deepseek-r1-distill-llama-70b | 128k | Fast |
**Rate limits (free tier):**
- 30 RPM (requests per minute)
- 6,000 TPM (tokens per minute) for large models
- 14,400 RPD (requests per day)
---
## Cerebras
**Get key:** `https://cloud.cerebras.ai/`
**Base URL:** `https://api.cerebras.ai/v1`
**Free tier:** Free with account
**Models (free):**
| Model | Notes |
|-------|-------|
| llama3.1-8b | Very fast inference |
| llama3.1-70b | Fast inference |
| llama-3.3-70b | Latest Llama |
**Advantage:** World's fastest inference (wafer-scale chip) — great for high-volume low-latency tasks.
---
## Mistral AI
**Get key:** `https://console.mistral.ai/api-keys/`
**Base URL:** `https://api.mistral.ai/v1`
**Free tier:** `mistral-small-latest` and open-weight models at 1 RPM free
**Models (free/open-weight):**
| Model | Notes |
|-------|-------|
| mistral-small-latest | 1 RPM free |
| open-mistral-7b | Free |
| open-mixtral-8x7b | Free |
| open-mistral-nemo | Free |
---
## OpenRouter
**Get key:** `https://openrouter.ai/keys`
**Base URL:** `https://openrouter.ai/api/v1`
**Free models:** 100+ models available for free (`:free` suffix)
**Best free models:**
| Model | Context |
|-------|---------|
| meta-llama/llama-3.3-70b-instruct:free | 128k |
| google/gemma-3-27b-it:free | 8k |
| mistralai/mistral-7b-instruct:free | 32k |
| deepseek/deepseek-r1:free | 64k |
| microsoft/phi-3-medium-128k-instruct:free | 128k |
**Notes:** Free models may have higher latency. Rate limits vary by model.
---
## Google AI Studio (Gemini)
**Get key:** `https://aistudio.google.com/app/apikey`
**Base URL:** `https://generativelanguage.googleapis.com/v1beta/openai/` (OpenAI-compatible)
**Free tier:**
| Model | Free RPM | Free RPD |
|-------|---------|---------|
| gemini-1.5-flash | 15 | 1,500 |
| gemini-1.5-pro | 2 | 50 |
| gemini-2.0-flash | 15 | 1,500 |
```python
client = OpenAI(
api_key=os.getenv("GEMINI_API_KEY"),
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
)
```
---
## Cohere
**Get key:** `https://dashboard.cohere.com/api-keys`
**Base URL:** `https://api.cohere.ai/compatibility/v1` (OpenAI-compatible)
**Free tier:** Trial key, 20 RPM
**Best free model:** `command-r` — great for RAG and tool use
---
## Quick Comparison
| Provider | Best for | Free limit | Signup friction |
|----------|---------|-----------|----------------|
| ChatAnywhere | GPT-4o access, CN users | 200/day | GitHub login |
| Groq | Speed, Llama models | 14,400/day | Email |
| Cerebras | Ultra-fast inference | Generous | Email |
| OpenRouter | Model variety | 100+ free models | Email |
| Google AI Studio | Gemini models | 1,500/day | Google account |
| Mistral | European models | Low (1 RPM) | Email |

View File

@@ -1,299 +0,0 @@
---
name: "review-fix-a11y"
description: "Check and fix accessibility (a11y) on front-end projects (web and mobile web), including Next.js, React, Vue, Angular. Use when the user asks about accessibility, a11y, WCAG, screen readers, voice control, keyboard navigation, focus management, ARIA, semantic HTML, color contrast, or fixing accessibility issues in HTML, React, Next.js, Vue, or other front-end code. For native mobile apps (React Native, iOS, Android), see reference; patterns differ."
license: MIT
metadata:
version: 1.0.0
author: Neha
category: engineering
updated: 2026-03-17
---
# Review and Fix Accessibility (a11y)
You are an accessibility expert specializing in WCAG 2.2 compliance for front-end projects. Your goal is to systematically audit and fix accessibility issues so every user — including those using screen readers, keyboard navigation, or voice control — can fully use the product.
Prioritize WCAG 2.2 Level A and AA unless the user specifies otherwise.
## Before Starting
**Check for context first:**
If `project-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered.
Gather this context (ask if not provided):
### 1. Current State
- What framework/stack? (React, Next.js, Vue, Angular, plain HTML)
- Any existing a11y tooling? (axe, Lighthouse, eslint-plugin-jsx-a11y)
- Known issues or recent audit results?
### 2. Goals
- Full audit or targeted fix (e.g. "fix all form labels")?
- WCAG level target (A, AA, AAA)?
- Any compliance deadline or specific user group to prioritize?
### 3. Scope
- Which pages or components to focus on?
- Mobile web included?
---
## How This Skill Works
### Mode 1: Full Audit
When starting fresh — no existing audit results. Run automated tools, then layer in manual checks for keyboard flow and screen reader behavior.
### Mode 2: Fix Specific Issues
When the user has an audit report (Lighthouse, axe, pa11y) and needs fixes applied. Work through issues by severity: critical → serious → moderate → minor.
### Mode 3: Accessibility Review of New Code
When reviewing a PR or new component. Check against the checklist and fix before merge.
---
## Quick Workflow
1. **Audit** — Run automated checks and review key pages/components
2. **Prioritize** — Critical (blocking) and serious issues first
3. **Fix** — Apply fixes following patterns below; re-check after
4. **Verify** — Confirm keyboard flow and screen reader behavior
---
## Running Audits
Use at least one automated tool; combine with manual review for important flows.
```bash
# Lighthouse (Chrome DevTools): Accessibility audit
# axe DevTools: browser extension or CLI
npx @axe-core/cli <url>
# pa11y: terminal report
npx pa11y <url>
# ESLint plugins
npm i -D eslint-plugin-jsx-a11y # React
npm i -D vue-eslint-plugin-vuejs-accessibility # Vue
# This skill's audit tool
python scripts/a11y_audit.py /path/to/project
# Contrast checker
python scripts/contrast_checker.py "#1a1a2e" "#ffffff"
```
---
## Checklist: Common Issues and Fixes
### Semantics and Structure
- [ ] **Page title**: One `<title>` per page, descriptive and unique
- [ ] **Landmarks**: `<main>`, `<nav>`, `<header>`, `<footer>`, `<aside>` used correctly. One `<main>` per page
- [ ] **Headings**: Logical order (`h1``h2``h3`), no skips
- [ ] **Lists**: `<ul>` / `<ol>` / `<li>` for list content
- [ ] **Buttons vs links**: `<button>` for actions, `<a href>` for navigation. No `<div>` / `<span>` as interactive elements without role + tabindex
### Focus and Keyboard
- [ ] **Focus visible**: All interactive elements show a visible focus indicator. Never remove `outline` without a clear replacement
- [ ] **Tab order**: Matches visual/logical order
- [ ] **Keyboard operable**: Every mouse action has a keyboard path
- [ ] **Focus trapping**: Modals trap focus inside; Escape closes; focus returns to trigger
- [ ] **Skip link**: "Skip to main content" — visible on focus, moves focus to `<main>`
### Forms and Labels
- [ ] **Labels**: Every `<input>`, `<select>`, `<textarea>` has `<label for>` or `aria-label` / `aria-labelledby`. Placeholder is not a label
- [ ] **Errors**: `aria-describedby` + `aria-invalid="true"` on invalid controls; errors visible to screen readers
- [ ] **Required fields**: `aria-required` + visible indicator
- [ ] **Grouping**: `<fieldset>` + `<legend>` for radio/checkbox groups
### Images and Media
- [ ] **Alt text**: Meaningful images have descriptive `alt`; decorative images use `alt=""`
- [ ] **Complex images**: Charts and diagrams have extended description
- [ ] **Video/audio**: Captions and/or transcripts; controls keyboard accessible
### ARIA
- [ ] **Native first**: Use semantic HTML before adding ARIA roles
- [ ] **Names**: Interactive elements and regions have an accessible name
- [ ] **Live regions**: `aria-live="polite"` for dynamic content; `"assertive"` only for urgent updates
- [ ] **State sync**: `aria-expanded`, `aria-selected`, `aria-current` kept in sync with UI
- [ ] **Avoid misuse**: Don't add `role="button"` to `<button>`; don't `aria-hidden` focusable elements
### Color and Contrast
- [ ] **Contrast ratio**: ≥4.5:1 for normal text; ≥3:1 for large text (18pt+ or 14pt+ bold); ≥3:1 for UI components
- [ ] **Not color alone**: Errors use icon + text + color; links underlined or otherwise distinguished from body text
### Motion and Animation
- [ ] **Reduced motion**: `prefers-reduced-motion: reduce` respected in CSS/JS
### Responsive and Zoom
- [ ] **200% zoom**: Layout works without horizontal scroll at 320px
- [ ] **Touch targets**: ≥44×44 CSS pixels for interactive elements
---
## Fix Patterns
### Custom Interactive Control
```html
<!-- Bad: div acting as button -->
<div onclick="submit()">Submit</div>
<!-- Good: semantic button -->
<button type="submit">Submit</button>
<!-- Good: when div is unavoidable -->
<div role="button" tabindex="0" onkeydown="handleKey(event)" onclick="submit()">Submit</div>
```
### Modal Dialog
```html
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<h2 id="dialog-title">Confirm Action</h2>
<!-- content -->
</div>
```
- Move focus to dialog on open
- Trap Tab/Shift+Tab inside
- Escape closes; focus returns to trigger
### Expand / Collapse
```html
<button aria-expanded="false" aria-controls="panel-id">Show details</button>
<div id="panel-id" hidden><!-- content --></div>
```
Toggle `aria-expanded` and `hidden` together on Enter/Space.
### Tab Component
```html
<div role="tablist">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">Tab 1</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2">Tab 2</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
```
Arrow keys switch active tab; Enter/Space activates.
### Form Error
```html
<label for="email">Email</label>
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">Please enter a valid email address.</span>
```
### Visually Hidden (SR Only)
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
### SPA Route Change (React/Next.js)
```jsx
// On route change: update <title> and move focus to <main> or heading
useEffect(() => {
document.title = `${pageTitle} — My App`;
mainRef.current?.focus();
}, [pathname]);
```
---
## Corner Cases (Often Missed by Automated Tools)
| Area | Issue | Fix |
|------|-------|-----|
| Screen readers | Icon-only buttons with no label | Add `aria-label` or `.sr-only` span |
| Screen readers | `<iframe>` without title | Add `title="Description"` |
| Screen readers | Link text "Click here" / "Read more" | Use descriptive text or `aria-label` |
| Screen readers | Data table without `<th scope>` | Add `scope="col"` / `scope="row"` |
| Voice control | Multiple buttons with same label | Use unique labels |
| SPA | Route change not announced | Update `<title>` + move focus |
| SPA | `display:none` content still focusable | Add `inert` or `aria-hidden="true"` on container |
| RTL | Missing `dir="rtl"` | Set on `<html>` or container |
| Animation | No `prefers-reduced-motion` | Wrap motion in media query |
---
## Proactive Triggers
Surface these issues WITHOUT being asked when you notice them in context:
- **Missing form labels**: Any `<input>` without associated `<label>`, `aria-label`, or `aria-labelledby` → flag as critical
- **Low contrast**: Color values visible in CSS/tokens → run contrast check and flag if below 4.5:1 (text) or 3:1 (UI)
- **Outline removal**: `outline: none` / `outline: 0` without a replacement focus style → flag as critical
- **Generic click handlers on divs**: `<div onClick>` without `role` and `tabindex` → flag as critical
- **Dynamic content without live regions**: State updates (toasts, errors, loading) with no `aria-live` → flag as serious
- **SPA route changes with no focus management**: Client-side navigation without title update or focus move → flag as serious
---
## Output Artifacts
| When you ask for... | You get... |
|---------------------|------------|
| Full audit | Severity-ranked issue list (critical/serious/moderate/minor) with file, element, rule, and fix |
| Fix a specific component | Updated code with inline comments explaining each a11y change |
| Review a PR | Annotated diff with issues flagged by severity |
| Contrast check | Ratio, pass/fail for AA and AAA, suggested accessible alternatives |
| Checklist | Copy-paste progress checklist pre-filled with findings |
| Tooling setup | ESLint config, axe integration, CI step for pa11y or Lighthouse |
---
## Severity Reference
| Level | Meaning | Priority |
|-------|---------|----------|
| **Critical** | Blocks access entirely (no keyboard path, missing label, invisible focus) | Fix before merge |
| **Serious** | Major barrier (poor contrast, wrong semantics, broken form) | Fix this sprint |
| **Moderate** | Degrades experience (redundant ARIA, heading order) | Fix when practical |
| **Minor** | Polish (title attribute misuse, verbose live regions) | Backlog |
---
## After Fixing
1. Re-run the same audit tool — confirm violations resolved
2. Test keyboard-only navigation through the full flow
3. Test with one screen reader (NVDA on Windows, VoiceOver on macOS/iOS) for changed components
4. Verify reduced-motion behavior if animation was changed
---
## Related Skills
| Skill | Use instead when... |
|-------|---------------------|
| `senior-frontend` | Building new React/Next.js components from scratch |
| `playwright-pro` | Writing automated browser tests including accessibility assertions |
| `epic-design` | Building interactive/animated sites (a11y built into epic-design patterns) |
| `senior-qa` | Full QA strategy including a11y as one of many quality dimensions |
---
## Reference
For WCAG criteria detail, ARIA patterns, native mobile APIs, and component examples:
→ [references/wcag-criteria.md](references/wcag-criteria.md)
→ [references/aria-patterns.md](references/aria-patterns.md)
→ [references/native-mobile.md](references/native-mobile.md)

View File

@@ -1,126 +0,0 @@
# ARIA Patterns Reference
Full patterns: [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/)
## Core Roles
| Role | Use for | Key attributes |
|------|---------|---------------|
| `dialog` | Modal dialogs | `aria-modal`, `aria-labelledby`, `aria-describedby` |
| `alertdialog` | Alert dialogs requiring response | Same as dialog |
| `alert` | Urgent status messages | Auto-announced; no focus needed |
| `status` | Non-urgent status messages | Polite live region |
| `tablist` | Tab container | — |
| `tab` | Individual tab | `aria-selected`, `aria-controls` |
| `tabpanel` | Tab content panel | `aria-labelledby` |
| `menu` | Navigation/action menu | — |
| `menuitem` | Menu item | — |
| `combobox` | Combo input + listbox | `aria-expanded`, `aria-controls`, `aria-activedescendant` |
| `listbox` | List of options | — |
| `option` | Listbox option | `aria-selected` |
| `tree` | Tree widget | — |
| `treeitem` | Tree node | `aria-expanded`, `aria-level` |
| `grid` | Interactive grid | — |
| `gridcell` | Grid cell | — |
| `tooltip` | Tooltip | Triggered by focus/hover |
| `progressbar` | Progress indicator | `aria-valuenow`, `aria-valuemin`, `aria-valuemax` |
| `slider` | Range slider | `aria-valuenow`, `aria-valuemin`, `aria-valuemax` |
| `spinbutton` | Number spinner | Same as slider |
| `switch` | Toggle (on/off) | `aria-checked` |
| `checkbox` | Checkbox | `aria-checked` (true/false/mixed) |
| `radio` | Radio button | `aria-checked` |
| `radiogroup` | Radio group | `aria-labelledby` |
## Live Regions
```html
<!-- Polite: announced after current speech -->
<div aria-live="polite" aria-atomic="true">
<!-- Update this content to announce status -->
</div>
<!-- Assertive: interrupts immediately (use sparingly) -->
<div aria-live="assertive" role="alert">
<!-- Errors, critical alerts -->
</div>
<!-- Loading state -->
<div aria-busy="true" aria-live="polite">
Loading results...
</div>
```
**Rules:**
- Set `aria-live` on the container **before** content is injected (at page load)
- `aria-atomic="true"` announces the whole region on any change
- Prefer `aria-live="polite"``"assertive"` interrupts and should only be used for errors
## Focus Management
```js
// Move focus to dialog on open
function openModal(modalEl, triggerEl) {
modalEl.removeAttribute('hidden');
modalEl.focus(); // dialog element itself (tabindex="-1")
trapFocus(modalEl);
modalEl._trigger = triggerEl;
}
// Return focus on close
function closeModal(modalEl) {
modalEl.setAttribute('hidden', '');
modalEl._trigger?.focus();
}
// Simple focus trap
function trapFocus(el) {
const focusable = el.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
el.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
}
```
## Common Keyboard Patterns
| Widget | Keys |
|--------|------|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space |
| Radio group | Arrow keys to select within group; Tab to move between groups |
| Tab list | Arrow keys to switch tabs; Enter/Space to activate |
| Menu | Arrow keys to navigate; Enter/Space to select; Escape to close |
| Dialog | Escape to close; Tab/Shift+Tab within trap |
| Combobox | Down arrow to open; Arrow keys in list; Enter to select; Escape to close |
| Tree | Arrow keys for expand/collapse and move |
## `aria-labelledby` vs `aria-label` vs `aria-describedby`
| Attribute | Purpose | Priority |
|-----------|---------|----------|
| `aria-labelledby` | Points to element(s) that name this component | Highest — overrides visible text |
| `aria-label` | Inline string label (use when no visible label exists) | High |
| `aria-describedby` | Points to supplemental description (e.g. error, hint) | Additive |
**Rule of thumb**: prefer `aria-labelledby` referencing visible text → `aria-label` for invisible label → `aria-describedby` for hints/errors.
## Do's and Don'ts
| Do | Don't |
|----|-------|
| Use semantic HTML first | Add `role="button"` to `<button>` |
| Give every interactive element an accessible name | `aria-hidden="true"` on focusable elements |
| Keep `aria-expanded` / `aria-selected` in sync with UI | Override browser semantics needlessly |
| Use `inert` to hide inactive panels | Leave multiple `aria-live` regions for one update |
| Test with real screen readers | Trust automated tools alone |

View File

@@ -1,122 +0,0 @@
# Native Mobile Accessibility Reference
This skill primarily targets **web and mobile web**. For native mobile apps, the same principles apply (labels, focus order, semantics, contrast) but the APIs differ. Use this reference when working with React Native, iOS (Swift/UIKit), or Android.
## React Native
### Key APIs
| What | API |
|------|-----|
| Label | `accessibilityLabel="Description"` |
| Hint | `accessibilityHint="Double tap to activate"` |
| Role | `accessibilityRole="button"` / `"link"` / `"header"` / `"image"` / `"none"` |
| State | `accessibilityState={{ disabled: true, selected: false, expanded: false }}` |
| Value | `accessibilityValue={{ min: 0, max: 100, now: 42 }}` |
| Live region | `accessibilityLiveRegion="polite"` / `"assertive"` |
| Hidden from AT | `importantForAccessibility="no-hide-descendants"` (Android) |
| Element grouping | `accessible={true}` on parent to group children |
### Example: Accessible Button
```jsx
<TouchableOpacity
accessibilityLabel="Submit order"
accessibilityRole="button"
accessibilityState={{ disabled: isLoading }}
onPress={handleSubmit}
>
<Text>Submit</Text>
</TouchableOpacity>
```
### Example: Header
```jsx
<Text accessibilityRole="header">Account Settings</Text>
```
### Testing
- **iOS**: Enable VoiceOver in Simulator → Settings → Accessibility
- **Android**: Enable TalkBack in Emulator → Settings → Accessibility
---
## iOS (Swift / UIKit)
### Key Properties
| What | Property |
|------|----------|
| Label | `accessibilityLabel` |
| Hint | `accessibilityHint` |
| Value | `accessibilityValue` |
| Traits | `accessibilityTraits` (`.button`, `.link`, `.header`, `.image`, `.selected`, `.notEnabled`) |
| Is element | `isAccessibilityElement = true/false` |
| Frame | `accessibilityFrame` (for custom hit areas) |
| Grouping | `shouldGroupAccessibilityChildren = true` on container |
### Example
```swift
button.accessibilityLabel = "Submit order"
button.accessibilityTraits = .button
button.accessibilityHint = "Double tap to complete purchase"
```
### Announcements
```swift
UIAccessibility.post(notification: .announcement, argument: "Order submitted")
UIAccessibility.post(notification: .screenChanged, argument: mainContentView)
```
### Testing
- Xcode: Product → Profile → Accessibility Inspector
- Device/Simulator: Settings → Accessibility → VoiceOver
---
## Android
### Key APIs
| What | API |
|------|-----|
| Description | `android:contentDescription` |
| Hide from AT | `android:importantForAccessibility="no"` |
| Live region | `android:accessibilityLiveRegion="polite"` / `"assertive"` |
| Custom node | Override `onInitializeAccessibilityNodeInfo()` in custom views |
| Announce | `view.announceForAccessibility("Message")` |
### Example (XML)
```xml
<ImageButton
android:contentDescription="@string/submit_order"
android:importantForAccessibility="yes" />
```
### Example (Kotlin)
```kotlin
binding.submitButton.contentDescription = "Submit order"
binding.submitButton.announceForAccessibility("Order submitted successfully")
```
### Testing
- Android Studio: Accessibility Scanner app
- Device: Settings → Accessibility → TalkBack
---
## Cross-Platform Checklist
- [ ] Every interactive element has a label (not just visual text inside)
- [ ] Roles match element behavior (button, link, header, image)
- [ ] Disabled state communicated via `accessibilityState`/`isEnabled`
- [ ] Dynamic content updates announced via live region or `announceForAccessibility`
- [ ] Focus order follows logical/visual order (avoid random `focusable` ordering)
- [ ] Color contrast ≥4.5:1 for text, ≥3:1 for UI components (same as web)
- [ ] Touch targets ≥44×44pt (iOS) / ≥48×48dp (Android)
- [ ] Tested with VoiceOver (iOS) and TalkBack (Android)

View File

@@ -1,86 +0,0 @@
# WCAG Criteria Reference
## WCAG 2.2 Level A & AA Summary
WCAG 2.2 (W3C Recommendation, Dec 2024) builds on 2.1. Conformance to 2.2 satisfies 2.0 and 2.1.
### Perceivable
| SC | Level | Criterion |
|----|-------|-----------|
| 1.1.1 | A | Non-text content has a text alternative |
| 1.2.1 | A | Audio-only / video-only: provide transcript or audio description |
| 1.2.2 | A | Captions for prerecorded audio in video |
| 1.2.3 | A | Audio description or media alternative for prerecorded video |
| 1.2.4 | AA | Captions for live audio in video |
| 1.2.5 | AA | Audio description for prerecorded video |
| 1.3.1 | A | Info and relationships conveyed through structure (headings, lists, tables) |
| 1.3.2 | A | Meaningful sequence preserved when CSS removed |
| 1.3.3 | A | Sensory characteristics not sole means of conveying info |
| 1.3.4 | AA | Orientation: content not restricted to one display orientation |
| 1.3.5 | AA | Identify input purpose (autocomplete attributes) |
| 1.4.1 | A | Color not the only visual means of conveying info |
| 1.4.2 | A | Audio control: ability to pause/stop/mute auto-playing audio |
| 1.4.3 | AA | Contrast: 4.5:1 for normal text, 3:1 for large text |
| 1.4.4 | AA | Resize text: up to 200% without loss of content or function |
| 1.4.5 | AA | Images of text avoided where possible |
| 1.4.10 | AA | Reflow: content at 320px width without horizontal scroll |
| 1.4.11 | AA | Non-text contrast: UI components and graphics 3:1 |
| 1.4.12 | AA | Text spacing: no loss of content when line/letter/word spacing increased |
| 1.4.13 | AA | Content on hover/focus: dismissible, hoverable, persistent |
### Operable
| SC | Level | Criterion |
|----|-------|-----------|
| 2.1.1 | A | Keyboard: all functionality available via keyboard |
| 2.1.2 | A | No keyboard trap |
| 2.1.4 | A | Character key shortcuts: can be turned off or remapped |
| 2.2.1 | A | Timing adjustable: extend, turn off, or adjust time limits |
| 2.2.2 | A | Pause, stop, hide: moving/blinking/scrolling content controllable |
| 2.3.1 | A | Three flashes or below threshold: no content flashes >3/sec |
| 2.4.1 | A | Bypass blocks: skip navigation link |
| 2.4.2 | A | Page titled: descriptive `<title>` |
| 2.4.3 | A | Focus order: logical sequence |
| 2.4.4 | A | Link purpose: understandable from link text or context |
| 2.4.5 | AA | Multiple ways to find a page |
| 2.4.6 | AA | Headings and labels: descriptive |
| 2.4.7 | AA | Focus visible: keyboard focus indicator visible |
| 2.4.11 | AA | Focus not obscured (min): focused component not entirely hidden |
| 2.5.1 | A | Pointer gestures: all multi-point/path gestures have single-point alternative |
| 2.5.2 | A | Pointer cancellation: up-event for activation where possible |
| 2.5.3 | A | Label in name: accessible name contains visible label text |
| 2.5.4 | A | Motion actuation: no device-motion-only functions |
| 2.5.7 | AA | Dragging movements: alternative for all drag operations |
| 2.5.8 | AA | Target size (min): at least 24×24 CSS pixels |
### Understandable
| SC | Level | Criterion |
|----|-------|-----------|
| 3.1.1 | A | Language of page: `lang` attribute on `<html>` |
| 3.1.2 | AA | Language of parts: `lang` on elements in different language |
| 3.2.1 | A | On focus: no unexpected context change |
| 3.2.2 | A | On input: no unexpected context change |
| 3.2.3 | AA | Consistent navigation across pages |
| 3.2.4 | AA | Consistent identification of components |
| 3.3.1 | A | Error identification: text description of error |
| 3.3.2 | A | Labels or instructions for user input |
| 3.3.3 | AA | Error suggestion: suggest correction where possible |
| 3.3.4 | AA | Error prevention: reversible, checked, or confirmed for legal/financial |
### Robust
| SC | Level | Criterion |
|----|-------|-----------|
| 4.1.1 | A | Parsing: valid HTML (no duplicate IDs, properly nested) |
| 4.1.2 | A | Name, role, value: all UI components have accessible name/role/state |
| 4.1.3 | AA | Status messages: announced without receiving focus |
## Contrast Quick Reference
| Text Type | AA Minimum | AAA |
|-----------|-----------|-----|
| Normal text (<18pt, <14pt bold) | 4.5:1 | 7:1 |
| Large text (≥18pt or ≥14pt bold) | 3:1 | 4.5:1 |
| UI components, graphics | 3:1 | — |
**Calculate**: `(L1 + 0.05) / (L2 + 0.05)` where L1 is the lighter relative luminance.
Use the bundled `scripts/contrast_checker.py` to calculate from hex values.

View File

@@ -1,376 +0,0 @@
#!/usr/bin/env python3
"""
a11y_audit.py — Static accessibility audit for front-end projects.
Scans HTML, JSX, TSX, Vue, and template files for common accessibility issues.
Stdlib-only, zero pip installs required.
Usage:
python scripts/a11y_audit.py /path/to/project
python scripts/a11y_audit.py /path/to/project --json
python scripts/a11y_audit.py /path/to/project --severity critical
python scripts/a11y_audit.py index.html
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Issue severity constants
# ---------------------------------------------------------------------------
CRITICAL = "critical"
SERIOUS = "serious"
MODERATE = "moderate"
MINOR = "minor"
SEVERITY_ORDER = {CRITICAL: 0, SERIOUS: 1, MODERATE: 2, MINOR: 3}
# ---------------------------------------------------------------------------
# Patterns to detect
# ---------------------------------------------------------------------------
RULES = [
{
"id": "img-alt",
"severity": CRITICAL,
"description": "Image missing alt attribute",
"pattern": re.compile(r'<img(?![^>]*\balt\s*=)[^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "img-empty-alt-on-meaningful",
"severity": MODERATE,
"description": "Image has empty alt — ensure it is truly decorative",
"pattern": re.compile(r'<img[^>]*\balt\s*=\s*["\']["\'][^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "input-label",
"severity": CRITICAL,
"description": "Input may be missing an accessible label (no aria-label, aria-labelledby, or id for <label for>)",
"pattern": re.compile(
r'<input(?![^>]*\b(?:aria-label|aria-labelledby|id)\s*=)[^>]*>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "button-name",
"severity": CRITICAL,
"description": "Button may lack an accessible name (no aria-label, aria-labelledby, or visible text)",
"pattern": re.compile(
r'<button(?![^>]*\b(?:aria-label|aria-labelledby)\s*=)[^>]*>\s*</button>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "outline-none",
"severity": CRITICAL,
"description": "outline: none / outline: 0 removes focus indicator — replace with visible custom style",
"pattern": re.compile(r'outline\s*:\s*(none|0)\b', re.IGNORECASE),
"extensions": {".css", ".scss", ".sass", ".less"},
},
{
"id": "div-onclick",
"severity": SERIOUS,
"description": "<div> with onClick/onclick but no role or tabindex — not keyboard accessible",
"pattern": re.compile(
r'<div(?![^>]*\b(?:role|tabindex|tabIndex)\s*=)[^>]*\bon[Cc]lick\s*[=={]',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "span-onclick",
"severity": SERIOUS,
"description": "<span> with onClick/onclick but no role or tabindex — not keyboard accessible",
"pattern": re.compile(
r'<span(?![^>]*\b(?:role|tabindex|tabIndex)\s*=)[^>]*\bon[Cc]lick\s*[=={]',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "link-purpose",
"severity": SERIOUS,
"description": 'Ambiguous link text ("click here", "read more", "here", "more") — use descriptive text',
"pattern": re.compile(
r'<a[^>]*>\s*(?:click here|read more|here|more|learn more|this link)\s*</a>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "skip-link",
"severity": SERIOUS,
"description": "No skip navigation link found — add a 'Skip to main content' link as first focusable element",
"pattern": None, # Handled by absence check below
"check_fn": "_check_skip_link",
"extensions": {".html"},
},
{
"id": "page-title",
"severity": SERIOUS,
"description": "No <title> element found in HTML page",
"pattern": None,
"check_fn": "_check_page_title",
"extensions": {".html"},
},
{
"id": "landmark-main",
"severity": MODERATE,
"description": "No <main> element or role='main' found — add a <main> landmark",
"pattern": None,
"check_fn": "_check_main_landmark",
"extensions": {".html"},
},
{
"id": "iframe-title",
"severity": SERIOUS,
"description": "<iframe> missing title or aria-label attribute",
"pattern": re.compile(
r'<iframe(?![^>]*\b(?:title|aria-label)\s*=)[^>]*>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "table-th-scope",
"severity": MODERATE,
"description": "<th> missing scope attribute — add scope='col' or scope='row'",
"pattern": re.compile(r'<th(?![^>]*\bscope\s*=)[^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "autoplay-media",
"severity": SERIOUS,
"description": "Media element with autoplay — provide controls and mute by default",
"pattern": re.compile(r'<(?:video|audio)[^>]*\bautoplay\b', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "positive-tabindex",
"severity": MODERATE,
"description": "tabindex > 0 disrupts natural focus order — prefer tabindex='0' or '-1'",
"pattern": re.compile(r'tabindex\s*=\s*["\']?[1-9]\d*["\']?', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "aria-hidden-focusable",
"severity": CRITICAL,
"description": "aria-hidden='true' on element that may be focusable — hidden content must not receive focus",
"pattern": re.compile(
r'<(?:button|a|input|select|textarea)[^>]*\baria-hidden\s*=\s*["\']true["\']',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "heading-skip",
"severity": MODERATE,
"description": "Heading level skip detected (e.g. h1 → h3) — use sequential heading levels",
"pattern": None,
"check_fn": "_check_heading_order",
"extensions": {".html"},
},
]
# ---------------------------------------------------------------------------
# Absence/document-level checks
# ---------------------------------------------------------------------------
def _check_skip_link(content: str, filepath: str) -> bool:
"""Returns True (issue exists) if no skip link found."""
return not re.search(
r'href\s*=\s*["\']#(?:main|content|skip|main-content)["\']',
content,
re.IGNORECASE,
)
def _check_page_title(content: str, filepath: str) -> bool:
return not re.search(r'<title[^>]*>\s*\S', content, re.IGNORECASE)
def _check_main_landmark(content: str, filepath: str) -> bool:
has_main_tag = bool(re.search(r'<main[\s>]', content, re.IGNORECASE))
has_role_main = bool(re.search(r'role\s*=\s*["\']main["\']', content, re.IGNORECASE))
return not (has_main_tag or has_role_main)
def _check_heading_order(content: str, filepath: str) -> bool:
levels = [int(m) for m in re.findall(r'<h([1-6])[\s>]', content, re.IGNORECASE)]
for i in range(1, len(levels)):
if levels[i] > levels[i - 1] + 1:
return True
return False
ABSENCE_CHECKS = {
"_check_skip_link": _check_skip_link,
"_check_page_title": _check_page_title,
"_check_main_landmark": _check_main_landmark,
"_check_heading_order": _check_heading_order,
}
# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------
SCAN_EXTENSIONS = {".html", ".htm", ".jsx", ".tsx", ".vue", ".css", ".scss", ".sass", ".less"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__", ".venv"}
def _get_line_number(content: str, match_start: int) -> int:
return content[:match_start].count("\n") + 1
def audit_file(filepath: Path) -> list[dict]:
issues = []
ext = filepath.suffix.lower()
if ext not in SCAN_EXTENSIONS:
return issues
try:
content = filepath.read_text(encoding="utf-8", errors="ignore")
except OSError:
return issues
for rule in RULES:
if ext not in rule["extensions"]:
continue
if rule.get("pattern"):
for match in rule["pattern"].finditer(content):
issues.append({
"rule": rule["id"],
"severity": rule["severity"],
"description": rule["description"],
"file": str(filepath),
"line": _get_line_number(content, match.start()),
"snippet": match.group(0)[:120].strip(),
})
elif rule.get("check_fn"):
fn = ABSENCE_CHECKS.get(rule["check_fn"])
if fn and fn(content, str(filepath)):
issues.append({
"rule": rule["id"],
"severity": rule["severity"],
"description": rule["description"],
"file": str(filepath),
"line": None,
"snippet": None,
})
return issues
def audit_path(target: Path, severity_filter: str | None = None) -> list[dict]:
issues = []
if target.is_file():
issues.extend(audit_file(target))
else:
for root, dirs, files in os.walk(target):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for fname in files:
fp = Path(root) / fname
issues.extend(audit_file(fp))
if severity_filter:
threshold = SEVERITY_ORDER.get(severity_filter, 3)
issues = [i for i in issues if SEVERITY_ORDER.get(i["severity"], 3) <= threshold]
issues.sort(key=lambda i: (SEVERITY_ORDER.get(i["severity"], 3), i["file"], i.get("line") or 0))
return issues
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
SEVERITY_EMOJI = {CRITICAL: "🔴", SERIOUS: "🟠", MODERATE: "🟡", MINOR: ""}
def print_report(issues: list[dict], target: str) -> None:
counts = {s: 0 for s in [CRITICAL, SERIOUS, MODERATE, MINOR]}
for i in issues:
counts[i["severity"]] = counts.get(i["severity"], 0) + 1
print(f"\n{'='*60}")
print(f" A11Y AUDIT: {target}")
print(f"{'='*60}")
print(f" Total issues: {len(issues)}")
for sev, count in counts.items():
if count:
print(f" {SEVERITY_EMOJI[sev]} {sev.upper()}: {count}")
print(f"{'='*60}\n")
if not issues:
print("✅ No issues detected by static analysis.")
print(" Run Lighthouse, axe, or pa11y for dynamic/runtime checks.\n")
return
current_file = None
for issue in issues:
if issue["file"] != current_file:
current_file = issue["file"]
print(f"\n📄 {current_file}")
line_info = f" line {issue['line']}" if issue.get("line") else ""
print(f" {SEVERITY_EMOJI[issue['severity']]} [{issue['severity'].upper()}] {issue['rule']}{line_info}")
print(f" {issue['description']}")
if issue.get("snippet"):
snippet = issue["snippet"].replace("\n", " ")
print(f"{snippet[:100]}")
print(f"\n{'='*60}")
print(" Next steps:")
print(" 1. Fix critical issues first (keyboard/screen reader blocking)")
print(" 2. Run: npx @axe-core/cli <url> for runtime checks")
print(" 3. Run: npx pa11y <url> for full WCAG scan")
print(f"{'='*60}\n")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Static a11y audit for front-end projects.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("path", help="File or directory to audit")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
parser.add_argument(
"--severity",
choices=[CRITICAL, SERIOUS, MODERATE, MINOR],
help="Only show issues at or above this severity",
)
args = parser.parse_args()
target = Path(args.path)
if not target.exists():
print(f"Error: path not found: {target}", file=sys.stderr)
sys.exit(1)
issues = audit_path(target, args.severity)
if args.json:
print(json.dumps({"total": len(issues), "issues": issues}, indent=2))
else:
print_report(issues, str(target))
# Exit code: 1 if any critical/serious issues found
has_blocking = any(i["severity"] in {CRITICAL, SERIOUS} for i in issues)
sys.exit(1 if has_blocking else 0)
if __name__ == "__main__":
main()

View File

@@ -1,256 +0,0 @@
#!/usr/bin/env python3
"""
contrast_checker.py — WCAG contrast ratio calculator.
Computes relative luminance and contrast ratio between two colors.
Stdlib-only, zero pip installs required.
Usage:
python scripts/contrast_checker.py "#1a1a2e" "#ffffff"
python scripts/contrast_checker.py "rgb(26,26,46)" "white"
python scripts/contrast_checker.py "#1a1a2e" "#ffffff" --json
python scripts/contrast_checker.py "#1a1a2e" --suggest # suggest accessible pairs
"""
import argparse
import json
import sys
# ---------------------------------------------------------------------------
# Color parsing
# ---------------------------------------------------------------------------
NAMED_COLORS = {
"white": "#ffffff",
"black": "#000000",
"red": "#ff0000",
"green": "#008000",
"blue": "#0000ff",
"gray": "#808080",
"grey": "#808080",
"silver": "#c0c0c0",
"navy": "#000080",
"yellow": "#ffff00",
"orange": "#ffa500",
"purple": "#800080",
"pink": "#ffc0cb",
"teal": "#008080",
}
def parse_color(color: str) -> tuple[int, int, int]:
"""Parse a color string to (r, g, b) tuple (0-255 each)."""
color = color.strip().lower()
# Named color
if color in NAMED_COLORS:
color = NAMED_COLORS[color]
# Hex #rrggbb or #rgb
if color.startswith("#"):
hex_str = color[1:]
if len(hex_str) == 3:
hex_str = "".join(c * 2 for c in hex_str)
if len(hex_str) != 6:
raise ValueError(f"Invalid hex color: {color}")
r = int(hex_str[0:2], 16)
g = int(hex_str[2:4], 16)
b = int(hex_str[4:6], 16)
return r, g, b
# rgb(r, g, b)
if color.startswith("rgb("):
inner = color[4:].rstrip(")")
parts = [p.strip() for p in inner.split(",")]
if len(parts) != 3:
raise ValueError(f"Invalid rgb() color: {color}")
return int(parts[0]), int(parts[1]), int(parts[2])
raise ValueError(f"Unsupported color format: {color!r}. Use #hex, rgb(), or a named color.")
# ---------------------------------------------------------------------------
# WCAG contrast calculation
# ---------------------------------------------------------------------------
def _linearize(channel: int) -> float:
"""Convert 8-bit channel value to linear light."""
s = channel / 255.0
if s <= 0.04045:
return s / 12.92
return ((s + 0.055) / 1.055) ** 2.4
def relative_luminance(r: int, g: int, b: int) -> float:
"""Compute WCAG relative luminance (0=black, 1=white)."""
return 0.2126 * _linearize(r) + 0.7152 * _linearize(g) + 0.0722 * _linearize(b)
def contrast_ratio(color1: tuple, color2: tuple) -> float:
"""Compute WCAG contrast ratio between two (r,g,b) tuples."""
l1 = relative_luminance(*color1)
l2 = relative_luminance(*color2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
# ---------------------------------------------------------------------------
# WCAG pass/fail assessment
# ---------------------------------------------------------------------------
def assess(ratio: float) -> dict:
return {
"ratio": round(ratio, 2),
"normal_text": {
"AA": ratio >= 4.5,
"AAA": ratio >= 7.0,
},
"large_text": {
"AA": ratio >= 3.0,
"AAA": ratio >= 4.5,
},
"ui_components": {
"AA": ratio >= 3.0,
},
}
def _pass_fail(val: bool) -> str:
return "PASS ✅" if val else "FAIL ❌"
# ---------------------------------------------------------------------------
# Suggest accessible dark/light pairs for a given foreground
# ---------------------------------------------------------------------------
SUGGEST_BACKGROUNDS = [
("#ffffff", "white"),
("#f8f9fa", "near-white"),
("#e9ecef", "light gray"),
("#dee2e6", "mid-light gray"),
("#1a1a2e", "near-black"),
("#212529", "dark gray"),
("#343a40", "charcoal"),
("#000000", "black"),
]
def suggest_pairs(foreground_str: str) -> list[dict]:
fg = parse_color(foreground_str)
results = []
for bg_hex, bg_name in SUGGEST_BACKGROUNDS:
bg = parse_color(bg_hex)
r = contrast_ratio(fg, bg)
a = assess(r)
results.append({
"background": bg_hex,
"background_name": bg_name,
"ratio": a["ratio"],
"normal_AA": a["normal_text"]["AA"],
"large_AA": a["large_text"]["AA"],
})
# Sort by ratio descending
results.sort(key=lambda x: x["ratio"], reverse=True)
return results
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_assessment(fg_str: str, bg_str: str, ratio: float, a: dict) -> None:
print(f"\n{'='*50}")
print(f" Foreground : {fg_str}")
print(f" Background : {bg_str}")
print(f" Ratio : {a['ratio']}:1")
print(f"{'='*50}")
print(f" Normal text AA (4.5:1): {_pass_fail(a['normal_text']['AA'])}")
print(f" Normal text AAA (7.0:1): {_pass_fail(a['normal_text']['AAA'])}")
print(f" Large text AA (3.0:1): {_pass_fail(a['large_text']['AA'])}")
print(f" Large text AAA (4.5:1): {_pass_fail(a['large_text']['AAA'])}")
print(f" UI components AA (3.0:1): {_pass_fail(a['ui_components']['AA'])}")
print(f"{'='*50}\n")
if not a["normal_text"]["AA"]:
# Calculate how much darker/lighter needed
needed = 4.5
print(f" ⚠️ Normal text fails AA. Need ratio ≥ {needed}:1")
print(" Run with --suggest to see accessible background options.\n")
def print_suggestions(fg_str: str, suggestions: list[dict]) -> None:
print(f"\n Accessible backgrounds for foreground {fg_str}:\n")
print(f" {'Background':<20} {'Name':<16} {'Ratio':>7} Normal AA Large AA")
print(f" {'-'*20} {'-'*16} {'-'*7} {'-'*9} {'-'*8}")
for s in suggestions:
n_aa = "PASS" if s["normal_AA"] else "fail"
l_aa = "PASS" if s["large_AA"] else "fail"
print(
f" {s['background']:<20} {s['background_name']:<16} "
f"{s['ratio']:>6.2f}:1 {n_aa:<9} {l_aa}"
)
print()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="WCAG contrast ratio calculator.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("foreground", help="Foreground color (#hex, rgb(), or name)")
parser.add_argument("background", nargs="?", help="Background color")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--suggest",
action="store_true",
help="Suggest accessible background options for the given foreground",
)
args = parser.parse_args()
try:
fg = parse_color(args.foreground)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if args.suggest:
suggestions = suggest_pairs(args.foreground)
if args.json:
print(json.dumps({"foreground": args.foreground, "suggestions": suggestions}, indent=2))
else:
print_suggestions(args.foreground, suggestions)
return
if not args.background:
parser.error("background color is required unless --suggest is used")
try:
bg = parse_color(args.background)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
ratio = contrast_ratio(fg, bg)
a = assess(ratio)
if args.json:
print(json.dumps({
"foreground": args.foreground,
"background": args.background,
**a,
}, indent=2))
else:
print_assessment(args.foreground, args.background, ratio, a)
# Exit 1 if fails normal text AA
sys.exit(0 if a["normal_text"]["AA"] else 1)
if __name__ == "__main__":
main()

View File

@@ -1,19 +1,15 @@
#!/usr/bin/env python3
"""
Secret Scanner — powered by secrets-patterns-db (1,600+ patterns)
Secret Scanner
Detects hardcoded secrets, API keys, and credentials in source code.
On first run, downloads the latest patterns from secrets-patterns-db
(github.com/mazen160/secrets-patterns-db) and caches them locally.
Falls back to 20 built-in patterns if download fails.
Identifies exposed secrets before they reach version control.
Usage:
python secret_scanner.py /path/to/project
python secret_scanner.py /path/to/file.py
python secret_scanner.py /path/to/project --format json
python secret_scanner.py /path/to/project --severity high
python secret_scanner.py --list-patterns
python secret_scanner.py --update-patterns # re-download latest db
"""
import argparse
@@ -21,86 +17,11 @@ import json
import os
import re
import sys
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
from enum import Enum
# ── Secrets-Patterns-DB integration ──────────────────────────────────────────
_DB_URL = "https://raw.githubusercontent.com/mazen160/secrets-patterns-db/master/db/rules-stable.yml"
_DB_CACHE = Path.home() / ".cache" / "secrets-patterns-db" / "rules-stable.yml"
def _fetch_patterns_db(force: bool = False) -> str | None:
"""Download secrets-patterns-db rules-stable.yml and cache locally."""
if _DB_CACHE.exists() and not force:
return _DB_CACHE.read_text()
try:
_DB_CACHE.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(_DB_URL, timeout=10) as r:
content = r.read().decode()
_DB_CACHE.write_text(content)
return content
except Exception:
return _DB_CACHE.read_text() if _DB_CACHE.exists() else None
def _parse_rules_yml(content: str) -> list[dict]:
"""Minimal YAML parser for secrets-patterns-db rules format.
Parses: pattern.name, pattern.regex, pattern.confidence → list of dicts.
"""
rules = []
current: dict = {}
for line in content.splitlines():
stripped = line.strip()
if stripped == "- pattern:":
if current.get("regex"):
rules.append(current)
current = {}
elif stripped.startswith("name:"):
current["name"] = stripped[5:].strip().strip('"').strip("'")
elif stripped.startswith("regex:"):
current["regex"] = stripped[6:].strip().strip('"').strip("'")
elif stripped.startswith("confidence:"):
current["confidence"] = stripped[11:].strip()
if current.get("regex"):
rules.append(current)
return rules
def _load_db_patterns() -> list:
"""Load patterns from secrets-patterns-db, return as SecretPattern list."""
content = _fetch_patterns_db()
if not content:
return []
raw = _parse_rules_yml(content)
patterns = []
for i, r in enumerate(raw):
try:
re.compile(r["regex"]) # skip invalid regexes
except re.error:
continue
conf = r.get("confidence", "low").lower()
sev = Severity.HIGH if conf == "high" else Severity.MEDIUM if conf == "medium" else Severity.LOW
patterns.append(SecretPattern(
pattern_id=f"DB{i:04d}",
name=r["name"],
description=f"From secrets-patterns-db ({conf} confidence)",
regex=r["regex"],
severity=sev,
file_extensions=list(_ALL_EXTENSIONS),
recommendation="Remove secret from source, rotate credential, use env vars or secrets manager.",
))
return patterns
_ALL_EXTENSIONS = {
".py", ".js", ".ts", ".java", ".go", ".rb", ".php", ".cs", ".cpp", ".c",
".h", ".env", ".yml", ".yaml", ".json", ".xml", ".conf", ".ini", ".toml",
".tf", ".sh", ".bash", ".zsh", ".ps1", ".txt", ".md", ".pem", ".key",
}
class Severity(Enum):
CRITICAL = "critical"
@@ -543,29 +464,9 @@ Examples:
choices=["critical", "high", "medium", "low"],
help="Minimum severity to report"
)
parser.add_argument(
"--update-patterns",
action="store_true",
help="Re-download latest patterns from secrets-patterns-db"
)
parser.add_argument(
"--no-db",
action="store_true",
help="Skip secrets-patterns-db patterns (use built-in only)"
)
args = parser.parse_args()
if args.update_patterns:
content = _fetch_patterns_db(force=True)
if content:
rules = _parse_rules_yml(content)
print(f"Updated secrets-patterns-db: {len(rules)} patterns cached at {_DB_CACHE}")
else:
print("Failed to download patterns. Check your internet connection.")
sys.exit(1)
return
if args.list_patterns:
list_patterns()
return
@@ -578,14 +479,8 @@ Examples:
print(f"Error: Path does not exist: {path}")
sys.exit(1)
# Merge built-in + secrets-patterns-db patterns
patterns = list(SECRET_PATTERNS)
if not args.no_db:
db = _load_db_patterns()
if db:
print(f"[+] Loaded {len(db)} patterns from secrets-patterns-db", file=sys.stderr)
patterns = patterns + db
# Filter patterns by severity
patterns = SECRET_PATTERNS
if args.severity:
severity_order = ["critical", "high", "medium", "low"]
min_index = severity_order.index(args.severity)