@hcgstudio/ogma 0.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/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +155 -0
- package/dist/cli.js.map +1 -0
- package/dist/defineOgmaReview.d.ts +3 -0
- package/dist/defineOgmaReview.d.ts.map +1 -0
- package/dist/defineOgmaReview.js +4 -0
- package/dist/defineOgmaReview.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/node/project.d.ts +18 -0
- package/dist/node/project.d.ts.map +1 -0
- package/dist/node/project.js +104 -0
- package/dist/node/project.js.map +1 -0
- package/dist/node/server.d.ts +21 -0
- package/dist/node/server.d.ts.map +1 -0
- package/dist/node/server.js +476 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/templates.d.ts +5 -0
- package/dist/node/templates.d.ts.map +1 -0
- package/dist/node/templates.js +145 -0
- package/dist/node/templates.js.map +1 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/viewer/OgmaReviewApp.d.ts +7 -0
- package/dist/viewer/OgmaReviewApp.d.ts.map +1 -0
- package/dist/viewer/OgmaReviewApp.js +387 -0
- package/dist/viewer/OgmaReviewApp.js.map +1 -0
- package/dist/viewer/client.d.ts +2 -0
- package/dist/viewer/client.d.ts.map +1 -0
- package/dist/viewer/client.js +13 -0
- package/dist/viewer/client.js.map +1 -0
- package/dist/viewer/defaultReview.d.ts +4 -0
- package/dist/viewer/defaultReview.d.ts.map +1 -0
- package/dist/viewer/defaultReview.js +55 -0
- package/dist/viewer/defaultReview.js.map +1 -0
- package/dist/viewer/normalizeReviewModule.d.ts +3 -0
- package/dist/viewer/normalizeReviewModule.d.ts.map +1 -0
- package/dist/viewer/normalizeReviewModule.js +58 -0
- package/dist/viewer/normalizeReviewModule.js.map +1 -0
- package/package.json +47 -0
- package/src/cli.ts +194 -0
- package/src/defineOgmaReview.ts +5 -0
- package/src/index.ts +17 -0
- package/src/node/project.ts +143 -0
- package/src/node/server.ts +598 -0
- package/src/node/templates.ts +148 -0
- package/src/types.ts +111 -0
- package/src/viewer/OgmaReviewApp.tsx +1099 -0
- package/src/viewer/client.tsx +18 -0
- package/src/viewer/defaultReview.tsx +168 -0
- package/src/viewer/normalizeReviewModule.ts +87 -0
- package/src/viewer/styles.css +1140 -0
- package/src/viewer/virtual.d.ts +11 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bot,
|
|
3
|
+
Camera,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
CircleDot,
|
|
6
|
+
Clipboard,
|
|
7
|
+
Code2,
|
|
8
|
+
Copy,
|
|
9
|
+
Download,
|
|
10
|
+
Eye,
|
|
11
|
+
FileText,
|
|
12
|
+
Laptop,
|
|
13
|
+
MessageCirclePlus,
|
|
14
|
+
MessageSquareText,
|
|
15
|
+
MousePointer2,
|
|
16
|
+
RefreshCcw,
|
|
17
|
+
Send,
|
|
18
|
+
Server,
|
|
19
|
+
Settings2,
|
|
20
|
+
Smartphone,
|
|
21
|
+
Tablet,
|
|
22
|
+
TerminalSquare,
|
|
23
|
+
Upload
|
|
24
|
+
} from 'lucide-react';
|
|
25
|
+
import {
|
|
26
|
+
Component,
|
|
27
|
+
type CSSProperties,
|
|
28
|
+
type MouseEvent,
|
|
29
|
+
type ReactNode,
|
|
30
|
+
useCallback,
|
|
31
|
+
useEffect,
|
|
32
|
+
useMemo,
|
|
33
|
+
useRef,
|
|
34
|
+
useState
|
|
35
|
+
} from 'react';
|
|
36
|
+
import type {
|
|
37
|
+
OgmaAnnotation,
|
|
38
|
+
OgmaAnnotationStatus,
|
|
39
|
+
OgmaClientConfig,
|
|
40
|
+
OgmaFeedbackExport,
|
|
41
|
+
OgmaReviewDefinition,
|
|
42
|
+
OgmaReviewSession,
|
|
43
|
+
OgmaServerStatus,
|
|
44
|
+
OgmaViewportSnapshot
|
|
45
|
+
} from '../types.js';
|
|
46
|
+
|
|
47
|
+
type WorkspaceView = 'setup' | 'review' | 'handoff' | 'feedback';
|
|
48
|
+
type ViewportMode = 'desktop' | 'tablet' | 'mobile';
|
|
49
|
+
|
|
50
|
+
export interface OgmaReviewAppProps {
|
|
51
|
+
config: OgmaClientConfig;
|
|
52
|
+
review: OgmaReviewDefinition;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const API_ROOT = '/api/ogma';
|
|
56
|
+
|
|
57
|
+
const viewItems: Array<{ id: WorkspaceView; label: string; icon: typeof Eye }> = [
|
|
58
|
+
{ id: 'setup', label: 'Setup', icon: Settings2 },
|
|
59
|
+
{ id: 'review', label: 'Review', icon: Eye },
|
|
60
|
+
{ id: 'handoff', label: 'Agents', icon: Bot },
|
|
61
|
+
{ id: 'feedback', label: 'Feedback', icon: MessageSquareText }
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const viewportItems: Array<{ id: ViewportMode; label: string; icon: typeof Laptop }> = [
|
|
65
|
+
{ id: 'desktop', label: 'Desktop', icon: Laptop },
|
|
66
|
+
{ id: 'tablet', label: 'Tablet', icon: Tablet },
|
|
67
|
+
{ id: 'mobile', label: 'Mobile', icon: Smartphone }
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function classNames(...names: Array<string | false | undefined>) {
|
|
71
|
+
return names.filter(Boolean).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function reviewIdFor(review: OgmaReviewDefinition) {
|
|
75
|
+
return review.title
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
79
|
+
.replace(/^-+|-+$/g, '') || 'ogma-review';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nowIso() {
|
|
83
|
+
return new Date().toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function nextFeedbackId(annotations: OgmaAnnotation[]) {
|
|
87
|
+
const next =
|
|
88
|
+
annotations.reduce((highest, annotation) => {
|
|
89
|
+
const match = /^OG-(\d+)$/.exec(annotation.id);
|
|
90
|
+
return match ? Math.max(highest, Number(match[1])) : highest;
|
|
91
|
+
}, 0) + 1;
|
|
92
|
+
|
|
93
|
+
return `OG-${next.toString().padStart(3, '0')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createFallbackSession(reviewId: string): OgmaReviewSession {
|
|
97
|
+
return {
|
|
98
|
+
reviewId,
|
|
99
|
+
annotations: [],
|
|
100
|
+
updatedAt: nowIso()
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readJson<T>(path: string): Promise<T> {
|
|
105
|
+
const response = await fetch(`${API_ROOT}${path}`);
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return response.json() as Promise<T>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function writeJson<T>(path: string, body: unknown): Promise<T> {
|
|
115
|
+
const response = await fetch(`${API_ROOT}${path}`, {
|
|
116
|
+
body: JSON.stringify(body),
|
|
117
|
+
headers: {
|
|
118
|
+
'content-type': 'application/json'
|
|
119
|
+
},
|
|
120
|
+
method: 'PUT'
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return response.json() as Promise<T>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
131
|
+
const response = await fetch(`${API_ROOT}${path}`, {
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
headers: {
|
|
134
|
+
'content-type': 'application/json'
|
|
135
|
+
},
|
|
136
|
+
method: 'POST'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return response.json() as Promise<T>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildFeedbackExport(
|
|
147
|
+
reviewId: string,
|
|
148
|
+
reviewUrl: string,
|
|
149
|
+
annotations: OgmaAnnotation[]
|
|
150
|
+
): OgmaFeedbackExport {
|
|
151
|
+
return {
|
|
152
|
+
reviewId,
|
|
153
|
+
generatedAt: nowIso(),
|
|
154
|
+
reviewUrl,
|
|
155
|
+
annotations: annotations.map((annotation) => ({
|
|
156
|
+
id: annotation.id,
|
|
157
|
+
screenId: annotation.screenId,
|
|
158
|
+
title: annotation.title,
|
|
159
|
+
detail: annotation.detail,
|
|
160
|
+
status: annotation.status,
|
|
161
|
+
action: annotation.action,
|
|
162
|
+
location: {
|
|
163
|
+
x: annotation.x,
|
|
164
|
+
y: annotation.y
|
|
165
|
+
}
|
|
166
|
+
}))
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildAgentPrompt(exportData: OgmaFeedbackExport) {
|
|
171
|
+
const activeItems = exportData.annotations.filter((item) => item.status !== 'addressed');
|
|
172
|
+
const ids = activeItems.map((item) => item.id).join(', ') || 'no open feedback';
|
|
173
|
+
|
|
174
|
+
return [
|
|
175
|
+
'Use the Ogma feedback queue to update the JSX prototype and product notes.',
|
|
176
|
+
`Review URL: ${exportData.reviewUrl}`,
|
|
177
|
+
`Feedback IDs: ${ids}`,
|
|
178
|
+
'Preserve each feedback ID in your change summary and mark which JSX screen changed.',
|
|
179
|
+
'',
|
|
180
|
+
JSON.stringify({ ...exportData, annotations: activeItems }, null, 2)
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface BoundaryProps {
|
|
185
|
+
boundaryKey: string;
|
|
186
|
+
children: ReactNode;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface BoundaryState {
|
|
190
|
+
error: Error | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
class PrototypeErrorBoundary extends Component<BoundaryProps, BoundaryState> {
|
|
194
|
+
override state: BoundaryState = {
|
|
195
|
+
error: null
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
static getDerivedStateFromError(error: Error): BoundaryState {
|
|
199
|
+
return { error };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override componentDidUpdate(previousProps: BoundaryProps) {
|
|
203
|
+
if (previousProps.boundaryKey !== this.props.boundaryKey && this.state.error) {
|
|
204
|
+
this.setState({ error: null });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override render() {
|
|
209
|
+
if (this.state.error) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="ogma-render-error">
|
|
212
|
+
<h2>Prototype render error</h2>
|
|
213
|
+
<pre>{this.state.error.message}</pre>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this.props.children;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function OgmaReviewApp({ config, review }: OgmaReviewAppProps) {
|
|
223
|
+
const reviewId = useMemo(() => reviewIdFor(review), [review]);
|
|
224
|
+
const [activeView, setActiveView] = useState<WorkspaceView>('review');
|
|
225
|
+
const [activeScreenId, setActiveScreenId] = useState(review.screens[0]?.id ?? '');
|
|
226
|
+
const [viewportMode, setViewportMode] = useState<ViewportMode>('desktop');
|
|
227
|
+
const [annotationMode, setAnnotationMode] = useState(false);
|
|
228
|
+
const [annotations, setAnnotations] = useState<OgmaAnnotation[]>([]);
|
|
229
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
230
|
+
const [sessionLoaded, setSessionLoaded] = useState(false);
|
|
231
|
+
const [serverStatus, setServerStatus] = useState<OgmaServerStatus | null>(null);
|
|
232
|
+
const [notice, setNotice] = useState('Ready');
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const firstScreenId = review.screens[0]?.id ?? '';
|
|
236
|
+
|
|
237
|
+
if (!review.screens.some((screen) => screen.id === activeScreenId)) {
|
|
238
|
+
setActiveScreenId(firstScreenId);
|
|
239
|
+
}
|
|
240
|
+
}, [activeScreenId, review.screens]);
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
let mounted = true;
|
|
244
|
+
|
|
245
|
+
readJson<OgmaReviewSession>('/session')
|
|
246
|
+
.then((session) => {
|
|
247
|
+
if (!mounted) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setAnnotations(session.annotations);
|
|
252
|
+
setSelectedId(session.annotations[0]?.id ?? null);
|
|
253
|
+
setSessionLoaded(true);
|
|
254
|
+
})
|
|
255
|
+
.catch(() => {
|
|
256
|
+
if (!mounted) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fallback = createFallbackSession(reviewId);
|
|
261
|
+
setAnnotations(fallback.annotations);
|
|
262
|
+
setSessionLoaded(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
readJson<OgmaServerStatus>('/status')
|
|
266
|
+
.then((status) => {
|
|
267
|
+
if (mounted) {
|
|
268
|
+
setServerStatus(status);
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
.catch(() => {
|
|
272
|
+
if (mounted) {
|
|
273
|
+
setServerStatus(null);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return () => {
|
|
278
|
+
mounted = false;
|
|
279
|
+
};
|
|
280
|
+
}, [reviewId]);
|
|
281
|
+
|
|
282
|
+
const saveSession = useCallback(
|
|
283
|
+
async (nextAnnotations: OgmaAnnotation[]) => {
|
|
284
|
+
const session: OgmaReviewSession = {
|
|
285
|
+
reviewId,
|
|
286
|
+
annotations: nextAnnotations,
|
|
287
|
+
updatedAt: nowIso()
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
await writeJson<OgmaReviewSession>('/session', session);
|
|
291
|
+
},
|
|
292
|
+
[reviewId]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!sessionLoaded) {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const handle = window.setTimeout(() => {
|
|
301
|
+
saveSession(annotations).catch(() => setNotice('Session is local-only until the server API is reachable'));
|
|
302
|
+
}, 250);
|
|
303
|
+
|
|
304
|
+
return () => window.clearTimeout(handle);
|
|
305
|
+
}, [annotations, saveSession, sessionLoaded]);
|
|
306
|
+
|
|
307
|
+
const activeScreen = review.screens.find((screen) => screen.id === activeScreenId) ?? review.screens[0];
|
|
308
|
+
const activeAnnotations = annotations.filter((annotation) => annotation.screenId === activeScreen?.id);
|
|
309
|
+
const selectedAnnotation = annotations.find((annotation) => annotation.id === selectedId) ?? null;
|
|
310
|
+
const counts = useMemo(
|
|
311
|
+
() => ({
|
|
312
|
+
addressed: annotations.filter((annotation) => annotation.status === 'addressed').length,
|
|
313
|
+
open: annotations.filter((annotation) => annotation.status === 'open').length,
|
|
314
|
+
queued: annotations.filter((annotation) => annotation.status === 'queued').length
|
|
315
|
+
}),
|
|
316
|
+
[annotations]
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
function mutateAnnotation(id: string, patch: Partial<OgmaAnnotation>) {
|
|
320
|
+
setAnnotations((current) =>
|
|
321
|
+
current.map((annotation) =>
|
|
322
|
+
annotation.id === id ? { ...annotation, ...patch, updatedAt: nowIso() } : annotation
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function addAnnotation(event: MouseEvent<HTMLDivElement>) {
|
|
328
|
+
if (!annotationMode || !activeScreen) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const target = event.target as HTMLElement;
|
|
333
|
+
|
|
334
|
+
if (target.closest('[data-ogma-pin]')) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
339
|
+
const x = Number((((event.clientX - bounds.left) / bounds.width) * 100).toFixed(1));
|
|
340
|
+
const y = Number((((event.clientY - bounds.top) / bounds.height) * 100).toFixed(1));
|
|
341
|
+
const id = nextFeedbackId(annotations);
|
|
342
|
+
const timestamp = nowIso();
|
|
343
|
+
const annotation: OgmaAnnotation = {
|
|
344
|
+
id,
|
|
345
|
+
screenId: activeScreen.id,
|
|
346
|
+
x: Math.min(98, Math.max(2, x)),
|
|
347
|
+
y: Math.min(98, Math.max(2, y)),
|
|
348
|
+
title: 'New review note',
|
|
349
|
+
detail: 'Captured directly on the prototype.',
|
|
350
|
+
status: 'open',
|
|
351
|
+
action: `Update ${activeScreen.title} JSX and product notes for this feedback.`,
|
|
352
|
+
createdAt: timestamp,
|
|
353
|
+
updatedAt: timestamp
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
setAnnotations((current) => [...current, annotation]);
|
|
357
|
+
setSelectedId(id);
|
|
358
|
+
setNotice(`${id} added`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function copyText(value: string, label: string) {
|
|
362
|
+
await navigator.clipboard.writeText(value);
|
|
363
|
+
setNotice(`${label} copied`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function selectAnnotation(annotation: OgmaAnnotation, view: WorkspaceView = 'review') {
|
|
367
|
+
setSelectedId(annotation.id);
|
|
368
|
+
setActiveScreenId(annotation.screenId);
|
|
369
|
+
setActiveView(view);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function markSelectedAddressed() {
|
|
373
|
+
if (selectedAnnotation) {
|
|
374
|
+
mutateAnnotation(selectedAnnotation.id, { status: 'addressed' });
|
|
375
|
+
setNotice(`${selectedAnnotation.id} marked addressed`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sendEditsToAgent() {
|
|
380
|
+
const queued = annotations.map((annotation) =>
|
|
381
|
+
annotation.status === 'open'
|
|
382
|
+
? { ...annotation, status: 'queued' as const, updatedAt: nowIso() }
|
|
383
|
+
: annotation
|
|
384
|
+
);
|
|
385
|
+
const exportData = buildFeedbackExport(reviewId, config.reviewUrl, queued);
|
|
386
|
+
|
|
387
|
+
setAnnotations(queued);
|
|
388
|
+
void copyText(buildAgentPrompt(exportData), 'Agent edit prompt');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function exportFeedback() {
|
|
392
|
+
const exportData = buildFeedbackExport(reviewId, config.reviewUrl, annotations);
|
|
393
|
+
void copyText(JSON.stringify(exportData, null, 2), 'Feedback JSON');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function importFeedback(text: string) {
|
|
397
|
+
const parsed = JSON.parse(text) as OgmaFeedbackExport;
|
|
398
|
+
const timestamp = nowIso();
|
|
399
|
+
const imported = parsed.annotations.map((annotation) => ({
|
|
400
|
+
id: annotation.id,
|
|
401
|
+
screenId: annotation.screenId,
|
|
402
|
+
x: annotation.location.x,
|
|
403
|
+
y: annotation.location.y,
|
|
404
|
+
title: annotation.title,
|
|
405
|
+
detail: annotation.detail,
|
|
406
|
+
status: annotation.status,
|
|
407
|
+
action: annotation.action,
|
|
408
|
+
createdAt: timestamp,
|
|
409
|
+
updatedAt: timestamp
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
setAnnotations(imported);
|
|
413
|
+
setSelectedId(imported[0]?.id ?? null);
|
|
414
|
+
setNotice(`${imported.length} feedback items imported`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function saveSnapshot() {
|
|
418
|
+
if (!activeScreen) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const snapshot: OgmaViewportSnapshot = {
|
|
423
|
+
id: `${Date.now()}`,
|
|
424
|
+
annotations: activeAnnotations,
|
|
425
|
+
createdAt: nowIso(),
|
|
426
|
+
reviewId,
|
|
427
|
+
reviewUrl: config.reviewUrl,
|
|
428
|
+
screenId: activeScreen.id,
|
|
429
|
+
viewportMode
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
postJson('/snapshots', snapshot)
|
|
433
|
+
.then(() => setNotice('Viewport snapshot saved'))
|
|
434
|
+
.catch(() => setNotice('Snapshot could not be saved'));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<div className="ogma-shell">
|
|
439
|
+
<aside className="ogma-nav" aria-label="Ogma workspace">
|
|
440
|
+
<div className="ogma-mark" aria-label="Ogma">
|
|
441
|
+
<span>O</span>
|
|
442
|
+
</div>
|
|
443
|
+
<div className="ogma-nav-list">
|
|
444
|
+
{viewItems.map((item) => {
|
|
445
|
+
const Icon = item.icon;
|
|
446
|
+
|
|
447
|
+
return (
|
|
448
|
+
<button
|
|
449
|
+
className={classNames('ogma-nav-item', activeView === item.id && 'is-active')}
|
|
450
|
+
key={item.id}
|
|
451
|
+
onClick={() => setActiveView(item.id)}
|
|
452
|
+
title={item.label}
|
|
453
|
+
type="button"
|
|
454
|
+
>
|
|
455
|
+
<Icon aria-hidden="true" size={20} />
|
|
456
|
+
<span>{item.label}</span>
|
|
457
|
+
</button>
|
|
458
|
+
);
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
</aside>
|
|
462
|
+
|
|
463
|
+
<main className="ogma-main">
|
|
464
|
+
<header className="ogma-topbar">
|
|
465
|
+
<div>
|
|
466
|
+
<p className="ogma-eyebrow">@hcgstudio/ogma</p>
|
|
467
|
+
<h1>{review.title}</h1>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="ogma-topbar-actions">
|
|
470
|
+
<div className={classNames('ogma-status-pill', serverStatus ? 'is-online' : 'is-offline')}>
|
|
471
|
+
<CircleDot aria-hidden="true" size={15} />
|
|
472
|
+
<span>{serverStatus ? 'server active' : 'local preview'}</span>
|
|
473
|
+
</div>
|
|
474
|
+
<button
|
|
475
|
+
className="ogma-icon-button"
|
|
476
|
+
onClick={() => void copyText(config.reviewUrl, 'Review URL')}
|
|
477
|
+
title="Copy review URL"
|
|
478
|
+
type="button"
|
|
479
|
+
>
|
|
480
|
+
<Copy aria-hidden="true" size={18} />
|
|
481
|
+
</button>
|
|
482
|
+
<button
|
|
483
|
+
className="ogma-filled-button"
|
|
484
|
+
onClick={() => window.location.reload()}
|
|
485
|
+
type="button"
|
|
486
|
+
>
|
|
487
|
+
<RefreshCcw aria-hidden="true" size={18} />
|
|
488
|
+
<span>Refresh</span>
|
|
489
|
+
</button>
|
|
490
|
+
</div>
|
|
491
|
+
</header>
|
|
492
|
+
|
|
493
|
+
{activeView === 'setup' && (
|
|
494
|
+
<SetupView
|
|
495
|
+
config={config}
|
|
496
|
+
notice={notice}
|
|
497
|
+
onCopy={copyText}
|
|
498
|
+
serverStatus={serverStatus}
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
{activeView === 'review' && activeScreen && (
|
|
502
|
+
<ReviewView
|
|
503
|
+
activeAnnotations={activeAnnotations}
|
|
504
|
+
activeScreen={activeScreen}
|
|
505
|
+
annotationMode={annotationMode}
|
|
506
|
+
counts={counts}
|
|
507
|
+
onAddAnnotation={addAnnotation}
|
|
508
|
+
onMarkAddressed={markSelectedAddressed}
|
|
509
|
+
onMutateAnnotation={mutateAnnotation}
|
|
510
|
+
onSaveSnapshot={saveSnapshot}
|
|
511
|
+
onScreenChange={setActiveScreenId}
|
|
512
|
+
onSelectAnnotation={selectAnnotation}
|
|
513
|
+
onToggleAnnotationMode={() => setAnnotationMode((value) => !value)}
|
|
514
|
+
onViewportChange={setViewportMode}
|
|
515
|
+
review={review}
|
|
516
|
+
selectedAnnotation={selectedAnnotation}
|
|
517
|
+
viewportMode={viewportMode}
|
|
518
|
+
/>
|
|
519
|
+
)}
|
|
520
|
+
{activeView === 'handoff' && (
|
|
521
|
+
<HandoffView config={config} onCopy={copyText} reviewUrl={config.reviewUrl} />
|
|
522
|
+
)}
|
|
523
|
+
{activeView === 'feedback' && (
|
|
524
|
+
<FeedbackView
|
|
525
|
+
annotations={annotations}
|
|
526
|
+
counts={counts}
|
|
527
|
+
onExportFeedback={exportFeedback}
|
|
528
|
+
onImportFeedback={importFeedback}
|
|
529
|
+
onSelectAnnotation={(annotation) => selectAnnotation(annotation, 'review')}
|
|
530
|
+
onSendEdits={sendEditsToAgent}
|
|
531
|
+
selectedId={selectedAnnotation?.id ?? null}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
</main>
|
|
535
|
+
</div>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface SetupViewProps {
|
|
540
|
+
config: OgmaClientConfig;
|
|
541
|
+
notice: string;
|
|
542
|
+
onCopy: (value: string, label: string) => Promise<void>;
|
|
543
|
+
serverStatus: OgmaServerStatus | null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function SetupView({ config, notice, onCopy, serverStatus }: SetupViewProps) {
|
|
547
|
+
const commands = [
|
|
548
|
+
'npm install -D @hcgstudio/ogma',
|
|
549
|
+
'npx ogma start',
|
|
550
|
+
`npx ogma start --review ${config.defaultDesignDir}`
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<div className="ogma-workspace-grid ogma-setup-grid">
|
|
555
|
+
<section className="ogma-panel">
|
|
556
|
+
<div className="ogma-section-heading">
|
|
557
|
+
<div>
|
|
558
|
+
<p className="ogma-eyebrow">Local setup</p>
|
|
559
|
+
<h2>Install, start, hand off</h2>
|
|
560
|
+
</div>
|
|
561
|
+
<button
|
|
562
|
+
className="ogma-tonal-button"
|
|
563
|
+
onClick={() => void onCopy(config.skillUrl, 'Skill URL')}
|
|
564
|
+
type="button"
|
|
565
|
+
>
|
|
566
|
+
<Clipboard aria-hidden="true" size={18} />
|
|
567
|
+
<span>Skill URL</span>
|
|
568
|
+
</button>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
<div className="ogma-command-stack">
|
|
572
|
+
{commands.map((command) => (
|
|
573
|
+
<CommandLine command={command} key={command} onCopy={onCopy} />
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
576
|
+
</section>
|
|
577
|
+
|
|
578
|
+
<aside className="ogma-side-panel">
|
|
579
|
+
<div className="ogma-panel-title">
|
|
580
|
+
<Server aria-hidden="true" size={20} />
|
|
581
|
+
<h3>Runtime</h3>
|
|
582
|
+
</div>
|
|
583
|
+
<dl className="ogma-runtime-list">
|
|
584
|
+
<div>
|
|
585
|
+
<dt>Review URL</dt>
|
|
586
|
+
<dd>{config.reviewUrl}</dd>
|
|
587
|
+
</div>
|
|
588
|
+
<div>
|
|
589
|
+
<dt>Design directory</dt>
|
|
590
|
+
<dd>{config.defaultDesignDir}</dd>
|
|
591
|
+
</div>
|
|
592
|
+
<div>
|
|
593
|
+
<dt>Session store</dt>
|
|
594
|
+
<dd>{config.dataDir}</dd>
|
|
595
|
+
</div>
|
|
596
|
+
<div>
|
|
597
|
+
<dt>Status</dt>
|
|
598
|
+
<dd>{serverStatus ? 'Dependencies ready' : notice}</dd>
|
|
599
|
+
</div>
|
|
600
|
+
</dl>
|
|
601
|
+
</aside>
|
|
602
|
+
</div>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function CommandLine({
|
|
607
|
+
command,
|
|
608
|
+
onCopy
|
|
609
|
+
}: {
|
|
610
|
+
command: string;
|
|
611
|
+
onCopy: (value: string, label: string) => Promise<void>;
|
|
612
|
+
}) {
|
|
613
|
+
return (
|
|
614
|
+
<div className="ogma-command-line">
|
|
615
|
+
<TerminalSquare aria-hidden="true" size={18} />
|
|
616
|
+
<code>{command}</code>
|
|
617
|
+
<button
|
|
618
|
+
className="ogma-icon-button is-compact"
|
|
619
|
+
onClick={() => void onCopy(command, 'Command')}
|
|
620
|
+
title="Copy command"
|
|
621
|
+
type="button"
|
|
622
|
+
>
|
|
623
|
+
<Copy aria-hidden="true" size={16} />
|
|
624
|
+
</button>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
interface ReviewViewProps {
|
|
630
|
+
activeAnnotations: OgmaAnnotation[];
|
|
631
|
+
activeScreen: OgmaReviewDefinition['screens'][number];
|
|
632
|
+
annotationMode: boolean;
|
|
633
|
+
counts: Record<OgmaAnnotationStatus, number>;
|
|
634
|
+
onAddAnnotation: (event: MouseEvent<HTMLDivElement>) => void;
|
|
635
|
+
onMarkAddressed: () => void;
|
|
636
|
+
onMutateAnnotation: (id: string, patch: Partial<OgmaAnnotation>) => void;
|
|
637
|
+
onSaveSnapshot: () => void;
|
|
638
|
+
onScreenChange: (screenId: string) => void;
|
|
639
|
+
onSelectAnnotation: (annotation: OgmaAnnotation) => void;
|
|
640
|
+
onToggleAnnotationMode: () => void;
|
|
641
|
+
onViewportChange: (mode: ViewportMode) => void;
|
|
642
|
+
review: OgmaReviewDefinition;
|
|
643
|
+
selectedAnnotation: OgmaAnnotation | null;
|
|
644
|
+
viewportMode: ViewportMode;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function ReviewView({
|
|
648
|
+
activeAnnotations,
|
|
649
|
+
activeScreen,
|
|
650
|
+
annotationMode,
|
|
651
|
+
counts,
|
|
652
|
+
onAddAnnotation,
|
|
653
|
+
onMarkAddressed,
|
|
654
|
+
onMutateAnnotation,
|
|
655
|
+
onSaveSnapshot,
|
|
656
|
+
onScreenChange,
|
|
657
|
+
onSelectAnnotation,
|
|
658
|
+
onToggleAnnotationMode,
|
|
659
|
+
onViewportChange,
|
|
660
|
+
review,
|
|
661
|
+
selectedAnnotation,
|
|
662
|
+
viewportMode
|
|
663
|
+
}: ReviewViewProps) {
|
|
664
|
+
const ScreenComponent = activeScreen.component;
|
|
665
|
+
const frameRef = useRef<HTMLDivElement | null>(null);
|
|
666
|
+
const [a11yIssueCount, setA11yIssueCount] = useState(0);
|
|
667
|
+
const screenStyle = {
|
|
668
|
+
'--ogma-screen-width': `${activeScreen.width ?? 1040}px`
|
|
669
|
+
} as CSSProperties;
|
|
670
|
+
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
const frame = frameRef.current;
|
|
673
|
+
|
|
674
|
+
if (!frame) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const unlabeledControls = Array.from(frame.querySelectorAll('button, a')).filter((control) => {
|
|
679
|
+
const element = control as HTMLElement;
|
|
680
|
+
const hasName =
|
|
681
|
+
element.textContent?.trim() ||
|
|
682
|
+
element.getAttribute('aria-label') ||
|
|
683
|
+
element.getAttribute('title');
|
|
684
|
+
|
|
685
|
+
return !hasName;
|
|
686
|
+
});
|
|
687
|
+
const imagesWithoutAlt = Array.from(frame.querySelectorAll('img')).filter(
|
|
688
|
+
(image) => !(image as HTMLImageElement).alt
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
setA11yIssueCount(unlabeledControls.length + imagesWithoutAlt.length);
|
|
692
|
+
}, [activeAnnotations.length, activeScreen.id]);
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<div className="ogma-workspace-grid ogma-review-grid">
|
|
696
|
+
<section className="ogma-canvas-panel">
|
|
697
|
+
<div className="ogma-review-toolbar">
|
|
698
|
+
<div className="ogma-segmented-control" aria-label="Prototype screen">
|
|
699
|
+
{review.screens.map((screen) => (
|
|
700
|
+
<button
|
|
701
|
+
className={classNames(activeScreen.id === screen.id && 'is-active')}
|
|
702
|
+
key={screen.id}
|
|
703
|
+
onClick={() => onScreenChange(screen.id)}
|
|
704
|
+
type="button"
|
|
705
|
+
>
|
|
706
|
+
{screen.title}
|
|
707
|
+
</button>
|
|
708
|
+
))}
|
|
709
|
+
</div>
|
|
710
|
+
<div className="ogma-tool-strip">
|
|
711
|
+
{viewportItems.map((item) => {
|
|
712
|
+
const Icon = item.icon;
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<button
|
|
716
|
+
aria-pressed={viewportMode === item.id}
|
|
717
|
+
className={classNames('ogma-icon-button', viewportMode === item.id && 'is-active')}
|
|
718
|
+
key={item.id}
|
|
719
|
+
onClick={() => onViewportChange(item.id)}
|
|
720
|
+
title={item.label}
|
|
721
|
+
type="button"
|
|
722
|
+
>
|
|
723
|
+
<Icon aria-hidden="true" size={18} />
|
|
724
|
+
</button>
|
|
725
|
+
);
|
|
726
|
+
})}
|
|
727
|
+
<button
|
|
728
|
+
aria-pressed={!annotationMode}
|
|
729
|
+
className={classNames('ogma-icon-button', !annotationMode && 'is-active')}
|
|
730
|
+
onClick={onToggleAnnotationMode}
|
|
731
|
+
title="Pointer"
|
|
732
|
+
type="button"
|
|
733
|
+
>
|
|
734
|
+
<MousePointer2 aria-hidden="true" size={18} />
|
|
735
|
+
</button>
|
|
736
|
+
<button
|
|
737
|
+
aria-pressed={annotationMode}
|
|
738
|
+
className={classNames('ogma-icon-button', annotationMode && 'is-active')}
|
|
739
|
+
onClick={onToggleAnnotationMode}
|
|
740
|
+
title="Add annotation"
|
|
741
|
+
type="button"
|
|
742
|
+
>
|
|
743
|
+
<MessageCirclePlus aria-hidden="true" size={18} />
|
|
744
|
+
</button>
|
|
745
|
+
<button
|
|
746
|
+
className="ogma-icon-button"
|
|
747
|
+
onClick={onSaveSnapshot}
|
|
748
|
+
title="Save viewport snapshot"
|
|
749
|
+
type="button"
|
|
750
|
+
>
|
|
751
|
+
<Camera aria-hidden="true" size={18} />
|
|
752
|
+
</button>
|
|
753
|
+
<div className={classNames('ogma-a11y-pill', a11yIssueCount === 0 && 'is-clear')}>
|
|
754
|
+
<span>A11y</span>
|
|
755
|
+
<strong>{a11yIssueCount}</strong>
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
</div>
|
|
759
|
+
|
|
760
|
+
<div
|
|
761
|
+
className={classNames('ogma-prototype-stage', `is-${viewportMode}`, annotationMode && 'is-annotating')}
|
|
762
|
+
onClick={onAddAnnotation}
|
|
763
|
+
role="presentation"
|
|
764
|
+
style={screenStyle}
|
|
765
|
+
>
|
|
766
|
+
<div className="ogma-prototype-frame" ref={frameRef}>
|
|
767
|
+
<PrototypeErrorBoundary boundaryKey={activeScreen.id}>
|
|
768
|
+
<ScreenComponent review={review} screen={activeScreen} />
|
|
769
|
+
</PrototypeErrorBoundary>
|
|
770
|
+
{activeAnnotations.map((annotation) => (
|
|
771
|
+
<button
|
|
772
|
+
className={classNames(
|
|
773
|
+
'ogma-annotation-pin',
|
|
774
|
+
`is-${annotation.status}`,
|
|
775
|
+
selectedAnnotation?.id === annotation.id && 'is-selected'
|
|
776
|
+
)}
|
|
777
|
+
data-ogma-pin=""
|
|
778
|
+
key={annotation.id}
|
|
779
|
+
onClick={(event) => {
|
|
780
|
+
event.stopPropagation();
|
|
781
|
+
onSelectAnnotation(annotation);
|
|
782
|
+
}}
|
|
783
|
+
style={{ left: `${annotation.x}%`, top: `${annotation.y}%` }}
|
|
784
|
+
title={annotation.title}
|
|
785
|
+
type="button"
|
|
786
|
+
>
|
|
787
|
+
{annotation.id.replace('OG-', '')}
|
|
788
|
+
</button>
|
|
789
|
+
))}
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
</section>
|
|
793
|
+
|
|
794
|
+
<aside className="ogma-side-panel">
|
|
795
|
+
<div className="ogma-panel-title">
|
|
796
|
+
<MessageSquareText aria-hidden="true" size={20} />
|
|
797
|
+
<h3>Annotation queue</h3>
|
|
798
|
+
</div>
|
|
799
|
+
<div className="ogma-metric-row">
|
|
800
|
+
<Metric label="Open" value={counts.open} tone="open" />
|
|
801
|
+
<Metric label="Queued" value={counts.queued} tone="queued" />
|
|
802
|
+
<Metric label="Addressed" value={counts.addressed} tone="addressed" />
|
|
803
|
+
</div>
|
|
804
|
+
{selectedAnnotation ? (
|
|
805
|
+
<AnnotationEditor
|
|
806
|
+
annotation={selectedAnnotation}
|
|
807
|
+
onMarkAddressed={onMarkAddressed}
|
|
808
|
+
onMutateAnnotation={onMutateAnnotation}
|
|
809
|
+
/>
|
|
810
|
+
) : (
|
|
811
|
+
<div className="ogma-empty-state">
|
|
812
|
+
<MessageCirclePlus aria-hidden="true" size={22} />
|
|
813
|
+
<p>No annotation selected.</p>
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
</aside>
|
|
817
|
+
</div>
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function Metric({
|
|
822
|
+
label,
|
|
823
|
+
tone,
|
|
824
|
+
value
|
|
825
|
+
}: {
|
|
826
|
+
label: string;
|
|
827
|
+
tone: OgmaAnnotationStatus;
|
|
828
|
+
value: number;
|
|
829
|
+
}) {
|
|
830
|
+
return (
|
|
831
|
+
<div className={classNames('ogma-metric', `is-${tone}`)}>
|
|
832
|
+
<strong>{value.toString().padStart(2, '0')}</strong>
|
|
833
|
+
<span>{label}</span>
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function AnnotationEditor({
|
|
839
|
+
annotation,
|
|
840
|
+
onMarkAddressed,
|
|
841
|
+
onMutateAnnotation
|
|
842
|
+
}: {
|
|
843
|
+
annotation: OgmaAnnotation;
|
|
844
|
+
onMarkAddressed: () => void;
|
|
845
|
+
onMutateAnnotation: (id: string, patch: Partial<OgmaAnnotation>) => void;
|
|
846
|
+
}) {
|
|
847
|
+
return (
|
|
848
|
+
<article className="ogma-annotation-editor">
|
|
849
|
+
<div className="ogma-editor-topline">
|
|
850
|
+
<span className={classNames('ogma-status-dot', `is-${annotation.status}`)} />
|
|
851
|
+
<strong>{annotation.id}</strong>
|
|
852
|
+
<span>{annotation.screenId}</span>
|
|
853
|
+
</div>
|
|
854
|
+
<label>
|
|
855
|
+
<span>Title</span>
|
|
856
|
+
<input
|
|
857
|
+
onChange={(event) => onMutateAnnotation(annotation.id, { title: event.target.value })}
|
|
858
|
+
value={annotation.title}
|
|
859
|
+
/>
|
|
860
|
+
</label>
|
|
861
|
+
<label>
|
|
862
|
+
<span>Detail</span>
|
|
863
|
+
<textarea
|
|
864
|
+
onChange={(event) => onMutateAnnotation(annotation.id, { detail: event.target.value })}
|
|
865
|
+
rows={4}
|
|
866
|
+
value={annotation.detail}
|
|
867
|
+
/>
|
|
868
|
+
</label>
|
|
869
|
+
<label>
|
|
870
|
+
<span>Expected agent action</span>
|
|
871
|
+
<textarea
|
|
872
|
+
onChange={(event) => onMutateAnnotation(annotation.id, { action: event.target.value })}
|
|
873
|
+
rows={3}
|
|
874
|
+
value={annotation.action}
|
|
875
|
+
/>
|
|
876
|
+
</label>
|
|
877
|
+
<label>
|
|
878
|
+
<span>Status</span>
|
|
879
|
+
<select
|
|
880
|
+
onChange={(event) =>
|
|
881
|
+
onMutateAnnotation(annotation.id, {
|
|
882
|
+
status: event.target.value as OgmaAnnotationStatus
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
value={annotation.status}
|
|
886
|
+
>
|
|
887
|
+
<option value="open">Open</option>
|
|
888
|
+
<option value="queued">Queued</option>
|
|
889
|
+
<option value="addressed">Addressed</option>
|
|
890
|
+
</select>
|
|
891
|
+
</label>
|
|
892
|
+
<div className="ogma-editor-actions">
|
|
893
|
+
<button className="ogma-tonal-button" onClick={onMarkAddressed} type="button">
|
|
894
|
+
<CheckCircle2 aria-hidden="true" size={18} />
|
|
895
|
+
<span>Addressed</span>
|
|
896
|
+
</button>
|
|
897
|
+
<button
|
|
898
|
+
className="ogma-icon-button"
|
|
899
|
+
onClick={() => void navigator.clipboard.writeText(annotation.id)}
|
|
900
|
+
title="Copy feedback ID"
|
|
901
|
+
type="button"
|
|
902
|
+
>
|
|
903
|
+
<Copy aria-hidden="true" size={17} />
|
|
904
|
+
</button>
|
|
905
|
+
</div>
|
|
906
|
+
</article>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function HandoffView({
|
|
911
|
+
config,
|
|
912
|
+
onCopy,
|
|
913
|
+
reviewUrl
|
|
914
|
+
}: {
|
|
915
|
+
config: OgmaClientConfig;
|
|
916
|
+
onCopy: (value: string, label: string) => Promise<void>;
|
|
917
|
+
reviewUrl: string;
|
|
918
|
+
}) {
|
|
919
|
+
const prompts = [
|
|
920
|
+
{
|
|
921
|
+
agent: 'Codex',
|
|
922
|
+
body: `Install/read the Ogma skill from ${config.skillUrl}. Create JSX screens in ${config.defaultDesignDir}, update product notes, install @hcgstudio/ogma if needed, start the review server, and give me the review URL.`
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
agent: 'Claude Code',
|
|
926
|
+
body: `Use the Ogma skill at ${config.skillUrl}. Generate the prototype as JSX screens plus product notes, run @hcgstudio/ogma locally, and preserve Ogma feedback IDs when applying reviewer edits.`
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
agent: 'Tool-agnostic',
|
|
930
|
+
body: `Follow the Ogma contract: JSX screens, product notes, review metadata, local server, stable feedback IDs, and a final review URL. Current review URL: ${reviewUrl}`
|
|
931
|
+
}
|
|
932
|
+
];
|
|
933
|
+
|
|
934
|
+
return (
|
|
935
|
+
<div className="ogma-workspace-grid ogma-handoff-grid">
|
|
936
|
+
<section className="ogma-panel">
|
|
937
|
+
<div className="ogma-section-heading">
|
|
938
|
+
<div>
|
|
939
|
+
<p className="ogma-eyebrow">Agent handoff</p>
|
|
940
|
+
<h2>Codex and Claude Code prompts</h2>
|
|
941
|
+
</div>
|
|
942
|
+
<button
|
|
943
|
+
className="ogma-filled-button"
|
|
944
|
+
onClick={() => void onCopy(config.skillUrl, 'Skill URL')}
|
|
945
|
+
type="button"
|
|
946
|
+
>
|
|
947
|
+
<Copy aria-hidden="true" size={18} />
|
|
948
|
+
<span>Copy URL</span>
|
|
949
|
+
</button>
|
|
950
|
+
</div>
|
|
951
|
+
<div className="ogma-agent-grid">
|
|
952
|
+
{prompts.map((prompt) => (
|
|
953
|
+
<article className="ogma-agent-card" key={prompt.agent}>
|
|
954
|
+
<div className="ogma-agent-card-top">
|
|
955
|
+
<Bot aria-hidden="true" size={20} />
|
|
956
|
+
<h3>{prompt.agent}</h3>
|
|
957
|
+
</div>
|
|
958
|
+
<p>{prompt.body}</p>
|
|
959
|
+
<button
|
|
960
|
+
className="ogma-tonal-button"
|
|
961
|
+
onClick={() => void onCopy(prompt.body, `${prompt.agent} prompt`)}
|
|
962
|
+
type="button"
|
|
963
|
+
>
|
|
964
|
+
<Clipboard aria-hidden="true" size={18} />
|
|
965
|
+
<span>Copy prompt</span>
|
|
966
|
+
</button>
|
|
967
|
+
</article>
|
|
968
|
+
))}
|
|
969
|
+
</div>
|
|
970
|
+
</section>
|
|
971
|
+
|
|
972
|
+
<aside className="ogma-side-panel">
|
|
973
|
+
<div className="ogma-panel-title">
|
|
974
|
+
<Code2 aria-hidden="true" size={20} />
|
|
975
|
+
<h3>Contract</h3>
|
|
976
|
+
</div>
|
|
977
|
+
<div className="ogma-contract-list">
|
|
978
|
+
{[
|
|
979
|
+
'JSX prototype screens',
|
|
980
|
+
'Product design notes',
|
|
981
|
+
'Review metadata',
|
|
982
|
+
'Feedback IDs in edits'
|
|
983
|
+
].map((item, index) => (
|
|
984
|
+
<div className="ogma-contract-item" key={item}>
|
|
985
|
+
<span>{index + 1}</span>
|
|
986
|
+
<p>{item}</p>
|
|
987
|
+
</div>
|
|
988
|
+
))}
|
|
989
|
+
</div>
|
|
990
|
+
</aside>
|
|
991
|
+
</div>
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function FeedbackView({
|
|
996
|
+
annotations,
|
|
997
|
+
counts,
|
|
998
|
+
onExportFeedback,
|
|
999
|
+
onImportFeedback,
|
|
1000
|
+
onSelectAnnotation,
|
|
1001
|
+
onSendEdits,
|
|
1002
|
+
selectedId
|
|
1003
|
+
}: {
|
|
1004
|
+
annotations: OgmaAnnotation[];
|
|
1005
|
+
counts: Record<OgmaAnnotationStatus, number>;
|
|
1006
|
+
onExportFeedback: () => void;
|
|
1007
|
+
onImportFeedback: (text: string) => void;
|
|
1008
|
+
onSelectAnnotation: (annotation: OgmaAnnotation) => void;
|
|
1009
|
+
onSendEdits: () => void;
|
|
1010
|
+
selectedId: string | null;
|
|
1011
|
+
}) {
|
|
1012
|
+
const importInputRef = useRef<HTMLInputElement | null>(null);
|
|
1013
|
+
|
|
1014
|
+
return (
|
|
1015
|
+
<div className="ogma-workspace-grid ogma-feedback-grid">
|
|
1016
|
+
<section className="ogma-panel">
|
|
1017
|
+
<div className="ogma-section-heading">
|
|
1018
|
+
<div>
|
|
1019
|
+
<p className="ogma-eyebrow">Feedback queue</p>
|
|
1020
|
+
<h2>Agent-ready review notes</h2>
|
|
1021
|
+
</div>
|
|
1022
|
+
<div className="ogma-inline-actions">
|
|
1023
|
+
<input
|
|
1024
|
+
accept="application/json,.json"
|
|
1025
|
+
hidden
|
|
1026
|
+
onChange={(event) => {
|
|
1027
|
+
const file = event.currentTarget.files?.[0];
|
|
1028
|
+
event.currentTarget.value = '';
|
|
1029
|
+
|
|
1030
|
+
if (!file) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
file
|
|
1035
|
+
.text()
|
|
1036
|
+
.then(onImportFeedback)
|
|
1037
|
+
.catch(() => undefined);
|
|
1038
|
+
}}
|
|
1039
|
+
ref={importInputRef}
|
|
1040
|
+
type="file"
|
|
1041
|
+
/>
|
|
1042
|
+
<button
|
|
1043
|
+
className="ogma-tonal-button"
|
|
1044
|
+
onClick={() => importInputRef.current?.click()}
|
|
1045
|
+
type="button"
|
|
1046
|
+
>
|
|
1047
|
+
<Upload aria-hidden="true" size={18} />
|
|
1048
|
+
<span>Import JSON</span>
|
|
1049
|
+
</button>
|
|
1050
|
+
<button className="ogma-tonal-button" onClick={onExportFeedback} type="button">
|
|
1051
|
+
<Download aria-hidden="true" size={18} />
|
|
1052
|
+
<span>Export JSON</span>
|
|
1053
|
+
</button>
|
|
1054
|
+
<button className="ogma-filled-button" onClick={onSendEdits} type="button">
|
|
1055
|
+
<Send aria-hidden="true" size={18} />
|
|
1056
|
+
<span>Send edits</span>
|
|
1057
|
+
</button>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
<div className="ogma-feedback-table" role="table">
|
|
1061
|
+
<div className="ogma-feedback-row is-heading" role="row">
|
|
1062
|
+
<span>ID</span>
|
|
1063
|
+
<span>Location</span>
|
|
1064
|
+
<span>Status</span>
|
|
1065
|
+
<span>Expected action</span>
|
|
1066
|
+
</div>
|
|
1067
|
+
{annotations.map((annotation) => (
|
|
1068
|
+
<button
|
|
1069
|
+
className={classNames('ogma-feedback-row', selectedId === annotation.id && 'is-selected')}
|
|
1070
|
+
key={annotation.id}
|
|
1071
|
+
onClick={() => onSelectAnnotation(annotation)}
|
|
1072
|
+
role="row"
|
|
1073
|
+
type="button"
|
|
1074
|
+
>
|
|
1075
|
+
<span>{annotation.id}</span>
|
|
1076
|
+
<span>{annotation.title}</span>
|
|
1077
|
+
<span className={classNames('ogma-status-chip', `is-${annotation.status}`)}>
|
|
1078
|
+
{annotation.status}
|
|
1079
|
+
</span>
|
|
1080
|
+
<span>{annotation.action}</span>
|
|
1081
|
+
</button>
|
|
1082
|
+
))}
|
|
1083
|
+
</div>
|
|
1084
|
+
</section>
|
|
1085
|
+
|
|
1086
|
+
<aside className="ogma-side-panel">
|
|
1087
|
+
<div className="ogma-panel-title">
|
|
1088
|
+
<FileText aria-hidden="true" size={20} />
|
|
1089
|
+
<h3>Summary</h3>
|
|
1090
|
+
</div>
|
|
1091
|
+
<div className="ogma-summary-list">
|
|
1092
|
+
<Metric label="Open notes" value={counts.open} tone="open" />
|
|
1093
|
+
<Metric label="AI edits" value={counts.queued} tone="queued" />
|
|
1094
|
+
<Metric label="Accepted" value={counts.addressed} tone="addressed" />
|
|
1095
|
+
</div>
|
|
1096
|
+
</aside>
|
|
1097
|
+
</div>
|
|
1098
|
+
);
|
|
1099
|
+
}
|