@cyber-dash-tech/revela 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,16 @@
1
1
  import { randomBytes } from "crypto"
2
2
  import { existsSync, readFileSync, statSync } from "fs"
3
3
  import { fileURLToPath } from "url"
4
- import { dirname, extname, isAbsolute, resolve, sep } from "path"
4
+ import { dirname, extname, isAbsolute, relative, resolve, sep } from "path"
5
5
  import type { EditableDeck } from "../edit/resolve-deck"
6
6
  import { buildEditPrompt, type EditCommentPayload } from "../edit/prompt"
7
7
  import type { InspectionElementSnapshot } from "../inspection-context/match"
8
8
  import { buildInspectionPrompt } from "../inspect/prompt"
9
9
  import { projectWorkspaceElement } from "../inspect/request"
10
10
  import { createInspectRequest, failInspectRequest, getInspectRequest } from "../inspect/requests"
11
+ import { saveMediaAsset } from "../media/save"
12
+ import { searchRemoteImages, type ImageCandidate } from "../media/search"
13
+ import type { MediaAssetRecord, MediaPurpose } from "../media/types"
11
14
 
12
15
  const TOKEN_BYTES = 24
13
16
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000
@@ -190,9 +193,148 @@ async function handleRequest(req: Request): Promise<Response> {
190
193
  return handleDeckVersion(session.value)
191
194
  }
192
195
 
196
+ if (url.pathname === "/api/assets/search" && req.method === "GET") {
197
+ const session = validateSession(url.searchParams.get("token"))
198
+ if (!session.ok) return session.response
199
+ return handleAssetSearch(url, session.value)
200
+ }
201
+
202
+ if (url.pathname === "/api/assets/save" && req.method === "POST") {
203
+ const session = validateSession(url.searchParams.get("token"))
204
+ if (!session.ok) return session.response
205
+ return handleAssetSave(req, session.value)
206
+ }
207
+
208
+ if (url.pathname === "/api/assets/list" && req.method === "GET") {
209
+ const session = validateSession(url.searchParams.get("token"))
210
+ if (!session.ok) return session.response
211
+ return handleAssetList(session.value)
212
+ }
213
+
193
214
  return textResponse("Not found", 404)
194
215
  }
195
216
 
217
+ async function handleAssetSearch(url: URL, session: EditSession): Promise<Response> {
218
+ const query = (url.searchParams.get("query") || "").trim()
219
+ if (!query) return jsonResponse({ ok: false, error: "query is required" }, 400)
220
+ const purpose = normalizeMediaPurpose(url.searchParams.get("purpose"))
221
+ const limit = Number(url.searchParams.get("limit") || 12)
222
+ const page = Number(url.searchParams.get("page") || 1)
223
+ try {
224
+ const candidates = await searchRemoteImages({ query, purpose, limit, page })
225
+ session.lastActiveAt = Date.now()
226
+ scheduleIdleStop()
227
+ return jsonResponse({ ok: true, candidates })
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error)
230
+ return jsonResponse({ ok: false, error: message }, 502)
231
+ }
232
+ }
233
+
234
+ async function handleAssetSave(req: Request, session: EditSession): Promise<Response> {
235
+ let body: any
236
+ try {
237
+ body = await req.json()
238
+ } catch {
239
+ return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
240
+ }
241
+
242
+ const candidate = normalizeImageCandidate(body?.candidate ?? body)
243
+ if (!candidate) return jsonResponse({ ok: false, error: "Valid image candidate is required" }, 400)
244
+ const purpose = normalizeMediaPurpose(body?.purpose) || candidate.purpose || "illustration"
245
+ const result = await saveMediaAsset({
246
+ topic: session.deck,
247
+ id: body?.id || candidate.candidateId,
248
+ type: "image",
249
+ purpose,
250
+ brief: body?.brief || `Saved from ${candidate.provider} for Refine asset placement.`,
251
+ status: "success",
252
+ sourceUrl: candidate.imageUrl,
253
+ alt: body?.alt || candidate.alt || candidate.title,
254
+ notes: body?.notes,
255
+ provider: candidate.provider,
256
+ sourcePageUrl: candidate.sourcePageUrl,
257
+ license: candidate.license,
258
+ attribution: candidate.attribution,
259
+ width: candidate.width,
260
+ height: candidate.height,
261
+ }, session.workspaceRoot)
262
+
263
+ session.lastActiveAt = Date.now()
264
+ scheduleIdleStop()
265
+ if (!result.ok) return jsonResponse({ ok: false, error: result.error }, 400)
266
+ if (result.status !== "success" || !result.path) {
267
+ return jsonResponse({ ok: false, error: `Failed to save asset: ${result.status}` }, 400)
268
+ }
269
+ const asset = savedAssetForResult(session, result.assetId)
270
+ if (!asset) return jsonResponse({ ok: false, error: "Saved asset was not found in workspace assets." }, 500)
271
+ return jsonResponse({ ok: true, asset, result })
272
+ }
273
+
274
+ function handleAssetList(session: EditSession): Response {
275
+ session.lastActiveAt = Date.now()
276
+ scheduleIdleStop()
277
+ return jsonResponse({ ok: true, assets: listSavedAssets(session) })
278
+ }
279
+
280
+ function savedAssetForResult(session: EditSession, assetId: string): (MediaAssetRecord & { previewUrl?: string; deckPath?: string }) | null {
281
+ return listSavedAssets(session).find((asset) => asset.id === assetId) ?? null
282
+ }
283
+
284
+ function listSavedAssets(session: EditSession): Array<MediaAssetRecord & { previewUrl?: string; deckPath?: string }> {
285
+ const manifestPath = resolve(session.workspaceRoot, "assets", slugify(session.deck), "media-manifest.json")
286
+ if (!existsSync(manifestPath)) return []
287
+ try {
288
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8")) as { assets?: MediaAssetRecord[] }
289
+ return (Array.isArray(parsed.assets) ? parsed.assets : [])
290
+ .filter((asset) => asset.status === "success" && asset.path)
291
+ .map((asset) => ({
292
+ ...asset,
293
+ previewUrl: asset.path ? assetUrlForRef(asset.path, session, session.workspaceRoot) ?? undefined : undefined,
294
+ deckPath: asset.path ? relative(dirname(session.absoluteFile), resolve(session.workspaceRoot, asset.path)).replace(/\\/g, "/") : undefined,
295
+ }))
296
+ } catch {
297
+ return []
298
+ }
299
+ }
300
+
301
+ function normalizeImageCandidate(input: any): ImageCandidate | null {
302
+ if (!input || typeof input !== "object") return null
303
+ const candidateId = typeof input.candidateId === "string" ? input.candidateId.trim() : ""
304
+ const provider = typeof input.provider === "string" ? input.provider.trim() : ""
305
+ const title = typeof input.title === "string" ? input.title.trim() : ""
306
+ const imageUrl = typeof input.imageUrl === "string" ? input.imageUrl.trim() : ""
307
+ const thumbnailUrl = typeof input.thumbnailUrl === "string" ? input.thumbnailUrl.trim() : imageUrl
308
+ if (!candidateId || !provider || !title || !imageUrl) return null
309
+ return {
310
+ candidateId,
311
+ provider,
312
+ title,
313
+ thumbnailUrl,
314
+ imageUrl,
315
+ sourcePageUrl: typeof input.sourcePageUrl === "string" ? input.sourcePageUrl : undefined,
316
+ width: typeof input.width === "number" ? input.width : undefined,
317
+ height: typeof input.height === "number" ? input.height : undefined,
318
+ alt: typeof input.alt === "string" ? input.alt : undefined,
319
+ license: typeof input.license === "string" ? input.license : undefined,
320
+ attribution: typeof input.attribution === "string" ? input.attribution : undefined,
321
+ purpose: normalizeMediaPurpose(input.purpose),
322
+ }
323
+ }
324
+
325
+ function normalizeMediaPurpose(input: unknown): MediaPurpose | undefined {
326
+ return input === "hero" || input === "illustration" || input === "portrait" || input === "logo" || input === "screenshot"
327
+ ? input
328
+ : undefined
329
+ }
330
+
331
+ function slugify(value: string): string {
332
+ return value
333
+ .toLowerCase()
334
+ .replace(/[^a-z0-9]+/g, "-")
335
+ .replace(/^-+|-+$/g, "")
336
+ }
337
+
196
338
  function handleDeck(session: EditSession): Response {
197
339
  session.assets.clear()
198
340
  session.assetKeys.clear()
@@ -460,6 +602,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
460
602
 
461
603
  const snapshot = normalizeSnapshot(body?.snapshot ?? body)
462
604
  const language = normalizeInspectLanguage(body?.language)
605
+ const comment = typeof body?.comment === "string" ? body.comment.trim().slice(0, 2000) : ""
463
606
  const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
464
607
  const version = readDeckVersion(session).version
465
608
  const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version
@@ -482,6 +625,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
482
625
  requestId,
483
626
  file: session.file,
484
627
  language,
628
+ comment,
485
629
  projection: staleReason
486
630
  ? { ...projection, stale: { stale: true, reason: staleReason } } as any
487
631
  : projection,
@@ -624,95 +768,167 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
624
768
  <style>
625
769
  :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
626
770
  * { box-sizing: border-box; }
627
- body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
771
+ body { margin: 0; background: #eee8dc; color: #1f2933; height: 100vh; overflow: hidden; }
628
772
  body.resizing { cursor: col-resize; user-select: none; }
629
773
  body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
630
774
  .app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
631
- .preview { position: relative; min-width: 0; background: #eef3f8; }
775
+ .preview { position: relative; min-width: 0; background: #e7dfd1; }
632
776
  .resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
633
777
  .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; }
634
778
  .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
635
779
  iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
636
780
  .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
637
- aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); overflow: auto; }
781
+ .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; }
782
+ .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; }
783
+ .deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
784
+ .deck-nav button:disabled { opacity: .38; }
785
+ .deck-nav-status { min-width: 76px; color: #e2e8f0; font-size: 12px; font-weight: 900; text-align: center; font-variant-numeric: tabular-nums; }
786
+ 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; }
638
787
  h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
