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.
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
|
import { ProjectIconView } from "@/components/project-icon-view";
|
|
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
|
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
import {
|
|
getProjectStatusBadgeContent,
|
|
type ProjectStatusBadgeContent,
|
|
type ProjectStatusBadgeDotBucket,
|
|
} from "@/utils/project-status-badge-content";
|
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
|
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
|
|
|
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
|
// between states. Only the thing inside it changes.
|
|
const STATUS_BADGE_SIZE = 12;
|
|
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
|
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
|
// sidebar row has no padding there to absorb the overhang.
|
|
const STATUS_BADGE_OFFSET = -4;
|
|
// Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional
|
|
// for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an
|
|
// odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps).
|
|
// Even sizes divide the shell into whole pixels and land dead center with no correction.
|
|
//
|
|
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 8 draws a ~6.6pt circle
|
|
// against the 6pt dot — the two states read as the same-diameter disc. 8 is the closest even
|
|
// size to the exact match (6 / 0.83 = 7.2); 6 would undershoot the dot rather than meet it.
|
|
const STATUS_BADGE_DOT_SIZE = 6;
|
|
const STATUS_BADGE_ALERT_SIZE = 8;
|
|
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
|
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
|
const LEADING_SLOT_HEIGHT = 20;
|
|
|
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const needsInputColorMapping = (theme: Theme) => ({
|
|
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
|
});
|
|
|
|
/**
|
|
* Leading slot of a sidebar project row: chevron on hover, archive spinner while removing,
|
|
* otherwise the project icon carrying the project's aggregate workspace status.
|
|
*/
|
|
export function ProjectLeadingVisual({
|
|
displayName,
|
|
iconDataUri,
|
|
statusBucket,
|
|
projectViewKey,
|
|
backdrop,
|
|
chevron = null,
|
|
showChevron = false,
|
|
isArchiving = false,
|
|
}: {
|
|
displayName: string;
|
|
iconDataUri: string | null;
|
|
/** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */
|
|
statusBucket: SidebarStateBucket | null;
|
|
projectViewKey: string;
|
|
/** The row's current background, so the status badge can knock out of it. */
|
|
backdrop: SurfaceBackdrop;
|
|
chevron?: "expand" | "collapse" | null;
|
|
showChevron?: boolean;
|
|
isArchiving?: boolean;
|
|
}) {
|
|
if (showChevron && chevron !== null) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot}>
|
|
<ProjectInlineChevron chevron={chevron} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (isArchiving) {
|
|
return (
|
|
<View style={styles.projectLeadingVisualSlot} testID="project-status-indicator-archiving">
|
|
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ProjectStatusIndicator
|
|
iconDataUri={iconDataUri}
|
|
displayName={displayName}
|
|
projectViewKey={projectViewKey}
|
|
statusBucket={statusBucket}
|
|
backdrop={backdrop}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// The project icon (the lettered box) is what marks a row as a *project* rather than a
|
|
// workspace, so it always stays and status annotates it instead of replacing it. Every
|
|
// surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input,
|
|
// a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and
|
|
// only its contents change.
|
|
export function ProjectStatusIndicator({
|
|
iconDataUri,
|
|
displayName,
|
|
projectViewKey,
|
|
statusBucket,
|
|
backdrop,
|
|
loading = false,
|
|
testID,
|
|
}: {
|
|
iconDataUri: string | null;
|
|
displayName: string;
|
|
projectViewKey: string;
|
|
statusBucket: SidebarStateBucket | null;
|
|
/** The row's current background, so the status badge can knock out of it. */
|
|
backdrop: SurfaceBackdrop;
|
|
loading?: boolean;
|
|
testID?: string;
|
|
}) {
|
|
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
|
|
.charAt(0)
|
|
.toUpperCase();
|
|
// A row that's still starting up and a row that's working are both "busy", and at this size
|
|
// there's no room to draw the difference — so `loading` just resolves to the running bucket
|
|
// and they share one badge.
|
|
const badgeBucket = loading ? "running" : statusBucket;
|
|
const badgeContent = getProjectStatusBadgeContent(badgeBucket);
|
|
|
|
return (
|
|
<View
|
|
style={styles.projectLeadingVisualSlot}
|
|
testID={
|
|
testID ??
|
|
(statusBucket && statusBucket !== "done"
|
|
? `project-status-indicator-${statusBucket}`
|
|
: "project-icon-only")
|
|
}
|
|
>
|
|
<View style={styles.projectIconBox}>
|
|
<ProjectIcon
|
|
iconDataUri={iconDataUri}
|
|
placeholderInitial={placeholderInitial}
|
|
projectViewKey={projectViewKey}
|
|
/>
|
|
{badgeContent === null || badgeBucket === null ? null : (
|
|
<ProjectStatusBadge
|
|
content={badgeContent}
|
|
statusBucket={badgeBucket}
|
|
backdrop={backdrop}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProjectStatusBadge({
|
|
content,
|
|
statusBucket,
|
|
backdrop,
|
|
}: {
|
|
content: ProjectStatusBadgeContent;
|
|
statusBucket: SidebarStateBucket;
|
|
backdrop: SurfaceBackdrop;
|
|
}) {
|
|
return (
|
|
<View
|
|
role="status"
|
|
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
|
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
|
|
testID="project-status-badge"
|
|
>
|
|
{content.kind === "alert" ? (
|
|
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={needsInputColorMapping} />
|
|
) : (
|
|
<ProjectStatusDot bucket={content.bucket} />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
|
|
switch (backdrop) {
|
|
case "surfaceSidebarHover":
|
|
return styles.statusBadgeOnSidebarHover;
|
|
case "surface2":
|
|
return styles.statusBadgeOnSurface2;
|
|
default:
|
|
return styles.statusBadgeOnSidebar;
|
|
}
|
|
}
|
|
|
|
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
|
if (bucket !== "running") {
|
|
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
|
}
|
|
return <PulsingStatusDot testID="project-status-dot" style={styles.statusDotRunning} />;
|
|
}
|
|
|
|
function ProjectIcon({
|
|
iconDataUri,
|
|
placeholderInitial,
|
|
projectViewKey,
|
|
}: {
|
|
iconDataUri: string | null;
|
|
placeholderInitial: string;
|
|
projectViewKey: string;
|
|
}) {
|
|
return (
|
|
<ProjectIconView
|
|
iconDataUri={iconDataUri}
|
|
initial={placeholderInitial}
|
|
projectViewKey={projectViewKey}
|
|
size={ICON_SIZE.md}
|
|
textStyle={styles.projectIconFallbackText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) {
|
|
if (chevron === null) {
|
|
return null;
|
|
}
|
|
if (chevron === "collapse") {
|
|
return <ChevronDown size={14} color="#9ca3af" />;
|
|
}
|
|
return <ChevronRight size={14} color="#9ca3af" />;
|
|
}
|
|
|
|
function getStatusDotColorStyle(
|
|
bucket: Exclude<ProjectStatusBadgeDotBucket, "running">,
|
|
): ViewStyle {
|
|
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => {
|
|
// Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring
|
|
// the alert does. The geometry is shared here and baked into each colored variant so the
|
|
// style prop stays a single stable object; the colors come from the one bucket-to-color map
|
|
// so this badge can't drift from the status dots everywhere else.
|
|
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
|
|
({
|
|
width: STATUS_BADGE_DOT_SIZE,
|
|
height: STATUS_BADGE_DOT_SIZE,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
|
|
}) as const;
|
|
|
|
return {
|
|
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
|
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
|
|
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
|
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
|
projectLeadingVisualSlot: {
|
|
width: theme.iconSize.md,
|
|
height: LEADING_SLOT_HEIGHT,
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
// Anchors the corner badge to the icon rather than to the taller slot.
|
|
projectIconBox: {
|
|
position: "relative",
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
},
|
|
projectIconFallbackText: {
|
|
fontSize: 9,
|
|
},
|
|
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
|
|
// (half in, half out) so the lettered project box stays readable. The shell is a knockout:
|
|
// it is filled with the colour of the row behind it, which is what makes the ring around the
|
|
// dot read as a gap in the icon rather than as a white halo drawn on top of it.
|
|
statusBadge: {
|
|
position: "absolute",
|
|
right: STATUS_BADGE_OFFSET,
|
|
bottom: STATUS_BADGE_OFFSET,
|
|
width: STATUS_BADGE_SIZE,
|
|
height: STATUS_BADGE_SIZE,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
},
|
|
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
|
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
|
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
|
|
statusDotRunning: statusDot("running"),
|
|
statusDotFailed: statusDot("failed"),
|
|
statusDotAttention: statusDot("attention"),
|
|
};
|
|
});
|