rewritable 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewritable",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for re-writeable: emit and import single-file rwa documents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -88,6 +88,17 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
88
88
  #rwa-info-panel .rwa-info-foot{margin-top:12px;padding-top:10px;border-top:1px solid var(--gray-100);font-size:11px;color:var(--gray-500);line-height:1.5;}
89
89
  #rwa-info-panel strong{color:var(--gray-900);font-weight:600;}
90
90
  #rwa-info-panel code{font-family:var(--font-mono);font-size:11px;background:var(--gray-50);padding:1px 5px;border-radius:4px;color:var(--gray-700);}
91
+ #rwa-share-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:18px 20px;display:none;max-width:340px;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);font-family:var(--font-ui);font-size:13px;line-height:1.55;color:var(--gray-700);}
92
+ #rwa-share-panel.open{display:block;}
93
+ #rwa-share-panel h4{margin:0 0 6px;font-size:15px;color:var(--gray-900);font-weight:600;}
94
+ #rwa-share-panel p{margin:0 0 10px;}
95
+ .rwa-share-url{font-family:var(--font-mono);font-size:11px;background:var(--gray-50);border:1px solid var(--gray-100);border-radius:6px;padding:6px 8px;margin:0 0 8px;cursor:pointer;word-break:break-all;color:var(--gray-700);}
96
+ .rwa-share-fresh{font-family:var(--font-mono);font-size:10px;color:var(--gray-500);margin-bottom:10px;}
97
+ .rwa-share-msg{font-size:12px;color:var(--gray-600);background:var(--gray-50);border-radius:6px;padding:6px 8px;margin-bottom:8px;}
98
+ .rwa-share-actions{display:flex;gap:6px;flex-wrap:wrap;}
99
+ .rwa-share-actions button{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--gray-300);border-radius:8px;background:var(--white);color:var(--gray-900);cursor:pointer;}
100
+ .rwa-share-actions button:disabled{opacity:.5;cursor:default;}
101
+ #rwa-share-create,#rwa-share-update{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
91
102
  .rwa-set-hint code{background:var(--gray-100);padding:1px 4px;border-radius:3px;font-size:10px;}
