@cyber-dash-tech/revela 0.15.1 → 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,11 +768,11 @@ 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); }
@@ -639,53 +783,99 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
639
783
  .deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
640
784
  .deck-nav button:disabled { opacity: .38; }
641
785
  .deck-nav-status { min-width: 76px; color: #e2e8f0; font-size: 12px; font-weight: 900; text-align: center; font-variant-numeric: tabular-nums; }
642
- aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); overflow: auto; }
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; }
643
787
  h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
644
788
  .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
645
- .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; }
646
790
  .panel { display: flex; flex-direction: column; gap: 10px; }
647
- .tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #dbe4ee; border-radius: 14px; background: #f1f5f9; }
648
- .tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #475569; box-shadow: none; font-weight: 900; }
649
- .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); }
650
794
  .tab-panel { display: none; flex-direction: column; gap: 12px; }
651
795
  .tab-panel.active { display: flex; }
652
- .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); }
653
- .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; }
654
799
  .selection-chips { display: flex; flex-wrap: wrap; gap: 6px; }
655
- .label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
656
- .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); }
657
- .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); }
658
- .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; }
659
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; }
660
- .comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
661
- .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); }
662
- .comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
663
- .comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
664
- .comment-bubble.stale { border-color: #facc15; background: #fefce8; }
665
- .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; }
666
813
  .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
667
- .comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
668
- .comment-bubble.updated .comment-bubble-state { color: #15803d; }
669
- .comment-bubble.stale .comment-bubble-state { color: #a16207; }
670
- .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; }
671
818
  .inspect-actions { display: flex; flex-direction: column; gap: 8px; }
672
819
  .inspect-options { display: flex; flex-direction: column; gap: 5px; }
673
- .inspect-options label { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
674
- .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; }
675
822
  .inspect-cards { display: flex; flex-direction: column; gap: 12px; }
676
- .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); }
677
824
  .inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
678
825
  .inspect-card h2 { margin: 0; font-size: 13px; color: #0f172a; }
679
- .badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e2e8f0; color: #475569; }
680
- .badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #dcfce7; color: #166534; }
681
- .badge.weak, .badge.missing { background: #fef3c7; color: #92400e; }
682
- .badge.unsupported { background: #fee2e2; color: #991b1b; }
683
- .inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #475569; font-size: 12px; line-height: 1.5; }
684
- .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; }
685
832
  .inspect-warning, .inspect-stale { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
686
- 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; }
687
873
  button:disabled { cursor: not-allowed; opacity: .5; }
688
- .status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
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; }
689
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; } }
690
880
  </style>
691
881
  </head>
@@ -696,28 +886,49 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
696
886
  <aside>
697
887
  <div>
698
888
  <h1><span class="wordmark">REVELA</span> Refine</h1>
699
- <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>
700
890
  </div>
701
- <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>
702
892
  <div class="tabs" role="tablist" aria-label="Refine mode">
703
893
  <button id="editTab" class="tab" type="button" role="tab">Edit</button>
704
894
  <button id="inspectTab" class="tab" type="button" role="tab">Inspect</button>
705
895
  </div>
706
896
  <div id="editPanel" class="tab-panel">
707
897
  <div class="panel">
708
- <div class="label">Comment</div>
709
- <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>
900
+ </div>
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>
710
906
  </div>
711
- <div id="commentThread" class="comment-thread" aria-live="polite"></div>
712
- <button id="send" disabled>Send Edit</button>
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>
713
909
  </div>
714
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>
715
915
  <div class="inspect-actions">
716
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>
717
- <button id="inspectButton" disabled>Inspect Selection</button>
917
+ <button id="inspectButton" disabled>Inspect Reference</button>
718
918
  <div id="inspectStale"></div>
719
919
  </div>
720
- <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>
721
932
  </div>
722
933
  <div id="status" class="status"></div>
723
934
  </aside>
@@ -764,6 +975,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
764
975
  activeInspectRequestId: '',
765
976
  inspectLanguage: 'Auto',
766
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,
767
991
  };
