Skip to content
Merged
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
152 changes: 152 additions & 0 deletions scripts/check-skin-contrast.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
// Reads the skin blocks out of src/styles.css and measures every text/surface
// pair the components actually produce. Run it after touching a palette:
//
// node scripts/check-skin-contrast.mjs
//
// It parses the CSS rather than taking a second copy of the values, so the
// check can never pass against a palette that is no longer the shipped one.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const css = readFileSync(join(root, "src/styles.css"), "utf8");

function declarations(body) {
const tokens = {};
for (const [, name, value] of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
tokens[name] = value.trim();
}
return tokens;
}

/** The tokens every skin starts from: the `@theme` defaults plus the bare
* `:root` block. A skin that does not redefine one of these still SHIPS it,
* so the check has to measure it — otherwise a token upstream adds is
* inherited untested by every skin, and the run stays green while a light
* skin wears a dark skin's focus ring. That is exactly what happened when
* upstream introduced --color-focus. */
function parseBase(source) {
const base = {};
for (const [, body] of source.matchAll(/(?:@theme|:root)\s*\{([^}]*)\}/g)) {
Object.assign(base, declarations(body));
}
return base;
}

/** Every `[data-skin="x"] { … }` block, as id → {token: value}, over the
* inherited base so a skin is measured as it actually renders. */
function parseSkins(source) {
const base = parseBase(source);
const skins = new Map();
for (const [, id, body] of source.matchAll(/\[data-skin="([a-z-]+)"\]\s*\{([^}]*)\}/g)) {
skins.set(id, { ...base, ...declarations(body) });
}
return skins;
}

function parseHex(hex) {
const h = hex.replace("#", "").trim();
const full = h.length === 3 ? [...h].map((c) => c + c).join("") : h;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1,
};
}

/** Foreground alpha composited over an opaque background. */
function flatten(fg, bg) {
if (fg.a === 1) return fg;
return {
r: fg.r * fg.a + bg.r * (1 - fg.a),
g: fg.g * fg.a + bg.g * (1 - fg.a),
b: fg.b * fg.a + bg.b * (1 - fg.a),
a: 1,
};
}

function luminance({ r, g, b }) {
const channel = (v) => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrast(fgHex, bgHex) {
const bg = parseHex(bgHex);
const fg = flatten(parseHex(fgHex), bg);
const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
return (hi + 0.05) / (lo + 0.05);
}

// Pairs taken from what the components render, not from what looks plausible:
// body copy sits on all five surfaces, the filled accent/danger buttons carry
// their own ink token, and the status colours are used as text on cards.
const SURFACES = ["--color-app", "--color-panel", "--color-raised", "--color-raised-hover", "--color-card", "--color-inset"];
const PAIRS = [
...SURFACES.map((s) => ["--color-ink", s, 4.5]),
...SURFACES.map((s) => ["--color-ink-secondary", s, 4.5]),
["--color-ink", "--color-bubble-user", 4.5],
["--color-accent-ink", "--color-accent", 4.5],
["--color-danger-ink", "--color-danger", 4.5],
["--color-accent-text", "--color-app", 4.5],
["--color-accent-text", "--color-panel", 4.5],
["--color-accent-text", "--color-card", 4.5],
["--color-danger", "--color-card", 4.5],
["--color-success", "--color-card", 4.5],
["--color-warning", "--color-card", 4.5],
// borders and dots are UI components, not text — AA asks 3:1 of them
["--color-hairline", "--color-app", 1.5],
["--color-accent", "--color-app", 3],
["--color-scrollbar", "--color-app", 1.5],
// The focus ring sits outside the control (outline-offset: 2px), so it
// lands on whatever surface is behind it — a skin that inherits another
// skin's ring keeps a colour that was never checked against its ground.
// WCAG 1.4.11 asks 3:1 of a non-text indicator.
["--color-focus", "--color-app", 3],
["--color-focus", "--color-panel", 3],
["--color-focus", "--color-card", 3],
];

const skins = parseSkins(css);
// Midnight is shipped as a faithful copy of upstream, contrast gaps included;
// it is reported but not allowed to fail the run.
const ADVISORY = new Set(["midnight"]);

let failed = false;
for (const [id, tokens] of skins) {
const problems = [];
const missing = [];
let measured = 0;
for (const [fg, bg, min] of PAIRS) {
// A pair we cannot measure is reported, never silently skipped: an
// unmeasured pair used to be counted as a passing one.
if (!tokens[fg] || !tokens[bg]) {
missing.push(!tokens[fg] ? fg : bg);
continue;
}
measured++;
const ratio = contrast(tokens[fg], tokens[bg]);
if (ratio < min) problems.push({ fg, bg, ratio, min });
}
const advisory = ADVISORY.has(id);
if (missing.length) {
console.log(`✗ ${id} — undefined token(s): ${[...new Set(missing)].join(", ")}`);
if (!advisory) failed = true;
}
if (problems.length === 0) {
if (!missing.length) console.log(`✓ ${id} — ${measured} pairs, none below target`);
continue;
}
console.log(`${advisory ? "~" : "✗"} ${id}${advisory ? " (advisory — upstream copy)" : ""}`);
for (const { fg, bg, ratio, min } of problems) {
console.log(` ${fg} on ${bg}: ${ratio.toFixed(2)}:1 (needs ${min}:1)`);
}
if (!advisory) failed = true;
}

process.exit(failed ? 1 : 0);
4 changes: 4 additions & 0 deletions src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CompanionSection } from "./CompanionSection";
import { Card } from "./SettingsPrimitives";
import { UsageSection } from "./UsageSection";
import { VoiceSettings } from "./VoiceSettings";
import { SkinPicker } from "./SkinPicker";
import { cn } from "@/lib/cn";

