@panphora/clayjs 1.13.1 → 1.15.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.
Files changed (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -0,0 +1,18 @@
1
+ // A function of one string that remembers its last few answers. A save hands the
2
+ // same document to several readers in turn, and each would otherwise parse it again.
3
+ export function recent(size, fn) {
4
+ const answers = new Map();
5
+ return (html) => {
6
+ if (typeof html !== 'string') return fn(html);
7
+ if (answers.has(html)) {
8
+ const answer = answers.get(html);
9
+ answers.delete(html);
10
+ answers.set(html, answer);
11
+ return answer;
12
+ }
13
+ const answer = fn(html);
14
+ answers.set(html, answer);
15
+ if (answers.size > size) answers.delete(answers.keys().next().value);
16
+ return answer;
17
+ };
18
+ }
@@ -0,0 +1,17 @@
1
+ import crypto from 'crypto';
2
+
3
+ export function documentEtag (storedHtml) {
4
+ return crypto.createHash('sha256').update(storedHtml || '').digest('hex').substring(0, 16);
5
+ }
6
+
7
+ export function ifMatchSatisfied (fieldValue, storedBytes) {
8
+ const stored = storedBytes || '';
9
+ const field = String(fieldValue ?? '').trim();
10
+ if (field === '*') return stored.length > 0;
11
+
12
+ const current = documentEtag(stored);
13
+ return field.split(',').some(entry => {
14
+ const tag = entry.trim().replace(/^W\//i, '').replace(/^"(.*)"$/, '$1');
15
+ return tag !== '' && tag === current;
16
+ });
17
+ }
package/src/standalone.js CHANGED
@@ -35,6 +35,28 @@ import { noteBootParams } from "./core/draft-mode.js";
35
35
 
36
36
  if (!clay.ready) mintReady();
37
37
 
38
+ // clay.start runs app code once per page, after boot. A live-sync merge can
39
+ // re-insert an inline script and run it again; the repeat call is a no-op.
40
+ // It lives in the bootstrap so an inline script can call it before boot ends.
41
+ if (!clay.start) {
42
+ var started = Object.create(null);
43
+ clay.start = function (name, fn) {
44
+ if (typeof name === "function") { fn = name; name = ""; }
45
+ if (started[name]) return;
46
+ started[name] = true;
47
+ // A failed boot rejects the old promise after minting a new one, so a call
48
+ // made before the failure waits for the corrected tag's boot instead.
49
+ var wait = function () {
50
+ var ready = clay.ready;
51
+ return ready.then(fn, function (err) {
52
+ if (clay.ready === ready) throw err;
53
+ return wait();
54
+ });
55
+ };
56
+ return wait();
57
+ };
58
+ }
59
+
38
60
  // clay.js needs its own URL to find the loader; this file needs it only for the
39
61
  // query string, so pasted inline into the page it boots with the defaults.
40
62
  var script = document.currentScript;
@@ -4,6 +4,7 @@ import { protectionOf, protectionGeneration, captureFootprintWitnesses, footprin
4
4
  import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
5
5
  import { savePage } from "../core/save.js";
6
6
  import { gateMarkDirty } from "../lib/dirty-gate.js";
7
+ import { repaintDerived } from "../lib/derived.js";
7
8
  import Mutation from "../lib/mutation.js";
8
9
  import { markExplicitSave } from "../lib/user-gesture.js";
9
10
  import { enableContentEditable } from "../core/admin-contenteditable.js";
@@ -1136,6 +1137,7 @@ export async function revertConflicts(ids) {
1136
1137
  await Promise.resolve();
1137
1138
  const plan = prepareRevert(ids);
1138
1139
  const revertedIds = applyRevert(plan);
1140
+ repaintDerived();
1139
1141
  let saveResult = null;
1140
1142
  if (revertedIds.length) {
1141
1143
  gateMarkDirty();
@@ -56,9 +56,9 @@ function install(apply, raw) {
56
56
  return id;
57
57
  }
58
58
 
59
- export function beginApply({ source, seq = null, etag = null, domain, root }) {
59
+ export function beginApply({ source, seq = null, etag = null, domain, root, recoveryRoot = null }) {
60
60
  const id = nextId('a');
61
- applies.set(id, { id, source, seq, etag, ticket: 0, domain, root, ids: new Set() });
61
+ applies.set(id, { id, source, seq, etag, ticket: 0, domain, root, recoveryRoot, ids: new Set() });
62
62
  pending.add(id);
63
63
  register().changed();
64
64
  return id;
@@ -62,6 +62,7 @@ import { SyncStream } from './stream.js';
62
62
  import { streamOpened, streamClosed, streamEvent, LIVE_EVENTS } from '../live/stream-bridge.js';
63
63
  import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
64
64
  import { beginLineageCapture } from './conflict-footprints.js';
65
+ import { isDerivedAttr, ownsDerivedText, stripDerived, repaintDerived, paintSignatureOfHtml, derivedActive } from '../lib/derived.js';
65
66
 
66
67
  // What a live-sync merge never reads or touches on any side: editor chrome,
67
68
  // content kept out of the save or the snapshot, the contents of frozen regions,
@@ -78,11 +79,13 @@ const SYNC_IGNORE_SELECTOR = [
78
79
  // A frozen element is a scratchpad: the element is part of the document and
79
80
  // merges like any other, but its contents belong to this tab. Every merge input
80
81
  // has its frozen elements emptied, and hyper-morph's opaque option leaves the
81
- // live contents alone.
82
+ // live contents alone. Painted output (lib/derived.js) is left out of every
83
+ // input the same way, and the engine repaints it once the merge has applied.
82
84
  const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
83
85
 
84
86
  function emptyScratchpads(root) {
85
87
  for (const el of root.querySelectorAll(FREEZE_SELECTOR)) el.replaceChildren();
88
+ if (derivedActive()) stripDerived(root);
86
89
  return root;
87
90
  }
88
91
 
@@ -187,7 +190,7 @@ function pairedBaseline() {
187
190
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
188
191
  return [forComparison, forDirty];
189
192
  }
190
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
193
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled, schedulePaintHeal } from '../core/save.js';
191
194
 
192
195
  /**
193
196
  * The two live-sync wires, and the rule for choosing between them.
@@ -287,6 +290,9 @@ class LiveSync {
287
290
  // document, and hostMeta() memoizes the request itself.
288
291
  this._profile = null;
289
292
  this._profilePromise = null;
293
+ this._sharedData = false;
294
+ this._sharedSync = false;
295
+ this._liveData = false;
290
296
  this._editing = null;
291
297
  this._ready = null;
292
298
  // Bumped by every start(), so a discovery that resolves after a stop/restart
@@ -701,8 +707,13 @@ class LiveSync {
701
707
  this._profilePromise = hostMeta()
702
708
  .then((meta) => {
703
709
  const extensions = meta?.extensions || [];
704
- this._sharedSync = extensions.includes('sync') && extensions.includes('sync-worker') && !extensions.includes('presence');
705
- return extensions.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
710
+ const hasSync = extensions.includes('sync');
711
+ const hasLiveData = extensions.includes('live-data');
712
+ const hasPresence = extensions.includes('presence');
713
+ this._sharedData = hasSync && hasLiveData && extensions.includes('sync-worker-data') && !hasPresence;
714
+ this._sharedSync = hasSync && !hasPresence && (this._sharedData || (extensions.includes('sync-worker') && !hasLiveData));
715
+ this._liveData = hasLiveData;
716
+ return hasSync ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
706
717
  })
707
718
  .catch(() => WIRE_PROFILES.legacy)
708
719
  .then((profile) => {
@@ -787,8 +798,11 @@ class LiveSync {
787
798
  // would otherwise point the sync stream at an origin the document chose.
788
799
  this.sse = new SyncStream(new URL(path, window.location.origin).href, {
789
800
  shared: this._sharedSync,
801
+ sharedData: this._sharedData,
790
802
  documentURL: window.location.href,
791
803
  lane: this.lane,
804
+ clientId: this.clientId,
805
+ keepAliveWhenHidden: this._liveData && this.lane === 'live',
792
806
  // Only a response that named its own version gives this tab anything to
793
807
  // compare against. On a host that served none there is no provenance to
794
808
  // check, and the page keeps its pre-1.9.0 startup behavior.
@@ -1691,9 +1705,9 @@ class LiveSync {
1691
1705
  live: document,
1692
1706
  remote,
1693
1707
  ignore: (el) => el.matches(SYNC_IGNORE_SELECTOR),
1694
- opaque: (el) => el.matches(FREEZE_SELECTOR),
1708
+ opaque: (el) => el.matches(FREEZE_SELECTOR) || (derivedActive() && ownsDerivedText(el)),
1695
1709
  remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
1696
- ignoreAttribute: isRootLocalAttr,
1710
+ ignoreAttribute: (el, name, merged) => isRootLocalAttr(el, name, merged) || (derivedActive() && isDerivedAttr(merged || el, name)),
1697
1711
  protectFocusedValue: true,
1698
1712
  keepLiveOnly: source === 'peer',
1699
1713
  scripts: { mergeTags: mergeTagRecognizers },
@@ -1743,9 +1757,13 @@ class LiveSync {
1743
1757
  },
1744
1758
  });
1745
1759
  } else {
1746
- localRoot = emptyScratchpads(captureLocal());
1760
+ const captured = captureLocal();
1761
+ // Download my copy serializes the page as it was, painted values included;
1762
+ // the merge itself needs them stripped.
1763
+ const recoveryRoot = captured.cloneNode(true);
1764
+ localRoot = emptyScratchpads(captured);
1747
1765
  applyId = beginApply({
1748
- source, seq, etag, root: localRoot,
1766
+ source, seq, etag, root: localRoot, recoveryRoot,
1749
1767
  domain: source === 'disk' ? 'save' : 'sync',
1750
1768
  });
1751
1769
  try {
@@ -1901,6 +1919,7 @@ class LiveSync {
1901
1919
  });
1902
1920
  diverged = report.localDiverged;
1903
1921
  typedDuringWait = typed;
1922
+ repaintDerived();
1904
1923
 
1905
1924
  // Restore viewport. Done after morph so layout has settled.
1906
1925
  window.scrollTo(scrollX, scrollY);
@@ -2027,6 +2046,7 @@ class LiveSync {
2027
2046
  * local edits.
2028
2047
  */
2029
2048
  async _doApplyExternal(html, seq, etag = null, by = null, fetchOptions = null) {
2049
+ const priorDisk = this._diskBase;
2030
2050
  this._log(`applyExternal - external disk change (seq=${seq})`);
2031
2051
  this.isPaused = true;
2032
2052
 
@@ -2103,6 +2123,7 @@ class LiveSync {
2103
2123
  });
2104
2124
  diverged = report.localDiverged;
2105
2125
  typedDuringWait = typed;
2126
+ repaintDerived();
2106
2127
  // The file holds these bytes and this tab has applied them. Dated at the
2107
2128
  // apply, so a save that captured during the await keeps its newer base.
2108
2129
  this._setDiskBase(html, ticket);
@@ -2180,6 +2201,13 @@ class LiveSync {
2180
2201
  // autosave is on, as in the peer lane.
2181
2202
  if (stamped) conflictResolvedBySync(etag);
2182
2203
  this._saveAfterMerge(diverged, typedDuringWait);
2204
+ // A disk frame from a writer that does not paint (an agent, the data API)
2205
+ // changes records and leaves the painted values as they were. If the page's
2206
+ // repainted values differ from them once the timer fires, one tab saves so
2207
+ // the bytes on disk heal. A writer that paints is left alone.
2208
+ if (priorDisk != null && paintSignatureOfHtml(priorDisk) === paintSignatureOfHtml(html)) {
2209
+ schedulePaintHeal(() => (this._diskBase == null ? null : paintSignatureOfHtml(this._diskBase)));
2210
+ }
2183
2211
  }
2184
2212
 
2185
2213
  /**
@@ -9,7 +9,7 @@ document.addEventListener('clay:workspace-foreground', () => { workspaceBackgrou
9
9
  const pageHidden = () => document.hidden || workspaceBackground;
10
10
 
11
11
  export class SyncStream extends EventTarget {
12
- constructor(url, { shared = false, documentURL, lane, startupCheck = false } = {}) {
12
+ constructor(url, { shared = false, sharedData = false, documentURL, lane, clientId, startupCheck = false, keepAliveWhenHidden = false } = {}) {
13
13
  super();
14
14
  this.url = url;
15
15
  this.readyState = 0;
@@ -17,8 +17,11 @@ export class SyncStream extends EventTarget {
17
17
  this.onmessage = null;
18
18
  this.onerror = null;
19
19
  this._shared = shared && typeof SharedWorker === 'function';
20
+ this._sharedData = sharedData;
20
21
  this._documentURL = documentURL;
21
22
  this._lane = lane;
23
+ this._clientId = clientId;
24
+ this._keepAliveWhenHidden = keepAliveWhenHidden;
22
25
  this._since = 0;
23
26
  this._closed = false;
24
27
  this._suspended = false;
@@ -35,17 +38,17 @@ export class SyncStream extends EventTarget {
35
38
  this._visibility = () => {
36
39
  clearTimeout(this._hiddenTimer);
37
40
  if (pageHidden()) {
38
- this._worker?.port.postMessage({ v: 1, type: 'hidden' });
39
- this._hiddenTimer = setTimeout(() => this._suspend(), HIDDEN_DELAY);
41
+ if (this._worker) this._postWorker(this._worker, 'hidden');
42
+ if (!this._keepAliveWhenHidden) this._hiddenTimer = setTimeout(() => this._suspend(), HIDDEN_DELAY);
40
43
  } else {
41
44
  if (this._suspended) this._resume();
42
45
  else if (this._worker) {
43
- this._worker.port.postMessage({ v: 1, type: 'visible' });
46
+ this._postWorker(this._worker, 'visible');
44
47
  }
45
48
  }
46
49
  };
47
- this._pagehide = () => this._suspend();
48
- this._pageshow = () => { if (!pageHidden()) this._resume(); };
50
+ this._pagehide = () => this._suspend(true);
51
+ this._pageshow = () => { if (!pageHidden() || this._keepAliveWhenHidden) this._resume(); };
49
52
  document.addEventListener('visibilitychange', this._visibility);
50
53
  document.addEventListener('clay:workspace-background', this._visibility);
51
54
  document.addEventListener('clay:workspace-foreground', this._visibility);
@@ -62,14 +65,22 @@ export class SyncStream extends EventTarget {
62
65
  this[`on${type}`]?.(event);
63
66
  }
64
67
 
68
+ _postWorker(worker, type, fields = {}) {
69
+ worker.port.postMessage({ v: this._sharedData ? 2 : 1, type, ...fields });
70
+ }
71
+
65
72
  _open() {
66
73
  if (this._closed || this._suspended) return;
67
74
  this.readyState = 0;
68
75
  if (!this._shared) return this._openDirect();
69
76
  try {
70
- const worker = new SharedWorker(new URL('/_/sync/worker.js', window.location.origin).href, 'clay-sync');
77
+ const worker = new SharedWorker(
78
+ new URL(this._sharedData ? '/_/sync/data-worker.js' : '/_/sync/worker.js', window.location.origin).href,
79
+ this._sharedData ? 'clay-sync-data' : 'clay-sync'
80
+ );
71
81
  this._worker = worker;
72
82
  let lastReply = Date.now();
83
+ let greeted = false;
73
84
  worker.onerror = () => {
74
85
  if (this._worker !== worker) return;
75
86
  this._shared = false;
@@ -78,13 +89,39 @@ export class SyncStream extends EventTarget {
78
89
  };
79
90
  this._startTimer = setTimeout(worker.onerror, START_TIMEOUT);
80
91
  worker.port.onmessage = ({ data }) => {
81
- if (this._worker !== worker || data?.v !== 1) return;
92
+ if (this._worker !== worker || data?.v !== (this._sharedData ? 2 : 1)) return;
93
+ if (this._sharedData) {
94
+ if (data.type === 'unsupported') {
95
+ worker.onerror();
96
+ return;
97
+ }
98
+ if (data.type === 'gone') {
99
+ this.readyState = 2;
100
+ this._emit('error');
101
+ this.close();
102
+ return;
103
+ }
104
+ if (!greeted) {
105
+ if (data.type !== 'hello') return;
106
+ if (!Array.isArray(data.features) || !data.features.includes('live-data')) {
107
+ worker.onerror();
108
+ return;
109
+ }
110
+ greeted = true;
111
+ clearTimeout(this._startTimer);
112
+ lastReply = Date.now();
113
+ return;
114
+ }
115
+ if (data.type === 'hello') return;
116
+ }
82
117
  lastReply = Date.now();
83
- if (['status', 'pong', 'cursor', 'frame', 'gone'].includes(data.type)) clearTimeout(this._startTimer);
118
+ if (!this._sharedData && ['status', 'pong', 'cursor', 'frame', 'gone'].includes(data.type)) clearTimeout(this._startTimer);
84
119
  if (data.type === 'status') {
85
120
  if (data.state === 'open') {
86
- this.readyState = 1;
87
- this._emit('open');
121
+ if (!this._sharedData || this.readyState !== 1) {
122
+ this.readyState = 1;
123
+ this._emit('open');
124
+ }
88
125
  } else if (data.state === 'connecting') {
89
126
  this.readyState = 0;
90
127
  this._emit('error');
@@ -100,6 +137,9 @@ export class SyncStream extends EventTarget {
100
137
  } else if (data.type === 'frame' && typeof data.data === 'string') {
101
138
  this._remember(data.data);
102
139
  this._emit('message', data.data);
140
+ } else if (this._sharedData && data.type === 'event' && ['data', 'data-state', 'data-writer'].includes(data.event)
141
+ && data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data)) {
142
+ try { this._emit(data.event, JSON.stringify(data.data)); } catch {}
103
143
  } else if (data.type === 'gone') {
104
144
  this.readyState = 2;
105
145
  this._emit('error');
@@ -107,14 +147,16 @@ export class SyncStream extends EventTarget {
107
147
  }
108
148
  };
109
149
  worker.port.start();
110
- worker.port.postMessage({ v: 1, type: pageHidden() ? 'hidden' : 'visible' });
111
- worker.port.postMessage({ v: 1, type: 'subscribe', document: this._documentURL, lane: this._lane, since: this._since });
150
+ this._postWorker(worker, pageHidden() ? 'hidden' : 'visible');
151
+ const subscription = { document: this._documentURL, lane: this._lane, since: this._since };
152
+ if (this._sharedData && this._lane === 'live') subscription.clientId = this._clientId;
153
+ this._postWorker(worker, 'subscribe', subscription);
112
154
  this._pingTimer = setInterval(() => {
113
155
  if (this._worker !== worker) return;
114
156
  if (Date.now() - lastReply > PING_INTERVAL * 3) {
115
157
  this._repair = true;
116
158
  this._reopen();
117
- } else worker.port.postMessage({ v: 1, type: 'ping' });
159
+ } else this._postWorker(worker, 'ping');
118
160
  }, PING_INTERVAL);
119
161
  } catch {
120
162
  this._shared = false;
@@ -170,7 +212,7 @@ export class SyncStream extends EventTarget {
170
212
  this._worker = null;
171
213
  worker.onerror = null;
172
214
  worker.port.onmessage = null;
173
- try { worker.port.postMessage({ v: 1, type: 'unsubscribe' }); } catch {}
215
+ try { this._postWorker(worker, 'unsubscribe'); } catch {}
174
216
  worker.port.close();
175
217
  }
176
218
  if (this._source) {
@@ -180,16 +222,20 @@ export class SyncStream extends EventTarget {
180
222
  }
181
223
 
182
224
  _reopen() {
225
+ const notify = (this._sharedData || this._keepAliveWhenHidden) && this.readyState === 1;
226
+ this.readyState = 0;
227
+ if (notify) this._emit('error');
183
228
  this._disconnect();
184
229
  this._open();
185
230
  }
186
231
 
187
- _suspend() {
232
+ _suspend(pageExit = false) {
188
233
  clearTimeout(this._hiddenTimer);
189
234
  if (this._closed || this._suspended) return;
190
235
  this._suspended = true;
191
236
  this._repair = true;
192
237
  this.readyState = 0;
238
+ if (pageExit && this._keepAliveWhenHidden) this._emit('error');
193
239
  this._disconnect();
194
240
  }
195
241
 
package/src/ui/index.js CHANGED
@@ -45,5 +45,9 @@ document.addEventListener("clay:save-saved", (e) => {
45
45
  });
46
46
  document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
47
47
  document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
48
+ document.addEventListener("clay:live-notice", (e) => {
49
+ if (e.detail?.code === "save-uncertain") toastPersistent(e.detail.message, "warning");
50
+ if (e.detail?.code === "op-too-large") toast(e.detail.message, "warning");
51
+ });
48
52
 
49
53
  export { toast, toastPersistent, ask, consent, tell, snippet, themodal };
package/src/ui/toast.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // The Hyperclay dashboard's toast (Bevel's React toast with the dashboard's overrides),
5
5
  // drawn inline: a 3px tone edge, a 16px Bevel glyph, 13.5px semibold text, a quiet close.
6
6
  import { set } from "../lib/hostile-css.js";
7
- import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon } from "./bevel-controls.js";
7
+ import { bevelBox, bevelSurface, bevelText, bevelButton, pageScheme, protectIcon } from "./bevel-controls.js";
8
8
  import { TOKENS, RULES, GLYPHS } from "./bevel.js";
9
9
 
10
10
  const TONES = {
@@ -39,7 +39,7 @@ function stack() {
39
39
  return box;
40
40
  }
41
41
 
42
- function build(message, type) {
42
+ function build(message, type, { detail = "", actions = [] } = {}) {
43
43
  const tone = TONES[type];
44
44
  const el = bevelSurface("div", [
45
45
  "display:flex", "align-items:flex-start", ...RULES.toast, "width:100%",
@@ -48,13 +48,29 @@ function build(message, type) {
48
48
  ...HIDDEN, `transition:${motion()}`,
49
49
  ]);
50
50
  el.setAttribute("data-clay-toast", type);
51
+ const body = bevelBox("div", ["flex:1 1 auto", "min-width:0", "display:flex", "flex-direction:column", "gap:8px"]);
52
+ const head = bevelBox("div", ["display:flex", "align-items:flex-start", "gap:8px"]);
51
53
  const glyph = bevelText("span", ["display:inline-grid", "flex:none", "padding-top:1px", `color:${tone.edge}`]);
52
54
  glyph.innerHTML = tone.icon;
53
55
  protectIcon(glyph.firstElementChild, 16);
54
56
  // A message can carry a filename, and a filename is whatever whoever wrote the
55
57
  // file chose. It goes in as text so markup in a name can never become markup here.
56
58
  const text = bevelText("span", ["flex:1 1 auto", "min-width:120px", ...RULES.toastTitle, "overflow-wrap:anywhere"], message);
57
- el.append(glyph, text);
59
+ head.append(glyph, text);
60
+ body.append(head);
61
+ // Detail and actions are the same untrusted text as the title: textContent for
62
+ // both, so a reason string can never become markup in the toast either.
63
+ if (detail) {
64
+ body.append(bevelText("span", ["display:block", `color:${TOKENS.muted}`, "overflow-wrap:anywhere"], detail));
65
+ }
66
+ if (actions.length) {
67
+ const row = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px"]);
68
+ for (const { label, onClick } of actions) {
69
+ row.append(bevelButton(label, { small: true, variant: "quiet", onClick }));
70
+ }
71
+ body.append(row);
72
+ }
73
+ el.append(body);
58
74
  return el;
59
75
  }
60
76
 
@@ -109,14 +125,14 @@ function toast(message, messageType = "success") {
109
125
  const activePersistentToasts = new Map();
110
126
 
111
127
  // Persistent toast function - doesn't auto-dismiss, requires click to close
112
- function toastPersistent(message, messageType = "warning") {
128
+ function toastPersistent(message, messageType = "warning", { detail = "", actions = [] } = {}) {
113
129
  const existing = activePersistentToasts.get(message);
114
130
  if (existing) {
115
131
  dismiss(existing);
116
132
  activePersistentToasts.delete(message);
117
133
  }
118
134
 
119
- const el = build(message, TONES[messageType] ? messageType : "warning");
135
+ const el = build(message, TONES[messageType] ? messageType : "warning", { detail, actions });
120
136
  set(el, "cursor", "default");
121
137
  const close = closeButton((e) => {
122
138
  e.stopPropagation();