639
788
  .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
640
- .hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
789
+ .hint { margin: 0; color: #756f66; font-size: 13px; line-height: 1.5; }
641
790
  .panel { display: flex; flex-direction: column; gap: 10px; }
642
- .tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #dbe4ee; border-radius: 14px; background: #f1f5f9; }
643
- .tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #475569; box-shadow: none; font-weight: 900; }
644
- .tab.active { background: #ffffff; color: #0f172a; box-shadow: 0 6px 18px rgba(15,23,42,.08); }
791
+ .tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #d8d2c6; border-radius: 14px; background: #ebe4d8; }
792
+ .tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #5f594f; box-shadow: none; font-weight: 900; }
793
+ .tab.active { background: #fbfaf7; color: #111827; box-shadow: 0 6px 16px rgba(31,41,51,.1); }
645
794
  .tab-panel { display: none; flex-direction: column; gap: 12px; }
646
795
  .tab-panel.active { display: flex; }
647
- .selection-summary { padding: 10px 12px; border: 1px solid #d7e0ea; border-radius: 14px; background: #fff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 24px rgba(15,23,42,.05); }
648
- .selection-summary strong { display: block; margin-bottom: 7px; color: #64748b; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
796
+ .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; }
797
+ .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); }
798
+ .selection-summary strong { display: block; margin-bottom: 7px; color: #756f66; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
649
799
  .selection-chips { display: flex; flex-wrap: wrap; gap: 6px; }
650
- .label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
651
- .comment-editor { width: 100%; min-height: 164px; max-height: 42vh; overflow: auto; padding: 13px 14px; border: 1px solid #d7e0ea; border-radius: 14px; background: #ffffff; color: #0f172a; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; box-shadow: 0 10px 28px rgba(15,23,42,.06); }
652
- .comment-editor:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.12), 0 10px 28px rgba(15,23,42,.06); }
653
- .comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
800
+ .label { color: #756f66; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
801
+ .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); }
802
+ .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); }
803
+ .comment-editor:empty::before { content: attr(data-placeholder); color: #a79d8e; pointer-events: none; }
654
804
  .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; }
