@panphora/clayjs 1.8.2 → 1.9.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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * people.js — who is editing, and who wrote what (spec §9, People).
3
+ *
4
+ * clay.me { id, name, initials, color } or null
5
+ * clay.people.get(id) always a record; an unknown id is "Unknown person"
6
+ * clay.people.list() the people this document names
7
+ * await clay.people.available() the host's team for a picker, or null
8
+ * clay.people.add(person) record a person in the document; returns the record
9
+ * clay.people.remove(id) forget a person the document no longer names
10
+ * clay.people.canRename() true when my name was chosen in this browser
11
+ * clay.people.rename(name) change that name here and in this page
12
+ * await clay.author(el, attr?) stamp el with my id (asks my name once if needed)
13
+ * event clay:people the viewer, the team or the registry changed
14
+ *
15
+ * An id is attribution, never authorization: anyone who can edit the page can
16
+ * write any id. Names on screen prefer the host's live answer; the copy stored in
17
+ * the document changes only when that person next writes (author or add), so
18
+ * opening a page never changes its bytes.
19
+ */
20
+
21
+ import { hostMeta, hostMetaOutcome } from "../core/host-meta.js";
22
+ import { isEditMode } from "../core/is-edit-mode.js";
23
+
24
+ const editing = () => (window.clay && "isEditMode" in window.clay ? !!window.clay.isEditMode : isEditMode);
25
+ import { bevelDialog, dismissOf, keepFocusIn } from "../ui/bevel-dialog.js";
26
+ import { bevelBox, bevelButton, bevelInput, bevelText } from "../ui/bevel-controls.js";
27
+ import { resolveTheme } from "../ui/theme.js";
28
+
29
+ const ID = /^[A-Za-z0-9_-]{8,64}$/;
30
+ const STORE = "clay:people:me";
31
+ const BOOT_WAIT_MS = 2000;
32
+ const COLORS = 8;
33
+ const MAX_NAME = 120;
34
+ const EMAIL = /\S+@\S+\.\S+/;
35
+
36
+ let host = { supports: false, me: null, members: null, unavailable: false };
37
+ let outcome = null;
38
+ let local;
39
+ let cache = null;
40
+ let asking = null;
41
+ let askingRoot = null;
42
+ let cancelAsking = null;
43
+ let lastRegistry = "";
44
+
45
+ function clean(person) {
46
+ if (!person || typeof person !== "object") return null;
47
+ const id = typeof person.id === "string" ? person.id : "";
48
+ const name = typeof person.name === "string" ? person.name.trim().replace(/\s+/g, " ") : "";
49
+ if (!ID.test(id) || !name || name.length > MAX_NAME || EMAIL.test(name)) return null;
50
+ return { id, name };
51
+ }
52
+
53
+ function initialsOf(name) {
54
+ const letters = name.split(" ").filter(Boolean).slice(0, 2).map((word) => Array.from(word)[0]);
55
+ return letters.join("").toUpperCase() || "?";
56
+ }
57
+
58
+ function colorOf(id) {
59
+ let hash = 0;
60
+ for (const ch of String(id)) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
61
+ return hash % COLORS;
62
+ }
63
+
64
+ function record({ id, name }) {
65
+ return Object.freeze({ id, name, initials: initialsOf(name), color: colorOf(id) });
66
+ }
67
+
68
+ function emit() {
69
+ document.dispatchEvent(new CustomEvent("clay:people", { detail: { me: currentMe() } }));
70
+ }
71
+
72
+ function registry() {
73
+ return document.querySelector("[clay-people]");
74
+ }
75
+
76
+ // Every registry counts: two editors whose first comments race can each create one, and a
77
+ // merge may keep both.
78
+ function entries() {
79
+ return [...document.querySelectorAll("[clay-people] data[value]")];
80
+ }
81
+
82
+ function saved() {
83
+ if (cache) return cache;
84
+ cache = new Map();
85
+ for (const el of entries()) {
86
+ const person = clean({ id: el.getAttribute("value"), name: el.textContent });
87
+ if (person && !cache.has(person.id)) cache.set(person.id, record(person));
88
+ }
89
+ return cache;
90
+ }
91
+
92
+ function readLocal() {
93
+ if (local === undefined) {
94
+ local = null;
95
+ try {
96
+ local = clean(JSON.parse(window.localStorage.getItem(STORE) || "null"));
97
+ } catch (_) {}
98
+ }
99
+ return local;
100
+ }
101
+
102
+ function writeLocal(person) {
103
+ local = person;
104
+ try {
105
+ window.localStorage.setItem(STORE, JSON.stringify(person));
106
+ } catch (_) {}
107
+ }
108
+
109
+ function mint() {
110
+ const bytes = new Uint8Array(16);
111
+ crypto.getRandomValues(bytes);
112
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
113
+ }
114
+
115
+ // Only an editor is anyone: a read-only page has no me, so it shows no compose UI. The
116
+ // host's answer wins when it names you. A host that failed to answer has not said you are
117
+ // nobody, so no local name stands in for a known account. Otherwise (no discovery, a host
118
+ // without people, or a guest it cannot name) an editor is whoever this browser says it is.
119
+ export function currentMe() {
120
+ if (!editing()) return null;
121
+ if (host.me) return record(host.me);
122
+ // The host knows who you are but cannot say right now: not a reason to become whoever
123
+ // this browser last was.
124
+ if (host.unavailable) return null;
125
+ if (outcome !== "ok" && outcome !== "none") return null;
126
+ const mine = readLocal();
127
+ return mine ? record(mine) : null;
128
+ }
129
+
130
+ function applyMeta(meta) {
131
+ const nextOutcome = hostMetaOutcome(meta);
132
+ // Once the host has named people, a later answer that is not a capability document is a
133
+ // blip (a deploy, a proxy), not news that it forgot everyone: the last good answer stands.
134
+ if (nextOutcome !== "ok" && host.supports) return;
135
+ const people = meta.document?.people;
136
+ const supports = meta.extensions.includes("people") && !!people && typeof people === "object";
137
+ const next = {
138
+ supports,
139
+ me: supports ? clean(people.me) : null,
140
+ members: supports && Array.isArray(people.members) ? people.members.map(clean).filter(Boolean) : null,
141
+ unavailable: supports && people.unavailable === true,
142
+ };
143
+ const changed = nextOutcome !== outcome || JSON.stringify(next) !== JSON.stringify(host);
144
+ host = next;
145
+ outcome = nextOutcome;
146
+ if (changed) emit();
147
+ }
148
+
149
+ async function refresh(fresh) {
150
+ applyMeta(await hostMeta({ fresh }));
151
+ }
152
+
153
+ function get(id) {
154
+ const key = String(id ?? "");
155
+ const live = (host.me?.id === key && host.me) || host.members?.find((person) => person.id === key);
156
+ if (live) return record(live);
157
+ const mine = readLocal();
158
+ if (mine?.id === key) return record(mine);
159
+ return saved().get(key) || Object.freeze({ id: key, name: "Unknown person", initials: "?", color: colorOf(key) });
160
+ }
161
+
162
+ function list() {
163
+ return [...saved().keys()].map(get);
164
+ }
165
+
166
+ async function available() {
167
+ await refresh(outcome !== "none");
168
+ return host.members ? host.members.map(record) : null;
169
+ }
170
+
171
+ function add(person) {
172
+ const p = clean(person);
173
+ if (!p) throw new TypeError("clay.people.add: needs an id of 8 to 64 letters, digits, _ or -, and a name of 1 to 120 characters");
174
+ let root = registry();
175
+ if (!root) {
176
+ root = document.createElement("div");
177
+ root.setAttribute("clay-people", "");
178
+ root.hidden = true;
179
+ document.body.append(root);
180
+ }
181
+ const same = entries().filter((el) => el.getAttribute("value") === p.id);
182
+ if (same.length) {
183
+ if (same[0].textContent !== p.name) same[0].textContent = p.name;
184
+ for (const extra of same.slice(1)) extra.remove();
185
+ } else {
186
+ const el = document.createElement("data");
187
+ el.setAttribute("value", p.id);
188
+ el.textContent = p.name;
189
+ root.append(el);
190
+ }
191
+ cache = null;
192
+ return record(p);
193
+ }
194
+
195
+ // Forget a person the document no longer names, so their name stops travelling with it.
196
+ function remove(id) {
197
+ const key = String(id ?? "");
198
+ for (const el of entries()) if (el.getAttribute("value") === key) el.remove();
199
+ cache = null;
200
+ }
201
+
202
+ // Only a name this browser chose can change here; a host's name is the host's to change.
203
+ function canRename() {
204
+ return editing() && !host.me && (outcome === "ok" || outcome === "none") && !!readLocal() && !host.unavailable;
205
+ }
206
+
207
+ /**
208
+ * Change the name this browser uses, and this page's record of it. Other pages keep the
209
+ * old name until this person next writes there. Returns the new record.
210
+ */
211
+ function rename(name) {
212
+ if (!canRename()) throw Object.assign(new Error("clay.people.rename: only a name chosen in this browser can be changed here"), { code: "not-renamable" });
213
+ const mine = readLocal();
214
+ const next = clean({ id: mine.id, name });
215
+ if (!next) throw new TypeError("clay.people.rename: needs a name of 1 to 120 characters that is not an email address");
216
+ writeLocal(next);
217
+ if (entries().some((el) => el.getAttribute("value") === next.id)) add(next);
218
+ emit();
219
+ return record(next);
220
+ }
221
+
222
+ function unavailable() {
223
+ return Object.assign(new Error("Can't reach the host to check who you are. Try again in a moment."), { code: "people-unavailable" });
224
+ }
225
+
226
+ /**
227
+ * Stamp `el` with the current person's id, recording them in the document.
228
+ * Resolves the person's record, or null when they cancel the name prompt.
229
+ * Rejects with code "people-unavailable" when the host failed to answer.
230
+ */
231
+ export async function author(el, attr = "data-by") {
232
+ if (!window.clay?.isEditMode) throw new Error("clay.author: this page is not in edit mode");
233
+ if (!(el instanceof Element)) throw new TypeError("clay.author: needs an element");
234
+ // A host that names people can change who you are while the page is open (a sign-out,
235
+ // a rename in the app), so a stamp asks it again first. If it cannot answer now, nothing
236
+ // is stamped: the last answer might name someone who is no longer here.
237
+ if (host.supports) {
238
+ const meta = await hostMeta({ fresh: true });
239
+ const fresh = hostMetaOutcome(meta);
240
+ if (fresh === "none") throw Object.assign(new Error("This page lost its connection to the host. Reload it to keep editing."), { code: "people-unavailable" });
241
+ if (fresh !== "ok") throw unavailable();
242
+ applyMeta(meta);
243
+ if (host.unavailable) throw Object.assign(new Error("The app hosting this file can't name you right now. Check its Profile settings, then try again."), { code: "people-unavailable" });
244
+ } else if (outcome !== "ok" && outcome !== "none") {
245
+ await refresh(outcome === "failed");
246
+ }
247
+ if (outcome === "failed") throw unavailable();
248
+ let me = currentMe();
249
+ if (!me) {
250
+ const chosen = await askName();
251
+ if (!chosen) return null;
252
+ writeLocal(chosen);
253
+ me = record(chosen);
254
+ emit();
255
+ }
256
+ add(me);
257
+ el.setAttribute(attr, me.id);
258
+ return me;
259
+ }
260
+
261
+ function askName() {
262
+ if (asking && askingRoot?.isConnected) return asking;
263
+ if (asking) cancelAsking();
264
+ asking = new Promise((resolve) => {
265
+ const theme = resolveTheme();
266
+ const { root, overlay, panel, heading, body, footer, close } = bevelDialog({
267
+ zIndex: "2147483001", width: "420px", closable: true, titled: true, theme, surface: "people",
268
+ });
269
+ askingRoot = root;
270
+ cancelAsking = () => finish(null);
271
+ heading.textContent = "What name should appear on your changes?";
272
+ panel.setAttribute("aria-label", "Your name");
273
+
274
+ const input = bevelInput("input", { rules: ["display:block", "width:100%", "margin:0"], theme, parts: ["dialog.input", "people.input"] });
275
+ input.setAttribute("aria-label", "Your name");
276
+ input.maxLength = MAX_NAME;
277
+ input.autocomplete = "name";
278
+ const hint = bevelText("p", ["display:block", "margin:10px 0 0", "font-size:13px", "opacity:.8"],
279
+ "Saved in this browser. Everyone who can read this page will see it.", { theme, parts: ["dialog.hint", "people.hint"], role: "mutedText" });
280
+ const hintText = hint.textContent;
281
+ body.append(input, hint);
282
+
283
+ const known = list().slice(0, 8);
284
+ if (known.length) {
285
+ const label = bevelText("p", ["display:block", "margin:18px 0 8px", "font-size:13px"], "Or continue as someone already in this page:", { theme, parts: ["people.choiceLabel"], role: "mutedText" });
286
+ const row = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px"], { theme, parts: ["people.choices"] });
287
+ for (const person of known) {
288
+ row.append(bevelButton(person.name, { small: true, onClick: () => finish({ id: person.id, name: person.name }), theme, parts: ["dialog.button", "people.button", "people.choice"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] }));
289
+ }
290
+ body.append(label, row);
291
+ }
292
+
293
+ const cancel = bevelButton("Cancel", { onClick: () => finish(null), theme, parts: ["dialog.button", "people.button"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] });
294
+ const ok = bevelButton("Continue", { variant: "primary", onClick: () => submit(), theme, parts: ["dialog.button", "people.button"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] });
295
+ footer.append(cancel, ok);
296
+
297
+ const submit = () => {
298
+ const name = input.value.trim().replace(/\s+/g, " ");
299
+ if (!name) {
300
+ input.focus();
301
+ return;
302
+ }
303
+ if (EMAIL.test(name)) {
304
+ hint.textContent = "Use a name, not an email address. Everyone who can read this page will see it.";
305
+ input.focus();
306
+ return;
307
+ }
308
+ finish({ id: mint(), name: name.slice(0, MAX_NAME) });
309
+ };
310
+ input.addEventListener("input", () => {
311
+ if (hint.textContent !== hintText) hint.textContent = hintText;
312
+ });
313
+
314
+ panel.addEventListener("submit", (event) => {
315
+ event.preventDefault();
316
+ submit();
317
+ });
318
+ close?.addEventListener("click", () => finish(null));
319
+ let pressedOnBackdrop = false;
320
+ overlay.addEventListener("mousedown", (event) => { pressedOnBackdrop = event.target === overlay; });
321
+ overlay.addEventListener("click", (event) => {
322
+ if (pressedOnBackdrop && event.target === overlay) finish(null);
323
+ pressedOnBackdrop = false;
324
+ });
325
+ const onKey = (event) => {
326
+ if (event.key === "Tab") {
327
+ event.stopPropagation();
328
+ keepFocusIn(panel, event);
329
+ return;
330
+ }
331
+ if (event.key === "Escape") {
332
+ event.preventDefault();
333
+ event.stopPropagation();
334
+ finish(null);
335
+ } else if (event.key === "Enter" && event.target === input) {
336
+ event.preventDefault();
337
+ event.stopPropagation();
338
+ submit();
339
+ }
340
+ };
341
+ document.addEventListener("keydown", onKey, true);
342
+
343
+ let done = false;
344
+ function finish(person) {
345
+ if (done) return;
346
+ done = true;
347
+ document.removeEventListener("keydown", onKey, true);
348
+ root.remove();
349
+ // Recorded before the next caller can look, so an author() in the same task finds a me
350
+ // instead of opening a second prompt.
351
+ if (person) writeLocal(person);
352
+ asking = null;
353
+ askingRoot = null;
354
+ cancelAsking = null;
355
+ resolve(person);
356
+ }
357
+ dismissOf.set(root, () => finish(null));
358
+
359
+ root.setAttribute("aria-hidden", "false");
360
+ modalHost().append(root);
361
+ input.focus();
362
+ });
363
+ return asking;
364
+ }
365
+
366
+ // A page's own modal <dialog> makes everything outside it inert, so the prompt goes inside
367
+ // the topmost one, or it could not be clicked or typed into.
368
+ function modalHost() {
369
+ const open = [...document.querySelectorAll("dialog[open]")].filter((d) => d.matches(":modal"));
370
+ return open[open.length - 1] || document.body;
371
+ }
372
+
373
+ function touchesRegistry(node) {
374
+ return node.nodeType === 1 && (node.matches("[clay-people]") || !!node.querySelector("[clay-people]"));
375
+ }
376
+
377
+ function registrySignature() {
378
+ return JSON.stringify([...saved().values()].map(({ id, name }) => [id, name]));
379
+ }
380
+
381
+ function observe() {
382
+ lastRegistry = registrySignature();
383
+ new MutationObserver((records) => {
384
+ for (const rec of records) {
385
+ const target = rec.target.nodeType === 1 ? rec.target : rec.target.parentElement;
386
+ // A removed clay-people attribute no longer matches the selector, so it is caught by name.
387
+ if (rec.attributeName === "clay-people" ||
388
+ target?.closest("[clay-people]") ||
389
+ [...rec.addedNodes].some(touchesRegistry) ||
390
+ [...rec.removedNodes].some(touchesRegistry)) {
391
+ cache = null;
392
+ // Only a real change is news; a listener that re-renders the registry must not loop.
393
+ const next = registrySignature();
394
+ if (next !== lastRegistry) {
395
+ lastRegistry = next;
396
+ emit();
397
+ }
398
+ return;
399
+ }
400
+ }
401
+ }).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["value", "clay-people"] });
402
+ }
403
+
404
+ async function init() {
405
+ observe();
406
+ // A reader is nobody and picks nobody, so a read-only page never asks the host.
407
+ if (!editing()) return;
408
+ window.addEventListener("storage", (event) => {
409
+ if (event.key !== STORE) return;
410
+ local = undefined;
411
+ emit();
412
+ });
413
+ document.addEventListener("visibilitychange", () => {
414
+ if (document.visibilityState === "visible" && outcome !== "none") refresh(true);
415
+ });
416
+ const first = refresh(false);
417
+ await Promise.race([first, new Promise((resolve) => setTimeout(resolve, BOOT_WAIT_MS))]);
418
+ }
419
+
420
+ export const people = Object.freeze({ get, list, available, add, remove, canRename, rename });
421
+
422
+ export const ready = init();
423
+
424
+ export default people;
@@ -17,9 +17,37 @@
17
17
  import { isEditMode } from "../core/is-edit-mode.js";
