@panphora/clayjs 1.5.3 → 1.6.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.
Files changed (46) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +15041 -10657
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-core.js +35 -2
  10. package/src/core/save-feedback.js +6 -0
  11. package/src/core/save.js +157 -3
  12. package/src/core/snapshot.js +21 -0
  13. package/src/core/stale-host-notice.js +21 -24
  14. package/src/core/unsaved-warning.js +64 -16
  15. package/src/lib/content-dom.js +4 -2
  16. package/src/lib/dirty-gate.js +37 -1
  17. package/src/lib/unsaved-state.js +55 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +10 -3
  20. package/src/plugins/ai-edit.js +489 -179
  21. package/src/plugins/cms.js +12 -0
  22. package/src/plugins/indicator.js +35 -16
  23. package/src/plugins/quickcrop.js +103 -0
  24. package/src/plugins/richclay.js +21 -0
  25. package/src/plugins/source.js +13 -6
  26. package/src/plugins/wire.js +4 -0
  27. package/src/sync/conflict-revert.js +1138 -0
  28. package/src/sync/conflicts.js +159 -0
  29. package/src/sync/live-sync.js +130 -109
  30. package/src/sync/presence.js +42 -36
  31. package/src/sync/section-notice.js +53 -28
  32. package/src/ui/bevel-controls.js +277 -0
  33. package/src/ui/bevel-dialog.js +112 -0
  34. package/src/ui/bevel.js +247 -0
  35. package/src/ui/dialogs.js +63 -86
  36. package/src/ui/index.js +22 -11
  37. package/src/ui/modal.js +80 -387
  38. package/src/ui/skins/cms-toggle.js +15 -0
  39. package/src/ui/skins/cms.js +4 -0
  40. package/src/ui/skins/quickcrop.js +4 -0
  41. package/src/ui/skins/richclay.js +4 -0
  42. package/src/ui/toast.js +116 -401
  43. package/src/ui/vendor-skin.js +65 -0
  44. package/src/vendor/hyper-morph.vendor.js +4 -4
  45. package/src/vendor/quickcrop.vendor.js +14 -8
  46. package/src/vendor/richclay.vendor.js +22 -41