const SECTIONS: Array<{ id: AppSettingsSection; label: string; icon: typeof User }> = [
Expand Down Expand Up @@ -202,6 +203,9 @@ export function SettingsModal() {
<Card title="Profile" subtitle="Shown in the sidebar. Saved as you go.">
<ProfileFields />
</Card>
<Card title="Skin" subtitle="Applies instantly and is remembered on this machine.">
<SkinPicker />
</Card>
<UpdatesRow />
</>
)}
Expand Down
113 changes: 113 additions & 0 deletions src/components/SkinPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Choosing a skin is a visual decision, so the options are shown visually: each
// card carries a working miniature of the app rendered in that skin, not a row
// of paint chips. That works because the skin blocks in styles.css are keyed on
// `[data-skin]` rather than `:root[data-skin]` — any element can open a skin
// context for its own subtree, so the miniature styles itself and can never
// drift from what picking it actually does.
import { useState } from "react";
import { Check } from "lucide-react";
import { SKINS, applySkin, readSkin, type SkinId } from "@/lib/skins";
import { cn } from "@/lib/cn";

/**
* The app's own layout at roughly 1/14 scale: rail, sidebar with a selected
* row, thread, composer. The selected row and the send button are drawn in the
* accent on purpose — a skin is mostly judged by where its colour lands, and a
* single dot was too small to judge.
*/
function Miniature({ skin }: { skin: SkinId }) {
return (
<div
data-skin={skin}
aria-hidden="true"
className="flex h-[78px] w-full overflow-hidden rounded-lg bg-app"
>
{/* rail */}
<div className="flex w-[11px] shrink-0 flex-col items-center gap-[3px] bg-panel pt-[5px]">
<span className="size-[5px] rounded-full bg-accent" />
<span className="size-[5px] rounded-full bg-ink-secondary/40" />
<span className="size-[5px] rounded-full bg-ink-secondary/40" />
</div>
{/* sidebar — the top row is the selected conversation */}
<div className="flex w-[30px] shrink-0 flex-col gap-[3px] border-r border-hairline bg-panel p-[4px]">
<span className="flex h-[9px] w-full items-center gap-[2px] rounded-sm bg-raised px-[2px]">
<span className="size-[4px] shrink-0 rounded-full bg-accent" />
<span className="h-[2px] flex-1 rounded-full bg-ink/50" />
</span>
<span className="h-[3px] w-[80%] rounded-full bg-ink-secondary/30" />
<span className="h-[3px] w-[62%] rounded-full bg-ink-secondary/30" />
<span className="h-[3px] w-[74%] rounded-full bg-ink-secondary/30" />
</div>
{/* thread */}
<div className="flex min-w-0 flex-1 flex-col gap-[4px] p-[6px]">
<span className="h-[13px] w-[62%] self-end rounded-md bg-bubble-user" />
<div className="flex w-[88%] flex-col gap-[3px] rounded-md bg-card p-[4px]">
<span className="h-[2px] w-full rounded-full bg-ink/45" />
<span className="h-[2px] w-[85%] rounded-full bg-ink/45" />
<span className="h-[2px] w-[60%] rounded-full bg-ink-secondary/40" />
</div>
<div className="mt-auto flex items-center gap-[4px]">
<span className="h-[11px] flex-1 rounded-full bg-inset ring-1 ring-hairline" />
{/* filled accent with its own ink — Foundry's inversion reads right
here: bright brass carrying a dark mark, where the others carry
a light one */}
<span className="flex size-[11px] items-center justify-center rounded-full bg-accent">
<span
className="h-[1.5px] w-[5px] rounded-full"
style={{ background: "var(--color-accent-ink)" }}
/>
</span>
</div>
</div>
</div>
);
}