768
992
  const els = {
769
993
  frame: null,
@@ -781,10 +1005,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
781
1005
  comment: null,
782
1006
  commentThread: null,
783
1007
  send: null,
1008
+ inspectComment: null,
784
1009
  inspectButton: null,
785
1010
  inspectLanguage: null,
786
1011
  inspectCards: null,
787
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,
788
1022
  status: null,
789
1023
  };
790
1024
 
@@ -814,14 +1048,24 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
814
1048
  els.comment = document.getElementById('comment');
815
1049
  els.commentThread = document.getElementById('commentThread');
816
1050
  els.send = document.getElementById('send');
1051
+ els.inspectComment = document.getElementById('inspectComment');
817
1052
  els.inspectButton = document.getElementById('inspectButton');
818
1053
  els.inspectCards = document.getElementById('inspectCards');
819
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');
820
1064
  els.status = document.getElementById('status');
821
1065
 
822
1066
  els.inspectLanguage = document.getElementById('inspectLanguage');
823
1067
 
824
- 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.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) {
825
1069
  throw new Error('Editor boot failed: required DOM nodes are missing.');
826
1070
  }
827
1071
 
@@ -830,6 +1074,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
830
1074
  setMode(state.mode);
831
1075
  setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
832
1076
  initFrame();
1077
+ loadSavedAssets();
833
1078
  startDeckVersionPolling();
834
1079
  } catch (error) {
835
1080
  reportError(error);
@@ -856,11 +1101,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
856
1101
  });
857
1102
  els.comment.addEventListener('input', () => {
858
1103
  saveCommentRange();
859
- syncReferencesFromComment(false);
1104
+ syncReferencesFromComment(false, els.comment);
1105
+ syncSelectedAssetFromComment();
860
1106
  updateSendState();
861
1107
  });
862
1108
  els.comment.addEventListener('keyup', saveCommentRange);
863
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);
864
1117
  document.addEventListener('selectionchange', saveCommentRange);
865
1118
  els.hitbox.addEventListener('pointermove', onHover);
866
1119
  els.hitbox.addEventListener('pointerdown', onPointerDown);
@@ -882,11 +1135,25 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
882
1135
  els.deckNext.addEventListener('click', nextDeckSlide);
883
1136
  els.send.addEventListener('click', sendComment);
884
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());
885
1149
  els.inspectLanguage.addEventListener('change', () => {
886
1150
  state.inspectLanguage = els.inspectLanguage.value || 'Auto';
887
1151
  });
888
1152
  els.editTab.addEventListener('click', () => setMode('edit'));
889
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);
890
1157
  }
891
1158
 
892
1159
  function setMode(mode) {
@@ -895,9 +1162,31 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
895
1162
  els.inspectTab.classList.toggle('active', state.mode === 'inspect');
896
1163
  els.editPanel.classList.toggle('active', state.mode === 'edit');
897
1164
  els.inspectPanel.classList.toggle('active', state.mode === 'inspect');
1165
+ saveCommentRange();
898
1166
  updateSendState();
899
1167
  }
900
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
+
901
1190
  function restoreEditorWidth() {
902
1191
  try {
903
1192
  const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
@@ -960,6 +1249,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
960
1249
  clearReferences(false);
961
1250
  state.hoverEl = null;
962
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)');
963
1254
  state.referenceOutlines = [];
964
1255
  doc.addEventListener('scroll', () => {
965
1256
  renderHoverOutline(state.hoverEl);
@@ -1110,6 +1401,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1110
1401
  clearReferences(true);
1111
1402
  state.hoverEl = null;
1112
1403
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
1404
+ state.assetDropTarget = null;
1405
+ if (state.assetDropOutline) state.assetDropOutline.style.display = 'none';
1113
1406
  state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
1114
1407
  state.referenceOutlines = [];
1115
1408
  updateSendState();
@@ -1161,34 +1454,353 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1161
1454
  }
1162
1455
 
1163
1456
  async function sendComment() {
1164
- syncReferencesFromComment(false);
1457
+ syncReferencesFromComment(false, els.comment);
1458
+ syncSelectedAssetFromComment();
1165
1459
  const text = getCommentText().trim();
1166
1460
  if (!text) return;
1167
1461
  const elements = state.references.map((reference) => reference.payload);
1462
+ const asset = state.selectedAsset || undefined;
1168
1463
  const commentId = addPendingComment(text, elements, 'sending');
1169
1464
  clearReferences(false);
1465
+ state.selectedAsset = null;
1170
1466
  els.comment.textContent = '';
1171
1467
  renderReferenceOutlines();
1172
- els.send.disabled = true;
1468
+ state.sendingEdit = true;
1469
+ updateSendState();
1173
1470
  setStatus('Sending...');
1174
1471
  try {
1175
1472
  const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
1176
1473
  method: 'POST',
1177
1474
  headers: { 'content-type': 'application/json' },
1178
- body: JSON.stringify({ comment: text, elements }),
1475
+ body: JSON.stringify({ comment: text, elements, asset }),
1179
1476
  });
1180
1477
  const body = await res.json().catch(() => ({}));
1181
1478
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
1182
1479
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
1183
1480
  if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
1481
+ state.sendingEdit = false;
1184
1482
  updateSendState();
1185
1483
  } catch (error) {
1186
1484
  updatePendingCommentStatus(commentId, 'failed');
1485
+ state.sendingEdit = false;
1187
1486
  reportError(error);
1188
1487
  updateSendState();
1189
1488
  }
1190
1489
  }
