@panphora/clayjs 1.11.3 → 1.12.1

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.
@@ -1,13 +1,13 @@
1
1
  // Edit mode system - combines the editmode toggle with page type setting.
2
2
  // Loads in BOTH view and edit mode: toggleEditMode must exist in view mode
3
3
  // (it's the way IN), and setPageTypeOnPageLoad marks <html> in either mode.
4
- import { isEditMode, isOwner } from "./is-edit-mode.js";
4
+ import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
5
5
  import onDomReady from "../lib/dom-ready.js";
6
6
  import { addDocumentTransform } from "./snapshot.js";
7
7
 
8
8
  export function toggleEditMode() {
9
9
  const url = new URL(window.location.href);
10
- const newMode = isEditMode ? "false" : "true";
10
+ const newMode = isEditMode && !isDraftMode ? "false" : "true";
11
11
  url.searchParams.set('editmode', newMode);
12
12
  window.location.href = url.toString();
13
13
  }
@@ -23,6 +23,11 @@ export function setPageTypeOnPageLoad () {
23
23
  onDomReady(() => {
24
24
  document.documentElement.setAttribute("editmode", isEditMode ? "true" : "false");
25
25
  document.documentElement.setAttribute("pageowner", isOwner ? "true" : "false");
26
+ if (isDraftMode) {
27
+ document.documentElement.setAttribute("draftmode", "true");
28
+ } else {
29
+ document.documentElement.removeAttribute("draftmode");
30
+ }
26
31
  });
27
32
  }
28
33
 
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { saveToken } from "./host-attrs.js";
17
+ import { isDraftMode } from "./is-edit-mode.js";
17
18
 
18
19
  const META_PATH = "/_/meta";
19
20
  const META_TIMEOUT_MS = 6000;
@@ -67,6 +68,7 @@ let refreshing = null;
67
68
  * @returns {Promise<{spec: ?number, extensions: string[], document: ?Object}>}
68
69
  */
