@panphora/clayjs 1.1.0 → 1.2.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/src/core/save.js CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
- isSaveInProgress
20
+ isSaveInProgress,
21
+ saveFateIsUnknown
21
22
  } from "./save-core.js";
22
23
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
23
24
  import { seedEtag } from "./etag.js";
@@ -54,8 +55,9 @@ let savingTimeout = null;
54
55
  * @param {string} state - One of: 'saving', 'saved', 'offline', 'error', 'conflict'
55
56
  * @param {string} msg - Optional message (e.g., error details)
56
57
  * @param {string} msgType - Optional severity from the server (e.g., 'warning')
58
+ * @param {Object} [extra] - Extra detail fields for the event (e.g. `changedBy`)
57
59
  */
58
- function setSaveState(state, msg = '', msgType = '') {
60
+ function setSaveState(state, msg = '', msgType = '', extra = null) {
59
61
  if (savingTimeout) {
60
62
  clearTimeout(savingTimeout);
61
63
  savingTimeout = null;
@@ -64,7 +66,7 @@ function setSaveState(state, msg = '', msgType = '') {
64
66
  document.documentElement.setAttribute('savestatus', state);
65
67
 
66
68
  const event = new CustomEvent(`clay:save-${state}`, {
67
- detail: { msg, msgType, timestamp: Date.now() }
69
+ detail: { msg, msgType, timestamp: Date.now(), ...(extra || {}) }
68
70
  });
69
71
  document.dispatchEvent(event);
70
72
  }
@@ -261,7 +263,10 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
261
263
  releaseConflictHold();
262
264
  } else if (result.msgType === 'conflict') {
263
265
  holdForConflict();
264
- setSaveState('conflict', result.msg, result.msgType);
266
+ setSaveState('conflict', result.msg, result.msgType, {
267
+ changedBy: result.changedBy ?? null,
268
+ afterTimeout: result.afterTimeout === true,
269
+ });
265
270
  } else if (result.msgType !== 'skipped') {
266
271
  if (!navigator.onLine) {
267
272
  setSaveState('offline', result.msg);
@@ -274,8 +279,18 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
274
279
  // Run the save that arrived while this one was in flight. savePage does its own
275
280
  // dirty check, so if nothing actually changed it resolves 'skipped' and stops:
276
281
  // this cannot spin.
282
+ //
283
+ // Not while the last save's fate is still unknown, which happens only when the
284
+ // save timed out AND the host could not be asked what became of it. Sending then
285
+ // is sending into a question: the host is unreachable, so the save is most likely
286
+ // to time out too, and if it does reach a host that took the first write it is
287
+ // refused, which puts a conflict bar on screen seconds after a person typed,
288
+ // unprompted, over what is really a network problem. The queued state is KEPT, not
289
+ // dropped, so the newer bytes still go out on the next save; the page is still
290
+ // dirty, so an edit or a close warning will produce one.
277
291
  function drainPendingSave() {
278
292
  if (!pendingSave) return;
293
+ if (saveFateIsUnknown()) return;
279
294
  pendingSave = false;
280
295
  savePage();
281
296
  }
@@ -477,7 +477,11 @@ export function serializeForSync(clone) {
477
477
  // truncate the broadcast.
478
478
  const shell = bareRoot.outerHTML;
479
479
  const endTag = `</${bareRoot.localName}>`;
480
- return shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
480
+ // The doctype, so this artifact is a complete document like every other one this
481
+ // module produces. Spec section 2 asks for that, and it costs nothing on the wire:
482
+ // a receiver parses the string and morphs documentElement against documentElement,
483
+ // so the prologue is consumed by the parser and never reaches the morph.
484
+ return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
481
485
  }
482
486
 
483
487
  /**
@@ -0,0 +1,95 @@
1
+ import { servedStaleToken } from "./host-attrs.js";
2
+ import { isEditMode } from "./is-edit-mode.js";
3
+ import onDomReady from "../lib/dom-ready.js";
4
+ import { set, make } from "../lib/hostile-css.js";
5
+
6
+ // The page's only visible word on a host too old to save to.
7
+ //
8
+ // This library reads one save-token name. A host older than that rename sends the
9
+ // other one, so there is no token here, and it also sets the owner cookie, so without
10
+ // help the page would open fully editable and 404 every save against a route that no
11
+ // longer matches. is-edit-mode.js takes editing away for that reason. This says why.
12
+ //
13
+ // It loads in the ALWAYS wave, not the edit-only one, which is the whole point: the
14
+ // case it exists for is precisely the case where edit mode is off, so a module gated on
15
+ // edit mode could never run in it. host-attrs.js also logs a line, and that line is for
16
+ // a developer. Somebody editing a document in a desktop app has no console open, and
17
+ // "why can I not edit this any more" is the question they are actually holding.
18
+ //
19
+ // One line, no choice to make: nothing on this page can fix it, so offering a button
20
+ // would be a lie. Dismissable, because after you have read it, it is only in the way.
21
+
22
+ const BG = "var(--clay-notice-bg,#222)";
23
+ const INK = "var(--clay-notice-ink,#fff)";
24
+ const EDGE = "var(--clay-notice-edge,rgba(255,255,255,.28))";
25
+ const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
26
+
27
+ const MESSAGE =
28
+ "This page can't be edited: the app serving it is out of date. " +
29
+ "Update HTML Clay to 1.9.0 or newer.";
30
+
31
+ let root = null;
32
+
33
+ // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
34
+ // layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
35
+ // as the conflict notice.
36
+ function place() {
37
+ if (!root) return;
38
+ const vv = window.visualViewport;
39
+ const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
40
+ set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
41
+ }
42
+
43
+ function dismiss() {
44
+ if (!root) return;
45
+ set(root, "display", "none");
46
+ window.visualViewport?.removeEventListener("resize", place);
47
+ window.visualViewport?.removeEventListener("scroll", place);
48
+ }
49
+
50
+ function build() {
51
+ root = make("div", [
52
+ "position:fixed", "left:50%", "transform:translateX(-50%)",
53
+ "z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
54
+ "max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
55
+ "padding:9px 12px", "border-radius:10px",
56
+ `background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
57
+ "box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
58
+ `font:${FONT}`, "text-align:left",
59
+ ]);
60
+ // Three markers, and each is load-bearing on a page that CAN save: this element is
61
+ // injected, so it is in no document on disk and must never reach one, never wake the
62
+ // watcher, and never ride out in a snapshot to somebody else's browser.
63
+ root.setAttribute("clay", "no-save no-watch no-snapshot");
64
+ root.setAttribute("data-clay-stale-host", "");
65
+ root.setAttribute("role", "alert");
66
+
67
+ root.append(make("span", ["margin-right:2px"], MESSAGE));
68
+
69
+ const close = make("button", [
70
+ "all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
71
+ "color:" + INK, "opacity:.72", "padding:2px 6px", "border-radius:6px", "flex:none",
72
+ ], "Dismiss");
73
+ close.type = "button";
74
+ close.setAttribute("aria-label", "Dismiss this message");
75
+ close.addEventListener("click", dismiss);
76
+ root.append(close);
77
+
78
+ document.body.appendChild(root);
79
+ place();
80
+ window.visualViewport?.addEventListener("resize", place);
81
+ window.visualViewport?.addEventListener("scroll", place);
82
+ }
83
+
84
+ function init() {
85
+ if (!servedStaleToken()) return;
86
+ // ?editmode=true outranks the stale-host check by design: that is a person at the
87
+ // keyboard asking for editing on this load, and is-edit-mode.js gives it to them.
88
+ // This notice never consulted that, so it appeared on a page that IS editable and
89
+ // told its reader the opposite. The message is only true while editing is actually
90
+ // off, so it is shown only then.
91
+ if (isEditMode) return;
92
+ build();
93
+ }
94
+
95
+ onDomReady(init);
@@ -0,0 +1,49 @@
1
+ // Styling that survives somebody else's stylesheet.
2
+ //
3
+ // Every declaration goes on as !important, and that is the whole reason anything this
4
+ // library draws on a stranger's page looks the way it was written. A plain inline style
5
+ // loses to an author rule carrying !important, and `button { ... !important }` is a
6
+ // thing real pages do: the first browser run of the conflict notice had both of its
7
+ // controls repainted in the host page's colours and font. Only an inline !important
8
+ // outranks an author !important.
9
+
10
+ /**
11
+ * Apply `prop:value` rules to an element, each one !important.
12
+ * @param {Element} el
13
+ * @param {string[]} rules
14
+ */
15
+ export function style(el, rules) {
16
+ for (const rule of rules) {
17
+ const at = rule.indexOf(":");
18
+ el.style.setProperty(rule.slice(0, at).trim(), rule.slice(at + 1).trim(), "important");
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Set one property, !important.
24
+ *
25
+ * Same reason as above, from the other direction: a later assignment has to be able to
26
+ * beat the !important already sitting on the element, and a plain `el.style.foo = x`
27
+ * silently cannot.
28
+ *
29
+ * @param {Element} el
30
+ * @param {string} prop
31
+ * @param {string} value
32
+ */
33
+ export function set(el, prop, value) {
34
+ el.style.setProperty(prop, value, "important");
35
+ }
36
+
37
+ /**
38
+ * Build an element with those rules already on it.
39
+ * @param {string} tag
40
+ * @param {string[]} rules
41
+ * @param {string} [text]
42
+ * @returns {HTMLElement}
43
+ */
44
+ export function make(tag, rules, text) {
45
+ const el = document.createElement(tag);
46
+ style(el, rules);
47
+ if (text) el.textContent = text;
48
+ return el;
49
+ }
@@ -8,31 +8,70 @@
8
8
  * peer's copy of them must never be applied.
9
9
  */
10
10
 
11
- // The two spellings of the save token. Spec §9 bounds one to per-file and per-tab
12
- // and makes the host strip it before writing, so it never reaches disk. But §9
13
- // bounds only the save path, and §10 fans a snapshot out to other editors'
11
+ // The save token, in the order a reader tries the two spellings. Spec §9 bounds it to
12
+ // per-file and per-tab and makes the host strip it before writing, so it never reaches
13
+ // disk. But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
14
14
  // browsers, which is the hole this module closes.
15
15
  //
16
16
  // Save tokens ONLY. host-attrs.js returns the first name it finds here and puts it
17
17
  // straight into the save URL, so anything added to this list becomes a credential
18
- // in a path. Durable identities go in the list below, never in this one.
19
- export const SAVE_TOKEN_ATTRS = ["savetoken", "htmlclaytoken"];
18
+ // in a path. Durable identities go in HOST_IDENTITY_ATTRS, never in this one.
19
+ //
20
+ // One name, deliberately. §9 names exactly one save-token attribute.
21
+ //
22
+ // This is a knowing break, not an oversight. htmlclay is the only host that mints a save
23
+ // token at all (hyperclay and Hyperclay Local authorize by cookie and inject nothing),
24
+ // it serves both spellings only from 1.9.0, and a document loads this library from a
25
+ // rolling URL. So a document served by an htmlclay at or below 1.8.0 keeps edit mode,
26
+ // because that host also sets the isAdminOfCurrentResource cookie, and every save 404s,
27
+ // because that host registers only `POST /_/save/{token}`.
28
+ //
29
+ // Taken on purpose, and taken now because the cost only grows: htmlclay 1.8.0 is days
30
+ // old, the product is pre-launch, and the alternative is carrying a second credential
31
+ // name in the save path permanently, since "wait until the old hosts are gone" is a
32
+ // condition nobody ever measures. host-attrs.js says so plainly in the console when it
33
+ // finds the old name, so the failure names its own cause and its own fix.
34
+ //
35
+ // ⚠️ RELEASE ORDER: htmlclay 1.9.0, which injects both names, must publish BEFORE this.
36
+ export const SAVE_TOKEN_ATTRS = ["savetoken"];
37
+
38
+ // The pre-rename spelling. Never read as a credential, which is the point of it being
39
+ // here rather than above, and never removed from HOST_TOKEN_ATTRS below, which is a
40
+ // different job: a name a host may inject has to go on being stripped before a save and
41
+ // kept out of a peer's morph, or a live token gets written into a document or handed to
42
+ // another tab. htmlclay injects it forever, because documents frozen against an older
43
+ // client read it, so this name never leaves this file.
44
+ export const LEGACY_SAVE_TOKEN_ATTRS = ["htmlclaytoken"];
20
45
 
21
- // Host-injected, but NOT credentials. htmlclayid is htmlclay's durable file
22
- // identity, stamped on every serve and absent from disk bytes, so it rides in the
23
- // morph protection below for the same reason a token does: a morph of raw disk
24
- // content would otherwise strip this tab's copy, and a peer's copy must never be
25
- // applied.
46
+ // Host-injected, but NOT credentials. This is htmlclay's durable file identity,
47
+ // stamped on every serve and absent from disk bytes, so it rides in the morph
48
+ // protection below for the same reason a token does: a morph of raw disk content
49
+ // would otherwise strip this tab's copy, and a peer's copy must never be applied.
50
+ //
51
+ // Two spellings, permanently, mirroring the token list above. htmlclay serves
52
+ // `documentid` and reads either, but a document saved before that rename holds
53
+ // `htmlclayid` on disk forever, and this list is what a morph consults. Knowing
54
+ // only the current name would leave every pre-rename file's identity unprotected,
55
+ // which is the failure this list exists to prevent.
26
56
  //
27
57
  // It was previously in the token list, where saveToken() returned it whenever a
28
58
  // host minted no token of its own. That is reachable: htmlclay stamps the id on
29
59
  // every serve but injects a token only for top-level document loads, and its save
30
60
  // route strips only the token, so the id reaches disk. Any such file hosted
31
61
  // somewhere tokenless made this library POST to /_/save/{id} with no cookie and
32
- // hand edit mode to every visitor.
33
- export const HOST_IDENTITY_ATTRS = ["htmlclayid"];
62
+ // hand edit mode to every visitor. Splitting this list off from the token list is
63
+ // what fixed that, and the split holds whatever token spellings are read above.
64
+ export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
34
65
 
35
- export const HOST_TOKEN_ATTRS = [...SAVE_TOKEN_ATTRS, ...HOST_IDENTITY_ATTRS];
66
+ // What a host may have injected, and therefore what has to be stripped before a save
67
+ // and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
68
+ // spelling is still injected by every htmlclay, so it still has to be stripped, whether
69
+ // or not this library will accept it as a credential.
70
+ export const HOST_TOKEN_ATTRS = [
71
+ ...SAVE_TOKEN_ATTRS,
72
+ ...LEGACY_SAVE_TOKEN_ATTRS,
73
+ ...HOST_IDENTITY_ATTRS,
74
+ ];
36
75
 
37
76
  // This library's own root state, and this tab's UI truth.
38
77
  export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
@@ -4,6 +4,10 @@ export const CORE_WAVES = {
4
4
  "core/edit-mode.js", // both modes — matches today: 'edit-mode' is NOT in EDIT_MODE_ONLY
5
5
  // (hyperclay.js:251-273 lists 'edit-mode-helpers', not 'edit-mode');
6
6
  // toggleEditMode must exist in view mode, it's the way IN
7
+ // always, deliberately: it speaks only when the host is too old to save to, which
8
+ // is exactly the case where edit mode is off, so the editOnly wave would never
9
+ // reach it. It draws nothing on any other page.
10
+ "core/stale-host-notice.js",
7
11
  ],
8
12
  editOnly: [
9
13
  "core/snapshot.js", "core/save-core.js", "core/save.js",
@@ -45,6 +49,7 @@ export const MODULES = {
45
49
  "lib/region-policy.js": () => import("./lib/region-policy.js"),
46
50
  "lib/mutation.js": () => import("./lib/mutation.js"),
47
51
  "core/edit-mode.js": () => import("./core/edit-mode.js"),
52
+ "core/stale-host-notice.js": () => import("./core/stale-host-notice.js"),
48
53
  "core/snapshot.js": () => import("./core/snapshot.js"),
49
54
  "core/save-core.js": () => import("./core/save-core.js"),
50
55
  "core/save.js": () => import("./core/save.js"),