@stevecastaneda/paseo-progress 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { AccessibilityInfo, Animated, Easing, Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
// Read at render time, not import time. The native driver can't run on web,
|
|
5
|
+
// and web is the only place filter blur renders.
|
|
6
|
+
const isWeb = () => Platform.OS === "web";
|
|
7
|
+
// On web, react-native-web falls back to JS for `useNativeDriver: true` but
|
|
8
|
+
// Animated.loop still takes the native path and plays one iteration.
|
|
9
|
+
export const nativeDriver = () => !isWeb();
|
|
10
|
+
const easeOut = () => Easing.out(Easing.ease);
|
|
11
|
+
const iconCurve = () => Easing.bezier(0.2, 0, 0, 1);
|
|
12
|
+
|
|
13
|
+
export function useReducedMotion(): boolean {
|
|
14
|
+
const [reduced, setReduced] = useState(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true;
|
|
17
|
+
void AccessibilityInfo.isReduceMotionEnabled().then((value) => live && setReduced(value));
|
|
18
|
+
const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduced);
|
|
19
|
+
return () => {
|
|
20
|
+
live = false;
|
|
21
|
+
subscription.remove();
|
|
22
|
+
};
|
|
23
|
+
}, []);
|
|
24
|
+
return reduced;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The last non-null value, so a dialog keeps its content while it animates
|
|
28
|
+
// closed instead of collapsing mid-exit.
|
|
29
|
+
export function useLastPresent<T>(value: T | null): T | null {
|
|
30
|
+
const last = useRef(value);
|
|
31
|
+
if (value !== null) last.current = value;
|
|
32
|
+
return value ?? last.current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A button that scales to 0.96 while pressed. `static` turns the scale off.
|
|
36
|
+
export function PressScale({ static: isStatic, style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
|
|
37
|
+
static?: boolean;
|
|
38
|
+
// Layout for the pressable itself, e.g. alignSelf; `style` is what scales.
|
|
39
|
+
outerStyle?: StyleProp<ViewStyle>;
|
|
40
|
+
style?: StyleProp<ViewStyle> | ((state: { pressed: boolean; hovered: boolean }) => StyleProp<ViewStyle>);
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
}) {
|
|
43
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
44
|
+
// Timing retargets from the current value, so a release mid-press eases back.
|
|
45
|
+
const to = (toValue: number) => Animated.timing(scale, { toValue, duration: 150, easing: easeOut(), useNativeDriver: nativeDriver() }).start();
|
|
46
|
+
const [pressed, setPressed] = useState(false);
|
|
47
|
+
const [hovered, setHovered] = useState(false);
|
|
48
|
+
return (
|
|
49
|
+
<Pressable {...props} style={outerStyle}
|
|
50
|
+
onHoverIn={(event) => { setHovered(true); props.onHoverIn?.(event); }}
|
|
51
|
+
onHoverOut={(event) => { setHovered(false); props.onHoverOut?.(event); }}
|
|
52
|
+
onPressIn={(event) => { setPressed(true); if (!isStatic) to(0.96); props.onPressIn?.(event); }}
|
|
53
|
+
onPressOut={(event) => { setPressed(false); if (!isStatic) to(1); props.onPressOut?.(event); }}>
|
|
54
|
+
<Animated.View style={[typeof style === "function" ? style({ pressed, hovered }) : style, { transform: [{ scale }] }]}>
|
|
55
|
+
{children}
|
|
56
|
+
</Animated.View>
|
|
57
|
+
</Pressable>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Cross-fades an icon when `swapKey` changes: the new one grows from 0.25 while
|
|
62
|
+
// the old one shrinks away. Nothing animates on first render.
|
|
63
|
+
export function IconSwap({ swapKey, size, children }: { swapKey: string; size: number; children: React.ReactNode }) {
|
|
64
|
+
const reduced = useReducedMotion();
|
|
65
|
+
const [shown, setShown] = useState(swapKey);
|
|
66
|
+
const [leaving, setLeaving] = useState<React.ReactNode>(null);
|
|
67
|
+
const last = useRef(children);
|
|
68
|
+
const enter = useRef(new Animated.Value(1)).current;
|
|
69
|
+
if (swapKey !== shown) {
|
|
70
|
+
setShown(swapKey);
|
|
71
|
+
setLeaving(reduced ? null : last.current);
|
|
72
|
+
}
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
last.current = children;
|
|
75
|
+
});
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
if (!leaving) return;
|
|
78
|
+
enter.setValue(0);
|
|
79
|
+
const animation = Animated.timing(enter, { toValue: 1, duration: 300, easing: iconCurve(), useNativeDriver: nativeDriver() });
|
|
80
|
+
animation.start(({ finished }) => finished && setLeaving(null));
|
|
81
|
+
return () => animation.stop();
|
|
82
|
+
}, [shown]);
|
|
83
|
+
const layer = (value: Animated.AnimatedInterpolation<number> | Animated.Value, blurFrom: [number, number]) => ({
|
|
84
|
+
opacity: value,
|
|
85
|
+
transform: [{ scale: value.interpolate({ inputRange: [0, 1], outputRange: [0.25, 1] }) }],
|
|
86
|
+
...(isWeb() && leaving ? { filter: value.interpolate({ inputRange: [0, 1], outputRange: blurFrom.map((px) => `blur(${px}px)`) }) } : null),
|
|
87
|
+
});
|
|
88
|
+
const exit = enter.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
|
|
89
|
+
return (
|
|
90
|
+
<Animated.View style={{ width: size, height: size }}>
|
|
91
|
+
{leaving ? <Animated.View style={[{ position: "absolute", top: 0, left: 0 }, layer(exit, [4, 0])]}>{leaving}</Animated.View> : null}
|
|
92
|
+
<Animated.View style={layer(enter, [4, 0])}>{children}</Animated.View>
|
|
93
|
+
</Animated.View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Sections that mount within this long of the panel stagger in; later ones enter at once.
|
|
98
|
+
const FIRST_LOAD_MS = 1000;
|
|
99
|
+
const StaggerStart = createContext<number | null>(null);
|
|
100
|
+
|
|
101
|
+
export function StaggerRoot({ children }: { children: React.ReactNode }) {
|
|
102
|
+
const [start] = useState(() => Date.now());
|
|
103
|
+
return <StaggerStart.Provider value={start}>{children}</StaggerStart.Provider>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ENTER_MS = 300;
|
|
107
|
+
const STAGGER_MS = 60;
|
|
108
|
+
|
|
109
|
+
// Fades a section in (opacity, 12px rise, blur) and out (150ms, 12px lift).
|
|
110
|
+
// `order` staggers the first load by 60ms per section, so the last is in by ~0.6s.
|
|
111
|
+
export function Presence({ show, order = 0, children }: { show: boolean; order?: number; children: React.ReactNode }) {
|
|
112
|
+
const reduced = useReducedMotion();
|
|
113
|
+
const start = useContext(StaggerStart);
|
|
114
|
+
const [mounted, setMounted] = useState(show);
|
|
115
|
+
const last = useRef(children);
|
|
116
|
+
if (show) last.current = children;
|
|
117
|
+
const enter = useRef(new Animated.Value(0)).current;
|
|
118
|
+
const exit = useRef(new Animated.Value(0)).current;
|
|
119
|
+
// Filter only while animating: a resting blur(0px) still costs a layer and can soften text.
|
|
120
|
+
const [moving, setMoving] = useState(false);
|
|
121
|
+
if (show && !mounted) setMounted(true);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!mounted) return;
|
|
125
|
+
if (show) {
|
|
126
|
+
exit.setValue(0);
|
|
127
|
+
if (reduced) {
|
|
128
|
+
enter.setValue(1);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const firstLoad = start !== null && Date.now() - start < FIRST_LOAD_MS;
|
|
132
|
+
const animation = Animated.timing(enter, { toValue: 1, duration: ENTER_MS, delay: firstLoad ? order * STAGGER_MS : 0, easing: easeOut(), useNativeDriver: nativeDriver() });
|
|
133
|
+
setMoving(true);
|
|
134
|
+
animation.start(({ finished }) => finished && setMoving(false));
|
|
135
|
+
return () => animation.stop();
|
|
136
|
+
}
|
|
137
|
+
if (reduced) {
|
|
138
|
+
setMounted(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const animation = Animated.timing(exit, { toValue: 1, duration: 150, easing: easeOut(), useNativeDriver: nativeDriver() });
|
|
142
|
+
setMoving(true);
|
|
143
|
+
animation.start(({ finished }) => {
|
|
144
|
+
if (finished) {
|
|
145
|
+
enter.setValue(0);
|
|
146
|
+
setMoving(false);
|
|
147
|
+
setMounted(false);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return () => animation.stop();
|
|
151
|
+
}, [show, mounted]);
|
|
152
|
+
|
|
153
|
+
if (!mounted) return null;
|
|
154
|
+
const opacity = Animated.multiply(enter, exit.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }));
|
|
155
|
+
const translateY = Animated.add(
|
|
156
|
+
enter.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }),
|
|
157
|
+
exit.interpolate({ inputRange: [0, 1], outputRange: [0, -12] }),
|
|
158
|
+
);
|
|
159
|
+
return (
|
|
160
|
+
<Animated.View style={[{ opacity, transform: [{ translateY }] }, isWeb() && moving ? blurStyle(enter, exit) : null]}>
|
|
161
|
+
{show ? children : last.current}
|
|
162
|
+
</Animated.View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function blurStyle(enter: Animated.Value, exit: Animated.Value) {
|
|
167
|
+
// Blur is 4px at either end of the motion and 0 at rest.
|
|
168
|
+
const amount = Animated.add(
|
|
169
|
+
enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }),
|
|
170
|
+
exit.interpolate({ inputRange: [0, 1], outputRange: [0, 4] }),
|
|
171
|
+
);
|
|
172
|
+
return { filter: amount.interpolate({ inputRange: [0, 4], outputRange: ["blur(0px)", "blur(4px)"] }) };
|
|
173
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The panel reports here once it has recorded that it was opened, so the
|
|
2
|
+
// "Progress" pill goes away at once rather than on the next poll.
|
|
3
|
+
// The marker file is the only lasting record.
|
|
4
|
+
const listeners = new Set<(workspaceId: string) => void>();
|
|
5
|
+
|
|
6
|
+
export function notePanelOpened(workspaceId: string) {
|
|
7
|
+
for (const listener of listeners) listener(workspaceId);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function onPanelOpened(listener: (workspaceId: string) => void): () => void {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => { listeners.delete(listener); };
|
|
13
|
+
}
|