rewritable 0.10.0 → 0.12.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; }
4045
+ }
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);
3668
4085
  }
3669
- function handleInlineInput() {
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) {
@@ -6787,28 +7249,99 @@ function _skB64(bytes) { let s = ''; for (const b of bytes) s += String.fromChar
6787
7249
  async function _vaultLoadRec() {
6788
7250
  if (_vaultRec) return _vaultRec;
6789
7251
  let rec = null; try { rec = await idbGet(RWA.VAULT); } catch (_) {}
6790
- 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
6791
7253
  _vaultRec = rec; return rec;
6792
7254
  }
6793
- async function _vaultDeriveKey(passphrase, saltB64) {
6794
- const km = await crypto.subtle.importKey('raw', _skUtf8(passphrase), 'PBKDF2', false, ['deriveKey']);
6795
- 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
+ });
6796
7275
  }
6797
- 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) {
6798
7299
  const iv = crypto.getRandomValues(new Uint8Array(12));
6799
- 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)));
6800
7301
  return { iv: _skB64(iv), ct: _skB64(ct) };
6801
7302
  }
6802
- async function _vaultDec(entry) {
6803
- return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, _vaultKey, _skFromB64(entry.ct)));
6804
- }
6805
- 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 || {};
6806
7315
  const rec = await _vaultLoadRec();
6807
- const key = await _vaultDeriveKey(passphrase, rec.salt);
6808
- _vaultKey = key;
6809
- if (rec.check) { try { await _vaultDec(rec.check); } catch (_) { _vaultKey = null; throw new Error('vault_bad_passphrase'); } }
6810
- else { rec.check = await _vaultEnc('rwa-vault-ok'); try { await idbPut(RWA.VAULT, rec); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); } }
6811
- 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 (_) {}
6812
7345
  return true;
6813
7346
  }
6814
7347
  async function _vaultReimportSession() {
@@ -7458,13 +7991,98 @@ function showAgentInstallDialog(envelope) {
7458
7991
  const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallAgent(envelope));
7459
7992
  }));
7460
7993
  }
7461
- // §1.3 / §12 — install trigger: pick a .rwa-skill.json OR .rwa-agent.json, parse, dispatch by format.
7994
+ // §1.3 / §12 — install trigger: pick a .rwa-skill.json / .rwa-agent.json envelope OR an
7995
+ // intelligence carrier .html (a rewritable carrying a signed rwa-agent/1 record), then route.
7462
7996
  function runtimePromptInstall() {
7463
- const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json';
7464
- inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid skill/agent JSON'); return; } if (env && env.format === 'rwa-agent/1') showAgentInstallDialog(env); else showSkillInstallDialog(env); }; rd.readAsText(f); };
7997
+ const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json,.html,.htm,text/html';
7998
+ inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { routeInstallFromText(String(rd.result || '')); }; rd.readAsText(f); };
7465
7999
  inp.click();
7466
8000
  }
7467
8001
 
