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:
@@ -22,12 +22,7 @@ import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
|
||||
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { Theme } from "@/styles/theme";
|
||||
const ThemedSquarePlus = withUnistyles(SquarePlus);
|
||||
const ThemedSquareMinus = withUnistyles(SquareMinus);
|
||||
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
useHostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
|
||||
if (status === "online") return theme.colors.palette.green[400];
|
||||
if (status === "connecting") return theme.colors.palette.amber[500];
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
|
||||
export function HostStatusDot({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const status = useHostRuntimeConnectionStatus(serverId);
|
||||
const backgroundColor = hostStatusDotColor(status, theme);
|
||||
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
|
||||
|
||||
return <View style={dotStyle} />;
|
||||
return <View style={[styles.dot, statusStyle(status)]} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
function statusStyle(status: HostRuntimeConnectionStatus) {
|
||||
if (status === "online") return styles.dotOnline;
|
||||
if (status === "connecting") return styles.dotConnecting;
|
||||
return styles.dotOffline;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
dotOnline: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
dotConnecting: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
dotOffline: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -105,6 +105,7 @@ import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
sidebarWorkspaceRowStyles,
|
||||
SidebarWorkspaceShortcutBadge,
|
||||
resolveTrailingActionVisibility,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
@@ -172,13 +173,13 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.statusDanger,
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
});
|
||||
const purpleColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedMerged,
|
||||
color: theme.colors.statusMerged,
|
||||
});
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
@@ -303,6 +304,9 @@ interface WorkspaceRowInnerProps {
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Row sits under a group header (a project row). Pinned rows are not grouped.
|
||||
* Drives the indent that lands the row on the header label's rail. */
|
||||
inGroup?: boolean;
|
||||
}
|
||||
|
||||
export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewStyle> }) {
|
||||
@@ -378,13 +382,16 @@ function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
selected,
|
||||
isHovered,
|
||||
inGroup,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inGroup: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
inGroup && sidebarWorkspaceRowStyles.rowIndented,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
@@ -1061,6 +1068,7 @@ function WorkspaceRowInner({
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inGroup = false,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative;
|
||||
@@ -1094,6 +1102,7 @@ function WorkspaceRowInner({
|
||||
isDragging,
|
||||
selected,
|
||||
isHovered,
|
||||
inGroup,
|
||||
});
|
||||
return (
|
||||
<View
|
||||
@@ -1190,6 +1199,7 @@ function WorkspaceRowWithMenu({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inGroup = false,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -1207,6 +1217,9 @@ function WorkspaceRowWithMenu({
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Row sits under a group header (a project row). Pinned rows are not grouped.
|
||||
* Drives the indent that lands the row on the header label's rail. */
|
||||
inGroup?: boolean;
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1348,6 +1361,7 @@ function WorkspaceRowWithMenu({
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inGroup={inGroup}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -1375,6 +1389,9 @@ interface WorkspaceRowItemProps {
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Row sits under a group header (a project row). Pinned rows are not grouped.
|
||||
* Drives the indent that lands the row on the header label's rail. */
|
||||
inGroup?: boolean;
|
||||
isCreating?: boolean;
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
@@ -1396,6 +1413,7 @@ function WorkspaceRowItem({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inGroup = false,
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
@@ -1424,6 +1442,7 @@ function WorkspaceRowItem({
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inGroup={inGroup}
|
||||
isCreating={isCreating}
|
||||
selected={isWorkspaceSelected({
|
||||
selection: activeWorkspaceSelection,
|
||||
@@ -1467,6 +1486,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
previous.canPin === next.canPin &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
|
||||
previous.inGroup === next.inGroup &&
|
||||
previous.isCreating === next.isCreating &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.drag === next.drag &&
|
||||
@@ -1493,6 +1513,7 @@ function WorkspaceRow({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inGroup = false,
|
||||
isCreating = false,
|
||||
selected,
|
||||
}: {
|
||||
@@ -1510,6 +1531,9 @@ function WorkspaceRow({
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Row sits under a group header (a project row). Pinned rows are not grouped.
|
||||
* Drives the indent that lands the row on the header label's rail. */
|
||||
inGroup?: boolean;
|
||||
isCreating?: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
@@ -1534,6 +1558,7 @@ function WorkspaceRow({
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inGroup={inGroup}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
);
|
||||
@@ -1633,6 +1658,7 @@ function ProjectBlock({
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
||||
inGroup
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === "git"}
|
||||
@@ -2499,7 +2525,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
|
||||
@@ -42,8 +42,8 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const amberColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.amber[500],
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -175,7 +175,7 @@ function ProjectStatusBadge({
|
||||
testID="project-status-badge"
|
||||
>
|
||||
{content.kind === "alert" ? (
|
||||
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />
|
||||
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={needsInputColorMapping} />
|
||||
) : (
|
||||
<ProjectStatusDot bucket={content.bucket} />
|
||||
)}
|
||||
|
||||
@@ -15,11 +15,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
// one that moves. Callers own the dot's geometry and color and pass it in as a style — this
|
||||
// module owns nothing but the pulse.
|
||||
//
|
||||
// Opacity only, and shallow: the dot sits in a column of static dots in neighboring rows, so
|
||||
// anything that changes its size or drops it near invisible reads as jitter down the list.
|
||||
// One full cycle is out-and-back, so the visible period is twice the half period.
|
||||
const PULSE_HALF_PERIOD_MS = 900;
|
||||
const PULSE_MIN_OPACITY = 0.65;
|
||||
// Opacity only: the dot sits in a column of static dots in neighboring rows, so anything that
|
||||
// changes its size reads as jitter down the list. The floor stays clear of invisible — a dot
|
||||
// that blinks out reads as a glitch rather than as work in progress, and the row loses its
|
||||
// status marker on every cycle. One full cycle is out-and-back, so the visible period is
|
||||
// twice the half period.
|
||||
const PULSE_HALF_PERIOD_MS = 700;
|
||||
const PULSE_MIN_OPACITY = 0.45;
|
||||
|
||||
// One clock for every pulsing dot in the app. Dots that each start their own animation drift
|
||||
// apart as rows mount at different times, and a column of them fading out of step is noise —
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
@@ -63,9 +64,20 @@ import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pi
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
// One mapping per bucket, resolved through the status-dot producer so a group header and
|
||||
// the rows under it cannot disagree about what "failed" looks like.
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
const failedColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
});
|
||||
const attentionColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
});
|
||||
const runningColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
});
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
@@ -386,13 +398,13 @@ function StatusGroupLeadingVisual({
|
||||
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
|
||||
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
|
||||
case "failed":
|
||||
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
|
||||
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
|
||||
case "attention":
|
||||
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
||||
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
|
||||
case "running":
|
||||
return <ThemedCircleDot size={14} uniProps={amberColorMapping} />;
|
||||
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
|
||||
case "done":
|
||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
@@ -921,7 +933,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
|
||||
@@ -29,24 +29,15 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
/**
|
||||
* How far a workspace row sits inside the status header above it. Only status grouping
|
||||
* indents: a project row carries an icon and its workspaces line up under it already, but a
|
||||
* status header is just a label, so its rows need the offset to read as belonging to it.
|
||||
*
|
||||
* It is row padding rather than a margin on the list, because the row's hover and selected
|
||||
* backgrounds have to keep spanning the group's full width. Indenting the container instead
|
||||
* pulls the highlight in with the content and the row stops lining up with its header.
|
||||
*/
|
||||
const WORKSPACE_ROW_INDENT = 12;
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
@@ -249,7 +240,7 @@ function WorkspaceStatusIndicator({
|
||||
if (bucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-needs_input">
|
||||
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
|
||||
<ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -342,10 +333,16 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
}
|
||||
|
||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
// Layered over the row's own padding rather than replacing it, so the indent stays one
|
||||
// number here instead of being baked into a row style.
|
||||
// How far a workspace row sits inside the group header above it — a project row or a
|
||||
// status group header. Both groupings share this one indent, so every grouped workspace row
|
||||
// in the sidebar sits on the same rail regardless of how the list is grouped. Pinned rows
|
||||
// are not grouped and stay flush.
|
||||
//
|
||||
// It is row padding rather than a margin on the list, because the row's hover and selected
|
||||
// backgrounds have to keep spanning the group's full width. Indenting the container instead
|
||||
// pulls the highlight in with the content and the row stops lining up with its header.
|
||||
rowIndented: {
|
||||
paddingLeft: theme.spacing[2] + WORKSPACE_ROW_INDENT,
|
||||
paddingLeft: theme.spacing[2] + theme.spacing[2],
|
||||
},
|
||||
rowRight: {
|
||||
flexDirection: "row",
|
||||
@@ -575,7 +572,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
standaloneRunningDot: {
|
||||
width: 8,
|
||||
@@ -608,11 +605,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 1,
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotFailed: {
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotRunning: {
|
||||
@@ -620,7 +617,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotAttention: {
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -85,16 +85,10 @@ function Pie({ fraction, color }: { fraction: number; color: string }) {
|
||||
|
||||
const ThemedCheckIndicatorSvg = withUnistyles(CheckIndicatorSvg);
|
||||
|
||||
/**
|
||||
* Passing CI is the uneventful case and asks nothing of the reader, so it gets the quiet
|
||||
* tier — green enough to name the state, close enough to the subtitle's weight that a
|
||||
* sidebar full of them doesn't bury the one red row. Failure and a run still deciding keep
|
||||
* real colour, because those are the two that want something from you.
|
||||
*/
|
||||
const COLOR_MAPPINGS: Record<CheckSummary["state"], (theme: Theme) => { color: string }> = {
|
||||
passed: (theme) => ({ color: theme.colors.statusMutedSuccess }),
|
||||
failed: (theme) => ({ color: theme.colors.statusMutedDanger }),
|
||||
running: (theme) => ({ color: theme.colors.statusMutedWarning }),
|
||||
passed: (theme) => ({ color: theme.colors.statusSuccess }),
|
||||
failed: (theme) => ({ color: theme.colors.statusDanger }),
|
||||
running: (theme) => ({ color: theme.colors.statusWarning }),
|
||||
};
|
||||
|
||||
export function CheckIndicator({ summary, size }: { summary: CheckSummary; size: number }) {
|
||||
|
||||
@@ -39,8 +39,8 @@ const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
|
||||
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
/**
|
||||
* The subtitle under a workspace title: which host it lives on, its change request, that
|
||||
@@ -212,7 +212,7 @@ function ScriptItem({ summary }: { summary: WorkspaceScriptSummary }) {
|
||||
);
|
||||
}
|
||||
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||
|
||||
const PR_ICONS = {
|
||||
open: ThemedGitPullRequest,
|
||||
@@ -261,7 +261,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
scriptPort: {
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
@@ -280,19 +280,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx.
|
||||
checksTextPassed: {
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
checksTextFailed: {
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
checksTextRunning: {
|
||||
color: theme.colors.statusMutedWarning,
|
||||
color: theme.colors.statusWarning,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -97,8 +97,12 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
const reduceMotion = useReducedMotion();
|
||||
const step = useSyncedLoaderStep(active, reduceMotion);
|
||||
|
||||
// The 2x3 grid fills `size` exactly on its long axis: the dot is whatever is left after
|
||||
// the two gaps, not a floored integer. Flooring cost the grid up to a third of a dot per
|
||||
// row — at size 10 it drew 8pt of ink in a 10pt box and read as a small mark in a large
|
||||
// slot. Fractional dots are fine here; these are sub-pixel radii on a moving glyph.
|
||||
const gap = Math.max(1, Math.round(size * 0.12));
|
||||
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
||||
const dotSize = Math.max(2, (size - gap * 2) / 3);
|
||||
const gridWidth = dotSize * 2 + gap;
|
||||
const gridHeight = dotSize * 3 + gap * 2;
|
||||
|
||||
|
||||
@@ -45,13 +45,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
// Tinted from the one status token rather than a palette step, so the pill tracks the
|
||||
// theme. `1a`/`33` are the 10%/20% alpha suffixes the identity table uses.
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
backgroundColor: `${theme.colors.statusSuccess}1a`,
|
||||
borderColor: `${theme.colors.statusSuccess}33`,
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
borderColor: theme.colors.palette.red[800],
|
||||
backgroundColor: `${theme.colors.statusDanger}1a`,
|
||||
borderColor: `${theme.colors.statusDanger}33`,
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -59,9 +61,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
pillTextSuccess: {
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
pillTextError: {
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -363,9 +363,9 @@ const ThemedCheck = withUnistyles(Check);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
|
||||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedWarning });
|
||||
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
|
||||
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
function InfoRow({
|
||||
icon: Icon,
|
||||
@@ -660,16 +660,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
checksStatusTextFailed: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
checksStatusTextPending: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedWarning,
|
||||
color: theme.colors.statusWarning,
|
||||
},
|
||||
checksStatusTextPassed: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PullRequestTimelineResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
|
||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
@@ -663,21 +664,11 @@ describe("mapPipelineStatus", () => {
|
||||
});
|
||||
|
||||
describe("deriveAvatarColor", () => {
|
||||
it("returns a deterministic color from the PR pane avatar palette", () => {
|
||||
const palette = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
it("returns a shared identity color, matched case-insensitively", () => {
|
||||
const palette = IDENTITY_COLOR_NAMES.map(identityColor);
|
||||
|
||||
expect(deriveAvatarColor("alice")).toBe(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("Alice"));
|
||||
expect(deriveAvatarColor("Alice")).toBe(deriveAvatarColor("alice"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import { type Forge, getForgePresentation } from "@/git/forge";
|
||||
import { parseClientForgeFacts } from "@/git/forges";
|
||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
|
||||
import { type CheckStatus, mapCheckStatus } from "./check-status";
|
||||
import { getNativeFallbackChecks } from "./native-data";
|
||||
|
||||
@@ -101,17 +102,6 @@ type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
type PullRequestTimelineItem = PullRequestTimeline["items"][number];
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
|
||||
export function mapPrPaneData(
|
||||
status: CheckoutPrStatus,
|
||||
timeline: PullRequestTimeline | null | undefined,
|
||||
@@ -156,8 +146,11 @@ function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
|
||||
return { id: forge, label: getForgePresentation(forge).brandLabel };
|
||||
}
|
||||
|
||||
// Avatars are identity, not status: they draw from the shared identity table so a PR
|
||||
// participant square sits at the same weight as a project icon. Logins are matched
|
||||
// case-insensitively, so the same person keeps one color across forges.
|
||||
export function deriveAvatarColor(login: string): string {
|
||||
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
|
||||
return identityColor(deriveIdentityColorName(login.toLowerCase()));
|
||||
}
|
||||
|
||||
export function formatAge(createdAtMs: number, nowMs = Date.now()): string {
|
||||
@@ -308,14 +301,6 @@ function parsePullRequestNumber(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
function hashLogin(login: string): number {
|
||||
let hash = 0;
|
||||
for (const character of login.toLowerCase()) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Server } from "lucide-react-native";
|
||||
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
|
||||
import { identityColor } from "@/styles/identity-colors";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
@@ -15,19 +15,26 @@ const ThemedServer = withUnistyles(Server);
|
||||
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// Theme-independent, so the mapping per host color is fixed for the life of the module. A
|
||||
// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass.
|
||||
// One stable mapping per host color for the life of the module — a fresh `uniProps` identity
|
||||
// on every render makes withUnistyles re-subscribe each pass. The scheme is read inside the
|
||||
// mapping rather than baked in, so a theme change repaints the glyph without a React render.
|
||||
//
|
||||
// Glyph and label share one color, so a badge reads as one object rather than a colored icon
|
||||
// with an unrelated grey word beside it.
|
||||
const HOST_ICON_MAPPINGS: Record<HostColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
|
||||
for (const color of HOST_COLORS) {
|
||||
byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) });
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? mutedMapping
|
||||
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
|
||||
}
|
||||
return byColor;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Which machine something lives on, drawn the same way everywhere it appears: a server glyph
|
||||
* in the host's identity color, and the host's name when the host is configured to show one.
|
||||
* and, when the host is configured to show one, its name — both in the host's identity color.
|
||||
*
|
||||
* A hostname is the least interesting thing on any line that carries it and the only one whose
|
||||
* length nobody chose, so the badge yields space before its neighbours rather than alongside
|
||||
@@ -42,7 +49,7 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) {
|
||||
>
|
||||
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
|
||||
{badge.showLabel ? (
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
<Text style={[styles.label, labelColorStyle(badge.color)]} numberOfLines={1}>
|
||||
{badge.label}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -50,6 +57,36 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Text has no `color` prop, so the label cannot ride the icon's uniProps mapping — its color
|
||||
// has to come from a registered style. One entry per host color, picked at render time; a
|
||||
// module-level lookup table would read the style proxies before the persisted theme lands.
|
||||
function labelColorStyle(color: HostColor) {
|
||||
switch (color) {
|
||||
case "none":
|
||||
return null;
|
||||
case "violet":
|
||||
return styles.labelViolet;
|
||||
case "sky":
|
||||
return styles.labelSky;
|
||||
case "emerald":
|
||||
return styles.labelEmerald;
|
||||
case "orange":
|
||||
return styles.labelOrange;
|
||||
case "pink":
|
||||
return styles.labelPink;
|
||||
case "indigo":
|
||||
return styles.labelIndigo;
|
||||
case "teal":
|
||||
return styles.labelTeal;
|
||||
case "red":
|
||||
return styles.labelRed;
|
||||
case "amber":
|
||||
return styles.labelAmber;
|
||||
case "blue":
|
||||
return styles.labelBlue;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
@@ -69,4 +106,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
labelViolet: { color: identityForeground("violet", theme.colorScheme) },
|
||||
labelSky: { color: identityForeground("sky", theme.colorScheme) },
|
||||
labelEmerald: { color: identityForeground("emerald", theme.colorScheme) },
|
||||
labelOrange: { color: identityForeground("orange", theme.colorScheme) },
|
||||
labelPink: { color: identityForeground("pink", theme.colorScheme) },
|
||||
labelIndigo: { color: identityForeground("indigo", theme.colorScheme) },
|
||||
labelTeal: { color: identityForeground("teal", theme.colorScheme) },
|
||||
labelRed: { color: identityForeground("red", theme.colorScheme) },
|
||||
labelAmber: { color: identityForeground("amber", theme.colorScheme) },
|
||||
labelBlue: { color: identityForeground("blue", theme.colorScheme) },
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
@@ -125,7 +125,7 @@ export function WorkspaceTabIcon({
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
const bucket = presentation.statusBucket;
|
||||
let statusDotColor: string | null = null;
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color;
|
||||
else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
|
||||
@@ -149,7 +149,7 @@ export function WorkspaceTabIcon({
|
||||
() => [
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor ?? undefined,
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
@@ -255,16 +255,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: theme.colors.palette.red[500],
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
},
|
||||
statusDotRunning: {
|
||||
color: theme.colors.palette.blue[500],
|
||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: theme.colors.palette.green[500],
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
iconActive: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -273,10 +273,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
syncedLoader: {
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -44,6 +44,51 @@ export function identityColor(name: IdentityColorName): string {
|
||||
return IDENTITY_COLORS[name];
|
||||
}
|
||||
|
||||
// The same ten identities used as *foreground* — a glyph or a label painted directly on the
|
||||
// row — rather than as a fill with a white letter on top. That is a different contrast
|
||||
// problem and the fill table cannot solve it: clearing 4.5:1 against a near-white surface
|
||||
// needs luminance <= 0.183, and against the dark sidebar it needs >= 0.212, so no single hex
|
||||
// serves both. Hence one set per scheme. The hue is what identifies a host, and the hue is
|
||||
// unchanged from IDENTITY_COLORS above; only lightness moves.
|
||||
//
|
||||
// The geometry is deliberately the status family's, not a band of its own: same lightness,
|
||||
// same chroma fraction as statusSuccess and friends in theme.ts. A sidebar meta row puts a
|
||||
// host badge next to a CI check and a diff stat, and if the two families sit at different
|
||||
// lightness the louder one wins an argument it should not be having — the fill table's
|
||||
// L=0.571 against status dark's L=0.700 is exactly why a passing check used to shout over a
|
||||
// host name. Anything that changes the status band has to change this one with it.
|
||||
const IDENTITY_FOREGROUND_LIGHT: Record<IdentityColorName, string> = {
|
||||
// L=0.50, chroma 60% of gamut max — 5.3:1 to 5.8:1 on the light sidebar
|
||||
violet: "#6d49b5",
|
||||
sky: "#3d6985",
|
||||
emerald: "#3e6e5d",
|
||||
orange: "#845838",
|
||||
pink: "#974168",
|
||||
indigo: "#5251c2",
|
||||
teal: "#3e6d6c",
|
||||
red: "#9c4243",
|
||||
amber: "#716239",
|
||||
blue: "#39649e",
|
||||
};
|
||||
|
||||
const IDENTITY_FOREGROUND_DARK: Record<IdentityColorName, string> = {
|
||||
// L=0.70, chroma 55% of gamut max — 6.3:1 to 7.2:1 on the dark sidebar
|
||||
violet: "#a392d5",
|
||||
sky: "#6aa6ce",
|
||||
emerald: "#6cae96",
|
||||
orange: "#cc8f64",
|
||||
pink: "#d87da3",
|
||||
indigo: "#9299d5",
|
||||
teal: "#6cacab",
|
||||
red: "#d88381",
|
||||
amber: "#b29d64",
|
||||
blue: "#7ba1d5",
|
||||
};
|
||||
|
||||
export function identityForeground(name: IdentityColorName, colorScheme: "light" | "dark"): string {
|
||||
return colorScheme === "light" ? IDENTITY_FOREGROUND_LIGHT[name] : IDENTITY_FOREGROUND_DARK[name];
|
||||
}
|
||||
|
||||
/** The same hue at 10% alpha, for fills that sit behind text of the full-strength color. */
|
||||
export function identityTint(name: IdentityColorName): string {
|
||||
return `${IDENTITY_COLORS[name]}1a`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,20 +8,24 @@ export function getStatusDotColor(input: {
|
||||
}): string | null {
|
||||
const { theme, bucket, showDoneAsInactive = false } = input;
|
||||
|
||||
// Working and needs_input share amber: both mean "this agent has your turn in it". They
|
||||
// never collide in practice because needs_input always draws the alert glyph, never a bare
|
||||
// dot — see getProjectStatusBadgeContent.
|
||||
// The one place the statusDot* band is read. Dots sit louder than the check icons and host
|
||||
// badges on the same row — see the band's note in theme.ts — so going through the status
|
||||
// family here instead would quietly put the row's state below its metadata.
|
||||
//
|
||||
// needs_input is amber because it wants something from you. Working is blue: an agent doing
|
||||
// its job is the one busy state that asks for nothing, so it should not sit in the same
|
||||
// color as the states that do.
|
||||
if (bucket === "needs_input") {
|
||||
return theme.colors.palette.amber[500];
|
||||
return theme.colors.statusDotWarning;
|
||||
}
|
||||
if (bucket === "failed") {
|
||||
return theme.colors.palette.red[500];
|
||||
return theme.colors.statusDotDanger;
|
||||
}
|
||||
if (bucket === "running") {
|
||||
return theme.colors.palette.amber[500];
|
||||
return theme.colors.statusDotRunning;
|
||||
}
|
||||
if (bucket === "attention") {
|
||||
return theme.colors.palette.green[500];
|
||||
return theme.colors.statusDotSuccess;
|
||||
}
|
||||
if (bucket === "done") {
|
||||
return showDoneAsInactive ? theme.colors.border : null;
|
||||
|
||||
Reference in New Issue
Block a user