@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +155 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/defineOgmaReview.d.ts +3 -0
  8. package/dist/defineOgmaReview.d.ts.map +1 -0
  9. package/dist/defineOgmaReview.js +4 -0
  10. package/dist/defineOgmaReview.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +4 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/node/project.d.ts +18 -0
  16. package/dist/node/project.d.ts.map +1 -0
  17. package/dist/node/project.js +104 -0
  18. package/dist/node/project.js.map +1 -0
  19. package/dist/node/server.d.ts +21 -0
  20. package/dist/node/server.d.ts.map +1 -0
  21. package/dist/node/server.js +476 -0
  22. package/dist/node/server.js.map +1 -0
  23. package/dist/node/templates.d.ts +5 -0
  24. package/dist/node/templates.d.ts.map +1 -0
  25. package/dist/node/templates.js +145 -0
  26. package/dist/node/templates.js.map +1 -0
  27. package/dist/types.d.ts +100 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +2 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/viewer/OgmaReviewApp.d.ts +7 -0
  32. package/dist/viewer/OgmaReviewApp.d.ts.map +1 -0
  33. package/dist/viewer/OgmaReviewApp.js +387 -0
  34. package/dist/viewer/OgmaReviewApp.js.map +1 -0
  35. package/dist/viewer/client.d.ts +2 -0
  36. package/dist/viewer/client.d.ts.map +1 -0
  37. package/dist/viewer/client.js +13 -0
  38. package/dist/viewer/client.js.map +1 -0
  39. package/dist/viewer/defaultReview.d.ts +4 -0
  40. package/dist/viewer/defaultReview.d.ts.map +1 -0
  41. package/dist/viewer/defaultReview.js +55 -0
  42. package/dist/viewer/defaultReview.js.map +1 -0
  43. package/dist/viewer/normalizeReviewModule.d.ts +3 -0
  44. package/dist/viewer/normalizeReviewModule.d.ts.map +1 -0
  45. package/dist/viewer/normalizeReviewModule.js +58 -0
  46. package/dist/viewer/normalizeReviewModule.js.map +1 -0
  47. package/package.json +47 -0
  48. package/src/cli.ts +194 -0
  49. package/src/defineOgmaReview.ts +5 -0
  50. package/src/index.ts +17 -0
  51. package/src/node/project.ts +143 -0
  52. package/src/node/server.ts +598 -0
  53. package/src/node/templates.ts +148 -0
  54. package/src/types.ts +111 -0
  55. package/src/viewer/OgmaReviewApp.tsx +1099 -0
  56. package/src/viewer/client.tsx +18 -0
  57. package/src/viewer/defaultReview.tsx +168 -0
  58. package/src/viewer/normalizeReviewModule.ts +87 -0
  59. package/src/viewer/styles.css +1140 -0
  60. 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
+ }