Trzeci dzień audytu własnego portfolio własnym toolkitem, na żywo, publicznie. Dziś jest dzień pierwszy i puszczam tę nudną warstwę.

Portfolio.sdet.it to dwujęzyczny site na Astro 5, który shippuję na produkcję. Nie fixture, nie wykombinowane demo. Realny content, realne komponenty, realny hosting na VPS, który skonfigurowałem tydzień temu. Trzy dni audytów, trzy wersje toola, ten sam target.

Dzień 1 (dzisiaj) to V0.2 mojego public WCAG toolkitu. Statyczny analizator TypeScript plus Playwright z axe-core. Deterministyczny, CI-friendly, zero LLM w pętli. Warstwa, której ufasz że wywali Ci build o drugiej w nocy.

Dzień 2 puści V0.3 public, ten sam toolkit plus 5 AI specjalistów czytających source przez Read/Grep/Glob. Dzień 3 puści V0.4 Pro, warstwę komercyjną. Każdy dzień dodaje capability. Każdy dzień łapie findings, których poprzednia warstwa nie widziała.

Dzisiejsza warstwa to to, co chodzi w CI. Łapie to, co jest w HTML i CSS. Pomija to, co jest intencją w source. Tak to wygląda na realnym projekcie.

Krótko o tym, jak tu trafiłem

Backstory zanim spojrzymy w liczby: to nie jest miejsce, w którym planowałem być.

Dzień 1 sprintu WCAG, cztery godziny w środku, miałem działający analizator TypeScript, który pattern-matchował HTML pod brakujące atrybuty alt, brak lang, brak landmarków. AI agenci owinięci wokół niego, tłumaczący axe-style findings na ładniejszy output. Ten sam regex matching w środku, ładniejszy syntax na wierzchu. Kolejny wrapper na axe-core.

Skasowałem go.

Regex nie wie, że onClick={handler} bez onKeyDown={handler} rozwala keyboard userów. AI czytający JSX wie. To był pivot. AI specjaliści czytają source przez Read/Grep/Glob i piszą findings własnymi słowami. Statyczne reguły zostają deterministycznym fallbackiem dla CI, nie głównym flow. Dwie warstwy, dwa różne zadania.

Dlatego V0.2 wygląda tak, jak wygląda. To nie jest “cały tool”. To deterministyczna podłoga toola, którego warstwa odkrywania jest AI. CI-friendly, szybka, przewidywalna. Puszczasz na każdym commicie i mówi Ci, co jest wyrenderowane źle. Nie powie Ci, co jest źle strukturalnie w source.

Pełna historia pivotu to osobny post. Na teraz: V0.2 to to, co zostawiasz, kiedy zdecydujesz, że AI jest warstwą odkrywania, a nie wrapperem.

Architektura V0.2

V0.2 public ma dwie ścieżki. Obie deterministyczne. Obie szybkie. Żadna AI.

Statyczny analizator TypeScript pattern-matchuje pliki HTML i CSS source. Brakujące atrybuty alt na img, brak html lang, brak ról landmarków, redundantne role ARIA. Rzeczy, które znajdziesz regexem na tekście source. Zero wywołań LLM, zero tokenów, sub-sekundowo na repo wielkości portfolio. CI-friendly z założenia.

Ścieżka dynamiczna puszcza Playwrighta na dev serwer, potem axe-core w realnej przeglądarce. Computed contrast ratio, focus indicators, keyboard-reachable controls, ARIA w wyrenderowanym DOM. Łapie to, co faktycznie shippuje się do userów, nie tylko to, co jest w source. Wolniejsza od statycznej (około 2 sekundy dla audytu 4 routów), ale widzi to, czego regex nie policzy.

graph LR
    A[Project Source] --> B[Static TS Analyzer]
    A --> C[Dev Server]
    C --> D[Dynamic Playwright + axe]
    B --> E[Findings Merge]
    D --> E
    E --> F[Markdown Reports]