18
18
  import Mutation from "../lib/mutation.js";
19
19
  import { capabilitySelector } from "../lib/region-capabilities.js";
20
+ import { onSnapshot } from "../core/snapshot.js";
20
21
 
21
22
  const EDITOR_UI_SELECTOR = capabilitySelector('history');
22
23
 
24
+ // Sortable marks the picked-up item and its links and images draggable="false" on press, and
25
+ // leaves style="" behind after a drag. Those attributes would be saved into the document, so a
26
+ // mere click would change the file. Record them when an item is chosen and put them back after.
27
+ const RUNTIME_ATTRS = ['draggable', 'style'];
28
+
29
+ const pressed = new Set();
30
+
31
+ function recordRuntimeAttrs(item) {
32
+ return [item, ...item.querySelectorAll('*')].map(el => [el, RUNTIME_ATTRS.map(name => el.getAttribute(name))]);
33
+ }
34
+
35
+ function restoreRuntimeAttrs(recorded) {
36
+ for (const [el, values] of recorded) {
37
+ RUNTIME_ATTRS.forEach((name, i) => {
38
+ const before = values[i];
39
+ const now = el.getAttribute(name);
40
+ if (now === before) return;
41
+ if (before === null) {
42
+ if (name === 'style' && now) return;
43
+ el.removeAttribute(name);
44
+ } else {
45
+ el.setAttribute(name, before);
46
+ }
47
+ });
48
+ }
49
+ }
50
+
23
51
  function makeSortable(sortableElem, Sortable) {
24
52
  let options = {};
25
53
  const childSelector = /^(UL|OL)$/.test(sortableElem.tagName) ? 'li' : '*';
@@ -87,6 +115,27 @@ function makeSortable(sortableElem, Sortable) {
87
115
  sortableElem.dispatchEvent(new Event('input', { bubbles: true }));
88
116
  };
89
117
 
118
+ // Record on the capture phase, before Sortable's own press handler changes anything, and
119
+ // restore once the press ends: a plain click never fires Sortable's unchoose.
120
+ const ENDS = ['pointerup', 'pointercancel', 'dragend', 'drop'];
121
+ let recorded = null;
122
+ const restoreSoon = () => {
123
+ const pending = recorded;
124
+ recorded = null;
125
+ pressed.delete(pending);
126
+ ENDS.forEach(type => document.removeEventListener(type, restoreSoon, true));
127
+ if (pending) setTimeout(() => restoreRuntimeAttrs(pending), 0);
128
+ };
129
+ sortableElem.addEventListener('pointerdown', (event) => {
130
+ let item = event.target instanceof Element ? event.target : null;
131
+ while (item && item.parentElement !== sortableElem) item = item.parentElement;
132
+ if (!item || recorded) return;
133
+ recorded = recordRuntimeAttrs(item);
134
+ pressed.add(recorded);
135
+ ENDS.forEach(type => document.addEventListener(type, restoreSoon, true));
136
+ }, true);
137
+ options.onUnchoose = restoreSoon;
138
+
90
139
  Sortable.create(sortableElem, options);
91
140
  }
