feat(app): add running status ring and lighten status dots
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert. Co-authored-by: Christoph Leiter <christoph@leiter.io>
This commit is contained in:
co-authored by
Christoph Leiter
parent
6ae6231d1f
commit
c0daf8e066
@@ -0,0 +1,58 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { getStatusRingRotation } from "@/components/status-ring/geometry";
|
||||
|
||||
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
|
||||
const activeRingCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
|
||||
function advanceSharedRotation(): void {
|
||||
"worklet";
|
||||
if (activeRingCount.value === 0) {
|
||||
clockRunning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
sharedRotation.value = getStatusRingRotation(Date.now());
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
|
||||
function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
activeRingCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
clockRunning.value = true;
|
||||
sharedRotation.value = getStatusRingRotation(Date.now());
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
activeRingCount.value -= 1;
|
||||
}
|
||||
|
||||
export function useStatusRingRotation(): SharedValue<number> {
|
||||
const registered = useSharedValue(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleOnUI(registerStatusRing, registered);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStatusRing, registered);
|
||||
};
|
||||
}, [registered]);
|
||||
|
||||
return sharedRotation;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
|
||||
import type { View as NativeView } from "react-native";
|
||||
import { STATUS_RING_PERIOD_MS } from "@/components/status-ring/geometry";
|
||||
|
||||
const STATUS_RING_KEYFRAMES: PropertyIndexedKeyframes = {
|
||||
transform: ["rotate(0deg)", "rotate(360deg)"],
|
||||
};
|
||||
const STATUS_RING_TIMING: KeyframeAnimationOptions = {
|
||||
duration: STATUS_RING_PERIOD_MS,
|
||||
easing: "linear",
|
||||
iterations: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const STATUS_RING_TIMELINE_START_MS = 0;
|
||||
|
||||
export function useStatusRingAnimationRef(): RefCallback<NativeView> {
|
||||
const arcElement = useRef<HTMLElement | null>(null);
|
||||
const setArcElement = useCallback((instance: NativeView | null) => {
|
||||
arcElement.current = instance instanceof HTMLElement ? instance : null;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = arcElement.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = element.animate(STATUS_RING_KEYFRAMES, STATUS_RING_TIMING);
|
||||
animation.startTime = STATUS_RING_TIMELINE_START_MS;
|
||||
return () => {
|
||||
animation.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return setArcElement;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet as RNStyleSheet, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import {
|
||||
STATUS_RING_FRAME_SIZE,
|
||||
STATUS_RING_HEAD_OPACITY,
|
||||
STATUS_RING_SIZE,
|
||||
STATUS_RING_STROKE,
|
||||
STATUS_RING_TRACK_OPACITY,
|
||||
} from "@/components/status-ring/geometry";
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
export interface StatusRingProps {
|
||||
// The surface the ring is drawn on top of, named rather than resolved, because theme colours
|
||||
// are only legible inside `StyleSheet.create` — see docs/unistyles.md. The frame fills itself
|
||||
// with this so the ring reads as a hole punched in whatever it overlaps: the agent icon under a
|
||||
// tab dot, the project tile under a sidebar badge. Leave it out where the ring sits on flat
|
||||
// background and has nothing to knock out.
|
||||
backdrop?: SurfaceBackdrop | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The static half of the running indicator: the knockout, the track, and the centre dot. The
|
||||
* platform entry points supply the rotating quarter and own only how it is driven.
|
||||
*
|
||||
* The ring has no colour prop. It only ever means one thing — an agent is running — so it reads
|
||||
* that one colour out of the theme itself. Passing the colour in would materialise it at the call
|
||||
* site, outside anything Unistyles tracks, and leave the mark in the old theme until something
|
||||
* incidental re-rendered the row.
|
||||
*/
|
||||
export function StatusRingFrame({ backdrop, children }: StatusRingProps & { children: ReactNode }) {
|
||||
return (
|
||||
<View style={[styles.frame, getBackdropStyle(backdrop)]}>
|
||||
<View style={styles.track} />
|
||||
{children}
|
||||
<View style={styles.centerDot} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) {
|
||||
switch (backdrop) {
|
||||
case "surface0":
|
||||
return styles.backdropSurface0;
|
||||
case "surface1":
|
||||
return styles.backdropSurface1;
|
||||
case "surfaceSidebar":
|
||||
return styles.backdropSurfaceSidebar;
|
||||
case "surfaceSidebarHover":
|
||||
return styles.backdropSurfaceSidebarHover;
|
||||
case "surface2":
|
||||
return styles.backdropSurface2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The rotating quarter's geometry, deliberately plain React Native rather than Unistyles: on
|
||||
// native this style lands on a Reanimated `Animated.View`, and applying a theme-tracked style to
|
||||
// one crashes when the theme changes — Unistyles and Reanimated both mutate the same native node.
|
||||
// See docs/unistyles.md. The colour it needs lives on `styles.arc`, one level in, on a view
|
||||
// Reanimated never touches.
|
||||
export const rotatorStyles = RNStyleSheet.create({
|
||||
rotator: {
|
||||
position: "absolute",
|
||||
width: STATUS_RING_SIZE,
|
||||
height: STATUS_RING_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
export const styles = StyleSheet.create((theme) => {
|
||||
const circle = {
|
||||
width: STATUS_RING_SIZE,
|
||||
height: STATUS_RING_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: STATUS_RING_STROKE,
|
||||
} as const;
|
||||
const runningColor = getStatusDotColor({ theme, bucket: "running" }) ?? undefined;
|
||||
|
||||
return {
|
||||
frame: {
|
||||
width: STATUS_RING_FRAME_SIZE,
|
||||
height: STATUS_RING_FRAME_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
backdropSurface0: { backgroundColor: theme.colors.surface0 },
|
||||
backdropSurface1: { backgroundColor: theme.colors.surface1 },
|
||||
backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
||||
backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
backdropSurface2: { backgroundColor: theme.colors.surface2 },
|
||||
|
||||
// The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one
|
||||
// object at one hue; the opacity is what puts it behind the quarter instead of competing.
|
||||
track: {
|
||||
...circle,
|
||||
position: "absolute",
|
||||
borderColor: runningColor,
|
||||
opacity: STATUS_RING_TRACK_OPACITY,
|
||||
},
|
||||
|
||||
// The moving quarter. Three sides transparent leaves the top border alone, which on a fully
|
||||
// rounded box is a 90° arc — the edge that makes the rotation legible against the track.
|
||||
arc: {
|
||||
...circle,
|
||||
borderColor: "transparent",
|
||||
borderTopColor: runningColor,
|
||||
opacity: STATUS_RING_HEAD_OPACITY,
|
||||
},
|
||||
|
||||
centerDot: {
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: runningColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
// The running indicator is a status dot with a rotating ring around it, and the whole set of
|
||||
// numbers below is a budget rather than four independent choices. The centre matches every other
|
||||
// filled status circle; the gap and stroke grow outward from it without changing that shared size.
|
||||
export const STATUS_RING_GAP = 1.5;
|
||||
export const STATUS_RING_STROKE = 1.5;
|
||||
// Derived outward from the dot, which means the dot is not a free variable: shrink it without
|
||||
// opening the gap by the same amount and the ring closes in behind it, the whole mark scales
|
||||
// down, and nothing about how the centre reads against the ring actually changes.
|
||||
export const STATUS_RING_SIZE =
|
||||
STATUS_INDICATOR_FILLED_DOT_SIZE + (STATUS_RING_GAP + STATUS_RING_STROKE) * 2;
|
||||
|
||||
// The knockout margin, and the reason the frame is wider than the ring. These indicators sit on
|
||||
// the bottom-right corner of an agent or project icon, and a dot only stays readable there
|
||||
// because it carries a ring of surface colour separating it from the art underneath. A ring drawn
|
||||
// out to the frame's edge has no such separation and collides with the icon, so the frame is the
|
||||
// knockout and the ring lives inside it.
|
||||
export const STATUS_RING_HALO_INSET = 1;
|
||||
export const STATUS_RING_FRAME_SIZE = STATUS_RING_SIZE + STATUS_RING_HALO_INSET * 2;
|
||||
|
||||
// The track and the quarter riding it are the same color and differ only here. The track is low
|
||||
// enough that the quarter is unmistakably the thing moving, high enough that the ring still reads
|
||||
// as a ring when the quarter is on the far side of it. The quarter is held off full so the centre
|
||||
// dot stays the solid part of the mark — the ring is what the dot is doing, not a second dot.
|
||||
export const STATUS_RING_TRACK_OPACITY = 0.3;
|
||||
export const STATUS_RING_HEAD_OPACITY = 0.9;
|
||||
|
||||
// One period for every ring in the app. Each platform owns one mount-independent timeline: web
|
||||
// animations share an absolute document-timeline start, while native publishes this wall-clock
|
||||
// phase from one UI-thread clock. A row that appears mid-flight therefore lands in step with the
|
||||
// rings already turning.
|
||||
export const STATUS_RING_PERIOD_MS = 900;
|
||||
|
||||
export function getStatusRingRotation(nowMs: number): number {
|
||||
"worklet";
|
||||
return ((nowMs % STATUS_RING_PERIOD_MS) / STATUS_RING_PERIOD_MS) * 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchors a corner status dot's offset for the ring that replaces it.
|
||||
*
|
||||
* The dots this replaces are pinned to an icon's bottom-right corner rather than centred in a
|
||||
* slot, so growing them in place would walk the indicator off the corner. Callers keep their
|
||||
* existing dot offset and pass it through here to grow around the same centre point.
|
||||
*/
|
||||
export function getStatusRingOffset(dotOffset: number, dotSize: number): number {
|
||||
return dotOffset - (STATUS_RING_FRAME_SIZE - dotSize) / 2;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import {
|
||||
StatusRingFrame,
|
||||
type StatusRingProps,
|
||||
rotatorStyles,
|
||||
styles,
|
||||
} from "@/components/status-ring/frame";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
|
||||
/**
|
||||
* Native running indicator. The rotation is published by one shared UI-thread clock rather than
|
||||
* per-instance timing, so a ring that mounts mid-flight is already in phase — see `clock.ts`.
|
||||
*
|
||||
* The rotated view carries no theme-tracked style; the coloured arc is nested inside it. Putting
|
||||
* a Unistyles style on a Reanimated view crashes on theme change (docs/unistyles.md).
|
||||
*/
|
||||
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const rotatorStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<StatusRingFrame backdrop={backdrop}>
|
||||
<Animated.View style={[rotatorStyles.rotator, rotatorStyle]}>
|
||||
<View style={styles.arc} />
|
||||
</Animated.View>
|
||||
</StatusRingFrame>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
StatusRingFrame,
|
||||
type StatusRingProps,
|
||||
rotatorStyles,
|
||||
styles,
|
||||
} from "@/components/status-ring/frame";
|
||||
import { useStatusRingAnimationRef } from "@/components/status-ring/clock.web";
|
||||
|
||||
/**
|
||||
* Web running indicator. A sidebar full of agents can hold dozens of these at once, so the browser
|
||||
* drives the rotation from one absolute document-timeline epoch rather than from mount time — see
|
||||
* `clock.web.ts`.
|
||||
*/
|
||||
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
|
||||
const rotatorRef = useStatusRingAnimationRef();
|
||||
|
||||
return (
|
||||
<StatusRingFrame backdrop={backdrop}>
|
||||
<View ref={rotatorRef} style={rotatorStyles.rotator}>
|
||||
<View style={styles.arc} />
|
||||
</View>
|
||||
</StatusRingFrame>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user