Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| | |
|---|---|
| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) |
| **Last updated** | 2026-10-17 (M3-17 app #38 contrast repair; WCAG AA semantic tokens added; 215 tests green) |
| **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) |
| **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) |
| **Readiness** | See [§3](#3-progress-snapshot) |
Expand Down Expand Up @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).

| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
| 2026-10-17 | `fix/contrast-muted-tokens` (#45) | app | M3-17 (status unchanged, `IN PROGRESS`) | Closes #38. Repaired landing-page step labels, footer disclaimer, and LagNotice text contrast to comply with WCAG AA (>= 4.5:1 body copy ratio) across both light and dark colour schemes. Introduced semantic tokens in `app/globals.css`: `--muted: #525a56;` in light mode (6.54:1 contrast ratio against `--paper: #f6f7f5`) and `--muted: #949f99;` in dark mode (6.74:1 contrast ratio against `--paper: #0f1512`), mapped via Tailwind v4 `@theme` as `--color-muted: var(--muted);`. Replaced all call sites using raw opacities `text-ink/50` and `text-ink/60` across `app/` and `components/` with `text-muted`. Added `lib/contrast.test.ts` (49 tests) with a pure WCAG 2.1 relative luminance and contrast ratio calculation engine to verify token ratios, demonstrate how raw opacities fail or are fragile on light paper, and scan `app/` and `components/` to prevent reintroducing `text-ink/50` and `text-ink/60`. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (215/215 passed across 16 files), and `pnpm build` all green. |
| 2026-10-08 | `chore/roadmap-copy-back` (#44) | app | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. |
| 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. |
| 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. |
Expand Down
4 changes: 2 additions & 2 deletions app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function HowItWorks() {
<ol className="mt-8 grid gap-8 md:grid-cols-3 md:gap-6">
{steps.map((step, i) => (
<li key={step.title} className="border-t border-line pt-4">
<span className="text-sm text-ink/50">{i + 1}</span>
<span className="text-sm text-muted">{i + 1}</span>
<h3 className="mt-1 text-lg font-medium">{step.title}</h3>
<p className="mt-2 text-ink/70">{step.body}</p>
</li>
Expand Down Expand Up @@ -116,7 +116,7 @@ function Principles() {
function Footer() {
return (
<footer className="mx-auto max-w-5xl px-4 py-12">
<div className="flex flex-col gap-4 border-t border-line pt-8 text-sm text-ink/60 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4 border-t border-line pt-8 text-sm text-muted md:flex-row md:items-center md:justify-between">
<p className="max-w-[48ch]">{t("pages.home.footerDisclaimer")}</p>
<nav className="flex gap-6">
<a href="https://github.com/Kinlock-Org" className="underline underline-offset-4">
Expand Down
7 changes: 7 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

@theme {
--color-ink: var(--ink);
--color-muted: var(--muted);
--color-paper: var(--paper);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
Expand All @@ -13,6 +14,10 @@

:root {
--ink: #141a17;
/* Muted text for secondary body copy, step counters, and meta hints (M3-17 / #38).
* WCAG AA requires contrast >= 4.5:1 on --paper (#f6f7f5). #525a56 achieves 6.54:1.
* Avoid raw opacity on --ink (e.g. text-ink/50 or text-ink/60) which fails or skirts the threshold. */
--muted: #525a56;
--paper: #f6f7f5;
--accent: #1c7c68;
--accent-ink: #f6f7f5;
Expand All @@ -22,6 +27,8 @@
@media (prefers-color-scheme: dark) {
:root {
--ink: #e8ede9;
/* Muted text in dark mode on --paper (#0f1512). #949f99 achieves 6.74:1 (WCAG AA >= 4.5:1). */
--muted: #949f99;
--paper: #0f1512;
--accent: #35a085;
--accent-ink: #0f1512;
Expand Down
2 changes: 1 addition & 1 deletion components/indexer/LagNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { t } from "@/lib/i18n/messages";
export function LagNotice({ lagging }: { lagging: boolean }) {
if (!lagging) return null;
return (
<p role="status" className="mt-2 text-sm text-ink/60">
<p role="status" className="mt-2 text-sm text-muted">
{t("common.indexerLagNotice")}
</p>
);
Expand Down
135 changes: 135 additions & 0 deletions lib/contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

/**
* WCAG 2.1 relative luminance calculation (Formula: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance).
*/
function sRgbToLinear(c: number): number {
const val = c / 255;
return val <= 0.04045 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4;
}

function relativeLuminance(hex: string): number {
const cleanHex = hex.replace("#", "");
const r = parseInt(cleanHex.slice(0, 2), 16);
const g = parseInt(cleanHex.slice(2, 4), 16);
const b = parseInt(cleanHex.slice(4, 6), 16);
return 0.2126 * sRgbToLinear(r) + 0.7152 * sRgbToLinear(g) + 0.0722 * sRgbToLinear(b);
}

function contrastRatio(hex1: string, hex2: string): number {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}

function composite(fgHex: string, bgHex: string, alpha: number): string {
const fg = fgHex.replace("#", "");
const bg = bgHex.replace("#", "");
const r = Math.round(
parseInt(fg.slice(0, 2), 16) * alpha + parseInt(bg.slice(0, 2), 16) * (1 - alpha),
);
const g = Math.round(
parseInt(fg.slice(2, 4), 16) * alpha + parseInt(bg.slice(2, 4), 16) * (1 - alpha),
);
const b = Math.round(
parseInt(fg.slice(4, 6), 16) * alpha + parseInt(bg.slice(4, 6), 16) * (1 - alpha),
);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}

describe("WCAG AA color contrast verification (M3-17 / #38)", () => {
const lightPaper = "#f6f7f5";
const lightInk = "#141a17";
const lightMuted = "#525a56";

const darkPaper = "#0f1512";
const darkInk = "#e8ede9";
const darkMuted = "#949f99";

it("light mode: primary ink clears AA body text (>= 4.5:1)", () => {
const ratio = contrastRatio(lightInk, lightPaper);
expect(ratio).toBeGreaterThanOrEqual(4.5);
expect(ratio).toBeGreaterThan(16);
});

it("light mode: semantic muted token clears AA body text (>= 4.5:1)", () => {
const ratio = contrastRatio(lightMuted, lightPaper);
expect(ratio).toBeGreaterThanOrEqual(4.5);
expect(ratio).toBeGreaterThan(6.5);
});

it("dark mode: primary ink clears AA body text (>= 4.5:1)", () => {
const ratio = contrastRatio(darkInk, darkPaper);
expect(ratio).toBeGreaterThanOrEqual(4.5);
expect(ratio).toBeGreaterThan(15);
});

it("dark mode: semantic muted token clears AA body text (>= 4.5:1)", () => {
const ratio = contrastRatio(darkMuted, darkPaper);
expect(ratio).toBeGreaterThanOrEqual(4.5);
expect(ratio).toBeGreaterThan(6.7);
});

it("demonstrates why raw ink opacities fail or are dangerously fragile on light paper", () => {
const ink50Equivalent = composite(lightInk, lightPaper, 0.5);
const ratio50 = contrastRatio(ink50Equivalent, lightPaper);
// 3.30:1 fails WCAG AA minimum 4.5:1
expect(ratio50).toBeLessThan(4.5);

const ink60Equivalent = composite(lightInk, lightPaper, 0.6);
const ratio60 = contrastRatio(ink60Equivalent, lightPaper);
// 4.54:1 barely clears 4.5:1 by 0.04
expect(ratio60).toBeGreaterThanOrEqual(4.5);
expect(ratio60).toBeLessThan(4.6);
});
});

describe("no forbidden low-contrast opacity classes (text-ink/50, text-ink/60)", () => {
const scannedDirs = ["app", "components"];
const FORBIDDEN_PATTERN = /text-ink\/(50|60)\b/;

function getFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const fullPath = join(dir, entry);
if (statSync(fullPath).isDirectory()) return getFiles(fullPath);
if (
/\.(tsx?)$/.test(fullPath) &&
!fullPath.endsWith(".test.ts") &&
!fullPath.endsWith(".test.tsx")
) {
return [fullPath];
}
return [];
});
}

const allFiles = scannedDirs.flatMap(getFiles);

it("finds source files across app/ and components/", () => {
expect(allFiles.length).toBeGreaterThan(5);
});

it.each(allFiles)("%s has no text-ink/50 or text-ink/60", (file) => {
const content = readFileSync(file, "utf8");
const matches = content.match(FORBIDDEN_PATTERN);
expect(matches).toBeNull();
});

it("detects forbidden opacities if re-introduced", () => {
expect(FORBIDDEN_PATTERN.test('className="text-sm text-ink/50"')).toBe(true);
expect(FORBIDDEN_PATTERN.test('className="text-sm text-ink/60"')).toBe(true);
expect(FORBIDDEN_PATTERN.test('className="text-sm text-ink/70"')).toBe(false);
expect(FORBIDDEN_PATTERN.test('className="text-sm text-muted"')).toBe(false);
});

it("ensures globals.css defines semantic muted tokens and @theme binding", () => {
const globalsCss = readFileSync("app/globals.css", "utf8");
expect(globalsCss).toContain("--color-muted: var(--muted);");
expect(globalsCss).toContain("--muted: #525a56;");
expect(globalsCss).toContain("--muted: #949f99;");
});
});
Loading