@@ -0,0 +1,112 @@
1
+ // The frame every ClayJS dialog draws: a backdrop, a Bevel panel, an optional corner
2
+ // close. It holds no behaviour. The modal and the crop adapter own focus, Escape,
3
+ // backdrop clicks and settling, so each keeps the rules it already had.
4
+ import { set } from "../lib/hostile-css.js";
5
+ import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon } from "./bevel-controls.js";
6
+ import { TOKENS, FONT_SANS, RULES } from "./bevel.js";
7
+
8
+ // A frame's own way to settle itself. The modal is a singleton and clears every frame
9
+ // when it opens; a frame it does not own (the crop dialog) registers here, so it is
10
+ // cancelled properly instead of vanishing with its caller still waiting.
11
+ export const dismissOf = new WeakMap();
12
+
13
+ const FOCUSABLE = 'a[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [contenteditable], [tabindex]:not([tabindex^="-"])';
14
+
15
+ // Tab and Shift+Tab wrap inside `container`, the rule MicroModal applies to the modal.
16
+ export function keepFocusIn(container, event) {
17
+ const nodes = [...container.querySelectorAll(FOCUSABLE)].filter((node) => node.offsetParent !== null);
18
+ if (!nodes.length) return;
19
+ const at = nodes.indexOf(document.activeElement);
20
+ if (at === -1) {
21
+ nodes[0].focus();
22
+ event.preventDefault();
23
+ } else if (event.shiftKey && at === 0) {
24
+ nodes[nodes.length - 1].focus();
25
+ event.preventDefault();
26
+ } else if (!event.shiftKey && at === nodes.length - 1) {
27
+ nodes[0].focus();
28
+ event.preventDefault();
29
+ }
30
+ }
31
+
32
+ // The dashboard's dialog (Bevel's React dialog with the dashboard's overrides), drawn
33
+ // inline. ClayJS loads no webfonts, so the title's Newsreader falls back to Georgia.
34
+ const LINE = `1px solid ${TOKENS["line-2"]}`;
35
+ const CLOSE_X =
36
+ '<svg viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>';
37
+
38
+ // The close: a column at the header's right edge in the button face, or a 62px square
39
+ // at the panel's corner when there is no header.
40
+ function dialogClose(label, inHeader) {
41
+ const hover = RULES.dialogCloseHover[0].slice("background:".length);
42
+ const b = bevelBox("button", [
43
+ "position:absolute", "top:-1px", "right:-1px", "z-index:2",
44
+ "display:grid", "place-items:center", inHeader ? "height:calc(100% + 1px)" : "height:62px",
45
+ "margin:0", "padding:0", "border:0", ...RULES.dialogClose, ...(inHeader ? [] : [`border-bottom:${LINE}`]),
46
+ "cursor:pointer", "outline:none",
47
+ ]);
48
+ b.type = "button";
49
+ b.setAttribute("aria-label", label);
50
+ b.title = label;
51
+ b.innerHTML = CLOSE_X;
52
+ protectIcon(b.firstElementChild, 16);
53
+ b.addEventListener("pointerenter", () => set(b, "background", hover));
54
+ b.addEventListener("pointerleave", () => set(b, "background", TOKENS.face));
55
+ b.addEventListener("focus", () => { set(b, "outline", `2px solid ${TOKENS.brass}`); set(b, "outline-offset", "-4px"); });
56
+ b.addEventListener("blur", () => set(b, "outline", "none"));
57
+ return b;
58
+ }
59
+
60
+ export function bevelDialog({ zIndex = "100", width = "600px", closable = false, titled = false } = {}) {
61
+ const root = bevelBox("div", ["display:block"]);
62
+ root.setAttribute("data-clay-modal", "");
63
+ root.setAttribute("aria-hidden", "true");
64
+ set(root, "color-scheme", pageScheme());
65
+
66
+ const overlay = bevelBox("div", [
67
+ "position:fixed", "inset:0", `z-index:${zIndex}`, "overflow:auto",
68
+ "display:flex", "align-items:flex-start", "justify-content:center",
69
+ "padding:min(96px, 10vh) 16px 16px",
70
+ `background:color-mix(in srgb, ${TOKENS.ground} 72%, transparent)`,
71
+ ]);
72
+ overlay.tabIndex = -1;
73
+
74
+ const panel = bevelSurface("form", [
75
+ "position:relative", "display:flex", "flex-direction:column", "margin:0",
76
+ `width:min(${width}, 100%)`, "max-height:calc(100dvh - min(96px, 10vh) - 16px)",
77
+ ...RULES.dialogPanel,
78
+ ]);
79
+ panel.setAttribute("role", "dialog");
80
+ panel.setAttribute("aria-modal", "true");
81
+
82
+ let header = null;
83
+ let heading = null;
84
+ if (titled) {
85
+ header = bevelBox("div", [
86
+ "position:relative", "display:block", "flex:none",
87
+ `padding:20px ${closable ? "68px" : "22px"} 16px 22px`, ...RULES.dialogHeader,
88
+ ]);
89
+ heading = bevelText("div", ["display:block", ...RULES.dialogHeading, "overflow-wrap:anywhere"]);
90
+ heading.setAttribute("role", "heading");
91
+ heading.setAttribute("aria-level", "2");
92
+ header.append(heading);
93
+ panel.append(header);
94
+ }
95
+
96
+ const body = bevelText("div", [
97
+ "display:block", "flex:1 1 auto", "min-height:0", "overflow-y:auto", "overflow-x:hidden",
98
+ `padding:20px ${closable && !titled ? "84px" : "22px"} 20px 22px`,
99
+ "overflow-wrap:anywhere", `font:14.5px/1.55 ${FONT_SANS}`,
100
+ ]);
101
+ const footer = bevelBox("div", [
102
+ "display:flex", "flex:none", "flex-wrap:wrap", "justify-content:flex-end", "align-items:center", ...RULES.dialogFooter,
103
+ ]);
104
+ panel.append(body, footer);
105
+
106
+ const close = closable ? dialogClose("Close", titled) : null;
107
+ if (close) (header || panel).append(close);
108
+
109
+ overlay.append(panel);
110
+ root.append(overlay);
111
+ return { root, overlay, panel, header, heading, body, footer, close };
112
+ }
@@ -0,0 +1,247 @@
1
+ // GENERATED from bevel/bevel.css by scripts/build-bevel-subset.mjs. Run `npm run build:bevel`.
2
+ export const SOURCE_SHA256 = "4532d98386c76070626c0cccea91f512dafd0314c0c846dbf1eea9372ddde541";
3
+ export const FONT_SANS = "system-ui,-apple-system,\"Segoe UI\",sans-serif";
4
+ export const FONT_MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
5
+ export const SHADOW = "0 1px 2px rgba(0,0,0,.12),0 10px 28px -12px rgba(0,0,0,.35)";
6
+ export const TOKENS = {
7
+ "ground": "light-dark(#FDF8F0, #0B0C13)",
8
+ "surface": "light-dark(#FFFCF6, #11131E)",
9
+ "sunk": "light-dark(#F4ECDF, #1A1D2C)",
10
+ "ink": "light-dark(#2B241B, #ECEAF2)",
11
+ "ink-2": "light-dark(#4A4033, #C9CDE0)",
12
+ "muted": "light-dark(#6B5942, #8A90AB)",
13
+ "faint": "light-dark(#9A8A70, #6A7090)",
14
+ "line": "light-dark(#E8DCC6, #262B42)",
15
+ "line-2": "light-dark(#D8C8AF, #353B52)",
16
+ "line-3": "light-dark(#C4B192, #46506E)",
17
+ "brass": "light-dark(#8F6310, #D9A445)",
18
+ "brass-soft": "light-dark(#F0E4CC, #3A2F17)",
19
+ "ox": "light-dark(#A83A2C, #FF5566)",
20
+ "ox-soft": "light-dark(#F6DED6, #3A2226)",
21
+ "teal": "light-dark(#256056, #63AE9E)",
22
+ "teal-soft": "light-dark(#DCEBE7, #17332E)",
23
+ "face": "light-dark(#F1E7D4, #22273E)",
24
+ "edge-hi": "light-dark(#FFF9ED, #3C4260)",
25
+ "edge-lo": "light-dark(#D6C3A5, #1B2033)",
26
+ "face-in": "light-dark(#E3D0B3, #28304D)",
27
+ "edge-in": "light-dark(#B89F7B, #1F2339)",
28
+ "edge-in-hi": "light-dark(#F0E1C9, #4D587A)",
29
+ "knob": "light-dark(#FCF8F1, #4A5275)",
30
+ "knob-hi": "light-dark(#FFF9ED, #6C759A)",
31
+ "ox-face": "light-dark(#7A2A1F, #842E22)",
32
+ "ox-edge-hi": "light-dark(#AE4F3E, #B85A47)",
33
+ "ox-edge-lo": "light-dark(#451510, #3E1410)",
34
+ "ox-ink": "light-dark(#FBEEE8, #FBEEE8)",
35
+ };
36
+ export const RULES = {
37
+ "button": [
38
+ "display:inline-flex",
39
+ "align-items:center",
40
+ "justify-content:center",
41
+ "gap:7px",
42
+ "font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif",
43
+ "font-size:13.5px",
44
+ "font-weight:500",
45
+ "line-height:1.5",
46
+ "cursor:pointer",
47
+ "user-select:none",
48
+ "text-align:center",
49
+ "text-decoration:none",
50
+ "color:light-dark(#2B241B, #ECEAF2)",
51
+ "background:light-dark(#F1E7D4, #22273E)",
52
+ "border:2px solid",
53
+ "border-color:light-dark(#FFF9ED, #3C4260) light-dark(#D6C3A5, #1B2033) light-dark(#D6C3A5, #1B2033) light-dark(#FFF9ED, #3C4260)",
54
+ "border-radius:0",
55
+ "padding:0 12px",
56
+ "height:32px",
57
+ "outline:none",
58
+ ],
59
+ "buttonLabel": [
60
+ "white-space:nowrap",
61
+ "user-select:none",
62
+ "display:inline-block",
63
+ ],
64
+ "buttonHover": [
65
+ "background:color-mix(in srgb, light-dark(#F1E7D4, #22273E), light-dark(#FFF9ED, #3C4260) 45%)",
66
+ ],
67
+ "buttonActive": [
68
+ "border-color:light-dark(#D6C3A5, #1B2033) light-dark(#FFF9ED, #3C4260) light-dark(#FFF9ED, #3C4260) light-dark(#D6C3A5, #1B2033)",
69
+ ],
70
+ "buttonActiveLabel": [
71
+ "translate:1px 1px",
72
+ ],
73
+ "buttonDisabled": [
74
+ "color:light-dark(#9A8A70, #6A7090)",
75
+ "cursor:default",
76
+ "opacity:1",
77
+ ],
78
+ "buttonDisabledLabel": [
79
+ "translate:none",
80
+ ],
81
+ "buttonSmall": [
82
+ "height:26px",
83
+ "font-size:12.5px",
84
+ "padding:0 9px",
85
+ ],
86
+ "buttonPrimary": [
87
+ "display:inline-flex",
88
+ "align-items:center",
89
+ "justify-content:center",
90
+ "gap:7px",
91
+ "font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif",
92
+ "font-size:13.5px",
93
+ "font-weight:500",
94
+ "line-height:1.5",
95
+ "cursor:pointer",
96
+ "user-select:none",
97
+ "text-align:center",
98
+ "text-decoration:none",
99
+ "color:light-dark(#2B241B, #ECEAF2)",
100
+ "background:light-dark(#2B241B, #ECEAF2)",
101
+ "border:2px solid",
102
+ "border-color:color-mix(in srgb, light-dark(#2B241B, #ECEAF2), white 24%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), black 32%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), black 32%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), white 24%)",
103
+ "border-radius:0",
104
+ "padding:0 12px",
105
+ "height:32px",
106
+ "outline:none",
107
+ "color:light-dark(#FDF8F0, #0B0C13)",
108
+ ],
109
+ "buttonPrimaryHover": [
110
+ "background:color-mix(in srgb, light-dark(#2B241B, #ECEAF2), light-dark(#FDF8F0, #0B0C13) 12%)",
111
+ ],
112
+ "buttonPrimaryHoverBase": [
113
+ "background:color-mix(in srgb, light-dark(#2B241B, #ECEAF2), color-mix(in srgb, light-dark(#2B241B, #ECEAF2), white 24%) 45%)",
114
+ ],
115
+ "buttonPrimaryActive": [
116
+ "border-color:color-mix(in srgb, light-dark(#2B241B, #ECEAF2), black 32%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), white 24%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), white 24%) color-mix(in srgb, light-dark(#2B241B, #ECEAF2), black 32%)",
117
+ ],
118
+ "buttonDanger": [
119
+ "display:inline-flex",
120
+ "align-items:center",
121
+ "justify-content:center",
122
+ "gap:7px",
123
+ "font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif",
124
+ "font-size:13.5px",
125
+ "font-weight:500",
126
+ "line-height:1.5",
127
+ "cursor:pointer",
128
+ "user-select:none",
129
+ "text-align:center",
130
+ "text-decoration:none",
131
+ "color:light-dark(#2B241B, #ECEAF2)",
132
+ "background:light-dark(#7A2A1F, #842E22)",
133
+ "border:2px solid",
134
+ "border-color:light-dark(#AE4F3E, #B85A47) light-dark(#451510, #3E1410) light-dark(#451510, #3E1410) light-dark(#AE4F3E, #B85A47)",
135
+ "border-radius:0",
136
+ "padding:0 12px",
137
+ "height:32px",
138
+ "outline:none",
139
+ "color:light-dark(#FBEEE8, #FBEEE8)",
140
+ ],
141
+ "buttonDangerHover": [
142
+ "background:color-mix(in srgb, light-dark(#7A2A1F, #842E22), light-dark(#AE4F3E, #B85A47) 45%)",
143
+ ],
144
+ "buttonDangerActive": [
145
+ "border-color:light-dark(#451510, #3E1410) light-dark(#AE4F3E, #B85A47) light-dark(#AE4F3E, #B85A47) light-dark(#451510, #3E1410)",
146
+ ],
147
+ "buttonQuiet": [
148
+ "background:none",
149
+ "border-color:transparent",
150
+ "color:light-dark(#8F6310, #D9A445)",
151
+ ],
152
+ "buttonQuietHover": [
153
+ "background:light-dark(#F4ECDF, #1A1D2C)",
154
+ "color:light-dark(#8F6310, #D9A445)",
155
+ ],
156
+ "buttonQuietActive": [
157
+ "border-color:transparent",
158
+ ],
159
+ "focus": [
160
+ "outline:2px solid light-dark(#8F6310, #D9A445)",
161
+ "outline-offset:2px",
162
+ ],
163
+ "recess": [
164
+ "border:2px solid",
165
+ "border-color:light-dark(#B89F7B, #1F2339) light-dark(#F0E1C9, #4D587A) light-dark(#F0E1C9, #4D587A) light-dark(#B89F7B, #1F2339)",
166
+ "background:light-dark(#E3D0B3, #28304D)",
167
+ ],
168
+ "input": [
169
+ "width:100%",
170
+ "display:block",
171
+ "font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif",
172
+ "font-size:13.5px",
173
+ "font-weight:400",
174
+ "line-height:normal",
175
+ "color:light-dark(#2B241B, #ECEAF2)",
176
+ "background:light-dark(#FDF8F0, #0B0C13)",
177
+ "border:1px solid light-dark(#D8C8AF, #353B52)",
178
+ "border-radius:0",
179
+ "padding:0 10px",
180
+ "block-size:34px",
181
+ "outline:none",
182
+ "transition:border-color .14s, background-color .14s",
183
+ ],
184
+ "inputHover": [
185
+ "border-color:light-dark(#9A8A70, #6A7090)",
186
+ ],
187
+ "inputFocus": [
188
+ "outline:2px solid light-dark(#8F6310, #D9A445)",
189
+ "outline-offset:1px",
190
+ "border-color:light-dark(#8F6310, #D9A445)",
191
+ ],
192
+ "toast": [
193
+ "gap:12px",
194
+ "padding:13px 12px 13px 15px",
195
+ ],
196
+ "toastTitle": [
197
+ "font:600 13.5px/1.3 system-ui,-apple-system,\"Segoe UI\",sans-serif",
198
+ ],
199
+ "toastClose": [
200
+ "margin:-2px -2px 0 0",
201
+ "inline-size:24px",
202
+ "block-size:24px",
203
+ "padding:0",
204
+ "border:0",
205
+ "background:transparent",
206
+ "color:light-dark(#6B5942, #8A90AB)",
207
+ ],
208
+ "dialogPanel": [
209
+ "border-radius:0",
210
+ "box-shadow:6px 6px 0 color-mix(in srgb, light-dark(#2B241B, #ECEAF2) 10%, transparent)",
211
+ ],
212
+ "dialogHeader": [
213
+ "border-bottom:1px solid light-dark(#D8C8AF, #353B52)",
214
+ ],
215
+ "dialogHeading": [
216
+ "margin:0",
217
+ "font:400 24px/1.15 Georgia,\"Times New Roman\",serif",
218
+ ],
219
+ "dialogFooter": [
220
+ "border-top:1px solid light-dark(#D8C8AF, #353B52)",
221
+ "padding:14px 22px",
222
+ "gap:10px",
223
+ ],
224
+ "dialogClose": [
225
+ "inline-size:62px",
226
+ "border-left:1px solid light-dark(#D8C8AF, #353B52)",
227
+ "background:light-dark(#F1E7D4, #22273E)",
228
+ "color:light-dark(#2B241B, #ECEAF2)",
229
+ ],
230
+ "dialogCloseHover": [
231
+ "background:color-mix(in srgb, light-dark(#F1E7D4, #22273E), light-dark(#FFF9ED, #3C4260) 45%)",
232
+ ],
233
+ "surface": [
234
+ "background:light-dark(#FFFCF6, #11131E)",
235
+ "color:light-dark(#2B241B, #ECEAF2)",
236
+ "border:1px solid light-dark(#D8C8AF, #353B52)",
237
+ "box-shadow:0 1px 2px rgba(0,0,0,.12),0 10px 28px -12px rgba(0,0,0,.35)",
238
+ ],
239
+ };
240
+ export const MEDIA = {
241
+ "reducedMotion": [
242
+ "translate:none",
243
+ ],
244
+ "forcedColors": [
245
+ "border:1px solid ButtonText",
246
+ ],
247
+ };
package/src/ui/dialogs.js CHANGED
@@ -2,37 +2,35 @@ import themodal from "./modal.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
3
  import toast from "./toast.js";
