@cyber-dash-tech/revela 0.18.4 → 0.18.5

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.
@@ -16,13 +16,20 @@ import type { MediaAssetRecord, MediaPurpose } from "../media/types"
16
16
  import { addCommentRequestEvent, completeCommentRequest, createCommentRequest, failCommentRequest, getCommentRequest, subscribeCommentRequestEvents } from "./comment-requests"
17
17
  import { createOpenCodeReviewPromptBridge, type ReviewPromptBridge } from "./prompt-bridge"
18
18
  import { suppressReviewApplyFixArtifactQa } from "./qa-suppression"
19
+ import { createReviewComment, deleteReviewComment, listReviewComments, markReviewCommentApplied, markReviewCommentApplying, markReviewCommentFailed, markReviewCommentQueued, markReviewCommentStopped, readReviewComment, type ReviewCommentRecord } from "./review-comments"
19
20
  import { annotateVisualEditTargets, applyVisualTargetChanges, type VisualEditTarget } from "./visual-targets"
20
21
 
21
22
  const TOKEN_BYTES = 24
22
23
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000
23
24
  const IDLE_STOP_MS = 30 * 60 * 1000
25
+ export const REVIEW_REF_LABEL_MAX_DISPLAY_CHARS = 32
24
26
  export const LIVE_EDITOR_IDLE_MS = 10 * 1000
25
27
 
