@cyber-dash-tech/revela 0.7.9 → 0.8.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,6 +1,6 @@
1
- import { existsSync } from "fs"
2
- import { basename, relative, resolve, sep } from "path"
3
- import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
1
+ import { existsSync, readdirSync } from "fs"
2
+ import { relative, resolve, sep } from "path"
3
+ import { DECKS_STATE_FILE, isDeckHtmlPath, workspaceDeckSlug } from "../decks-state"
4
4
 
5
5
  export interface EditableDeck {
6
6
  slug: string
@@ -9,68 +9,29 @@ export interface EditableDeck {
9
9
  source: "decks-state" | "fallback" | "file-path"
10
10
  }
11
11
 
12
- export function resolveEditableDeck(workspaceRoot: string, input: string): EditableDeck {
13
- const requested = input.trim()
14
- if (!requested) return resolveDefaultDeck(workspaceRoot)
15
-
16
- const slug = normalizeSlug(requested)
17
-
18
- if (hasDecksState(workspaceRoot)) {
19
- const state = readDecksState(workspaceRoot)
20
- const deck = state.decks[requested] ?? (slug ? state.decks[slug] : undefined)
21
- if (deck) {
22
- return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
23
- }
24
- }
25
-
26
- if (looksLikePath(requested)) {
27
- return resolvePathTarget(workspaceRoot, requested)
28
- }
29
-
30
- if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
31
-
32
- return resolveDeckFile(workspaceRoot, slug, `decks/${slug}.html`, "fallback")
33
- }
34
-
35
- function resolveDefaultDeck(workspaceRoot: string): EditableDeck {
36
- if (!hasDecksState(workspaceRoot)) {
37
- throw new Error(`No ${DECKS_STATE_FILE} found. Use /revela edit <deck-slug|decks/file.html>.`)
12
+ export function resolveEditableDeck(workspaceRoot: string, input = ""): EditableDeck {
13
+ if (input.trim()) {
14
+ throw new Error("/revela edit no longer accepts a target. It opens the only HTML deck in decks/.")
38
15
  }
39
16
 
40
- const state = readDecksState(workspaceRoot)
41
- const activeSlug = normalizeSlug(state.activeDeck || "")
42
- if (activeSlug) {
43
- const deck = state.decks[activeSlug]
44
- if (!deck) throw new Error(`Active deck ${activeSlug} does not exist in ${DECKS_STATE_FILE}. Use /revela edit <target>.`)
45
- return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
17
+ const htmlFiles = listDeckHtmlFiles(workspaceRoot)
18
+ if (htmlFiles.length === 0) {
19
+ throw new Error("No deck HTML found in decks/. Generate a deck first.")
46
20
  }
47
-
48
- const decks = Object.values(state.decks)
49
- if (decks.length === 1) {
50
- const deck = decks[0]
51
- return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
52
- }
53
-
54
- if (decks.length === 0) {
55
- throw new Error(`${DECKS_STATE_FILE} has no decks. Use /revela edit <deck-slug|decks/file.html>.`)
21
+ if (htmlFiles.length > 1) {
22
+ throw new Error("This workspace contains multiple deck HTML files. Revela 0.8 expects one deck per workspace. Move extra decks to separate workspaces.")
56
23
  }
57
24
 
58
- throw new Error(`${DECKS_STATE_FILE} has multiple decks and no activeDeck. Use /revela edit <target>.`)
25
+ return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), htmlFiles[0], "file-path")
59
26
  }
60
27
 
61
- function resolvePathTarget(workspaceRoot: string, requested: string): EditableDeck {
62
- if (isAbsoluteLike(requested)) {
63
- throw new Error("/revela edit only accepts workspace-relative decks/*.html paths.")
64
- }
65
-
66
- const normalized = normalizePath(requested).replace(/^\.\//, "")
67
- if (!isDeckHtmlPath(normalized)) {
68
- throw new Error("/revela edit file paths must point to decks/*.html.")
69
- }
70
-
71
- const slug = normalizeSlug(basename(normalized, ".html"))
72
- if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
73
- return resolveDeckFile(workspaceRoot, slug, normalized, "file-path")
28
+ function listDeckHtmlFiles(workspaceRoot: string): string[] {
29
+ const dir = resolve(workspaceRoot, "decks")
30
+ if (!existsSync(dir)) return []
31
+ return readdirSync(dir, { withFileTypes: true })
32
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".html"))
33
+ .map((entry) => `decks/${entry.name}`)
34
+ .sort((a, b) => a.localeCompare(b))
74
35
  }
75
36
 
76
37
  function resolveDeckFile(
@@ -93,7 +54,7 @@ function resolveDeckFile(
93
54
  }
94
55
 
95
56
  return {
96
- slug: normalizeSlug(slug),
57
+ slug,
97
58
  file: workspaceRelative(root, absoluteFile),
98
59
  absoluteFile,
99
60
  source,
@@ -107,19 +68,3 @@ function isInside(root: string, target: string): boolean {
107
68
  function workspaceRelative(root: string, target: string): string {
108
69
  return relative(root, target).split(sep).join("/")
109
70
  }
110
-
111
- function looksLikePath(value: string): boolean {
112
- return value.includes("/") || value.includes("\\") || value.endsWith(".html")
113
- }
114
-
115
- function isAbsoluteLike(value: string): boolean {
116
- return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)
117
- }
118
-
119
- function normalizePath(value: string): string {
120
- return value.replace(/\\/g, "/")
121
- }
122
-
123
- function normalizeSlug(value: string): string {
124
- return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
125
- }
@@ -1,11 +1,13 @@
1
1
  import { randomBytes } from "crypto"
2
2
  import { readFileSync, statSync } from "fs"
3
+ import { resolve, sep } from "path"
3
4
  import type { EditableDeck } from "./resolve-deck"
4
5
  import { buildEditPrompt, type EditCommentPayload } from "./prompt"
5
6
 
6
7
  const TOKEN_BYTES = 24
7
8
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000
8
9
  const IDLE_STOP_MS = 30 * 60 * 1000
10
+ export const LIVE_EDITOR_IDLE_MS = 10 * 1000
9
11
 
10
12
  interface EditSession {
11
13
  token: string
@@ -20,7 +22,13 @@ interface EditSession {
20
22
 
21
23
  export interface EditServerHandle {
22
24
  baseUrl: string
23
- createSession(input: { client: any; sessionID: string; deck: EditableDeck }): string
25
+ getOrCreateSession(input: { client: any; sessionID: string; deck: EditableDeck }): EditServerSessionResult
26
+ }
27
+
28
+ export interface EditServerSessionResult {
29
+ token: string
30
+ reused: boolean
31
+ live: boolean
24
32
  }
25
33
 
26
34
  let server: ReturnType<typeof Bun.serve> | undefined
@@ -41,8 +49,21 @@ export function startEditServer(): EditServerHandle {
41
49
 
42
50
  return {
43
51
  baseUrl,
44
- createSession(input) {
52
+ getOrCreateSession(input) {
45
53
  cleanupExpiredSessions()
54
+ const existing = findSessionForDeck(input.deck.absoluteFile)
55
+ if (existing) {
56
+ existing.session.client = input.client
57
+ existing.session.sessionID = input.sessionID
58
+ existing.session.deck = input.deck.slug
59
+ existing.session.file = input.deck.file
60
+ return {
61
+ token: existing.token,
62
+ reused: true,
63
+ live: isSessionLive(existing.session),
64
+ }
65
+ }
66
+
46
67
  const token = randomBytes(TOKEN_BYTES).toString("base64url")
47
68
  sessions.set(token, {
48
69
  token,
@@ -54,11 +75,47 @@ export function startEditServer(): EditServerHandle {
54
75
  createdAt: Date.now(),
55
76
  lastActiveAt: Date.now(),
56
77
  })
57
- return token
78
+ return { token, reused: false, live: false }
58
79
  },
59
80
  }
60
81
  }
61
82
 
83
+ export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
84
+ cleanupExpiredSessions()
85
+ const existing = findSessionForDeck(deck.absoluteFile)
86
+ return existing ? isSessionLive(existing.session, maxIdleMs) : false
87
+ }
88
+
89
+ export function hasLiveEditorSessionForFile(workspaceRoot: string, filePath: string, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
90
+ if (!filePath) return false
91
+ const root = resolve(workspaceRoot)
92
+ const absoluteFile = resolve(root, filePath)
93
+ if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return false
94
+ cleanupExpiredSessions()
95
+ const existing = findSessionForDeck(absoluteFile)
96
+ return existing ? isSessionLive(existing.session, maxIdleMs) : false
97
+ }
98
+
99
+ export function stopEditServer(): void {
100
+ if (idleTimer) clearTimeout(idleTimer)
101
+ idleTimer = undefined
102
+ sessions.clear()
103
+ server?.stop()
104
+ server = undefined
105
+ baseUrl = ""
106
+ }
107
+
108
+ function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined {
109
+ for (const [token, session] of sessions) {
110
+ if (session.absoluteFile === absoluteFile) return { token, session }
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
116
+ return Date.now() - session.lastActiveAt <= maxIdleMs
117
+ }
118
+
62
119
  async function handleRequest(req: Request): Promise<Response> {
63
120
  cleanupExpiredSessions()
64
121
  const url = new URL(req.url)
@@ -156,7 +213,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
156
213
  if (!token) return { ok: false, response: textResponse("Missing token", 401) }
157
214
  const session = sessions.get(token)
158
215
  if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
159
- if (Date.now() - session.createdAt > SESSION_TTL_MS) {
216
+ if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
160
217
  sessions.delete(token)
161
218
  return { ok: false, response: textResponse("Expired token", 401) }
162
219
  }
@@ -167,7 +224,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
167
224
  function cleanupExpiredSessions(): void {
168
225
  const now = Date.now()
169
226
  for (const [token, session] of sessions) {
170
- if (now - session.createdAt > SESSION_TTL_MS) sessions.delete(token)
227
+ if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
171
228
  }
172
229
  }
173
230
 
@@ -212,7 +269,7 @@ function jsonResponse(body: unknown, status = 200): Response {
212
269
  })
213
270
  }
214
271
 
215
- function renderEditorShell(token: string): string {
272
+ export function renderEditorShell(token: string): string {
216
273
  const encodedToken = JSON.stringify(token)
217
274
  return `<!doctype html>
218
275
  <html lang="en">
@@ -221,50 +278,57 @@ function renderEditorShell(token: string): string {
221
278
  <meta name="viewport" content="width=device-width, initial-scale=1" />
222
279
  <title>Revela Edit</title>
223
280
  <style>
224
- :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
281
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
225
282
  * { 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; }
283
+ body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
284
+ body.resizing { cursor: col-resize; user-select: none; }
285
+ body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
286
+ .app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
287
+ .preview { position: relative; min-width: 0; background: #eef3f8; }
288
+ .resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
289
+ .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; }
290
+ .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
291
+ iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
230
292
  .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; }
293
+ aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); }
294
+ h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
295
+ .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
296
+ .hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
234
297
  .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; }
298
+ .label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
299
+ .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); }
300
+ .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); }
301
+ .comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
302
+ .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
303
  .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); }
304
+ .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); }
305
+ .comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
306
+ .comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
307
+ .comment-bubble.stale { border-color: #facc15; background: #fefce8; }
308
+ .comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
246
309
  .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; }
310
+ .comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
311
+ .comment-bubble.updated .comment-bubble-state { color: #15803d; }
312
+ .comment-bubble.stale .comment-bubble-state { color: #a16207; }
313
+ .comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
314
+ 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
315
  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; } }
316
+ .status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
317
+ @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
318
  </style>
256
319
  </head>
257
320
  <body>
258
321
  <main class="app">
259
322
  <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>
323
+ <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
324
  <aside>
261
325
  <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>
326
+ <h1><span class="wordmark">REVELA</span> Editor</h1>
327
+ <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
328
  </div>
265
329
  <div class="panel">
266
330
  <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>
331
+ <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
332
  </div>
269
333
  <div id="commentThread" class="comment-thread" aria-live="polite"></div>
270
334
  <button id="send" disabled>Send comments</button>
@@ -275,6 +339,22 @@ function renderEditorShell(token: string): string {
275
339
  (() => {
276
340
  const token = ${encodedToken};
277
341
  const COMMENT_STALE_MS = 60000;
342
+ const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
343
+ const DEFAULT_EDITOR_WIDTH = 376;
344
+ const MIN_EDITOR_WIDTH = 320;
345
+ const MAX_EDITOR_WIDTH = 620;
346
+ const REFERENCE_COLORS = [
347
+ { border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
348
+ { border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
349
+ { border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
350
+ { border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
351
+ { border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
352
+ { border: '#73b8bd', fill: 'rgba(115,184,189,.18)', bg: '#e8f6f7', text: '#285f64' },
353
+ { border: '#c7b46e', fill: 'rgba(199,180,110,.18)', bg: '#f8f3df', text: '#6b5b1e' },
354
+ { border: '#9eb27e', fill: 'rgba(158,178,126,.18)', bg: '#f1f6e9', text: '#4f642e' },
355
+ { border: '#c08fc8', fill: 'rgba(192,143,200,.18)', bg: '#f7edf8', text: '#6b3f73' },
356
+ { border: '#8fa7c9', fill: 'rgba(143,167,201,.18)', bg: '#eef3fa', text: '#405a7b' },
357
+ ];
278
358
  const state = {
279
359
  references: [],
280
360
  pendingComments: [],
@@ -288,10 +368,12 @@ function renderEditorShell(token: string): string {
288
368
  pendingRefreshMessage: false,
289
369
  bound: false,
290
370
  commentRange: null,
371
+ resizeDrag: null,
291
372
  };
292
373
  const els = {
293
374
  frame: null,
294
375
  hitbox: null,
376
+ resizeHandle: null,
295
377
  comment: null,
296
378
  commentThread: null,
297
379
  send: null,
@@ -311,15 +393,17 @@ function renderEditorShell(token: string): string {
311
393
  try {
312
394
  els.frame = document.getElementById('deck');
313
395
  els.hitbox = document.getElementById('hitbox');
396
+ els.resizeHandle = document.getElementById('resizeHandle');
314
397
  els.comment = document.getElementById('comment');
315
398
  els.commentThread = document.getElementById('commentThread');
316
399
  els.send = document.getElementById('send');
317
400
  els.status = document.getElementById('status');
318
401
 
319
- if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
402
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !els.comment || !els.commentThread || !els.send || !els.status) {
320
403
  throw new Error('Editor boot failed: required DOM nodes are missing.');
321
404
  }
322
405
 
406
+ restoreEditorWidth();
323
407
  bindEvents();
324
408
  setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
325
409
  initFrame();
@@ -358,9 +442,59 @@ function renderEditorShell(token: string): string {
358
442
  renderHoverOutline(state.hoverEl);
359
443
  renderReferenceOutlines();
360
444
  }, { passive: false });
445
+ els.resizeHandle.addEventListener('pointerdown', startEditorResize);
446
+ els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
361
447
  els.send.addEventListener('click', sendComment);
362
448
  }
363
449
 
450
+ function restoreEditorWidth() {
451
+ try {
452
+ const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
453
+ setEditorWidth(Number.isFinite(saved) ? saved : DEFAULT_EDITOR_WIDTH, false);
454
+ } catch {
455
+ setEditorWidth(DEFAULT_EDITOR_WIDTH, false);
456
+ }
457
+ }
458
+
459
+ function startEditorResize(event) {
460
+ event.preventDefault();
461
+ const currentWidth = Number.parseFloat(getComputedStyle(document.querySelector('.app')).getPropertyValue('--editor-width')) || DEFAULT_EDITOR_WIDTH;
462
+ state.resizeDrag = { startX: event.clientX, startWidth: currentWidth };
463
+ document.body.classList.add('resizing');
464
+ els.resizeHandle.setPointerCapture?.(event.pointerId);
465
+ window.addEventListener('pointermove', resizeEditor);
466
+ window.addEventListener('pointerup', stopEditorResize, { once: true });
467
+ }
468
+
469
+ function resizeEditor(event) {
470
+ if (!state.resizeDrag) return;
471
+ const nextWidth = state.resizeDrag.startWidth + state.resizeDrag.startX - event.clientX;
472
+ setEditorWidth(nextWidth, true);
473
+ }
474
+
475
+ function stopEditorResize() {
476
+ state.resizeDrag = null;
477
+ document.body.classList.remove('resizing');
478
+ window.removeEventListener('pointermove', resizeEditor);
479
+ }
480
+
481
+ function resetEditorWidth() {
482
+ setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
483
+ }
484
+
485
+ function setEditorWidth(width, persist) {
486
+ const nextWidth = clampEditorWidth(width);
487
+ document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
488
+ if (!persist) return;
489
+ try {
490
+ window.localStorage.setItem(EDITOR_WIDTH_KEY, String(nextWidth));
491
+ } catch {}
492
+ }
493
+
494
+ function clampEditorWidth(width) {
495
+ return Math.min(MAX_EDITOR_WIDTH, Math.max(MIN_EDITOR_WIDTH, Math.round(width)));
496
+ }
497
+
364
498
  function initFrame() {
365
499
  try {
366
500
  const doc = els.frame.contentDocument;
@@ -406,6 +540,7 @@ function renderEditorShell(token: string): string {
406
540
  const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
407
541
  if (!state.deckVersion) {
408
542
  state.deckVersion = nextVersion;
543
+ markCommentsUpdatedForVersion(nextVersion);
409
544
  markStaleComments();
410
545
  return;
411
546
  }
@@ -414,7 +549,7 @@ function renderEditorShell(token: string): string {
414
549
  return;
415
550
  }
416
551
  state.deckVersion = nextVersion;
417
- markCommentsAppliedForVersion(nextVersion);
552
+ markCommentsUpdatedForVersion(nextVersion);
418
553
  refreshDeckPreview(body.mtimeMs);
419
554
  } catch (error) {
420
555
  reportError(error);
@@ -497,7 +632,7 @@ function renderEditorShell(token: string): string {
497
632
  const body = await res.json().catch(() => ({}));
498
633
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
499
634
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
500
- setStatus('Comment sent. Waiting for deck update...');
635
+ if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
501
636
  updateSendState();
502
637
  } catch (error) {
503
638
  updatePendingCommentStatus(commentId, 'failed');
@@ -525,9 +660,10 @@ function renderEditorShell(token: string): string {
525
660
  return;
526
661
  }
527
662
  const payload = collectPayload(target);
663
+ const color = REFERENCE_COLORS[(state.nextReferenceId - 1) % REFERENCE_COLORS.length];
528
664
  const id = 'ref-' + state.nextReferenceId++;
529
665
  const label = nextReferenceLabel(payload);
530
- const reference = { id, target, label, payload };
666
+ const reference = { id, target, label, payload, color };
531
667
  state.references.push(reference);
532
668
  insertReferenceChip(reference);
533
669
  renderReferenceOutlines();
@@ -571,7 +707,7 @@ function renderEditorShell(token: string): string {
571
707
  status,
572
708
  createdAt: Date.now(),
573
709
  baseDeckVersion: state.deckVersion,
574
- appliedVersion: null,
710
+ updatedVersion: null,
575
711
  });
576
712
  renderCommentThread();
577
713
  return id;
@@ -580,42 +716,46 @@ function renderEditorShell(token: string): string {
580
716
  function updatePendingCommentStatus(id, status, updates) {
581
717
  const comment = state.pendingComments.find((item) => item.id === id);
582
718
  if (!comment) return;
583
- if (comment.status === 'applied' && status !== 'failed') return;
719
+ if (comment.status === 'updated' && status !== 'failed') return;
584
720
  comment.status = status;
585
721
  if (updates) Object.assign(comment, updates);
586
722
  renderCommentThread();
587
723
  }
588
724
 
589
- function markCommentsAppliedForVersion(version) {
725
+ function markCommentsUpdatedForVersion(version) {
590
726
  let changed = false;
591
727
  state.pendingComments.forEach((comment) => {
592
728
  if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
593
- comment.status = 'applied';
594
- comment.appliedVersion = version;
729
+ comment.status = 'updated';
730
+ comment.updatedVersion = version;
595
731
  changed = true;
596
732
  }
597
733
  });
598
- if (changed) renderCommentThread();
734
+ if (changed) {
735
+ renderCommentThread();
736
+ setStatus('Deck file updated. Preview will refresh automatically.');
737
+ }
599
738
  }
600
739
 
601
740
  function markStaleComments() {
602
741
  const now = Date.now();
603
742
  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;
743
+ state.pendingComments.forEach((comment) => {
744
+ if (comment.status !== 'sent' && comment.status !== 'sending') return;
745
+ if (now - comment.createdAt < COMMENT_STALE_MS) return;
607
746
  comment.status = 'stale';
608
747
  changed = true;
609
- return true;
610
748
  });
611
749
  if (changed) {
612
750
  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...');
751
+ setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
616
752
  }
617
753
  }
618
754
 
755
+ function pendingCommentStatus(id) {
756
+ return state.pendingComments.find((comment) => comment.id === id)?.status || '';
757
+ }
758
+
619
759
  function renderCommentThread() {
620
760
  els.commentThread.textContent = '';
621
761
  state.pendingComments.forEach((comment) => {
@@ -637,7 +777,7 @@ function renderEditorShell(token: string): string {
637
777
  }
638
778
 
639
779
  function commentStatusLabel(status) {
640
- if (status === 'applied') return ' Applied';
780
+ if (status === 'updated') return 'Deck file updated';
641
781
  if (status === 'stale') return 'Still waiting for deck file update';
642
782
  if (status === 'failed') return 'Failed to send';
643
783
  if (status === 'sending') return 'Sending to OpenCode...';
@@ -661,6 +801,12 @@ function renderEditorShell(token: string): string {
661
801
  return outline;
662
802
  }
663
803
 
804
+ function setOutlineColor(outline, color) {
805
+ if (!outline || !color) return;
806
+ outline.style.borderColor = color.border;
807
+ outline.style.background = color.fill;
808
+ }
809
+
664
810
  function renderBox(outline, target) {
665
811
  if (!outline || !target || !target.getBoundingClientRect) {
666
812
  if (outline) outline.style.display = 'none';
@@ -681,8 +827,12 @@ function renderEditorShell(token: string): string {
681
827
  function renderReferenceOutlines() {
682
828
  const doc = els.frame.contentDocument;
683
829
  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));
830
+ while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
831
+ state.referenceOutlines.forEach((outline, index) => {
832
+ const reference = state.references[index];
833
+ setOutlineColor(outline, reference?.color);
834
+ renderBox(outline, reference?.target);
835
+ });
686
836
  }
687
837
 
688
838
  function clearHover() {
@@ -704,6 +854,9 @@ function renderEditorShell(token: string): string {
704
854
  chip.className = 'ref-chip';
705
855
  chip.contentEditable = 'false';
706
856
  chip.dataset.refId = reference.id;
857
+ chip.style.setProperty('--ref-bg', reference.color.bg);
858
+ chip.style.setProperty('--ref-border', reference.color.border);
859
+ chip.style.setProperty('--ref-text', reference.color.text);
707
860
  chip.textContent = '@' + reference.label;
708
861
  const trailingSpace = document.createTextNode(' ');
709
862
  const range = getCommentInsertRange();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.7.9",
3
+ "version": "0.8.2",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",