mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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.
185 lines
4.9 KiB
TypeScript
185 lines
4.9 KiB
TypeScript
import { useLayoutEffect, useMemo, useState } from "react";
|
|
import { View } from "react-native";
|
|
import Animated, {
|
|
makeMutable,
|
|
type SharedValue,
|
|
useAnimatedStyle,
|
|
useReducedMotion,
|
|
useSharedValue,
|
|
} from "react-native-reanimated";
|
|
import { scheduleOnUI } from "react-native-worklets";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
import {
|
|
SYNCED_LOADER_DOT_COUNT,
|
|
getSyncedLoaderDotOpacity,
|
|
getSyncedLoaderStep,
|
|
} from "@/components/synced-loader-state";
|
|
|
|
const GRID_COLUMNS = 2;
|
|
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
|
|
const sharedStep = makeMutable(0);
|
|
const activeLoaderCount = makeMutable(0);
|
|
const clockRunning = makeMutable(false);
|
|
let nextStepListenerId = 1;
|
|
|
|
function advanceSharedStep(): void {
|
|
"worklet";
|
|
if (activeLoaderCount.value === 0) {
|
|
clockRunning.value = false;
|
|
return;
|
|
}
|
|
|
|
const nextStep = getSyncedLoaderStep(Date.now());
|
|
if (sharedStep.value !== nextStep) {
|
|
sharedStep.value = nextStep;
|
|
}
|
|
requestAnimationFrame(advanceSharedStep);
|
|
}
|
|
|
|
function registerStepListener(
|
|
step: SharedValue<number>,
|
|
registered: SharedValue<boolean>,
|
|
listenerId: number,
|
|
): void {
|
|
"worklet";
|
|
if (registered.value) {
|
|
return;
|
|
}
|
|
|
|
registered.value = true;
|
|
step.value = getSyncedLoaderStep(Date.now());
|
|
sharedStep.addListener(listenerId, (nextStep) => {
|
|
step.value = nextStep;
|
|
});
|
|
activeLoaderCount.value += 1;
|
|
|
|
if (!clockRunning.value) {
|
|
clockRunning.value = true;
|
|
sharedStep.value = step.value;
|
|
requestAnimationFrame(advanceSharedStep);
|
|
}
|
|
}
|
|
|
|
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
|
|
"worklet";
|
|
if (!registered.value) {
|
|
return;
|
|
}
|
|
|
|
registered.value = false;
|
|
sharedStep.removeListener(listenerId);
|
|
activeLoaderCount.value -= 1;
|
|
}
|
|
|
|
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
|
|
// The local value lets retained loaders detach from the app-wide clock without
|
|
// unmounting their animated views or leaving hidden style worklets subscribed.
|
|
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
|
|
const registered = useSharedValue(false);
|
|
const [listenerId] = useState(() => nextStepListenerId++);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!active || reduceMotion) {
|
|
return;
|
|
}
|
|
|
|
scheduleOnUI(registerStepListener, step, registered, listenerId);
|
|
return () => {
|
|
scheduleOnUI(unregisterStepListener, registered, listenerId);
|
|
};
|
|
}, [active, listenerId, reduceMotion, registered, step]);
|
|
|
|
return step;
|
|
}
|
|
|
|
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
|
const active = useRetainedPanelActive();
|
|
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, (size - gap * 2) / 3);
|
|
const gridWidth = dotSize * 2 + gap;
|
|
const gridHeight = dotSize * 3 + gap * 2;
|
|
|
|
const gridStyle = useMemo(
|
|
() => ({ width: gridWidth, height: gridHeight }),
|
|
[gridHeight, gridWidth],
|
|
);
|
|
const containerStyle = useMemo(
|
|
() =>
|
|
({
|
|
width: size,
|
|
height: size,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}) as const,
|
|
[size],
|
|
);
|
|
|
|
return (
|
|
<View style={containerStyle}>
|
|
<View style={gridStyle}>
|
|
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
|
|
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
|
const columnIndex = dotIndex % GRID_COLUMNS;
|
|
|
|
return (
|
|
<SpinnerDot
|
|
key={DOT_KEYS[dotIndex]}
|
|
color={color}
|
|
dotSize={dotSize}
|
|
dotIndex={dotIndex}
|
|
step={step}
|
|
left={columnIndex * (dotSize + gap)}
|
|
top={rowIndex * (dotSize + gap)}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SpinnerDot({
|
|
color,
|
|
dotSize,
|
|
dotIndex,
|
|
step,
|
|
left,
|
|
top,
|
|
}: {
|
|
color: string;
|
|
dotSize: number;
|
|
dotIndex: number;
|
|
step: SharedValue<number>;
|
|
left: number;
|
|
top: number;
|
|
}) {
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
|
|
}));
|
|
|
|
const dotStyle = useMemo(
|
|
() => [
|
|
animatedStyle,
|
|
{
|
|
width: dotSize,
|
|
height: dotSize,
|
|
borderRadius: dotSize / 2,
|
|
backgroundColor: color,
|
|
position: "absolute" as const,
|
|
left,
|
|
top,
|
|
},
|
|
],
|
|
[animatedStyle, dotSize, color, left, top],
|
|
);
|
|
|
|
return <Animated.View style={dotStyle} />;
|
|
}
|