Obie ścieżki emitują ten sam kształt WcagFinding: ruleId, file:line (albo url:selector), severity, WCAG SC reference, sugerowany fix. Orchestrator merguje je, deduplikuje po (ruleId, file:line, url), scoruje wg modelu kar (-15 critical, -10 serious, -5 moderate, -2 minor, podłoga 0) i emituje grade A-F.

To jest deterministyczna podłoga. To nie jest nic. To jest po prostu za mało.

Liczby z baseline V0.2

Co V0.2 zobaczył na portfolio.sdet.it.

ŹródłoFindingsRealNotatki
Static TS20Oba playwright-report/index.html (artefakt testów)
Dynamic axe43.hero-load + .license x 3 contrast
Dynamic focus-visibility40Wszystkie <astro-dev-toolbar> (Astro dev injection)
Total103

Severity breakdown: 0 critical, 10 serious, 0 moderate, 0 minor. Score: 0/100 (penalty 100, podłoga). Grade: F. Wall time: 2.14 sekundy.

Failing grade z czystym dziesięcioma findings. Wygląda dramatycznie. Rzeczywistość jest nudniejsza: 7 z 10 to noise. Artefakty testów i dev-mode injecty, które nigdy nie shippują na produkcję.

Plik playwright-report/index.html siedzi w playwright-report/, bo Playwright HTML reporter zapisuje tam po każdym test runie. To autogenerowany HTML, nigdy nie deployowany, ale statyczny analizator skanuje go, bo siedzi w repo. Naprawialne .wcagignore globem, na roadmapie.

Findings z <astro-dev-toolbar> to injection toolbara dev-mode od Astro. Istnieje tylko, kiedy puszczasz astro dev. Nigdy nie shippuje na produkcję. Re-run na buildzie z pnpm preview je eliminuje. Udokumentowany szum.

Uczciwa liczba po odsianiu noise: 3 realne bugi. Wszystkie kontrast. Wszystkie w dark theme. Wszystkie prześlizgnęłyby się obok V0.2, gdyby nie wyrenderowały się w przeglądarce. Statyczny złapał zero z nich, bo wszystkie trzy chowają się za indirectionem CSS variables i design tokens.

Trzy findings na 23-stronicowym site. Czysto. Ale to dlatego, że static plus dynamic łapie tylko to, co jest w HTML i CSS. Bugi w source nadal siedzą w source.

3 realne findings

Trzy realne bugi. Ten sam kształt root cause: misuse design tokenów chowający się za zmiennymi CSS. Statyczny ich nie zobaczył. Dynamic zobaczył.

Bug 1 siedzi w src/components/sections/Hero.astro linia 17. Linia hero load (“KERNAL READY”) renderuje #38935a na białym przy 3.82:1. WCAG 1.4.3 chce minimum 4.5:1 dla normalnego tekstu. Source używa var(--color-accent) plus opacity: 0.85, czego analiza statyczna nie pomnoży. Dynamic axe puścił stronę, dostał computed color, policzył ratio, oflagował.

Bugi 2-4 to ten sam root cause na trzech project cards. Plik: src/components/cards/ProjectCard.astro linie 199-202. Badge’e .license (AGPL-3.0, MIT featured, MIT trzecia karta) renderują #22c55e na #fafafa przy 2.18:1. To dobrze poniżej podłogi 3:1 dla non-text contrast, nie mówiąc o 4.5:1 dla tekstu.

Jeden design token (--color-accent-muted, rozwiązujący się do za jasnego zielonego w light theme) zasila trzy wyrenderowane komponenty. Analiza statyczna widzi trzy osobne selektory .license z var(--color-accent-muted) i nie potrafi pójść za indirectionem, żeby wiedzieć, jaka wartość RGB wychodzi z drugiej strony. Dynamic axe chodzi po każdej stronie projektu, liczy faktyczny renderowany kolor, raportuje trzy findings. Trzy widoczne symptomy, jeden root cause, ale V0.2 raportuje je jako trzy osobne findings, bo tyle zobaczy.