28
+ export function displayReviewReferenceLabel(label: string): string {
29
+ const text = String(label || "")
30
+ return text.length > REVIEW_REF_LABEL_MAX_DISPLAY_CHARS ? text.slice(0, REVIEW_REF_LABEL_MAX_DISPLAY_CHARS - 1) + "…" : text
31
+ }
32
+
26
33
  interface EditAsset {
27
34
  id: string
28
35
  absoluteFile: string
@@ -45,11 +52,27 @@ interface EditSession {
45
52
  defaultMode: RefineMode
46
53
  visualTargets: Map<string, VisualEditTarget>
47
54
  visualTargetDeckVersion?: string
55
+ activeApplyCommentId?: string
56
+ applyQueue: string[]
48
57
  }
49
58
 
50
59
  export type RefineMode = "edit" | "inspect"
51
60
  export type ReviewShellSurface = "legacy" | "codex"
52
61
 
62
+ function lucideIcon(name: "image" | "list" | "play" | "plus" | "refresh-cw" | "send" | "square" | "trash-2", className = "composer-icon"): string {
63
+ const paths: Record<typeof name, string> = {
64
+ image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
65
+ list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
66
+ play: '<polygon points="6 3 20 12 6 21 6 3"/>',
67
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
68
+ "refresh-cw": '<path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/>',
69
+ send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
70
+ square: '<rect width="14" height="14" x="5" y="5" rx="2"/>',
71
+ "trash-2": '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
72
+ }
73
+ return `<svg class="lucide lucide-${name} ${className}" data-lucide="${name}" viewBox="0 0 24 24" aria-hidden="true">${paths[name]}</svg>`
74
+ }
75
+
53
76
  export interface RefineServerHandle {
54
77
  baseUrl: string
55
78
  getOrCreateSession(input: {
@@ -98,6 +121,7 @@ export function startRefineServer(): RefineServerHandle {
98
121
  existing.session.workspaceRoot = resolve(input.workspaceRoot)
99
122
  existing.session.defaultMode = input.mode ?? "edit"
100
123
  existing.session.visualTargets = existing.session.visualTargets ?? new Map()
124
+ existing.session.applyQueue = existing.session.applyQueue ?? []
101
125
  return {
102
126
  token: existing.token,
103
127
  reused: true,
@@ -122,6 +146,7 @@ export function startRefineServer(): RefineServerHandle {
122
146
  lastActiveAt: Date.now(),
123
147
  defaultMode: input.mode ?? "edit",
124
148
  visualTargets: new Map(),
149
+ applyQueue: [],
125
150
  })
126
151
  return { token, reused: false, live: false }
127
152
  },
@@ -202,6 +227,39 @@ async function handleRequest(req: Request): Promise<Response> {
202
227
  return handleComment(req, session.value)
203
228
  }
204
229
 
230
+ if (url.pathname === "/api/comments" && req.method === "GET") {
231
+ const session = validateSession(url.searchParams.get("token"))
232
+ if (!session.ok) return session.response
233
+ return handleReviewCommentsList(session.value)
234
+ }
235
+
236
+ if (url.pathname === "/api/comments" && req.method === "POST") {
237
+ const session = validateSession(url.searchParams.get("token"))
238
+ if (!session.ok) return session.response
239
+ return handleReviewCommentCreate(req, session.value)
240
+ }
241
+
242
+ const applyMatch = url.pathname.match(/^\/api\/comments\/([^/]+)\/apply$/)
243
+ if (applyMatch && req.method === "POST") {
244
+ const session = validateSession(url.searchParams.get("token"))
245
+ if (!session.ok) return session.response
246
+ return handleReviewCommentApply(decodeURIComponent(applyMatch[1]), req, session.value)
247
+ }
248
+
249
+ const stopMatch = url.pathname.match(/^\/api\/comments\/([^/]+)\/stop$/)
250
+ if (stopMatch && req.method === "POST") {
251
+ const session = validateSession(url.searchParams.get("token"))
252
+ if (!session.ok) return session.response
253
+ return handleReviewCommentStop(decodeURIComponent(stopMatch[1]), session.value)
254
+ }
255
+
256
+ const deleteMatch = url.pathname.match(/^\/api\/comments\/([^/]+)$/)
257
+ if (deleteMatch && req.method === "DELETE") {
258
+ const session = validateSession(url.searchParams.get("token"))
259
+ if (!session.ok) return session.response
260
+ return handleReviewCommentDelete(decodeURIComponent(deleteMatch[1]), session.value)
261
+ }
262
+
205
263
  if (url.pathname === "/api/comment-result" && req.method === "GET") {
206
264
  const session = validateSession(url.searchParams.get("token"))
207
265
  if (!session.ok) return session.response
@@ -749,6 +807,174 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
749
807
  return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
750
808
  }
751
809
 
810
+ return applyCommentPayload(body, session)
811
+ }
812
+
813
+ function handleReviewCommentsList(session: EditSession): Response {
814
+ const deckVersion = readDeckVersion(session).version
815
+ const comments = listReviewComments(session.workspaceRoot, session.file)
816
+ session.lastActiveAt = Date.now()
817
+ scheduleIdleStop()
818
+ return jsonResponse({ ok: true, deckVersion, comments })
819
+ }
820
+
821
+ async function handleReviewCommentCreate(req: Request, session: EditSession): Promise<Response> {
822
+ let body: Partial<EditCommentPayload>
823
+ try {
824
+ body = await req.json()
825
+ } catch {
826
+ return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
827
+ }
828
+
829
+ const comment = typeof body.comment === "string" ? body.comment.trim() : ""
830
+ const elements = Array.isArray(body.elements) ? body.elements : []
831
+ if (!comment) return jsonResponse({ ok: false, error: "Comment is required" }, 400)
832
+ try {
833
+ const deckVersion = readDeckVersion(session).version
834
+ const saved = createReviewComment(session.workspaceRoot, {
835
+ deckFile: session.file,
836
+ deckVersion,
837
+ comment,
838
+ elements,
839
+ asset: (body as any).asset,
840
+ drop: (body as any).drop,
841
+ })
842
+ session.lastActiveAt = Date.now()
843
+ scheduleIdleStop()
844
+ return jsonResponse({ ok: true, comment: saved, deckVersion })
845
+ } catch (error) {
846
+ const message = error instanceof Error ? error.message : String(error)
847
+ return jsonResponse({ ok: false, error: message }, 400)
848
+ }
849
+ }
850
+
851
+ async function handleReviewCommentApply(commentId: string, req: Request, session: EditSession): Promise<Response> {
852
+ let body: any = {}
853
+ try {
854
+ const text = await req.text()
855
+ body = text ? JSON.parse(text) : {}
856
+ } catch {
857
+ return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
858
+ }
859
+
860
+ const comment = readReviewComment(session.workspaceRoot, commentId)
861
+ if (!comment || comment.deckFile !== session.file) return jsonResponse({ ok: false, error: "Review comment not found" }, 404)
862
+ return enqueueOrStartPersistedReviewCommentApply(session, comment, body)
863
+ }
864
+
865
+ function handleReviewCommentStop(commentId: string, session: EditSession): Response {
866
+ const comment = readReviewComment(session.workspaceRoot, commentId)
867
+ if (!comment || comment.deckFile !== session.file) return jsonResponse({ ok: false, error: "Review comment not found" }, 404)
868
+ session.applyQueue = (session.applyQueue ?? []).filter((id) => id !== comment.id)
869
+ if (session.activeApplyCommentId === comment.id) session.activeApplyCommentId = undefined
870
+ const stopped = markReviewCommentStopped(session.workspaceRoot, comment.id) ?? comment
871
+ session.lastActiveAt = Date.now()
872
+ scheduleIdleStop()
873
+ if (comment.status === "queued") drainPersistedReviewCommentApplyQueue(session)
874
+ return jsonResponse({ ok: true, status: "stopped", comment: stopped, deckVersion: readDeckVersion(session).version })
875
+ }
876
+
877
+ function handleReviewCommentDelete(commentId: string, session: EditSession): Response {
878
+ const comment = readReviewComment(session.workspaceRoot, commentId)
879
+ if (!comment || comment.deckFile !== session.file) return jsonResponse({ ok: false, error: "Review comment not found" }, 404)
880
+ if (comment.status === "applying") return jsonResponse({ ok: false, error: "Stop the applying comment before deleting it." }, 409)
881
+ session.applyQueue = (session.applyQueue ?? []).filter((id) => id !== comment.id)
882
+ const deleted = deleteReviewComment(session.workspaceRoot, comment.id)
883
+ if (!deleted) return jsonResponse({ ok: false, error: "Review comment not found" }, 404)
884
+ session.lastActiveAt = Date.now()
885
+ scheduleIdleStop()
886
+ return jsonResponse({ ok: true, deleted: true, commentId: comment.id, deckVersion: readDeckVersion(session).version })
887
+ }
888
+
889
+ async function enqueueOrStartPersistedReviewCommentApply(session: EditSession, comment: ReviewCommentRecord, body: any = {}): Promise<Response> {
890
+ session.applyQueue = session.applyQueue ?? []
891
+ if (session.activeApplyCommentId === comment.id) {
892
+ const current = readReviewComment(session.workspaceRoot, comment.id) ?? comment
893
+ return jsonResponse({
894
+ ok: true,
895
+ requestId: current.lastApplyRequestId,
896
+ commentRequestId: current.lastApplyRequestId,
897
+ deckVersion: readDeckVersion(session).version,
898
+ status: "pending",
899
+ comment: current,
900
+ })
901
+ }
902
+
903
+ const queuedIndex = session.applyQueue.indexOf(comment.id)
904
+ if (queuedIndex >= 0) {
905
+ const queued = markReviewCommentQueued(session.workspaceRoot, comment.id) ?? comment
906
+ return jsonResponse({
907
+ ok: true,
908
+ deckVersion: readDeckVersion(session).version,
909
+ status: "queued",
910
+ queuePosition: queuedIndex + 1,
911
+ comment: queued,
912
+ })
913
+ }
914
+
915
+ if (session.activeApplyCommentId) {
916
+ session.applyQueue.push(comment.id)
917
+ const queued = markReviewCommentQueued(session.workspaceRoot, comment.id) ?? comment
918
+ session.lastActiveAt = Date.now()
919
+ scheduleIdleStop()
920
+ return jsonResponse({
921
+ ok: true,
922
+ deckVersion: readDeckVersion(session).version,
923
+ status: "queued",
924
+ queuePosition: session.applyQueue.length,
925
+ comment: queued,
926
+ })
927
+ }
928
+
929
+ return startPersistedReviewCommentApply(session, comment, body)
930
+ }
931
+
932
+ async function startPersistedReviewCommentApply(session: EditSession, comment: ReviewCommentRecord, body: any = {}): Promise<Response> {
933
+ session.activeApplyCommentId = comment.id
934
+ try {
935
+ const response = await applyCommentPayload({
936
+ ...body,
937
+ comment: comment.comment,
938
+ elements: comment.elements,
939
+ asset: comment.asset,
940
+ drop: comment.drop,
941
+ requestId: typeof body.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url"),
942
+ }, session, {
943
+ persistedCommentId: comment.id,
944
+ onSettled: () => settlePersistedReviewCommentApply(session, comment.id),
945
+ })
946
+ if (!response.ok) settlePersistedReviewCommentApply(session, comment.id)
947
+ return response
948
+ } catch (error) {
949
+ settlePersistedReviewCommentApply(session, comment.id)
950
+ throw error
951
+ }
952
+ }
953
+
954
+ function settlePersistedReviewCommentApply(session: EditSession, commentId: string): void {
955
+ if (session.activeApplyCommentId === commentId) session.activeApplyCommentId = undefined
956
+ drainPersistedReviewCommentApplyQueue(session)
957
+ }
958
+
959
+ function drainPersistedReviewCommentApplyQueue(session: EditSession): void {
960
+ if (session.activeApplyCommentId) return
961
+ session.applyQueue = session.applyQueue ?? []
962
+ while (session.applyQueue.length) {
963
+ const nextId = session.applyQueue.shift()
964
+ if (!nextId) continue
965
+ const next = readReviewComment(session.workspaceRoot, nextId)
966
+ if (!next || next.deckFile !== session.file) continue
967
+ void startPersistedReviewCommentApply(session, next)
968
+ return
969
+ }
970
+ }
971
+
972
+ async function applyCommentPayload(
973
+ body: Partial<EditCommentPayload>,
974
+ session: EditSession,
975
+ options: { persistedCommentId?: string; onSettled?: () => void } = {},
976
+ ): Promise<Response> {
977
+ const { persistedCommentId, onSettled } = options
752
978
  const comments = Array.isArray(body.comments)
753
979
  ? body.comments
754
980
  .map((draft: any) => ({
@@ -778,6 +1004,7 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
778
1004
  ? (body as any).requestId.trim()
779
1005
  : randomBytes(10).toString("base64url")
780
1006
  createCommentRequest({ requestId, deckVersion })
1007
+ if (persistedCommentId) markReviewCommentApplying(session.workspaceRoot, persistedCommentId, requestId)
781
1008
  suppressReviewApplyFixArtifactQa({
782
1009
  workspaceRoot: session.workspaceRoot,
783
1010
  file: session.file,
@@ -794,17 +1021,23 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
794
1021
  }).then((result) => {
795
1022
  if (result.ok) {
796
1023
  completeCommentRequest(requestId)
1024
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentApplied(session.workspaceRoot, persistedCommentId)
797
1025
  } else {
798
1026
  failCommentRequest(requestId, result.error, result.raw)
1027
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentFailed(session.workspaceRoot, persistedCommentId, result.error, result.raw)
799
1028
  }
1029
+ onSettled?.()
800
1030
  }).catch((error: unknown) => {
801
1031
  const message = error instanceof Error ? error.message : String(error)
802
1032
  failCommentRequest(requestId, message)
1033
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentFailed(session.workspaceRoot, persistedCommentId, message)
1034
+ onSettled?.()
803
1035
  })
804
1036
 
805
1037
  session.lastActiveAt = Date.now()
806
1038
  scheduleIdleStop()
807
- return jsonResponse({ ok: true, requestId, commentRequestId: requestId, deckVersion, status: "pending" })
1039
+ const persistedComment = persistedCommentId ? readReviewComment(session.workspaceRoot, persistedCommentId) : undefined
1040
+ return jsonResponse({ ok: true, requestId, commentRequestId: requestId, deckVersion, status: "pending", ...(persistedComment ? { comment: persistedComment } : {}) })
808
1041
  }
809
1042
 
810
1043
  function handleCommentEvents(requestId: string | null, session: EditSession): Response {
@@ -1088,7 +1321,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1088
1321
  const encodedToken = JSON.stringify(token)
1089
1322
  const encodedDefaultMode = JSON.stringify(defaultMode)
1090
1323
  const encodedSurface = JSON.stringify(surface)
1091
- const activityLabel = surface === "codex" ? "Codex Activity" : "Activity"
1324
+ const sendButtonHtml = `${lucideIcon("send")}<span class="sr-only">Leave Comment</span>`
1325
+ const encodedSendButtonHtml = JSON.stringify(sendButtonHtml)
1326
+ const activityLabel = "Comments"
1092
1327
  const bodyClass = surface === "codex" ? "codex-review" : "legacy-review"
1093
1328
  return `<!doctype html>
1094
1329
  <html lang="en">
@@ -1099,96 +1334,131 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1099
1334
  <style>
1100
1335
  :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
1101
1336
  * { box-sizing: border-box; }
1102
- body { margin: 0; background: #eee8dc; color: #1f2933; height: 100vh; overflow: hidden; }
1337
+ body { margin: 0; background: #f8fafc; color: #111827; height: 100vh; overflow: hidden; }
1103
1338
  body.resizing { cursor: col-resize; user-select: none; }
1104
1339
  body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
1105
1340
  .app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
1106
- .preview { position: relative; min-width: 0; background: #e7dfd1; }
1341
+ .preview { position: relative; min-width: 0; background: #eef2f7; }
1107
1342
  .resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
1108
1343
  .resize-handle::before { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 44px; border-radius: 999px; transform: translate(-50%, -50%); background: rgba(148,163,184,.34); box-shadow: 0 1px 2px rgba(15,23,42,.06); transition: background .16s ease, height .16s ease, box-shadow .16s ease; }
1109
1344
  .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
1110
1345
  iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
1346
+ .comment-highlight-layer { position: absolute; inset: 0; z-index: 6; pointer-events: none; overflow: hidden; }
1347
+ .comment-highlight-box { position: absolute; display: none; border: 2px solid #2563eb; border-radius: 6px; background: rgba(37,99,235,.12); box-shadow: 0 0 0 3px rgba(255,255,255,.74), 0 10px 28px rgba(15,23,42,.14); }
1111
1348
  .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
1112
- .visual-move-handle { position: absolute; z-index: 4; width: 16px; height: 16px; border: 2px solid #111827; border-radius: 999px; background: #fbfaf7; box-shadow: 0 6px 16px rgba(31,41,51,.22); transform: translate(-50%, -50%); pointer-events: none; display: none; }
1349
+ .visual-move-handle { position: absolute; z-index: 4; width: 16px; height: 16px; border: 2px solid #111827; border-radius: 999px; background: #ffffff; box-shadow: 0 6px 16px rgba(15,23,42,.18); transform: translate(-50%, -50%); pointer-events: none; display: none; }
1113
1350
  .visual-move-handle::before { content: ""; position: absolute; inset: 4px; border-top: 2px solid #111827; border-left: 2px solid #111827; transform: rotate(45deg); }
1114
- .visual-resize-handle { position: absolute; z-index: 3; width: 14px; height: 14px; border: 2px solid #111827; border-radius: 4px; background: #fbfaf7; box-shadow: 0 6px 16px rgba(31,41,51,.22); transform: translate(-50%, -50%); pointer-events: none; display: none; }
1351
+ .visual-resize-handle { position: absolute; z-index: 3; width: 14px; height: 14px; border: 2px solid #111827; border-radius: 4px; background: #ffffff; box-shadow: 0 6px 16px rgba(15,23,42,.18); transform: translate(-50%, -50%); pointer-events: none; display: none; }
1115
1352
  .visual-resize-handle[data-mode="text-width"] { width: 10px; height: 28px; border-radius: 999px; cursor: ew-resize; }
1116
- .visual-edit-toolbar { position: absolute; top: 14px; left: 50%; z-index: 6; display: none; align-items: center; gap: 8px; transform: translateX(-50%); padding: 8px 10px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(17,24,39,.88); color: #fbfaf7; box-shadow: 0 16px 40px rgba(31,41,51,.26); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); font-size: 12px; font-weight: 800; }
1117
- .visual-edit-toolbar.active { display: inline-flex; }
1353
+ .visual-edit-toolbar { position: absolute; top: 14px; left: 50%; z-index: 6; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 8px 10px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(15,23,42,.78); color: #ffffff; box-shadow: 0 16px 40px rgba(15,23,42,.2); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); font-size: 12px; font-weight: 800; }
1354
+ .visual-edit-toolbar.active { background: rgba(15,23,42,.9); box-shadow: 0 16px 40px rgba(15,23,42,.24); }
1118
1355
  .visual-edit-toolbar button { width: auto; min-width: 0; padding: 7px 10px; border-radius: 999px; border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.12); color: #fff; box-shadow: none; font-size: 12px; }
1119
- .visual-edit-toolbar .save-visual { background: #fbfaf7; color: #111827; }
1356
+ .visual-edit-toolbar .save-visual { background: #ffffff; color: #111827; }
1120
1357
  .deck-nav { position: absolute; left: 50%; bottom: 18px; z-index: 4; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(15,23,42,.76); box-shadow: 0 16px 44px rgba(15,23,42,.24); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); pointer-events: auto; }
1121
1358
  .deck-nav button { width: auto; min-width: 84px; padding: 8px 12px; border-radius: 999px; background: rgba(255,255,255,.12); color: #fff; box-shadow: none; font-size: 12px; font-weight: 900; }
1122
1359
  .deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
1123
1360
  .deck-nav button:disabled { opacity: .38; }
1124
1361
  .deck-nav-status { min-width: 76px; color: #e2e8f0; font-size: 12px; font-weight: 900; text-align: center; font-variant-numeric: tabular-nums; }
1125
- aside { position: relative; display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #fbfaf7 0%, #f2eee6 100%); overflow: auto; border-left: 1px solid #d8d2c6; font-family: Garamond, "Iowan Old Style", Georgia, serif; }
1362
+ aside { position: relative; display: flex; flex-direction: column; gap: 16px; padding: 18px; background: #ffffff; overflow: hidden; border-left: 1px solid #e2e8f0; box-shadow: -10px 0 28px rgba(15,23,42,.06); }
1126
1363
  aside button, aside input, aside select, aside textarea, aside .comment-editor { font-family: inherit; }
1127
- h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
1128
- .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
1364
+ h1 { margin: 0; font-size: 17px; line-height: 1.2; letter-spacing: 0; color: #0f172a; }
1365
+ .wordmark { font-family: inherit; font-size: 17px; letter-spacing: 0; font-weight: 800; }
1129
1366
  .panel { display: flex; flex-direction: column; gap: 10px; }
1130
- .tabs { display: flex; gap: 2px; padding: 0 0 0 8px; border-bottom: 1px solid #d8d2c6; background: transparent; }
1131
- .tab { width: auto; min-width: 112px; padding: 10px 18px; border: 1px solid transparent; border-bottom: 0; border-radius: 13px 13px 0 0; background: transparent; color: #5f594f; box-shadow: none; font-weight: 900; }
1132
- .tab:hover:not(:disabled) { background: rgba(255,253,248,.58); }
1133
- .tab.active { position: relative; top: 1px; background: #fbfaf7; border-color: #d8d2c6; color: #111827; box-shadow: 0 -7px 16px rgba(31,41,51,.05); }
1134
- .tab-panel { display: none; flex-direction: column; gap: 12px; padding-top: 12px; }
1367
+ .tabs { display: flex; gap: 4px; padding: 3px; border: 1px solid #e2e8f0; border-radius: 999px; background: #f8fafc; }
1368
+ .tab { width: auto; min-width: 112px; padding: 8px 16px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: #64748b; box-shadow: none; font-weight: 750; }
1369
+ .tab:hover:not(:disabled) { background: #eef2f7; color: #334155; }
1370
+ .tab.active { position: relative; top: 0; background: #ffffff; border-color: #e2e8f0; color: #0f172a; box-shadow: 0 1px 3px rgba(15,23,42,.08); }
1371
+ .tab-panel { display: none; flex-direction: column; gap: 12px; min-height: 0; padding-top: 12px; }
1135
1372
  .tab-panel.active { display: flex; }
1373
+ #editPanel { flex: 1 1 auto; min-height: 0; }
1136
1374
  #inspectTab, #inspectPanel { display: none !important; }
1137
1375
  .sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
1138
- .selection-summary { padding: 10px 12px; border: 1px solid #d8d2c6; border-radius: 14px; background: #fbfaf7; color: #3f3a33; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 22px rgba(31,41,51,.05); }
1139
- .selection-summary strong { display: block; margin-bottom: 7px; color: #756f66; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
1376
+ .selection-summary { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 14px; background: #f8fafc; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: none; }
1377
+ .selection-summary strong { display: block; margin-bottom: 7px; color: #64748b; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
1140
1378
  .selection-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1141
- .label { color: #756f66; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
1142
- .comment-editor { width: 100%; min-height: 164px; max-height: 42vh; overflow: auto; padding: 13px 14px; border: 1px solid #d8d2c6; border-radius: 14px; background: #fffdf8; color: #111827; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; box-shadow: 0 10px 24px rgba(31,41,51,.06); }
1143
- .comment-editor:focus { border-color: #a9793f; box-shadow: 0 0 0 3px rgba(169,121,63,.14), 0 10px 24px rgba(31,41,51,.07); }
1144
- .comment-editor:empty::before { content: attr(data-placeholder); color: #a79d8e; pointer-events: none; }
1145
- .ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px 7px; border-radius: 999px; background: var(--ref-bg, #e0f2fe); color: var(--ref-text, #075985); border: 1px solid var(--ref-border, #7dd3fc); font-weight: 800; white-space: nowrap; }
1146
- .activity-panel { display: flex; flex-direction: column; gap: 8px; padding-top: 2px; }
1147
- .comment-thread { display: flex; flex-direction: column; gap: 8px; max-height: 24vh; overflow: auto; }
1148
- .comment-thread:empty { display: none; }
1149
- .comment-bubble { border: 1px solid #d8d2c6; border-radius: 14px; padding: 10px 12px; background: #fffdf8; color: #3f3a33; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 22px rgba(31,41,51,.05); }
1150
- .comment-bubble.sending { border-color: #c8b88f; background: #f7f0df; }
1151
- .comment-bubble.updated { border-color: #9dac8a; background: #f0f2e8; }
1152
- .comment-bubble.stale { border-color: #c6a96a; background: #f8efd7; }
1153
- .comment-bubble.failed { border-color: #c58f82; background: #f7eae5; }
1154
- .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
1155
- .comment-bubble-state { margin-top: 8px; color: #8a6231; font-size: 12px; font-weight: 800; }
1156
- .comment-progress { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; color: #5f574d; font-size: 12px; }
1157
- .comment-progress-line { display: flex; gap: 6px; align-items: flex-start; }
1158
- .comment-progress-line::before { content: ""; width: 6px; height: 6px; margin-top: 6px; border-radius: 999px; background: #b48b52; flex: 0 0 auto; }
1159
- .comment-raw { margin-top: 8px; color: #6f473c; font-size: 12px; }
1379
+ .label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
1380
+ .comment-composer { position: relative; display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #e2e8f0; }
1381
+ .comment-input-box { position: relative; display: flex; flex-direction: column; min-height: 166px; border: 1px solid #dbe3ef; border-radius: 18px; background: #ffffff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
1382
+ .comment-input-box:focus-within { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(59,130,246,.12), 0 8px 22px rgba(15,23,42,.06); }
1383
+ .comment-editor { width: 100%; min-height: 132px; max-height: 30vh; overflow: auto; padding: 13px 14px; border: 1px solid #dbe3ef; border-radius: 14px; background: #ffffff; color: #111827; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; box-shadow: none; }
1384
+ .comment-editor:focus { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
1385
+ .comment-input-box .comment-editor { min-height: 116px; padding: 14px 56px 8px 14px; border: 0; border-radius: 18px 18px 0 0; background: transparent; box-shadow: none; }
1386
+ .comment-input-box .comment-editor:focus { outline: none; box-shadow: none; }
1387
+ .comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
1388
+ .ref-chip { display: inline-flex; align-items: center; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; margin: 0 2px; padding: 1px 7px; border-radius: 999px; background: var(--ref-bg, #eff6ff); color: var(--ref-text, #1d4ed8); border: 1px solid var(--ref-border, #bfdbfe); font-weight: 800; white-space: nowrap; }
1389
+ .activity-panel { display: flex; flex: 1 1 auto; flex-direction: column; gap: 8px; min-height: 0; padding-top: 2px; }
1390
+ .comment-thread { display: flex; flex: 1 1 auto; flex-direction: column; gap: 9px; min-height: 160px; overflow-y: auto; overflow-x: hidden; padding: 1px 2px 1px 1px; }
1391
+ .comment-thread:empty::before { content: "No activity yet. Leave a comment to start."; display: block; padding: 14px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.45; background: #f8fafc; box-shadow: none; }
1392
+ .comment-bubble { position: relative; display: flex; flex: 0 0 132px; flex-direction: column; min-height: 132px; max-height: 132px; overflow: hidden; border: 1px solid #e2e8f0; border-radius: 15px; padding: 11px 12px 10px 14px; background: #ffffff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 1px 2px rgba(15,23,42,.04), inset 3px 0 0 var(--comment-accent, #e2e8f0); transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; cursor: pointer; }
1393
+ .comment-bubble:hover { transform: translateY(-1px); border-color: #cbd5e1; box-shadow: 0 8px 20px rgba(15,23,42,.07), inset 3px 0 0 var(--comment-accent, #e2e8f0); }
1394
+ .comment-bubble.active { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(59,130,246,.12), 0 8px 20px rgba(15,23,42,.08), inset 3px 0 0 #2563eb; }
1395
+ .comment-bubble.sending { --comment-accent: #3b82f6; border-color: #bfdbfe; background: #f8fbff; }
1396
+ .comment-bubble.open { --comment-accent: #cbd5e1; border-color: #e2e8f0; background: #ffffff; }
1397
+ .comment-bubble.queued { --comment-accent: #f59e0b; border-color: #fde68a; background: #fffdf4; }
1398
+ .comment-bubble.applying { --comment-accent: #2563eb; border-color: #bfdbfe; background: #f8fbff; box-shadow: 0 8px 22px rgba(37,99,235,.1), inset 3px 0 0 var(--comment-accent); }
1399
+ .comment-bubble.applied { --comment-accent: #16a34a; border-color: #bbf7d0; background: #f8fefb; }
1400
+ .comment-bubble.updated { --comment-accent: #15803d; border-color: #bbf7d0; background: #f7fef9; }
1401
+ .comment-bubble.stale { --comment-accent: #d97706; border-color: #fed7aa; background: #fffaf0; }
1402
+ .comment-bubble.failed { --comment-accent: #dc2626; border-color: #fecaca; background: #fffafa; }
1403
+ .comment-bubble-text { flex: 1 1 auto; min-height: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1404
+ .comment-bubble-state { margin-top: 8px; align-self: flex-start; padding: 2px 7px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 800; }
1405
+ .comment-bubble-meta { margin-bottom: 6px; color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
1406
+ .comment-actions { position: absolute; right: 9px; bottom: 9px; display: flex; gap: 6px; }
1407
+ .comment-action-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; min-width: 30px; height: 30px; min-height: 30px; padding: 0; border-radius: 999px; border-color: #e2e8f0; background: rgba(255,255,255,.92); color: #475569; box-shadow: 0 1px 2px rgba(15,23,42,.06); }
1408
+ .comment-action-button:hover:not(:disabled) { background: #f1f5f9; color: #111827; transform: translateY(-1px); }
1409
+ .comment-action-button.danger { color: #dc2626; }
1410
+ .comment-action-button.stop { color: #b45309; }
1411
+ .comment-action-icon { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
1412
+ .comment-progress { margin-top: 7px; display: flex; flex-direction: column; gap: 4px; color: #475569; font-size: 12px; }
1413
+ .comment-progress-line { display: flex; gap: 8px; align-items: flex-start; padding: 7px 8px; border: 1px solid #dbeafe; border-radius: 10px; background: #eff6ff; }
1414
+ .comment-progress-line::before { content: ""; width: 7px; height: 7px; margin-top: 5px; border-radius: 999px; background: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,.12); flex: 0 0 auto; animation: progress-pulse 1.2s ease-in-out infinite; }
1415
+ .comment-raw { margin-top: 8px; color: #b91c1c; font-size: 12px; }
1160
1416
  .comment-raw summary { cursor: pointer; font-weight: 800; }
1161
- .comment-raw pre { margin: 6px 0 0; max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: rgba(255,255,255,.55); border: 1px solid rgba(143,70,56,.22); border-radius: 8px; padding: 8px; }
1162
- .codex-log { margin-top: 8px; color: #4b5563; font-size: 12px; }
1417
+ .comment-raw pre { margin: 6px 0 0; max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: #f8fafc; border: 1px solid #fecaca; border-radius: 8px; padding: 8px; }
1418
+ .codex-log { margin-top: 8px; color: #475569; font-size: 12px; }
1163
1419
  .codex-log summary { cursor: pointer; font-weight: 900; }
1164
- .codex-log-list { margin-top: 7px; display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow: auto; }
1165
- .codex-log-entry { padding: 7px 8px; border: 1px solid rgba(148,163,184,.34); border-radius: 8px; background: rgba(255,255,255,.58); }
1166
- .codex-log-meta { display: flex; justify-content: space-between; gap: 8px; color: #6b7280; font-size: 11px; font-weight: 800; text-transform: uppercase; }
1167
- .codex-log-message { margin-top: 4px; color: #374151; white-space: pre-wrap; overflow-wrap: anywhere; }
1168
- .codex-log-detail { margin: 5px 0 0; max-height: 120px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #111827; background: rgba(17,24,39,.05); border-radius: 6px; padding: 6px; }
1169
- .comment-bubble.updated .comment-bubble-state { color: #556b3f; }
1170
- .comment-bubble.stale .comment-bubble-state { color: #8a6231; }
1171
- .comment-bubble.failed .comment-bubble-state { color: #8f4638; }
1420
+ .codex-log-list { margin-top: 7px; display: flex; flex-direction: column; gap: 0; max-height: 240px; overflow: auto; }
1421
+ .codex-log-entry { position: relative; padding: 9px 10px 9px 18px; border: 0; border-left: 1px solid #dbe3ef; border-radius: 0; background: transparent; }
1422
+ .codex-log-entry::before { content: ""; position: absolute; left: -4px; top: 15px; width: 7px; height: 7px; border-radius: 999px; background: #94a3b8; box-shadow: 0 0 0 3px #ffffff; }
1423
+ .codex-log-meta { display: flex; justify-content: space-between; gap: 8px; color: #94a3b8; font-size: 11px; font-weight: 750; text-transform: uppercase; }
1424
+ .codex-log-message { margin-top: 4px; color: #334155; white-space: pre-wrap; overflow-wrap: anywhere; }
1425
+ .codex-log-detail { margin: 6px 0 0; max-height: 120px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #e2e8f0; background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1426
+ .codex-log-modal { position: fixed; inset: 0; z-index: 80; display: none; align-items: center; justify-content: center; padding: 24px; }
1427
+ .codex-log-modal.open { display: flex; }
1428
+ .codex-log-backdrop { position: absolute; inset: 0; background: rgba(15,23,42,.42); }
1429
+ .codex-log-dialog { position: relative; z-index: 1; display: flex; flex-direction: column; width: min(860px, calc(100vw - 48px)); max-height: min(720px, calc(100vh - 48px)); overflow: hidden; border: 1px solid #e2e8f0; border-radius: 16px; background: #ffffff; box-shadow: 0 28px 70px rgba(15,23,42,.28); }
1430
+ .codex-log-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
1431
+ .codex-log-title { margin: 0; color: #111827; font-size: 15px; line-height: 1.25; font-weight: 900; }
1432
+ .codex-log-close { display: inline-flex; align-items: center; justify-content: center; width: 32px; min-width: 32px; height: 32px; padding: 0; border-radius: 999px; box-shadow: none; }
1433
+ .codex-log-modal .codex-log-list { margin: 0; padding: 14px; max-height: none; overflow: auto; }
1434
+ .codex-log-modal .codex-log-entry { background: transparent; }
1435
+ .codex-log-modal .codex-log-detail { max-height: 260px; }
1436
+ .comment-bubble.updated .comment-bubble-state { background: #dcfce7; color: #166534; }
1437
+ .comment-bubble.applied .comment-bubble-state { background: #dcfce7; color: #166534; }
1438
+ .comment-bubble.applying .comment-bubble-state { background: #dbeafe; color: #1d4ed8; }
1439
+ .comment-bubble.queued .comment-bubble-state { background: #fef3c7; color: #92400e; }
1440
+ .comment-bubble.stale .comment-bubble-state { background: #ffedd5; color: #9a3412; }
1441
+ .comment-bubble.failed .comment-bubble-state { background: #fee2e2; color: #991b1b; }
1172
1442
  .inspect-actions { display: flex; flex-direction: column; gap: 8px; }
1173
1443
  .inspect-options { display: flex; flex-direction: column; gap: 5px; }
1174
- .inspect-options label { color: #756f66; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
1175
- .inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #d8d2c6; border-radius: 12px; background: #fffdf8; color: #111827; font-weight: 700; }
1444
+ .inspect-options label { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
1445
+ .inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #dbe3ef; border-radius: 12px; background: #ffffff; color: #111827; font-weight: 700; }
1176
1446
  .inspect-cards { display: flex; flex-direction: column; gap: 12px; }
1177
- .inspect-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
1447
+ .inspect-card { border: 1px solid #e2e8f0; border-radius: 16px; background: #ffffff; padding: 13px; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
1178
1448
  .inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
1179
1449
  .inspect-card h2 { margin: 0; font-size: 13px; color: #0f172a; }
1180
- .badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e8e1d4; color: #5f594f; }
1181
- .badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #e7ecdc; color: #4d6138; }
1182
- .badge.weak, .badge.missing { background: #f1e5c8; color: #765326; }
1183
- .badge.unsupported { background: #f0d9d1; color: #7f3d31; }
1184
- .inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #5f594f; font-size: 12px; line-height: 1.5; }
1185
- .inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f7f3ea; color: #3f3a33; font-size: 12px; line-height: 1.45; }
1450
+ .badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #f1f5f9; color: #475569; }
1451
+ .badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #dcfce7; color: #166534; }
1452
+ .badge.weak, .badge.missing { background: #fef3c7; color: #92400e; }
1453
+ .badge.unsupported { background: #fee2e2; color: #991b1b; }
1454
+ .inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #475569; font-size: 12px; line-height: 1.5; }
1455
+ .inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f8fafc; color: #334155; font-size: 12px; line-height: 1.45; }
1186
1456
  .inspect-warning, .inspect-stale { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
1187
1457
  .loading-row { display: inline-flex; align-items: center; gap: 8px; }
1188
- .spinner { width: 16px; height: 16px; border: 2px solid rgba(169,121,63,.22); border-top-color: currentColor; border-radius: 999px; animation: spin .8s linear infinite; }
1458
+ .spinner { width: 16px; height: 16px; border: 2px solid rgba(59,130,246,.22); border-top-color: currentColor; border-radius: 999px; animation: spin .8s linear infinite; }
1189
1459
  button .spinner { width: 15px; height: 15px; border-color: rgba(255,255,255,.36); border-top-color: #fff; }
1190
- .skeleton-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
1191
- .skeleton-line { height: 10px; margin: 8px 0; border-radius: 999px; background: linear-gradient(90deg, #ded5c6 0%, #fbfaf7 48%, #ded5c6 100%); background-size: 200% 100%; animation: shimmer 1.2s ease-in-out infinite; }
1460
+ .skeleton-card { border: 1px solid #e2e8f0; border-radius: 16px; background: #ffffff; padding: 13px; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
1461
+ .skeleton-line { height: 10px; margin: 8px 0; border-radius: 999px; background: linear-gradient(90deg, #e2e8f0 0%, #f8fafc 48%, #e2e8f0 100%); background-size: 200% 100%; animation: shimmer 1.2s ease-in-out infinite; }
1192
1462
  .skeleton-line.short { width: 42%; }
1193
1463
  .skeleton-line.medium { width: 68%; }
1194
1464
  .skeleton-line.long { width: 92%; }
@@ -1196,48 +1466,57 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1196
1466
  .asset-card.is-saving .asset-save { z-index: 1; }
1197
1467
  .asset-card.is-saved-candidate .asset-thumb { opacity: .72; }
1198
1468
  @keyframes spin { to { transform: rotate(360deg); } }
1469
+ @keyframes progress-pulse { 0%, 100% { transform: scale(.85); opacity: .68; } 50% { transform: scale(1.16); opacity: 1; } }
1199
1470
  @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1471
+ @media (prefers-reduced-motion: reduce) { .comment-bubble, .comment-progress-line::before, .skeleton-line, .spinner { animation: none !important; transition: none !important; } .comment-bubble:hover { transform: none; } }
1200
1472
  .asset-search { display: grid; grid-template-columns: minmax(0, 1fr) 118px; gap: 8px; }
1201
- .asset-search input, .asset-search select { min-width: 0; padding: 10px 11px; border: 1px solid #d8d2c6; border-radius: 12px; background: #fffdf8; color: #111827; font: inherit; font-size: 12px; font-weight: 700; outline: none; }
1202
- .asset-search input:focus, .asset-search select:focus { border-color: #a9793f; box-shadow: 0 0 0 3px rgba(169,121,63,.14); }
1473
+ .asset-search input, .asset-search select { min-width: 0; padding: 10px 11px; border: 1px solid #dbe3ef; border-radius: 12px; background: #ffffff; color: #111827; font: inherit; font-size: 12px; font-weight: 700; outline: none; }
1474
+ .asset-search input:focus, .asset-search select:focus { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
1203
1475
  .asset-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
1204
1476
  .asset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
1205
- .asset-card { position: relative; min-width: 0; aspect-ratio: 1 / 1; overflow: hidden; border: 1px solid #d8d2c6; border-radius: 14px; background: #fffdf8; box-shadow: 0 8px 18px rgba(31,41,51,.05); }
1477
+ .asset-card { position: relative; min-width: 0; aspect-ratio: 1 / 1; overflow: hidden; border: 1px solid #e2e8f0; border-radius: 14px; background: #ffffff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
1206
1478
  .asset-card.saved { width: 64px; height: 64px; aspect-ratio: auto; border-radius: 12px; }
1207
1479
  .asset-card[draggable="true"] { cursor: grab; }
1208
1480
  .asset-card[draggable="true"]:active { cursor: grabbing; }
1209
- .asset-thumb { width: 100%; height: 100%; display: block; background: #eee8dc; object-fit: contain; }
1210
- .asset-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
1211
- .asset-search-toggle { width: auto; min-width: 32px; height: 28px; padding: 0 9px; border-radius: 999px; box-shadow: 0 6px 14px rgba(31,41,51,.08); font-size: 16px; line-height: 1; }
1212
- .asset-search-view { position: absolute; inset: 0; z-index: 12; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: linear-gradient(180deg, #fbfaf7 0%, #f2eee6 100%); overflow: auto; transform: translateX(105%); transition: transform .2s ease; box-shadow: -18px 0 44px rgba(31,41,51,.16); }
1481
+ .asset-thumb { width: 100%; height: 100%; display: block; background: #f1f5f9; object-fit: contain; }
1482
+ .composer-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 50px; padding: 8px 10px 10px 10px; }
1483
+ .asset-menu-wrap { position: static; min-width: 0; }
1484
+ .asset-menu-toggle { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; min-width: 42px; padding: 0; border-radius: 999px; background: transparent; box-shadow: none; color: #64748b; }
1485
+ .asset-menu-toggle:hover:not(:disabled) { background: #f1f5f9; color: #111827; }
1486
+ .local-assets-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 10px); z-index: 11; display: none; width: 100%; padding: 10px; border: 1px solid #e2e8f0; border-radius: 16px; background: #ffffff; box-shadow: 0 18px 44px rgba(15,23,42,.12); }
1487
+ .local-assets-menu.open { display: block; }
1488
+ .local-assets-menu-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
1489
+ .asset-search-toggle { display: inline-flex; align-items: center; justify-content: center; width: 32px; min-width: 32px; height: 32px; padding: 0; border-radius: 999px; box-shadow: 0 1px 2px rgba(15,23,42,.06); }
1490
+ .asset-search-view { position: absolute; inset: 0; z-index: 12; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #ffffff; overflow: auto; transform: translateX(105%); transition: transform .2s ease; box-shadow: -18px 0 44px rgba(15,23,42,.12); }
1213
1491
  .asset-search-view.open { transform: translateX(0); }
1214
1492
  .asset-search-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
1215
1493
  .asset-search-title { display: flex; flex-direction: column; gap: 2px; }
1216
- .asset-search-title h2 { margin: 0; color: #0f172a; font-size: 16px; letter-spacing: -.01em; }
1217
- .asset-search-title span { color: #756f66; font-size: 12px; line-height: 1.35; }
1218
- .asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #ebe4d8; color: #111827; box-shadow: none; }
1219
- .asset-save { position: absolute; left: 7px; right: 7px; bottom: 7px; width: auto; padding: 7px 8px; border-radius: 10px; font-size: 11px; background: rgba(17,24,39,.9); color: #fbfaf7; box-shadow: 0 8px 16px rgba(31,41,51,.2); opacity: .96; }
1220
- .asset-save.saved { background: rgba(77,97,56,.94); color: #fbfaf7; cursor: default; }
1221
- .asset-empty { grid-column: 1 / -1; margin: 0; color: #756f66; font-size: 12px; line-height: 1.45; }
1222
- .edit-assets { padding: 10px; border: 1px solid #d8d2c6; border-radius: 16px; background: #f7f3ea; }
1223
- .edit-assets .panel { gap: 8px; }
1224
- .edit-assets .asset-grid { grid-template-columns: repeat(auto-fill, 64px); align-items: start; max-height: 176px; overflow: auto; }
1225
- .edit-assets .asset-thumb { width: 64px; height: 64px; }
1226
- .drop-active .hitbox { background: rgba(169,121,63,.1); outline: 2px dashed rgba(169,121,63,.48); outline-offset: -10px; }
1227
- button { width: 100%; padding: 12px 14px; border: 1px solid #d8d2c6; border-radius: 12px; background: #ebe4d8; color: #111827; font-weight: 800; cursor: pointer; box-shadow: 0 8px 16px rgba(31,41,51,.08); }
1228
- button:hover:not(:disabled) { background: #e3dacb; }
1494
+ .asset-search-title h2 { margin: 0; color: #0f172a; font-size: 16px; letter-spacing: 0; }
1495
+ .asset-search-title span { color: #64748b; font-size: 12px; line-height: 1.35; }
1496
+ .asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #f1f5f9; color: #111827; box-shadow: none; }
1497
+ .asset-save { position: absolute; left: 7px; right: 7px; bottom: 7px; width: auto; padding: 7px 8px; border-radius: 10px; font-size: 11px; background: rgba(15,23,42,.9); color: #ffffff; box-shadow: 0 8px 16px rgba(15,23,42,.18); opacity: .96; }
1498
+ .asset-save.saved { background: rgba(22,101,52,.94); color: #ffffff; cursor: default; }
1499
+ .asset-empty { grid-column: 1 / -1; margin: 0; color: #64748b; font-size: 12px; line-height: 1.45; }
1500
+ .local-assets-menu .asset-grid { grid-template-columns: repeat(auto-fill, 64px); align-items: start; max-height: 220px; overflow: auto; }
1501
+ .local-assets-menu .asset-thumb { width: 64px; height: 64px; }
1502
+ .drop-active .hitbox { background: rgba(37,99,235,.08); outline: 2px dashed rgba(37,99,235,.38); outline-offset: -10px; }
1503
+ button { width: 100%; padding: 12px 14px; border: 1px solid #dbe3ef; border-radius: 12px; background: #f8fafc; color: #111827; font-weight: 800; cursor: pointer; box-shadow: none; }
1504
+ button:hover:not(:disabled) { background: #f1f5f9; }
1229
1505
  button:disabled { cursor: not-allowed; opacity: .5; }
1230
- .primary-action { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; border-radius: 14px; border-color: #111827; background: linear-gradient(135deg, #111827 0%, #1f2937 100%); color: #fbfaf7; font-size: 14px; letter-spacing: .01em; box-shadow: 0 12px 24px rgba(31,41,51,.24); transition: transform .14s ease, box-shadow .14s ease, filter .14s ease; }
1231
- .primary-action:hover:not(:disabled) { background: linear-gradient(135deg, #0f1720 0%, #283241 100%); transform: translateY(-1px); box-shadow: 0 16px 30px rgba(31,41,51,.28); filter: saturate(1.02); }
1232
- .primary-action:active:not(:disabled) { transform: translateY(0); box-shadow: 0 9px 20px rgba(31,41,51,.22); }
1233
- .send-icon { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
1234
- .status { min-height: 20px; color: #5f594f; font-size: 13px; line-height: 1.45; }
1235
- @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } .deck-nav { bottom: 10px; } }
1506
+ .primary-action { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; border-radius: 14px; border-color: #1d4ed8; background: #2563eb; color: #ffffff; font-size: 14px; letter-spacing: 0; box-shadow: 0 8px 18px rgba(37,99,235,.22); transition: transform .14s ease, box-shadow .14s ease, background .14s ease; }
1507
+ .primary-action:hover:not(:disabled) { background: #1d4ed8; transform: translateY(-1px); box-shadow: 0 12px 24px rgba(37,99,235,.26); }
1508
+ .primary-action:active:not(:disabled) { transform: translateY(0); box-shadow: 0 6px 14px rgba(37,99,235,.22); }
1509
+ .composer-send { position: absolute; right: 10px; bottom: 10px; width: 42px; min-width: 42px; height: 42px; min-height: 42px; padding: 0; border-radius: 999px; }
1510
+ .composer-send:disabled { opacity: .44; }
1511
+ .composer-send .spinner + span { display: none; }
1512
+ .composer-icon { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
1513
+ .status { min-height: 20px; color: #64748b; font-size: 13px; line-height: 1.45; }
1514
+ @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; min-width: 0; border-left: 0; border-top: 1px solid #e2e8f0; } .deck-nav { bottom: 10px; } .asset-search { grid-template-columns: 1fr; } }
1236
1515
  </style>
1237
1516
  </head>
1238
1517
  <body class="${bodyClass}">
1239
1518
  <main class="app">
1240
- <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><div id="visualMoveHandle" class="visual-move-handle" aria-hidden="true"></div><div id="visualResizeHandle" class="visual-resize-handle" aria-hidden="true"></div><div id="visualEditToolbar" class="visual-edit-toolbar" aria-live="polite"><span id="visualEditCount">No unsaved visual changes</span><button id="visualUndo" type="button">Undo</button><button id="visualReset" type="button">Reset</button><button id="visualSave" class="save-visual" type="button">Save Changes</button></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
1519
+ <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="commentHighlightLayer" class="comment-highlight-layer" aria-hidden="true"></div><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><div id="visualMoveHandle" class="visual-move-handle" aria-hidden="true"></div><div id="visualResizeHandle" class="visual-resize-handle" aria-hidden="true"></div><div id="visualEditToolbar" class="visual-edit-toolbar" aria-live="polite"><span id="visualEditCount">No unsaved visual changes</span><button id="visualUndo" type="button">Undo</button><button id="visualReset" type="button">Reset</button><button id="visualSave" class="save-visual" type="button">Save Changes</button></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
1241
1520
  <div id="resizeHandle" class="resize-handle" role="separator" aria-label="Resize editor panel" aria-orientation="vertical" title="Drag to resize editor. Double-click to reset."></div>
1242
1521
  <aside>
1243
1522
  <div>
@@ -1249,18 +1528,23 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1249
1528
  <button id="inspectTab" class="tab" type="button" role="tab">Insight</button>
1250
1529
  </div>
1251
1530
  <div id="editPanel" class="tab-panel">
1252
- <div class="panel">
1531
+ <div class="activity-panel"><div class="label">${activityLabel}</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
1532
+ <div class="comment-composer">
1253
1533
  <div class="label">Describe the change</div>
1254
- <div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Cmd/Ctrl-click slide elements to add @refs, then describe the exact edit."></div>
1255
- </div>
1256
- <div class="edit-assets" aria-label="Comment assets">
1257
- <div class="panel">
1258
- <div class="asset-tools"><div class="label">Local Assets</div><button id="assetSearchToggle" class="asset-search-toggle" type="button" aria-expanded="false" aria-controls="assetSearchView" title="Search assets">+</button></div>
1259
- <div id="editSavedAssets" class="asset-grid"><p class="asset-empty">No local assets yet. Click + to search assets.</p></div>
1534
+ <div class="comment-input-box">
1535
+ <div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Cmd/Ctrl-click slide elements to add @refs, then describe the exact edit."></div>
1536
+ <div class="composer-actions">
1537
+ <div class="asset-menu-wrap">
1538
+ <button id="localAssetToggle" class="asset-menu-toggle" type="button" aria-label="Local Assets" title="Local Assets" aria-expanded="false" aria-controls="localAssetMenu">${lucideIcon("image")}</button>
1539
+ <div id="localAssetMenu" class="local-assets-menu" aria-hidden="true">
1540
+ <div class="local-assets-menu-head"><div class="label">Local Assets</div><button id="assetSearchToggle" class="asset-search-toggle" type="button" aria-label="Search assets" aria-expanded="false" aria-controls="assetSearchView" title="Search assets">${lucideIcon("plus")}</button></div>
1541
+ <div id="editSavedAssets" class="asset-grid"><p class="asset-empty">No local assets yet. Click + to search assets.</p></div>
1542
+ </div>
1543
+ </div>
1544
+ </div>
1545
+ <button id="send" class="primary-action composer-send" type="button" aria-label="Leave Comment" title="Leave Comment" disabled>${sendButtonHtml}</button>
1260
1546
  </div>
1261
1547
  </div>
1262
- <button id="send" class="primary-action" disabled><svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94L14.7 6.3z"/></svg><span>Apply Fix</span></button>
1263
- <div class="activity-panel"><div class="label">${activityLabel}</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
1264
1548
  </div>
1265
1549
  <div id="inspectPanel" class="tab-panel">
1266
1550
  <div class="panel">
@@ -1288,11 +1572,22 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1288
1572
  <div id="status" class="status"></div>
1289
1573
  </aside>
1290
1574
  </main>
1575
+ <div id="codexLogModal" class="codex-log-modal" aria-hidden="true">
1576
+ <div id="codexLogBackdrop" class="codex-log-backdrop"></div>
1577
+ <section class="codex-log-dialog" role="dialog" aria-modal="true" aria-labelledby="codexLogTitle">
1578
+ <div class="codex-log-head">
1579
+ <h2 id="codexLogTitle" class="codex-log-title">Codex execution log</h2>
1580
+ <button id="codexLogClose" class="codex-log-close" type="button" aria-label="Close execution log">×</button>
1581
+ </div>
1582
+ <div id="codexLogBody" class="codex-log-list"></div>
1583
+ </section>
1584
+ </div>
1291
1585
  <script>
1292
1586
  (() => {
1293
1587
  const token = ${encodedToken};
1294
1588
  const defaultMode = ${encodedDefaultMode};
1295
1589
  const reviewSurface = ${encodedSurface};
1590
+ const sendButtonHtml = ${encodedSendButtonHtml};
1296
1591
  const codexReview = reviewSurface === 'codex';
1297
1592
  const COMMENT_STALE_MS = 60000;
1298
1593
  const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
@@ -1314,9 +1609,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1314
1609
  const state = {
1315
1610
  references: [],
1316
1611
  pendingComments: [],
1612
+ queuedCommentPolls: new Set(),
1317
1613
  hoverEl: null,
1318
1614
  hoverOutline: null,
1319
1615
  referenceOutlines: [],
1616
+ activeCommentId: '',
1617
+ activeCommentElements: [],
1618
+ commentHighlightOutlines: [],
1320
1619
  nextReferenceId: 1,
1321
1620
  nextCommentId: 1,
1322
1621
  initializedDoc: null,
@@ -1355,6 +1654,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1355
1654
  };
1356
1655
  const els = {
1357
1656
  frame: null,
1657
+ commentHighlightLayer: null,
1358
1658
  hitbox: null,
1359
1659
  resizeHandle: null,
1360
1660
  visualMoveHandle: null,
@@ -1381,6 +1681,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1381
1681
  inspectLanguage: null,
1382
1682
  inspectCards: null,
1383
1683
  inspectStale: null,
1684
+ localAssetToggle: null,
1685
+ localAssetMenu: null,
1384
1686
  assetSearchToggle: null,
1385
1687
  assetSearchBack: null,
1386
1688
  assetSearchView: null,
@@ -1390,6 +1692,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1390
1692
  assetShuffleButton: null,
1391
1693
  assetResults: null,
1392
1694
  editSavedAssets: null,
1695
+ codexLogModal: null,
1696
+ codexLogBackdrop: null,
1697
+ codexLogClose: null,
1698
+ codexLogTitle: null,
1699
+ codexLogBody: null,
1393
1700
  status: null,
1394
1701
  };
1395
1702
 
@@ -1405,6 +1712,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1405
1712
  function boot() {
1406
1713
  try {
1407
1714
  els.frame = document.getElementById('deck');
1715
+ els.commentHighlightLayer = document.getElementById('commentHighlightLayer');
1408
1716
  els.hitbox = document.getElementById('hitbox');
1409
1717
  els.resizeHandle = document.getElementById('resizeHandle');
1410
1718
  els.visualMoveHandle = document.getElementById('visualMoveHandle');
@@ -1430,6 +1738,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1430
1738
  els.inspectButton = document.getElementById('inspectButton');
1431
1739
  els.inspectCards = document.getElementById('inspectCards');
1432
1740
  els.inspectStale = document.getElementById('inspectStale');
1741
+ els.localAssetToggle = document.getElementById('localAssetToggle');
1742
+ els.localAssetMenu = document.getElementById('localAssetMenu');
1433
1743
  els.assetSearchToggle = document.getElementById('assetSearchToggle');
1434
1744
  els.assetSearchBack = document.getElementById('assetSearchBack');
1435
1745
  els.assetSearchView = document.getElementById('assetSearchView');
@@ -1439,19 +1749,26 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1439
1749
  els.assetShuffleButton = document.getElementById('assetShuffleButton');
1440
1750
  els.assetResults = document.getElementById('assetResults');
1441
1751
  els.editSavedAssets = document.getElementById('editSavedAssets');
1752
+ els.codexLogModal = document.getElementById('codexLogModal');
1753
+ els.codexLogBackdrop = document.getElementById('codexLogBackdrop');
1754
+ els.codexLogClose = document.getElementById('codexLogClose');
1755
+ els.codexLogTitle = document.getElementById('codexLogTitle');
1756
+ els.codexLogBody = document.getElementById('codexLogBody');
1442
1757
  els.status = document.getElementById('status');
1443
1758
 
1444
1759
  els.inspectLanguage = document.getElementById('inspectLanguage');
1445
1760
 
1446
- if (!els.frame || !els.hitbox || !els.resizeHandle || !els.visualMoveHandle || !els.visualResizeHandle || !els.visualEditToolbar || !els.visualEditCount || !els.visualUndo || !els.visualReset || !els.visualSave || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectComment || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.assetSearchToggle || !els.assetSearchBack || !els.assetSearchView || !els.assetQuery || !els.assetPurpose || !els.assetSearchButton || !els.assetShuffleButton || !els.assetResults || !els.editSavedAssets || !els.status) {
1761
+ if (!els.frame || !els.commentHighlightLayer || !els.hitbox || !els.resizeHandle || !els.visualMoveHandle || !els.visualResizeHandle || !els.visualEditToolbar || !els.visualEditCount || !els.visualUndo || !els.visualReset || !els.visualSave || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectComment || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.localAssetToggle || !els.localAssetMenu || !els.assetSearchToggle || !els.assetSearchBack || !els.assetSearchView || !els.assetQuery || !els.assetPurpose || !els.assetSearchButton || !els.assetShuffleButton || !els.assetResults || !els.editSavedAssets || !els.codexLogModal || !els.codexLogBackdrop || !els.codexLogClose || !els.codexLogTitle || !els.codexLogBody || !els.status) {
1447
1762
  throw new Error('Editor boot failed: required DOM nodes are missing.');
1448
1763
  }
1449
1764
 
1450
1765
  restoreEditorWidth();
1451
1766
  bindEvents();
1452
1767
  setMode(state.mode);
1768
+ updateVisualToolbar();
1453
1769
  setStatus('Review ready. Ctrl/Cmd + click deck elements to reference them.');
1454
1770
  initFrame();
1771
+ loadReviewComments();
1455
1772
  loadSavedAssets();
1456
1773
  startDeckVersionPolling();
1457
1774
  } catch (error) {
@@ -1463,8 +1780,14 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1463
1780
  if (state.bound) return;
1464
1781
  state.bound = true;
1465
1782
  els.frame.addEventListener('load', initFrame);
1783
+ window.addEventListener('resize', renderActiveCommentHighlights);
1466
1784
  document.addEventListener('keydown', (event) => {
1467
1785
  if (event.key === 'Escape') {
1786
+ if (els.codexLogModal.classList.contains('open')) {
1787
+ closeCodexLogModal();
1788
+ return;
1789
+ }
1790
+ closeLocalAssetMenu();
1468
1791
  clearHover();
1469
1792
  return;
1470
1793
  }
@@ -1477,6 +1800,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1477
1800
  prevDeckSlide();
1478
1801
  }
1479
1802
  });
1803
+ document.addEventListener('click', (event) => {
1804
+ if (!els.localAssetMenu.classList.contains('open')) return;
1805
+ const target = event.target;
1806
+ if (target instanceof Node && (els.localAssetMenu.contains(target) || els.localAssetToggle.contains(target))) return;
1807
+ closeLocalAssetMenu();
1808
+ });
1480
1809
  els.comment.addEventListener('input', () => {
1481
1810
  saveCommentRange();
1482
1811
  syncReferencesFromComment(false, els.comment);
@@ -1507,6 +1836,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1507
1836
  win.scrollBy({ top: event.deltaY, left: event.deltaX, behavior: 'auto' });
1508
1837
  renderHoverOutline(state.hoverEl);
1509
1838
  renderReferenceOutlines();
1839
+ renderActiveCommentHighlights();
1510
1840
  }, { passive: false });
1511
1841
  els.resizeHandle.addEventListener('pointerdown', startEditorResize);
1512
1842
  els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
@@ -1517,10 +1847,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1517
1847
  els.deckNext.addEventListener('click', nextDeckSlide);
1518
1848
  els.send.addEventListener('click', sendComment);
1519
1849
  els.inspectButton.addEventListener('click', inspectCurrentSelection);
1850
+ els.localAssetToggle.addEventListener('click', toggleLocalAssetMenu);
1520
1851
  els.assetSearchToggle.addEventListener('click', toggleAssetSearchPanel);
1521
1852
  els.assetSearchBack.addEventListener('click', closeAssetSearchPanel);
1522
1853
  els.assetSearchButton.addEventListener('click', () => searchAssets(false));
1523
1854
  els.assetShuffleButton.addEventListener('click', () => searchAssets(true));
1855
+ els.codexLogBackdrop.addEventListener('click', closeCodexLogModal);
1856
+ els.codexLogClose.addEventListener('click', closeCodexLogModal);
1524
1857
  els.assetQuery.addEventListener('keydown', (event) => {
1525
1858
  if (event.key === 'Enter') {
1526
1859
  event.preventDefault();
@@ -1552,6 +1885,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1552
1885
  return state.mode === 'inspect' ? els.inspectComment : els.comment;
1553
1886
  }
1554
1887
 
1888
+ function toggleLocalAssetMenu() {
1889
+ setLocalAssetMenuOpen(!els.localAssetMenu.classList.contains('open'));
1890
+ }
1891
+
1892
+ function closeLocalAssetMenu() {
1893
+ setLocalAssetMenuOpen(false);
1894
+ }
1895
+
1896
+ function setLocalAssetMenuOpen(open) {
1897
+ els.localAssetMenu.classList.toggle('open', open);
1898
+ els.localAssetMenu.setAttribute('aria-hidden', open ? 'false' : 'true');
1899
+ els.localAssetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
1900
+ }
1901
+
1555
1902
  function toggleAssetSearchPanel() {
1556
1903
  const open = !els.assetSearchView.classList.contains('open');
1557
1904
  setAssetSearchOpen(open);
@@ -1562,10 +1909,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1562
1909
  }
1563
1910
 
1564
1911
  function setAssetSearchOpen(open) {
1912
+ if (open) closeLocalAssetMenu();
1565
1913
  els.assetSearchView.classList.toggle('open', open);
1566
1914
  els.assetSearchView.setAttribute('aria-hidden', open ? 'false' : 'true');
1567
1915
  els.assetSearchToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
1568
- els.assetSearchToggle.textContent = '+';
1916
+ els.assetSearchToggle.innerHTML = '${lucideIcon("plus")}';
1569
1917
  if (open) els.assetQuery.focus();
1570
1918
  }
1571
1919
 
@@ -1895,7 +2243,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1895
2243
  function updateVisualToolbar() {
1896
2244
  const count = state.visualChanges.length;
1897
2245
  els.visualEditToolbar.classList.toggle('active', count > 0);
1898
- els.visualEditCount.textContent = count + ' unsaved visual change' + (count === 1 ? '' : 's');
2246
+ els.visualEditCount.textContent = count === 0 ? 'No unsaved visual changes' : count + ' unsaved visual change' + (count === 1 ? '' : 's');
1899
2247
  els.visualUndo.disabled = count === 0 || state.savingVisualChanges;
1900
2248
  els.visualReset.disabled = count === 0 || state.savingVisualChanges;
1901
2249
  els.visualSave.disabled = count === 0 || state.savingVisualChanges;
@@ -1955,17 +2303,21 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1955
2303
  state.hoverEl = null;
1956
2304
  state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
1957
2305
  state.assetDropTarget = null;
1958
- state.assetDropOutline = createOutline(doc, '#a9793f', 'rgba(169,121,63,.16)');
2306
+ state.assetDropOutline = createOutline(doc, '#2563eb', 'rgba(37,99,235,.12)');
1959
2307
  state.referenceOutlines = [];
2308
+ state.commentHighlightOutlines.forEach((outline) => outline.remove());
2309
+ state.commentHighlightOutlines = [];
1960
2310
  doc.addEventListener('scroll', () => {
1961
2311
  renderHoverOutline(state.hoverEl);
1962
2312
  renderVisualHandles(state.hoverEl);
1963
2313
  renderReferenceOutlines();
2314
+ renderActiveCommentHighlights();
1964
2315
  }, true);
1965
2316
  const slides = getSlides(doc);
1966
2317
  syncDeckNavigation();
1967
2318
  restoreDeckSlideAfterRefresh();
1968
2319
  updateSendState();
2320
+ renderActiveCommentHighlights();
1969
2321
  if (state.pendingRefreshMessage) {
1970
2322
  state.pendingRefreshMessage = false;
1971
2323
  setStatus('Deck updated. Preview refreshed. Element references were cleared.');
@@ -2050,6 +2402,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2050
2402
  else renderHoverOutline(state.hoverEl);
2051
2403
  renderVisualHandles(state.hoverEl);
2052
2404
  renderReferenceOutlines();
2405
+ renderActiveCommentHighlights();
2053
2406
  } catch (error) {
2054
2407
  reportError(error);
2055
2408
  }
@@ -2130,44 +2483,18 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2130
2483
  const { body, version: nextVersion } = await fetchDeckVersion();
2131
2484
  if (!state.deckVersion) {
2132
2485
  state.deckVersion = nextVersion;
2133
- markCommentsUpdatedForVersion(nextVersion);
2134
- markStaleComments();
2135
2486
  return;
2136
2487
  }
2137
2488
  if (state.deckVersion === nextVersion) {
2138
- markStaleComments();
2139
2489
  return;
2140
2490
  }
2141
2491
  state.deckVersion = nextVersion;
2142
- markCommentsUpdatedForVersion(nextVersion);
2143
2492
  refreshDeckPreview(body.mtimeMs);
2144
2493
  } catch (error) {
2145
2494
  reportError(error);
2146
2495
  }
2147
2496
  }
2148
2497
 
2149
- async function watchDeckVersionAfterComment(commentId) {
2150
- const comment = state.pendingComments.find((item) => item.id === commentId);
2151
- const baseDeckVersion = comment?.baseDeckVersion || state.deckVersion;
2152
- const started = Date.now();
2153
- while (Date.now() - started < 15000) {
2154
- if (pendingCommentStatus(commentId) === 'updated' || pendingCommentStatus(commentId) === 'failed') return;
2155
- await delay(250);
2156
- try {
2157
- const { body, version: nextVersion } = await fetchDeckVersion();
2158
- if (nextVersion && nextVersion !== baseDeckVersion) {
2159
- state.deckVersion = nextVersion;
2160
- markCommentsUpdatedForVersion(nextVersion);
2161
- refreshDeckPreview(body.mtimeMs);
2162
- return;
2163
- }
2164
- } catch (error) {
2165
- reportError(error);
2166
- return;
2167
- }
2168
- }
2169
- }
2170
-
2171
2498
  function refreshDeckPreview(version) {
2172
2499
  state.pendingRefreshMessage = true;
2173
2500
  state.pendingDeckSlideRestore = state.deckSlideIndex;
@@ -2184,6 +2511,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2184
2511
  if (state.assetDropOutline) state.assetDropOutline.style.display = 'none';
2185
2512
  state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
2186
2513
  state.referenceOutlines = [];
2514
+ state.commentHighlightOutlines.forEach((outline) => outline.remove());
2515
+ state.commentHighlightOutlines = [];
2187
2516
  updateSendState();
2188
2517
  els.frame.src = '/deck?token=' + encodeURIComponent(token) + '&v=' + encodeURIComponent(String(version));
2189
2518
  setStatus('Deck changed. Refreshing preview...');
@@ -2249,27 +2578,24 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2249
2578
  if (!text) return;
2250
2579
  const elements = state.references.map((reference) => reference.payload);
2251
2580
  const asset = state.selectedAsset || undefined;
2252
- const commentId = addPendingComment(text, elements, 'sending');
2253
- clearReferences(false);
2254
- state.selectedAsset = null;
2255
- els.comment.textContent = '';
2256
- renderReferenceOutlines();
2257
2581
  state.sendingEdit = true;
2258
2582
  updateSendState();
2259
- setStatus('Sending...');
2583
+ setStatus('Saving comment...');
2260
2584
  try {
2261
- const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
2585
+ const res = await fetch('/api/comments?token=' + encodeURIComponent(token), {
2262
2586
  method: 'POST',
2263
2587
  headers: { 'content-type': 'application/json' },
2264
2588
  body: JSON.stringify({ comment: text, elements, asset }),
2265
2589
  });
2266
2590
  const body = await res.json().catch(() => ({}));
2267
- if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
2268
- updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion, requestId: body.commentRequestId || body.requestId || '' });
2269
- if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
2270
- if (body.commentRequestId || body.requestId) watchCommentProgress(commentId, body.commentRequestId || body.requestId);
2591
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save comment');
2592
+ upsertPersistedComment(body.comment);
2593
+ clearReferences(false);
2594
+ state.selectedAsset = null;
2595
+ els.comment.textContent = '';
2596
+ renderReferenceOutlines();
2597
+ setStatus('Comment saved. Use Apply on the comment card when you want Codex to edit the deck.');
2271
2598
  } catch (error) {
2272
- updatePendingCommentStatus(commentId, 'failed');
2273
2599
  reportError(error);
2274
2600
  } finally {
2275
2601
  state.sendingEdit = false;
@@ -2277,6 +2603,150 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2277
2603
  }
2278
2604
  }
2279
2605
 
2606
+ async function loadReviewComments() {
2607
+ try {
2608
+ const res = await fetch('/api/comments?token=' + encodeURIComponent(token), { cache: 'no-store' });
2609
+ const body = await res.json().catch(() => ({}));
2610
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to load comments');
2611
+ state.pendingComments = Array.isArray(body.comments) ? body.comments.map(commentFromRecord) : [];
2612
+ renderCommentThread();
2613
+ state.pendingComments.forEach((comment) => {
2614
+ if (comment.status === 'queued') pollQueuedComment(comment.id);
2615
+ else if (comment.status === 'applying' && comment.requestId) watchCommentProgress(comment.id, comment.requestId);
2616
+ });
2617
+ } catch (error) {
2618
+ reportError(error);
2619
+ }
2620
+ }
2621
+
2622
+ function upsertPersistedComment(record) {
2623
+ if (!record || !record.id) return;
2624
+ const next = commentFromRecord(record);
2625
+ const index = state.pendingComments.findIndex((item) => item.id === next.id);
2626
+ if (index >= 0) state.pendingComments[index] = { ...state.pendingComments[index], ...next };
2627
+ else state.pendingComments.push(next);
2628
+ if (state.activeCommentId === next.id) state.activeCommentElements = Array.isArray(next.elements) ? next.elements : [];
2629
+ state.pendingComments.sort((a, b) => (a.slideIndex || 0) - (b.slideIndex || 0) || (a.createdAt || 0) - (b.createdAt || 0));
2630
+ renderCommentThread();
2631
+ }
2632
+
2633
+ function commentFromRecord(record) {
2634
+ const createdAt = Date.parse(record.createdAt || '') || Date.now();
2635
+ return {
2636
+ id: record.id,
2637
+ persisted: true,
2638
+ text: record.comment || '',
2639
+ elements: Array.isArray(record.elements) ? record.elements : [],
2640
+ slideIndex: record.slideIndex,
2641
+ status: record.status || 'open',
2642
+ createdAt,
2643
+ baseDeckVersion: record.deckVersion || state.deckVersion,
2644
+ updatedVersion: null,
2645
+ requestId: record.lastApplyRequestId || '',
2646
+ progressEvent: null,
2647
+ eventLog: [],
2648
+ failureRaw: record.lastApplyRaw || '',
2649
+ failureMessage: record.lastApplyError || '',
2650
+ stopped: record.status === 'failed' && record.lastApplyError === 'Stopped by user.',
2651
+ };
2652
+ }
2653
+
2654
+ async function applyPersistedComment(commentId) {
2655
+ const comment = state.pendingComments.find((item) => item.id === commentId);
2656
+ if (!comment || comment.status === 'applying' || comment.status === 'queued') return;
2657
+ updatePendingCommentStatus(commentId, 'applying', { baseDeckVersion: state.deckVersion || comment.baseDeckVersion, progressEvent: null, eventLog: [], failureRaw: '', failureMessage: '' });
2658
+ setStatus(comment.status === 'applied' || comment.status === 'updated' || comment.status === 'stale' ? 'Re-applying saved comment...' : 'Applying saved comment...');
2659
+ try {
2660
+ const res = await fetch('/api/comments/' + encodeURIComponent(commentId) + '/apply?token=' + encodeURIComponent(token), {
2661
+ method: 'POST',
2662
+ headers: { 'content-type': 'application/json' },
2663
+ body: JSON.stringify({}),
2664
+ });
2665
+ const body = await res.json().catch(() => ({}));
2666
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to apply comment');
2667
+ if (body.comment) upsertPersistedComment(body.comment);
2668
+ if (body.status === 'queued') {
2669
+ updatePendingCommentStatus(commentId, 'queued', { requestId: '', progressEvent: null, failureRaw: '', failureMessage: '' });
2670
+ setStatus('Comment queued. It will apply after the current edit finishes.');
2671
+ pollQueuedComment(commentId);
2672
+ return;
2673
+ }
2674
+ updatePendingCommentStatus(commentId, 'applying', { requestId: body.commentRequestId || body.requestId || '', baseDeckVersion: body.deckVersion || state.deckVersion });
2675
+ if (body.commentRequestId || body.requestId) watchCommentProgress(commentId, body.commentRequestId || body.requestId);
2676
+ } catch (error) {
2677
+ updatePendingCommentStatus(commentId, 'failed', { failureMessage: error instanceof Error ? error.message : String(error) });
2678
+ reportError(error);
2679
+ }
2680
+ }
2681
+
2682
+ async function stopPersistedComment(commentId) {
2683
+ const comment = state.pendingComments.find((item) => item.id === commentId);
2684
+ if (!comment || !canStopPersistedComment(comment.status)) return;
2685
+ try {
2686
+ const res = await fetch('/api/comments/' + encodeURIComponent(commentId) + '/stop?token=' + encodeURIComponent(token), { method: 'POST' });
2687
+ const body = await res.json().catch(() => ({}));
2688
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to stop comment');
2689
+ if (body.comment) upsertPersistedComment(body.comment);
2690
+ updatePendingCommentStatus(commentId, 'failed', { stopped: true, requestId: '', progressEvent: null, failureRaw: 'Stopped by user.', failureMessage: 'Stopped by user.' });
2691
+ setStatus('Stopped comment apply.');
2692
+ } catch (error) {
2693
+ reportError(error);
2694
+ }
2695
+ }
2696
+
2697
+ async function deletePersistedComment(commentId) {
2698
+ const comment = state.pendingComments.find((item) => item.id === commentId);
2699
+ if (!comment || !canDeletePersistedComment(comment.status)) return;
2700
+ try {
2701
+ const res = await fetch('/api/comments/' + encodeURIComponent(commentId) + '?token=' + encodeURIComponent(token), { method: 'DELETE' });
2702
+ const body = await res.json().catch(() => ({}));
2703
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to delete comment');
2704
+ state.pendingComments = state.pendingComments.filter((item) => item.id !== commentId);
2705
+ if (state.activeCommentId === commentId) {
2706
+ state.activeCommentId = '';
2707
+ state.activeCommentElements = [];
2708
+ state.commentHighlightOutlines.forEach((outline) => renderParentBox(outline, null));
2709
+ }
2710
+ renderCommentThread();
2711
+ setStatus('Deleted comment.');
2712
+ } catch (error) {
2713
+ reportError(error);
2714
+ }
2715
+ }
2716
+
2717
+ function pollQueuedComment(commentId) {
2718
+ if (state.queuedCommentPolls.has(commentId)) return;
2719
+ state.queuedCommentPolls.add(commentId);
2720
+ const tick = async () => {
2721
+ const current = state.pendingComments.find((item) => item.id === commentId);
2722
+ if (!current || current.status !== 'queued') {
2723
+ state.queuedCommentPolls.delete(commentId);
2724
+ return;
2725
+ }
2726
+ try {
2727
+ const res = await fetch('/api/comments?token=' + encodeURIComponent(token), { cache: 'no-store' });
2728
+ const body = await res.json().catch(() => ({}));
2729
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to refresh comments');
2730
+ const records = Array.isArray(body.comments) ? body.comments : [];
2731
+ records.forEach(upsertPersistedComment);
2732
+ const record = records.find((item) => item && item.id === commentId);
2733
+ if (record && record.status === 'applying' && record.lastApplyRequestId) {
2734
+ state.queuedCommentPolls.delete(commentId);
2735
+ watchCommentProgress(commentId, record.lastApplyRequestId);
2736
+ return;
2737
+ }
2738
+ if (record && record.status !== 'queued') {
2739
+ state.queuedCommentPolls.delete(commentId);
2740
+ return;
2741
+ }
2742
+ } catch (error) {
2743
+ // Keep polling: queued comments become actionable only when the server starts them.
2744
+ }
2745
+ setTimeout(tick, 1500);
2746
+ };
2747
+ setTimeout(tick, 750);
2748
+ }
2749
+
2280
2750
  async function searchAssets(nextBatch) {
2281
2751
  const query = (els.assetQuery.value || '').trim();
2282
2752
  if (!query || state.assetSearchBusy) return;
@@ -2431,7 +2901,10 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2431
2901
  state.savedAssets.forEach((asset) => {
2432
2902
  const card = assetCard(asset, true, 0);
2433
2903
  card.draggable = true;
2434
- card.addEventListener('click', () => addAssetToComment(asset));
2904
+ card.addEventListener('click', () => {
2905
+ addAssetToComment(asset);
2906
+ closeLocalAssetMenu();
2907
+ });
2435
2908
  card.addEventListener('dragstart', (event) => {
2436
2909
  state.draggingAsset = asset;
2437
2910
  event.dataTransfer?.setData('application/revela-asset-id', asset.id || '');
@@ -2499,7 +2972,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2499
2972
  insertPlainText(' ');
2500
2973
  setMode('edit');
2501
2974
  updateSendState();
2502
- setStatus('Asset added to the Edit comment. Describe where or how to use it, then Apply Fix.');
2975
+ closeLocalAssetMenu();
2976
+ setStatus('Asset added to the Edit comment. Describe where or how to use it, then Leave Comment.');
2503
2977
  }
2504
2978
 
2505
2979
  function onAssetDragOver(event) {
@@ -2578,22 +3052,111 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2578
3052
  }
2579
3053
 
2580
3054
  function elementFromPayload(payload) {
3055
+ const resolved = resolveElementFromPayload(payload);
3056
+ return resolved.target;
3057
+ }
3058
+
3059
+ function resolveElementFromPayload(payload) {
2581
3060
  const doc = els.frame.contentDocument;
2582
- if (!doc || !payload) return null;
3061
+ if (!doc || !payload) return { target: null, matchedSelector: false, strategy: 'none' };
2583
3062
  const slides = getSlides(doc);
2584
3063
  const slide = slides.find((item, index) => {
2585
3064
  const explicit = Number(item.getAttribute('data-slide-index'));
2586
3065
  const slideIndex = Number.isFinite(explicit) && explicit > 0 ? explicit : index + 1;
2587
3066
  return slideIndex === payload.slideIndex;
2588
3067
  });
2589
- if (!slide) return null;
3068
+ if (!slide) return { target: null, matchedSelector: false, strategy: 'none' };
2590
3069
  if (payload.selector) {
3070
+ try {
3071
+ const selected = doc.querySelector(payload.selector);
3072
+ if (selected && (selected === slide || slide.contains(selected))) return { target: selected, matchedSelector: true, strategy: 'selector' };
3073
+ } catch {}
2591
3074
  try {
2592
3075
  const selected = slide.querySelector(payload.selector);
2593
- if (selected) return selected;
3076
+ if (selected) return { target: selected, matchedSelector: true, strategy: 'selector' };
2594
3077
  } catch {}
2595
3078
  }
2596
- return slide;
3079
+ const fallback = resolveElementByFingerprint(slide, payload);
3080
+ if (fallback) return { target: fallback.target, matchedSelector: false, strategy: fallback.strategy, score: fallback.score };
3081
+ return { target: null, matchedSelector: false, strategy: 'missing' };
3082
+ }
3083
+
3084
+ function resolveElementByFingerprint(slide, payload) {
3085
+ const candidates = Array.from(slide.querySelectorAll('*'));
3086
+ if (payload.tagName && String(payload.tagName).toLowerCase() === slide.tagName.toLowerCase()) candidates.unshift(slide);
3087
+ let best = null;
3088
+ candidates.forEach((candidate) => {
3089
+ const match = scoreTargetCandidate(candidate, slide, payload);
3090
+ if (!match) return;
3091
+ if (!best || match.score > best.score) best = match;
3092
+ });
3093
+ return best && best.score >= 55 ? best : null;
3094
+ }
3095
+
3096
+ function scoreTargetCandidate(candidate, slide, payload) {
3097
+ const candidatePayload = targetIdentityPayload(candidate, slide);
3098
+ let score = 0;
3099
+ let strategy = '';
3100
+ const payloadFingerprint = payload.fingerprint || {};
3101
+ const candidateFingerprint = candidatePayload.fingerprint || {};
3102
+ if (payloadFingerprint.contentHash && payloadFingerprint.contentHash === candidateFingerprint.contentHash) {
3103
+ score += 120;
3104
+ strategy = 'fingerprint';
3105
+ }
3106
+ if (payloadFingerprint.textHash && payloadFingerprint.textHash === candidateFingerprint.textHash) {
3107
+ score += 80;
3108
+ strategy = strategy || 'fingerprint';
3109
+ }
3110
+ if (payloadFingerprint.structureHash && payloadFingerprint.structureHash === candidateFingerprint.structureHash) {
3111
+ score += 40;
3112
+ strategy = strategy || 'structure';
3113
+ }
3114
+ if (payloadFingerprint.contextHash && payloadFingerprint.contextHash === candidateFingerprint.contextHash) {
3115
+ score += 20;
3116
+ strategy = strategy || 'fingerprint';
3117
+ }
3118
+ const payloadTag = String(payload.tagName || '').toLowerCase();
3119
+ if (payloadTag && payloadTag === candidatePayload.tagName) score += 32;
3120
+ const payloadText = payload.textNormalized || normalizeTargetText(payload.text);
3121
+ if (payloadText && payloadText === candidatePayload.textNormalized) {
3122
+ score += 72;
3123
+ strategy = strategy || 'text';
3124
+ } else if (payloadText && candidatePayload.textNormalized && (payloadText.includes(candidatePayload.textNormalized) || candidatePayload.textNormalized.includes(payloadText))) {
3125
+ score += 34;
3126
+ strategy = strategy || 'text';
3127
+ }
3128
+ if (payload.semanticKind && payload.semanticKind === candidatePayload.semanticKind) score += 18;
3129
+ const classOverlap = overlapCount(payload.classList, candidatePayload.classList);
3130
+ score += Math.min(24, classOverlap * 8);
3131
+ if (payloadFingerprint.structureHash && payloadFingerprint.structureHash === candidateFingerprint.structureHash && (classOverlap > 0 || partialTextOverlap(payloadText, candidatePayload.textNormalized))) {
3132
+ score += 32;
3133
+ strategy = strategy || 'structure';
3134
+ }
3135
+ const distance = relativeBoxDistance(payload.slideRelativeBox, candidatePayload.slideRelativeBox);
3136
+ if (Number.isFinite(distance)) {
3137
+ const positionScore = Math.max(0, 36 - distance * 72);
3138
+ score += positionScore;
3139
+ if (!strategy && positionScore > 20) strategy = 'relativeBox';
3140
+ }
3141
+ if (payloadTag && payloadTag !== candidatePayload.tagName) score -= 20;
3142
+ return score > 0 ? { target: candidate, score, strategy: strategy || 'relativeBox' } : null;
3143
+ }
3144
+
3145
+ function targetIdentityPayload(el, slide) {
3146
+ const semanticKind = semanticKindForElement(el);
3147
+ const tagName = el.tagName.toLowerCase();
3148
+ const classList = Array.from(el.classList || []);
3149
+ const textNormalized = normalizeTargetText(el.innerText || el.textContent || '');
3150
+ const slideTitle = slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : '';
3151
+ const nearbyText = slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : '';
3152
+ return {
3153
+ tagName,
3154
+ semanticKind,
3155
+ classList,
3156
+ textNormalized,
3157
+ slideRelativeBox: slideRelativeBox(el, slide),
3158
+ fingerprint: fingerprintForTarget({ tagName, semanticKind, classList, textNormalized, slideTitle, nearbyText }),
3159
+ };
2597
3160
  }
2598
3161
 
2599
3162
  function elementFromVisualChange(change) {
@@ -2615,28 +3178,25 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2615
3178
  : 'add it near the drop point';
2616
3179
  const comment = 'Place workspace asset ' + asset.path + ' on slide ' + placement.slideIndex + ' as a ' + (asset.purpose || 'visual asset') + '; ' + modeText + '. Preserve the current layout and do not cover existing text, charts, tables, or evidence.';
2617
3180
  const elements = placement.target ? [placement.target] : [];
2618
- const commentId = addPendingComment(comment, elements, 'sending');
2619
- setStatus('Sending asset placement comment...');
3181
+ setStatus('Saving asset placement comment...');
2620
3182
  try {
2621
- const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
3183
+ const res = await fetch('/api/comments?token=' + encodeURIComponent(token), {
2622
3184
  method: 'POST',
2623
3185
  headers: { 'content-type': 'application/json' },
2624
3186
  body: JSON.stringify({ comment, elements, asset, drop: placement }),
2625
3187
  });
2626
3188
  const body = await res.json().catch(() => ({}));
2627
- if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send asset placement');
2628
- updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion, requestId: body.commentRequestId || body.requestId || '' });
2629
- if (pendingCommentStatus(commentId) !== 'updated') setStatus('Asset placement sent. Waiting for deck update...');
2630
- if (body.commentRequestId || body.requestId) watchCommentProgress(commentId, body.commentRequestId || body.requestId);
3189
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset placement');
3190
+ upsertPersistedComment(body.comment);
3191
+ setStatus('Asset placement comment saved. Use Apply on the comment card when ready.');
2631
3192
  } catch (error) {
2632
- updatePendingCommentStatus(commentId, 'failed');
2633
3193
  reportError(error);
2634
3194
  }
2635
3195
  }
2636
3196
 
2637
3197
  function selectable(node) {
2638
3198
  if (!node || node.nodeType !== 1) return null;
2639
- if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
3199
+ if (node === state.hoverOutline || node === state.assetDropOutline || state.referenceOutlines.includes(node)) return null;
2640
3200
  return findSlide(node) ? node : null;
2641
3201
  }
2642
3202
 
@@ -2726,41 +3286,10 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2726
3286
  if (comment.status === 'updated' && status !== 'failed') return;
2727
3287
  comment.status = status;
2728
3288
  if (updates) Object.assign(comment, updates);
2729
- if (status === 'updated' || status === 'failed') comment.progressEvent = null;
3289
+ if (status === 'updated' || status === 'failed' || status === 'applied') comment.progressEvent = null;
2730
3290
  renderCommentThread();
2731
3291
  }
2732
3292
 
2733
- function markCommentsUpdatedForVersion(version) {
2734
- let changed = false;
2735
- state.pendingComments.forEach((comment) => {
2736
- if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
2737
- comment.status = 'updated';
2738
- comment.updatedVersion = version;
2739
- comment.progressEvent = null;
2740
- changed = true;
2741
- }
2742
- });
2743
- if (changed) {
2744
- renderCommentThread();
2745
- setStatus('Deck file updated. Preview will refresh automatically.');
2746
- }
2747
- }
2748
-
2749
- function markStaleComments() {
2750
- const now = Date.now();
2751
- let changed = false;
2752
- state.pendingComments.forEach((comment) => {
2753
- if (comment.status !== 'sent' && comment.status !== 'sending') return;
2754
- if (now - comment.createdAt < COMMENT_STALE_MS) return;
2755
- comment.status = 'stale';
2756
- changed = true;
2757
- });
2758
- if (changed) {
2759
- renderCommentThread();
2760
- setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
2761
- }
2762
- }
2763
-
2764
3293
  function pendingCommentStatus(id) {
2765
3294
  return state.pendingComments.find((comment) => comment.id === id)?.status || '';
2766
3295
  }
@@ -2780,6 +3309,10 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2780
3309
  return;
2781
3310
  }
2782
3311
  if (body.status === 'completed') {
3312
+ if (pendingCommentStatus(commentId) === 'applying') {
3313
+ updatePendingCommentStatus(commentId, 'applied', { progressEvent: null });
3314
+ }
3315
+ setStatus('Codex completed.');
2783
3316
  return;
2784
3317
  }
2785
3318
  } catch (error) {
@@ -2829,8 +3362,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2829
3362
  } else if (payload.type === 'completed') {
2830
3363
  closed = true;
2831
3364
  source.close();
2832
- if (pendingCommentStatus(commentId) !== 'updated') setStatus(payload.message || 'Waiting for deck file update...');
2833
- watchDeckVersionAfterComment(commentId);
3365
+ setStatus(payload.message || 'Codex completed.');
2834
3366
  } else if (payload.message) {
2835
3367
  setStatus(payload.message);
2836
3368
  }
@@ -2846,11 +3378,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2846
3378
  function recordCommentProgress(commentId, event) {
2847
3379
  const comment = state.pendingComments.find((item) => item.id === commentId);
2848
3380
  if (!comment || !event || !event.message) return;
3381
+ if (comment.stopped || comment.status === 'failed' && comment.failureMessage === 'Stopped by user.') return;
2849
3382
  if (codexReview) {
2850
3383
  appendCodexEventLog(comment, event);
2851
3384
  }
2852
3385
  if (event.type === 'completed') {
2853
3386
  comment.progressEvent = null;
3387
+ if (comment.persisted && comment.status === 'applying') comment.status = 'applied';
2854
3388
  if (codexReview) renderCommentThread();
2855
3389
  return;
2856
3390
  }
@@ -2891,6 +3425,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2891
3425
  details.className = 'codex-log';
2892
3426
  const summary = document.createElement('summary');
2893
3427
  summary.textContent = codexLogSummary(log);
3428
+ const list = renderCodexLogEntries(log);
3429
+ details.appendChild(summary);
3430
+ details.appendChild(list);
3431
+ return details;
3432
+ }
3433
+
3434
+ function renderCodexLogEntries(log) {
2894
3435
  const list = document.createElement('div');
2895
3436
  list.className = 'codex-log-list';
2896
3437
  log.forEach((item) => {
@@ -2917,27 +3458,90 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2917
3458
  }
2918
3459
  list.appendChild(row);
2919
3460
  });
2920
- details.appendChild(summary);
2921
- details.appendChild(list);
2922
- return details;
3461
+ return list;
2923
3462
  }
2924
3463
 
2925
- function renderCommentThread() {
3464
+ function openCodexLogModal(log) {
3465
+ if (!codexReview || !Array.isArray(log) || !log.length) return;
3466
+ els.codexLogTitle.textContent = codexLogSummary(log);
3467
+ els.codexLogBody.replaceChildren(...Array.from(renderCodexLogEntries(log).children));
3468
+ els.codexLogModal.classList.add('open');
3469
+ els.codexLogModal.setAttribute('aria-hidden', 'false');
3470
+ els.codexLogClose.focus();
3471
+ }
3472
+
3473
+ function closeCodexLogModal() {
3474
+ els.codexLogModal.classList.remove('open');
3475
+ els.codexLogModal.setAttribute('aria-hidden', 'true');
3476
+ els.codexLogBody.textContent = '';
3477
+ }
3478
+
3479
+ function renderCommentThread(scrollToBottom = true) {
2926
3480
  els.commentThread.textContent = '';
2927
3481
  state.pendingComments.forEach((comment) => {
2928
3482
  const bubble = document.createElement('div');
2929
- bubble.className = 'comment-bubble ' + comment.status;
3483
+ bubble.className = 'comment-bubble ' + comment.status + (comment.id === state.activeCommentId ? ' active' : '');
3484
+ bubble.tabIndex = 0;
3485
+ bubble.setAttribute('role', 'button');
3486
+ bubble.setAttribute('aria-pressed', comment.id === state.activeCommentId ? 'true' : 'false');
3487
+ bubble.addEventListener('click', () => selectPersistedComment(comment.id));
3488
+ bubble.addEventListener('keydown', (event) => {
3489
+ if (event.key === 'Enter' || event.key === ' ') {
3490
+ event.preventDefault();
3491
+ selectPersistedComment(comment.id);
3492
+ }
3493
+ });
3494
+
3495
+ const meta = document.createElement('div');
3496
+ meta.className = 'comment-bubble-meta';
3497
+ meta.textContent = 'Slide ' + (comment.slideIndex || slideIndexFromElements(comment.elements) || '?') + (comment.baseDeckVersion && state.deckVersion && comment.baseDeckVersion !== state.deckVersion && comment.status === 'open' ? ' · stale deck version' : '');
2930
3498
 
2931
3499
  const text = document.createElement('div');
2932
3500
  text.className = 'comment-bubble-text';
2933
3501
  text.textContent = comment.text;
2934
3502
 
3503
+ const refs = renderCommentReferenceChips(comment.elements);
3504
+
2935
3505
  const status = document.createElement('div');
2936
3506
  status.className = 'comment-bubble-state';
2937
3507
  status.textContent = commentStatusLabel(comment.status);
2938
3508
 
3509
+ bubble.appendChild(meta);
3510
+ if (refs) bubble.appendChild(refs);
2939
3511
  bubble.appendChild(text);
2940
3512
  bubble.appendChild(status);
3513
+ if (comment.persisted && canApplyPersistedComment(comment.status)) {
3514
+ const actions = document.createElement('div');
3515
+ actions.className = 'comment-actions';
3516
+ const apply = commentActionButton(isReapplyStatus(comment.status) ? 'Re-apply' : 'Apply', isReapplyStatus(comment.status) ? '${lucideIcon("refresh-cw", "comment-action-icon")}' : '${lucideIcon("play", "comment-action-icon")}');
3517
+ apply.addEventListener('click', () => applyPersistedComment(comment.id));
3518
+ actions.appendChild(apply);
3519
+ bubble.appendChild(actions);
3520
+ }
3521
+ if (comment.persisted && canStopPersistedComment(comment.status)) {
3522
+ const actions = bubble.querySelector('.comment-actions') || document.createElement('div');
3523
+ actions.className = 'comment-actions';
3524
+ const stop = commentActionButton('Stop', '${lucideIcon("square", "comment-action-icon")}', 'stop');
3525
+ stop.addEventListener('click', () => stopPersistedComment(comment.id));
3526
+ actions.appendChild(stop);
3527
+ if (!actions.parentElement) bubble.appendChild(actions);
3528
+ }
3529
+ if (comment.persisted && canDeletePersistedComment(comment.status)) {
3530
+ const actions = bubble.querySelector('.comment-actions') || document.createElement('div');
3531
+ actions.className = 'comment-actions';
3532
+ const remove = commentActionButton('Delete', '${lucideIcon("trash-2", "comment-action-icon")}', 'danger');
3533
+ remove.addEventListener('click', () => deletePersistedComment(comment.id));
3534
+ actions.appendChild(remove);
3535
+ if (!actions.parentElement) bubble.appendChild(actions);
3536
+ }
3537
+ if (codexReview && Array.isArray(comment.eventLog) && comment.eventLog.length) {
3538
+ const actions = bubble.querySelector('.comment-actions') || document.createElement('div');
3539
+ actions.className = 'comment-actions';
3540
+ const log = commentActionButton('Execution Log', '${lucideIcon("list", "comment-action-icon")}', 'log');
3541
+ log.addEventListener('click', () => openCodexLogModal(comment.eventLog));
3542
+ actions.appendChild(log);
3543
+ if (!actions.parentElement) bubble.appendChild(actions);
3544
+ }
2941
3545
  if (comment.progressEvent) {
2942
3546
  const progress = document.createElement('div');
2943
3547
  progress.className = 'comment-progress';
@@ -2958,20 +3562,104 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2958
3562
  details.appendChild(pre);
2959
3563
  bubble.appendChild(details);
2960
3564
  }
2961
- const codexLog = renderCodexLog(comment.eventLog);
2962
- if (codexLog) bubble.appendChild(codexLog);
2963
3565
  els.commentThread.appendChild(bubble);
2964
3566
  });
3567
+ if (scrollToBottom) scrollCommentThreadToBottom();
3568
+ }
3569
+
3570
+ function commentActionButton(label, iconHtml, variant) {
3571
+ const button = document.createElement('button');
3572
+ button.type = 'button';
3573
+ button.className = 'comment-action-button' + (variant ? ' ' + variant : '');
3574
+ button.setAttribute('aria-label', label);
3575
+ button.title = label;
3576
+ button.innerHTML = iconHtml;
3577
+ button.addEventListener('click', (event) => event.stopPropagation());
3578
+ return button;
3579
+ }
3580
+
3581
+ function renderCommentReferenceChips(elements) {
3582
+ if (!Array.isArray(elements) || !elements.length) return null;
3583
+ const wrap = document.createElement('div');
3584
+ wrap.className = 'selection-chips';
3585
+ elements.slice(0, 4).forEach((payload) => {
3586
+ const label = elementDisplayLabel(payload);
3587
+ const chip = document.createElement('span');
3588
+ chip.className = 'ref-chip';
3589
+ chip.title = label;
3590
+ chip.textContent = displayReferenceLabel(label);
3591
+ wrap.appendChild(chip);
3592
+ });
3593
+ if (elements.length > 4) {
3594
+ const more = document.createElement('span');
3595
+ more.className = 'ref-chip';
3596
+ more.textContent = '+' + (elements.length - 4) + ' more';
3597
+ more.title = more.textContent;
3598
+ wrap.appendChild(more);
3599
+ }
3600
+ return wrap;
3601
+ }
3602
+
3603
+ function selectPersistedComment(commentId) {
3604
+ const comment = state.pendingComments.find((item) => item.id === commentId);
3605
+ if (!comment) return;
3606
+ state.activeCommentId = comment.id;
3607
+ state.activeCommentElements = Array.isArray(comment.elements) ? comment.elements : [];
3608
+ if (!state.activeCommentElements.length) setStatus('Comment has no saved deck target.');
3609
+ const slideIndex = comment.slideIndex || slideIndexFromElements(comment.elements);
3610
+ if (Number.isInteger(slideIndex) && slideIndex > 0 && slideIndex !== currentDeckSlideIndex()) {
3611
+ goToDeckSlide(slideIndex - 1);
3612
+ }
3613
+ renderCommentThread(false);
3614
+ requestAnimationFrame(renderActiveCommentHighlights);
3615
+ }
3616
+
3617
+ function scrollCommentThreadToBottom() {
3618
+ requestAnimationFrame(() => {
3619
+ els.commentThread.scrollTop = els.commentThread.scrollHeight;
3620
+ });
2965
3621
  }
2966
3622
 
2967
3623
  function commentStatusLabel(status) {
2968
- if (status === 'updated') return 'Deck file updated';
2969
- if (status === 'stale') return 'Still waiting for deck file update';
2970
- if (status === 'failed') return 'Failed to send';
3624
+ if (status === 'open') return 'Saved comment';
3625
+ if (status === 'queued') return 'Queued for apply';
3626
+ if (status === 'applying') return 'Applying with Codex...';
3627
+ if (status === 'applied' || status === 'updated' || status === 'stale') return 'Codex completed';
3628
+ if (status === 'failed') return 'Failed to apply';
2971
3629
  if (status === 'sending') return 'Sending to Review agent...';
2972
3630
  return 'Sent to Review agent';
2973
3631
  }
2974
3632
 
3633
+ function canApplyPersistedComment(status) {
3634
+ return status === 'open' || status === 'failed' || isReapplyStatus(status);
3635
+ }
3636
+
3637
+ function canStopPersistedComment(status) {
3638
+ return status === 'applying' || status === 'queued';
3639
+ }
3640
+
3641
+ function canDeletePersistedComment(status) {
3642
+ return status !== 'applying';
3643
+ }
3644
+
3645
+ function isReapplyStatus(status) {
3646
+ return status === 'applied' || status === 'updated' || status === 'stale';
3647
+ }
3648
+
3649
+ function slideIndexFromElements(elements) {
3650
+ if (!Array.isArray(elements)) return null;
3651
+ const found = elements.map((item) => item && item.slideIndex).find((value) => Number.isInteger(value) && value > 0);
3652
+ return found || null;
3653
+ }
3654
+
3655
+ function currentDeckSlideIndex() {
3656
+ const doc = els.frame.contentDocument;
3657
+ const slides = doc ? getSlides(doc) : [];
3658
+ const currentSlide = slides[state.deckSlideIndex];
3659
+ const explicitSlideIndex = Number(currentSlide?.getAttribute('data-slide-index'));
3660
+ return Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : state.deckSlideIndex + 1;
3661
+ }
3662
+
2975
3663
  function targetFromPointer(event) {
2976
3664
  const doc = els.frame.contentDocument;
2977
3665
  if (!doc || doc.location.href === 'about:blank') return null;
@@ -3014,6 +3702,28 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3014
3702
  outline.style.height = rect.height + 'px';
3015
3703
  }
3016
3704
 
3705
+ function createCommentHighlightBox() {
3706
+ const box = document.createElement('div');
3707
+ box.className = 'comment-highlight-box';
3708
+ els.commentHighlightLayer.appendChild(box);
3709
+ return box;
3710
+ }
3711
+
3712
+ function renderParentBox(box, target) {
3713
+ if (!box || !target || !target.getBoundingClientRect) {
3714
+ if (box) box.style.display = 'none';
3715
+ return;
3716
+ }
3717
+ const frameRect = els.frame.getBoundingClientRect();
3718
+ const previewRect = els.commentHighlightLayer.getBoundingClientRect();
3719
+ const rect = target.getBoundingClientRect();
3720
+ box.style.display = 'block';
3721
+ box.style.left = (frameRect.left - previewRect.left + rect.left) + 'px';
3722
+ box.style.top = (frameRect.top - previewRect.top + rect.top) + 'px';
3723
+ box.style.width = rect.width + 'px';
3724
+ box.style.height = rect.height + 'px';
3725
+ }
3726
+
3017
3727
  function renderHoverOutline(target) {
3018
3728
  renderBox(state.hoverOutline, target);
3019
3729
  }
@@ -3021,10 +3731,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3021
3731
  function renderReferenceOutlines() {
3022
3732
  const doc = els.frame.contentDocument;
3023
3733
  if (!doc || doc.location.href === 'about:blank') return;
3024
- const slides = getSlides(doc);
3025
- const currentSlide = slides[state.deckSlideIndex];
3026
- const explicitSlideIndex = Number(currentSlide?.getAttribute('data-slide-index'));
3027
- const currentSlideIndex = Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : state.deckSlideIndex + 1;
3734
+ const currentSlideIndex = currentDeckSlideIndex();
3028
3735
  while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
3029
3736
  state.referenceOutlines.forEach((outline, index) => {
3030
3737
  const reference = state.references[index];
@@ -3037,6 +3744,36 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3037
3744
  });
3038
3745
  }
3039
3746
 
3747
+ function renderActiveCommentHighlights() {
3748
+ const doc = els.frame.contentDocument;
3749
+ if (!doc || doc.location.href === 'about:blank') return;
3750
+ const elements = Array.isArray(state.activeCommentElements) ? state.activeCommentElements : [];
3751
+ while (state.commentHighlightOutlines.length < elements.length) state.commentHighlightOutlines.push(createCommentHighlightBox());
3752
+ let visible = 0;
3753
+ let missing = 0;
3754
+ const currentSlideIndex = currentDeckSlideIndex();
3755
+ state.commentHighlightOutlines.forEach((outline, index) => {
3756
+ const payload = elements[index];
3757
+ if (!payload || payload.slideIndex !== currentSlideIndex) {
3758
+ renderParentBox(outline, null);
3759
+ return;
3760
+ }
3761
+ const resolved = resolveElementFromPayload(payload);
3762
+ const target = resolved.target;
3763
+ if (!target) {
3764
+ missing += 1;
3765
+ renderParentBox(outline, null);
3766
+ return;
3767
+ }
3768
+ visible += 1;
3769
+ renderParentBox(outline, target);
3770
+ });
3771
+ for (let i = elements.length; i < state.commentHighlightOutlines.length; i++) renderParentBox(state.commentHighlightOutlines[i], null);
3772
+ if (!state.activeCommentId) return;
3773
+ if (visible > 0) setStatus('Highlighted ' + visible + ' comment target' + (visible === 1 ? '' : 's') + '.');
3774
+ else if (missing > 0) setStatus('Comment target is no longer available on this deck version.');
3775
+ }
3776
+
3040
3777
  function clearHoverSilently() {
3041
3778
  state.hoverEl = null;
3042
3779
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
@@ -3052,7 +3789,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3052
3789
 
3053
3790
  function updateSendState() {
3054
3791
  if (state.sendingEdit) setButtonLoading(els.send, true, 'Sending...');
3055
- else setButtonLoading(els.send, false, '<svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94L14.7 6.3z"/></svg><span>Apply Fix</span>', true);
3792
+ else setButtonLoading(els.send, false, sendButtonHtml, true);
3056
3793
  els.send.disabled = state.sendingEdit || !getCommentText().trim();
3057
3794
  if (state.inspecting) setButtonLoading(els.inspectButton, true, 'Getting insight...');
3058
3795
  else setButtonLoading(els.inspectButton, false, 'Get Insight');
@@ -3077,7 +3814,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3077
3814
  chip.style.setProperty('--ref-bg', reference.color.bg);
3078
3815
  chip.style.setProperty('--ref-border', reference.color.border);
3079
3816
  chip.style.setProperty('--ref-text', reference.color.text);
3080
- chip.textContent = '@' + reference.label;
3817
+ const label = '@' + reference.label;
3818
+ chip.title = label;
3819
+ chip.textContent = displayReferenceLabel(label);
3081
3820
  els.selectionChips.appendChild(chip);
3082
3821
  });
3083
3822
  }
@@ -3340,7 +4079,14 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3340
4079
  function escapeHtml(value) { return String(value || '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])); }
3341
4080
 
3342
4081
  function nextReferenceLabel(payload) {
3343
- return humanElementName(payload) + ' ' + (state.references.length + 1);
4082
+ return elementDisplayLabel(payload);
4083
+ }
4084
+
4085
+ const REF_LABEL_MAX_DISPLAY_CHARS = ${REVIEW_REF_LABEL_MAX_DISPLAY_CHARS};
4086
+
4087
+ function displayReferenceLabel(label) {
4088
+ const text = String(label || '');
4089
+ return text.length > REF_LABEL_MAX_DISPLAY_CHARS ? text.slice(0, REF_LABEL_MAX_DISPLAY_CHARS - 1) + '…' : text;
3344
4090
  }
3345
4091
 
3346
4092
  function insertReferenceChip(reference) {
@@ -3352,7 +4098,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3352
4098
  chip.style.setProperty('--ref-bg', reference.color.bg);
3353
4099
  chip.style.setProperty('--ref-border', reference.color.border);
3354
4100
  chip.style.setProperty('--ref-text', reference.color.text);
3355
- chip.textContent = '@' + reference.label;
4101
+ const label = '@' + reference.label;
4102
+ chip.title = label;
4103
+ chip.textContent = displayReferenceLabel(label);
3356
4104
  const trailingSpace = document.createTextNode(' ');
3357
4105
  const range = getCommentInsertRange();
3358
4106
  if (range) {
@@ -3378,7 +4126,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3378
4126
  chip.style.setProperty('--ref-bg', '#dcfce7');
3379
4127
  chip.style.setProperty('--ref-border', '#86efac');
3380
4128
  chip.style.setProperty('--ref-text', '#166534');
3381
- chip.textContent = '@Asset ' + (asset.id || asset.deckPath || asset.path || 'image');
4129
+ const label = '@Asset ' + (asset.id || asset.deckPath || asset.path || 'image');
4130
+ chip.title = label;
4131
+ chip.textContent = displayReferenceLabel(label);
3382
4132
  const range = getCommentInsertRange();
3383
4133
  if (range) {
3384
4134
  range.insertNode(chip);
@@ -3435,7 +4185,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3435
4185
 
3436
4186
  function getCommentText(editor) {
3437
4187
  const source = editor || els.comment;
3438
- return (source.innerText || source.textContent || '').replace(/\\u00a0/g, ' ');
4188
+ const clone = source.cloneNode(true);
4189
+ clone.querySelectorAll('.ref-chip[title]').forEach((chip) => {
4190
+ chip.textContent = chip.getAttribute('title') || chip.textContent || '';
4191
+ });
4192
+ return (clone.innerText || clone.textContent || '').replace(/\\u00a0/g, ' ');
3439
4193
  }
3440
4194
 
3441
4195
  function placeCaretAfter(node) {
@@ -3480,14 +4234,23 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3480
4234
  function humanElementName(payload) {
3481
4235
  const tag = payload.tagName || 'element';
3482
4236
  const classes = payload.classList || [];
3483
- if (/^h[1-6]$/.test(tag)) return 'Heading';
3484
- if (tag === 'p') return 'Text block';
4237
+ if (payload.semanticKind === 'heading' || /^h[1-6]$/.test(tag)) return 'Heading';
4238
+ if (payload.semanticKind === 'text' || tag === 'p') return 'Text';
4239
+ if (payload.semanticKind === 'source-note') return 'Source';
3485
4240
  if (classes.some((name) => /card/i.test(name))) return 'Card';
3486
- if (classes.some((name) => /stat|metric|value/i.test(name))) return 'Metric';
3487
- if (tag === 'img' || tag === 'svg') return 'Visual';
4241
+ if (payload.semanticKind === 'metric' || classes.some((name) => /stat|metric|value/i.test(name))) return 'Metric';
4242
+ if (payload.semanticKind === 'visual' || tag === 'img' || tag === 'svg') return 'Visual';
3488
4243
  return 'Element';
3489
4244
  }
3490
4245
 
4246
+ function elementDisplayLabel(payload) {
4247
+ if (!payload) return 'Element';
4248
+ if (payload.displayLabel) return payload.displayLabel;
4249
+ const kind = humanElementName(payload);
4250
+ const text = String(payload.text || payload.textNormalized || '').replace(/\\s+/g, ' ').trim().slice(0, 56);
4251
+ return text ? kind + ': ' + text : kind;
4252
+ }
4253
+
3491
4254
  function collectPayload(el) {
3492
4255
  const doc = els.frame.contentDocument;
3493
4256
  const slides = getSlides(doc);
@@ -3496,22 +4259,116 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3496
4259
  const explicitSlideIndex = slide ? Number(slide.getAttribute('data-slide-index')) : Number.NaN;
3497
4260
  const slideIndex = slide && Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : slide ? slides.indexOf(slide) + 1 : undefined;
3498
4261
  const win = els.frame.contentWindow;
4262
+ const tagName = el.tagName.toLowerCase();
4263
+ const classList = Array.from(el.classList || []);
4264
+ const text = (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600);
4265
+ const textNormalized = normalizeTargetText(text);
4266
+ const semanticKind = semanticKindForElement(el);
4267
+ const slideTitle = slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : undefined;
4268
+ const nearbyText = slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : undefined;
4269
+ const identity = { tagName, semanticKind, classList, textNormalized, slideTitle, nearbyText };
4270
+ const displayLabel = elementDisplayLabel({ ...identity, text });
3499
4271
  return {
3500
4272
  slideIndex,
3501
- slideTitle: slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : undefined,
4273
+ slideTitle,
3502
4274
  selector: buildSelector(el, slide),
3503
4275
  domPath: buildDomPath(el, slide),
3504
- tagName: el.tagName.toLowerCase(),
4276
+ tagName,
3505
4277
  id: el.id || undefined,
3506
- classList: Array.from(el.classList || []),
3507
- text: (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600),
4278
+ classList,
4279
+ semanticKind,
4280
+ displayLabel,
4281
+ text,
4282
+ textNormalized,
3508
4283
  outerHTMLExcerpt: (el.outerHTML || '').replace(/\\s+/g, ' ').slice(0, 1200),
3509
- nearbyText: slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : undefined,
4284
+ nearbyText,
3510
4285
  boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
4286
+ slideRelativeBox: slideRelativeBox(el, slide),
4287
+ fingerprint: fingerprintForTarget(identity),
3511
4288
  viewport: { width: win ? win.innerWidth : undefined, height: win ? win.innerHeight : undefined },
3512
4289
  };
3513
4290
  }
3514
4291
 
4292
+ function semanticKindForElement(el) {
4293
+ const tag = el?.tagName ? el.tagName.toLowerCase() : '';
4294
+ const classes = Array.from(el?.classList || []);
4295
+ if (/^h[1-6]$/.test(tag) || el?.matches?.('[data-title]')) return 'heading';
4296
+ if (tag === 'img' || tag === 'svg' || tag === 'canvas' || tag === 'video' || classes.some((name) => /image|media|visual|chart|figure|logo/i.test(name))) return 'visual';
4297
+ if (classes.some((name) => /source|citation|footnote|note/i.test(name))) return 'source-note';
4298
+ if (classes.some((name) => /stat|metric|kpi|value|number/i.test(name))) return 'metric';
4299
+ if (classes.some((name) => /card|box|panel|tile/i.test(name))) return 'card';
4300
+ if (tag === 'p' || tag === 'li' || tag === 'blockquote' || tag === 'span') return 'text';
4301
+ return 'element';
4302
+ }
4303
+
4304
+ function normalizeTargetText(value) {
4305
+ return String(value || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim().toLowerCase().slice(0, 600);
4306
+ }
4307
+
4308
+ function fingerprintForTarget(input) {
4309
+ const tagName = String(input.tagName || '').toLowerCase();
4310
+ const textNormalized = normalizeTargetText(input.textNormalized);
4311
+ const classList = Array.isArray(input.classList) ? input.classList.map(String).sort() : [];
4312
+ const semanticKind = input.semanticKind || 'element';
4313
+ const context = normalizeTargetText(String(input.slideTitle || '') + ' ' + String(input.nearbyText || '').slice(0, 400));
4314
+ return {
4315
+ textHash: textNormalized ? stableHash(textNormalized) : undefined,
4316
+ contentHash: stableHash(tagName + '|' + textNormalized),
4317
+ structureHash: stableHash(tagName + '|' + semanticKind + '|' + classList.join('.')),
4318
+ contextHash: context ? stableHash(context) : undefined,
4319
+ };
4320
+ }
4321
+
4322
+ function stableHash(value) {
4323
+ let hash = 2166136261;
4324
+ const text = String(value || '');
4325
+ for (let i = 0; i < text.length; i++) {
4326
+ hash ^= text.charCodeAt(i);
4327
+ hash = Math.imul(hash, 16777619);
4328
+ }
4329
+ return (hash >>> 0).toString(16).padStart(8, '0');
4330
+ }
4331
+
4332
+ function slideRelativeBox(el, slide) {
4333
+ if (!el || !slide || !el.getBoundingClientRect || !slide.getBoundingClientRect) return undefined;
4334
+ const rect = el.getBoundingClientRect();
4335
+ const slideRect = slide.getBoundingClientRect();
4336
+ if (!slideRect.width || !slideRect.height) return undefined;
4337
+ return {
4338
+ x: roundRatio((rect.left - slideRect.left) / slideRect.width),
4339
+ y: roundRatio((rect.top - slideRect.top) / slideRect.height),
4340
+ width: roundRatio(rect.width / slideRect.width),
4341
+ height: roundRatio(rect.height / slideRect.height),
4342
+ };
4343
+ }
4344
+
4345
+ function roundRatio(value) {
4346
+ return Math.round(Math.max(-2, Math.min(2, Number(value) || 0)) * 10000) / 10000;
4347
+ }
4348
+
4349
+ function overlapCount(left, right) {
4350
+ const rightSet = new Set(Array.isArray(right) ? right.map(String) : []);
4351
+ return (Array.isArray(left) ? left : []).map(String).filter((item) => rightSet.has(item)).length;
4352
+ }
4353
+
4354
+ function partialTextOverlap(left, right) {
4355
+ if (!left || !right) return false;
4356
+ if (left.includes(right) || right.includes(left)) return true;
4357
+ const leftWords = new Set(String(left).split(' ').filter((word) => word.length > 3));
4358
+ const rightWords = String(right).split(' ').filter((word) => word.length > 3);
4359
+ return rightWords.some((word) => leftWords.has(word));
4360
+ }
4361
+
4362
+ function relativeBoxDistance(left, right) {
4363
+ if (!left || !right) return Number.NaN;
4364
+ const dx = Number(left.x) - Number(right.x);
4365
+ const dy = Number(left.y) - Number(right.y);
4366
+ const dw = Number(left.width) - Number(right.width);
4367
+ const dh = Number(left.height) - Number(right.height);
4368
+ if (![dx, dy, dw, dh].every(Number.isFinite)) return Number.NaN;
4369
+ return Math.sqrt(dx * dx + dy * dy + dw * dw * .35 + dh * dh * .35);
4370
+ }
4371
+
3515
4372
  function buildSelector(el, slide) {
3516
4373
  if (el.id) return '#' + cssEscape(el.id);
3517
4374
  const parts = [];