Files
coder/site/src/index.css
T
Jaayden Halko 6149fc3619 feat(site): add colorblind-friendly themes for protan/deuter and tritan (#24672)
This is part 1 to lay the foundation for the theme changes.

Part 2 which adds the UI implementation is in this PR,
https://github.com/coder/coder/pull/24680

-----------------

Adds four sitewide colorblind-friendly theme palettes alongside the
existing
light and dark themes. The palettes retune the red/green and blue/yellow
semantic axes so success/error, warning, and diff additions/deletions
remain distinguishable under the most common color vision deficiencies.

| Preference ID | Purpose |
|---|---|
| `dark-protan-deuter` / `light-protan-deuter` | Protanopia &
deuteranopia (red/green). Success and additions shift to sky-blue;
destructive and deletions shift to vermilion/orange; warning shifts to
fuchsia so warning and destructive states do not collapse onto the same
hue. |
| `dark-tritan` / `light-tritan` | Tritanopia (blue/yellow). Warning
shifts from amber to fuchsia; red/green semantic pair is preserved. |

The diff panel and every semantic role (`success`, `error`, `warning`,
`notice`, `danger`) pick up the new palette automatically because they
consume the sitewide CSS variables in `site/src/index.css`. No backend
or database change is required: `theme_preference` is already a
free-form `text` column.

The existing `"dark"`, `"light"`, and `"auto"` preferences are
unchanged.

This PR ships the palettes and the resolution machinery
(`CONCRETE_THEMES`, `resolveThemeName`, `isConcreteThemeName`, and the
ThemeProvider/AgentEmbedPage plumbing). It intentionally does **not**
add UI to select the new themes; the follow-up PR #24680 adds a Theme
mode dropdown (Sync with system / Single theme) that exposes every
concrete theme, including the four added here.

Produced with Coder Agents assistance.

<details>
<summary>Implementation plan and decision log</summary>

Full plan (investigation, file layout, TDD phases, open risks) is
attached to the chat that produced this PR.

Key decisions:

- **Sitewide, not agent-scoped.** The diff panel already consumes the
sitewide theme via CSS variables. Keeping the change at the user
appearance layer also fixes red/green accents in alerts, badges, and
build states in one change, and matches how comparable products (e.g.
GitHub) ship this feature.
- **No backend change.** `codersdk/users.go` already accepts any
`theme_preference` string, and `ThemeProvider` now has a shared resolver
(`resolveThemeName`) that maps any persisted value to a concrete theme,
tolerating unknowns and the legacy `"auto"` value.
- **Palette provenance.** The protan/deuter palette is inspired by
CVD-safe blue/orange palettes and tuned within the existing Tailwind
color scales; the tritan palette keeps red/green semantics intact and
shifts warning to fuchsia. This PR does not claim exact Okabe-Ito or
WCAG AA derivation without recorded contrast data.
- **UI deferred.** Selecting the new themes is gated on the follow-up PR
#24680 which replaces the flat theme grid with a Theme mode dropdown.

</details>
2026-05-04 14:02:58 +01:00

370 lines
11 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
To enable opacity in colors, please use the new HSL format.
Related issue: https://github.com/shadcn-ui/ui/issues/805#issuecomment-1616021820
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "seti";
src: url("/seti.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer components {
@media (max-width: 767px) {
/*
Full-width mobile dropdowns. We set a --mobile-dropdown-bottom
custom property on the chat input container so the dropdown
position tracks the actual input box, not a hardcoded offset.
*/
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) {
position: fixed !important;
left: 1rem !important;
width: calc(100vw - 2rem) !important;
min-width: 0 !important;
transform: none !important;
bottom: var(--mobile-dropdown-bottom, 5rem) !important;
top: auto !important;
}
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown-top) {
bottom: auto !important;
top: var(--mobile-dropdown-top, 3.5rem) !important;
}
[data-radix-popper-content-wrapper]:has(
> .mobile-full-width-dropdown-top-below-header
) {
bottom: auto !important;
top: 5rem !important;
}
.mobile-full-width-dropdown {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
}
}
}
@layer base {
:root,
.light {
--content-primary: 240 10% 4%;
--content-secondary: 240 5% 34%;
--content-link: 221 83% 53%;
--content-invert: 0 0% 98%;
--content-disabled: 240 5% 65%;
--content-success: 142 72% 29%;
--content-warning: 27 96% 61%;
--content-destructive: 0 84% 60%;
--surface-primary: 0 0% 100%;
--surface-secondary: 240 5% 96%;
--surface-tertiary: 240 6% 90%;
--surface-quaternary: 240 5% 84%;
--surface-invert-primary: 240 4% 16%;
--surface-invert-secondary: 240 5% 26%;
--surface-destructive: 0 93% 94%;
--surface-green: 141 79% 85%;
--surface-grey: 240 5% 96%;
--surface-orange: 34 100% 92%;
--surface-sky: 191, 67%, 89%;
--surface-red: 0 93% 94%;
--surface-purple: 259, 100%, 95%;
--surface-magenta: 289 100% 98%;
--border-purple: 255 92% 76%;
--border-default: 240 6% 90%;
--border-success: 142 76% 36%;
--border-sky: 203 90% 40%;
--border-green: 138 82% 82%;
--border-magenta: 295, 68%, 46%;
--border-warning: 30.66, 97.16%, 72.35%;
--border-destructive: 0 84% 60%;
--border-secondary: 240 5% 65%;
--overlay-default: 240 5% 84% / 80%;
--radius: 0.5rem;
--highlight-purple: 271, 61%, 35%;
--highlight-green: 143 64% 24%;
--highlight-orange: 30 100% 32%;
--highlight-sky: 195, 61%, 22%;
--highlight-red: 0 74% 42%;
--highlight-magenta: 295, 68%, 40%;
--syntax-key: 211 95% 33%;
--syntax-string: 0 77% 36%;
--syntax-number: 158 88% 28%;
--syntax-boolean: 240 100% 50%;
--git-added: 142 72% 29%;
--git-deleted: 0 74% 42%;
--git-modified: 17 88% 40%;
--git-merged: 271 91% 65%;
--git-added-bright: 142 72% 29%;
--git-deleted-bright: 0 74% 42%;
--git-merged-bright: 272 72% 47%;
--surface-git-added: 141 84% 93%;
--surface-git-deleted: 0 93% 94%;
--surface-git-merged: 269 100% 95%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
/*
shadcn-compatible aliases consumed by streamdown and
internal components (e.g. shimmer). These names are
intentionally generic so they match shadcn conventions.
Do NOT rename without updating every consumer.
*/
--background: var(--surface-primary);
--foreground: var(--content-primary);
--muted: var(--surface-secondary);
--muted-foreground: var(--content-secondary);
--primary: var(--content-link);
--primary-foreground: var(--surface-primary);
--avatar-lg: 2.5rem;
--avatar-default: 2rem;
--avatar-sm: 1.5rem;
}
.dark {
--content-primary: 0 0% 100%;
--content-secondary: 240 5% 65%;
--content-link: 213 94% 68%;
--content-invert: 240 10% 4%;
--content-disabled: 240 5% 26%;
--content-success: 142 76% 36%;
--content-warning: 31 97% 72%;
--content-destructive: 0 91% 71%;
--surface-primary: 240 10% 4%;
--surface-secondary: 240 6% 10%;
--surface-tertiary: 240 4% 16%;
--surface-quaternary: 240 5% 26%;
--surface-invert-primary: 240 6% 90%;
--surface-invert-secondary: 240 5% 65%;
--surface-destructive: 0 75% 15%;
--surface-green: 145 80% 10%;
--surface-grey: 240 6% 10%;
--surface-orange: 13 81% 15%;
--surface-sky: 196, 67%, 12%;
--surface-red: 0 75% 15%;
--surface-purple: 268, 68%, 17%;
--surface-magenta: 291, 74%, 15%;
--border-purple: 255 92% 76%;
--border-default: 240 4% 16%;
--border-success: 142 76% 36%;
--border-magenta: 292, 100%, 78%;
--border-warning: 30.66, 97.16%, 72.35%;
--border-destructive: 0 91% 71%;
--border-sky: 194 90% 62%;
--border-green: 143 77% 87%;
--border-secondary: 240 5% 26%;
--overlay-default: 240 10% 4% / 80%;
--highlight-purple: 269, 100%, 74%;
--highlight-green: 141 79% 85%;
--highlight-orange: 31 100% 70%;
--highlight-sky: 188, 75%, 80%;
--highlight-red: 0 91% 71%;
--highlight-magenta: 292, 100%, 78%;
--syntax-key: 201 98% 80%;
--syntax-string: 18 47% 64%;
--syntax-number: 100 28% 73%;
--syntax-boolean: 207 61% 59%;
--git-added: 142 77% 73%;
--git-deleted: 0 94% 82%;
--git-modified: 31 97% 72%;
--git-merged: 271 91% 65%;
--git-added-bright: 142 71% 45%;
--git-deleted-bright: 0 91% 71%;
--git-merged-bright: 270 95% 75%;
--surface-git-added: 145 80% 10%;
--surface-git-deleted: 0 75% 15%;
--surface-git-merged: 274 87% 21%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
/* shadcn-compatible aliases – see :root block for details. */
--background: var(--surface-primary);
--foreground: var(--content-primary);
--muted: var(--surface-secondary);
--muted-foreground: var(--content-secondary);
--primary: var(--content-link);
--primary-foreground: var(--surface-primary);
}
/*
Colorblind-friendly variants. ThemeProvider applies these classes
alongside the base mode class (`dark` or `light`), so unchanged
variables cascade from the base mode and each block only overrides
the semantic colors that shift for colorblind accessibility.
Palette rationale:
- dark-protan-deuter / light-protan-deuter: shift the red/green
success+error axis onto sky-blue (success) + vermilion/orange
(destructive). Warning shifts to fuchsia so it does not collide
with destructive states on the orange axis.
- dark-tritan / light-tritan: keep the red/green success+error
axis intact and move warning from amber to fuchsia because
amber and sky-blue blur together under tritanopia.
*/
.light-protan-deuter {
--content-success: 199 89% 48%;
--content-warning: 322 81% 43%;
--content-destructive: 24 95% 53%;
--surface-destructive: 34 100% 92%;
--surface-green: 201 94% 86%;
--surface-orange: 289 100% 98%;
--surface-red: 34 100% 92%;
--border-success: 199 89% 48%;
--border-green: 201 94% 86%;
--border-warning: 322 81% 60%;
--border-destructive: 24 95% 53%;
--highlight-green: 201 94% 36%;
--highlight-orange: 322 81% 43%;
--highlight-red: 24 95% 42%;
--syntax-string: 24 95% 42%;
--syntax-number: 199 89% 38%;
--git-added: 199 89% 48%;
--git-deleted: 24 95% 53%;
--git-modified: 271 91% 45%;
--git-added-bright: 199 89% 48%;
--git-deleted-bright: 24 95% 53%;
--surface-git-added: 204 94% 94%;
--surface-git-deleted: 33 100% 93%;
}
.dark-protan-deuter {
--content-success: 199 82% 67%;
--content-warning: 322 81% 67%;
--content-destructive: 27 96% 67%;
--surface-destructive: 17 75% 15%;
--surface-green: 201 80% 14%;
--surface-orange: 322 70% 15%;
--surface-red: 17 75% 15%;
--border-success: 199 82% 67%;
--border-warning: 322 81% 67%;
--border-destructive: 27 96% 67%;
--border-green: 201 94% 86%;
--highlight-green: 201 94% 86%;
--highlight-orange: 322 81% 67%;
--highlight-red: 27 96% 67%;
--git-added: 199 82% 67%;
--git-deleted: 27 96% 67%;
--git-modified: 271 91% 65%;
--git-added-bright: 199 89% 48%;
--git-deleted-bright: 24 95% 53%;
--surface-git-added: 201 80% 14%;
--surface-git-deleted: 17 75% 15%;
}
.light-tritan {
--content-warning: 322 81% 43%;
--surface-orange: 289 100% 98%;
--border-warning: 322 81% 60%;
--highlight-orange: 322 81% 43%;
--highlight-magenta: 322, 81%, 43%;
--syntax-boolean: 322 81% 43%;
--git-modified: 322 81% 43%;
}
.dark-tritan {
--content-warning: 322 81% 67%;
--surface-orange: 322 70% 15%;
--surface-magenta: 322 70% 15%;
--border-magenta: 322 81% 72%;
--border-warning: 322 81% 67%;
--highlight-orange: 322 81% 67%;
--highlight-magenta: 322 81% 72%;
--syntax-boolean: 322 81% 67%;
--git-modified: 322 81% 72%;
}
}
@layer base {
* {
@apply border-border;
}
html,
body,
#root,
#storybook-root {
@apply h-full;
}
button,
input {
@apply font-sans;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 100px hsl(var(--surface-primary)) inset !important;
}
::placeholder {
@apply text-content-secondary;
}
fieldset {
@apply border-none p-0 m-0 w-full;
}
/* Global scrollbar color baseline: surface-quaternary thumb on
transparent track. scrollbar-color is inherited, so this cascades
to all scroll containers. We intentionally do NOT set
scrollbar-width globally because any non-auto value opts out of
overlay scrollbars on macOS/Chromium, causing a permanent gutter
on every container (even when content does not overflow). */
:root {
scrollbar-color: hsl(var(--surface-quaternary)) transparent;
}
/* Prevent layout shift when modals open by maintaining scrollbar width.
scrollbar-gutter: stable reserves space for the scrollbar so Radix's
scroll-bar compensation (margin-right/padding-right) is unnecessary
and causes double spacing. We zero it out with !important to win
over Radix's injected !important styles. The @supports guard ensures
we only do this on browsers that actually support scrollbar-gutter.
There’s a related issue on GitHub: Radix UI Primitives Issue #3251
https://github.com/radix-ui/primitives/issues/3251
*/
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable;
}
html body[data-scroll-locked] {
--removed-body-scroll-bar-size: 0 !important;
margin-right: 0 !important;
overflow-y: scroll !important;
}
}
/*
This is a temporary fix for MUI Modals/Popovers until they are removed.
When html has scrollbar-gutter: stable, the browser reserves space for the scrollbar.
MUI Modals/Popovers, when locking body scroll, add `overflow: hidden` and `padding-right`
to the body to compensate for the scrollbar they are hiding. This added padding-right
conflicts with the already reserved gutter space, causing a layout shift.
This rule overrides MUI's added padding-right on the body specifically when MUI
is likely to have set both overflow:hidden and padding-right.
*/
body[style*="overflow: hidden"][style*="padding-right"] {
padding-right: 0px;
}
}
@layer utilities {
.text-xs-mono {
@apply text-xs font-mono font-medium;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}