@milesroxas/design-hub 0.0.0-stage → 0.1.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 (217) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/client.d.ts +6 -0
  4. package/configs/biome/biome.json +63 -0
  5. package/configs/tsconfig.project.json +17 -0
  6. package/core/bytes.test.ts +14 -0
  7. package/core/bytes.ts +14 -0
  8. package/core/catalog.test.ts +44 -0
  9. package/core/catalog.ts +43 -0
  10. package/core/checks.test.ts +23 -0
  11. package/core/checks.ts +72 -0
  12. package/core/contacts.test.ts +31 -0
  13. package/core/contacts.ts +38 -0
  14. package/core/doc.ts +4 -0
  15. package/core/feature.ts +6 -0
  16. package/core/fixtures.test.ts +33 -0
  17. package/core/fixtures.ts +69 -0
  18. package/core/frontmatter.ts +15 -0
  19. package/core/html.ts +3 -0
  20. package/core/image.test.ts +22 -0
  21. package/core/image.ts +37 -0
  22. package/core/jev.test.ts +66 -0
  23. package/core/jev.ts +77 -0
  24. package/core/review/model.ts +189 -0
  25. package/core/review/policy.ts +26 -0
  26. package/core/review/service.test.ts +163 -0
  27. package/core/review/service.ts +309 -0
  28. package/core/review/store.ts +51 -0
  29. package/core/review/triage.test.ts +41 -0
  30. package/core/review/triage.ts +58 -0
  31. package/core/states.test.ts +36 -0
  32. package/core/states.ts +34 -0
  33. package/dist/core/bytes.d.ts +5 -0
  34. package/dist/core/bytes.js +9 -0
  35. package/dist/core/catalog.d.ts +17 -0
  36. package/dist/core/catalog.js +24 -0
  37. package/dist/core/checks.d.ts +41 -0
  38. package/dist/core/checks.js +53 -0
  39. package/dist/core/contacts.d.ts +9 -0
  40. package/dist/core/contacts.js +34 -0
  41. package/dist/core/doc.d.ts +11 -0
  42. package/dist/core/doc.js +1 -0
  43. package/dist/core/feature.d.ts +3 -0
  44. package/dist/core/feature.js +4 -0
  45. package/dist/core/fixtures.d.ts +19 -0
  46. package/dist/core/fixtures.js +52 -0
  47. package/dist/core/frontmatter.d.ts +4 -0
  48. package/dist/core/frontmatter.js +15 -0
  49. package/dist/core/html.d.ts +2 -0
  50. package/dist/core/html.js +2 -0
  51. package/dist/core/image.d.ts +6 -0
  52. package/dist/core/image.js +34 -0
  53. package/dist/core/jev.d.ts +54 -0
  54. package/dist/core/jev.js +52 -0
  55. package/dist/core/review/model.d.ts +85 -0
  56. package/dist/core/review/model.js +112 -0
  57. package/dist/core/review/policy.d.ts +3 -0
  58. package/dist/core/review/policy.js +19 -0
  59. package/dist/core/review/service.d.ts +79 -0
  60. package/dist/core/review/service.js +245 -0
  61. package/dist/core/review/store.d.ts +20 -0
  62. package/dist/core/review/store.js +37 -0
  63. package/dist/core/review/triage.d.ts +6 -0
  64. package/dist/core/review/triage.js +45 -0
  65. package/dist/core/states.d.ts +6 -0
  66. package/dist/core/states.js +29 -0
  67. package/dist/scripts/bootstrap.d.ts +51 -0
  68. package/dist/scripts/bootstrap.js +278 -0
  69. package/dist/scripts/calibrate.d.ts +127 -0
  70. package/dist/scripts/calibrate.js +164 -0
  71. package/dist/scripts/check.d.ts +25 -0
  72. package/dist/scripts/check.js +30 -0
  73. package/dist/scripts/cli.d.ts +2 -0
  74. package/dist/scripts/cli.js +73 -0
  75. package/dist/scripts/commands.d.ts +25 -0
  76. package/dist/scripts/commands.js +49 -0
  77. package/dist/scripts/comments.d.ts +63 -0
  78. package/dist/scripts/comments.js +152 -0
  79. package/dist/scripts/dev.d.ts +46 -0
  80. package/dist/scripts/dev.js +84 -0
  81. package/dist/scripts/elyx.d.ts +61 -0
  82. package/dist/scripts/elyx.js +195 -0
  83. package/dist/scripts/fixtures-pull.d.ts +60 -0
  84. package/dist/scripts/fixtures-pull.js +121 -0
  85. package/dist/scripts/guard.d.ts +58 -0
  86. package/dist/scripts/guard.js +201 -0
  87. package/dist/scripts/hook.d.ts +18 -0
  88. package/dist/scripts/hook.js +46 -0
  89. package/dist/scripts/lib/bin.d.ts +4 -0
  90. package/dist/scripts/lib/bin.js +20 -0
  91. package/dist/scripts/lib/catalog.d.ts +7 -0
  92. package/dist/scripts/lib/catalog.js +18 -0
  93. package/dist/scripts/lib/cli.d.ts +75 -0
  94. package/dist/scripts/lib/cli.js +88 -0
  95. package/dist/scripts/lib/css-tokens.d.ts +13 -0
  96. package/dist/scripts/lib/css-tokens.js +67 -0
  97. package/dist/scripts/lib/judge.d.ts +24 -0
  98. package/dist/scripts/lib/judge.js +60 -0
  99. package/dist/scripts/lib/ports.d.ts +35 -0
  100. package/dist/scripts/lib/ports.js +113 -0
  101. package/dist/scripts/lib/review.d.ts +7 -0
  102. package/dist/scripts/lib/review.js +22 -0
  103. package/dist/scripts/lib/rules.d.ts +12 -0
  104. package/dist/scripts/lib/rules.js +20 -0
  105. package/dist/scripts/lib/vercel.d.ts +17 -0
  106. package/dist/scripts/lib/vercel.js +53 -0
  107. package/dist/scripts/new.d.ts +47 -0
  108. package/dist/scripts/new.js +113 -0
  109. package/dist/scripts/people.d.ts +59 -0
  110. package/dist/scripts/people.js +73 -0
  111. package/dist/scripts/preflight.d.ts +35 -0
  112. package/dist/scripts/preflight.js +251 -0
  113. package/dist/scripts/release-check.d.ts +19 -0
  114. package/dist/scripts/release-check.js +39 -0
  115. package/dist/scripts/skill-hint.d.ts +32 -0
  116. package/dist/scripts/skill-hint.js +103 -0
  117. package/dist/scripts/skills.d.ts +13 -0
  118. package/dist/scripts/skills.js +34 -0
  119. package/dist/scripts/visual-match.d.ts +46 -0
  120. package/dist/scripts/visual-match.js +93 -0
  121. package/dist/server/api.d.ts +22 -0
  122. package/dist/server/api.js +143 -0
  123. package/dist/server/checks.d.ts +4 -0
  124. package/dist/server/checks.js +22 -0
  125. package/dist/server/dev.d.ts +2 -0
  126. package/dist/server/dev.js +54 -0
  127. package/dist/server/env.d.ts +8 -0
  128. package/dist/server/env.js +57 -0
  129. package/dist/server/fake-upstash.d.ts +1 -0
  130. package/dist/server/fake-upstash.js +37 -0
  131. package/dist/server/file-kv.d.ts +2 -0
  132. package/dist/server/file-kv.js +24 -0
  133. package/dist/server/local.d.ts +4 -0
  134. package/dist/server/local.js +25 -0
  135. package/dist/server/paths.d.ts +22 -0
  136. package/dist/server/paths.js +81 -0
  137. package/dist/server/session.d.ts +22 -0
  138. package/dist/server/session.js +110 -0
  139. package/dist/server/upstash.d.ts +7 -0
  140. package/dist/server/upstash.js +41 -0
  141. package/dist/vercel/index.d.ts +5 -0
  142. package/dist/vercel/index.js +12 -0
  143. package/dist/vercel/proxy.d.ts +11 -0
  144. package/dist/vercel/proxy.js +238 -0
  145. package/dist/vite/kit.d.ts +4 -0
  146. package/dist/vite/kit.js +35 -0
  147. package/dist/vite/markdown.d.ts +2 -0
  148. package/dist/vite/markdown.js +212 -0
  149. package/dist/vite/plugin.d.ts +15 -0
  150. package/dist/vite/plugin.js +84 -0
  151. package/dist/vite/registry.d.ts +7 -0
  152. package/dist/vite/registry.js +70 -0
  153. package/kit/Icon.tsx +39 -0
  154. package/kit/Space.tsx +25 -0
  155. package/kit/Variants.tsx +134 -0
  156. package/kit/ViewTools.tsx +34 -0
  157. package/kit/base.css +27 -0
  158. package/kit/dials.css +98 -0
  159. package/kit/dials.tsx +322 -0
  160. package/kit/fixtures.ts +3 -0
  161. package/kit/format.ts +13 -0
  162. package/kit/harness.css +99 -0
  163. package/kit/hooks.ts +35 -0
  164. package/kit/icons.ts +329 -0
  165. package/kit/motion.ts +18 -0
  166. package/kit/prefs.ts +111 -0
  167. package/kit/space.css +12 -0
  168. package/kit/ui/Button.tsx +135 -0
  169. package/kit/ui/Menu.tsx +386 -0
  170. package/kit/ui/Overlay.tsx +300 -0
  171. package/kit/ui/Toast.tsx +187 -0
  172. package/kit/ui/index.ts +13 -0
  173. package/kit/ui/layer.ts +51 -0
  174. package/kit/ui/ui.css +238 -0
  175. package/package.json +87 -3
  176. package/shell/Guide.tsx +160 -0
  177. package/shell/Index.tsx +301 -0
  178. package/shell/Notice.tsx +27 -0
  179. package/shell/Prototype.tsx +31 -0
  180. package/shell/a11y.ts +50 -0
  181. package/shell/doc.d.ts +7 -0
  182. package/shell/doc.ts +3 -0
  183. package/shell/guide.css +133 -0
  184. package/shell/index.html +17 -0
  185. package/shell/main.tsx +53 -0
  186. package/shell/public/favicon.svg +1 -0
  187. package/shell/registry.ts +21 -0
  188. package/shell/registry.virtual.d.ts +10 -0
  189. package/shell/review/Review.tsx +989 -0
  190. package/shell/review/anchor.ts +132 -0
  191. package/shell/review/client.ts +165 -0
  192. package/shell/review/mount.tsx +12 -0
  193. package/shell/review/review.css +296 -0
  194. package/shell/shell.css +160 -0
  195. package/shell/system.css +238 -0
  196. package/skills/miles-conductor/SKILL.md +37 -0
  197. package/skills/miles-deploy/SKILL.md +67 -0
  198. package/skills/miles-design-hub/SKILL.md +62 -0
  199. package/skills/miles-get-hostile/SKILL.md +61 -0
  200. package/skills/miles-new-feature/SKILL.md +40 -0
  201. package/skills/miles-paper-to-prototype/SKILL.md +46 -0
  202. package/skills/miles-port/SKILL.md +59 -0
  203. package/skills/miles-port/references/astro.md +35 -0
  204. package/skills/miles-port/references/next.md +36 -0
  205. package/skills/miles-prototype/SKILL.md +83 -0
  206. package/skills/miles-prototype/references/accessibility.md +48 -0
  207. package/skills/miles-prototype/references/motion.md +59 -0
  208. package/skills/miles-prototype/references/performance.md +45 -0
  209. package/skills/miles-review/SKILL.md +39 -0
  210. package/templates/app/README.md +41 -0
  211. package/templates/app/index.tsx +32 -0
  212. package/templates/app/styles.css +1 -0
  213. package/templates/conductor/settings.toml +29 -0
  214. package/templates/guide.md +38 -0
  215. package/templates/space/README.md +37 -0
  216. package/templates/space/index.tsx +43 -0
  217. package/templates/space/styles.css +13 -0
