rewritable 0.9.0 → 0.11.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.
@@ -41,20 +41,26 @@
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
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);}
48
44
  .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;}
49
45
  .rwa-st-btn:hover{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-50);}
50
46
  .rwa-st-btn.dirty{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-100);}
51
47
  .rwa-st-btn.pri{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
52
48
  .rwa-st-btn.pri:hover{background:var(--gray-700);border-color:var(--gray-700);color:var(--white);}
49
+ #rwa-st-modeseg{display:inline-flex;background:var(--gray-100);border:1px solid var(--gray-200);border-radius:8px;padding:2px;gap:2px;}
50
+ .rwa-seg{background:transparent;border:0;color:var(--gray-500);font:600 11px var(--font-ui);padding:5px 12px;border-radius:6px;cursor:pointer;letter-spacing:.2px;transition:color .12s,background .12s;}
51
+ .rwa-seg:hover{color:var(--gray-900);}
52
+ .rwa-seg.on{background:var(--white);color:var(--gray-900);box-shadow:0 1px 3px rgba(0,0,0,0.12);}
53
53
  .rwa-st-btn.run{color:var(--gray-700);border-color:var(--gray-300);background:var(--gray-50);}
54
54
  .rwa-st-btn.err{color:var(--red);border-color:var(--red);background:var(--white);}
55
55
  .rwa-st-btn.ok{color:var(--green);border-color:var(--green);background:var(--white);}
56
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);}
57
57
  #rwa-set-panel.open{display:block;}
58
+ #rwa-st-menu-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:6px;display:flex;flex-direction:column;gap:2px;min-width:172px;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);}
59
+ #rwa-st-menu-panel[hidden]{display:none;}
60
+ .rwa-st-menu-item{display:flex;align-items:center;gap:10px;background:transparent;border:0;border-radius:6px;padding:8px 10px;font:500 13px var(--font-ui);color:var(--gray-800);cursor:pointer;text-align:left;text-transform:none;letter-spacing:0;}
61
+ .rwa-st-menu-item:hover{background:var(--gray-100);color:var(--gray-900);}
62
+ .rwa-st-menu-item.on{background:var(--gray-100);color:var(--gray-900);}
63
+ .rwa-st-menu-ic{font-size:14px;width:16px;text-align:center;color:var(--gray-500);}
58
64
  .rwa-set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px;}
59
65
  .rwa-set-row:last-child{margin-bottom:0;}
60
66
  .rwa-set-row label{font-family:var(--font-mono);font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-500);}
@@ -190,21 +196,38 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
190
196
  #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);}
191
197
  #rwa-img-chip button:hover{background:var(--gray-100);}
192
198
  #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);}
199
+ /* The bubble floats over the line above the selection. It is pointer-transparent by default
200
+ so a press meant to start a NEW selection passes THROUGH it to the text (otherwise the
201
+ press lands on the bar and its buttons' mousedown-preventDefault swallows the selection —
202
+ the "bubble appears every second selection" bug). It "arms" (becomes clickable) only after
203
+ the pointer deliberately dwells over it (see maybeArmBar). */
204
+ #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);pointer-events:none;}
205
+ #rwa-selection-bar.armed{pointer-events:auto;}
194
206
  #rwa-selection-bar[hidden]{display:none!important;}
195
207
  #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
208
  #rwa-selection-bar button:hover{background:var(--gray-100);color:var(--gray-900);}
197
209
  #rwa-selection-bar button.pri{background:var(--gray-900);color:var(--white);}
210
+ #rwa-selection-bar button.mono{font-family:var(--font-mono);font-size:11px;}
211
+ #rwa-selection-bar button.on{background:var(--gray-900);color:var(--white);}
212
+ #rwa-selection-bar .rwa-sel-sep{width:1px;align-self:stretch;margin:2px 1px;background:var(--gray-200);}
213
+ #rwa-selection-bar button svg{display:block;}
214
+ #rwa-selection-block,#rwa-selection-size{height:28px;border:1px solid var(--gray-200);border-radius:7px;background:var(--white);color:var(--gray-800);font:600 12px var(--font-ui);padding:0 4px;cursor:pointer;}
215
+ #rwa-selection-block:hover,#rwa-selection-size:hover{background:var(--gray-100);}
216
+ #rwa-selection-cmdwrap{display:flex;align-items:center;gap:4px;}
217
+ #rwa-selection-cmdwrap[hidden]{display:none;}
198
218
  #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);}
219
+ #rwa-selection-cmd,#rwa-selection-link-input{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);}
220
+ #rwa-selection-cmd:focus,#rwa-selection-link-input:focus{border-color:var(--gray-400);}
221
+ #rwa-selection-link-input{width:160px;}
222
+ #rwa-selection-bar input[hidden]{display:none!important;}
223
+ #rwa-selection-bar .rwa-color-a{border-bottom:3px solid #dc2626;line-height:1;padding-bottom:1px;font-weight:700;}
224
+ #rwa-selection-color-pop{position:absolute;top:40px;left:8px;display:flex;gap:5px;padding:7px;background:var(--white);border:1px solid var(--gray-200);border-radius:9px;box-shadow:0 8px 24px rgba(0,0,0,0.12);z-index:71;}
225
+ #rwa-selection-color-pop[hidden]{display:none;}
226
+ #rwa-selection-color-pop .rwa-sw{width:18px;height:18px;border:1px solid rgba(0,0,0,0.14);border-radius:50%;cursor:pointer;padding:0;transition:transform .1s;}
227
+ #rwa-selection-color-pop .rwa-sw:hover{transform:scale(1.15);}
201
228
  body:not([data-rwa-mode="edit"]) #rwa-lens,
202
229
  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
230
  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
231
  body:not([data-rwa-mode="edit"]) #rwa-img-chip,
209
232
  body:not([data-rwa-mode="edit"]) #rwa-selection-bar{display:none!important;}
210
233
  body:not([data-rwa-mode="document"]) #rwa-view-toggle,
@@ -225,11 +248,13 @@ body:not([data-rwa-mode="document"]) #rwa-view-chrome{display:none!important;}
225
248
  .rwa-editable-leaf:hover{background:rgba(59,130,246,0.045);box-shadow:0 0 0 3px rgba(59,130,246,0.08);}
226
249
  .rwa-editable-leaf:focus-visible{outline:2px solid rgba(59,130,246,0.5);outline-offset:2px;}
227
250
  .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);}
228
- /* Inline prompt mode — the block's text starts with "/" (addressing the model,
229
- not writing content). Tint + inset accent bar only: no border/padding so the
230
- per-keystroke toggle never shifts layout (caret stays put), and no ::before
231
- glyph (pseudo-elements on contenteditable cause Chromium caret jumps). */
251
+ /* Inline prompt mode — a "/" at a word boundary addresses the model. ONLY the "/command" suffix
252
+ turns the accent colour (the data-rwa-cmd-text span), so the content before it stays normal
253
+ (Notion-style); the block also gets a faint tint + inset accent bar as a secondary cue. Colour/
254
+ tint/shadow only — no border/padding (would shift layout/move the caret) and no ::before glyph
255
+ (Chromium caret jumps). */
232
256
  [data-rwa-cmd="on"]{background:rgba(59,130,246,0.06);box-shadow:inset 2px 0 0 var(--blue);border-radius:4px;}
257
+ [data-rwa-cmd-text]{color:var(--blue);}
233
258
  .rwa-locked{position:relative;background:rgba(239,68,68,0.04);border-left:3px solid var(--red);padding-left:8px;}
234
259
  .rwa-locked::before{content:'\1F512';position:absolute;top:4px;right:8px;font-size:12px;opacity:.6;}
235
260
  /* === DOCUMENT-PRODUCT DEFAULT: baseline content typography === */
@@ -1007,6 +1032,8 @@ function closeRuntimePanels() {
1007
1032
  }
1008
1033
  const hist = document.getElementById('rwa-lens-hist-panel');
1009
1034
  if (hist) hist.hidden = true;
1035
+ const menu = document.getElementById('rwa-st-menu-panel');
1036
+ if (menu && !menu.hidden) { menu.hidden = true; const mb = document.getElementById('rwa-st-menu'); if (mb) mb.setAttribute('aria-expanded', 'false'); }
1010
1037
  }
1011
1038
 
