# CI Guide — Cowork UI Kit

> Single-command quality gate for tokens, brand presets, and archetype starters.
> Added in v0.5.6 (Iter 25 U) — consolidates 4 standalone guards built across v0.5.x.
> Iter 37 (v0.5.10, 2026-05-20) — added 5th guard: gradient-contrast-probe (G14).
> Iter 41 (v0.5.10, 2026-05-20) — probe timeout fix (20s → 45s), CI_GRADIENT_PROBE default flipped ON. Guard count: **7**.
> Iter 43K (v0.5.11, 2026-05-20) — Lighthouse URLs 42→46, Probe URLs 16→20 (Iter 42 form atoms added). Decorative-low-contrast policy documented.

---

## Quick start

```bash
# Unix / macOS / Git Bash
bash _build/ci-check.sh

# Windows PowerShell
pwsh _build/ci-check.ps1
# or
powershell -File _build/ci-check.ps1
```

Exits `0` if all checks pass, non-zero otherwise. Safe to chain in pre-commit hooks, CI workflows, deploy scripts.

---

## What each guard catches

| # | Guard | Catches | Iter introduced |
|---|---|---|---|
| 1 | `token-audit.js` | Rogue `#hex` / `rgb()` / raw `hsl()` literals in HTML, undefined `var(--xxx)` refs | Iter 12 O |
| 2 | `path-audit.js` | `starter/archetypes/*.html` using 1-level relative paths instead of 2-level (`../tokens` vs `../../tokens`) | Iter 23 S |
| 3 | `contrast-check-brand.js` | WCAG AA/AAA contrast for `primary` on warm + dark surfaces, brand-aware via `--brand-file` | Iter 16 V + 20 J |
| 4 | `visual-diff.js` | Optional — 3-tier PNG comparison vs baseline (not run by default in `ci-check.sh`, opt-in) | Iter 18 Z |
| 5 | `lighthouse-check.sh` | Perf/A11y/BP/SEO thresholds on 6 archetype URLs (default ON since v0.5.8 Iter 32; `CI_LIGHTHOUSE=0` to skip) | Iter 30 → Iter 32 default-on |
| 6 | `gradient-contrast-probe.sh` | **G14** — WCAG AA on text-over-gradient backgrounds that Lighthouse/axe miss (`getBackgroundColor()` returns 'transparent' or first stop only for gradient parents). **Default ON since Iter 41 (v0.5.10)** after timeout fix (20s → 45s) stabilized probe. Set `CI_GRADIENT_PROBE=0` to skip. | Iter 37 added / Iter 41 default-on |

`ci-check.sh` runs **1, 2, 3, 5, 6** by default (visual-diff opt-in only when local baselines are wired). Use `CI_LIGHTHOUSE=0` to skip the Lighthouse step for occasional local debug. Set `CI_GRADIENT_PROBE=0` to skip the gradient probe (default ON since Iter 41 — see "Probe timeout fix" below).

---

## Interpreting output

- **Exit 0** — all checks passed, safe to commit / deploy
- **Exit 1+** — at least one guard failed, do NOT commit until fixed
- Each guard reports its own counts and writes detail files where applicable:
  - `_build/token-audit-report.json` — structured
  - `_build/token-audit-dashboard.md` — human-readable

### Sample run (clean)

```
==> [1/3] Token audit (rogue literals + undefined vars)
[token-audit] Done in 0.09s
==> [2/3] Path audit (starter/archetypes/ relative paths)
path-audit: 0 violations across 7 files.
==> [3/3] Contrast check (3 sample brands)
--- Brand: annhien (h=28 s=40 l=38)
Verdict: annhien passes WCAG AA across both aesthetics.
...
==> Summary
All checks PASSED — safe to commit
```

---

## Pre-commit hook setup

Drop this into `.git/hooks/pre-commit`:

```bash
#!/bin/bash
exec bash _build/ci-check.sh
```

Then `chmod +x .git/hooks/pre-commit`. Every `git commit` will run the gate first.

PowerShell users can use:

```bash
#!/bin/bash
exec powershell -File _build/ci-check.ps1
```

---

## GitHub Actions