1191
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
+
1192
1804
  function selectable(node) {
1193
1805
  if (!node || node.nodeType !== 1) return null;
1194
1806
  if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
@@ -1217,7 +1829,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1217
1829
  renderReferenceOutlines();
1218
1830
  updateSendState();
1219
1831
  renderSelectionSummary();
1220
- 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.');
1221
1833
  setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
1222
1834
  }
1223
1835
 
@@ -1239,8 +1851,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1239
1851
  updateSendState();
1240
1852
  }
1241
1853
 
1242
- function syncReferencesFromComment(showStatus) {
1243
- 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')));
1244
1857
  const before = state.references.length;
1245
1858
  state.references = state.references.filter((reference) => activeIds.has(reference.id));
1246
1859
  if (state.references.length !== before) {
@@ -1250,6 +1863,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1250
1863
  }
1251
1864
  }
1252
1865
 
1866
+ function syncSelectedAssetFromComment() {
1867
+ if (els.comment.querySelector('.asset-ref-chip[data-asset-id]')) return;
1868
+ state.selectedAsset = null;
1869
+ }
1870
+
1253
1871
  function addPendingComment(text, elements, status) {
1254
1872
  const id = 'comment-' + state.nextCommentId++;
1255
1873
  state.pendingComments.push({
@@ -1394,10 +2012,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1394
2012
  }
1395
2013
 
1396
2014
  function updateSendState() {
1397
- 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');
1398
2020
  els.inspectButton.disabled = state.inspecting || state.references.length === 0;
1399
2021
  }
1400
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
+
1401
2029
  function renderSelectionSummary() {
1402
2030
  const label = state.references.length
1403
2031
  ? state.references.length + ' referenced element' + (state.references.length === 1 ? '' : 's') + ' selected.'
@@ -1421,27 +2049,39 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1421
2049
  els.inspectCards.innerHTML = '<div class="inspect-empty">' + escapeHtml(message) + '</div>';
1422
2050
  }
1423
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
+
1424
2063
  async function inspectCurrentSelection() {
1425
2064
  if (!state.references.length || state.inspecting) return;
1426
2065
  const snapshot = collectReferenceSnapshot();
2066
+ const comment = getInspectComment();
1427
2067
  state.inspecting = true;
1428
2068
  updateSendState();
1429
2069
  setMode('inspect');
1430
2070
  els.inspectStale.innerHTML = '';
1431
2071
  state.inspectFallback = null;
1432
- 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...');
1433
2073
  try {
1434
2074
  const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
1435
2075
  method: 'POST',
1436
2076
  headers: { 'content-type': 'application/json' },
1437
- body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage }),
2077
+ body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage, comment }),
1438
2078
  });
1439
2079
  const body = await res.json().catch(() => ({}));
1440
2080
  if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection failed');
1441
2081
  state.deckVersion = body.deckVersion || state.deckVersion;
1442
2082
  state.activeInspectRequestId = body.requestId;
1443
2083
  state.inspectFallback = body.preprocess || null;
