@slowcook-ai/review-overlay 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,8 +25,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
25
25
  import { useEffect, useState, useRef, useCallback } from "react";
26
26
  import { extractSelector, resolveStoredSelector } from "../selector.js";
27
27
  import { buildPayload, formatReviewComment, formatLcrIssue, } from "../comment-format.js";
28
- import { loadPat, savePat, submitComment, createIssue, fetchOverlayComments, loadCachedComments, saveCachedComments, fetchPrLabels, addLabelsToPr, submitPrApproval, APPROVED_LABEL, } from "../github.js";
29
- import { loadReviewerToken, loadReviewerIdentity, saveReviewerToken, saveReviewerIdentity, clearReviewerSession, runDeviceLogin, identifyReviewer, } from "../reviewer-session.js";
28
+ import { loadPat, savePat, submitComment, createIssue, fetchOverlayComments, fetchLcrIssues, loadCachedComments, saveCachedComments, fetchPrLabels, addLabelsToPr, submitPrApproval, fetchDocFile, commitDocFile, APPROVED_LABEL, } from "../github.js";
29
+ import { renderMarkdown } from "./markdown.js";
30
+ import { loadReviewerToken, loadReviewerIdentity, saveReviewerToken, saveReviewerIdentity, clearReviewerSession, runDeviceLogin, identifyReviewer, checkRepoWriteAccess, } from "../reviewer-session.js";
30
31
  import { isResolvedStatus } from "../comment-format.js";
31
32
  import { readCurrentStory } from "./use-story-marker.js";
32
33
  const ACCENT = "#FF6B6B";
