hale-commenting-system 2.1.1 → 2.2.1

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 (99) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.editorconfig +17 -0
  3. package/.eslintrc.js +75 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
  5. package/.github/workflows/ci.yaml +51 -0
  6. package/.prettierignore +1 -0
  7. package/.prettierrc +4 -0
  8. package/GITHUB_OAUTH_ENV_TEMPLATE.md +53 -0
  9. package/LICENSE +21 -0
  10. package/README.md +92 -21
  11. package/package.json +74 -50
  12. package/scripts/README.md +42 -0
  13. package/scripts/integrate.js +440 -0
  14. package/src/app/AppLayout/AppLayout.tsx +248 -0
  15. package/src/app/Comments/Comments.tsx +273 -0
  16. package/src/app/Dashboard/Dashboard.tsx +10 -0
  17. package/src/app/NotFound/NotFound.tsx +35 -0
  18. package/src/app/Settings/General/GeneralSettings.tsx +16 -0
  19. package/src/app/Settings/Profile/ProfileSettings.tsx +18 -0
  20. package/src/app/Support/Support.tsx +50 -0
  21. package/src/app/__snapshots__/app.test.tsx.snap +524 -0
  22. package/src/app/app.css +11 -0
  23. package/src/app/app.test.tsx +55 -0
  24. package/src/app/bgimages/Patternfly-Logo.svg +28 -0
  25. package/src/app/commenting-system/components/CommentOverlay.tsx +93 -0
  26. package/src/app/commenting-system/components/CommentPanel.tsx +534 -0
  27. package/src/app/commenting-system/components/CommentPin.tsx +60 -0
  28. package/src/app/commenting-system/components/DetailsTab.tsx +516 -0
  29. package/src/app/commenting-system/components/FloatingWidget.tsx +130 -0
  30. package/src/app/commenting-system/components/JiraTab.tsx +696 -0
  31. package/src/app/commenting-system/contexts/CommentContext.tsx +1033 -0
  32. package/src/app/commenting-system/contexts/GitHubAuthContext.tsx +84 -0
  33. package/{dist/index.d.ts → src/app/commenting-system/index.ts} +5 -4
  34. package/src/app/commenting-system/services/githubAdapter.ts +359 -0
  35. package/src/app/commenting-system/types/index.ts +27 -0
  36. package/src/app/commenting-system/utils/version.ts +19 -0
  37. package/src/app/index.tsx +22 -0
  38. package/src/app/routes.tsx +81 -0
  39. package/src/app/utils/useDocumentTitle.ts +13 -0
  40. package/src/favicon.png +0 -0
  41. package/src/index.html +18 -0
  42. package/src/index.tsx +25 -0
  43. package/src/test/setup.ts +33 -0
  44. package/src/typings.d.ts +12 -0
  45. package/stylePaths.js +14 -0
  46. package/tsconfig.json +34 -0
  47. package/vitest.config.ts +19 -0
  48. package/webpack.common.js +139 -0
  49. package/webpack.dev.js +318 -0
  50. package/webpack.prod.js +38 -0
  51. package/bin/detect.d.ts +0 -10
  52. package/bin/detect.js +0 -134
  53. package/bin/generators.d.ts +0 -18
  54. package/bin/generators.js +0 -193
  55. package/bin/hale-commenting.js +0 -4
  56. package/bin/index.d.ts +0 -2
  57. package/bin/index.js +0 -61
  58. package/bin/onboarding.d.ts +0 -1
  59. package/bin/onboarding.js +0 -349
  60. package/bin/postinstall.d.ts +0 -2
  61. package/bin/postinstall.js +0 -65
  62. package/bin/validators.d.ts +0 -2
  63. package/bin/validators.js +0 -66
  64. package/dist/cli/detect.d.ts +0 -10
  65. package/dist/cli/detect.js +0 -134
  66. package/dist/cli/generators.d.ts +0 -18
  67. package/dist/cli/generators.js +0 -193
  68. package/dist/cli/index.d.ts +0 -2
  69. package/dist/cli/index.js +0 -61
  70. package/dist/cli/onboarding.d.ts +0 -1
  71. package/dist/cli/onboarding.js +0 -349
  72. package/dist/cli/postinstall.d.ts +0 -2
  73. package/dist/cli/postinstall.js +0 -65
  74. package/dist/cli/validators.d.ts +0 -2
  75. package/dist/cli/validators.js +0 -66
  76. package/dist/components/CommentOverlay.d.ts +0 -2
  77. package/dist/components/CommentOverlay.js +0 -101
  78. package/dist/components/CommentPanel.d.ts +0 -6
  79. package/dist/components/CommentPanel.js +0 -334
  80. package/dist/components/CommentPin.d.ts +0 -11
  81. package/dist/components/CommentPin.js +0 -64
  82. package/dist/components/DetailsTab.d.ts +0 -2
  83. package/dist/components/DetailsTab.js +0 -380
  84. package/dist/components/FloatingWidget.d.ts +0 -8
  85. package/dist/components/FloatingWidget.js +0 -128
  86. package/dist/components/JiraTab.d.ts +0 -2
  87. package/dist/components/JiraTab.js +0 -507
  88. package/dist/contexts/CommentContext.d.ts +0 -30
  89. package/dist/contexts/CommentContext.js +0 -891
  90. package/dist/contexts/GitHubAuthContext.d.ts +0 -13
  91. package/dist/contexts/GitHubAuthContext.js +0 -96
  92. package/dist/index.js +0 -27
  93. package/dist/services/githubAdapter.d.ts +0 -56
  94. package/dist/services/githubAdapter.js +0 -321
  95. package/dist/types/index.d.ts +0 -25
  96. package/dist/types/index.js +0 -2
  97. package/dist/utils/version.d.ts +0 -1
  98. package/dist/utils/version.js +0 -23
  99. package/templates/webpack-middleware.js +0 -226