1444
- 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...');
1445
2085
  await pollInspectResult(body.requestId);
1446
2086
  } catch (error) {
1447
2087
  if (state.inspectFallback) {
@@ -1500,8 +2140,6 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1500
2140
  else els.inspectStale.innerHTML = '';
1501
2141
  els.inspectCards.innerHTML = [
1502
2142
  '<div class="status">' + escapeHtml(phase || 'Inspection') + '</div>',
1503
- result.cards.reading ? renderInspectCard('Narrative Reading', result.cards.reading.status, result.cards.reading.rationale, renderReading(result.cards.reading)) : '',
1504
- result.cards.exploratory ? renderInspectCard('Exploratory Reading', result.cards.exploratory.status, result.cards.exploratory.rationale, renderExploratory(result.cards.exploratory)) : '',
1505
2143
  renderInspectCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
1506
2144
  renderInspectCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
1507
2145
  ].join('');
@@ -1579,6 +2217,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1579
2217
  }
1580
2218
 
1581
2219
  function insertReferenceChip(reference) {
2220
+ const editor = activeCommentEditor();
1582
2221
  const chip = document.createElement('span');
1583
2222
  chip.className = 'ref-chip';
1584
2223
  chip.contentEditable = 'false';
@@ -1596,16 +2235,60 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1596
2235
  range.collapse(true);
1597
2236
  applyCommentRange(range);
1598
2237
  } else {
1599
- if (els.comment.textContent && !/\\s$/.test(els.comment.textContent)) els.comment.appendChild(document.createTextNode(' '));
1600
- els.comment.appendChild(chip);
1601
- els.comment.appendChild(trailingSpace);
2238
+ if (editor.textContent && !/\\s$/.test(editor.textContent)) editor.appendChild(document.createTextNode(' '));
2239
+ editor.appendChild(chip);
2240
+ editor.appendChild(trailingSpace);
1602
2241
  placeCaretAfter(trailingSpace);
1603
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
+ }
1604
2265
  els.comment.focus();
1605
2266
  }
1606
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
+
1607
2290
  function removeReferenceChip(id) {
1608
- const chip = els.comment.querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
2291
+ const chip = activeCommentEditor().querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
1609
2292
  if (!chip) return;
1610
2293
  const next = chip.nextSibling;
1611
2294
  chip.remove();
@@ -1614,13 +2297,18 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1614
2297
 
1615
2298
  function clearReferences(removeChips) {
1616
2299
  state.references = [];
1617
- 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
+ }
1618
2305
  renderSelectionSummary();
1619
- 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.');
1620
2307
  }
1621
2308
 
1622
- function getCommentText() {
1623
- 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, ' ');
1624
2312
  }
1625
2313
 
1626
2314
  function placeCaretAfter(node) {
@@ -1633,18 +2321,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1633
2321
  function saveCommentRange() {
1634
2322
  const selection = window.getSelection();
1635
2323
  if (!selection || selection.rangeCount === 0) return;
1636
- if (!els.comment || !els.comment.contains(selection.anchorNode)) return;
2324
+ const editor = activeCommentEditor();
2325
+ if (!editor || !editor.contains(selection.anchorNode)) return;
1637
2326
  state.commentRange = selection.getRangeAt(0).cloneRange();
1638
2327
  }
1639
2328
 
1640
2329
  function getCommentInsertRange() {
2330
+ const editor = activeCommentEditor();
1641
2331
  const selection = window.getSelection();
1642
- if (selection && selection.rangeCount > 0 && els.comment.contains(selection.anchorNode)) {
2332
+ if (selection && selection.rangeCount > 0 && editor.contains(selection.anchorNode)) {
1643
2333
  const range = selection.getRangeAt(0).cloneRange();
1644
2334
  range.deleteContents();
1645
2335
  return range;
1646
2336
  }
1647
- if (state.commentRange && els.comment.contains(state.commentRange.commonAncestorContainer)) {
2337
+ if (state.commentRange && editor.contains(state.commentRange.commonAncestorContainer)) {
1648
2338
  const range = state.commentRange.cloneRange();
1649
2339
  range.deleteContents();
1650
2340
  return range;