refactor(app): make the sidebar's running status a plain dot
The running dot no longer animates, and the machinery behind it is gone rather than switched off: the shared refcounted clock, the theme-scheme prop mapping, and the extra wave color all existed only to drive it. Four affordances were tried and each failed for its own reason, which is worth writing down before someone reaches for one of them again. Fading the dot blends it toward the row's surface rather than toward black, so the bottom of every cycle was a washed-out blue that read as the success dot one row up. Dropping chroma holds brightness but turns the low point grey. Raising chroma has nowhere to go: blue at these lightnesses is already within 11% of the sRGB gamut edge, so the only real headroom is at the hue-250 cusp, which costs lightness. An expanding ring solved the color problem outright — motion cannot be confused with another hue — but stayed too subtle at a size that suits an 8pt dot sitting beside a title. If the ring is revisited: the project badge shell clips its contents to 12pt, which is what made the first attempt invisible on exactly the rows most likely to be on screen. The clip is not what makes the shell read as a knockout; its fill is.
This commit is contained in:
@@ -2,7 +2,6 @@ import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||||
import { ProjectIconView } from "@/components/project-icon-view";
|
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 { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
@@ -195,10 +194,7 @@ function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
||||||
if (bucket !== "running") {
|
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
||||||
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
|
||||||
}
|
|
||||||
return <PulsingStatusDot testID="project-status-dot" style={styles.statusDotRunning} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectIcon({
|
function ProjectIcon({
|
||||||
@@ -231,10 +227,10 @@ function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | nu
|
|||||||
return <ChevronRight size={14} color="#9ca3af" />;
|
return <ChevronRight size={14} color="#9ca3af" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusDotColorStyle(
|
function getStatusDotColorStyle(bucket: ProjectStatusBadgeDotBucket): ViewStyle {
|
||||||
bucket: Exclude<ProjectStatusBadgeDotBucket, "running">,
|
if (bucket === "failed") return styles.statusDotFailed;
|
||||||
): ViewStyle {
|
if (bucket === "running") return styles.statusDotRunning;
|
||||||
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
|
return styles.statusDotAttention;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import type { StyleProp, ViewStyle } from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
makeMutable,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useReducedMotion,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
||||||
|
|
||||||
// The busy affordance for a sidebar row. Every status in the sidebar is a dot; busy is the
|
|
||||||
// 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: 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 —
|
|
||||||
// the same reason SyncedLoader drives all its instances from a single step. The value *is*
|
|
||||||
// the opacity, so subscribers read it directly and there is nothing per-instance to fall out
|
|
||||||
// of phase.
|
|
||||||
//
|
|
||||||
// Refcounted so the animation only runs while at least one dot is mounted: an unattended
|
|
||||||
// withRepeat keeps the UI thread's frame loop awake forever.
|
|
||||||
const pulseClock = makeMutable(1);
|
|
||||||
let pulseClockSubscribers = 0;
|
|
||||||
|
|
||||||
function acquirePulseClock(): void {
|
|
||||||
pulseClockSubscribers += 1;
|
|
||||||
if (pulseClockSubscribers > 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pulseClock.value = withRepeat(
|
|
||||||
withTiming(PULSE_MIN_OPACITY, {
|
|
||||||
duration: PULSE_HALF_PERIOD_MS,
|
|
||||||
easing: Easing.inOut(Easing.quad),
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function releasePulseClock(): void {
|
|
||||||
pulseClockSubscribers -= 1;
|
|
||||||
if (pulseClockSubscribers > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancelAnimation(pulseClock);
|
|
||||||
pulseClock.value = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A status dot, breathing in step with every other one on screen. */
|
|
||||||
export function PulsingStatusDot({
|
|
||||||
style,
|
|
||||||
testID,
|
|
||||||
}: {
|
|
||||||
style: StyleProp<ViewStyle>;
|
|
||||||
testID?: string;
|
|
||||||
}) {
|
|
||||||
const active = useRetainedPanelActive();
|
|
||||||
const reduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active || reduceMotion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
acquirePulseClock();
|
|
||||||
return releasePulseClock;
|
|
||||||
}, [active, reduceMotion]);
|
|
||||||
|
|
||||||
const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseClock.value }));
|
|
||||||
|
|
||||||
// Reduced motion leaves the dot fully opaque. It still carries the running color, and the
|
|
||||||
// status is spelled out in the surrounding row's accessible label either way.
|
|
||||||
return <Animated.View testID={testID} style={[style, reduceMotion ? null : pulseStyle]} />;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
|||||||
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
||||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
|
||||||
import {
|
import {
|
||||||
WorkspaceMetaRow,
|
WorkspaceMetaRow,
|
||||||
type WorkspaceServiceSummary,
|
type WorkspaceServiceSummary,
|
||||||
@@ -217,14 +216,14 @@ function WorkspaceStatusIndicator({
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
reserveIdleSpace?: boolean;
|
reserveIdleSpace?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Busy is a pulsing dot here for the same reason it is on a project icon: every status in
|
// Busy is a dot here for the same reason it is on a project icon: every status in the
|
||||||
// the sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
|
// sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
|
||||||
// A row starting up and a row working are both busy, so they share the dot and differ only
|
// A row starting up and a row working are both busy, so they share the dot and differ only
|
||||||
// in testID.
|
// in testID.
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||||
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
<View style={styles.standaloneRunningDot} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +231,7 @@ function WorkspaceStatusIndicator({
|
|||||||
if (shouldRenderSyncedStatusLoader({ bucket })) {
|
if (shouldRenderSyncedStatusLoader({ bucket })) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
||||||
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
<View style={styles.standaloneRunningDot} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,12 +165,11 @@ const darkStatusColors = {
|
|||||||
// for them. Same four hues and the same generation rule as the status colors above, but its
|
// 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.
|
// 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
|
// A dot is 6-8pt of solid color with no shape to read and no label attached. At the status
|
||||||
// one pulses down to PULSE_MIN_OPACITY for half of every cycle. At the status band's lightness
|
// band's lightness the dots read dimmer than the static text and icons beside them on the same
|
||||||
// the dots read dimmer than the static text and icons beside them on the same row, which is
|
// row, which is backwards — the dot is the row's state. So the band is pushed away from the
|
||||||
// backwards — the dot is the row's state. So the band is pushed away from the surface rather
|
// surface rather than toward it (darker in light, lighter in dark) and carries more chroma:
|
||||||
// than toward it (darker in light, lighter in dark) and carries more chroma: 90% of gamut max
|
// 90% of gamut max against the status family's 55-60%.
|
||||||
// against the status family's 55-60%.
|
|
||||||
//
|
//
|
||||||
// All four move together. A dot matching its siblings in lightness and chroma says only which
|
// 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
|
// state the row is in; one that does not says "this row matters more", which is a claim the
|
||||||
|
|||||||
Reference in New Issue
Block a user