@ponchia/ui 0.4.0 → 0.4.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 (44) hide show
  1. package/CHANGELOG.md +128 -10
  2. package/README.md +20 -13
  3. package/behaviors/index.d.ts +3 -1
  4. package/behaviors/index.js +179 -106
  5. package/classes/index.d.ts +40 -0
  6. package/classes/index.js +41 -0
  7. package/classes/vscode.css-custom-data.json +18 -14
  8. package/css/disclosure.css +29 -0
  9. package/css/dots.css +2 -0
  10. package/css/feedback.css +53 -0
  11. package/css/motion.css +88 -0
  12. package/css/overlay.css +52 -1
  13. package/css/report.css +382 -0
  14. package/css/tokens.css +56 -26
  15. package/dist/bronto.css +1 -1
  16. package/dist/css/disclosure.css +1 -1
  17. package/dist/css/dots.css +1 -1
  18. package/dist/css/feedback.css +1 -1
  19. package/dist/css/motion.css +1 -1
  20. package/dist/css/overlay.css +1 -1
  21. package/dist/css/report.css +1 -0
  22. package/dist/css/tokens.css +1 -1
  23. package/docs/adr/0001-color-system.md +26 -27
  24. package/docs/adr/0002-scope-and-2026-baseline.md +104 -0
  25. package/docs/adr/0003-theme-model.md +94 -0
  26. package/docs/contrast.md +42 -42
  27. package/docs/reference.md +112 -15
  28. package/docs/reporting.md +270 -0
  29. package/docs/stability.md +37 -0
  30. package/docs/theming.md +18 -6
  31. package/docs/usage.md +21 -3
  32. package/llms.txt +61 -4
  33. package/package.json +28 -3
  34. package/qwik/index.d.ts +55 -0
  35. package/qwik/index.js +129 -0
  36. package/react/index.d.ts +35 -14
  37. package/react/index.js +22 -5
  38. package/solid/index.d.ts +35 -14
  39. package/solid/index.js +21 -3
  40. package/tokens/index.d.ts +3 -3
  41. package/tokens/index.js +16 -14
  42. package/tokens/index.json +32 -28
  43. package/tokens/resolved.json +34 -32
  44. package/tokens/tokens.dtcg.json +22 -14
@@ -54,6 +54,24 @@ function bindOnce(target, key, add) {
54
54
  return cleanup;
55
55
  }
56
56
 