@@ -0,0 +1,989 @@
1
+ // The review layer on a deployed prototype: a Comments button, a side panel
2
+ // with the threads for this prototype, pins on the elements they are about,
3
+ // and, for editors, the people who may comment. It is harness, not product:
4
+ // it wears the harness material and never borrows the prototype's chrome.
5
+ import { Icon } from '@kit/Icon';
6
+ import {
7
+ type FormEvent,
8
+ type KeyboardEvent,
9
+ type RefObject,
10
+ useEffect,
11
+ useMemo,
12
+ useReducer,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
16
+ import {
17
+ type Anchor,
18
+ type Comment,
19
+ INVITE_DAYS,
20
+ KINDS,
21
+ type Kind,
22
+ LIMITS,
23
+ type Role,
24
+ } from '../../core/review/model';
25
+ import { captureAnchor, currentUrl, isHarness, openAnchor, pinPoint, resumeAnchor } from './anchor';
26
+ import { createReviewStore, type Me, type ReviewStore, useReviewState } from './client';
27
+ import './review.css';
28
+
29
+ type Props = { prototype: string; feature: string; states: string[] };
30
+ type Draft = { target: Element | null; point: { x: number; y: number } | null };
31
+
32
+ const KIND_LABEL: Record<Kind, string> = { bug: 'Bug', taste: 'Taste', copy: 'Copy' };
33
+ const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
34
+ const STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
35
+ ['year', 31_536_000],
36
+ ['month', 2_592_000],
37
+ ['week', 604_800],
38
+ ['day', 86_400],
39
+ ['hour', 3600],
40
+ ['minute', 60],
41
+ ];
42
+ function ago(iso: string): string {
43
+ const seconds = (Date.parse(iso) - Date.now()) / 1000;
44
+ for (const [unit, size] of STEPS)
45
+ if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
46
+ return 'just now';
47
+ }
48
+
49
+ const describe = (el: Element | null) => {
50
+ if (!el) return 'the whole page';
51
+ const text = (el.getAttribute('aria-label') || el.textContent || '').trim().replace(/\s+/g, ' ');
52
+ return text ? `“${text.length > 40 ? `${text.slice(0, 40)}…` : text}”` : `a ${el.localName} element`;
53
+ };
54
+
55
+ // The store shows every failure in the panel, so a click handler has nothing left to do with one.
56
+ const act = (pending: Promise<unknown>) => void pending.catch(() => {});
57
+
58
+ const submitOnModEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
59
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
60
+ e.preventDefault();
61
+ e.currentTarget.form?.requestSubmit();
62
+ }
63
+ };
64
+
65
+ /**
66
+ * What the layer shows: closed, the panel, or the page while an element is
67
+ * being picked. One reducer, so a transition (pick, back to the panel, close)
68
+ * changes every field it touches at once and no two fields can disagree.
69
+ */
70
+ type View = { mode: 'closed' | 'panel' | 'picking'; tab: Tab; draft: Draft | null; focus: string | null };
71
+ type ViewAction =
72
+ | { type: 'open'; focus?: string }
73
+ | { type: 'close' }
74
+ | { type: 'pick' }
75
+ | { type: 'picked'; draft: Draft | null }
76
+ | { type: 'tab'; tab: Tab }
77
+ | { type: 'drafted' };
78
+
79
+ function view(state: View, action: ViewAction): View {
80
+ switch (action.type) {
81
+ case 'open':
82
+ return { ...state, mode: 'panel', focus: action.focus ?? state.focus };
83
+ case 'close':
84
+ return { ...state, mode: 'closed', draft: null };
85
+ case 'pick':
86
+ return { ...state, mode: 'picking' };
87
+ case 'picked':
88
+ return { ...state, mode: 'panel', draft: action.draft };
89
+ case 'tab':
90
+ return { ...state, tab: action.tab };
91
+ case 'drafted':
92
+ return { ...state, draft: null };
93
+ }
94
+ }
95
+
96
+ const INITIAL_VIEW: View = { mode: 'closed', tab: 'comments', draft: null, focus: null };
97
+
98
+ export function Review({ prototype, feature, states }: Props) {
99
+ const store = useMemo(() => createReviewStore(prototype), [prototype]);
100
+ const s = useReviewState(store);
101
+ const [v, dispatch] = useReducer(view, INITIAL_VIEW);
102
+ const url = useCurrentUrl();
103
+
104
+ useEffect(() => {
105
+ void store.load();
106
+ resumeAnchor((id) => dispatch({ type: 'open', focus: id }));
107
+ }, [store]);
108
+
109
+ const shown = s.status !== 'off' && s.status !== 'loading';
110
+ useToastLift(shown);
111
+ if (!shown) return null;
112
+
113
+ const close = () => dispatch({ type: 'close' });
114
+ const roots = s.comments.filter((c) => c.thread === c.id);
115
+ const number = new Map(roots.map((c, i) => [c.id, i + 1]));
116
+
117
+ return (
118
+ <div data-harness="review" className="rv">
119
+ {v.mode === 'picking' ? (
120
+ <PickLayer
121
+ onPick={(target, point) => dispatch({ type: 'picked', draft: { target, point } })}
122
+ onPage={() => dispatch({ type: 'picked', draft: { target: null, point: null } })}
123
+ onCancel={() => dispatch({ type: 'picked', draft: null })}
124
+ />
125
+ ) : (
126
+ <Launcher
127
+ open={v.mode === 'panel'}
128
+ openCount={roots.filter((c) => c.status === 'open').length}
129
+ onClick={() => (v.mode === 'panel' ? close() : dispatch({ type: 'open' }))}
130
+ />
131
+ )}
132
+ {s.status === 'ready' && (
133
+ <Pins
134
+ comments={roots.filter((c) => c.status === 'open' && c.anchor?.url === url)}
135
+ number={number}
136
+ draft={v.draft}
137
+ onOpen={(id) => dispatch({ type: 'open', focus: id })}
138
+ />
139
+ )}
140
+ {v.mode === 'panel' && (
141
+ <Panel
142
+ store={store}
143
+ state={s}
144
+ tab={s.me?.role === 'editor' ? v.tab : null}
145
+ onTab={(tab) => {
146
+ dispatch({ type: 'tab', tab });
147
+ if (tab === 'people' && !s.people) act(store.loadPeople());
148
+ }}
149
+ onClose={close}
150
+ threads={{
151
+ store,
152
+ me: s.me,
153
+ roots,
154
+ replies: s.comments.filter((c) => c.thread !== c.id),
155
+ number,
156
+ states,
157
+ focus: v.focus,
158
+ draft: v.draft,
159
+ onNew: () => dispatch({ type: 'pick' }),
160
+ onCancelDraft: () => dispatch({ type: 'drafted' }),
161
+ onPost: async (body) => {
162
+ const { target = null, point = null } = v.draft ?? {};
163
+ await store.add(body, captureAnchor({ prototype, feature }, target, point), states);
164
+ dispatch({ type: 'drafted' });
165
+ },
166
+ // A changed hash reaches useCurrentUrl through hashchange; the panel only moves its focus.
167
+ onShow: (anchor, id) => openAnchor(anchor, id, () => dispatch({ type: 'open', focus: id })),
168
+ }}
169
+ />
170
+ )}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /** The page's path, query and hash, kept current as hash routes change. */
176
+ function useCurrentUrl(): string {
177
+ const [url, setUrl] = useState(currentUrl);
178
+ useEffect(() => {
179
+ const onRoute = () => setUrl(currentUrl());
180
+ addEventListener('hashchange', onRoute);
181
+ return () => removeEventListener('hashchange', onRoute);
182
+ }, []);
183
+ return url;
184
+ }
185
+
186
+ // The launcher sits where toasts land; lift them above it while it is on the page.
187
+ function useToastLift(active: boolean) {
188
+ useEffect(() => {
189
+ if (!active) return;
190
+ document.documentElement.style.setProperty('--toast-lift', '56px');
191
+ return () => {
192
+ document.documentElement.style.removeProperty('--toast-lift');
193
+ };
194
+ }, [active]);
195
+ }
196
+
197
+ /** Focuses the panel's first autofocus control, preferring the body's, whenever `when` turns true. */
198
+ function useAutofocus(panel: RefObject<HTMLElement | null>, when: boolean) {
199
+ useEffect(() => {
200
+ if (!when) return;
201
+ const p = panel.current;
202
+ (
203
+ p?.querySelector<HTMLElement>('.rv-body [data-autofocus]') ??
204
+ p?.querySelector<HTMLElement>('[data-autofocus]')
205
+ )?.focus();
206
+ }, [panel, when]);
207
+ }
208
+
209
+ type Tab = 'comments' | 'people';
210
+
211
+ function Launcher({ open, openCount, onClick }: { open: boolean; openCount: number; onClick: () => void }) {
212
+ const ref = useRef<HTMLButtonElement>(null);
213
+ const wasOpen = useRef(open);
214
+ // Closing the panel hands focus back to the button that opened it.
215
+ useEffect(() => {
216
+ if (wasOpen.current && !open) ref.current?.focus();
217
+ wasOpen.current = open;
218
+ }, [open]);
219
+ return (
220
+ <button
221
+ ref={ref}
222
+ type="button"
223
+ className="rv-launch"
224
+ aria-expanded={open}
225
+ aria-controls="rv-panel"
226
+ onClick={onClick}
227
+ >
228
+ <Icon name="message-text-square-01" size={16} />
229
+ <span>Comments</span>
230
+ {openCount > 0 && (
231
+ <span className="rv-count">
232
+ {openCount}
233
+ <span className="rv-sr"> open</span>
234
+ </span>
235
+ )}
236
+ </button>
237
+ );
238
+ }
239
+
240
+ /** Tabs for an editor (tab is set), a plain title for everyone else (tab is null). */
241
+ function PanelHead({
242
+ tab,
243
+ onTab,
244
+ onClose,
245
+ }: {
246
+ tab: Tab | null;
247
+ onTab: (t: Tab) => void;
248
+ onClose: () => void;
249
+ }) {
250
+ return (
251
+ <header className="rv-head">
252
+ {tab ? (
253
+ <div className="rv-tabs" role="tablist" aria-label="Review">
254
+ {(['comments', 'people'] as const).map((t) => (
255
+ <button
256
+ key={t}
257
+ type="button"
258
+ role="tab"
259
+ aria-selected={tab === t}
260
+ data-autofocus={tab === t ? '' : undefined}
261
+ onClick={() => onTab(t)}
262
+ >
263
+ {t === 'comments' ? 'Comments' : 'People'}
264
+ </button>
265
+ ))}
266
+ </div>
267
+ ) : (
268
+ <h2 className="rv-title">Comments</h2>
269
+ )}
270
+ <button
271
+ type="button"
272
+ className="rv-icon"
273
+ aria-label="Close review"
274
+ onClick={onClose}
275
+ data-autofocus={tab ? undefined : ''}
276
+ >
277
+ <Icon name="x-close" size={16} />
278
+ </button>
279
+ </header>
280
+ );
281
+ }
282
+
283
+ type ThreadsProps = Parameters<typeof Threads>[0];
284
+
285
+ function Panel({
286
+ store,
287
+ state,
288
+ tab,
289
+ onTab,
290
+ onClose,
291
+ threads,
292
+ }: {
293
+ store: ReviewStore;
294
+ state: ReturnType<ReviewStore['get']>;
295
+ tab: Tab | null;
296
+ onTab: (t: Tab) => void;
297
+ onClose: () => void;
298
+ threads: ThreadsProps;
299
+ }) {
300
+ const ref = useRef<HTMLElement>(null);
301
+ // The panel mounts again after every pick, so focus goes into it each time; a draft focuses its own text box.
302
+ useAutofocus(ref, !threads.draft);
303
+ return (
304
+ <aside
305
+ id="rv-panel"
306
+ ref={ref}
307
+ className="rv-panel"
308
+ aria-label="Review"
309
+ onKeyDown={(e) => {
310
+ // Esc closes the panel, except from a text box with words in it, where it would lose them.
311
+ if (e.key === 'Escape' && !(e.target instanceof HTMLTextAreaElement && e.target.value)) onClose();
312
+ }}
313
+ >
314
+ <PanelHead tab={tab} onTab={onTab} onClose={onClose} />
315
+ {state.error && (
316
+ <p className="rv-error" role="alert">
317
+ {state.error}
318
+ <button type="button" className="rv-link" onClick={store.clearError}>
319
+ Dismiss
320
+ </button>
321
+ </p>
322
+ )}
323
+ {state.status === 'guest' ? (
324
+ <p className="rv-empty">
325
+ You are viewing with the shared password. Ask the team for an invite link to read and leave
326
+ comments.
327
+ </p>
328
+ ) : tab === 'people' ? (
329
+ <People store={store} me={state.me} people={state.people} />
330
+ ) : (
331
+ <Threads {...threads} />
332
+ )}
333
+ </aside>
334
+ );
335
+ }
336
+
337
+ function PickLayer({
338
+ onPick,
339
+ onPage,
340
+ onCancel,
341
+ }: {
342
+ onPick: (target: Element, point: { x: number; y: number }) => void;
343
+ onPage: () => void;
344
+ onCancel: () => void;
345
+ }) {
346
+ const box = useRef<HTMLDivElement>(null);
347
+ const pageButton = useRef<HTMLButtonElement>(null);
348
+ const under = (x: number, y: number) =>
349
+ document
350
+ .elementsFromPoint(x, y)
351
+ .find((el) => !isHarness(el) && el !== document.body && el !== document.documentElement) ?? null;
352
+
353
+ useEffect(() => {
354
+ pageButton.current?.focus();
355
+ const onKey = (e: globalThis.KeyboardEvent) => e.key === 'Escape' && onCancel();
356
+ addEventListener('keydown', onKey);
357
+ return () => removeEventListener('keydown', onKey);
358
+ }, [onCancel]);
359
+
360
+ return (
361
+ <>
362
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: a pointer-only picking surface; the bar's buttons are the keyboard path */}
363
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: as above, the keyboard picks "The whole page" in the bar */}
364
+ <div
365
+ className="rv-pick"
366
+ onPointerMove={(e) => {
367
+ const el = under(e.clientX, e.clientY);
368
+ const b = box.current;
369
+ if (!b) return;
370
+ if (!el) {
371
+ b.hidden = true;
372
+ return;
373
+ }
374
+ const r = el.getBoundingClientRect();
375
+ b.hidden = false;
376
+ b.style.transform = `translate(${r.left}px, ${r.top}px)`;
377
+ b.style.width = `${r.width}px`;
378
+ b.style.height = `${r.height}px`;
379
+ }}
380
+ onClick={(e) => {
381
+ const el = under(e.clientX, e.clientY);
382
+ if (el) onPick(el, { x: e.clientX, y: e.clientY });
383
+ }}
384
+ />
385
+ <div ref={box} className="rv-pick-box" hidden />
386
+ <section className="rv-pick-bar" aria-label="Choose what to comment on">
387
+ <span>Click the element your comment is about.</span>
388
+ <button ref={pageButton} type="button" className="rv-button" onClick={onPage}>
389
+ The whole page
390
+ </button>
391
+ <button type="button" className="rv-link" onClick={onCancel}>
392
+ Cancel
393
+ </button>
394
+ </section>
395
+ </>
396
+ );
397
+ }
398
+
399
+ function Pins({
400
+ comments,
401
+ number,
402
+ draft,
403
+ onOpen,
404
+ }: {
405
+ comments: Comment[];
406
+ number: Map<string, number>;
407
+ draft: Draft | null;
408
+ onOpen: (id: string) => void;
409
+ }) {
410
+ const layer = useRef<HTMLDivElement>(null);
411
+
412
+ // Positions are written straight to the pins' transforms on scroll, resize
413
+ // and DOM changes, at most once a frame, so a scroll re-renders nothing.
414
+ useEffect(() => {
415
+ let frame = 0;
416
+ const place = () => {
417
+ frame = 0;
418
+ for (const pin of layer.current?.querySelectorAll<HTMLElement>('[data-pin]') ?? []) {
419
+ const c = comments.find((x) => x.id === pin.dataset.pin);
420
+ const at = c?.anchor ? pinPoint(c.anchor) : null;
421
+ pin.hidden = !at;
422
+ if (at) pin.style.transform = `translate(${at.x}px, ${at.y}px)`;
423
+ }
424
+ const d = layer.current?.querySelector<HTMLElement>('[data-draft]');
425
+ if (d && draft?.point) d.style.transform = `translate(${draft.point.x}px, ${draft.point.y}px)`;
426
+ };
427
+ const schedule = () => {
428
+ if (!frame) frame = requestAnimationFrame(place);
429
+ };
430
+ place();
431
+ const observer = new MutationObserver(schedule);
432
+ observer.observe(document.body, {
433
+ subtree: true,
434
+ childList: true,
435
+ attributes: true,
436
+ attributeFilter: ['class', 'style', 'hidden'],
437
+ });
438
+ addEventListener('scroll', schedule, { capture: true, passive: true });
439
+ addEventListener('resize', schedule);
440
+ return () => {
441
+ cancelAnimationFrame(frame);
442
+ observer.disconnect();
443
+ removeEventListener('scroll', schedule, { capture: true });
444
+ removeEventListener('resize', schedule);
445
+ };
446
+ }, [comments, draft]);
447
+
448
+ return (
449
+ // The list in the panel is the accessible path to every comment; pins are a pointer shortcut.
450
+ <div ref={layer} className="rv-pins" aria-hidden="true">
451
+ {comments.map((c) => (
452
+ <button
453
+ key={c.id}
454
+ type="button"
455
+ tabIndex={-1}
456
+ className="rv-pin"
457
+ data-pin={c.id}
458
+ hidden
459
+ onClick={() => onOpen(c.id)}
460
+ >
461
+ {number.get(c.id)}
462
+ </button>
463
+ ))}
464
+ {draft?.point && <span className="rv-pin" data-draft="" data-new="" />}
465
+ </div>
466
+ );
467
+ }
468
+
469
+ function Threads({
470
+ store,
471
+ me,
472
+ roots,
473
+ replies,
474
+ number,
475
+ states,
476
+ focus,
477
+ draft,
478
+ onNew,
479
+ onCancelDraft,
480
+ onPost,
481
+ onShow,
482
+ }: {
483
+ store: ReviewStore;
484
+ me: Me | null;
485
+ roots: Comment[];
486
+ replies: Comment[];
487
+ number: Map<string, number>;
488
+ states: string[];
489
+ focus: string | null;
490
+ draft: Draft | null;
491
+ onNew: () => void;
492
+ onCancelDraft: () => void;
493
+ onPost: (body: string) => Promise<void>;
494
+ onShow: (anchor: Anchor, id: string) => void;
495
+ }) {
496
+ const [filter, setFilter] = useState<'open' | 'resolved'>('open');
497
+ const list = roots.filter((c) => c.status === filter).reverse();
498
+ const counts = {
499
+ open: roots.filter((c) => c.status === 'open').length,
500
+ resolved: roots.filter((c) => c.status === 'resolved').length,
501
+ };
502
+
503
+ useEffect(() => {
504
+ if (!focus) return;
505
+ const target = roots.find((c) => c.id === focus);
506
+ if (target && target.status !== filter) setFilter(target.status);
507
+ requestAnimationFrame(() => document.getElementById(`rv-${focus}`)?.scrollIntoView({ block: 'nearest' }));
508
+ }, [focus, roots, filter]);
509
+
510
+ return (
511
+ <div className="rv-body">
512
+ {draft ? (
513
+ <Composer
514
+ label={`New comment on ${describe(draft.target)}`}
515
+ submit="Post"
516
+ onSubmit={onPost}
517
+ onCancel={onCancelDraft}
518
+ />
519
+ ) : (
520
+ <button type="button" className="rv-button rv-new" onClick={onNew} data-autofocus="">
521
+ <Icon name="plus" size={16} /> New comment
522
+ </button>
523
+ )}
524
+ <fieldset className="rv-filter">
525
+ <legend className="rv-sr">Show</legend>
526
+ {(['open', 'resolved'] as const).map((f) => (
527
+ <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)}>
528
+ {f === 'open' ? 'Open' : 'Resolved'} <span className="rv-muted">{counts[f]}</span>
529
+ </button>
530
+ ))}
531
+ </fieldset>
532
+ {list.length === 0 ? (
533
+ <p className="rv-empty">
534
+ {filter === 'open' ? 'No open comments on this prototype.' : 'Nothing resolved yet.'}
535
+ </p>
536
+ ) : (
537
+ <ol className="rv-list">
538
+ {list.map((c) => {
539
+ const { anchor } = c;
540
+ return (
541
+ <Thread
542
+ key={c.id}
543
+ store={store}
544
+ me={me}
545
+ root={c}
546
+ replies={replies.filter((r) => r.thread === c.id)}
547
+ n={number.get(c.id) ?? 0}
548
+ states={states}
549
+ focused={focus === c.id}
550
+ onShow={anchor ? () => onShow(anchor, c.id) : undefined}
551
+ />
552
+ );
553
+ })}
554
+ </ol>
555
+ )}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ type TriageChange = { kind: string | null; state: string | null };
561
+
562
+ function TriageSelect({
563
+ label,
564
+ empty,
565
+ value,
566
+ options,
567
+ onChange,
568
+ }: {
569
+ label: string;
570
+ empty: string;
571
+ value: string | null;
572
+ options: [string, string][];
573
+ onChange: (value: string | null) => void;
574
+ }) {
575
+ return (
576
+ <label className="rv-select">
577
+ <span className="rv-sr">{label}</span>
578
+ <select value={value ?? ''} onChange={(e) => onChange(e.target.value || null)}>
579
+ <option value="">{empty}</option>
580
+ {options.map(([v, text]) => (
581
+ <option key={v} value={v}>
582
+ {text}
583
+ </option>
584
+ ))}
585
+ </select>
586
+ </label>
587
+ );
588
+ }
589
+
590
+ /** Editors correct the kind and state; everyone else reads them. */
591
+ function TriageTags({
592
+ editor,
593
+ triage,
594
+ states,
595
+ onChange,
596
+ }: {
597
+ editor: boolean;
598
+ triage: Comment['triage'];
599
+ states: string[];
600
+ onChange: (next: TriageChange) => void;
601
+ }) {
602
+ const { kind = null, state = null } = triage ?? {};
603
+ return (
604
+ <div className="rv-tags">
605
+ {editor ? (
606
+ <>
607
+ <TriageSelect
608
+ label="Kind"
609
+ empty="No kind"
610
+ value={kind}
611
+ options={KINDS.map((k) => [k, KIND_LABEL[k]])}
612
+ onChange={(next) => onChange({ kind: next, state })}
613
+ />
614
+ {states.length > 0 && (
615
+ <TriageSelect
616
+ label="State"
617
+ empty="No state"
618
+ value={state}
619
+ options={states.map((st) => [st, st])}
620
+ onChange={(next) => onChange({ kind, state: next })}
621
+ />
622
+ )}
623
+ </>
624
+ ) : (
625
+ [kind && KIND_LABEL[kind], state].filter(Boolean).map((tag) => (
626
+ <span key={tag} className="rv-tag">
627
+ {tag}
628
+ </span>
629
+ ))
630
+ )}
631
+ {triage?.by === 'jev' && <span className="rv-muted">sorted automatically</span>}
632
+ </div>
633
+ );
634
+ }
635
+
636
+ function ThreadActions({
637
+ store,
638
+ root,
639
+ canClose,
640
+ onReply,
641
+ onShow,
642
+ }: {
643
+ store: ReviewStore;
644
+ root: Comment;
645
+ canClose: boolean;
646
+ onReply: () => void;
647
+ onShow?: () => void;
648
+ }) {
649
+ const [confirming, setConfirming] = useState(false);
650
+ if (confirming)
651
+ return (
652
+ <div className="rv-actions">
653
+ <span>Delete the thread and its replies?</span>
654
+ <button type="button" className="rv-link rv-danger" onClick={() => act(store.remove(root.id))}>
655
+ Delete
656
+ </button>
657
+ <button type="button" className="rv-link" onClick={() => setConfirming(false)}>
658
+ Keep
659
+ </button>
660
+ </div>
661
+ );
662
+ return (
663
+ <div className="rv-actions">
664
+ {onShow && (
665
+ <button type="button" className="rv-link" onClick={onShow}>
666
+ Show state
667
+ </button>
668
+ )}
669
+ <button type="button" className="rv-link" onClick={onReply}>
670
+ Reply
671
+ </button>
672
+ {canClose && (
673
+ <>
674
+ <button
675
+ type="button"
676
+ className="rv-link"
677
+ onClick={() => act(store.resolve(root.id, root.status === 'open'))}
678
+ >
679
+ {root.status === 'open' ? 'Resolve' : 'Reopen'}
680
+ </button>
681
+ <button type="button" className="rv-link" onClick={() => setConfirming(true)}>
682
+ Delete
683
+ </button>
684
+ </>
685
+ )}
686
+ </div>
687
+ );
688
+ }
689
+
690
+ function Thread({
691
+ store,
692
+ me,
693
+ root,
694
+ replies,
695
+ n,
696
+ states,
697
+ focused,
698
+ onShow,
699
+ }: {
700
+ store: ReviewStore;
701
+ me: Me | null;
702
+ root: Comment;
703
+ replies: Comment[];
704
+ n: number;
705
+ states: string[];
706
+ focused: boolean;
707
+ onShow?: () => void;
708
+ }) {
709
+ const [replying, setReplying] = useState(false);
710
+ const editor = me?.role === 'editor';
711
+ return (
712
+ <li
713
+ id={`rv-${root.id}`}
714
+ className="rv-thread"
715
+ data-focused={focused ? '' : undefined}
716
+ data-resolved={root.status === 'resolved' ? '' : undefined}
717
+ >
718
+ <Entry store={store} me={me} comment={root} n={n} />
719
+ <TriageTags
720
+ editor={editor}
721
+ triage={root.triage}
722
+ states={states}
723
+ onChange={(next) => act(store.triage(root.id, next, states))}
724
+ />
725
+ {replies.length > 0 && (
726
+ <ol className="rv-replies">
727
+ {replies.map((r) => (
728
+ <li key={r.id}>
729
+ <Entry store={store} me={me} comment={r} />
730
+ </li>
731
+ ))}
732
+ </ol>
733
+ )}
734
+ {replying ? (
735
+ <Composer
736
+ label="Reply"
737
+ submit="Reply"
738
+ onSubmit={async (body) => {
739
+ await store.reply(root.id, body);
740
+ setReplying(false);
741
+ }}
742
+ onCancel={() => setReplying(false)}
743
+ />
744
+ ) : (
745
+ <ThreadActions
746
+ store={store}
747
+ root={root}
748
+ canClose={editor || me?.id === root.author.id}
749
+ onReply={() => setReplying(true)}
750
+ onShow={onShow}
751
+ />
752
+ )}
753
+ </li>
754
+ );
755
+ }
756
+
757
+ function Entry({
758
+ store,
759
+ me,
760
+ comment,
761
+ n,
762
+ }: {
763
+ store: ReviewStore;
764
+ me: Me | null;
765
+ comment: Comment;
766
+ n?: number;
767
+ }) {
768
+ const [editing, setEditing] = useState(false);
769
+ const own = me?.id === comment.author.id;
770
+ return (
771
+ <article className="rv-entry">
772
+ <header>
773
+ {n !== undefined && <span className="rv-no">{n}</span>}
774
+ <strong>{comment.author.name}</strong>
775
+ <span className="rv-muted">
776
+ {comment.author.team} · <time dateTime={comment.createdAt}>{ago(comment.createdAt)}</time>
777
+ {comment.editedAt && ' · edited'}
778
+ </span>
779
+ {own && !editing && (
780
+ <button type="button" className="rv-link rv-edit" onClick={() => setEditing(true)}>
781
+ Edit
782
+ </button>
783
+ )}
784
+ </header>
785
+ {editing ? (
786
+ <Composer
787
+ label="Edit comment"
788
+ submit="Save"
789
+ initial={comment.body}
790
+ onSubmit={async (body) => {
791
+ await store.edit(comment.id, body);
792
+ setEditing(false);
793
+ }}
794
+ onCancel={() => setEditing(false)}
795
+ />
796
+ ) : (
797
+ <p className="rv-text">{comment.body}</p>
798
+ )}
799
+ </article>
800
+ );
801
+ }
802
+
803
+ function Composer({
804
+ label,
805
+ submit,
806
+ initial = '',
807
+ onSubmit,
808
+ onCancel,
809
+ }: {
810
+ label: string;
811
+ submit: string;
812
+ initial?: string;
813
+ onSubmit: (body: string) => Promise<void>;
814
+ onCancel: () => void;
815
+ }) {
816
+ const [body, setBody] = useState(initial);
817
+ const [busy, setBusy] = useState(false);
818
+ const area = useRef<HTMLTextAreaElement>(null);
819
+ useEffect(() => area.current?.focus(), []);
820
+ const send = async (e: FormEvent) => {
821
+ e.preventDefault();
822
+ if (!body.trim() || busy) return;
823
+ setBusy(true);
824
+ try {
825
+ await onSubmit(body);
826
+ } catch {
827
+ // The store shows the error; the text stays so nothing typed is lost.
828
+ } finally {
829
+ setBusy(false);
830
+ }
831
+ };
832
+ return (
833
+ <form className="rv-composer" onSubmit={send} aria-busy={busy}>
834
+ <label className="rv-label">
835
+ {label}
836
+ <textarea
837
+ ref={area}
838
+ value={body}
839
+ rows={3}
840
+ maxLength={LIMITS.body}
841
+ onChange={(e) => setBody(e.target.value)}
842
+ onKeyDown={submitOnModEnter}
843
+ />
844
+ </label>
845
+ <div className="rv-actions">
846
+ <button type="submit" className="rv-button" disabled={!body.trim() || busy}>
847
+ {submit}
848
+ </button>
849
+ <button type="button" className="rv-link" onClick={onCancel}>
850
+ Cancel
851
+ </button>
852
+ <span className="rv-muted rv-hint">⌘ Enter</span>
853
+ </div>
854
+ </form>
855
+ );
856
+ }
857
+
858
+ function People({ store, me, people }: { store: ReviewStore; me: Me | null; people: Me[] | null }) {
859
+ const [link, setLink] = useState<{ name: string; href: string } | null>(null);
860
+ const [copied, setCopied] = useState(false);
861
+ const [confirm, setConfirm] = useState<string | null>(null);
862
+ const linkField = useRef<HTMLInputElement>(null);
863
+ const share = (name: string, href: string) => {
864
+ setLink({ name, href });
865
+ setCopied(false);
866
+ };
867
+
868
+ return (
869
+ <div className="rv-body">
870
+ <form
871
+ className="rv-invite"
872
+ onSubmit={(e) => {
873
+ e.preventDefault();
874
+ const f = new FormData(e.currentTarget);
875
+ const name = String(f.get('name') ?? '');
876
+ const form = e.currentTarget;
877
+ act(
878
+ store.invite(name, String(f.get('team') ?? ''), String(f.get('role')) as Role).then((href) => {
879
+ share(name, href);
880
+ form.reset();
881
+ }),
882
+ );
883
+ }}
884
+ >
885
+ <label className="rv-label">
886
+ Name
887
+ <input name="name" required maxLength={LIMITS.name} autoComplete="off" data-autofocus="" />
888
+ </label>
889
+ <label className="rv-label">
890
+ Team
891
+ <input
892
+ name="team"
893
+ required
894
+ maxLength={LIMITS.team}
895
+ autoComplete="off"
896
+ placeholder="Studio, or the client's company"
897
+ />
898
+ </label>
899
+ <label className="rv-label">
900
+ Role
901
+ <select name="role" defaultValue="reviewer">
902
+ <option value="reviewer">Reviewer: comments and replies</option>
903
+ <option value="editor">Editor: also resolves, sorts and invites</option>
904
+ </select>
905
+ </label>
906
+ <button type="submit" className="rv-button">
907
+ Create invite link
908
+ </button>
909
+ </form>
910
+ {link && (
911
+ <div className="rv-share" role="status">
912
+ <p>
913
+ Send this link to {link.name}. It works for {INVITE_DAYS} days, and a new link replaces it.
914
+ </p>
915
+ <div className="rv-share-row">
916
+ <input
917
+ ref={linkField}
918
+ readOnly
919
+ value={link.href}
920
+ aria-label={`Invite link for ${link.name}`}
921
+ onFocus={(e) => e.currentTarget.select()}
922
+ />
923
+ <button
924
+ type="button"
925
+ className="rv-button"
926
+ onClick={() =>
927
+ navigator.clipboard.writeText(link.href).then(
928
+ () => setCopied(true),
929
+ // No clipboard access (an insecure origin, a denied permission): select the link to copy by hand.
930
+ () => linkField.current?.select(),
931
+ )
932
+ }
933
+ >
934
+ {copied ? 'Copied' : 'Copy'}
935
+ </button>
936
+ </div>
937
+ </div>
938
+ )}
939
+ {people === null ? (
940
+ <p className="rv-empty">Loading people…</p>
941
+ ) : (
942
+ <ul className="rv-people">
943
+ {people.map((p) => (
944
+ <li key={p.id} data-revoked={p.revokedAt ? '' : undefined}>
945
+ <div>
946
+ <strong>{p.name}</strong>
947
+ <span className="rv-muted">
948
+ {p.team} · {p.role}
949
+ {p.revokedAt && ' · revoked'}
950
+ </span>
951
+ </div>
952
+ {p.id !== me?.id && !p.revokedAt && (
953
+ <div className="rv-actions">
954
+ {confirm === p.id ? (
955
+ <>
956
+ <button
957
+ type="button"
958
+ className="rv-link rv-danger"
959
+ onClick={() => act(store.revoke(p.id).then(() => setConfirm(null)))}
960
+ >
961
+ Revoke
962
+ </button>
963
+ <button type="button" className="rv-link" onClick={() => setConfirm(null)}>
964
+ Keep
965
+ </button>
966
+ </>
967
+ ) : (
968
+ <>
969
+ <button
970
+ type="button"
971
+ className="rv-link"
972
+ onClick={() => act(store.reissue(p.id).then((href) => share(p.name, href)))}
973
+ >
974
+ New link
975
+ </button>
976
+ <button type="button" className="rv-link" onClick={() => setConfirm(p.id)}>
977
+ Revoke
978
+ </button>
979
+ </>
980
+ )}
981
+ </div>
982
+ )}
983
+ </li>
984
+ ))}
985
+ </ul>
986
+ )}
987
+ </div>
988
+ );
989
+ }