8002
+ // ── intelligence/0.2 (docs/specs/rwa-intelligence-spec.md §5) — the file-drop bridge.
8003
+ // An "intelligence" ships as a CARRIER: a (skill-host) rewritable carrying a signed rwa-agent/1
8004
+ // record in its frozen #rwa-agents zone (the overlay half is the v0.9 agent role — see
8005
+ // runtime.agents / buildAgentZone above). Dropping a carrier onto a target extracts that record
8006
+ // and routes it to the existing consent dialog + runtime.agents.install. In a carrier's RAW bytes
8007
+ // the record lives inside INLINE_DOC, so its closing script tag is backslash-escaped (the zone
8008
+ // <div>/</div> delimiters are not). We extract + un-escape INLINE_DOC (the inverse of buildFile's
8009
+ // escapeTL — every escape is one backslash + one char), then parse the zone exactly as
8010
+ // readTrustworthyAgents does. The signature stays the trust anchor: the dialog re-verifies.
8011
+ function _carrierDoc(html) {
8012
+ const marker = 'const INLINE_DOC = `';
8013
+ const i = String(html || '').indexOf(marker);
8014
+ if (i < 0) return String(html || ''); // not a full container — treat the text itself as the doc
8015
+ let j = i + marker.length;
8016
+ for (; j < html.length; j++) { const c = html[j]; if (c === '\\') { j++; continue; } if (c === '`') break; }
8017
+ return html.slice(i + marker.length, j).replace(/\\([\s\S])/g, '$1');
8018
+ }
8019
+ function extractAgentEnvelopesFromCarrier(html) {
8020
+ const zone = _agExtractZone(_carrierDoc(html));
8021
+ if (!zone) return [];
8022
+ const out = [];
8023
+ for (const m of zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g)) {
8024
+ let env; try { env = JSON.parse(new TextDecoder().decode(_skFromB64(m[1].trim()))); } catch (_) { continue; }
8025
+ if (env && env.agent && typeof env.agent.role === 'string') out.push(env);
8026
+ }
8027
+ return out;
8028
+ }
8029
+ // Classify a dropped/picked file's text: a carrier .html, a bare envelope JSON, or nothing.
8030
+ function classifyInstallText(text) {
8031
+ const s = String(text || '');
8032
+ let obj = null; try { obj = JSON.parse(s); } catch (_) {}
8033
+ if (obj && typeof obj === 'object') {
8034
+ if (obj.agent || obj.format === 'rwa-agent/1') return { kind: 'json-agent', envelope: obj };
8035
+ if (obj.skill || obj.format === 'rwa-skill/1') return { kind: 'json-skill', envelope: obj };
8036
+ return { kind: 'none' };
8037
+ }
8038
+ const envelopes = extractAgentEnvelopesFromCarrier(s);
8039
+ if (envelopes.length) return { kind: 'agent-carrier', envelopes };
8040
+ return { kind: 'none' };
8041
+ }
8042
+ // Route extracted content to the right consent dialog. Install stays behind the dialog (the trust
8043
+ // anchor); the dialog is fire-and-forget so the drop handler returns immediately. Multiple records
8044
+ // in one carrier are queued (each dialog awaits the previous close).
8045
+ async function routeInstallFromText(text) {
8046
+ const c = classifyInstallText(text);
8047
+ if (c.kind === 'json-agent') showAgentInstallDialog(c.envelope);
8048
+ else if (c.kind === 'json-skill') showSkillInstallDialog(c.envelope);
8049
+ else if (c.kind === 'agent-carrier') { (async () => { for (const env of c.envelopes) { await showAgentInstallDialog(env); } })(); }
8050
+ else if (typeof setStatus === 'function') setStatus('err', 'no installable skill or intelligence found in that file');
8051
+ return c;
8052
+ }
8053
+ async function _readDroppedText(file) {
8054
+ if (file && typeof file.text === 'function') return file.text();
8055
+ return new Promise((res, rej) => { const rd = new FileReader(); rd.onload = () => res(String(rd.result || '')); rd.onerror = rej; rd.readAsText(file); });
8056
+ }
8057
+ // Drop gesture — capture phase, so a carrier is claimed before the Edit-mode image-mount drop.
8058
+ // Acts only on an .html/.htm file; any other drop flows through untouched to existing handlers.
8059
+ // A carrier is a self-contained .html (seed + doc, ~0.6 MB; larger with embedded images); cap the
8060
+ // read so a wildly oversized drop can't be slurped into memory (mirrors the image-ingest size cap).
8061
+ const CARRIER_MAX_BYTES = 32 * 1024 * 1024;
8062
+ async function handleCarrierDrop(e) {
8063
+ const files = Array.from((e && e.dataTransfer && e.dataTransfer.files) || []);
8064
+ const carrier = files.find(f => /text\/html/i.test(f.type || '') || /\.html?$/i.test(f.name || ''));
8065
+ if (!carrier) return; // not a carrier — let the image/other drop handlers run
8066
+ if (e.preventDefault) e.preventDefault();
8067
+ if (e.stopPropagation) e.stopPropagation();
8068
+ if (carrier.size > CARRIER_MAX_BYTES) { if (typeof setStatus === 'function') setStatus('err', 'that file is too large to be an intelligence carrier (' + Math.round(carrier.size / 1048576) + ' MB)'); return; }
8069
+ try { await routeInstallFromText(await _readDroppedText(carrier)); }
8070
+ catch (_) { if (typeof setStatus === 'function') setStatus('err', 'could not read the dropped file'); }
8071
+ }
8072
+ function handleCarrierDragOver(e) {
8073
+ // Let a file drop fire anywhere on the page (a carrier can be dropped onto the document, not only
8074
+ // the edit mount). Idempotent with the mount's own dragover; the drop handler decides whether to claim it.
8075
+ const t = e && e.dataTransfer;
8076
+ if (t && (Array.from(t.items || []).some(i => i.kind === 'file') || Array.from(t.types || []).includes('Files'))) e.preventDefault();
8077
+ }
8078
+ window.addEventListener('dragover', handleCarrierDragOver, true);
8079
+ window.addEventListener('drop', handleCarrierDrop, true);
8080
+ // Automation/test hooks (mirror window.__ingestImageFile).
8081
+ window.__rwaExtractAgentCarrier = extractAgentEnvelopesFromCarrier;
8082
+ window.__rwaClassifyInstallText = classifyInstallText;
8083
+ window.__rwaInstallFromText = routeInstallFromText;
8084
+ window.__rwaHandleCarrierDrop = handleCarrierDrop;
8085
+
7468
8086
  // §4/§5a — does a network: host pattern admit a host? Mirror of cli/src/skill-manifest.mjs
7469
8087
  // matchNetworkOrigin (keep in step). The bridge's per-call origin check.
7470
8088
  function _skMatchNetworkOrigin(pattern, host) {