@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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * presence.js — who else is on this document.
3
3
  *
4
- * A fixed-corner stack of circles, one per participant the host chose to name,
4
+ * A fixed-corner stack of squares, one per participant the host chose to name,
5
5
  * plus a count of everyone it did not. The host decides who is named, per
6
6
  * recipient, from that recipient's own access; nothing here asks for a name and
7
7
  * nothing here can widen what arrived.
@@ -37,26 +37,24 @@
37
37
 
38
38
  import { hostSupports } from '../core/host-meta.js';
39
39
  import { make, set } from '../lib/hostile-css.js';
40
+ import { pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
41
+ import { TOKENS, FONT_MONO, SHADOW } from '../ui/bevel.js';
40
42
 
41
- // Runtime-only chrome, in one string so no root can carry two of the three.
42
- const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
43
-
44
- // Two initials at 12px need about 15px of the circle, so the overlap has to
45
- // leave that much clear or the neighbouring circle eats the second letter.
43
+ // Two initials at 11px need about 15px of the square, so the overlap has to
44
+ // leave that much clear or the neighbouring square eats the second letter.
46
45
  const SIZE = 30;
47
46
  const OVERLAP = 6;
48
- const FONT = "12px/1 system-ui,-apple-system,'Segoe UI',sans-serif";
49
- const LABEL_FONT = "12px/1.4 system-ui,-apple-system,'Segoe UI',sans-serif";
50
-
51
- // The chrome around the circles is themeable like every other clayjs surface.
52
- // The circles themselves are not: their colour is computed per participant, so
53
- // there is no one value a page could override.
54
- const INK = 'var(--clay-presence-ink,#1f2023)';
55
- const CHIP_BG = 'var(--clay-presence-bg,#fff)';
56
- const CHIP_EDGE = 'var(--clay-presence-edge,rgba(0,0,0,.14))';
57
- const TIP_BG = 'var(--clay-presence-tip-bg,#222)';
58
- const TIP_INK = 'var(--clay-presence-tip-ink,#fff)';
59
- const FACE = '#ffffff';
47
+ const FONT = `600 11px/1 ${FONT_MONO}`;
48
+ const LABEL_FONT = `500 11px/1.4 ${FONT_MONO}`;
49
+
50
+ // all:initial first on every element, so a page rule has nothing left to reach; it
51
+ // resets color-scheme and direction too, which are put back straight after.
52
+ const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
53
+
54
+ // The paper a participant's colour sits on: the initials of a solid square, the fill
55
+ // of a hollow one. One literal in both schemes, because every colour below was chosen
56
+ // against it.
57
+ const FACE = '#FFFCF6';
60
58
 
61
59
  // A fixed set rather than a computed hue: every one of these was looked at
62
60
  // against white initials, and a hue wheel puts neighbouring pseudonyms on two
@@ -97,7 +95,7 @@ function initialsOf(name) {
97
95
  /**
98
96
  * Read the roster frame defensively. Everything here arrived over a wire, and a
99
97
  * frame this function cannot understand must draw nothing rather than draw a
100
- * guess: `[object Object]` in a circle is worse than an empty corner.
98
+ * guess: `[object Object]` in a square is worse than an empty corner.
101
99
  */
102
100
  function normalize(data) {
103
101
  const list = data && Array.isArray(data.people) ? data.people : [];
@@ -176,11 +174,12 @@ class Presence {
176
174
  if (!this.build()) return;
177
175
 
178
176
  this.hideTip();
177
+ set(this.root, 'color-scheme', pageScheme());
179
178
  this.stack.textContent = '';
180
179
  for (const person of people) {
181
180
  this.stack.appendChild(this.avatar(person));
182
181
  }
183
- // The overlap belongs between circles, so the leftmost one does not pull
182
+ // The overlap belongs between squares, so the leftmost one does not pull
184
183
  // itself into the count beside it.
185
184
  if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
186
185
 
@@ -207,44 +206,50 @@ class Presence {
207
206
  if (typeof document === 'undefined' || !document.body) return false;
208
207
 
209
208
  this.root = runtimeRoot(make('div', [
209
+ ...RESET,
210
+ `color-scheme:${pageScheme()}`,
210
211
  'position:fixed',
211
212
  'top:calc(12px + env(safe-area-inset-top,0px))',
212
213
  'right:calc(12px + env(safe-area-inset-right,0px))',
213
214
  'z-index:2147483000',
214
215
  'display:flex', 'align-items:center', 'gap:8px',
215
216
  'max-width:calc(100vw - 24px)',
216
- `font:${LABEL_FONT}`, `color:${INK}`,
217
- // Only the circles take a pointer. A fixed corner that swallowed clicks
217
+ `font:${LABEL_FONT}`, `color:${TOKENS.ink}`,
218
+ // Only the squares take a pointer. A fixed corner that swallowed clicks
218
219
  // would take a bite out of whatever the page put underneath it.
219
220
  'pointer-events:none',
220
221
  ]));
221
222
  this.root.setAttribute('data-clay-presence', '');
222
223
 
223
224
  this.count = runtimeRoot(make('span', [
225
+ ...RESET,
224
226
  'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
225
- 'padding:4px 8px', 'border-radius:999px',
226
- `background-color:${CHIP_BG}`, 'background-image:none', `color:${INK}`,
227
- 'border-width:1px', 'border-style:solid', `border-color:${CHIP_EDGE}`,
228
- `font:${LABEL_FONT}`,
229
- 'box-shadow:0 1px 3px rgba(0,0,0,.2)',
227
+ 'padding:3px 8px', 'border-radius:0',
228
+ `background:${TOKENS.surface}`, `color:${TOKENS.muted}`,
229
+ `border:1px solid ${TOKENS['line-2']}`,
230
+ `font:${LABEL_FONT}`, 'letter-spacing:.02em',
231
+ `box-shadow:${SHADOW}`,
230
232
  'display:none',
233
+ 'pointer-events:none',
231
234
  ]));
232
235
  this.count.setAttribute('data-clay-presence-count', '');
233
236
 
234
237
  this.stack = runtimeRoot(make('div', [
235
- 'display:flex', 'align-items:center', 'flex:none',
238
+ ...RESET,
239
+ 'display:flex', 'align-items:center', 'flex:none', 'pointer-events:none',
236
240
  ]));
237
241
 
238
242
  // Inside the root rather than appended to <body>, so the one element that
239
243
  // holds a name at rest cannot outlive the subtree that keeps it out of a
240
244
  // snapshot. It carries the marking of its own too.
241
245
  this.tip = runtimeRoot(make('span', [
246
+ ...RESET,
242
247
  'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
243
248
  'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
244
- 'padding:4px 8px', 'border-radius:6px',
245
- `background-color:${TIP_BG}`, 'background-image:none',
246
- `color:${TIP_INK}`, `font:${LABEL_FONT}`,
247
- 'box-shadow:0 4px 14px rgba(0,0,0,.28)',
249
+ 'padding:5px 9px', 'border-radius:0',
250
+ `background:${TOKENS.ink}`, `color:${TOKENS.ground}`,
251
+ `font:${LABEL_FONT}`, 'letter-spacing:.02em',
252
+ `box-shadow:${SHADOW}`,
248
253
  'display:none',
249
254
  ]));
250
255
  this.tip.setAttribute('data-clay-presence-tip', '');
@@ -264,20 +269,21 @@ class Presence {
264
269
  : [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
265
270
 
266
271
  const el = runtimeRoot(make('div', [
272
+ ...RESET,
267
273
  'box-sizing:border-box', 'flex:none',
268
- `width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:50%',
274
+ `width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:0',
269
275
  'display:flex', 'align-items:center', 'justify-content:center',
270
- `font:${FONT}`, 'font-weight:600',
276
+ `font:${FONT}`, 'letter-spacing:.02em',
271
277
  `margin-left:-${OVERLAP}px`,
272
278
  'border-width:2px', 'border-style:solid', 'background-image:none',
273
- 'box-shadow:0 1px 3px rgba(0,0,0,.28)',
279
+ `box-shadow:${SHADOW}`,
274
280
  'user-select:none', 'cursor:default', 'pointer-events:auto',
275
281
  ...skin,
276
282
  ], initialsOf(person.name)));
277
283
  el.setAttribute('data-clay-presence-avatar', '');
278
284
 
279
285
  // The name lives in this closure, never in an attribute on the element, and
280
- // it reaches the DOM only while a pointer is actually on the circle.
286
+ // it reaches the DOM only while a pointer is actually on the square.
281
287
  const label = person.you ? `${person.name} (you)` : person.name;
282
288
  el.addEventListener('mouseenter', () => this.showTip(label));
283
289
  el.addEventListener('mouseleave', () => this.hideTip());
@@ -31,7 +31,8 @@
31
31
  * The region is kept AFTER focus leaves, deliberately. The three-way merge
32
32
  * keeps the reader's own typing in the focused region and restores the caret,
33
33
  * so the frame worth reporting is the one that lands on the paragraph the
34
- * reader just left.
34
+ * reader just left. The line is drawn beside that region, so the reader sees
35
+ * which section it means.
35
36
  *
36
37
  * DISMISS ONLY. An undo here would mean recovering displaced local work, which
37
38
  * nothing in this library builds. Unsaved local work survives the three-way
@@ -56,15 +57,8 @@
56
57
  * nowhere else — no attribute, no data store, nothing a serializer walks.
57
58
  */
58
59
 
59
- import { make, set } from '../lib/hostile-css.js';
60
-
61
- // Runtime-only chrome, in one string so no root can carry two of the three.
62
- const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
63
-
64
- const BG = 'var(--clay-section-bg,#222)';
65
- const INK = 'var(--clay-section-ink,#fff)';
66
- const EDGE = 'var(--clay-section-edge,rgba(255,255,255,.28))';
67
- const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
60
+ import { set } from '../lib/hostile-css.js';
61
+ import { bevelButton, bevelSurface, bevelText, pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
68
62
 
69
63
  // A control's work is its live value, which its markup does not carry. An
70
64
  // editable subtree's is its markup, and `outerHTML` covers the region's own
@@ -114,6 +108,7 @@ class SectionNotice {
114
108
  this._onFocusIn = (event) => this.remember(event.target);
115
109
  this._onEdit = (event) => this.refresh(event.target);
116
110
  this._onApplied = (event) => this.applied(event && event.detail);
111
+ this._onMove = () => this.place();
117
112
  }
118
113
 
119
114
  /** Idempotent, so importing this module twice cannot double-report a frame. */
@@ -130,6 +125,7 @@ class SectionNotice {
130
125
  destroy() {
131
126
  if (!this._wired) return;
132
127
  this._wired = false;
128
+ this.follow(false);
133
129
  document.removeEventListener('focusin', this._onFocusIn);
134
130
  document.removeEventListener('input', this._onEdit);
135
131
  document.removeEventListener('clay:sync-applied', this._onApplied);
@@ -168,21 +164,62 @@ class SectionNotice {
168
164
 
169
165
  show(name) {
170
166
  if (!this.build()) return;
167
+ // The conflict notice sits in the same corner and matters more.
168
+ const conflict = document.querySelector('[data-clay-conflict]');
169
+ if (conflict && conflict.style.display !== 'none') return;
171
170
  this.line.textContent = `${name} changed this section`;
171
+ set(this.root, 'color-scheme', pageScheme());
172
172
  set(this.root, 'display', 'flex');
173
+ this.place();
174
+ this.follow(true);
173
175
  }
174
176
 
175
177
  hide() {
176
178
  if (!this.root) return;
177
179
  this.line.textContent = '';
178
180
  set(this.root, 'display', 'none');
181
+ this.follow(false);
182
+ }
183
+
184
+ // Beside the section it names: just above its top edge, or just below it when there
185
+ // is no room above. A section scrolled out of view, removed by the frame, or never
186
+ // focused leaves the line in its corner, clear of the conflict bar.
187
+ place() {
188
+ if (!this.root) return;
189
+ // The conflict notice hides the line directly; stop following until the next show().
190
+ if (this.root.style.display === 'none') { this.follow(false); return; }
191
+ const r = this.region && this.region.isConnected ? this.region.getBoundingClientRect() : null;
192
+ const vw = document.documentElement.clientWidth || window.innerWidth;
193
+ const vh = document.documentElement.clientHeight || window.innerHeight;
194
+ if (!r || !(r.width || r.height) || r.bottom <= 0 || r.top >= vh) {
195
+ set(this.root, 'top', 'auto');
196
+ set(this.root, 'left', 'calc(12px + env(safe-area-inset-left,0px))');
197
+ set(this.root, 'bottom', 'calc(72px + env(safe-area-inset-bottom,0px))');
198
+ return;
199
+ }
200
+ const h = this.root.offsetHeight || 40;
201
+ const w = this.root.offsetWidth || 280;
202
+ const gap = 6;
203
+ const top = r.top - h - gap >= 8 ? r.top - h - gap : Math.min(r.bottom + gap, vh - h - 8);
204
+ const left = Math.max(12, Math.min(r.left, vw - w - 12));
205
+ set(this.root, 'bottom', 'auto');
206
+ set(this.root, 'top', `${Math.round(top)}px`);
207
+ set(this.root, 'left', `${Math.round(left)}px`);
208
+ }
209
+
210
+ follow(on) {
211
+ if (typeof window === 'undefined' || !!on === !!this._following) return;
212
+ this._following = !!on;
213
+ const method = on ? 'addEventListener' : 'removeEventListener';
214
+ window[method]('scroll', this._onMove, { capture: true, passive: true });
215
+ window[method]('resize', this._onMove, { passive: true });
179
216
  }
180
217
 
181
218
  build() {
182
219
  if (this.root && this.root.isConnected) return true;
183
220
  if (typeof document === 'undefined' || !document.body) return false;
184
221
 
185
- this.root = runtimeRoot(make('div', [
222
+ this.root = runtimeRoot(bevelSurface('div', [
186
223
  'position:fixed',
187
224
  'left:calc(12px + env(safe-area-inset-left,0px))',
188
225
  // Clear of the conflict bar, which is bottom-anchored and can span almost
@@ -190,30 +227,18 @@ class SectionNotice {
190
227
  // this one moves rather than covering it.
191
228
  'bottom:calc(72px + env(safe-area-inset-bottom,0px))',
192
229
  'z-index:2147483000',
193
- 'display:none', 'align-items:center', 'gap:10px',
194
- 'box-sizing:border-box', 'max-width:calc(100vw - 24px)',
195
- 'padding:9px 12px', 'border-radius:10px',
196
- `background-color:${BG}`, 'background-image:none', `color:${INK}`,
197
- 'border-width:1px', 'border-style:solid', `border-color:${EDGE}`,
198
- 'box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)',
199
- `font:${FONT}`, 'text-align:left',
230
+ 'display:none', 'align-items:center', 'gap:10px 12px',
231
+ 'max-width:calc(100vw - 24px)', 'padding:8px 8px 8px 14px', 'text-align:left',
232
+ `color-scheme:${pageScheme()}`,
200
233
  ]));
201
234
  this.root.setAttribute('data-clay-section-notice', '');
202
235
  this.root.setAttribute('role', 'status');
203
236
 
204
- this.line = runtimeRoot(make('span', ['flex:1 1 auto']));
237
+ this.line = runtimeRoot(bevelText('span', ['flex:1 1 auto']));
205
238
  this.line.setAttribute('data-clay-section-notice-line', '');
206
239
 
207
- // all:initial first, because a page restyling every button is the normal
208
- // case rather than the adversarial one; everything it needs is restated.
209
- const dismiss = runtimeRoot(make('button', [
210
- 'all:initial', 'box-sizing:border-box', 'cursor:pointer', `font:${FONT}`,
211
- 'font-weight:500', 'border-radius:6px', 'padding:4px 8px',
212
- 'white-space:nowrap', 'flex:none', `color:${INK}`, 'opacity:.72',
213
- ], 'Dismiss'));
214
- dismiss.type = 'button';
240
+ const dismiss = bevelButton('Dismiss', { small: true, variant: 'quiet', onClick: () => this.hide() });
215
241
  dismiss.setAttribute('data-clay-section-notice-dismiss', '');
216
- dismiss.addEventListener('click', () => this.hide());
217
242
 
218
243
  this.root.append(this.line, dismiss);
219
244
  document.body.appendChild(this.root);
@@ -0,0 +1,277 @@
1
+ // Bevel controls for UI ClayJS draws on somebody else's page. Every declaration
2
+ // goes on inline and !important (hostile-css.js), so the page's stylesheet cannot
3
+ // repaint them. Inline styles have no :hover or :active, so each button keeps its
4
+ // state in flags and rebuilds its whole inline style from them on every change:
5
+ // a property one state set can never survive into the next. Style a button only
6
+ // through `extra` and `labelExtra`; anything set on it afterwards is rebuilt away.
7
+ //
8
+ // all:initial resets color-scheme and direction too. Both are put back after every
9
+ // reset, so light-dark() follows the scheme the UI root sets and a page cannot
10
+ // flip the text direction.
11
+ import { style, make, set } from '../lib/hostile-css.js';
12
+ import { RULES, MEDIA, FONT_SANS, TOKENS } from './bevel.js';
13
+
14
+ export const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
15
+
16
+ const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
17
+
18
+ const SIZE = {
19
+ normal: [`font:500 14px/1.45 ${FONT_SANS}`, 'padding:4px 12px'],
20
+ small: [`font:500 12.5px/1.45 ${FONT_SANS}`, 'padding:1px 8px'],
21
+ };
22
+
23
+ // Bevel composes :hover and :active in source order; so do these lists.
24
+ const VARIANT = {
25
+ default: { rest: RULES.button, hover: RULES.buttonHover, active: RULES.buttonActive },
26
+ primary: { rest: RULES.buttonPrimary, hover: [...RULES.buttonPrimaryHoverBase, ...RULES.buttonPrimaryHover], active: RULES.buttonPrimaryActive },
27
+ quiet: { rest: [...RULES.button, ...RULES.buttonQuiet], hover: [...RULES.buttonHover, ...RULES.buttonQuietHover], active: [...RULES.buttonActive, ...RULES.buttonQuietActive] },
28
+ danger: { rest: RULES.buttonDanger, hover: RULES.buttonDangerHover, active: RULES.buttonDangerActive },
29
+ };
30
+
31
+ const media = (query) => typeof matchMedia === 'function' && matchMedia(query).matches;
32
+
33
+ let keyboardModality = false;
34
+ const focusedButtons = new Set();
35
+ if (typeof document !== 'undefined') {
36
+ document.addEventListener('keydown', () => {
37
+ keyboardModality = true;
38
+ for (const refresh of focusedButtons) refresh();
39
+ }, true);
40
+ document.addEventListener('pointerdown', () => { keyboardModality = false; }, true);
41
+ }
42
+
43
+ function runtime(el) {
44
+ el.setAttribute('clay', RUNTIME_ONLY);
45
+ return el;
46
+ }
47
+
48
+ function restyle(el, rules) {
49
+ el.style.cssText = '';
50
+ style(el, rules);
51
+ }
52
+
53
+ const SVG_NS = 'http://www.w3.org/2000/svg';
54
+ const SHAPES = new Set(['path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g']);
55
+
56
+ // Presentation attributes lose to any author rule, so the icon's own attributes are
57
+ // restated as inline !important declarations: the page cannot hide, resize or
58
+ // repaint it. A path's geometry is pinned through the `d` property as well.
59
+ // It inherits colour, so a currentColor shape takes the ink of the control it sits in.
60
+ function protectIcon(svg, size) {
61
+ if (!svg || svg.namespaceURI !== SVG_NS) return;
62
+ runtime(svg);
63
+ style(svg, [
64
+ ...RESET, 'color:inherit', 'display:block', `width:${size}px`, `height:${size}px`, 'overflow:visible',
65
+ 'visibility:visible', 'opacity:1', 'transform:none', 'pointer-events:none',
66
+ ]);
67
+ for (const el of svg.querySelectorAll('*')) {
68
+ if (!SHAPES.has(el.localName)) continue;
69
+ const attr = (name, fallback) => el.getAttribute(name) ?? fallback;
70
+ const rules = [
71
+ 'visibility:visible', 'opacity:1', 'transform:none', 'display:inline',
72
+ `fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
73
+ `stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
74
+ ];
75
+ if (el.hasAttribute('stroke-width')) rules.push(`stroke-width:${el.getAttribute('stroke-width')}`);
76
+ if (el.hasAttribute('stroke-linecap')) rules.push(`stroke-linecap:${el.getAttribute('stroke-linecap')}`);
77
+ if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
78
+ style(el, rules);
79
+ }
80
+ }
81
+
82
+ export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null } = {}) {
83
+ const v = VARIANT[variant] || VARIANT.default;
84
+ const size = small ? SIZE.small : SIZE.normal;
85
+ const reducedMotion = media('(prefers-reduced-motion: reduce)');
86
+ const forced = media('(forced-colors: active)');
87
+ const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
88
+ const pinned = new Map();
89
+
90
+ const b = runtime(make('button', []));
91
+ b.type = 'button';
92
+ const span = runtime(make('span', []));
93
+ span.textContent = label;
94
+ b.append(span);
95
+
96
+ function applyState() {
97
+ const rules = [...RESET, 'box-sizing:border-box', ...v.rest, ...size];
98
+ if (flags.disabled) {
99
+ rules.push(...RULES.buttonDisabled);
100
+ } else {
101
+ if (flags.hovered) rules.push(...v.hover);
102
+ if (flags.pressed) rules.push(...v.active);
103
+ }
104
+ if (flags.focusVisible) rules.push(...RULES.focus);
105
+ if (forced) rules.push(...MEDIA.forcedColors);
106
+ rules.push(...extra);
107
+ for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
108
+ restyle(b, rules);
109
+ const labelRules = [...RESET, 'font:inherit', 'color:inherit', ...RULES.buttonLabel];
110
+ if (flags.disabled) labelRules.push(...RULES.buttonDisabledLabel);
111
+ else if (flags.pressed) labelRules.push(...(reducedMotion ? MEDIA.reducedMotion : RULES.buttonActiveLabel));
112
+ labelRules.push(...labelExtra);
113
+ restyle(span, labelRules);
114
+ onState?.({ ...flags });
115
+ }
116
+
117
+ function refreshFocus() {
118
+ let visible;
119
+ try { visible = b.matches(':focus-visible'); } catch { visible = false; }
120
+ const next = visible || keyboardModality;
121
+ if (next !== flags.focusVisible) { flags.focusVisible = next; applyState(); }
122
+ }
123
+
124
+ const on = (type, fn) => b.addEventListener(type, fn);
125
+ on('pointerenter', () => { flags.hovered = true; applyState(); });
126
+ on('pointerleave', () => { flags.hovered = false; flags.pressed = false; applyState(); });
127
+ on('pointercancel', () => { flags.pressed = false; applyState(); });
128
+ on('pointerdown', (e) => {
129
+ if (e.button !== undefined && e.button !== 0) return;
130
+ if (!flags.disabled) { flags.pressed = true; applyState(); }
131
+ });
132
+ on('pointerup', () => { flags.pressed = false; applyState(); });
133
+ on('keydown', (e) => {
134
+ if ((e.key === ' ' || e.key === 'Enter') && !flags.disabled) { flags.pressed = true; applyState(); }
135
+ });
136
+ on('keyup', () => { if (flags.pressed) { flags.pressed = false; applyState(); } });
137
+ on('focus', () => { focusedButtons.add(refreshFocus); flags.focusVisible = false; refreshFocus(); applyState(); });
138
+ on('blur', () => { focusedButtons.delete(refreshFocus); flags.focusVisible = false; flags.pressed = false; applyState(); });
139
+ on('click', (e) => {
140
+ if (flags.pressed) { flags.pressed = false; applyState(); }
141
+ if (flags.disabled) { e.preventDefault(); e.stopImmediatePropagation(); return; }
142
+ if (onClick) onClick(e);
143
+ });
144
+
145
+ b.setLabel = (text) => { span.textContent = text; };
146
+ b.setDisabled = (disabled) => {
147
+ flags.disabled = !!disabled;
148
+ if (flags.disabled) b.setAttribute('aria-disabled', 'true');
149
+ else b.removeAttribute('aria-disabled');
150
+ applyState();
151
+ };
152
+ b.isDisabled = () => flags.disabled;
153
+ // Declarations that must outlive every state rebuild: where a floating button sits,
154
+ // whether it shows. Written anywhere else, they would be rebuilt away on the next hover.
155
+ b.pin = (props) => {
156
+ for (const [prop, value] of Object.entries(props)) pinned.set(prop, value);
157
+ applyState();
158
+ };
159
+
160
+ applyState();
161
+ return b;
162
+ }
163
+
164
+ export function bevelIconButton(svg, { label, onClick = null }) {
165
+ const b = bevelButton('', {
166
+ small: true,
167
+ onClick,
168
+ extra: ['width:26px', 'height:26px', 'padding:0', 'display:inline-grid', 'place-items:center'],
169
+ labelExtra: ['display:inline-grid', 'width:12px', 'height:12px'],
170
+ });
171
+ b.firstChild.innerHTML = svg;
172
+ protectIcon(b.firstChild.firstElementChild, 12);
173
+ b.setAttribute('aria-label', label);
174
+ b.title = label;
175
+ return b;
176
+ }
177
+
178
+ // Bevel's overlay close: a 68px corner cut on the diagonal, the pixel X drawn on it.
179
+ // Hover lifts the corner to the face, keyboard focus fills it with brass.
180
+ const CORNER = [
181
+ 'position:absolute', 'top:-1px', 'right:-1px', 'z-index:2', 'display:block',
182
+ 'width:68px', 'height:68px', 'padding:0', 'margin:0', 'border:0', 'outline:none',
183
+ 'background:transparent', 'clip-path:polygon(0 4%, 0 0, 100% 0, 100% 100%, 94% 100%)',
184
+ ];
185
+ const CORNER_BG = 'M132 132.5 1 1.5h131v131Z';
186
+ const CORNER_X = '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';
187
+
188
+ export function bevelCornerClose({ label = 'Close', onClick = null } = {}) {
189
+ let bg = null;
190
+ let x = null;
191
+ const paint = ({ hovered, focusVisible }) => {
192
+ if (!bg) return;
193
+ set(bg, 'fill', focusVisible ? TOKENS.brass : hovered ? TOKENS.face : TOKENS.sunk);
194
+ set(x, 'fill', focusVisible ? TOKENS.face : TOKENS.ink);
195
+ };
196
+ const b = bevelButton('', {
197
+ variant: 'quiet', onClick, extra: CORNER,
198
+ labelExtra: ['display:block', 'width:100%', 'height:100%'], onState: paint,
199
+ });
200
+ b.setAttribute('aria-label', label);
201
+ b.firstChild.innerHTML = `<svg viewBox="0 0 134 134" xmlns="http://www.w3.org/2000/svg"><path d="${CORNER_BG}"/><path fill-rule="evenodd" clip-rule="evenodd" d="${CORNER_X}"/></svg>`;
202
+ const svg = b.firstChild.firstElementChild;
203
+ protectIcon(svg, 68);
204
+ [bg, x] = svg.children;
205
+ set(x, 'fill-rule', 'evenodd');
206
+ set(x, 'clip-rule', 'evenodd');
207
+ paint({ hovered: false, focusVisible: false });
208
+ return b;
209
+ }
210
+
211
+ // A plain runtime-only box: reset, then only the given rules. For structure inside a
212
+ // surface (rows, stacks, backdrops) that carries no material of its own.
213
+ export function bevelBox(tag, rules = []) {
214
+ return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...rules]));
215
+ }
216
+
217
+ export function bevelSurface(tag, rules = []) {
218
+ return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...RULES.surface, `font:14px/1.5 ${FONT_SANS}`, ...rules]));
219
+ }
220
+
221
+ export function bevelWell(rules = []) {
222
+ return runtime(make('div', [...RESET, 'display:block', 'box-sizing:border-box', `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`, ...RULES.recess, ...rules]));
223
+ }
224
+
225
+ export function bevelText(tag, rules = [], text) {
226
+ const el = runtime(make(tag, [...RESET, 'font:inherit', `color:${TOKENS.ink}`, ...rules]));
227
+ if (text != null) el.textContent = text;
228
+ return el;
229
+ }
230
+
231
+ // A text field in Bevel's input material, on an element that already exists (markup a
232
+ // dialog wrote) or a new one. Focus and hover live in flags, as on a button. A
233
+ // textarea keeps the height a person dragged it to across every repaint.
234
+ export function paintInput(el, { rules = [] } = {}) {
235
+ const flags = { hovered: false, focused: false };
236
+ const forced = media('(forced-colors: active)');
237
+ runtime(el);
238
+ function applyState() {
239
+ const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
240
+ const list = [...RESET, 'box-sizing:border-box', ...RULES.input];
241
+ if (flags.hovered) list.push(...RULES.inputHover);
242
+ if (flags.focused) list.push(...RULES.inputFocus);
243
+ if (forced) list.push(...MEDIA.forcedColors);
244
+ list.push(...rules);
245
+ if (height) list.push(`height:${height}`);
246
+ restyle(el, list);
247
+ }
248
+ el.addEventListener('pointerenter', () => { flags.hovered = true; applyState(); });
249
+ el.addEventListener('pointerleave', () => { flags.hovered = false; applyState(); });
250
+ el.addEventListener('focus', () => { flags.focused = true; applyState(); });
251
+ el.addEventListener('blur', () => { flags.focused = false; applyState(); });
252
+ applyState();
253
+ return el;
254
+ }
255
+
256
+ export function bevelInput(tag = 'input', opts = {}) {
257
+ const el = document.createElement(tag);
258
+ if (tag === 'input') el.type = 'text';
259
+ return paintInput(el, opts);
260
+ }
261
+
262
+ // The scheme a UI root gives its subtree: the page's own when it declares one, so
263
+ // light-dark() matches what the person is looking at, else the reader's preference.
264
+ export function pageScheme() {
265
+ const s = getComputedStyle(document.documentElement).colorScheme;
266
+ return s === 'light' || s === 'dark' ? s : 'light dark';
267
+ }
268
+
269
+ // `hidden` loses to an inline !important display, so showing and hiding a control
270
+ // sets both: the attribute for assistive tech and callers, the declaration for looks.
271
+ export function setShown(el, shown, display = 'block') {
272
+ el.hidden = !shown;
273
+ if (el.pin) el.pin({ display: shown ? display : 'none' });
274
+ else set(el, 'display', shown ? display : 'none');
275
+ }
276
+
277
+ export { protectIcon };