rewritable 0.6.0 → 0.8.0

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.
@@ -40,7 +40,11 @@
40
40
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
41
41
  [hidden]{display:none!important;}
42
42
  body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:160px;line-height:1.5;-webkit-font-smoothing:antialiased;}
43
- #rwa-set{position:fixed;top:12px;right:12px;display:flex;gap:6px;z-index:1000;}
43
+ #rwa-set{position:fixed;top:12px;right:12px;display:flex;gap:6px;align-items:center;z-index:1000;}
44
+ #rwa-mode-tabs{display:flex;gap:2px;align-items:center;background:var(--white);border:1px solid var(--gray-200);border-radius:8px;padding:2px;box-shadow:0 1px 4px rgba(0,0,0,0.04);}
45
+ .rwa-mode-tab{background:transparent;border:0;color:var(--gray-500);font-family:var(--font-mono);font-size:10px;padding:5px 8px;border-radius:6px;cursor:pointer;letter-spacing:.4px;text-transform:uppercase;transition:color .15s,background .15s;}
46
+ .rwa-mode-tab:hover{color:var(--gray-900);background:var(--gray-50);}
47
+ .rwa-mode-tab.on{background:var(--gray-900);color:var(--white);}
44
48
  .rwa-st-btn{background:var(--white);border:1px solid var(--gray-200);color:var(--gray-500);font-family:var(--font-mono);font-size:10px;padding:6px 10px;border-radius:6px;cursor:pointer;letter-spacing:.5px;text-transform:uppercase;transition:color .15s,background .15s,border-color .15s;}
45
49
  .rwa-st-btn:hover{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-50);}
46
50
  .rwa-st-btn.dirty{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-100);}
@@ -49,16 +53,16 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
49
53
  .rwa-st-btn.run{color:var(--gray-700);border-color:var(--gray-300);background:var(--gray-50);}
50
54
  .rwa-st-btn.err{color:var(--red);border-color:var(--red);background:var(--white);}
51
55
  .rwa-st-btn.ok{color:var(--green);border-color:var(--green);background:var(--white);}
52
- #rwa-set-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:14px;display:none;min-width:300px;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);}
56
+ #rwa-set-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:14px;display:none;width:340px;max-width:calc(100vw - 24px);z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);}
53
57
  #rwa-set-panel.open{display:block;}
54
58
  .rwa-set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px;}
55
59
  .rwa-set-row:last-child{margin-bottom:0;}
56
60
  .rwa-set-row label{font-family:var(--font-mono);font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-500);}
57
- .rwa-set-row input,.rwa-set-row select{background:var(--white);border:1px solid var(--gray-200);color:var(--gray-900);font-family:var(--font-ui);font-size:13px;padding:8px 10px;outline:none;border-radius:8px;transition:border-color .15s;}
61
+ .rwa-set-row input,.rwa-set-row select{width:100%;background:var(--white);border:1px solid var(--gray-200);color:var(--gray-900);font-family:var(--font-ui);font-size:13px;padding:8px 10px;outline:none;border-radius:8px;transition:border-color .15s;}
58
62
  .rwa-set-row input:focus,.rwa-set-row select:focus{border-color:var(--gray-400);}
59
63
  .rwa-set-row select{appearance:none;-webkit-appearance:none;cursor:pointer;}
60
64
  .rwa-set-base-url-line{display:flex;gap:6px;}
61
- .rwa-set-base-url-line input{flex:1;}
65
+ .rwa-set-base-url-line input{flex:1;min-width:0;}
62
66
  .rwa-set-base-url-line button{background:var(--gray-100);border:1px solid var(--gray-200);border-radius:8px;padding:6px 12px;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--gray-700);}
63
67
  .rwa-set-base-url-line button:hover{background:var(--gray-200);}
64
68
  .rwa-set-hint{font-family:var(--font-mono);font-size:10px;line-height:1.5;color:var(--gray-500);}
@@ -99,6 +103,28 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
99
103
  .rwa-share-actions button{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--gray-300);border-radius:8px;background:var(--white);color:var(--gray-900);cursor:pointer;}
100
104
  .rwa-share-actions button:disabled{opacity:.5;cursor:default;}
101
105
  #rwa-share-create,#rwa-share-update{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