57
+ function byIdInHost(host, id) {
58
+ if (!id) return null;
59
+ if (host === document) return document.getElementById(id);
60
+ if (host.id === id) return host;
61
+ return (
62
+ Array.from(host.querySelectorAll?.('[id]') || []).find((el) => el.id === id) ||
63
+ document.getElementById(id)
64
+ );
65
+ }
66
+
67
+ function closestSafe(el, selector) {
68
+ try {
69
+ return el.closest(selector);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
57
75
  /**
58
76
  * Apply the persisted theme to <html data-theme>. Call as early as
59
77
  * possible (an inline module in <head>) to avoid a flash before the
@@ -145,7 +163,7 @@ export function dismissible({ root } = {}) {
145
163
  const btn = e.target.closest('[data-bronto-dismiss]');
146
164
  if (!btn || !host.contains(btn)) return;
147
165
  const sel = btn.getAttribute('data-bronto-dismiss');
148
- const target = sel ? btn.closest(sel) : btn.closest('[data-bronto-dismissible]');
166
+ const target = sel ? closestSafe(btn, sel) : btn.closest('[data-bronto-dismissible]');
149
167
  if (!target) return;
150
168
  const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
151
169
  if (target.dispatchEvent(ev)) target.remove();
@@ -264,81 +282,73 @@ export function initTabs({ root } = {}) {
264
282
  * `close` event, so keyboard/SR users are never dropped at `<body>`.
265
283
  * SSR-safe and idempotent; returns cleanup.
266
284
  *
267
- * `root` scopes which triggers are delegated (default `document`); the
268
- * dialog itself is still resolved by id document-wide, because a modal
269
- * <dialog> is promoted to the top layer and is inherently document-global
270
- * (same model as `initThemeToggle`, where `root` scopes controls but the
271
- * theme applies to <html>).
285
+ * `root` scopes delegated triggers (default `document`). Controlled targets are
286
+ * resolved root-first, then document-wide, so scoped islands win duplicate-id
287
+ * conflicts without breaking body/portal-mounted overlays.
272
288
  */
273
289
  export function initDialog({ root } = {}) {
274
290
  if (!hasDom()) return noop;
275
291
  const host = root || document;
292
+ const managedDialogs = new WeakSet();
293
+ const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
294
+
295
+ const openFrom = (opener) => {
296
+ const dlg = byIdInHost(host, opener.getAttribute('data-bronto-open'));
297
+ if (!dlg || typeof dlg.showModal !== 'function' || dlg.open) return;
298
+ managedDialogs.add(dlg);
299
+ dlg.addEventListener(
300
+ 'close',
301
+ () => {
302
+ if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
303
+ },
304
+ { once: true },
305
+ );
306
+ dlg.showModal();
307
+ };
308
+
309
+ const closeFrom = (closer) => {
310
+ const dlg = closer.closest('dialog');
311
+ if (dlg && dlg.open && canManageDialog(dlg, closer)) dlg.close();
312
+ };
313
+
314
+ const lightDismiss = (dlg) => {
315
+ if (
316
+ dlg.tagName === 'DIALOG' &&
317
+ dlg.open &&
318
+ dlg.hasAttribute('data-bronto-dialog-light') &&
319
+ canManageDialog(dlg, dlg)
320
+ ) {
321
+ dlg.close();
322
+ }
323
+ };
324
+
276
325
  const onClick = (e) => {
277
326
  const opener = e.target.closest('[data-bronto-open]');
278
327
  if (opener && host.contains(opener)) {
279
- const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
280
- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
281
- dlg.addEventListener(
282
- 'close',
283
- () => {
284
- if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
285
- },
286
- { once: true },
287
- );
288
- dlg.showModal();
289
- }
328
+ openFrom(opener);
290
329
  return;
291
330
  }
292
331
  const closer = e.target.closest('[data-bronto-close]');
293
- if (closer && host.contains(closer)) {
294
- const dlg = closer.closest('dialog');
295
- if (dlg && dlg.open) dlg.close();
332
+ if (closer) {
333
+ closeFrom(closer);
296
334
  return;
297
335
  }
298
336
  // Light-dismiss: a click whose target is the <dialog> itself is the
299
337
  // backdrop (content sits in child elements).
300
- const dlg = e.target;
301
- if (
302
- dlg.tagName === 'DIALOG' &&
303
- dlg.open &&
304
- dlg.hasAttribute('data-bronto-dialog-light') &&
305
- host.contains(dlg)
306
- ) {
307
- dlg.close();
308
- }
338
+ lightDismiss(e.target);
309
339
  };
310
340
  return bindOnce(host, 'dialog', () => {
311
- host.addEventListener('click', onClick);
312
- return () => host.removeEventListener('click', onClick);
341
+ document.addEventListener('click', onClick);
342
+ return () => document.removeEventListener('click', onClick);
313
343
  });
314
344
  }
315
345
 
316
- /**
317
- * Push a transient toast into a shared, screen-anchored stack. The stack
318
- * is the `aria-live="polite"` region: it is created once, appended to
319
- * <body>, and **kept resident even when empty** so the live region is
320
- * always present before content is inserted (a freshly created region
321
- * that receives its first child in the same tick is not reliably
322
- * announced by VoiceOver/NVDA). On first creation the empty region is
323
- * inserted and the toast is appended on the next frame for the same
324
- * reason. `tone` is accent/success/warning/danger/info; `title` is an
325
- * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
326
- * it until dismissed). Returns a function that dismisses the toast
327
- * early. SSR-safe (no-op).
328
- */
329
- export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
330
- if (!hasDom()) return noop;
331
- // Errors must interrupt: danger toasts (or an explicit `assertive`)
332
- // go to a SEPARATE assertive region so they announce immediately,
333
- // while status toasts stay polite. Two regions — not a per-item
334
- // role=alert nested in a polite parent — avoids the double
335
- // announcement that nesting causes in some screen readers.
336
- const isAssertive = assertive ?? tone === 'danger';
346
+ function toastStack(isAssertive) {
337
347
  const stackSel = isAssertive
338
348
  ? '.ui-toast-stack--assertive'
339
349
  : '.ui-toast-stack:not(.ui-toast-stack--assertive)';
340
350
  let stack = document.querySelector(stackSel);
341
- const freshStack = !stack;
351
+ const fresh = !stack;
342
352
  if (!stack) {
343
353
  stack = document.createElement('div');
344
354
  stack.className = isAssertive ? 'ui-toast-stack ui-toast-stack--assertive' : 'ui-toast-stack';
@@ -346,6 +356,26 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
346
356
  if (isAssertive) stack.setAttribute('role', 'alert');
347
357
  document.body.appendChild(stack);
348
358
  }
359
+ return { stack, fresh };
360
+ }
361
+
362
+ function enqueueToast(place, freshStack) {
363
+ const canDefer = typeof requestAnimationFrame === 'function';
364
+ if (freshStack && canDefer) {
365
+ toastQueue.push(place);
366
+ toastFlushScheduled = true;
367
+ requestAnimationFrame(() => {
368
+ toastFlushScheduled = false;
369
+ for (const fn of toastQueue.splice(0)) fn();
370
+ });
371
+ } else if (toastFlushScheduled) {
372
+ toastQueue.push(place);
373
+ } else {
374
+ place();
375
+ }
376
+ }
377
+
378
+ function toastElement(message, { tone, title }) {
349
379
  const el = document.createElement('div');
350
380
  el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
351
381
  // No per-item role: the stack itself is the live region; a nested
@@ -359,6 +389,72 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
359
389
  const body = document.createElement('div');
360
390
  body.textContent = message;
361
391
  el.appendChild(body);
392
+ return el;
393
+ }
394
+
395
+ // Remove a toast, animating its exit when — and only when — a transition
396
+ // is actually in effect. Detached nodes, reduced-motion, and the no-CSS
397
+ // test/SSR env all resolve to instant removal, so the dismiss contract
398
+ // (toast gone now, the aria-live stack stays resident) is unchanged there;
399
+ // a real browser with motion gets the CSS `.is-leaving` fade-out, with a
400
+ // timeout fallback so an interrupted/never-firing transitionend can't strand
401
+ // a toast in the live region.
402
+ function removeToast(el) {
403
+ const reduce =
404
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
405
+ const cs =
406
+ !reduce && el.isConnected && typeof getComputedStyle === 'function'
407
+ ? getComputedStyle(el)
408
+ : null;
409
+ const dur = cs ? parseFloat(cs.transitionDuration) || 0 : 0;
410
+ if (dur <= 0) {
411
+ el.remove();
412
+ return;
413
+ }
414
+ el.classList.add('is-leaving');
415
+ let done = false;
416
+ const finish = () => {
417
+ if (done) return;
418
+ done = true;
419
+ el.remove();
420
+ };
421
+ el.addEventListener('transitionend', finish, { once: true });
422
+ const timer = setTimeout(finish, dur * 1000 + 120);
423
+ timer?.unref?.(); // don't keep a Node test process alive
424
+ }
425
+
426
+ function addToastClose(el, dismiss) {
427
+ const close = document.createElement('button');
428
+ close.type = 'button';
429
+ close.className = 'ui-toast__close';
430
+ close.setAttribute('aria-label', 'Dismiss');
431
+ close.addEventListener('click', dismiss);
432
+ el.appendChild(close);
433
+ }
434
+
435
+ /**
436
+ * Push a transient toast into a shared, screen-anchored stack. The stack
437
+ * is the `aria-live="polite"` region: it is created once, appended to
438
+ * <body>, and **kept resident even when empty** so the live region is
439
+ * always present before content is inserted (a freshly created region
440
+ * that receives its first child in the same tick is not reliably
441
+ * announced by VoiceOver/NVDA). On first creation the empty region is
442
+ * inserted and the toast is appended on the next frame for the same
443
+ * reason. `tone` is accent/success/warning/danger/info; `title` is an
444
+ * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
445
+ * it until dismissed). Returns a function that dismisses the toast
446
+ * early. SSR-safe (no-op).
447
+ */
448
+ export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
449
+ if (!hasDom()) return noop;
450
+ // Errors must interrupt: danger toasts (or an explicit `assertive`)
451
+ // go to a SEPARATE assertive region so they announce immediately,
452
+ // while status toasts stay polite. Two regions — not a per-item
453
+ // role=alert nested in a polite parent — avoids the double
454
+ // announcement that nesting causes in some screen readers.
455
+ const isAssertive = assertive ?? tone === 'danger';
456
+ const { stack, fresh: freshStack } = toastStack(isAssertive);
457
+ const el = toastElement(message, { tone, title });
362
458
  // Append after a frame the *first* time so the empty live region is
363
459
  // observed by AT before its first child arrives; once the region has
364
460
  // been observed, later toasts append synchronously.
@@ -369,28 +465,14 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
369
465
  const place = () => {
370
466
  if (!dismissed) stack.appendChild(el);
371
467
  };
372
- const canDefer = typeof requestAnimationFrame === 'function';
373
- if (freshStack && canDefer) {
374
- toastQueue.push(place);
375
- toastFlushScheduled = true;
376
- requestAnimationFrame(() => {
377
- toastFlushScheduled = false;
378
- for (const fn of toastQueue.splice(0)) fn();
379
- });
380
- } else if (toastFlushScheduled) {
381
- // A first-frame deferral is in flight — queue behind it so FIFO
382
- // order holds and the region still isn't populated synchronously.
383
- toastQueue.push(place);
384
- } else {
385
- place();
386
- }
468
+ enqueueToast(place, freshStack);
387
469
 
388
470
  let timer;
389
471
  const dismiss = () => {
390
472
  if (dismissed) return;
391
473
  dismissed = true;
392
474
  if (timer) clearTimeout(timer);
393
- el.remove();
475
+ removeToast(el);
394
476
  // The stack is a persistent live region — never removed on drain, so
395
477
  // the next toast does not recreate (and thus mis-announce) it.
396
478
  };
@@ -398,14 +480,7 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
398
480
  // it gets a dismiss affordance by default; any toast can opt in via
399
481
  // `closable`. The button carries no text node (glyph is a CSS
400
482
  // ::before) so the toast's announced/textContent stays the message.
401
- if (closable ?? duration === 0) {
402
- const close = document.createElement('button');
403
- close.type = 'button';
404
- close.className = 'ui-toast__close';
405
- close.setAttribute('aria-label', 'Dismiss');
406
- close.addEventListener('click', dismiss);
407
- el.appendChild(close);
408
- }
483
+ if (closable ?? duration === 0) addToastClose(el, dismiss);
409
484
  if (duration > 0) timer = setTimeout(dismiss, duration);
410
485
  return dismiss;
411
486
  }
@@ -422,7 +497,7 @@ export function initDisclosure({ root } = {}) {
422
497
  const trigger = e.target.closest('[data-bronto-disclosure]');
423
498
  if (!trigger || !host.contains(trigger)) return;
424
499
  const id = trigger.getAttribute('aria-controls');
425
- const panel = id && document.getElementById(id);
500
+ const panel = byIdInHost(host, id);
426
501
  if (!panel) return;
427
502
  const open = trigger.getAttribute('aria-expanded') === 'true';
428
503
  trigger.setAttribute('aria-expanded', String(!open));
@@ -755,6 +830,24 @@ export function initCombobox({ root } = {}) {
755
830
  active = options.indexOf(vis[next]);
756
831
  setActive(options[active]);
757
832
  };
833
+ const activateEdge = (which) => {
834
+ if (list.hidden) return false;
835
+ const v = visible();
836
+ if (!v.length) return true;
837
+ active = options.indexOf(which === 'first' ? v[0] : v[v.length - 1]);
838
+ setActive(options[active]);
839
+ return true;
840
+ };
841
+ const selectActive = () => {
842
+ if (list.hidden || active < 0 || options[active].hidden) return false;
843
+ select(options[active]);
844
+ return true;
845
+ };
846
+ const closeIfOpen = () => {
847
+ if (list.hidden) return false;
848
+ close();
849
+ return true;
850
+ };
758
851
 
759
852
  const onInput = () => filter();
760
853
  const onKey = (e) => {
@@ -768,36 +861,16 @@ export function initCombobox({ root } = {}) {
768
861
  move(-1);
769
862
  break;
770
863
  case 'Home':
771
- if (!list.hidden) {
772
- e.preventDefault();
773
- const v = visible();
774
- if (v.length) {
775
- active = options.indexOf(v[0]);
776
- setActive(options[active]);
777
- }
778
- }
864
+ if (activateEdge('first')) e.preventDefault();
779
865
  break;
780
866
  case 'End':
781
- if (!list.hidden) {
782
- e.preventDefault();
783
- const v = visible();
784
- if (v.length) {
785
- active = options.indexOf(v[v.length - 1]);
786
- setActive(options[active]);
787
- }
788
- }
867
+ if (activateEdge('last')) e.preventDefault();
789
868
  break;
790
869
  case 'Enter':
791
- if (!list.hidden && active >= 0 && !options[active].hidden) {
792
- e.preventDefault();
793
- select(options[active]);
794
- }
870
+ if (selectActive()) e.preventDefault();
795
871
  break;
796
872
  case 'Escape':
797
- if (!list.hidden) {
798
- e.preventDefault();
799
- close();
800
- }
873
+ if (closeIfOpen()) e.preventDefault();
801
874
  break;
802
875
  case 'Tab':
803
876
  close();
@@ -904,8 +977,8 @@ export function initPopover({ root } = {}) {
904
977
 
905
978
  const onClick = (e) => {
906
979
  const trigger = e.target.closest?.('[data-bronto-popover]');
907
- if (trigger) {
908
- const panel = document.getElementById(trigger.getAttribute('data-bronto-popover'));
980
+ if (trigger && host.contains(trigger)) {
981
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
909
982
  if (!panel) return;
910
983
  e.preventDefault();
911
984
  if (openPanel === panel) close();
@@ -926,12 +999,12 @@ export function initPopover({ root } = {}) {
926
999
  };
927
1000
 
928
1001
  return bindOnce(host, 'popover', () => {
929
- host.addEventListener('click', onClick);
1002
+ document.addEventListener('click', onClick);
930
1003
  document.addEventListener('keydown', onKey);
931
1004
  view?.addEventListener('scroll', onReflow, true);
932
1005
  view?.addEventListener('resize', onReflow);
933
1006
  return () => {
934
- host.removeEventListener('click', onClick);
1007
+ document.removeEventListener('click', onClick);
935
1008
  document.removeEventListener('keydown', onKey);
936
1009
  view?.removeEventListener('scroll', onReflow, true);
937
1010
  view?.removeEventListener('resize', onReflow);
@@ -232,6 +232,43 @@ export declare const cls: {
232
232
  readonly timeline: 'ui-timeline';
233
233
  readonly timelineItem: 'ui-timeline__item';
234
234
  readonly timelineTime: 'ui-timeline__time';
235
+ readonly report: 'ui-report';
236
+ readonly reportCompact: 'ui-report--compact';
237
+ readonly reportNumbered: 'ui-report--numbered';
238
+ readonly reportCover: 'ui-report__cover';
239
+ readonly reportCoverCompact: 'ui-report__cover--compact';
240
+ readonly reportHeader: 'ui-report__header';
241
+ readonly reportTitle: 'ui-report__title';
242
+ readonly reportSubtitle: 'ui-report__subtitle';
243
+ readonly reportMeta: 'ui-report__meta';
244
+ readonly reportToc: 'ui-report__toc';
245
+ readonly reportSummary: 'ui-report__summary';
246
+ readonly reportSection: 'ui-report__section';
247
+ readonly reportSectionUnnumbered: 'ui-report__section--unnumbered';
248
+ readonly reportSectionHead: 'ui-report__section-head';
249
+ readonly reportFinding: 'ui-report__finding';
250
+ readonly reportEvidence: 'ui-report__evidence';
251
+ readonly reportFigure: 'ui-report__figure';
252
+ readonly reportCaption: 'ui-report__caption';
253
+ readonly reportSources: 'ui-report__sources';
254
+ readonly reportAppendix: 'ui-report__appendix';
255
+ readonly reportFootnotes: 'ui-report__footnotes';
256
+ readonly chart: 'ui-chart';
257
+ readonly chartLegend: 'ui-chart__legend';
258
+ readonly chartSwatch: 'ui-chart__swatch';
259
+ readonly chartCaption: 'ui-chart__caption';
260
+ readonly chartPlot: 'ui-chart__plot';
261
+ readonly chartBar: 'ui-chart__bar';
262
+ readonly chartLabel: 'ui-chart__label';
263
+ readonly chartTrack: 'ui-chart__track';
264
+ readonly chartFill: 'ui-chart__fill';
265
+ readonly chartFallback: 'ui-chart__fallback';
266
+ readonly printOnly: 'ui-print-only';
267
+ readonly screenOnly: 'ui-screen-only';
268
+ readonly breakBefore: 'ui-break-before';
269
+ readonly breakAfter: 'ui-break-after';
270
+ readonly keep: 'ui-keep';
271
+ readonly printExact: 'ui-print-exact';
235
272
  readonly kbd: 'ui-kbd';
236
273
  readonly display: 'ui-display';
237
274
  readonly mono: 'ui-mono';
@@ -248,6 +285,9 @@ export declare const cls: {
248
285
  readonly animateFade: 'ui-animate-fade';
249
286
  readonly animateDot: 'ui-animate-dot';
250
287
  readonly animateMatrix: 'ui-animate-matrix';
288
+ readonly scrollProgress: 'ui-scroll-progress';
289
+ readonly scrollReveal: 'ui-scroll-reveal';
290
+ readonly vt: 'ui-vt';
251
291
  readonly appShell: 'ui-app-shell';
252
292
  readonly appShellFull: 'ui-app-shell--full';
253
293
  readonly appRail: 'ui-app-rail';
package/classes/index.js CHANGED
@@ -245,6 +245,44 @@ export const cls = Object.freeze({
245
245
  timeline: 'ui-timeline',
246
246
  timelineItem: 'ui-timeline__item',
247
247
  timelineTime: 'ui-timeline__time',
248
+ // report kit (opt-in css/report.css)
249
+ report: 'ui-report',
250
+ reportCompact: 'ui-report--compact',
251
+ reportNumbered: 'ui-report--numbered',
252
+ reportCover: 'ui-report__cover',
253
+ reportCoverCompact: 'ui-report__cover--compact',
254
+ reportHeader: 'ui-report__header',
255
+ reportTitle: 'ui-report__title',
256
+ reportSubtitle: 'ui-report__subtitle',
257
+ reportMeta: 'ui-report__meta',
258
+ reportToc: 'ui-report__toc',
259
+ reportSummary: 'ui-report__summary',
260
+ reportSection: 'ui-report__section',
261
+ reportSectionUnnumbered: 'ui-report__section--unnumbered',
262
+ reportSectionHead: 'ui-report__section-head',
263
+ reportFinding: 'ui-report__finding',
264
+ reportEvidence: 'ui-report__evidence',
265
+ reportFigure: 'ui-report__figure',
266
+ reportCaption: 'ui-report__caption',
267
+ reportSources: 'ui-report__sources',
268
+ reportAppendix: 'ui-report__appendix',
269
+ reportFootnotes: 'ui-report__footnotes',
270
+ chart: 'ui-chart',
271
+ chartLegend: 'ui-chart__legend',
272
+ chartSwatch: 'ui-chart__swatch',
273
+ chartCaption: 'ui-chart__caption',
274
+ chartPlot: 'ui-chart__plot',
275
+ chartBar: 'ui-chart__bar',
276
+ chartLabel: 'ui-chart__label',
277
+ chartTrack: 'ui-chart__track',
278
+ chartFill: 'ui-chart__fill',
279
+ chartFallback: 'ui-chart__fallback',
280
+ printOnly: 'ui-print-only',
281
+ screenOnly: 'ui-screen-only',
282
+ breakBefore: 'ui-break-before',
283
+ breakAfter: 'ui-break-after',
284
+ keep: 'ui-keep',
285
+ printExact: 'ui-print-exact',
248
286
  kbd: 'ui-kbd',
249
287
  display: 'ui-display',
250
288
  mono: 'ui-mono',
@@ -262,6 +300,9 @@ export const cls = Object.freeze({
262
300
  animateFade: 'ui-animate-fade',
263
301
  animateDot: 'ui-animate-dot',
264
302
  animateMatrix: 'ui-animate-matrix',
303
+ scrollProgress: 'ui-scroll-progress',
304
+ scrollReveal: 'ui-scroll-reveal',
305
+ vt: 'ui-vt',
265
306
  // admin shell (was the legacy .app-* vocabulary; promoted in 0.3.0)
266
307
  appShell: 'ui-app-shell',
267
308
  appShellFull: 'ui-app-shell--full',
@@ -7,19 +7,19 @@
7
7
  },
8
8
  {
9
9
  "name": "--accent-1",
10
- "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 8%, var(--bg))`"
10
+ "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 8%, var(--accent-ramp-end))`"
11
11
  },
12
12
  {
13
13
  "name": "--accent-2",
14
- "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 16%, var(--bg))`"
14
+ "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 16%, var(--accent-ramp-end))`"
15
15
  },
16
16
  {
17
17
  "name": "--accent-3",
18
- "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 32%, var(--bg))`"
18
+ "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 32%, var(--accent-ramp-end))`"
19
19
  },
20
20
  {
21
21
  "name": "--accent-4",
22
- "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 60%, var(--bg))`"
22
+ "description": "Global scale token. Value: `color-mix(in oklch, var(--accent) 60%, var(--accent-ramp-end))`"
23
23
  },
24
24
  {
25
25
  "name": "--accent-5",
@@ -29,6 +29,10 @@
29
29
  "name": "--accent-6",
30
30
  "description": "Global scale token. Value: `var(--accent-strong)`"
31
31
  },
32
+ {
33
+ "name": "--accent-ramp-end",
34
+ "description": "Theme token. Light: `#ffffff` · Dark: `#000000`"
35
+ },
32
36
  {
33
37
  "name": "--accent-soft",
34
38
  "description": "Theme token. Light: `color-mix(in srgb, var(--accent) 10%, transparent)` · Dark: `color-mix(in srgb, var(--accent) 14%, transparent)`"
@@ -43,7 +47,7 @@
43
47
  },
44
48
  {
45
49
  "name": "--bg",
46
- "description": "Theme token. Light: `#f4f4f2` · Dark: `#000000`"
50
+ "description": "Theme token. Light: `#f4f4f2` · Dark: `#121212`"
47
51
  },
48
52
  {
49
53
  "name": "--bg-accent",
@@ -51,7 +55,7 @@
51
55
  },
52
56
  {
53
57
  "name": "--bg-elevated",
54
- "description": "Theme token. Light: `#fbfbfa` · Dark: `#0a0a0a`"
58
+ "description": "Theme token. Light: `#fbfbfa` · Dark: `#181818`"
55
59
  },
56
60
  {
57
61
  "name": "--border",
@@ -199,11 +203,11 @@
199
203
  },
200
204
  {
201
205
  "name": "--line",
202
- "description": "Theme token. Light: `#d8d8d4` · Dark: `#2a2a2a`"
206
+ "description": "Theme token. Light: `#d8d8d4` · Dark: `#383838`"
203
207
  },
204
208
  {
205
209
  "name": "--line-strong",
206
- "description": "Theme token. Light: `#a8a8a2` · Dark: `#444444`"
210
+ "description": "Theme token. Light: `#a8a8a2` · Dark: `#555555`"
207
211
  },
208
212
  {
209
213
  "name": "--mono",
@@ -211,15 +215,15 @@
211
215
  },
212
216
  {
213
217
  "name": "--panel",
214
- "description": "Theme token. Light: `#ffffff` · Dark: `#0c0c0c`"
218
+ "description": "Theme token. Light: `#ffffff` · Dark: `#1c1c1c`"
215
219
  },
216
220
  {
217
221
  "name": "--panel-soft",
218
- "description": "Theme token. Light: `#ececea` · Dark: `#1a1a1a`"
222
+ "description": "Theme token. Light: `#ececea` · Dark: `#242424`"
219
223
  },
220
224
  {
221
225
  "name": "--panel-strong",
222
- "description": "Theme token. Light: `#ffffff` · Dark: `#141414`"
226
+ "description": "Theme token. Light: `#ffffff` · Dark: `#222222`"
223
227
  },
224
228
  {
225
229
  "name": "--radius-lg",
@@ -327,7 +331,7 @@
327
331
  },
328
332
  {
329
333
  "name": "--text",
330
- "description": "Theme token. Light: `#0a0a0a` · Dark: `#f2f2f2`"
334
+ "description": "Theme token. Light: `#0a0a0a` · Dark: `#e6e6e6`"
331
335
  },
332
336
  {
333
337
  "name": "--text-2xs",
@@ -339,7 +343,7 @@
339
343
  },
340
344
  {
341
345
  "name": "--text-dim",
342
- "description": "Theme token. Light: `#686863` · Dark: `#858585`"
346
+ "description": "Theme token. Light: `#686863` · Dark: `#a0a0a0`"
343
347
  },
344
348
  {
345
349
  "name": "--text-lg",
@@ -351,7 +355,7 @@
351
355
  },
352
356
  {
353
357
  "name": "--text-soft",
354
- "description": "Theme token. Light: `#353533` · Dark: `#c4c4c4`"
358
+ "description": "Theme token. Light: `#353533` · Dark: `#c8c8c8`"
355
359
  },
356
360
  {
357
361
  "name": "--text-xl",
@@ -128,6 +128,35 @@
128
128
  margin-block-start: 0;
129
129
  }
130
130
 
131
+ /* Auto-height open/close for the native <details> accordion: animate the
132
+ content box from 0 to its intrinsic height. `interpolate-size:
133
+ allow-keywords` makes `auto` interpolable; `::details-content` exposes
134
+ the otherwise-anonymous content box; `content-visibility ... allow-discrete`
135
+ keeps the content laid out (and the dot marker's own rotation in sync)
136
+ through the close. Strict progressive enhancement — gated on
137
+ `@supports selector(::details-content)` (Chrome 131+/Safari 18.4+; Firefox
138
+ not yet) so engines without it just snap open/closed exactly as before —
139
+ and on `prefers-reduced-motion: no-preference`. */
140
+ @supports selector(::details-content) {
141
+ @media (prefers-reduced-motion: no-preference) {
142
+ .ui-accordion {
143
+ interpolate-size: allow-keywords;
144
+ }
145
+
146
+ .ui-accordion__item::details-content {
147
+ block-size: 0;
148
+ overflow: hidden;
149
+ transition:
150
+ block-size var(--duration-base) var(--ease-out),
151
+ content-visibility var(--duration-base) allow-discrete;
152
+ }
153
+
154
+ .ui-accordion__item[open]::details-content {
155
+ block-size: auto;
156
+ }
157
+ }
158
+ }
159
+
131
160
  /* --- Segmented control — CSS-only radio group --- */
132
161
 
133
162
  .ui-segmented {
package/css/dots.css CHANGED
@@ -129,6 +129,8 @@
129
129
  block-size: var(--icon-size, 1em);
130
130
  background: currentcolor;
131
131
  vertical-align: -0.125em;
132
+ /* stylelint-disable-next-line property-no-vendor-prefix -- Safari still needs the prefixed mask property. */
133
+ -webkit-mask: var(--icon-mask) center / contain no-repeat;
132
134
  mask: var(--icon-mask) center / contain no-repeat;
133
135
  }
134
136