fix(a11y): ta bort alfa-tonad textfärg — 82 kontrastöverträdelser (UI-09) #121
@@ -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)
|
||||
|
||||
@@ -88,7 +88,7 @@ function ScreenTool() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-3 text-[10.5px] text-muted-foreground/70">{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}</p>
|
||||
<p className="mt-3 text-[10.5px] text-muted-foreground">{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function DescribePage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{svc ? <p className="mt-3 text-sm text-muted-foreground">{t(svc.covers)} <span className="text-muted-foreground/60">→ {t(svc.deliverable)}</span></p> : null}
|
||||
{svc ? <p className="mt-3 text-sm text-muted-foreground">{t(svc.covers)} <span className="text-muted-foreground">→ {t(svc.deliverable)}</span></p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
|
||||
+2
-2
@@ -273,7 +273,7 @@ export default function SourcesPage() {
|
||||
>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<span className="text-[15px] tracking-tight">{s.name}</span>
|
||||
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground/60">
|
||||
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{s.source_id}
|
||||
</span>
|
||||
</td>
|
||||
@@ -301,7 +301,7 @@ export default function SourcesPage() {
|
||||
<p className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{rows.length} {t({ en: "sources · every field carries its provenance", sv: "källor · varje fält bär sin härkomst" })}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground/70">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t({ en: "Licence terms verified before use", sv: "Licensvillkor verifieras innan användning" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -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 ? "✓" : "⧉"}
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ServicesPage() {
|
||||
<section key={flow} className="mt-16">
|
||||
<div className="flex items-baseline justify-between border-b border-border pb-4">
|
||||
<h2 className="font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{t(FLOW_LABEL[flow])}</h2>
|
||||
<span className="font-mono text-[11px] tabular-nums text-muted-foreground/60">{String(items.length).padStart(2, "0")}</span>
|
||||
<span className="font-mono text-[11px] tabular-nums text-muted-foreground">{String(items.length).padStart(2, "0")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-px border border-border bg-border md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((s, i) => {
|
||||
@@ -50,7 +50,7 @@ export default function ServicesPage() {
|
||||
<h3 className="text-lg font-semibold tracking-[-0.01em]">{t(s.name)}</h3>
|
||||
<p className="mt-1 font-mono text-[11px] uppercase tracking-[0.06em] text-muted-foreground">{t(s.tagline)}</p>
|
||||
<p className="mt-4 flex-1 text-sm text-muted-foreground">{t(s.covers)}</p>
|
||||
<p className="mt-4 font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground/70">→ {t(s.deliverable)}</p>
|
||||
<p className="mt-4 font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">→ {t(s.deliverable)}</p>
|
||||
<Link href={cta.href} className="mt-5 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.1em] underline-offset-4 hover:underline">
|
||||
{t(cta.label)}
|
||||
<span aria-hidden className="transition-transform duration-300 group-hover:translate-x-1">→</span>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span>{t(n.label)}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/50">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CollectionSection() {
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="font-mono text-sm font-semibold tracking-tight">{m.code}</p>
|
||||
<span className="font-mono text-[9px] text-muted-foreground/60">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="font-mono text-[9px] text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{m.live ? `${m.coverage}%` : t({ en: "coming", sv: "kommande" })}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function FeaturedProductsSection() {
|
||||
<span className="absolute left-0 top-0 h-px w-0 transition-all duration-300 group-hover:w-full" style={{ backgroundColor: "var(--accent-red)" }} />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">{d.markets}</p>
|
||||
<span className="font-mono text-[10px] tabular-nums text-foreground/20">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-xl font-semibold tracking-[-0.01em] transition-transform duration-300 group-hover:translate-x-1">{t(d.title)}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t(d.desc)}</p>
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<span className="absolute -left-2 top-0 h-px w-0 bg-foreground transition-all duration-500 ease-out group-hover:w-full group-hover:opacity-100" aria-hidden />
|
||||
<span className="font-mono text-[clamp(1.5rem,2vw,2.25rem)] font-medium leading-none tracking-[-0.02em] text-muted-foreground/40 transition-colors duration-300 group-hover:text-foreground">
|
||||
<span className="font-mono text-[clamp(1.5rem,2vw,2.25rem)] font-medium leading-none tracking-[-0.02em] text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
|
||||
{n}
|
||||
</span>
|
||||
<div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function PricingSection() {
|
||||
{MODELS.map((m, i) => (
|
||||
<div key={m.index} className={`reveal delay-${i + 1} group relative flex flex-col bg-background p-8`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground/60">{m.index}</span>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">{m.index}</span>
|
||||
{m.featured ? <span className="mt-1 inline-block h-2 w-2" style={{ backgroundColor: "var(--accent-red)" }} /> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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" : ""}`}
|
||||
>
|
||||
<p className="font-mono text-[11px] tabular-nums text-muted-foreground/60">0{i + 1}</p>
|
||||
<p className="font-mono text-[11px] tabular-nums text-muted-foreground">0{i + 1}</p>
|
||||
<p
|
||||
className="mt-3 text-[clamp(3.2rem,8vw,6rem)] font-semibold leading-[0.9] tracking-[-0.04em] transition-transform duration-300 group-hover:-translate-y-0.5"
|
||||
style={{ color: red ? "var(--accent-red)" : "var(--foreground)" }}
|
||||
|
||||
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
'absolute right-2 top-2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
"typecheck:app": "tsc --noEmit -p tsconfig.json",
|
||||
"siax:doctor": "node scripts/siax-doctor.mjs",
|
||||
"deps:check": "depcruise src app components lib --config .dependency-cruiser.cjs",
|
||||
"vehiq:oltp-ingest": "tsx scripts/vehiq-oltp-ingest/run.ts"
|
||||
"vehiq:oltp-ingest": "tsx scripts/vehiq-oltp-ingest/run.ts",
|
||||
"check:contrast": "node scripts/ci/check-text-contrast.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Blocking guard for finding UI-09 (measured 2026-09-26): 0pt.app carried 82
|
||||
* `color-contrast` violations at axe-core 4.10.2 impact=serious — the estate's
|
||||
* single largest a11y defect. Every one of them came from the same shape: a
|
||||
* Tailwind alpha modifier on a muted text colour.
|
||||
*
|
||||
* Measured WCAG 2.2 contrast against the two theme backgrounds in
|
||||
* styles/globals.css (--background: oklch(1 0 0) light / oklch(0.145 0 0) dark,
|
||||
* --muted-foreground: oklch(0.556 0 0) / oklch(0.708 0 0)):
|
||||
*
|
||||
* text-muted-foreground 4.73:1 light 7.63:1 dark PASS (AA needs 4.5)
|
||||
* text-muted-foreground/70 2.71:1 4.20:1 FAIL
|
||||
* text-muted-foreground/60 2.29:1 3.37:1 FAIL
|
||||
* text-muted-foreground/50 1.96:1 2.67:1 FAIL
|
||||
* text-muted-foreground/40 1.69:1 2.11:1 FAIL
|
||||
* text-muted-foreground/10 1.13:1 1.13:1 FAIL
|
||||
*
|
||||
* The unmodified token passes in both themes, so the fix is simply never to
|
||||
* fade it. Purely decorative text is exempt — but it has to SAY so: the element
|
||||
* must carry `aria-hidden`, which is also what makes axe skip it. The single
|
||||
* remaining case (app/bestall/page.tsx, the oversized "01" masthead numeral) is
|
||||
* `aria-hidden` + `select-none` + `pointer-events-none` and is allowed by that
|
||||
* rule.
|
||||
*
|
||||
* This is a source-level guard, not a browser run. The full axe-core job that
|
||||
* UI-09 also asks for needs a build + serve in CI and is tracked separately;
|
||||
* until it exists this stops the regression at the shape that caused it.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = new URL('../..', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const SKIP = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '.turbo']);
|
||||
const EXT = /\.(tsx|ts|jsx|js|css)$/;
|
||||
/** Alpha-modified colour utilities on TEXT. Background/border alphas are fine. */
|
||||
const PATTERN = /\btext-(muted-foreground|foreground|primary|secondary|accent-foreground|card-foreground|popover-foreground)\/\d+/g;
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (EXT.test(name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of walk(ROOT)) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
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)');
|
||||
Reference in New Issue
Block a user