@panphora/clayjs 1.2.0 → 1.4.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 (42) hide show
  1. package/README.md +7 -3
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +22539 -14260
  4. package/entries/clay-data.js +1 -1
  5. package/entries/sap.js +1 -1
  6. package/package.json +7 -2
  7. package/packed-contract.json +17 -0
  8. package/src/attrs/save-freeze.js +10 -18
  9. package/src/core/admin-contenteditable.js +8 -6
  10. package/src/core/admin-inputs.js +23 -13
  11. package/src/core/admin-onclick.js +5 -0
  12. package/src/core/is-edit-mode.js +7 -2
  13. package/src/core/persist.js +5 -10
  14. package/src/core/save-core.js +35 -0
  15. package/src/core/save.js +13 -1
  16. package/src/core/snapshot.js +186 -14
  17. package/src/core/source-map.js +1025 -0
  18. package/src/core/unsaved-warning.js +3 -0
  19. package/src/dom/dom-helpers.js +5 -1
  20. package/src/lib/content-dom.js +108 -0
  21. package/src/lib/mutation.js +26 -3
  22. package/src/lib/region-capabilities.js +69 -0
  23. package/src/lib/region-policy.js +18 -13
  24. package/src/loader-logic.js +27 -5
  25. package/src/loader.js +6 -0
  26. package/src/plugins/ai-edit.js +625 -0
  27. package/src/plugins/demo.js +3 -0
  28. package/src/plugins/sortable.js +6 -1
  29. package/src/plugins/source.js +410 -0
  30. package/src/plugins/wire.js +248 -47
  31. package/src/sync/live-sync.js +106 -42
  32. package/src/sync/presence.js +303 -0
  33. package/src/sync/section-notice.js +230 -0
  34. package/src/sync/splice-merge.js +7 -10
  35. package/src/sync/stream.js +190 -0
  36. package/src/vendor/control-serialize.vendor.js +10 -6
  37. package/src/vendor/hyper-morph.vendor.js +2 -2
  38. package/src/vendor/hyper-undo.vendor.js +1 -1
  39. package/src/vendor/hypercms.vendor.js +438 -45
  40. package/src/vendor/parse5.vendor.js +3 -0
  41. package/src/vendor/quickcrop.vendor.js +1 -1
  42. package/src/vendor/richclay.vendor.js +22 -15
@@ -40,12 +40,17 @@ import Mutation from "../lib/mutation.js";
40
40
  import { isSnapshotRemoved } from "../lib/region-policy.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
42
  import { mergeTagRecognizers } from "./merge-tags.js";
43
- import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
43
+ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
45
  import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
+ import { presence } from './presence.js';
47
+ // Side-effect import: the section-changed notice wires itself to
48
+ // `clay:sync-applied`, which this file is the only dispatcher of.
49
+ import './section-notice.js';
46
50
  import { hostMeta } from '../core/host-meta.js';
47
51
  import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
48
52
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
53
+ import { SyncStream } from './stream.js';
49
54
 
50
55
  // The page just took a frame verified clean against its baseline, so it now IS
51
56
  // the file on disk. Both saved baselines move together from one capture: leaving
@@ -75,6 +80,12 @@ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../
75
80
  * One profile is chosen once and used for BOTH directions for the life of the page.
76
81
  * Letting send and receive decide independently is exactly the bug this replaces: a
77
82
  * client that posted to the spec address while streaming from the legacy one.
83
+ *
84
+ * Both stream addresses carry `client-id`, in the same kebab spelling as the rest of
85
+ * the query. It is the tab's own sender id — the one every outbound frame already
86
+ * carries — so a host that reads it at admission knows this connection by the value
87
+ * its frames arrive under, and can tell two tabs of one cookie-less guest apart. A
88
+ * host that does not read it ignores an unknown parameter.
78
89
  */
