Files
PaperClipAI/ui/src/components/DestructiveThemeTokens.test.ts
T
Vyacheslav ScherbininandClaude Opus 5.5 3b34d45a92 fix(ui): make destructive-foreground readable in the light theme (#14328)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI uses shadcn theme tokens from `ui/src/index.css`.
> - Delete and remove dialogs confirm with a red button: `bg-destructive
text-destructive-foreground`.
> - In the light theme, `--destructive-foreground` has the same value as
`--destructive`. The label is red text on a red fill, so it is not
visible.
> - Operators cannot read the button that deletes data or removes a
connection.
> - This pull request sets the light `--destructive-foreground` to the
near-white value that the dark theme already uses.
> - The benefit is that every destructive confirm button shows a
readable label in both themes.

## Linked Issues or Issue Description

Fixes #14327

I searched open and closed PRs for `destructive-foreground`, "Remove
connection" and related terms. I found no PR that changes this token.

## What Changed

- `ui/src/index.css`: in `:root`, `--destructive-foreground` changes
from `oklch(0.577 0.245 27.325)` (the same as `--destructive`) to
`oklch(0.985 0 0)`. This is the value in `.dark`.
- Added `ui/src/components/DestructiveThemeTokens.test.ts`. The test
converts both tokens from OKLCH to sRGB and computes the WCAG contrast
ratio. `:root` must reach 4.5:1 (AA for normal text). `.dark` must reach
3:1, because it keeps the brighter red chosen in the gallery review
(about 3.6:1 today).

The fix applies to all five buttons that use
`text-destructive-foreground`: remove connection (`Browse.tsx`,
`Connections.tsx`), delete folder (`FolderControls.tsx`), delete
environment (`CompanyEnvironments.tsx`) and remove chat endpoint
(`ChatEndpointDetail.tsx`). No other file uses the token.

## Verification

- `pnpm exec vitest run src/components/DestructiveThemeTokens.test.ts`
in `ui`: 2 tests pass. The light theme contrast is about 4.8:1.
- Before the fix, the test fails for `:root` (1:1, red on red). With a
light gray foreground `oklch(0.88 0 0)` it also fails (3.3:1 < 4.5:1).
- Manual check on a self-hosted instance in the light theme: Apps →
connection → **Remove connection** showed a red button with no visible
label. After the fix, the label is white and readable.


Before and after, light theme (the same classes as the confirm button:
`bg-destructive text-destructive-foreground`):

<img
src="https://gist.githubusercontent.com/gentslava/3aa0d498e2475dd44e12bed11e7d7862/raw/c793570ebd416fe04cc390df8554515b4207590d/remove-connection-before-after.png"
alt="Remove connection button before and after the fix" width="520">

## Risks

- Low risk. The change is one token in the light theme. Only the five
confirm buttons above read `--destructive-foreground`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits). It found the cause, wrote the fix and the test, and
wrote this description.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

@cryppadotta @devinfoley a tiny one-line theme fix: the delete buttons
in the light theme are red-on-red right now, so people can't see what
they are about to click. Would be great to get it in when you have a
minute 🙏

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:34:26 -07:00

68 lines
2.8 KiB
TypeScript

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const stylesheet = readFileSync(fileURLToPath(new URL("../index.css", import.meta.url)), "utf8");
function cssBlock(selector: string): string {
const start = stylesheet.indexOf(`\n${selector} {`);
expect(start, `Missing CSS selector: ${selector}`).toBeGreaterThanOrEqual(0);
const bodyStart = stylesheet.indexOf("{", start);
const bodyEnd = stylesheet.indexOf("\n}", bodyStart);
return stylesheet.slice(bodyStart + 1, bodyEnd);
}
function token(block: string, name: string): string {
const match = block.match(new RegExp(`^\\s*--${name}:\\s*([^;]+);`, "m"));
expect(match, `Missing token --${name}`).not.toBeNull();
return match![1].trim();
}
function oklch(value: string): [number, number, number] {
const match = value.match(/^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
expect(match, `Expected an oklch() color, got ${value}`).not.toBeNull();
return [Number(match![1]), Number(match![2]), Number(match![3])];
}
// WCAG 2 relative luminance of an oklch() color, via OKLab and linear sRGB.
function relativeLuminance(value: string): number {
const [l, c, h] = oklch(value);
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const lms = [
l + 0.3963377774 * a + 0.2158037573 * b,
l - 0.1055613458 * a - 0.0638541728 * b,
l - 0.0894841775 * a - 1.291485548 * b,
].map((v) => v ** 3);
const rgb = [
4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2],
-1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2],
-0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.707614701 * lms[2],
].map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function contrastRatio(first: string, second: string): number {
const [lighter, darker] = [relativeLuminance(first), relativeLuminance(second)].sort((x, y) => y - x);
return (lighter + 0.05) / (darker + 0.05);
}
describe("destructive theme tokens", () => {
// Confirm buttons use `bg-destructive text-destructive-foreground`. The label
// must stay readable on the destructive fill in both themes.
// Light theme meets WCAG AA for normal text (4.5:1). The dark theme keeps the
// brighter red chosen in the gallery review, so it only has to meet 3:1.
for (const [selector, minimum] of [
[":root", 4.5],
[".dark", 3],
] as const) {
it(`keeps destructive-foreground readable on the destructive fill in ${selector}`, () => {
const block = cssBlock(selector);
const fill = token(block, "destructive");
const foreground = token(block, "destructive-foreground");
expect(contrastRatio(foreground, fill)).toBeGreaterThanOrEqual(minimum);
});
}
});