4
4
  import copyToClipboard from "../utils/copy-to-clipboard.js";
5
-
6
- const CLOSE_BUTTON_SVG = `<svg viewBox="0 0 134 134" fill="none" xmlns="http://www.w3.org/2000/svg"><path class="micromodal__close-bg" d="M132 132.5 1 1.5h131v131Z" /><path class="micromodal__close-x" fill-rule="evenodd" clip-rule="evenodd" d="M0 0h3v1.5h1.5V3H6v1.5h1.5V6H9v1.5h1.5V9H12v1.5h1.5V12H15v1.5h1.5V15H18v1.5h1.5V18H21v1.5h1.5V21H24v1.5h1.5V24H27v1.5h1.5V27H30v1.5h1.5V30H33v1.5h1.5V33H36v1.5h1.5V36H39v1.5h1.5V39H42v1.5h1.5V42H45v1.5h1.5V45H48v1.5h1.5V48H51v1.5h1.5V51H54v1.5h1.5V54H57v1.5h1.5V57H60v1.5h1.5V60H63v1.5h1.5V63H66v1.5h1.5V66H69v1.5h1.5V69H72v1.5h1.5V72H75v1.5h1.5V75H78v1.5h1.5V78H81v1.5h1.5V81H84v1.5h1.5V84H87v1.5h1.5V87H90v1.5h1.5V90H93v1.5h1.5V93H96v1.5h1.5V96H99v1.5h1.5V99h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v3h-3V132H129v-1.5h-1.5V129H126v-1.5h-1.5V126H123v-1.5h-1.5V123H120v-1.5h-1.5V120H117v-1.5h-1.5V117H114v-1.5h-1.5V114H111v-1.5h-1.5V111H108v-1.5h-1.5V108H105v-1.5h-1.5V105H102v-1.5h-1.5V102H99v-1.5h-1.5V99H96v-1.5h-1.5V96H93v-1.5h-1.5V93H90v-1.5h-1.5V90H87v-1.5h-1.5V87H84v-1.5h-1.5V84H81v-1.5h-1.5V81H78v-1.5h-1.5V78H75v-1.5h-1.5V75H72v-1.5h-1.5V72H69v-1.5h-1.5V69H66v-1.5h-1.5V66H63v-1.5h-1.5V63H60v-1.5h-1.5V60H57v-1.5h-1.5V57H54v-1.5h-1.5V54H51v-1.5h-1.5V51H48v-1.5h-1.5V48H45v-1.5h-1.5V45H42v-1.5h-1.5V42H39v-1.5h-1.5V39H36v-1.5h-1.5V36H33v-1.5h-1.5V33H30v-1.5h-1.5V30H27v-1.5h-1.5V27H24v-1.5h-1.5V24H21v-1.5h-1.5V21H18v-1.5h-1.5V18H15v-1.5h-1.5V15H12v-1.5h-1.5V12H9v-1.5H7.5V9H6V7.5H4.5V6H3V4.5H1.5V3H0V0ZM108.8 22h5.2v5.1h-2.6v2.6H109v2.6h-2.6v2.6h-2.6v2.5h-2.6v5.2h2.6V45h2.6v2.6h2.6v2.6h2.5v2.6h2.6V58h-5.1v-2.6h-2.6V53h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6h-5.2v2.6H91v2.6h-2.6v2.6h-2.6v2.5h-2.6V58H78v-5.1h2.6v-2.6H83v-2.6h2.6v-2.6h2.6v-2.5h2.6v-5.2h-2.6V35h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6H78V22h5.2v2.6h2.5V27h2.6v2.6h2.6v2.6h2.5v2.6h5.2v-2.6h2.5v-2.6h2.6v-2.6h2.6v-2.5h2.5V22Z" /></svg>`;
7
- const CONFIRM_BUTTON_SVG = `<div style="width: 28px;"><svg viewBox="0 0 60 33" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M34.5 0.75H43.5V5.25H49V9.75H54.5V14.25H60V18.75H54.5V23.25H49V27.75H43.5V32.25H34.5V27.75H40V23.25H45.5V18.75H0V14.25H45.5V9.75H40V5.25H34.5V0.75Z" fill="white"/></svg></div>`;
8
-
9
- // Only for the prompt's default value. promptText and extraContent are markup by
10
- // design, and callers pass elements through them.
11
- function escapeAttribute(value) {
12
- return String(value)
13
- .replaceAll("&", "&amp;")
14
- .replaceAll('"', "&quot;")
15
- .replaceAll("<", "&lt;");
16
- }
17
-
18
- function createModal(promptText, yesCallback, extraContent = "", includeInput = false, defaultValue = "") {
19
- const inputHtml = includeInput
20
- ? `<div><input class="micromodal__input" type="text" value="${escapeAttribute(defaultValue)}" required></div>`
21
- : "";
22
-
23
- themodal.html = `<div>
24
- <div class="micromodal__heading">${promptText}</div>
25
- ${inputHtml}
26
- ${extraContent}
27
- </div>`;
28
- themodal.closeHtml = CLOSE_BUTTON_SVG;
29
- themodal.yes = CONFIRM_BUTTON_SVG;
5
+ import { bevelBox, bevelButton, bevelInput, bevelText } from "./bevel-controls.js";
6
+ import { TOKENS, FONT_SANS, FONT_MONO } from "./bevel.js";
7
+
8
+ // Caller markup (promptText, extraContent, tell's paragraphs) goes in as it is: those
9
+ // are markup by design, and callers pass elements through them. Only the frame around
10
+ // it is ClayJS's to style. The prompt is the dialog's title, as in the dashboard.
11
+ function createModal(promptText, yesCallback, extraContent = "", includeInput = false, defaultValue = "", yesLabel = "OK") {
12
+ const content = bevelBox("div", ["display:flex", "flex-direction:column", "gap:14px"]);
13
+ let input = null;
14
+ if (includeInput) {
15
+ input = bevelInput("input", { rules: ["display:block", "width:100%", "margin:0"] });
16
+ input.setAttribute("value", String(defaultValue));
17
+ input.required = true;
18
+ content.append(input);
19
+ }
20
+ content.insertAdjacentHTML("beforeend", extraContent);
21
+
22
+ themodal.title = promptText;
23
+ themodal.html = includeInput || extraContent ? content : "";
24
+ themodal.width = "440px";
25
+ themodal.closeHtml = "x";
26
+ themodal.no = "Cancel";
27
+ themodal.yes = yesLabel;
30
28
 
31
29
  const promise = new Promise((resolve, reject) => {
32
30
  themodal.onYes(() => {
33
31
  let promptResult;
34
32
  if (includeInput) {
35
- promptResult = document.querySelector(".micromodal__input").value;
33
+ promptResult = input.value;
36
34
  if (!promptResult) return false; // keep modal open on empty input
37
35
  }
38
36
  // Run the validation callback synchronously so a throw can keep the
@@ -61,7 +59,7 @@ function createModal(promptText, yesCallback, extraContent = "", includeInput =
61
59
  themodal.open();
62
60
 
63
61
  setTimeout(() => {
64
- const modalContainer = document.querySelector('.micromodal-parent');
62
+ const modalContainer = document.querySelector('[data-clay-modal]');
65
63
  if (modalContainer) {
66
64
  modalContainer.addEventListener('click', (event) => {
67
65
  const btn = event.target.closest('[data-copy]');
@@ -83,11 +81,11 @@ function createModal(promptText, yesCallback, extraContent = "", includeInput =
83
81
 
84
82
  // Public API functions
85
83
  export function ask(promptText, yesCallback, defaultValue = "", extraContent = "") {
86
- return createModal(promptText, yesCallback, extraContent, true, defaultValue);
84
+ return createModal(promptText, yesCallback, extraContent, true, defaultValue, "OK");
87
85
  }
88
86
 
89
87
  export function consent(promptText, yesCallback, extraContent = "") {
90
- return createModal(promptText, yesCallback, extraContent, false);
88
+ return createModal(promptText, yesCallback, extraContent, false, "", "Confirm");
91
89
  }
92
90
 
93
91
  /**
@@ -97,16 +95,18 @@ export function consent(promptText, yesCallback, extraContent = "") {
97
95
  * @returns {Promise} Resolves when user confirms, rejects on close
98
96
  */
99
97
  export function tell(promptText, ...content) {
100
- const contentHtml = content.length > 0
101
- ? content.map(c => `<div class="micromodal__tell-content">${c}</div>`).join("")
102
- : "";
103
-
104
- themodal.html = `<div class="micromodal__tell">
105
- <div class="micromodal__tell-title">${promptText}</div>
106
- ${contentHtml}
107
- </div>`;
108
- themodal.closeHtml = CLOSE_BUTTON_SVG;
109
- themodal.yes = CONFIRM_BUTTON_SVG;
98
+ const box = bevelBox("div", ["display:flex", "flex-direction:column", "gap:12px"]);
99
+ for (const c of content) {
100
+ const paragraph = bevelText("div", [`font:14.5px/1.55 ${FONT_SANS}`, `color:${TOKENS["ink-2"]}`, "overflow-wrap:anywhere"]);
101
+ paragraph.innerHTML = c;
102
+ box.append(paragraph);
103
+ }
104
+
105
+ themodal.title = promptText;
106
+ themodal.html = content.length ? box : "";
107
+ themodal.width = "470px";
108
+ themodal.closeHtml = "x";
109
+ themodal.yes = "OK";
110
110
 
111
111
  const promise = new Promise((resolve, reject) => {
112
112
  themodal.onYes(() => {
@@ -133,64 +133,41 @@ export function tell(promptText, ...content) {
133
133
  * @param {string} title - The modal heading
134
134
  * @param {string} content - The code to display
135
135
  * @param {string} extraContent - Optional raw HTML rendered below the copy button.
136
- * Callers style their own container; use `<div class="snippet-warning">…</div>`
137
- * for the standard yellow-bordered warning box.
136
+ * Callers style their own container.
138
137
  */
139
138
  export function snippet(title, content, extraContent = '') {
139
+ const box = bevelBox("div", ["display:block"]);
140
+ const well = bevelBox("div", [
141
+ "display:block", "max-width:100%", "overflow-x:auto", "margin:0 0 14px", "padding:14px 16px",
142
+ `background:${TOKENS.sunk}`, `border:1px solid ${TOKENS["line-2"]}`,
143
+ ]);
144
+ const pre = bevelText("pre", ["display:block", "margin:0", "white-space:nowrap", `font:13px/1.6 ${FONT_MONO}`]);
145
+ pre.innerHTML = content;
146
+ well.append(pre);
147
+ const copy = bevelButton("Copy", {
148
+ small: true,
149
+ extra: ["margin:0 0 14px"],
150
+ onClick: () => {
151
+ copyToClipboard(content);
152
+ toast('Copied to clipboard!', 'success');
153
+ },
154
+ });
155
+ box.append(well, copy);
156
+ box.insertAdjacentHTML("beforeend", extraContent || "");
140
157
 
141
- // Create the modal content with copy button
142
- const modalContent = `
143
- <div class="snippet-code-block">
144
- <pre>${content}</pre>
145
- </div>
146
-
147
- <button type="button" class="micromodal__secondary-btn copy-snippet-btn" style="margin-bottom: 14px;">copy</button>
148
-
149
- ${extraContent || ''}
150
- `;
151
-
152
- // Use the existing modal system
153
- themodal.html = `<div>
154
- <div class="micromodal__heading">${title}</div>
155
- ${modalContent}
156
- </div>`;
157
-
158
- themodal.closeHtml = CLOSE_BUTTON_SVG;
158
+ themodal.title = title;
159
+ themodal.html = box;
160
+ themodal.width = "540px";
161
+ themodal.closeHtml = "x";
159
162
  themodal.yes = '';
160
163
 
161
164
  const promise = new Promise((resolve) => {
162
- // Local copy function
163
- const handleCopy = function(event) {
164
- if (event.target.closest('.copy-snippet-btn')) {
165
- copyToClipboard(content);
166
- toast('Copied to clipboard!', 'success');
167
- }
168
- };
169
-
170
- // Add event listener to the modal container after it opens
171
- setTimeout(() => {
172
- const modalContainer = document.querySelector('.micromodal-parent');
173
- if (modalContainer) {
174
- modalContainer.addEventListener('click', handleCopy);
175
- }
176
- }, 0);
177
-
178
165
  themodal.onYes(() => {
179
- // Clean up the event listener synchronously — the DOM may be torn down
180
- // before our deferred resolve fires otherwise.
181
- const modalContainer = document.querySelector('.micromodal-parent');
182
- if (modalContainer) {
183
- modalContainer.removeEventListener('click', handleCopy);
184
- }
185
166
  setTimeout(resolve, 0);
186
167
  return true;
187
168
  });
188
169
 
189
170
  themodal.onNo = () => {
190
- const modalContainer = document.querySelector('.micromodal-parent');
191
- if (modalContainer) {
192
- modalContainer.removeEventListener('click', handleCopy);
193
- }
194
171
  setTimeout(resolve, 0);
195
172
  };
196
173
  });
@@ -203,7 +180,7 @@ export function snippet(title, content, extraContent = '') {
203
180
  // Auto-initialize - cleanup any leftover modal elements
204
181
  export function init() {
205
182
  onDomReady(() => {
206
- const micromodalParentElem = document.querySelector(".micromodal-parent");
183
+ const micromodalParentElem = document.querySelector("[data-clay-modal]");
207
184
  if (micromodalParentElem) {
208
185
  micromodalParentElem.remove();
209
186
  document.body.style.overflow = "";
package/src/ui/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import toast, { toastPersistent } from "./toast.js";
1
+ import toast, { toastPersistent, dismissPersistent } from "./toast.js";
2
2
  import themodal from "./modal.js";
3
3
  import { ask, consent, tell, snippet } from "./dialogs.js";
4
+ import { saveFeedback } from "../core/save-feedback.js";
4
5
 
5
6
  // Attach the public surface explicitly (§2.3). We do NOT rely on toast.js /
6
7
  // dialogs.js evaluation side effects — their window auto-exports are stripped.
@@ -19,17 +20,27 @@ if (typeof window.toast === "undefined") window.toast = toast;
19
20
  if (typeof window.toastPersistent === "undefined") window.toastPersistent = toastPersistent;
20
21
 
21
22
  // Automatic save feedback. clay:save-saving stays deliberately silent (a toast for
22
- // a sub-second transient is noise). Keep your own toast lib? The events are public.
23
- document.addEventListener("clay:save-saved", (e) =>
24
- toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
23
+ // a sub-second transient is noise). With the indicator plugin loaded, saved belongs
24
+ // to its chip; a failed or offline save stays up as a toast until the next save
25
+ // lands. Keep your own toast lib? The events are public. clay.saveToast moves saved
26
+ // from the chip to a toast.
27
+ saveFeedback.toasts = true;
28
+ // clay.saveToast = true shows Saved as a toast (with the host's message) instead of on
29
+ // the chip. A page may set it before this loads (window.clay = { saveToast: true }).
30
+ saveFeedback.savedToast = !!clay.saveToast;
31
+ Object.defineProperty(clay, "saveToast", {
32
+ get: () => saveFeedback.savedToast,
33
+ set: (on) => { saveFeedback.savedToast = !!on; },
34
+ enumerable: true,
35
+ configurable: true,
36
+ });
37
+ document.addEventListener("clay:save-saved", (e) => {
38
+ dismissPersistent("Couldn't save");
39
+ dismissPersistent("Offline, not saved");
40
+ if (e.detail?.msgType === "warning") toast(e.detail.msg || "Saved", "warning");
41
+ else if (!saveFeedback.chip || saveFeedback.savedToast) toast(e.detail?.msg || "Saved", "success");
42
+ });
25
43
  document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
26
- // Spec §6. Persistent, and carrying the host's own words, because this is the one
27
- // save outcome the person has to act on: their edits are safe and unsaved, and
28
- // autosave has stopped until they choose. Deliberately a toast and not a dialog:
29
- // most conflicts surface on an autosave nobody asked for, and a modal thrown over
30
- // the page a person is typing into is worse than the problem it reports.
31
- document.addEventListener("clay:save-conflict", (e) =>
32
- toastPersistent(e.detail?.msg || "This document changed since you opened it", "warning"));
33
44
  document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
34
45
 
35
46
  export { toast, toastPersistent, ask, consent, tell, snippet, themodal };