export function SkinPicker() {
// The document is the source of truth, not storage: main.tsx has already
// stamped it, and reading it back keeps the checkmark honest even if the
// skin was set some other way.
// SAFETY: main.tsx writes this attribute from applySkin() before the first
// paint, and readSkin() covers the case where it is absent or unreadable.
const [active, setActive] = useState<SkinId>(
() => (document.documentElement.dataset.skin as SkinId) || readSkin(),
);

return (
// One row, so the whole set is visible without scrolling the modal —
// 2x2 pushed the second row below the fold of its 560px frame.
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{SKINS.map((skin) => {
const selected = skin.id === active;
return (
<button
key={skin.id}
type="button"
onClick={() => {
applySkin(skin.id);
setActive(skin.id);
}}
aria-pressed={selected}
className={cn(
"flex flex-col gap-2 rounded-xl border p-2 text-left transition-colors",
selected
? "border-accent-border bg-raised"
: "border-hairline/60 hover:border-hairline hover:bg-raised/50",
)}
>
<Miniature skin={skin.id} />
<div className="flex items-start gap-1.5 px-0.5 pb-0.5">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-ink">{skin.name}</div>
<div className="mt-0.5 text-[11px] leading-snug text-ink-secondary">
{skin.tagline}
</div>
</div>
{selected && <Check size={13} className="mt-0.5 shrink-0 text-accent-text" />}
</div>
</button>
);
})}
</div>
);
}
49 changes: 49 additions & 0 deletions src/lib/skins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The registry and the stylesheet are two halves of one contract: a skin listed
// here without a matching CSS block renders as whatever was active before, with
// no error anywhere. That failure is silent, so it gets a test.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { SKINS, SKIN_IDS, DEFAULT_SKIN } from "./skins";

const css = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "../styles.css"),
"utf8",
);

const blocks = new Set(
[...css.matchAll(/\[data-skin="([a-z-]+)"\]/g)].map(([, id]) => id),
);

describe("skins", () => {
it("gives every registered skin a stylesheet block", () => {
for (const id of SKIN_IDS) expect(blocks).toContain(id);
});

it("registers every stylesheet block", () => {
// SAFETY: the assertion only fits toContain()'s parameter type — the
// assertion IS the check, and an unregistered block fails the test.
for (const id of blocks) expect(SKIN_IDS).toContain(id as (typeof SKIN_IDS)[number]);
});

it("defines the same tokens in every skin", () => {
const tokensOf = (id: string) => {
const body = css.match(new RegExp(`\\[data-skin="${id}"\\]\\s*\\{([^}]*)\\}`))?.[1] ?? "";
return new Set([...body.matchAll(/(--[\w-]+)\s*:/g)].map(([, name]) => name));
};
const reference = tokensOf(DEFAULT_SKIN);
expect(reference.size).toBeGreaterThan(15);
for (const id of SKIN_IDS) {
expect([...reference].filter((t) => !tokensOf(id).has(t))).toEqual([]);
}
});

it("describes each skin exactly once", () => {
expect(SKINS.map((s) => s.id).sort()).toEqual([...SKIN_IDS].sort());
for (const skin of SKINS) {
expect(skin.name.length).toBeGreaterThan(0);
expect(skin.tagline.length).toBeGreaterThan(0);
}
});
});
Loading
Loading