79
90
  const WIRE_PROFILES = {
80
91
  spec: {
@@ -82,8 +93,8 @@ const WIRE_PROFILES = {
82
93
  relayPath: '/_/sync',
83
94
  documentHeader: 'Document-URL',
84
95
  snapshotKey: 'snapshot',
85
- streamPath: (href, lane, resumeId) =>
86
- `/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
96
+ streamPath: (href, lane, resumeId, clientId) =>
97
+ `/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
87
98
  },
88
99
  // What every published clayjs speaks, and what hyperclayjs and the inline script in
89
100
  // every Collection dashboard hardcode. Hosts keep these addresses permanently.
@@ -92,8 +103,8 @@ const WIRE_PROFILES = {
92
103
  relayPath: '/_/live-sync/save',
93
104
  documentHeader: 'Page-URL',
94
105
  snapshotKey: 'html',
95
- streamPath: (href, lane, resumeId) =>
96
- `/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
106
+ streamPath: (href, lane, resumeId, clientId) =>
107
+ `/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
97
108
  },
98
109
  };
99
110
 
@@ -168,6 +179,11 @@ class LiveSync {
168
179
  // cleanly. It rides in the slot beside the bytes it describes so the two can
169
180
  // never be separated, which is the whole rule (§6, and §22 of the plan).
170
181
  this._pendingEtag = null;
182
+ // Who the server says sent the pending frame, `{ id, name }`. Rides in the
183
+ // slot for the same reason the stamp does: it describes these bytes and
184
+ // nothing else, so a newer frame replacing them takes its author with it.
185
+ // Reported on `clay:sync-applied` once the frame has actually applied.
186
+ this._pendingBy = null;
171
187
  this._morphInFlight = false;
172
188
  this._rafHandle = null;
173
189
 
@@ -327,6 +343,10 @@ class LiveSync {
327
343
  this.sse = null;
328
344
  }
329
345
 
346
+ // Nothing feeds the roster once the stream is gone, so leaving the stack up
347
+ // would show a list of people who may all have left.
348
+ presence.clear();
349
+
330
350
  if (this._snapshotHandler) {
331
351
  document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
332
352
  this._snapshotHandler = null;
@@ -350,6 +370,7 @@ class LiveSync {
350
370
  this._pendingHtml = null;
351
371
  this._pendingSeq = null;
352
372
  this._pendingIdentityMap = null;
373
+ this._pendingBy = null;
353
374
  this._pendingExternal = null;
354
375
  clearTimeout(this._holdRetryPeer);
355
376
  clearTimeout(this._holdRetryExt);
@@ -382,33 +403,25 @@ class LiveSync {
382
403
  const map = {};
383
404
  if (!liveRoot || !cloneRoot) return map;
384
405
 
385
- const visit = (live, clone, path) => {
386
- let id = this.liveWeakMap.get(live);
387
- if (!id) {
388
- id = this._mintId();
389
- this.liveWeakMap.set(live, id);
406
+ const visit = (clone, path) => {
407
+ const live = originalSnapshotNode(clone);
408
+ if (live) {
409
+ let id = this.liveWeakMap.get(live);
410
+ if (!id) {
411
+ id = this._mintId();
412
+ this.liveWeakMap.set(live, id);
413
+ }
414
+ map[path] = id;
390
415
  }
391
- map[path] = id;
392
416
 
393
- const liveKids = [];
394
- for (const c of live.children) {
395
- if (!isSnapshotRemoved(c)) liveKids.push(c);
396
- }
397
417
  const cloneKids = clone.children;
398
418
 
399
- if (liveKids.length !== cloneKids.length) {
400
- this._log(
401
- `identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
402
- );
403
- return;
404
- }
405
-
406
- for (let i = 0; i < liveKids.length; i++) {
407
- visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
419
+ for (let i = 0; i < cloneKids.length; i++) {
420
+ visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
408
421
  }
409
422
  };
410
423
 
411
- visit(liveRoot, cloneRoot, '');
424
+ visit(cloneRoot, '');
412
425
  return map;
413
426
  }
414
427
 
@@ -511,7 +524,11 @@ class LiveSync {
511
524
  _resolveProfile() {
512
525
  if (!this._profilePromise) {
513
526
  this._profilePromise = hostMeta()
514
- .then((meta) => (meta?.extensions?.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy))
527
+ .then((meta) => {
528
+ const extensions = meta?.extensions || [];
529
+ this._sharedSync = extensions.includes('sync') && extensions.includes('sync-worker') && !extensions.includes('presence');
530
+ return extensions.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
531
+ })
515
532
  .catch(() => WIRE_PROFILES.legacy)
516
533
  .then((profile) => {
517
534
  this._profile = profile;
@@ -590,10 +607,14 @@ class LiveSync {
590
607
  // Whichever wire discovery selected. start() does not call connect() until the
591
608
  // profile is known, so this is never null on the normal path.
592
609
  const profile = this._profile || WIRE_PROFILES.legacy;
593
- const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
610
+ const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId);
594
611
  // Resolved against the real origin: a <base href> in the authored document
595
612
  // would otherwise point the sync stream at an origin the document chose.
596
- this.sse = new EventSource(new URL(path, window.location.origin).href);
613
+ this.sse = new SyncStream(new URL(path, window.location.origin).href, {
614
+ shared: this._sharedSync,
615
+ documentURL: window.location.href,
616
+ lane: this.lane,
617
+ });
597
618
 
598
619
  this.sse.onopen = () => {
599
620
  console.log('[LiveSync] Connected');
@@ -628,6 +649,21 @@ class LiveSync {
628
649
  });
629
650
  });
630
651
 
652
+ // The roster is a NAMED event for the same reason the cursor frame is: a tab
653
+ // with no listener for it never sees a bare data line, so every published
654
+ // client is counted in the roster without being sent one. The frame is
655
+ // handed on untouched — who is named is the host's decision, taken per
656
+ // recipient from that recipient's own access, and nothing here can widen it.
657
+ this.sse.addEventListener('presence', (event) => {
658
+ let data;
659
+ try {
660
+ data = JSON.parse(event.data);
661
+ } catch {
662
+ return;
663
+ }
664
+ presence.update(data);
665
+ });
666
+
631
667
  this.sse.onmessage = (event) => {
632
668
  const data = JSON.parse(event.data);
633
669
 
@@ -696,7 +732,7 @@ class LiveSync {
696
732
  }
697
733
 
698
734
  this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
699
- this.applyUpdate(html, seq, identityMap, etag);
735
+ this.applyUpdate(html, seq, identityMap, etag, data.by);
700
736
  if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
701
737
  };
702
738
 
@@ -924,7 +960,7 @@ class LiveSync {
924
960
  const info = data.data;
925
961
  if (info && info.kind === 'external-change') {
926
962
  if (typeof info.html === 'string') {
927
- this._enqueueExternal(info.html, data.seq, info.etag);
963
+ this._enqueueExternal(info.html, data.seq, info.etag, info.by);
928
964
  } else {
929
965
  this._fetchExternalChange(data.seq);
930
966
  }
@@ -937,7 +973,7 @@ class LiveSync {
937
973
  return false;
938
974
  }
939
975
 
940
- _enqueueExternal(html, seq, etag) {
976
+ _enqueueExternal(html, seq, etag, by) {
941
977
  if (typeof seq === 'number') {
942
978
  if (seq <= this._lastExternalSeq) {
943
979
  this._log(`Dropping replayed external change: seq=${seq}`);
@@ -950,6 +986,11 @@ class LiveSync {
950
986
  seq,
951
987
  saveEpoch: this._saveEpoch,
952
988
  etag: typeof etag === 'string' && etag ? etag : null,
989
+ // Carried for the same reason the stamp is, and null on every disk frame
990
+ // today: hyperclay stamps an author on the two live relays and nowhere
991
+ // else, so a change that arrived from the filesystem has no author to
992
+ // name. The slot carries it so a host that does stamp one is believed.
993
+ by: by ?? null,
953
994
  };
954
995
  this._scheduleNextFrame();
955
996
  }
@@ -977,6 +1018,7 @@ class LiveSync {
977
1018
  */
978
1019
  _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
979
1020
  const epoch = this._saveEpoch;
1021
+ if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
980
1022
  fetch(new URL(window.location.href), { cache: 'no-store' })
981
1023
  .then((response) => (response.ok ? response.text() : null))
982
1024
  .then((html) => {
@@ -987,7 +1029,7 @@ class LiveSync {
987
1029
  // said replay cannot fix this page, so if the newer fetch fails there is
988
1030
  // nothing else coming. Refetch rather than drop the only repair.
989
1031
  if (repair && attempt < 3) {
990
- this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
1032
+ this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
991
1033
  }
992
1034
  return;
993
1035
  }
@@ -1003,8 +1045,9 @@ class LiveSync {
1003
1045
  }
1004
1046
  // No stamp: this body came from a GET of the served page, which nobody
1005
1047
  // stamped, so the apply leaves the held stamp alone and etag.js asks the
1006
- // host for a replacement.
1007
- this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null };
1048
+ // host for a replacement. No author either, for the same reason — a GET
1049
+ // answers what disk holds, not who put it there.
1050
+ this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
1008
1051
  this._scheduleNextFrame();
1009
1052
  })
1010
1053
  .catch((err) => {
@@ -1031,8 +1074,10 @@ class LiveSync {
1031
1074
  * @param {string} html - Full document HTML
1032
1075
  * @param {number} [seq] - Optional monotonic seq from the server
1033
1076
  * @param {Object} [identityMap] - Optional element-identity map from sender
1077
+ * @param {string} [etag] - Optional version stamp for these bytes
1078
+ * @param {Object} [by] - Optional `{ id, name }` author stamp for these bytes
1034
1079
  */
1035
- applyUpdate(html, seq, identityMap, etag) {
1080
+ applyUpdate(html, seq, identityMap, etag, by) {
1036
1081
  if (this.isDestroyed) return;
1037
1082
  this._pendingHtml = html;
1038
1083
  this._pendingSeq = seq;
@@ -1041,6 +1086,7 @@ class LiveSync {
1041
1086
  // correct: the stamp belongs to bytes that are no longer what will apply.
1042
1087
  // Losing it costs one honest 412 later, which is the safe direction.
1043
1088
  this._pendingEtag = etag ?? null;
1089
+ this._pendingBy = by ?? null;
1044
1090
  this._scheduleNextFrame();
1045
1091
  }
1046
1092
 
@@ -1098,7 +1144,7 @@ class LiveSync {
1098
1144
  } else {
1099
1145
  this._morphInFlight = true;
1100
1146
  try {
1101
- await this._doApplyExternal(ext.html, ext.seq, ext.etag);
1147
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
1102
1148
  } catch (err) {
1103
1149
  console.error('[LiveSync] applyExternal failed:', err);
1104
1150
  } finally {
@@ -1110,15 +1156,17 @@ class LiveSync {
1110
1156
  const seq = this._pendingSeq;
1111
1157
  const identityMap = this._pendingIdentityMap;
1112
1158
  const etag = this._pendingEtag;
1159
+ const by = this._pendingBy;
1113
1160
  this._pendingHtml = null;
1114
1161
  this._pendingSeq = null;
1115
1162
  this._pendingIdentityMap = null;
1116
1163
  this._pendingEtag = null;
1164
+ this._pendingBy = null;
1117
1165
  if (html == null) return;
1118
1166
 
1119
1167
  this._morphInFlight = true;
1120
1168
  try {
1121
- await this._doApplyUpdate(html, seq, identityMap, etag);
1169
+ await this._doApplyUpdate(html, seq, identityMap, etag, by);
1122
1170
  } catch (err) {
1123
1171
  console.error('[LiveSync] applyUpdate failed:', err);
1124
1172
  } finally {
@@ -1157,9 +1205,11 @@ class LiveSync {
1157
1205
  * @param {string} html
1158
1206
  * @param {number} [seq]
1159
1207
  * @param {Object} [identityMap]
1208
+ * @param {string} [etag]
1209
+ * @param {Object} [by]
1160
1210
  * @returns {Promise<void>}
1161
1211
  */
1162
- async _doApplyUpdate(html, seq, identityMap, etag) {
1212
+ async _doApplyUpdate(html, seq, identityMap, etag, by = null) {
1163
1213
  this._log('applyUpdate - pausing mutations and morphing');
1164
1214
  this.isPaused = true;
1165
1215
 
@@ -1247,6 +1297,7 @@ class LiveSync {
1247
1297
  this._pendingHtml = html;
1248
1298
  this._pendingSeq = seq;
1249
1299
  this._pendingIdentityMap = identityMap;
1300
+ this._pendingBy = by;
1250
1301
  this._scheduleNextFrame();
1251
1302
  }, 3000);
1252
1303
  return;
@@ -1354,8 +1405,13 @@ class LiveSync {
1354
1405
  // `source` is what lets a listener tell the two apply paths apart. clay.wire
1355
1406
  // waits for a DISK frame to call an agent's write landed, and another tab's
1356
1407
  // edit arriving first would otherwise report the wrong bytes as delivered.
1408
+ //
1409
+ // `by` is the server's answer about who sent these bytes, and it is on the
1410
+ // event rather than on the frame's arrival for one reason: a frame that held
1411
+ // returns above without reaching here, so nothing can name an author for a
1412
+ // change this tab never took. Null on every frame the host did not stamp.
1357
1413
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1358
- detail: { seq, source: 'peer' }
1414
+ detail: { seq, source: 'peer', by: by || null }
1359
1415
  }));
1360
1416
  } finally {
1361
1417
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1392,7 +1448,7 @@ class LiveSync {
1392
1448
  * holds or a later dirty peer apply misclassifies this frame's content as
1393
1449
  * local edits.
1394
1450
  */
1395
- async _doApplyExternal(html, seq, etag = null) {
1451
+ async _doApplyExternal(html, seq, etag = null, by = null) {
1396
1452
  this._log(`applyExternal - external disk change (seq=${seq})`);
1397
1453
  this.isPaused = true;
1398
1454
 
@@ -1432,7 +1488,7 @@ class LiveSync {
1432
1488
  this._holdRetryExt = setTimeout(() => {
1433
1489
  this._holdRetryExt = null;
1434
1490
  if (this.isDestroyed || this._pendingExternal != null) return;
1435
- this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
1491
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
1436
1492
  this._scheduleNextFrame();
1437
1493
  }, 3000);
1438
1494
  return;
@@ -1501,7 +1557,15 @@ class LiveSync {
1501
1557
  }
1502
1558
 
1503
1559
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1504
- detail: { seq, source: 'disk', etag: typeof etag === 'string' && etag ? etag : null }
1560
+ detail: {
1561
+ seq,
1562
+ source: 'disk',
1563
+ etag: typeof etag === 'string' && etag ? etag : null,
1564
+ by: by || null,
1565
+ // The bytes now on disk, exactly as they arrived. The source map models them
1566
+ // so this tab's next save copies from the file somebody else just wrote.
1567
+ html,
1568
+ }
1505
1569
  }));
1506
1570
  } finally {
1507
1571
  this._log('applyExternal - morph complete, resuming mutations');
@@ -0,0 +1,303 @@
1
+ /**
2
+ * presence.js — who else is on this document.
3
+ *
4
+ * A fixed-corner stack of circles, one per participant the host chose to name,
5
+ * plus a count of everyone it did not. The host decides who is named, per
6
+ * recipient, from that recipient's own access; nothing here asks for a name and
7
+ * nothing here can widen what arrived.
8
+ *
9
+ * THE RULE THIS FILE EXISTS TO GET RIGHT.
10
+ *
11
+ * captureForSaveAndComparison() clones the document, hands that clone to peers
12
+ * on `clay:snapshot-ready`, and only THEN strips the save-only regions. So
13
+ * `no-save` alone is not enough: it keeps names out of the file while
14
+ * broadcasting them to every peer on the document, including a visitor the host
15
+ * deliberately answered with a count and no names. `no-snapshot` is the token
16
+ * that runs BEFORE the clone is emitted (captureSnapshot), so every runtime root
17
+ * here carries all three, tooltip included:
18
+ *
19
+ * no-save not written to the saved file
20
+ * no-watch invisible to the mutation system, so a roster change is not an edit
21
+ * no-snapshot absent from every snapshot, including the one peers receive
22
+ *
23
+ * The roster lives in this module's memory. It is never written to a DOM data
24
+ * store, an attribute or anything else a serializer walks — the names on screen
25
+ * are text nodes inside a subtree that no snapshot contains, and the hover label
26
+ * is held in a closure rather than in `title`. Styling is inline and hostile-css
27
+ * !important throughout, so there is no injected <style> tag to mark either.
28
+ *
29
+ * Gated on the host, not on the first frame: a host that does not announce
30
+ * `presence` draws nothing at all rather than an empty stack. That is what
31
+ * hyperclay-local, HTML Clay and makerclay do today and it stays that way until
32
+ * they adopt it.
33
+ *
34
+ * `people[].id` is an opaque keyed pseudonym. It picks the colour and nothing
35
+ * else: it is not a database id, it names no account, and it is never logged.
36
+ */
37
+
38
+ import { hostSupports } from '../core/host-meta.js';
39
+ import { make, set } from '../lib/hostile-css.js';
40
+
41
+ // Runtime-only chrome, in one string so no root can carry two of the three.
42
+ const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
43
+
44
+ // Two initials at 12px need about 15px of the circle, so the overlap has to
45
+ // leave that much clear or the neighbouring circle eats the second letter.
46
+ const SIZE = 30;
47
+ const OVERLAP = 6;
48
+ const FONT = "12px/1 system-ui,-apple-system,'Segoe UI',sans-serif";
49
+ const LABEL_FONT = "12px/1.4 system-ui,-apple-system,'Segoe UI',sans-serif";
50
+
51
+ // The chrome around the circles is themeable like every other clayjs surface.
52
+ // The circles themselves are not: their colour is computed per participant, so
53
+ // there is no one value a page could override.
54
+ const INK = 'var(--clay-presence-ink,#1f2023)';
55
+ const CHIP_BG = 'var(--clay-presence-bg,#fff)';
56
+ const CHIP_EDGE = 'var(--clay-presence-edge,rgba(0,0,0,.14))';
57
+ const TIP_BG = 'var(--clay-presence-tip-bg,#222)';
58
+ const TIP_INK = 'var(--clay-presence-tip-ink,#fff)';
59
+ const FACE = '#ffffff';
60
+
61
+ // A fixed set rather than a computed hue: every one of these was looked at
62
+ // against white initials, and a hue wheel puts neighbouring pseudonyms on two
63
+ // blues nobody can tell apart.
64
+ const COLORS = [
65
+ '#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e',
66
+ '#0369a1', '#1d4ed8', '#6d28d9', '#a21caf', '#9f1239',
67
+ ];
68
+
69
+ /** Every element this module creates, marked the same way. */
70
+ function runtimeRoot(el) {
71
+ el.setAttribute('clay', RUNTIME_ONLY);
72
+ return el;
73
+ }
74
+
75
+ function hashOf(id) {
76
+ let h = 5381;
77
+ for (let i = 0; i < id.length; i++) {
78
+ h = (((h << 5) + h) ^ id.charCodeAt(i)) >>> 0;
79
+ }
80
+ return h;
81
+ }
82
+
83
+ // The pseudonym picks the colour and nothing else.
84
+ function colorFor(id) {
85
+ return COLORS[hashOf(id) % COLORS.length];
86
+ }
87
+
88
+ // Array.from, not [0]: a name starting outside the BMP would otherwise show half
89
+ // a character.
90
+ function initialsOf(name) {
91
+ const words = String(name).trim().split(/\s+/).filter(Boolean);
92
+ if (!words.length) return '?';
93
+ const first = (word) => Array.from(word)[0].toUpperCase();
94
+ return words.length === 1 ? first(words[0]) : first(words[0]) + first(words[1]);
95
+ }
96
+
97
+ /**
98
+ * Read the roster frame defensively. Everything here arrived over a wire, and a
99
+ * frame this function cannot understand must draw nothing rather than draw a
100
+ * guess: `[object Object]` in a circle is worse than an empty corner.
101
+ */
102
+ function normalize(data) {
103
+ const list = data && Array.isArray(data.people) ? data.people : [];
104
+ const people = [];
105
+ for (const person of list) {
106
+ if (!person || typeof person.id !== 'string' || !person.id) continue;
107
+ const name = typeof person.name === 'string' && person.name.trim() ? person.name.trim() : 'Someone';
108
+ people.push({
109
+ id: person.id,
110
+ name,
111
+ canEdit: person.canEdit === true,
112
+ you: person.you === true,
113
+ });
114
+ }
115
+ const count = data && data.anonymous;
116
+ const anonymous = typeof count === 'number' && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
117
+ return { people, anonymous };
118
+ }
119
+
120
+ class Presence {
121
+ constructor() {
122
+ this.roster = { people: [], anonymous: 0 };
123
+ this.root = null;
124
+ this.stack = null;
125
+ this.count = null;
126
+ this.tip = null;
127
+ this._gate = null;
128
+ }
129
+
130
+ /**
131
+ * Does this host run presence at all? Asked once, of /_/meta, and never
132
+ * inferred from a frame arriving: the answer decides whether the chrome is
133
+ * built, so a host that says nothing draws nothing forever.
134
+ */
135
+ supported() {
136
+ if (!this._gate) this._gate = hostSupports('presence').then((ok) => ok === true, () => false);
137
+ return this._gate;
138
+ }
139
+
140
+ /**
141
+ * Take a roster frame. Held in memory first so a frame that arrives before
142
+ * discovery answers is not lost, and drawn only if the host announced the
143
+ * capability.
144
+ *
145
+ * @param {{people: Array, anonymous: number}} data
146
+ * @returns {Promise<void>}
147
+ */
148
+ async update(data) {
149
+ this.roster = normalize(data);
150
+ if (!(await this.supported())) return;
151
+ this.render();
152
+ }
153
+
154
+ /** The stream went away, so the roster is no longer true about anything. */
155
+ clear() {
156
+ this.roster = { people: [], anonymous: 0 };
157
+ if (!this.root) return;
158
+ this.root.remove();
159
+ this.root = null;
160
+ this.stack = null;
161
+ this.count = null;
162
+ this.tip = null;
163
+ }
164
+
165
+ render() {
166
+ const { people, anonymous } = this.roster;
167
+
168
+ // A person alone on a document is told nothing at all. The count is the
169
+ // total either way: a recipient the host would not name gets people: [] and
170
+ // everyone in `anonymous`, so this sum is the same participant count in both
171
+ // directions.
172
+ if (people.length + anonymous <= 1) {
173
+ this.hide();
174
+ return;
175
+ }
176
+ if (!this.build()) return;
177
+
178
+ this.hideTip();
179
+ this.stack.textContent = '';
180
+ for (const person of people) {
181
+ this.stack.appendChild(this.avatar(person));
182
+ }
183
+ // The overlap belongs between circles, so the leftmost one does not pull
184
+ // itself into the count beside it.
185
+ if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
186
+
187
+ if (anonymous > 0) {
188
+ this.count.textContent = `+${anonymous} viewing`;
189
+ set(this.count, 'display', 'inline-block');
190
+ } else {
191
+ this.count.textContent = '';
192
+ set(this.count, 'display', 'none');
193
+ }
194
+ set(this.root, 'display', 'flex');
195
+ }
196
+
197
+ hide() {
198
+ if (!this.root) return;
199
+ this.hideTip();
200
+ this.stack.textContent = '';
201
+ this.count.textContent = '';
202
+ set(this.root, 'display', 'none');
203
+ }
204
+
205
+ build() {
206
+ if (this.root) return true;
207
+ if (typeof document === 'undefined' || !document.body) return false;
208
+
209
+ this.root = runtimeRoot(make('div', [
210
+ 'position:fixed',
211
+ 'top:calc(12px + env(safe-area-inset-top,0px))',
212
+ 'right:calc(12px + env(safe-area-inset-right,0px))',
213
+ 'z-index:2147483000',
214
+ 'display:flex', 'align-items:center', 'gap:8px',
215
+ 'max-width:calc(100vw - 24px)',
216
+ `font:${LABEL_FONT}`, `color:${INK}`,
217
+ // Only the circles take a pointer. A fixed corner that swallowed clicks
218
+ // would take a bite out of whatever the page put underneath it.
219
+ 'pointer-events:none',
220
+ ]));
221
+ this.root.setAttribute('data-clay-presence', '');
222
+
223
+ this.count = runtimeRoot(make('span', [
224
+ 'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
225
+ 'padding:4px 8px', 'border-radius:999px',
226
+ `background-color:${CHIP_BG}`, 'background-image:none', `color:${INK}`,
227
+ 'border-width:1px', 'border-style:solid', `border-color:${CHIP_EDGE}`,
228
+ `font:${LABEL_FONT}`,
229
+ 'box-shadow:0 1px 3px rgba(0,0,0,.2)',
230
+ 'display:none',
231
+ ]));
232
+ this.count.setAttribute('data-clay-presence-count', '');
233
+
234
+ this.stack = runtimeRoot(make('div', [
235
+ 'display:flex', 'align-items:center', 'flex:none',
236
+ ]));
237
+
238
+ // Inside the root rather than appended to <body>, so the one element that
239
+ // holds a name at rest cannot outlive the subtree that keeps it out of a
240
+ // snapshot. It carries the marking of its own too.
241
+ this.tip = runtimeRoot(make('span', [
242
+ 'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
243
+ 'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
244
+ 'padding:4px 8px', 'border-radius:6px',
245
+ `background-color:${TIP_BG}`, 'background-image:none',
246
+ `color:${TIP_INK}`, `font:${LABEL_FONT}`,
247
+ 'box-shadow:0 4px 14px rgba(0,0,0,.28)',
248
+ 'display:none',
249
+ ]));
250
+ this.tip.setAttribute('data-clay-presence-tip', '');
251
+
252
+ this.root.append(this.stack, this.count, this.tip);
253
+ document.body.appendChild(this.root);
254
+ return true;
255
+ }
256
+
257
+ avatar(person) {
258
+ const color = colorFor(person.id);
259
+ // Solid says this person can change the document; hollow says they are
260
+ // reading it. The two are the same three colours inverted, so a stack of
261
+ // both reads as one set rather than as two kinds of thing.
262
+ const skin = person.canEdit
263
+ ? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
264
+ : [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
265
+
266
+ const el = runtimeRoot(make('div', [
267
+ 'box-sizing:border-box', 'flex:none',
268
+ `width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:50%',
269
+ 'display:flex', 'align-items:center', 'justify-content:center',
270
+ `font:${FONT}`, 'font-weight:600',
271
+ `margin-left:-${OVERLAP}px`,
272
+ 'border-width:2px', 'border-style:solid', 'background-image:none',
273
+ 'box-shadow:0 1px 3px rgba(0,0,0,.28)',
274
+ 'user-select:none', 'cursor:default', 'pointer-events:auto',
275
+ ...skin,
276
+ ], initialsOf(person.name)));
277
+ el.setAttribute('data-clay-presence-avatar', '');
278
+
279
+ // The name lives in this closure, never in an attribute on the element, and
280
+ // it reaches the DOM only while a pointer is actually on the circle.
281
+ const label = person.you ? `${person.name} (you)` : person.name;
282
+ el.addEventListener('mouseenter', () => this.showTip(label));
283
+ el.addEventListener('mouseleave', () => this.hideTip());
284
+ return el;
285
+ }
286
+
287
+ showTip(text) {
288
+ if (!this.tip) return;
289
+ this.tip.textContent = text;
290
+ set(this.tip, 'display', 'block');
291
+ }
292
+
293
+ hideTip() {
294
+ if (!this.tip) return;
295
+ this.tip.textContent = '';
296
+ set(this.tip, 'display', 'none');
297
+ }
298
+ }
299
+
300
+ const presence = new Presence();
301
+
302
+ export { presence, Presence };
303
+ export default presence;