@pokit/reporter-web 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokit/reporter-web",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Web/React event reporter for pok CLI applications",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,9 +25,8 @@
25
25
  "bugs": {
26
26
  "url": "https://github.com/notation-dev/openpok/issues"
27
27
  },
28
- "main": "./src/index.ts",
29
- "module": "./src/index.ts",
30
- "types": "./src/index.ts",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
31
30
  "exports": {
32
31
  ".": {
33
32
  "bun": "./src/index.ts",
@@ -38,7 +37,8 @@
38
37
  "files": [
39
38
  "dist",
40
39
  "README.md",
41
- "LICENSE"
40
+ "LICENSE",
41
+ "src"
42
42
  ],
43
43
  "publishConfig": {
44
44
  "access": "public"
@@ -46,11 +46,11 @@
46
46
  "devDependencies": {
47
47
  "@types/bun": "latest",
48
48
  "@types/react": "^18.2.0",
49
- "@pokit/core": "0.0.1"
49
+ "@pokit/core": "0.0.2"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^18.0.0 || ^19.0.0",
53
- "@pokit/core": "0.0.1"
53
+ "@pokit/core": "0.0.2"
54
54
  },
55
55
  "engines": {
56
56
  "bun": ">=1.0.0"
package/src/adapter.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Web Reporter Adapter
3
+ *
4
+ * Implements the ReporterAdapter interface for web/React environments.
5
+ * Connects the EventBus to a ReporterStore for state management.
6
+ */
7
+
8
+ import type { ReporterAdapter, ReporterAdapterController, EventBus } from '@pokit/core';
9
+ import type { ReporterStoreWithHandler } from './store';
10
+
11
+ /**
12
+ * Create a web reporter adapter that pipes events to a store
13
+ *
14
+ * @param store - The reporter store (must be created with createReporterStore)
15
+ * @returns ReporterAdapter instance
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { createReporterStore, createWebReporterAdapter } from '@pokit/reporter-web';
20
+ * import { createEventBus } from '@pokit/core';
21
+ *
22
+ * const store = createReporterStore();
23
+ * const adapter = createWebReporterAdapter(store);
24
+ * const bus = createEventBus();
25
+ *
26
+ * const controller = adapter.start(bus);
27
+ *
28
+ * // Events emitted to the bus will update the store
29
+ * bus.emit({ type: 'root:start', appName: 'my-app' });
30
+ *
31
+ * // In React:
32
+ * // const state = useReporterState(store);
33
+ *
34
+ * // Cleanup
35
+ * controller.stop();
36
+ * ```
37
+ */
38
+ export function createWebReporterAdapter(store: ReporterStoreWithHandler): ReporterAdapter {
39
+ return {
40
+ start(bus: EventBus): ReporterAdapterController {
41
+ let stopped = false;
42
+
43
+ const unsubscribe = bus.on((event) => {
44
+ if (stopped) return;
45
+ store._handleEvent(event);
46
+ });
47
+
48
+ return {
49
+ stop(): void {
50
+ if (stopped) return;
51
+ stopped = true;
52
+ unsubscribe();
53
+ },
54
+ };
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CommandBlock - A headless component for displaying shell commands with output.
3
+ *
4
+ * CSS Variables Contract:
5
+ * - --tutorial-code-bg: Background color for command/output area
6
+ * - --tutorial-bg: Default background color
7
+ * - --tutorial-text: Primary text color
8
+ * - --tutorial-text-muted: Secondary/muted text color
9
+ * - --tutorial-border: Border color
10
+ * - --tutorial-action-bg: Action button background
11
+ * - --tutorial-action-hover: Action button hover background
12
+ *
13
+ * Data Attributes:
14
+ * - [data-status="idle|running|complete|failed"]: Command status for styling
15
+ */
16
+
17
+ export type CommandBlockStatus = 'idle' | 'running' | 'complete' | 'failed';
18
+
19
+ export type CommandBlockActionProps = {
20
+ onClick: () => void;
21
+ status: CommandBlockStatus;
22
+ disabled: boolean;
23
+ };
24
+
25
+ export type CommandBlockProps = {
26
+ /** Command to display */
27
+ command: string;
28
+ /** Current status of the command execution */
29
+ status: CommandBlockStatus;
30
+ /** Output lines from the command */
31
+ output?: string[];
32
+ /** Callback when run action is triggered (if no renderAction provided) */
33
+ onRun?: () => void;
34
+ /** Render prop for custom action button */
35
+ renderAction?: (props: CommandBlockActionProps) => React.ReactNode;
36
+ };
37
+
38
+ export function CommandBlock({
39
+ command,
40
+ status,
41
+ output,
42
+ onRun,
43
+ renderAction,
44
+ }: CommandBlockProps) {
45
+ const disabled = status === 'running';
46
+ const handleClick = () => {
47
+ if (!disabled && onRun) {
48
+ onRun();
49
+ }
50
+ };
51
+
52
+ const actionProps: CommandBlockActionProps = {
53
+ onClick: handleClick,
54
+ status,
55
+ disabled,
56
+ };
57
+
58
+ return (
59
+ <div className="command-block" data-status={status}>
60
+ <div className="command-block-command">
61
+ <span className="command-block-prompt">$</span>
62
+ <span className="command-block-text">{command}</span>
63
+ </div>
64
+ {output && output.length > 0 && (
65
+ <div className="command-block-output">
66
+ {output.map((line, index) => (
67
+ <div key={index} className="command-block-output-line">
68
+ {line}
69
+ </div>
70
+ ))}
71
+ </div>
72
+ )}
73
+ {(renderAction || onRun) && (
74
+ <div className="command-block-actions">
75
+ {renderAction ? (
76
+ renderAction(actionProps)
77
+ ) : (
78
+ <button
79
+ className="command-block-action-button"
80
+ onClick={handleClick}
81
+ disabled={disabled}
82
+ data-status={status}
83
+ >
84
+ {status === 'idle' && 'Run'}
85
+ {status === 'running' && 'Running...'}
86
+ {status === 'complete' && 'Run Again'}
87
+ {status === 'failed' && 'Retry'}
88
+ </button>
89
+ )}
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ContentBox - A headless container component for info/tip/warning content.
3
+ *
4
+ * CSS Variables Contract:
5
+ * - --tutorial-bg: Default background color
6
+ * - --tutorial-text: Primary text color
7
+ * - --tutorial-text-muted: Secondary/muted text color
8
+ * - --tutorial-border: Border color
9
+ *
10
+ * Data Attributes:
11
+ * - [data-variant="info|tip|warning"]: Content variant for styling
12
+ */
13
+
14
+ export type ContentBoxVariant = 'info' | 'tip' | 'warning';
15
+
16
+ export type ContentBoxProps = {
17
+ /** Visual variant of the content box */
18
+ variant: ContentBoxVariant;
19
+ /** Optional title for the content box */
20
+ title?: string;
21
+ /** Content to display inside the box */
22
+ children: React.ReactNode;
23
+ };
24
+
25
+ export function ContentBox({ variant, title, children }: ContentBoxProps) {
26
+ return (
27
+ <div className="content-box" data-variant={variant}>
28
+ {title && <div className="content-box-title">{title}</div>}
29
+ <div className="content-box-content">{children}</div>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * FilePreview - A headless component for displaying file content with action slot.
3
+ *
4
+ * CSS Variables Contract:
5
+ * - --tutorial-code-bg: Background color for code/file content area
6
+ * - --tutorial-bg: Default background color
7
+ * - --tutorial-text: Primary text color
8
+ * - --tutorial-text-muted: Secondary/muted text color
9
+ * - --tutorial-border: Border color
10
+ * - --tutorial-action-bg: Action button background
11
+ * - --tutorial-action-hover: Action button hover background
12
+ *
13
+ * Data Attributes:
14
+ * - [data-status="pending|creating|created"]: File status for styling
15
+ * - [data-language="..."]: Language hint for syntax highlighting
16
+ */
17
+
18
+ export type FilePreviewStatus = 'pending' | 'creating' | 'created';
19
+
20
+ export type FilePreviewActionProps = {
21
+ onClick: () => void;
22
+ status: FilePreviewStatus;
23
+ disabled: boolean;
24
+ };
25
+
26
+ export type FilePreviewProps = {
27
+ /** File path to display in header */
28
+ path: string;
29
+ /** File content to display */
30
+ content: string;
31
+ /** Language for syntax highlighting hint */
32
+ language?: string;
33
+ /** Current status of the file operation */
34
+ status: FilePreviewStatus;
35
+ /** Callback when action is triggered (if no renderAction provided) */
36
+ onAction?: () => void;
37
+ /** Render prop for custom action button */
38
+ renderAction?: (props: FilePreviewActionProps) => React.ReactNode;
39
+ };
40
+
41
+ export function FilePreview({
42
+ path,
43
+ content,
44
+ language,
45
+ status,
46
+ onAction,
47
+ renderAction,
48
+ }: FilePreviewProps) {
49
+ const disabled = status === 'creating' || status === 'created';
50
+ const handleClick = () => {
51
+ if (!disabled && onAction) {
52
+ onAction();
53
+ }
54
+ };
55
+
56
+ const actionProps: FilePreviewActionProps = {
57
+ onClick: handleClick,
58
+ status,
59
+ disabled,
60
+ };
61
+
62
+ return (
63
+ <div
64
+ className="file-preview"
65
+ data-status={status}
66
+ data-language={language}
67
+ >
68
+ <div className="file-preview-header">
69
+ <span className="file-preview-path">{path}</span>
70
+ {language && (
71
+ <span className="file-preview-language">{language}</span>
72
+ )}
73
+ </div>
74
+ <div className="file-preview-content">
75
+ <pre className="file-preview-code">
76
+ <code>{content}</code>
77
+ </pre>
78
+ </div>
79
+ {(renderAction || onAction) && (
80
+ <div className="file-preview-actions">
81
+ {renderAction ? (
82
+ renderAction(actionProps)
83
+ ) : (
84
+ <button
85
+ className="file-preview-action-button"
86
+ onClick={handleClick}
87
+ disabled={disabled}
88
+ data-status={status}
89
+ >
90
+ {status === 'pending' && 'Create'}
91
+ {status === 'creating' && 'Creating...'}
92
+ {status === 'created' && 'Created'}
93
+ </button>
94
+ )}
95
+ </div>
96
+ )}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ProgressIndicator - A headless component for displaying tutorial progress.
3
+ *
4
+ * CSS Variables Contract:
5
+ * - --tutorial-text: Primary text color
6
+ * - --tutorial-text-muted: Secondary/muted text color
7
+ *
8
+ * Data Attributes:
9
+ * - [data-progress]: Current progress ratio (0-1) as data attribute
10
+ * - [data-complete="true"]: When current equals total
11
+ */
12
+
13
+ export type ProgressIndicatorProps = {
14
+ /** Current step number (1-indexed) */
15
+ current: number;
16
+ /** Total number of steps */
17
+ total: number;
18
+ /** Optional custom label (defaults to "Step X of Y") */
19
+ label?: string;
20
+ };
21
+
22
+ export function ProgressIndicator({
23
+ current,
24
+ total,
25
+ label,
26
+ }: ProgressIndicatorProps) {
27
+ const progress = total > 0 ? current / total : 0;
28
+ const isComplete = current >= total;
29
+ const displayLabel = label ?? `Step ${current} of ${total}`;
30
+
31
+ return (
32
+ <div
33
+ className="progress-indicator"
34
+ data-progress={progress.toFixed(2)}
35
+ data-complete={isComplete ? 'true' : undefined}
36
+ >
37
+ <span className="progress-indicator-label">{displayLabel}</span>
38
+ <div className="progress-indicator-bar">
39
+ <div
40
+ className="progress-indicator-fill"
41
+ style={{ width: `${progress * 100}%` }}
42
+ />
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TutorialStep - A headless component for rendering tutorial step containers.
3
+ *
4
+ * CSS Variables Contract:
5
+ * - --tutorial-step-active: Background/border color for active step
6
+ * - --tutorial-step-complete: Background/border color for complete step
7
+ * - --tutorial-step-pending: Background/border color for pending step
8
+ * - --tutorial-bg: Default background color
9
+ * - --tutorial-text: Primary text color
10
+ * - --tutorial-text-muted: Secondary/muted text color
11
+ * - --tutorial-border: Border color
12
+ *
13
+ * Data Attributes:
14
+ * - [data-status="pending|active|complete"]: Step status for styling
15
+ */
16
+
17
+ export type TutorialStepStatus = 'pending' | 'active' | 'complete';
18
+
19
+ export type TutorialStepProps = {
20
+ /** Step number displayed in the header */
21
+ number: number;
22
+ /** Step title */
23
+ title: string;
24
+ /** Current status of the step */
25
+ status: TutorialStepStatus;
26
+ /** Step content */
27
+ children: React.ReactNode;
28
+ };
29
+
30
+ export function TutorialStep({
31
+ number,
32
+ title,
33
+ status,
34
+ children,
35
+ }: TutorialStepProps) {
36
+ return (
37
+ <div className="tutorial-step" data-status={status}>
38
+ <div className="tutorial-step-header">
39
+ <span className="tutorial-step-number">{number}</span>
40
+ <span className="tutorial-step-title">{title}</span>
41
+ </div>
42
+ <div className="tutorial-step-content">{children}</div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tutorial Renderer Components
3
+ *
4
+ * Headless React components for rendering tutorial content.
5
+ * All components use CSS variables and data attributes for styling,
6
+ * allowing full customization by the consuming application.
7
+ *
8
+ * CSS Variables Contract:
9
+ * --tutorial-bg Default background color
10
+ * --tutorial-step-active Background/border for active step
11
+ * --tutorial-step-complete Background/border for complete step
12
+ * --tutorial-step-pending Background/border for pending step
13
+ * --tutorial-code-bg Background for code/command blocks
14
+ * --tutorial-action-bg Action button background
15
+ * --tutorial-action-hover Action button hover background
16
+ * --tutorial-border Border color
17
+ * --tutorial-text Primary text color
18
+ * --tutorial-text-muted Secondary/muted text color
19
+ */
20
+
21
+ // TutorialStep
22
+ export { TutorialStep } from './TutorialStep';
23
+ export type {
24
+ TutorialStepProps,
25
+ TutorialStepStatus,
26
+ } from './TutorialStep';
27
+
28
+ // FilePreview
29
+ export { FilePreview } from './FilePreview';
30
+ export type {
31
+ FilePreviewProps,
32
+ FilePreviewStatus,
33
+ FilePreviewActionProps,
34
+ } from './FilePreview';
35
+
36
+ // CommandBlock
37
+ export { CommandBlock } from './CommandBlock';
38
+ export type {
39
+ CommandBlockProps,
40
+ CommandBlockStatus,
41
+ CommandBlockActionProps,
42
+ } from './CommandBlock';
43
+
44
+ // ProgressIndicator
45
+ export { ProgressIndicator } from './ProgressIndicator';
46
+ export type { ProgressIndicatorProps } from './ProgressIndicator';
47
+
48
+ // ContentBox
49
+ export { ContentBox } from './ContentBox';
50
+ export type {
51
+ ContentBoxProps,
52
+ ContentBoxVariant,
53
+ } from './ContentBox';
package/src/hooks.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Reporter Web React Hooks
3
+ *
4
+ * React hooks for subscribing to reporter state using useSyncExternalStore.
5
+ * Provides full state subscription and selective subscriptions for individual
6
+ * activities and groups.
7
+ */
8
+
9
+ import { useSyncExternalStore, useCallback, useRef } from 'react';
10
+ import type { ActivityId, GroupId } from '@pokit/core';
11
+ import type { ReporterStore, ReporterState, ActivityState, GroupState } from './types';
12
+
13
+ /**
14
+ * Subscribe to the full reporter state
15
+ *
16
+ * @param store - The reporter store
17
+ * @returns Current reporter state
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const state = useReporterState(store);
22
+ * return <div>Status: {state.root.status}</div>;
23
+ * ```
24
+ */
25
+ export function useReporterState(store: ReporterStore): ReporterState {
26
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
27
+ }
28
+
29
+ /**
30
+ * Subscribe to a single activity by ID
31
+ * Returns undefined if activity doesn't exist
32
+ *
33
+ * @param store - The reporter store
34
+ * @param id - Activity ID to subscribe to
35
+ * @returns Activity state or undefined
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const activity = useActivity(store, 'task-1');
40
+ * if (!activity) return null;
41
+ * return <div>{activity.label}: {activity.status}</div>;
42
+ * ```
43
+ */
44
+ export function useActivity(store: ReporterStore, id: ActivityId): ActivityState | undefined {
45
+ // Track the previous activity reference for shallow comparison
46
+ const prevActivityRef = useRef<ActivityState | undefined>(undefined);
47
+
48
+ const getSnapshot = useCallback(() => {
49
+ const state = store.getSnapshot();
50
+ const activity = state.activities.get(id);
51
+
52
+ // Return same reference if activity hasn't changed (shallow comparison)
53
+ if (prevActivityRef.current === activity) {
54
+ return prevActivityRef.current;
55
+ }
56
+
57
+ // Check if activity content is the same (for Map recreation scenarios)
58
+ if (
59
+ prevActivityRef.current &&
60
+ activity &&
61
+ prevActivityRef.current.id === activity.id &&
62
+ prevActivityRef.current.status === activity.status &&
63
+ prevActivityRef.current.progress === activity.progress &&
64
+ prevActivityRef.current.message === activity.message &&
65
+ prevActivityRef.current.justStarted === activity.justStarted &&
66
+ prevActivityRef.current.justCompleted === activity.justCompleted &&
67
+ prevActivityRef.current.justFailed === activity.justFailed &&
68
+ prevActivityRef.current.completedAt === activity.completedAt
69
+ ) {
70
+ return prevActivityRef.current;
71
+ }
72
+
73
+ prevActivityRef.current = activity;
74
+ return activity;
75
+ }, [store, id]);
76
+
77
+ const getServerSnapshot = useCallback(() => {
78
+ return store.getServerSnapshot().activities.get(id);
79
+ }, [store, id]);
80
+
81
+ return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
82
+ }
83
+
84
+ /**
85
+ * Subscribe to a single group by ID
86
+ * Returns undefined if group doesn't exist
87
+ *
88
+ * @param store - The reporter store
89
+ * @param id - Group ID to subscribe to
90
+ * @returns Group state or undefined
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * const group = useGroup(store, 'checks');
95
+ * if (!group) return null;
96
+ * return <div>{group.label} ({group.activityIds.length} tasks)</div>;
97
+ * ```
98
+ */
99
+ export function useGroup(store: ReporterStore, id: GroupId): GroupState | undefined {
100
+ // Track the previous group reference for shallow comparison
101
+ const prevGroupRef = useRef<GroupState | undefined>(undefined);
102
+
103
+ const getSnapshot = useCallback(() => {
104
+ const state = store.getSnapshot();
105
+ const group = state.groups.get(id);
106
+
107
+ // Return same reference if group hasn't changed
108
+ if (prevGroupRef.current === group) {
109
+ return prevGroupRef.current;
110
+ }
111
+
112
+ // Check if group content is the same (for Map recreation scenarios)
113
+ if (
114
+ prevGroupRef.current &&
115
+ group &&
116
+ prevGroupRef.current.id === group.id &&
117
+ prevGroupRef.current.label === group.label &&
118
+ prevGroupRef.current.hasFailure === group.hasFailure &&
119
+ prevGroupRef.current.justStarted_group === group.justStarted_group &&
120
+ prevGroupRef.current.justEnded === group.justEnded &&
121
+ prevGroupRef.current.endedAt === group.endedAt &&
122
+ prevGroupRef.current.activityIds.length === group.activityIds.length &&
123
+ prevGroupRef.current.childGroupIds.length === group.childGroupIds.length
124
+ ) {
125
+ return prevGroupRef.current;
126
+ }
127
+
128
+ prevGroupRef.current = group;
129
+ return group;
130
+ }, [store, id]);
131
+
132
+ const getServerSnapshot = useCallback(() => {
133
+ return store.getServerSnapshot().groups.get(id);
134
+ }, [store, id]);
135
+
136
+ return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
137
+ }
138
+
139
+ /**
140
+ * Subscribe to root state only
141
+ * More efficient than useReporterState when you only need root info
142
+ *
143
+ * @param store - The reporter store
144
+ * @returns Root state
145
+ *
146
+ * @example
147
+ * ```tsx
148
+ * const root = useRootState(store);
149
+ * return <div>App: {root.appName} - {root.status}</div>;
150
+ * ```
151
+ */
152
+ export function useRootState(store: ReporterStore): ReporterState['root'] {
153
+ const prevRootRef = useRef<ReporterState['root'] | undefined>(undefined);
154
+
155
+ const getSnapshot = useCallback(() => {
156
+ const state = store.getSnapshot();
157
+ const root = state.root;
158
+
159
+ // Return same reference if root hasn't changed
160
+ if (
161
+ prevRootRef.current &&
162
+ prevRootRef.current.status === root.status &&
163
+ prevRootRef.current.appName === root.appName &&
164
+ prevRootRef.current.exitCode === root.exitCode
165
+ ) {
166
+ return prevRootRef.current;
167
+ }
168
+
169
+ prevRootRef.current = root;
170
+ return root;
171
+ }, [store]);
172
+
173
+ const getServerSnapshot = useCallback(() => {
174
+ return store.getServerSnapshot().root;
175
+ }, [store]);
176
+
177
+ return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
178
+ }
179
+
180
+ /**
181
+ * Subscribe to logs
182
+ * Returns the full log array
183
+ *
184
+ * @param store - The reporter store
185
+ * @returns Array of log entries
186
+ *
187
+ * @example
188
+ * ```tsx
189
+ * const logs = useLogs(store);
190
+ * return (
191
+ * <ul>
192
+ * {logs.map(log => <li key={log.id}>{log.message}</li>)}
193
+ * </ul>
194
+ * );
195
+ * ```
196
+ */
197
+ export function useLogs(store: ReporterStore): ReporterState['logs'] {
198
+ const prevLogsRef = useRef<ReporterState['logs'] | undefined>(undefined);
199
+
200
+ const getSnapshot = useCallback(() => {
201
+ const state = store.getSnapshot();
202
+ const logs = state.logs;
203
+
204
+ // Return same reference if logs haven't changed
205
+ if (prevLogsRef.current && prevLogsRef.current.length === logs.length) {
206
+ // Quick check - if lengths match and last item is same, assume unchanged
207
+ if (
208
+ logs.length === 0 ||
209
+ prevLogsRef.current[logs.length - 1]?.id === logs[logs.length - 1]?.id
210
+ ) {
211
+ return prevLogsRef.current;
212
+ }
213
+ }
214
+
215
+ prevLogsRef.current = logs;
216
+ return logs;
217
+ }, [store]);
218
+
219
+ const getServerSnapshot = useCallback(() => {
220
+ return store.getServerSnapshot().logs;
221
+ }, [store]);
222
+
223
+ return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
224
+ }
225
+
226
+ /**
227
+ * Subscribe to suspended state
228
+ *
229
+ * @param store - The reporter store
230
+ * @returns Whether reporter is suspended
231
+ */
232
+ export function useSuspended(store: ReporterStore): boolean {
233
+ const getSnapshot = useCallback(() => {
234
+ return store.getSnapshot().suspended;
235
+ }, [store]);
236
+
237
+ const getServerSnapshot = useCallback(() => {
238
+ return store.getServerSnapshot().suspended;
239
+ }, [store]);
240
+
241
+ return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
242
+ }
package/src/store.ts ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Reporter Web Store
3
+ *
4
+ * Creates an external store for React integration.
5
+ * Handles all CLIEvent types and maintains normalized state.
6
+ */
7
+
8
+ import type { CLIEvent, ActivityId, GroupId } from '@pokit/core';
9
+ import type {
10
+ ReporterState,
11
+ ReporterStore,
12
+ StateListener,
13
+ ActivityState,
14
+ GroupState,
15
+ LogEntry,
16
+ } from './types';
17
+
18
+ /** Default delay for clearing temporal markers (ms) */
19
+ const TEMPORAL_MARKER_DELAY = 600;
20
+
21
+ /** Counter for generating unique log IDs */
22
+ let logIdCounter = 0;
23
+
24
+ /**
25
+ * Create initial reporter state
26
+ */
27
+ function createInitialState(): ReporterState {
28
+ return {
29
+ root: {
30
+ status: 'idle',
31
+ },
32
+ groups: new Map(),
33
+ activities: new Map(),
34
+ logs: [],
35
+ suspended: false,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Options for creating a reporter store
41
+ */
42
+ export type CreateReporterStoreOptions = {
43
+ /** Custom delay for clearing temporal markers (default: 600ms) */
44
+ temporalMarkerDelay?: number;
45
+ /** Disable temporal marker auto-clearing (useful for testing) */
46
+ disableTemporalMarkerClearing?: boolean;
47
+ };
48
+
49
+ /**
50
+ * Type for the store with internal event handler exposed
51
+ */
52
+ export type ReporterStoreWithHandler = ReporterStore & {
53
+ _handleEvent: (event: CLIEvent) => void;
54
+ };
55
+
56
+ /**
57
+ * Create a reporter store for React integration
58
+ *
59
+ * @param options - Optional configuration
60
+ * @returns Store compatible with useSyncExternalStore
61
+ */
62
+ export function createReporterStore(options?: CreateReporterStoreOptions): ReporterStoreWithHandler {
63
+ const temporalMarkerDelay = options?.temporalMarkerDelay ?? TEMPORAL_MARKER_DELAY;
64
+ const disableTemporalMarkerClearing = options?.disableTemporalMarkerClearing ?? false;
65
+
66
+ let state = createInitialState();
67
+ const listeners = new Set<StateListener>();
68
+
69
+ /**
70
+ * Notify all listeners of state change
71
+ */
72
+ function notifyListeners(): void {
73
+ for (const listener of listeners) {
74
+ listener();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Schedule clearing of temporal markers for an activity
80
+ */
81
+ function scheduleActivityMarkerClear(activityId: ActivityId, markers: (keyof ActivityState)[]): void {
82
+ if (disableTemporalMarkerClearing) return;
83
+
84
+ setTimeout(() => {
85
+ const activity = state.activities.get(activityId);
86
+ if (!activity) return;
87
+
88
+ // Check if any markers are still set
89
+ const hasMarkers = markers.some((marker) => activity[marker]);
90
+ if (!hasMarkers) return;
91
+
92
+ // Create new state with cleared markers
93
+ const updatedActivity = { ...activity };
94
+ for (const marker of markers) {
95
+ delete updatedActivity[marker];
96
+ }
97
+
98
+ const newActivities = new Map(state.activities);
99
+ newActivities.set(activityId, updatedActivity);
100
+
101
+ state = {
102
+ ...state,
103
+ activities: newActivities,
104
+ };
105
+ notifyListeners();
106
+ }, temporalMarkerDelay);
107
+ }
108
+
109
+ /**
110
+ * Schedule clearing of temporal markers for a group
111
+ */
112
+ function scheduleGroupMarkerClear(groupId: GroupId, markers: (keyof GroupState)[]): void {
113
+ if (disableTemporalMarkerClearing) return;
114
+
115
+ setTimeout(() => {
116
+ const group = state.groups.get(groupId);
117
+ if (!group) return;
118
+
119
+ // Check if any markers are still set
120
+ const hasMarkers = markers.some((marker) => group[marker]);
121
+ if (!hasMarkers) return;
122
+
123
+ // Create new state with cleared markers
124
+ const updatedGroup = { ...group };
125
+ for (const marker of markers) {
126
+ delete updatedGroup[marker];
127
+ }
128
+
129
+ const newGroups = new Map(state.groups);
130
+ newGroups.set(groupId, updatedGroup);
131
+
132
+ state = {
133
+ ...state,
134
+ groups: newGroups,
135
+ };
136
+ notifyListeners();
137
+ }, temporalMarkerDelay);
138
+ }
139
+
140
+ /**
141
+ * Handle a CLI event and update state
142
+ */
143
+ function handleEvent(event: CLIEvent): void {
144
+ switch (event.type) {
145
+ case 'root:start': {
146
+ state = {
147
+ ...state,
148
+ root: {
149
+ status: 'running',
150
+ appName: event.appName,
151
+ version: event.version,
152
+ startedAt: Date.now(),
153
+ },
154
+ };
155
+ notifyListeners();
156
+ break;
157
+ }
158
+
159
+ case 'root:end': {
160
+ state = {
161
+ ...state,
162
+ root: {
163
+ ...state.root,
164
+ status: event.exitCode === 0 ? 'complete' : 'error',
165
+ exitCode: event.exitCode,
166
+ endedAt: Date.now(),
167
+ },
168
+ };
169
+ notifyListeners();
170
+ break;
171
+ }
172
+
173
+ case 'group:start': {
174
+ const newGroup: GroupState = {
175
+ id: event.id,
176
+ parentId: event.parentId,
177
+ label: event.label,
178
+ layout: event.layout,
179
+ activityIds: [],
180
+ childGroupIds: [],
181
+ hasFailure: false,
182
+ startedAt: Date.now(),
183
+ justStarted_group: true,
184
+ };
185
+
186
+ const newGroups = new Map(state.groups);
187
+ newGroups.set(event.id, newGroup);
188
+
189
+ // Add to parent's childGroupIds if parent exists
190
+ if (event.parentId) {
191
+ const parent = state.groups.get(event.parentId);
192
+ if (parent) {
193
+ newGroups.set(event.parentId, {
194
+ ...parent,
195
+ childGroupIds: [...parent.childGroupIds, event.id],
196
+ });
197
+ }
198
+ }
199
+
200
+ state = {
201
+ ...state,
202
+ groups: newGroups,
203
+ };
204
+ notifyListeners();
205
+ scheduleGroupMarkerClear(event.id, ['justStarted_group']);
206
+ break;
207
+ }
208
+
209
+ case 'group:end': {
210
+ const group = state.groups.get(event.id);
211
+ if (!group) break;
212
+
213
+ const newGroups = new Map(state.groups);
214
+ newGroups.set(event.id, {
215
+ ...group,
216
+ endedAt: Date.now(),
217
+ justEnded: true,
218
+ });
219
+
220
+ state = {
221
+ ...state,
222
+ groups: newGroups,
223
+ };
224
+ notifyListeners();
225
+ scheduleGroupMarkerClear(event.id, ['justEnded']);
226
+ break;
227
+ }
228
+
229
+ case 'activity:start': {
230
+ const newActivity: ActivityState = {
231
+ id: event.id,
232
+ parentId: event.parentId,
233
+ label: event.label,
234
+ status: 'running',
235
+ meta: event.meta,
236
+ startedAt: Date.now(),
237
+ justStarted: true,
238
+ };
239
+
240
+ const newActivities = new Map(state.activities);
241
+ newActivities.set(event.id, newActivity);
242
+
243
+ // Add to parent group's activityIds if parent is a group
244
+ const newGroups = new Map(state.groups);
245
+ if (event.parentId) {
246
+ const parentGroup = state.groups.get(event.parentId as GroupId);
247
+ if (parentGroup) {
248
+ newGroups.set(event.parentId as GroupId, {
249
+ ...parentGroup,
250
+ activityIds: [...parentGroup.activityIds, event.id],
251
+ });
252
+ }
253
+ }
254
+
255
+ state = {
256
+ ...state,
257
+ activities: newActivities,
258
+ groups: newGroups,
259
+ };
260
+ notifyListeners();
261
+ scheduleActivityMarkerClear(event.id, ['justStarted']);
262
+ break;
263
+ }
264
+
265
+ case 'activity:update': {
266
+ const activity = state.activities.get(event.id);
267
+ if (!activity) break;
268
+
269
+ const { progress, message, ...rest } = event.payload;
270
+
271
+ const newActivities = new Map(state.activities);
272
+ newActivities.set(event.id, {
273
+ ...activity,
274
+ progress: progress ?? activity.progress,
275
+ message: message ?? activity.message,
276
+ payload: {
277
+ ...activity.payload,
278
+ ...rest,
279
+ },
280
+ });
281
+
282
+ state = {
283
+ ...state,
284
+ activities: newActivities,
285
+ };
286
+ notifyListeners();
287
+ break;
288
+ }
289
+
290
+ case 'activity:success': {
291
+ const activity = state.activities.get(event.id);
292
+ if (!activity) break;
293
+
294
+ const newActivities = new Map(state.activities);
295
+ newActivities.set(event.id, {
296
+ ...activity,
297
+ status: 'success',
298
+ completedAt: Date.now(),
299
+ justCompleted: true,
300
+ });
301
+
302
+ state = {
303
+ ...state,
304
+ activities: newActivities,
305
+ };
306
+ notifyListeners();
307
+ scheduleActivityMarkerClear(event.id, ['justCompleted']);
308
+ break;
309
+ }
310
+
311
+ case 'activity:failure': {
312
+ const activity = state.activities.get(event.id);
313
+ if (!activity) break;
314
+
315
+ const errorMessage = event.error instanceof Error ? event.error.message : String(event.error);
316
+
317
+ const newActivities = new Map(state.activities);
318
+ newActivities.set(event.id, {
319
+ ...activity,
320
+ status: 'failure',
321
+ completedAt: Date.now(),
322
+ justFailed: true,
323
+ error: {
324
+ message: errorMessage,
325
+ remediation: event.remediation,
326
+ documentationUrl: event.documentationUrl,
327
+ },
328
+ });
329
+
330
+ // Mark parent group as having failure
331
+ const newGroups = new Map(state.groups);
332
+ if (activity.parentId) {
333
+ const parentGroup = state.groups.get(activity.parentId as GroupId);
334
+ if (parentGroup) {
335
+ newGroups.set(activity.parentId as GroupId, {
336
+ ...parentGroup,
337
+ hasFailure: true,
338
+ });
339
+ }
340
+ }
341
+
342
+ state = {
343
+ ...state,
344
+ activities: newActivities,
345
+ groups: newGroups,
346
+ };
347
+ notifyListeners();
348
+ scheduleActivityMarkerClear(event.id, ['justFailed']);
349
+ break;
350
+ }
351
+
352
+ case 'log': {
353
+ const logEntry: LogEntry = {
354
+ id: `log-${++logIdCounter}`,
355
+ activityId: event.activityId,
356
+ level: event.level,
357
+ message: event.message,
358
+ timestamp: Date.now(),
359
+ };
360
+
361
+ state = {
362
+ ...state,
363
+ logs: [...state.logs, logEntry],
364
+ };
365
+ notifyListeners();
366
+ break;
367
+ }
368
+
369
+ case 'reporter:suspend': {
370
+ state = {
371
+ ...state,
372
+ suspended: true,
373
+ };
374
+ notifyListeners();
375
+ break;
376
+ }
377
+
378
+ case 'reporter:resume': {
379
+ state = {
380
+ ...state,
381
+ suspended: false,
382
+ };
383
+ notifyListeners();
384
+ break;
385
+ }
386
+ }
387
+ }
388
+
389
+ return {
390
+ getState(): ReporterState {
391
+ return state;
392
+ },
393
+
394
+ getSnapshot(): ReporterState {
395
+ return state;
396
+ },
397
+
398
+ getServerSnapshot(): ReporterState {
399
+ return state;
400
+ },
401
+
402
+ subscribe(listener: StateListener): () => void {
403
+ listeners.add(listener);
404
+ return () => {
405
+ listeners.delete(listener);
406
+ };
407
+ },
408
+
409
+ /**
410
+ * Internal method to handle events - exposed for adapter use
411
+ */
412
+ _handleEvent: handleEvent,
413
+ };
414
+ }
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Reporter Web Types
3
+ *
4
+ * State shape definitions for the web reporter store.
5
+ * Designed for React integration via useSyncExternalStore.
6
+ */
7
+
8
+ import type { ActivityId, GroupId, GroupLayout, LogLevel } from '@pokit/core';
9
+
10
+ /**
11
+ * Root lifecycle status
12
+ */
13
+ export type RootStatus = 'idle' | 'running' | 'complete' | 'error';
14
+
15
+ /**
16
+ * Activity status
17
+ */
18
+ export type ActivityStatus = 'pending' | 'running' | 'success' | 'failure';
19
+
20
+ /**
21
+ * Temporal markers for animation hints
22
+ * These auto-clear after a short delay (600ms) to enable smooth UI transitions
23
+ */
24
+ export type TemporalMarkers = {
25
+ /** Activity just started (for entrance animations) */
26
+ justStarted?: boolean;
27
+ /** Activity just completed successfully (for success animations) */
28
+ justCompleted?: boolean;
29
+ /** Activity just failed (for error animations) */
30
+ justFailed?: boolean;
31
+ /** Group just started */
32
+ justStarted_group?: boolean;
33
+ /** Group just ended */
34
+ justEnded?: boolean;
35
+ };
36
+
37
+ /**
38
+ * Activity state - represents a unit of work
39
+ */
40
+ export type ActivityState = TemporalMarkers & {
41
+ id: ActivityId;
42
+ parentId?: GroupId | ActivityId;
43
+ label: string;
44
+ status: ActivityStatus;
45
+ /** Progress 0-100 */
46
+ progress?: number;
47
+ /** Current status message */
48
+ message?: string;
49
+ /** Custom metadata */
50
+ meta?: Record<string, unknown>;
51
+ /** Error information if failed */
52
+ error?: {
53
+ message: string;
54
+ remediation?: string[];
55
+ documentationUrl?: string;
56
+ };
57
+ /** Custom payload data from updates */
58
+ payload?: Record<string, unknown>;
59
+ /** Timestamp when started */
60
+ startedAt: number;
61
+ /** Timestamp when completed (success or failure) */
62
+ completedAt?: number;
63
+ };
64
+
65
+ /**
66
+ * Group state - represents a container for activities
67
+ */
68
+ export type GroupState = TemporalMarkers & {
69
+ id: GroupId;
70
+ parentId?: GroupId;
71
+ label: string;
72
+ layout: GroupLayout;
73
+ /** Child activity IDs in order */
74
+ activityIds: ActivityId[];
75
+ /** Child group IDs in order */
76
+ childGroupIds: GroupId[];
77
+ /** Whether any child has failed */
78
+ hasFailure: boolean;
79
+ /** Timestamp when started */
80
+ startedAt: number;
81
+ /** Timestamp when ended */
82
+ endedAt?: number;
83
+ };
84
+
85
+ /**
86
+ * Log entry
87
+ */
88
+ export type LogEntry = {
89
+ id: string;
90
+ activityId?: ActivityId;
91
+ level: LogLevel;
92
+ message: string;
93
+ timestamp: number;
94
+ };
95
+
96
+ /**
97
+ * Root state for the reporter
98
+ */
99
+ export type RootState = {
100
+ appName?: string;
101
+ version?: string;
102
+ status: RootStatus;
103
+ startedAt?: number;
104
+ endedAt?: number;
105
+ exitCode?: number;
106
+ };
107
+
108
+ /**
109
+ * Complete reporter state
110
+ */
111
+ export type ReporterState = {
112
+ /** Root lifecycle state */
113
+ root: RootState;
114
+ /** Groups indexed by ID */
115
+ groups: Map<GroupId, GroupState>;
116
+ /** Activities indexed by ID */
117
+ activities: Map<ActivityId, ActivityState>;
118
+ /** Log entries in chronological order */
119
+ logs: LogEntry[];
120
+ /** Whether reporter output is suspended */
121
+ suspended: boolean;
122
+ };
123
+
124
+ /**
125
+ * Subscription callback type
126
+ */
127
+ export type StateListener = () => void;
128
+
129
+ /**
130
+ * Reporter store interface compatible with useSyncExternalStore
131
+ */
132
+ export type ReporterStore = {
133
+ /** Get current state snapshot */
134
+ getState(): ReporterState;
135
+ /** Get snapshot for useSyncExternalStore (same as getState for immutable updates) */
136
+ getSnapshot(): ReporterState;
137
+ /** Subscribe to state changes */
138
+ subscribe(listener: StateListener): () => void;
139
+ /** Get server snapshot for SSR (returns same as getSnapshot) */
140
+ getServerSnapshot(): ReporterState;
141
+ };