92
103
  .rwa-set-hint.ok{color:#15803d;}
93
104
  .rwa-set-hint.err{color:#b91c1c;}
@@ -376,6 +387,13 @@ const RWA = {
376
387
  // for users with a Claude subscription; the price is a per-call subprocess
377
388
  // (~5-10s startup) and a single-shot agent loop (no mid-stream tool_calls).
378
389
  BRIDGE_URL:'http://127.0.0.1:8765/run',
390
+ // Connected-share service (the ↗ panel): a stable share URL the user
391
+ // explicitly publishes versions to. Network I/O happens ONLY on share
392
+ // gestures — never at boot, never on ⌘S — so offline-first holds. Dev /
393
+ // self-hosted services override via sessionStorage rwa_share_base.
394
+ // Design: docs/plans/2026-06-11-save-affordance-framings.md §7c.
395
+ SHARE_BASE:'https://rewritable.ikangai.com',
396
+ K_SHARE_BASE:'rwa_share_base',
379
397
  };
380
398
 
381
399
  // `rwa new -o` / `rwa import -o` can pass first-paint configuration via URL
@@ -1095,6 +1113,7 @@ function buildUI() {
1095
1113
  <button class="rwa-st-btn" id="rwa-st-info" title="what is this?" aria-label="what is this?">ⓘ</button>
1096
1114
  <button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
1097
1115
  <button class="rwa-st-btn" id="rwa-st-skin" title="skins — pick a look" aria-label="skins">✦</button>
1116
+ <button class="rwa-st-btn" id="rwa-st-share" title="share at a link" aria-label="share at a link">↗</button>
1098
1117
  <button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
1099
1118
  </div>
1100
1119
  <div id="rwa-set-panel">
@@ -1108,6 +1127,7 @@ function buildUI() {
1108
1127
  </div>
1109
1128
  <div id="rwa-info-panel"></div>
1110
1129
  <div id="rwa-skin-panel"></div>
1130
+ <div id="rwa-share-panel"></div>
1111
1131
  <div id="rwa-pal">
1112
1132
  <div id="rwa-pal-box">
1113
1133
  <div class="rwa-pal-top">
@@ -1289,6 +1309,7 @@ function buildUI() {
1289
1309
  document.getElementById('rwa-st-cog').onclick = () => {
1290
1310
  document.getElementById('rwa-info-panel').classList.remove('open'); // panels are mutually exclusive
1291
1311
  document.getElementById('rwa-skin-panel').classList.remove('open');
1312
+ document.getElementById('rwa-share-panel').classList.remove('open');
1292
1313
  document.getElementById('rwa-set-panel').classList.toggle('open');
1293
1314
  };
1294
1315
  // ⓘ "what is this?" — render the rwa-identity/1 bundle as prose on open so it
@@ -1297,6 +1318,7 @@ function buildUI() {
1297
1318
  document.getElementById('rwa-st-info').onclick = () => {
1298
1319
  document.getElementById('rwa-set-panel').classList.remove('open');
1299
1320
  document.getElementById('rwa-skin-panel').classList.remove('open');
1321
+ document.getElementById('rwa-share-panel').classList.remove('open');
1300
1322
  const panel = document.getElementById('rwa-info-panel');
1301
1323
  if (!panel.classList.contains('open')) panel.innerHTML = renderInfoPanel();
1302
1324
  panel.classList.toggle('open');
@@ -1307,6 +1329,17 @@ function buildUI() {
1307
1329
  if (panel && panel.classList.contains('open')) { panel.classList.remove('open'); return; }
1308
1330
  openSkinPanel();
1309
1331
  };
1332
+ // ↗ connected share — open re-renders from the live record so the panel
1333
+ // always reflects the current connection + freshness. Mutually exclusive
1334
+ // with the other three panels.
1335
+ document.getElementById('rwa-st-share').onclick = () => {
1336
+ const panel = document.getElementById('rwa-share-panel');
1337
+ if (panel.classList.contains('open')) { panel.classList.remove('open'); return; }
1338
+ document.getElementById('rwa-set-panel').classList.remove('open');
1339
+ document.getElementById('rwa-info-panel').classList.remove('open');
1340
+ document.getElementById('rwa-skin-panel').classList.remove('open');
1341
+ renderSharePanel().then(() => panel.classList.add('open'));
1342
+ };
1310
1343
  document.getElementById('rwa-st-commit').onclick = commit;
1311
1344
 
1312
1345
  const pal = document.getElementById('rwa-pal'), inp = document.getElementById('rwa-pal-inp'), go = document.getElementById('rwa-pal-go');
@@ -4159,6 +4192,7 @@ HARD RULES: colors are hex strings only (e.g. "#c0392b"); fonts are ONE of the f
4159
4192
  if (!panel) return;
4160
4193
  const setP = document.getElementById('rwa-set-panel'); if (setP) setP.classList.remove('open');
4161
4194
  const infoP = document.getElementById('rwa-info-panel'); if (infoP) infoP.classList.remove('open');
4195
+ const shareP = document.getElementById('rwa-share-panel'); if (shareP) shareP.classList.remove('open');
4162
4196
  const active = currentSkinName();
4163
4197
  panel.innerHTML =
4164
4198
  '<div class="rwa-skin-hd"><span>Skins</span><span>' + (active || '—') + '</span></div>'
@@ -6154,6 +6188,155 @@ function renderInfoPanel() {
6154
6188
  ].join('');
6155
6189
  }
6156
6190
 
6191
+ // ─── Connected share (the ↗ panel) ──────────────────────────────────
6192
+ // A rewritable can be CONNECTED to a stable URL: "Create share link" POSTs the
6193
+ // full current file bytes (buildFile output — exactly the ⌘S artifact) to the
6194
+ // share service; "Publish this version" re-POSTs to the same short under the
6195
+ // returned update token. The record {short,url,token,publishedHash,publishedAt}
6196
+ // lives in rwa_state — machine-local on purpose: the token is a capability,
6197
+ // and buildFile only serializes INLINE_DOC, so neither can ever travel in the
6198
+ // file. The copy says VERSION on purpose: the link shows the last published
6199
+ // version, not live edits (docs/plans/2026-06-11-save-affordance-framings.md
6200
+ // §7c). These are the ONLY fetches the runtime makes outside the agent
6201
+ // backends, each behind an explicit user gesture — offline-first holds.
6202
+ const shareBaseUrl = () =>
6203
+ (sessionStorage.getItem(RWA.K_SHARE_BASE) || '').trim() || RWA.SHARE_BASE;
6204
+
6205
+ // Hash + file bytes from ONE doc read, so publishedHash always describes the
6206
+ // exact bytes that went out (no mid-flight-edit skew).
6207
+ async function shareSnapshot() {
6208
+ const d = await getDoc();
6209
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(d));
6210
+ const hash = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
6211
+ return { text: buildFile(d), hash };
6212
+ }
6213
+ async function shareDocHashHex() { return (await shareSnapshot()).hash; }
6214
+
6215
+ const getShareConn = () => idbGet(RWA.STATE, 'share_conn');
6216
+ const setShareConn = v => idbPut(RWA.STATE, v, 'share_conn');
6217
+ const clearShareConn = () => idbDel(RWA.STATE, 'share_conn');
6218
+
6219
+ class ShareError extends Error { constructor(code, msg) { super(msg || code); this.code = code; } }
6220
+
6221
+ async function shareFetch(path, { method = 'POST', token, body } = {}) {
6222
+ const headers = {};
6223
+ if (body != null) headers['Content-Type'] = 'text/html';
6224
+ if (token) headers.Authorization = 'Bearer ' + token;
6225
+ try {
6226
+ return await fetch(shareBaseUrl() + path, { method, headers, body });
6227
+ } catch (_) {
6228
+ throw new ShareError('share_unreachable', 'Sharing service unreachable — check your connection and try again.');
6229
+ }
6230
+ }
6231
+
6232
+ async function shareErrDetail(res) {
6233
+ try { return (await res.json()).error || ('http ' + res.status); } catch (_) { return 'http ' + res.status; }
6234
+ }
6235
+
6236
+ async function shareCreate() {
6237
+ const snap = await shareSnapshot();
6238
+ const res = await shareFetch('/share', { body: snap.text });
6239
+ if (res.status !== 201) throw new ShareError('share_failed', 'Could not create the share link (' + await shareErrDetail(res) + ').');
6240
+ const out = await res.json();
6241
+ const conn = { short: out.short, url: out.url, token: out.token, publishedHash: snap.hash, publishedAt: Date.now() };
6242
+ await setShareConn(conn);
6243
+ return conn;
6244
+ }
6245
+
6246
+ async function shareUpdate() {
6247
+ const conn = await getShareConn();
6248
+ if (!conn) throw new ShareError('share_not_connected', 'Not connected to a link yet.');
6249
+ const snap = await shareSnapshot();
6250
+ const res = await shareFetch('/share/' + conn.short, { token: conn.token, body: snap.text });
6251
+ if (res.status === 401 || res.status === 404 || res.status === 410) {
6252
+ // The capability is dead (unshared elsewhere, expired, or revoked). Keep
6253
+ // nothing stale around — the honest state is "not connected".
6254
+ await clearShareConn();
6255
+ throw new ShareError('share_connection_lost', 'This link can no longer be updated — create a new one.');
6256
+ }
6257
+ if (res.status !== 200) throw new ShareError('share_failed', 'Could not publish this version (' + await shareErrDetail(res) + ').');
6258
+ const next = { ...conn, publishedHash: snap.hash, publishedAt: Date.now() };
6259
+ await setShareConn(next);
6260
+ return next;
6261
+ }
6262
+
6263
+ async function shareUnshare() {
6264
+ const conn = await getShareConn();
6265
+ if (!conn) return;
6266
+ // A network failure throws BEFORE the clear — an unreachable service must
6267
+ // not silently orphan a live public link. 401/404 mean the share is already
6268
+ // gone server-side; clearing is the honest state either way.
6269
+ await shareFetch('/share/' + conn.short, { method: 'DELETE', token: conn.token });
6270
+ await clearShareConn();
6271
+ }
6272
+
6273
+ function shareRelTime(ts) {
6274
+ const m = Math.round((Date.now() - ts) / 60000);
6275
+ if (m < 1) return 'just now';
6276
+ if (m < 60) return m + ' min ago';
6277
+ const h = Math.round(m / 60);
6278
+ if (h < 24) return h + ' h ago';
6279
+ return Math.round(h / 24) + ' d ago';
6280
+ }
6281
+
6282
+ // One in-flight share action at a time; the panel re-renders disabled while
6283
+ // busy and re-enabled (with an error message, if any) after.
6284
+ let shareBusy = false;
6285
+ async function shareAction(fn) {
6286
+ if (shareBusy) return;
6287
+ shareBusy = true;
6288
+ try { await renderSharePanel(); } catch (_) {}
6289
+ let msg = null;
6290
+ try { await fn(); }
6291
+ catch (e) { msg = (e && e.message) || String(e); }
6292
+ shareBusy = false;
6293
+ await renderSharePanel(msg);
6294
+ }
6295
+
6296
+ function copyShareUrl(url) {
6297
+ if (navigator.clipboard && navigator.clipboard.writeText) {
6298
+ navigator.clipboard.writeText(url)
6299
+ .then(() => renderSharePanel('Link copied.'))
6300
+ .catch(() => {});
6301
+ }
6302
+ }
6303
+
6304
+ async function renderSharePanel(msg) {
6305
+ const panel = document.getElementById('rwa-share-panel');
6306
+ if (!panel) return;
6307
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
6308
+ const note = msg ? '<div class="rwa-share-msg">' + esc(msg) + '</div>' : '';
6309
+ const dis = shareBusy ? ' disabled' : '';
6310
+ const conn = await getShareConn();
6311
+ if (!conn) {
6312
+ panel.innerHTML = [
6313
+ '<h4>Share at a link</h4>',
6314
+ '<p>Anyone with the link sees the version you publish — not your live edits.</p>',
6315
+ note,
6316
+ '<div class="rwa-share-actions"><button type="button" id="rwa-share-create"' + dis + '>Create share link</button></div>',
6317
+ ].join('');
6318
+ document.getElementById('rwa-share-create').onclick = () => shareAction(shareCreate);
6319
+ return;
6320
+ }
6321
+ const current = (await shareDocHashHex()) === conn.publishedHash;
6322
+ panel.innerHTML = [
6323
+ '<h4>Shared at a link</h4>',
6324
+ '<div class="rwa-share-url" id="rwa-share-url" title="click to copy">' + esc(conn.url) + '</div>',
6325
+ '<div class="rwa-share-fresh" id="rwa-share-fresh">Published ' + esc(shareRelTime(conn.publishedAt)) + ' · ' +
6326
+ (current ? 'the link shows this version' : 'behind your latest edits') + '</div>',
6327
+ note,
6328
+ '<div class="rwa-share-actions">',
6329
+ '<button type="button" id="rwa-share-update"' + dis + '>Publish this version</button>',
6330
+ '<button type="button" id="rwa-share-copy">Copy link</button>',
6331
+ '<button type="button" id="rwa-share-stop"' + dis + '>Stop sharing</button>',
6332
+ '</div>',
6333
+ ].join('');
6334
+ document.getElementById('rwa-share-update').onclick = () => shareAction(shareUpdate);
6335
+ document.getElementById('rwa-share-stop').onclick = () => shareAction(shareUnshare);
6336
+ document.getElementById('rwa-share-copy').onclick = () => copyShareUrl(conn.url);
6337
+ document.getElementById('rwa-share-url').onclick = () => copyShareUrl(conn.url);
6338
+ }
6339
+
6157
6340
  // ─── First-party 'presentation' view provider (spec §5.10) ──────────
6158
6341
  // WRAP-IN-PLACE: split the doc at <h1>/<h2> boundaries into
6159
6342
  // <section class="rwa-slide"> wrappers WITHOUT reordering. SECTION ∉