Workflow scaffold at 📄 [.github/workflows/ci.yml](.github/workflows/ci.yml). It:

- Triggers on push/PR touching `tokens/`, `brands/`, `starter/archetypes/`, `_build/`
- Runs `bash _build/ci-check.sh` on Ubuntu + Node 20
- Uploads token-audit reports as artifacts (always, even on failure)

Fork the kit, push to your repo, and the workflow runs automatically.

---

## When to update visual-diff baselines

`visual-diff.js` is **opt-in** (not in `ci-check.sh` default chain) because baselines are environment-sensitive. Update baselines when:

- You intentionally restyle an archetype (E.g. C-landing hero polish in Iter 19 D)
- A brand preset changes hue (HSL shift visible in screenshots)
- After a Tailwind config change that affects rendered output

Procedure: `node _build/visual-diff.js --update-baseline` then commit the new PNGs in `_build/baselines/`.

---

## Lenient mode — known-failure brands

Some brand presets intentionally trade WCAG AA for brand fidelity (e.g. `betterbuy` coral at `h:12 l:60` — documented in ADR-008, Iter 7-8). To prevent these from blocking CI for external dev / forks, list them in `BRAND_KNOWN_FAILURES`:

```bash
# Unix / Git Bash
BRAND_KNOWN_FAILURES="betterbuy" bash _build/ci-check.sh

# PowerShell
$env:BRAND_KNOWN_FAILURES = "betterbuy"; pwsh _build/ci-check.ps1
```

Multiple brands space-separated:

```bash
BRAND_KNOWN_FAILURES="betterbuy haogood" bash _build/ci-check.sh
```

### Behaviour

| Scenario | Exit | Log |
|---|---|---|
| Brand in list, contrast fails | masked (no propagation) | `[LENIENT] <brand> expected to fail — masking exit` |
| Brand in list, contrast passes | masked (no propagation) | `[LENIENT] <brand> listed as known failure but PASSED — consider removing from BRAND_KNOWN_FAILURES` |
| Brand NOT in list, contrast fails | propagates exit 1 | normal `FAIL AA` output |
| Brand NOT in list, contrast passes | exit 0 | normal `PASS AA` output |

Token-audit + path-audit failures **always** propagate exit — only contrast-check is leniency-eligible.

### When to use

- Production CI for projects accepting brand-fidelity trade-off (vibrant coral / pastel hues that pre-date WCAG AA constraints)
- Forks customizing brand presets beyond default AA constraints
- During brand-design iteration before contrast tuning is final

### When NOT to use

- New brand contributions to upstream — should pass strict by default (helps catch issues early)
- Audit / compliance reports — strict gives accurate picture
- Default `bash _build/ci-check.sh` without env var = strict, which is intentional

---

## Lighthouse gate (Iter 30 added → Iter 32 default-on, v0.5.8)

`_build/lighthouse-check.sh` enforces per-URL performance budget against the 6 live archetype pages. **Default ON as of v0.5.8** (Iter 32) — `_build/ci-check.{sh,ps1}` invoke it automatically. Set `CI_LIGHTHOUSE=0` to skip for occasional local debug.

### Thresholds

| Category | Threshold | Meaning |
|---|---|---|
| `performance` | ≥ **90** | LCP, FCP, TBT, CLS aggregate. Network + render speed. |
| `accessibility` | ≥ **95** | Axe-style checks: labels, contrast, ARIA, focusable order |
| `best-practices` | ≥ **90** | HTTPS, console errors, image aspect, deprecated APIs |
| `seo` | ≥ **90** | meta description, canonical, viewport, link text |

Any URL scoring below ANY threshold fails the gate (exit 1). All 4 thresholds tested per URL, all **46 URLs** tested per run = 184 score-checks (v0.5.11, up from 132 in v0.5.9 and 168 in v0.5.10).

### Default URL set (v0.5.9 — Iter 36 expanded 7 → 33 URLs covering full public surface)

Public surface covered (root mutable path `https://ui.dang.pm/...`):

**6 archetype pages + brand gallery** (original CI baseline since Iter 30):
- `pages/page-A-dashboard.html`, `page-B-tool.html`, `page-C-landing.html`, `page-D-editorial.html`, `page-E-deck.html`, `page-F-pwa-app-flow.html`
- `brands/`