655
- .comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
656
- .comment-bubble { border: 1px solid #dbe4ee; border-radius: 14px; padding: 10px 12px; background: #ffffff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 24px rgba(15,23,42,.05); }
657
- .comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
658
- .comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
659
- .comment-bubble.stale { border-color: #facc15; background: #fefce8; }
660
- .comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
805
+ .activity-panel { display: flex; flex-direction: column; gap: 8px; padding-top: 2px; }
806
+ .comment-thread { display: flex; flex-direction: column; gap: 8px; max-height: 24vh; overflow: auto; }
807
+ .comment-thread:empty { display: none; }
808
+ .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); }
809
+ .comment-bubble.sending { border-color: #c8b88f; background: #f7f0df; }
810
+ .comment-bubble.updated { border-color: #9dac8a; background: #f0f2e8; }
811
+ .comment-bubble.stale { border-color: #c6a96a; background: #f8efd7; }
812
+ .comment-bubble.failed { border-color: #c58f82; background: #f7eae5; }
661
813
  .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
662
- .comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
663
- .comment-bubble.updated .comment-bubble-state { color: #15803d; }
664
- .comment-bubble.stale .comment-bubble-state { color: #a16207; }
665
- .comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
814
+ .comment-bubble-state { margin-top: 8px; color: #8a6231; font-size: 12px; font-weight: 800; }
815
+ .comment-bubble.updated .comment-bubble-state { color: #556b3f; }
816
+ .comment-bubble.stale .comment-bubble-state { color: #8a6231; }
817
+ .comment-bubble.failed .comment-bubble-state { color: #8f4638; }
666
818
  .inspect-actions { display: flex; flex-direction: column; gap: 8px; }
667
819
  .inspect-options { display: flex; flex-direction: column; gap: 5px; }
668
- .inspect-options label { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
669
- .inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #d7e0ea; border-radius: 12px; background: #fff; color: #0f172a; font-weight: 700; }
820
+ .inspect-options label { color: #756f66; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
821
+ .inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #d8d2c6; border-radius: 12px; background: #fffdf8; color: #111827; font-weight: 700; }
670
822
  .inspect-cards { display: flex; flex-direction: column; gap: 12px; }
671
- .inspect-card { border: 1px solid #d7e0ea; border-radius: 16px; background: #fff; padding: 13px; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
823
+ .inspect-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
672
824
  .inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
673
825
  .inspect-card h2 { margin: 0; font-size: 13px; color: #0f172a; }
674
- .badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e2e8f0; color: #475569; }
675
- .badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #dcfce7; color: #166534; }
676
- .badge.weak, .badge.missing { background: #fef3c7; color: #92400e; }
677
- .badge.unsupported { background: #fee2e2; color: #991b1b; }
678
- .inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #475569; font-size: 12px; line-height: 1.5; }
679
- .inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f8fafc; color: #334155; font-size: 12px; line-height: 1.45; }
826
+ .badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e8e1d4; color: #5f594f; }
827
+ .badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #e7ecdc; color: #4d6138; }
828
+ .badge.weak, .badge.missing { background: #f1e5c8; color: #765326; }
829
+ .badge.unsupported { background: #f0d9d1; color: #7f3d31; }
830
+ .inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #5f594f; font-size: 12px; line-height: 1.5; }
831
+ .inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f7f3ea; color: #3f3a33; font-size: 12px; line-height: 1.45; }
680
832
  .inspect-warning, .inspect-stale { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
681
- button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #2563eb; color: #ffffff; font-weight: 800; cursor: pointer; box-shadow: 0 10px 24px rgba(37,99,235,.22); }
833
+ .loading-row { display: inline-flex; align-items: center; gap: 8px; }
834
+ .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; }
835
+ button .spinner { width: 15px; height: 15px; border-color: rgba(255,255,255,.36); border-top-color: #fff; }
836
+ .skeleton-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
837
+ .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; }
838
+ .skeleton-line.short { width: 42%; }
839
+ .skeleton-line.medium { width: 68%; }
840
+ .skeleton-line.long { width: 92%; }
841
+ .asset-card.is-saving::after { content: ""; position: absolute; inset: 0; background: rgba(15,23,42,.32); }
842
+ .asset-card.is-saving .asset-save { z-index: 1; }
843
+ @keyframes spin { to { transform: rotate(360deg); } }
844
+ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
845
+ .asset-search { display: grid; grid-template-columns: minmax(0, 1fr) 118px; gap: 8px; }
846
+ .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; }
847
+ .asset-search input:focus, .asset-search select:focus { border-color: #a9793f; box-shadow: 0 0 0 3px rgba(169,121,63,.14); }
848
+ .asset-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
849
+ .asset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
850
+ .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); }
851
+ .asset-card.saved { width: 64px; height: 64px; aspect-ratio: auto; border-radius: 12px; }
852
+ .asset-card[draggable="true"] { cursor: grab; }
853
+ .asset-card[draggable="true"]:active { cursor: grabbing; }
854
+ .asset-thumb { width: 100%; height: 100%; display: block; background: #eee8dc; object-fit: contain; }
855
+ .asset-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
856
+ .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; }
857
+ .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); }
858
+ .asset-search-view.open { transform: translateX(0); }
859
+ .asset-search-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
860
+ .asset-search-title { display: flex; flex-direction: column; gap: 2px; }
861
+ .asset-search-title h2 { margin: 0; color: #0f172a; font-size: 16px; letter-spacing: -.01em; }
862
+ .asset-search-title span { color: #756f66; font-size: 12px; line-height: 1.35; }
863
+ .asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #ebe4d8; color: #111827; box-shadow: none; }
864
+ .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; }
865
+ .asset-empty { grid-column: 1 / -1; margin: 0; color: #756f66; font-size: 12px; line-height: 1.45; }
866
+ .edit-assets { padding: 10px; border: 1px solid #d8d2c6; border-radius: 16px; background: #f7f3ea; }
867
+ .edit-assets .panel { gap: 8px; }
868
+ .edit-assets .asset-grid { grid-template-columns: repeat(auto-fill, 64px); align-items: start; max-height: 176px; overflow: auto; }
869
+ .edit-assets .asset-thumb { width: 64px; height: 64px; }
870
+ .drop-active .hitbox { background: rgba(169,121,63,.1); outline: 2px dashed rgba(169,121,63,.48); outline-offset: -10px; }
871
+ 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); }
872
+ button:hover:not(:disabled) { background: #e3dacb; }
682
873
  button:disabled { cursor: not-allowed; opacity: .5; }
683
- .status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
684
- @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } }
874
+ .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; }
875
+ .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); }
876
+ .primary-action:active:not(:disabled) { transform: translateY(0); box-shadow: 0 9px 20px rgba(31,41,51,.22); }
877
+ .send-icon { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
878
+ .status { min-height: 20px; color: #5f594f; font-size: 13px; line-height: 1.45; }
879
+ @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; } }
685
880
  </style>
686
881
  </head>
687
882
  <body>
688
883
  <main class="app">
689
- <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div></section>
884
+ <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></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>
690
885
  <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>
691
886
  <aside>
692
887
  <div>
693
888
  <h1><span class="wordmark">REVELA</span> Refine</h1>
694
- <p class="hint">Cmd/Ctrl-click slide elements once, then use Edit for fast changes or Inspect for Narrative Reading, Exploratory Reading, Source, and Purpose review.</p>
889
+ <p class="hint">Select refs, describe the change, then send. Use Inspect only when you need source or purpose context.</p>
695
890
  </div>
696
- <div id="selectionSummary" class="selection-summary"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
891
+ <div id="selectionSummary" class="selection-summary sr-only" aria-live="polite"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
697
892
  <div class="tabs" role="tablist" aria-label="Refine mode">
698
893
  <button id="editTab" class="tab" type="button" role="tab">Edit</button>
699
894
  <button id="inspectTab" class="tab" type="button" role="tab">Inspect</button>
700
895
  </div>
701
896
  <div id="editPanel" class="tab-panel">
702
897
  <div class="panel">
703
- <div class="label">Comment</div>
704
- <div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example: Cmd/Ctrl-click to ref the chart title, then ask to make it shorter and align it with the KPI row."></div>
898
+ <div class="label">Describe the change</div>
899
+ <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>
705
900
  </div>
706
- <div id="commentThread" class="comment-thread" aria-live="polite"></div>
707
- <button id="send" disabled>Send Edit</button>
901
+ <div class="edit-assets" aria-label="Edit assets">
902
+ <div class="panel">
903
+ <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>
904
+ <div id="editSavedAssets" class="asset-grid"><p class="asset-empty">No local assets yet. Click + to search assets.</p></div>
905
+ </div>
906
+ </div>
907
+ <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>
908
+ <div class="activity-panel"><div class="label">Activity</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
708
909
  </div>
709
910
  <div id="inspectPanel" class="tab-panel">
911
+ <div class="panel">
912
+ <label class="label" for="inspectComment">Inspect comment</label>
913
+ <div id="inspectComment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Cmd/Ctrl-click slide elements to add @refs, then ask about purpose or source."></div>
914
+ </div>
710
915
  <div class="inspect-actions">
711
916
  <div class="inspect-options"><label for="inspectLanguage">Display Language</label><select id="inspectLanguage" class="inspect-select"><option>Auto</option><option>English</option><option>简体中文</option><option>繁體中文</option><option>日本語</option><option>Deutsch</option><option>Français</option><option>Español</option><option>Português</option><option>Arabic</option></select></div>
712
- <button id="inspectButton" disabled>Inspect Selection</button>
917
+ <button id="inspectButton" disabled>Inspect Reference</button>
713
918
  <div id="inspectStale"></div>
714
919
  </div>
715
- <div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select one or more deck elements, then inspect them for Narrative Reading, Exploratory Reading, Source, and Purpose. This does not edit the deck.</div></div>
920
+ <div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select a deck element to create an @ref, optionally ask a question, then Inspect. This does not edit the deck.</div></div>
921
+ </div>
922
+ <div id="assetSearchView" class="asset-search-view" aria-hidden="true">
923
+ <div class="asset-search-head">
924
+ <button id="assetSearchBack" class="asset-back" type="button">← Back</button>
925
+ <div class="asset-search-title"><h2>Search Assets</h2><span>Save images to Local Assets, then use them from Edit.</span></div>
926
+ </div>
927
+ <div class="panel">
928
+ <div class="asset-search"><input id="assetQuery" type="search" placeholder="Company logo, product photo, portrait..." /><select id="assetPurpose"><option value="logo" selected>logo</option><option value="illustration">photo</option><option value="hero">hero</option><option value="portrait">portrait</option><option value="screenshot">screenshot</option></select></div>
929
+ <div class="asset-actions"><button id="assetSearchButton" type="button">Search Assets</button><button id="assetShuffleButton" type="button" disabled>Refresh</button></div>
930
+ <div id="assetResults" class="asset-grid"><p class="asset-empty">Search image candidates, then save one to the workspace.</p></div>
931
+ </div>
716
932
  </div>
717
933
  <div id="status" class="status"></div>
718
934
  </aside>
@@ -752,16 +968,34 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
752
968
  bound: false,
753
969
  commentRange: null,
754
970
  resizeDrag: null,
971
+ deckSlideIndex: 0,
972
+ deckSlideCount: 0,
755
973
  mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
756
974
  inspecting: false,
757
975
  activeInspectRequestId: '',
758
976
  inspectLanguage: 'Auto',
759
977
  inspectFallback: null,
978
+ sendingEdit: false,
979
+ assetCandidates: [],
980
+ savedAssets: [],
981
+ selectedAsset: null,
982
+ draggingAsset: null,
983
+ assetDropTarget: null,
984
+ assetDropOutline: null,
985
+ assetSearchBusy: false,
986
+ assetSavingIndex: -1,
987
+ assetSearchPage: 1,
988
+ assetSearchKey: '',
989
+ assetVisibleCount: 0,
990
+ assetPendingCount: 0,
760
991
  };
761
992
  const els = {
762
993
  frame: null,
763
994
  hitbox: null,
764
995
  resizeHandle: null,
996
+ deckPrev: null,
997
+ deckNext: null,
998
+ deckCounter: null,
765
999
  selectionSummary: null,
766
1000
  selectionChips: null,
767
1001
  editTab: null,
@@ -771,10 +1005,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
771
1005
  comment: null,
772
1006
  commentThread: null,
773
1007
  send: null,
1008
+ inspectComment: null,
774
1009
  inspectButton: null,
775
1010
  inspectLanguage: null,
776
1011
  inspectCards: null,
777
1012
  inspectStale: null,
1013
+ assetSearchToggle: null,
1014
+ assetSearchBack: null,
1015
+ assetSearchView: null,
1016
+ assetQuery: null,
1017
+ assetPurpose: null,
1018
+ assetSearchButton: null,
1019
+ assetShuffleButton: null,
1020
+ assetResults: null,
1021
+ editSavedAssets: null,
778
1022
  status: null,
779
1023
  };
780
1024
 
@@ -792,6 +1036,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
792
1036
  els.frame = document.getElementById('deck');
793
1037
  els.hitbox = document.getElementById('hitbox');
794
1038
  els.resizeHandle = document.getElementById('resizeHandle');
1039
+ els.deckPrev = document.getElementById('deckPrev');
1040
+ els.deckNext = document.getElementById('deckNext');
1041
+ els.deckCounter = document.getElementById('deckCounter');
795
1042
  els.selectionSummary = document.getElementById('selectionSummary');
796
1043
  els.selectionChips = document.getElementById('selectionChips');
797
1044
  els.editTab = document.getElementById('editTab');
@@ -801,14 +1048,24 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
801
1048
  els.comment = document.getElementById('comment');
802
1049
  els.commentThread = document.getElementById('commentThread');
803
1050
  els.send = document.getElementById('send');
1051
+ els.inspectComment = document.getElementById('inspectComment');
804
1052
  els.inspectButton = document.getElementById('inspectButton');
805
1053
  els.inspectCards = document.getElementById('inspectCards');
806
1054
  els.inspectStale = document.getElementById('inspectStale');
1055
+ els.assetSearchToggle = document.getElementById('assetSearchToggle');
1056
+ els.assetSearchBack = document.getElementById('assetSearchBack');
1057
+ els.assetSearchView = document.getElementById('assetSearchView');
1058
+ els.assetQuery = document.getElementById('assetQuery');
1059
+ els.assetPurpose = document.getElementById('assetPurpose');
1060
+ els.assetSearchButton = document.getElementById('assetSearchButton');
1061
+ els.assetShuffleButton = document.getElementById('assetShuffleButton');
1062
+ els.assetResults = document.getElementById('assetResults');
1063
+ els.editSavedAssets = document.getElementById('editSavedAssets');
807
1064
  els.status = document.getElementById('status');
808
1065
 
809
1066
  els.inspectLanguage = document.getElementById('inspectLanguage');
810
1067
 
811
- if (!els.frame || !els.hitbox || !els.resizeHandle || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.status) {
1068
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !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) {
812
1069
  throw new Error('Editor boot failed: required DOM nodes are missing.');
813
1070
  }
814
1071
 
@@ -817,6 +1074,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
817
1074
  setMode(state.mode);
818
1075
  setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
819
1076
  initFrame();
1077
+ loadSavedAssets();
820
1078
  startDeckVersionPolling();
821
1079
  } catch (error) {
822
1080
  reportError(error);
@@ -828,15 +1086,34 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
828
1086
  state.bound = true;
829
1087
  els.frame.addEventListener('load', initFrame);
830
1088
  document.addEventListener('keydown', (event) => {
831
- if (event.key === 'Escape') clearHover();
1089
+ if (event.key === 'Escape') {
1090
+ clearHover();
1091
+ return;
1092
+ }
1093
+ if (isTextInputTarget(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
1094
+ if (['ArrowDown', 'ArrowRight', ' ', 'PageDown'].includes(event.key)) {
1095
+ event.preventDefault();
1096
+ nextDeckSlide();
1097
+ } else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(event.key)) {
1098
+ event.preventDefault();
1099
+ prevDeckSlide();
1100
+ }
832
1101
  });
833
1102
  els.comment.addEventListener('input', () => {
834
1103
  saveCommentRange();
835
- syncReferencesFromComment(false);
1104
+ syncReferencesFromComment(false, els.comment);
1105
+ syncSelectedAssetFromComment();
836
1106
  updateSendState();
837
1107
  });
838
1108
  els.comment.addEventListener('keyup', saveCommentRange);
839
1109
  els.comment.addEventListener('mouseup', saveCommentRange);
1110
+ els.inspectComment.addEventListener('input', () => {
1111
+ saveCommentRange();
1112
+ syncReferencesFromComment(false, els.inspectComment);
1113
+ updateSendState();
1114
+ });
1115
+ els.inspectComment.addEventListener('keyup', saveCommentRange);
1116
+ els.inspectComment.addEventListener('mouseup', saveCommentRange);
840
1117
  document.addEventListener('selectionchange', saveCommentRange);
841
1118
  els.hitbox.addEventListener('pointermove', onHover);
842
1119
  els.hitbox.addEventListener('pointerdown', onPointerDown);
@@ -854,13 +1131,29 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
854
1131
  }, { passive: false });
855
1132
  els.resizeHandle.addEventListener('pointerdown', startEditorResize);
856
1133
  els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
1134
+ els.deckPrev.addEventListener('click', prevDeckSlide);
1135
+ els.deckNext.addEventListener('click', nextDeckSlide);
857
1136
  els.send.addEventListener('click', sendComment);
858
1137
  els.inspectButton.addEventListener('click', inspectCurrentSelection);
1138
+ els.assetSearchToggle.addEventListener('click', toggleAssetSearchPanel);
1139
+ els.assetSearchBack.addEventListener('click', closeAssetSearchPanel);
1140
+ els.assetSearchButton.addEventListener('click', () => searchAssets(false));
1141
+ els.assetShuffleButton.addEventListener('click', () => searchAssets(true));
1142
+ els.assetQuery.addEventListener('keydown', (event) => {
1143
+ if (event.key === 'Enter') {
1144
+ event.preventDefault();
1145
+ searchAssets(false);
1146
+ }
1147
+ });
1148
+ els.assetPurpose.addEventListener('change', () => resetAssetSearchBatch());
859
1149
  els.inspectLanguage.addEventListener('change', () => {
860
1150
  state.inspectLanguage = els.inspectLanguage.value || 'Auto';
861
1151
  });
862
1152
  els.editTab.addEventListener('click', () => setMode('edit'));
863
1153
  els.inspectTab.addEventListener('click', () => setMode('inspect'));
1154
+ els.hitbox.addEventListener('dragover', onAssetDragOver);
1155
+ els.hitbox.addEventListener('dragleave', onAssetDragLeave);
1156
+ els.hitbox.addEventListener('drop', onAssetDrop);
864
1157
  }
865
1158
 
866
1159
  function setMode(mode) {
@@ -869,9 +1162,31 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
869
1162
  els.inspectTab.classList.toggle('active', state.mode === 'inspect');
870
1163
  els.editPanel.classList.toggle('active', state.mode === 'edit');
871
1164
  els.inspectPanel.classList.toggle('active', state.mode === 'inspect');
1165
+ saveCommentRange();
872
1166
  updateSendState();
873
1167
  }
874
1168
 
1169
+ function activeCommentEditor() {
1170
+ return state.mode === 'inspect' ? els.inspectComment : els.comment;
1171
+ }
1172
+
1173
+ function toggleAssetSearchPanel() {
1174
+ const open = !els.assetSearchView.classList.contains('open');
1175
+ setAssetSearchOpen(open);
1176
+ }
1177
+
1178
+ function closeAssetSearchPanel() {
1179
+ setAssetSearchOpen(false);
1180
+ }
1181
+
1182
+ function setAssetSearchOpen(open) {
1183
+ els.assetSearchView.classList.toggle('open', open);
1184
+ els.assetSearchView.setAttribute('aria-hidden', open ? 'false' : 'true');
1185
+ els.assetSearchToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
1186
+ els.assetSearchToggle.textContent = '+';
1187
+ if (open) els.assetQuery.focus();
1188
+ }
1189
+
875
1190
  function restoreEditorWidth() {
876
1191
  try {
877
1192
  const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
@@ -934,12 +1249,15 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
934
1249
  clearReferences(false);
935
1250
  state.hoverEl = null;
936
1251
  state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
1252
+ state.assetDropTarget = null;
1253
+ state.assetDropOutline = createOutline(doc, '#a9793f', 'rgba(169,121,63,.16)');
937
1254
  state.referenceOutlines = [];
938
1255
  doc.addEventListener('scroll', () => {
939
1256
  renderHoverOutline(state.hoverEl);
940
1257
  renderReferenceOutlines();
941
1258
  }, true);
942
1259
  const slides = getSlides(doc);
1260
+ syncDeckNavigation();
943
1261
  updateSendState();
944
1262
  if (state.pendingRefreshMessage) {
945
1263
  state.pendingRefreshMessage = false;
@@ -952,6 +1270,102 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
952
1270
  }
953
1271
  }
954
1272
 
1273
+ function isTextInputTarget(target) {
1274
+ if (!target || !(target instanceof Element)) return false;
1275
+ const tag = target.tagName.toLowerCase();
1276
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable || Boolean(target.closest('[contenteditable="true"]'));
1277
+ }
1278
+
1279
+ function syncDeckNavigation() {
1280
+ try {
1281
+ const doc = els.frame.contentDocument;
1282
+ const slides = doc ? getSlides(doc) : [];
1283
+ state.deckSlideCount = slides.length;
1284
+ state.deckSlideIndex = Math.max(0, Math.min(state.deckSlideIndex, Math.max(0, slides.length - 1)));
1285
+ updateDeckNavControls();
1286
+ } catch {
1287
+ state.deckSlideCount = 0;
1288
+ state.deckSlideIndex = 0;
1289
+ updateDeckNavControls();
1290
+ }
1291
+ }
1292
+
1293
+ function updateDeckNavControls() {
1294
+ const total = state.deckSlideCount;
1295
+ const current = total > 0 ? state.deckSlideIndex + 1 : 0;
1296
+ els.deckCounter.textContent = total > 0 ? current + ' / ' + total : '-- / --';
1297
+ els.deckPrev.disabled = total <= 1 || state.deckSlideIndex <= 0;
1298
+ els.deckNext.disabled = total <= 1 || state.deckSlideIndex >= total - 1;
1299
+ }
1300
+
1301
+ function prevDeckSlide() {
1302
+ goToDeckSlide(state.deckSlideIndex - 1);
1303
+ }
1304
+
1305
+ function nextDeckSlide() {
1306
+ goToDeckSlide(state.deckSlideIndex + 1);
1307
+ }
1308
+
1309
+ function goToDeckSlide(index) {
1310
+ try {
1311
+ const doc = els.frame.contentDocument;
1312
+ const win = els.frame.contentWindow;
1313
+ if (!doc || !win) return;
1314
+ const slides = getSlides(doc);
1315
+ if (!slides.length) {
1316
+ syncDeckNavigation();
1317
+ return;
1318
+ }
1319
+ const clamped = Math.max(0, Math.min(slides.length - 1, index));
1320
+ const nav = win.RevelaDeckNav;
1321
+ let handled = false;
1322
+ if (nav && typeof nav.goTo === 'function') {
1323
+ try {
1324
+ nav.goTo(clamped);
1325
+ handled = true;
1326
+ } catch {}
1327
+ } else if (nav && clamped > state.deckSlideIndex && typeof nav.next === 'function') {
1328
+ try {
1329
+ nav.next();
1330
+ handled = true;
1331
+ } catch {}
1332
+ } else if (nav && clamped < state.deckSlideIndex && typeof nav.prev === 'function') {
1333
+ try {
1334
+ nav.prev();
1335
+ handled = true;
1336
+ } catch {}
1337
+ }
1338
+ if (!handled) applyFallbackDeckNavigation(win, doc, slides, clamped);
1339
+ state.deckSlideIndex = clamped;
1340
+ updateDeckNavControls();
1341
+ renderHoverOutline(state.hoverEl);
1342
+ renderReferenceOutlines();
1343
+ } catch (error) {
1344
+ reportError(error);
1345
+ }
1346
+ }
1347
+
1348
+ function applyFallbackDeckNavigation(win, doc, slides, index) {
1349
+ const target = slides[index];
1350
+ const usesOverlaySlides = slides.some((slide) => {
1351
+ const style = win.getComputedStyle(slide);
1352
+ return style.position === 'absolute' || style.position === 'fixed' || style.opacity === '0' || slide.style.opacity !== '';
1353
+ });
1354
+ if (usesOverlaySlides) {
1355
+ slides.forEach((slide, i) => {
1356
+ slide.style.opacity = i === index ? '1' : '0';
1357
+ slide.style.pointerEvents = i === index ? 'auto' : 'none';
1358
+ });
1359
+ win.scrollTo?.(0, 0);
1360
+ return;
1361
+ }
1362
+ if (target && typeof target.scrollIntoView === 'function') {
1363
+ target.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
1364
+ return;
1365
+ }
1366
+ doc.defaultView?.scrollTo?.(0, index * win.innerHeight);
1367
+ }
1368
+
955
1369
  function startDeckVersionPolling() {
956
1370
  pollDeckVersion();
957
1371
  window.setInterval(pollDeckVersion, 2000);
@@ -987,6 +1401,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
987
1401
  clearReferences(true);
988
1402
  state.hoverEl = null;
989
1403
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
1404
+ state.assetDropTarget = null;
1405
+ if (state.assetDropOutline) state.assetDropOutline.style.display = 'none';
990
1406
  state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
991
1407
  state.referenceOutlines = [];
992
1408
  updateSendState();
@@ -1038,34 +1454,353 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1038
1454
  }
1039
1455
 
1040
1456
  async function sendComment() {
1041
- syncReferencesFromComment(false);
1457
+ syncReferencesFromComment(false, els.comment);
1458
+ syncSelectedAssetFromComment();
1042
1459
  const text = getCommentText().trim();
1043
1460
  if (!text) return;
1044
1461
  const elements = state.references.map((reference) => reference.payload);
1462
+ const asset = state.selectedAsset || undefined;
1045
1463
  const commentId = addPendingComment(text, elements, 'sending');
1046
1464
  clearReferences(false);
1465
+ state.selectedAsset = null;
1047
1466
  els.comment.textContent = '';
1048
1467
  renderReferenceOutlines();
1049
- els.send.disabled = true;
1468
+ state.sendingEdit = true;
1469
+ updateSendState();
1050
1470
  setStatus('Sending...');
1051
1471
  try {
1052
1472
  const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
1053
1473
  method: 'POST',
1054
1474
  headers: { 'content-type': 'application/json' },
1055
- body: JSON.stringify({ comment: text, elements }),
1475
+ body: JSON.stringify({ comment: text, elements, asset }),
1056
1476
  });
1057
1477
  const body = await res.json().catch(() => ({}));
1058
1478
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
1059
1479
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
1060
1480
  if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
1481
+ state.sendingEdit = false;
1061
1482
  updateSendState();
1062
1483
  } catch (error) {
1063
1484
  updatePendingCommentStatus(commentId, 'failed');
1485
+ state.sendingEdit = false;
1064
1486
  reportError(error);
1065
1487
  updateSendState();
1066
1488
  }
1067
1489
  }
1068
1490
 
1491
+ async function searchAssets(nextBatch) {
1492
+ const query = (els.assetQuery.value || '').trim();
1493
+ if (!query || state.assetSearchBusy) return;
1494
+ const key = query + '\u0000' + (els.assetPurpose.value || 'illustration');
1495
+ if (!nextBatch || state.assetSearchKey !== key) {
1496
+ state.assetSearchPage = 1;
1497
+ state.assetSearchKey = key;
1498
+ } else {
1499
+ state.assetSearchPage += 1;
1500
+ }
1501
+ state.assetSearchBusy = true;
1502
+ els.assetSearchButton.disabled = true;
1503
+ els.assetShuffleButton.disabled = true;
1504
+ renderAssetSearchLoading(nextBatch ? 'Searching another batch...' : 'Searching remote image sources...');
1505
+ setButtonLoading(els.assetSearchButton, true, 'Searching...');
1506
+ setStatus(nextBatch ? 'Searching another asset batch...' : 'Searching assets...');
1507
+ try {
1508
+ const params = new URLSearchParams({ query, purpose: els.assetPurpose.value || 'illustration', limit: '24', page: String(state.assetSearchPage) });
1509
+ const res = await fetch('/api/assets/search?token=' + encodeURIComponent(token) + '&' + params.toString(), { cache: 'no-store' });
1510
+ const body = await res.json().catch(() => ({}));
1511
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Asset search failed');
1512
+ state.assetCandidates = Array.isArray(body.candidates) ? body.candidates : [];
1513
+ renderAssetCandidates();
1514
+ setStatus(state.assetCandidates.length ? 'Asset search complete. Click + to save an asset to the workspace.' : (nextBatch ? 'No more assets found. Try another query or purpose.' : 'No assets found. Try another query or purpose.'));
1515
+ } catch (error) {
1516
+ if (nextBatch) state.assetSearchPage = Math.max(1, state.assetSearchPage - 1);
1517
+ els.assetResults.innerHTML = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
1518
+ reportError(error);
1519
+ } finally {
1520
+ state.assetSearchBusy = false;
1521
+ setButtonLoading(els.assetSearchButton, false, 'Search Assets');
1522
+ updateAssetShuffleState();
1523
+ }
1524
+ }
1525
+
1526
+ function renderAssetSearchLoading(message) {
1527
+ els.assetResults.innerHTML = '<p class="asset-empty"><span class="loading-row"><span class="spinner" aria-hidden="true"></span>' + escapeHtml(message) + '</span></p>'
1528
+ + Array.from({ length: 8 }, () => '<div class="skeleton-card asset-skeleton"><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>').join('');
1529
+ }
1530
+
1531
+ function resetAssetSearchBatch() {
1532
+ state.assetSearchPage = 1;
1533
+ state.assetSearchKey = '';
1534
+ updateAssetShuffleState();
1535
+ }
1536
+
1537
+ function updateAssetShuffleState() {
1538
+ els.assetShuffleButton.disabled = state.assetSearchBusy || !state.assetCandidates.length;
1539
+ }
1540
+
1541
+ function renderAssetCandidates() {
1542
+ els.assetResults.textContent = '';
1543
+ state.assetVisibleCount = 0;
1544
+ state.assetPendingCount = state.assetCandidates.length;
1545
+ if (!state.assetCandidates.length) {
1546
+ els.assetResults.innerHTML = '<p class="asset-empty">No assets found. Try another query or purpose.</p>';
1547
+ return;
1548
+ }
1549
+ state.assetCandidates.forEach((candidate, index) => {
1550
+ const card = assetCard(candidate, false, index);
1551
+ if (state.assetSavingIndex === index) {
1552
+ card.classList.add('is-saving');
1553
+ appendAssetSaveButton(card, 'Saving...', 'Saving to workspace', () => {}, true);
1554
+ } else {
1555
+ appendAssetSaveButton(card, 'Save', 'Save to workspace', () => saveCandidate(index));
1556
+ }
1557
+ els.assetResults.appendChild(card);
1558
+ });
1559
+ updateAssetShuffleState();
1560
+ }
1561
+
1562
+ async function saveCandidate(index) {
1563
+ const candidate = state.assetCandidates[index];
1564
+ if (!candidate) return;
1565
+ state.assetSavingIndex = index;
1566
+ renderAssetCandidates();
1567
+ setStatus('Saving asset to workspace...');
1568
+ try {
1569
+ const res = await fetch('/api/assets/save?token=' + encodeURIComponent(token), {
1570
+ method: 'POST',
1571
+ headers: { 'content-type': 'application/json' },
1572
+ body: JSON.stringify({ candidate, purpose: els.assetPurpose.value || candidate.purpose || 'illustration' }),
1573
+ });
1574
+ const body = await res.json().catch(() => ({}));
1575
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset');
1576
+ await loadSavedAssets();
1577
+ const path = body.asset && (body.asset.path || body.asset.deckPath);
1578
+ setStatus(path ? 'Saved to ' + path + '. Use it from Local Assets.' : 'Asset saved. Use it from Local Assets.');
1579
+ } catch (error) {
1580
+ reportError(error);
1581
+ } finally {
1582
+ state.assetSavingIndex = -1;
1583
+ renderAssetCandidates();
1584
+ }
1585
+ }
1586
+
1587
+ async function loadSavedAssets() {
1588
+ try {
1589
+ const res = await fetch('/api/assets/list?token=' + encodeURIComponent(token), { cache: 'no-store' });
1590
+ const body = await res.json().catch(() => ({}));
1591
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to list assets');
1592
+ state.savedAssets = Array.isArray(body.assets) ? body.assets : [];
1593
+ renderSavedAssets();
1594
+ } catch (error) {
1595
+ const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
1596
+ els.editSavedAssets.innerHTML = message;
1597
+ }
1598
+ }
1599
+
1600
+ function renderSavedAssets() {
1601
+ renderSavedAssetGrid(els.editSavedAssets, 'No local assets yet. Click + to search assets.');
1602
+ }
1603
+
1604
+ function renderSavedAssetGrid(container, emptyMessage) {
1605
+ container.textContent = '';
1606
+ if (!state.savedAssets.length) {
1607
+ container.innerHTML = '<p class="asset-empty">' + escapeHtml(emptyMessage) + '</p>';
1608
+ return;
1609
+ }
1610
+ state.savedAssets.forEach((asset) => {
1611
+ const card = assetCard(asset, true, 0);
1612
+ card.draggable = true;
1613
+ card.addEventListener('click', () => addAssetToComment(asset));
1614
+ card.addEventListener('dragstart', (event) => {
1615
+ state.draggingAsset = asset;
1616
+ event.dataTransfer?.setData('application/revela-asset-id', asset.id || '');
1617
+ event.dataTransfer?.setData('text/plain', asset.path || asset.id || '');
1618
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
1619
+ });
1620
+ card.addEventListener('dragend', () => {
1621
+ state.draggingAsset = null;
1622
+ document.body.classList.remove('drop-active');
1623
+ });
1624
+ container.appendChild(card);
1625
+ });
1626
+ }
1627
+
1628
+ function assetCard(asset, saved, index) {
1629
+ const card = document.createElement('div');
1630
+ card.className = saved ? 'asset-card saved' : 'asset-card';
1631
+ const image = document.createElement('img');
1632
+ image.className = 'asset-thumb';
1633
+ image.loading = !saved && index < 8 ? 'eager' : 'lazy';
1634
+ image.decoding = 'async';
1635
+ image.alt = asset.alt || asset.title || asset.id || 'Image asset';
1636
+ if (!saved) {
1637
+ image.addEventListener('load', () => markAssetImageLoaded());
1638
+ image.addEventListener('error', () => hideBrokenAssetCard(card));
1639
+ }
1640
+ image.src = saved ? (asset.previewUrl || '') : (asset.thumbnailUrl || asset.imageUrl || '');
1641
+ card.title = asset.title || asset.id || asset.alt || 'Image asset';
1642
+ card.appendChild(image);
1643
+ return card;
1644
+ }
1645
+
1646
+ function markAssetImageLoaded() {
1647
+ state.assetVisibleCount += 1;
1648
+ state.assetPendingCount = Math.max(0, state.assetPendingCount - 1);
1649
+ }
1650
+
1651
+ function hideBrokenAssetCard(card) {
1652
+ card.remove();
1653
+ state.assetPendingCount = Math.max(0, state.assetPendingCount - 1);
1654
+ if (!state.assetVisibleCount && !state.assetPendingCount) {
1655
+ els.assetResults.innerHTML = '<p class="asset-empty">No displayable images found. Try Refresh or another purpose.</p>';
1656
+ }
1657
+ }
1658
+
1659
+ function appendAssetSaveButton(card, text, label, onClick, loading) {
1660
+ const button = document.createElement('button');
1661
+ button.type = 'button';
1662
+ button.className = 'asset-save';
1663
+ button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(text) + '</span>' : escapeHtml(text);
1664
+ button.disabled = !!loading;
1665
+ button.setAttribute('aria-label', label);
1666
+ button.title = label;
1667
+ button.addEventListener('click', onClick);
1668
+ card.appendChild(button);
1669
+ }
1670
+
1671
+ function addAssetToComment(asset) {
1672
+ if (!asset) return;
1673
+ state.selectedAsset = asset;
1674
+ removeAssetChip();
1675
+ const intro = els.comment.textContent && !/\s$/.test(els.comment.textContent) ? ' Use asset ' : 'Use asset ';
1676
+ insertPlainText(intro);
1677
+ insertAssetChip(asset);
1678
+ insertPlainText(' ');
1679
+ setMode('edit');
1680
+ updateSendState();
1681
+ setStatus('Asset added to the Edit comment. Describe where or how to use it, then Apply Fix.');
1682
+ }
1683
+
1684
+ function onAssetDragOver(event) {
1685
+ if (!state.draggingAsset) return;
1686
+ event.preventDefault();
1687
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
1688
+ document.body.classList.add('drop-active');
1689
+ const placement = collectAssetPlacement(event, state.draggingAsset);
1690
+ renderAssetDropTarget(placement);
1691
+ }
1692
+
1693
+ function onAssetDragLeave() {
1694
+ document.body.classList.remove('drop-active');
1695
+ renderAssetDropTarget(null);
1696
+ }
1697
+
1698
+ async function onAssetDrop(event) {
1699
+ const asset = state.draggingAsset || findSavedAsset(event.dataTransfer?.getData('application/revela-asset-id'));
1700
+ if (!asset) return;
1701
+ event.preventDefault();
1702
+ document.body.classList.remove('drop-active');
1703
+ renderAssetDropTarget(null);
1704
+ const placement = collectAssetPlacement(event, asset);
1705
+ if (!placement) {
1706
+ setStatus('Drop the asset onto a deck slide.');
1707
+ return;
1708
+ }
1709
+ await sendAssetPlacement(asset, placement);
1710
+ }
1711
+
1712
+ function findSavedAsset(id) {
1713
+ return state.savedAssets.find((asset) => asset.id === id) || null;
1714
+ }
1715
+
1716
+ function collectAssetPlacement(event, asset) {
1717
+ initFrame();
1718
+ const doc = els.frame.contentDocument;
1719
+ const win = els.frame.contentWindow;
1720
+ if (!doc || !win) return null;
1721
+ const frameRect = els.frame.getBoundingClientRect();
1722
+ const frameX = event.clientX - frameRect.left;
1723
+ const frameY = event.clientY - frameRect.top;
1724
+ const rawTarget = doc.elementFromPoint(frameX, frameY);
1725
+ const target = selectable(rawTarget);
1726
+ const slide = findSlide(target) || findSlide(rawTarget);
1727
+ if (!slide) return null;
1728
+ const slides = getSlides(doc);
1729
+ const explicitSlideIndex = Number(slide.getAttribute('data-slide-index'));
1730
+ const slideIndex = Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : slides.indexOf(slide) + 1;
1731
+ const slideRect = slide.getBoundingClientRect();
1732
+ const x = slideRect.width ? Math.max(0, Math.min(1, (frameX - slideRect.left) / slideRect.width)) : 0;
1733
+ const y = slideRect.height ? Math.max(0, Math.min(1, (frameY - slideRect.top) / slideRect.height)) : 0;
1734
+ const targetPayload = target && target !== slide ? collectPayload(target) : null;
1735
+ const targetMode = targetPayload
1736
+ ? (target && target.tagName && target.tagName.toLowerCase() === 'img' ? 'replace' : 'insert-into')
1737
+ : 'add';
1738
+ return {
1739
+ slideIndex,
1740
+ x,
1741
+ y,
1742
+ viewport: { width: win.innerWidth, height: win.innerHeight },
1743
+ nearbyText: (slide.innerText || slide.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 1000),
1744
+ targetMode,
1745
+ target: targetPayload,
1746
+ targetLabel: targetMode === 'replace' ? 'Replace image' : targetMode === 'insert-into' ? 'Insert into this element' : 'Add near here',
1747
+ targetTagName: target?.tagName?.toLowerCase(),
1748
+ };
1749
+ }
1750
+
1751
+ function renderAssetDropTarget(placement) {
1752
+ if (!state.assetDropOutline) return;
1753
+ const target = placement?.target ? elementFromPayload(placement.target) : null;
1754
+ state.assetDropTarget = target;
1755
+ renderBox(state.assetDropOutline, target);
1756
+ if (placement?.targetLabel) setStatus(placement.targetLabel + '. Drop to send an asset placement comment.');
1757
+ }
1758
+
1759
+ function elementFromPayload(payload) {
1760
+ const doc = els.frame.contentDocument;
1761
+ if (!doc || !payload) return null;
1762
+ const slides = getSlides(doc);
1763
+ const slide = slides.find((item, index) => {
1764
+ const explicit = Number(item.getAttribute('data-slide-index'));
1765
+ const slideIndex = Number.isFinite(explicit) && explicit > 0 ? explicit : index + 1;
1766
+ return slideIndex === payload.slideIndex;
1767
+ });
1768
+ if (!slide) return null;
1769
+ if (payload.selector) {
1770
+ try {
1771
+ const selected = slide.querySelector(payload.selector);
1772
+ if (selected) return selected;
1773
+ } catch {}
1774
+ }
1775
+ return slide;
1776
+ }
1777
+
1778
+ async function sendAssetPlacement(asset, placement) {
1779
+ const modeText = placement.targetMode === 'replace'
1780
+ ? 'replace the image at the drop target'
1781
+ : placement.targetMode === 'insert-into'
1782
+ ? 'insert it into the target element'
1783
+ : 'add it near the drop point';
1784
+ 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.';
1785
+ const elements = placement.target ? [placement.target] : [];
1786
+ const commentId = addPendingComment(comment, elements, 'sending');
1787
+ setStatus('Sending asset placement comment...');
1788
+ try {
1789
+ const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
1790
+ method: 'POST',
1791
+ headers: { 'content-type': 'application/json' },
1792
+ body: JSON.stringify({ comment, elements, asset, drop: placement }),
1793
+ });
1794
+ const body = await res.json().catch(() => ({}));
1795
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send asset placement');
1796
+ updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
1797
+ if (pendingCommentStatus(commentId) !== 'updated') setStatus('Asset placement sent. Waiting for deck update...');
1798
+ } catch (error) {
1799
+ updatePendingCommentStatus(commentId, 'failed');
1800
+ reportError(error);
1801
+ }
1802
+ }
1803
+
1069
1804
  function selectable(node) {
1070
1805
  if (!node || node.nodeType !== 1) return null;
1071
1806
  if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
@@ -1094,7 +1829,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1094
1829
  renderReferenceOutlines();
1095
1830
  updateSendState();
1096
1831
  renderSelectionSummary();
1097
- resetInspectCards('References ready. Open Inspect and click Inspect Selection when you want Narrative Reading, Exploratory Reading, Source, and Purpose review.');
1832
+ resetInspectCards('References ready. Open Inspect and click Inspect Reference for concise Purpose and Source context.');
1098
1833
  setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
1099
1834
  }
1100
1835
 
@@ -1116,8 +1851,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1116
1851
  updateSendState();
1117
1852
  }
1118
1853
 
1119
- function syncReferencesFromComment(showStatus) {
1120
- const activeIds = new Set(Array.from(els.comment.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
1854
+ function syncReferencesFromComment(showStatus, editor) {
1855
+ const source = editor || activeCommentEditor();
1856
+ const activeIds = new Set(Array.from(source.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
1121
1857
  const before = state.references.length;
1122
1858
  state.references = state.references.filter((reference) => activeIds.has(reference.id));
1123
1859
  if (state.references.length !== before) {
@@ -1127,6 +1863,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1127
1863
  }
1128
1864
  }
1129
1865
 
1866
+ function syncSelectedAssetFromComment() {
1867
+ if (els.comment.querySelector('.asset-ref-chip[data-asset-id]')) return;
1868
+ state.selectedAsset = null;
1869
+ }
1870
+
1130
1871
  function addPendingComment(text, elements, status) {
1131
1872
  const id = 'comment-' + state.nextCommentId++;
1132
1873
  state.pendingComments.push({
@@ -1271,10 +2012,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1271
2012
  }
1272
2013
 
1273
2014
  function updateSendState() {
1274
- els.send.disabled = !getCommentText().trim();
2015
+ if (state.sendingEdit) setButtonLoading(els.send, true, 'Sending...');
2016
+ 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);
2017
+ els.send.disabled = state.sendingEdit || !getCommentText().trim();
2018
+ if (state.inspecting) setButtonLoading(els.inspectButton, true, 'Inspecting...');
2019
+ else setButtonLoading(els.inspectButton, false, 'Inspect Reference');
1275
2020
  els.inspectButton.disabled = state.inspecting || state.references.length === 0;
1276
2021
  }
1277
2022
 
2023
+ function setButtonLoading(button, loading, label, html) {
2024
+ if (!button) return;
2025
+ button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(label) + '</span>' : (html ? label : escapeHtml(label));
2026
+ button.disabled = !!loading;
2027
+ }
2028
+
1278
2029
  function renderSelectionSummary() {
1279
2030
  const label = state.references.length
1280
2031
  ? state.references.length + ' referenced element' + (state.references.length === 1 ? '' : 's') + ' selected.'
@@ -1298,27 +2049,39 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1298
2049
  els.inspectCards.innerHTML = '<div class="inspect-empty">' + escapeHtml(message) + '</div>';
1299
2050
  }
1300
2051
 
2052
+ function renderInspectLoading(message) {
2053
+ els.inspectCards.innerHTML = '<div class="inspect-loading"><span class="loading-row"><span class="spinner" aria-hidden="true"></span><b>' + escapeHtml(message) + '</b></span><br>Preparing concise Purpose and Source context.</div>'
2054
+ + '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>'
2055
+ + '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>';
2056
+ }
2057
+
2058
+ function getInspectComment() {
2059
+ syncReferencesFromComment(false, els.inspectComment);
2060
+ return getCommentText(els.inspectComment).trim().slice(0, 2000);
2061
+ }
2062
+
1301
2063
  async function inspectCurrentSelection() {
1302
2064
  if (!state.references.length || state.inspecting) return;
1303
2065
  const snapshot = collectReferenceSnapshot();
2066
+ const comment = getInspectComment();
1304
2067
  state.inspecting = true;
1305
2068
  updateSendState();
1306
2069
  setMode('inspect');
1307
2070
  els.inspectStale.innerHTML = '';
1308
2071
  state.inspectFallback = null;
1309
- els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Reading selection...</b><br>Sending grounded selection context to OpenCode. Deterministic context is kept as fallback if generation fails.</div>';
2072
+ renderInspectLoading('Reading selection...');
1310
2073
  try {
1311
2074
  const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
1312
2075
  method: 'POST',
1313
2076
  headers: { 'content-type': 'application/json' },
1314
- body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage }),
2077
+ body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage, comment }),
1315
2078
  });
1316
2079
  const body = await res.json().catch(() => ({}));
1317
2080
  if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection failed');
1318
2081
  state.deckVersion = body.deckVersion || state.deckVersion;
1319
2082
  state.activeInspectRequestId = body.requestId;
1320
2083
  state.inspectFallback = body.preprocess || null;
1321
- els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Reading selection...</b><br>Waiting for localized structured reading cards.</div>';
2084
+ renderInspectLoading('Waiting for Purpose and Source...');
1322
2085
  await pollInspectResult(body.requestId);
1323
2086
  } catch (error) {
1324
2087
  if (state.inspectFallback) {
@@ -1377,8 +2140,6 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1377
2140
  else els.inspectStale.innerHTML = '';
1378
2141
  els.inspectCards.innerHTML = [
1379
2142
  '<div class="status">' + escapeHtml(phase || 'Inspection') + '</div>',
1380
- result.cards.reading ? renderInspectCard('Narrative Reading', result.cards.reading.status, result.cards.reading.rationale, renderReading(result.cards.reading)) : '',
1381
- result.cards.exploratory ? renderInspectCard('Exploratory Reading', result.cards.exploratory.status, result.cards.exploratory.rationale, renderExploratory(result.cards.exploratory)) : '',
1382
2143
  renderInspectCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
1383
2144
  renderInspectCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
1384
2145
  ].join('');
@@ -1456,6 +2217,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1456
2217
  }
1457
2218
 
1458
2219
  function insertReferenceChip(reference) {
2220
+ const editor = activeCommentEditor();
1459
2221
  const chip = document.createElement('span');
1460
2222
  chip.className = 'ref-chip';
1461
2223
  chip.contentEditable = 'false';
@@ -1473,16 +2235,60 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1473
2235
  range.collapse(true);
1474
2236
  applyCommentRange(range);
1475
2237
  } else {
1476
- if (els.comment.textContent && !/\\s$/.test(els.comment.textContent)) els.comment.appendChild(document.createTextNode(' '));
1477
- els.comment.appendChild(chip);
1478
- els.comment.appendChild(trailingSpace);
2238
+ if (editor.textContent && !/\\s$/.test(editor.textContent)) editor.appendChild(document.createTextNode(' '));
2239
+ editor.appendChild(chip);
2240
+ editor.appendChild(trailingSpace);
1479
2241
  placeCaretAfter(trailingSpace);
1480
2242
  }
2243
+ editor.focus();
2244
+ }
2245
+
2246
+ function insertAssetChip(asset) {
2247
+ const chip = document.createElement('span');
2248
+ chip.className = 'ref-chip asset-ref-chip';
2249
+ chip.contentEditable = 'false';
2250
+ chip.dataset.assetId = asset.id || '';
2251
+ chip.style.setProperty('--ref-bg', '#dcfce7');
2252
+ chip.style.setProperty('--ref-border', '#86efac');
2253
+ chip.style.setProperty('--ref-text', '#166534');
2254
+ chip.textContent = '@Asset ' + (asset.id || asset.deckPath || asset.path || 'image');
2255
+ const range = getCommentInsertRange();
2256
+ if (range) {
2257
+ range.insertNode(chip);
2258
+ range.setStartAfter(chip);
2259
+ range.collapse(true);
2260
+ applyCommentRange(range);
2261
+ } else {
2262
+ els.comment.appendChild(chip);
2263
+ placeCaretAfter(chip);
2264
+ }
1481
2265
  els.comment.focus();
1482
2266
  }
1483
2267
 
2268
+ function insertPlainText(text) {
2269
+ const node = document.createTextNode(text);
2270
+ const range = getCommentInsertRange();
2271
+ if (range) {
2272
+ range.insertNode(node);
2273
+ range.setStartAfter(node);
2274
+ range.collapse(true);
2275
+ applyCommentRange(range);
2276
+ } else {
2277
+ els.comment.appendChild(node);
2278
+ placeCaretAfter(node);
2279
+ }
2280
+ }
2281
+
2282
+ function removeAssetChip() {
2283
+ els.comment.querySelectorAll('.asset-ref-chip').forEach((chip) => {
2284
+ const next = chip.nextSibling;
2285
+ chip.remove();
2286
+ if (next && next.nodeType === Node.TEXT_NODE && next.textContent === ' ') next.remove();
2287
+ });
2288
+ }
2289
+
1484
2290
  function removeReferenceChip(id) {
1485
- const chip = els.comment.querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
2291
+ const chip = activeCommentEditor().querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
1486
2292
  if (!chip) return;
1487
2293
  const next = chip.nextSibling;
1488
2294
  chip.remove();
@@ -1491,13 +2297,18 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1491
2297
 
1492
2298
  function clearReferences(removeChips) {
1493
2299
  state.references = [];
1494
- if (removeChips) els.comment.querySelectorAll('.ref-chip').forEach((chip) => chip.remove());
2300
+ if (removeChips) {
2301
+ activeCommentEditor().querySelectorAll('.ref-chip[data-ref-id]').forEach((chip) => chip.remove());
2302
+ state.selectedAsset = null;
2303
+ removeAssetChip();
2304
+ }
1495
2305
  renderSelectionSummary();
1496
- resetInspectCards('Select one or more deck elements, then inspect them for Narrative Reading, Exploratory Reading, Source, and Purpose. This does not edit the deck.');
2306
+ resetInspectCards('Select a deck element to create an @ref, optionally ask a question, then Inspect. This does not edit the deck.');
1497
2307
  }
1498
2308
 
1499
- function getCommentText() {
1500
- return (els.comment.innerText || els.comment.textContent || '').replace(/\\u00a0/g, ' ');
2309
+ function getCommentText(editor) {
2310
+ const source = editor || els.comment;
2311
+ return (source.innerText || source.textContent || '').replace(/\\u00a0/g, ' ');
1501
2312
  }
1502
2313
 
1503
2314
  function placeCaretAfter(node) {
@@ -1510,18 +2321,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1510
2321
  function saveCommentRange() {
1511
2322
  const selection = window.getSelection();
1512
2323
  if (!selection || selection.rangeCount === 0) return;
1513
- if (!els.comment || !els.comment.contains(selection.anchorNode)) return;
2324
+ const editor = activeCommentEditor();
2325
+ if (!editor || !editor.contains(selection.anchorNode)) return;
1514
2326
  state.commentRange = selection.getRangeAt(0).cloneRange();
1515
2327
  }
1516
2328
 
1517
2329
  function getCommentInsertRange() {
2330
+ const editor = activeCommentEditor();
1518
2331
  const selection = window.getSelection();
1519
- if (selection && selection.rangeCount > 0 && els.comment.contains(selection.anchorNode)) {
2332
+ if (selection && selection.rangeCount > 0 && editor.contains(selection.anchorNode)) {
1520
2333
  const range = selection.getRangeAt(0).cloneRange();
1521
2334
  range.deleteContents();
1522
2335
  return range;
1523
2336
  }
1524
- if (state.commentRange && els.comment.contains(state.commentRange.commonAncestorContainer)) {
2337
+ if (state.commentRange && editor.contains(state.commentRange.commonAncestorContainer)) {
1525
2338
  const range = state.commentRange.cloneRange();
1526
2339
  range.deleteContents();
1527
2340
  return range;