@panphora/clayjs 1.5.3 → 1.6.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.
Files changed (46) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +15041 -10657
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-core.js +35 -2
  10. package/src/core/save-feedback.js +6 -0
  11. package/src/core/save.js +157 -3
  12. package/src/core/snapshot.js +21 -0
  13. package/src/core/stale-host-notice.js +21 -24
  14. package/src/core/unsaved-warning.js +64 -16
  15. package/src/lib/content-dom.js +4 -2
  16. package/src/lib/dirty-gate.js +37 -1
  17. package/src/lib/unsaved-state.js +55 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +10 -3
  20. package/src/plugins/ai-edit.js +489 -179
  21. package/src/plugins/cms.js +12 -0
  22. package/src/plugins/indicator.js +35 -16
  23. package/src/plugins/quickcrop.js +103 -0
  24. package/src/plugins/richclay.js +21 -0
  25. package/src/plugins/source.js +13 -6
  26. package/src/plugins/wire.js +4 -0
  27. package/src/sync/conflict-revert.js +1138 -0
  28. package/src/sync/conflicts.js +159 -0
  29. package/src/sync/live-sync.js +130 -109
  30. package/src/sync/presence.js +42 -36
  31. package/src/sync/section-notice.js +53 -28
  32. package/src/ui/bevel-controls.js +277 -0
  33. package/src/ui/bevel-dialog.js +112 -0
  34. package/src/ui/bevel.js +247 -0
  35. package/src/ui/dialogs.js +63 -86
  36. package/src/ui/index.js +22 -11
  37. package/src/ui/modal.js +80 -387
  38. package/src/ui/skins/cms-toggle.js +15 -0
  39. package/src/ui/skins/cms.js +4 -0
  40. package/src/ui/skins/quickcrop.js +4 -0
  41. package/src/ui/skins/richclay.js +4 -0
  42. package/src/ui/toast.js +116 -401
  43. package/src/ui/vendor-skin.js +65 -0
  44. package/src/vendor/hyper-morph.vendor.js +4 -4
  45. package/src/vendor/quickcrop.vendor.js +14 -8
  46. package/src/vendor/richclay.vendor.js +22 -41
@@ -3,11 +3,10 @@
3
3
  *
4
4
  * Direct editing stays primary: text units carry editmode:contenteditable, so a
5
5
  * click places the caret and a figure keeps its own interactivity. This adds the
6
- * comment box on demand: a floating chip near the hovered unit, Cmd+K for the unit
7
- * holding the caret (the selection rides along as a quote), a click on bare section
8
- * padding, or the fixed bottom-right bubble for the whole document. Units resolve to
9
- * the nearest h1-h6/p/figure inside a [data-edit-id] section, falling back to the
10
- * section itself.
6
+ * comment box on demand: Cmd+J (Ctrl+J elsewhere) or the small chip at the end of a
7
+ * selection, for the block around it; on pages built from [data-edit-id] sections,
8
+ * also a hover chip near the unit, a click on bare section padding, and the fixed
9
+ * bottom-right bubble for the whole document.
11
10
  *
12
11
  * The request leaves as a named helper request on the wire
13
12
  * (`clay.wire.send(payload, { helper: "ai-edit" })`) and comes back as the edited
@@ -15,9 +14,10 @@
15
14
  * writes nothing: this page morphs the result in and Keep saves it through
16
15
  * `clay.save()`, exactly as before.
17
16
  *
