@shipfox/client-onboarding 23.0.0 → 25.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +28 -0
- package/README.md +26 -7
- package/dist/components/setup-checklist-body.d.ts +3 -0
- package/dist/components/setup-checklist-body.d.ts.map +1 -0
- package/dist/components/setup-checklist-body.js +25 -0
- package/dist/components/setup-checklist-body.js.map +1 -0
- package/dist/components/setup-checklist-completion.d.ts +6 -0
- package/dist/components/setup-checklist-completion.d.ts.map +1 -0
- package/dist/components/setup-checklist-completion.js +156 -0
- package/dist/components/setup-checklist-completion.js.map +1 -0
- package/dist/components/setup-checklist-confetti.d.ts +13 -0
- package/dist/components/setup-checklist-confetti.d.ts.map +1 -0
- package/dist/components/setup-checklist-confetti.js +24 -0
- package/dist/components/setup-checklist-confetti.js.map +1 -0
- package/dist/components/setup-checklist-host-primitives.d.ts +11 -0
- package/dist/components/setup-checklist-host-primitives.d.ts.map +1 -0
- package/dist/components/setup-checklist-host-primitives.js +71 -0
- package/dist/components/setup-checklist-host-primitives.js.map +1 -0
- package/dist/components/setup-checklist-row.d.ts +7 -0
- package/dist/components/setup-checklist-row.d.ts.map +1 -0
- package/dist/components/setup-checklist-row.js +177 -0
- package/dist/components/setup-checklist-row.js.map +1 -0
- package/dist/components/setup-checklist-types.d.ts +19 -0
- package/dist/components/setup-checklist-types.d.ts.map +1 -0
- package/dist/components/setup-checklist-types.js +3 -0
- package/dist/components/setup-checklist-types.js.map +1 -0
- package/dist/components/setup-checklist.d.ts +5 -0
- package/dist/components/setup-checklist.d.ts.map +1 -0
- package/dist/components/setup-checklist.js +5 -0
- package/dist/components/setup-checklist.js.map +1 -0
- package/dist/components/setup-checklist.stories.d.ts +19 -0
- package/dist/components/setup-checklist.stories.d.ts.map +1 -0
- package/dist/components/setup-checklist.stories.js +305 -0
- package/dist/components/setup-checklist.stories.js.map +1 -0
- package/dist/components/workspace-setup-checklist.d.ts +3 -0
- package/dist/components/workspace-setup-checklist.d.ts.map +1 -0
- package/dist/components/workspace-setup-checklist.js +80 -0
- package/dist/components/workspace-setup-checklist.js.map +1 -0
- package/dist/components/workspace-setup-indicator.d.ts +3 -0
- package/dist/components/workspace-setup-indicator.d.ts.map +1 -0
- package/dist/components/workspace-setup-indicator.js +133 -0
- package/dist/components/workspace-setup-indicator.js.map +1 -0
- package/dist/core/setup-checklist.d.ts +2 -0
- package/dist/core/setup-checklist.d.ts.map +1 -1
- package/dist/core/setup-checklist.js +11 -6
- package/dist/core/setup-checklist.js.map +1 -1
- package/dist/feature.d.ts +6 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +19 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/setup-checklist.d.ts +13 -0
- package/dist/hooks/api/setup-checklist.d.ts.map +1 -0
- package/dist/hooks/api/setup-checklist.js +103 -0
- package/dist/hooks/api/setup-checklist.js.map +1 -0
- package/dist/hooks/use-checklist-analytics.d.ts +6 -0
- package/dist/hooks/use-checklist-analytics.d.ts.map +1 -0
- package/dist/hooks/use-checklist-analytics.js +51 -0
- package/dist/hooks/use-checklist-analytics.js.map +1 -0
- package/dist/hooks/use-checklist-dismissal.d.ts +5 -0
- package/dist/hooks/use-checklist-dismissal.d.ts.map +1 -0
- package/dist/hooks/use-checklist-dismissal.js +31 -0
- package/dist/hooks/use-checklist-dismissal.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +12 -5
- package/src/components/setup-checklist-body.tsx +35 -0
- package/src/components/setup-checklist-completion.test.tsx +124 -0
- package/src/components/setup-checklist-completion.tsx +165 -0
- package/src/components/setup-checklist-confetti.test.ts +19 -0
- package/src/components/setup-checklist-confetti.ts +39 -0
- package/src/components/setup-checklist-host-primitives.tsx +65 -0
- package/src/components/setup-checklist-row.tsx +189 -0
- package/src/components/setup-checklist-types.ts +21 -0
- package/src/components/setup-checklist.stories.tsx +281 -0
- package/src/components/setup-checklist.test.tsx +564 -0
- package/src/components/setup-checklist.tsx +8 -0
- package/src/components/workspace-setup-checklist.tsx +91 -0
- package/src/components/workspace-setup-indicator.tsx +126 -0
- package/src/core/setup-checklist.test.ts +2 -2
- package/src/core/setup-checklist.ts +19 -10
- package/src/feature.ts +16 -0
- package/src/hooks/api/setup-checklist.ts +144 -0
- package/src/hooks/use-checklist-analytics.ts +60 -0
- package/src/hooks/use-checklist-dismissal.ts +30 -0
- package/src/index.ts +8 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vitest.config.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-onboarding",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "25.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -14,14 +14,21 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./feature": {
|
|
19
|
+
"types": "./dist/feature.d.ts",
|
|
20
|
+
"default": "./dist/feature.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"dependencies": {
|
|
20
24
|
"@swc/helpers": "^0.5.17",
|
|
21
|
-
"@shipfox/client-agent": "
|
|
22
|
-
"@shipfox/client-integrations": "
|
|
23
|
-
"@shipfox/client-projects": "
|
|
24
|
-
"@shipfox/client-
|
|
25
|
+
"@shipfox/client-agent": "25.0.0",
|
|
26
|
+
"@shipfox/client-integrations": "25.0.0",
|
|
27
|
+
"@shipfox/client-projects": "25.0.0",
|
|
28
|
+
"@shipfox/client-runners": "25.0.0",
|
|
29
|
+
"@shipfox/client-shell": "25.0.0",
|
|
30
|
+
"@shipfox/client-workspace-settings": "25.0.0",
|
|
31
|
+
"@shipfox/react-ui": "2.2.0"
|
|
25
32
|
},
|
|
26
33
|
"peerDependencies": {
|
|
27
34
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {SetupChecklistCompletion} from './setup-checklist-completion.js';
|
|
2
|
+
import {ChecklistRow} from './setup-checklist-row.js';
|
|
3
|
+
import type {SetupChecklistBodyProps} from './setup-checklist-types.js';
|
|
4
|
+
|
|
5
|
+
export function SetupChecklistBody({
|
|
6
|
+
checklist,
|
|
7
|
+
workspaceSlug,
|
|
8
|
+
completion = false,
|
|
9
|
+
showBurst = false,
|
|
10
|
+
onBurstComplete,
|
|
11
|
+
onAction,
|
|
12
|
+
onDone,
|
|
13
|
+
}: SetupChecklistBodyProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
{completion ? (
|
|
17
|
+
<SetupChecklistCompletion
|
|
18
|
+
showBurst={showBurst}
|
|
19
|
+
onBurstComplete={onBurstComplete}
|
|
20
|
+
onDone={onDone}
|
|
21
|
+
/>
|
|
22
|
+
) : null}
|
|
23
|
+
<ol aria-label="Setup steps" className="m-0 list-none p-0">
|
|
24
|
+
{checklist.items.map((item) => (
|
|
25
|
+
<ChecklistRow
|
|
26
|
+
key={item.id}
|
|
27
|
+
item={item}
|
|
28
|
+
workspaceSlug={workspaceSlug}
|
|
29
|
+
onAction={onAction}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</ol>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {afterEach, describe, expect, test, vi} from '@shipfox/vitest/vi';
|
|
4
|
+
import {act, render} from '@testing-library/react';
|
|
5
|
+
import {SetupChecklistCompletion} from './setup-checklist-completion.js';
|
|
6
|
+
import {CONFETTI_PARTICLE_COUNT} from './setup-checklist-confetti.js';
|
|
7
|
+
|
|
8
|
+
const CONFETTI_START_TIME = 1000;
|
|
9
|
+
const CONFETTI_DURATION_MS = 2000;
|
|
10
|
+
const CANVAS_WIDTH = 480;
|
|
11
|
+
const CANVAS_HEIGHT = 88;
|
|
12
|
+
|
|
13
|
+
interface CanvasContextSpies {
|
|
14
|
+
clearRect: ReturnType<typeof vi.fn>;
|
|
15
|
+
fillRect: ReturnType<typeof vi.fn>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setupCanvas(reducedMotion: boolean): CanvasContextSpies {
|
|
19
|
+
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0');
|
|
20
|
+
vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
21
|
+
(query) =>
|
|
22
|
+
({
|
|
23
|
+
addEventListener: vi.fn(),
|
|
24
|
+
addListener: vi.fn(),
|
|
25
|
+
dispatchEvent: vi.fn(() => false),
|
|
26
|
+
matches: reducedMotion,
|
|
27
|
+
media: query,
|
|
28
|
+
onchange: null,
|
|
29
|
+
removeEventListener: vi.fn(),
|
|
30
|
+
removeListener: vi.fn(),
|
|
31
|
+
}) as MediaQueryList,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const clearRect = vi.fn();
|
|
35
|
+
const fillRect = vi.fn();
|
|
36
|
+
const context = {
|
|
37
|
+
clearRect,
|
|
38
|
+
fillRect,
|
|
39
|
+
fillStyle: '',
|
|
40
|
+
globalAlpha: 1,
|
|
41
|
+
restore: vi.fn(),
|
|
42
|
+
rotate: vi.fn(),
|
|
43
|
+
save: vi.fn(),
|
|
44
|
+
scale: vi.fn(),
|
|
45
|
+
translate: vi.fn(),
|
|
46
|
+
} as unknown as CanvasRenderingContext2D;
|
|
47
|
+
|
|
48
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
49
|
+
bottom: CANVAS_HEIGHT,
|
|
50
|
+
height: CANVAS_HEIGHT,
|
|
51
|
+
left: 0,
|
|
52
|
+
right: CANVAS_WIDTH,
|
|
53
|
+
top: 0,
|
|
54
|
+
width: CANVAS_WIDTH,
|
|
55
|
+
x: 0,
|
|
56
|
+
y: 0,
|
|
57
|
+
toJSON: () => ({}),
|
|
58
|
+
} as DOMRect);
|
|
59
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
|
60
|
+
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
61
|
+
getPropertyValue: () => '#22c55e',
|
|
62
|
+
} as unknown as CSSStyleDeclaration);
|
|
63
|
+
vi.spyOn(performance, 'now').mockReturnValue(CONFETTI_START_TIME);
|
|
64
|
+
|
|
65
|
+
return {clearRect, fillRect};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('SetupChecklistCompletion burst', () => {
|
|
73
|
+
test('keeps a reduced-motion frame after the host consumes the burst', () => {
|
|
74
|
+
const context = setupCanvas(true);
|
|
75
|
+
const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame');
|
|
76
|
+
const onBurstComplete = vi.fn();
|
|
77
|
+
const {rerender} = render(
|
|
78
|
+
<SetupChecklistCompletion showBurst onBurstComplete={onBurstComplete} />,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(onBurstComplete).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
|
83
|
+
expect(context.fillRect).toHaveBeenCalledTimes(CONFETTI_PARTICLE_COUNT);
|
|
84
|
+
|
|
85
|
+
const clearCallsAfterDraw = context.clearRect.mock.calls.length;
|
|
86
|
+
rerender(<SetupChecklistCompletion showBurst={false} onBurstComplete={onBurstComplete} />);
|
|
87
|
+
|
|
88
|
+
expect(context.clearRect).toHaveBeenCalledTimes(clearCallsAfterDraw);
|
|
89
|
+
expect(context.fillRect).toHaveBeenCalledTimes(CONFETTI_PARTICLE_COUNT);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('animates and completes the burst when motion is allowed', () => {
|
|
93
|
+
const context = setupCanvas(false);
|
|
94
|
+
let frameCallback: FrameRequestCallback | undefined;
|
|
95
|
+
const requestAnimationFrame = vi
|
|
96
|
+
.spyOn(window, 'requestAnimationFrame')
|
|
97
|
+
.mockImplementation((callback) => {
|
|
98
|
+
frameCallback = callback;
|
|
99
|
+
return 1;
|
|
100
|
+
});
|
|
101
|
+
const cancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame');
|
|
102
|
+
const onBurstComplete = vi.fn();
|
|
103
|
+
|
|
104
|
+
render(<SetupChecklistCompletion showBurst onBurstComplete={onBurstComplete} />);
|
|
105
|
+
|
|
106
|
+
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(context.fillRect).not.toHaveBeenCalled();
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
frameCallback?.(CONFETTI_START_TIME + 16);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(context.fillRect).toHaveBeenCalledTimes(CONFETTI_PARTICLE_COUNT);
|
|
114
|
+
expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
|
|
115
|
+
expect(onBurstComplete).not.toHaveBeenCalled();
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
frameCallback?.(CONFETTI_START_TIME + CONFETTI_DURATION_MS);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(onBurstComplete).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(cancelAnimationFrame).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
2
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
3
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
4
|
+
import {useEffect, useRef} from 'react';
|
|
5
|
+
import {createConfettiParticles} from './setup-checklist-confetti.js';
|
|
6
|
+
|
|
7
|
+
const JSDOM_USER_AGENT_RE = /jsdom/u;
|
|
8
|
+
const CONFETTI_DURATION_MS = 2000;
|
|
9
|
+
const CONFETTI_RANDOM_SEED = 0x5f3759df;
|
|
10
|
+
|
|
11
|
+
export function SetupChecklistCompletion({
|
|
12
|
+
showBurst,
|
|
13
|
+
onBurstComplete,
|
|
14
|
+
onDone,
|
|
15
|
+
}: {
|
|
16
|
+
showBurst: boolean;
|
|
17
|
+
onBurstComplete?: (() => void) | undefined;
|
|
18
|
+
onDone?: (() => void) | undefined;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="relative overflow-hidden border-b border-tag-success-border bg-tag-success-bg p-panel">
|
|
22
|
+
<ConfettiBurst active={showBurst} onComplete={onBurstComplete} />
|
|
23
|
+
<div className="relative flex items-center gap-group">
|
|
24
|
+
<Icon
|
|
25
|
+
name="checkCircleSolid"
|
|
26
|
+
className="size-20 shrink-0 text-tag-success-icon"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
/>
|
|
29
|
+
<div className="min-w-0 flex-1">
|
|
30
|
+
<Text size="sm" bold className="text-tag-success-text">
|
|
31
|
+
You're set up
|
|
32
|
+
</Text>
|
|
33
|
+
<Text size="xs" className="text-tag-success-text">
|
|
34
|
+
Your workspace is ready for its first workflow.
|
|
35
|
+
</Text>
|
|
36
|
+
</div>
|
|
37
|
+
<Button type="button" size="sm" variant="primary" onClick={onDone}>
|
|
38
|
+
Done
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ConfettiBurst({
|
|
46
|
+
active,
|
|
47
|
+
onComplete,
|
|
48
|
+
}: {
|
|
49
|
+
active: boolean;
|
|
50
|
+
onComplete?: (() => void) | undefined;
|
|
51
|
+
}) {
|
|
52
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!active || typeof window === 'undefined') return;
|
|
56
|
+
let completed = false;
|
|
57
|
+
const finish = () => {
|
|
58
|
+
if (completed) return;
|
|
59
|
+
completed = true;
|
|
60
|
+
onComplete?.();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const prefersReducedMotion =
|
|
64
|
+
window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
|
|
65
|
+
if (typeof navigator !== 'undefined' && JSDOM_USER_AGENT_RE.test(navigator.userAgent)) {
|
|
66
|
+
finish();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const canvas = canvasRef.current;
|
|
71
|
+
if (!canvas) {
|
|
72
|
+
finish();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const bounds = canvas.getBoundingClientRect();
|
|
76
|
+
if (bounds.width === 0 && bounds.height === 0) {
|
|
77
|
+
finish();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const context = canvas.getContext('2d');
|
|
81
|
+
if (!context) {
|
|
82
|
+
finish();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
87
|
+
const width = Math.max(bounds.width, 1);
|
|
88
|
+
const height = Math.max(bounds.height, 1);
|
|
89
|
+
canvas.width = Math.floor(width * pixelRatio);
|
|
90
|
+
canvas.height = Math.floor(height * pixelRatio);
|
|
91
|
+
context.scale(pixelRatio, pixelRatio);
|
|
92
|
+
|
|
93
|
+
const styles = getComputedStyle(canvas);
|
|
94
|
+
const colors = [
|
|
95
|
+
styles.getPropertyValue('--color-background-accent-blue-base').trim(),
|
|
96
|
+
styles.getPropertyValue('--color-background-accent-purple-base').trim(),
|
|
97
|
+
styles.getPropertyValue('--color-background-accent-success-base').trim(),
|
|
98
|
+
styles.getPropertyValue('--color-background-accent-warning-base').trim(),
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
if (colors.length === 0) {
|
|
101
|
+
finish();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const particles = createConfettiParticles(
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
colors,
|
|
108
|
+
prefersReducedMotion ? CONFETTI_RANDOM_SEED : undefined,
|
|
109
|
+
);
|
|
110
|
+
let frame = 0;
|
|
111
|
+
const startedAt = performance.now();
|
|
112
|
+
|
|
113
|
+
const draw = (now: number, advance = true) => {
|
|
114
|
+
const elapsed = now - startedAt;
|
|
115
|
+
context.clearRect(0, 0, width, height);
|
|
116
|
+
context.globalAlpha = Math.max(0, 1 - elapsed / CONFETTI_DURATION_MS);
|
|
117
|
+
for (const particle of particles) {
|
|
118
|
+
if (advance) {
|
|
119
|
+
particle.vy += 0.025;
|
|
120
|
+
particle.vx *= 0.985;
|
|
121
|
+
particle.x += particle.vx;
|
|
122
|
+
particle.y += particle.vy;
|
|
123
|
+
particle.rotation += 0.1;
|
|
124
|
+
}
|
|
125
|
+
context.save();
|
|
126
|
+
context.translate(particle.x, particle.y);
|
|
127
|
+
context.rotate(particle.rotation);
|
|
128
|
+
context.fillStyle = particle.color;
|
|
129
|
+
context.fillRect(
|
|
130
|
+
-particle.size / 2,
|
|
131
|
+
-particle.size / 2,
|
|
132
|
+
particle.size,
|
|
133
|
+
particle.size * 0.6,
|
|
134
|
+
);
|
|
135
|
+
context.restore();
|
|
136
|
+
}
|
|
137
|
+
context.globalAlpha = 1;
|
|
138
|
+
if (!advance) return;
|
|
139
|
+
if (elapsed < CONFETTI_DURATION_MS) {
|
|
140
|
+
frame = requestAnimationFrame(draw);
|
|
141
|
+
} else {
|
|
142
|
+
finish();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (prefersReducedMotion) {
|
|
147
|
+
draw(startedAt, false);
|
|
148
|
+
finish();
|
|
149
|
+
// Keep the static frame after hosts consume the burst immediately.
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
frame = requestAnimationFrame(draw);
|
|
154
|
+
return () => {
|
|
155
|
+
cancelAnimationFrame(frame);
|
|
156
|
+
context.clearRect(0, 0, width, height);
|
|
157
|
+
};
|
|
158
|
+
}, [active, onComplete]);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-0 size-full">
|
|
162
|
+
<canvas ref={canvasRef} className="size-full" />
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {describe, expect, test} from '@shipfox/vitest/vi';
|
|
2
|
+
import {CONFETTI_PARTICLE_COUNT, createConfettiParticles} from './setup-checklist-confetti.js';
|
|
3
|
+
|
|
4
|
+
describe('createConfettiParticles', () => {
|
|
5
|
+
test('creates a deterministic particle layout within the burst bounds', () => {
|
|
6
|
+
const palette = ['blue', 'purple', 'success', 'warning'];
|
|
7
|
+
const seed = 0x5f3759df;
|
|
8
|
+
|
|
9
|
+
const first = createConfettiParticles(480, 88, palette, seed);
|
|
10
|
+
const second = createConfettiParticles(480, 88, palette, seed);
|
|
11
|
+
const xPositions = first.map(({x}) => x);
|
|
12
|
+
|
|
13
|
+
expect(first).toEqual(second);
|
|
14
|
+
expect(first).toHaveLength(CONFETTI_PARTICLE_COUNT);
|
|
15
|
+
expect(Math.min(...xPositions)).toBeGreaterThanOrEqual(480 * 0.225);
|
|
16
|
+
expect(Math.max(...xPositions)).toBeLessThanOrEqual(480 * 0.775);
|
|
17
|
+
expect(new Set(xPositions).size).toBeGreaterThan(1);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const CONFETTI_PARTICLE_COUNT = 48;
|
|
2
|
+
|
|
3
|
+
interface ConfettiParticle {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
vx: number;
|
|
7
|
+
vy: number;
|
|
8
|
+
rotation: number;
|
|
9
|
+
size: number;
|
|
10
|
+
color: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createConfettiParticles(
|
|
14
|
+
width: number,
|
|
15
|
+
height: number,
|
|
16
|
+
palette: readonly string[],
|
|
17
|
+
seed?: number,
|
|
18
|
+
): ConfettiParticle[] {
|
|
19
|
+
const random = seed === undefined ? Math.random : createSeededRandom(seed);
|
|
20
|
+
|
|
21
|
+
return Array.from({length: CONFETTI_PARTICLE_COUNT}, (_, index) => ({
|
|
22
|
+
x: width / 2 + (random() - 0.5) * width * 0.55,
|
|
23
|
+
y: height * (0.4 + random() * 0.15),
|
|
24
|
+
vx: (random() - 0.5) * 2.5,
|
|
25
|
+
vy: -(random() * 0.7 + 0.4),
|
|
26
|
+
rotation: random() * Math.PI,
|
|
27
|
+
size: random() * 5 + 5,
|
|
28
|
+
color: palette[index % palette.length] ?? palette[0] ?? '',
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createSeededRandom(seed: number): () => number {
|
|
33
|
+
let state = seed >>> 0;
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
|
|
37
|
+
return state / 2 ** 32;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {Button, IconButton} from '@shipfox/react-ui/button';
|
|
2
|
+
import {PanelActions, PanelHeader, PanelTitle} from '@shipfox/react-ui/panel';
|
|
3
|
+
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
4
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
5
|
+
import type {SetupChecklist} from '#core/setup-checklist.js';
|
|
6
|
+
|
|
7
|
+
export function ChecklistDismissAction({onDismiss}: {onDismiss: () => void}) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex justify-end border-t border-border-neutral-base px-row py-row">
|
|
10
|
+
<Button type="button" size="sm" variant="transparentMuted" onClick={onDismiss}>
|
|
11
|
+
Hide setup guide
|
|
12
|
+
</Button>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ChecklistHeader({
|
|
18
|
+
count,
|
|
19
|
+
onDismiss,
|
|
20
|
+
}: {
|
|
21
|
+
count?: string | undefined;
|
|
22
|
+
onDismiss: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<PanelHeader>
|
|
26
|
+
<div className="flex min-w-0 items-center gap-group">
|
|
27
|
+
<PanelTitle>Get started</PanelTitle>
|
|
28
|
+
{count ? (
|
|
29
|
+
<Text as="span" size="sm" className="shrink-0 text-foreground-neutral-muted">
|
|
30
|
+
{count}
|
|
31
|
+
</Text>
|
|
32
|
+
) : null}
|
|
33
|
+
</div>
|
|
34
|
+
<PanelActions>
|
|
35
|
+
<IconButton
|
|
36
|
+
type="button"
|
|
37
|
+
variant="transparent"
|
|
38
|
+
size="sm"
|
|
39
|
+
muted
|
|
40
|
+
icon="close"
|
|
41
|
+
aria-label="Hide setup guide"
|
|
42
|
+
onClick={onDismiss}
|
|
43
|
+
/>
|
|
44
|
+
</PanelActions>
|
|
45
|
+
</PanelHeader>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ChecklistSkeleton() {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
role="status"
|
|
53
|
+
aria-label="Loading setup guide"
|
|
54
|
+
className="flex flex-col gap-inline p-panel"
|
|
55
|
+
>
|
|
56
|
+
<Skeleton className="h-16 w-3/4" />
|
|
57
|
+
<Skeleton className="h-16 w-5/6" />
|
|
58
|
+
<Skeleton className="h-16 w-2/3" />
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checklistCountLabel(checklist: SetupChecklist) {
|
|
64
|
+
return `${checklist.trackedCount - checklist.openCount} of ${checklist.trackedCount} done`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {ButtonLink} from '@shipfox/react-ui/button';
|
|
2
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
3
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
4
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
5
|
+
import {Link} from '@tanstack/react-router';
|
|
6
|
+
import type {SetupChecklistItem} from '#core/setup-checklist.js';
|
|
7
|
+
|
|
8
|
+
const GETTING_STARTED_URL = 'https://www.shipfox.io/docs/getting-started';
|
|
9
|
+
|
|
10
|
+
export function ChecklistRow({
|
|
11
|
+
item,
|
|
12
|
+
workspaceSlug,
|
|
13
|
+
onAction,
|
|
14
|
+
}: {
|
|
15
|
+
item: SetupChecklistItem;
|
|
16
|
+
workspaceSlug: string;
|
|
17
|
+
onAction?: ((item: SetupChecklistItem) => void) | undefined;
|
|
18
|
+
}) {
|
|
19
|
+
const pointer = !item.tracked;
|
|
20
|
+
const showDetails = pointer || item.status === 'open';
|
|
21
|
+
const statusLabel =
|
|
22
|
+
item.status === 'done'
|
|
23
|
+
? 'done'
|
|
24
|
+
: pointer
|
|
25
|
+
? 'next step'
|
|
26
|
+
: item.attention
|
|
27
|
+
? 'needs attention'
|
|
28
|
+
: 'to do';
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<li className="flex min-w-0 items-start gap-group border-b border-border-neutral-base px-row py-row last:border-b-0">
|
|
32
|
+
<ChecklistStatus item={item} pointer={pointer} label={statusLabel} />
|
|
33
|
+
<div className="min-w-0 flex-1">
|
|
34
|
+
<Text
|
|
35
|
+
as="span"
|
|
36
|
+
size="sm"
|
|
37
|
+
className={cn(
|
|
38
|
+
'block',
|
|
39
|
+
item.status === 'done' && !pointer
|
|
40
|
+
? 'text-foreground-neutral-muted'
|
|
41
|
+
: 'text-foreground-neutral-base',
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{item.title}
|
|
45
|
+
</Text>
|
|
46
|
+
{showDetails && item.purpose ? (
|
|
47
|
+
<Text as="span" size="xs" className="mt-tight block text-foreground-neutral-muted">
|
|
48
|
+
{item.purpose}
|
|
49
|
+
</Text>
|
|
50
|
+
) : null}
|
|
51
|
+
{showDetails && item.action ? (
|
|
52
|
+
<div className="mt-inline">
|
|
53
|
+
<ChecklistActionLink
|
|
54
|
+
item={item}
|
|
55
|
+
action={item.action}
|
|
56
|
+
pointer={pointer}
|
|
57
|
+
workspaceSlug={workspaceSlug}
|
|
58
|
+
onAction={onAction}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
</li>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ChecklistStatus({
|
|
68
|
+
item,
|
|
69
|
+
pointer,
|
|
70
|
+
label,
|
|
71
|
+
}: {
|
|
72
|
+
item: SetupChecklistItem;
|
|
73
|
+
pointer: boolean;
|
|
74
|
+
label: string;
|
|
75
|
+
}) {
|
|
76
|
+
if (pointer) {
|
|
77
|
+
return (
|
|
78
|
+
<span className="mt-1 shrink-0">
|
|
79
|
+
<Icon
|
|
80
|
+
name={item.status === 'done' ? 'checkCircleSolid' : 'circleDottedLine'}
|
|
81
|
+
className={cn(
|
|
82
|
+
'size-16',
|
|
83
|
+
item.status === 'done'
|
|
84
|
+
? 'text-foreground-highlight-interactive'
|
|
85
|
+
: 'text-foreground-neutral-subtle',
|
|
86
|
+
)}
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
/>
|
|
89
|
+
<span className="sr-only">{label}</span>
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (item.status === 'done') {
|
|
95
|
+
return (
|
|
96
|
+
<span className="mt-1 shrink-0">
|
|
97
|
+
<Icon
|
|
98
|
+
name="checkCircleSolid"
|
|
99
|
+
className="size-16 text-foreground-highlight-interactive"
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
/>
|
|
102
|
+
<span className="sr-only">{label}</span>
|
|
103
|
+
</span>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<span className="mt-1 shrink-0">
|
|
109
|
+
<span
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
className="block size-16 rounded-full border-2 border-foreground-neutral-subtle"
|
|
112
|
+
/>
|
|
113
|
+
<span className="sr-only">{label}</span>
|
|
114
|
+
</span>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ChecklistActionLink({
|
|
119
|
+
item,
|
|
120
|
+
action,
|
|
121
|
+
pointer,
|
|
122
|
+
workspaceSlug,
|
|
123
|
+
onAction,
|
|
124
|
+
}: {
|
|
125
|
+
item: SetupChecklistItem;
|
|
126
|
+
action: NonNullable<SetupChecklistItem['action']>;
|
|
127
|
+
pointer: boolean;
|
|
128
|
+
workspaceSlug: string;
|
|
129
|
+
onAction?: ((item: SetupChecklistItem) => void) | undefined;
|
|
130
|
+
}) {
|
|
131
|
+
const handleClick = () => onAction?.(item);
|
|
132
|
+
const variant = pointer ? 'muted' : 'interactive';
|
|
133
|
+
|
|
134
|
+
if (action.href === '/docs/getting-started') {
|
|
135
|
+
return (
|
|
136
|
+
<ButtonLink asChild variant={variant} underline iconRight="chevronRight">
|
|
137
|
+
<a href={GETTING_STARTED_URL} onClick={handleClick}>
|
|
138
|
+
{action.label}
|
|
139
|
+
</a>
|
|
140
|
+
</ButtonLink>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (action.href === '/settings/integrations') {
|
|
145
|
+
return (
|
|
146
|
+
<ButtonLink asChild variant={variant} underline iconRight="chevronRight">
|
|
147
|
+
<Link
|
|
148
|
+
to="/w/$workspaceSlug/settings/integrations"
|
|
149
|
+
params={{workspaceSlug}}
|
|
150
|
+
onClick={handleClick}
|
|
151
|
+
>
|
|
152
|
+
{action.label}
|
|
153
|
+
</Link>
|
|
154
|
+
</ButtonLink>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (action.href === '/settings/runners') {
|
|
159
|
+
return (
|
|
160
|
+
<ButtonLink asChild variant={variant} underline iconRight="chevronRight">
|
|
161
|
+
<Link
|
|
162
|
+
to="/w/$workspaceSlug/settings/runners"
|
|
163
|
+
params={{workspaceSlug}}
|
|
164
|
+
onClick={handleClick}
|
|
165
|
+
>
|
|
166
|
+
{action.label}
|
|
167
|
+
</Link>
|
|
168
|
+
</ButtonLink>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (action.href === '/settings/agents') {
|
|
173
|
+
return (
|
|
174
|
+
<ButtonLink asChild variant={variant} underline iconRight="chevronRight">
|
|
175
|
+
<Link to="/w/$workspaceSlug/settings/agents" params={{workspaceSlug}} onClick={handleClick}>
|
|
176
|
+
{action.label}
|
|
177
|
+
</Link>
|
|
178
|
+
</ButtonLink>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<ButtonLink asChild variant={variant} underline iconRight="chevronRight">
|
|
184
|
+
<Link to="/w/$workspaceSlug/settings/members" params={{workspaceSlug}} onClick={handleClick}>
|
|
185
|
+
{action.label}
|
|
186
|
+
</Link>
|
|
187
|
+
</ButtonLink>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {SetupChecklist, SetupChecklistItem} from '#core/setup-checklist.js';
|
|
2
|
+
|
|
3
|
+
export interface WorkspaceReference {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorkspaceSetupHostProps {
|
|
9
|
+
/** A stable workspace makes the hosts easy to compose in isolated surfaces and stories. */
|
|
10
|
+
workspace?: WorkspaceReference;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SetupChecklistBodyProps {
|
|
14
|
+
checklist: SetupChecklist;
|
|
15
|
+
workspaceSlug: string;
|
|
16
|
+
completion?: boolean;
|
|
17
|
+
showBurst?: boolean;
|
|
18
|
+
onBurstComplete?: () => void;
|
|
19
|
+
onAction?: ((item: SetupChecklistItem) => void) | undefined;
|
|
20
|
+
onDone?: () => void;
|
|
21
|
+
}
|