**Doc + atomic-design galleries** (added Iter 36, all verified a11y 100 in Iter 35a/c):
- `USAGE.html`, `templates/`, `mobile/`, `atoms/`, `molecules/`, `organisms/`, `icons/`, `demo/brand-switcher.html`, `_variant-matrix/`

**Preview pages** (added Iter 36, all verified a11y 100 in Iter 35b):
- `_previews/`, `_previews/01-landing.html`, `02-admin.html`, `03-ecom.html`, `trend-01-bento-grid.html`, `trend-02-ai-tool-dark.html`, `trend-03-mesh-gradient.html`, `trend-04-editorial-maximalist.html`, `trend-05-dark-mesh-fusion.html`, `trend-06-warm-friendly.html`

**Showcase pages** (added Iter 36, already-passing):
- `showcase/`, `showcase/01-primitives.html`, `02-composites.html`, `02-composites-a.html`, `02-composites-b.html`, `03-dataviz.html`, `04-patterns.html`

**Iter 40A/B + Iter 41 surfaces** (added Iter 41, v0.5.10):
- `motion/`, `uxstate/`, `atoms/loading-skeleton.html`, `atoms/loading-spinner.html`, `atoms/loading-progress.html`, `atoms/loading-dots.html`, `molecules/empty-state.html`, `molecules/error-state.html`, `organisms/toast.html` (9 URLs)

**Iter 42 form atoms** (added Iter 43K, v0.5.11):
- `molecules/date-picker.html`, `atoms/number-input.html`, `atoms/slider.html`, `atoms/file-uploader.html` (4 URLs)

Total **46 URLs** (42 from Iter 41 + 4 added Iter 43K). Per G10 in FINAL-STATE.md — every public URL on `ui.dang.pm` must be on the CI gate URL list. Use `LH_URLS=` override to point at v0.5.11 immutable path if desired.

Override via env:

```bash
LH_URLS="https://example.com/a https://example.com/b" bash _build/lighthouse-check.sh
LH_OUTDIR=_lighthouse/v0.5.8 bash _build/lighthouse-check.sh
```

### Current baseline (Iter 32 — 2026-05-20, v0.5.8)

| Page | perf | a11y | bp | seo | status |
|---|---|---|---|---|---|
| page-A | 97 | 100 | 100 | 100 | ✓ PASS |
| page-B | 99 | 100 | 96 | 100 | ✓ PASS |
| page-C | 92 | 100 | 96 | 100 | ✓ PASS |
| page-D | 93 | 100 | 96 | 100 | ✓ PASS |
| page-E | 97 | 100 | 96 | 100 | ✓ PASS |
| page-F | 92 | 100 | 100 | 100 | ✓ PASS |

Aggregate: **2354/2400**. All 6 URLs PASS strict gate (perf≥90 / a11y≥95 / bp≥90 / seo≥90). `CI_LIGHTHOUSE=1` is now the default in `_build/ci-check.{sh,ps1}`.

Method: 2 runs per URL on root mutable path `https://ui.dang.pm/pages/...`, min-of-2 conservative aggregation. Raw runs + medians in 📁 [_lighthouse/v0.5.8/](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_lighthouse/v0.5.8/).

**Iter 31a root cause + fix recap:** nginx `cowork-ui-nginx` had `gzip` directive commented out → 24 KiB HTML + ~75% CSS savings missed. Fix: enabled `gzip on` (comp_level 6) in ⚙️ [_build/nginx.conf](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/nginx.conf), mirrored to VPS 5 `/opt/cowork-ui-kit/nginx.conf`, container reloaded. nginx.conf now travels with the kit (G9 in FINAL-STATE.md).

### Running

```bash
# Standalone
bash _build/lighthouse-check.sh

# Already chained by default into ci-check (Iter 32 v0.5.8)
bash _build/ci-check.sh

# PowerShell
pwsh _build/lighthouse-check.ps1
```

