Skip to content

Stop using the theme color and the muted color where they cannot be read #330

Closed

Description

@ryota-murakami

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

  1. Token --link. Opaque oklch(L C H) (no %) so readThemeColor can 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), and paintOver(primary, background, 0.20). Computed 2026-10-18; copy these bytes:

    Block --link
    :root oklch(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)
    .dark and named dark oklch(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.

  2. Tailwind. In src/styles/globals.css @theme inline, add --color-link: var(--link); next to --color-favorite.

  3. Validator. Add --link to REQUIRED_THEME_VARIABLES and REQUIRED_BASE_VARIABLES in scripts/validate-themes.ts.

  4. Muted, System light only. In :root only, set --muted-foreground: oklch(0.54 0 0). That is 4.64:1 on :root --muted and --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 the prefers-contrast: more block.

  5. Shared class. Add src/lib/constants/link-text.ts:

    export const LINK_TEXT_CLASS = 'text-link'

    Use LINK_TEXT_CLASS only where the class string at rest is exactly text-link (Button link, comment Save, radio, move icon, URL). Hover and group-hover must be the full literal utilities hover:text-link and group-hover:text-link written in source so Tailwind emits them. Do not write hover:${LINK_TEXT_CLASS}. Login: `${LINK_TEXT_CLASS} hover:text-link hover:underline`. link-node.tsx: `${LINK_TEXT_CLASS} decoration-link` (or export LINK_DECORATION_CLASS = 'decoration-link'). Drop hover:text-primary/80. Comment Save keeps hover:bg-primary/10. Drop zones keep border-primary and bg-primary/10 or /20; only the text class changes.

  6. Call sites. Replace text-primary that colors text or an icon on a surface in the table above. Leave bg-primary, text-primary-foreground, border-primary, decoration-primary only when it is a fill or a border (radio border-primary stays). Leave every not.toHaveClass('text-primary') assertion as it is.

  7. Contrast tests.

    • Add src/tests/unit/styles/link-color-contrast.test.ts on the warning-text pattern: --link on --background, --card, --popover, --muted; on paintOver(primary, background, 0.10) and 0.20. Threshold AA_TEXT_CONTRAST (4.5). Radio inner dot is an icon; 4.5 covers AA_ICON_CONTRAST (3).
    • Add or extend a muted test (new file or a case in an existing muted test): :root --muted-foreground on :root --muted and --accent is ≥ 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'.
  8. Class assertions. Tests and stories that quote text-primary on a remapped control must quote text-link. Search the tree. Known: button.stories.tsx Link story needs no class pin (visual). CommentInlineEdit.test.tsx can pin the Save button class. BoardCard.test.tsx can pin group-hover:text-link.

  9. 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 to theme-token-call-sites.test.ts` (same file or a sibling).

  10. Docs. In CLAUDE.md / AGENTS.md (symlink) under "14 Theme System", say theme-colored text on a surface is text-link, not text-primary, and that System light --muted-foreground is the readable muted. Point at the new contrast tests. Do not cite TODOS.md. In TODOS.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.

  11. E2E.

    • e2e/unauthenticated/login.spec.ts (--project=unauthenticated): Terms of Use and Privacy Policy are visible and have text-link. Painted color of either link matches a probe with color: var(--link) via a 2d canvas fillStyle hex (same helper as the favorite star: Chromium may report oklab vs lab). 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 until transition-colors ends (expect.poll); painted color matches --link by the same canvas hex. Open the seed board, open a comment editor, assert Save (data-testid="comment-save-btn") has text-link, press Escape, do not save. Do not Sign Out. Do not confirm Delete Account.
  12. Live check as the GitHub account ryota-murakami. "Readable" for a title or Save means the painted color matches a var(--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:3008 before 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.

  13. Version. VERSION / CHANGELOG 0.7.20.0 PATCH. Changelog must not cite TODOS.md. Co-author trailer on the VERSION commit only: Co-Authored-By: Claude <noreply@anthropic.com>. The implementation commit has no trailer.

  14. Regression commands that must exit 0: pnpm test, pnpm lint, pnpm typecheck, pnpm build. pnpm e2e:parallel exit 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

  1. :root, .dark, and all 14 named themes define --link as the opaque oklch in the table, or a same-hue L recorded in the PR that the required contrast tests accept.
  2. @theme inline exposes text-link and decoration-link.
  3. pnpm validate:themes passes with --link required.
  4. Every row in the call-site table uses text-link (and decoration-link on the note link). Hover is text-link, not /80.
  5. :root --muted-foreground is oklch(0.54 0 0). Named themes and .dark muted tokens are unchanged.
  6. link-color-contrast.test.ts passes on every THEME_SELECTORS entry for the surfaces and tints listed. :root muted-foreground on muted and accent is ≥ 4.5.
  7. Source lock fails if a listed file regains text-primary (except text-primary-foreground).
  8. Unauthenticated E2E: login links class + painted --link. Logged-in E2E: hovered board title painted --link; comment Save text-link; Escape, no save.
  9. Local and production checks as ryota-murakami on a board with cards. Do not Sign Out. Do not delete Laststance / Hobby / Complete / Practice.
  10. 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">.
  11. pnpm test, pnpm lint, pnpm typecheck, and pnpm build exit 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

  1. Acceptance criteria are the protects / fails_when / no_claim profile of each test: the observable it protects, the wrong result it rejects, and what green does not prove. Never "make the tests pass".
  2. 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.
  3. 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.
  4. 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.
  5. 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 --primary or --primary-foreground (fills stay)
  • Dark-theme --muted-foreground on --accent (unused; rows already use text-accent-foreground)
  • Named-theme --muted-foreground (already ≥ 4.5 on muted)
  • --link as a fill; no --link-foreground
  • Smaller design gaps heading (transition-all, sidebar width, /settings sidebar, menu separators, picker native select, star-count emoji)
  • Sign Out, account deletion, writes on Laststance / Hobby / Complete / Practice

Related

Activity

  1. ryota-murakami commented on Oct 18, 2026

    @ryota-murakami
    ContributorAuthor

    Shipped in v0.7.20.0 via #331. Production verify as ryota-murakami is next.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions