rewritable 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/rwa.mjs +58 -0
- package/package.json +1 -1
- package/seeds/rewritable.html +876 -89
- package/src/import.mjs +206 -122
- package/src/skill-manifest.mjs +7 -0
- package/src/skill-publish.mjs +59 -0
package/seeds/rewritable.html
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
<button class="rwa-st-btn
|
|
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="Edit — format 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
|
-
<
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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"><></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
|
-
|
|
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">
|
|
2149
|
-
'<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
|
-
//
|
|
3127
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
4021
|
+
// Inline LLM command: a "/" at a word boundary (block start, after leading whitespace, or after
|
|
4022
|
+
// any whitespace mid-block) turns the suffix from there to the end into an instruction for the
|
|
4023
|
+
// model. Returns that "/" position (the LAST such — so "fix this /shorten it" targets the trailing
|
|
4024
|
+
// command), or -1. The \/ escape and mid-word slashes (http://, /etc/hosts) are NOT commands.
|
|
4025
|
+
function commandStartIndex(text) {
|
|
4026
|
+
let last = -1;
|
|
4027
|
+
for (let i = 0; i < text.length; i++) {
|
|
4028
|
+
if (text[i] !== '/') continue;
|
|
4029
|
+
if (i > 0 && text[i - 1] === '\\') continue; // \/ escape → literal slash
|
|
4030
|
+
if (i === 0 || /\s/.test(text[i - 1])) last = i; // start, or preceded by whitespace
|
|
4031
|
+
}
|
|
4032
|
+
return last;
|
|
4033
|
+
}
|
|
4034
|
+
// Caret <-> character offset within the block, so the command-colour span can be rebuilt without
|
|
4035
|
+
// the caret jumping (defensive: jsdom's Range/Selection support is partial — never throw).
|
|
4036
|
+
function inlineCaretOffset(el) {
|
|
4037
|
+
const sel = window.getSelection && window.getSelection();
|
|
4038
|
+
if (!sel || !sel.rangeCount) return null;
|
|
4039
|
+
try {
|
|
4040
|
+
const pre = document.createRange();
|
|
4041
|
+
pre.selectNodeContents(el);
|
|
4042
|
+
pre.setEnd(sel.getRangeAt(0).startContainer, sel.getRangeAt(0).startOffset);
|
|
4043
|
+
return pre.toString().length;
|
|
4044
|
+
} catch (_) { return null; }
|
|
3668
4045
|
}
|
|
3669
|
-
function
|
|
4046
|
+
function inlineOffsetPoint(el, offset) {
|
|
4047
|
+
let rem = offset;
|
|
4048
|
+
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
|
4049
|
+
let n;
|
|
4050
|
+
while ((n = w.nextNode())) { if (rem <= n.data.length) return { node: n, off: rem }; rem -= n.data.length; }
|
|
4051
|
+
return null;
|
|
4052
|
+
}
|
|
4053
|
+
function inlineSetCaret(el, offset) {
|
|
4054
|
+
if (offset == null) return;
|
|
4055
|
+
const sel = window.getSelection && window.getSelection();
|
|
4056
|
+
if (!sel) return;
|
|
4057
|
+
try {
|
|
4058
|
+
const p = inlineOffsetPoint(el, offset);
|
|
4059
|
+
const r = document.createRange();
|
|
4060
|
+
if (p) r.setStart(p.node, p.off); else { r.selectNodeContents(el); r.collapse(false); }
|
|
4061
|
+
r.collapse(true); sel.removeAllRanges(); sel.addRange(r);
|
|
4062
|
+
} catch (_) {}
|
|
4063
|
+
}
|
|
4064
|
+
// Colour ONLY the "/command" suffix (span[data-rwa-cmd-text]); the content before it stays normal.
|
|
4065
|
+
// Rebuilt from a clean tree each keystroke; caret preserved by offset; surroundContents preserves
|
|
4066
|
+
// any inline markup before the command.
|
|
4067
|
+
function paintCommandSpan(el, isCmd) {
|
|
4068
|
+
const off = inlineCaretOffset(el);
|
|
4069
|
+
el.querySelectorAll('span[data-rwa-cmd-text]').forEach(s => {
|
|
4070
|
+
const p = s.parentNode; while (s.firstChild) p.insertBefore(s.firstChild, s); p.removeChild(s);
|
|
4071
|
+
});
|
|
4072
|
+
el.normalize();
|
|
4073
|
+
if (isCmd) {
|
|
4074
|
+
const text = el.textContent;
|
|
4075
|
+
const ci = commandStartIndex(text);
|
|
4076
|
+
const a = inlineOffsetPoint(el, ci), b = inlineOffsetPoint(el, text.length);
|
|
4077
|
+
if (a && b) {
|
|
4078
|
+
const r = document.createRange();
|
|
4079
|
+
r.setStart(a.node, a.off); r.setEnd(b.node, b.off);
|
|
4080
|
+
const span = document.createElement('span'); span.setAttribute('data-rwa-cmd-text', '');
|
|
4081
|
+
r.surroundContents(span);
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
inlineSetCaret(el, off);
|
|
4085
|
+
}
|
|
4086
|
+
function handleInlineInput(e) {
|
|
3670
4087
|
if (!inlineEdit) return;
|
|
3671
|
-
if (
|
|
3672
|
-
|
|
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
|
|
3696
|
-
//
|
|
3697
|
-
// slashes doesn't fight the user
|
|
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
|
|
3734
|
-
|
|
3735
|
-
|
|
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 "<h2>", "&" not "&"). 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(/</g, '<').replace(/>/g, '>')
|
|
3737
4169
|
.replace(/"/g, '"').replace(/'/g, "'")
|
|
3738
4170
|
.replace(/&/g, '&')
|
|
@@ -3801,13 +4233,43 @@ window.commitInlineEdit = commitInlineEdit;
|
|
|
3801
4233
|
|
|
3802
4234
|
function startInlineEditFromEvent(e) {
|
|
3803
4235
|
if (rwaMode !== 'edit' || activeView) return false;
|
|
3804
|
-
if (inlineEdit) return false;
|
|
3805
4236
|
const hit = resolveInlineEditTarget(e && e.target);
|
|
3806
4237
|
if (!hit) return false;
|
|
4238
|
+
// Already editing this exact block → leave it; the browser handles the caret move.
|
|
4239
|
+
if (inlineEdit && inlineEdit.el === hit.el) return false;
|
|
4240
|
+
// Editing a DIFFERENT block → switch atomically (commit/exit old, enter new) so the cursor
|
|
4241
|
+
// and the working-block outline land on the new block in ONE click, never lagging a block
|
|
4242
|
+
// behind (design 2026-06-24-working-block).
|
|
4243
|
+
if (inlineEdit) { switchInlineEditTo(hit); return true; }
|
|
3807
4244
|
enterInlineEdit(hit.el, hit.entry);
|
|
3808
4245
|
return true;
|
|
3809
4246
|
}
|
|
3810
4247
|
|
|
4248
|
+
// Atomic block switch. Clean (unedited) old block → exit + enter, synchronous. Dirty old block
|
|
4249
|
+
// → commit it (which re-renders), then re-resolve the target by its ORDINAL in the rebuilt DOM
|
|
4250
|
+
// and enter it (ordinals are stable because a leaf commit changes the block's text, not the
|
|
4251
|
+
// block order). Either way the outline ends on the clicked block, never the previous one.
|
|
4252
|
+
function switchInlineEditTo(hit) {
|
|
4253
|
+
const mount = document.getElementById('rwa-doc-mount');
|
|
4254
|
+
const targetOrd = mount ? anchorableOrdinal(mount, hit.el) : -1;
|
|
4255
|
+
let dirty = false;
|
|
4256
|
+
try { dirty = serializeLeafSafe(inlineEdit.el) !== inlineEdit.original; } catch (_) {}
|
|
4257
|
+
if (!dirty) {
|
|
4258
|
+
exitInlineEdit();
|
|
4259
|
+
enterInlineEdit(hit.el, hit.entry);
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
// Dirty switch is async (commit re-renders). Flag it so the click that triggered the switch
|
|
4263
|
+
// doesn't anchor the clicked leaf in the gap before the new block is entered.
|
|
4264
|
+
_inlineSwitchPending = true;
|
|
4265
|
+
commitInlineEdit().then(() => {
|
|
4266
|
+
if (targetOrd < 0 || !sourceMap) return;
|
|
4267
|
+
const newEntry = sourceMap[targetOrd];
|
|
4268
|
+
const newEl = newEntry ? liveNodeForEntry(newEntry) : null;
|
|
4269
|
+
if (newEl && newEntry) enterInlineEdit(newEl, newEntry);
|
|
4270
|
+
}).catch(() => {}).then(() => { _inlineSwitchPending = false; });
|
|
4271
|
+
}
|
|
4272
|
+
|
|
3811
4273
|
// Pointer-down opens the edit before the browser performs its default caret
|
|
3812
4274
|
// placement for the ensuing click. This is the WYSIWYG path: click text, type.
|
|
3813
4275
|
function handleMountPointerDown(e) {
|
|
@@ -6426,22 +6888,59 @@ function runtimeSetView(name) {
|
|
|
6426
6888
|
sessionStorage.setItem(rwaViewKey(), '');
|
|
6427
6889
|
} else {
|
|
6428
6890
|
const spec = providers.view;
|
|
6429
|
-
if (
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6891
|
+
if (spec && spec.name === name) {
|
|
6892
|
+
validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
|
|
6893
|
+
releaseAnchor();
|
|
6894
|
+
if (rwaMode !== 'document') {
|
|
6895
|
+
hideEditTransients();
|
|
6896
|
+
closeRuntimePanels();
|
|
6897
|
+
rwaMode = 'document';
|
|
6898
|
+
emitRuntimeEvent('mode', { mode: rwaMode });
|
|
6899
|
+
}
|
|
6900
|
+
activeView = spec;
|
|
6901
|
+
sessionStorage.setItem(rwaViewKey(), name);
|
|
6902
|
+
} else {
|
|
6903
|
+
// I7 (v0.9 §8) — an INSTALLED view skill (by skillId or name). Resolve SYNCHRONOUSLY so an
|
|
6904
|
+
// unknown name still throws (unchanged behavior); only a genuine installed view goes async.
|
|
6905
|
+
// Its render() runs in a Worker, so we invoke once, validate the returned HTML main-side (same
|
|
6906
|
+
// contract as a first-party view — no <script>, no reserved ids), and activate a SNAPSHOT
|
|
6907
|
+
// overlay whose sync render() returns the cached HTML. A view never commits (read-only);
|
|
6908
|
+
// re-activate to refresh (auto-refresh-on-change is the deferred `observe` opt-in).
|
|
6909
|
+
const rec = installedSkills.get(name) || Array.from(installedSkills.values()).find(s => s.name === name && s.kind === 'view');
|
|
6910
|
+
if (!rec || rec.kind !== 'view' || !(rec.manifest && rec.manifest.output && rec.manifest.output.kind === 'html-render')) throw new Error('no registered view named ' + name);
|
|
6911
|
+
runtimeActivateInstalledView(rec);
|
|
6912
|
+
return;
|
|
6437
6913
|
}
|
|
6438
|
-
activeView = spec;
|
|
6439
|
-
sessionStorage.setItem(rwaViewKey(), name);
|
|
6440
6914
|
}
|
|
6441
6915
|
if (typeof syncModeChrome === 'function') syncModeChrome();
|
|
6442
6916
|
if (typeof syncViewChrome === 'function') syncViewChrome();
|
|
6443
6917
|
getDoc().then(d => renderDoc(canonLF(d)));
|
|
6444
6918
|
}
|
|
6919
|
+
async function runtimeActivateInstalledView(rec) {
|
|
6920
|
+
try {
|
|
6921
|
+
const d = canonLF(await getDoc());
|
|
6922
|
+
const html = String(await runtimeInvokeSkill(rec.skillId, { doc: d, ctx: viewCtx() }));
|
|
6923
|
+
validateViewOutput(html, { name: rec.name }); // throws → never activates (fail-loud, same as first-party)
|
|
6924
|
+
releaseAnchor();
|
|
6925
|
+
if (rwaMode !== 'document') { hideEditTransients(); closeRuntimePanels(); rwaMode = 'document'; emitRuntimeEvent('mode', { mode: rwaMode }); }
|
|
6926
|
+
activeView = { name: rec.name, label: rec.name, skillId: rec.skillId, __provenance: 'installed', __html: html, render() { return this.__html; } };
|
|
6927
|
+
if (typeof syncModeChrome === 'function') syncModeChrome();
|
|
6928
|
+
if (typeof syncViewChrome === 'function') syncViewChrome();
|
|
6929
|
+
renderDoc(d);
|
|
6930
|
+
} catch (e) {
|
|
6931
|
+
setStatus('err', '✗ ' + ((e && e.message) || 'view failed'));
|
|
6932
|
+
}
|
|
6933
|
+
}
|
|
6934
|
+
// I7 (v0.9 §8) — invoke an INSTALLED edit-surface skill: its run() returns an rwa-edit/1 envelope
|
|
6935
|
+
// (a deterministic, model-free transform), which is applied through the SAME validated commit path
|
|
6936
|
+
// the agent/lens use (frozen-zone + structural-shape guards, one ⌘Z), attributed to the skill.
|
|
6937
|
+
async function runtimeInvokeEditSurface(skillId, input) {
|
|
6938
|
+
const rec = installedSkills.get(skillId);
|
|
6939
|
+
if (!rec || rec.kind !== 'edit-surface') throw new Error('not an edit-surface skill');
|
|
6940
|
+
const envelope = await runtimeInvokeSkill(skillId, input || {});
|
|
6941
|
+
if (!envelope || typeof envelope !== 'object' || envelope.version !== 'rwa-edit/1') throw new Error('invalid_transform_output');
|
|
6942
|
+
return runtimeApplyEnvelope(envelope, { surface: 'skill:edit-surface', actor: 'skill:transform:' + String(skillId).slice(0, 8) });
|
|
6943
|
+
}
|
|
6445
6944
|
function rwaViewKey() { return 'rwa_view_active_' + DOC_UUID; }
|
|
6446
6945
|
function rwaSlideKey() { return 'rwa_view_slide_' + DOC_UUID; }
|
|
6447
6946
|
|
|
@@ -6655,6 +7154,43 @@ async function _skSourceGet(pubkey) {
|
|
|
6655
7154
|
if (!pubkey) return null;
|
|
6656
7155
|
try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
|
|
6657
7156
|
}
|
|
7157
|
+
// I6 (v0.9 §11) — TOFU author identity. Fingerprint = sha256(pubkey).hex[:16] (mirrors the
|
|
7158
|
+
// service's skillFingerprint). The per-author install count + first_seen come from rwa_sources (I5),
|
|
7159
|
+
// so the dialog can say "first time seeing this author" vs "trusted, N installs".
|
|
7160
|
+
async function _skFingerprint(pubkey) {
|
|
7161
|
+
const h = await _skSha256(_skUtf8(String(pubkey)));
|
|
7162
|
+
let s = ''; for (const b of h) s += b.toString(16).padStart(2, '0');
|
|
7163
|
+
return s.slice(0, 16);
|
|
7164
|
+
}
|
|
7165
|
+
async function _skTofu(pubkey) {
|
|
7166
|
+
const rec = await _skSourceGet(pubkey);
|
|
7167
|
+
const installs = (rec && rec.count) || 0;
|
|
7168
|
+
return { fingerprint: await _skFingerprint(pubkey), firstTime: installs === 0, installs };
|
|
7169
|
+
}
|
|
7170
|
+
// I6 (v0.9 §11) — marketplace discovery (opt-in network, like the ↗ share panel). discover →
|
|
7171
|
+
// GET /skills/index (paginated/filterable). fetch → GET /skills/index/:id → the full envelope,
|
|
7172
|
+
// VERIFIED client-side (WebCrypto Ed25519 via _skVerify) before any install; a revoked skill returns
|
|
7173
|
+
// {revoked:true}. The index only informs — install still runs the dialog + gates (the trust anchor).
|
|
7174
|
+
const SKILLS_INDEX_DEFAULT = 'https://rewritable.ikangai.com';
|
|
7175
|
+
async function runtimeDiscoverSkills(opts) {
|
|
7176
|
+
opts = opts || {};
|
|
7177
|
+
const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
|
|
7178
|
+
const qs = new URLSearchParams();
|
|
7179
|
+
for (const k of ['kind', 'author', 'search', 'verified_only', 'page', 'limit']) if (opts[k] != null) qs.set(k, String(opts[k]));
|
|
7180
|
+
const res = await fetch(base + '/skills/index' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
7181
|
+
if (!res.ok) throw new Error('discover_failed:' + res.status);
|
|
7182
|
+
return res.json(); // { entries, total, page, limit }
|
|
7183
|
+
}
|
|
7184
|
+
async function runtimeFetchSkillFromIndex(skillId, opts) {
|
|
7185
|
+
opts = opts || {};
|
|
7186
|
+
const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
|
|
7187
|
+
const res = await fetch(base + '/skills/index/' + encodeURIComponent(skillId));
|
|
7188
|
+
if (res.status === 410) return { revoked: true, envelope: null, verified: false };
|
|
7189
|
+
if (!res.ok) throw new Error('fetch_failed:' + res.status);
|
|
7190
|
+
const data = await res.json();
|
|
7191
|
+
const verified = await _skVerify(data.envelope); // client-side: never trust the index's `verified`
|
|
7192
|
+
return { envelope: data.envelope, metadata: data.metadata, verified, revoked: false };
|
|
7193
|
+
}
|
|
6658
7194
|
// Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
|
|
6659
7195
|
async function _skSourceRecord(pubkey, name, at) {
|
|
6660
7196
|
if (!pubkey || !name) return;
|
|
@@ -6713,28 +7249,99 @@ function _skB64(bytes) { let s = ''; for (const b of bytes) s += String.fromChar
|
|
|
6713
7249
|
async function _vaultLoadRec() {
|
|
6714
7250
|
if (_vaultRec) return _vaultRec;
|
|
6715
7251
|
let rec = null; try { rec = await idbGet(RWA.VAULT); } catch (_) {}
|
|
6716
|
-
if (!rec || typeof rec !== 'object') rec = { salt: _skB64(crypto.getRandomValues(new Uint8Array(16))), check: null, entries: {} };
|
|
7252
|
+
if (!rec || typeof rec !== 'object') rec = { salt: _skB64(crypto.getRandomValues(new Uint8Array(16))), kdf_version: 1, check: null, entries: {} }; // new vaults default to v1 (Argon2id); a loaded v0.8 record keeps its (absent→0) version
|
|
6717
7253
|
_vaultRec = rec; return rec;
|
|
6718
7254
|
}
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
7255
|
+
// rwa:argon2:begin ARGON2_SRC
|
|
7256
|
+
// Vendored Argon2id (pure JS) — @noble/hashes@2.2.0, MIT. Bundled by
|
|
7257
|
+
// tools/vendor-argon2.mjs (esbuild iife/min). Assigns globalThis._argon2id(pw,salt,
|
|
7258
|
+
// {t,m,p,dkLen,key?,ad?}). Used to build the blob: Worker in _argon2idViaWorker; the
|
|
7259
|
+
// frozen CSP is unchanged (no WASM, no eval). RFC-9106-pinned by tests/vault-kdf.mjs.
|
|
7260
|
+
const ARGON2_SRC = "(()=>{var q=BigInt(4294967295),ht=BigInt(32);function ut(e,t=!1){return t?{h:Number(e&q),l:Number(e>>ht&q)}:{h:Number(e>>ht&q)|0,l:Number(e&q)|0}}var j=(e,t,n)=>e>>>n|t<<32-n,C=(e,t,n)=>e<<32-n|t>>>n,J=(e,t,n)=>e<<64-n|t>>>n-32,Q=(e,t,n)=>e>>>n-32|t<<64-n,W=(e,t)=>t,K=(e,t)=>e;function rt(e,t,n,s){let f=(t>>>0)+(s>>>0);return{h:e+n+(f/2**32|0)|0,l:f|0}}var $=(e,t,n)=>(e>>>0)+(t>>>0)+(n>>>0),z=(e,t,n,s)=>t+n+s+(e/2**32|0)|0;function At(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name===\"Uint8Array\"&&\"BYTES_PER_ELEMENT\"in e&&e.BYTES_PER_ELEMENT===1}function G(e,t=\"\"){if(typeof e!=\"number\"){let n=t&&`\"${t}\" `;throw new TypeError(`${n}expected number, got ${typeof e}`)}if(!Number.isSafeInteger(e)||e<0){let n=t&&`\"${t}\" `;throw new RangeError(`${n}expected integer >= 0, got ${e}`)}}function H(e,t,n=\"\"){let s=At(e),f=e?.length,r=t!==void 0;if(!s||r&&f!==t){let i=n&&`\"${n}\" `,o=r?` of length ${t}`:\"\",h=s?`length=${f}`:`type=${typeof e}`,u=i+\"expected Uint8Array\"+o+\", got \"+h;throw s?new RangeError(u):new TypeError(u)}return e}function ot(e,t=!0){if(e.destroyed)throw new Error(\"Hash instance has been destroyed\");if(t&&e.finished)throw new Error(\"Hash#digest() has already been called\")}function at(e,t){H(e,void 0,\"digestInto() output\");let n=t.outputLen;if(e.length<n)throw new RangeError('\"digestInto() output\" expected to be of length >='+n)}function P(e){return new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}function I(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function E(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}var dt=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function pt(e){return e<<24&4278190080|e<<8&16711680|e>>>8&65280|e>>>24&255}var m=dt?e=>e:e=>pt(e)>>>0;function kt(e){for(let t=0;t<e.length;t++)e[t]=pt(e[t]);return e}var L=dt?e=>e:kt;function Lt(e){if(typeof e!=\"string\")throw new TypeError(\"string expected\");return new Uint8Array(new TextEncoder().encode(e))}function v(e,t=\"\"){return typeof e==\"string\"?Lt(e):H(e,void 0,t)}function xt(e,t={}){let n=(f,r)=>e(r).update(f).digest(),s=e(void 0);return n.outputLen=s.outputLen,n.blockLen=s.blockLen,n.canXOF=s.canXOF,n.create=f=>e(f),Object.assign(n,t),Object.freeze(n)}var yt=Uint8Array.from([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9,12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11,13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10,6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5,10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9]);var w=Uint32Array.from([4089235720,1779033703,2227873595,3144134277,4271175723,1013904242,1595750129,2773480762,2917565137,1359893119,725511199,2600822924,4215389547,528734635,327033209,1541459225]),c=new Uint32Array(32);function T(e,t,n,s,f,r){let i=f[r],o=f[r+1],h=c[2*e],u=c[2*e+1],l=c[2*t],a=c[2*t+1],x=c[2*n],y=c[2*n+1],d=c[2*s],p=c[2*s+1],B=$(h,l,i);u=z(B,u,a,o),h=B|0,{Dh:p,Dl:d}={Dh:p^u,Dl:d^h},{Dh:p,Dl:d}={Dh:W(p,d),Dl:K(p,d)},{h:y,l:x}=rt(y,x,p,d),{Bh:a,Bl:l}={Bh:a^y,Bl:l^x},{Bh:a,Bl:l}={Bh:j(a,l,24),Bl:C(a,l,24)},c[2*e]=h,c[2*e+1]=u,c[2*t]=l,c[2*t+1]=a,c[2*n]=x,c[2*n+1]=y,c[2*s]=d,c[2*s+1]=p}function S(e,t,n,s,f,r){let i=f[r],o=f[r+1],h=c[2*e],u=c[2*e+1],l=c[2*t],a=c[2*t+1],x=c[2*n],y=c[2*n+1],d=c[2*s],p=c[2*s+1],B=$(h,l,i);u=z(B,u,a,o),h=B|0,{Dh:p,Dl:d}={Dh:p^u,Dl:d^h},{Dh:p,Dl:d}={Dh:j(p,d,16),Dl:C(p,d,16)},{h:y,l:x}=rt(y,x,p,d),{Bh:a,Bl:l}={Bh:a^y,Bl:l^x},{Bh:a,Bl:l}={Bh:J(a,l,63),Bl:Q(a,l,63)},c[2*e]=h,c[2*e+1]=u,c[2*t]=l,c[2*t+1]=a,c[2*n]=x,c[2*n+1]=y,c[2*s]=d,c[2*s+1]=p}function Et(e,t={},n,s,f){if(G(n),e<=0||e>n)throw new Error(\"outputLen bigger than keyLen\");let{key:r,salt:i,personalization:o}=t;if(r!==void 0&&(r.length<1||r.length>n))throw new Error('\"key\" expected to be undefined or of length=1..'+n);i!==void 0&&H(i,s,\"salt\"),o!==void 0&&H(o,f,\"personalization\")}var it=class{buffer;buffer32;finished=!1;destroyed=!1;length=0;pos=0;blockLen;outputLen;canXOF=!1;constructor(t,n){G(t),G(n),this.blockLen=t,this.outputLen=n,this.buffer=new Uint8Array(t),this.buffer32=I(this.buffer)}update(t){ot(this),H(t);let{blockLen:n,buffer:s,buffer32:f}=this,r=t.length,i=t.byteOffset,o=t.buffer;for(let h=0;h<r;){this.pos===n&&(L(f),this.compress(f,0,!1),L(f),this.pos=0);let u=Math.min(n-this.pos,r-h),l=i+h;if(u===n&&!(l%4)&&h+u<r){let a=new Uint32Array(o,l,Math.floor((r-h)/4));L(a);for(let x=0;h+n<r;x+=f.length,h+=n)this.length+=n,this.compress(a,x,!1);L(a);continue}s.set(t.subarray(h,h+u),this.pos),this.pos+=u,this.length+=u,h+=u}return this}digestInto(t){ot(this),at(t,this);let{pos:n,buffer32:s}=this;if(this.finished=!0,E(this.buffer.subarray(n)),L(s),this.compress(s,0,!0),L(s),t.byteOffset&3)throw new RangeError('\"digestInto() output\" expected 4-byte aligned byteOffset, got '+t.byteOffset);let f=this.get(),r=I(t),i=Math.floor(this.outputLen/4);for(let l=0;l<i;l++)r[l]=m(f[l]);let o=this.outputLen%4;if(!o)return;let h=i*4,u=f[i];for(let l=0;l<o;l++)t[h+l]=u>>>8*l}digest(){let{buffer:t,outputLen:n}=this;this.digestInto(t);let s=t.slice(0,n);return this.destroy(),s}_cloneInto(t){let{buffer:n,length:s,finished:f,destroyed:r,outputLen:i,pos:o}=this;return t||=new this.constructor({dkLen:i}),t.set(...this.get()),t.buffer.set(n),t.destroyed=r,t.finished=f,t.length=s,t.pos=o,t.outputLen=i,t}clone(){return this._cloneInto()}},st=class extends it{v0l=w[0]|0;v0h=w[1]|0;v1l=w[2]|0;v1h=w[3]|0;v2l=w[4]|0;v2h=w[5]|0;v3l=w[6]|0;v3h=w[7]|0;v4l=w[8]|0;v4h=w[9]|0;v5l=w[10]|0;v5h=w[11]|0;v6l=w[12]|0;v6h=w[13]|0;v7l=w[14]|0;v7h=w[15]|0;constructor(t={}){let n=t.dkLen===void 0?64:t.dkLen;super(128,n),Et(n,t,64,16,16);let{key:s,personalization:f,salt:r}=t,i=0;if(s!==void 0&&(H(s,void 0,\"key\"),i=s.length),this.v0l^=this.outputLen|i<<8|65536|1<<24,r!==void 0){H(r,void 0,\"salt\");let o=I(r);this.v4l^=m(o[0]),this.v4h^=m(o[1]),this.v5l^=m(o[2]),this.v5h^=m(o[3])}if(f!==void 0){H(f,void 0,\"personalization\");let o=I(f);this.v6l^=m(o[0]),this.v6h^=m(o[1]),this.v7l^=m(o[2]),this.v7h^=m(o[3])}if(s!==void 0){let o=new Uint8Array(this.blockLen);o.set(s),this.update(o)}}get(){let{v0l:t,v0h:n,v1l:s,v1h:f,v2l:r,v2h:i,v3l:o,v3h:h,v4l:u,v4h:l,v5l:a,v5h:x,v6l:y,v6h:d,v7l:p,v7h:B}=this;return[t,n,s,f,r,i,o,h,u,l,a,x,y,d,p,B]}set(t,n,s,f,r,i,o,h,u,l,a,x,y,d,p,B){this.v0l=t|0,this.v0h=n|0,this.v1l=s|0,this.v1h=f|0,this.v2l=r|0,this.v2h=i|0,this.v3l=o|0,this.v3h=h|0,this.v4l=u|0,this.v4h=l|0,this.v5l=a|0,this.v5h=x|0,this.v6l=y|0,this.v6h=d|0,this.v7l=p|0,this.v7h=B|0}compress(t,n,s){this.get().forEach((h,u)=>c[u]=h),c.set(w,16);let{h:f,l:r}=ut(BigInt(this.length));c[24]=w[8]^r,c[25]=w[9]^f,s&&(c[28]=~c[28],c[29]=~c[29]);let i=0,o=yt;for(let h=0;h<12;h++)T(0,4,8,12,t,n+2*o[i++]),S(0,4,8,12,t,n+2*o[i++]),T(1,5,9,13,t,n+2*o[i++]),S(1,5,9,13,t,n+2*o[i++]),T(2,6,10,14,t,n+2*o[i++]),S(2,6,10,14,t,n+2*o[i++]),T(3,7,11,15,t,n+2*o[i++]),S(3,7,11,15,t,n+2*o[i++]),T(0,5,10,15,t,n+2*o[i++]),S(0,5,10,15,t,n+2*o[i++]),T(1,6,11,12,t,n+2*o[i++]),S(1,6,11,12,t,n+2*o[i++]),T(2,7,8,13,t,n+2*o[i++]),S(2,7,8,13,t,n+2*o[i++]),T(3,4,9,14,t,n+2*o[i++]),S(3,4,9,14,t,n+2*o[i++]);this.v0l^=c[0]^c[16],this.v0h^=c[1]^c[17],this.v1l^=c[2]^c[18],this.v1h^=c[3]^c[19],this.v2l^=c[4]^c[20],this.v2h^=c[5]^c[21],this.v3l^=c[6]^c[22],this.v3h^=c[7]^c[23],this.v4l^=c[8]^c[24],this.v4h^=c[9]^c[25],this.v5l^=c[10]^c[26],this.v5h^=c[11]^c[27],this.v6l^=c[12]^c[28],this.v6h^=c[13]^c[29],this.v7l^=c[14]^c[30],this.v7h^=c[15]^c[31],E(c)}destroy(){this.destroyed=!0,E(this.buffer32),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},N=xt(e=>new st(e));var et={Argond2d:0,Argon2i:1,Argon2id:2},F=4,bt=(e,t=\"\")=>e===void 0?Uint8Array.of():v(e,t);function ft(e,t){let n=e&65535,s=e>>>16,f=t&65535,r=t>>>16,i=Math.imul(n,f),o=Math.imul(s,f),h=Math.imul(n,r),u=Math.imul(s,r),l=(i>>>16)+(o&65535)+h,a=u+(o>>>16)+(l>>>16)|0,x=l<<16|i&65535;return{h:a,l:x}}function Ut(e,t){let{h:n,l:s}=ft(e,t);return{h:(n<<1|s>>>31)&4294967295,l:s<<1&4294967295}}function tt(e,t,n,s){let{h:f,l:r}=Ut(t,s),i=$(t,s,r);return{h:z(i,e,n,f),l:i|0}}var b=new Uint32Array(256);function D(e,t,n,s){let f=b[2*e],r=b[2*e+1],i=b[2*t],o=b[2*t+1],h=b[2*n],u=b[2*n+1],l=b[2*s],a=b[2*s+1];({h:r,l:f}=tt(r,f,o,i)),{Dh:a,Dl:l}={Dh:a^r,Dl:l^f},{Dh:a,Dl:l}={Dh:W(a,l),Dl:K(a,l)},{h:u,l:h}=tt(u,h,a,l),{Bh:o,Bl:i}={Bh:o^u,Bl:i^h},{Bh:o,Bl:i}={Bh:j(o,i,24),Bl:C(o,i,24)},{h:r,l:f}=tt(r,f,o,i),{Dh:a,Dl:l}={Dh:a^r,Dl:l^f},{Dh:a,Dl:l}={Dh:j(a,l,16),Dl:C(a,l,16)},{h:u,l:h}=tt(u,h,a,l),{Bh:o,Bl:i}={Bh:o^u,Bl:i^h},{Bh:o,Bl:i}={Bh:J(o,i,63),Bl:Q(o,i,63)},b[2*e]=f,b[2*e+1]=r,b[2*t]=i,b[2*t+1]=o,b[2*n]=h,b[2*n+1]=u,b[2*s]=l,b[2*s+1]=a}function wt(e,t,n,s,f,r,i,o,h,u,l,a,x,y,d,p){D(e,f,h,x),D(t,r,u,y),D(n,i,l,d),D(s,o,a,p),D(e,r,l,p),D(t,i,a,x),D(n,o,h,y),D(s,f,u,d)}function X(e,t,n,s,f){for(let r=0;r<256;r++)b[r]=e[t+r]^e[n+r];for(let r=0;r<128;r+=16)wt(r,r+1,r+2,r+3,r+4,r+5,r+6,r+7,r+8,r+9,r+10,r+11,r+12,r+13,r+14,r+15);for(let r=0;r<16;r+=2)wt(r,r+1,r+16,r+17,r+32,r+33,r+48,r+49,r+64,r+65,r+80,r+81,r+96,r+97,r+112,r+113);if(f)for(let r=0;r<256;r++)e[s+r]^=b[r]^e[t+r]^e[n+r];else for(let r=0;r<256;r++)e[s+r]=b[r]^e[t+r]^e[n+r];E(b)}function ct(e,t){let n=P(e),s=new Uint32Array(1),f=P(s);if(s[0]=m(t),t<=64)return N.create({dkLen:t}).update(f).update(n).digest();let r=new Uint8Array(t),i=N.create({}).update(f).update(n).digest(),o=0;for(r.set(i.subarray(0,32)),o+=32;t-o>64;o+=32){let h=N.create({}).update(i);h.digestInto(i),h.destroy(),r.set(i.subarray(0,32),o)}return r.set(N(i,{dkLen:t-o}),o),E(i,s),r}function Ht(e,t,n,s,f,r,i=!1){let o;e===0?t===0?o=f-1:i?o=t*s+f-1:o=t*s+(f==0?-1:0):i?o=n-s+f-1:o=n-s+(f==0?-1:0);let h=e!==0&&t!==F-1?(t+1)*s:0,u=o-1-ft(o,ft(r,r).h).h;return(h+u)%n}var gt=Math.pow(2,32);function _(e){return Number.isSafeInteger(e)&&e>=0&&e<gt}function It(e){let t={version:19,dkLen:32,maxmem:gt-1,asyncTick:10};for(let[u,l]of Object.entries(e))l!==void 0&&(t[u]=l);let{dkLen:n,p:s,m:f,t:r,version:i,onProgress:o,asyncTick:h}=t;if(!_(n)||n<4)throw new Error('\"dkLen\" must be 4..');if(!_(s)||s<1||s>=Math.pow(2,24))throw new Error('\"p\" must be 1..2^24');if(!_(f))throw new Error('\"m\" must be 0..2^32');if(!_(r)||r<1)throw new Error('\"t\" (iterations) must be 1..2^32');if(o!==void 0&&typeof o!=\"function\")throw new Error('\"progressCb\" must be a function');if(G(h,\"asyncTick\"),!_(f)||f<8*s)throw new Error('\"m\" (memory) must be at least 8*p bytes');if(i!==16&&i!==19)throw new Error('\"version\" must be 0x10 or 0x13, got '+i);return t}function Tt(e,t,n,s){if(e=v(e,\"password\"),t=v(t,\"salt\"),!_(e.length))throw new Error('\"password\" must be less of length 1..4Gb');if(!_(t.length)||t.length<8)throw new Error('\"salt\" must be of length 8..4Gb');if(!Object.values(et).includes(n))throw new Error('\"type\" was invalid');let{p:f,dkLen:r,m:i,t:o,version:h,key:u,personalization:l,maxmem:a,onProgress:x,asyncTick:y}=It(s);u=bt(u,\"key\"),l=bt(l,\"personalization\");let d=N.create(),p=new Uint32Array(1),B=P(p);for(let A of[f,r,i,o,h,n])p[0]=m(A),d.update(B);for(let A of[e,t,u,l])p[0]=m(A.length),d.update(B).update(A);let g=new Uint32Array(18),k=P(g);d.digestInto(k);let O=f,M=4*f*Math.floor(i/(F*f)),U=Math.floor(M/f),V=Math.floor(U/F),R=M*1024;if(!_(a))throw new Error('\"maxmem\" expected <2**32, got '+a);if(R>a)throw new Error('\"maxmem\" limit was hit: memUsed(mP*1024)='+R+\", maxmem=\"+a);let nt=new Uint32Array(R/4);for(let A=0;A<f;A++){let Y=256*U*A;g[17]=m(A),g[16]=m(0),nt.set(L(I(ct(g,1024))),Y),g[16]=m(1),nt.set(L(I(ct(g,1024))),Y+256)}let lt=()=>{};if(x){let A=o*F*f*V-2*f,Y=Math.max(Math.floor(A/1e4),1),Z=0;lt=()=>{Z++,x&&(!(Z%Y)||Z===A)&&x(Z/A)}}return E(p,g),{type:n,mP:M,p:f,t:o,version:h,B:nt,laneLen:U,lanes:O,segmentLen:V,dkLen:r,perBlock:lt,asyncTick:y}}function St(e,t,n,s){let f=new Uint32Array(256);for(let i=0;i<t;i++)for(let o=0;o<256;o++)f[o]^=e[256*(n*i+n-1)+o];let r=ct(L(f),s);return E(e,f),r}function Dt(e,t,n,s,f,r,i,o,h,u,l,a,x){u%i&&(l=u-1);let y,d;if(a){let k=r%128;k===0&&(t[268]++,X(t,256,2*256,0,!1),X(t,0,2*256,0,!1)),y=t[2*k],d=t[2*k+1]}else{let k=256*l;y=e[k],d=e[k+1]}let p=s===0&&f===0?n:d%h,B=Ht(s,f,i,o,r,y,p==n),g=i*p+B;X(e,256*l,256*g,u*256,x)}function _t(e,t,n,s){let{mP:f,p:r,t:i,version:o,B:h,laneLen:u,lanes:l,segmentLen:a,dkLen:x,perBlock:y}=Tt(t,n,e,s),d=new Uint32Array(3*256);d[262]=f,d[264]=i,d[266]=e;for(let p=0;p<i;p++){let B=p!==0&&o===19;d[256]=p;for(let g=0;g<F;g++){d[260]=g;let k=e==et.Argon2i||e==et.Argon2id&&p===0&&g<2;for(let O=0;O<r;O++){d[258]=O,d[268]=0;let M=0;p===0&&g===0&&(M=2,k&&(d[268]++,X(d,256,2*256,0,!1),X(d,0,2*256,0,!1)));let U=O*u+g*a+M,V=U%u?U-1:U+u-1;for(let R=M;R<a;R++,U++,V++)y(),Dt(h,d,O,p,g,R,u,a,l,U,V,k,B)}}}return E(d),St(h,r,u,x)}var mt=(e,t,n)=>_t(et.Argon2id,e,t,n);globalThis._argon2id=function(e,t,n){return n=n||{},mt(e,t,{t:n.t,m:n.m,p:n.p,dkLen:n.dkLen||32,key:n.key,personalization:n.ad})};})();";
|
|
7261
|
+
// rwa:argon2:end ARGON2_SRC
|
|
7262
|
+
function _argon2idViaWorker(pwBytes, saltBytes, params) {
|
|
7263
|
+
return new Promise((resolve, reject) => {
|
|
7264
|
+
let url = null, w = null;
|
|
7265
|
+
const TAIL = '\nself.onmessage=function(e){try{var d=e.data;var h=self._argon2id(d.pw,d.salt,{t:d.t,m:d.m,p:d.p,dkLen:d.dkLen});self.postMessage({hash:h});}catch(err){self.postMessage({error:String(err&&err.message||err)});}};';
|
|
7266
|
+
try { url = URL.createObjectURL(new Blob([ARGON2_SRC + TAIL], { type: 'text/javascript' })); w = new Worker(url); }
|
|
7267
|
+
catch (_) { if (url) { try { URL.revokeObjectURL(url); } catch (e) {} } reject(new Error('vault_kdf_unavailable')); return; }
|
|
7268
|
+
let settled = false;
|
|
7269
|
+
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(to); try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(url); } catch (_) {} fn(arg); };
|
|
7270
|
+
const to = setTimeout(() => finish(reject, new Error('vault_kdf_timeout')), 15000);
|
|
7271
|
+
w.onmessage = (e) => { if (e.data && e.data.error) finish(reject, new Error('vault_kdf_error')); else finish(resolve, new Uint8Array(e.data.hash)); };
|
|
7272
|
+
w.onerror = () => finish(reject, new Error('vault_kdf_error'));
|
|
7273
|
+
w.postMessage({ pw: pwBytes, salt: saltBytes, t: params.t, m: params.m, p: params.p, dkLen: params.dkLen });
|
|
7274
|
+
});
|
|
6722
7275
|
}
|
|
6723
|
-
|
|
7276
|
+
// Argon2id is synchronous (~1.5s @ 64 MiB) — run it in a transient blob: Worker so the
|
|
7277
|
+
// unlock never freezes the page (the frozen CSP already allows worker-src blob:; no WASM,
|
|
7278
|
+
// no eval). No Worker (jsdom / very old engines) → a sync fallback uses an injected
|
|
7279
|
+
// globalThis._argon2id, else fails loud (Workers are already a skill-host requirement).
|
|
7280
|
+
function _argon2idHash(pwBytes, saltBytes, params) {
|
|
7281
|
+
if (typeof Worker !== 'undefined' && typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) return _argon2idViaWorker(pwBytes, saltBytes, params);
|
|
7282
|
+
if (typeof globalThis._argon2id === 'function') { try { return Promise.resolve(globalThis._argon2id(pwBytes, saltBytes, params)); } catch (_) { return Promise.reject(new Error('vault_kdf_error')); } }
|
|
7283
|
+
return Promise.reject(new Error('vault_kdf_unavailable'));
|
|
7284
|
+
}
|
|
7285
|
+
// _vaultDeriveKey(passphrase, saltB64, kdfVersion=0): 0 = PBKDF2-200k (v0.8 + I13 transport
|
|
7286
|
+
// callers default here, byte-unchanged); 1 = Argon2id(m=64 MiB,t=3,p=4) — memory-hard (I9 §13).
|
|
7287
|
+
async function _vaultDeriveKey(passphrase, saltB64, kdfVersion = 0) {
|
|
7288
|
+
if (kdfVersion === 0) {
|
|
7289
|
+
const km = await crypto.subtle.importKey('raw', _skUtf8(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
7290
|
+
return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: _skFromB64(saltB64), iterations: 200000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
7291
|
+
}
|
|
7292
|
+
if (kdfVersion === 1) {
|
|
7293
|
+
const hash = await _argon2idHash(_skUtf8(passphrase), _skFromB64(saltB64), { t: 3, m: 65536, p: 4, dkLen: 32 }); // m=65536 KiB = 64 MiB
|
|
7294
|
+
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
|
7295
|
+
}
|
|
7296
|
+
throw new Error('vault_unknown_kdf_version');
|
|
7297
|
+
}
|
|
7298
|
+
async function _encWith(key, text) {
|
|
6724
7299
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
6725
|
-
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv },
|
|
7300
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, _skUtf8(text)));
|
|
6726
7301
|
return { iv: _skB64(iv), ct: _skB64(ct) };
|
|
6727
7302
|
}
|
|
6728
|
-
async function
|
|
6729
|
-
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) },
|
|
6730
|
-
}
|
|
6731
|
-
|
|
7303
|
+
async function _decWith(key, entry) {
|
|
7304
|
+
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, key, _skFromB64(entry.ct)));
|
|
7305
|
+
}
|
|
7306
|
+
function _vaultEnc(text) { return _encWith(_vaultKey, text); }
|
|
7307
|
+
function _vaultDec(entry) { return _decWith(_vaultKey, entry); }
|
|
7308
|
+
// runtimeVaultUnlock(passphrase, options?:{targetKdfVersion}) — derive under the record's
|
|
7309
|
+
// kdf_version (0=PBKDF2, 1=Argon2id; missing→0). targetKdfVersion>current migrates the vault
|
|
7310
|
+
// (I9 §13): re-derive + re-encrypt check + every entry under the new KDF, committed as ONE
|
|
7311
|
+
// record write; _vaultKey/_vaultRec are assigned ONLY after the put resolves, so a
|
|
7312
|
+
// mid-migration failure leaves the vault locked and the stored record intact (Inv 42/43).
|
|
7313
|
+
async function runtimeVaultUnlock(passphrase, options) {
|
|
7314
|
+
const opts = options || {};
|
|
6732
7315
|
const rec = await _vaultLoadRec();
|
|
6733
|
-
const
|
|
6734
|
-
_vaultKey =
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
7316
|
+
const cur = (rec.kdf_version == null) ? 0 : rec.kdf_version;
|
|
7317
|
+
if (cur !== 0 && cur !== 1) { _vaultKey = null; throw new Error('vault_unknown_kdf_version'); }
|
|
7318
|
+
const target = opts.targetKdfVersion;
|
|
7319
|
+
if (target != null && target !== 0 && target !== 1) { _vaultKey = null; throw new Error('vault_unknown_kdf_version'); }
|
|
7320
|
+
if (rec.check == null) {
|
|
7321
|
+
// new / empty vault: adopt the latest KDF (default v1) — covers new-vault-default AND
|
|
7322
|
+
// auto-migrate-on-empty (no data at risk); an explicit target wins.
|
|
7323
|
+
const useVer = (target != null) ? target : 1;
|
|
7324
|
+
const key = await _vaultDeriveKey(passphrase, rec.salt, useVer);
|
|
7325
|
+
rec.kdf_version = useVer;
|
|
7326
|
+
rec.check = await _encWith(key, 'rwa-vault-ok');
|
|
7327
|
+
try { await idbPut(RWA.VAULT, rec); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); }
|
|
7328
|
+
_vaultKey = key;
|
|
7329
|
+
try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', key)))); } catch (_) {}
|
|
7330
|
+
return true;
|
|
7331
|
+
}
|
|
7332
|
+
const oldKey = await _vaultDeriveKey(passphrase, rec.salt, cur);
|
|
7333
|
+
try { await _decWith(oldKey, rec.check); } catch (_) { _vaultKey = null; throw new Error('vault_bad_passphrase'); }
|
|
7334
|
+
if (target != null && target > cur) {
|
|
7335
|
+
const newKey = await _vaultDeriveKey(passphrase, rec.salt, target);
|
|
7336
|
+
const next = { salt: rec.salt, kdf_version: target, check: await _encWith(newKey, 'rwa-vault-ok'), entries: {} };
|
|
7337
|
+
for (const k of Object.keys(rec.entries)) next.entries[k] = await _encWith(newKey, await _decWith(oldKey, rec.entries[k]));
|
|
7338
|
+
try { await idbPut(RWA.VAULT, next); } catch (_) { _vaultKey = null; throw new Error('vault_storage_error'); }
|
|
7339
|
+
_vaultRec = next; _vaultKey = newKey;
|
|
7340
|
+
try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', newKey)))); } catch (_) {}
|
|
7341
|
+
return true;
|
|
7342
|
+
}
|
|
7343
|
+
_vaultKey = oldKey;
|
|
7344
|
+
try { sessionStorage.setItem('rwa_vault_key', _skB64(new Uint8Array(await crypto.subtle.exportKey('raw', oldKey)))); } catch (_) {}
|
|
6738
7345
|
return true;
|
|
6739
7346
|
}
|
|
6740
7347
|
async function _vaultReimportSession() {
|
|
@@ -6766,6 +7373,64 @@ async function runtimeVaultNamespaces() {
|
|
|
6766
7373
|
for (const k of Object.keys(rec.entries)) { const i = k.indexOf('\0'); if (i > 0) set.add(k.slice(0, i)); }
|
|
6767
7374
|
return Array.from(set);
|
|
6768
7375
|
}
|
|
7376
|
+
// ── I13 (v0.9 §14) — portable vault EXPORT/IMPORT (offline; escrow + account service deferred to
|
|
7377
|
+
// v1). A version-tagged, self-contained `rwa-vault-export/1` file: selected namespaces re-encrypted
|
|
7378
|
+
// under a SEPARATE transport passphrase (PBKDF2-200k + AES-256-GCM, per-namespace salt + check), so
|
|
7379
|
+
// it decrypts on another machine with only the passphrase — no server. The machine-local vault stays
|
|
7380
|
+
// the default; this travels ONLY on an explicit user action. Requires the vault unlocked (to read
|
|
7381
|
+
// plaintext to re-wrap). Never logs the passphrase. CLI/Worker have no access (UI/runtime action only).
|
|
7382
|
+
async function runtimeVaultExport(passphrase, namespaces) {
|
|
7383
|
+
if (!_vaultKey) throw new Error('vault_locked');
|
|
7384
|
+
if (!passphrase) throw new Error('vault_bad_passphrase');
|
|
7385
|
+
const rec = await _vaultLoadRec();
|
|
7386
|
+
const allNs = await runtimeVaultNamespaces();
|
|
7387
|
+
const sel = (Array.isArray(namespaces) && namespaces.length) ? namespaces.filter(n => allNs.includes(n)) : allNs;
|
|
7388
|
+
const out = { rwa: 'rwa-vault-export/1', containerUuid: DOC_UUID, exportedAt: Date.now(), namespaces: sel, entries: {} };
|
|
7389
|
+
for (const ns of sel) {
|
|
7390
|
+
const salt = _skB64(crypto.getRandomValues(new Uint8Array(16)));
|
|
7391
|
+
const ekey = await _vaultDeriveKey(passphrase, salt);
|
|
7392
|
+
const enc = async (text) => { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ekey, _skUtf8(text))); return { iv: _skB64(iv), ct: _skB64(ct) }; };
|
|
7393
|
+
const items = [];
|
|
7394
|
+
for (const k of Object.keys(rec.entries)) {
|
|
7395
|
+
const i = k.indexOf('\0'); if (i <= 0 || k.slice(0, i) !== ns) continue;
|
|
7396
|
+
const plain = await _vaultDec(rec.entries[k]); // decrypt under the LOCAL vault key…
|
|
7397
|
+
items.push(Object.assign({ key: k.slice(i + 1) }, await enc(plain))); // …re-encrypt under the export key
|
|
7398
|
+
}
|
|
7399
|
+
out.entries[ns] = { salt, check: await enc('rwa-vault-export-ok'), items };
|
|
7400
|
+
}
|
|
7401
|
+
return out;
|
|
7402
|
+
}
|
|
7403
|
+
async function runtimeVaultImport(exportObj, passphrase, opts) {
|
|
7404
|
+
opts = opts || {};
|
|
7405
|
+
if (!_vaultKey) throw new Error('vault_locked'); // need the local key to re-wrap imported items
|
|
7406
|
+
if (!exportObj || exportObj.rwa !== 'rwa-vault-export/1' || !exportObj.entries || typeof exportObj.entries !== 'object') throw new Error('account_export_malformed');
|
|
7407
|
+
const rec = await _vaultLoadRec();
|
|
7408
|
+
const result = { imported: 0, skipped: 0, namespaces: [], containerMismatch: exportObj.containerUuid !== DOC_UUID };
|
|
7409
|
+
for (const ns of Object.keys(exportObj.entries)) {
|
|
7410
|
+
const e = exportObj.entries[ns];
|
|
7411
|
+
if (!e || typeof e.salt !== 'string' || !Array.isArray(e.items)) throw new Error('account_export_malformed');
|
|
7412
|
+
const ekey = await _vaultDeriveKey(passphrase, e.salt);
|
|
7413
|
+
const dec = async (entry) => new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, ekey, _skFromB64(entry.ct)));
|
|
7414
|
+
if (e.check) { try { await dec(e.check); } catch (_) { throw new Error('vault_decrypt_failed'); } } // wrong passphrase fails here, before any write
|
|
7415
|
+
for (const it of e.items) {
|
|
7416
|
+
let plain; try { plain = await dec(it); } catch (_) { throw new Error('vault_decrypt_failed'); }
|
|
7417
|
+
const dk = ns + '\0' + it.key;
|
|
7418
|
+
if (rec.entries[dk] && !opts.overwrite) { result.skipped++; continue; } // don't clobber without explicit overwrite
|
|
7419
|
+
rec.entries[dk] = await _vaultEnc(plain); // re-encrypt under the LOCAL vault key (usable immediately)
|
|
7420
|
+
result.imported++;
|
|
7421
|
+
}
|
|
7422
|
+
result.namespaces.push(ns);
|
|
7423
|
+
}
|
|
7424
|
+
try { await idbPut(RWA.VAULT, rec); } catch (_) { throw new Error('vault_storage_error'); }
|
|
7425
|
+
return result;
|
|
7426
|
+
}
|
|
7427
|
+
// I13 — live-only account identity (opt-in, sessionStorage rwa_account; default null). Never stamped
|
|
7428
|
+
// into the file; never exposed to skill code (UI/describe only). Escrow/account-service deferred to v1.
|
|
7429
|
+
function runtimeAccountIdentity() {
|
|
7430
|
+
let raw = null; try { raw = sessionStorage.getItem('rwa_account'); } catch (_) { return null; }
|
|
7431
|
+
if (!raw) return null;
|
|
7432
|
+
try { const a = JSON.parse(raw); return a && a.mode ? { mode: a.mode, accountId: a.accountId || null, lastSync: a.lastSync || null } : null; } catch (_) { return null; }
|
|
7433
|
+
}
|
|
6769
7434
|
// §6 — the bridge's per-call vault gate (mirror of cli/src/skill-manifest.mjs vaultNamespaceAllowed).
|
|
6770
7435
|
function _skVaultAllowed(skill, ns) {
|
|
6771
7436
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
@@ -6776,6 +7441,9 @@ function _skBusAllowed(skill, topic) {
|
|
|
6776
7441
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6777
7442
|
return perms.indexOf('bus:' + topic) !== -1;
|
|
6778
7443
|
}
|
|
7444
|
+
// §5 (I1b) — per-message subscribe filter. true for all today; I12 wires a peer-allowlist here
|
|
7445
|
+
// (defense-in-depth — a declared bus: perm can't be further runtime-restricted; Shape B holds).
|
|
7446
|
+
function _skBusMessageAllowed(_skill, _envelope) { return true; }
|
|
6779
7447
|
// §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
|
|
6780
7448
|
// a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
|
|
6781
7449
|
function _skFsAllowed(skill, path) {
|
|
@@ -6955,6 +7623,13 @@ function _skValidateInstall(skill, vr) {
|
|
|
6955
7623
|
if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
|
|
6956
7624
|
// §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
|
|
6957
7625
|
if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
|
|
7626
|
+
// §8 (I7): view/edit-surface are zero-capability DOM authors — reject any permission + require a
|
|
7627
|
+
// matching typed output contract (view → html-render, edit-surface → dom-transform).
|
|
7628
|
+
if (skill.kind === 'view' || skill.kind === 'edit-surface') {
|
|
7629
|
+
if (perms.length > 0) errors.push('output_skill_with_permissions');
|
|
7630
|
+
const want = skill.kind === 'view' ? 'html-render' : 'dom-transform';
|
|
7631
|
+
if (!skill.output || skill.output.kind !== want) errors.push('invalid_output_kind');
|
|
7632
|
+
}
|
|
6958
7633
|
if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
6959
7634
|
// Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
|
|
6960
7635
|
if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
|
|
@@ -7004,6 +7679,8 @@ async function runtimeReviewSkill(envelope) {
|
|
|
7004
7679
|
} catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
|
|
7005
7680
|
// I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
|
|
7006
7681
|
const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
|
|
7682
|
+
// I6 — TOFU author identity (fingerprint + per-author install count) for the install dialog.
|
|
7683
|
+
const tofu = await _skTofu(skill.author_pubkey);
|
|
7007
7684
|
return {
|
|
7008
7685
|
name: skill.name, version: skill.version, kind: skill.kind,
|
|
7009
7686
|
purpose: skill.description || '(no description provided)',
|
|
@@ -7011,7 +7688,7 @@ async function runtimeReviewSkill(envelope) {
|
|
|
7011
7688
|
permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
|
|
7012
7689
|
compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
|
|
7013
7690
|
lookalike, lookalikeKind, lookalikeBlock,
|
|
7014
|
-
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
|
|
7691
|
+
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange, tofu,
|
|
7015
7692
|
gates: _skValidateInstall(skill, vr), update,
|
|
7016
7693
|
};
|
|
7017
7694
|
}
|
|
@@ -7070,6 +7747,7 @@ async function runtimeInstallSkill(envelope) {
|
|
|
7070
7747
|
// I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
|
|
7071
7748
|
// install). On a same-key rename the new name is appended; identity stays anchored on the key.
|
|
7072
7749
|
await _skSourceRecord(skill.author_pubkey, skill.name);
|
|
7750
|
+
_skEvictPool(id); // I2 — an install/update may change the code → drop any stale pooled Workers
|
|
7073
7751
|
return { ok: true, skillId: id };
|
|
7074
7752
|
}
|
|
7075
7753
|
// §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
|
|
@@ -7077,6 +7755,7 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
7077
7755
|
const prev = installedSkills.get(skillId);
|
|
7078
7756
|
if (!prev) return { ok: false, errors: ['not_installed'] };
|
|
7079
7757
|
installedSkills.delete(skillId);
|
|
7758
|
+
_skEvictPool(skillId); // I2 — drop any pooled Workers for the removed skill
|
|
7080
7759
|
try {
|
|
7081
7760
|
await runtimeRegionCommit({ regions: [_skSkillsRegion()], actor: 'skill:uninstall', reachability: 'frozen' });
|
|
7082
7761
|
} catch (e) {
|
|
@@ -7253,6 +7932,8 @@ function showSkillInstallDialog(envelope) {
|
|
|
7253
7932
|
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
|
|
7254
7933
|
'<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
|
|
7255
7934
|
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
|
|
7935
|
+
// I6 — TOFU author identity: the key fingerprint + whether you've installed from this author before.
|
|
7936
|
+
(rv.tofu ? '<p style="margin:.2em 0;color:#555">🔑 Author fingerprint: <code>' + _skEsc(rv.tofu.fingerprint) + '</code>. ' + (rv.tofu.firstTime ? 'First time seeing this author.' : 'Trusted — ' + rv.tofu.installs + ' previous install' + (rv.tofu.installs === 1 ? '' : 's') + '.') + '</p>' : '') +
|
|
7256
7937
|
lookalikeHtml +
|
|
7257
7938
|
nameChangeHtml +
|
|
7258
7939
|
updHtml +
|
|
@@ -7346,13 +8027,13 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
7346
8027
|
'use strict';
|
|
7347
8028
|
var REMOVE = ['importScripts','Worker','SharedWorker','ServiceWorkerContainer','XMLHttpRequest','WebSocket','EventSource','indexedDB','eval','Function','fetch','WebAssembly'];
|
|
7348
8029
|
for (var i=0;i<REMOVE.length;i++){ try { Object.defineProperty(self, REMOVE[i], { value: undefined, writable: false, configurable: false }); } catch(_e){} }
|
|
7349
|
-
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={};
|
|
8030
|
+
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={}, _subs={};
|
|
7350
8031
|
function _bridge(type, payload){ return new Promise(function(res,rej){ var id=++_seq; _pending.set(id,{res:res,rej:rej}); self.postMessage({ type:type, id:id, identity_tag:IDENTITY, payload:payload }); }); }
|
|
7351
8032
|
function _serializeOpts(o){ if(!o||typeof o!=='object') return undefined; var out={}; if(o.method) out.method=String(o.method); if(typeof o.body==='string') out.body=o.body; if(o.headers&&typeof o.headers==='object') out.headers=o.headers; return out; }
|
|
7352
8033
|
function _installBridge(){
|
|
7353
8034
|
RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
|
|
7354
8035
|
RUNTIME.vault={ get:function(ns,k){ return _bridge('bridge:vault',{op:'get',ns:ns,key:k}); }, set:function(ns,k,v){ return _bridge('bridge:vault',{op:'set',ns:ns,key:k,val:v}); }, has:function(ns,k){ return _bridge('bridge:vault',{op:'has',ns:ns,key:k}); } };
|
|
7355
|
-
RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); } };
|
|
8036
|
+
RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); }, subscribe:function(topic,cb){ var t=String(topic); return _bridge('bridge:bus:subscribe',{ topic:t }).then(function(){ (_subs[t]=_subs[t]||[]).push(cb); return function(){ var a=_subs[t]||[], i=a.indexOf(cb); if(i>=0) a.splice(i,1); try{ _bridge('bridge:bus:unsubscribe',{ topic:t }); }catch(_e){} }; }); } };
|
|
7356
8037
|
RUNTIME.fs={ read:function(p){ return _bridge('bridge:fs',{op:'read',path:String(p)}); }, write:function(p,d){ return _bridge('bridge:fs',{op:'write',path:String(p),data:d}); }, del:function(p){ return _bridge('bridge:fs',{op:'del',path:String(p)}); }, list:function(p){ return _bridge('bridge:fs',{op:'list',path:String(p)}); } };
|
|
7357
8038
|
RUNTIME.db={ get:function(s,k){ return _bridge('bridge:idb',{op:'get',store:String(s),key:k}); }, put:function(s,k,v){ return _bridge('bridge:idb',{op:'put',store:String(s),key:k,value:v}); }, del:function(s,k){ return _bridge('bridge:idb',{op:'del',store:String(s),key:k}); }, all:function(s){ return _bridge('bridge:idb',{op:'all',store:String(s)}); } };
|
|
7358
8039
|
}
|
|
@@ -7366,10 +8047,85 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
7366
8047
|
.catch(function(err){ self.postMessage({ type:'result', id:msg.id, identity_tag:IDENTITY, ok:false, error:String(err&&err.message||err) }); });
|
|
7367
8048
|
return;
|
|
7368
8049
|
}
|
|
8050
|
+
if(msg.type==='bus:message'){ var env=msg.envelope||{}, subs=_subs[env.topic]||[]; for(var i=0;i<subs.length;i++){ try{ subs[i](env); }catch(_e){} } return; } // I1b — deliver to skill-side subscribers
|
|
8051
|
+
if(msg.type==='shutdown'){ try{ self.postMessage({ type:'shutdown_ack', identity_tag:null }); }catch(_e){} return; } // I2 — pool drain handshake
|
|
7369
8052
|
};
|
|
7370
8053
|
})();
|
|
7371
8054
|
`;
|
|
7372
8055
|
|
|
8056
|
+
// ── I2 (v0.9 §10) — optional compute-Worker POOL. DISABLED BY DEFAULT: only an explicit
|
|
8057
|
+
// poolingHint {pooling:'enabled'} on a COMPUTE skill (no role) takes this path; every other invoke
|
|
8058
|
+
// (tools, agent-role, no-hint) rides the byte-unchanged spawn→invoke→terminate path below. Pooled
|
|
8059
|
+
// Workers are compute-only (bridgeless, same worker-scoped CSP), keyed by skillId+code-hash (a code
|
|
8060
|
+
// change evicts the pool), bounded by an idle timeout + a hard cap, and drained on shutdown. Per-
|
|
8061
|
+
// invocation isolation is unchanged: each invoke re-inits a fresh identity_tag and races the 5s
|
|
8062
|
+
// timeout; a timeout/error terminates the Worker (never returns it to the pool). Statelessness is
|
|
8063
|
+
// the author's responsibility (Inv 25; no global reset between invokes — documented "pool only if pure").
|
|
8064
|
+
const SKILL_POOLS = new Map(); // skillId → { codeHash, idle:[Worker], lastUsed:Map<Worker,ts> }
|
|
8065
|
+
const SKILL_POOL_CAP = Math.min(4, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 1);
|
|
8066
|
+
let SKILL_POOL_IDLE_MS = 60000; // mutable so the browser proof can shorten it
|
|
8067
|
+
async function _skCodeHash(skillId, code) {
|
|
8068
|
+
return _skB64url(await _skSha256(_skConcat(_skUtf8(String(skillId)), _skNUL, _skUtf8(String(code || '')))));
|
|
8069
|
+
}
|
|
8070
|
+
function _skSpawnComputeWorker(skill) {
|
|
8071
|
+
const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
|
|
8072
|
+
const url = URL.createObjectURL(blob);
|
|
8073
|
+
const w = new Worker(url); w.__rwaUrl = url; return w;
|
|
8074
|
+
}
|
|
8075
|
+
function _skKillWorker(w) { try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(w.__rwaUrl); } catch (_) {} }
|
|
8076
|
+
function _skEnforcePoolCap(pool) {
|
|
8077
|
+
while (pool.idle.length > SKILL_POOL_CAP) {
|
|
8078
|
+
let oi = 0; for (let i = 1; i < pool.idle.length; i++) if ((pool.lastUsed.get(pool.idle[i]) || 0) < (pool.lastUsed.get(pool.idle[oi]) || 0)) oi = i;
|
|
8079
|
+
const [w] = pool.idle.splice(oi, 1); pool.lastUsed.delete(w); _skKillWorker(w); // evict oldest-idle
|
|
8080
|
+
}
|
|
8081
|
+
}
|
|
8082
|
+
function _skEvictPool(skillId) { // code-hash may change on install/update/uninstall → drop stale Workers
|
|
8083
|
+
const pool = SKILL_POOLS.get(skillId);
|
|
8084
|
+
if (pool) { pool.idle.forEach(_skKillWorker); SKILL_POOLS.delete(skillId); }
|
|
8085
|
+
}
|
|
8086
|
+
function _skPoolEvictIdle() { // background sweep: terminate Workers idle ≥ SKILL_POOL_IDLE_MS
|
|
8087
|
+
const now = Date.now();
|
|
8088
|
+
for (const pool of SKILL_POOLS.values())
|
|
8089
|
+
pool.idle = pool.idle.filter(w => { if (now - (pool.lastUsed.get(w) || 0) >= SKILL_POOL_IDLE_MS) { pool.lastUsed.delete(w); _skKillWorker(w); return false; } return true; });
|
|
8090
|
+
}
|
|
8091
|
+
async function _skPoolShutdown() { // send shutdown, 500ms grace, terminate (idempotent)
|
|
8092
|
+
const all = [];
|
|
8093
|
+
for (const pool of SKILL_POOLS.values()) for (const w of pool.idle) all.push(w);
|
|
8094
|
+
for (const w of all) { try { w.postMessage({ type: 'shutdown', identity_tag: null }); } catch (_) {} }
|
|
8095
|
+
await new Promise(r => setTimeout(r, 500));
|
|
8096
|
+
for (const w of all) _skKillWorker(w);
|
|
8097
|
+
SKILL_POOLS.clear();
|
|
8098
|
+
}
|
|
8099
|
+
function runtimePoolStats() {
|
|
8100
|
+
const pools = {}; let live = 0;
|
|
8101
|
+
for (const [id, pool] of SKILL_POOLS) { pools[id] = pool.idle.length; live += pool.idle.length; }
|
|
8102
|
+
return { live, cap: SKILL_POOL_CAP, idleMs: SKILL_POOL_IDLE_MS, pools };
|
|
8103
|
+
}
|
|
8104
|
+
// Run ONE invocation on a pooled compute Worker (reuse-or-spawn). Success → return to pool; timeout
|
|
8105
|
+
// or error → terminate (never pool). Compute skills have no bridge, so a pooled Worker only ever
|
|
8106
|
+
// emits `result` — the onmessage here is the full message contract for the pooled path.
|
|
8107
|
+
async function _skPooledInvoke(skillId, skill, input) {
|
|
8108
|
+
const codeHash = await _skCodeHash(skillId, skill.code);
|
|
8109
|
+
let pool = SKILL_POOLS.get(skillId);
|
|
8110
|
+
if (pool && pool.codeHash !== codeHash) { _skEvictPool(skillId); pool = null; }
|
|
8111
|
+
if (!pool) { pool = { codeHash, idle: [], lastUsed: new Map() }; SKILL_POOLS.set(skillId, pool); }
|
|
8112
|
+
const w = pool.idle.pop() || _skSpawnComputeWorker(skill);
|
|
8113
|
+
const tag = crypto.randomUUID();
|
|
8114
|
+
return new Promise((resolve, reject) => {
|
|
8115
|
+
let settled = false;
|
|
8116
|
+
const done = (fn, arg, keep) => {
|
|
8117
|
+
if (settled) return; settled = true; clearTimeout(timer); w.onmessage = null; w.onerror = null;
|
|
8118
|
+
if (keep) { pool.lastUsed.set(w, Date.now()); pool.idle.push(w); _skEnforcePoolCap(pool); }
|
|
8119
|
+
else { pool.lastUsed.delete(w); _skKillWorker(w); }
|
|
8120
|
+
fn(arg);
|
|
8121
|
+
};
|
|
8122
|
+
const timer = setTimeout(() => done(reject, new Error('timeout'), false), 5000); // per-invocation, not per-tenure
|
|
8123
|
+
w.onmessage = (e) => { const m = e.data; if (!m || m.identity_tag !== tag) return; if (m.type === 'result') m.ok ? done(resolve, m.result, true) : done(reject, new Error(m.error || 'runtime_error'), false); };
|
|
8124
|
+
w.onerror = () => done(reject, new Error('runtime_error'), false);
|
|
8125
|
+
w.postMessage({ type: 'init', identity_tag: tag, bridged: false }); // re-init each invoke: fresh tag, never bridged
|
|
8126
|
+
w.postMessage({ type: 'invoke', id: 1, input });
|
|
8127
|
+
});
|
|
8128
|
+
}
|
|
7373
8129
|
// §5a — invoke an installed skill in an isolated Worker. compute = bridgeless; tool =
|
|
7374
8130
|
// bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
|
|
7375
8131
|
// Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
|
|
@@ -7397,6 +8153,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7397
8153
|
// skills (which never passed through runtimeInstallSkill's gate), every kind.
|
|
7398
8154
|
const forbidden = _skCodeForbidden(skill.code);
|
|
7399
8155
|
if (forbidden) return Promise.reject(new Error(forbidden));
|
|
8156
|
+
// I2 — opt-in compute-Worker pool. Only a compute skill with an explicit pooling hint and no
|
|
8157
|
+
// agent-role takes the warm path; everything else falls through to the byte-unchanged fresh spawn.
|
|
8158
|
+
if (opts && opts.pooling === 'enabled' && skill.kind === 'compute' && !agentRole) return _skPooledInvoke(skillId, skill, input);
|
|
7400
8159
|
const identity_tag = crypto.randomUUID();
|
|
7401
8160
|
const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
|
|
7402
8161
|
const url = URL.createObjectURL(blob);
|
|
@@ -7405,8 +8164,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7405
8164
|
// F6: cancel an in-flight bridge fetch when the 5s timeout (or any settle)
|
|
7406
8165
|
// fires, so a slow request doesn't keep running after the skill is finished.
|
|
7407
8166
|
const _skAc = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
8167
|
+
const busSubs = []; // I1b — active bus subscriptions for THIS invoke; torn down on settle
|
|
7408
8168
|
return new Promise((resolve, reject) => {
|
|
7409
|
-
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
|
|
8169
|
+
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } for (const u of busSubs) { try { u(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
|
|
7410
8170
|
const timer = setTimeout(() => finish(reject, new Error('timeout')), 5000);
|
|
7411
8171
|
const reply = (id, ok, extra) => w.postMessage(Object.assign({ type: 'bridge:response', id, identity_tag, ok }, extra));
|
|
7412
8172
|
w.onmessage = async (e) => {
|
|
@@ -7451,6 +8211,22 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7451
8211
|
catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
7452
8212
|
return;
|
|
7453
8213
|
}
|
|
8214
|
+
if (msg.type === 'bridge:bus:subscribe') {
|
|
8215
|
+
const pl = msg.payload || {};
|
|
8216
|
+
if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
|
|
8217
|
+
// I1b — forward each allowed envelope to the Worker until it settles (finish() tears these
|
|
8218
|
+
// down). The 5s timeout bounds the subscribe CALL (the ok reply), not the subscription.
|
|
8219
|
+
try {
|
|
8220
|
+
const unsub = runtimeBusSubscribe(pl.topic, (env) => {
|
|
8221
|
+
if (!_skBusMessageAllowed(skill, env)) return;
|
|
8222
|
+
try { w.postMessage({ type: 'bus:message', id: msg.id, identity_tag, envelope: { topic: env.topic, from: env.from, at: env.at, message: env.message } }); } catch (_) {}
|
|
8223
|
+
});
|
|
8224
|
+
busSubs.push(unsub);
|
|
8225
|
+
reply(msg.id, true, { result: true });
|
|
8226
|
+
} catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
8227
|
+
return;
|
|
8228
|
+
}
|
|
8229
|
+
if (msg.type === 'bridge:bus:unsubscribe') { reply(msg.id, true, { result: true }); return; } // MVP: precise teardown deferred; finish() unsubscribes all on settle
|
|
7454
8230
|
if (msg.type === 'bridge:fs') {
|
|
7455
8231
|
const pl = msg.payload || {}, op = pl.op, p = pl.path;
|
|
7456
8232
|
// Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
|
|
@@ -7558,6 +8334,9 @@ function runtimeDescribe() {
|
|
|
7558
8334
|
// undo-only — there is no redo (re-write-able-spec Invariant 7).
|
|
7559
8335
|
baseline: { edit: ['lens'], tools: ['apply_dsl_plan', 'apply_edits', 'replace_document'], export: ['html', 'print'], history: ['undo'] },
|
|
7560
8336
|
activeView: activeView ? activeView.name : null,
|
|
8337
|
+
// I13 (v0.9 §14) — opt-in account identity, LIVE-only (never stamped into the file). null unless
|
|
8338
|
+
// the user linked an account this session (sessionStorage rwa_account); machine-local is the default.
|
|
8339
|
+
accountIdentity: runtimeAccountIdentity(),
|
|
7561
8340
|
};
|
|
7562
8341
|
}
|
|
7563
8342
|
|
|
@@ -8741,12 +9520,16 @@ document.addEventListener('keydown', e => {
|
|
|
8741
9520
|
describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
|
|
8742
9521
|
listSkills: runtimeListSkills, // v0.8 §8 — installed skills (provenance:'installed')
|
|
8743
9522
|
invokeSkill: runtimeInvokeSkill, // v0.8 §5a — run a skill in an isolated Worker
|
|
9523
|
+
invokeEditSurface: runtimeInvokeEditSurface, // v0.9 §8 (I7) — run an edit-surface skill → apply its rwa-edit/1 transform
|
|
9524
|
+
poolStats: runtimePoolStats, // v0.9 §10 (I2) — compute-Worker pool observability {live,cap,idleMs,pools}
|
|
8744
9525
|
reviewSkill: runtimeReviewSkill, // v0.8 §1 — structured trust info for the install dialog
|
|
8745
9526
|
installSkill: runtimeInstallSkill, // v0.8 §1/§7 — gates + verify + register + persist to the frozen zone (survives reload)
|
|
8746
9527
|
uninstallSkill: runtimeUninstallSkill, // v0.8 §7 — remove + persist
|
|
8747
9528
|
showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
|
|
8748
9529
|
promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
|
|
8749
|
-
|
|
9530
|
+
discoverSkills: runtimeDiscoverSkills, // v0.9 §11 (I6) — GET the marketplace index (opt-in network)
|
|
9531
|
+
fetchSkillFromIndex: runtimeFetchSkillFromIndex, // v0.9 §11 (I6) — fetch + client-side-verify an indexed skill
|
|
9532
|
+
vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked, export: runtimeVaultExport, import: runtimeVaultImport }, // v0.8 §6 + v0.9 §14 (I13) portable export/import
|
|
8750
9533
|
agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
|
|
8751
9534
|
hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
|
|
8752
9535
|
};
|
|
@@ -8764,6 +9547,10 @@ document.addEventListener('keydown', e => {
|
|
|
8764
9547
|
configurable: false,
|
|
8765
9548
|
});
|
|
8766
9549
|
startWorkspacePresence();
|
|
9550
|
+
// I2 (v0.9 §10) — background idle eviction (every 30s, terminate compute Workers idle ≥ idleMs)
|
|
9551
|
+
// + drain the pool on unload (shutdown handshake + 500ms grace). No-op until a pooled invoke runs.
|
|
9552
|
+
setInterval(_skPoolEvictIdle, 30000);
|
|
9553
|
+
window.addEventListener('pagehide', () => { _skPoolShutdown(); });
|
|
8767
9554
|
// §5.10: the presentation render mode ships ONLY for presentation
|
|
8768
9555
|
// containers. For every other kind this block is skipped entirely —
|
|
8769
9556
|
// activeView stays null, no provider is registered, no chrome is built,
|