rewritable 0.7.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
@@ -851,12 +900,13 @@ async function runtimeFsList(prefix) {
851
900
  const runtimeEvents = {
852
901
  commit: new Set(),
853
902
  modify: new Set(),
903
+ mode: new Set(),
854
904
  status: new Set(),
855
905
  };
856
906
 
857
907
  function runtimeOn(event, callback) {
858
908
  if (!Object.prototype.hasOwnProperty.call(runtimeEvents, event)) {
859
- throw new Error("unknown event '" + event + "' (use 'commit', 'modify', or 'status')");
909
+ throw new Error("unknown event '" + event + "' (use 'commit', 'modify', 'mode', or 'status')");
860
910
  }
861
911
  if (typeof callback !== 'function') throw new TypeError('callback must be a function');
862
912
  runtimeEvents[event].add(callback);
@@ -876,6 +926,147 @@ function emitRuntimeEvent(event, payload) {
876
926
  }
877
927
  }
878
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
+
879
1070
  // === Status surface (spec §7) ==============================================
880
1071
  // FSA permission state. Tracked here rather than re-derived each call because
881
1072
  // the permission events that move it (showSaveFilePicker, queryPermission,
@@ -1019,6 +1210,7 @@ function renderDoc(html) {
1019
1210
  // the agent's view of the document is invisible-by-construction to any render mode.
1020
1211
  setSourceMap(html);
1021
1212
  rebuildLockedRanges(html);
1213
+ const editReady = rwaMode === 'edit' && !activeView;
1022
1214
  // rwa-lens/1: click-to-anchor (Task 5.1). renderDoc runs on every commit and
1023
1215
  // on bootstrap; remove first so listeners don't multiply across renders. Same
1024
1216
  // function reference, so removing the previous instance is safe. With a
@@ -1026,12 +1218,22 @@ function renderDoc(html) {
1026
1218
  // listener is removed and not re-added (§5.10). For activeView===null this is
1027
1219
  // byte-identical to the previous behavior.
1028
1220
  m.removeEventListener('click', handleMountClick);
1029
- if (!activeView) m.addEventListener('click', handleMountClick);
1030
- // 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
1031
1226
  // remove-then-add discipline as click-to-anchor; not registered under an
1032
1227
  // active view (byte-offset resolution is undefined there).
1228
+ if (typeof refreshInlineEditAffordances === 'function') refreshInlineEditAffordances(m);
1229
+ m.removeEventListener('pointerdown', handleMountPointerDown);
1033
1230
  m.removeEventListener('dblclick', handleMountDblClick);
1034
- 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
+ }
1035
1237
  // images-v1: drag-drop insert + hover-✕ chip. Same remove-then-add
1036
1238
  // discipline; inert under a render mode (byte-offset resolution undefined).
1037
1239
  // The chip/drop-mark reference DOM the innerHTML reset just destroyed.
@@ -1043,7 +1245,7 @@ function renderDoc(html) {
1043
1245
  m.removeEventListener('drop', handleMountDrop);
1044
1246
  m.removeEventListener('mouseover', handleMountImgOver);
1045
1247
  m.removeEventListener('mouseout', handleMountImgOut);
1046
- if (!activeView) {
1248
+ if (editReady) {
1047
1249
  m.addEventListener('dragover', handleMountDragOver);
1048
1250
  m.addEventListener('dragleave', handleMountDragLeave);
1049
1251
  m.addEventListener('drop', handleMountDrop);
@@ -1056,6 +1258,136 @@ function renderDoc(html) {
1056
1258
  if (activeView && typeof activeView.mounted === 'function') {
1057
1259
  activeView.mounted(m, viewCtx());
1058
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
+ }
1059
1391
  }
1060
1392
 
1061
1393
  // ─── URL fragment scroll (rwa-bootstrap 0.9) ───────────────────────
@@ -1116,6 +1448,12 @@ function buildFile(doc) {
1116
1448
  function buildUI() {
1117
1449
  document.getElementById('rwa-runtime').innerHTML = `
1118
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>
1119
1457
  <button class="rwa-st-btn" id="rwa-st-status">● ready</button>
1120
1458
  <button class="rwa-st-btn" id="rwa-st-info" title="what is this?" aria-label="what is this?">ⓘ</button>
1121
1459
  <button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
@@ -1135,6 +1473,7 @@ function buildUI() {
1135
1473
  <div id="rwa-info-panel"></div>
1136
1474
  <div id="rwa-skin-panel"></div>
1137
1475
  <div id="rwa-share-panel"></div>
1476
+ <div id="rwa-mode-panel"></div>
1138
1477
  <div id="rwa-pal">
1139
1478
  <div id="rwa-pal-box">
1140
1479
  <div class="rwa-pal-top">
@@ -1156,6 +1495,12 @@ function buildUI() {
1156
1495
  <div id="rwa-lens-paste-hint" hidden></div>
1157
1496
  <div id="rwa-lens-hint"></div>
1158
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>
1159
1504
  <div id="rwa-lens-hist-panel" hidden></div>`;
1160
1505
 
1161
1506
  const k = document.getElementById('rwa-key'), m = document.getElementById('rwa-model');
@@ -1163,6 +1508,7 @@ function buildUI() {
1163
1508
  m.value = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
1164
1509
  k.oninput = e => sessionStorage.setItem(RWA.K_API, e.target.value.trim());
1165
1510
  m.oninput = e => sessionStorage.setItem(RWA.K_MODEL, e.target.value.trim() || RWA.MODEL);
1511
+ attachModeTabs();
1166
1512
 
1167
1513
  // Bridge SESSION backend config: the bearer token the bridge requires on every
1168
1514
  // /session/* call, and a server-side working dir for the claude session.
@@ -1301,11 +1647,14 @@ function buildUI() {
1301
1647
  opt.value = id;
1302
1648
  modelOptsEl.appendChild(opt);
1303
1649
  }
1304
- // If no model is set yet (or the current one isn't in the list), suggest the first.
1305
- 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();
1306
1654
  const isLocal = backendEl.value === 'ollama' || backendEl.value === 'lmstudio';
1307
- const stuckOnDefault = isLocal && (currentModel === '' || currentModel === RWA.MODEL);
1308
- if (stuckOnDefault && models.length > 0) {
1655
+ const shouldSuggestLocal = isLocal && models.length > 0
1656
+ && (currentModel === '' || currentModel === RWA.MODEL || !models.includes(currentModel));
1657
+ if (shouldSuggestLocal) {
1309
1658
  m.value = models[0];
1310
1659
  sessionStorage.setItem(RWA.K_MODEL, models[0]);
1311
1660
  }
@@ -1323,6 +1672,7 @@ function buildUI() {
1323
1672
  document.getElementById('rwa-info-panel').classList.remove('open'); // panels are mutually exclusive
1324
1673
  document.getElementById('rwa-skin-panel').classList.remove('open');
1325
1674
  document.getElementById('rwa-share-panel').classList.remove('open');
1675
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1326
1676
  document.getElementById('rwa-set-panel').classList.toggle('open');
1327
1677
  };
1328
1678
  // ⓘ "what is this?" — render the rwa-identity/1 bundle as prose on open so it
@@ -1332,6 +1682,7 @@ function buildUI() {
1332
1682
  document.getElementById('rwa-set-panel').classList.remove('open');
1333
1683
  document.getElementById('rwa-skin-panel').classList.remove('open');
1334
1684
  document.getElementById('rwa-share-panel').classList.remove('open');
1685
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1335
1686
  const panel = document.getElementById('rwa-info-panel');
1336
1687
  if (!panel.classList.contains('open')) panel.innerHTML = renderInfoPanel();
1337
1688
  panel.classList.toggle('open');
@@ -1351,6 +1702,7 @@ function buildUI() {
1351
1702
  document.getElementById('rwa-set-panel').classList.remove('open');
1352
1703
  document.getElementById('rwa-info-panel').classList.remove('open');
1353
1704
  document.getElementById('rwa-skin-panel').classList.remove('open');
1705
+ document.getElementById('rwa-mode-panel').classList.remove('open');
1354
1706
  renderSharePanel().then(() => panel.classList.add('open'));
1355
1707
  };
1356
1708
  document.getElementById('rwa-st-commit').onclick = commit;
@@ -1460,6 +1812,24 @@ function buildUI() {
1460
1812
  });
1461
1813
  }
1462
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
+
1463
1833
  // rwa-lens/1: Esc releases the anchor when one is held. Listener is on
1464
1834
  // `window` so Esc anywhere works (including with focus outside the lens
1465
1835
  // input — e.g. on the doc itself after a click).
@@ -1651,6 +2021,139 @@ async function renderHistoryPanel(panel) {
1651
2021
  if (closeBtn) closeBtn.addEventListener('click', () => { panel.hidden = true; });
1652
2022
  }
1653
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
+
1654
2157
  // ─── Agent (rwa-edit/1) ─────────────────────────────────────────────
1655
2158
  // Spec: rwa-edit-spec.md (v1.4). The agent edits the doc via tool calls,
1656
2159
  // not by emitting a full rewritten document. unchanged regions are byte-
@@ -1834,6 +2337,15 @@ The stored document is ordinary prose HTML inside a single <article>: <h1>/<h2>
1834
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.
1835
2338
 
1836
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.
1837
2349
  ${SYSTEM_PROMPT_RULES}`,
1838
2350
  };
1839
2351
  // rwa:extract:end SYSTEM_PROMPTS
@@ -2589,6 +3101,10 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2589
3101
  // live mount produces the same order via the same outer-wins traversal — so
2590
3102
  // the i-th live anchorable corresponds to map[i].
2591
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;
2592
3108
  // Audit R3 (scoped): respect the per-kind click-to-anchor flag. When
2593
3109
  // false (e.g. workflow files), all clicks pass through without anchoring
2594
3110
  // — the lens stays in default state and every command runs against the
@@ -2749,10 +3265,11 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2749
3265
  }
2750
3266
 
2751
3267
  // ─── Inline manual edit (edit-surface: direct, no-LLM block editing) ──────
2752
- // 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
2753
3269
  // commits through the existing non-agent commit path (runtimeApplyEnvelope,
2754
- // actor 'user:edit-surface') — no model call, works offline. Single-click
2755
- // 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:
2756
3273
  // docs/plans/2026-06-08-inline-manual-edit-design.md.
2757
3274
  //
2758
3275
  // Editable set = the leaf-text members of ANCHORABLE_TAGS, so the
@@ -2760,6 +3277,7 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
2760
3277
  // containers; FIGCAPTION is not independently anchorable (lives in FIGURE).
2761
3278
  const INLINE_EDITABLE = new Set(['P','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','TD']);
2762
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]';
2763
3281
 
2764
3282
  // Controlled serializer: turn an edited contenteditable node into a clean
2765
3283
  // replace string. Emits ONLY escaped text and <br> (the Shift+Enter soft
@@ -2779,6 +3297,270 @@ function serializeLeafSafe(el) {
2779
3297
  }
2780
3298
  window.serializeLeafSafe = serializeLeafSafe;
2781
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
+
2782
3564
  function enterInlineEdit(el, entry) {
2783
3565
  if (inlineEdit) exitInlineEdit();
2784
3566
  inlineEdit = { el, entry, original: '', commandMode: false, demoted: false };
@@ -2996,34 +3778,39 @@ async function commitInlineEdit() {
2996
3778
  }
2997
3779
  window.commitInlineEdit = commitInlineEdit;
2998
3780
 
2999
- // Double-click a leaf block -> enter hand-edit. Mount-delegated (re-registered
3000
- // each render in renderDoc, so it survives the post-commit re-render). Gated:
3001
- // frozen zones (data-rwa-frozen attribute, marker-form via isWithinLockedRange)
3002
- // and class-locked subtrees are off-limits; under an active view the listener
3003
- // 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.
3004
3799
  function handleMountDblClick(e) {
3005
- if (activeView) return;
3006
- // An in-flight modify re-renders on commit, wiping any session opened now —
3007
- // and a session-triggered render would rebuild the sourceMap under the
3008
- // in-flight anchor. Cheapest correct behavior: don't open the session.
3009
- if (modifyMutex) return;
3010
- const mount = document.getElementById('rwa-doc-mount');
3011
- if (!mount) return;
3012
- let el = e.target;
3013
- while (el && el !== mount && !INLINE_EDITABLE.has(el.tagName)) el = el.parentNode;
3014
- if (!el || el === mount) return;
3015
- if (el.closest('[data-rwa-frozen]') || el.closest('.rwa-locked')) return;
3016
- const ord = anchorableOrdinal(mount, el);
3017
- const entry = (ord >= 0 && sourceMap) ? sourceMap[ord] : null;
3018
- // A nested anchorable (an inner <li>, or a <p> inside a <td>) is not recorded
3019
- // by the outer-wins descent, so ord is -1 and we no-op — consistent with the
3020
- // lens's anchoring model, which can't target sub-blocks either.
3021
- if (!entry) return;
3022
- if (isWithinLockedRange(entry.start, entry.end)) return; // marker-form frozen backstop
3023
- 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);
3024
3810
  }
3025
3811
  if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3026
3812
  window.__handleMountDblClick = handleMountDblClick;
3813
+ window.__handleMountPointerDown = handleMountPointerDown;
3027
3814
  }
3028
3815
 
3029
3816
  // ─── Image ingestion (images-v1) ──────────────────────────────────────
@@ -3211,7 +3998,7 @@ function dragHasFiles(e) {
3211
3998
  return Array.from(t.types || []).includes('Files');
3212
3999
  }
3213
4000
  function handleMountDragOver(e) {
3214
- if (modifyMutex || !dragHasFiles(e)) return;
4001
+ if (rwaMode !== 'edit' || activeView || modifyMutex || !dragHasFiles(e)) return;
3215
4002
  e.preventDefault();
3216
4003
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
3217
4004
  clearDropMark();
@@ -3226,6 +4013,7 @@ function handleMountDragLeave(e) {
3226
4013
  if (!m || !e.relatedTarget || !m.contains(e.relatedTarget)) clearDropMark();
3227
4014
  }
3228
4015
  function handleMountDrop(e) {
4016
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return;
3229
4017
  if (!dragHasFiles(e)) return;
3230
4018
  e.preventDefault();
3231
4019
  const target = findImageDropTarget(e);
@@ -3240,7 +4028,7 @@ function handleMountDrop(e) {
3240
4028
 
3241
4029
  // ── paste (a screenshot is the #1 case) ──
3242
4030
  function handleDocumentImagePaste(e) {
3243
- if (activeView) return;
4031
+ if (rwaMode !== 'edit' || activeView) return;
3244
4032
  if (typeof inlineEdit !== 'undefined' && inlineEdit) return; // hand-edit owns its paste
3245
4033
  const files = Array.from((e.clipboardData && e.clipboardData.files) || []).filter(f => /^image\//.test(f.type || ''));
3246
4034
  if (files.length === 0) return;
@@ -3256,6 +4044,7 @@ document.addEventListener('paste', handleDocumentImagePaste);
3256
4044
 
3257
4045
  // ── /image picker ──
3258
4046
  function openImagePicker() {
4047
+ if (rwaMode !== 'edit' || activeView) return;
3259
4048
  const input = document.createElement('input');
3260
4049
  input.type = 'file';
3261
4050
  input.accept = 'image/*';
@@ -3346,7 +4135,7 @@ function ensureImgChip() {
3346
4135
  return chip;
3347
4136
  }
3348
4137
  function handleMountImgOver(e) {
3349
- if (activeView || modifyMutex) return;
4138
+ if (rwaMode !== 'edit' || activeView || modifyMutex) return;
3350
4139
  const img = e.target instanceof Element ? e.target.closest('img') : null;
3351
4140
  const m = document.getElementById('rwa-doc-mount');
3352
4141
  if (!img || !m || !m.contains(img)) return;
@@ -4206,6 +4995,7 @@ HARD RULES: colors are hex strings only (e.g. "#c0392b"); fonts are ONE of the f
4206
4995
  const setP = document.getElementById('rwa-set-panel'); if (setP) setP.classList.remove('open');
4207
4996
  const infoP = document.getElementById('rwa-info-panel'); if (infoP) infoP.classList.remove('open');
4208
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');
4209
4999
  const active = currentSkinName();
4210
5000
  panel.innerHTML =
4211
5001
  '<div class="rwa-skin-hd"><span>Skins</span><span>' + (active || '—') + '</span></div>'
@@ -5588,9 +6378,16 @@ function runtimeSetView(name) {
5588
6378
  if (!spec || spec.name !== name) throw new Error('no registered view named ' + name);
5589
6379
  validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
5590
6380
  releaseAnchor();
6381
+ if (rwaMode !== 'document') {
6382
+ hideEditTransients();
6383
+ closeRuntimePanels();
6384
+ rwaMode = 'document';
6385
+ emitRuntimeEvent('mode', { mode: rwaMode });
6386
+ }
5591
6387
  activeView = spec;
5592
6388
  sessionStorage.setItem(rwaViewKey(), name);
5593
6389
  }
6390
+ if (typeof syncModeChrome === 'function') syncModeChrome();
5594
6391
  if (typeof syncViewChrome === 'function') syncViewChrome();
5595
6392
  getDoc().then(d => renderDoc(canonLF(d)));
5596
6393
  }
@@ -7241,7 +8038,11 @@ async function commit() {
7241
8038
  document.addEventListener('keydown', e => {
7242
8039
  const mod = navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey;
7243
8040
  if (!mod) return;
7244
- 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
+ }
7245
8046
  else if (e.key === 'z') { e.preventDefault(); undo(); }
7246
8047
  else if (e.key === 's') { e.preventDefault(); commit(); }
7247
8048
  });
@@ -7326,11 +8127,16 @@ document.addEventListener('keydown', e => {
7326
8127
  del: runtimeFsDel,
7327
8128
  list: runtimeFsList,
7328
8129
  },
8130
+ bus: {
8131
+ publish: runtimeBusPublish,
8132
+ subscribe: runtimeBusSubscribe,
8133
+ },
7329
8134
  modify: runtimeModify,
7330
8135
  commit: runtimeCommit,
7331
8136
  undo: runtimeUndo,
7332
8137
  applyEnvelope: runtimeApplyEnvelope,
7333
8138
  on: runtimeOn,
8139
+ setMode: runtimeSetMode,
7334
8140
  provide: runtimeProvide, // spec §5.10 — register a view provider
7335
8141
  setView: runtimeSetView, // spec §5.10 — activate/deactivate a render mode
7336
8142
  describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
@@ -7351,6 +8157,12 @@ document.addEventListener('keydown', e => {
7351
8157
  enumerable: true,
7352
8158
  configurable: false,
7353
8159
  });
8160
+ Object.defineProperty(window.runtime, 'mode', {
8161
+ get: () => rwaMode,
8162
+ enumerable: true,
8163
+ configurable: false,
8164
+ });
8165
+ startWorkspacePresence();
7354
8166
  // §5.10: the presentation render mode ships ONLY for presentation
7355
8167
  // containers. For every other kind this block is skipped entirely —
7356
8168
  // activeView stays null, no provider is registered, no chrome is built,