69
70
  export function hostMeta({ fresh = false } = {}) {
71
+ if (isDraftMode) return Promise.resolve(bareHost());
70
72
  if (!fresh) {
71
73
  if (!inFlight) inFlight = fetchMeta().catch(bareHost);
72
74
  return inFlight;
@@ -1,6 +1,7 @@
1
1
  import cookie from "../lib/cookie.js";
2
2
  import query from "../lib/query.js";
3
3
  import { hasSaveToken, servedStaleToken, servedSiteVisibility } from "./host-attrs.js";
4
+ import { draftEligible } from "./draft-mode.js";
4
5
 
5
6
  // Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
6
7
  // authored `viewonly` attribute on <html> (a tool page that must never save its
@@ -11,7 +12,9 @@ import { hasSaveToken, servedStaleToken, servedSiteVisibility } from "./host-att
11
12
  // a save token the host put on the root, then the isAdminOfCurrentResource cookie. The
12
13
  // global is for standalone uses (demos, htmlclay, any self-saving file) that are
13
14
  // always editable and have no owner cookie; setting it before clayjs loads turns
14
- // on the edit-only modules.
15
+ // on the edit-only modules. On a page that opts into visitor drafts, draft mode
16
+ // comes before ?editmode=true unless the owner cookie is present, and before the
17
+ // owner cookie otherwise.
15
18
  //
16
19
  // The token sits above the cookie because it is the stronger claim and often the
17
20
  // only one visible: the host minted it for this response and nothing else, while
@@ -37,19 +40,34 @@ if (typeof window !== "undefined") {
37
40
  const isViewOnlyDocument =
38
41
  typeof document !== "undefined" && document.documentElement.hasAttribute("viewonly");
39
42
 
40
- const isEditMode = query.editmode
41
- ? query.editmode === "true" // takes precedence over viewonly, the global, token and cookie
42
- : isViewOnlyDocument || servedStaleToken()
43
- ? false
44
- : forcedEditMode != null
45
- ? forcedEditMode
46
- : hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
43
+ const draftCandidate = typeof document !== "undefined" && draftEligible();
44
+ const ownerCookie = Boolean(cookie.get("isAdminOfCurrentResource"));
47
45
 
48
- const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
46
+ const isDraftMode =
47
+ draftCandidate &&
48
+ query.editmode !== "false" &&
49
+ !(query.editmode === "true" && ownerCookie) &&
50
+ !isViewOnlyDocument &&
51
+ !servedStaleToken() &&
52
+ forcedEditMode == null &&
53
+ !hasSaveToken();
54
+
55
+ const isEditMode = isDraftMode
56
+ ? true
57
+ : query.editmode
58
+ ? query.editmode === "true" // takes precedence over viewonly, the global, token and cookie
59
+ : isViewOnlyDocument || servedStaleToken()
60
+ ? false
61
+ : forcedEditMode != null
62
+ ? forcedEditMode
63
+ : hasSaveToken() || ownerCookie;
64
+
65
+ const isOwner = ownerCookie;
49
66
 
50
67
  const siteVisibility = servedSiteVisibility;
51
68
 
52
69
  export {
70
+ isDraftMode,
53
71
  isEditMode,
54
72
  isOwner,
55
73
  siteVisibility
@@ -132,6 +132,12 @@ async function defaultWriteModules() {
132
132
  return { ...save, ...saveCore, ...etag, ...warning, ...editMode };
133
133
  }
134
134
 
135
+ async function draftRefusal() {
136
+ const { isDraftMode } = await import("./is-edit-mode.js");
137
+ if (!isDraftMode) return null;
138
+ return refusal("DataUnavailableInDraft", "Page data is kept in this browser for this visitor.");
139
+ }
140
+
135
141
  export function createPageDataApi({
136
142
  supports = hostSupports,
137
143
  token = saveToken,
@@ -151,6 +157,9 @@ export function createPageDataApi({
151
157
  }
152
158
 
153
159
  async function readData(rules) {
160
+ const draft = await draftRefusal();
161
+ if (draft) throw draft;
162
+
154
163
  if (!(await supports(DATA_READ))) {
155
164
  throw refusal("DataReadUnsupported", "This host does not support reading page data.");
156
165
  }
@@ -201,6 +210,9 @@ export function createPageDataApi({
201
210
  }
202
211
 
203
212
  async function writeData(values, { rules, ifMatch } = {}) {
213
+ const draft = await draftRefusal();
214
+ if (draft) throw draft;
215
+
204
216
  if (!(await supports(DATA_WRITE))) {
205
217
  throw refusal("DataWriteUnsupported", "This host does not support writing page data.");
206
218
  }
@@ -7,7 +7,8 @@
7
7
  * For full save system with state management, use save.js instead.
8
8
  */
9
9
 
10
- import { isEditMode } from "./is-edit-mode.js";
10
+ import { isEditMode, isDraftMode } from "./is-edit-mode.js";
11
+ import { draftTransport } from "./draft-mode.js";
11
12
  import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
12
13
  import { saveToken } from "./host-attrs.js";
13
14
  import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
@@ -379,6 +380,24 @@ function resolveSaveUrl(path) {
379
380
  return new URL(path, origin).href;
380
381
  }
381
382
 
383
+ function draftResponse(status, body) {
384
+ const text = JSON.stringify(body);
385
+ return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? "OK" : "Insufficient Storage", text: () => Promise.resolve(text) };
386
+ }
387
+
388
+ function sendToDraftStore(content) {
389
+ const send = draftTransport();
390
+ if (!send) return Promise.resolve(draftResponse(503, { msg: "Saving in this browser is not ready yet", msgType: "error", code: "draft-not-ready" }));
391
+ return Promise.resolve().then(() => {
392
+ try {
393
+ send(content);
394
+ return draftResponse(200, { msg: "Saved in this browser", msgType: "success" });
395
+ } catch (error) {
396
+ return draftResponse(507, { msg: "Couldn't save in this browser. Storage is full or turned off.", msgType: "error", code: error.code || "draft-storage" });
397
+ }
398
+ });
399
+ }
400
+
382
401
  /**
383
402
  * Send one save and decode one response.
384
403
  *
@@ -414,7 +433,7 @@ function sendOnce(content, saveId, etag) {
414
433
  const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
415
434
  rememberSentId(saveId);
416
435
 
417
- return fetch(url, options)
436
+ return (isDraftMode ? sendToDraftStore(content) : fetch(url, options))
418
437
  .then(res => res.text().then(text => {
419
438
  let data = {};
420
439
  if (text) {
@@ -689,6 +708,7 @@ export function replacePageWith(url, callback = () => {}) {
689
708
 
690
709
  if (!isEditMode) return done(skippedResult('Not in edit mode'));
691
710
  if (saveInProgress) return done(skippedResult('Save already in progress'));
711
+ if (isDraftMode) return done(false);
692
712
 
693
713
  // Resolved against the document's real location, not a <base href> the page
694
714
  // may have set.
package/src/core/save.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import throttle from "../lib/throttle.js";
13
13
  import Mutation from "../lib/mutation.js";
14
- import { isEditMode, isOwner } from "./is-edit-mode.js";
14
+ import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
15
15
  import {
16
16
  saveHtml,
17
17
  getPageContents,
@@ -742,7 +742,7 @@ export async function saveOverwritingConflict(callback = () => {}) {
742
742
  * @param {string} url - URL to fetch from
743
743
  */
744
744
  export function replacePageWith(url) {
745
- if (!isEditMode) {
745
+ if (!isEditMode || isDraftMode) {
746
746
  return;
747
747
  }
748
748
 
@@ -15,7 +15,8 @@
15
15
  * Requires the 'save-system' module (automatically included as dependency).
16
16
  */
17
17
 
18
- import { isEditMode } from "./is-edit-mode.js";
18
+ import { isEditMode, isDraftMode } from "./is-edit-mode.js";
19
+ import { draftStoreFailed, flushDraft } from "./draft-mode.js";
19
20
  import { captureForDirtyCheck } from "./snapshot.js";
20
21
  import { getLastSavedDirty } from "./save.js";
21
22
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
@@ -52,6 +53,7 @@ export function hasUnsavedChanges() {
52
53
  // Work outside the DOM first: it needs no capture, and a capture that throws
53
54
  // must not hide it. The demo plugin does not save it either, so it warns there too.
54
55
  if (hasUnsavedState()) return true;
56
+ if (isDraftMode) return draftStoreFailed();
55
57
 
56
58
  // The demo plugin saves the page's bytes into this browser's own storage, so
57
59
  // leaving loses none of them.
@@ -84,7 +86,8 @@ window.addEventListener('beforeunload', (event) => {
84
86
  return;
85
87
  }
86
88
 
87
- if (!hasUnsavedChanges()) return;
89
+ const flushed = isDraftMode ? flushDraft() : true;
90
+ if (flushed && !hasUnsavedChanges()) return;
88
91
 
89
92
  event.preventDefault();
90
93
  event.returnValue = '';
@@ -94,7 +94,7 @@ export const HOST_TOKEN_ATTRS = [
94
94
  ];
95
95
 
96
96
  // This library's own root state, and this tab's UI truth.
97
- export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
97
+ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner", "draftmode"];
98
98
 
99
99
  // Never copied between tabs: not written onto this root by an incoming morph, not
100
100
  // sent out on a sync broadcast.
@@ -55,6 +55,7 @@ export const PLUGIN_PATHS = {
55
55
  // verify is sent as the ordinary full serialization, so the floor is the behaviour
56
56
  // it replaces. `exclude=source` turns it off.
57
57
  source: { path: "plugins/source.js", editOnly: true, default: true },
58
+ "visitor-drafts": { path: "plugins/visitor-drafts.js", editOnly: false, default: false },
58
59
  };
59
60
 
60
61
  // One literal import per module the loader can ask for, so a bundler can see the
@@ -68,6 +69,7 @@ export const PLUGIN_PATHS = {
68
69
  // PLUGIN_PATHS has an entry here and that each entry imports the file it names.
69
70
  export const MODULES = {
70
71
  "core/is-edit-mode.js": () => import("./core/is-edit-mode.js"),
72
+ "core/draft-mode.js": () => import("./core/draft-mode.js"),
71
73
  "lib/region-policy.js": () => import("./lib/region-policy.js"),
72
74
  "lib/mutation.js": () => import("./lib/mutation.js"),
73
75
  "core/edit-mode.js": () => import("./core/edit-mode.js"),
@@ -100,12 +102,13 @@ export const MODULES = {
100
102
  "plugins/wire.js": () => import("./plugins/wire.js"),
101
103
  "plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
102
104
  "plugins/demo.js": () => import("./plugins/demo.js"),
105
+ "plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
103
106
  "plugins/source.js": () => import("./plugins/source.js"),
104
107
  };
105
108
 
106
109
  // `source` is last on purpose: its install captures a save clone, so it wants every
107
110
  // plugin that registers a document transform to have registered it first.
108
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
111
+ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
109
112
 
110
113
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
111
114
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
@@ -143,7 +146,15 @@ function parseCsv(params, key, enabled, apply) {
143
146
  }
144
147
  }
145
148
 
146
- export function resolveModules(params, isEditMode) {
149
+ // Each of these talks to the host or writes outside the visitor's own record, so a
150
+ // page that keeps its edits in this browser cannot carry them.
151
+ const DRAFT_DROPS = ["sync", "live", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
152
+
153
+ // [refetch-on-save] swaps its element for a fresh GET of the same URL after every
154
+ // save, which on a visitor's own draft is a request to the host.
155
+ const DRAFT_DROPS_CORE = ["attrs/refetch-on-save.js"];
156
+
157
+ export function resolveModules(params, isEditMode, { isDraftMode = false } = {}) {
147
158
  const core = [...CORE_WAVES.always];
148
159
  if (isEditMode) core.push(...CORE_WAVES.editOnly);
149
160
 
@@ -166,6 +177,14 @@ export function resolveModules(params, isEditMode) {
166
177
  for (const implied of IMPLIES[name] || []) enabled.add(implied);
167
178
  }
168
179
  for (const name of excluded) enabled.delete(name);
180
+ if (isDraftMode) {
181
+ for (const name of DRAFT_DROPS) enabled.delete(name);
182
+ for (const path of DRAFT_DROPS_CORE) {
183
+ const at = core.indexOf(path);
184
+ if (at > -1) core.splice(at, 1);
185
+ }
186
+ enabled.add("visitor-drafts");
187
+ }
169
188
 
170
189
  const plugins = [];
171
190
  for (const name of PLUGIN_ORDER) {
package/src/loader.js CHANGED
@@ -14,6 +14,8 @@ function domReady() {
14
14
  // no clayjs at all.
15
15
  export async function boot(base, params, readyResolve) {
16
16
  initTheme();
17
+ const draftMode = await MODULES["core/draft-mode.js"]();
18
+ draftMode.noteBootParams(params);
17
19
 
18
20
  await domReady(); // Mutation observes document.body
19
21
  // unconditionally, and a <head> placement
@@ -30,14 +32,16 @@ export async function boot(base, params, readyResolve) {
30
32
 
31
33
  const regionPolicy = await MODULES["lib/region-policy.js"]();
32
34
 
33
- const plan = resolveModules(params, isEditMode);
35
+ const { isDraftMode } = editMode;
36
+
37
+ const plan = resolveModules(params, isEditMode, { isDraftMode });
34
38
  const loaded = {};
35
39
 
36
40
  for (const path of plan.core) {
37
41
  loaded[path] = await MODULES[path](); // sequential: order is load-bearing
38
42
  }
39
43
 
40
- assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy); // window.clay MUST be assembled
44
+ assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy); // window.clay MUST be assembled
41
45
  // before any plugin import
42
46
 
43
47
  for (const path of plan.plugins) {
@@ -69,7 +73,7 @@ export async function boot(base, params, readyResolve) {
69
73
  document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
70
74
  }
71
75
 
72
- function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy) {
76
+ function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy) {
73
77
  const clay = window.clay;
74
78
 
75
79
  const mutation = loaded["lib/mutation.js"];
@@ -77,6 +81,7 @@ function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPol
77
81
 
78
82
  Object.assign(clay, {
79
83
  toggleEditMode: editModeMod.toggleEditMode,
84
+ isDraftMode,
80
85
  isEditMode,
81
86
  isOwner,
82
87
  siteVisibility,
@@ -148,6 +153,8 @@ function attachPluginMember(path, mod, loaded) {
148
153
  clay.quickcrop = mod.quickcrop || mod.default;
149
154
  } else if (path === "plugins/source.js") {
150
155
  clay.source = mod.source || mod.default;
156
+ } else if (path === "plugins/visitor-drafts.js") {
157
+ clay.visitorDrafts = mod.visitorDrafts;
151
158
  }
152
159
  }
153
160
 
@@ -0,0 +1,31 @@
1
+ export const BOTTOM_UI_CHANGED = "clay:bottom-ui-changed";
2
+
3
+ export function announceBottomUi() {
4
+ document.dispatchEvent(new CustomEvent(BOTTOM_UI_CHANGED));
5
+ }
6
+
7
+ export function bottomBarHeight(x, skip) {
8
+ const h = window.innerHeight;
9
+ let raise = 0;
10
+ const hits = document.elementsFromPoint ? document.elementsFromPoint(x, h - 3) : [];
11
+ for (const hit of hits) {
12
+ for (let el = hit; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
13
+ if (skip(el)) break;
14
+ const position = getComputedStyle(el).position;
15
+ if (position === "fixed" || position === "sticky") {
16
+ const height = Math.ceil(h - el.getBoundingClientRect().top);
17
+ if (height < h / 3) raise = Math.max(raise, height);
18
+ break;
19
+ }
20
+ }
21
+ }
22
+ return raise;
23
+ }
24
+
25
+ export function promptPanel(box) {
26
+ const panel = document.querySelector("#hcp:not([hidden])");
27
+ if (!panel) return 0;
28
+ const r = panel.getBoundingClientRect();
29
+ if (!r.width || !r.height || r.right <= box.left || r.left >= box.right) return 0;
30
+ return Math.ceil(window.innerHeight - r.top);
31
+ }
@@ -0,0 +1,19 @@
1
+ export const INTRO_CSS = `html:has(> body > .clay-intro) { height: auto !important; overflow-y: auto !important; }
2
+ body:has(> .clay-intro) { height: auto !important; overflow: visible !important; }
3
+ .clay-intro {
4
+ position: relative;
5
+ display: block;
6
+ }
7
+ .clay-intro__close {
8
+ position: absolute; top: 10px; right: 10px; z-index: 1;
9
+ display: inline-flex; align-items: center; justify-content: center;
10
+ width: 28px; height: 28px; min-height: 0; margin: 0; padding: 0;
11
+ border: 0; border-radius: 4px; background: none; box-shadow: none;
12
+ color: inherit; opacity: .55; cursor: pointer; line-height: 1;
13
+ }
14
+ .clay-intro .clay-intro__close:hover { opacity: 1; background: none; color: inherit; }
15
+ .clay-intro__close:focus-visible { opacity: 1; outline: 2px solid currentColor; outline-offset: 2px; }
16
+ .clay-intro__close svg { display: block; width: 12px; height: 12px; fill: currentColor; stroke: none; }
17
+ @media (pointer: coarse) { .clay-intro__close { width: 44px; height: 44px; top: 4px; right: 4px; } }
18
+ @media print { .clay-intro { display: none; } }
19
+ `;
@@ -0,0 +1,118 @@
1
+ import { INTRO_CSS } from "./intro-style.js";
2
+ import { announceBottomUi } from "./bottom-ui.js";
3
+
4
+ export const COPY = {
5
+ close: "Hide this introduction",
6
+ label: (appName) => `About ${appName}`,
7
+ };
8
+
9
+ const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]:not([tabindex='-1'])";
10
+ const TEXT_ENTRY = "textarea, select";
11
+ const NON_TEXT_INPUT = ["checkbox", "radio", "button", "submit", "reset", "file"];
12
+
13
+ function isTextEntry(el) {
14
+ if (el.isContentEditable || el.closest(TEXT_ENTRY)) return true;
15
+ if (el.tagName !== "INPUT") return false;
16
+ return NON_TEXT_INPUT.indexOf(el.type) < 0;
17
+ }
18
+
19
+ function forced() {
20
+ try {
21
+ return new URLSearchParams(window.location.search).get("intro") === "1";
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function stylesOnce() {
28
+ if (document.getElementById("clay-intro-style")) return;
29
+ const style = document.createElement("style");
30
+ style.id = "clay-intro-style";
31
+ style.setAttribute("clay", "editor-ui");
32
+ style.textContent = INTRO_CSS;
33
+ document.head.appendChild(style);
34
+ }
35
+
36
+ function focusApp() {
37
+ const next = [...document.querySelectorAll(FOCUSABLE)].find(
38
+ (el) => !el.disabled && !isTextEntry(el) && !el.closest("[clay~='editor-ui']") && (typeof el.checkVisibility !== "function" || el.checkVisibility())
39
+ );
40
+ if (next) next.focus({ preventScroll: true });
41
+ else document.body.focus({ preventScroll: true });
42
+ }
43
+
44
+ export function mountIntro({ memory, template, appName, hadDraft }) {
45
+ if (!template || !template.content.textContent.trim() && !template.content.querySelector("img, svg, video, canvas")) return null;
46
+ if (!forced()) {
47
+ if (memory.get().intro.done || hadDraft) return null;
48
+ if (window.location.hash) return null;
49
+ if (window.matchMedia && window.matchMedia("print").matches) return null;
50
+ }
51
+ return mount({ memory, template, appName });
52
+ }
53
+
54
+ function mount({ memory, template, appName }) {
55
+ stylesOnce();
56
+
57
+ const section = document.createElement("section");
58
+ section.className = "clay-intro";
59
+ section.setAttribute("clay", "editor-ui");
60
+ section.setAttribute("sap-ignore", "");
61
+ section.setAttribute("aria-label", COPY.label(appName));
62
+ section.appendChild(template.content.cloneNode(true));
63
+
64
+ const close = document.createElement("button");
65
+ close.type = "button";
66
+ close.className = "clay-intro__close";
67
+ close.setAttribute("aria-label", COPY.close);
68
+ close.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M1.4 0 6 4.6 10.6 0 12 1.4 7.4 6 12 10.6 10.6 12 6 7.4 1.4 12 0 10.6 4.6 6 0 1.4z"/></svg>';
69
+ section.appendChild(close);
70
+
71
+ let gone = false;
72
+ let frame = 0;
73
+
74
+ function dismiss(reason) {
75
+ if (gone) return;
76
+ gone = true;
77
+ removeEventListener("scroll", onScroll);
78
+ if (frame) cancelAnimationFrame(frame);
79
+ const height = section.getBoundingClientRect().height;
80
+ const before = window.scrollY;
81
+ memory.get().intro.done = reason;
82
+ memory.save();
83
+ section.remove();
84
+ if (reason === "scrolled") {
85
+ const locked = /hidden|clip/.test(getComputedStyle(document.body).overflowY + getComputedStyle(document.documentElement).overflowY);
86
+ window.scrollTo(0, locked ? 0 : Math.max(0, before - height));
87
+ }
88
+ announceBottomUi();
89
+ if (reason !== "scrolled") focusApp();
90
+ }
91
+
92
+ function onScroll() {
93
+ if (frame) return;
94
+ frame = requestAnimationFrame(() => {
95
+ frame = 0;
96
+ const bottom = section.getBoundingClientRect().bottom;
97
+ const atEnd = scrollY > 0 && scrollY + innerHeight >= document.documentElement.scrollHeight - 1;
98
+ if (bottom < 1 || (atEnd && bottom < innerHeight)) dismiss("scrolled");
99
+ });
100
+ }
101
+
102
+ section.addEventListener("click", (event) => {
103
+ if (event.target.closest(".clay-intro__close, [visitor-intro-dismiss]")) {
104
+ dismiss("closed");
105
+ return;
106
+ }
107
+ if (event.target.closest("[visitor-intro-start]")) {
108
+ event.preventDefault();
109
+ window.scrollTo({ top: window.scrollY + section.getBoundingClientRect().bottom, behavior: "smooth" });
110
+ }
111
+ });
112
+
113
+ document.body.prepend(section);
114
+ announceBottomUi();
115
+ addEventListener("scroll", onScroll, { passive: true });
116
+
117
+ return section;
118
+ }