@panphora/clayjs 1.12.1 → 1.13.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.
@@ -0,0 +1,157 @@
1
+ // The page plugin's ProseMirror schema.
2
+ //
3
+ // Every top-level node is one block of the live record (src/page/model.js): it
4
+ // carries `id` (its key in the store), `pos` (its fractional order key) and
5
+ // `parent` (the toggle it sits inside, or null). The document is flat on
6
+ // purpose: lists are items with `kind` and `indent`, a toggle's body is the
7
+ // blocks naming it as parent. Two people editing two items of one list then
8
+ // touch two keys, never one shared array.
9
+ //
10
+ // toDOM here draws the editing surface. The saved, readable HTML is the
11
+ // projection's job, not this file's.
12
+ import { model } from "../vendor/prosemirror.vendor.js";
13
+
14
+ const block = { id: { default: null }, pos: { default: null }, parent: { default: null } };
15
+ const ref = (n, extra = {}) => ({ "data-id": n.attrs.id, ...extra });
16
+
17
+ export const schema = new model.Schema({
18
+ nodes: {
19
+ doc: { content: "block+" },
20
+
21
+ paragraph: {
22
+ group: "block",
23
+ content: "inline*",
24
+ attrs: block,
25
+ toDOM: (n) => ["p", ref(n), 0],
26
+ },
27
+ heading: {
28
+ group: "block",
29
+ content: "inline*",
30
+ defining: true,
31
+ attrs: { ...block, level: { default: 2 } },
32
+ toDOM: (n) => [`h${n.attrs.level}`, ref(n), 0],
33
+ },
34
+ list_item: {
35
+ group: "block",
36
+ content: "inline*",
37
+ attrs: {
38
+ ...block,
39
+ kind: { default: "bullet" },
40
+ indent: { default: 0 },
41
+ checked: { default: false },
42
+ owner: { default: null },
43
+ due: { default: null },
44
+ },
45
+ toDOM: (n) => ["div", ref(n, {
46
+ class: "page-li",
47
+ "data-kind": n.attrs.kind,
48
+ "data-indent": String(n.attrs.indent),
49
+ "data-checked": n.attrs.checked ? "true" : "false",
50
+ }), 0],
51
+ },
52
+ callout: {
53
+ group: "block",
54
+ content: "inline*",
55
+ attrs: block,
56
+ toDOM: (n) => ["aside", ref(n, { class: "page-callout" }), 0],
57
+ },
58
+ code_block: {
59
+ group: "block",
60
+ content: "text*",
61
+ marks: "",
62
+ code: true,
63
+ defining: true,
64
+ attrs: { ...block, lang: { default: "" } },
65
+ toDOM: (n) => ["pre", ref(n, { "data-lang": n.attrs.lang }), ["code", 0]],
66
+ },
67
+ toggle: {
68
+ group: "block",
69
+ content: "inline*",
70
+ attrs: { ...block, open: { default: false } },
71
+ toDOM: (n) => ["div", ref(n, { class: "page-toggle", "data-open": n.attrs.open ? "true" : "false" }), 0],
72
+ },
73
+ divider: {
74
+ group: "block",
75
+ atom: true,
76
+ attrs: block,
77
+ toDOM: (n) => ["hr", ref(n)],
78
+ },
79
+ image: {
80
+ group: "block",
81
+ atom: true,
82
+ draggable: true,
83
+ attrs: { ...block, src: { default: "" }, alt: { default: "" }, caption: { default: "" }, width: { default: null } },
84
+ toDOM: (n) => ["figure", ref(n), ["img", { src: n.attrs.src, alt: n.attrs.alt }], ["figcaption", n.attrs.caption]],
85
+ },
86
+ table: {
87
+ group: "block",
88
+ content: "table_row+",
89
+ isolating: true,
90
+ attrs: { ...block, cols: { default: {} }, sort: { default: null } },
91
+ toDOM: (n) => ["table", ref(n), ["tbody", 0]],
92
+ },
93
+ table_row: {
94
+ content: "table_cell+",
95
+ attrs: { id: { default: null }, pos: { default: null } },
96
+ toDOM: (n) => ["tr", ref(n), 0],
97
+ },
98
+ table_cell: {
99
+ content: "text*",
100
+ marks: "",
101
+ isolating: true,
102
+ attrs: { col: { default: null } },
103
+ toDOM: (n) => ["td", { "data-col": n.attrs.col }, 0],
104
+ },
105
+ chart: {
106
+ group: "block",
107
+ atom: true,
108
+ attrs: {
109
+ ...block,
110
+ source: { default: null },
111
+ kind: { default: "line" },
112
+ x: { default: null },
113
+ series: { default: [] },
114
+ title: { default: "" },
115
+ highlight: { default: null },
116
+ },
117
+ toDOM: (n) => ["figure", ref(n, { "data-type": "chart" })],
118
+ },
119
+ board: {
120
+ group: "block",
121
+ atom: true,
122
+ attrs: { ...block, columns: { default: {} }, cards: { default: {} } },
123
+ toDOM: (n) => ["section", ref(n, { "data-type": "board" })],
124
+ },
125
+ tool: {
126
+ group: "block",
127
+ atom: true,
128
+ attrs: { ...block, artifact: { default: null }, rev: { default: null }, state: { default: {} } },
129
+ toDOM: (n) => ["section", ref(n, { "data-type": "tool" })],
130
+ },
131
+ preserved: {
132
+ group: "block",
133
+ atom: true,
134
+ attrs: { ...block, html: { default: "" } },
135
+ toDOM: (n) => ["div", ref(n, { "data-type": "preserved" })],
136
+ },
137
+
138
+ text: { group: "inline" },
139
+ hard_break: {
140
+ group: "inline",
141
+ inline: true,
142
+ selectable: false,
143
+ toDOM: () => ["br"],
144
+ },
145
+ },
146
+ marks: {
147
+ strong: { toDOM: () => ["strong", 0] },
148
+ em: { toDOM: () => ["em", 0] },
149
+ strike: { toDOM: () => ["s", 0] },
150
+ code: { toDOM: () => ["code", 0] },
151
+ link: {
152
+ attrs: { href: {} },
153
+ inclusive: false,
154
+ toDOM: (m) => ["a", { href: m.attrs.href }, 0],
155
+ },
156
+ },
157
+ });
@@ -61,10 +61,17 @@ function unsavedLiveChanges() {
61
61
  return false;
62
62
  }
