From e3930aa63361ca602ee0cc2f890f570142e7d249 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 27 Sep 2026 01:27:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20ta=20bort=20alfa-tonad=20textf?= =?UTF-8?q?=C3=A4rg=20=E2=80=94=2082=20kontrast=C3=B6vertr=C3=A4delser=20(?= =?UTF-8?q?UI-09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe-core 4.10.2 mot 0pt.app 2026-09-26: en enda regel, color-contrast, impact=serious, 82 noder. Estatets största enskilda a11y-defekt. Alla 82 har samma form: en Tailwind-alfamodifierare på en textfärg. Uppmätt WCAG 2.2-kontrast mot bägge temabakgrunderna i styles/globals.css: text-muted-foreground 4.73:1 ljust 7.63:1 mörkt GODKÄNT text-muted-foreground/70 2.71:1 4.20:1 faller text-muted-foreground/60 2.29:1 3.37:1 faller text-muted-foreground/50 1.96:1 2.67:1 faller text-muted-foreground/40 1.69:1 2.11:1 faller text-foreground/20 1.57:1 1.74:1 faller Den omodifierade token klarar AA i bägge teman, så åtgärden är helt enkelt att aldrig tona den. Elva textställen bytte till text-muted-foreground. Kopieringsknappen i app/portal går från 1.57:1 till 4.73:1 i viloläge och 19.79:1 vid hover — den föll även 1.4.11 för icke-textkomponenter. Toastens stängknapp klarade redan 1.4.11 (3.71:1) men blir 4.73:1. Kvar med alfa är endast den dekorativa masthead-siffran i app/bestall, som redan bär aria-hidden + select-none + pointer-events-none. Den är undantagen både av WCAG och av axe, och guarden nedan tillåter den på just den grunden. scripts/ci/check-text-contrast.mjs är en blockerande källkodsgrind i quality-jobbet. Den är avsiktligt inte en axe-körning: den fullständiga axe-jobb som UI-09 också begär kräver build + serve i CI och spåras separat. Tills det finns stoppar den här grinden regressionen vid den form som orsakade defekten. Refs: UI-09 Co-Authored-By: Claude Opus 5 --- .gitea/workflows/ci.yml | 7 ++ app/admin/verktyg/page.tsx | 2 +- app/bestall/beskriv/page.tsx | 2 +- app/kallor/page.tsx | 4 +- app/portal/page.tsx | 2 +- app/tjanster/page.tsx | 4 +- components/header.tsx | 2 +- components/sections/collection-section.tsx | 2 +- .../sections/featured-products-section.tsx | 2 +- components/sections/philosophy-section.tsx | 2 +- components/sections/pricing-section.tsx | 2 +- components/sections/testimonials-section.tsx | 2 +- components/ui/toast.tsx | 2 +- package.json | 3 +- scripts/ci/check-text-contrast.mjs | 74 +++++++++++++++++++ 15 files changed, 97 insertions(+), 15 deletions(-) create mode 100755 scripts/ci/check-text-contrast.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 05ed9df..941df82 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,6 +37,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck (tsc over src/ + tests/ -- blocking) run: pnpm run typecheck + # UI-09: 0pt carried 82 axe color-contrast violations (impact=serious), the + # estate's largest single a11y defect, and every one came from a Tailwind + # alpha modifier on a text colour. The unmodified token measures 4.73:1 + # light / 7.63:1 dark and passes AA; every faded variant fails. Blocking, + # because a contrast regression is invisible in review. + - name: Text contrast shape (WCAG 2.2 AA 1.4.3 -- blocking) + run: pnpm run check:contrast - name: Test (vitest -- blocking) run: pnpm run test - name: Lint (eslint -- advisory; legacy tree has pre-existing noise) diff --git a/app/admin/verktyg/page.tsx b/app/admin/verktyg/page.tsx index db0e2a2..cddfa8e 100644 --- a/app/admin/verktyg/page.tsx +++ b/app/admin/verktyg/page.tsx @@ -88,7 +88,7 @@ function ScreenTool() { ))} )} -

{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}

+

{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}

); diff --git a/app/bestall/beskriv/page.tsx b/app/bestall/beskriv/page.tsx index f688ff7..29440d9 100644 --- a/app/bestall/beskriv/page.tsx +++ b/app/bestall/beskriv/page.tsx @@ -79,7 +79,7 @@ export default function DescribePage() { ))} - {svc ?

{t(svc.covers)} → {t(svc.deliverable)}

: null} + {svc ?

{t(svc.covers)} → {t(svc.deliverable)}

: null}
diff --git a/app/kallor/page.tsx b/app/kallor/page.tsx index 48c4929..4de3b0d 100644 --- a/app/kallor/page.tsx +++ b/app/kallor/page.tsx @@ -273,7 +273,7 @@ export default function SourcesPage() { > {s.name} - + {s.source_id} @@ -301,7 +301,7 @@ export default function SourcesPage() {

{rows.length} {t({ en: "sources · every field carries its provenance", sv: "källor · varje fält bär sin härkomst" })}

-

+

{t({ en: "Licence terms verified before use", sv: "Licensvillkor verifieras innan användning" })}

diff --git a/app/portal/page.tsx b/app/portal/page.tsx index 7e5bb8c..e850326 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -286,7 +286,7 @@ function CopyId({ id }: { id: string }) { setTimeout(() => setCopied(false), 2000); }); }} - className="text-foreground/20 transition-colors hover:text-foreground/60" + className="text-muted-foreground transition-colors hover:text-foreground" > {copied ? "✓" : "⧉"} diff --git a/app/tjanster/page.tsx b/app/tjanster/page.tsx index d65e1f6..bd6c884 100644 --- a/app/tjanster/page.tsx +++ b/app/tjanster/page.tsx @@ -40,7 +40,7 @@ export default function ServicesPage() {

{t(FLOW_LABEL[flow])}

- {String(items.length).padStart(2, "0")} + {String(items.length).padStart(2, "0")}
{items.map((s, i) => { @@ -50,7 +50,7 @@ export default function ServicesPage() {

{t(s.name)}

{t(s.tagline)}

{t(s.covers)}

-

→ {t(s.deliverable)}

+

→ {t(s.deliverable)}

{t(cta.label)} → diff --git a/components/header.tsx b/components/header.tsx index 65831b2..2ad979d 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -123,7 +123,7 @@ export function Header() { className="flex items-baseline justify-between border-b border-border py-4 font-mono text-[12px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground" > {t(n.label)} - {String(i + 1).padStart(2, "0")} + {String(i + 1).padStart(2, "0")} ))}
diff --git a/components/sections/collection-section.tsx b/components/sections/collection-section.tsx index 13bd99d..ae22c15 100644 --- a/components/sections/collection-section.tsx +++ b/components/sections/collection-section.tsx @@ -44,7 +44,7 @@ export function CollectionSection() { >

{m.code}

- {String(i + 1).padStart(2, "0")} + {String(i + 1).padStart(2, "0")}

{m.live ? `${m.coverage}%` : t({ en: "coming", sv: "kommande" })} diff --git a/components/sections/featured-products-section.tsx b/components/sections/featured-products-section.tsx index 47807f9..2f3cdd3 100644 --- a/components/sections/featured-products-section.tsx +++ b/components/sections/featured-products-section.tsx @@ -91,7 +91,7 @@ export function FeaturedProductsSection() {

{d.markets}

- {String(i + 1).padStart(2, "0")} + {String(i + 1).padStart(2, "0")}

{t(d.title)}

{t(d.desc)}

diff --git a/components/sections/philosophy-section.tsx b/components/sections/philosophy-section.tsx index a04be34..525ef26 100644 --- a/components/sections/philosophy-section.tsx +++ b/components/sections/philosophy-section.tsx @@ -33,7 +33,7 @@ export function PhilosophySection() { className={`reveal delay-${i + 1} group relative grid grid-cols-[3.5rem_1fr] items-baseline gap-8 border-b border-border py-8 transition-[padding] duration-300 ease-out hover:pl-2`} > - + {n}
diff --git a/components/sections/pricing-section.tsx b/components/sections/pricing-section.tsx index 648b760..65f7747 100644 --- a/components/sections/pricing-section.tsx +++ b/components/sections/pricing-section.tsx @@ -64,7 +64,7 @@ export function PricingSection() { {MODELS.map((m, i) => (
- {m.index} + {m.index} {m.featured ? : null}
diff --git a/components/sections/testimonials-section.tsx b/components/sections/testimonials-section.tsx index 759046c..21b2d63 100644 --- a/components/sections/testimonials-section.tsx +++ b/components/sections/testimonials-section.tsx @@ -32,7 +32,7 @@ export function TestimonialsSection() { key={l.sv} className={`reveal delay-${i + 2} group relative border-b border-border py-12 sm:border-b-0 sm:border-l sm:py-0 sm:pl-8 sm:pr-4 ${i === 0 ? "sm:border-l-0 sm:pl-0" : ""}`} > -

0{i + 1}

+

0{i + 1}

{ + for (const m of line.matchAll(PATTERN)) { + // Decorative text is exempt when the element declares itself hidden to AT. + // The attribute may sit on an earlier line of the same JSX element. + const window = lines.slice(Math.max(0, i - 6), i + 1).join('\n'); + if (/\baria-hidden\b/.test(window)) continue; + violations.push({ file: relative(ROOT, file), line: i + 1, token: m[0], text: line.trim() }); + } + }); +} + +if (violations.length) { + console.error(`\n✗ ${violations.length} alpha-faded text colour(s) found — WCAG 2.2 AA 1.4.3 (finding UI-09).\n`); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.token}`); + console.error(` ${v.text.slice(0, 160)}`); + } + console.error('\n Use the unmodified token (text-muted-foreground = 4.73:1 light / 7.63:1 dark).'); + console.error(' If the text really is decoration, put aria-hidden on the element.\n'); + process.exit(1); +} +console.log('✓ no alpha-faded text colours — WCAG 2.2 AA 1.4.3 contrast shape is clean (UI-09)'); -- 2.54.0