92
141
 
@@ -99,6 +148,25 @@ async function init() {
99
148
  const mod = await import('../vendor/Sortable.vendor.js');
100
149
  const Sortable = window.Sortable || mod.default;
101
150
 
151
+ // A save taken mid-drag would otherwise write Sortable's classes and attributes into the
152
+ // file. Strip them from the snapshot clone only, so the live drag carries on untouched.
153
+ onSnapshot((root, { original }) => {
154
+ for (const el of root.querySelectorAll("[sortable] .sortable-chosen, [sortable] .sortable-ghost, [sortable] .sortable-drag")) {
155
+ el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
156
+ if (!el.classList.length) el.removeAttribute("class");
157
+ }
158
+ if (!pressed.size) return;
159
+ const before = new Map();
160
+ for (const recording of pressed) for (const [el, values] of recording) before.set(el, values);
161
+ for (const el of root.querySelectorAll("[sortable] *")) {
162
+ const values = before.get(original(el));
163
+ if (!values) continue;
164
+ RUNTIME_ATTRS.forEach((name, i) => {
165
+ if (values[i] === null) el.removeAttribute(name); else el.setAttribute(name, values[i]);
166
+ });
167
+ }
168
+ });
169
+
102
170
  // Set up sortable on page load
103
171
  document.querySelectorAll('[sortable]').forEach(el => makeSortable(el, Sortable));
104
172
 
@@ -58,6 +58,12 @@ export async function upload(file, { onProgress, signal } = {}) {
58
58
  // core host, and embedding is what it should do.
59
59
  return result(false, "This host does not store uploaded files", "skipped", "unsupported");
60
60
  }
61
+ // The host stores files, but not for this caller on this document: the spec withholds the
62
+ // document's upload block by omission, and a client that finds none does not upload. Not
63
+ // "unsupported", because embedding instead would route around the host's own decision.
64
+ if (!meta.document?.upload) {
65
+ return result(false, "You can't add files to this document", "error", "forbidden");
66
+ }
61
67
 
62
68
  // The one local pre-check the spec asks for, and the only reason `maxBytes` is
63
69
  // published: refusing a 40 MB photo here costs nothing, and sending it to be
@@ -10,6 +10,7 @@
10
10
  // flip the text direction.
11
11
  import { style, make, set } from '../lib/hostile-css.js';
12
12
  import { RULES, MEDIA, FONT_SANS, TOKENS } from './bevel.js';
13
+ import { themed, tokenRulesFor, partRules, markParts, floorRules } from './theme-parts.js';
13
14
 
14
15
  export const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
15
16
 
@@ -81,12 +82,13 @@ function protectIcon(svg, size) {
81
82
  }
82
83
  }
