Repository navigation
Description
Context
text-primary is the theme fill color used as text. On the page, a card, muted, and the 10% / 20% primary tint that is 4.5:1 or better in System and Default, and below that in 11 of the other 14 named themes (Sunrise 3.19:1 on --background through Plum 3.30:1). #213 already moved labels on a tint or on bg-accent off text-primary. The remaining sites are still the fill color. --muted-foreground of :root (System on a light screen) is 4.34:1 on --muted and --accent, which is the language chip of a picker row at rest.
Who: signed-in users on every theme, and signed-out visitors on /login. Solo owner.
Current State
Verified 2026-10-18 on main at a5fb239 with readThemeColor / contrastRatio / paintOver from src/tests/unit/styles/theme-color-math.ts.
text-primary on --background (same pair as --card on :root):
| Selector | primary/bg | primary/muted | primary on bg-primary/10 |
primary on bg-primary/20 |
|---|---|---|---|---|
:root |
17.91 | 16.42 | 14.59 | 11.69 |
html[data-theme='default'] |
13.85 | 12.65 | 11.44 | 9.31 |
| sunrise | 3.19 | 2.91 | 2.87 | 2.57 |
| sandstone | 5.11 | 4.66 | 4.49 | 3.92 |
| mint | 3.34 | 3.04 | 2.99 | 2.67 |
| sky | 3.62 | 3.30 | 3.23 | 2.87 |
| lavender | 3.78 | 3.47 | 3.38 | 3.01 |
| rose | 4.42 | 4.09 | 3.84 | 3.31 |
.dark / named dark |
15.72 | 12.00 | 12.95 | 9.62 |
| midnight | 4.05 | 3.61 | 3.80 | 3.46 |
| graphite | 4.12 | 3.58 | 3.83 | 3.45 |
| forest | 3.59 | 3.19 | 3.38 | 3.11 |
| ocean | 4.08 | 3.62 | 3.82 | 3.47 |
| plum | 3.30 | 2.95 | 3.12 | 2.89 |
| rust | 3.43 | 3.07 | 3.24 | 3.00 |
--muted-foreground on --muted / --accent:
| Selector | mf/bg | mf/muted | mf/accent |
|---|---|---|---|
:root |
4.73 | 4.34 | 4.34 |
| named light themes | 6.20–6.82 | 5.65–6.23 | 4.69–5.19 |
.dark / named dark |
7.63 | 5.83 | 5.83 |
| other dark themes | 5.47–6.29 | 4.75–5.60 | 1.90–2.36 |
Dark-theme muted-on-accent is already out of product use: a hovered or selected picker row is text-accent-foreground (OPTION_ROW_COLOR_CLASS in AddRepositoryCombobox.tsx:60-61). accent-fill-contrast.test.ts:50-63 computes the hovered chip (accent-foreground on --muted) and no_claims the chip at rest.
Product text-primary still on a surface (this work remaps these):
| Surface | Classes | File |
|---|---|---|
| Login Terms / Privacy | text-primary hover:text-primary/80 |
src/app/login/page.tsx:52,59 |
Button link variant |
text-primary |
src/components/ui/button.tsx:21 (story only; no product caller) |
| Note editor link | text-primary decoration-primary |
src/components/ui/link-node.tsx:21 |
| Project-link URL | text-primary |
src/components/ui/editable-url-item.tsx:206 |
| Comment editor Save | text-primary hover:text-primary hover:bg-primary/10 |
src/components/Board/CommentInlineEdit.tsx:296 |
| New-row drop label while over | bg-primary/10 text-primary |
src/components/Board/NewRowDropZone.tsx:56 |
| Column-insert drop label while over | text-primary on bg-primary/20 |
src/components/Board/ColumnInsertZone.tsx:61,75 |
| Board-card title on hover | group-hover:text-primary |
src/components/Boards/BoardCard.tsx:220 |
| Radio indicator | text-primary |
src/components/ui/radio-group.tsx:41 |
| Move dialog title icon | text-primary |
src/components/Modals/MoveToAnotherBoardDialog.tsx:367 |
:root --muted-foreground is oklch(0.556 0 0) (globals.css:103). Named Default light is already oklch(0.45 0.015 75). The high-contrast media query already sets :root to oklch(0.4 0 0) and must stay.
There is no --link. --primary stays the fill (bg-primary, text-primary-foreground).
Proposed Change
One PR, PATCH 0.7.20.0. Add --link (no foreground pair; it is never a fill). Map it in @theme inline. Remap the table above to text-link / decoration-link. Darken only :root --muted-foreground so System light reaches 4.5:1 on --muted and --accent. Do not change --primary.
Implementation Details
-
Token
--link. Opaqueoklch(L C H)(no%) soreadThemeColorcan read it. C and H are that block's--primary. L is the value that first makes--link≥ 4.5:1 on--background,--card,--popover,--muted,paintOver(primary, background, 0.10), andpaintOver(primary, background, 0.20). Computed 2026-10-18; copy these bytes:Block --link:rootoklch(0.205 0 0)html[data-theme='default']oklch(0.25 0.02 70)sunrise oklch(0.48 0.18 60)sandstone oklch(0.46 0.06 50)mint oklch(0.44 0.15 158)sky oklch(0.43 0.16 220)lavender oklch(0.48 0.18 285)rose oklch(0.47 0.2 5).darkand nameddarkoklch(0.922 0 0)midnight oklch(0.62 0.2 260)graphite oklch(0.62 0.02 240)forest oklch(0.59 0.14 145)ocean oklch(0.59 0.12 200)plum oklch(0.63 0.22 300)rust oklch(0.62 0.16 40)If a listed value fails a required contrast test, change only that token's L (keep C and H) and write the new L in the PR. That corrected value meets acceptance when the required contrast tests pass. Do not invent a hue. Do not add
--link-foreground. -
Tailwind. In
src/styles/globals.css@theme inline, add--color-link: var(--link);next to--color-favorite. -
Validator. Add
--linktoREQUIRED_THEME_VARIABLESandREQUIRED_BASE_VARIABLESinscripts/validate-themes.ts. -
Muted, System light only. In
:rootonly, set--muted-foreground: oklch(0.54 0 0). That is 4.64:1 on:root--mutedand--accent(current 4.34:1 at L 0.556; L 0.54 is the first step of 0.01 that clears 4.5). Do not change named themes,.dark, or theprefers-contrast: moreblock. -
Shared class. Add
src/lib/constants/link-text.ts:export const LINK_TEXT_CLASS = 'text-link'
Use
LINK_TEXT_CLASSonly where the class string at rest is exactlytext-link(Buttonlink, comment Save, radio, move icon, URL). Hover and group-hover must be the full literal utilitieshover:text-linkandgroup-hover:text-linkwritten in source so Tailwind emits them. Do not writehover:${LINK_TEXT_CLASS}. Login:`${LINK_TEXT_CLASS} hover:text-link hover:underline`.link-node.tsx:`${LINK_TEXT_CLASS} decoration-link`(or exportLINK_DECORATION_CLASS = 'decoration-link'). Drophover:text-primary/80. Comment Save keepshover:bg-primary/10. Drop zones keepborder-primaryandbg-primary/10or/20; only the text class changes. -
Call sites. Replace
text-primarythat colors text or an icon on a surface in the table above. Leavebg-primary,text-primary-foreground,border-primary,decoration-primaryonly when it is a fill or a border (radioborder-primarystays). Leave everynot.toHaveClass('text-primary')assertion as it is. -
Contrast tests.
- Add
src/tests/unit/styles/link-color-contrast.test.tson the warning-text pattern:--linkon--background,--card,--popover,--muted; onpaintOver(primary, background, 0.10)and0.20. ThresholdAA_TEXT_CONTRAST(4.5). Radio inner dot is an icon; 4.5 coversAA_ICON_CONTRAST(3). - Add or extend a muted test (new file or a case in an existing muted test):
:root--muted-foregroundon:root--mutedand--accentis ≥ 4.5. Named themes stay as they are (do not require dark-theme muted-on-accent; that pair is unused). - Pin
LINK_TEXT_CLASS === 'text-link'.
- Add
-
Class assertions. Tests and stories that quote
text-primaryon a remapped control must quotetext-link. Search the tree. Known:button.stories.tsxLink story needs no class pin (visual).CommentInlineEdit.test.tsxcan pin the Save button class.BoardCard.test.tsxcan pingroup-hover:text-link. -
Source lock. Scan each listed file's non-comment source (skip
//and/* */lines). Fail if a Tailwind utility matches/(?:^|[\s"'])(?:hover:|group-hover:|dark:|dark:hover:)*text-primary(?!-foreground)\b/. History comments may name the old class. Files:src/app/login/page.tsx,src/components/ui/button.tsx,src/components/ui/link-node.tsx,src/components/ui/editable-url-item.tsx,src/components/Board/CommentInlineEdit.tsx,src/components/Board/NewRowDropZone.tsx,src/components/Board/ColumnInsertZone.tsx,src/components/Boards/BoardCard.tsx,src/components/ui/radio-group.tsx,src/components/Modals/MoveToAnotherBoardDialog.tsx. Put this next totheme-token-call-sites.test.ts` (same file or a sibling). -
Docs. In
CLAUDE.md/AGENTS.md(symlink) under "14 Theme System", say theme-colored text on a surface istext-link, nottext-primary, and that System light--muted-foregroundis the readable muted. Point at the new contrast tests. Do not citeTODOS.md. InTODOS.md, delete the heading Stop using the theme color and the muted color where they cannot be read and that heading's body up to but not including the next###. Leave the other headings. -
E2E.
e2e/unauthenticated/login.spec.ts(--project=unauthenticated): Terms of Use and Privacy Policy are visible and havetext-link. Paintedcolorof either link matches a probe withcolor: var(--link)via a 2d canvasfillStylehex (same helper as the favorite star: Chromium may reportoklabvslab). Do not click through to GitHub in a way that leaves the app. Do not Sign Out (this project is already signed out).e2e/logged-in/shipped-todo-coverage.spec.ts(--project=logged-in): on/boards, hover a seed board heading untiltransition-colorsends (expect.poll); painted color matches--linkby the same canvas hex. Open the seed board, open a comment editor, assert Save (data-testid="comment-save-btn") hastext-link, press Escape, do not save. Do not Sign Out. Do not confirm Delete Account.
-
Live check as the GitHub account
ryota-murakami. "Readable" for a title or Save means the paintedcolormatches avar(--link)probe by the same canvas hex as E2E. The language chip is a visual read; the 4.5 number is the unit test.Local
http://localhost:3008before merge: sign in. If the owner has no board with cards (an E2E reset emptied the local database), add repositories of this account from the picker (cards in at least two columns) as setup, then do the reads. On System: hover a board name (hex match); open that board, open a comment, Save hex-matches--link, Escape, no save; open Add Repositories and read a language chip (do not add). Switch to Sunrise, hover a board name and confirm Save still hex-matches, then switch back to System. Do not leave Sunrise stored.Production after deploy: do not change the stored theme. Same reads on an existing board with cards (Laststance is read-only) in whatever theme the account already has. Do not favorite, rename, move, or delete Laststance / Hobby / Complete / Practice. A write is not required on production. Do not Sign Out.
-
Version.
VERSION/CHANGELOG0.7.20.0PATCH. Changelog must not citeTODOS.md. Co-author trailer on the VERSION commit only:Co-Authored-By: Claude <noreply@anthropic.com>. The implementation commit has no trailer. -
Regression commands that must exit 0:
pnpm test,pnpm lint,pnpm typecheck,pnpm build.pnpm e2e:parallelexit 0; if local port 3008 is busy, CI on the PR is the e2e gate. Existing contrast tests (destructive, warning, favorite, accent, sidebar, selected-repository badge) stay green.
Acceptance Criteria
:root,.dark, and all 14 named themes define--linkas the opaqueoklchin the table, or a same-hue L recorded in the PR that the required contrast tests accept.@theme inlineexposestext-linkanddecoration-link.pnpm validate:themespasses with--linkrequired.- Every row in the call-site table uses
text-link(anddecoration-linkon the note link). Hover istext-link, not/80. :root --muted-foregroundisoklch(0.54 0 0). Named themes and.darkmuted tokens are unchanged.link-color-contrast.test.tspasses on everyTHEME_SELECTORSentry for the surfaces and tints listed.:rootmuted-foreground on muted and accent is ≥ 4.5.- Source lock fails if a listed file regains
text-primary(excepttext-primary-foreground). - Unauthenticated E2E: login links class + painted
--link. Logged-in E2E: hovered board title painted--link; comment Savetext-link; Escape, no save. - Local and production checks as
ryota-murakamion a board with cards. Do not Sign Out. Do not delete Laststance / Hobby / Complete / Practice. - Each new or retargeted test states:
protects=<observable it protects>; fails_when=<wrong result it rejects>; no_claim=<what green does not prove; which fixtures or mocks stand in for what, or "none beyond protects">. pnpm test,pnpm lint,pnpm typecheck, andpnpm buildexit 0. Existing contrast tests listed in step 14 stay green.
Testing Plan
| Layer | What | Count |
|---|---|---|
| Unit | --link on surfaces + primary tints for every THEME_SELECTORS; :root muted pair |
+1 file, +1 muted case |
| Unit | LINK_TEXT_CLASS pin; Save / board-card class pins; source lock |
update existing + lock |
| E2E | Login links class + canvas hex; hovered board title canvas hex; comment Save class | +3 cases |
| Live | Owner account local then production, read-only | 2 |
Rules for the implementing agent
- Acceptance criteria are the
protects / fails_when / no_claimprofile of each test: the observable it protects, the wrong result it rejects, and what green does not prove. Never "make the tests pass". - Real code and real tests land in the same change; a placeholder, a stub assertion or a test deferred to a follow-up is not done.
- Any change to a test, validator, CI step, timeout, tolerance, threshold, snapshot or suppression pragma is reported on its own line in your summary with its justification, before any other result.
- A reported command names the command and its observed result (exit status, counts, the failing line); a command you did not run is not reported, and stderr is never silenced in a command you cite.
- Your report is a claim until the parent re-executes it: say what you verified, what you reused, and what you could not check.
Rollback Plan
Revert the PR. --link and the remapped classes go away. :root --muted-foreground returns to oklch(0.556 0 0). No migration.
Effort Estimate
~1h tokens + validator + muted L, ~1h call sites, ~1.5h contrast and class tests, ~0.5h E2E, ~0.5h live checks.
Files Reference
| File | Change |
|---|---|
src/styles/globals.css |
@theme inline map; :root / .dark --link; :root --muted-foreground L 0.54 |
src/styles/themes.css |
--link on each of 14 themes |
scripts/validate-themes.ts |
require --link |
src/lib/constants/link-text.ts |
new |
src/app/login/page.tsx:52,59 |
text-link |
src/components/ui/button.tsx:21 |
link variant text-link |
src/components/ui/link-node.tsx:21 |
text-link decoration-link |
src/components/ui/editable-url-item.tsx:206 |
text-link |
src/components/Board/CommentInlineEdit.tsx:296 |
Save text-link |
src/components/Board/NewRowDropZone.tsx:56 |
over label text-link |
src/components/Board/ColumnInsertZone.tsx:75 |
over label text-link |
src/components/Boards/BoardCard.tsx:220 |
group-hover:text-link |
src/components/ui/radio-group.tsx:41 |
text-link |
src/components/Modals/MoveToAnotherBoardDialog.tsx:367 |
icon text-link |
src/tests/unit/styles/link-color-contrast.test.ts |
new |
CLAUDE.md |
14 Theme System |
TODOS.md |
delete only this heading |
Out of Scope
- Landing (
HeroSection,FeaturesSection,HowItWorksSection,KanbanPreview) - Comment-color amber palette, Sonner
--warning-text - Changing
--primaryor--primary-foreground(fills stay) - Dark-theme
--muted-foregroundon--accent(unused; rows already usetext-accent-foreground) - Named-theme
--muted-foreground(already ≥ 4.5 on muted) --linkas a fill; no--link-foreground- Smaller design gaps heading (
transition-all, sidebar width,/settingssidebar, menu separators, picker native select, star-count emoji) - Sign Out, account deletion, writes on Laststance / Hobby / Complete / Practice
Related
- Linked to the 2026-10-12 design review leftover (P3). Warning / favorite tokens shipped in v0.7.19.0 via v0.7.19.0 feat: add warning and favorite theme tokens #329.
Activity
ryota-murakami commentedon Oct 18, 2026
Shipped in v0.7.20.0 via #331. Production verify as ryota-murakami is next.