106
+ #rwa-mode-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:18px 20px;display:none;width:min(420px,92vw);max-height:calc(100dvh - 72px);overflow:auto;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);font-family:var(--font-ui);font-size:13px;line-height:1.5;color:var(--gray-700);}
107
+ #rwa-mode-panel.open{display:block;}
108
+ #rwa-mode-panel h4{margin:0 0 8px;font-size:15px;color:var(--gray-900);font-weight:600;}
109
+ #rwa-mode-panel p{margin:0 0 10px;}
110
+ .rwa-mode-section{border-top:1px solid var(--gray-100);padding-top:12px;margin-top:12px;}
111
+ .rwa-mode-section:first-child{border-top:0;padding-top:0;margin-top:0;}
112
+ .rwa-mode-kicker{font-family:var(--font-mono);font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-500);margin-bottom:6px;}
113
+ .rwa-mode-empty{color:var(--gray-400);font-style:italic;padding:6px 0;}
114
+ .rwa-mode-actions{display:flex;gap:6px;flex-wrap:wrap;}
115
+ .rwa-mode-actions button,.rwa-mode-row button{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--gray-300);border-radius:8px;background:var(--white);color:var(--gray-900);cursor:pointer;}
116
+ .rwa-mode-actions button.pri{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
117
+ .rwa-mode-actions button:disabled,.rwa-mode-row button:disabled{opacity:.5;cursor:default;}
118
+ .rwa-mode-row{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid var(--gray-100);}
119
+ .rwa-mode-row:last-child{border-bottom:0;}
120
+ .rwa-mode-title{font-weight:600;color:var(--gray-900);}
121
+ .rwa-mode-meta{font-family:var(--font-mono);font-size:10px;color:var(--gray-500);word-break:break-all;}
122
+ .rwa-mode-invoke{display:flex;gap:6px;margin-top:8px;}
123
+ .rwa-mode-invoke input{flex:1;min-width:0;border:1px solid var(--gray-200);border-radius:8px;padding:6px 8px;font:inherit;font-size:12px;}
124
+ .rwa-mode-result{margin-top:8px;font-family:var(--font-mono);font-size:10px;background:var(--gray-50);border-radius:6px;padding:6px 8px;white-space:pre-wrap;word-break:break-word;color:var(--gray-600);}
125
+ .rwa-mode-hist{padding:8px 0;border-bottom:1px solid var(--gray-100);}
126
+ .rwa-mode-hist:last-child{border-bottom:0;}
127
+ .rwa-mode-hist .rwa-mode-meta{margin-bottom:3px;}
102
128
  .rwa-set-hint code{background:var(--gray-100);padding:1px 4px;border-radius:3px;font-size:10px;}
103
129
  .rwa-set-hint.ok{color:#15803d;}
104
130
  .rwa-set-hint.err{color:#b91c1c;}
@@ -164,6 +190,25 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
164
190
  #rwa-img-chip button{width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--gray-900);font-size:11px;line-height:1;cursor:pointer;padding:0;font-family:var(--font-ui);}
165
191
  #rwa-img-chip button:hover{background:var(--gray-100);}
166
192
  #rwa-img-chip button.on{background:var(--gray-900);color:var(--white);}
193
+ #rwa-selection-bar{position:absolute;z-index:70;display:flex;align-items:center;gap:4px;padding:5px;border-radius:10px;border:1px solid var(--gray-200);background:var(--white);box-shadow:0 8px 24px rgba(0,0,0,0.12);font-family:var(--font-ui);}
194
+ #rwa-selection-bar[hidden]{display:none!important;}
195
+ #rwa-selection-bar button{height:28px;border:0;border-radius:7px;background:transparent;color:var(--gray-700);font:600 12px var(--font-ui);cursor:pointer;padding:0 9px;}
196
+ #rwa-selection-bar button:hover{background:var(--gray-100);color:var(--gray-900);}
197
+ #rwa-selection-bar button.pri{background:var(--gray-900);color:var(--white);}
198
+ #rwa-selection-bar[data-listening="1"] #rwa-selection-voice{background:var(--red);color:var(--white);}
199
+ #rwa-selection-cmd{width:170px;border:1px solid var(--gray-200);border-radius:7px;padding:6px 8px;outline:none;font:12px var(--font-ui);color:var(--gray-900);}
200
+ #rwa-selection-cmd:focus{border-color:var(--gray-400);}
201
+ body:not([data-rwa-mode="edit"]) #rwa-lens,
202
+ body:not([data-rwa-mode="edit"]) #rwa-pal,
203
+ body:not([data-rwa-mode="edit"]) #rwa-set-panel,
204
+ body:not([data-rwa-mode="edit"]) #rwa-skin-panel,
205
+ body:not([data-rwa-mode="edit"]) #rwa-lens-hist-panel,
206
+ body:not([data-rwa-mode="edit"]) #rwa-st-cog,
207
+ body:not([data-rwa-mode="edit"]) #rwa-st-skin,
208
+ body:not([data-rwa-mode="edit"]) #rwa-img-chip,
209
+ body:not([data-rwa-mode="edit"]) #rwa-selection-bar{display:none!important;}
210
+ body:not([data-rwa-mode="document"]) #rwa-view-toggle,
211
+ body:not([data-rwa-mode="document"]) #rwa-view-chrome{display:none!important;}
167
212
  /* images-v1: deterministic width presets (set by the toolbar; class lives in the doc) */
168
213
  :where(#rwa-doc-mount) figure.rwa-img-sm{max-width:33%;}
169
214
  :where(#rwa-doc-mount) figure.rwa-img-md{max-width:66%;}
@@ -176,6 +221,10 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
176
221
  .rwa-frag-pulse{animation:rwa-frag-pulse 1.6s ease-out;}
177
222
  @keyframes rwa-frag-pulse{0%{background:rgba(59,130,246,0.22);}100%{background:transparent;}}
178
223
  [data-rwa-anchored]{outline:2px solid var(--gray-900);outline-offset:2px;border-radius:4px;}
224
+ .rwa-editable-leaf{cursor:text;border-radius:4px;transition:background .12s ease,box-shadow .12s ease,outline-color .12s ease;}
225
+ .rwa-editable-leaf:hover{background:rgba(59,130,246,0.045);box-shadow:0 0 0 3px rgba(59,130,246,0.08);}
226
+ .rwa-editable-leaf:focus-visible{outline:2px solid rgba(59,130,246,0.5);outline-offset:2px;}
227
+ .rwa-editable-leaf[contenteditable="true"]{background:rgba(59,130,246,0.07);box-shadow:0 0 0 2px rgba(59,130,246,0.22);outline:none;caret-color:var(--blue);}
179
228
  /* Inline prompt mode — the block's text starts with "/" (addressing the model,
180
229
  not writing content). Tint + inset accent bar only: no border/padding so the
181
230
  per-keystroke toggle never shifts layout (caret stays put), and no ::before
@@ -380,6 +429,13 @@ const RWA = {
380
429
  // not block these requests even when the container is served over HTTPS.
381
430
  DEFAULT_OLLAMA_URL:'http://localhost:11434/v1',
382
431
  DEFAULT_LMSTUDIO_URL:'http://localhost:1234/v1',
432
+ // atomic.chat: a local OpenAI-compatible inference server (MLX-backed on
433
+ // Apple Silicon) with the same /v1/chat/completions + /v1/models shape and
434
+ // real multi-turn tool_calls. CORS note: it allows http(s) page origins out
435
+ // of the box but NOT the file:// null origin — serve the container from a
436
+ // local origin (or a hosted projection) to use it.
437
+ DEFAULT_ATOMIC_URL:'http://127.0.0.1:1337/v1',
438
+ K_BASE_URL_ATOMIC:'rwa_base_url_atomic',
383
439
  // Optional alternative agent backend: a localhost CLI bridge
384
440
  // (https://github.com/martintreiber/web_cli_bridge style) that runs
385
441
  // arbitrary shell commands. When the user picks "bridge" in settings, ⌘K
@@ -411,7 +467,7 @@ try {
411
467
  qs.delete('key');
412
468
  }
413
469
  const b = qs.get('backend');
414
- if (b && ['openrouter','ollama','lmstudio','bridge','bridge-session'].includes(b)) {
470
+ if (b && ['openrouter','ollama','lmstudio','atomic','bridge','bridge-session'].includes(b)) {
415
471
  sessionStorage.setItem(RWA.K_BACKEND, b);
416
472
  qs.delete('backend');
417
473
  }
@@ -844,12 +900,13 @@ async function runtimeFsList(prefix) {
844
900
  const runtimeEvents = {
845
901
  commit: new Set(),
846
902
  modify: new Set(),
903
+ mode: new Set(),
847
904
  status: new Set(),
848
905
  };
849
906
 
850
907
  function runtimeOn(event, callback) {
851
908
  if (!Object.prototype.hasOwnProperty.call(runtimeEvents, event)) {
852
- throw new Error("unknown event '" + event + "' (use 'commit', 'modify', or 'status')");
909
+ throw new Error("unknown event '" + event + "' (use 'commit', 'modify', 'mode', or 'status')");
853
910
  }
854
911
  if (typeof callback !== 'function') throw new TypeError('callback must be a function');
855
912
  runtimeEvents[event].add(callback);
@@ -869,6 +926,147 @@ function emitRuntimeEvent(event, payload) {
869
926
  }
870
927
  }
871
928
 
929
+ // === Cross-container bus (workspace-presence subset) ========================
930
+ // BroadcastChannel-backed pub/sub across same-origin rewritables. This is a
931
+ // deliberately small public surface: documents can publish/subscribe to normal
932
+ // topics, while runtime-reserved prefixes stay off-limits. The first concrete
933
+ // consumer is workspace autodiscovery on `workspace:presence`.
934
+ const runtimeBusChannels = new Map();
935
+
936
+ function assertRuntimeBusTopic(topic) {
937
+ if (typeof topic !== 'string' || !topic) throw new TypeError('bus topic must be a non-empty string');
938
+ if (topic.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9:_./-]*$/.test(topic)) {
939
+ throw new TypeError('bus topic contains unsupported characters');
940
+ }
941
+ if (/^(?:rwa[:_]|skills:)/.test(topic)) {
942
+ throw new RwaReservedError(topic);
943
+ }
944
+ }
945
+
946
+ function runtimeBusChannel(topic) {
947
+ assertRuntimeBusTopic(topic);
948
+ if (typeof BroadcastChannel === 'undefined') throw new Error('BroadcastChannel is not available');
949
+ let ch = runtimeBusChannels.get(topic);
950
+ if (!ch) {
951
+ ch = new BroadcastChannel('rwa_bus:' + topic);
952
+ if (typeof ch.unref === 'function') ch.unref();
953
+ runtimeBusChannels.set(topic, ch);
954
+ }
955
+ return ch;
956
+ }
957
+
958
+ function runtimeBusPublish(topic, message) {
959
+ const envelope = { topic, from: DOC_UUID, at: Date.now(), message };
960
+ runtimeBusChannel(topic).postMessage(envelope);
961
+ return envelope;
962
+ }
963
+
964
+ function runtimeBusSubscribe(topic, callback) {
965
+ assertRuntimeBusTopic(topic);
966
+ if (typeof callback !== 'function') throw new TypeError('callback must be a function');
967
+ if (typeof BroadcastChannel === 'undefined') throw new Error('BroadcastChannel is not available');
968
+ const ch = new BroadcastChannel('rwa_bus:' + topic);
969
+ if (typeof ch.unref === 'function') ch.unref();
970
+ const handler = (evt) => {
971
+ const data = evt && evt.data;
972
+ if (!data || data.from === DOC_UUID) return;
973
+ try { callback(data); } catch (_) { /* never let a subscriber break delivery */ }
974
+ };
975
+ ch.addEventListener('message', handler);
976
+ return () => { ch.removeEventListener('message', handler); ch.close(); };
977
+ }
978
+
979
+ // === First-class runtime modes =============================================
980
+ // Modes are page-load local state, deliberately not serialized into INLINE_DOC.
981
+ // Document is the default every time a file opens; Edit is the only mode that
982
+ // attaches WYSIWYG/lens/image editing affordances.
983
+ const RWA_MODES = Object.freeze(['document', 'edit', 'skills', 'actions']);
984
+ let rwaMode = 'document';
985
+
986
+ function escRuntimeHtml(s) {
987
+ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
988
+ }
989
+
990
+ function isRwaSkillHost() {
991
+ return PRODUCT_KIND === 'skill-host' && !!document.getElementById('rwa-skills');
992
+ }
993
+
994
+ function closeRuntimePanels() {
995
+ for (const id of ['rwa-set-panel', 'rwa-info-panel', 'rwa-skin-panel', 'rwa-share-panel', 'rwa-mode-panel']) {
996
+ const el = document.getElementById(id);
997
+ if (el) el.classList.remove('open');
998
+ }
999
+ const hist = document.getElementById('rwa-lens-hist-panel');
1000
+ if (hist) hist.hidden = true;
1001
+ }
1002
+
1003
+ function hideEditTransients() {
1004
+ if (typeof exitInlineEdit === 'function') exitInlineEdit();
1005
+ if (typeof releaseAnchor === 'function') releaseAnchor();
1006
+ if (typeof closePal === 'function') closePal();
1007
+ if (typeof clearDropMark === 'function') clearDropMark();
1008
+ if (typeof hideSelectionCommandBar === 'function') hideSelectionCommandBar();
1009
+ const chip = document.getElementById('rwa-img-chip');
1010
+ if (chip) chip.hidden = true;
1011
+ const hist = document.getElementById('rwa-lens-hist-panel');
1012
+ if (hist) hist.hidden = true;
1013
+ }
1014
+
1015
+ function syncModeChrome() {
1016
+ if (document.body) document.body.dataset.rwaMode = rwaMode;
1017
+ document.querySelectorAll('[data-rwa-mode-target]').forEach(btn => {
1018
+ btn.classList.toggle('on', btn.dataset.rwaModeTarget === rwaMode);
1019
+ });
1020
+ const panel = document.getElementById('rwa-mode-panel');
1021
+ if (!panel) return;
1022
+ if (rwaMode === 'skills' || rwaMode === 'actions') {
1023
+ panel.classList.add('open');
1024
+ renderModePanel().catch(err => {
1025
+ panel.innerHTML = '<h4>' + escRuntimeHtml(rwaMode) + '</h4><p>Could not render this panel: ' + escRuntimeHtml(err?.message || err) + '</p>';
1026
+ });
1027
+ } else {
1028
+ panel.classList.remove('open');
1029
+ }
1030
+ if (typeof syncViewChrome === 'function') syncViewChrome();
1031
+ }
1032
+
1033
+ function runtimeSetMode(mode) {
1034
+ if (!RWA_MODES.includes(mode)) throw new Error('unknown mode: ' + mode);
1035
+ if (modifyMutex) {
1036
+ setStatus('err', '✗ modify in progress');
1037
+ throw new Error('cannot switch mode during modify');
1038
+ }
1039
+ if (mode === rwaMode) {
1040
+ syncModeChrome();
1041
+ return rwaMode;
1042
+ }
1043
+ if (rwaMode === 'edit' || mode !== 'edit') hideEditTransients();
1044
+ closeRuntimePanels();
1045
+ if (mode === 'edit' && activeView) {
1046
+ activeView = null;
1047
+ try { sessionStorage.setItem(rwaViewKey(), ''); } catch (_) {}
1048
+ }
1049
+ rwaMode = mode;
1050
+ syncModeChrome();
1051
+ emitRuntimeEvent('mode', { mode: rwaMode });
1052
+ getDoc().then(d => renderDoc(canonLF(d))).catch(() => {});
1053
+ return rwaMode;
1054
+ }
1055
+
1056
+ async function renderModePanel() {
1057
+ const panel = document.getElementById('rwa-mode-panel');
1058
+ if (!panel) return;
1059
+ if (rwaMode === 'skills') return renderSkillsModePanel(panel);
1060
+ if (rwaMode === 'actions') return renderActionsModePanel(panel);
1061
+ }
1062
+
1063
+ function attachModeTabs() {
1064
+ document.querySelectorAll('[data-rwa-mode-target]').forEach(btn => {
1065
+ btn.addEventListener('click', () => runtimeSetMode(btn.dataset.rwaModeTarget));
1066
+ });
1067
+ syncModeChrome();
1068
+ }
1069
+
872
1070
  // === Status surface (spec §7) ==============================================
873
1071
  // FSA permission state. Tracked here rather than re-derived each call because
874
1072
  // the permission events that move it (showSaveFilePicker, queryPermission,
@@ -1012,6 +1210,7 @@ function renderDoc(html) {
1012
1210
  // the agent's view of the document is invisible-by-construction to any render mode.
1013
1211
  setSourceMap(html);
1014
1212
  rebuildLockedRanges(html);
1213
+ const editReady = rwaMode === 'edit' && !activeView;
1015
1214
  // rwa-lens/1: click-to-anchor (Task 5.1). renderDoc runs on every commit and
1016
1215
  // on bootstrap; remove first so listeners don't multiply across renders. Same
1017
1216
  // function reference, so removing the previous instance is safe. With a
@@ -1019,12 +1218,22 @@ function renderDoc(html) {
1019
1218
  // listener is removed and not re-added (§5.10). For activeView===null this is
1020
1219
  // byte-identical to the previous behavior.
1021
1220
  m.removeEventListener('click', handleMountClick);
1022
- if (!activeView) m.addEventListener('click', handleMountClick);
1023
- // rwa inline-edit: double-click a leaf block to hand-edit it (no LLM). Same
1221
+ if (editReady) m.addEventListener('click', handleMountClick);
1222
+ // rwa inline-edit: leaf text blocks behave like page text in an editor.
1223
+ // Pointer-down starts editing early enough for the browser to place the caret
1224
+ // where the user clicked; dblclick stays registered for old tests/muscle
1225
+ // memory; keydown gives keyboard users a non-pointer entry path. Same
1024
1226
  // remove-then-add discipline as click-to-anchor; not registered under an
1025
1227
  // active view (byte-offset resolution is undefined there).
1228
+ if (typeof refreshInlineEditAffordances === 'function') refreshInlineEditAffordances(m);
1229
+ m.removeEventListener('pointerdown', handleMountPointerDown);
1026
1230
  m.removeEventListener('dblclick', handleMountDblClick);
1027
- if (!activeView) m.addEventListener('dblclick', handleMountDblClick);
1231
+ m.removeEventListener('keydown', handleMountEditKeydown);
1232
+ if (editReady) {
1233
+ m.addEventListener('pointerdown', handleMountPointerDown);
1234
+ m.addEventListener('dblclick', handleMountDblClick);
1235
+ m.addEventListener('keydown', handleMountEditKeydown);
1236
+ }
1028
1237
  // images-v1: drag-drop insert + hover-✕ chip. Same remove-then-add
1029
1238
  // discipline; inert under a render mode (byte-offset resolution undefined).
1030
1239
  // The chip/drop-mark reference DOM the innerHTML reset just destroyed.
@@ -1036,7 +1245,7 @@ function renderDoc(html) {
1036
1245
  m.removeEventListener('drop', handleMountDrop);
1037
1246
  m.removeEventListener('mouseover', handleMountImgOver);
1038
1247
  m.removeEventListener('mouseout', handleMountImgOut);
1039
- if (!activeView) {
1248
+ if (editReady) {
1040
1249
  m.addEventListener('dragover', handleMountDragOver);
1041
1250
  m.addEventListener('dragleave', handleMountDragLeave);
1042
1251
  m.addEventListener('drop', handleMountDrop);
@@ -1049,6 +1258,136 @@ function renderDoc(html) {
1049
1258
  if (activeView && typeof activeView.mounted === 'function') {
1050
1259
  activeView.mounted(m, viewCtx());
1051
1260
  }
1261
+ if (PRODUCT_KIND === 'workspace' && typeof renderWorkspacePresence === 'function') {
1262
+ renderWorkspacePresence();
1263
+ }
1264
+ }
1265
+
1266
+ // ─── Workspace presence over runtime.bus ─────────────────────────────
1267
+ const RWA_WORKSPACE_PRESENCE_TOPIC = 'workspace:presence';
1268
+ let workspacePresenceTimer = null;
1269
+ let workspacePresenceUnsub = null;
1270
+ let workspaceMonitorUnsub = null;
1271
+ const workspacePeers = new Map();
1272
+
1273
+ function rwaLocationSansHash() {
1274
+ try {
1275
+ const u = new URL(location.href);
1276
+ u.hash = '';
1277
+ return u.href;
1278
+ } catch (_) {
1279
+ return String(location.href || '').split('#')[0];
1280
+ }
1281
+ }
1282
+
1283
+ function rwaDirectoryHref(href) {
1284
+ try { return new URL('.', href || location.href).href; }
1285
+ catch (_) { return ''; }
1286
+ }
1287
+
1288
+ function sameWorkspaceDirectory(href) {
1289
+ const mine = rwaDirectoryHref(rwaLocationSansHash());
1290
+ const theirs = rwaDirectoryHref(href);
1291
+ return !!mine && !!theirs && mine === theirs;
1292
+ }
1293
+
1294
+ function workspacePresencePayload(action) {
1295
+ const d = runtimeDescribe();
1296
+ return {
1297
+ schema: 'rwa-presence/1',
1298
+ action,
1299
+ uuid: DOC_UUID,
1300
+ kind: PRODUCT_KIND,
1301
+ title: d.title || document.title || RWA.FILE,
1302
+ file: RWA.FILE,
1303
+ url: rwaLocationSansHash(),
1304
+ affordances: Array.isArray(d.affordances) ? d.affordances.map(a => ({ kind: a.kind, name: a.name, label: a.label, provenance: a.provenance })) : [],
1305
+ };
1306
+ }
1307
+
1308
+ function workspaceManifestDocs() {
1309
+ const el = document.getElementById('rwa-workspace');
1310
+ if (!el) return [];
1311
+ try {
1312
+ const parsed = JSON.parse(el.textContent || '{}');
1313
+ return Array.isArray(parsed.documents) ? parsed.documents : [];
1314
+ } catch (_) {
1315
+ return [];
1316
+ }
1317
+ }
1318
+
1319
+ function workspaceKnownKeys() {
1320
+ const keys = new Set();
1321
+ for (const d of workspaceManifestDocs()) {
1322
+ if (d && d.uuid) keys.add('uuid:' + d.uuid);
1323
+ if (d && d.file) keys.add('file:' + d.file);
1324
+ }
1325
+ return keys;
1326
+ }
1327
+
1328
+ function workspacePeerKnown(peer, known) {
1329
+ return !!(peer && ((peer.uuid && known.has('uuid:' + peer.uuid)) || (peer.file && known.has('file:' + peer.file))));
1330
+ }
1331
+
1332
+ function workspacePresenceCard(peer, known) {
1333
+ const isKnown = workspacePeerKnown(peer, known);
1334
+ const href = peer.url || '#';
1335
+ const aff = peer.affordances && peer.affordances.length ? peer.affordances.map(a => a.kind).join(', ') : 'baseline';
1336
+ return '<a class="rwa-ws-card rwa-ws-live-card" href="' + escRuntimeHtml(href) + '">' +
1337
+ '<span class="rwa-ws-kind">' + escRuntimeHtml(peer.kind || 'document') + '</span>' +
1338
+ '<strong>' + escRuntimeHtml(peer.title || peer.file || 'Untitled') + '</strong>' +
1339
+ '<span>' + escRuntimeHtml(peer.file || peer.url || peer.uuid || '') + '</span>' +
1340
+ '<small>' + (isKnown ? 'indexed' : 'new since sync') + ' · open now · ' + escRuntimeHtml(aff) + '</small>' +
1341
+ '</a>';
1342
+ }
1343
+
1344
+ function renderWorkspacePresence() {
1345
+ if (PRODUCT_KIND !== 'workspace') return;
1346
+ const root = document.querySelector('[data-rwa-workspace-live]');
1347
+ if (!root) return;
1348
+ const grid = root.querySelector('[data-rwa-workspace-live-grid]');
1349
+ if (!grid) return;
1350
+ const cutoff = Date.now() - 45000;
1351
+ const peers = [...workspacePeers.values()]
1352
+ .filter(p => p && p.lastSeen >= cutoff && sameWorkspaceDirectory(p.url))
1353
+ .sort((a, b) => String(a.title || a.file || '').localeCompare(String(b.title || b.file || '')));
1354
+ root.hidden = peers.length === 0;
1355
+ if (!peers.length) { grid.innerHTML = ''; return; }
1356
+ const known = workspaceKnownKeys();
1357
+ grid.innerHTML = peers.map(p => workspacePresenceCard(p, known)).join('');
1358
+ }
1359
+
1360
+ function handleWorkspacePresence(envelope) {
1361
+ const msg = envelope && envelope.message;
1362
+ if (!msg || msg.schema !== 'rwa-presence/1') return;
1363
+ if (msg.action === 'request') {
1364
+ runtimeBusPublish(RWA_WORKSPACE_PRESENCE_TOPIC, workspacePresencePayload('hello'));
1365
+ return;
1366
+ }
1367
+ if (PRODUCT_KIND !== 'workspace' || msg.action !== 'hello' || !sameWorkspaceDirectory(msg.url)) return;
1368
+ workspacePeers.set(msg.uuid || envelope.from, { ...msg, lastSeen: Date.now() });
1369
+ renderWorkspacePresence();
1370
+ }
1371
+
1372
+ function startWorkspacePresence() {
1373
+ try {
1374
+ workspacePresenceUnsub = runtimeBusSubscribe(RWA_WORKSPACE_PRESENCE_TOPIC, handleWorkspacePresence);
1375
+ runtimeBusPublish(RWA_WORKSPACE_PRESENCE_TOPIC, workspacePresencePayload('hello'));
1376
+ if (!(typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || ''))) {
1377
+ workspacePresenceTimer = setInterval(() => {
1378
+ runtimeBusPublish(RWA_WORKSPACE_PRESENCE_TOPIC, workspacePresencePayload('hello'));
1379
+ if (PRODUCT_KIND === 'workspace') renderWorkspacePresence();
1380
+ }, 15000);
1381
+ if (workspacePresenceTimer && typeof workspacePresenceTimer.unref === 'function') workspacePresenceTimer.unref();
1382
+ }
1383
+ if (PRODUCT_KIND === 'workspace') {
1384
+ workspaceMonitorUnsub = () => {};
1385
+ setTimeout(() => runtimeBusPublish(RWA_WORKSPACE_PRESENCE_TOPIC, workspacePresencePayload('request')), 50);
1386
+ }
1387
+ } catch (_) {
1388
+ // BroadcastChannel is unavailable in some constrained contexts. The durable
1389
+ // workspace manifest still works; only live autodiscovery is disabled.
1390
+ }
1052
1391
  }
1053
1392
 
1054
1393
  // ─── URL fragment scroll (rwa-bootstrap 0.9) ───────────────────────
@@ -1109,6 +1448,12 @@ function buildFile(doc) {
1109
1448
  function buildUI() {
1110
1449
  document.getElementById('rwa-runtime').innerHTML = `
1111
1450
  <div id="rwa-set">
1451
+ <div id="rwa-mode-tabs" role="tablist" aria-label="runtime mode">
1452
+ <button class="rwa-mode-tab" type="button" data-rwa-mode-target="document">Document</button>
1453
+ <button class="rwa-mode-tab" type="button" data-rwa-mode-target="edit">Edit</button>
1454
+ <button class="rwa-mode-tab" type="button" data-rwa-mode-target="skills">Skills</button>
1455
+ <button class="rwa-mode-tab" type="button" data-rwa-mode-target="actions">Actions</button>
1456
+ </div>
1112
1457
  <button class="rwa-st-btn" id="rwa-st-status">● ready</button>
1113
1458
  <button class="rwa-st-btn" id="rwa-st-info" title="what is this?" aria-label="what is this?">ⓘ</button>
1114
1459
  <button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
@@ -1117,7 +1462,7 @@ function buildUI() {
1117
1462
  <button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
1118
1463
  </div>
1119
1464
  <div id="rwa-set-panel">
1120
- <div class="rwa-set-row"><label>Backend</label><select id="rwa-backend"><option value="openrouter">OpenRouter (API key)</option><option value="ollama">Ollama (localhost)</option><option value="lmstudio">LM Studio (localhost)</option><option value="bridge">Bridge (claude -p, localhost)</option><option value="bridge-session">Bridge session (claude, persistent)</option></select></div>
1465
+ <div class="rwa-set-row"><label>Backend</label><select id="rwa-backend"><option value="openrouter">OpenRouter (API key)</option><option value="ollama">Ollama (localhost)</option><option value="lmstudio">LM Studio (localhost)</option><option value="atomic">atomic.chat (localhost)</option><option value="bridge">Bridge (claude -p, localhost)</option><option value="bridge-session">Bridge session (claude, persistent)</option></select></div>
1121
1466
  <div class="rwa-set-row" id="rwa-set-row-key"><label>OpenRouter Key</label><input type="password" id="rwa-key" placeholder="sk-or-..." autocomplete="off"></div>
1122
1467
  <div class="rwa-set-row" id="rwa-set-row-bridge-token" style="display:none;"><label>Bridge Token</label><input type="password" id="rwa-bridge-token" placeholder="WEB_CLI_BRIDGE_TOKEN" autocomplete="off"></div>
1123
1468
  <div class="rwa-set-row" id="rwa-set-row-bridge-cwd" style="display:none;"><label>Session Dir</label><input type="text" id="rwa-bridge-cwd" placeholder="/path/on/bridge/host" autocomplete="off" spellcheck="false"></div>
@@ -1128,6 +1473,7 @@ function buildUI() {
1128
1473
  <div id="rwa-info-panel"></div>
1129
1474
  <div id="rwa-skin-panel"></div>
1130
1475
  <div id="rwa-share-panel"></div>
1476
+ <div id="rwa-mode-panel"></div>
1131
1477
  <div id="rwa-pal">
1132
1478
  <div id="rwa-pal-box">
1133
1479
  <div class="rwa-pal-top">
@@ -1149,6 +1495,12 @@ function buildUI() {
1149
1495
  <div id="rwa-lens-paste-hint" hidden></div>
1150
1496
  <div id="rwa-lens-hint"></div>
1151
1497
  </div>
1498
+ <div id="rwa-selection-bar" hidden data-rwa-no-inline-edit>
1499
+ <button type="button" id="rwa-selection-bold" title="Bold selection" aria-label="bold selection"><strong>B</strong></button>
1500
+ <input id="rwa-selection-cmd" placeholder="make it bold" autocomplete="off" spellcheck="false">
1501
+ <button type="button" class="pri" id="rwa-selection-run">Run</button>
1502
+ <button type="button" id="rwa-selection-voice">Mic</button>
1503
+ </div>
1152
1504
  <div id="rwa-lens-hist-panel" hidden></div>`;
1153
1505
 
1154
1506
  const k = document.getElementById('rwa-key'), m = document.getElementById('rwa-model');
@@ -1156,6 +1508,7 @@ function buildUI() {
1156
1508
  m.value = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
1157
1509
  k.oninput = e => sessionStorage.setItem(RWA.K_API, e.target.value.trim());
1158
1510
  m.oninput = e => sessionStorage.setItem(RWA.K_MODEL, e.target.value.trim() || RWA.MODEL);
1511
+ attachModeTabs();
1159
1512
 
1160
1513
  // Bridge SESSION backend config: the bearer token the bridge requires on every
1161
1514
  // /session/* call, and a server-side working dir for the claude session.
@@ -1198,6 +1551,12 @@ function buildUI() {
1198
1551
  defaultUrl: RWA.DEFAULT_LMSTUDIO_URL,
1199
1552
  storageKey: RWA.K_BASE_URL_LMSTUDIO,
1200
1553
  },
1554
+ atomic: {
1555
+ showKey: false, showBaseUrl: true, showModel: true, showHint: true,
1556
+ hintHTML: 'atomic.chat serves an OpenAI-compatible API on <code>127.0.0.1:1337</code>, no key needed — use <code>Test</code> to list its models. CORS allows http(s) page origins out of the box but <strong>not <code>file://</code> pages</strong> (the null origin): open this container from a local web server or a hosted projection to use it.',
1557
+ defaultUrl: RWA.DEFAULT_ATOMIC_URL,
1558
+ storageKey: RWA.K_BASE_URL_ATOMIC,
1559
+ },
1201
1560
  bridge: {
1202
1561
  showKey: false, showBaseUrl: false, showModel: false, showHint: false,
1203
1562
  hintHTML: '',
@@ -1288,11 +1647,14 @@ function buildUI() {
1288
1647
  opt.value = id;
1289
1648
  modelOptsEl.appendChild(opt);
1290
1649
  }
1291
- // If no model is set yet (or the current one isn't in the list), suggest the first.
1292
- const currentModel = (sessionStorage.getItem(RWA.K_MODEL) || '').trim();
1650
+ // For local OpenAI-compatible backends, pick a discovered model when the
1651
+ // current field is empty, still the baked default, or belongs to another
1652
+ // backend. The datalist still carries every returned model for manual choice.
1653
+ const currentModel = (m.value || sessionStorage.getItem(RWA.K_MODEL) || '').trim();
1293
1654
  const isLocal = backendEl.value === 'ollama' || backendEl.value === 'lmstudio';
1294
- const stuckOnDefault = isLocal && (currentModel === '' || currentModel === RWA.MODEL);
1295
- if (stuckOnDefault && models.length > 0) {
1655
+ const shouldSuggestLocal = isLocal && models.length > 0
1656
+ && (currentModel === '' || currentModel === RWA.MODEL || !models.includes(currentModel));
1657
+ if (shouldSuggestLocal) {
1296
1658
  m.value = models[0];
1297
1659
  sessionStorage.setItem(RWA.K_MODEL, models[0]);
1298
1660
  }
@@ -1310,6 +1672,7 @@ function buildUI() {
1310
1672
  document.getElementById('rwa-info-panel').classList.remove('open'); // panels are mutually exclusive
1311
1673
  document.getElementById('rwa-skin-panel').classList.remove('open');
1312
1674
  document.getElementById('rwa-share-panel').classList.remove('open');
1675
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1313
1676
  document.getElementById('rwa-set-panel').classList.toggle('open');
1314
1677
  };
1315
1678
  // ⓘ "what is this?" — render the rwa-identity/1 bundle as prose on open so it
@@ -1319,6 +1682,7 @@ function buildUI() {
1319
1682
  document.getElementById('rwa-set-panel').classList.remove('open');
1320
1683
  document.getElementById('rwa-skin-panel').classList.remove('open');
1321
1684
  document.getElementById('rwa-share-panel').classList.remove('open');
1685
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1322
1686
  const panel = document.getElementById('rwa-info-panel');
1323
1687
  if (!panel.classList.contains('open')) panel.innerHTML = renderInfoPanel();
1324
1688
  panel.classList.toggle('open');
@@ -1338,6 +1702,7 @@ function buildUI() {
1338
1702
  document.getElementById('rwa-set-panel').classList.remove('open');
1339
1703
  document.getElementById('rwa-info-panel').classList.remove('open');
1340
1704
  document.getElementById('rwa-skin-panel').classList.remove('open');
1705
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1341
1706
  renderSharePanel().then(() => panel.classList.add('open'));
1342
1707
  };
1343
1708
  document.getElementById('rwa-st-commit').onclick = commit;
@@ -1447,6 +1812,24 @@ function buildUI() {
1447
1812
  });
1448
1813
  }
1449
1814
 
1815
+ const selCmd = document.getElementById('rwa-selection-cmd');
1816
+ document.getElementById('rwa-selection-bold').addEventListener('click', () => runSelectionCommand('make it bold', { actor: 'user:selection-command' }).catch(() => {}));
1817
+ document.getElementById('rwa-selection-run').addEventListener('click', () => {
1818
+ runSelectionCommand(selCmd.value || '', { actor: 'user:selection-command' }).catch(() => {});
1819
+ });
1820
+ document.getElementById('rwa-selection-voice').addEventListener('click', () => startSelectionVoice());
1821
+ selCmd.addEventListener('keydown', (e) => {
1822
+ if (e.key === 'Enter') {
1823
+ e.preventDefault();
1824
+ runSelectionCommand(selCmd.value || '', { actor: 'user:selection-command' }).catch(() => {});
1825
+ } else if (e.key === 'Escape') {
1826
+ hideSelectionCommandBar();
1827
+ }
1828
+ });
1829
+ document.addEventListener('selectionchange', scheduleSelectionCommandRefresh);
1830
+ document.addEventListener('mouseup', scheduleSelectionCommandRefresh);
1831
+ document.addEventListener('keyup', scheduleSelectionCommandRefresh);
1832
+
1450
1833
  // rwa-lens/1: Esc releases the anchor when one is held. Listener is on
1451
1834
  // `window` so Esc anywhere works (including with focus outside the lens
1452
1835
  // input — e.g. on the doc itself after a click).
@@ -1638,6 +2021,139 @@ async function renderHistoryPanel(panel) {
1638
2021
  if (closeBtn) closeBtn.addEventListener('click', () => { panel.hidden = true; });
1639
2022
  }
1640
2023
 
2024
+ function renderSkillsModePanel(panel) {
2025
+ if (!isRwaSkillHost()) {
2026
+ panel.innerHTML = [
2027
+ '<div class="rwa-mode-section">',
2028
+ '<div class="rwa-mode-kicker">Skills</div>',
2029
+ '<h4>Skill runtime unavailable</h4>',
2030
+ '<p>This file does not include the skill runtime.</p>',
2031
+ '</div>',
2032
+ ].join('');
2033
+ return;
2034
+ }
2035
+ const canInstall = typeof runtimeInstallSkill === 'function' && typeof runtimeUninstallSkill === 'function' && typeof runtimeInvokeSkill === 'function';
2036
+ const skills = runtimeListSkills();
2037
+ const rows = skills.length ? skills.map(s => {
2038
+ const id = escRuntimeHtml(s.skillId);
2039
+ const name = escRuntimeHtml(s.name || s.skillId);
2040
+ const kind = escRuntimeHtml(s.kind || 'skill');
2041
+ const verified = s.verified ? 'verified' : 'unsigned/unverified';
2042
+ return [
2043
+ '<div class="rwa-mode-row" data-skill-id="' + id + '">',
2044
+ '<div style="min-width:0;flex:1">',
2045
+ '<div class="rwa-mode-title">' + name + '</div>',
2046
+ '<div class="rwa-mode-meta">' + kind + ' · ' + escRuntimeHtml(verified) + ' · ' + id + '</div>',
2047
+ canInstall ? '<div class="rwa-mode-invoke"><input data-skill-input="' + id + '" placeholder=\'JSON input, optional\'><button type="button" data-skill-invoke="' + id + '">Invoke</button></div>' : '',
2048
+ '<div class="rwa-mode-result" data-skill-result="' + id + '" hidden></div>',
2049
+ '</div>',
2050
+ canInstall ? '<button type="button" data-skill-uninstall="' + id + '">Uninstall</button>' : '',
2051
+ '</div>',
2052
+ ].join('');
2053
+ }).join('') : '<div class="rwa-mode-empty">No skills installed.</div>';
2054
+ panel.innerHTML = [
2055
+ '<div class="rwa-mode-section">',
2056
+ '<div class="rwa-mode-kicker">Skills</div>',
2057
+ '<h4>Installed skills</h4>',
2058
+ canInstall ? '<div class="rwa-mode-actions"><button type="button" class="pri" id="rwa-skills-install">Install skill...</button></div>' : '<p>Skill install and invoke APIs are not available in this container.</p>',
2059
+ '</div>',
2060
+ '<div class="rwa-mode-section">' + rows + '</div>',
2061
+ ].join('');
2062
+ const install = panel.querySelector('#rwa-skills-install');
2063
+ if (install) install.addEventListener('click', async () => {
2064
+ const out = await runtimePromptInstall();
2065
+ renderSkillsModePanel(panel);
2066
+ if (out && out.ok === false) setStatus('err', '✗ skill install failed');
2067
+ });
2068
+ panel.querySelectorAll('[data-skill-uninstall]').forEach(btn => {
2069
+ btn.addEventListener('click', async () => {
2070
+ const id = btn.getAttribute('data-skill-uninstall');
2071
+ const out = await runtimeUninstallSkill(id);
2072
+ if (out && out.ok === false) setStatus('err', '✗ skill uninstall failed');
2073
+ renderSkillsModePanel(panel);
2074
+ });
2075
+ });
2076
+ panel.querySelectorAll('[data-skill-invoke]').forEach(btn => {
2077
+ btn.addEventListener('click', async () => {
2078
+ const id = btn.getAttribute('data-skill-invoke');
2079
+ const row = btn.closest('[data-skill-id]');
2080
+ const inputEl = row && row.querySelector('[data-skill-input]');
2081
+ const resultEl = row && row.querySelector('[data-skill-result]');
2082
+ let input = {};
2083
+ try {
2084
+ const raw = (inputEl && inputEl.value || '').trim();
2085
+ input = raw ? JSON.parse(raw) : {};
2086
+ } catch (e) {
2087
+ if (resultEl) { resultEl.hidden = false; resultEl.textContent = 'Invalid JSON input'; }
2088
+ return;
2089
+ }
2090
+ btn.disabled = true;
2091
+ try {
2092
+ const out = await runtimeInvokeSkill(id, input);
2093
+ if (resultEl) { resultEl.hidden = false; resultEl.textContent = JSON.stringify(out, null, 2); }
2094
+ } catch (e) {
2095
+ if (resultEl) { resultEl.hidden = false; resultEl.textContent = e?.message || String(e); }
2096
+ } finally {
2097
+ btn.disabled = false;
2098
+ }
2099
+ });
2100
+ });
2101
+ }
2102
+
2103
+ async function renderActionsModePanel(panel) {
2104
+ const d = runtimeDescribe();
2105
+ let entries = [];
2106
+ try { entries = (await idbGet(RWA.HIST)) || []; } catch (_) { entries = []; }
2107
+ if (rwaMode !== 'actions') return;
2108
+ const viewAff = d.affordances.find(a => a.kind === 'view' && a.verified === true);
2109
+ const skillAffs = d.affordances.filter(a => a.provenance === 'installed' && a.skillId);
2110
+ const providerAffs = d.affordances.filter(a => a.provenance !== 'installed');
2111
+ const fmtTs = ts => { try { return new Date(ts).toLocaleString(); } catch (_) { return String(ts); } };
2112
+ const histRows = entries.length ? entries.slice(0, 12).map(e => {
2113
+ const surface = e.surface || e.kind || 'edit';
2114
+ const actor = e.actor ? ' · ' + e.actor : '';
2115
+ const instr = e.instruction || e.reason || '(no instruction recorded)';
2116
+ return '<div class="rwa-mode-hist"><div class="rwa-mode-meta">' + escRuntimeHtml(surface + actor + ' · ' + fmtTs(e.ts)) + '</div><div>' + escRuntimeHtml(instr) + '</div></div>';
2117
+ }).join('') : '<div class="rwa-mode-empty">No runs yet.</div>';
2118
+ const affRows = providerAffs.length ? providerAffs.map(a => {
2119
+ const detail = [a.kind, a.verified ? 'verified' : 'declared'].join(' · ');
2120
+ return '<div class="rwa-mode-row"><div><div class="rwa-mode-title">' + escRuntimeHtml(a.label || a.name) + '</div><div class="rwa-mode-meta">' + escRuntimeHtml(detail + ' · ' + a.name) + '</div></div></div>';
2121
+ }).join('') : '<div class="rwa-mode-empty">No declared live affordances.</div>';
2122
+ const skillRows = skillAffs.length ? skillAffs.map(a =>
2123
+ '<div class="rwa-mode-row"><div><div class="rwa-mode-title">' + escRuntimeHtml(a.name) + '</div><div class="rwa-mode-meta">' + escRuntimeHtml(a.kind || 'skill') + ' · ' + escRuntimeHtml(a.skillId) + '</div></div><button type="button" data-action-skill="' + escRuntimeHtml(a.skillId) + '">Open in Skills</button></div>'
2124
+ ).join('') : '<div class="rwa-mode-empty">No installed skills.</div>';
2125
+ panel.innerHTML = [
2126
+ '<div class="rwa-mode-section">',
2127
+ '<div class="rwa-mode-kicker">Actions</div>',
2128
+ '<h4>Action center</h4>',
2129
+ '<div class="rwa-mode-actions">',
2130
+ '<button type="button" id="rwa-actions-undo">Undo</button>',
2131
+ '<button type="button" class="pri" id="rwa-actions-save">Save / Export</button>',
2132
+ '<button type="button" id="rwa-actions-share">Share</button>',
2133
+ viewAff ? '<button type="button" id="rwa-actions-view">' + escRuntimeHtml(d.activeView === viewAff.name ? 'Exit ' + viewAff.label : viewAff.label) + '</button>' : '',
2134
+ '</div>',
2135
+ '</div>',
2136
+ '<div class="rwa-mode-section"><div class="rwa-mode-kicker">Recent runs</div>' + histRows + '</div>',
2137
+ '<div class="rwa-mode-section"><div class="rwa-mode-kicker">Live affordances</div>' + affRows + '</div>',
2138
+ '<div class="rwa-mode-section"><div class="rwa-mode-kicker">Installed skill actions</div>' + skillRows + '</div>',
2139
+ ].join('');
2140
+ const undoBtn = panel.querySelector('#rwa-actions-undo');
2141
+ if (undoBtn) undoBtn.addEventListener('click', () => runtimeUndo());
2142
+ const saveBtn = panel.querySelector('#rwa-actions-save');
2143
+ if (saveBtn) saveBtn.addEventListener('click', () => runtimeCommit());
2144
+ const shareBtn = panel.querySelector('#rwa-actions-share');
2145
+ if (shareBtn) shareBtn.addEventListener('click', async () => {
2146
+ document.getElementById('rwa-mode-panel')?.classList.remove('open');
2147
+ await renderSharePanel();
2148
+ document.getElementById('rwa-share-panel')?.classList.add('open');
2149
+ });
2150
+ const viewBtn = panel.querySelector('#rwa-actions-view');
2151
+ if (viewBtn && viewAff) viewBtn.addEventListener('click', () => runtimeSetView(d.activeView === viewAff.name ? null : viewAff.name));
2152
+ panel.querySelectorAll('[data-action-skill]').forEach(btn => {
2153
+ btn.addEventListener('click', () => runtimeSetMode('skills'));
2154
+ });
2155
+ }
2156
+
1641
2157
  // ─── Agent (rwa-edit/1) ─────────────────────────────────────────────
1642
2158
  // Spec: rwa-edit-spec.md (v1.4). The agent edits the doc via tool calls,
1643
2159
  // not by emitting a full rewritten document. unchanged regions are byte-
@@ -1821,6 +2337,15 @@ The stored document is ordinary prose HTML inside a single <article>: <h1>/<h2>
1821
2337
  Slide model the user reasons about: a new slide STARTS at each <h1> or <h2>. So "add a slide" = add a new <h2> title followed by its body paragraphs/bullets at the right position. "Split this slide" = add an <h2> in the middle. "Merge two slides" = remove the second slide's heading. "Reorder slides" = move the heading-plus-its-body block. Keep slide bodies concise — a deck slide is a title plus a few short paragraphs or a short list, not an essay.
1822
2338
 
1823
2339
  Preserve every existing data-rwa-id verbatim (the runtime assigns them). Anchor edits on unique text near the heading you mean.
2340
+ ${SYSTEM_PROMPT_RULES}`,
2341
+
2342
+ workspace: `You are editing a rewritable workspace index. Apply the user's request as a small set of surgical edits via tool calls.
2343
+
2344
+ The stored document is a directory control center, normally named rwa-index.html. It summarizes sibling rewritable files from the same folder. The visible <article class="rwa-workspace"> is editable dashboard content; the <script id="rwa-workspace" type="application/rwa-workspace+json"> manifest is generated by the CLI and wrapped in a frozen zone.
2345
+
2346
+ Preserve the workspace-style and workspace-manifest frozen zones exactly. Do not invent files that are not already listed unless the user explicitly asks for placeholder planning content. To refresh the real file list, the user should run \`rwa workspace sync\`; you should not manually edit the manifest.
2347
+
2348
+ When changing the dashboard, keep links to sibling documents relative, and preserve every existing data-rwa-id verbatim.
1824
2349
  ${SYSTEM_PROMPT_RULES}`,
1825
2350
  };
1826
2351
  // rwa:extract:end SYSTEM_PROMPTS
@@ -2576,6 +3101,10 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2576
3101
  // live mount produces the same order via the same outer-wins traversal — so
2577
3102
  // the i-th live anchorable corresponds to map[i].
2578
3103
  function handleMountClick(e) {
3104
+ if (rwaMode !== 'edit' || activeView) return;
3105
+ // Pointer-down may already have opened a WYSIWYG inline edit. In that case
3106
+ // the click belongs to caret placement, not to lens anchoring.
3107
+ if (inlineEdit) return;
2579
3108
  // Audit R3 (scoped): respect the per-kind click-to-anchor flag. When
2580
3109
  // false (e.g. workflow files), all clicks pass through without anchoring
2581
3110
  // — the lens stays in default state and every command runs against the
@@ -2736,10 +3265,11 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2736
3265
  }
2737
3266
 
2738
3267
  // ─── Inline manual edit (edit-surface: direct, no-LLM block editing) ──────
2739
- // Double-click a leaf text block to edit its text by hand; Enter or blur
3268
+ // Click a leaf text block and type in place; Enter or blur
2740
3269
  // commits through the existing non-agent commit path (runtimeApplyEnvelope,
2741
- // actor 'user:edit-surface') — no model call, works offline. Single-click
2742
- // still anchors the lens (handleMountClick), unchanged. Design:
3270
+ // actor 'user:edit-surface') — no model call, works offline. Container clicks
3271
+ // still anchor the lens (handleMountClick); leaf text clicks become WYSIWYG
3272
+ // edit sessions. Double-click remains a compatibility path. Design:
2743
3273
  // docs/plans/2026-06-08-inline-manual-edit-design.md.
2744
3274
  //
2745
3275
  // Editable set = the leaf-text members of ANCHORABLE_TAGS, so the
@@ -2747,6 +3277,7 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2747
3277
  // containers; FIGCAPTION is not independently anchorable (lives in FIGURE).
2748
3278
  const INLINE_EDITABLE = new Set(['P','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','TD']);
2749
3279
  let inlineEdit = null; // { el, entry } while a block is being hand-edited
3280
+ const INLINE_EDIT_BYPASS = 'button,input,textarea,select,option,summary,[contenteditable="true"],[data-rwa-no-inline-edit]';
2750
3281
 
2751
3282
  // Controlled serializer: turn an edited contenteditable node into a clean
2752
3283
  // replace string. Emits ONLY escaped text and <br> (the Shift+Enter soft
@@ -2766,6 +3297,270 @@ function serializeLeafSafe(el) {
2766
3297
  }
2767
3298
  window.serializeLeafSafe = serializeLeafSafe;
2768
3299
 
3300
+ // ─── Selection command surface (typed or voice) ─────────────────────
3301
+ // Edit-mode-only layer over a user text selection. Deterministic commands
3302
+ // such as "make it bold" compile to rwa-edit/1 locally; voice recognition is
3303
+ // only an input method that fills/runs the same command path.
3304
+ let selectionCommandState = null; // { el, entry, text, occurrence, range }
3305
+ let selectionVoiceRecognizer = null;
3306
+
3307
+ function hideSelectionCommandBar() {
3308
+ selectionCommandState = null;
3309
+ const bar = document.getElementById('rwa-selection-bar');
3310
+ if (bar) {
3311
+ bar.hidden = true;
3312
+ delete bar.dataset.listening;
3313
+ }
3314
+ }
3315
+
3316
+ function nodeElement(n) {
3317
+ return n && (n.nodeType === 1 ? n : n.parentElement);
3318
+ }
3319
+
3320
+ function closestSelectionLeaf(node, mount) {
3321
+ let el = nodeElement(node);
3322
+ while (el && el !== mount && !INLINE_EDITABLE.has(el.tagName)) el = el.parentElement;
3323
+ return (el && el !== mount) ? el : null;
3324
+ }
3325
+
3326
+ function countTextOccurrences(haystack, needle) {
3327
+ if (!needle) return 0;
3328
+ let n = 0, i = 0;
3329
+ while ((i = haystack.indexOf(needle, i)) !== -1) { n++; i += needle.length; }
3330
+ return n;
3331
+ }
3332
+
3333
+ function nthIndexOf(haystack, needle, nth) {
3334
+ let i = -1, from = 0;
3335
+ for (let n = 0; n <= nth; n++) {
3336
+ i = haystack.indexOf(needle, from);
3337
+ if (i < 0) return -1;
3338
+ from = i + needle.length;
3339
+ }
3340
+ return i;
3341
+ }
3342
+
3343
+ function resolveSelectionCommandTarget() {
3344
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return null;
3345
+ const mount = document.getElementById('rwa-doc-mount');
3346
+ const sel = window.getSelection && window.getSelection();
3347
+ if (!mount || !sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
3348
+ const range = sel.getRangeAt(0);
3349
+ const text = sel.toString();
3350
+ if (!text || !text.trim()) return null;
3351
+ if (!mount.contains(range.commonAncestorContainer)) return null;
3352
+ const startLeaf = closestSelectionLeaf(range.startContainer, mount);
3353
+ const endLeaf = closestSelectionLeaf(range.endContainer, mount);
3354
+ if (!startLeaf || startLeaf !== endLeaf) return null;
3355
+ if (startLeaf.closest('[data-rwa-frozen]') || startLeaf.closest('.rwa-locked')) return null;
3356
+ const ord = anchorableOrdinal(mount, startLeaf);
3357
+ const entry = (ord >= 0 && sourceMap) ? sourceMap[ord] : null;
3358
+ if (!entry || isWithinLockedRange(entry.start, entry.end)) return null;
3359
+ let occurrence = 0;
3360
+ try {
3361
+ const prefix = document.createRange();
3362
+ prefix.selectNodeContents(startLeaf);
3363
+ prefix.setEnd(range.startContainer, range.startOffset);
3364
+ occurrence = countTextOccurrences(prefix.toString(), text);
3365
+ } catch (_) { occurrence = 0; }
3366
+ return { el: startLeaf, entry, text, occurrence, range };
3367
+ }
3368
+
3369
+ function locateSelectionSource(target) {
3370
+ const block = currentDocCache.slice(target.entry.start, target.entry.end);
3371
+ const tag = target.entry.tag.toLowerCase();
3372
+ const openEnd = block.indexOf('>');
3373
+ const closeRe = new RegExp('</' + tag + '\\s*>\\s*$', 'i');
3374
+ const closeMatch = closeRe.exec(block);
3375
+ if (openEnd < 0 || !closeMatch) return null;
3376
+ const innerStart = openEnd + 1;
3377
+ const innerEnd = closeMatch.index;
3378
+ const inner = block.slice(innerStart, innerEnd);
3379
+ const candidates = [];
3380
+ const plain = escapeHtml(target.text);
3381
+ const br = plain.replace(/\r\n|\r|\n/g, '<br>');
3382
+ candidates.push(br);
3383
+ if (plain !== br) candidates.push(plain);
3384
+ for (const selectedSource of candidates) {
3385
+ const idx = nthIndexOf(inner, selectedSource, target.occurrence || 0);
3386
+ if (idx >= 0) {
3387
+ return {
3388
+ block,
3389
+ selectedSource,
3390
+ start: innerStart + idx,
3391
+ end: innerStart + idx + selectedSource.length,
3392
+ };
3393
+ }
3394
+ }
3395
+ return null;
3396
+ }
3397
+
3398
+ function parseSelectionCommand(raw) {
3399
+ const t = String(raw || '').trim().toLowerCase();
3400
+ if (!t) return null;
3401
+ if (/\b(bold|strong|emphasize strongly)\b/.test(t)) return { kind: 'wrap', tag: 'strong', label: 'bold' };
3402
+ if (/\b(italic|italics|emphasize)\b/.test(t)) return { kind: 'wrap', tag: 'em', label: 'italic' };
3403
+ if (/\b(code|monospace|inline code)\b/.test(t)) return { kind: 'wrap', tag: 'code', label: 'code' };
3404
+ return null;
3405
+ }
3406
+
3407
+ async function applySelectionWrap(target, action, actor, instruction) {
3408
+ if (inlineEdit && serializeLeafSafe(inlineEdit.el) !== inlineEdit.original) {
3409
+ showAffordance('finish the current edit first');
3410
+ return { ok: false, reason: 'inline_edit_dirty' };
3411
+ }
3412
+ if (inlineEdit) exitInlineEdit();
3413
+ const loc = locateSelectionSource(target);
3414
+ if (!loc) {
3415
+ showAffordance('selection could not be mapped to source');
3416
+ return { ok: false, reason: 'selection_unmapped' };
3417
+ }
3418
+ const a = resolveAnchorFind(target.entry);
3419
+ if (!a) {
3420
+ showAffordance('selection block is ambiguous');
3421
+ return { ok: false, reason: 'anchor_unresolved' };
3422
+ }
3423
+ const tag = action.tag;
3424
+ const nextBlock = loc.block.slice(0, loc.start) + '<' + tag + '>' + loc.selectedSource + '</' + tag + '>' + loc.block.slice(loc.end);
3425
+ const replace = a.replacePrefix + nextBlock + a.replaceSuffix;
3426
+ await runtimeApplyEnvelope(
3427
+ { version: 'rwa-edit/1', edits: [{ find: a.find, replace, reason: 'selection:' + action.label }] },
3428
+ { surface: 'selection-edit', instruction: instruction || action.label, actor: actor || 'user:selection-command' });
3429
+ try { window.getSelection().removeAllRanges(); } catch (_) {}
3430
+ hideSelectionCommandBar();
3431
+ showAffordance('selection: ' + action.label + ' — ⌘Z to undo');
3432
+ return { ok: true };
3433
+ }
3434
+
3435
+ async function runSelectionCommand(raw, options) {
3436
+ options = options || {};
3437
+ const target = resolveSelectionCommandTarget() || selectionCommandState;
3438
+ if (!target) {
3439
+ showAffordance('select text in Edit mode first');
3440
+ return { ok: false, reason: 'no_selection' };
3441
+ }
3442
+ const action = parseSelectionCommand(raw);
3443
+ if (!action) {
3444
+ showAffordance('selection command not available yet');
3445
+ return { ok: false, reason: 'unknown_command' };
3446
+ }
3447
+ if (action.kind === 'wrap') return applySelectionWrap(target, action, options.actor, raw);
3448
+ return { ok: false, reason: 'unknown_command' };
3449
+ }
3450
+
3451
+ function positionSelectionCommandBar(target) {
3452
+ const bar = document.getElementById('rwa-selection-bar');
3453
+ if (!bar || !target || !target.range) return;
3454
+ let rect = null;
3455
+ try { rect = target.range.getBoundingClientRect(); } catch (_) {}
3456
+ if (!rect || (rect.width === 0 && rect.height === 0)) {
3457
+ try { rect = target.el.getBoundingClientRect(); } catch (_) {}
3458
+ }
3459
+ const top = Math.max(8, window.scrollY + (rect ? rect.top : 0) - 42);
3460
+ const left = Math.max(8, Math.min(window.scrollX + (rect ? rect.left : 0), window.scrollX + window.innerWidth - 320));
3461
+ bar.style.top = top + 'px';
3462
+ bar.style.left = left + 'px';
3463
+ bar.hidden = false;
3464
+ }
3465
+
3466
+ function refreshSelectionCommandBar() {
3467
+ const bar = document.getElementById('rwa-selection-bar');
3468
+ if (bar && bar.contains(document.activeElement)) return;
3469
+ const target = resolveSelectionCommandTarget();
3470
+ if (!target) { hideSelectionCommandBar(); return; }
3471
+ selectionCommandState = target;
3472
+ positionSelectionCommandBar(target);
3473
+ }
3474
+
3475
+ let selectionRefreshQueued = false;
3476
+ function scheduleSelectionCommandRefresh() {
3477
+ if (selectionRefreshQueued) return;
3478
+ selectionRefreshQueued = true;
3479
+ requestAnimationFrame(() => {
3480
+ selectionRefreshQueued = false;
3481
+ refreshSelectionCommandBar();
3482
+ });
3483
+ }
3484
+
3485
+ function startSelectionVoice() {
3486
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
3487
+ const bar = document.getElementById('rwa-selection-bar');
3488
+ const input = document.getElementById('rwa-selection-cmd');
3489
+ if (!selectionCommandState && !resolveSelectionCommandTarget()) {
3490
+ showAffordance('select text first');
3491
+ return;
3492
+ }
3493
+ if (!SR) {
3494
+ showAffordance('voice input is not supported in this browser');
3495
+ if (input) input.focus();
3496
+ return;
3497
+ }
3498
+ try {
3499
+ if (selectionVoiceRecognizer) selectionVoiceRecognizer.abort();
3500
+ } catch (_) {}
3501
+ const rec = new SR();
3502
+ selectionVoiceRecognizer = rec;
3503
+ rec.lang = navigator.language || 'en-US';
3504
+ rec.interimResults = false;
3505
+ rec.maxAlternatives = 1;
3506
+ rec.onstart = () => { if (bar) bar.dataset.listening = '1'; };
3507
+ rec.onresult = (e) => {
3508
+ const text = e?.results?.[0]?.[0]?.transcript || '';
3509
+ if (input) input.value = text;
3510
+ runSelectionCommand(text, { actor: 'user:voice-selection' }).catch(err => showAffordance('voice: ' + (err?.message || err)));
3511
+ };
3512
+ rec.onerror = (e) => showAffordance('voice: ' + (e?.error || 'could not hear command'));
3513
+ rec.onend = () => { if (bar) delete bar.dataset.listening; selectionVoiceRecognizer = null; };
3514
+ rec.start();
3515
+ }
3516
+
3517
+ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3518
+ window.__refreshSelectionCommandBar = refreshSelectionCommandBar;
3519
+ window.__runSelectionCommand = runSelectionCommand;
3520
+ window.__startSelectionVoice = startSelectionVoice;
3521
+ window.__parseSelectionCommand = parseSelectionCommand;
3522
+ }
3523
+
3524
+ function resolveInlineEditTarget(target) {
3525
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return null;
3526
+ const mount = document.getElementById('rwa-doc-mount');
3527
+ if (!mount || !target) return null;
3528
+ if (target.closest && target.closest(INLINE_EDIT_BYPASS)) return null;
3529
+ let el = target;
3530
+ while (el && el !== mount && !INLINE_EDITABLE.has(el.tagName)) el = el.parentNode;
3531
+ if (!el || el === mount) return null;
3532
+ if (el.closest('[data-rwa-frozen]') || el.closest('.rwa-locked')) return null;
3533
+ const ord = anchorableOrdinal(mount, el);
3534
+ const entry = (ord >= 0 && sourceMap) ? sourceMap[ord] : null;
3535
+ // A nested anchorable (an inner <li>, or a <p> inside a <td>) is not recorded
3536
+ // by the outer-wins descent, so ord is -1 and we no-op — consistent with the
3537
+ // lens's anchoring model, which can't target sub-blocks either.
3538
+ if (!entry) return null;
3539
+ if (isWithinLockedRange(entry.start, entry.end)) return null; // marker-form frozen backstop
3540
+ return { el, entry };
3541
+ }
3542
+
3543
+ function refreshInlineEditAffordances(mount) {
3544
+ if (!mount) return;
3545
+ mount.querySelectorAll('.rwa-editable-leaf').forEach(el => {
3546
+ el.classList.remove('rwa-editable-leaf');
3547
+ if (el.getAttribute('tabindex') === '0' && el.dataset.rwaEditTab === '1') {
3548
+ el.removeAttribute('tabindex');
3549
+ delete el.dataset.rwaEditTab;
3550
+ }
3551
+ });
3552
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return;
3553
+ mount.querySelectorAll(Array.from(INLINE_EDITABLE).map(t => t.toLowerCase()).join(',')).forEach(el => {
3554
+ const hit = resolveInlineEditTarget(el);
3555
+ if (!hit || hit.el !== el) return;
3556
+ el.classList.add('rwa-editable-leaf');
3557
+ if (!el.hasAttribute('tabindex')) {
3558
+ el.setAttribute('tabindex', '0');
3559
+ el.dataset.rwaEditTab = '1';
3560
+ }
3561
+ });
3562
+ }
3563
+
2769
3564
  function enterInlineEdit(el, entry) {
2770
3565
  if (inlineEdit) exitInlineEdit();
2771
3566
  inlineEdit = { el, entry, original: '', commandMode: false, demoted: false };
@@ -2983,34 +3778,39 @@ async function commitInlineEdit() {
2983
3778
  }
2984
3779
  window.commitInlineEdit = commitInlineEdit;
2985
3780
 
2986
- // Double-click a leaf block -> enter hand-edit. Mount-delegated (re-registered
2987
- // each render in renderDoc, so it survives the post-commit re-render). Gated:
2988
- // frozen zones (data-rwa-frozen attribute, marker-form via isWithinLockedRange)
2989
- // and class-locked subtrees are off-limits; under an active view the listener
2990
- // is not registered at all (renderDoc).
3781
+ function startInlineEditFromEvent(e) {
3782
+ if (rwaMode !== 'edit' || activeView) return false;
3783
+ if (inlineEdit) return false;
3784
+ const hit = resolveInlineEditTarget(e && e.target);
3785
+ if (!hit) return false;
3786
+ enterInlineEdit(hit.el, hit.entry);
3787
+ return true;
3788
+ }
3789
+
3790
+ // Pointer-down opens the edit before the browser performs its default caret
3791
+ // placement for the ensuing click. This is the WYSIWYG path: click text, type.
3792
+ function handleMountPointerDown(e) {
3793
+ if (e.button != null && e.button !== 0) return;
3794
+ if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
3795
+ startInlineEditFromEvent(e);
3796
+ }
3797
+
3798
+ // Double-click compatibility for existing behavior and tests.
2991
3799
  function handleMountDblClick(e) {
2992
- if (activeView) return;
2993
- // An in-flight modify re-renders on commit, wiping any session opened now —
2994
- // and a session-triggered render would rebuild the sourceMap under the
2995
- // in-flight anchor. Cheapest correct behavior: don't open the session.
2996
- if (modifyMutex) return;
2997
- const mount = document.getElementById('rwa-doc-mount');
2998
- if (!mount) return;
2999
- let el = e.target;
3000
- while (el && el !== mount && !INLINE_EDITABLE.has(el.tagName)) el = el.parentNode;
3001
- if (!el || el === mount) return;
3002
- if (el.closest('[data-rwa-frozen]') || el.closest('.rwa-locked')) return;
3003
- const ord = anchorableOrdinal(mount, el);
3004
- const entry = (ord >= 0 && sourceMap) ? sourceMap[ord] : null;
3005
- // A nested anchorable (an inner <li>, or a <p> inside a <td>) is not recorded
3006
- // by the outer-wins descent, so ord is -1 and we no-op — consistent with the
3007
- // lens's anchoring model, which can't target sub-blocks either.
3008
- if (!entry) return;
3009
- if (isWithinLockedRange(entry.start, entry.end)) return; // marker-form frozen backstop
3010
- enterInlineEdit(el, entry);
3800
+ startInlineEditFromEvent(e);
3801
+ }
3802
+
3803
+ function handleMountEditKeydown(e) {
3804
+ if (inlineEdit) return;
3805
+ if (e.key !== 'Enter' && e.key !== 'F2') return;
3806
+ const hit = resolveInlineEditTarget(e.target);
3807
+ if (!hit || hit.el !== e.target) return;
3808
+ e.preventDefault();
3809
+ enterInlineEdit(hit.el, hit.entry);
3011
3810
  }
3012
3811
  if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3013
3812
  window.__handleMountDblClick = handleMountDblClick;
3813
+ window.__handleMountPointerDown = handleMountPointerDown;
3014
3814
  }
3015
3815
 
3016
3816
  // ─── Image ingestion (images-v1) ──────────────────────────────────────
@@ -3198,7 +3998,7 @@ function dragHasFiles(e) {
3198
3998
  return Array.from(t.types || []).includes('Files');
3199
3999
  }
3200
4000
  function handleMountDragOver(e) {
3201
- if (modifyMutex || !dragHasFiles(e)) return;
4001
+ if (rwaMode !== 'edit' || activeView || modifyMutex || !dragHasFiles(e)) return;
3202
4002
  e.preventDefault();
3203
4003
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
3204
4004
  clearDropMark();
@@ -3213,6 +4013,7 @@ function handleMountDragLeave(e) {
3213
4013
  if (!m || !e.relatedTarget || !m.contains(e.relatedTarget)) clearDropMark();
3214
4014
  }
3215
4015
  function handleMountDrop(e) {
4016
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return;
3216
4017
  if (!dragHasFiles(e)) return;
3217
4018
  e.preventDefault();
3218
4019
  const target = findImageDropTarget(e);
@@ -3227,7 +4028,7 @@ function handleMountDrop(e) {
3227
4028
 
3228
4029
  // ── paste (a screenshot is the #1 case) ──
3229
4030
  function handleDocumentImagePaste(e) {
3230
- if (activeView) return;
4031
+ if (rwaMode !== 'edit' || activeView) return;
3231
4032
  if (typeof inlineEdit !== 'undefined' && inlineEdit) return; // hand-edit owns its paste
3232
4033
  const files = Array.from((e.clipboardData && e.clipboardData.files) || []).filter(f => /^image\//.test(f.type || ''));
3233
4034
  if (files.length === 0) return;
@@ -3243,6 +4044,7 @@ document.addEventListener('paste', handleDocumentImagePaste);
3243
4044
 
3244
4045
  // ── /image picker ──
3245
4046
  function openImagePicker() {
4047
+ if (rwaMode !== 'edit' || activeView) return;
3246
4048
  const input = document.createElement('input');
3247
4049
  input.type = 'file';
3248
4050
  input.accept = 'image/*';
@@ -3333,7 +4135,7 @@ function ensureImgChip() {
3333
4135
  return chip;
3334
4136
  }
3335
4137
  function handleMountImgOver(e) {
3336
- if (activeView || modifyMutex) return;
4138
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return;
3337
4139
  const img = e.target instanceof Element ? e.target.closest('img') : null;
3338
4140
  const m = document.getElementById('rwa-doc-mount');
3339
4141
  if (!img || !m || !m.contains(img)) return;
@@ -4193,6 +4995,7 @@ HARD RULES: colors are hex strings only (e.g. "#c0392b"); fonts are ONE of the f
4193
4995
  const setP = document.getElementById('rwa-set-panel'); if (setP) setP.classList.remove('open');
4194
4996
  const infoP = document.getElementById('rwa-info-panel'); if (infoP) infoP.classList.remove('open');
4195
4997
  const shareP = document.getElementById('rwa-share-panel'); if (shareP) shareP.classList.remove('open');
4998
+ const modeP = document.getElementById('rwa-mode-panel'); if (modeP) modeP.classList.remove('open');
4196
4999
  const active = currentSkinName();
4197
5000
  panel.innerHTML =
4198
5001
  '<div class="rwa-skin-hd"><span>Skins</span><span>' + (active || '—') + '</span></div>'
@@ -4321,6 +5124,18 @@ function resolveBackendConfig() {
4321
5124
  apiKey: null, extraHeaders: {}, requiresKey: false,
4322
5125
  };
4323
5126
  }
5127
+ if (backend === 'atomic') {
5128
+ return {
5129
+ backend, kind:'openai_compat',
5130
+ baseUrl: (sessionStorage.getItem(RWA.K_BASE_URL_ATOMIC) || '').trim() || RWA.DEFAULT_ATOMIC_URL,
5131
+ apiKey: null, extraHeaders: {}, requiresKey: false,
5132
+ // atomic.chat REJECTS (400) requests whose prompt + max generation
5133
+ // exceed its MAX_KV_SIZE (16384 default) rather than clamping like
5134
+ // ollama/lmstudio — half the window for generation, half for the
5135
+ // prompt + document. Callers read cfg.maxTokens || 32000.
5136
+ maxTokens: 8192,
5137
+ };
5138
+ }
4324
5139
  if (backend === 'bridge') {
4325
5140
  return { backend, kind:'bridge' };
4326
5141
  }
@@ -4379,7 +5194,7 @@ async function callAgentSingleShot(prompt) {
4379
5194
  const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
4380
5195
  const data = await openAiCompatChat(cfg, {
4381
5196
  model,
4382
- max_tokens: 32000,
5197
+ max_tokens: cfg.maxTokens || 32000,
4383
5198
  messages: [{ role:'user', content: prompt }],
4384
5199
  });
4385
5200
  const msg = data.choices?.[0]?.message;
@@ -5563,9 +6378,16 @@ function runtimeSetView(name) {
5563
6378
  if (!spec || spec.name !== name) throw new Error('no registered view named ' + name);
5564
6379
  validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
5565
6380
  releaseAnchor();
6381
+ if (rwaMode !== 'document') {
6382
+ hideEditTransients();
6383
+ closeRuntimePanels();
6384
+ rwaMode = 'document';
6385
+ emitRuntimeEvent('mode', { mode: rwaMode });
6386
+ }
5566
6387
  activeView = spec;
5567
6388
  sessionStorage.setItem(rwaViewKey(), name);
5568
6389
  }
6390
+ if (typeof syncModeChrome === 'function') syncModeChrome();
5569
6391
  if (typeof syncViewChrome === 'function') syncViewChrome();
5570
6392
  getDoc().then(d => renderDoc(canonLF(d)));
5571
6393
  }
@@ -6578,7 +7400,7 @@ async function modify(instr, lensMeta = null, opts = null) {
6578
7400
  try {
6579
7401
  data = await openAiCompatChat(cfg, {
6580
7402
  model,
6581
- max_tokens: 32000,
7403
+ max_tokens: cfg.maxTokens || 32000,
6582
7404
  messages,
6583
7405
  tools: TOOL_SCHEMAS,
6584
7406
  tool_choice: 'auto',
@@ -7216,7 +8038,11 @@ async function commit() {
7216
8038
  document.addEventListener('keydown', e => {
7217
8039
  const mod = navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey;
7218
8040
  if (!mod) return;
7219
- if (e.key === 'k') { e.preventDefault(); document.getElementById('rwa-pal').classList.contains('open') ? closePal() : openPal(); }
8041
+ if (e.key === 'k') {
8042
+ e.preventDefault();
8043
+ try { if (rwaMode !== 'edit') runtimeSetMode('edit'); } catch (_) { return; }
8044
+ document.getElementById('rwa-pal').classList.contains('open') ? closePal() : openPal();
8045
+ }
7220
8046
  else if (e.key === 'z') { e.preventDefault(); undo(); }
7221
8047
  else if (e.key === 's') { e.preventDefault(); commit(); }
7222
8048
  });
@@ -7301,11 +8127,16 @@ document.addEventListener('keydown', e => {
7301
8127
  del: runtimeFsDel,
7302
8128
  list: runtimeFsList,
7303
8129
  },
8130
+ bus: {
8131
+ publish: runtimeBusPublish,
8132
+ subscribe: runtimeBusSubscribe,
8133
+ },
7304
8134
  modify: runtimeModify,
7305
8135
  commit: runtimeCommit,
7306
8136
  undo: runtimeUndo,
7307
8137
  applyEnvelope: runtimeApplyEnvelope,
7308
8138
  on: runtimeOn,
8139
+ setMode: runtimeSetMode,
7309
8140
  provide: runtimeProvide, // spec §5.10 — register a view provider
7310
8141
  setView: runtimeSetView, // spec §5.10 — activate/deactivate a render mode
7311
8142
  describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
@@ -7326,6 +8157,12 @@ document.addEventListener('keydown', e => {
7326
8157
  enumerable: true,
7327
8158
  configurable: false,
7328
8159
  });
8160
+ Object.defineProperty(window.runtime, 'mode', {
8161
+ get: () => rwaMode,
8162
+ enumerable: true,
8163
+ configurable: false,
8164
+ });
8165
+ startWorkspacePresence();
7329
8166
  // §5.10: the presentation render mode ships ONLY for presentation
7330
8167
  // containers. For every other kind this block is skipped entirely —
7331
8168
  // activeView stays null, no provider is registered, no chrome is built,