18
- * Dormant unless the host lists a ready `ai-edit` helper. On HTML Clay, or with the
19
- * desktop toggle off, `clay.wire.helpers()` never lists one and nothing is built at
20
- * all: the page is then just a page.
17
+ * Dormant unless the host lists an `ai-edit` helper: then nothing is built and the
18
+ * page is just a page. Listed as unavailable (the host's toggle is off), the
19
+ * shortcut opens the panel with a note and no Send, and the state is checked again
20
+ * each time the panel opens.
21
21
  *
22
22
  * The bus version previewed the reply as it streamed. The wire carries status lines
23
23
  * only — replaceable progress, capped and droppable — so the page shows throttled
@@ -39,14 +39,23 @@ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
39
39
  import { mergeTagRecognizers } from "../sync/merge-tags.js";
40
40
  import Mutation from "../lib/mutation.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
+ import { holdAllSaves, releaseAllSaves } from "../core/save.js";
42
43
  import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
43
44
  import { enableContentEditable } from "../core/admin-contenteditable.js";
44
45
  import onDomReady from "../lib/dom-ready.js";
45
46
  import wire from "./wire.js";
47
+ import { set } from "../lib/hostile-css.js";
48
+ import { bevelBox, bevelButton, bevelSurface, bevelText, bevelInput, pageScheme, setShown, RUNTIME_ONLY } from "../ui/bevel-controls.js";
49
+ import { TOKENS, FONT_SANS, FONT_MONO, SHADOW } from "../ui/bevel.js";
46
50
 
47
51
  const HELPER = "ai-edit";
48
52
  const UNIT_SELECTOR = "h1,h2,h3,h4,h5,h6,p,figure";
49
- const RUNTIME_ONLY = "no-save no-watch no-snapshot";
53
+ // Outside [data-edit-id] sections: the nearest text block that holds the whole
54
+ // selection, else the nearest container that does. A selection across top-level blocks
55
+ // edits the whole page.
56
+ const BLOCK_SELECTOR = "p,h1,h2,h3,h4,h5,h6,li,dt,dd,blockquote,pre,figcaption,td,th";
57
+ const CONTAINER_SELECTOR = "section,article,aside,header,footer,nav,main,div,ul,ol,dl,table,figure,form";
58
+ const NOT_TEXT = "script,style,template,textarea,input,select,button,iframe,svg,math";
50
59
  // The request ceiling, kept below the host's 1 MiB envelope so the refusal happens
51
60
  // here, where the message can say what to do about it.
52
61
  const MAX_PAYLOAD_BYTES = 900 * 1024;
@@ -54,17 +63,30 @@ const TOO_LARGE = "This section is too large for AI editing; select a smaller pa
54
63
 
55
64
  let requestCounter = 0;
56
65
  let session = null; // one edit at a time
57
- let panel, ring, textarea, quoteEl, statusEl, warningsEl, chip, docBubble;
66
+ let panel, ring, textarea, statusEl, warningsEl, pointer, chip, docBubble;
58
67
  let buttons = {};
59
68
  let anchorEl = null; // element the panel is currently anchored to
60
69
  let chipTarget = null; // unit the hover chip currently points at
61
70
  let chipHideTimer = null;
71
+ let pendingSelection; // { text, start, end } for the open panel, or undefined
72
+
73
+ let chipSelection; // the selection the chip was raised for; undefined for the hover chip
74
+ let selectionTimer = null;
75
+ let anchorRange = null; // the selection the open panel was raised for
76
+ let returnFocus = null; // where focus goes back to when the panel closes
77
+ let helperState = null; // "ready" or "unavailable"
78
+
79
+ const HIGHLIGHT = 'clay-ai-edit';
80
+ const OFF_MESSAGE = 'AI editing is turned off. Turn it on from the app’s menu.';
62
81
 
63
82
  // ---------------------------------------------------------------- transport
64
83
 
65
- async function available() {
84
+ // The helper's state as this host lists it: "ready", "unavailable" (listed, but the
85
+ // host's toggle is off), or null when there is no ai-edit helper to talk to.
86
+ async function helperStateNow() {
66
87
  const list = await wire.helpers();
67
- return list.some(h => h.name === HELPER && h.state === "ready");
88
+ const helper = list.find(h => h.name === HELPER);
89
+ return helper && (helper.state === 'ready' || helper.state === 'unavailable') ? helper.state : null;
68
90
  }
69
91
 
70
92
  // ---------------------------------------------------------------- observers (undo/autosave)
@@ -94,18 +116,52 @@ function editLabel(el) {
94
116
  const own = el.getAttribute('data-edit-id');
95
117
  if (own) return own;
96
118
  const section = el.closest('[data-edit-id]');
97
- return (section ? section.getAttribute('data-edit-id') + ' \u203a ' : '') + el.tagName.toLowerCase();
119
+ if (!section) return el.id ? '#' + el.id : el.tagName.toLowerCase();
120
+ return section.getAttribute('data-edit-id') + ' \u203a ' + el.tagName.toLowerCase();
98
121
  }
99
122
 
100
- function quoteFromSelection(scope) {
123
+ function liveRange() {
101
124
  const selection = window.getSelection();
102
- if (!selection || selection.isCollapsed) return undefined;
103
- const text = selection.toString().trim();
104
- if (!text) return undefined;
105
- let node = selection.getRangeAt(0).commonAncestorContainer;
125
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
126
+ return selection.getRangeAt(0);
127
+ }
128
+
129
+ // The element a selection edits. Section pages keep their unit rules; everywhere
130
+ // else it is the block around the whole selection, so a backward selection and a
131
+ // forward one land on the same element.
132
+ function targetFromRange(range) {
133
+ let node = range.commonAncestorContainer;
106
134
  if (node.nodeType !== 1) node = node.parentElement;
107
- if (scope && scope !== document.body && !scope.contains(node)) return undefined;
108
- return text.length > 400 ? text.slice(0, 400) + '\u2026' : text;
135
+ if (!node || !document.body.contains(node)) return null;
136
+ // A selection across top-level blocks has only the page around it.
137
+ if (node === document.body) return document.body;
138
+ if (node.closest('[data-clay-ai-edit]') || node.closest(STRIP_FROM_SAVE) || node.closest(NOT_TEXT)) return null;
139
+ if (node.closest('[data-edit-id]')) return unitFrom(node);
140
+ const target = node.closest(BLOCK_SELECTOR) || node.closest(CONTAINER_SELECTOR);
141
+ return target && target !== document.body ? target : null;
142
+ }
143
+
144
+ // The selected text, whole, with its character offsets inside the target's text,
145
+ // so a phrase that appears twice is unambiguous.
146
+ function selectionIn(range, target) {
147
+ if (!range || !target || !target.contains(range.commonAncestorContainer)) return undefined;
148
+ const text = range.toString();
149
+ if (!text.trim()) return undefined;
150
+ const before = document.createRange();
151
+ before.selectNodeContents(target);
152
+ before.setEnd(range.startContainer, range.startOffset);
153
+ const start = before.toString().length;
154
+ return { text, start, end: start + text.length };
155
+ }
156
+
157
+ function quoteFromSelection(scope) {
158
+ const range = liveRange();
159
+ if (!range) return undefined;
160
+ if (scope === document.body) {
161
+ const text = range.toString();
162
+ return text.trim() ? { text, start: undefined, end: undefined } : undefined;
163
+ }
164
+ return selectionIn(range, scope);
109
165
  }
110
166
 
111
167
  function strippedBodyHTML() {
@@ -128,9 +184,11 @@ function morphTo(el, content, scripts) {
128
184
  // against the snapshot the model saw, so data the user changed mid-flight survives
129
185
  // the reply. Rewinds must restore the snapshot exactly, so they disable merging.
130
186
  function sessionMorph(s, content, { rewind = false } = {}) {
187
+ // handle:false: a reply's scripts never run, so the preview cannot execute code
188
+ // before Keep, and neither can a rewind.
131
189
  const scripts = rewind
132
- ? { merge: false }
133
- : { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers };
190
+ ? { merge: false, handle: false }
191
+ : { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers, handle: false };
134
192
  if (s.docMode) {
135
193
  if (typeof content === 'string') {
136
194
  // Parse to an element ourselves: hyper-morph parses a body string into a
@@ -177,9 +235,11 @@ function newSession(el, comment, quote) {
177
235
  // Context refs are @tokens containing a dot or slash (@notes.md, @src/x.js). Bare
178
236
  // @words are not refs: a leading one is an engine token (@fable, @codex — routed
179
237
  // helper-side), and mid-text ones are prose.
180
- const contextRefs = [...comment.matchAll(/@([\w.-]*[/.][\w./-]*)/g)]
181
- .map(m => m[1])
182
- .filter(ref => ref !== 'page');
238
+ // The @ has to start a word, so an email address or a URL is prose too.
239
+ const contextRefs = [...comment.matchAll(/(?<![\w\u00C0-\u024F.@\/:-])@([\w.-]*[/.][\w./-]*)/g)]
240
+ .map(m => m[1].replace(/\.+$/, '')) // a sentence's full stop is not part of the name
241
+ .filter(ref => ref !== 'page')
242
+ .filter(ref => /[/.]/.test(ref));
183
243
  const elementHTML = docMode ? strippedBodyHTML() : el.outerHTML;
184
244
  const payload = {
185
245
  id: 'req-' + Date.now().toString(36) + '-' + (++requestCounter),
@@ -189,10 +249,13 @@ function newSession(el, comment, quote) {
189
249
  comment,
190
250
  contextRefs
191
251
  };
192
- if (quote) payload.quote = quote;
252
+ if (quote) {
253
+ payload.quote = quote.text;
254
+ if (Number.isInteger(quote.start)) payload.selection = { start: quote.start, end: quote.end };
255
+ }
193
256
  // Document mode reads the live page itself, and every other @page request reads
194
257
  // the file from disk: the plugin saves first, then asks for it by flag.
195
- if (!docMode && /@page\b/.test(comment)) payload.page = true;
258
+ if (!docMode && /(?<![\w\u00C0-\u024F.@\/:-])@page(?![\w\/-]|\.\w)/.test(comment)) payload.page = true;
196
259
  return {
197
260
  id: payload.id,
198
261
  el,
@@ -223,7 +286,7 @@ async function sendRequest(el, comment, quote) {
223
286
  setStatus('sending\u2026');
224
287
  showButtons('stop');
225
288
  // @page reads the file the host has on disk, so the page has to be on disk first.
226
- if (/@page\b/.test(comment) && !session.docMode) await window.clay.save();
289
+ if (session.payload.page) await window.clay.save();
227
290
  if (!session) return; // cancelled while saving
228
291
  // The host owns the deadline; this side only renders. A named request defaults to
229
292
  // `document: "none"`, so nothing here asks anyone to write the file.
@@ -241,6 +304,63 @@ async function sendRequest(el, comment, quote) {
241
304
  else onError(outcome.error || 'the helper reported an error');
242
305
  }
243
306
 
307
+ // What a reply may not add: anything that runs code. Each part is counted, and the
308
+ // reply is refused when it holds more of any part than the element the model was
309
+ // given, so a page's own scripts and handlers are not a reason to refuse an edit
310
+ // near them, while a copied handler or a changed script is.
311
+ const ACTIVE_ATTR = /^(on|hx-on|x-|@|:|v-on:|v-bind:|data-on[-:])/;
312
+ const ACTIVE_TAGS = new Set(['iframe', 'frame', 'object', 'embed', 'base', 'link']);
313
+
314
+ // URL parsing removes tab, CR and LF anywhere and controls and spaces at the start,
315
+ // so "java&#9;script:" is still a javascript: URL.
316
+ function isScriptURL(value) {
317
+ const url = value.replace(/[\t\n\r]/g, '').replace(/^[\u0000- ]+/, '').toLowerCase();
318
+ return url.startsWith('javascript:') || url.startsWith('vbscript:');
319
+ }
320
+
321
+ // A script whose type the browser does not run (JSON, a template) is data.
322
+ function runsAsCode(script) {
323
+ const type = (script.getAttribute('type') || '').trim().toLowerCase();
324
+ return !type || /javascript|ecmascript/.test(type) || ['module', 'importmap', 'speculationrules'].includes(type);
325
+ }
326
+
327
+ function attrsOf(el) {
328
+ return [...el.attributes].map(a => a.name.toLowerCase() + '=' + a.value).sort().join(' ');
329
+ }
330
+
331
+ function activeParts(root) {
332
+ const parts = new Map();
333
+ const add = (part) => parts.set(part, (parts.get(part) || 0) + 1);
334
+ for (const el of [root, ...root.querySelectorAll('*')]) {
335
+ const tag = el.tagName.toLowerCase();
336
+ if (tag === 'script') {
337
+ if (runsAsCode(el)) add('script ' + attrsOf(el) + '|' + el.textContent.trim());
338
+ } else if (ACTIVE_TAGS.has(tag) || (tag === 'meta' && el.hasAttribute('http-equiv'))) {
339
+ add(tag + ' ' + attrsOf(el));
340
+ } else if ((tag === 'animate' || tag === 'set') && /href/i.test(el.getAttribute('attributeName') || '')) {
341
+ add(tag + ' ' + attrsOf(el));
342
+ }
343
+ for (const attr of el.attributes) {
344
+ const name = attr.name.toLowerCase();
345
+ if (ACTIVE_ATTR.test(name)) add(tag + ' ' + name + '=' + attr.value.trim());
346
+ else if (name === 'srcdoc') add(tag + ' srcdoc=' + attr.value);
347
+ else if (isScriptURL(attr.value)) add(tag + ' ' + name + '=' + attr.value);
348
+ }
349
+ }
350
+ return parts;
351
+ }
352
+
353
+ function addsActiveContent(candidate, snapshot, docMode) {
354
+ const original = docMode
355
+ ? new DOMParser().parseFromString(snapshot, 'text/html').body
356
+ : (() => { const t = document.createElement('template'); t.innerHTML = snapshot; return t.content.firstElementChild; })();
357
+ const before = original ? activeParts(original) : new Map();
358
+ for (const [part, count] of activeParts(candidate)) if (count > (before.get(part) || 0)) return true;
359
+ return false;
360
+ }
361
+
362
+ const ACTIVE_REFUSED = 'The reply adds a script or event handler, so it was not applied. AI editing changes content, not code.';
363
+
244
364
  function onDone(payload) {
245
365
  if (!session || session.state !== 'requesting') return;
246
366
  session.state = 'deciding';
@@ -259,25 +379,27 @@ function onDone(payload) {
259
379
  }
260
380
  }
261
381
  if (session.reassertId) candidate.setAttribute('data-edit-id', session.reassertId);
262
- if (!session.docMode) { // the real body legitimately contains scripts; skip in doc mode
263
- if (candidate.querySelector('script')) {
264
- warnings.push('reply contains <script> \u2014 review before keeping');
265
- }
266
- for (const el of [candidate, ...candidate.querySelectorAll('*')]) {
267
- if ([...el.attributes].some(a => a.name.toLowerCase().startsWith('on'))) {
268
- warnings.push('reply contains inline event handlers \u2014 review before keeping');
269
- break;
270
- }
271
- }
382
+ if (addsActiveContent(candidate, session.snapshot, session.docMode)) {
383
+ onError(ACTIVE_REFUSED);
384
+ return;
272
385
  }
273
386
 
387
+ holdAllSaves();
388
+ session.held = true;
389
+
274
390
  session.finalCandidate = candidate;
275
- sessionMorph(session, candidate); // authoritative morph from the full final HTML
391
+ try {
392
+ sessionMorph(session, candidate); // authoritative morph from the full final HTML
393
+ } catch (error) {
394
+ onError('the reply could not be applied: ' + (error?.message || error));
395
+ return;
396
+ }
276
397
  positionChrome();
277
398
  setStatus(payload.model ? `done (${payload.model})` : 'done');
278
399
  showWarnings(warnings);
279
400
  showButtons('keep', 'revert');
280
401
  textarea.value = '';
402
+ fitTextarea();
281
403
  }
282
404
 
283
405
  function onError(message) {
@@ -293,18 +415,26 @@ function revertSession() {
293
415
  const s = session;
294
416
  session = null;
295
417
  if (!s) return;
296
- if (s.state === 'requesting' || s.state === 'deciding') {
297
- sessionMorph(s, s.snapshot, { rewind: true });
418
+ try {
419
+ if (s.state === 'requesting' || s.state === 'deciding') {
420
+ sessionMorph(s, s.snapshot, { rewind: true });
421
+ }
422
+ } finally {
423
+ if (s.paused) resumeObservers();
424
+ if (s.held) releaseAllSaves();
425
+ positionChrome();
298
426
  }
299
- if (s.paused) resumeObservers();
300
- positionChrome();
301
427
  }
302
428
 
303
429
  async function keepSession() {
304
430
  const s = session;
305
431
  session = null;
306
- sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
307
- if (s.paused) resumeObservers(); // boundary drain discards the rewind
432
+ try {
433
+ sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
434
+ } finally {
435
+ if (s.paused) resumeObservers(); // boundary drain discards the rewind
436
+ if (s.held) releaseAllSaves({ replay: false });
437
+ }
308
438
  sessionMorph(s, s.finalCandidate); // recorded: the whole edit = one undo step
309
439
  positionChrome();
310
440
  showButtons('send');
@@ -324,8 +454,15 @@ function cancelStream() {
324
454
 
325
455
  // ---------------------------------------------------------------- chrome
326
456
 
327
- function markRuntimeOnly(el) {
328
- el.setAttribute('clay', RUNTIME_ONLY);
457
+ // Everything below is drawn on somebody else's page, so it follows the hostile-CSS
458
+ // contract: runtime-only, no ids or classes, every declaration inline and !important
459
+ // from all:initial, in Bevel material from the generated subset. Show and hide go
460
+ // through setShown and positions through set(), both !important, so no page rule can
461
+ // reveal a hidden part or move a placed one.
462
+ const FLOATING = ['z-index:99999', 'padding:0', 'display:inline-grid', 'place-items:center', 'line-height:1', `box-shadow:${SHADOW}`];
463
+
464
+ function marked(el, name, value) {
465
+ el.setAttribute(name, value);
329
466
  return el;
330
467
  }
331
468
 
@@ -333,108 +470,85 @@ function buildChrome() {
333
470
  const style = document.createElement('style');
334
471
  style.setAttribute('clay', RUNTIME_ONLY);
335
472
  style.textContent = `
336
- #hyper-edit-panel {
337
- position: fixed; z-index: 99999; width: min(30rem, calc(100vw - 2rem));
338
- background: #16161d; color: #e8e8f0; border: 1px solid #34343f;
339
- border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,.45);
340
- font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
341
- padding: 10px;
342
- }
343
- #hyper-edit-panel[hidden] { display: none; }
344
- #hyper-edit-panel .hep-quote {
345
- color: #9a9ab0; border-left: 2px solid #4a4a5a; padding-left: 8px;
346
- margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
347
- }
348
- #hyper-edit-panel textarea {
349
- width: 100%; box-sizing: border-box; min-height: 3.2em; resize: vertical;
350
- background: #0e0e13; color: inherit; border: 1px solid #34343f;
351
- border-radius: 6px; padding: 8px; font: inherit; outline: none;
352
- }
353
- #hyper-edit-panel textarea:focus { border-color: #6a6a8a; }
354
- #hyper-edit-panel .hep-row {
355
- display: flex; align-items: center; gap: 8px; margin-top: 8px;
356
- }
357
- #hyper-edit-panel .hep-status { flex: 1; color: #9a9ab0; min-width: 0; }
358
- #hyper-edit-panel .hep-status.warn { color: #e8b04a; }
359
- #hyper-edit-panel button {
360
- background: #26262f; color: inherit; border: 1px solid #44444f;
361
- border-radius: 6px; padding: 4px 12px; font: inherit; cursor: pointer;
362
- }
363
- #hyper-edit-panel button:hover { background: #32323d; }
364
- #hyper-edit-panel .hep-keep { background: #1e3a2a; border-color: #2e5a40; }
365
- #hyper-edit-panel .hep-warnings {
366
- margin-top: 8px; color: #e8b04a; white-space: pre-line;
367
- }
368
- #hyper-edit-ring {
369
- position: fixed; z-index: 99998; pointer-events: none;
370
- border: 1px solid #7a7aff; border-radius: 4px; opacity: .8;
371
- }
372
- #hyper-edit-ring[hidden] { display: none; }
373
- #hyper-edit-chip {
374
- position: fixed; z-index: 99999; width: 26px; height: 26px; padding: 0;
375
- display: flex; align-items: center; justify-content: center;
376
- background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
377
- border-radius: 50%; cursor: pointer; font-size: 13px; line-height: 1;
378
- box-shadow: 0 4px 14px rgba(0,0,0,.4);
379
- }
380
- #hyper-edit-chip[hidden] { display: none; }
381
- #hyper-edit-chip:hover { background: #32323d; border-color: #6a6a8a; }
382
- #hyper-edit-doc-bubble {
383
- position: fixed; right: 16px; bottom: 16px; z-index: 99999;
384
- width: 44px; height: 44px; padding: 0; border-radius: 50%;
385
- display: flex; align-items: center; justify-content: center;
386
- background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
387
- font-size: 18px; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.45);
388
- }
389
- #hyper-edit-doc-bubble:hover { background: #32323d; border-color: #6a6a8a; }
390
473
  [editmode\\:contenteditable][contenteditable]:focus {
391
474
  outline: 1px solid #4a4a6a; outline-offset: 4px; border-radius: 2px;
392
475
  }
476
+ ::highlight(clay-ai-edit) { background-color: ${TOKENS['brass-soft']} !important; }
393
477
  `;
394
478
  document.head.appendChild(style);
395
479
 
396
- ring = markRuntimeOnly(document.createElement('div'));
397
- ring.id = 'hyper-edit-ring';
398
- ring.hidden = true;
399
-
400
- panel = markRuntimeOnly(document.createElement('div'));
401
- panel.id = 'hyper-edit-panel';
402
- panel.hidden = true;
403
- panel.innerHTML = `
404
- <div class="hep-quote" hidden></div>
405
- <textarea rows="2" placeholder="Describe the change\u2026 (@file.ext adds context, @fable / @claude picks the agent)"></textarea>
406
- <div class="hep-row">
407
- <span class="hep-status"></span>
408
- <button class="hep-send" type="button">Send</button>
409
- <button class="hep-stop" type="button" hidden>Stop</button>
410
- <button class="hep-revert" type="button" hidden>Revert</button>
411
- <button class="hep-keep" type="button" hidden>Keep</button>
412
- </div>
413
- <div class="hep-warnings" hidden></div>
414
- `;
480
+ const scheme = `color-scheme:${pageScheme()}`;
481
+
482
+ ring = marked(bevelBox('div', [
483
+ 'box-sizing:content-box', 'position:fixed', 'z-index:99998', 'pointer-events:none',
484
+ `border:2px solid ${TOKENS.brass}`, 'border-radius:0', scheme,
485
+ ]), 'data-clay-ai-edit', 'ring');
486
+ setShown(ring, false);
487
+
488
+ panel = marked(bevelSurface('div', [
489
+ 'position:fixed', 'z-index:99999', 'width:min(28rem, calc(100vw - 32px))',
490
+ 'padding:12px', 'border-radius:12px', `box-shadow:${SHADOW}`, `font:13px/1.5 ${FONT_SANS}`, scheme,
491
+ ]), 'data-clay-ai-edit', 'panel');
492
+ setShown(panel, false);
493
+ panel.setAttribute('role', 'dialog');
494
+ panel.setAttribute('aria-label', 'AI edit');
495
+
496
+ const part = (el, name) => marked(el, 'data-clay-ai-edit-part', name);
497
+
498
+ // The notch on the panel's edge that points at the selected words.
499
+ pointer = part(bevelBox('div', [
500
+ 'position:absolute', 'width:12px', 'height:12px', 'pointer-events:none',
501
+ `background:${TOKENS.surface}`, `border-left:1px solid ${TOKENS['line-2']}`, `border-top:1px solid ${TOKENS['line-2']}`,
502
+ ]), 'pointer');
503
+ setShown(pointer, false);
504
+
505
+ textarea = part(bevelInput('textarea', {
506
+ rules: ['flex:1', 'min-width:0', 'min-height:40px', 'max-height:9.5em', 'resize:none', 'overflow-y:auto',
507
+ 'padding:8px 12px', `font:15px/1.45 ${FONT_SANS}`],
508
+ }), 'input');
509
+ textarea.rows = 1;
510
+ textarea.placeholder = 'Describe the change';
511
+ textarea.setAttribute('aria-label', 'Describe the change');
512
+ textarea.title = 'Enter sends · Shift+Enter adds a line · @fable or @codex picks another agent · @file.ext adds context';
513
+
514
+ statusEl = part(bevelText('div', [
515
+ 'display:block', 'margin-top:0', `color:${TOKENS.muted}`, `font:12.5px/1.5 ${FONT_MONO}`,
516
+ ]), 'status');
517
+ statusEl.setAttribute('role', 'status');
518
+ statusEl.setAttribute('aria-live', 'polite');
519
+
520
+ const variants = { send: 'primary', stop: 'default', revert: 'quiet', keep: 'primary' };
521
+ const labels = { send: 'Send', stop: 'Stop', revert: 'Revert', keep: 'Keep' };
522
+ for (const name of ['send', 'stop', 'revert', 'keep']) {
523
+ buttons[name] = part(bevelButton(labels[name], { variant: variants[name], extra: ['min-height:40px', 'flex:none'] }), name);
524
+ setShown(buttons[name], name === 'send', 'inline-flex');
525
+ }
526
+
527
+ const row = bevelBox('div', ['display:flex', 'align-items:flex-start', 'gap:8px']);
528
+ row.append(textarea, buttons.send, buttons.stop, buttons.revert, buttons.keep);
415
529
 
416
- chip = markRuntimeOnly(document.createElement('button'));
417
- chip.id = 'hyper-edit-chip';
418
- chip.type = 'button';
419
- chip.title = 'Comment on this (\u2318K)';
420
- chip.textContent = '\ud83d\udcac';
421
- chip.hidden = true;
530
+ warningsEl = part(bevelText('div', [
531
+ 'display:block', 'margin-top:8px', `color:${TOKENS.ox}`, 'white-space:pre-line',
532
+ ]), 'warnings');
533
+ setShown(warningsEl, false);
422
534
 
423
- docBubble = markRuntimeOnly(document.createElement('button'));
424
- docBubble.id = 'hyper-edit-doc-bubble';
425
- docBubble.type = 'button';
535
+ panel.append(pointer, row, statusEl, warningsEl);
536
+
537
+ chip = marked(bevelButton('AI', {
538
+ small: true,
539
+ extra: ['position:fixed', 'width:26px', 'height:26px', `font:600 11px/1 ${FONT_SANS}`, ...FLOATING, scheme],
540
+ }), 'data-clay-ai-edit', 'chip');
541
+ chip.title = 'Comment on this (\u2318J)';
542
+ setShown(chip, false, 'inline-grid');
543
+
544
+ docBubble = marked(bevelButton('AI', {
545
+ extra: ['position:fixed', 'width:40px', 'height:40px', `font:600 14px/1 ${FONT_SANS}`, ...FLOATING, scheme],
546
+ }), 'data-clay-ai-edit', 'bubble');
426
547
  docBubble.title = 'Comment on the whole page';
427
- docBubble.textContent = '\ud83d\udcac';
548
+ placeBubble();
428
549
 
429
550
  document.body.append(ring, panel, chip, docBubble);
430
-
431
- quoteEl = panel.querySelector('.hep-quote');
432
- textarea = panel.querySelector('textarea');
433
- statusEl = panel.querySelector('.hep-status');
434
- warningsEl = panel.querySelector('.hep-warnings');
435
- for (const name of ['send', 'stop', 'keep', 'revert']) {
436
- buttons[name] = panel.querySelector('.hep-' + name);
437
- }
551
+ syncBubble();
438
552
 
439
553
  buttons.send.addEventListener('click', submit);
440
554
  buttons.stop.addEventListener('click', cancelStream);
@@ -447,18 +561,110 @@ function buildChrome() {
447
561
  }
448
562
  });
449
563
 
564
+ // One line to start, growing with the request up to the max-height.
565
+ textarea.addEventListener('input', fitTextarea);
566
+
567
+ chip.addEventListener('mousedown', (event) => event.preventDefault()); // keeps the page's selection alive
450
568
  chip.addEventListener('click', () => {
451
569
  const target = chipTarget;
570
+ const selection = chipSelection;
452
571
  hideChip();
453
- if (target && !session) openPanel(target, quoteFromSelection(target));
572
+ if (target && !session) openPanel(target, selection || quoteFromSelection(target));
454
573
  });
455
574
  docBubble.addEventListener('click', () => {
456
575
  if (session) return;
576
+ // Like a click outside, a second click keeps a typed comment: it closes an empty panel only.
577
+ if (!panel.hidden && anchorEl === document.body) {
578
+ if (textarea.value.trim()) textarea.focus();
579
+ else closePanel();
580
+ return;
581
+ }
457
582
  openPanel(document.body, quoteFromSelection(document.body));
458
583
  });
459
584
 
460
585
  window.addEventListener('scroll', () => { positionChrome(); hideChip(); }, { passive: true });
461
- window.addEventListener('resize', positionChrome, { passive: true });
586
+ window.addEventListener('resize', () => { placeBubble(); if (panel && !panel.hidden) fitTextarea(); positionChrome(); }, { passive: true });
587
+ // The CMS toggle can arrive after this chrome, and a docked sidebar slides it left.
588
+ const replace = () => { placeBubble(); positionChrome(); };
589
+ document.addEventListener('hcms:open', () => { replace(); setTimeout(replace, 350); });
590
+ document.addEventListener('hcms:close', () => { replace(); setTimeout(replace, 350); });
591
+ new MutationObserver((records) => {
592
+ syncBubble();
593
+ const touched = (nodes) => [...nodes].some((n) => n.nodeType === 1 && n.hasAttribute('data-hcms-toggle-host'));
594
+ if (records.some((r) => touched(r.addedNodes) || touched(r.removedNodes))) replace();
595
+ }).observe(document.body, { childList: true });
596
+
597
+ // A target removed from the page ends its edit, so the save hold and the paused
598
+ // observers never outlive it.
599
+ new MutationObserver(() => {
600
+ if (anchorEl && !anchorEl.isConnected && !panel.hidden) closePanel();
601
+ }).observe(document.body, { childList: true, subtree: true });
602
+ }
603
+
604
+ // The panel's height changes as the box grows and the status line fills, so it is
605
+ // placed again after each change.
606
+ function placePanelAgain() {
607
+ if (anchorEl?.isConnected) positionChrome();
608
+ }
609
+
610
+ function fitTextarea() {
611
+ set(textarea, 'height', 'auto');
612
+ if (textarea.value) set(textarea, 'height', textarea.scrollHeight + 2 + 'px');
613
+ placePanelAgain();
614
+ }
615
+
616
+ // The whole-page bubble belongs to pages built from [data-edit-id] sections. A plain
617
+ // page gets the selection chip and the shortcut instead.
618
+ function syncBubble() {
619
+ const doc = docBubble?.ownerDocument;
620
+ if (!doc) return;
621
+ setShown(docBubble, helperState === 'ready' && !!doc.querySelector('[data-edit-id]'), 'inline-grid');
622
+ }
623
+
624
+ // The selected words stay marked while the person types, through the CSS Custom
625
+ // Highlight API, so the page's DOM never changes.
626
+ function highlight(range) {
627
+ if (!range || typeof window.Highlight !== 'function' || !window.CSS?.highlights) return;
628
+ window.CSS.highlights.set(HIGHLIGHT, new window.Highlight(range));
629
+ }
630
+
631
+ function clearHighlight() {
632
+ window.CSS?.highlights?.delete(HIGHLIGHT);
633
+ }
634
+
635
+ function applyHelperState() {
636
+ const on = helperState === 'ready';
637
+ textarea.disabled = !on;
638
+ if (on) {
639
+ if (statusEl.textContent === OFF_MESSAGE) setStatus('');
640
+ if (!session) showButtons('send');
641
+ } else {
642
+ setStatus(OFF_MESSAGE, 'warn');
643
+ showButtons();
644
+ }
645
+ syncBubble();
646
+ }
647
+
648
+ // The host's toggle can change while the page is open, so each opening asks again.
649
+ function refreshHelperState() {
650
+ helperStateNow().then((state) => {
651
+ helperState = state || 'unavailable';
652
+ if (!panel.hidden && !session) applyHelperState();
653
+ else syncBubble();
654
+ }).catch(() => {});
655
+ }
656
+
657
+ // The bubble sits in the bottom-right corner, or just left of the CMS's "Edit content"
658
+ // toggle when the page has one, bottoms aligned, so the two never overlap.
659
+ function placeBubble() {
660
+ if (!docBubble) return;
661
+ const host = document.querySelector('[data-hcms-toggle-host]');
662
+ const rect = host && host.getBoundingClientRect();
663
+ const beside = !!rect && rect.width > 0 && rect.height > 0;
664
+ docBubble.pin({
665
+ right: beside ? `${Math.round((document.documentElement.clientWidth || window.innerWidth) - rect.left + 8)}px` : '16px',
666
+ bottom: beside ? `${Math.round((document.documentElement.clientHeight || window.innerHeight) - rect.bottom)}px` : '16px',
667
+ });
462
668
  }
463
669
 
464
670
  // ---------------------------------------------------------------- hover chip
@@ -467,17 +673,22 @@ function showChipFor(unit) {
467
673
  clearTimeout(chipHideTimer);
468
674
  chipHideTimer = null;
469
675
  chipTarget = unit;
676
+ chipSelection = undefined;
470
677
  const rect = unit.getBoundingClientRect();
471
- chip.hidden = false;
472
- chip.style.left = Math.min(rect.right + 8, window.innerWidth - 34) + 'px';
473
- chip.style.top = Math.max(8, rect.top) + 'px';
678
+ chip.pin({
679
+ left: Math.min(rect.right + 8, window.innerWidth - 34) + 'px',
680
+ top: Math.max(8, rect.top) + 'px',
681
+ 'color-scheme': pageScheme(),
682
+ });
683
+ setShown(chip, true, 'inline-grid');
474
684
  }
475
685
 
476
686
  function hideChip() {
477
687
  clearTimeout(chipHideTimer);
478
688
  chipHideTimer = null;
479
- chip.hidden = true;
689
+ setShown(chip, false, 'inline-grid');
480
690
  chipTarget = null;
691
+ chipSelection = undefined;
481
692
  }
482
693
 
483
694
  function scheduleChipHide() {
@@ -485,79 +696,163 @@ function scheduleChipHide() {
485
696
  chipHideTimer = setTimeout(hideChip, 400);
486
697
  }
487
698
 
699
+ // The chip at the end of a selection: for people who do not know the shortcut, and
700
+ // for browsers that keep Ctrl+J for themselves.
701
+ function showChipForSelection() {
702
+ if (session || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
703
+ const range = liveRange();
704
+ const target = range && targetFromRange(range);
705
+ const selection = target && selectionIn(range, target);
706
+ if (!selection) {
707
+ if (chipSelection) hideChip();
708
+ return;
709
+ }
710
+ const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : [];
711
+ const end = rects.length ? rects[rects.length - 1] : target.getBoundingClientRect();
712
+ clearTimeout(chipHideTimer);
713
+ chipHideTimer = null;
714
+ chipTarget = target;
715
+ chipSelection = selection;
716
+ chip.pin({
717
+ left: Math.min(end.right + 6, window.innerWidth - 34) + 'px',
718
+ top: Math.max(8, end.top + end.height / 2 - 13) + 'px',
719
+ 'color-scheme': pageScheme(),
720
+ });
721
+ setShown(chip, true, 'inline-grid');
722
+ }
723
+
488
724
  function setStatus(text, tone) {
725
+ set(statusEl, 'margin-top', text ? '8px' : '0');
489
726
  statusEl.textContent = text || '';
490
- statusEl.classList.toggle('warn', tone === 'warn');
727
+ if (tone === 'warn') statusEl.setAttribute('data-tone', 'warn');
728
+ else statusEl.removeAttribute('data-tone');
729
+ set(statusEl, 'color', tone === 'warn' ? TOKENS.ox : TOKENS.muted);
730
+ placePanelAgain();
491
731
  }
492
732
 
493
733
  function showButtons(...names) {
494
734
  for (const [name, button] of Object.entries(buttons)) {
495
- button.hidden = !names.includes(name);
735
+ setShown(button, names.includes(name), 'inline-flex');
496
736
  }
737
+ fitTextarea();
497
738
  }
498
739
 
499
740
  function showWarnings(warnings) {
500
- warningsEl.hidden = !warnings.length;
741
+ setShown(warningsEl, warnings.length > 0);
501
742
  warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
743
+ placePanelAgain();
502
744
  }
503
745
 
504
746
  function positionChrome() {
505
747
  if (!anchorEl || panel.hidden) return;
506
748
  if (!anchorEl.isConnected) { closePanel(); return; }
507
749
  if (anchorEl === document.body) {
508
- // document mode: no ring, panel pinned above the bottom-right bubble
509
- ring.hidden = true;
510
- panel.style.left = Math.max(16, window.innerWidth - (panel.offsetWidth || 480) - 16) + 'px';
511
- panel.style.top = Math.max(16, window.innerHeight - (panel.offsetHeight || 120) - 76) + 'px';
750
+ // document mode: no ring, panel pinned above the bubble
751
+ setShown(ring, false);
752
+ setShown(pointer, false);
753
+ const bubble = docBubble.getBoundingClientRect();
754
+ const width = panel.offsetWidth || 480;
755
+ set(panel, 'left', Math.max(16, Math.min((bubble.right || window.innerWidth - 16) - width, window.innerWidth - width - 16)) + 'px');
756
+ set(panel, 'top', Math.max(16, (bubble.top || window.innerHeight - 56) - (panel.offsetHeight || 120) - 12) + 'px');
512
757
  return;
513
758
  }
514
759
  const rect = anchorEl.getBoundingClientRect();
515
- ring.hidden = false;
516
- ring.style.left = rect.left - 4 + 'px';
517
- ring.style.top = rect.top - 4 + 'px';
518
- ring.style.width = rect.width + 6 + 'px';
519
- ring.style.height = rect.height + 6 + 'px';
760
+ // With selected words the highlight marks them until Send; the ring is for a whole element and for a running edit.
761
+ setShown(ring, !pendingSelection || !!session);
762
+ set(ring, 'left', rect.left - 5 + 'px');
763
+ set(ring, 'top', rect.top - 5 + 'px');
764
+ set(ring, 'width', rect.width + 6 + 'px');
765
+ set(ring, 'height', rect.height + 6 + 'px');
766
+ // Under the selection while it is still in the page, else under the element.
767
+ const at = anchorRange && anchorRange.startContainer.isConnected && typeof anchorRange.getBoundingClientRect === 'function'
768
+ ? anchorRange.getBoundingClientRect() : null;
769
+ const place = at && (at.width || at.height) ? at : rect;
520
770
  const panelWidth = panel.offsetWidth || 480;
521
- const left = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16));
522
- let top = rect.bottom + 10;
771
+ const left = Math.max(16, Math.min(place.left, window.innerWidth - panelWidth - 16));
772
+ let top = place.bottom + 10;
523
773
  const panelHeight = panel.offsetHeight || 120;
524
774
  if (top + panelHeight > window.innerHeight - 16) {
525
- top = Math.max(16, rect.top - panelHeight - 10);
775
+ top = Math.max(16, place.top - panelHeight - 10);
526
776
  }
527
- panel.style.left = left + 'px';
528
- panel.style.top = top + 'px';
777
+ set(panel, 'left', left + 'px');
778
+ set(panel, 'top', top + 'px');
779
+
780
+ // The pointer sits above the start of the selection, on the top edge, or on the
781
+ // bottom edge when the panel had to open above it.
782
+ const below = top >= place.bottom;
783
+ const tip = Math.max(14, Math.min(place.left + 10 - left, panelWidth - 28));
784
+ setShown(pointer, true);
785
+ set(pointer, 'left', tip + 'px');
786
+ set(pointer, 'top', below ? '-7px' : (panelHeight - 6) + 'px');
787
+ set(pointer, 'transform', below ? 'rotate(45deg)' : 'rotate(225deg)');
529
788
  }
530
789
 
531
790
  function openPanel(el, quote) {
791
+ const range = quote ? liveRange() : null; // read before focus moves into the panel
792
+ const active = document.activeElement;
793
+ returnFocus = active && active !== document.body && !panel.contains(active) ? active : null;
532
794
  anchorEl = el;
795
+ anchorRange = range ? range.cloneRange() : null;
533
796
  hideChip();
534
- panel.hidden = false;
535
- quoteEl.hidden = !quote;
536
- quoteEl.textContent = quote ? `\u201c${quote}\u201d` : '';
537
- panel.dataset.quote = quote || '';
797
+ set(panel, 'color-scheme', pageScheme());
798
+ set(ring, 'color-scheme', pageScheme());
799
+ setShown(panel, true);
800
+ pendingSelection = quote;
801
+ const shown = quote ? (quote.text.trim().length > 400 ? quote.text.trim().slice(0, 400) + '\u2026' : quote.text.trim()) : '';
802
+ panel.dataset.quote = shown;
538
803
  setStatus('');
539
804
  showWarnings([]);
540
805
  showButtons('send');
806
+ highlight(anchorRange);
807
+ applyHelperState();
541
808
  positionChrome();
542
809
  textarea.focus();
810
+ refreshHelperState();
543
811
  }
544
812
 
545
813
  function closePanel() {
546
- if (session) revertSession();
814
+ if (session) { session.handle?.cancel(); revertSession(); }
815
+ const back = returnFocus;
816
+ const hadFocus = panel.contains(document.activeElement);
547
817
  anchorEl = null;
548
- panel.hidden = true;
549
- ring.hidden = true;
818
+ anchorRange = null;
819
+ returnFocus = null;
820
+ pendingSelection = undefined;
821
+ clearHighlight();
822
+ setShown(panel, false);
823
+ setShown(ring, false);
550
824
  textarea.value = '';
825
+ fitTextarea();
826
+ if (hadFocus && back && back.isConnected) back.focus({ preventScroll: true });
551
827
  }
552
828
 
553
829
  function submit() {
554
830
  const comment = textarea.value.trim();
555
- if (!comment || !anchorEl || session) return;
556
- sendRequest(anchorEl, comment, panel.dataset.quote || undefined);
831
+ if (!comment || !anchorEl || session || helperState !== 'ready') return;
832
+ clearHighlight();
833
+ sendRequest(anchorEl, comment, pendingSelection);
557
834
  }
558
835
 
559
836
  // ---------------------------------------------------------------- interactions
560
837
 
838
+ const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
839
+
840
+ // Cmd+J on a Mac, Ctrl+J elsewhere. event.code covers keyboard layouts where the
841
+ // J key types another letter.
842
+ function isShortcut(event) {
843
+ const modifier = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
844
+ if (!modifier || event.shiftKey || event.altKey || event.repeat || event.isComposing) return false;
845
+ return event.code === 'KeyJ' || (event.key || '').toLowerCase() === 'j';
846
+ }
847
+
848
+ // A field the page owns keeps its own Ctrl+J, and so does the panel's own box.
849
+ function pageOwnsFocus() {
850
+ const el = document.activeElement;
851
+ if (!el || el === document.body) return false;
852
+ if (panel && panel.contains(el)) return !panel.hidden;
853
+ return el.matches('input,textarea,select');
854
+ }
855
+
561
856
  function wireInteractions() {
562
857
  // Clicks: text units are contenteditable (caret) and figures keep their own
563
858
  // interactivity, so neither is intercepted. Only a click on bare section padding
@@ -581,6 +876,7 @@ function wireInteractions() {
581
876
  if (now - lastMove < 80) return;
582
877
  lastMove = now;
583
878
  if (session || !panel.hidden) { scheduleChipHide(); return; }
879
+ if (helperState !== 'ready' || chipSelection) return;
584
880
  if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
585
881
  const unit = unitFrom(event.target);
586
882
  if (unit) showChipFor(unit);
@@ -588,14 +884,22 @@ function wireInteractions() {
588
884
  });
589
885
 
590
886
  document.addEventListener('keydown', (event) => {
591
- if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
592
- if (session) return;
887
+ if (isShortcut(event)) {
888
+ if (!session && anchorEl && !anchorEl.isConnected) closePanel();
889
+ if (session || pageOwnsFocus()) return;
890
+ const range = liveRange();
891
+ let target = range ? targetFromRange(range) : null;
892
+ if (!target) {
893
+ // Section pages: a caret inside a unit, or the unit under the hover chip.
894
+ const selection = window.getSelection();
895
+ let node = selection && selection.anchorNode;
896
+ if (node && node.nodeType !== 1) node = node.parentElement;
897
+ target = (node && unitFrom(node)) || chipTarget;
898
+ }
899
+ // Nothing to open: leave the key to the browser.
900
+ if (!target) return;
593
901
  event.preventDefault();
594
- const selection = window.getSelection();
595
- let node = selection && selection.anchorNode;
596
- if (node && node.nodeType !== 1) node = node.parentElement;
597
- const target = (node && unitFrom(node)) || chipTarget;
598
- if (target) openPanel(target, quoteFromSelection(target));
902
+ openPanel(target, quoteFromSelection(target));
599
903
  return;
600
904
  }
601
905
  if (event.key !== 'Escape' || panel.hidden) return;
@@ -609,13 +913,19 @@ function wireInteractions() {
609
913
  closePanel();
610
914
  }
611
915
  });
916
+
917
+ document.addEventListener('selectionchange', () => {
918
+ clearTimeout(selectionTimer);
919
+ selectionTimer = setTimeout(showChipForSelection, 150);
920
+ });
612
921
  }
613
922
 
614
923
  // ---------------------------------------------------------------- boot
615
924
 
616
925
  async function init() {
617
- if (!isEditMode) return; // an owner/editor feature, nothing else
618
- if (!(await available())) return; // no ai-edit helper (e.g. HTML Clay, or the toggle off): stay dormant
926
+ if (!isEditMode || panel) return; // an owner/editor feature, built once
927
+ helperState = await helperStateNow();
928
+ if (!helperState) return; // no ai-edit helper on this host: stay dormant
619
929
  buildChrome();
620
930
  wireInteractions();
621
931
  }