1012
1039
  function hideEditTransients() {
@@ -1024,7 +1051,9 @@ function hideEditTransients() {
1024
1051
  function syncModeChrome() {
1025
1052
  if (document.body) document.body.dataset.rwaMode = rwaMode;
1026
1053
  document.querySelectorAll('[data-rwa-mode-target]').forEach(btn => {
1027
- btn.classList.toggle('on', btn.dataset.rwaModeTarget === rwaMode);
1054
+ const on = btn.dataset.rwaModeTarget === rwaMode; // View/Edit segments + Skills/Activity menu items
1055
+ btn.classList.toggle('on', on);
1056
+ if (btn.hasAttribute('aria-pressed')) btn.setAttribute('aria-pressed', String(on));
1028
1057
  });
1029
1058
  const panel = document.getElementById('rwa-mode-panel');
1030
1059
  if (!panel) return;
@@ -1469,18 +1498,21 @@ function buildFile(doc) {
1469
1498
  function buildUI() {
1470
1499
  document.getElementById('rwa-runtime').innerHTML = `
1471
1500
  <div id="rwa-set">
1472
- <div id="rwa-mode-tabs" role="tablist" aria-label="runtime mode">
1473
- <button class="rwa-mode-tab" type="button" data-rwa-mode-target="document">Document</button>
1474
- <button class="rwa-mode-tab" type="button" data-rwa-mode-target="edit">Edit</button>
1475
- <button class="rwa-mode-tab" type="button" data-rwa-mode-target="skills">Skills</button>
1476
- <button class="rwa-mode-tab" type="button" data-rwa-mode-target="actions">Actions</button>
1477
- </div>
1478
1501
  <button class="rwa-st-btn" id="rwa-st-status">● ready</button>
1479
- <button class="rwa-st-btn" id="rwa-st-info" title="what is this?" aria-label="what is this?">ⓘ</button>
1480
- <button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
1481
- <button class="rwa-st-btn" id="rwa-st-skin" title="skinspick a look" aria-label="skins">✦</button>
1482
- <button class="rwa-st-btn" id="rwa-st-share" title="share at a link" aria-label="share at a link">↗</button>
1483
- <button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
1502
+ <div id="rwa-st-modeseg" role="group" aria-label="view or edit mode">
1503
+ <button class="rwa-seg" type="button" id="rwa-st-view" data-rwa-mode-target="document" aria-pressed="true" title="Reading view">View</button>
1504
+ <button class="rwa-seg" type="button" id="rwa-st-edit" data-rwa-mode-target="edit" aria-pressed="false" title="Editformat and edit in place">Edit</button>
1505
+ </div>
1506
+ <button class="rwa-st-btn" id="rwa-st-menu" title="Menu" aria-label="menu" aria-haspopup="true" aria-expanded="false">⋯</button>
1507
+ <button class="rwa-st-btn pri" id="rwa-st-commit" title="Save (⌘S)" hidden>⌘S</button>
1508
+ </div>
1509
+ <div id="rwa-st-menu-panel" hidden role="menu" aria-label="runtime menu">
1510
+ <button class="rwa-st-menu-item" id="rwa-st-info" role="menuitem" title="what is this?"><span class="rwa-st-menu-ic" aria-hidden="true">ⓘ</span>What is this?</button>
1511
+ <button class="rwa-st-menu-item" id="rwa-st-cog" role="menuitem"><span class="rwa-st-menu-ic" aria-hidden="true">⚙</span>Settings</button>
1512
+ <button class="rwa-st-menu-item" id="rwa-st-skin" role="menuitem" title="skins — pick a look"><span class="rwa-st-menu-ic" aria-hidden="true">✦</span>Skins</button>
1513
+ <button class="rwa-st-menu-item" id="rwa-st-share" role="menuitem" title="share at a link"><span class="rwa-st-menu-ic" aria-hidden="true">↗</span>Share…</button>
1514
+ <button class="rwa-st-menu-item" id="rwa-st-skills" role="menuitem" data-rwa-mode-target="skills" title="manage installed skills" hidden><span class="rwa-st-menu-ic" aria-hidden="true">◈</span>Skills</button>
1515
+ <button class="rwa-st-menu-item" id="rwa-st-activity" role="menuitem" data-rwa-mode-target="actions" title="recent edits, history and live affordances"><span class="rwa-st-menu-ic" aria-hidden="true">↻</span>Activity</button>
1484
1516
  </div>
1485
1517
  <div id="rwa-set-panel">
1486
1518
  <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>
@@ -1490,6 +1522,7 @@ function buildUI() {
1490
1522
  <div class="rwa-set-row" id="rwa-set-row-base-url" style="display:none;"><label>Base URL</label><div class="rwa-set-base-url-line"><input type="text" id="rwa-base-url" autocomplete="off" spellcheck="false"><button type="button" id="rwa-base-url-test">Test</button></div><div id="rwa-base-url-result" class="rwa-set-hint"></div></div>
1491
1523
  <div class="rwa-set-row" id="rwa-set-row-model"><label>Model</label><input type="text" id="rwa-model" autocomplete="off" list="rwa-model-options"><datalist id="rwa-model-options"></datalist></div>
1492
1524
  <div class="rwa-set-row" id="rwa-set-row-hint" style="display:none;"><div id="rwa-backend-hint" class="rwa-set-hint"></div></div>
1525
+ <div class="rwa-set-row" id="rwa-set-row-vault-kdf" style="display:none;"><label>Vault KDF</label><div><button type="button" id="rwa-vault-upgrade">Upgrade to Argon2id</button><div id="rwa-vault-kdf-result" class="rwa-set-hint"></div></div></div>
1493
1526
  </div>
1494
1527
  <div id="rwa-info-panel"></div>
1495
1528
  <div id="rwa-skin-panel"></div>
@@ -1517,10 +1550,48 @@ function buildUI() {
1517
1550
  <div id="rwa-lens-hint"></div>
1518
1551
  </div>
1519
1552
  <div id="rwa-selection-bar" hidden data-rwa-no-inline-edit>
1520
- <button type="button" id="rwa-selection-bold" title="Bold selection" aria-label="bold selection"><strong>B</strong></button>
1521
- <input id="rwa-selection-cmd" placeholder="make it bold" autocomplete="off" spellcheck="false">
1522
- <button type="button" class="pri" id="rwa-selection-run">Run</button>
1523
- <button type="button" id="rwa-selection-voice">Mic</button>
1553
+ <select id="rwa-selection-block" title="Block type" aria-label="block type">
1554
+ <option value="paragraph">¶</option>
1555
+ <option value="h1">H1</option>
1556
+ <option value="h2">H2</option>
1557
+ <option value="h3">H3</option>
1558
+ </select>
1559
+ <select id="rwa-selection-size" title="Text size" aria-label="text size">
1560
+ <option value="" disabled selected>Size</option>
1561
+ <option value="small">Small</option>
1562
+ <option value="normal">Normal</option>
1563
+ <option value="large">Large</option>
1564
+ <option value="huge">Huge</option>
1565
+ </select>
1566
+ <span class="rwa-sel-sep" aria-hidden="true"></span>
1567
+ <button type="button" id="rwa-selection-bold" title="Bold" aria-label="bold selection"><strong>B</strong></button>
1568
+ <button type="button" id="rwa-selection-italic" title="Italic" aria-label="italic selection"><em>I</em></button>
1569
+ <button type="button" id="rwa-selection-underline" title="Underline" aria-label="underline selection"><span style="text-decoration:underline">U</span></button>
1570
+ <button type="button" id="rwa-selection-strike" title="Strikethrough" aria-label="strikethrough selection"><span style="text-decoration:line-through">S</span></button>
1571
+ <button type="button" id="rwa-selection-code" title="Inline code" aria-label="inline code selection" class="mono">&lt;&gt;</button>
1572
+ <button type="button" id="rwa-selection-link" title="Link" aria-label="link selection"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M6.5 9.5l3-3"/><path d="M7 4.5l1-1a2.5 2.5 0 0 1 3.5 3.5l-1 1"/><path d="M9 11.5l-1 1a2.5 2.5 0 0 1-3.5-3.5l1-1"/></svg></button>
1573
+ <input id="rwa-selection-link-input" hidden type="text" placeholder="https://…" autocomplete="off" spellcheck="false">
1574
+ <button type="button" id="rwa-selection-color" title="Text color" aria-label="text color"><span class="rwa-color-a">A</span></button>
1575
+ <div id="rwa-selection-color-pop" hidden role="menu" aria-label="text color">
1576
+ <button class="rwa-sw" type="button" data-color="#111827" title="Ink" aria-label="ink" style="background:#111827"></button>
1577
+ <button class="rwa-sw" type="button" data-color="#dc2626" title="Red" aria-label="red" style="background:#dc2626"></button>
1578
+ <button class="rwa-sw" type="button" data-color="#d97706" title="Amber" aria-label="amber" style="background:#d97706"></button>
1579
+ <button class="rwa-sw" type="button" data-color="#16a34a" title="Green" aria-label="green" style="background:#16a34a"></button>
1580
+ <button class="rwa-sw" type="button" data-color="#2563eb" title="Blue" aria-label="blue" style="background:#2563eb"></button>
1581
+ <button class="rwa-sw" type="button" data-color="#7c3aed" title="Violet" aria-label="violet" style="background:#7c3aed"></button>
1582
+ <button class="rwa-sw" type="button" data-color="#6b7280" title="Gray" aria-label="gray" style="background:#6b7280"></button>
1583
+ </div>
1584
+ <span class="rwa-sel-sep" aria-hidden="true"></span>
1585
+ <button type="button" id="rwa-selection-align-left" title="Align left" aria-label="align left"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="2" y1="8" x2="10" y2="8"/><line x1="2" y1="12" x2="13" y2="12"/></svg></button>
1586
+ <button type="button" id="rwa-selection-align-center" title="Align center" aria-label="align center"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="3" y1="4" x2="13" y2="4"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="4" y1="12" x2="12" y2="12"/></svg></button>
1587
+ <button type="button" id="rwa-selection-align-right" title="Align right" aria-label="align right"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="3" y1="12" x2="14" y2="12"/></svg></button>
1588
+ <span class="rwa-sel-sep" aria-hidden="true"></span>
1589
+ <button type="button" id="rwa-selection-more" title="Command / voice" aria-label="command and voice" aria-haspopup="true" aria-expanded="false">⋯</button>
1590
+ <span id="rwa-selection-cmdwrap" hidden>
1591
+ <input id="rwa-selection-cmd" placeholder="make it bold…" autocomplete="off" spellcheck="false">
1592
+ <button type="button" class="pri" id="rwa-selection-run">Run</button>
1593
+ <button type="button" id="rwa-selection-voice" title="Dictate a command" aria-label="dictate a command">Mic</button>
1594
+ </span>
1524
1595
  </div>
1525
1596
  <div id="rwa-lens-hist-panel" hidden></div>`;
1526
1597
 
@@ -1529,7 +1600,10 @@ function buildUI() {
1529
1600
  m.value = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
1530
1601
  k.oninput = e => sessionStorage.setItem(RWA.K_API, e.target.value.trim());
1531
1602
  m.oninput = e => sessionStorage.setItem(RWA.K_MODEL, e.target.value.trim() || RWA.MODEL);
1532
- attachModeTabs();
1603
+ attachModeTabs(); // wires the View/Edit segments + the Activity/Skills menu items (data-rwa-mode-target)
1604
+ // Skills is a skill-host-only menu item.
1605
+ const skillsItem = document.getElementById('rwa-st-skills'); // PRODUCT_KIND is available now; the doc (#rwa-skills) may not be rendered yet
1606
+ if (skillsItem && PRODUCT_KIND === 'skill-host') skillsItem.hidden = false;
1533
1607
 
1534
1608
  // Bridge SESSION backend config: the bearer token the bridge requires on every
1535
1609
  // /session/* call, and a server-side working dir for the claude session.
@@ -1689,6 +1763,25 @@ function buildUI() {
1689
1763
  }
1690
1764
  };
1691
1765
 
1766
+ // Unobtrusive chrome: the secondary actions (info/settings/skins/share) live behind a
1767
+ // single ⋯ menu so the persistent row stays quiet. Opening it closes any panel; choosing
1768
+ // an item runs the item's own handler (below) then closes the menu; an outside click
1769
+ // dismisses it. The item ids/handlers are unchanged — they're just relocated.
1770
+ const stMenuBtn = document.getElementById('rwa-st-menu');
1771
+ const stMenuPanel = document.getElementById('rwa-st-menu-panel');
1772
+ if (stMenuBtn && stMenuPanel) {
1773
+ stMenuBtn.onclick = (e) => {
1774
+ e.stopPropagation();
1775
+ const willOpen = stMenuPanel.hidden;
1776
+ closeRuntimePanels();
1777
+ stMenuPanel.hidden = !willOpen;
1778
+ stMenuBtn.setAttribute('aria-expanded', String(willOpen));
1779
+ };
1780
+ stMenuPanel.addEventListener('click', () => { stMenuPanel.hidden = true; stMenuBtn.setAttribute('aria-expanded', 'false'); });
1781
+ document.addEventListener('click', (e) => {
1782
+ if (!stMenuPanel.hidden && !stMenuPanel.contains(e.target) && e.target !== stMenuBtn) { stMenuPanel.hidden = true; stMenuBtn.setAttribute('aria-expanded', 'false'); }
1783
+ });
1784
+ }
1692
1785
  document.getElementById('rwa-st-cog').onclick = () => {
1693
1786
  document.getElementById('rwa-info-panel').classList.remove('open'); // panels are mutually exclusive
1694
1787
  document.getElementById('rwa-skin-panel').classList.remove('open');
@@ -1696,6 +1789,21 @@ function buildUI() {
1696
1789
  document.getElementById('rwa-mode-panel').classList.remove('open');
1697
1790
  document.getElementById('rwa-set-panel').classList.toggle('open');
1698
1791
  };
1792
+ // Vault KDF upgrade (I9 §13) — skill-host only. Argon2id migration needs the passphrase
1793
+ // (to derive the old key), so prompt for it; the ~1–2s derive runs in a blob: Worker.
1794
+ const vkRow = document.getElementById('rwa-set-row-vault-kdf');
1795
+ if (vkRow && PRODUCT_KIND === 'skill-host') {
1796
+ vkRow.style.display = '';
1797
+ const vkBtn = document.getElementById('rwa-vault-upgrade'), vkRes = document.getElementById('rwa-vault-kdf-result');
1798
+ vkBtn.onclick = async () => {
1799
+ const pass = prompt('Vault passphrase (to upgrade the vault KDF to Argon2id):');
1800
+ if (!pass) return;
1801
+ vkBtn.disabled = true; vkRes.textContent = 'Deriving key (Argon2id, ~1–2s)…';
1802
+ try { await runtimeVaultUnlock(pass, { targetKdfVersion: 1 }); vkRes.textContent = 'Vault KDF upgraded to Argon2id.'; }
1803
+ catch (e) { vkRes.textContent = 'Upgrade failed: ' + ((e && e.message) || e); }
1804
+ finally { vkBtn.disabled = false; }
1805
+ };
1806
+ }
1699
1807
  // ⓘ "what is this?" — render the rwa-identity/1 bundle as prose on open so it
1700
1808
  // reflects current state every time (title/blocks/locked regions change as the
1701
1809
  // doc is edited). Mutually exclusive with the settings panel.
@@ -1834,7 +1942,75 @@ function buildUI() {
1834
1942
  }
1835
1943
 
1836
1944
  const selCmd = document.getElementById('rwa-selection-cmd');
1837
- document.getElementById('rwa-selection-bold').addEventListener('click', () => runSelectionCommand('make it bold', { actor: 'user:selection-command' }).catch(() => {}));
1945
+ // Deterministic formatting buttons the same local rwa-edit/1 wrap path (no model).
1946
+ // mousedown preventDefault keeps the text selection alive through the click (a real
1947
+ // browser would otherwise collapse it when focus moves to the button).
1948
+ const wireFormatBtn = (id, command) => {
1949
+ const b = document.getElementById(id);
1950
+ if (!b) return;
1951
+ b.addEventListener('mousedown', (e) => e.preventDefault());
1952
+ b.addEventListener('click', () => runSelectionCommand(command, { actor: 'user:selection-command' }).catch(() => {}));
1953
+ };
1954
+ wireFormatBtn('rwa-selection-bold', 'make it bold');
1955
+ wireFormatBtn('rwa-selection-italic', 'italic');
1956
+ wireFormatBtn('rwa-selection-underline', 'underline');
1957
+ wireFormatBtn('rwa-selection-strike', 'strikethrough');
1958
+ wireFormatBtn('rwa-selection-code', 'inline code');
1959
+ wireFormatBtn('rwa-selection-align-left', 'align left');
1960
+ wireFormatBtn('rwa-selection-align-center', 'align center');
1961
+ wireFormatBtn('rwa-selection-align-right', 'align right');
1962
+ // Link control: the button reveals a URL field (prefilled if the selection is already a
1963
+ // link); Enter applies the sanitized href, Esc cancels. mousedown preventDefault keeps
1964
+ // the text selection alive; the cached selectionCommandState backs the apply.
1965
+ const linkBtn = document.getElementById('rwa-selection-link');
1966
+ const linkInput = document.getElementById('rwa-selection-link-input');
1967
+ if (linkBtn && linkInput) {
1968
+ linkBtn.addEventListener('mousedown', (e) => e.preventDefault());
1969
+ linkBtn.addEventListener('click', () => {
1970
+ if (!linkInput.hidden) { linkInput.hidden = true; return; }
1971
+ const t = resolveSelectionCommandTarget() || selectionCommandState;
1972
+ let pre = '';
1973
+ try { const n = t && t.range && t.range.startContainer; const el = n && (n.nodeType === 1 ? n : n.parentElement); const a = el && el.closest && el.closest('a[href]'); if (a) pre = a.getAttribute('href') || ''; } catch (_) {}
1974
+ linkInput.value = pre; linkInput.hidden = false; linkInput.focus();
1975
+ });
1976
+ linkInput.addEventListener('keydown', (e) => {
1977
+ if (e.key === 'Enter') { e.preventDefault(); const url = linkInput.value || ''; linkInput.hidden = true; runSelectionLink(url, { actor: 'user:selection-command' }).catch(() => {}); }
1978
+ else if (e.key === 'Escape') { e.preventDefault(); linkInput.hidden = true; }
1979
+ });
1980
+ }
1981
+ // Color control: the button reveals a fixed swatch palette; a swatch applies its hex.
1982
+ const colorBtn = document.getElementById('rwa-selection-color');
1983
+ const colorPop = document.getElementById('rwa-selection-color-pop');
1984
+ if (colorBtn && colorPop) {
1985
+ colorBtn.addEventListener('mousedown', (e) => e.preventDefault());
1986
+ colorBtn.addEventListener('click', () => { colorPop.hidden = !colorPop.hidden; });
1987
+ colorPop.addEventListener('mousedown', (e) => e.preventDefault());
1988
+ colorPop.addEventListener('click', (e) => {
1989
+ const sw = e.target.closest('[data-color]');
1990
+ if (!sw) return;
1991
+ colorPop.hidden = true;
1992
+ runSelectionColor(sw.getAttribute('data-color'), { actor: 'user:selection-command' }).catch(() => {});
1993
+ });
1994
+ }
1995
+ // ⋯ reveals the typed-command + voice cluster (tucked away so the bubble stays compact —
1996
+ // all formatting is one click via the buttons above; this is the command/voice fallback).
1997
+ const moreBtn = document.getElementById('rwa-selection-more');
1998
+ const cmdWrap = document.getElementById('rwa-selection-cmdwrap');
1999
+ if (moreBtn && cmdWrap) {
2000
+ moreBtn.addEventListener('click', () => {
2001
+ cmdWrap.hidden = !cmdWrap.hidden;
2002
+ moreBtn.setAttribute('aria-expanded', String(!cmdWrap.hidden));
2003
+ moreBtn.classList.toggle('on', !cmdWrap.hidden);
2004
+ if (!cmdWrap.hidden && selCmd) selCmd.focus();
2005
+ });
2006
+ }
2007
+ // The block-type <select> can't preventDefault its mousedown (that would block the
2008
+ // dropdown); it relies on the cached selectionCommandState, which survives because the
2009
+ // bar holds focus while the select is open.
2010
+ const blockSel = document.getElementById('rwa-selection-block');
2011
+ if (blockSel) blockSel.addEventListener('change', () => runSelectionCommand(blockSel.value, { actor: 'user:selection-command' }).catch(() => {}));
2012
+ const sizeSel = document.getElementById('rwa-selection-size');
2013
+ if (sizeSel) sizeSel.addEventListener('change', () => { const v = sizeSel.value; sizeSel.value = ''; if (v) runSelectionSize(v, { actor: 'user:selection-command' }).catch(() => {}); });
1838
2014
  document.getElementById('rwa-selection-run').addEventListener('click', () => {
1839
2015
  runSelectionCommand(selCmd.value || '', { actor: 'user:selection-command' }).catch(() => {});
1840
2016
  });
@@ -1850,6 +2026,9 @@ function buildUI() {
1850
2026
  document.addEventListener('selectionchange', scheduleSelectionCommandRefresh);
1851
2027
  document.addEventListener('mouseup', scheduleSelectionCommandRefresh);
1852
2028
  document.addEventListener('keyup', scheduleSelectionCommandRefresh);
2029
+ // Track the pointer to arm the bubble on a deliberate dwell (so it stays pointer-transparent
2030
+ // to a press that's starting a selection over the text it overlaps).
2031
+ document.addEventListener('mousemove', (e) => { _lastPointer = { x: e.clientX, y: e.clientY }; maybeArmSelectionBar(e.clientX, e.clientY); });
1853
2032
 
1854
2033
  // rwa-lens/1: Esc releases the anchor when one is held. Listener is on
1855
2034
  // `window` so Esc anywhere works (including with focus outside the lens
@@ -1862,7 +2041,7 @@ function buildUI() {
1862
2041
  }
1863
2042
  const setStatus = (cls, msg) => { const e = document.getElementById('rwa-st-status'); if (e) { e.className = 'rwa-st-btn ' + (cls || ''); e.textContent = msg; } };
1864
2043
  const setPalSt = (cls, msg) => { const e = document.getElementById('rwa-pal-st'); if (e) { e.className = cls || ''; e.textContent = msg; } };
1865
- const setDirty = d => { const e = document.getElementById('rwa-st-commit'); if (e) e.classList.toggle('dirty', d); };
2044
+ const setDirty = d => { const e = document.getElementById('rwa-st-commit'); if (e) { e.classList.toggle('dirty', d); e.hidden = !d; } }; // Save is shown only when there are unsaved changes
1866
2045
 
1867
2046
  // rwa-lens/1 progress chip: inline status above the textarea during in-flight
1868
2047
  // agent runs. State drives both visibility and color (CSS keyed off
@@ -2145,8 +2324,8 @@ async function renderActionsModePanel(panel) {
2145
2324
  ).join('') : '<div class="rwa-mode-empty">No installed skills.</div>';
2146
2325
  panel.innerHTML = [
2147
2326
  '<div class="rwa-mode-section">',
2148
- '<div class="rwa-mode-kicker">Actions</div>',
2149
- '<h4>Action center</h4>',
2327
+ '<div class="rwa-mode-kicker">Activity</div>',
2328
+ '<h4>Activity</h4>',
2150
2329
  '<div class="rwa-mode-actions">',
2151
2330
  '<button type="button" id="rwa-actions-undo">Undo</button>',
2152
2331
  '<button type="button" class="pri" id="rwa-actions-save">Save / Export</button>',
@@ -3123,9 +3302,18 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3123
3302
  // the i-th live anchorable corresponds to map[i].
3124
3303
  function handleMountClick(e) {
3125
3304
  if (rwaMode !== 'edit' || activeView) return;
3126
- // Pointer-down may already have opened a WYSIWYG inline edit. In that case
3127
- // the click belongs to caret placement, not to lens anchoring.
3305
+ // The working-block outline is owned solely by inline edit (enterInlineEdit→anchorTo). While
3306
+ // editing, this click path must NOT anchor a second writer here is exactly what raced the
3307
+ // outline onto the previous block. handleMountClick now only anchors NON-editable containers
3308
+ // (figure/pre/aside/table), which have no inline edit (design 2026-06-24-working-block).
3128
3309
  if (inlineEdit) return;
3310
+ // During a DIRTY block switch inlineEdit is briefly null (old block committed, new not yet
3311
+ // entered). Anchoring the clicked leaf here left a black outline with no edit ("the outline
3312
+ // comes back, the blue disappears"). The pending enter will paint it — skip this writer.
3313
+ if (_inlineSwitchPending) return;
3314
+ // A drag that produced a text selection is a FORMAT gesture — the floating toolbar owns it.
3315
+ const _sel = window.getSelection && window.getSelection();
3316
+ if (_sel && _sel.rangeCount > 0 && !_sel.isCollapsed) return;
3129
3317
  // Audit R3 (scoped): respect the per-kind click-to-anchor flag. When
3130
3318
  // false (e.g. workflow files), all clicks pass through without anchoring
3131
3319
  // — the lens stays in default state and every command runs against the
@@ -3243,8 +3431,13 @@ function anchorTo(entry) {
3243
3431
  x.addEventListener('click', releaseAnchor);
3244
3432
  badge.appendChild(x);
3245
3433
  }
3246
- // Visual highlight on the live block.
3247
- if (lensState._highlighted) lensState._highlighted.removeAttribute('data-rwa-anchored');
3434
+ // Visual highlight on the live block. Clear EVERY existing outline first — never trust the
3435
+ // single _highlighted ref: it goes stale across re-renders (the old node is detached, the
3436
+ // rebuilt one keeps no attribute), so removing only from it left the PREVIOUS block outlined
3437
+ // and just stacked a second outline on the new block. Querying the live DOM is the source of
3438
+ // truth. (This is why the outline "showed the previous block" while anchoring itself worked.)
3439
+ const _hlMount = document.getElementById('rwa-doc-mount');
3440
+ if (_hlMount) _hlMount.querySelectorAll('[data-rwa-anchored]').forEach(n => n.removeAttribute('data-rwa-anchored'));
3248
3441
  if (liveNode) {
3249
3442
  liveNode.setAttribute('data-rwa-anchored', '');
3250
3443
  lensState._highlighted = liveNode;
@@ -3271,10 +3464,11 @@ function releaseAnchor() {
3271
3464
  badge.hidden = true;
3272
3465
  badge.textContent = '';
3273
3466
  }
3274
- if (lensState._highlighted) {
3275
- lensState._highlighted.removeAttribute('data-rwa-anchored');
3276
- lensState._highlighted = null;
3277
- }
3467
+ // Clear EVERY outline (not just the possibly-stale _highlighted ref) so releasing always
3468
+ // leaves a clean DOM — no orphan outline can linger on a previously-anchored block.
3469
+ const _hlMount = document.getElementById('rwa-doc-mount');
3470
+ if (_hlMount) _hlMount.querySelectorAll('[data-rwa-anchored]').forEach(n => n.removeAttribute('data-rwa-anchored'));
3471
+ lensState._highlighted = null;
3278
3472
  lensState.anchor = null;
3279
3473
  const input = lens.querySelector('#rwa-lens-input');
3280
3474
  if (input) input.placeholder = LENS_PLACEHOLDER;
@@ -3283,6 +3477,7 @@ window.releaseAnchor = releaseAnchor;
3283
3477
  if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3284
3478
  window.__handleMountClick = handleMountClick;
3285
3479
  window.__liveNodeForEntry = liveNodeForEntry;
3480
+ window.commandStartIndex = commandStartIndex;
3286
3481
  }
3287
3482
 
3288
3483
  // ─── Inline manual edit (edit-surface: direct, no-LLM block editing) ──────
@@ -3298,6 +3493,7 @@ if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3298
3493
  // containers; FIGCAPTION is not independently anchorable (lives in FIGURE).
3299
3494
  const INLINE_EDITABLE = new Set(['P','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','TD']);
3300
3495
  let inlineEdit = null; // { el, entry } while a block is being hand-edited
3496
+ let _inlineSwitchPending = false; // true between exit-old and enter-new during a DIRTY block switch
3301
3497
  const INLINE_EDIT_BYPASS = 'button,input,textarea,select,option,summary,[contenteditable="true"],[data-rwa-no-inline-edit]';
3302
3498
 
3303
3499
  // Controlled serializer: turn an edited contenteditable node into a clean
@@ -3325,8 +3521,28 @@ window.serializeLeafSafe = serializeLeafSafe;
3325
3521
  let selectionCommandState = null; // { el, entry, text, occurrence, range }
3326
3522
  let selectionVoiceRecognizer = null;
3327
3523
 
3524
+ // The bubble is pointer-transparent (CSS) until "armed". Arming on a brief pointer dwell
3525
+ // distinguishes "moving onto the bar to click a control" (arm → clickable) from "pressing to
3526
+ // select text the bar happens to overlap" (no dwell → press passes through to the text). A
3527
+ // fresh/hidden bar is always disarmed, so a press right after it appears reaches the text.
3528
+ let _barArmTimer = null, _lastPointer = { x: -1, y: -1 };
3529
+ function disarmSelectionBar() {
3530
+ if (_barArmTimer) { clearTimeout(_barArmTimer); _barArmTimer = null; }
3531
+ const bar = document.getElementById('rwa-selection-bar');
3532
+ if (bar) bar.classList.remove('armed');
3533
+ }
3534
+ function maybeArmSelectionBar(x, y) {
3535
+ const bar = document.getElementById('rwa-selection-bar');
3536
+ if (!bar || bar.hidden) { disarmSelectionBar(); return; }
3537
+ let r; try { r = bar.getBoundingClientRect(); } catch (_) { return; }
3538
+ const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
3539
+ if (inside) {
3540
+ if (!bar.classList.contains('armed') && !_barArmTimer) _barArmTimer = setTimeout(() => { _barArmTimer = null; bar.classList.add('armed'); }, 130);
3541
+ } else { disarmSelectionBar(); }
3542
+ }
3328
3543
  function hideSelectionCommandBar() {
3329
3544
  selectionCommandState = null;
3545
+ disarmSelectionBar();
3330
3546
  const bar = document.getElementById('rwa-selection-bar');
3331
3547
  if (bar) {
3332
3548
  bar.hidden = true;
@@ -3421,7 +3637,16 @@ function parseSelectionCommand(raw) {
3421
3637
  if (!t) return null;
3422
3638
  if (/\b(bold|strong|emphasize strongly)\b/.test(t)) return { kind: 'wrap', tag: 'strong', label: 'bold' };
3423
3639
  if (/\b(italic|italics|emphasize)\b/.test(t)) return { kind: 'wrap', tag: 'em', label: 'italic' };
3640
+ if (/\b(underline|underlined)\b/.test(t)) return { kind: 'wrap', tag: 'u', label: 'underline' };
3641
+ if (/\b(strikethrough|strike through|strikethru|strike|crossed out|cross out)\b/.test(t)) return { kind: 'wrap', tag: 's', label: 'strikethrough' };
3424
3642
  if (/\b(code|monospace|inline code)\b/.test(t)) return { kind: 'wrap', tag: 'code', label: 'code' };
3643
+ if (/\b(h1|heading 1|title)\b/.test(t)) return { kind: 'retag', tag: 'h1', label: 'heading 1' };
3644
+ if (/\b(h2|heading 2|heading)\b/.test(t)) return { kind: 'retag', tag: 'h2', label: 'heading 2' };
3645
+ if (/\b(h3|heading 3|subheading|subhead)\b/.test(t)) return { kind: 'retag', tag: 'h3', label: 'heading 3' };
3646
+ if (/\b(paragraph|body text|normal text|plain text)\b/.test(t)) return { kind: 'retag', tag: 'p', label: 'paragraph' };
3647
+ if (/\b(align center|center|centre|centered|centred)\b/.test(t)) return { kind: 'align', align: 'center', label: 'center' };
3648
+ if (/\b(align right|right align|right)\b/.test(t)) return { kind: 'align', align: 'right', label: 'right' };
3649
+ if (/\b(align left|left align|left)\b/.test(t)) return { kind: 'align', align: 'left', label: 'left' };
3425
3650
  return null;
3426
3651
  }
3427
3652
 
@@ -3442,7 +3667,7 @@ async function applySelectionWrap(target, action, actor, instruction) {
3442
3667
  return { ok: false, reason: 'anchor_unresolved' };
3443
3668
  }
3444
3669
  const tag = action.tag;
3445
- const nextBlock = loc.block.slice(0, loc.start) + '<' + tag + '>' + loc.selectedSource + '</' + tag + '>' + loc.block.slice(loc.end);
3670
+ const nextBlock = loc.block.slice(0, loc.start) + '<' + tag + (action.attrs || '') + '>' + loc.selectedSource + '</' + tag + '>' + loc.block.slice(loc.end);
3446
3671
  const replace = a.replacePrefix + nextBlock + a.replaceSuffix;
3447
3672
  await runtimeApplyEnvelope(
3448
3673
  { version: 'rwa-edit/1', edits: [{ find: a.find, replace, reason: 'selection:' + action.label }] },
@@ -3453,6 +3678,63 @@ async function applySelectionWrap(target, action, actor, instruction) {
3453
3678
  return { ok: true };
3454
3679
  }
3455
3680
 
3681
+ // Split a leaf block's source into its open tag, attributes, inner HTML and any
3682
+ // trailing whitespace — so a retag/restyle preserves every attribute (data-rwa-id
3683
+ // included) and the surrounding bytes exactly.
3684
+ function blockOpenInner(block, tag) {
3685
+ const lt = tag.toLowerCase();
3686
+ const openEnd = block.indexOf('>');
3687
+ const closeMatch = new RegExp('(</' + lt + '\\s*>)(\\s*)$', 'i').exec(block);
3688
+ if (openEnd < 0 || !closeMatch) return null;
3689
+ return { openTag: block.slice(0, openEnd + 1), attrs: block.slice(1 + lt.length, openEnd), inner: block.slice(openEnd + 1, closeMatch.index), trailing: closeMatch[2] };
3690
+ }
3691
+ // Merge text-align into an open tag's style attribute (idempotent; keeps other props).
3692
+ function setOpenTagTextAlign(openTag, align) {
3693
+ const decl = 'text-align:' + align;
3694
+ const m = /\sstyle="([^"]*)"/i.exec(openTag);
3695
+ if (m) {
3696
+ let props = m[1].replace(/\s*text-align\s*:[^;]*;?/ig, '').trim().replace(/;\s*$/, '');
3697
+ props = props ? props + '; ' + decl : decl;
3698
+ return openTag.slice(0, m.index) + ' style="' + props + '"' + openTag.slice(m.index + m[0].length);
3699
+ }
3700
+ return openTag.slice(0, -1) + ' style="' + decl + '">';
3701
+ }
3702
+ // Block-level formatting (heading retag / alignment) — operates on the whole leaf the
3703
+ // selection sits in, committed through the same non-agent path as the inline wraps.
3704
+ async function applyBlockFormat(target, action, actor) {
3705
+ if (inlineEdit && serializeLeafSafe(inlineEdit.el) !== inlineEdit.original) {
3706
+ showAffordance('finish the current edit first');
3707
+ return { ok: false, reason: 'inline_edit_dirty' };
3708
+ }
3709
+ if (inlineEdit) exitInlineEdit();
3710
+ const block = currentDocCache.slice(target.entry.start, target.entry.end);
3711
+ const parts = blockOpenInner(block, target.entry.tag);
3712
+ if (!parts) { showAffordance('block could not be parsed'); return { ok: false, reason: 'block_unparsed' }; }
3713
+ let nextBlock;
3714
+ if (action.kind === 'retag') {
3715
+ if (!/^(p|h[1-6])$/.test(target.entry.tag.toLowerCase())) { showAffordance("can't change this block's type"); return { ok: false, reason: 'not_retaggable' }; }
3716
+ if (target.entry.tag.toLowerCase() === action.tag) { showAffordance('already ' + action.label); return { ok: true, noop: true }; }
3717
+ nextBlock = '<' + action.tag + parts.attrs + '>' + parts.inner + '</' + action.tag + '>' + parts.trailing;
3718
+ } else {
3719
+ nextBlock = setOpenTagTextAlign(parts.openTag, action.align) + block.slice(block.indexOf('>') + 1);
3720
+ }
3721
+ const a = resolveAnchorFind(target.entry);
3722
+ if (!a) { showAffordance('selection block is ambiguous'); return { ok: false, reason: 'anchor_unresolved' }; }
3723
+ const replace = a.replacePrefix + nextBlock + a.replaceSuffix;
3724
+ try {
3725
+ await runtimeApplyEnvelope(
3726
+ { version: 'rwa-edit/1', edits: [{ find: a.find, replace, reason: 'format:' + action.label }] },
3727
+ { surface: 'selection-edit', instruction: action.label, actor: actor || 'user:selection-command' });
3728
+ } catch (e) {
3729
+ showAffordance("couldn't " + action.label + ' here — ' + ((e && e.message) || 'blocked'));
3730
+ return { ok: false, reason: (e && e.code) || 'apply_failed' };
3731
+ }
3732
+ try { window.getSelection().removeAllRanges(); } catch (_) {}
3733
+ hideSelectionCommandBar();
3734
+ showAffordance(action.label + ' — ⌘Z to undo');
3735
+ return { ok: true };
3736
+ }
3737
+
3456
3738
  async function runSelectionCommand(raw, options) {
3457
3739
  options = options || {};
3458
3740
  const target = resolveSelectionCommandTarget() || selectionCommandState;
@@ -3466,9 +3748,64 @@ async function runSelectionCommand(raw, options) {
3466
3748
  return { ok: false, reason: 'unknown_command' };
3467
3749
  }
3468
3750
  if (action.kind === 'wrap') return applySelectionWrap(target, action, options.actor, raw);
3751
+ if (action.kind === 'retag' || action.kind === 'align') return applyBlockFormat(target, action, options.actor);
3469
3752
  return { ok: false, reason: 'unknown_command' };
3470
3753
  }
3471
3754
 
3755
+ // Link insertion — wraps the selection in <a href>. The URL is sanitized (scheme allowlist:
3756
+ // http(s)/mailto/tel, plus scheme-less relative/anchor; javascript:/data:/vbscript:/etc. are
3757
+ // refused) and attribute-escaped, so a hostile URL can never become script in the document.
3758
+ function sanitizeHref(url) {
3759
+ const u = String(url || '').trim();
3760
+ if (!u) return null;
3761
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(u);
3762
+ if (scheme) return /^(https?|mailto|tel)$/i.test(scheme[1]) ? u : null;
3763
+ return u; // no scheme → relative / anchor → allowed
3764
+ }
3765
+ async function applySelectionLink(target, url, actor) {
3766
+ const href = sanitizeHref(url);
3767
+ if (!href) { showAffordance('that link is not allowed'); return { ok: false, reason: 'bad_url' }; }
3768
+ const esc = href.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3769
+ return applySelectionWrap(target, { tag: 'a', label: 'link', attrs: ' href="' + esc + '"' }, actor, 'link');
3770
+ }
3771
+ async function runSelectionLink(url, options) {
3772
+ options = options || {};
3773
+ const target = resolveSelectionCommandTarget() || selectionCommandState;
3774
+ if (!target) { showAffordance('select text in Edit mode first'); return { ok: false, reason: 'no_selection' }; }
3775
+ return applySelectionLink(target, url, options.actor || 'user:selection-command');
3776
+ }
3777
+
3778
+ // Text color — wraps the selection in <span style="color:#hex">. The color MUST be a plain
3779
+ // hex (the swatch palette feeds it); anything else is refused so no extra style declaration
3780
+ // (e.g. background:url(...)) can be injected through the attribute.
3781
+ function sanitizeColor(c) { const v = String(c || '').trim(); return /^#[0-9a-f]{3,8}$/i.test(v) ? v : null; }
3782
+ async function applySelectionColor(target, color, actor) {
3783
+ const c = sanitizeColor(color);
3784
+ if (!c) { showAffordance('that color is not allowed'); return { ok: false, reason: 'bad_color' }; }
3785
+ return applySelectionWrap(target, { tag: 'span', label: 'color', attrs: ' style="color:' + c + '"' }, actor, 'color');
3786
+ }
3787
+ async function runSelectionColor(color, options) {
3788
+ options = options || {};
3789
+ const target = resolveSelectionCommandTarget() || selectionCommandState;
3790
+ if (!target) { showAffordance('select text in Edit mode first'); return { ok: false, reason: 'no_selection' }; }
3791
+ return applySelectionColor(target, color, options.actor || 'user:selection-command');
3792
+ }
3793
+
3794
+ // Font size — a fixed em-scale (named steps only) wrapped as <span style="font-size:Xem">.
3795
+ // Headings give semantic sizes; this is the fine control the user's "font/-sizes" ask wants.
3796
+ const RWA_FONT_SIZE_SCALE = { small: '0.85em', normal: '1em', large: '1.3em', huge: '1.7em' };
3797
+ async function applySelectionSize(target, size, actor) {
3798
+ const em = RWA_FONT_SIZE_SCALE[String(size || '').trim().toLowerCase()];
3799
+ if (!em) { showAffordance('that size is not available'); return { ok: false, reason: 'bad_size' }; }
3800
+ return applySelectionWrap(target, { tag: 'span', label: 'size', attrs: ' style="font-size:' + em + '"' }, actor, 'size');
3801
+ }
3802
+ async function runSelectionSize(size, options) {
3803
+ options = options || {};
3804
+ const target = resolveSelectionCommandTarget() || selectionCommandState;
3805
+ if (!target) { showAffordance('select text in Edit mode first'); return { ok: false, reason: 'no_selection' }; }
3806
+ return applySelectionSize(target, size, options.actor || 'user:selection-command');
3807
+ }
3808
+
3472
3809
  function positionSelectionCommandBar(target) {
3473
3810
  const bar = document.getElementById('rwa-selection-bar');
3474
3811
  if (!bar || !target || !target.range) return;
@@ -3481,7 +3818,15 @@ function positionSelectionCommandBar(target) {
3481
3818
  const left = Math.max(8, Math.min(window.scrollX + (rect ? rect.left : 0), window.scrollX + window.innerWidth - 320));
3482
3819
  bar.style.top = top + 'px';
3483
3820
  bar.style.left = left + 'px';
3821
+ const blockSel = document.getElementById('rwa-selection-block');
3822
+ if (blockSel) { const tg = (target.entry && target.entry.tag || '').toLowerCase(); blockSel.value = /^h[1-3]$/.test(tg) ? tg : 'paragraph'; }
3823
+ // A fresh bubble starts collapsed — hide the reveal-on-demand surfaces (command/voice
3824
+ // cluster, link field, color swatches) so they don't carry over from a prior selection.
3825
+ for (const sid of ['rwa-selection-cmdwrap', 'rwa-selection-link-input', 'rwa-selection-color-pop']) { const e = document.getElementById(sid); if (e) e.hidden = true; }
3826
+ const moreBtn = document.getElementById('rwa-selection-more'); if (moreBtn) { moreBtn.setAttribute('aria-expanded', 'false'); moreBtn.classList.remove('on'); }
3827
+ bar.classList.remove('armed'); // a freshly (re)positioned bar starts pointer-transparent
3484
3828
  bar.hidden = false;
3829
+ maybeArmSelectionBar(_lastPointer.x, _lastPointer.y); // arm only if the pointer is already dwelling on it
3485
3830
  }
3486
3831
 
3487
3832
  function refreshSelectionCommandBar() {
@@ -3538,7 +3883,11 @@ function startSelectionVoice() {
3538
3883
  if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
3539
3884
  window.__refreshSelectionCommandBar = refreshSelectionCommandBar;
3540
3885
  window.__runSelectionCommand = runSelectionCommand;
3886
+ window.__runSelectionLink = runSelectionLink;
3887
+ window.__runSelectionColor = runSelectionColor;
3888
+ window.__runSelectionSize = runSelectionSize;
3541
3889
  window.__startSelectionVoice = startSelectionVoice;
3890
+ window.__setDirty = setDirty;
3542
3891
  window.__parseSelectionCommand = parseSelectionCommand;
3543
3892
  }
3544
3893
 
@@ -3594,6 +3943,12 @@ function enterInlineEdit(el, entry) {
3594
3943
  // Baseline serialization for the no-op guard (so an accidental double-click +
3595
3944
  // click-away commits nothing — no undo frame, no history record, no dirty).
3596
3945
  inlineEdit.original = serializeLeafSafe(el);
3946
+ // "The block I'm working on": the block your caret is in IS the single source of truth for the
3947
+ // outline + the lens (⌘K) target. anchorTo paints exactly this block — clearing every other
3948
+ // outline — and points the lens at it. There is no second writer (handleMountClick no longer
3949
+ // anchors while editing), and switching blocks goes through exit→enter atomically, so the
3950
+ // cursor and outline can never land on different blocks (design 2026-06-24-working-block).
3951
+ if (entry && typeof anchorTo === 'function') anchorTo(entry);
3597
3952
  }
3598
3953
  function exitInlineEdit() {
3599
3954
  if (!inlineEdit) return;
@@ -3605,6 +3960,8 @@ function exitInlineEdit() {
3605
3960
  try { el.removeAttribute('contenteditable'); } catch (_) {}
3606
3961
  try { delete el.dataset.rwaCmd; } catch (_) {}
3607
3962
  inlineEdit = null;
3963
+ // Stop editing → the lens releases back to whole-doc (the anchor never lingers past the edit).
3964
+ if (typeof releaseAnchor === 'function') releaseAnchor();
3608
3965
  }
3609
3966
  // Esc — discard the hand-edit and restore the block from the stored doc.
3610
3967
  function revertInlineEdit() {
@@ -3661,15 +4018,78 @@ function insertSoftBreak() {
3661
4018
  // the serialized text is what a commit would write, so the mode the user sees
3662
4019
  // matches what would actually happen. A leading "/" survives HTML-escaping
3663
4020
  // unchanged, so the serializer is safe to discriminate on.
3664
- function isSlashCommand(text) {
3665
- // leading whitespace ignored; a lone "/" still shows prompt mode
3666
- const t = text.replace(/^\s+/, '');
3667
- return t.startsWith('/') && !t.startsWith('\\/');
4021
+ // Inline LLM command: a "/" at a word boundary (block start, after leading whitespace, or after
4022
+ // any whitespace mid-block) turns the suffix from there to the end into an instruction for the
4023
+ // model. Returns that "/" position (the LAST such — so "fix this /shorten it" targets the trailing
4024
+ // command), or -1. The \/ escape and mid-word slashes (http://, /etc/hosts) are NOT commands.
4025
+ function commandStartIndex(text) {
4026
+ let last = -1;
4027
+ for (let i = 0; i < text.length; i++) {
4028
+ if (text[i] !== '/') continue;
4029
+ if (i > 0 && text[i - 1] === '\\') continue; // \/ escape → literal slash
4030
+ if (i === 0 || /\s/.test(text[i - 1])) last = i; // start, or preceded by whitespace
4031
+ }
4032
+ return last;
4033
+ }
4034
+ // Caret <-> character offset within the block, so the command-colour span can be rebuilt without
4035
+ // the caret jumping (defensive: jsdom's Range/Selection support is partial — never throw).
4036
+ function inlineCaretOffset(el) {
4037
+ const sel = window.getSelection && window.getSelection();
4038
+ if (!sel || !sel.rangeCount) return null;
4039
+ try {
4040
+ const pre = document.createRange();
4041
+ pre.selectNodeContents(el);
4042
+ pre.setEnd(sel.getRangeAt(0).startContainer, sel.getRangeAt(0).startOffset);
4043
+ return pre.toString().length;
4044
+ } catch (_) { return null; }
3668
4045
  }
3669
- function handleInlineInput() {
4046
+ function inlineOffsetPoint(el, offset) {
4047
+ let rem = offset;
4048
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
4049
+ let n;
4050
+ while ((n = w.nextNode())) { if (rem <= n.data.length) return { node: n, off: rem }; rem -= n.data.length; }
4051
+ return null;
4052
+ }
4053
+ function inlineSetCaret(el, offset) {
4054
+ if (offset == null) return;
4055
+ const sel = window.getSelection && window.getSelection();
4056
+ if (!sel) return;
4057
+ try {
4058
+ const p = inlineOffsetPoint(el, offset);
4059
+ const r = document.createRange();
4060
+ if (p) r.setStart(p.node, p.off); else { r.selectNodeContents(el); r.collapse(false); }
4061
+ r.collapse(true); sel.removeAllRanges(); sel.addRange(r);
4062
+ } catch (_) {}
4063
+ }
4064
+ // Colour ONLY the "/command" suffix (span[data-rwa-cmd-text]); the content before it stays normal.
4065
+ // Rebuilt from a clean tree each keystroke; caret preserved by offset; surroundContents preserves
4066
+ // any inline markup before the command.
4067
+ function paintCommandSpan(el, isCmd) {
4068
+ const off = inlineCaretOffset(el);
4069
+ el.querySelectorAll('span[data-rwa-cmd-text]').forEach(s => {
4070
+ const p = s.parentNode; while (s.firstChild) p.insertBefore(s.firstChild, s); p.removeChild(s);
4071
+ });
4072
+ el.normalize();
4073
+ if (isCmd) {
4074
+ const text = el.textContent;
4075
+ const ci = commandStartIndex(text);
4076
+ const a = inlineOffsetPoint(el, ci), b = inlineOffsetPoint(el, text.length);
4077
+ if (a && b) {
4078
+ const r = document.createRange();
4079
+ r.setStart(a.node, a.off); r.setEnd(b.node, b.off);
4080
+ const span = document.createElement('span'); span.setAttribute('data-rwa-cmd-text', '');
4081
+ r.surroundContents(span);
4082
+ }
4083
+ }
4084
+ inlineSetCaret(el, off);
4085
+ }
4086
+ function handleInlineInput(e) {
3670
4087
  if (!inlineEdit) return;
3671
- if (inlineEdit.demoted) { setInlineCmd(false); return; } // Esc demoted to plain text — stay there
3672
- setInlineCmd(isSlashCommand(serializeLeafSafe(inlineEdit.el)));
4088
+ if (e && e.isComposing) return; // mid-IME don't touch the DOM
4089
+ const el = inlineEdit.el;
4090
+ const isCmd = !inlineEdit.demoted && commandStartIndex(el.textContent) >= 0;
4091
+ setInlineCmd(isCmd);
4092
+ if (isCmd || el.querySelector('span[data-rwa-cmd-text]')) { try { paintCommandSpan(el, isCmd); } catch (_) {} }
3673
4093
  }
3674
4094
  function setInlineCmd(on) {
3675
4095
  if (!inlineEdit) return;
@@ -3692,11 +4112,13 @@ function handleInlineKeydown(e) {
3692
4112
  } else if (e.key === 'Escape') {
3693
4113
  e.preventDefault();
3694
4114
  if (inlineEdit.commandMode) {
3695
- // Esc in command mode demotes to literal text "/usr/local" must be
3696
- // typeable. Sticky for the rest of this edit session so re-typing
3697
- // slashes doesn't fight the user. Second Esc reverts as always.
4115
+ // Esc stops command mode but KEEPS the text: the "/command" becomes plain literal text
4116
+ // (un-coloured), it is NOT reverted/deleted. ("/usr/local" must be typeable too.) Sticky
4117
+ // for the rest of this session so re-typing slashes doesn't fight the user; a second Esc
4118
+ // (now not in command mode) reverts the block as always.
3698
4119
  inlineEdit.demoted = true;
3699
4120
  setInlineCmd(false);
4121
+ try { paintCommandSpan(inlineEdit.el, false); } catch (_) {} // unwrap the colour span, keep the text
3700
4122
  } else {
3701
4123
  revertInlineEdit();
3702
4124
  }
@@ -3730,9 +4152,19 @@ function handleInlineBlur(e) {
3730
4152
  // The quote entities are future-proofing — escapeHtml doesn't emit them today.
3731
4153
  async function runInlineCommand() {
3732
4154
  if (!inlineEdit) return;
3733
- const instruction = serializeLeafSafe(inlineEdit.el)
3734
- .replace(/^\s*\/\s?/, '')
3735
- .replace(/<br\s*\/?>/gi, '\n')
4155
+ const el = inlineEdit.el;
4156
+ // Unwrap the colour span so the serialization is clean (no span tags leak into the prompt).
4157
+ el.querySelectorAll('span[data-rwa-cmd-text]').forEach(s => { const p = s.parentNode; while (s.firstChild) p.insertBefore(s.firstChild, s); p.removeChild(s); });
4158
+ el.normalize();
4159
+ // The instruction is the text AFTER the command "/" — which may be MID-block ("fix this /shorten
4160
+ // it"). Convert soft breaks to newlines FIRST (so a "<br/>" slash can't masquerade as the
4161
+ // command), find the word-boundary command "/", take the suffix, then unescape the entities so
4162
+ // the model sees what the user typed ("<h2>" not "&lt;h2&gt;", "&" not "&amp;"). renderDoc below
4163
+ // restores the committed block, so the "/command" — never committed — is removed.
4164
+ const ser = serializeLeafSafe(el).replace(/<br\s*\/?>/gi, '\n');
4165
+ const ci = commandStartIndex(ser);
4166
+ const instruction = (ci >= 0 ? ser.slice(ci + 1) : ser.replace(/^\s*\/\s?/, ''))
4167
+ .replace(/^[ \t]?/, '')
3736
4168
  .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
3737
4169
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
3738
4170
  .replace(/&amp;/g, '&')
@@ -3801,13 +4233,43 @@ window.commitInlineEdit = commitInlineEdit;
3801
4233
 
3802
4234
  function startInlineEditFromEvent(e) {
3803
4235
  if (rwaMode !== 'edit' || activeView) return false;
3804
- if (inlineEdit) return false;
3805
4236
  const hit = resolveInlineEditTarget(e && e.target);
3806
4237
  if (!hit) return false;
4238
+ // Already editing this exact block → leave it; the browser handles the caret move.
4239
+ if (inlineEdit && inlineEdit.el === hit.el) return false;
4240
+ // Editing a DIFFERENT block → switch atomically (commit/exit old, enter new) so the cursor
4241
+ // and the working-block outline land on the new block in ONE click, never lagging a block
4242
+ // behind (design 2026-06-24-working-block).
4243
+ if (inlineEdit) { switchInlineEditTo(hit); return true; }
3807
4244
  enterInlineEdit(hit.el, hit.entry);
3808
4245
  return true;
3809
4246
  }
3810
4247
 
4248
+ // Atomic block switch. Clean (unedited) old block → exit + enter, synchronous. Dirty old block
4249
+ // → commit it (which re-renders), then re-resolve the target by its ORDINAL in the rebuilt DOM
4250
+ // and enter it (ordinals are stable because a leaf commit changes the block's text, not the
4251
+ // block order). Either way the outline ends on the clicked block, never the previous one.
4252
+ function switchInlineEditTo(hit) {
4253
+ const mount = document.getElementById('rwa-doc-mount');
4254
+ const targetOrd = mount ? anchorableOrdinal(mount, hit.el) : -1;
4255
+ let dirty = false;
4256
+ try { dirty = serializeLeafSafe(inlineEdit.el) !== inlineEdit.original; } catch (_) {}
4257
+ if (!dirty) {
4258
+ exitInlineEdit();
4259
+ enterInlineEdit(hit.el, hit.entry);
4260
+ return;
4261
+ }
4262
+ // Dirty switch is async (commit re-renders). Flag it so the click that triggered the switch
4263
+ // doesn't anchor the clicked leaf in the gap before the new block is entered.
4264
+ _inlineSwitchPending = true;
4265
+ commitInlineEdit().then(() => {
4266
+ if (targetOrd < 0 || !sourceMap) return;
4267
+ const newEntry = sourceMap[targetOrd];
4268
+ const newEl = newEntry ? liveNodeForEntry(newEntry) : null;
4269
+ if (newEl && newEntry) enterInlineEdit(newEl, newEntry);
4270
+ }).catch(() => {}).then(() => { _inlineSwitchPending = false; });
4271
+ }
4272
+
3811
4273
  // Pointer-down opens the edit before the browser performs its default caret
3812
4274
  // placement for the ensuing click. This is the WYSIWYG path: click text, type.
3813
4275
  function handleMountPointerDown(e) {
@@ -6426,22 +6888,59 @@ function runtimeSetView(name) {
6426
6888
  sessionStorage.setItem(rwaViewKey(), '');
6427
6889
  } else {
6428
6890
  const spec = providers.view;
6429
- if (!spec || spec.name !== name) throw new Error('no registered view named ' + name);
6430
- validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
6431
- releaseAnchor();
6432
- if (rwaMode !== 'document') {
6433
- hideEditTransients();
6434
- closeRuntimePanels();
6435
- rwaMode = 'document';
6436
- emitRuntimeEvent('mode', { mode: rwaMode });
6891
+ if (spec && spec.name === name) {
6892
+ validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
6893
+ releaseAnchor();
6894
+ if (rwaMode !== 'document') {
6895
+ hideEditTransients();
6896
+ closeRuntimePanels();
6897
+ rwaMode = 'document';
6898
+ emitRuntimeEvent('mode', { mode: rwaMode });
6899
+ }
6900
+ activeView = spec;
6901
+ sessionStorage.setItem(rwaViewKey(), name);
6902
+ } else {
6903
+ // I7 (v0.9 §8) — an INSTALLED view skill (by skillId or name). Resolve SYNCHRONOUSLY so an
6904
+ // unknown name still throws (unchanged behavior); only a genuine installed view goes async.
6905
+ // Its render() runs in a Worker, so we invoke once, validate the returned HTML main-side (same
6906
+ // contract as a first-party view — no <script>, no reserved ids), and activate a SNAPSHOT
6907
+ // overlay whose sync render() returns the cached HTML. A view never commits (read-only);
6908
+ // re-activate to refresh (auto-refresh-on-change is the deferred `observe` opt-in).
6909
+ const rec = installedSkills.get(name) || Array.from(installedSkills.values()).find(s => s.name === name && s.kind === 'view');
6910
+ if (!rec || rec.kind !== 'view' || !(rec.manifest && rec.manifest.output && rec.manifest.output.kind === 'html-render')) throw new Error('no registered view named ' + name);
6911
+ runtimeActivateInstalledView(rec);
6912
+ return;
6437
6913
  }
6438
- activeView = spec;
6439
- sessionStorage.setItem(rwaViewKey(), name);
6440
6914
  }
6441
6915
  if (typeof syncModeChrome === 'function') syncModeChrome();
6442
6916
  if (typeof syncViewChrome === 'function') syncViewChrome();
6443
6917
  getDoc().then(d => renderDoc(canonLF(d)));
6444
6918
  }
6919
+ async function runtimeActivateInstalledView(rec) {
6920
+ try {
6921
+ const d = canonLF(await getDoc());
6922
+ const html = String(await runtimeInvokeSkill(rec.skillId, { doc: d, ctx: viewCtx() }));
6923
+ validateViewOutput(html, { name: rec.name }); // throws → never activates (fail-loud, same as first-party)
6924
+ releaseAnchor();
6925
+ if (rwaMode !== 'document') { hideEditTransients(); closeRuntimePanels(); rwaMode = 'document'; emitRuntimeEvent('mode', { mode: rwaMode }); }
6926
+ activeView = { name: rec.name, label: rec.name, skillId: rec.skillId, __provenance: 'installed', __html: html, render() { return this.__html; } };
6927
+ if (typeof syncModeChrome === 'function') syncModeChrome();
6928
+ if (typeof syncViewChrome === 'function') syncViewChrome();
6929
+ renderDoc(d);
6930
+ } catch (e) {
6931
+ setStatus('err', '✗ ' + ((e && e.message) || 'view failed'));
6932
+ }
6933
+ }
6934
+ // I7 (v0.9 §8) — invoke an INSTALLED edit-surface skill: its run() returns an rwa-edit/1 envelope
6935
+ // (a deterministic, model-free transform), which is applied through the SAME validated commit path
6936
+ // the agent/lens use (frozen-zone + structural-shape guards, one ⌘Z), attributed to the skill.
6937
+ async function runtimeInvokeEditSurface(skillId, input) {
6938
+ const rec = installedSkills.get(skillId);
6939
+ if (!rec || rec.kind !== 'edit-surface') throw new Error('not an edit-surface skill');
6940
+ const envelope = await runtimeInvokeSkill(skillId, input || {});
6941
+ if (!envelope || typeof envelope !== 'object' || envelope.version !== 'rwa-edit/1') throw new Error('invalid_transform_output');
6942
+ return runtimeApplyEnvelope(envelope, { surface: 'skill:edit-surface', actor: 'skill:transform:' + String(skillId).slice(0, 8) });
6943
+ }
6445
6944
  function rwaViewKey() { return 'rwa_view_active_' + DOC_UUID; }
6446
6945
  function rwaSlideKey() { return 'rwa_view_slide_' + DOC_UUID; }
6447
6946
 
@@ -6655,6 +7154,43 @@ async function _skSourceGet(pubkey) {
6655
7154
  if (!pubkey) return null;
6656
7155
  try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
6657
7156
  }
7157
+ // I6 (v0.9 §11) — TOFU author identity. Fingerprint = sha256(pubkey).hex[:16] (mirrors the
7158
+ // service's skillFingerprint). The per-author install count + first_seen come from rwa_sources (I5),
7159
+ // so the dialog can say "first time seeing this author" vs "trusted, N installs".
7160
+ async function _skFingerprint(pubkey) {
7161
+ const h = await _skSha256(_skUtf8(String(pubkey)));
7162
+ let s = ''; for (const b of h) s += b.toString(16).padStart(2, '0');
7163
+ return s.slice(0, 16);
7164
+ }
7165
+ async function _skTofu(pubkey) {
7166
+ const rec = await _skSourceGet(pubkey);
7167
+ const installs = (rec && rec.count) || 0;
7168
+ return { fingerprint: await _skFingerprint(pubkey), firstTime: installs === 0, installs };
7169
+ }
7170
+ // I6 (v0.9 §11) — marketplace discovery (opt-in network, like the ↗ share panel). discover →
7171
+ // GET /skills/index (paginated/filterable). fetch → GET /skills/index/:id → the full envelope,
7172
+ // VERIFIED client-side (WebCrypto Ed25519 via _skVerify) before any install; a revoked skill returns
7173
+ // {revoked:true}. The index only informs — install still runs the dialog + gates (the trust anchor).
7174
+ const SKILLS_INDEX_DEFAULT = 'https://rewritable.ikangai.com';
7175
+ async function runtimeDiscoverSkills(opts) {
7176
+ opts = opts || {};
7177
+ const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
7178
+ const qs = new URLSearchParams();
7179
+ for (const k of ['kind', 'author', 'search', 'verified_only', 'page', 'limit']) if (opts[k] != null) qs.set(k, String(opts[k]));
7180
+ const res = await fetch(base + '/skills/index' + (qs.toString() ? '?' + qs.toString() : ''));
7181
+ if (!res.ok) throw new Error('discover_failed:' + res.status);
7182
+ return res.json(); // { entries, total, page, limit }
7183
+ }
7184
+ async function runtimeFetchSkillFromIndex(skillId, opts) {
7185
+ opts = opts || {};
7186
+ const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
7187
+ const res = await fetch(base + '/skills/index/' + encodeURIComponent(skillId));
7188
+ if (res.status === 410) return { revoked: true, envelope: null, verified: false };
7189
+ if (!res.ok) throw new Error('fetch_failed:' + res.status);
7190
+ const data = await res.json();
7191
+ const verified = await _skVerify(data.envelope); // client-side: never trust the index's `verified`
7192
+ return { envelope: data.envelope, metadata: data.metadata, verified, revoked: false };
7193
+ }
6658
7194
  // Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
6659
7195
  async function _skSourceRecord(pubkey, name, at) {
6660
7196
  if (!pubkey || !name) return;
@@ -6713,28 +7249,99 @@ function _skB64(bytes) { let s = ''; for (const b of bytes) s += String.fromChar
6713
7249
  async function _vaultLoadRec() {
6714
7250
  if (_vaultRec) return _vaultRec;
6715
7251
  let rec = null; try { rec = await idbGet(RWA.VAULT); } catch (_) {}
6716
- if (!rec || typeof rec !== 'object') rec = { salt: _skB64(crypto.getRandomValues(new Uint8Array(16))), check: null, entries: {} };
7252
+ if (!rec || typeof rec !== 'object') rec = { salt: _skB64(crypto.getRandomValues(new Uint8Array(16))), kdf_version: 1, check: null, entries: {} }; // new vaults default to v1 (Argon2id); a loaded v0.8 record keeps its (absent→0) version
6717
7253
  _vaultRec = rec; return rec;
6718
7254
  }
6719
- async function _vaultDeriveKey(passphrase, saltB64) {
6720
- const km = await crypto.subtle.importKey('raw', _skUtf8(passphrase), 'PBKDF2', false, ['deriveKey']);
6721
- return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: _skFromB64(saltB64), iterations: 200000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
7255
+ // rwa:argon2:begin ARGON2_SRC
7256
+ // Vendored Argon2id (pure JS) — @noble/hashes@2.2.0, MIT. Bundled by
7257
+ // tools/vendor-argon2.mjs (esbuild iife/min). Assigns globalThis._argon2id(pw,salt,
7258
+ // {t,m,p,dkLen,key?,ad?}). Used to build the blob: Worker in _argon2idViaWorker; the
7259
+ // frozen CSP is unchanged (no WASM, no eval). RFC-9106-pinned by tests/vault-kdf.mjs.
7260
+ const ARGON2_SRC = "(()=>{var q=BigInt(4294967295),ht=BigInt(32);function ut(e,t=!1){return t?{h:Number(e&q),l:Number(e>>ht&q)}:{h:Number(e>>ht&q)|0,l:Number(e&q)|0}}var j=(e,t,n)=>e>>>n|t<<32-n,C=(e,t,n)=>e<<32-n|t>>>n,J=(e,t,n)=>e<<64-n|t>>>n-32,Q=(e,t,n)=>e>>>n-32|t<<64-n,W=(e,t)=>t,K=(e,t)=>e;function rt(e,t,n,s){let f=(t>>>0)+(s>>>0);return{h:e+n+(f/2**32|0)|0,l:f|0}}var $=(e,t,n)=>(e>>>0)+(t>>>0)+(n>>>0),z=(e,t,n,s)=>t+n+s+(e/2**32|0)|0;function At(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name===\"Uint8Array\"&&\"BYTES_PER_ELEMENT\"in e&&e.BYTES_PER_ELEMENT===1}function G(e,t=\"\"){if(typeof e!=\"number\"){let n=t&&`\"${t}\" `;throw new TypeError(`${n}expected number, got ${typeof e}`)}if(!Number.isSafeInteger(e)||e<0){let n=t&&`\"${t}\" `;throw new RangeError(`${n}expected integer >= 0, got ${e}`)}}function H(e,t,n=\"\"){let s=At(e),f=e?.length,r=t!==void 0;if(!s||r&&f!==t){let i=n&&`\"${n}\" `,o=r?` of length ${t}`:\"\",h=s?`length=${f}`:`type=${typeof e}`,u=i+\"expected Uint8Array\"+o+\", got \"+h;throw s?new RangeError(u):new TypeError(u)}return e}function ot(e,t=!0){if(e.destroyed)throw new Error(\"Hash instance has been destroyed\");if(t&&e.finished)throw new Error(\"Hash#digest() has already been called\")}function at(e,t){H(e,void 0,\"digestInto() output\");let n=t.outputLen;if(e.length<n)throw new RangeError('\"digestInto() output\" expected to be of length >='+n)}function P(e){return new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}function I(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function E(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}var dt=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function pt(e){return e<<24&4278190080|e<<8&16711680|e>>>8&65280|e>>>24&255}var m=dt?e=>e:e=>pt(e)>>>0;function kt(e){for(let t=0;t<e.length;t++)e[t]=pt(e[t]);return e}var L=dt?e=>e:kt;function Lt(e){if(typeof e!=\"string\")throw new TypeError(\"string expected\");return new Uint8Array(new TextEncoder().encode(e))}function v(e,t=\"\"){return typeof e==\"string\"?Lt(e):H(e,void 0,t)}function xt(e,t={}){let n=(f,r)=>e(r).update(f).digest(),s=e(void 0);return n.outputLen=s.outputLen,n.blockLen=s.blockLen,n.canXOF=s.canXOF,n.create=f=>e(f),Object.assign(n,t),Object.freeze(n)}var yt=Uint8Array.from([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9,12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11,13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10,6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5,10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9]);var w=Uint32Array.from([4089235720,1779033703,2227873595,3144134277,4271175723,1013904242,1595750129,2773480762,2917565137,1359893119,725511199,2600822924,4215389547,528734635,327033209,1541459225]),c=new Uint32Array(32);function T(e,t,n,s,f,r){let i=f[r],o=f[r+1],h=c[2*e],u=c[2*e+1],l=c[2*t],a=c[2*t+1],x=c[2*n],y=c[2*n+1],d=c[2*s],p=c[2*s+1],B=$(h,l,i);u=z(B,u,a,o),h=B|0,{Dh:p,Dl:d}={Dh:p^u,Dl:d^h},{Dh:p,Dl:d}={Dh:W(p,d),Dl:K(p,d)},{h:y,l:x}=rt(y,x,p,d),{Bh:a,Bl:l}={Bh:a^y,Bl:l^x},{Bh:a,Bl:l}={Bh:j(a,l,24),Bl:C(a,l,24)},c[2*e]=h,c[2*e+1]=u,c[2*t]=l,c[2*t+1]=a,c[2*n]=x,c[2*n+1]=y,c[2*s]=d,c[2*s+1]=p}function S(e,t,n,s,f,r){let i=f[r],o=f[r+1],h=c[2*e],u=c[2*e+1],l=c[2*t],a=c[2*t+1],x=c[2*n],y=c[2*n+1],d=c[2*s],p=c[2*s+1],B=$(h,l,i);u=z(B,u,a,o),h=B|0,{Dh:p,Dl:d}={Dh:p^u,Dl:d^h},{Dh:p,Dl:d}={Dh:j(p,d,16),Dl:C(p,d,16)},{h:y,l:x}=rt(y,x,p,d),{Bh:a,Bl:l}={Bh:a^y,Bl:l^x},{Bh:a,Bl:l}={Bh:J(a,l,63),Bl:Q(a,l,63)},c[2*e]=h,c[2*e+1]=u,c[2*t]=l,c[2*t+1]=a,c[2*n]=x,c[2*n+1]=y,c[2*s]=d,c[2*s+1]=p}function Et(e,t={},n,s,f){if(G(n),e<=0||e>n)throw new Error(\"outputLen bigger than keyLen\");let{key:r,salt:i,personalization:o}=t;if(r!==void 0&&(r.length<1||r.length>n))throw new Error('\"key\" expected to be undefined or of length=1..'+n);i!==void 0&&H(i,s,\"salt\"),o!==void 0&&H(o,f,\"personalization\")}var it=class{buffer;buffer32;finished=!1;destroyed=!1;length=0;pos=0;blockLen;outputLen;canXOF=!1;constructor(t,n){G(t),G(n),this.blockLen=t,this.outputLen=n,this.buffer=new Uint8Array(t),this.buffer32=I(this.buffer)}update(t){ot(this),H(t);let{blockLen:n,buffer:s,buffer32:f}=this,r=t.length,i=t.byteOffset,o=t.buffer;for(let h=0;h<r;){this.pos===n&&(L(f),this.compress(f,0,!1),L(f),this.pos=0);let u=Math.min(n-this.pos,r-h),l=i+h;if(u===n&&!(l%4)&&h+u<r){let a=new Uint32Array(o,l,Math.floor((r-h)/4));L(a);for(let x=0;h+n<r;x+=f.length,h+=n)this.length+=n,this.compress(a,x,!1);L(a);continue}s.set(t.subarray(h,h+u),this.pos),this.pos+=u,this.length+=u,h+=u}return this}digestInto(t){ot(this),at(t,this);let{pos:n,buffer32:s}=this;if(this.finished=!0,E(this.buffer.subarray(n)),L(s),this.compress(s,0,!0),L(s),t.byteOffset&3)throw new RangeError('\"digestInto() output\" expected 4-byte aligned byteOffset, got '+t.byteOffset);let f=this.get(),r=I(t),i=Math.floor(this.outputLen/4);for(let l=0;l<i;l++)r[l]=m(f[l]);let o=this.outputLen%4;if(!o)return;let h=i*4,u=f[i];for(let l=0;l<o;l++)t[h+l]=u>>>8*l}digest(){let{buffer:t,outputLen:n}=this;this.digestInto(t);let s=t.slice(0,n);return this.destroy(),s}_cloneInto(t){let{buffer:n,length:s,finished:f,destroyed:r,outputLen:i,pos:o}=this;return t||=new this.constructor({dkLen:i}),t.set(...this.get()),t.buffer.set(n),t.destroyed=r,t.finished=f,t.length=s,t.pos=o,t.outputLen=i,t}clone(){return this._cloneInto()}},st=class extends it{v0l=w[0]|0;v0h=w[1]|0;v1l=w[2]|0;v1h=w[3]|0;v2l=w[4]|0;v2h=w[5]|0;v3l=w[6]|0;v3h=w[7]|0;v4l=w[8]|0;v4h=w[9]|0;v5l=w[10]|0;v5h=w[11]|0;v6l=w[12]|0;v6h=w[13]|0;v7l=w[14]|0;v7h=w[15]|0;constructor(t={}){let n=t.dkLen===void 0?64:t.dkLen;super(128,n),Et(n,t,64,16,16);let{key:s,personalization:f,salt:r}=t,i=0;if(s!==void 0&&(H(s,void 0,\"key\"),i=s.length),this.v0l^=this.outputLen|i<<8|65536|1<<24,r!==void 0){H(r,void 0,\"salt\");let o=I(r);this.v4l^=m(o[0]),this.v4h^=m(o[1]),this.v5l^=m(o[2]),this.v5h^=m(o[3])}if(f!==void 0){H(f,void 0,\"personalization\");let o=I(f);this.v6l^=m(o[0]),this.v6h^=m(o[1]),this.v7l^=m(o[2]),this.v7h^=m(o[3])}if(s!==void 0){let o=new Uint8Array(this.blockLen);o.set(s),this.update(o)}}get(){let{v0l:t,v0h:n,v1l:s,v1h:f,v2l:r,v2h:i,v3l:o,v3h:h,v4l:u,v4h:l,v5l:a,v5h:x,v6l:y,v6h:d,v7l:p,v7h:B}=this;return[t,n,s,f,r,i,o,h,u,l,a,x,y,d,p,B]}set(t,n,s,f,r,i,o,h,u,l,a,x,y,d,p,B){this.v0l=t|0,this.v0h=n|0,this.v1l=s|0,this.v1h=f|0,this.v2l=r|0,this.v2h=i|0,this.v3l=o|0,this.v3h=h|0,this.v4l=u|0,this.v4h=l|0,this.v5l=a|0,this.v5h=x|0,this.v6l=y|0,this.v6h=d|0,this.v7l=p|0,this.v7h=B|0}compress(t,n,s){this.get().forEach((h,u)=>c[u]=h),c.set(w,16);let{h:f,l:r}=ut(BigInt(this.length));c[24]=w[8]^r,c[25]=w[9]^f,s&&(c[28]=~c[28],c[29]=~c[29]);let i=0,o=yt;for(let h=0;h<12;h++)T(0,4,8,12,t,n+2*o[i++]),S(0,4,8,12,t,n+2*o[i++]),T(1,5,9,13,t,n+2*o[i++]),S(1,5,9,13,t,n+2*o[i++]),T(2,6,10,14,t,n+2*o[i++]),S(2,6,10,14,t,n+2*o[i++]),T(3,7,11,15,t,n+2*o[i++]),S(3,7,11,15,t,n+2*o[i++]),T(0,5,10,15,t,n+2*o[i++]),S(0,5,10,15,t,n+2*o[i++]),T(1,6,11,12,t,n+2*o[i++]),S(1,6,11,12,t,n+2*o[i++]),T(2,7,8,13,t,n+2*o[i++]),S(2,7,8,13,t,n+2*o[i++]),T(3,4,9,14,t,n+2*o[i++]),S(3,4,9,14,t,n+2*o[i++]);this.v0l^=c[0]^c[16],this.v0h^=c[1]^c[17],this.v1l^=c[2]^c[18],this.v1h^=c[3]^c[19],this.v2l^=c[4]^c[20],this.v2h^=c[5]^c[21],this.v3l^=c[6]^c[22],this.v3h^=c[7]^c[23],this.v4l^=c[8]^c[24],this.v4h^=c[9]^c[25],this.v5l^=c[10]^c[26],this.v5h^=c[11]^c[27],this.v6l^=c[12]^c[28],this.v6h^=c[13]^c[29],this.v7l^=c[14]^c[30],this.v7h^=c[15]^c[31],E(c)}destroy(){this.destroyed=!0,E(this.buffer32),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},N=xt(e=>new st(e));var et={Argond2d:0,Argon2i:1,Argon2id:2},F=4,bt=(e,t=\"\")=>e===void 0?Uint8Array.of():v(e,t);function ft(e,t){let n=e&65535,s=e>>>16,f=t&65535,r=t>>>16,i=Math.imul(n,f),o=Math.imul(s,f),h=Math.imul(n,r),u=Math.imul(s,r),l=(i>>>16)+(o&65535)+h,a=u+(o>>>16)+(l>>>16)|0,x=l<<16|i&65535;return{h:a,l:x}}function Ut(e,t){let{h:n,l:s}=ft(e,t);return{h:(n<<1|s>>>31)&4294967295,l:s<<1&4294967295}}function tt(e,t,n,s){let{h:f,l:r}=Ut(t,s),i=$(t,s,r);return{h:z(i,e,n,f),l:i|0}}var b=new Uint32Array(256);function D(e,t,n,s){let f=b[2*e],r=b[2*e+1],i=b[2*t],o=b[2*t+1],h=b[2*n],u=b[2*n+1],l=b[2*s],a=b[2*s+1];({h:r,l:f}=tt(r,f,o,i)),{Dh:a,Dl:l}={Dh:a^r,Dl:l^f},{Dh:a,Dl:l}={Dh:W(a,l),Dl:K(a,l)},{h:u,l:h}=tt(u,h,a,l),{Bh:o,Bl:i}={Bh:o^u,Bl:i^h},{Bh:o,Bl:i}={Bh:j(o,i,24),Bl:C(o,i,24)},{h:r,l:f}=tt(r,f,o,i),{Dh:a,Dl:l}={Dh:a^r,Dl:l^f},{Dh:a,Dl:l}={Dh:j(a,l,16),Dl:C(a,l,16)},{h:u,l:h}=tt(u,h,a,l),{Bh:o,Bl:i}={Bh:o^u,Bl:i^h},{Bh:o,Bl:i}={Bh:J(o,i,63),Bl:Q(o,i,63)},b[2*e]=f,b[2*e+1]=r,b[2*t]=i,b[2*t+1]=o,b[2*n]=h,b[2*n+1]=u,b[2*s]=l,b[2*s+1]=a}function wt(e,t,n,s,f,r,i,o,h,u,l,a,x,y,d,p){D(e,f,h,x),D(t,r,u,y),D(n,i,l,d),D(s,o,a,p),D(e,r,l,p),D(t,i,a,x),D(n,o,h,y),D(s,f,u,d)}function X(e,t,n,s,f){for(let r=0;r<256;r++)b[r]=e[t+r]^e[n+r];for(let r=0;r<128;r+=16)wt(r,r+1,r+2,r+3,r+4,r+5,r+6,r+7,r+8,r+9,r+10,r+11,r+12,r+13,r+14,r+15);for(let r=0;r<16;r+=2)wt(r,r+1,r+16,r+17,r+32,r+33,r+48,r+49,r+64,r+65,r+80,r+81,r+96,r+97,r+112,r+113);if(f)for(let r=0;r<256;r++)e[s+r]^=b[r]^e[t+r]^e[n+r];else for(let r=0;r<256;r++)e[s+r]=b[r]^e[t+r]^e[n+r];E(b)}function ct(e,t){let n=P(e),s=new Uint32Array(1),f=P(s);if(s[0]=m(t),t<=64)return N.create({dkLen:t}).update(f).update(n).digest();let r=new Uint8Array(t),i=N.create({}).update(f).update(n).digest(),o=0;for(r.set(i.subarray(0,32)),o+=32;t-o>64;o+=32){let h=N.create({}).update(i);h.digestInto(i),h.destroy(),r.set(i.subarray(0,32),o)}return r.set(N(i,{dkLen:t-o}),o),E(i,s),r}function Ht(e,t,n,s,f,r,i=!1){let o;e===0?t===0?o=f-1:i?o=t*s+f-1:o=t*s+(f==0?-1:0):i?o=n-s+f-1:o=n-s+(f==0?-1:0);let h=e!==0&&t!==F-1?(t+1)*s:0,u=o-1-ft(o,ft(r,r).h).h;return(h+u)%n}var gt=Math.pow(2,32);function _(e){return Number.isSafeInteger(e)&&e>=0&&e<gt}function It(e){let t={version:19,dkLen:32,maxmem:gt-1,asyncTick:10};for(let[u,l]of Object.entries(e))l!==void 0&&(t[u]=l);let{dkLen:n,p:s,m:f,t:r,version:i,onProgress:o,asyncTick:h}=t;if(!_(n)||n<4)throw new Error('\"dkLen\" must be 4..');if(!_(s)||s<1||s>=Math.pow(2,24))throw new Error('\"p\" must be 1..2^24');if(!_(f))throw new Error('\"m\" must be 0..2^32');if(!_(r)||r<1)throw new Error('\"t\" (iterations) must be 1..2^32');if(o!==void 0&&typeof o!=\"function\")throw new Error('\"progressCb\" must be a function');if(G(h,\"asyncTick\"),!_(f)||f<8*s)throw new Error('\"m\" (memory) must be at least 8*p bytes');if(i!==16&&i!==19)throw new Error('\"version\" must be 0x10 or 0x13, got '+i);return t}function Tt(e,t,n,s){if(e=v(e,\"password\"),t=v(t,\"salt\"),!_(e.length))throw new Error('\"password\" must be less of length 1..4Gb');if(!_(t.length)||t.length<8)throw new Error('\"salt\" must be of length 8..4Gb');if(!Object.values(et).includes(n))throw new Error('\"type\" was invalid');let{p:f,dkLen:r,m:i,t:o,version:h,key:u,personalization:l,maxmem:a,onProgress:x,asyncTick:y}=It(s);u=bt(u,\"key\"),l=bt(l,\"personalization\");let d=N.create(),p=new Uint32Array(1),B=P(p);for(let A of[f,r,i,o,h,n])p[0]=m(A),d.update(B);for(let A of[e,t,u,l])p[0]=m(A.length),d.update(B).update(A);let g=new Uint32Array(18),k=P(g);d.digestInto(k);let O=f,M=4*f*Math.floor(i/(F*f)),U=Math.floor(M/f),V=Math.floor(U/F),R=M*1024;if(!_(a))throw new Error('\"maxmem\" expected <2**32, got '+a);if(R>a)throw new Error('\"maxmem\" limit was hit: memUsed(mP*1024)='+R+\", maxmem=\"+a);let nt=new Uint32Array(R/4);for(let A=0;A<f;A++){let Y=256*U*A;g[17]=m(A),g[16]=m(0),nt.set(L(I(ct(g,1024))),Y),g[16]=m(1),nt.set(L(I(ct(g,1024))),Y+256)}let lt=()=>{};if(x){let A=o*F*f*V-2*f,Y=Math.max(Math.floor(A/1e4),1),Z=0;lt=()=>{Z++,x&&(!(Z%Y)||Z===A)&&x(Z/A)}}return E(p,g),{type:n,mP:M,p:f,t:o,version:h,B:nt,laneLen:U,lanes:O,segmentLen:V,dkLen:r,perBlock:lt,asyncTick:y}}function St(e,t,n,s){let f=new Uint32Array(256);for(let i=0;i<t;i++)for(let o=0;o<256;o++)f[o]^=e[256*(n*i+n-1)+o];let r=ct(L(f),s);return E(e,f),r}function Dt(e,t,n,s,f,r,i,o,h,u,l,a,x){u%i&&(l=u-1);let y,d;if(a){let k=r%128;k===0&&(t[268]++,X(t,256,2*256,0,!1),X(t,0,2*256,0,!1)),y=t[2*k],d=t[2*k+1]}else{let k=256*l;y=e[k],d=e[k+1]}let p=s===0&&f===0?n:d%h,B=Ht(s,f,i,o,r,y,p==n),g=i*p+B;X(e,256*l,256*g,u*256,x)}function _t(e,t,n,s){let{mP:f,p:r,t:i,version:o,B:h,laneLen:u,lanes:l,segmentLen:a,dkLen:x,perBlock:y}=Tt(t,n,e,s),d=new Uint32Array(3*256);d[262]=f,d[264]=i,d[266]=e;for(let p=0;p<i;p++){let B=p!==0&&o===19;d[256]=p;for(let g=0;g<F;g++){d[260]=g;let k=e==et.Argon2i||e==et.Argon2id&&p===0&&g<2;for(let O=0;O<r;O++){d[258]=O,d[268]=0;let M=0;p===0&&g===0&&(M=2,k&&(d[268]++,X(d,256,2*256,0,!1),X(d,0,2*256,0,!1)));let U=O*u+g*a+M,V=U%u?U-1:U+u-1;for(let R=M;R<a;R++,U++,V++)y(),Dt(h,d,O,p,g,R,u,a,l,U,V,k,B)}}}return E(d),St(h,r,u,x)}var mt=(e,t,n)=>_t(et.Argon2id,e,t,n);globalThis._argon2id=function(e,t,n){return n=n||{},mt(e,t,{t:n.t,m:n.m,p:n.p,dkLen:n.dkLen||32,key:n.key,personalization:n.ad})};})();";
7261
+ // rwa:argon2:end ARGON2_SRC
7262
+ function _argon2idViaWorker(pwBytes, saltBytes, params) {
7263
+ return new Promise((resolve, reject) => {
7264
+ let url = null, w = null;
7265
+ const TAIL = '\nself.onmessage=function(e){try{var d=e.data;var h=self._argon2id(d.pw,d.salt,{t:d.t,m:d.m,p:d.p,dkLen:d.dkLen});self.postMessage({hash:h});}catch(err){self.postMessage({error:String(err&&err.message||err)});}};';
7266
+ try { url = URL.createObjectURL(new Blob([ARGON2_SRC + TAIL], { type: 'text/javascript' })); w = new Worker(url); }
7267
+ catch (_) { if (url) { try { URL.revokeObjectURL(url); } catch (e) {} } reject(new Error('vault_kdf_unavailable')); return; }
7268
+ let settled = false;
7269
+ const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(to); try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(url); } catch (_) {} fn(arg); };
7270
+ const to = setTimeout(() => finish(reject, new Error('vault_kdf_timeout')), 15000);
7271
+ w.onmessage = (e) => { if (e.data && e.data.error) finish(reject, new Error('vault_kdf_error')); else finish(resolve, new Uint8Array(e.data.hash)); };
7272
+ w.onerror = () => finish(reject, new Error('vault_kdf_error'));
7273
+ w.postMessage({ pw: pwBytes, salt: saltBytes, t: params.t, m: params.m, p: params.p, dkLen: params.dkLen });
7274
+ });
6722
7275
  }
6723
- async function _vaultEnc(text) {
7276
+ // Argon2id is synchronous (~1.5s @ 64 MiB) — run it in a transient blob: Worker so the
7277
+ // unlock never freezes the page (the frozen CSP already allows worker-src blob:; no WASM,
7278
+ // no eval). No Worker (jsdom / very old engines) → a sync fallback uses an injected
7279
+ // globalThis._argon2id, else fails loud (Workers are already a skill-host requirement).
7280
+ function _argon2idHash(pwBytes, saltBytes, params) {
7281
+ if (typeof Worker !== 'undefined' && typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) return _argon2idViaWorker(pwBytes, saltBytes, params);
7282
+ if (typeof globalThis._argon2id === 'function') { try { return Promise.resolve(globalThis._argon2id(pwBytes, saltBytes, params)); } catch (_) { return Promise.reject(new Error('vault_kdf_error')); } }
7283
+ return Promise.reject(new Error('vault_kdf_unavailable'));
7284
+ }
7285
+ // _vaultDeriveKey(passphrase, saltB64, kdfVersion=0): 0 = PBKDF2-200k (v0.8 + I13 transport
7286
+ // callers default here, byte-unchanged); 1 = Argon2id(m=64 MiB,t=3,p=4) — memory-hard (I9 §13).
7287
+ async function _vaultDeriveKey(passphrase, saltB64, kdfVersion = 0) {
7288
+ if (kdfVersion === 0) {
7289
+ const km = await crypto.subtle.importKey('raw', _skUtf8(passphrase), 'PBKDF2', false, ['deriveKey']);
7290
+ return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: _skFromB64(saltB64), iterations: 200000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
7291
+ }
7292
+ if (kdfVersion === 1) {
7293
+ const hash = await _argon2idHash(_skUtf8(passphrase), _skFromB64(saltB64), { t: 3, m: 65536, p: 4, dkLen: 32 }); // m=65536 KiB = 64 MiB
7294
+ return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
7295
+ }
7296
+ throw new Error('vault_unknown_kdf_version');
7297
+ }
7298
+ async function _encWith(key, text) {
6724
7299
  const iv = crypto.getRandomValues(new Uint8Array(12));
6725
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, _vaultKey, _skUtf8(text)));
7300
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, _skUtf8(text)));
6726
7301
  return { iv: _skB64(iv), ct: _skB64(ct) };
6727
7302
  }
6728
- async function _vaultDec(entry) {
6729
- return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, _vaultKey, _skFromB64(entry.ct)));
6730
- }
6731
- async function runtimeVaultUnlock(passphrase) {
7303
+ async function _decWith(key, entry) {
7304
+ return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, key, _skFromB64(entry.ct)));
7305
+ }
7306
+ function _vaultEnc(text) { return _encWith(_vaultKey, text); }
7307
+ function _vaultDec(entry) { return _decWith(_vaultKey, entry); }
7308
+ // runtimeVaultUnlock(passphrase, options?:{targetKdfVersion}) — derive under the record's
7309
+ // kdf_version (0=PBKDF2, 1=Argon2id; missing→0). targetKdfVersion>current migrates the vault
7310
+ // (I9 §13): re-derive + re-encrypt check + every entry under the new KDF, committed as ONE
7311
+ // record write; _vaultKey/_vaultRec are assigned ONLY after the put resolves, so a
7312
+ // mid-migration failure leaves the vault locked and the stored record intact (Inv 42/43).
7313
+ async function runtimeVaultUnlock(passphrase, options) {
7314
+ const opts = options || {};
6732
7315
  const rec = await _vaultLoadRec();
6733
- const key = await _vaultDeriveKey(passphrase, rec.salt);
6734
- _vaultKey = key;
6735
- if (rec.check) { try { await _vaultDec(rec.check); } catch (_) { _vaultKey = null; throw new Error('vault_bad_passphrase'); } }
6736
- else { rec.check = await _vaultEnc('rwa-vault-ok'); try { await idbPut(RWA.VAULT, rec); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); } }
6737
- try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', key)))); } catch (_) {}
7316
+ const cur = (rec.kdf_version == null) ? 0 : rec.kdf_version;
7317
+ if (cur !== 0 && cur !== 1) { _vaultKey = null; throw new Error('vault_unknown_kdf_version'); }
7318
+ const target = opts.targetKdfVersion;
7319
+ if (target != null && target !== 0 && target !== 1) { _vaultKey = null; throw new Error('vault_unknown_kdf_version'); }
7320
+ if (rec.check == null) {
7321
+ // new / empty vault: adopt the latest KDF (default v1) — covers new-vault-default AND
7322
+ // auto-migrate-on-empty (no data at risk); an explicit target wins.
7323
+ const useVer = (target != null) ? target : 1;
7324
+ const key = await _vaultDeriveKey(passphrase, rec.salt, useVer);
7325
+ rec.kdf_version = useVer;
7326
+ rec.check = await _encWith(key, 'rwa-vault-ok');
7327
+ try { await idbPut(RWA.VAULT, rec); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); }
7328
+ _vaultKey = key;
7329
+ try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', key)))); } catch (_) {}
7330
+ return true;
7331
+ }
7332
+ const oldKey = await _vaultDeriveKey(passphrase, rec.salt, cur);
7333
+ try { await _decWith(oldKey, rec.check); } catch (_) { _vaultKey = null; throw new Error('vault_bad_passphrase'); }
7334
+ if (target != null && target > cur) {
7335
+ const newKey = await _vaultDeriveKey(passphrase, rec.salt, target);
7336
+ const next = { salt: rec.salt, kdf_version: target, check: await _encWith(newKey, 'rwa-vault-ok'), entries: {} };
7337
+ for (const k of Object.keys(rec.entries)) next.entries[k] = await _encWith(newKey, await _decWith(oldKey, rec.entries[k]));
7338
+ try { await idbPut(RWA.VAULT, next); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); }
7339
+ _vaultRec = next; _vaultKey = newKey;
7340
+ try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', newKey)))); } catch (_) {}
7341
+ return true;
7342
+ }
7343
+ _vaultKey = oldKey;
7344
+ try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', oldKey)))); } catch (_) {}
6738
7345
  return true;
6739
7346
  }
6740
7347
  async function _vaultReimportSession() {
@@ -6766,6 +7373,64 @@ async function runtimeVaultNamespaces() {
6766
7373
  for (const k of Object.keys(rec.entries)) { const i = k.indexOf('\0'); if (i > 0) set.add(k.slice(0, i)); }
6767
7374
  return Array.from(set);
6768
7375
  }
7376
+ // ── I13 (v0.9 §14) — portable vault EXPORT/IMPORT (offline; escrow + account service deferred to
7377
+ // v1). A version-tagged, self-contained `rwa-vault-export/1` file: selected namespaces re-encrypted
7378
+ // under a SEPARATE transport passphrase (PBKDF2-200k + AES-256-GCM, per-namespace salt + check), so
7379
+ // it decrypts on another machine with only the passphrase — no server. The machine-local vault stays
7380
+ // the default; this travels ONLY on an explicit user action. Requires the vault unlocked (to read
7381
+ // plaintext to re-wrap). Never logs the passphrase. CLI/Worker have no access (UI/runtime action only).
7382
+ async function runtimeVaultExport(passphrase, namespaces) {
7383
+ if (!_vaultKey) throw new Error('vault_locked');
7384
+ if (!passphrase) throw new Error('vault_bad_passphrase');
7385
+ const rec = await _vaultLoadRec();
7386
+ const allNs = await runtimeVaultNamespaces();
7387
+ const sel = (Array.isArray(namespaces) && namespaces.length) ? namespaces.filter(n => allNs.includes(n)) : allNs;
7388
+ const out = { rwa: 'rwa-vault-export/1', containerUuid: DOC_UUID, exportedAt: Date.now(), namespaces: sel, entries: {} };
7389
+ for (const ns of sel) {
7390
+ const salt = _skB64(crypto.getRandomValues(new Uint8Array(16)));
7391
+ const ekey = await _vaultDeriveKey(passphrase, salt);
7392
+ const enc = async (text) => { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ekey, _skUtf8(text))); return { iv: _skB64(iv), ct: _skB64(ct) }; };
7393
+ const items = [];
7394
+ for (const k of Object.keys(rec.entries)) {
7395
+ const i = k.indexOf('\0'); if (i <= 0 || k.slice(0, i) !== ns) continue;
7396
+ const plain = await _vaultDec(rec.entries[k]); // decrypt under the LOCAL vault key…
7397
+ items.push(Object.assign({ key: k.slice(i + 1) }, await enc(plain))); // …re-encrypt under the export key
7398
+ }
7399
+ out.entries[ns] = { salt, check: await enc('rwa-vault-export-ok'), items };
7400
+ }
7401
+ return out;
7402
+ }
7403
+ async function runtimeVaultImport(exportObj, passphrase, opts) {
7404
+ opts = opts || {};
7405
+ if (!_vaultKey) throw new Error('vault_locked'); // need the local key to re-wrap imported items
7406
+ if (!exportObj || exportObj.rwa !== 'rwa-vault-export/1' || !exportObj.entries || typeof exportObj.entries !== 'object') throw new Error('account_export_malformed');
7407
+ const rec = await _vaultLoadRec();
7408
+ const result = { imported: 0, skipped: 0, namespaces: [], containerMismatch: exportObj.containerUuid !== DOC_UUID };
7409
+ for (const ns of Object.keys(exportObj.entries)) {
7410
+ const e = exportObj.entries[ns];
7411
+ if (!e || typeof e.salt !== 'string' || !Array.isArray(e.items)) throw new Error('account_export_malformed');
7412
+ const ekey = await _vaultDeriveKey(passphrase, e.salt);
7413
+ const dec = async (entry) => new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, ekey, _skFromB64(entry.ct)));
7414
+ if (e.check) { try { await dec(e.check); } catch (_) { throw new Error('vault_decrypt_failed'); } } // wrong passphrase fails here, before any write
7415
+ for (const it of e.items) {
7416
+ let plain; try { plain = await dec(it); } catch (_) { throw new Error('vault_decrypt_failed'); }
7417
+ const dk = ns + '\0' + it.key;
7418
+ if (rec.entries[dk] && !opts.overwrite) { result.skipped++; continue; } // don't clobber without explicit overwrite
7419
+ rec.entries[dk] = await _vaultEnc(plain); // re-encrypt under the LOCAL vault key (usable immediately)
7420
+ result.imported++;
7421
+ }
7422
+ result.namespaces.push(ns);
7423
+ }
7424
+ try { await idbPut(RWA.VAULT, rec); } catch (_) { throw new Error('vault_storage_error'); }
7425
+ return result;
7426
+ }
7427
+ // I13 — live-only account identity (opt-in, sessionStorage rwa_account; default null). Never stamped
7428
+ // into the file; never exposed to skill code (UI/describe only). Escrow/account-service deferred to v1.
7429
+ function runtimeAccountIdentity() {
7430
+ let raw = null; try { raw = sessionStorage.getItem('rwa_account'); } catch (_) { return null; }
7431
+ if (!raw) return null;
7432
+ try { const a = JSON.parse(raw); return a && a.mode ? { mode: a.mode, accountId: a.accountId || null, lastSync: a.lastSync || null } : null; } catch (_) { return null; }
7433
+ }
6769
7434
  // §6 — the bridge's per-call vault gate (mirror of cli/src/skill-manifest.mjs vaultNamespaceAllowed).
6770
7435
  function _skVaultAllowed(skill, ns) {
6771
7436
  const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
@@ -6776,6 +7441,9 @@ function _skBusAllowed(skill, topic) {
6776
7441
  const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
6777
7442
  return perms.indexOf('bus:' + topic) !== -1;
6778
7443
  }
7444
+ // §5 (I1b) — per-message subscribe filter. true for all today; I12 wires a peer-allowlist here
7445
+ // (defense-in-depth — a declared bus: perm can't be further runtime-restricted; Shape B holds).
7446
+ function _skBusMessageAllowed(_skill, _envelope) { return true; }
6779
7447
  // §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
6780
7448
  // a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
6781
7449
  function _skFsAllowed(skill, path) {
@@ -6955,6 +7623,13 @@ function _skValidateInstall(skill, vr) {
6955
7623
  if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
6956
7624
  // §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
6957
7625
  if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
7626
+ // §8 (I7): view/edit-surface are zero-capability DOM authors — reject any permission + require a
7627
+ // matching typed output contract (view → html-render, edit-surface → dom-transform).
7628
+ if (skill.kind === 'view' || skill.kind === 'edit-surface') {
7629
+ if (perms.length > 0) errors.push('output_skill_with_permissions');
7630
+ const want = skill.kind === 'view' ? 'html-render' : 'dom-transform';
7631
+ if (!skill.output || skill.output.kind !== want) errors.push('invalid_output_kind');
7632
+ }
6958
7633
  if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
6959
7634
  // Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
6960
7635
  if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
@@ -7004,6 +7679,8 @@ async function runtimeReviewSkill(envelope) {
7004
7679
  } catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
7005
7680
  // I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
7006
7681
  const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
7682
+ // I6 — TOFU author identity (fingerprint + per-author install count) for the install dialog.
7683
+ const tofu = await _skTofu(skill.author_pubkey);
7007
7684
  return {
7008
7685
  name: skill.name, version: skill.version, kind: skill.kind,
7009
7686
  purpose: skill.description || '(no description provided)',
@@ -7011,7 +7688,7 @@ async function runtimeReviewSkill(envelope) {
7011
7688
  permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
7012
7689
  compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
7013
7690
  lookalike, lookalikeKind, lookalikeBlock,
7014
- priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
7691
+ priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange, tofu,
7015
7692
  gates: _skValidateInstall(skill, vr), update,
7016
7693
  };
7017
7694
  }
@@ -7070,6 +7747,7 @@ async function runtimeInstallSkill(envelope) {
7070
7747
  // I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
7071
7748
  // install). On a same-key rename the new name is appended; identity stays anchored on the key.
7072
7749
  await _skSourceRecord(skill.author_pubkey, skill.name);
7750
+ _skEvictPool(id); // I2 — an install/update may change the code → drop any stale pooled Workers
7073
7751
  return { ok: true, skillId: id };
7074
7752
  }
7075
7753
  // §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
@@ -7077,6 +7755,7 @@ async function runtimeUninstallSkill(skillId) {
7077
7755
  const prev = installedSkills.get(skillId);
7078
7756
  if (!prev) return { ok: false, errors: ['not_installed'] };
7079
7757
  installedSkills.delete(skillId);
7758
+ _skEvictPool(skillId); // I2 — drop any pooled Workers for the removed skill
7080
7759
  try {
7081
7760
  await runtimeRegionCommit({ regions: [_skSkillsRegion()], actor: 'skill:uninstall', reachability: 'frozen' });
7082
7761
  } catch (e) {
@@ -7253,6 +7932,8 @@ function showSkillInstallDialog(envelope) {
7253
7932
  '<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
7254
7933
  '<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
7255
7934
  '<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
7935
+ // I6 — TOFU author identity: the key fingerprint + whether you've installed from this author before.
7936
+ (rv.tofu ? '<p style="margin:.2em 0;color:#555">🔑 Author fingerprint: <code>' + _skEsc(rv.tofu.fingerprint) + '</code>. ' + (rv.tofu.firstTime ? 'First time seeing this author.' : 'Trusted — ' + rv.tofu.installs + ' previous install' + (rv.tofu.installs === 1 ? '' : 's') + '.') + '</p>' : '') +
7256
7937
  lookalikeHtml +
7257
7938
  nameChangeHtml +
7258
7939
  updHtml +
@@ -7346,13 +8027,13 @@ const SKILL_WORKER_PROLOGUE = `(function(){
7346
8027
  'use strict';
7347
8028
  var REMOVE = ['importScripts','Worker','SharedWorker','ServiceWorkerContainer','XMLHttpRequest','WebSocket','EventSource','indexedDB','eval','Function','fetch','WebAssembly'];
7348
8029
  for (var i=0;i<REMOVE.length;i++){ try { Object.defineProperty(self, REMOVE[i], { value: undefined, writable: false, configurable: false }); } catch(_e){} }
7349
- var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={};
8030
+ var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={}, _subs={};
7350
8031
  function _bridge(type, payload){ return new Promise(function(res,rej){ var id=++_seq; _pending.set(id,{res:res,rej:rej}); self.postMessage({ type:type, id:id, identity_tag:IDENTITY, payload:payload }); }); }
7351
8032
  function _serializeOpts(o){ if(!o||typeof o!=='object') return undefined; var out={}; if(o.method) out.method=String(o.method); if(typeof o.body==='string') out.body=o.body; if(o.headers&&typeof o.headers==='object') out.headers=o.headers; return out; }
7352
8033
  function _installBridge(){
7353
8034
  RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
7354
8035
  RUNTIME.vault={ get:function(ns,k){ return _bridge('bridge:vault',{op:'get',ns:ns,key:k}); }, set:function(ns,k,v){ return _bridge('bridge:vault',{op:'set',ns:ns,key:k,val:v}); }, has:function(ns,k){ return _bridge('bridge:vault',{op:'has',ns:ns,key:k}); } };
7355
- RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); } };
8036
+ RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); }, subscribe:function(topic,cb){ var t=String(topic); return _bridge('bridge:bus:subscribe',{ topic:t }).then(function(){ (_subs[t]=_subs[t]||[]).push(cb); return function(){ var a=_subs[t]||[], i=a.indexOf(cb); if(i>=0) a.splice(i,1); try{ _bridge('bridge:bus:unsubscribe',{ topic:t }); }catch(_e){} }; }); } };
7356
8037
  RUNTIME.fs={ read:function(p){ return _bridge('bridge:fs',{op:'read',path:String(p)}); }, write:function(p,d){ return _bridge('bridge:fs',{op:'write',path:String(p),data:d}); }, del:function(p){ return _bridge('bridge:fs',{op:'del',path:String(p)}); }, list:function(p){ return _bridge('bridge:fs',{op:'list',path:String(p)}); } };
7357
8038
  RUNTIME.db={ get:function(s,k){ return _bridge('bridge:idb',{op:'get',store:String(s),key:k}); }, put:function(s,k,v){ return _bridge('bridge:idb',{op:'put',store:String(s),key:k,value:v}); }, del:function(s,k){ return _bridge('bridge:idb',{op:'del',store:String(s),key:k}); }, all:function(s){ return _bridge('bridge:idb',{op:'all',store:String(s)}); } };
7358
8039
  }
@@ -7366,10 +8047,85 @@ const SKILL_WORKER_PROLOGUE = `(function(){
7366
8047
  .catch(function(err){ self.postMessage({ type:'result', id:msg.id, identity_tag:IDENTITY, ok:false, error:String(err&&err.message||err) }); });
7367
8048
  return;
7368
8049
  }
8050
+ if(msg.type==='bus:message'){ var env=msg.envelope||{}, subs=_subs[env.topic]||[]; for(var i=0;i<subs.length;i++){ try{ subs[i](env); }catch(_e){} } return; } // I1b — deliver to skill-side subscribers
8051
+ if(msg.type==='shutdown'){ try{ self.postMessage({ type:'shutdown_ack', identity_tag:null }); }catch(_e){} return; } // I2 — pool drain handshake
7369
8052
  };
7370
8053
  })();
7371
8054
  `;
7372
8055
 
8056
+ // ── I2 (v0.9 §10) — optional compute-Worker POOL. DISABLED BY DEFAULT: only an explicit
8057
+ // poolingHint {pooling:'enabled'} on a COMPUTE skill (no role) takes this path; every other invoke
8058
+ // (tools, agent-role, no-hint) rides the byte-unchanged spawn→invoke→terminate path below. Pooled
8059
+ // Workers are compute-only (bridgeless, same worker-scoped CSP), keyed by skillId+code-hash (a code
8060
+ // change evicts the pool), bounded by an idle timeout + a hard cap, and drained on shutdown. Per-
8061
+ // invocation isolation is unchanged: each invoke re-inits a fresh identity_tag and races the 5s
8062
+ // timeout; a timeout/error terminates the Worker (never returns it to the pool). Statelessness is
8063
+ // the author's responsibility (Inv 25; no global reset between invokes — documented "pool only if pure").
8064
+ const SKILL_POOLS = new Map(); // skillId → { codeHash, idle:[Worker], lastUsed:Map<Worker,ts> }
8065
+ const SKILL_POOL_CAP = Math.min(4, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 1);
8066
+ let SKILL_POOL_IDLE_MS = 60000; // mutable so the browser proof can shorten it
8067
+ async function _skCodeHash(skillId, code) {
8068
+ return _skB64url(await _skSha256(_skConcat(_skUtf8(String(skillId)), _skNUL, _skUtf8(String(code || '')))));
8069
+ }
8070
+ function _skSpawnComputeWorker(skill) {
8071
+ const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
8072
+ const url = URL.createObjectURL(blob);
8073
+ const w = new Worker(url); w.__rwaUrl = url; return w;
8074
+ }
8075
+ function _skKillWorker(w) { try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(w.__rwaUrl); } catch (_) {} }
8076
+ function _skEnforcePoolCap(pool) {
8077
+ while (pool.idle.length > SKILL_POOL_CAP) {
8078
+ let oi = 0; for (let i = 1; i < pool.idle.length; i++) if ((pool.lastUsed.get(pool.idle[i]) || 0) < (pool.lastUsed.get(pool.idle[oi]) || 0)) oi = i;
8079
+ const [w] = pool.idle.splice(oi, 1); pool.lastUsed.delete(w); _skKillWorker(w); // evict oldest-idle
8080
+ }
8081
+ }
8082
+ function _skEvictPool(skillId) { // code-hash may change on install/update/uninstall → drop stale Workers
8083
+ const pool = SKILL_POOLS.get(skillId);
8084
+ if (pool) { pool.idle.forEach(_skKillWorker); SKILL_POOLS.delete(skillId); }
8085
+ }
8086
+ function _skPoolEvictIdle() { // background sweep: terminate Workers idle ≥ SKILL_POOL_IDLE_MS
8087
+ const now = Date.now();
8088
+ for (const pool of SKILL_POOLS.values())
8089
+ pool.idle = pool.idle.filter(w => { if (now - (pool.lastUsed.get(w) || 0) >= SKILL_POOL_IDLE_MS) { pool.lastUsed.delete(w); _skKillWorker(w); return false; } return true; });
8090
+ }
8091
+ async function _skPoolShutdown() { // send shutdown, 500ms grace, terminate (idempotent)
8092
+ const all = [];
8093
+ for (const pool of SKILL_POOLS.values()) for (const w of pool.idle) all.push(w);
8094
+ for (const w of all) { try { w.postMessage({ type: 'shutdown', identity_tag: null }); } catch (_) {} }
8095
+ await new Promise(r => setTimeout(r, 500));
8096
+ for (const w of all) _skKillWorker(w);
8097
+ SKILL_POOLS.clear();
8098
+ }
8099
+ function runtimePoolStats() {
8100
+ const pools = {}; let live = 0;
8101
+ for (const [id, pool] of SKILL_POOLS) { pools[id] = pool.idle.length; live += pool.idle.length; }
8102
+ return { live, cap: SKILL_POOL_CAP, idleMs: SKILL_POOL_IDLE_MS, pools };
8103
+ }
8104
+ // Run ONE invocation on a pooled compute Worker (reuse-or-spawn). Success → return to pool; timeout
8105
+ // or error → terminate (never pool). Compute skills have no bridge, so a pooled Worker only ever
8106
+ // emits `result` — the onmessage here is the full message contract for the pooled path.
8107
+ async function _skPooledInvoke(skillId, skill, input) {
8108
+ const codeHash = await _skCodeHash(skillId, skill.code);
8109
+ let pool = SKILL_POOLS.get(skillId);
8110
+ if (pool && pool.codeHash !== codeHash) { _skEvictPool(skillId); pool = null; }
8111
+ if (!pool) { pool = { codeHash, idle: [], lastUsed: new Map() }; SKILL_POOLS.set(skillId, pool); }
8112
+ const w = pool.idle.pop() || _skSpawnComputeWorker(skill);
8113
+ const tag = crypto.randomUUID();
8114
+ return new Promise((resolve, reject) => {
8115
+ let settled = false;
8116
+ const done = (fn, arg, keep) => {
8117
+ if (settled) return; settled = true; clearTimeout(timer); w.onmessage = null; w.onerror = null;
8118
+ if (keep) { pool.lastUsed.set(w, Date.now()); pool.idle.push(w); _skEnforcePoolCap(pool); }
8119
+ else { pool.lastUsed.delete(w); _skKillWorker(w); }
8120
+ fn(arg);
8121
+ };
8122
+ const timer = setTimeout(() => done(reject, new Error('timeout'), false), 5000); // per-invocation, not per-tenure
8123
+ w.onmessage = (e) => { const m = e.data; if (!m || m.identity_tag !== tag) return; if (m.type === 'result') m.ok ? done(resolve, m.result, true) : done(reject, new Error(m.error || 'runtime_error'), false); };
8124
+ w.onerror = () => done(reject, new Error('runtime_error'), false);
8125
+ w.postMessage({ type: 'init', identity_tag: tag, bridged: false }); // re-init each invoke: fresh tag, never bridged
8126
+ w.postMessage({ type: 'invoke', id: 1, input });
8127
+ });
8128
+ }
7373
8129
  // §5a — invoke an installed skill in an isolated Worker. compute = bridgeless; tool =
7374
8130
  // bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
7375
8131
  // Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
@@ -7397,6 +8153,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
7397
8153
  // skills (which never passed through runtimeInstallSkill's gate), every kind.
7398
8154
  const forbidden = _skCodeForbidden(skill.code);
7399
8155
  if (forbidden) return Promise.reject(new Error(forbidden));
8156
+ // I2 — opt-in compute-Worker pool. Only a compute skill with an explicit pooling hint and no
8157
+ // agent-role takes the warm path; everything else falls through to the byte-unchanged fresh spawn.
8158
+ if (opts && opts.pooling === 'enabled' && skill.kind === 'compute' && !agentRole) return _skPooledInvoke(skillId, skill, input);
7400
8159
  const identity_tag = crypto.randomUUID();
7401
8160
  const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
7402
8161
  const url = URL.createObjectURL(blob);
@@ -7405,8 +8164,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
7405
8164
  // F6: cancel an in-flight bridge fetch when the 5s timeout (or any settle)
7406
8165
  // fires, so a slow request doesn't keep running after the skill is finished.
7407
8166
  const _skAc = (typeof AbortController !== 'undefined') ? new AbortController() : null;
8167
+ const busSubs = []; // I1b — active bus subscriptions for THIS invoke; torn down on settle
7408
8168
  return new Promise((resolve, reject) => {
7409
- const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
8169
+ const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } for (const u of busSubs) { try { u(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
7410
8170
  const timer = setTimeout(() => finish(reject, new Error('timeout')), 5000);
7411
8171
  const reply = (id, ok, extra) => w.postMessage(Object.assign({ type: 'bridge:response', id, identity_tag, ok }, extra));
7412
8172
  w.onmessage = async (e) => {
@@ -7451,6 +8211,22 @@ function runtimeInvokeSkill(skillId, input, opts) {
7451
8211
  catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
7452
8212
  return;
7453
8213
  }
8214
+ if (msg.type === 'bridge:bus:subscribe') {
8215
+ const pl = msg.payload || {};
8216
+ if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
8217
+ // I1b — forward each allowed envelope to the Worker until it settles (finish() tears these
8218
+ // down). The 5s timeout bounds the subscribe CALL (the ok reply), not the subscription.
8219
+ try {
8220
+ const unsub = runtimeBusSubscribe(pl.topic, (env) => {
8221
+ if (!_skBusMessageAllowed(skill, env)) return;
8222
+ try { w.postMessage({ type: 'bus:message', id: msg.id, identity_tag, envelope: { topic: env.topic, from: env.from, at: env.at, message: env.message } }); } catch (_) {}
8223
+ });
8224
+ busSubs.push(unsub);
8225
+ reply(msg.id, true, { result: true });
8226
+ } catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
8227
+ return;
8228
+ }
8229
+ if (msg.type === 'bridge:bus:unsubscribe') { reply(msg.id, true, { result: true }); return; } // MVP: precise teardown deferred; finish() unsubscribes all on settle
7454
8230
  if (msg.type === 'bridge:fs') {
7455
8231
  const pl = msg.payload || {}, op = pl.op, p = pl.path;
7456
8232
  // Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
@@ -7558,6 +8334,9 @@ function runtimeDescribe() {
7558
8334
  // undo-only — there is no redo (re-write-able-spec Invariant 7).
7559
8335
  baseline: { edit: ['lens'], tools: ['apply_dsl_plan', 'apply_edits', 'replace_document'], export: ['html', 'print'], history: ['undo'] },
7560
8336
  activeView: activeView ? activeView.name : null,
8337
+ // I13 (v0.9 §14) — opt-in account identity, LIVE-only (never stamped into the file). null unless
8338
+ // the user linked an account this session (sessionStorage rwa_account); machine-local is the default.
8339
+ accountIdentity: runtimeAccountIdentity(),
7561
8340
  };
7562
8341
  }
7563
8342
 
@@ -8741,12 +9520,16 @@ document.addEventListener('keydown', e => {
8741
9520
  describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
8742
9521
  listSkills: runtimeListSkills, // v0.8 §8 — installed skills (provenance:'installed')
8743
9522
  invokeSkill: runtimeInvokeSkill, // v0.8 §5a — run a skill in an isolated Worker
9523
+ invokeEditSurface: runtimeInvokeEditSurface, // v0.9 §8 (I7) — run an edit-surface skill → apply its rwa-edit/1 transform
9524
+ poolStats: runtimePoolStats, // v0.9 §10 (I2) — compute-Worker pool observability {live,cap,idleMs,pools}
8744
9525
  reviewSkill: runtimeReviewSkill, // v0.8 §1 — structured trust info for the install dialog
8745
9526
  installSkill: runtimeInstallSkill, // v0.8 §1/§7 — gates + verify + register + persist to the frozen zone (survives reload)
8746
9527
  uninstallSkill: runtimeUninstallSkill, // v0.8 §7 — remove + persist
8747
9528
  showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
8748
9529
  promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
8749
- vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked }, // v0.8 §6
9530
+ discoverSkills: runtimeDiscoverSkills, // v0.9 §11 (I6) GET the marketplace index (opt-in network)
9531
+ fetchSkillFromIndex: runtimeFetchSkillFromIndex, // v0.9 §11 (I6) — fetch + client-side-verify an indexed skill
9532
+ vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked, export: runtimeVaultExport, import: runtimeVaultImport }, // v0.8 §6 + v0.9 §14 (I13) portable export/import
8750
9533
  agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
8751
9534
  hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
8752
9535
  };
@@ -8764,6 +9547,10 @@ document.addEventListener('keydown', e => {
8764
9547
  configurable: false,
8765
9548
  });
8766
9549
  startWorkspacePresence();
9550
+ // I2 (v0.9 §10) — background idle eviction (every 30s, terminate compute Workers idle ≥ idleMs)
9551
+ // + drain the pool on unload (shutdown handshake + 500ms grace). No-op until a pooled invoke runs.
9552
+ setInterval(_skPoolEvictIdle, 30000);
9553
+ window.addEventListener('pagehide', () => { _skPoolShutdown(); });
8767
9554
  // §5.10: the presentation render mode ships ONLY for presentation
8768
9555
  // containers. For every other kind this block is skipped entirely —
8769
9556
  // activeView stays null, no provider is registered, no chrome is built,