63
63
 
64
+ // Callbacks waiting for the file to catch up, run after every checkpoint save
65
+ // and every time the room reports a landing.
66
+ const fileWaiters = new Set();
67
+ function fileMoved() {
68
+ for (const fn of [...fileWaiters]) fn();
69
+ }
70
+
64
71
  const checkpoints = createCheckpoints({
65
72
  needsSave: unsavedLiveChanges,
66
73
  blocked: () => !baselineSettled() || isSaveConflicted() || autosaveIsSuspended(),
67
- save: () => savePageForce(),
74
+ save: () => savePageForce().finally(fileMoved),
68
75
  saveNow: () => withKeepalive(true, () => savePageForce()),
69
76
  });
70
77
 
@@ -124,7 +131,10 @@ function laneOptions(transport) {
124
131
  entries,
125
132
  onWriter: () => checkpoints.changed(),
126
133
  onApplied: liveMoved,
127
- onLanded: liveMoved,
134
+ onLanded: () => {
135
+ liveMoved();
136
+ fileMoved();
137
+ },
128
138
  onCheckpoint: () => checkpoints.soon(),
129
139
  onLeave: (name, at) => {
130
140
  const entry = entries.get(name);
@@ -443,4 +453,41 @@ export function fastLane() {
443
453
  return fast;
444
454
  }
445
455
 
456
+ // The page editor's pause save: checkpoint within ms of now. Only the room's
457
+ // writer saves, so in other tabs this changes nothing.
458
+ export function checkpointWithin(ms) {
459
+ checkpoints.within(ms);
460
+ }
461
+
462
+ // Resolves { ok: true } once the file holds every live change this tab knows
463
+ // of now, or { ok: false, code: "timeout" }. The writer saves as soon as it
464
+ // can; another tab waits for the writer's save to land.
465
+ export function requestCheckpoint({ timeoutMs = 10_000 } = {}) {
466
+ const target = new Map();
467
+ for (const [name, entry] of entries) {
468
+ if (entry.store.ephemeral) continue;
469
+ target.set(name, entry.store.handle.status.mode === "fast" ? entry.last ?? 0 : null);
470
+ }
471
+ const reached = () => [...target].every(([name, last]) => {
472
+ const entry = entries.get(name);
473
+ if (!entry) return true;
474
+ if (last === null || entry.store.handle.status.mode !== "fast") return entry.store.pending.length === 0;
475
+ return (entry.landed ?? 0) >= last;
476
+ });
477
+ if (reached()) return Promise.resolve({ ok: true });
478
+ return new Promise((resolve) => {
479
+ const finish = (result) => {
480
+ clearTimeout(timer);
481
+ fileWaiters.delete(check);
482
+ resolve(result);
483
+ };
484
+ const check = () => {
485
+ if (reached()) finish({ ok: true });
486
+ };
487
+ const timer = setTimeout(() => finish({ ok: false, code: "timeout" }), timeoutMs);
488
+ fileWaiters.add(check);
489
+ if (isEditMode && (!fast || fast.isWriter())) checkpoints.soon();
490
+ });
491
+ }
492
+
446
493
  export default live;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * message.js — send a visitor's form to the document's owner (the host's `/_/message`).
3
+ *
4
+ * <form message-form="Thanks, I'll reply soon"> ... </form>
5
+ * const result = await clay.message.send({ email, note }); // { ok, error }
6
+ *
7
+ * Any form carrying `message-form`, or whose `action` is `/_/message`, is sent by
8
+ * script on submit: its fields as JSON, with the `Document-URL` header so the host
9
+ * knows whose document it came from. The form resets, the attribute's text shows
10
+ * as a toast, and `clay:message-sent` or `clay:message-failed` fires on the form.
11
+ * Cancel that event to draw your own confirmation instead of the toast.
12
+ *
13
+ * The bot check. The host refuses a message that carries no token from
14
+ * `GET /_/form-token`, so a bot that posts without running the page sends nothing.
15
+ * The token is fetched the first time the visitor focuses or types inside a
16
+ * message form (an untouched form costs no request), and again at submit when it
17
+ * is missing or old. Each token is used once. `formSignals` counts trusted
18
+ * events and reads `navigator.webdriver`; the host only logs it.
19
+ *
20
+ * `send` never rejects, matching upload: one result shape from every exit.
21
+ */
22
+
23
+ import getDataFromForm from "../dom/form-data.js";
24
+
25
+ const MESSAGE_PATH = "/_/message";
26
+ const TOKEN_PATH = "/_/form-token";
27
+ // The host accepts a token for two hours; refetch well before that.
28
+ const TOKEN_MAX_AGE_MS = 90 * 60 * 1000;
29
+
30
+ const TRUSTED_EVENTS = ["click", "keydown", "pointerdown", "touchstart", "input", "change"];
31
+ const trusted = Object.fromEntries(TRUSTED_EVENTS.map((type) => [type, 0]));
32
+
33
+ let token = null;
34
+ let tokenAt = 0;
35
+ let tokenRequest = null;
36
+ const sending = new WeakSet();
37
+
38
+ function fetchToken() {
39
+ if (!tokenRequest) {
40
+ tokenRequest = fetch(TOKEN_PATH, { credentials: "omit", cache: "no-store" })
41
+ .then((res) => (res.ok ? res.json() : null))
42
+ .then((body) => {
43
+ if (body && typeof body.token === "string") {
44
+ token = body.token;
45
+ tokenAt = Date.now();
46
+ }
47
+ })
48
+ .catch(() => {})
49
+ .finally(() => {
50
+ tokenRequest = null;
51
+ });
52
+ }
53
+ return tokenRequest;
54
+ }
55
+
56
+ async function takeToken() {
57
+ if (!token || Date.now() - tokenAt > TOKEN_MAX_AGE_MS) await fetchToken();
58
+ const taken = token;
59
+ token = null;
60
+ return taken;
61
+ }
62
+
63
+ function signals() {
64
+ return { webdriver: navigator.webdriver === true, trusted: { ...trusted } };
65
+ }
66
+
67
+ function isMessageForm(form) {
68
+ if (!(form instanceof HTMLFormElement)) return false;
69
+ if (form.hasAttribute("message-form")) return true;
70
+ const action = form.getAttribute("action");
71
+ if (!action) return false;
72
+ try {
73
+ const url = new URL(action, window.location.href);
74
+ return url.origin === window.location.origin && url.pathname === MESSAGE_PATH;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Send one message to the document's owner.
82
+ *
83
+ * @param {Object} data - the fields, as the owner should read them
84
+ * @returns {Promise<{ok: boolean, error: ?string}>}
85
+ */
86
+ export async function send(data = {}) {
87
+ const result = await post(data);
88
+ // The host refused the token itself (it restarted, or the token expired): one fresh token,
89
+ // one more try, and the visitor never knows.
90
+ if (result.code === "form-token") return post(data);
91
+ return result;
92
+ }
93
+
94
+ async function post(data) {
95
+ const formToken = await takeToken();
96
+ let res;
97
+ try {
98
+ res = await fetch(MESSAGE_PATH, {
99
+ method: "POST",
100
+ // A signed-in owner testing their own form must not trip the host's CSRF
101
+ // guard, and the message never needs the visitor's cookies.
102
+ credentials: "omit",
103
+ headers: { "Content-Type": "application/json", "Document-URL": window.location.href },
104
+ body: JSON.stringify({ ...data, formToken, formSignals: signals() }),
105
+ });
106
+ } catch {
107
+ return { ok: false, error: "The message couldn't be sent. Check your connection and try again." };
108
+ }
109
+ const body = await res.json().catch(() => null);
110
+ // A host without the message endpoint can answer any path with its HTML page at 200.
111
+ if (res.ok) return body ? { ok: true, error: null } : { ok: false, error: "This page can't send messages." };
112
+ if (!body) return { ok: false, error: `The message couldn't be sent (${res.status}).` };
113
+ const error = body.error || body.msg || `The message couldn't be sent (${res.status}).`;
114
+ return body.code === "form-token" ? { ok: false, error, code: "form-token" } : { ok: false, error };
115
+ }
116
+
117
+ async function onSubmit(event) {
118
+ const form = event.target;
119
+ if (event.defaultPrevented || !isMessageForm(form)) return;
120
+ event.preventDefault();
121
+ if (sending.has(form)) return;
122
+ sending.add(form);
123
+ try {
124
+ const result = await send(getDataFromForm(form));
125
+ if (result.ok) form.reset();
126
+ const outcome = new CustomEvent(result.ok ? "clay:message-sent" : "clay:message-failed", {
127
+ bubbles: true,
128
+ cancelable: true,
129
+ detail: result,
130
+ });
131
+ if (!form.dispatchEvent(outcome)) return;
132
+ const toast = window.clay?.toast;
133
+ if (!toast) return;
134
+ if (result.ok) toast(form.getAttribute("message-form") || "Sent", "success");
135
+ else toast(result.error, "error");
136
+ } finally {
137
+ sending.delete(form);
138
+ }
139
+ }
140
+
141
+ function warmToken(event) {
142
+ if (token || tokenRequest) return;
143
+ const form = event.target?.closest?.("form");
144
+ if (form && isMessageForm(form)) fetchToken();
145
+ }
146
+
147
+ if (typeof document !== "undefined") {
148
+ for (const type of TRUSTED_EVENTS) {
149
+ document.addEventListener(type, (event) => {
150
+ if (event.isTrusted) trusted[type]++;
151
+ }, { capture: true, passive: true });
152
+ }
153
+ document.addEventListener("focusin", warmToken, true);
154
+ document.addEventListener("input", warmToken, true);
155
+ document.addEventListener("submit", onSubmit);
156
+ }
157
+
158
+ export const message = { send };
159
+ export default message;
@@ -91,6 +91,8 @@ export const PROMPT_CSS = `.hcp {
91
91
  .hcp .hcp-btn--primary:hover { background: color-mix(in srgb, var(--hcp-ink), var(--hcp-ground) 12%); color: var(--hcp-ground); }
92
92
 
93
93
  .hcp-dock { display: flex; align-items: stretch; box-shadow: 0 6px 16px -8px rgba(43, 36, 27, .45); }
94
+ .hcp:not([data-hcp-arrived]) .hcp-dock { animation: hcp-arrive 2.4s cubic-bezier(.16, 1, .3, 1) 3s both; }
95
+ @keyframes hcp-arrive { from { transform: translateX(calc(100% + 48px)); } to { transform: none; } }
94
96
  .hcp-tab { font-size: 13.5px; padding: 0 12px 0 10px; }
95
97
  .hcp-tab svg { color: var(--hcp-brass); }
96
98
  .hcp-dock .hcp-x {
@@ -151,22 +153,7 @@ export const PROMPT_CSS = `.hcp {
151
153
  .hcp-file small { display: block; font-size: 12px; color: var(--hcp-muted); line-height: 1.4; }
152
154
  .hcp-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
153
155
  .hcp-actions .hcp-btn--primary { flex: 1 1 auto; }
154
- .hcp-signup { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hcp-line); }
155
- .hcp-signup label { display: block; font-size: 13px; line-height: 1.45; color: var(--hcp-ink-2); margin-bottom: 8px; }
156
- .hcp-signup-row { display: flex; gap: 8px; }
157
- .hcp-signup-row[hidden] { display: none; }
158
- .hcp-signup input[type="email"] {
159
- flex: 1 1 auto; min-width: 0; height: 34px; padding: 0 10px; margin: 0;
160
- font: 14px var(--hcp-sans); color: var(--hcp-ink); background: var(--hcp-surface);
161
- border: 1px solid var(--hcp-line-2); border-radius: 0; box-shadow: none;
162
- }
163
- .hcp-signup input[type="email"]:focus-visible { outline: 2px solid var(--hcp-brass); outline-offset: 1px; }
164
- .hcp-hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
165
- .hcp-msg { margin-top: 8px; font-size: 13px; color: var(--hcp-ink-2); }
166
- .hcp[data-hcp-reason="error"] .hcp-signup { display: none; }
167
- @media (max-width: 360px) { .hcp-signup-row { flex-direction: column; } }
168
- .hcp-foot { margin-top: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
169
- .hcp-foot small { font-size: 12px; color: var(--hcp-muted); }
156
+ .hcp-foot { margin-top: 12px; display: flex; justify-content: flex-end; }
170
157
  .hcp-quiet {
171
158
  min-height: 0; border-radius: 0;
172
159
  font-size: 12px; color: var(--hcp-muted); cursor: pointer;
@@ -188,6 +175,8 @@ export const PROMPT_CSS = `.hcp {
188
175
  @media (prefers-reduced-motion: reduce) {
189
176
  .hcp { transition: none; }
190
177
  .hcp[data-hcp-state="card"] .hcp-card { animation: none; }
178
+ .hcp:not([data-hcp-arrived]) .hcp-dock { animation: hcp-arrive-fade .4s ease 3s both; }
179
+ @keyframes hcp-arrive-fade { from { opacity: 0; } to { opacity: 1; } }
191
180
  .hcp-btn:active > span { translate: none; }
192
181
  }
193
182
  @media (forced-colors: active) {
@@ -1,11 +1,6 @@
1
1
  import { PROMPT_CSS } from "./prompt-style.js";
2
2
  import { announceBottomUi, bottomBarHeight } from "./bottom-ui.js";
3
3
 
4
- export const SIGNUP_COPY = {
5
- thanks: "Thanks. Check your email for a link to confirm.",
6
- failed: "That didn't go through. Check the address and try again.",
7
- };
8
-
9
4
  const FONT_HREF = "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&family=IBM+Plex+Sans:wght@400;500;600&family=Newsreader:opsz,wght@6..72,500&display=swap";
10
5
 
11
6
  const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app" data-hcp-state="tab" data-hcp-reason="open" sap-ignore clay="editor-ui">
@@ -18,18 +13,10 @@ const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app"
18
13
  <svg width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg>
19
14
  <div><b class="hcp-name" data-hcp-name>This app</b><small>One file with the whole app and your work in it</small></div>
20
15
  </div>
21
- <p class="hcp-body">Open it with HTML Clay, a free app for Mac, Windows and Linux. It opens the file in a window and saves every change into it, so your work stays on your computer.</p>
22
16
  <div class="hcp-actions">
23
17
  <a class="hcp-btn hcp-btn--primary" data-hcp-link href="/_/download?as=htmlclay"><span><svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg>Download free</span></a>
24
- <button type="button" class="hcp-btn" data-hcp="minimize"><span>Not now</span></button>
25
18
  </div>
26
- <form class="hcp-signup" data-hcp-signup novalidate>
27
- <label for="hcp-email">I make one of these about every two weeks. Get the next one by email.</label>
28
- <div class="hcp-signup-row"><input id="hcp-email" type="email" autocomplete="email" required placeholder="you@example.com"><button type="submit" class="hcp-btn"><span>Send me the next one</span></button></div>
29
- <input class="hcp-hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
30
- <p class="hcp-msg" role="status" hidden></p>
31
- </form>
32
- <div class="hcp-foot"><small>Free, and the file is yours to keep.</small><button type="button" class="hcp-quiet" data-hcp="dismiss">Don&rsquo;t show again</button></div>
19
+ <div class="hcp-foot"><button type="button" class="hcp-quiet" data-hcp="dismiss">Don&rsquo;t show again</button></div>
33
20
  </section>
34
21
  <div class="hcp-dock">
35
22
  <button type="button" class="hcp-btn hcp-tab" data-hcp="open" aria-controls="hcp-card" aria-expanded="false" aria-label="This app is free to keep"><span><svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg><span class="hcp-tab__label">This app is free to keep</span></span></button>
@@ -37,7 +24,7 @@ const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app"
37
24
  </div>
38
25
  </aside>`;
39
26
 
40
- export function mountPrompt({ memory, appName, link, tag, source }) {
27
+ export function mountPrompt({ memory, appName, link }) {
41
28
  if (!document.getElementById("hcp-style")) {
42
29
  document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" clay="editor-ui" href="${FONT_HREF}">\n<style clay="editor-ui" id="hcp-style">${PROMPT_CSS}</style>`);
43
30
  }
@@ -55,31 +42,6 @@ export function mountPrompt({ memory, appName, link, tag, source }) {
55
42
  const linkEl = root.querySelector("[data-hcp-link]");
56
43
  linkEl.href = link;
57
44
 
58
- const signup = root.querySelector("[data-hcp-signup]");
59
- if (signup) {
60
- const email = signup.querySelector("#hcp-email");
61
- const honey = signup.querySelector(".hcp-hp");
62
- const row = signup.querySelector(".hcp-signup-row");
63
- const message = signup.querySelector(".hcp-msg");
64
- signup.addEventListener("submit", (event) => {
65
- event.preventDefault();
66
- fetch("/_/newsletter/subscribe", {
67
- method: "POST",
68
- headers: { "Content-Type": "application/json" },
69
- credentials: "same-origin",
70
- body: JSON.stringify({ email: email.value.trim(), tags: [tag], source, website: honey.value }),
71
- }).then((response) => {
72
- if (!response.ok) throw new Error("signup failed");
73
- row.hidden = true;
74
- message.textContent = SIGNUP_COPY.thanks;
75
- message.hidden = false;
76
- }).catch(() => {
77
- message.textContent = SIGNUP_COPY.failed;
78
- message.hidden = false;
79
- });
80
- });
81
- }
82
-
83
45
  if (appName) root.querySelector("[data-hcp-name]").textContent = appName;
84
46
 
85
47
  const tab = root.querySelector("[data-hcp=open]");
@@ -88,7 +50,7 @@ export function mountPrompt({ memory, appName, link, tag, source }) {
88
50
  root.setAttribute("data-hcp-state", state);
89
51
  if (reason) root.setAttribute("data-hcp-reason", reason);
90
52
  tab.setAttribute("aria-expanded", state === "card" ? "true" : "false");
91
- if (state === "card") { mem.shown = true; memory.save(); }
53
+ if (state === "card") { mem.shown = true; memory.save(); root.setAttribute("data-hcp-arrived", ""); }
92
54
  lift();
93
55
  }
94
56
 
@@ -176,6 +138,9 @@ export function mountPrompt({ memory, appName, link, tag, source }) {
176
138
  mem.downloaded = true; mem.shown = true; memory.save();
177
139
  }
178
140
  });
141
+ root.addEventListener("animationend", (e) => {
142
+ if (e.animationName === "hcp-arrive" || e.animationName === "hcp-arrive-fade") root.setAttribute("data-hcp-arrived", "");
143
+ });
179
144
  root.addEventListener("keydown", (e) => {
180
145
  if (e.key === "Escape" && root.getAttribute("data-hcp-state") === "card") { set("mini"); tab.focus(); }
181
146
  });
@@ -14,8 +14,3 @@ export function downloadUrl(src, loc = window.location) {
14
14
  return `/_/download${path}?as=htmlclay${src ? `&src=${encodeURIComponent(src)}` : ""}`;
15
15
  }
16
16
 
17
- export function signupTag(slot, template) {
18
- const explicit = template?.getAttribute("signup-tag");
19
- const name = explicit || slot.replace(/-v\d+$/, "");
20
- return `app-${name}`.slice(0, 50);
21
- }
@@ -3,7 +3,7 @@ import { hasSaveToken } from "../core/host-attrs.js";
3
3
  import { draftSlot, setDraftTransport, setDraftFlush } from "../core/draft-mode.js";
4
4
  import { createStore, safeStorage } from "./visitor-drafts/store.js";
5
5
  import { createUiMemory } from "./visitor-drafts/ui-memory.js";
6
- import { rememberSource, downloadUrl, signupTag } from "./visitor-drafts/source.js";
6
+ import { rememberSource, downloadUrl } from "./visitor-drafts/source.js";
7
7
  import { mountPrompt } from "./visitor-drafts/prompt.js";
8
8
  import { mountIntro } from "./visitor-drafts/intro.js";
9
9
  import { mountOwnerChip } from "./visitor-drafts/owner-chip.js";
@@ -30,7 +30,7 @@ if (isDraftMode && slot && rulesTag) {
30
30
  const appName = document.querySelector('meta[name="application-name"]')?.content?.trim() || document.title.split(/:|\s\|\s/)[0].trim() || "This app";
31
31
  const template = document.querySelector("template[visitor-intro]");
32
32
  const start = () => {
33
- const prompt = mountPrompt({ memory, appName, link: downloadLink, tag: signupTag(slot, template), source });
33
+ const prompt = mountPrompt({ memory, appName, link: downloadLink });
34
34
  mountIntro({ memory, template, appName, hadDraft: result.hadDraft });
35
35
  if (isOwner) mountOwnerChip();
36
36
  document.addEventListener("clay:drafts-store-failed", () => prompt.showError());