83
84
 
84
- export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null } = {}) {
85
+ export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null, theme = null, parts = [], labelParts = [] } = {}) {
85
86
  const v = VARIANT[variant] || VARIANT.default;
86
87
  const size = small ? SIZE.small : SIZE.normal;
87
88
  const reducedMotion = media('(prefers-reduced-motion: reduce)');
88
89
  const forced = media('(forced-colors: active)');
89
90
  const coarse = media('(pointer: coarse)');
91
+ const narrow = media('(max-width: 760px)');
90
92
  const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
91
93
  const pinned = new Map();
92
94
 
@@ -96,6 +98,12 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
96
98
  span.textContent = label;
97
99
  b.append(span);
98
100
 
101
+ const variantName = VARIANT[variant] ? variant : 'default';
102
+ const names = ['button', `button.${variantName}`, ...parts];
103
+ const labelNames = ['buttonLabel', ...labelParts];
104
+ markParts(b, theme, names);
105
+ markParts(span, theme, labelNames);
106
+
99
107
  function applyState() {
100
108
  const rules = [...RESET, 'box-sizing:border-box', ...v.rest, ...size];
101
109
  if (flags.disabled) {
@@ -107,13 +115,25 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
107
115
  if (flags.focusVisible) rules.push(...RULES.focus);
108
116
  if (forced) rules.push(...MEDIA.forcedColors);
109
117
  if (coarse) rules.push(...MEDIA.coarseButton);
118
+ if (narrow) rules.push('min-height:44px');
110
119
  rules.push(...extra);
120
+ if (themed(theme)) {
121
+ const act = { hover: flags.hovered, active: flags.pressed, focus: flags.focusVisible, disabled: flags.disabled };
122
+ rules.push(...tokenRulesFor(theme, `button.${variantName}`, act), ...partRules(theme, names, act));
123
+ if (forced) rules.push(...MEDIA.forcedColors);
124
+ rules.push(...floorRules(rules, 'min-height', narrow ? 44 : coarse ? 40 : 0));
125
+ }
111
126
  for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
112
127
  restyle(b, rules);
113
128
  const labelRules = [...RESET, 'font:inherit', 'color:inherit', ...RULES.buttonLabel];
114
129
  if (flags.disabled) labelRules.push(...RULES.buttonDisabledLabel);
115
130
  else if (flags.pressed) labelRules.push(...(reducedMotion ? MEDIA.reducedMotion : RULES.buttonActiveLabel));
116
131
  labelRules.push(...labelExtra);
132
+ if (themed(theme)) {
133
+ const act = { hover: flags.hovered, active: flags.pressed, focus: flags.focusVisible, disabled: flags.disabled };
134
+ labelRules.push(...partRules(theme, labelNames, act));
135
+ if (flags.pressed && !flags.disabled && reducedMotion) labelRules.push(...MEDIA.reducedMotion);
136
+ }
117
137
  restyle(span, labelRules);
118
138
  onState?.({ ...flags });
119
139
  }
@@ -213,33 +233,67 @@ export function bevelCornerClose({ label = 'Close', onClick = null } = {}) {
213
233
  }
214
234
 
215
235
  // A plain runtime-only box: reset, then only the given rules. For structure inside a
216
- // surface (rows, stacks, backdrops) that carries no material of its own.
217
- export function bevelBox(tag, rules = []) {
218
- return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...rules]));
236
+ // surface (rows, stacks, backdrops) that carries no material of its own. `role` names the
237
+ // token declarations it takes, or null for parts only.
238
+ export function bevelBox(tag, rules = [], { theme = null, parts = [], role = null } = {}) {
239
+ const el = runtime(make(tag, [...RESET, 'box-sizing:border-box', ...rules]));
240
+ if (themed(theme)) {
241
+ if (role) style(el, tokenRulesFor(theme, role, {}));
242
+ const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
243
+ style(el, partRules(theme, names, {}));
244
+ markParts(el, theme, names);
245
+ }
246
+ return el;
219
247
  }
