@cyber-dash-tech/revela 0.7.8 → 0.8.1

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.
@@ -6,6 +6,7 @@ import { buildEditPrompt, type EditCommentPayload } from "./prompt"
6
6
  const TOKEN_BYTES = 24
7
7
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000
8
8
  const IDLE_STOP_MS = 30 * 60 * 1000
9
+ export const LIVE_EDITOR_IDLE_MS = 10 * 1000
9
10
 
10
11
  interface EditSession {
11
12
  token: string
@@ -20,7 +21,13 @@ interface EditSession {
20
21
 
21
22
  export interface EditServerHandle {
22
23
  baseUrl: string
23
- createSession(input: { client: any; sessionID: string; deck: EditableDeck }): string
24
+ getOrCreateSession(input: { client: any; sessionID: string; deck: EditableDeck }): EditServerSessionResult
25
+ }
26
+
27
+ export interface EditServerSessionResult {
28
+ token: string
29
+ reused: boolean
30
+ live: boolean
24
31
  }
25
32
 
26
33
  let server: ReturnType<typeof Bun.serve> | undefined
@@ -41,8 +48,21 @@ export function startEditServer(): EditServerHandle {
41
48
 
42
49
  return {
43
50
  baseUrl,
44
- createSession(input) {
51
+ getOrCreateSession(input) {
45
52
  cleanupExpiredSessions()
53
+ const existing = findSessionForDeck(input.deck.absoluteFile)
54
+ if (existing) {
55
+ existing.session.client = input.client
56
+ existing.session.sessionID = input.sessionID
57
+ existing.session.deck = input.deck.slug
58
+ existing.session.file = input.deck.file
59
+ return {
60
+ token: existing.token,
61
+ reused: true,
62
+ live: isSessionLive(existing.session),
63
+ }
64
+ }
65
+
46
66
  const token = randomBytes(TOKEN_BYTES).toString("base64url")
47
67
  sessions.set(token, {
48
68
  token,
@@ -54,11 +74,37 @@ export function startEditServer(): EditServerHandle {
54
74
  createdAt: Date.now(),
55
75
  lastActiveAt: Date.now(),
56
76
  })
57
- return token
77
+ return { token, reused: false, live: false }
58
78
  },
59
79
  }
60
80
  }
61
81
 
82
+ export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
83
+ cleanupExpiredSessions()
84
+ const existing = findSessionForDeck(deck.absoluteFile)
85
+ return existing ? isSessionLive(existing.session, maxIdleMs) : false
86
+ }
87
+
88
+ export function stopEditServer(): void {
89
+ if (idleTimer) clearTimeout(idleTimer)
90
+ idleTimer = undefined
91
+ sessions.clear()
92
+ server?.stop()
93
+ server = undefined
94
+ baseUrl = ""
95
+ }
96
+
97
+ function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined {
98
+ for (const [token, session] of sessions) {
99
+ if (session.absoluteFile === absoluteFile) return { token, session }
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
105
+ return Date.now() - session.lastActiveAt <= maxIdleMs
106
+ }
107
+
62
108
  async function handleRequest(req: Request): Promise<Response> {
63
109
  cleanupExpiredSessions()
64
110
  const url = new URL(req.url)
@@ -156,7 +202,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
156
202
  if (!token) return { ok: false, response: textResponse("Missing token", 401) }
157
203
  const session = sessions.get(token)
158
204
  if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
159
- if (Date.now() - session.createdAt > SESSION_TTL_MS) {
205
+ if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
160
206
  sessions.delete(token)
161
207
  return { ok: false, response: textResponse("Expired token", 401) }
162
208
  }
@@ -167,7 +213,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
167
213
  function cleanupExpiredSessions(): void {
168
214
  const now = Date.now()
169
215
  for (const [token, session] of sessions) {
170
- if (now - session.createdAt > SESSION_TTL_MS) sessions.delete(token)
216
+ if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
171
217
  }
172
218
  }
173
219
 
@@ -212,7 +258,7 @@ function jsonResponse(body: unknown, status = 200): Response {
212
258
  })
213
259
  }
214
260
 
215
- function renderEditorShell(token: string): string {
261
+ export function renderEditorShell(token: string): string {
216
262
  const encodedToken = JSON.stringify(token)
217
263
  return `<!doctype html>
218
264
  <html lang="en">
@@ -221,50 +267,57 @@ function renderEditorShell(token: string): string {
221
267
  <meta name="viewport" content="width=device-width, initial-scale=1" />
222
268
  <title>Revela Edit</title>
223
269
  <style>
224
- :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
270
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
225
271
  * { box-sizing: border-box; }
226
- body { margin: 0; background: #0d1117; color: #f0f6fc; height: 100vh; overflow: hidden; }
227
- .app { display: grid; grid-template-columns: minmax(0, 1fr) 380px; height: 100vh; }
228
- .preview { position: relative; min-width: 0; background: #05070a; border-right: 1px solid #30363d; }
229
- iframe { display: block; width: 100%; height: 100%; border: 0; background: white; }
272
+ body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
273
+ body.resizing { cursor: col-resize; user-select: none; }
274
+ body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
275
+ .app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
276
+ .preview { position: relative; min-width: 0; background: #eef3f8; }
277
+ .resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
278
+ .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; }
279
+ .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
280
+ iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
230
281
  .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
231
- aside { display: flex; flex-direction: column; gap: 16px; padding: 18px; background: #111827; }
232
- h1 { margin: 0; font-size: 18px; line-height: 1.2; }
233
- .hint { margin: 0; color: #9ca3af; font-size: 13px; line-height: 1.5; }
282
+ aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); }
283
+ h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
284
+ .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
285
+ .hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
234
286
  .panel { display: flex; flex-direction: column; gap: 10px; }
235
- .label { color: #9ca3af; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
236
- .comment-editor { width: 100%; min-height: 160px; max-height: 42vh; overflow: auto; padding: 12px; border: 1px solid #374151; border-radius: 12px; background: #020617; color: #f8fafc; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; }
237
- .comment-editor:focus { border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56,189,248,.18); }
238
- .comment-editor:empty::before { content: attr(data-placeholder); color: #64748b; pointer-events: none; }
239
- .ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px 6px; border-radius: 999px; background: rgba(56,189,248,.18); color: #bae6fd; border: 1px solid rgba(56,189,248,.45); font-weight: 700; white-space: nowrap; }
287
+ .label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
288
+ .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); }
289
+ .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); }
290
+ .comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
291
+ .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; }
240
292
  .comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
241
- .comment-bubble { border: 1px solid #374151; border-radius: 14px; padding: 10px 12px; background: #0f172a; color: #e5e7eb; font-size: 13px; line-height: 1.45; }
242
- .comment-bubble.sending { border-color: rgba(56,189,248,.5); background: rgba(14,116,144,.14); }
243
- .comment-bubble.applied { border-color: rgba(34,197,94,.55); background: rgba(22,101,52,.18); }
244
- .comment-bubble.stale { border-color: rgba(251,191,36,.6); background: rgba(120,53,15,.2); }
245
- .comment-bubble.failed { border-color: rgba(248,113,113,.65); background: rgba(127,29,29,.2); }
293
+ .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); }
294
+ .comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
295
+ .comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
296
+ .comment-bubble.stale { border-color: #facc15; background: #fefce8; }
297
+ .comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
246
298
  .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
247
- .comment-bubble-state { margin-top: 8px; color: #93c5fd; font-size: 12px; font-weight: 700; }
248
- .comment-bubble.applied .comment-bubble-state { color: #86efac; }
249
- .comment-bubble.stale .comment-bubble-state { color: #fcd34d; }
250
- .comment-bubble.failed .comment-bubble-state { color: #fca5a5; }
251
- button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #38bdf8; color: #04111d; font-weight: 700; cursor: pointer; }
299
+ .comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
300
+ .comment-bubble.updated .comment-bubble-state { color: #15803d; }
301
+ .comment-bubble.stale .comment-bubble-state { color: #a16207; }
302
+ .comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
303
+ 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); }
252
304
  button:disabled { cursor: not-allowed; opacity: .5; }
253
- .status { min-height: 20px; color: #93c5fd; font-size: 13px; }
254
- @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } aside { max-height: 48vh; } }
305
+ .status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
306
+ @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } }
255
307
  </style>
256
308
  </head>
257
309
  <body>
258
310
  <main class="app">
259
311
  <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>
312
+ <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>
260
313
  <aside>
261
314
  <div>
262
- <h1>Revela Visual Edit</h1>
263
- <p class="hint">Write one comment. Use Ctrl/Cmd + click on deck elements to insert precise references into the comment.</p>
315
+ <h1><span class="wordmark">REVELA</span> Editor</h1>
316
+ <p class="hint">Refine your deck with precise visual comments. Cmd/Ctrl-click any slide element to attach it as a reference, then describe the change you want.</p>
264
317
  </div>
265
318
  <div class="panel">
266
319
  <div class="label">Comment</div>
267
- <div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example: Align @Metric 1 with @Metric 2, and remove @Text block 3."></div>
320
+ <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>
268
321
  </div>
269
322
  <div id="commentThread" class="comment-thread" aria-live="polite"></div>
270
323
  <button id="send" disabled>Send comments</button>
@@ -275,6 +328,22 @@ function renderEditorShell(token: string): string {
275
328
  (() => {
276
329
  const token = ${encodedToken};
277
330
  const COMMENT_STALE_MS = 60000;
331
+ const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
332
+ const DEFAULT_EDITOR_WIDTH = 376;
333
+ const MIN_EDITOR_WIDTH = 320;
334
+ const MAX_EDITOR_WIDTH = 620;
335
+ const REFERENCE_COLORS = [
336
+ { border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
337
+ { border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
338
+ { border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
339
+ { border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
340
+ { border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
341
+ { border: '#73b8bd', fill: 'rgba(115,184,189,.18)', bg: '#e8f6f7', text: '#285f64' },
342
+ { border: '#c7b46e', fill: 'rgba(199,180,110,.18)', bg: '#f8f3df', text: '#6b5b1e' },
343
+ { border: '#9eb27e', fill: 'rgba(158,178,126,.18)', bg: '#f1f6e9', text: '#4f642e' },
344
+ { border: '#c08fc8', fill: 'rgba(192,143,200,.18)', bg: '#f7edf8', text: '#6b3f73' },
345
+ { border: '#8fa7c9', fill: 'rgba(143,167,201,.18)', bg: '#eef3fa', text: '#405a7b' },
346
+ ];
278
347
  const state = {
279
348
  references: [],
280
349
  pendingComments: [],
@@ -288,10 +357,12 @@ function renderEditorShell(token: string): string {
288
357
  pendingRefreshMessage: false,
289
358
  bound: false,
290
359
  commentRange: null,
360
+ resizeDrag: null,
291
361
  };
292
362
  const els = {
293
363
  frame: null,
294
364
  hitbox: null,
365
+ resizeHandle: null,
295
366
  comment: null,
296
367
  commentThread: null,
297
368
  send: null,
@@ -311,15 +382,17 @@ function renderEditorShell(token: string): string {
311
382
  try {
312
383
  els.frame = document.getElementById('deck');
313
384
  els.hitbox = document.getElementById('hitbox');
385
+ els.resizeHandle = document.getElementById('resizeHandle');
314
386
  els.comment = document.getElementById('comment');
315
387
  els.commentThread = document.getElementById('commentThread');
316
388
  els.send = document.getElementById('send');
317
389
  els.status = document.getElementById('status');
318
390
 
319
- if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
391
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !els.comment || !els.commentThread || !els.send || !els.status) {
320
392
  throw new Error('Editor boot failed: required DOM nodes are missing.');
321
393
  }
322
394
 
395
+ restoreEditorWidth();
323
396
  bindEvents();
324
397
  setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
325
398
  initFrame();
@@ -358,9 +431,59 @@ function renderEditorShell(token: string): string {
358
431
  renderHoverOutline(state.hoverEl);
359
432
  renderReferenceOutlines();
360
433
  }, { passive: false });
434
+ els.resizeHandle.addEventListener('pointerdown', startEditorResize);
435
+ els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
361
436
  els.send.addEventListener('click', sendComment);
362
437
  }
363
438
 
439
+ function restoreEditorWidth() {
440
+ try {
441
+ const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
442
+ setEditorWidth(Number.isFinite(saved) ? saved : DEFAULT_EDITOR_WIDTH, false);
443
+ } catch {
444
+ setEditorWidth(DEFAULT_EDITOR_WIDTH, false);
445
+ }
446
+ }
447
+
448
+ function startEditorResize(event) {
449
+ event.preventDefault();
450
+ const currentWidth = Number.parseFloat(getComputedStyle(document.querySelector('.app')).getPropertyValue('--editor-width')) || DEFAULT_EDITOR_WIDTH;
451
+ state.resizeDrag = { startX: event.clientX, startWidth: currentWidth };
452
+ document.body.classList.add('resizing');
453
+ els.resizeHandle.setPointerCapture?.(event.pointerId);
454
+ window.addEventListener('pointermove', resizeEditor);
455
+ window.addEventListener('pointerup', stopEditorResize, { once: true });
456
+ }
457
+
458
+ function resizeEditor(event) {
459
+ if (!state.resizeDrag) return;
460
+ const nextWidth = state.resizeDrag.startWidth + state.resizeDrag.startX - event.clientX;
461
+ setEditorWidth(nextWidth, true);
462
+ }
463
+
464
+ function stopEditorResize() {
465
+ state.resizeDrag = null;
466
+ document.body.classList.remove('resizing');
467
+ window.removeEventListener('pointermove', resizeEditor);
468
+ }
469
+
470
+ function resetEditorWidth() {
471
+ setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
472
+ }
473
+
474
+ function setEditorWidth(width, persist) {
475
+ const nextWidth = clampEditorWidth(width);
476
+ document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
477
+ if (!persist) return;
478
+ try {
479
+ window.localStorage.setItem(EDITOR_WIDTH_KEY, String(nextWidth));
480
+ } catch {}
481
+ }
482
+
483
+ function clampEditorWidth(width) {
484
+ return Math.min(MAX_EDITOR_WIDTH, Math.max(MIN_EDITOR_WIDTH, Math.round(width)));
485
+ }
486
+
364
487
  function initFrame() {
365
488
  try {
366
489
  const doc = els.frame.contentDocument;
@@ -406,6 +529,7 @@ function renderEditorShell(token: string): string {
406
529
  const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
407
530
  if (!state.deckVersion) {
408
531
  state.deckVersion = nextVersion;
532
+ markCommentsUpdatedForVersion(nextVersion);
409
533
  markStaleComments();
410
534
  return;
411
535
  }
@@ -414,7 +538,7 @@ function renderEditorShell(token: string): string {
414
538
  return;
415
539
  }
416
540
  state.deckVersion = nextVersion;
417
- markCommentsAppliedForVersion(nextVersion);
541
+ markCommentsUpdatedForVersion(nextVersion);
418
542
  refreshDeckPreview(body.mtimeMs);
419
543
  } catch (error) {
420
544
  reportError(error);
@@ -497,7 +621,7 @@ function renderEditorShell(token: string): string {
497
621
  const body = await res.json().catch(() => ({}));
498
622
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
499
623
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
500
- setStatus('Comment sent. Waiting for deck update...');
624
+ if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
501
625
  updateSendState();
502
626
  } catch (error) {
503
627
  updatePendingCommentStatus(commentId, 'failed');
@@ -525,9 +649,10 @@ function renderEditorShell(token: string): string {
525
649
  return;
526
650
  }
527
651
  const payload = collectPayload(target);
652
+ const color = REFERENCE_COLORS[(state.nextReferenceId - 1) % REFERENCE_COLORS.length];
528
653
  const id = 'ref-' + state.nextReferenceId++;
529
654
  const label = nextReferenceLabel(payload);
530
- const reference = { id, target, label, payload };
655
+ const reference = { id, target, label, payload, color };
531
656
  state.references.push(reference);
532
657
  insertReferenceChip(reference);
533
658
  renderReferenceOutlines();
@@ -571,7 +696,7 @@ function renderEditorShell(token: string): string {
571
696
  status,
572
697
  createdAt: Date.now(),
573
698
  baseDeckVersion: state.deckVersion,
574
- appliedVersion: null,
699
+ updatedVersion: null,
575
700
  });
576
701
  renderCommentThread();
577
702
  return id;
@@ -580,42 +705,46 @@ function renderEditorShell(token: string): string {
580
705
  function updatePendingCommentStatus(id, status, updates) {
581
706
  const comment = state.pendingComments.find((item) => item.id === id);
582
707
  if (!comment) return;
583
- if (comment.status === 'applied' && status !== 'failed') return;
708
+ if (comment.status === 'updated' && status !== 'failed') return;
584
709
  comment.status = status;
585
710
  if (updates) Object.assign(comment, updates);
586
711
  renderCommentThread();
587
712
  }
588
713
 
589
- function markCommentsAppliedForVersion(version) {
714
+ function markCommentsUpdatedForVersion(version) {
590
715
  let changed = false;
591
716
  state.pendingComments.forEach((comment) => {
592
717
  if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
593
- comment.status = 'applied';
594
- comment.appliedVersion = version;
718
+ comment.status = 'updated';
719
+ comment.updatedVersion = version;
595
720
  changed = true;
596
721
  }
597
722
  });
598
- if (changed) renderCommentThread();
723
+ if (changed) {
724
+ renderCommentThread();
725
+ setStatus('Deck file updated. Preview will refresh automatically.');
726
+ }
599
727
  }
600
728
 
601
729
  function markStaleComments() {
602
730
  const now = Date.now();
603
731
  let changed = false;
604
- const hasWaiting = state.pendingComments.some((comment) => {
605
- if (comment.status !== 'sent' && comment.status !== 'sending') return false;
606
- if (now - comment.createdAt < COMMENT_STALE_MS) return true;
732
+ state.pendingComments.forEach((comment) => {
733
+ if (comment.status !== 'sent' && comment.status !== 'sending') return;
734
+ if (now - comment.createdAt < COMMENT_STALE_MS) return;
607
735
  comment.status = 'stale';
608
736
  changed = true;
609
- return true;
610
737
  });
611
738
  if (changed) {
612
739
  renderCommentThread();
613
- setStatus('Still waiting for deck file update. If OpenCode already finished, refresh the editor.');
614
- } else if (hasWaiting) {
615
- setStatus('Comment sent. Waiting for deck update...');
740
+ setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
616
741
  }
617
742
  }
618
743
 
744
+ function pendingCommentStatus(id) {
745
+ return state.pendingComments.find((comment) => comment.id === id)?.status || '';
746
+ }
747
+
619
748
  function renderCommentThread() {
620
749
  els.commentThread.textContent = '';
621
750
  state.pendingComments.forEach((comment) => {
@@ -637,7 +766,7 @@ function renderEditorShell(token: string): string {
637
766
  }
638
767
 
639
768
  function commentStatusLabel(status) {
640
- if (status === 'applied') return ' Applied';
769
+ if (status === 'updated') return 'Deck file updated';
641
770
  if (status === 'stale') return 'Still waiting for deck file update';
642
771
  if (status === 'failed') return 'Failed to send';
643
772
  if (status === 'sending') return 'Sending to OpenCode...';
@@ -661,6 +790,12 @@ function renderEditorShell(token: string): string {
661
790
  return outline;
662
791
  }
663
792
 
793
+ function setOutlineColor(outline, color) {
794
+ if (!outline || !color) return;
795
+ outline.style.borderColor = color.border;
796
+ outline.style.background = color.fill;
797
+ }
798
+
664
799
  function renderBox(outline, target) {
665
800
  if (!outline || !target || !target.getBoundingClientRect) {
666
801
  if (outline) outline.style.display = 'none';
@@ -681,8 +816,12 @@ function renderEditorShell(token: string): string {
681
816
  function renderReferenceOutlines() {
682
817
  const doc = els.frame.contentDocument;
683
818
  if (!doc || doc.location.href === 'about:blank') return;
684
- while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#f59e0b', 'rgba(245,158,11,.16)'));
685
- state.referenceOutlines.forEach((outline, index) => renderBox(outline, state.references[index]?.target));
819
+ while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
820
+ state.referenceOutlines.forEach((outline, index) => {
821
+ const reference = state.references[index];
822
+ setOutlineColor(outline, reference?.color);
823
+ renderBox(outline, reference?.target);
824
+ });
686
825
  }
687
826
 
688
827
  function clearHover() {
@@ -704,6 +843,9 @@ function renderEditorShell(token: string): string {
704
843
  chip.className = 'ref-chip';
705
844
  chip.contentEditable = 'false';
706
845
  chip.dataset.refId = reference.id;
846
+ chip.style.setProperty('--ref-bg', reference.color.bg);
847
+ chip.style.setProperty('--ref-border', reference.color.border);
848
+ chip.style.setProperty('--ref-text', reference.color.text);
707
849
  chip.textContent = '@' + reference.label;
708
850
  const trailingSpace = document.createTextNode(' ');
709
851
  const range = getCommentInsertRange();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.7.8",
3
+ "version": "0.8.1",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  import { handlePdf } from "./lib/commands/pdf"
47
47
  import { handlePptx } from "./lib/commands/pptx"
48
48
  import { handleEdit } from "./lib/commands/edit"
49
+ import { ensureEditableDeckOpenForChange } from "./lib/edit/open"
49
50
  import { handleDesignsPreview } from "./lib/commands/designs-preview"
50
51
  import {
51
52
  parseDesignsNewArgs,
@@ -170,6 +171,27 @@ const server: Plugin = (async (pluginCtx) => {
170
171
  }
171
172
  }
172
173
 
174
+ function extractSessionID(input: any): string {
175
+ return input?.sessionID ?? input?.session?.id ?? input?.context?.sessionID ?? ""
176
+ }
177
+
178
+ function ensureEditorOpenAfterDeckChange(filePath: string, sessionID: string): void {
179
+ if (!isDeckHtmlPath(filePath) || !sessionID) return
180
+
181
+ try {
182
+ ensureEditableDeckOpenForChange("", {
183
+ client,
184
+ sessionID,
185
+ workspaceRoot,
186
+ })
187
+ } catch (e) {
188
+ childLog("edit").warn("failed to ensure visual editor after deck change", {
189
+ filePath,
190
+ error: e instanceof Error ? e.message : String(e),
191
+ })
192
+ }
193
+ }
194
+
173
195
  // ── Startup: seed + build initial prompt ────────────────────────────────
174
196
  try {
175
197
  seedBuiltinDesigns()
@@ -279,15 +301,23 @@ const server: Plugin = (async (pluginCtx) => {
279
301
  return
280
302
  }
281
303
  if (sub === "review") {
304
+ if (param) {
305
+ await send("`/revela review` no longer accepts a deck name. It reviews the current workspace deck.")
306
+ throw new Error("__REVELA_REVIEW_USAGE_HANDLED__")
307
+ }
282
308
  output.parts.length = 0
283
309
  output.parts.push({
284
310
  type: "text",
285
- text: buildReviewPrompt({ slug: param || undefined, exists: hasDecksState(workspaceRoot), workspaceRoot }),
311
+ text: buildReviewPrompt({ exists: hasDecksState(workspaceRoot), workspaceRoot }),
286
312
  } as any)
287
313
  return
288
314
  }
289
315
  if (sub === "edit") {
290
- await handleEdit(param, { client, sessionID, workspaceRoot }, send)
316
+ if (param) {
317
+ await send("`/revela edit` no longer accepts a target. It opens the only HTML deck in `decks/`.")
318
+ throw new Error("__REVELA_EDIT_USAGE_HANDLED__")
319
+ }
320
+ await handleEdit({ client, sessionID, workspaceRoot }, send)
291
321
  throw new Error("__REVELA_EDIT_HANDLED__")
292
322
  }
293
323
  if (sub === "designs" && !param) {
@@ -651,6 +681,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
651
681
  return
652
682
  }
653
683
  await appendComplianceReport(filePath, output)
684
+ ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
654
685
  return
655
686
  }
656
687
 
@@ -671,12 +702,15 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
671
702
  const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
672
703
  for (const target of targets) {
673
704
  await appendComplianceReport(target, output)
705
+ ensureEditorOpenAfterDeckChange(target, extractSessionID(input))
674
706
  }
675
707
  return
676
708
  }
677
709
 
678
710
  if (input.tool === "edit") {
679
- await appendComplianceReport(extractEditFilePath(input.args), output)
711
+ const filePath = extractEditFilePath(input.args)
712
+ await appendComplianceReport(filePath, output)
713
+ ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
680
714
  return
681
715
  }
682
716
  },