refactor(app): unify status colours on one token family
Three systems were reporting the same states: a full and a muted status tier in the theme, plus raw palette steps in StatusBadge and getStatusDotColor. A PR icon was muted in the sidebar and full-strength in the PR panel, and the Resolved badge used a theme-independent green so it painted a dark block in light mode. The muted tier is deleted and every status surface now reads the one token per signal. Dots keep a band of their own. A dot is a few points of solid colour with no shape to read and no label attached, and the running one pulses, so at the status band's lightness it sat below the check icons and host badge on its own row — backwards, since the dot is the row's state. Chroma caps at 90% of gamut rather than 100% because at the edge green reaches C=0.215 while blue manages 0.116 and the set stops reading as one family; dark lightness caps at 0.72 because red desaturates to pink above it. Host badges needed a foreground variant of the identity table. That table is tuned for a white letter on the colour as a fill; used as text it cannot clear 4.5:1 on both themes at once, since light needs luminance <= 0.183 and dark needs >= 0.212. The new set is generated on the status family's exact lightness and chroma fraction, so a host name and a CI check in the same meta row carry equal weight instead of the brighter one winning the row. Also: PR avatars had their own eight-hex palette and hash duplicating identity-colors; workspace rows under a project group now indent to the same rail as status-grouped rows; group headers are muted; and the pulsing dot is faster and fades further.
This commit is contained in:
@@ -113,9 +113,8 @@ export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "gho
|
||||
// survive being scanned line by line, so it stays saturated. Light uses muted tones, dark
|
||||
// uses the brighter palette values.
|
||||
//
|
||||
// A diff *stat* — the "+12 −3" footnote next to a title — is not this. It reads as one of
|
||||
// several muted signals in a row, so it uses statusMutedSuccess/Danger below rather than a
|
||||
// near-duplicate tier of its own.
|
||||
// A diff *stat* — the "+12 −3" footnote next to a title — is not this. It is a status
|
||||
// signal, so it uses statusSuccess/statusDanger below rather than a tier of its own.
|
||||
const lightDiffColors = {
|
||||
diffAddition: "#15803d", // green-700 — readable on white without screaming
|
||||
diffDeletion: "#b91c1c", // red-700
|
||||
@@ -126,47 +125,76 @@ const darkDiffColors = {
|
||||
diffDeletion: "#ef4444", // red-500
|
||||
};
|
||||
|
||||
// Status colors — semantic signals for success/danger/warning/merged. Used by check
|
||||
// statuses, PR states, and review decisions.
|
||||
// Status colors — semantic signals for success/danger/warning/merged. There is exactly one
|
||||
// token per signal, and every status surface uses it: PR state icons, CI check icons and
|
||||
// pies, diff stats, file-change icons, status badges, usage bars. Status *dots* are the
|
||||
// exception and have their own band below. A surface does not otherwise get a quieter or
|
||||
// louder variant of a status color because of where it sits — if a dense list feels loud,
|
||||
// that is a density or weight problem, not a color problem.
|
||||
//
|
||||
// Normalized, not hand-picked. Every color below shares one lightness and one chroma within
|
||||
// its tier; only the hue changes, and each hue is the one that family already had. Chroma is
|
||||
// a fixed fraction of what sRGB allows at that lightness and hue, because the gamut is
|
||||
// lopsided — amber runs out of room long before red does, so a literal equal-chroma set
|
||||
// leaves amber flat and red screaming. Equal fractions is what makes four hues read as one
|
||||
// family. Regenerate with the same rule rather than nudging one value.
|
||||
// The level is set by the densest consumer, the sidebar workspace list: quiet enough that a
|
||||
// column of green checks reads as one line of subtitle and the single red row still stands
|
||||
// out, saturated enough to name the state on its own. Every other surface follows it.
|
||||
//
|
||||
// Two tiers. Full is for a surface whose job is to report the status — the PR pane, a usage
|
||||
// bar. Muted is for the same signal repeated on every row of a dense list, where full
|
||||
// saturation makes the list itself the loudest thing on screen; it sits at roughly
|
||||
// foregroundMuted's weight so a row built from it reads as one line of subtitle.
|
||||
// Normalized, not hand-picked. Every color below shares one lightness and one chroma; only
|
||||
// the hue changes, and each hue is the one that family already had. Chroma is a fixed
|
||||
// fraction of what sRGB allows at that lightness and hue, because the gamut is lopsided —
|
||||
// amber runs out of room long before red does, so a literal equal-chroma set leaves amber
|
||||
// flat and red screaming. Equal fractions is what makes four hues read as one family.
|
||||
// Regenerate with the same rule rather than nudging one value.
|
||||
//
|
||||
// Deliberately not on this scale: the workspace status dot. That one is supposed to shout,
|
||||
// so it keeps the raw palette — see getStatusDotColor.
|
||||
// Hues are fixed per family across both themes: success 150, danger 27, warning 70.5,
|
||||
// merged 300.
|
||||
const lightStatusColors = {
|
||||
// L=0.53, chroma 90% of gamut max
|
||||
statusSuccess: "#1f8040",
|
||||
statusDanger: "#c32424",
|
||||
statusWarning: "#91601b",
|
||||
statusMerged: "#8728e5",
|
||||
// L=0.50, chroma 45% of gamut max
|
||||
statusMutedSuccess: "#496d50",
|
||||
statusMutedDanger: "#904d46",
|
||||
statusMutedWarning: "#755f45",
|
||||
statusMutedMerged: "#6e519d",
|
||||
// L=0.50, chroma 60% of gamut max
|
||||
statusSuccess: "#3e704a",
|
||||
statusDanger: "#9d433b",
|
||||
statusWarning: "#7b5d39",
|
||||
statusMerged: "#7347af",
|
||||
};
|
||||
|
||||
const darkStatusColors = {
|
||||
// L=0.66, chroma 90% of gamut max
|
||||
statusSuccess: "#2ead58",
|
||||
statusDanger: "#f65048",
|
||||
statusWarning: "#c38328",
|
||||
statusMerged: "#a670f5",
|
||||
// L=0.70, chroma 40% of gamut max
|
||||
statusMutedSuccess: "#7cac85",
|
||||
statusMutedDanger: "#ca8c85",
|
||||
statusMutedWarning: "#b79875",
|
||||
statusMutedMerged: "#a594c6",
|
||||
// L=0.70, chroma 55% of gamut max
|
||||
statusSuccess: "#6cb17b",
|
||||
statusDanger: "#d8847b",
|
||||
statusWarning: "#c09664",
|
||||
statusMerged: "#a890d5",
|
||||
};
|
||||
|
||||
// Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in
|
||||
// for them. Same four hues and the same generation rule as the status colors above, but its
|
||||
// own band, because a dot is doing a different job than a check icon or a host badge.
|
||||
//
|
||||
// A dot is 6-8pt of solid color with no shape to read and no label attached, and the running
|
||||
// one pulses down to PULSE_MIN_OPACITY for half of every cycle. At the status band's lightness
|
||||
// the dots read dimmer than the static text and icons beside them on the same row, which is
|
||||
// backwards — the dot is the row's state. So the band is pushed away from the surface rather
|
||||
// than toward it (darker in light, lighter in dark) and carries more chroma: 90% of gamut max
|
||||
// against the status family's 55-60%.
|
||||
//
|
||||
// All four move together. A dot matching its siblings in lightness and chroma says only which
|
||||
// state the row is in; one that does not says "this row matters more", which is a claim the
|
||||
// color has no business making. Regenerate the set, never one hue.
|
||||
//
|
||||
// 90% and not 100%: at the gamut edge the lopsidedness is worst — green reaches C=0.215 while
|
||||
// blue manages 0.116 — so the set stops reading as one family and green wins. Red running out
|
||||
// of chroma as lightness climbs is what caps the dark band at L=0.72; higher turns the failed
|
||||
// dot pink. Running is blue at hue 250, clear of identity-colors' blue at 256.6 so a blue host
|
||||
// badge and a working dot on the same row do not read as related.
|
||||
const lightStatusDotColors = {
|
||||
// L=0.46, chroma 90% of gamut max
|
||||
statusDotSuccess: "#186933",
|
||||
statusDotDanger: "#a11c1c",
|
||||
statusDotWarning: "#774e14",
|
||||
statusDotRunning: "#165a96",
|
||||
};
|
||||
|
||||
const darkStatusDotColors = {
|
||||
// L=0.72, chroma 90% of gamut max
|
||||
statusDotSuccess: "#35c264",
|
||||
statusDotDanger: "#f7796d",
|
||||
statusDotWarning: "#db932e",
|
||||
statusDotRunning: "#5caaf6",
|
||||
};
|
||||
|
||||
// Semantic color tokens - Layer-based system
|
||||
@@ -221,6 +249,7 @@ const lightSemanticColors = {
|
||||
|
||||
...lightDiffColors,
|
||||
...lightStatusColors,
|
||||
...lightStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
background: "#ffffff",
|
||||
@@ -337,6 +366,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
|
||||
...darkDiffColors,
|
||||
...darkStatusColors,
|
||||
...darkStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
background: tint.surface0,
|
||||
|
||||
Reference in New Issue
Block a user