To są wygrane testowania dynamicznego. Przegrane przyjdą jutro.

Czego V0.2 nie widzi

I to jest ten haczyk. W tym codebase siedzi jeszcze dziewięć produkcyjnych bugów WCAG. V0.2 ich nie znajdzie. Ani w tym runie, ani w dziesięciu kolejnych.

Sneak preview bez spoilerów (te są na Część 2):

  • aria-label misuse na elementach semantycznych: aria-label="C64 boot" na <p>, aria-label="Tech stack" na <ul>, aria-label="Key metrics" na <dl>. Trzy instancje, trzy różne elementy, jeden anti-pattern.
  • Dziury w hierarchii nagłówków w MDX content.
  • Issue na poziomie tokena affecting sześć komponentów na dark theme przez jedną wartość --color-text-subtle.
  • Filter pille bez stanu toggle (aria-pressed).
  • Parę pomniejszych rzeczy.

Dlaczego statyczny tego nie zobaczy: wartości aria-label to stringi. Regex matchuje obecność atrybutu, nie to, czy atrybut jest sensowny na tym elemencie. Token misuse propaguje się przez var() indirection w wielu plikach. Stan toggle wymaga rozumienia kontekstu interakcji, nie tekstu.

Dlaczego dynamic też tego nie zobaczy: większość misuse aria jest w JSX albo Astro template’ach, a wyrenderowany HTML nadal ma ten atrybut (axe sprawdza obecność, nie sensowność). Sweep tylko po odwiedzonych stronach pomija wewnętrzne route’y artykułów. Obecność atrybutu ARIA nie równa się semantycznej poprawności, a to jest semantyczna decyzja, nie pattern.

To są wygrane AI specjalistów czytających source. Otwierają plik, rozpoznają, że <p> ma już implicit role i widoczny tekst, i flagują aria-label jako anti-pattern przesłaniający override. Idą za łańcuchem var() przez pliki. Rozumują o stanie toggle.

Jutro Część 2.

Uczciwy cliffhanger

Gdyby V0.2 było całym toolem, shippowałbym jako “użyj tego w CI, resztę audytuj manualnie”. Tam się większość statycznych toolkitów zatrzymuje i to jest sensowne miejsce zatrzymania. Trzy produkcyjne bugi złapane na żywym portfolio to realna wartość. CI failujące na kontraście 3.82:1 vs wymagane 4.5:1 to dokładnie ten guardrail, dla którego statyczna analiza istnieje.

Ale to za mało. Dziewięć kolejnych bugów siedzi teraz w source i żadna ilość re-runów V0.2 ich nie wyciągnie. Inna powierzchnia skanu, inna warstwa. Statyczny pyta “co jest renderowane”. Dynamic pyta “co jest computed”. Żaden nie pyta “co jest intencją”.

Jutro Część 2: to samo portfolio, V0.3 public dodaje 5 AI specjalistów czytających source przez Read/Grep/Glob. Spoiler: 16 unique findings złapanych przez dwa runy audytów. Triangulacja, nie regression. #FromTheField.

Repo i clone

Repo: github.com/darco81/sdet-wcag-toolkit (AGPL-3.0).

Quick start:

pnpm install
pnpm -r build
wcag-toolkit audit . --url http://localhost:4321

To daje Ci baseline V0.2 (static + dynamic). Dla pełnej warstwy AI V0.3 otwórz Claude Code w korzeniu projektu i puść /wcag:audit. Skill dispatch’uje 5 specjalistów równolegle przez Task tool, merguje findings z deterministycznym backbone, emituje grade A-F.

Pro tier na sdet.it/services: multi-runtime (Claude Code, OpenCode, Ollama lokalnie dla wrażliwych client repo), auto-fix engine, niche specjaliści.

Seria leci jutro. Część 2: triangulacja.