@@ -48,6 +49,21 @@ export function SlowcookReviewOverlay(props) {
48
49
  const authBase = props.authBase ?? process.env["NEXT_PUBLIC_SLOWCOOK_AUTH_BASE"] ?? "";
49
50
  const overlayVersion = props.overlayVersion ?? "0.6.0";
50
51
  const repoCoord = { owner, repo };
52
+ // 0.7.0 — docs studio config.
53
+ const docPaths = props.docPaths ??
54
+ (process.env["NEXT_PUBLIC_SLOWCOOK_DOC_PATHS"]?.split(",").map((s) => s.trim()).filter(Boolean)) ??
55
+ ["docs/PRD.md", "docs/ROADMAP.md", "docs/USER_STORIES.md", "docs/ARCHITECTURE.md"];
56
+ const branchProp = props.branch ?? process.env["NEXT_PUBLIC_SLOWCOOK_BRANCH"] ?? "";
57
+ // 0.7.1 — review surfaces (persona switcher lives in the pane, not the mock).
58
+ const surfaces = props.surfaces ?? (() => {
59
+ try {
60
+ const raw = process.env["NEXT_PUBLIC_SLOWCOOK_SURFACES"];
61
+ return raw ? JSON.parse(raw) : [];
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ })();
51
67
  // 0.5.1 — hydration-mismatch fix. The overlay can't render during
52
68
  // SSR (no localStorage, no window.matchMedia, no DOM), so it returns
53
69
  // null. But on first client render it would normally render the
@@ -63,6 +79,8 @@ export function SlowcookReviewOverlay(props) {
63
79
  // are declared. Bails early during SSR via the typeof window check.
64
80
  const [mode, setMode] = useState("nav");
65
81
  const [target, setTarget] = useState(null);
82
+ // 0.6.5 — element under the cursor in comment mode (green hover preview).
83
+ const [hoverEl, setHoverEl] = useState(null);
66
84
  const [composerOpen, setComposerOpen] = useState(false);
67
85
  // 0.5.0 — comments-list panel state. Opened by the "📋" button in
68
86
  // the pill OR by clicking the count badge on the Comment toggle.
@@ -86,6 +104,10 @@ export function SlowcookReviewOverlay(props) {
86
104
  // comments (applied/declined/noop) hide by default; needs-clarification +
87
105
  // unresolved always show.
88
106
  const [showApplied, setShowApplied] = useState(false);
107
+ // 0.7.0 — docs studio (textual review): panel open + the resolved working
108
+ // branch a scope-change commits to.
109
+ const [docsPanelOpen, setDocsPanelOpen] = useState(false);
110
+ const [resolvedBranch, setResolvedBranch] = useState(branchProp);
89
111
  // 0.2.0 — track viewport width for the icon-only mobile collapse + the
90
112
  // picker-route hide. Updates on resize + initial mount.
91
113
  const [isMobile, setIsMobile] = useState(false);
@@ -96,18 +118,107 @@ export function SlowcookReviewOverlay(props) {
96
118
  // refresh; background-refresh on focus.
97
119
  const [comments, setComments] = useState([]);
98
120
  const [openCommentId, setOpenCommentId] = useState(null);
121
+ // 0.6.8 — "new activity" badge: track which comment states the reviewer has
122
+ // already seen, so new replies / newly-applied resolutions ping the Comment
123
+ // button with a green count. Signature = id + reply status + reply length.
124
+ const seenKey = `slowcook.review-overlay.seen.${owner}/${repo}`;
125
+ const [seenSigs, setSeenSigs] = useState(() => {
126
+ try {
127
+ return new Set(JSON.parse(window.localStorage.getItem(seenKey) ?? "[]"));
128
+ }
129
+ catch {
130
+ return new Set();
131
+ }
132
+ });
133
+ const sigOf = (r) => `${r.commentId}:${r.plateReply?.status ?? ""}:${(r.plateReply?.summary ?? "").length}`;
134
+ // 0.6.10 — only an AGENT action is an "event": a reply comment (plateCommentUrl)
135
+ // or a resolution (applied). A reviewer's own freshly-filed comment (open issue,
136
+ // no reply) is NOT an event — it shouldn't ping their own badge.
137
+ const isAgentEvent = (r) => r.plateReply != null && (r.plateReply.status === "applied" || !!r.plateCommentUrl);
138
+ const newCount = comments.filter((r) => isAgentEvent(r) && !seenSigs.has(sigOf(r))).length;
139
+ const markAllSeen = useCallback(() => {
140
+ const sigs = comments.map(sigOf);
141
+ setSeenSigs(new Set(sigs));
142
+ try {
143
+ window.localStorage.setItem(seenKey, JSON.stringify(sigs));
144
+ }
145
+ catch { /* ignore */ }
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [comments]);
99
148
  // 0.4.2 — approved state. True when the PR carries the
100
149
  // slowcook-mockup-approved label; pill renders green-tinted +
101
150
  // hides the Approve button. Nav + Comment still work for follow-up
102
151
  // discussion (plate refuses to amend either way).
103
152
  const [isApproved, setIsApproved] = useState(false);
104
- // Mount-time + on-focus fetch of overlay comments.
153
+ // Lock page scroll while a composer or thread popover is open. Those boxes
154
+ // (and the element highlight) are positioned once at open time, so a scroll
155
+ // would leave them behind — easy to lose on mobile. Freezing the page keeps
156
+ // the box and its highlight together until the reviewer is done.
157
+ useEffect(() => {
158
+ const locked = composerOpen || generalComposerOpen || openCommentId !== null;
159
+ if (!locked || typeof document === "undefined")
160
+ return;
161
+ const body = document.body;
162
+ const prevOverflow = body.style.overflow;
163
+ const prevTouch = body.style.touchAction;
164
+ body.style.overflow = "hidden";
165
+ body.style.touchAction = "none";
166
+ return () => { body.style.overflow = prevOverflow; body.style.touchAction = prevTouch; };
167
+ }, [composerOpen, generalComposerOpen, openCommentId]);
168
+ // 0.7.0 — resolve the working branch for doc scope-changes. Prefer the
169
+ // configured branch; else the head branch of the open mockup PR; else the
170
+ // repo default. Runs once in lcr mode when no branch was provided.
171
+ useEffect(() => {
172
+ if (typeof window === "undefined" || reviewMode !== "lcr" || branchProp || !owner || !repo)
173
+ return;
174
+ let alive = true;
175
+ (async () => {
176
+ const base = getProxyApiBase() ?? "https://api.github.com";
177
+ try {
178
+ const r = await fetch(`${base}/repos/${owner}/${repo}`, { headers: { Accept: "application/vnd.github+json" } });
179
+ if (r.ok && alive) {
180
+ const j = (await r.json());
181
+ if (j.default_branch)
182
+ setResolvedBranch(j.default_branch);
183
+ }
184
+ }
185
+ catch { /* offline / rate-limited — Docs read falls back to default ref */ }
186
+ })();
187
+ return () => { alive = false; };
188
+ }, [reviewMode, branchProp, owner, repo]);
189
+ // Mount-time + on-focus fetch of overlay comments / LCR issues.
105
190
  useEffect(() => {
106
191
  if (typeof window === "undefined")
107
192
  return;
108
- if (!enabled)
193
+ if (!enabled || !owner || !repo)
109
194
  return;
110
- if (!owner || !repo || !prNumber)
195
+ // 0.6.4 lcr mode: comments are `lcr-review` ISSUES, retrieved with the
196
+ // signed-in reviewer's token. Closed issues = applied (hidden behind the
197
+ // "show already-applied" toggle); open = shown. Without a token (not signed
198
+ // in yet) there's nothing to read on a private repo — refetches after login
199
+ // via the reviewerIdentity dep.
200
+ if (reviewMode === "lcr") {
201
+ const cached = loadCachedComments(window.localStorage, { owner, repo }, 0);
202
+ if (cached)
203
+ setComments(cached);
204
+ const refresh = () => {
205
+ const token = loadReviewerToken(window.localStorage, { owner, repo });
206
+ if (!token)
207
+ return;
208
+ void fetchLcrIssues({ owner, repo, token })
209
+ .then((records) => {
210
+ setComments(records);
211
+ saveCachedComments(window.localStorage, { owner, repo }, 0, records);
212
+ })
213
+ .catch(() => { });
214
+ };
215
+ refresh();
216
+ const onFocus = () => refresh();
217
+ window.addEventListener("focus", onFocus);
218
+ return () => window.removeEventListener("focus", onFocus);
219
+ }
220
+ // scenarios mode — the pin layer reads PR comments (needs a PR).
221
+ if (!prNumber)
111
222
  return;
112
223
  const cached = loadCachedComments(window.localStorage, { owner, repo }, prNumber);
113
224
  if (cached)
@@ -133,7 +244,7 @@ export function SlowcookReviewOverlay(props) {
133
244
  const onFocus = () => refresh();
134
245
  window.addEventListener("focus", onFocus);
135
246
  return () => window.removeEventListener("focus", onFocus);
136
- }, [enabled, owner, repo, prNumber]);
247
+ }, [enabled, owner, repo, prNumber, reviewMode, reviewerIdentity]);
137
248
  useEffect(() => {
138
249
  if (typeof window === "undefined")
139
250
  return;
@@ -174,12 +285,17 @@ export function SlowcookReviewOverlay(props) {
174
285
  if (!token)
175
286
  return;
176
287
  try {
177
- const id = await identifyReviewer(token);
288
+ const base = await identifyReviewer(token);
289
+ // Derive the apply tier from repo write access (push/maintain/admin).
290
+ const canApply = await checkRepoWriteAccess(token, { owner, repo });
291
+ const id = { ...base, canApply };
178
292
  saveReviewerToken(window.localStorage, { owner, repo }, token);
179
293
  saveReviewerIdentity(window.localStorage, { owner, repo }, id);
180
294
  setReviewerIdentity(id);
181
295
  setLogin({ open: false, status: "" });
182
- setFeedback(`Signed in as @${id.login}.`);
296
+ setFeedback(canApply
297
+ ? `Signed in as @${id.login} — your comments are applied.`
298
+ : `Signed in as @${id.login} — your feedback goes to the team for review.`);
183
299
  }
184
300
  catch (e) {
185
301
  setLogin({ open: true, status: `Could not read your GitHub identity: ${e instanceof Error ? e.message : String(e)}` });
@@ -197,7 +313,9 @@ export function SlowcookReviewOverlay(props) {
197
313
  // the `vibe` label, since an LCR note is about a requirement, not a PR.
198
314
  const postPayload = useCallback(async (payload, pat) => {
199
315
  if (reviewMode === "lcr") {
200
- const issue = formatLcrIssue({ payload });
316
+ // Only write-access reviewers get the `vibe` (auto-apply) label; others
317
+ // are labelled `community-review` and held for the team to triage.
318
+ const issue = formatLcrIssue({ payload, canApply: reviewerIdentity?.canApply === true });
201
319
  const res = await createIssue({
202
320
  owner: repoCoord.owner, repo: repoCoord.repo, pat,
203
321
  title: issue.title, body: issue.body, labels: issue.labels,
@@ -211,7 +329,7 @@ export function SlowcookReviewOverlay(props) {
211
329
  apiBase: getProxyApiBase() ?? undefined,
212
330
  });
213
331
  return { res, kind: "comment" };
214
- }, [reviewMode, repoCoord, prNumber]);
332
+ }, [reviewMode, repoCoord, prNumber, reviewerIdentity]);
215
333
  useEffect(() => {
216
334
  if (typeof window === "undefined")
217
335
  return;
@@ -252,31 +370,75 @@ export function SlowcookReviewOverlay(props) {
252
370
  useEffect(() => {
253
371
  if (typeof window === "undefined")
254
372
  return;
255
- if (mode === "nav")
373
+ if (mode === "nav") {
374
+ setHoverEl(null);
256
375
  return;
376
+ }
377
+ const isOwnUi = (el) => !el ||
378
+ (composerRef.current && composerRef.current.contains(el)) ||
379
+ !!el.closest('[data-slowcook-overlay-ui="1"]');
257
380
  function onClick(e) {
258
381
  const el = e.target;
259
- if (!el)
260
- return;
261
- // Don't capture clicks on the overlay's own UI.
262
- if (composerRef.current && composerRef.current.contains(el))
263
- return;
264
- if (el.closest('[data-slowcook-overlay-ui="1"]'))
382
+ if (!el || isOwnUi(el))
265
383
  return;
266
384
  e.preventDefault();
267
385
  e.stopPropagation();
268
386
  if (mode === "comment") {
269
387
  setTarget(el);
388
+ setHoverEl(null);
270
389
  setComposerOpen(true);
271
390
  }
272
391
  else if (mode === "approve") {
273
392
  void submitApproval();
274
393
  }
275
394
  }
395
+ // 0.6.5 — live hover preview in comment mode: green-outline the element that
396
+ // would be selected on click, so the reviewer isn't minesweeping. (The red
397
+ // outline marks the element already chosen for the open composer.)
398
+ function onMove(e) {
399
+ if (mode !== "comment")
400
+ return;
401
+ const el = e.target;
402
+ setHoverEl(el && !isOwnUi(el) ? el : null);
403
+ }
404
+ // 0.6.15 — native controls (a <select>, <input>, link, button) activate on
405
+ // mousedown/pointerdown, BEFORE click — so without this they'd open their
406
+ // dropdown / focus / navigate instead of letting the click attach a comment.
407
+ // Swallow the down-press on the page (not the overlay's own UI) in
408
+ // comment/approve mode; the click still fires and opens the composer.
409
+ function onDown(e) {
410
+ const el = e.target;
411
+ if (!el || isOwnUi(el))
412
+ return;
413
+ e.preventDefault();
414
+ e.stopPropagation();
415
+ }
276
416
  document.addEventListener("click", onClick, { capture: true });
277
- return () => document.removeEventListener("click", onClick, { capture: true });
417
+ document.addEventListener("mouseover", onMove, { capture: true });
418
+ document.addEventListener("mousedown", onDown, { capture: true });
419
+ document.addEventListener("pointerdown", onDown, { capture: true });
420
+ return () => {
421
+ document.removeEventListener("click", onClick, { capture: true });
422
+ document.removeEventListener("mouseover", onMove, { capture: true });
423
+ document.removeEventListener("mousedown", onDown, { capture: true });
424
+ document.removeEventListener("pointerdown", onDown, { capture: true });
425
+ };
278
426
  // eslint-disable-next-line react-hooks/exhaustive-deps
279
427
  }, [mode]);
428
+ // 0.6.6 — entering Nav clears the whole comment surface: any open composer,
429
+ // the comments-list panel, a general-note composer, an open pin popover, the
430
+ // approve confirm, the hover preview. Nav = "done reviewing, clean slate".
431
+ useEffect(() => {
432
+ if (mode !== "nav")
433
+ return;
434
+ setComposerOpen(false);
435
+ setTarget(null);
436
+ setGeneralComposerOpen(false);
437
+ setListPanelOpen(false);
438
+ setOpenCommentId(null);
439
+ setApproveConfirmOpen(false);
440
+ setHoverEl(null);
441
+ }, [mode]);
280
442
  const onApproveClicked = useCallback(() => {
281
443
  // 0.2.0 — two-step confirm; protects against fat-finger approval.
282
444
  setMode("approve");
@@ -509,23 +671,45 @@ export function SlowcookReviewOverlay(props) {
509
671
  if (isPickerRoute)
510
672
  return null;
511
673
  // 0.5.1 — auto-detect path: skip when env vars + props together
512
- // don't supply a real owner/repo/pr. Avoids "submit comment"
513
- // failing silently because the API call goes nowhere.
514
- if (!owner || !repo || !prNumber)
674
+ // don't supply a real owner/repo. Avoids "submit comment" failing
675
+ // silently because the API call goes nowhere.
676
+ // 0.6.1 — lcr mode files ISSUES (not PR comments), so it needs no PR;
677
+ // only scenarios mode requires a prNumber.
678
+ if (!owner || !repo)
679
+ return null;
680
+ if (reviewMode !== "lcr" && !prNumber)
515
681
  return null;
516
682
  return (_jsxs("div", { "data-slowcook-overlay-ui": "1", style: {
517
683
  position: "fixed",
518
684
  inset: 0,
519
685
  pointerEvents: "none",
520
686
  zIndex: 2147483000,
521
- }, children: [mode !== "nav" && (_jsx("div", { style: {
687
+ }, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: `[data-slowcook-overlay-ui] textarea,[data-slowcook-overlay-ui] input,[data-slowcook-overlay-ui] select{` +
688
+ `color:#1a1a1a !important;background-color:#fff !important;` +
689
+ `-webkit-text-fill-color:#1a1a1a !important;border-color:rgba(0,0,0,0.15) !important;caret-color:#1a1a1a !important;}` +
690
+ `[data-slowcook-overlay-ui] textarea::placeholder,[data-slowcook-overlay-ui] input::placeholder{` +
691
+ `color:rgba(0,0,0,0.4) !important;-webkit-text-fill-color:rgba(0,0,0,0.4) !important;}` +
692
+ // 0.7.2 — the compact pane select. Higher specificity (attr + class +
693
+ // element) so the host's `select{font-size:16px !important}` (iOS-zoom
694
+ // guard) can't bloat it; keep it small + dark to match the pane.
695
+ `[data-slowcook-overlay-ui] select.sc-ovl-pane-select{` +
696
+ `font-size:11px !important;padding:4px 7px !important;line-height:1.15 !important;` +
697
+ `color:#1a1a1a !important;background-color:#fff !important;font-weight:700 !important;}`
698
+ } }), mode !== "nav" && (_jsx("div", { style: {
522
699
  position: "absolute",
523
700
  inset: 0,
524
701
  backgroundColor: mode === "comment"
525
702
  ? "rgba(255, 107, 107, 0.05)"
526
703
  : "rgba(74, 222, 128, 0.05)",
527
704
  pointerEvents: "none",
528
- }, "aria-hidden": "true" })), _jsx(ModeToggle, { mode: mode, onChange: (m) => (m === "approve" ? onApproveClicked() : setMode(m)), disabled: submitting, isMobile: isMobile, isApproved: isApproved, commentCount: comments.length, onListClick: () => setListPanelOpen(true) }), mode === "comment" && comments.length > 0 && (_jsx(CommentPins, { records: comments.filter((c) => c.payload.element !== null), openCommentId: openCommentId, onOpen: (id) => setOpenCommentId(id), onClose: () => setOpenCommentId(null), flashCommentId: flashCommentId })), listPanelOpen && (_jsx(CommentsListPanel, { records: comments, showApplied: showApplied, onToggleApplied: () => setShowApplied((v) => !v), onClose: () => setListPanelOpen(false), onOpenComment: (id) => {
705
+ }, "aria-hidden": "true" })), mode === "comment" && !composerOpen && hoverEl && _jsx(HoverHighlight, { el: hoverEl }), _jsx(ModeToggle, { mode: mode, onChange: (m) => (m === "approve" ? onApproveClicked() : setMode(m)), disabled: submitting, isMobile: isMobile, isApproved: isApproved, commentCount: comments.length, newCount: newCount, onListClick: () => { setListPanelOpen(true); markAllSeen(); }, docsEnabled: reviewMode === "lcr" && docPaths.length > 0, onDocsClick: () => setDocsPanelOpen(true), surfaces: reviewMode === "lcr" ? surfaces : [], onNavigate: (home) => {
706
+ if (typeof window === "undefined")
707
+ return;
708
+ // Router-agnostic SPA nav: pushState + popstate so react-router (or any
709
+ // history listener) updates without a full reload.
710
+ window.history.pushState({}, "", home);
711
+ window.dispatchEvent(new PopStateEvent("popstate"));
712
+ }, reviewMode: reviewMode, identity: reviewerIdentity, onSignIn: () => void signIn(), onSignOut: signOut }), mode === "comment" && comments.length > 0 && (_jsx(CommentPins, { records: comments.filter((c) => c.payload.element !== null), showApplied: showApplied, openCommentId: openCommentId, onOpen: (id) => setOpenCommentId(id), onClose: () => setOpenCommentId(null), flashCommentId: flashCommentId })), listPanelOpen && (_jsx(CommentsListPanel, { records: comments, showApplied: showApplied, onToggleApplied: () => setShowApplied((v) => !v), onClose: () => setListPanelOpen(false), onOpenComment: (id) => {
529
713
  setListPanelOpen(false);
530
714
  setMode("comment");
531
715
  setOpenCommentId(id);
@@ -534,7 +718,7 @@ export function SlowcookReviewOverlay(props) {
534
718
  }, onAddGeneral: () => {
535
719
  setListPanelOpen(false);
536
720
  setGeneralComposerOpen(true);
537
- } })), generalComposerOpen && (_jsx(GeneralComposer, { onCancel: () => setGeneralComposerOpen(false), onSubmit: submitGeneralComment, submitting: submitting })), composerOpen && target && (_jsx(Composer, { target: target, onCancel: () => {
721
+ }, onApprove: () => { setListPanelOpen(false); onApproveClicked(); }, isApproved: isApproved })), docsPanelOpen && (_jsx(DocsPanel, { repo: repoCoord, docPaths: docPaths, branch: resolvedBranch, identity: reviewerIdentity, getToken: () => (typeof window !== "undefined" ? loadReviewerToken(window.localStorage, repoCoord) : null), onSignIn: () => void signIn(), apiBase: getProxyApiBase() ?? undefined, onClose: () => setDocsPanelOpen(false), onFeedback: (t) => setFeedback(t) })), generalComposerOpen && (_jsx(GeneralComposer, { onCancel: () => setGeneralComposerOpen(false), onSubmit: submitGeneralComment, submitting: submitting })), composerOpen && target && (_jsx(Composer, { target: target, onCancel: () => {
538
722
  setComposerOpen(false);
539
723
  setTarget(null);
540
724
  }, onSubmit: submitFromComposer, submitting: submitting, composerRef: composerRef })), approveConfirmOpen && (_jsx(ApproveConfirm, { onCancel: () => {
@@ -543,21 +727,31 @@ export function SlowcookReviewOverlay(props) {
543
727
  }, onConfirm: async () => {
544
728
  setApproveConfirmOpen(false);
545
729
  await submitApproval();
546
- }, submitting: submitting })), reviewMode === "lcr" && (_jsx(ReviewerSignInBadge, { identity: reviewerIdentity, onSignIn: () => void signIn(), onSignOut: signOut })), login.open && (_jsx(ReviewerLoginDialog, { userCode: login.userCode, verificationUri: login.verificationUri, status: login.status, onClose: () => { loginAbort.current = true; setLogin({ open: false, status: "" }); } })), feedback && _jsx(FeedbackToast, { text: feedback, onDismiss: () => setFeedback(null) })] }));
730
+ }, submitting: submitting })), login.open && (_jsx(ReviewerLoginDialog, { userCode: login.userCode, verificationUri: login.verificationUri, status: login.status, onClose: () => { loginAbort.current = true; setLogin({ open: false, status: "" }); } })), feedback && _jsx(FeedbackToast, { text: feedback, onDismiss: () => setFeedback(null) })] }));
547
731
  }
548
732
  /**
549
- * 0.6.0 — sign-in badge for LCR review (top-right). Shows "Sign in with
550
- * GitHub" until the reviewer authenticates, then their @login + sign-out.
551
- * Every comment then posts as that reviewer (real GitHub attribution).
733
+ /**
734
+ * 0.6.5 green hover preview in comment mode. Outlines the element the cursor
735
+ * is over (the one a click would attach the comment to) so the reviewer can see
736
+ * the target before committing — no minesweeping. Distinct from the red outline,
737
+ * which marks the element already chosen for the open composer.
552
738
  */
553
- function ReviewerSignInBadge({ identity, onSignIn, onSignOut, }) {
554
- return (_jsx("div", { style: { position: "fixed", top: 12, right: 12, pointerEvents: "auto", zIndex: 2147483600 }, children: identity ? (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, background: "#fff", border: "1px solid rgba(0,0,0,0.12)", borderRadius: 999, padding: "5px 10px 5px 6px", boxShadow: "0 2px 10px rgba(0,0,0,0.12)", fontSize: 13 }, children: [identity.avatarUrl
555
- ? _jsx("img", { src: identity.avatarUrl, alt: "", width: 22, height: 22, style: { borderRadius: "50%" } })
556
- : _jsx("span", { "aria-hidden": true, style: { width: 22, height: 22, borderRadius: "50%", background: "#eee", display: "inline-flex", alignItems: "center", justifyContent: "center" }, children: "\uD83D\uDC64" }), _jsxs("span", { style: { fontWeight: 600 }, children: ["@", identity.login] }), _jsx("button", { onClick: onSignOut, title: "Sign out", style: { border: "none", background: "transparent", cursor: "pointer", color: "#888", fontSize: 12 }, children: "sign out" })] })) : (_jsxs("button", { onClick: onSignIn, style: { display: "flex", alignItems: "center", gap: 8, background: "#24292f", color: "#fff", border: "none", borderRadius: 999, padding: "8px 14px", cursor: "pointer", boxShadow: "0 2px 10px rgba(0,0,0,0.18)", fontSize: 13, fontWeight: 600 }, children: [_jsx("span", { "aria-hidden": true, children: "\u29C9" }), " Sign in with GitHub"] })) }));
739
+ function HoverHighlight({ el }) {
740
+ const r = el.getBoundingClientRect();
741
+ return (_jsx("div", { "data-slowcook-overlay-ui": "1", "aria-hidden": "true", style: {
742
+ position: "fixed",
743
+ top: r.top, left: r.left, width: r.width, height: r.height,
744
+ border: "2px solid #22c55e",
745
+ background: "rgba(34, 197, 94, 0.12)",
746
+ borderRadius: 4,
747
+ boxShadow: "0 0 0 1px rgba(34,197,94,0.35)",
748
+ pointerEvents: "none",
749
+ zIndex: 2147483200,
750
+ } }));
557
751
  }
558
752
  /** 0.6.0 — device-flow login dialog: shows the user code + verification link. */
559
753
  function ReviewerLoginDialog({ userCode, verificationUri, status, onClose, }) {
560
- return (_jsx("div", { style: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "auto", zIndex: 2147483601 }, children: _jsxs("div", { style: { background: "#fff", borderRadius: 14, padding: 24, width: 360, maxWidth: "90vw", boxShadow: "0 12px 40px rgba(0,0,0,0.3)", fontFamily: "system-ui, sans-serif" }, children: [_jsx("div", { style: { fontSize: 17, fontWeight: 700, marginBottom: 6, color: "#111" }, children: "Sign in with GitHub" }), userCode ? (_jsxs(_Fragment, { children: [_jsx("p", { style: { fontSize: 13.5, color: "#555", margin: "0 0 14px" }, children: "Open GitHub and enter this code to review as yourself:" }), _jsx("div", { style: { fontSize: 30, fontWeight: 800, letterSpacing: "0.18em", textAlign: "center", background: "#f3f4f6", borderRadius: 10, padding: "12px 0", color: "#111", fontFamily: "ui-monospace, monospace" }, children: userCode }), _jsxs("a", { href: verificationUri, target: "_blank", rel: "noreferrer", style: { display: "block", textAlign: "center", marginTop: 14, background: "#2da44e", color: "#fff", borderRadius: 999, padding: "9px 0", textDecoration: "none", fontWeight: 600, fontSize: 14 }, children: ["Open ", verificationUri?.replace(/^https?:\/\//, "")] })] })) : (_jsx("p", { style: { fontSize: 14, color: "#555", margin: "8px 0 14px" }, children: status || "Starting…" })), userCode && _jsx("p", { style: { fontSize: 12, color: "#888", marginTop: 12 }, children: status }), _jsx("button", { onClick: onClose, style: { marginTop: 16, width: "100%", border: "1px solid rgba(0,0,0,0.12)", background: "#fff", borderRadius: 999, padding: "8px 0", cursor: "pointer", fontSize: 13, color: "#444" }, children: "Cancel" })] }) }));
754
+ return (_jsx("div", { style: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "auto", zIndex: 2147483647 /* 0.7.1 — above the Docs panel (…602) so the sign-in popup isn't hidden behind it */ }, children: _jsxs("div", { style: { background: "#fff", borderRadius: 14, padding: 24, width: 360, maxWidth: "90vw", boxShadow: "0 12px 40px rgba(0,0,0,0.3)", fontFamily: "system-ui, sans-serif" }, children: [_jsx("div", { style: { fontSize: 17, fontWeight: 700, marginBottom: 6, color: "#111" }, children: "Sign in with GitHub" }), userCode ? (_jsxs(_Fragment, { children: [_jsx("p", { style: { fontSize: 13.5, color: "#555", margin: "0 0 14px" }, children: "Open GitHub and enter this code to review as yourself:" }), _jsx("div", { style: { fontSize: 30, fontWeight: 800, letterSpacing: "0.18em", textAlign: "center", background: "#f3f4f6", borderRadius: 10, padding: "12px 0", color: "#111", fontFamily: "ui-monospace, monospace" }, children: userCode }), _jsxs("a", { href: verificationUri, target: "_blank", rel: "noreferrer", style: { display: "block", textAlign: "center", marginTop: 14, background: "#2da44e", color: "#fff", borderRadius: 999, padding: "9px 0", textDecoration: "none", fontWeight: 600, fontSize: 14 }, children: ["Open ", verificationUri?.replace(/^https?:\/\//, "")] })] })) : (_jsx("p", { style: { fontSize: 14, color: "#555", margin: "8px 0 14px" }, children: status || "Starting…" })), userCode && _jsx("p", { style: { fontSize: 12, color: "#888", marginTop: 12 }, children: status }), _jsx("button", { onClick: onClose, style: { marginTop: 16, width: "100%", border: "1px solid rgba(0,0,0,0.12)", background: "#fff", borderRadius: 999, padding: "8px 0", cursor: "pointer", fontSize: 13, color: "#444" }, children: "Cancel" })] }) }));
561
755
  }
562
756
  /**
563
757
  * 0.2.0 — draggable toggle pill with grip handle, slowcook logo,
@@ -596,16 +790,31 @@ function saveTogglePosition(p) {
596
790
  }
597
791
  }
598
792
  function ModeToggle(props) {
599
- const { mode, onChange, disabled, isMobile, isApproved, commentCount, onListClick } = props;
793
+ const { mode, onChange, disabled, isMobile, isApproved, commentCount, newCount, onListClick, docsEnabled, onDocsClick, surfaces, onNavigate, reviewMode, identity, onSignIn, onSignOut } = props;
600
794
  // 0.5.1 — initialise with the default; load saved position from
601
795
  // localStorage AFTER mount. Eliminates a hydration mismatch where
602
796
  // SSR/first-client render disagreed on the position value.
603
797
  const [pos, setPos] = useState({ top: 12, right: 12 });
604
798
  useEffect(() => { setPos(loadTogglePosition()); }, []);
605
799
  const dragRef = useRef(null);
800
+ // 0.6.3 — sign-out is a two-step confirm: the disk floats, so a single
801
+ // mis-click shouldn't log you out. First click/tap on the identity chip arms
802
+ // confirmation (the chip turns into "Sign out?"); a second click/tap within a
803
+ // few seconds confirms. ANY other action (mode toggle, list, drag) cancels.
804
+ const [confirmLogout, setConfirmLogout] = useState(false);
805
+ const cancelLogout = useCallback(() => setConfirmLogout(false), []);
806
+ useEffect(() => {
807
+ if (!confirmLogout)
808
+ return;
809
+ const t = setTimeout(() => setConfirmLogout(false), 3500);
810
+ return () => clearTimeout(t);
811
+ }, [confirmLogout]);
812
+ const onChangeSafe = useCallback((m) => { setConfirmLogout(false); onChange(m); }, [onChange]);
813
+ const onListSafe = useCallback(() => { setConfirmLogout(false); onListClick(); }, [onListClick]);
606
814
  // Drag handlers — pointer events for unified mouse + touch.
607
815
  const onPointerDown = useCallback((e) => {
608
816
  // Only drag from the grip itself; clicks on toggle buttons must stay clicks.
817
+ setConfirmLogout(false); // dragging cancels a pending sign-out confirm
609
818
  e.preventDefault();
610
819
  e.stopPropagation();
611
820
  const target = e.currentTarget;
@@ -641,13 +850,22 @@ function ModeToggle(props) {
641
850
  pointerEvents: "auto",
642
851
  display: "flex",
643
852
  alignItems: "center",
853
+ // 0.7.1 — wrap to multiple rows when crowded (Nav/Comment + 📋 + Docs +
854
+ // persona switcher + sign-in don't fit one row on portrait mobile).
855
+ flexWrap: "wrap",
856
+ justifyContent: "flex-end",
857
+ // 0.7.2 — cap to the viewport (not a fixed px) so it stays one row on
858
+ // desktop but wraps to two on portrait mobile when crowded (Comment mode
859
+ // adds 📋 + Sign-in on top of Docs + the persona switcher).
860
+ maxWidth: "94vw",
644
861
  gap: 4,
862
+ rowGap: 5,
645
863
  // 0.4.2 — green-tinted background + green border when approved.
646
864
  background: isApproved
647
865
  ? "rgba(20, 83, 45, 0.92)" // dark-green pill
648
866
  : "rgba(15, 15, 24, 0.92)", // default dark
649
- padding: "4px 4px 4px 6px",
650
- borderRadius: 999,
867
+ padding: "5px 6px",
868
+ borderRadius: 16,
651
869
  border: isApproved
652
870
  ? `1px solid rgba(34, 197, 94, 0.55)` // brighter green border
653
871
  : "1px solid rgba(255, 255, 255, 0.16)",
@@ -668,17 +886,15 @@ function ModeToggle(props) {
668
886
  justifyContent: "center",
669
887
  opacity: 0.55,
670
888
  touchAction: "none",
671
- }, children: _jsxs("svg", { width: "6", height: "14", viewBox: "0 0 6 14", "aria-hidden": "true", children: [_jsx("circle", { cx: "1.5", cy: "2", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "2", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "1.5", cy: "7", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "7", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "1.5", cy: "12", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "12", r: "1.1", fill: "currentColor" })] }) }), _jsx(ToggleButton, { active: mode === "nav", onClick: () => onChange("nav"), disabled: disabled, label: isMobile ? "🧭" : "Nav", title: "Navigate (default)" }), _jsx(ToggleButton, { active: mode === "comment", onClick: () => onChange("comment"), disabled: disabled, label: isMobile ? "💬" : "💬 Comment", title: "Comment on an element", accent: true }), isApproved ? (_jsx("span", { "data-slowcook-overlay-ui": "1", title: "Mockup approved \u2014 comment thread stays open for follow-up; plate refuses to amend", style: {
672
- display: "inline-flex",
673
- alignItems: "center",
674
- gap: 4,
675
- padding: "6px 12px",
676
- borderRadius: 999,
677
- background: APPROVED_GREEN,
678
- color: "white",
679
- fontWeight: 700,
680
- fontSize: 13,
681
- }, children: "\u2713 Approved" })) : (_jsx(ToggleButton, { active: mode === "approve", onClick: () => onChange("approve"), disabled: disabled, label: isMobile ? "✅" : "✅ Approve", title: "Approve the mockup (asks for confirmation)", approve: true })), _jsxs("button", { type: "button", onClick: onListClick, disabled: disabled, title: `See all comments (${commentCount})`, style: {
889
+ }, children: _jsxs("svg", { width: "6", height: "14", viewBox: "0 0 6 14", "aria-hidden": "true", children: [_jsx("circle", { cx: "1.5", cy: "2", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "2", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "1.5", cy: "7", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "7", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "1.5", cy: "12", r: "1.1", fill: "currentColor" }), _jsx("circle", { cx: "4.5", cy: "12", r: "1.1", fill: "currentColor" })] }) }), _jsx(ToggleButton, { active: mode === "comment", onClick: () => onChangeSafe(mode === "comment" ? "nav" : "comment"), disabled: disabled, label: mode === "comment"
890
+ ? (isMobile ? "💬" : "💬 Commenting")
891
+ : (isMobile ? "🧭" : "🧭 Navigating"), title: mode === "comment"
892
+ ? "Comment mode on — click to go back to navigating"
893
+ : (newCount ? `Click to comment — ${newCount} new update(s)` : "Click to comment on an element"), accent: true, badge: newCount }), isApproved && (_jsx("span", { "data-slowcook-overlay-ui": "1", title: "Mockup approved \u2014 comment thread stays open for follow-up; plate refuses to amend", style: {
894
+ display: "inline-flex", alignItems: "center", gap: 4,
895
+ padding: "6px 12px", borderRadius: 999, background: APPROVED_GREEN,
896
+ color: "white", fontWeight: 700, fontSize: 13,
897
+ }, children: "\u2713 Approved" })), mode === "comment" && (_jsxs("button", { type: "button", onClick: onListSafe, disabled: disabled, title: `See all comments (${commentCount})`, style: {
682
898
  marginLeft: 4,
683
899
  background: "rgba(255,255,255,0.06)",
684
900
  color: "white",
@@ -701,7 +917,40 @@ function ModeToggle(props) {
701
917
  fontWeight: 700,
702
918
  minWidth: 14,
703
919
  textAlign: "center",
704
- }, children: commentCount }))] })] }));
920
+ }, children: commentCount }))] })), docsEnabled && (_jsx("button", { type: "button", onClick: () => { setConfirmLogout(false); onDocsClick(); }, disabled: disabled, title: "Review & edit the spec docs (textual review)", style: {
921
+ marginLeft: 4, background: "rgba(255,255,255,0.06)", color: "white",
922
+ border: "none", padding: "6px 10px", borderRadius: 999,
923
+ cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1,
924
+ font: "inherit", fontSize: 12, display: "inline-flex", alignItems: "center", gap: 5,
925
+ }, children: "\uD83D\uDCC4 Docs" })), surfaces.length > 0 && _jsx(SurfaceSwitcher, { surfaces: surfaces, onNavigate: onNavigate, disabled: disabled }), reviewMode === "lcr" && mode === "comment" && (identity ? (_jsx("span", { title: confirmLogout
926
+ ? "Click again to sign out (or click anything else to cancel)"
927
+ : identity.canApply
928
+ ? `Signed in as @${identity.login} — you have write access, so your comments are applied`
929
+ : `Signed in as @${identity.login} — no write access, so your feedback is gathered for the team to review (not auto-applied)`, onClick: () => { if (confirmLogout) {
930
+ setConfirmLogout(false);
931
+ onSignOut();
932
+ }
933
+ else {
934
+ setConfirmLogout(true);
935
+ } }, style: {
936
+ marginLeft: 4, display: "inline-flex", alignItems: "center", gap: 6,
937
+ padding: "4px 10px 4px 4px", borderRadius: 999, cursor: "pointer",
938
+ background: confirmLogout ? "rgba(255,107,107,0.25)" : "rgba(255,255,255,0.10)",
939
+ border: confirmLogout ? "1px solid rgba(255,107,107,0.7)" : "1px solid transparent",
940
+ color: "white", fontSize: 12, fontWeight: 600,
941
+ }, children: confirmLogout ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\uD83D\uDEAA" }), " Sign out?"] })) : (_jsxs(_Fragment, { children: [identity.avatarUrl
942
+ ? _jsx("img", { src: identity.avatarUrl, alt: "", width: 20, height: 20, style: { borderRadius: "50%" } })
943
+ : _jsx("span", { "aria-hidden": true, style: { width: 20, height: 20, borderRadius: "50%", background: "rgba(255,255,255,0.2)", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 11 }, children: "\uD83D\uDC64" }), "@", identity.login, _jsx("span", { style: {
944
+ fontSize: 9.5, fontWeight: 800, letterSpacing: 0.3, textTransform: "uppercase",
945
+ padding: "1px 6px", borderRadius: 999,
946
+ background: identity.canApply ? "rgba(34,197,94,0.22)" : "rgba(148,163,184,0.25)",
947
+ color: identity.canApply ? "#4ade80" : "#cbd5e1",
948
+ }, children: identity.canApply ? "applies" : "review" })] })) })) : (_jsxs("button", { type: "button", onClick: onSignIn, disabled: disabled, title: "Sign in with GitHub to comment as yourself", style: {
949
+ marginLeft: 4, display: "inline-flex", alignItems: "center", gap: 6,
950
+ padding: "6px 12px", borderRadius: 999, border: "none",
951
+ background: "white", color: "#24292f", cursor: disabled ? "not-allowed" : "pointer",
952
+ font: "inherit", fontSize: 12, fontWeight: 700,
953
+ }, children: [_jsx("svg", { width: "13", height: "13", viewBox: "0 0 16 16", fill: "#24292f", "aria-hidden": "true", children: _jsx("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) }), "Sign in"] })))] }));
705
954
  }
706
955
  /**
707
956
  * Two-step approval confirm — guards against fat-finger taps on the
@@ -759,7 +1008,8 @@ function ToggleButton(props) {
759
1008
  ? ACCENT
760
1009
  : "rgba(255,255,255,0.18)"
761
1010
  : "transparent";
762
- return (_jsx("button", { type: "button", onClick: props.onClick, disabled: props.disabled, title: props.title, style: {
1011
+ return (_jsxs("button", { type: "button", onClick: props.onClick, disabled: props.disabled, title: props.title, style: {
1012
+ position: "relative",
763
1013
  background: bg,
764
1014
  color: "white",
765
1015
  border: "none",
@@ -768,7 +1018,28 @@ function ToggleButton(props) {
768
1018
  cursor: props.disabled ? "not-allowed" : "pointer",
769
1019
  opacity: props.disabled ? 0.6 : 1,
770
1020
  font: "inherit",
771
- }, children: props.label }));
1021
+ }, children: [props.label, props.badge ? (_jsx("span", { style: {
1022
+ position: "absolute", top: -5, right: -5, minWidth: 16, height: 16,
1023
+ padding: "0 4px", borderRadius: 999, background: "#22c55e", color: "white",
1024
+ fontSize: 10, fontWeight: 800, display: "inline-flex", alignItems: "center",
1025
+ justifyContent: "center", boxShadow: "0 0 0 2px rgba(15,15,24,0.92)",
1026
+ }, children: props.badge })) : null] }));
1027
+ }
1028
+ /**
1029
+ * 0.6.13 — page-context badge in the composer so the reviewer sees which page
1030
+ * the comment is on (route + the story it declares, if any) before submitting.
1031
+ */
1032
+ function PageBadge() {
1033
+ if (typeof window === "undefined")
1034
+ return null;
1035
+ const route = window.location.pathname + window.location.search;
1036
+ const story = readCurrentStory();
1037
+ return (_jsxs("div", { style: {
1038
+ display: "inline-flex", alignItems: "center", gap: 6, maxWidth: "100%",
1039
+ fontSize: 11.5, color: "#3a3a3a", background: "rgba(255,107,107,0.10)",
1040
+ border: "1px solid rgba(255,107,107,0.30)", borderRadius: 6,
1041
+ padding: "3px 9px", marginBottom: 8,
1042
+ }, children: [_jsx("span", { "aria-hidden": true, children: "\uD83D\uDCC4" }), _jsx("span", { style: { fontFamily: "ui-monospace, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: route }), story && _jsxs("span", { style: { fontWeight: 700, color: "#d6336c" }, children: ["\u00B7 story-", story] })] }));
772
1043
  }
773
1044
  function Composer(props) {
774
1045
  const [prose, setProse] = useState("");
@@ -830,12 +1101,13 @@ function Composer(props) {
830
1101
  fontFamily: "system-ui, -apple-system, sans-serif",
831
1102
  fontSize: 13,
832
1103
  zIndex: 2147483647,
833
- }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 8 }, children: "Review comment" }), _jsx("div", { style: { fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 11, opacity: 0.7, marginBottom: 8, wordBreak: "break-all" }, children: sel.selector }), _jsx("textarea", { "aria-label": "Comment text", autoFocus: true, value: prose, onChange: (e) => setProse(e.target.value), placeholder: "What's off about this element?", rows: 5, style: {
1104
+ }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 6 }, children: "Review comment" }), _jsx(PageBadge, {}), _jsx("div", { style: { fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 11, opacity: 0.7, marginBottom: 8, wordBreak: "break-all" }, children: sel.selector }), _jsx("textarea", { "aria-label": "Comment text", autoFocus: true, value: prose, onChange: (e) => setProse(e.target.value), placeholder: "What's off about this element?", rows: 5, style: {
834
1105
  width: "100%",
835
1106
  padding: 8,
836
1107
  border: "1px solid rgba(0,0,0,0.15)",
837
1108
  borderRadius: 6,
838
1109
  font: "inherit",
1110
+ fontSize: 16, // 0.6.12 — ≥16px stops iOS Safari auto-zooming on focus
839
1111
  resize: "vertical",
840
1112
  boxSizing: "border-box",
841
1113
  } }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 12 }, children: [_jsx("button", { type: "button", onClick: props.onCancel, disabled: props.submitting, style: {
@@ -941,7 +1213,13 @@ function ensurePat(repo, lcr = false) {
941
1213
  * sticky to the underlying DOM.
942
1214
  */
943
1215
  function CommentPins(props) {
944
- const { records, openCommentId, onOpen, onClose, flashCommentId } = props;
1216
+ const { records: allRecords, showApplied, openCommentId, onOpen, onClose, flashCommentId } = props;
1217
+ // Hide pins for resolved/applied comments unless the reviewer has asked to
1218
+ // see them (the same "show applied" toggle that governs the list). An open
1219
+ // pin stays visible so "Locate from list" still works.
1220
+ const records = allRecords.filter((r) => showApplied ||
1221
+ r.commentId === openCommentId ||
1222
+ !(r.plateReply != null && isResolvedStatus(r.plateReply.status)));
945
1223
  // tick forces a re-render on every animation frame so pins follow
946
1224
  // the underlying DOM as the page scrolls / reflows.
947
1225
  const [tick, setTick] = useState(0);
@@ -995,7 +1273,9 @@ function CommentPins(props) {
995
1273
  }
996
1274
  return { record: r, rect, drifted };
997
1275
  });
998
- return (_jsxs("div", { "data-slowcook-overlay-ui": "1", style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [placements.map(({ record, rect, drifted }) => (_jsx(PinIcon, { record: record, x: rect.x, y: rect.y, drifted: drifted, flashing: flashCommentId === record.commentId, onClick: () => onOpen(record.commentId) }, record.commentId))), openCommentId !== null && (() => {
1276
+ return (_jsxs("div", { "data-slowcook-overlay-ui": "1", style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [placements
1277
+ .filter(({ record, drifted }) => !drifted || record.commentId === openCommentId)
1278
+ .map(({ record, rect, drifted }) => (_jsx(PinIcon, { record: record, x: rect.x, y: rect.y, drifted: drifted, flashing: flashCommentId === record.commentId, onClick: () => onOpen(record.commentId) }, record.commentId))), openCommentId !== null && (() => {
999
1279
  const placement = placements.find((p) => p.record.commentId === openCommentId);
1000
1280
  if (!placement)
1001
1281
  return null;
@@ -1010,41 +1290,70 @@ function pinPalette(status, drifted) {
1010
1290
  case "declined": return { bg: "#94a3b8", fg: "white", glyph: "⊘", ring: "rgba(148, 163, 184, 0.35)" };
1011
1291
  case "spec-altering": return { bg: "#facc15", fg: "#1a1a1a", glyph: "!", ring: "rgba(250, 204, 21, 0.35)" };
1012
1292
  case "noop": return { bg: "#94a3b8", fg: "white", glyph: "•", ring: "rgba(148, 163, 184, 0.35)" };
1293
+ case "needs-clarification": return { bg: "#4D96FF", fg: "white", glyph: "?", ring: "rgba(77, 150, 255, 0.35)" };
1013
1294
  default: return { bg: ACCENT, fg: "white", glyph: "💬", ring: "rgba(255, 107, 107, 0.35)" };
1014
1295
  }
1015
1296
  }
1297
+ /** Per-author colour — Figma-style. Hashes the FULL author handle to a hue, so
1298
+ * two reviewers with the same initial still get distinct colours. */
1299
+ function authorColor(author) {
1300
+ let h = 0;
1301
+ for (let i = 0; i < author.length; i++)
1302
+ h = (h * 31 + author.charCodeAt(i)) >>> 0;
1303
+ const hue = h % 360;
1304
+ return { bg: `hsl(${hue}, 58%, 42%)`, ring: `hsla(${hue}, 58%, 42%, 0.35)` };
1305
+ }
1306
+ /** First alphanumeric character of the author handle, uppercased. */
1307
+ function authorInitial(author) {
1308
+ const m = author.replace(/[^a-zA-Z0-9]/g, "");
1309
+ return (m[0] ?? "?").toUpperCase();
1310
+ }
1016
1311
  function PinIcon(props) {
1312
+ const author = props.record.author || "unknown";
1017
1313
  const status = props.record.plateReply?.status ?? null;
1018
- const palette = pinPalette(status, props.drifted);
1019
- const title = props.drifted
1020
- ? `Selector drifted (anchored at original bbox); click to view`
1021
- : `${status ?? "unresolved"} · click to view`;
1022
- return (_jsx("button", { type: "button", "data-slowcook-overlay-ui": "1", "data-slowcook-comment-id": props.record.commentId, title: title, onClick: (e) => { e.stopPropagation(); props.onClick(); }, style: {
1314
+ const col = authorColor(author);
1315
+ // Status is shown as a small corner badge (not the whole pin) so the pin's
1316
+ // body can carry the author's identity instead. No badge for a plain
1317
+ // unresolved comment; drifted/resolved states get one.
1318
+ const statusBadge = props.drifted
1319
+ ? pinPalette(null, true)
1320
+ : status
1321
+ ? pinPalette(status, false)
1322
+ : null;
1323
+ const title = `@${author}${props.drifted ? " · selector drifted" : status ? ` · ${status}` : ""} · click to view`;
1324
+ return (_jsxs("button", { type: "button", "data-slowcook-overlay-ui": "1", "data-slowcook-comment-id": props.record.commentId, title: title, onClick: (e) => { e.stopPropagation(); props.onClick(); }, style: {
1023
1325
  position: "absolute",
1024
1326
  left: props.x,
1025
1327
  top: props.y,
1026
1328
  width: 22,
1027
1329
  height: 22,
1028
1330
  borderRadius: 999,
1029
- background: palette.bg,
1030
- color: palette.fg,
1331
+ background: col.bg,
1332
+ color: "#fff",
1031
1333
  border: `2px solid white`,
1032
1334
  boxShadow: props.flashing
1033
- ? `0 2px 6px rgba(0,0,0,0.25), 0 0 0 12px ${palette.ring}`
1034
- : `0 2px 6px rgba(0,0,0,0.25), 0 0 0 4px ${palette.ring}`,
1335
+ ? `0 2px 6px rgba(0,0,0,0.25), 0 0 0 12px ${col.ring}`
1336
+ : `0 2px 6px rgba(0,0,0,0.25), 0 0 0 4px ${col.ring}`,
1035
1337
  transform: props.flashing ? "scale(1.4)" : "scale(1)",
1036
1338
  transition: "transform 220ms ease, box-shadow 220ms ease",
1037
1339
  cursor: "pointer",
1038
1340
  pointerEvents: "auto",
1039
1341
  fontSize: 11,
1040
- fontWeight: 700,
1342
+ fontWeight: 800,
1041
1343
  display: "flex",
1042
1344
  alignItems: "center",
1043
1345
  justifyContent: "center",
1044
1346
  font: "inherit",
1045
1347
  padding: 0,
1046
1348
  lineHeight: 1,
1047
- }, children: palette.glyph }));
1349
+ }, children: [authorInitial(author), statusBadge && (_jsx("span", { "aria-hidden": true, style: {
1350
+ position: "absolute", top: -5, right: -5,
1351
+ width: 12, height: 12, borderRadius: 999,
1352
+ background: statusBadge.bg, color: statusBadge.fg,
1353
+ border: "1.5px solid white", fontSize: 8, fontWeight: 800,
1354
+ display: "flex", alignItems: "center", justifyContent: "center",
1355
+ lineHeight: 1,
1356
+ }, children: statusBadge.glyph }))] }));
1048
1357
  }
1049
1358
  /**
1050
1359
  * Thread popover — opens above the pin (or below when near the
@@ -1114,7 +1423,7 @@ function CommentThreadPopover(props) {
1114
1423
  background: "rgba(34, 197, 94, 0.08)",
1115
1424
  borderLeft: "3px solid #22c55e",
1116
1425
  borderRadius: 4,
1117
- }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 600, color: "#15803d", marginBottom: 4 }, children: "slowcook plate" }), _jsx("div", { style: { whiteSpace: "pre-wrap" }, children: record.plateReply.summary }), record.plateReply.files_touched && record.plateReply.files_touched.length > 0 && (_jsxs("div", { style: { marginTop: 6, fontSize: 11, opacity: 0.7, fontFamily: "ui-monospace, SFMono-Regular, monospace" }, children: ["touched: ", record.plateReply.files_touched.join(", ")] }))] })), _jsxs("div", { style: { display: "flex", gap: 12, marginTop: 12, fontSize: 11 }, children: [_jsx("a", { href: record.htmlUrl, target: "_blank", rel: "noreferrer", style: { color: ACCENT, textDecoration: "none" }, children: "\u2197 Comment on GitHub" }), record.plateCommentUrl && (_jsx("a", { href: record.plateCommentUrl, target: "_blank", rel: "noreferrer", style: { color: "#22c55e", textDecoration: "none" }, children: "\u2197 Plate reply on GitHub" }))] })] }));
1426
+ }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 600, color: "#15803d", marginBottom: 4 }, children: "slowcook plate" }), _jsx("div", { style: { whiteSpace: "pre-wrap" }, children: record.plateReply.summary }), record.plateReply.files_touched && record.plateReply.files_touched.length > 0 && (_jsxs("div", { style: { marginTop: 6, fontSize: 11, opacity: 0.7, fontFamily: "ui-monospace, SFMono-Regular, monospace" }, children: ["touched: ", record.plateReply.files_touched.join(", ")] }))] })), _jsxs("div", { style: { display: "flex", gap: 12, marginTop: 12, fontSize: 11 }, children: [_jsx("a", { href: record.htmlUrl, target: "_blank", rel: "noreferrer", style: { color: ACCENT, textDecoration: "none" }, children: "\u2197 Comment on GitHub" }), record.plateCommentUrl && (_jsx("a", { href: record.plateCommentUrl, target: "_blank", rel: "noreferrer", style: { color: "#22c55e", textDecoration: "none" }, children: "\u2197 Reply on GitHub" }))] })] }));
1118
1427
  }
1119
1428
  function formatTimeAgo(iso) {
1120
1429
  try {
@@ -1146,7 +1455,10 @@ function formatTimeAgo(iso) {
1146
1455
  * + visible, also flashes the pin in-place.
1147
1456
  */
1148
1457
  function CommentsListPanel(props) {
1149
- const { records, showApplied, onToggleApplied, onClose, onOpenComment, onAddGeneral } = props;
1458
+ const { records, showApplied, onToggleApplied, onClose, onOpenComment, onAddGeneral, onApprove, isApproved } = props;
1459
+ // 0.6.14 — expand/collapse a row in place to read the full prose + reply,
1460
+ // open the GitHub issue, or (for anchored comments) locate the pin on the page.
1461
+ const [expandedId, setExpandedId] = useState(null);
1150
1462
  // 0.6.0 — resolved comments (applied/declined/noop) hide by default so the
1151
1463
  // list shows what still needs attention; needs-clarification + unresolved
1152
1464
  // always show. The toggle reveals the resolved ones.
@@ -1178,69 +1490,62 @@ function CommentsListPanel(props) {
1178
1490
  fontSize: 18,
1179
1491
  lineHeight: 1,
1180
1492
  padding: 0,
1181
- }, children: "\u00D7" })] }), _jsx("div", { style: { padding: 12, borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: _jsx("button", { type: "button", onClick: onAddGeneral, style: {
1182
- width: "100%",
1183
- padding: "10px 12px",
1184
- background: "rgba(255,107,107,0.12)",
1185
- color: ACCENT,
1186
- border: `1px dashed ${ACCENT}`,
1187
- borderRadius: 8,
1188
- cursor: "pointer",
1189
- font: "inherit",
1190
- fontWeight: 600,
1191
- fontSize: 13,
1192
- }, children: "+ Add note (about the page, not an element)" }) }), hiddenCount > 0 && (_jsx("button", { type: "button", onClick: onToggleApplied, style: { margin: "0 12px 8px", padding: "7px 10px", background: "transparent", color: "rgba(255,255,255,0.6)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, cursor: "pointer", font: "inherit", fontSize: 12 }, children: showApplied ? `Hide ${hiddenCount} already-applied` : `Show ${hiddenCount} already-applied` })), _jsx("div", { style: { overflow: "auto", flex: 1, padding: 8 }, children: visible.length === 0 ? (_jsx("div", { style: { padding: 16, opacity: 0.55, textAlign: "center", fontSize: 12 }, children: records.length === 0
1193
- ? "No comments yet. Toggle 💬 Comment + click an element to anchor a comment, or use the button above for a page-level note."
1194
- : "Nothing needs attention — all comments are applied. Use the toggle above to see them." })) : (visible.slice().reverse().map((r) => {
1195
- const status = r.plateReply?.status ?? null;
1196
- const palette = pinPalette(status, false);
1197
- const anchored = r.payload.element !== null;
1198
- const live = anchored && typeof document !== "undefined"
1199
- ? resolveStoredSelector(document, r.payload.element.selector, r.payload.element.fallback_selector)
1200
- : null;
1201
- const hidden = anchored && live && (live.element.getBoundingClientRect().width === 0 ||
1202
- live.element.offsetParent === null);
1203
- const anchorLabel = !anchored
1204
- ? { text: "note", color: "#94a3b8", bg: "rgba(148,163,184,0.18)" }
1205
- : !live
1206
- ? { text: "drifted", color: "#facc15", bg: "rgba(250,204,21,0.18)" }
1207
- : hidden
1208
- ? { text: "hidden", color: "#94a3b8", bg: "rgba(148,163,184,0.18)" }
1209
- : { text: "anchored", color: "#22c55e", bg: "rgba(34,197,94,0.18)" };
1210
- return (_jsxs("button", { type: "button", onClick: () => onOpenComment(r.commentId), style: {
1211
- display: "block",
1493
+ }, children: "\u00D7" })] }), _jsxs("div", { style: { padding: 12, borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: [_jsx("button", { type: "button", onClick: onAddGeneral, style: {
1212
1494
  width: "100%",
1213
- textAlign: "left",
1214
- background: "rgba(255,255,255,0.03)",
1215
- border: "1px solid rgba(255,255,255,0.08)",
1495
+ padding: "10px 12px",
1496
+ background: "rgba(255,107,107,0.12)",
1497
+ color: ACCENT,
1498
+ border: `1px dashed ${ACCENT}`,
1216
1499
  borderRadius: 8,
1217
- padding: 10,
1218
- marginBottom: 6,
1219
1500
  cursor: "pointer",
1220
- color: "white",
1221
1501
  font: "inherit",
1222
- }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }, children: [_jsx("span", { style: {
1223
- display: "inline-flex",
1224
- alignItems: "center",
1225
- justifyContent: "center",
1226
- width: 18,
1227
- height: 18,
1228
- borderRadius: 999,
1229
- background: palette.bg,
1230
- color: palette.fg,
1231
- fontSize: 10,
1232
- fontWeight: 700,
1233
- }, children: palette.glyph }), _jsx("span", { style: {
1234
- fontSize: 10,
1235
- fontWeight: 700,
1236
- textTransform: "uppercase",
1237
- letterSpacing: 0.4,
1238
- padding: "1px 6px",
1239
- borderRadius: 999,
1240
- background: anchorLabel.bg,
1241
- color: anchorLabel.color,
1242
- }, children: anchorLabel.text }), _jsxs("span", { style: { fontSize: 10, opacity: 0.55, marginLeft: "auto" }, children: ["@", r.author, " \u00B7 ", formatTimeAgo(r.createdAt)] })] }), _jsx("div", { style: { fontSize: 12, opacity: 0.85, lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden" }, children: r.payload.prose })] }, r.commentId));
1243
- })) })] }));
1502
+ fontWeight: 600,
1503
+ fontSize: 13,
1504
+ }, children: "+ Add note (about the page, not an element)" }), _jsx("button", { type: "button", onClick: isApproved ? undefined : onApprove, disabled: isApproved, title: isApproved ? "Mockup already approved" : "Approve the whole mockup (asks to confirm)", style: {
1505
+ width: "100%", marginTop: 8, padding: "10px 12px",
1506
+ background: isApproved ? "rgba(34,197,94,0.15)" : "rgba(34,197,94,0.10)",
1507
+ color: APPROVED_GREEN, border: `1px solid ${APPROVED_GREEN}`,
1508
+ borderRadius: 8, cursor: isApproved ? "default" : "pointer",
1509
+ font: "inherit", fontWeight: 700, fontSize: 13,
1510
+ }, children: isApproved ? "✓ Mockup approved" : "✅ Approve mockup" })] }), hiddenCount > 0 && (_jsx("button", { type: "button", onClick: onToggleApplied, style: { margin: "0 12px 8px", padding: "7px 10px", background: "transparent", color: "rgba(255,255,255,0.6)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, cursor: "pointer", font: "inherit", fontSize: 12 }, children: showApplied ? `Hide ${hiddenCount} already-applied` : `Show ${hiddenCount} already-applied` })), _jsx("div", { style: { overflow: "auto", flex: 1, padding: 8 }, children: visible.length === 0 ? (_jsx("div", { style: { padding: 16, opacity: 0.55, textAlign: "center", fontSize: 12 }, children: records.length === 0
1511
+ ? "No comments yet. Toggle 💬 Comment + click an element to anchor a comment, or use the button above for a page-level note."
1512
+ : "Nothing needs attention — all comments are applied. Use the toggle above to see them." })) : ((() => {
1513
+ // #198-overlay separate page-level notes from element-anchored
1514
+ // comments into labelled groups; element comments are the ones that
1515
+ // get sticky pins, page notes live only here.
1516
+ const rows = visible.slice().reverse();
1517
+ const groups = [
1518
+ { label: "📍 On an element", items: rows.filter((r) => r.payload.element !== null) },
1519
+ { label: "📄 On the page", items: rows.filter((r) => r.payload.element === null) },
1520
+ ];
1521
+ const renderRow = (r) => {
1522
+ const status = r.plateReply?.status ?? null;
1523
+ const palette = pinPalette(status, false);
1524
+ const anchored = r.payload.element !== null;
1525
+ const live = anchored && typeof document !== "undefined"
1526
+ ? resolveStoredSelector(document, r.payload.element.selector, r.payload.element.fallback_selector)
1527
+ : null;
1528
+ const hidden = anchored && live && (live.element.getBoundingClientRect().width === 0 ||
1529
+ live.element.offsetParent === null);
1530
+ const anchorLabel = !anchored
1531
+ ? { text: "note", color: "#94a3b8", bg: "rgba(148,163,184,0.18)" }
1532
+ : !live
1533
+ ? { text: "drifted", color: "#facc15", bg: "rgba(250,204,21,0.18)" }
1534
+ : hidden
1535
+ ? { text: "hidden", color: "#94a3b8", bg: "rgba(148,163,184,0.18)" }
1536
+ : { text: "anchored", color: "#22c55e", bg: "rgba(34,197,94,0.18)" };
1537
+ const expanded = expandedId === r.commentId;
1538
+ const clamp = (lines) => expanded ? {} : { display: "-webkit-box", WebkitLineClamp: lines, WebkitBoxOrient: "vertical", overflow: "hidden" };
1539
+ return (_jsxs("div", { style: {
1540
+ background: "rgba(255,255,255,0.03)",
1541
+ border: `1px solid ${expanded ? "rgba(255,255,255,0.18)" : "rgba(255,255,255,0.08)"}`,
1542
+ borderRadius: 8, padding: 10, marginBottom: 6, color: "white",
1543
+ }, children: [_jsxs("button", { type: "button", onClick: () => setExpandedId(expanded ? null : r.commentId), style: { display: "flex", alignItems: "center", gap: 6, width: "100%", textAlign: "left", color: "white", font: "inherit", cursor: "pointer" }, children: [_jsxs("span", { style: { position: "relative", display: "inline-flex", flexShrink: 0 }, children: [_jsx("span", { style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: 18, height: 18, borderRadius: 999, background: authorColor(r.author || "unknown").bg, color: "#fff", fontSize: 9, fontWeight: 800 }, children: authorInitial(r.author || "unknown") }), status && (_jsx("span", { "aria-hidden": true, style: { position: "absolute", top: -4, right: -4, width: 10, height: 10, borderRadius: 999, background: palette.bg, color: palette.fg, border: "1.5px solid rgba(15,15,24,1)", fontSize: 7, fontWeight: 800, display: "flex", alignItems: "center", justifyContent: "center", lineHeight: 1 }, children: palette.glyph }))] }), _jsx("span", { style: { fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.4, padding: "1px 6px", borderRadius: 999, background: anchorLabel.bg, color: anchorLabel.color }, children: anchorLabel.text }), _jsxs("span", { style: { fontSize: 10, opacity: 0.55, marginLeft: "auto" }, children: ["@", r.author, " \u00B7 ", formatTimeAgo(r.createdAt)] }), _jsx("span", { "aria-hidden": true, style: { fontSize: 10, opacity: 0.55, transform: expanded ? "rotate(90deg)" : "none", transition: "transform .15s" }, children: "\u25B6" })] }), _jsx("div", { style: { fontSize: 12, opacity: 0.85, lineHeight: 1.4, marginTop: 4, ...clamp(3) }, children: r.payload.prose }), r.plateReply?.summary && (_jsxs("div", { style: { marginTop: 6, paddingTop: 6, borderTop: "1px solid rgba(255,255,255,0.08)", fontSize: 11.5, lineHeight: 1.45, color: "rgba(255,255,255,0.82)", whiteSpace: "pre-wrap", ...clamp(4) }, children: [_jsx("span", { style: { color: palette.bg, fontWeight: 700 }, children: "\u21B3 reply: " }), r.plateReply.summary] })), _jsxs("div", { style: { display: "flex", gap: 12, marginTop: 8, fontSize: 11.5 }, children: [_jsx("a", { href: r.htmlUrl, target: "_blank", rel: "noreferrer", style: { color: "#7cc7ff", textDecoration: "none" }, children: "\u2197 Open issue on GitHub" }), anchored && (_jsxs("button", { type: "button", onClick: () => onOpenComment(r.commentId), style: { color: "#22c55e", font: "inherit", fontSize: 11.5, cursor: "pointer" }, children: ["\uD83D\uDCCD ", live ? "Locate on page" : "Anchor drifted"] })), !expanded && (r.payload.prose.length > 120 || (r.plateReply?.summary?.length ?? 0) > 180) && (_jsx("button", { type: "button", onClick: () => setExpandedId(r.commentId), style: { color: "rgba(255,255,255,0.6)", font: "inherit", fontSize: 11.5, cursor: "pointer", marginLeft: "auto" }, children: "Expand" }))] })] }, r.commentId));
1544
+ };
1545
+ return groups
1546
+ .filter((g) => g.items.length > 0)
1547
+ .map((g) => (_jsxs("div", { children: [_jsxs("div", { style: { fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.6, color: "rgba(255,255,255,0.42)", padding: "10px 6px 4px" }, children: [g.label, " \u00B7 ", g.items.length] }), g.items.map(renderRow)] }, g.label)));
1548
+ })()) })] }));
1244
1549
  }
1245
1550
  /**
1246
1551
  * 0.5.0 — composer for general (no-anchor) comments. Same shape as the
@@ -1264,12 +1569,13 @@ function GeneralComposer(props) {
1264
1569
  pointerEvents: "auto",
1265
1570
  fontFamily: "system-ui, -apple-system, sans-serif",
1266
1571
  fontSize: 13,
1267
- }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 4, fontSize: 14 }, children: "Add page note" }), _jsx("div", { style: { fontSize: 12, opacity: 0.65, marginBottom: 10 }, children: "Comment about overall behavior \u2014 not anchored to a specific element." }), _jsx("textarea", { "aria-label": "Note text", autoFocus: true, value: prose, onChange: (e) => setProse(e.target.value), placeholder: "e.g. 'Show an inline error when the user submits a duplicate name.'", rows: 5, style: {
1572
+ }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 6, fontSize: 14 }, children: "Add page note" }), _jsx(PageBadge, {}), _jsx("div", { style: { fontSize: 12, opacity: 0.65, marginBottom: 10 }, children: "Comment about overall behavior \u2014 not anchored to a specific element." }), _jsx("textarea", { "aria-label": "Note text", autoFocus: true, value: prose, onChange: (e) => setProse(e.target.value), placeholder: "e.g. 'Show an inline error when the user submits a duplicate name.'", rows: 5, style: {
1268
1573
  width: "100%",
1269
1574
  padding: 8,
1270
1575
  border: "1px solid rgba(0,0,0,0.15)",
1271
1576
  borderRadius: 6,
1272
1577
  font: "inherit",
1578
+ fontSize: 16, // 0.6.12 — ≥16px stops iOS Safari auto-zooming on focus
1273
1579
  resize: "vertical",
1274
1580
  boxSizing: "border-box",
1275
1581
  } }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 12 }, children: [_jsx("button", { type: "button", onClick: props.onCancel, disabled: props.submitting, style: {
@@ -1292,4 +1598,121 @@ function GeneralComposer(props) {
1292
1598
  fontWeight: 600,
1293
1599
  }, children: props.submitting ? "Posting…" : "Post note" })] })] }));
1294
1600
  }
1601
+ /**
1602
+ * 0.7.0 — Docs studio: the textual half of review. Reads the spine markdown docs
1603
+ * on the working branch, lets a reviewer edit them with an edit/preview toggle,
1604
+ * and (write-access only) commits the edit to the branch as a "scope change"
1605
+ * that `refine` reconciles. Non-write reviewers get read + preview.
1606
+ */
1607
+ function DocsPanel(props) {
1608
+ const { repo, docPaths, branch, identity, getToken, onSignIn, apiBase, onClose, onFeedback } = props;
1609
+ const [path, setPath] = useState(docPaths[0] ?? "");
1610
+ const [file, setFile] = useState(null);
1611
+ const [draft, setDraft] = useState("");
1612
+ const [loading, setLoading] = useState(true);
1613
+ const [error, setError] = useState(null);
1614
+ const [view, setView] = useState("preview");
1615
+ const [submitting, setSubmitting] = useState(false);
1616
+ const canApply = identity?.canApply === true;
1617
+ const dirty = file != null && draft !== file.content;
1618
+ const ref = branch || "HEAD";
1619
+ const load = useCallback(async (p) => {
1620
+ setLoading(true);
1621
+ setError(null);
1622
+ setFile(null);
1623
+ const token = getToken();
1624
+ const res = await fetchDocFile({ owner: repo.owner, repo: repo.repo, path: p, ref, pat: token ?? undefined, apiBase });
1625
+ if (res.ok) {
1626
+ setFile(res.file);
1627
+ setDraft(res.file.content);
1628
+ setView("preview");
1629
+ }
1630
+ else if (!token && (res.status === 404 || res.status === 401)) {
1631
+ setError(`Sign in with GitHub to read ${p.split("/").pop()} — this repo is private.`);
1632
+ }
1633
+ else {
1634
+ setError(`Couldn't load ${p}: ${res.message} (${res.status})`);
1635
+ }
1636
+ setLoading(false);
1637
+ }, [repo.owner, repo.repo, ref, apiBase, getToken]);
1638
+ useEffect(() => { if (path)
1639
+ void load(path); }, [path, load]);
1640
+ async function submit() {
1641
+ if (!file || !dirty)
1642
+ return;
1643
+ const token = getToken();
1644
+ if (!canApply || !token)
1645
+ return;
1646
+ setSubmitting(true);
1647
+ const res = await commitDocFile({
1648
+ owner: repo.owner, repo: repo.repo, path, content: draft, sha: file.sha,
1649
+ branch: ref, message: `docs(scope): ${path} edited via review (PM scope change)`,
1650
+ pat: token, apiBase,
1651
+ });
1652
+ setSubmitting(false);
1653
+ if (res.ok) {
1654
+ onFeedback(`Scope change committed to ${ref} — refine will reconcile ${path.split("/").pop()}.`);
1655
+ await load(path); // refresh sha + content
1656
+ }
1657
+ else {
1658
+ setError(`Commit failed: ${res.message} (${res.status})`);
1659
+ }
1660
+ }
1661
+ const name = (p) => p.split("/").pop() ?? p;
1662
+ return (_jsxs("div", { "data-slowcook-overlay-ui": "1", role: "dialog", "aria-label": "Docs studio", onClick: (e) => e.stopPropagation(), style: {
1663
+ position: "fixed", inset: 0, zIndex: 2147483602,
1664
+ background: "rgba(15,15,24,0.97)", color: "white", pointerEvents: "auto",
1665
+ fontFamily: "system-ui, -apple-system, sans-serif", fontSize: 13,
1666
+ display: "flex", flexDirection: "column",
1667
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid rgba(255,255,255,0.1)" }, children: [_jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: "\uD83D\uDCC4 Docs \u00B7 textual review" }), _jsxs("span", { style: { fontSize: 11, opacity: 0.6 }, children: ["branch ", _jsx("code", { style: { background: "rgba(255,255,255,0.1)", padding: "1px 6px", borderRadius: 5 }, children: ref })] }), _jsx("span", { style: { marginLeft: "auto" } }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close docs", style: { background: "transparent", border: "none", color: "rgba(255,255,255,0.6)", cursor: "pointer", fontSize: 22, lineHeight: 1 }, children: "\u00D7" })] }), _jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, padding: "10px 16px", borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: docPaths.map((p) => (_jsxs("button", { type: "button", onClick: () => setPath(p), title: p, style: {
1668
+ fontSize: 12, fontWeight: p === path ? 800 : 600, padding: "5px 11px", borderRadius: 999,
1669
+ cursor: "pointer", border: "1px solid " + (p === path ? "rgba(255,107,107,0.7)" : "rgba(255,255,255,0.15)"),
1670
+ background: p === path ? "rgba(255,107,107,0.18)" : "transparent", color: p === path ? "#ffb4b4" : "rgba(255,255,255,0.75)",
1671
+ }, children: [name(p), dirty && p === path ? " •" : ""] }, p))) }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "8px 16px", borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: [_jsx("div", { style: { display: "inline-flex", background: "rgba(255,255,255,0.06)", borderRadius: 999, padding: 2 }, children: ["preview", "edit"].map((v) => (_jsx("button", { type: "button", onClick: () => setView(v), style: { fontSize: 12, fontWeight: 700, padding: "4px 12px", borderRadius: 999, border: "none", cursor: "pointer",
1672
+ background: view === v ? "white" : "transparent", color: view === v ? "#111" : "rgba(255,255,255,0.7)" }, children: v === "preview" ? "Preview" : "Edit" }, v))) }), _jsx("span", { style: { marginLeft: "auto" } }), identity ? (canApply ? (_jsx("button", { type: "button", disabled: !dirty || submitting, onClick: () => void submit(), title: dirty ? "Commit this edit to the branch as a scope change" : "No changes yet", style: { fontSize: 12.5, fontWeight: 800, padding: "7px 14px", borderRadius: 8, border: "none",
1673
+ cursor: !dirty || submitting ? "not-allowed" : "pointer", opacity: !dirty || submitting ? 0.5 : 1,
1674
+ background: "#22c55e", color: "white" }, children: submitting ? "Submitting…" : "Submit scope change" })) : (_jsx("span", { style: { fontSize: 11.5, opacity: 0.7, maxWidth: 280 }, children: "Read-only \u2014 write access is required to submit a scope change; your notes can go via a review comment." }))) : (_jsx("button", { type: "button", onClick: onSignIn, style: { fontSize: 12.5, fontWeight: 700, padding: "7px 14px", borderRadius: 8, border: "none", cursor: "pointer", background: "white", color: "#111" }, children: "Sign in to edit" }))] }), _jsx("div", { style: { flex: 1, overflow: "auto", padding: 0 }, children: loading ? (_jsxs("div", { style: { padding: 24, opacity: 0.6 }, children: ["Loading ", name(path), "\u2026"] })) : error ? (_jsx("div", { style: { padding: 24, color: "#ffb4b4" }, children: error })) : view === "edit" ? (_jsx("textarea", { value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, style: {
1675
+ width: "100%", height: "100%", minHeight: 400, boxSizing: "border-box",
1676
+ padding: "16px 20px", border: "none", outline: "none", resize: "none",
1677
+ background: "#11111b", color: "#e8e8f0", fontSize: 16, lineHeight: 1.55,
1678
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
1679
+ } })) : (_jsx("div", { className: "sc-doc-prose", style: { padding: "16px 24px", maxWidth: 860, margin: "0 auto", lineHeight: 1.6 }, dangerouslySetInnerHTML: { __html: renderMarkdown(draft) } })) }), _jsx("style", { dangerouslySetInnerHTML: { __html: `.sc-doc-prose h1,.sc-doc-prose h2,.sc-doc-prose h3{color:#fff;margin:1.1em 0 .4em;line-height:1.25}` +
1680
+ `.sc-doc-prose h1{font-size:24px}.sc-doc-prose h2{font-size:19px;border-bottom:1px solid rgba(255,255,255,0.12);padding-bottom:4px}.sc-doc-prose h3{font-size:16px}` +
1681
+ `.sc-doc-prose p,.sc-doc-prose li{color:rgba(255,255,255,0.85)}` +
1682
+ `.sc-doc-prose code{background:rgba(255,255,255,0.1);padding:1px 5px;border-radius:5px;font-size:0.9em}` +
1683
+ `.sc-doc-prose pre{background:#11111b;padding:12px 14px;border-radius:8px;overflow:auto}.sc-doc-prose pre code{background:none;padding:0}` +
1684
+ `.sc-doc-prose a{color:#7cc7ff}` +
1685
+ `.sc-doc-prose blockquote{border-left:3px solid rgba(255,107,107,0.6);margin:.6em 0;padding:.2em 0 .2em 14px;color:rgba(255,255,255,0.7)}` +
1686
+ `.sc-doc-prose hr{border:none;border-top:1px solid rgba(255,255,255,0.12);margin:1.2em 0}` +
1687
+ `.sc-doc-prose table{border-collapse:collapse;width:100%;margin:.8em 0;font-size:12.5px}` +
1688
+ `.sc-doc-prose th,.sc-doc-prose td{border:1px solid rgba(255,255,255,0.15);padding:6px 10px;text-align:left}` +
1689
+ `.sc-doc-prose th{background:rgba(255,255,255,0.06)}`
1690
+ } })] }));
1691
+ }
1692
+ /**
1693
+ * 0.7.1 — "Viewing as" surface switcher, in the review pane. Lets a reviewer
1694
+ * jump between the mock's persona/section surfaces. This is review-only — a real
1695
+ * user is one role — so it must NOT live in the product mock. Tracks the current
1696
+ * route (longest matching surface home) and navigates via the parent's
1697
+ * router-agnostic handler.
1698
+ */
1699
+ function SurfaceSwitcher(props) {
1700
+ const { surfaces, onNavigate, disabled } = props;
1701
+ const [path, setPath] = useState(typeof window !== "undefined" ? window.location.pathname : "/");
1702
+ useEffect(() => {
1703
+ if (typeof window === "undefined")
1704
+ return;
1705
+ const on = () => setPath(window.location.pathname);
1706
+ window.addEventListener("popstate", on);
1707
+ return () => window.removeEventListener("popstate", on);
1708
+ }, []);
1709
+ const current = surfaces
1710
+ .filter((s) => (s.home === "/" ? true : path === s.home || path.startsWith(s.home + "/") || path === s.home))
1711
+ .sort((a, b) => b.home.length - a.home.length)[0] ?? surfaces[0];
1712
+ return (_jsx("select", { className: "sc-ovl-pane-select", "aria-label": "Viewing as (review surface)", title: "Viewing as \u2014 jump between persona surfaces (review only; not a real control)", value: current?.home ?? "", disabled: disabled, onChange: (e) => onNavigate(e.target.value), style: {
1713
+ marginLeft: 2, maxWidth: 150,
1714
+ border: "1px solid rgba(0,0,0,0.15)", borderRadius: 999,
1715
+ cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1,
1716
+ }, children: surfaces.map((s) => (_jsx("option", { value: s.home, style: { color: "#111" }, children: (s.icon ? s.icon + " " : "") + "as " + s.label }, s.home))) }));
1717
+ }
1295
1718
  //# sourceMappingURL=overlay.js.map