JSON output → `_lighthouse/v0.5.7/page-{A..F}.json` (or `LH_OUTDIR` override). Open in Chrome via [PageSpeed Insights → upload report](https://googlechrome.github.io/lighthouse/viewer/) for full audit drilldown.

### Override locally for one-off fail

If you need to commit despite a transient Lighthouse fail (e.g. CDN slow during run):

```bash
# Skip the lighthouse step for this run
CI_LIGHTHOUSE=0 bash _build/ci-check.sh
# PowerShell:
$env:CI_LIGHTHOUSE="0"; pwsh _build/ci-check.ps1
```

There's no `BRAND_KNOWN_FAILURES` analog for Lighthouse (per-URL not per-brand). Either: (a) re-run to confirm not transient variance, (b) fix the underlying perf bug, or (c) commit with `CI_LIGHTHOUSE=0` and document the trade-off in commit msg.

### Why default-on (since v0.5.8 Iter 32)

- All 6 URLs verified pass strict threshold (Iter 32 measurement). Aggregate 2354/2400.
- Gating commits on real measured perf/a11y/bp/seo prevents silent regressions.
- ~30s × 6 URLs = ~3 minutes — acceptable for a release-blocker gate (not per-keystroke).
- Run-to-run variance (±2-5 perf points) — script measures once per call; `CI_LIGHTHOUSE=0` available for transient skip.
- Network required (CI runners need outbound) — most environments have it; airgapped sandboxes can `CI_LIGHTHOUSE=0`.

---

## Gradient contrast probe (Iter 37 added, Iter 41 default-on — v0.5.10)

`_build/gradient-contrast-probe.{sh,ps1}` catches **G14**: text on brand-tinted gradient backgrounds that Lighthouse/axe systematically miss because `getBackgroundColor()` returns `'transparent'` or first-stop only when the parent has `background-image: linear-gradient(...)` (no `background-color`).

### Why this guard exists

In Iter 37 (2026-05-20), Dang spotted "màu chữ với màu nền y nhau" on `brands/index.html` `.brand-vibe` paragraph. Manual probe measured A-Kryphan 3.0:1, HaoGood 3.2:1 — both FAIL WCAG AA. Yet Lighthouse a11y score was **100**. Root cause: axe's contrast rule cascades parent backgrounds until it finds a non-transparent `background-color` — but stops there. It does NOT parse `background-image` gradients, so when the brand-card uses `background-image: linear-gradient(135deg, var(--primary-soft), var(--accent-soft))` with no fallback `background-color`, axe sees only the body's cream `--bg`, computes a passing ratio, and the real text-over-gradient contrast goes unmeasured.

### How it works

1. **Probe HTML** at `_build/probe-runner.html` — deployed to VPS 5 at `/opt/cowork-ui-kit/site/_probe/index.html` → reachable at `https://ui.dang.pm/_probe/`.
2. **Script invokes Chrome headless** with `https://ui.dang.pm/_probe/?url=<target>`. Probe is same-origin to all `ui.dang.pm` URLs so it can `fetch()` them CORS-free.
3. Probe **fetches the target HTML, parses it, copies stylesheets + body into a hidden stage div** in the probe doc. Inline scripts are re-executed (wrapped in try/catch); external `<script src=…>` is skipped to avoid framework re-bootstrap (best-effort static analysis).
4. **TreeWalker visits every visible text node**. For each text element, the probe computes:
   - `fg` = `getComputedStyle().color`
   - `bg` = effective background by **walking parent chain**, parsing `background-image` for gradient color stops (uses **first stop as worst-case approximation**), alpha-blending each layer through any `color-mix(... transparent)` semi-transparent backgrounds.
5. **WCAG ratio** computed via standard sRGB → linear luminance formula. Threshold = **4.5:1 normal** / **3.0:1 large** (24px+ or 18.66px+ bold per WCAG 2.1 large-text rule).
6. Probe writes `JSON.stringify(results)` into `document.title` as `PROBE_JSON:{…}`. CLI script reads it back via Chrome's `--dump-dom` output.
7. **Exit 1** if any URL has any FAIL text element. Exit 0 otherwise.

### Default URL set (20 surfaces — expanded Iter 43K)

7 original highest-risk gradient surfaces + 9 added Iter 41 (motion + uxstate + 4 loading atoms + 2 state molecules + toast) + 4 added Iter 43K (Iter 42 form atoms):

```
https://ui.dang.pm/brands/
https://ui.dang.pm/pages/page-C-landing.html
https://ui.dang.pm/_previews/01-landing.html
https://ui.dang.pm/_previews/02-admin.html
https://ui.dang.pm/demo/brand-switcher.html
https://ui.dang.pm/_variant-matrix/
https://ui.dang.pm/templates/
https://ui.dang.pm/motion/                         # Iter 41
https://ui.dang.pm/uxstate/                        # Iter 41
https://ui.dang.pm/atoms/loading-skeleton.html     # Iter 41
https://ui.dang.pm/atoms/loading-spinner.html      # Iter 41
https://ui.dang.pm/atoms/loading-progress.html     # Iter 41
https://ui.dang.pm/atoms/loading-dots.html         # Iter 41
https://ui.dang.pm/molecules/empty-state.html      # Iter 41
https://ui.dang.pm/molecules/error-state.html      # Iter 41
https://ui.dang.pm/organisms/toast.html            # Iter 41
https://ui.dang.pm/molecules/date-picker.html      # Iter 43K
https://ui.dang.pm/atoms/number-input.html         # Iter 43K
https://ui.dang.pm/atoms/slider.html               # Iter 43K
https://ui.dang.pm/atoms/file-uploader.html        # Iter 43K
```

Override by passing args:

```bash
bash _build/gradient-contrast-probe.sh \
  https://ui.dang.pm/pages/page-D-editorial.html \
  https://ui.dang.pm/_previews/trend-03-mesh-gradient.html
```

### Usage

```bash
# Standalone — Bash
bash _build/gradient-contrast-probe.sh

# Standalone — PowerShell
powershell -File _build/gradient-contrast-probe.ps1

# Chained into ci-check (DEFAULT ON since Iter 41)
bash _build/ci-check.sh
# To skip (local debug):
CI_GRADIENT_PROBE=0 bash _build/ci-check.sh
$env:CI_GRADIENT_PROBE="0"; powershell -File _build/ci-check.ps1
```

### Probe timeout fix (Iter 41)

Default `--virtual-time-budget` raised **20s → 45s** in 🔧 [_build/gradient-contrast-probe.sh](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/gradient-contrast-probe.sh) + 🔧 [_build/gradient-contrast-probe.ps1](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/gradient-contrast-probe.ps1) after Iter 40B agent reported `"probe:init"` (probe never finished) — uxstate pages with multiple inline scripts needed >20s. `PROBE_TIMEOUT` env override preserved (`PROBE_TIMEOUT=60000 bash _build/gradient-contrast-probe.sh` for stubborn pages).

Verification (Iter 41, 2026-05-20):
- `https://ui.dang.pm/brands/` → 231 text elements, **PASS** 0 fails
- `https://ui.dang.pm/motion/` → 139 text elements, **PASS** 0 fails

After 2 known-clean baselines passed, `CI_GRADIENT_PROBE` default flipped `0 → 1` in 🔧 [_build/ci-check.sh](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/ci-check.sh) + 🔧 [_build/ci-check.ps1](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/ci-check.ps1).

### Local mode

For pre-deploy QC (before pushing to VPS), use `--local` to load the probe via `file://`:

```bash
bash _build/gradient-contrast-probe.sh --local https://ui.dang.pm/brands/
```

Note: `file://` probe can fetch any URL Chrome can reach. However, accuracy depends on the target serving its assets correctly. Prefer live mode (VPS-hosted probe) for accurate measurements against `ui.dang.pm` pages.

### Smoke test results (Iter 37 verification, 2026-05-20)

| URL | Total text elements | WCAG AA fails | Status |
|---|---|---|---|
| `https://ui.dang.pm/brands/` (current, post-Iter 37 fix) | 231 | 18 | FAIL (remaining `.brand-vibe` + `.meta-tag` at 3.88-4.15:1) |
| `https://ui.dang.pm/v0.5.7/brands/` (OLD, pre-fix) | 231 | 42 | FAIL (regression confirmed — pre-fix had toggle button + more brand-card text below threshold) |

42 → 18 fails proves the Iter 37 partial fix worked but `.brand-vibe` text on the gradient body still misses AA. Iter 38a will resolve the remaining 18; this probe will gate against regressions.

### Limitations (known)

- **Gradient first-stop approximation**: probe uses the FIRST color stop of any `linear-gradient/radial-gradient` as the "effective bg" for text overlaying it. Real text floats over the gradient transition — actual contrast may be slightly higher OR lower depending on text position. Worst-case approximation chosen because Lighthouse/axe miss the bug entirely; first-stop catches the regression class even if the precise ratio differs by ±0.3.
- **External `<script src=…>` skipped**: target pages that JS-render content via external bundles (React/Vue SPAs) will have their main content missing from the staged DOM. Inline scripts ARE re-executed, which is why `brands/` (inline template-literal rendering) probes correctly with 231 text nodes including JS-rendered brand cards.
- **`color-mix()` parsing**: probe parses `getComputedStyle()` which resolves `color-mix()` to `rgba()` for modern Chrome. Older Chrome may report literal `color-mix(...)` — probe falls back to first parsed `rgb()` token in that case.
- **Same-origin requirement (live mode)**: probe must be deployed at `https://ui.dang.pm/_probe/`. Cross-origin targets require either (a) target's CORS headers permit anonymous fetch, or (b) `--local` mode + `--disable-web-security` Chrome flag.

### When to skip

- **Local dev without VPS**: if `ui.dang.pm` is unreachable from the dev box, use `--local` mode against `file://` paths to your kit clone.
- **Probe URL not deployed yet**: 5-min one-off deploy: `scp _build/probe-runner.html krycloud:/opt/cowork-ui-kit/site/_probe/index.html`. Once deployed, no maintenance needed (static HTML).
- **Transient false-positive**: re-run; probe relies on `--virtual-time-budget=45000ms` waiting for inline script render (raised from 20s in Iter 41). If you suspect a race, bump via `PROBE_TIMEOUT=60000 bash _build/gradient-contrast-probe.sh`.

### Decorative-low-contrast policy (Iter 43K, v0.5.11)

Sometimes a probe FAIL is **intentional by design** — wireframe annotations, decorative ornaments, sample placeholders. The probe correctly measures low contrast, but the element is NOT meant to be readable content; it's a visual device. Example surfaces: 📁 [templates/](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/templates/) gallery has bracket-wrapped `[Primary action]` labels signaling "this is where the CTA goes" + numbered step circles `01-04` with `fg==bg` 1:1 (the number is intentionally hidden behind the filled circle — a tonal ornament).

**Policy:** when you confirm an element is decorative-by-design, apply ALL THREE of:
1. **`aria-hidden="true"`** on the element so screen readers skip it (most important — actual a11y impact)
2. **HTML comment** documenting why low-contrast is intentional, e.g. `<!-- DECORATIVE by design: bracket-wrapped wireframe placeholder, not real CTA. -->`
3. **Accept probe FAIL as known/intentional** — the probe doesn't read `aria-hidden`, so it WILL still flag. Do NOT "fix" by changing color or contrast; that would defeat the visual intent. Either:
   - Document the surface as "expected fails: N decorative elements" in the relevant component README, OR
   - Maintain a probe-allowlist mechanism if the count grows large enough to mask real regressions

**When is something genuinely decorative?**
- Number `01`-`04` with `fg==bg` 1:1 in an ornament circle (visible as a "step badge" outline, not as readable digits)
- Bracket-wrapped text `[X]` in a wireframe/template gallery (signals "placeholder", not real content)
- Repeated decorative dividers, ornamental icons inside heading frames, sparkle/glow accents

**When is something NOT decorative (i.e., still fix it)?**
- Brand badges or chips that USERS will actually read (these need full AA contrast)
- Disabled states displayed to USERS (still must communicate state clearly enough — use opacity + outline + cursor, not pure low-contrast)
- Tooltip / helper text (functional, must pass AA)

Reference Iter 43K triage of 7 fails on `https://ui.dang.pm/templates/` — all 7 were decorative wireframe annotations (4× numbered circles + 2× `.cta-primary-ghost` placeholders + 1× `.zone-note`). All received `aria-hidden="true"` + HTML comment. Probe still reports 7 fails — accepted as known intentional, NOT a kit regression.

### Files

- 🌐 [https://ui.dang.pm/_probe/](https://ui.dang.pm/_probe/) — Probe HTML on VPS 5 (deployed Iter 37)
- 📄 [_build/probe-runner.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/probe-runner.html) — Probe source
- 🔧 [_build/gradient-contrast-probe.sh](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/gradient-contrast-probe.sh) — Bash CLI
- 🔧 [_build/gradient-contrast-probe.ps1](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_build/gradient-contrast-probe.ps1) — PowerShell CLI

### Deploy / re-deploy probe HTML

If you edit `_build/probe-runner.html`, re-deploy via:

```bash
scp _build/probe-runner.html krycloud:/opt/cowork-ui-kit/site/_probe/index.html
curl -sI https://ui.dang.pm/_probe/  # verify 200
```

No nginx reload needed (static file).

---

## Troubleshooting

### "Contrast check fails on brand X"

The `contrast-check-brand.js` script prints a `Try l:NN` hint when `primary` fails AA on warm surfaces. Lower `--brand-primary-l` in the brand CSS file until both `primary vs bg-cream` and `primary vs surface` clear 4.5:1.

If the brand identity requires the lighter shade, override `--primary-on` in the brand file to swap the foreground (dark text on light primary):

```css
:root {
  --brand-primary-h: 12;
  --brand-primary-s: 50%;
  --brand-primary-l: 60%;
  --primary-on: hsl(0 0% 10%);  /* override auto-detection */
}
```

### "token-audit reports 380 hex literals but exits 0"

Counts are informational. `token-audit.js` exits 0 unless `undef_var` count exceeds threshold or brand preset keys diverge from baseline (`annhien.brand.css`). Use `_build/token-audit-dashboard.md` to triage rogue literals — most are intentional in `_archive/` / `_iter*` folders.

### "path-audit reports violations after adding a new archetype"

New archetype HTML files in `starter/archetypes/` must use `../../tokens/foundation.css` (2 levels up), not `../tokens/foundation.css`. Fix the file or relocate it outside `starter/archetypes/` if it's not meant to be a starter template.

### "PowerShell version errors out parsing HSL"

The PowerShell variant uses `-match` regex on the brand CSS file. If your brand file uses non-standard formatting (e.g. multi-line HSL declarations), parsing may return `$null`. Normalize the brand file to the canonical single-line format:

```css
--brand-primary-h: 28;
--brand-primary-s: 40%;
--brand-primary-l: 38%;
```

---

## Files

- 🔧 [_build/ci-check.sh](_build/ci-check.sh) — Bash entry point
- 🔧 [_build/ci-check.ps1](_build/ci-check.ps1) — PowerShell entry point
- ⚙️ [.github/workflows/ci.yml](.github/workflows/ci.yml) — GitHub Actions
- 🐍 [_build/token-audit.js](_build/token-audit.js) — Guard 1
- 🐍 [_build/path-audit.js](_build/path-audit.js) — Guard 2
- 🐍 [_build/contrast-check-brand.js](_build/contrast-check-brand.js) — Guard 3
- 🐍 [_build/visual-diff.js](_build/visual-diff.js) — Guard 4 (opt-in)
- 🔧 [_build/lighthouse-check.sh](_build/lighthouse-check.sh) — Guard 5 Bash (default ON)
- 🔧 [_build/lighthouse-check.ps1](_build/lighthouse-check.ps1) — Guard 5 PowerShell (default ON)
- 🔧 [_build/gradient-contrast-probe.sh](_build/gradient-contrast-probe.sh) — Guard 6 Bash (opt-in, G14)
- 🔧 [_build/gradient-contrast-probe.ps1](_build/gradient-contrast-probe.ps1) — Guard 6 PowerShell (opt-in, G14)
- 📄 [_build/probe-runner.html](_build/probe-runner.html) — Probe HTML (deploy to VPS 5 `/opt/cowork-ui-kit/site/_probe/`)
- 📊 [_lighthouse/v0.5.7/](_lighthouse/v0.5.7/) — Lighthouse JSON outputs per archetype