@@ -0,0 +1,516 @@
1
+ import * as React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import {
4
+ ActionList,
5
+ ActionListItem,
6
+ Button,
7
+ Card,
8
+ CardBody,
9
+ TextArea,
10
+ Title,
11
+ } from '@patternfly/react-core';
12
+ import { githubAdapter, isGitHubConfigured } from '../services/githubAdapter';
13
+
14
+ type DetailsScope = 'page' | 'section';
15
+
16
+ interface DetailsRecord {
17
+ designGoal: string;
18
+ primaryGoals: string;
19
+ keyFeaturesBeingValidated: string;
20
+ targetedUsers: string;
21
+ scope: DetailsScope;
22
+ // the route this record is anchored to; for section scope it will be the section root (e.g. "/support")
23
+ anchorRoute: string;
24
+ updatedAt: string;
25
+ }
26
+
27
+ type DetailsStore = Record<string, DetailsRecord>;
28
+
29
+ const STORAGE_KEY = 'hale_commenting_details_v1';
30
+ const GH_DETAILS_PATH = '.hale/details.json';
31
+
32
+ function safeParseStore(raw: string | null): DetailsStore {
33
+ if (!raw) return {};
34
+ try {
35
+ const parsed = JSON.parse(raw) as unknown;
36
+ if (!parsed || typeof parsed !== 'object') return {};
37
+ return parsed as DetailsStore;
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function getStore(): DetailsStore {
44
+ if (typeof window === 'undefined') return {};
45
+ return safeParseStore(window.localStorage.getItem(STORAGE_KEY));
46
+ }
47
+
48
+ function setStore(next: DetailsStore) {
49
+ if (typeof window === 'undefined') return;
50
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
51
+ }
52
+
53
+ function normalizePathname(pathname: string): string {
54
+ if (!pathname) return '/';
55
+ const cleaned = pathname.split('?')[0].split('#')[0];
56
+ return cleaned === '' ? '/' : cleaned;
57
+ }
58
+
59
+ function getSectionRoute(pathname: string): string {
60
+ const normalized = normalizePathname(pathname);
61
+ const parts = normalized.split('/').filter(Boolean);
62
+ if (parts.length === 0) return '/';
63
+ return `/${parts[0]}`;
64
+ }
65
+
66
+ function getPageKey(pathname: string): string {
67
+ return `page:${normalizePathname(pathname)}`;
68
+ }
69
+
70
+ function getSectionKey(sectionRoute: string): string {
71
+ return `section:${normalizePathname(sectionRoute)}/*`;
72
+ }
73
+
74
+ function loadForRoute(pathname: string): { record: DetailsRecord | null; source: 'page' | 'section' | null } {
75
+ const store = getStore();
76
+ const pageKey = getPageKey(pathname);
77
+ if (store[pageKey]) return { record: coerceRecord(store[pageKey]), source: 'page' };
78
+
79
+ const sectionRoute = getSectionRoute(pathname);
80
+ const sectionKey = getSectionKey(sectionRoute);
81
+ if (store[sectionKey]) return { record: coerceRecord(store[sectionKey]), source: 'section' };
82
+
83
+ return { record: null, source: null };
84
+ }
85
+
86
+ function isStructuredRecord(r: any): r is DetailsRecord {
87
+ return (
88
+ r &&
89
+ typeof r === 'object' &&
90
+ typeof r.designGoal === 'string' &&
91
+ typeof r.primaryGoals === 'string' &&
92
+ typeof r.keyFeaturesBeingValidated === 'string' &&
93
+ typeof r.targetedUsers === 'string' &&
94
+ (r.scope === 'page' || r.scope === 'section') &&
95
+ typeof r.anchorRoute === 'string' &&
96
+ typeof r.updatedAt === 'string'
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Backward compatibility: older drafts may have { title, body }.
102
+ * We keep them by mapping `body` into Design Goal (best-effort) and leaving the rest blank.
103
+ */
104
+ function coerceRecord(raw: any): DetailsRecord {
105
+ if (isStructuredRecord(raw)) return raw;
106
+
107
+ const legacyBody = typeof raw?.body === 'string' ? raw.body : '';
108
+ const legacyScope: DetailsScope = raw?.scope === 'page' || raw?.scope === 'section' ? raw.scope : 'section';
109
+ const legacyAnchor = typeof raw?.anchorRoute === 'string' ? raw.anchorRoute : '/';
110
+ const legacyUpdatedAt = typeof raw?.updatedAt === 'string' ? raw.updatedAt : new Date().toISOString();
111
+
112
+ return {
113
+ designGoal: legacyBody,
114
+ primaryGoals: '',
115
+ keyFeaturesBeingValidated: '',
116
+ targetedUsers: '',
117
+ scope: legacyScope,
118
+ anchorRoute: legacyAnchor,
119
+ updatedAt: legacyUpdatedAt,
120
+ };
121
+ }
122
+
123
+ export const DetailsTab: React.FunctionComponent = () => {
124
+ const location = useLocation();
125
+ const route = normalizePathname(location.pathname);
126
+ const sectionRoute = getSectionRoute(route);
127
+
128
+ const [{ record, source }, setResolved] = React.useState(() => loadForRoute(route));
129
+ const [isEditing, setIsEditing] = React.useState(false);
130
+ const [isLoadingRemote, setIsLoadingRemote] = React.useState(false);
131
+ const [remoteError, setRemoteError] = React.useState<string | null>(null);
132
+ const remoteShaRef = React.useRef<string | undefined>(undefined);
133
+
134
+ // editable draft state
135
+ const [draftScope, setDraftScope] = React.useState<DetailsScope>('section');
136
+ const [draftDesignGoal, setDraftDesignGoal] = React.useState('');
137
+ const [draftPrimaryGoals, setDraftPrimaryGoals] = React.useState('');
138
+ const [draftKeyFeaturesBeingValidated, setDraftKeyFeaturesBeingValidated] = React.useState('');
139
+ const [draftTargetedUsers, setDraftTargetedUsers] = React.useState('');
140
+
141
+ React.useEffect(() => {
142
+ // when navigating, refresh resolved record and exit edit mode
143
+ setResolved(loadForRoute(route));
144
+ setIsEditing(false);
145
+ }, [route]);
146
+
147
+ // Load Details from GitHub (source of truth) when authenticated; keep localStorage as cache/fallback.
148
+ React.useEffect(() => {
149
+ const load = async () => {
150
+ if (!isGitHubConfigured()) return;
151
+ setIsLoadingRemote(true);
152
+ setRemoteError(null);
153
+ try {
154
+ const local = getStore();
155
+ const res = await githubAdapter.getRepoFile(GH_DETAILS_PATH);
156
+ if (!res.success) {
157
+ setRemoteError(res.error || 'Failed to load details from GitHub');
158
+ return;
159
+ }
160
+
161
+ if (!res.data) {
162
+ // No remote file yet. If we already have local details, publish them as the initial remote.
163
+ if (Object.keys(local).length > 0) {
164
+ const created = await githubAdapter.putRepoFile({
165
+ path: GH_DETAILS_PATH,
166
+ text: JSON.stringify(local, null, 2) + '\n',
167
+ message: 'chore(details): initialize details store',
168
+ });
169
+ if (created.success) {
170
+ remoteShaRef.current = created.data?.sha;
171
+ }
172
+ }
173
+ return;
174
+ }
175
+
176
+ remoteShaRef.current = res.data.sha;
177
+ const parsed = safeParseStore(res.data.text);
178
+ setStore(parsed);
179
+ setResolved(loadForRoute(route));
180
+ } finally {
181
+ setIsLoadingRemote(false);
182
+ }
183
+ };
184
+
185
+ void load();
186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
187
+ }, []);
188
+
189
+ const startNew = () => {
190
+ setDraftScope('section');
191
+ setDraftDesignGoal('');
192
+ setDraftPrimaryGoals('');
193
+ setDraftKeyFeaturesBeingValidated('');
194
+ setDraftTargetedUsers('');
195
+ setIsEditing(true);
196
+ };
197
+
198
+ const startEdit = (mode: 'edit-existing' | 'override-page') => {
199
+ if (mode === 'override-page') {
200
+ // copy inherited section record into a page-scoped override draft
201
+ setDraftScope('page');
202
+ setDraftDesignGoal(record?.designGoal ?? '');
203
+ setDraftPrimaryGoals(record?.primaryGoals ?? '');
204
+ setDraftKeyFeaturesBeingValidated(record?.keyFeaturesBeingValidated ?? '');
205
+ setDraftTargetedUsers(record?.targetedUsers ?? '');
206
+ setIsEditing(true);
207
+ return;
208
+ }
209
+
210
+ // edit existing record as-is
211
+ const existingScope: DetailsScope = record?.scope ?? 'section';
212
+ setDraftScope(existingScope);
213
+ setDraftDesignGoal(record?.designGoal ?? '');
214
+ setDraftPrimaryGoals(record?.primaryGoals ?? '');
215
+ setDraftKeyFeaturesBeingValidated(record?.keyFeaturesBeingValidated ?? '');
216
+ setDraftTargetedUsers(record?.targetedUsers ?? '');
217
+ setIsEditing(true);
218
+ };
219
+
220
+ const save = () => {
221
+ const next: DetailsRecord = {
222
+ designGoal: draftDesignGoal,
223
+ primaryGoals: draftPrimaryGoals,
224
+ keyFeaturesBeingValidated: draftKeyFeaturesBeingValidated,
225
+ targetedUsers: draftTargetedUsers,
226
+ scope: draftScope,
227
+ anchorRoute: draftScope === 'section' ? sectionRoute : route,
228
+ updatedAt: new Date().toISOString(),
229
+ };
230
+
231
+ const store = getStore();
232
+ const key = draftScope === 'section' ? getSectionKey(sectionRoute) : getPageKey(route);
233
+ const nextStore = { ...store, [key]: next };
234
+ setStore(nextStore);
235
+
236
+ setResolved(loadForRoute(route));
237
+ setIsEditing(false);
238
+
239
+ // Best-effort: persist to GitHub as a repo file so designers/admins can edit outside the codebase.
240
+ if (isGitHubConfigured()) {
241
+ (async () => {
242
+ const text = JSON.stringify(nextStore, null, 2) + '\n';
243
+ const message = `chore(details): update ${key}`;
244
+ const sha = remoteShaRef.current;
245
+
246
+ const write = await githubAdapter.putRepoFile({ path: GH_DETAILS_PATH, text, message, sha });
247
+ if (write.success && write.data?.sha) {
248
+ remoteShaRef.current = write.data.sha;
249
+ setRemoteError(null);
250
+ return;
251
+ }
252
+
253
+ // If sha mismatch (someone else updated), refetch and retry once.
254
+ const refreshed = await githubAdapter.getRepoFile(GH_DETAILS_PATH);
255
+ if (refreshed.success && refreshed.data?.sha) {
256
+ remoteShaRef.current = refreshed.data.sha;
257
+ const retry = await githubAdapter.putRepoFile({ path: GH_DETAILS_PATH, text, message, sha: refreshed.data.sha });
258
+ if (retry.success && retry.data?.sha) {
259
+ remoteShaRef.current = retry.data.sha;
260
+ setRemoteError(null);
261
+ return;
262
+ }
263
+ }
264
+
265
+ setRemoteError(write.error || 'Failed to save details to GitHub');
266
+ })();
267
+ }
268
+ };
269
+
270
+ const remove = () => {
271
+ const store = getStore();
272
+ const keyToRemove =
273
+ source === 'page' ? getPageKey(route) : source === 'section' ? getSectionKey(sectionRoute) : null;
274
+ if (!keyToRemove) return;
275
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
276
+ const { [keyToRemove]: _removed, ...rest } = store;
277
+ setStore(rest);
278
+ setResolved(loadForRoute(route));
279
+ setIsEditing(false);
280
+
281
+ if (isGitHubConfigured()) {
282
+ (async () => {
283
+ const text = JSON.stringify(rest, null, 2) + '\n';
284
+ const message = `chore(details): remove ${keyToRemove}`;
285
+ const sha = remoteShaRef.current;
286
+ const write = await githubAdapter.putRepoFile({ path: GH_DETAILS_PATH, text, message, sha });
287
+ if (write.success && write.data?.sha) {
288
+ remoteShaRef.current = write.data.sha;
289
+ setRemoteError(null);
290
+ return;
291
+ }
292
+ setRemoteError(write.error || 'Failed to update details in GitHub');
293
+ })();
294
+ }
295
+ };
296
+
297
+ const scopeLabel =
298
+ source === 'page'
299
+ ? 'This page'
300
+ : source === 'section'
301
+ ? `Section (${sectionRoute}/*)`
302
+ : null;
303
+
304
+ const remoteStatusLine = isGitHubConfigured()
305
+ ? isLoadingRemote
306
+ ? 'Loading details from GitHub…'
307
+ : remoteError
308
+ ? `GitHub sync: ${remoteError}`
309
+ : 'GitHub sync enabled'
310
+ : null;
311
+
312
+ if (!record && !isEditing) {
313
+ return (
314
+ <div style={{ display: 'grid', gap: '1rem' }}>
315
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '1rem' }}>
316
+ <div>
317
+ <Title headingLevel="h3" size="lg">
318
+ Details
319
+ </Title>
320
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
321
+ No details set for <b>{route}</b>.
322
+ </div>
323
+ {remoteStatusLine && (
324
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
325
+ {remoteStatusLine}
326
+ </div>
327
+ )}
328
+ </div>
329
+ <Button variant="primary" onClick={startNew}>
330
+ Add details
331
+ </Button>
332
+ </div>
333
+
334
+ <Card>
335
+ <CardBody>
336
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
337
+ Add designer notes, design goals, links, and context for reviewers.
338
+ </div>
339
+ </CardBody>
340
+ </Card>
341
+ </div>
342
+ );
343
+ }
344
+
345
+ if (isEditing) {
346
+ const effectiveAnchor = draftScope === 'section' ? `${sectionRoute}/*` : route;
347
+
348
+ return (
349
+ <div style={{ display: 'grid', gap: '1rem' }}>
350
+ <div style={{ display: 'grid', gap: '0.5rem' }}>
351
+ <div>
352
+ <Title headingLevel="h3" size="lg">
353
+ Edit details
354
+ </Title>
355
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
356
+ Applies to: <b>{effectiveAnchor}</b>
357
+ </div>
358
+ {remoteStatusLine && (
359
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
360
+ {remoteStatusLine}
361
+ </div>
362
+ )}
363
+ </div>
364
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
365
+ <Button
366
+ variant={draftScope === 'page' ? 'primary' : 'secondary'}
367
+ onClick={() => setDraftScope('page')}
368
+ >
369
+ This page only
370
+ </Button>
371
+ <Button
372
+ variant={draftScope === 'section' ? 'primary' : 'secondary'}
373
+ onClick={() => setDraftScope('section')}
374
+ isDisabled={sectionRoute === '/' && route === '/'}
375
+ >
376
+ This section ({sectionRoute}/*)
377
+ </Button>
378
+ </div>
379
+ </div>
380
+
381
+ <Card>
382
+ <CardBody>
383
+ <div style={{ display: 'grid', gap: '0.75rem' }}>
384
+ <div>
385
+ <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
386
+ <b>Design Goal</b>
387
+ </div>
388
+ <TextArea
389
+ aria-label="Design goal"
390
+ value={draftDesignGoal}
391
+ onChange={(_e, v) => setDraftDesignGoal(v)}
392
+ rows={3}
393
+ />
394
+ </div>
395
+
396
+ <div>
397
+ <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
398
+ <b>Primary Goal(s)</b>
399
+ </div>
400
+ <TextArea
401
+ aria-label="Primary goals"
402
+ value={draftPrimaryGoals}
403
+ onChange={(_e, v) => setDraftPrimaryGoals(v)}
404
+ rows={4}
405
+ />
406
+ </div>
407
+
408
+ <div>
409
+ <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
410
+ <b>Key Feature(s) Being Validated</b>
411
+ </div>
412
+ <TextArea
413
+ aria-label="Key features being validated"
414
+ value={draftKeyFeaturesBeingValidated}
415
+ onChange={(_e, v) => setDraftKeyFeaturesBeingValidated(v)}
416
+ rows={4}
417
+ />
418
+ </div>
419
+
420
+ <div>
421
+ <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
422
+ <b>Targeted User(s)</b>
423
+ </div>
424
+ <TextArea
425
+ aria-label="Targeted users"
426
+ value={draftTargetedUsers}
427
+ onChange={(_e, v) => setDraftTargetedUsers(v)}
428
+ rows={4}
429
+ />
430
+ </div>
431
+
432
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
433
+ <Button variant="primary" onClick={save}>
434
+ Save
435
+ </Button>
436
+ <Button variant="link" onClick={() => setIsEditing(false)}>
437
+ Cancel
438
+ </Button>
439
+ </div>
440
+ </div>
441
+ </CardBody>
442
+ </Card>
443
+ </div>
444
+ );
445
+ }
446
+
447
+ if (!record) {
448
+ return null;
449
+ }
450
+
451
+ return (
452
+ <div style={{ display: 'grid', gap: '1rem' }}>
453
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '1rem' }}>
454
+ <div>
455
+ <Title headingLevel="h3" size="lg">
456
+ Details
457
+ </Title>
458
+ {scopeLabel && (
459
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
460
+ Source: <b>{scopeLabel}</b>
461
+ </div>
462
+ )}
463
+ </div>
464
+
465
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
466
+ {source === 'section' && (
467
+ <Button variant="secondary" onClick={() => startEdit('override-page')}>
468
+ Override for this page
469
+ </Button>
470
+ )}
471
+ <Button variant="secondary" onClick={() => startEdit('edit-existing')}>
472
+ Edit
473
+ </Button>
474
+ </div>
475
+ </div>
476
+
477
+ <Card>
478
+ <CardBody>
479
+ <Title headingLevel="h4" size="md">
480
+ Design Goal
481
+ </Title>
482
+ <div style={{ marginTop: '0.5rem', whiteSpace: 'pre-wrap' }}>{record.designGoal || '—'}</div>
483
+
484
+ <Title headingLevel="h4" size="md" style={{ marginTop: '1rem' }}>
485
+ Primary Goal(s)
486
+ </Title>
487
+ <div style={{ marginTop: '0.5rem', whiteSpace: 'pre-wrap' }}>{record.primaryGoals || '—'}</div>
488
+
489
+ <Title headingLevel="h4" size="md" style={{ marginTop: '1rem' }}>
490
+ Key Feature(s) Being Validated
491
+ </Title>
492
+ <div style={{ marginTop: '0.5rem', whiteSpace: 'pre-wrap' }}>{record.keyFeaturesBeingValidated || '—'}</div>
493
+
494
+ <Title headingLevel="h4" size="md" style={{ marginTop: '1rem' }}>
495
+ Targeted User(s)
496
+ </Title>
497
+ <div style={{ marginTop: '0.5rem', whiteSpace: 'pre-wrap' }}>{record.targetedUsers || '—'}</div>
498
+
499
+ <div style={{ marginTop: '0.75rem', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
500
+ Updated: {new Date(record.updatedAt).toLocaleString()}
501
+ </div>
502
+
503
+ <ActionList style={{ marginTop: '0.75rem' }}>
504
+ <ActionListItem>
505
+ <Button variant="link" isDanger onClick={remove}>
506
+ Remove
507
+ </Button>
508
+ </ActionListItem>
509
+ </ActionList>
510
+ </CardBody>
511
+ </Card>
512
+ </div>
513
+ );
514
+ };
515
+
516
+
@@ -0,0 +1,130 @@
1
+ import * as React from 'react';
2
+ import { Button, Card, CardBody, Title } from '@patternfly/react-core';
3
+ import { GripVerticalIcon, TimesIcon, WindowMinimizeIcon } from '@patternfly/react-icons';
4
+
5
+ interface FloatingWidgetProps {
6
+ children: React.ReactNode;
7
+ onClose: () => void;
8
+ title?: string;
9
+ }
10
+
11
+ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ children, onClose, title = 'Hale Commenting System' }) => {
12
+ const [position, setPosition] = React.useState({ x: window.innerWidth - 520, y: 100 });
13
+ const [isDragging, setIsDragging] = React.useState(false);
14
+ const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
15
+ const [isMinimized, setIsMinimized] = React.useState(false);
16
+ const widgetRef = React.useRef<HTMLDivElement>(null);
17
+
18
+ const handleMouseDown = (e: React.MouseEvent) => {
19
+ if (!widgetRef.current) return;
20
+ const rect = widgetRef.current.getBoundingClientRect();
21
+ setDragOffset({
22
+ x: e.clientX - rect.left,
23
+ y: e.clientY - rect.top,
24
+ });
25
+ setIsDragging(true);
26
+ };
27
+
28
+ React.useEffect(() => {
29
+ if (!isDragging) return;
30
+
31
+ const handleMouseMove = (e: MouseEvent) => {
32
+ setPosition({
33
+ x: e.clientX - dragOffset.x,
34
+ y: e.clientY - dragOffset.y,
35
+ });
36
+ };
37
+
38
+ const handleMouseUp = () => {
39
+ setIsDragging(false);
40
+ };
41
+
42
+ document.addEventListener('mousemove', handleMouseMove);
43
+ document.addEventListener('mouseup', handleMouseUp);
44
+
45
+ return () => {
46
+ document.removeEventListener('mousemove', handleMouseMove);
47
+ document.removeEventListener('mouseup', handleMouseUp);
48
+ };
49
+ }, [isDragging, dragOffset]);
50
+
51
+ // Constrain to viewport but allow dragging header even when partially off-screen
52
+ const constrainedPosition = React.useMemo(() => {
53
+ const widgetWidth = 500;
54
+ const widgetHeight = isMinimized ? 60 : 400;
55
+ const maxX = window.innerWidth - 50; // Allow 50px of widget to be visible for dragging
56
+ const maxY = window.innerHeight - 50;
57
+ return {
58
+ x: Math.max(-widgetWidth + 50, Math.min(position.x, maxX)),
59
+ y: Math.max(-widgetHeight + 50, Math.min(position.y, maxY)),
60
+ };
61
+ }, [position, isMinimized]);
62
+
63
+ return (
64
+ <div
65
+ ref={widgetRef}
66
+ style={{
67
+ position: 'fixed',
68
+ left: `${constrainedPosition.x}px`,
69
+ top: `${constrainedPosition.y}px`,
70
+ width: '500px',
71
+ height: isMinimized ? '60px' : '80vh',
72
+ maxHeight: '80vh',
73
+ zIndex: 10000,
74
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)',
75
+ borderRadius: 'var(--pf-t--global--border--radius--medium)',
76
+ backgroundColor: '#ffffff',
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ cursor: isDragging ? 'grabbing' : 'default',
80
+ }}
81
+ >
82
+ <div
83
+ onMouseDown={handleMouseDown}
84
+ style={{
85
+ padding: '1rem',
86
+ borderBottom: isMinimized ? 'none' : '1px solid var(--pf-t--global--border--color--default)',
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'space-between',
90
+ cursor: 'grab',
91
+ userSelect: 'none',
92
+ backgroundColor: '#ffffff',
93
+ borderRadius: isMinimized ? 'var(--pf-t--global--border--radius--medium)' : 'var(--pf-t--global--border--radius--medium) var(--pf-t--global--border--radius--medium) 0 0',
94
+ }}
95
+ >
96
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1 }}>
97
+ <GripVerticalIcon style={{ color: 'var(--pf-t--global--text--color--subtle)' }} />
98
+ <Title headingLevel="h2" size="lg">
99
+ {title}
100
+ </Title>
101
+ </div>
102
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
103
+ <Button
104
+ variant="plain"
105
+ icon={<WindowMinimizeIcon />}
106
+ onClick={(e) => {
107
+ e.stopPropagation();
108
+ setIsMinimized(!isMinimized);
109
+ }}
110
+ aria-label={isMinimized ? 'Maximize widget' : 'Minimize widget'}
111
+ />
112
+ <Button variant="plain" icon={<TimesIcon />} onClick={onClose} aria-label="Close widget" />
113
+ </div>
114
+ </div>
115
+ {!isMinimized && (
116
+ <div
117
+ style={{
118
+ overflow: 'auto',
119
+ flex: 1,
120
+ backgroundColor: '#ffffff',
121
+ borderRadius: '0 0 var(--pf-t--global--border--radius--medium) var(--pf-t--global--border--radius--medium)',
122
+ }}
123
+ >
124
+ {children}
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ };
130
+