220
248
 
221
- export function bevelSurface(tag, rules = []) {
222
- return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...RULES.surface, `font:14px/1.5 ${FONT_SANS}`, ...rules]));
249
+ // Rebuild a box's whole inline style: what a stateful control does on every change.
250
+ export function restyleBox(el, rules) {
251
+ restyle(el, [...RESET, 'box-sizing:border-box', ...rules]);
223
252
  }
224
253
 
225
- export function bevelWell(rules = []) {
226
- return runtime(make('div', [...RESET, 'display:block', 'box-sizing:border-box', `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`, ...RULES.recess, ...rules]));
254
+ export function bevelSurface(tag, rules = [], { theme = null, parts = [], role = 'surface' } = {}) {
255
+ const el = runtime(make(tag, [...RESET, 'box-sizing:border-box', ...RULES.surface, `font:14px/1.5 ${FONT_SANS}`, ...rules]));
256
+ if (themed(theme)) {
257
+ if (role) style(el, tokenRulesFor(theme, role, {}));
258
+ style(el, partRules(theme, parts, {}));
259
+ markParts(el, theme, parts);
260
+ }
261
+ return el;
227
262
  }
228
263
 
229
- export function bevelText(tag, rules = [], text) {
264
+ export function bevelWell(rules = [], { theme = null, parts = [], role = 'well' } = {}) {
265
+ const el = runtime(make('div', [...RESET, 'display:block', 'box-sizing:border-box', `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`, ...RULES.recess, ...rules]));
266
+ if (themed(theme)) {
267
+ if (role) style(el, tokenRulesFor(theme, role, {}));
268
+ const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
269
+ style(el, partRules(theme, names, {}));
270
+ markParts(el, theme, names);
271
+ }
272
+ return el;
273
+ }
274
+
275
+ export function bevelText(tag, rules = [], text, { theme = null, parts = [], role = 'text' } = {}) {
230
276
  const el = runtime(make(tag, [...RESET, 'font:inherit', `color:${TOKENS.ink}`, ...rules]));
231
277
  if (text != null) el.textContent = text;
278
+ if (themed(theme)) {
279
+ if (role) style(el, tokenRulesFor(theme, role, {}));
280
+ const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
281
+ style(el, partRules(theme, names, {}));
282
+ markParts(el, theme, names);
283
+ }
232
284
  return el;
233
285
  }
234
286
 
235
287
  // A text field in Bevel's input material, on an element that already exists (markup a
236
288
  // dialog wrote) or a new one. Focus and hover live in flags, as on a button. A
237
289
  // textarea keeps the height a person dragged it to across every repaint.
238
- export function paintInput(el, { rules = [] } = {}) {
290
+ export function paintInput(el, { rules = [], theme = null, parts = [] } = {}) {
239
291
  const flags = { hovered: false, focused: false };
240
292
  const forced = media('(forced-colors: active)');
241
293
  const coarse = media('(pointer: coarse)');
242
294
  runtime(el);
295
+ const names = ['input', ...parts];
296
+ markParts(el, theme, names);
243
297
  function applyState() {
244
298
  const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
245
299
  const list = [...RESET, 'box-sizing:border-box', ...RULES.input];
@@ -248,6 +302,12 @@ export function paintInput(el, { rules = [] } = {}) {
248
302
  if (forced) list.push(...MEDIA.forcedColors);
249
303
  list.push(...rules);
250
304
  if (coarse) list.push(...MEDIA.coarseInput);
305
+ if (themed(theme)) {
306
+ const act = { hover: flags.hovered && !el.disabled, focus: flags.focused, disabled: !!el.disabled };
307
+ list.push(...tokenRulesFor(theme, 'input', act), ...partRules(theme, names, act));
308
+ if (forced) list.push(...MEDIA.forcedColors);
309
+ if (coarse) list.push(...floorRules(list, 'font-size', 16));
310
+ }
251
311
  if (height) list.push(`height:${height}`);
252
312
  restyle(el, list);
253
313
  }