@apliteni/apliteni-ui 0.129.0 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  // the published name docs/components.md documents.
6
6
  // why: docs/components.md#the-page-shell
7
7
  import { esc } from './index.js';
8
+ import { avatarInitials } from '../logic/avatar.js';
8
9
 
9
10
  // nav.js item objects. Labels are raw text — every nav primitive escapes, so a
10
11
  // pre-escaped `&` would come out as `&`. `key` means credentials;
@@ -38,10 +39,6 @@ export const accountMenuNav = () => ACCOUNT_NAV.map(toMenuTuple);
38
39
  // beside ACCOUNT_NAV for the same reason ACCOUNT_NAV lives here: both files
39
40
  // need it and shell.js already imports topbar.js.
40
41
  //
41
- // The display name is what a reader recognises, so it wins; the address is the
42
- // fallback. `?` is what nobody at all comes out as.
43
- export const initials = (name, email) => {
44
- const from = String(name ?? '').trim() || String(email ?? '').split('@')[0] || '';
45
- const parts = from.split(/[\s._-]+/).filter(Boolean).map((w) => w[0]);
46
- return (parts.slice(0, 2).join('') || '?').toUpperCase();
47
- };
42
+ // The displayed name alone supplies the letters. The part draws its user glyph
43
+ // when no name is shown.
44
+ export const initials = (name) => avatarInitials(name);
@@ -0,0 +1,53 @@
1
+ import { esc, icon } from './index.js';
2
+ import { safeUrl } from '../html.js';
3
+ import { avatarHue, avatarInitials } from '../logic/avatar.js';
4
+ export { avatarHue, avatarInitials, AVATAR_HUES } from '../logic/avatar.js';
5
+
6
+ // Avatar — a person's round mark. The letters come from `name`, the hue from a hash of
7
+ // `key`, and a photo covers the letter. A photo that fails to load removes itself, so the
8
+ // letter shows and not the browser's broken-image glyph.
9
+ // With no name the mark is the `user` glyph; `empty` is a dashed ring for "no one yet".
10
+ // It is decoration beside the name it stands for, so it is hidden from assistive
11
+ // technology, and it has no cursor, hover or focus of its own.
12
+ // why: docs/components.md#avatar
13
+ const cx = (...a) => a.filter(Boolean).join(' ');
14
+ const wiredDocs = new WeakSet();
15
+
16
+ function wireAvatarPhotos(doc) {
17
+ if (!doc || wiredDocs.has(doc)) return;
18
+ wiredDocs.add(doc);
19
+ doc.querySelectorAll('.ui-avatar > img[data-avatar-photo]').forEach((img) => {
20
+ if (!img.complete) return;
21
+ if (img.naturalWidth > 0) img.classList.add('is-loaded');
22
+ else img.remove();
23
+ });
24
+ doc.addEventListener('load', (event) => {
25
+ if (event.target?.matches?.('.ui-avatar > img[data-avatar-photo]')) event.target.classList.add('is-loaded');
26
+ }, true);
27
+ doc.addEventListener('error', (event) => {
28
+ if (event.target?.matches?.('.ui-avatar > img[data-avatar-photo]')) event.target.remove();
29
+ }, true);
30
+ }
31
+
32
+ if (typeof document !== 'undefined') wireAvatarPhotos(document);
33
+
34
+ /**
35
+ * @param {object} [o]
36
+ * @param {string|number} [o.key] the app's stable id for the person; picks the hue
37
+ * @param {string} [o.name] the person's name; empty draws the `user` glyph
38
+ * @param {string} [o.initials] the letters to draw, when the caller has already chosen them
39
+ * @param {string} [o.src] a photo URL; the letters show if it fails to load
40
+ * @param {24|32} [o.size] the mark's size in px
41
+ * @param {boolean} [o.empty] a dashed ring in place of a person
42
+ * @returns {string} html
43
+ */
44
+ export function avatar({ key, name, initials, src, size = 24, empty = false } = {}) {
45
+ if (typeof document !== 'undefined') wireAvatarPhotos(document);
46
+ const base = ['ui-avatar', size === 32 && 'ui-avatar--32'];
47
+ if (empty) return `<span class="${esc(cx(...base, 'ui-avatar--empty'))}" aria-hidden="true"></span>`;
48
+ const letters = initials || avatarInitials(name);
49
+ if (!letters) return `<span class="${esc(cx(...base, 'ui-avatar--anon'))}" aria-hidden="true">${icon('user')}</span>`;
50
+ const hue = avatarHue(key ?? name ?? letters);
51
+ const img = src ? `<img data-avatar-photo src="${esc(safeUrl(src))}" alt="" loading="lazy">` : '';
52
+ return `<span class="${esc(cx(...base, `ui-avatar--h${hue}`))}" data-initials="${letters.length}" aria-hidden="true">${esc(letters)}${img}</span>`;
53
+ }
@@ -16,6 +16,7 @@ export { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdo
16
16
  // wireDropdown(container); // or let wireTopbar() do it
17
17
  import { esc, icon } from './index.js';
18
18
  import { safeUrl } from '../html.js';
19
+ import { avatar } from './avatar.js';
19
20
  // The fade's own length, read off the stylesheet rather than copied. why: src/motion.js
20
21
  import { transitionMs } from '../motion.js';
21
22
  const cx = (...a) => a.filter(Boolean).join(' ');
@@ -39,7 +40,7 @@ function ddItem(it, listbox, ext) {
39
40
  const role = listbox ? 'option' : 'menuitem';
40
41
  const asLink = !!it.href && !disabled && !listbox;
41
42
  const tag = asLink ? 'a' : 'div';
42
- const lead = it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
43
+ const lead = it.avatar ? avatar(it.avatar) : it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
43
44
  const desc = it.description ? `<span class="ui-dropdown__desc">${esc(it.description)}</span>` : '';
44
45
  const main = `<span class="ui-dropdown__main"><span class="ui-dropdown__label">${esc(it.label)}</span>${desc}</span>`;
45
46
  const badge = ddBadge(it.badge);
@@ -87,14 +88,25 @@ const ddIsRow = (it) => it && it !== '---' && !it.separator;
87
88
 
88
89
  let _ddSeq = 0;
89
90
 
90
- function ddSearchContext(search, id) {
91
+ // "Search 214 people": the size of the whole set, in the placeholder and in the field's
92
+ // name. `count: true` counts the rows, less a "No one" row, which is not a person.
93
+ // why: docs/components.md#a-dropdown-with-a-search-field
94
+ function ddCount(count, rows) {
95
+ if (count == null || count === false) return null;
96
+ const n = count === true ? rows.filter((it) => ddIsRow(it) && !it.avatar?.empty).length : Number(count);
97
+ return Number.isFinite(n) && n >= 0 ? n : null;
98
+ }
99
+
100
+ function ddSearchContext(search, id, rows) {
91
101
  const o = search === true ? {} : search;
102
+ const n = ddCount(o.count, rows);
103
+ const counted = n == null ? null : `Search ${n} ${o.noun || 'people'}`;
92
104
  return {
93
105
  base: id || `ui-dd-${++_ddSeq}`,
94
106
  n: 0,
95
107
  q: o.query || '',
96
- label: o.label,
97
- placeholder: o.placeholder || 'Search',
108
+ label: o.label || counted,
109
+ placeholder: o.placeholder || counted || 'Search',
98
110
  empty: o.empty || 'No match for “{q}”',
99
111
  hint: o.hint || 'Check the spelling, or try fewer letters.',
100
112
  };
@@ -146,7 +158,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
146
158
  * @param {string} [o.value] current value shown in the trigger
147
159
  * @param {string} [o.placeholder] shown when there is no value
148
160
  * @param {string} [o.variant] 'select' (listbox) | 'menu' (inferred from items)
149
- * @param {Array} [o.items] [{ label, value?, description?, icon?, badge?, selected?, disabled?, href?, danger? }]
161
+ * @param {Array} [o.items] [{ label, value?, description?, icon?, avatar?, badge?, selected?, disabled?, href?, danger? }]
150
162
  * @param {Array} [o.sections] [{ label, items }] — grouped alternative to items
151
163
  * @param {string} [o.foot] content for a pinned block at the panel's bottom edge,
152
164
  * drawn as `.ui-dropdown__foot` — raw HTML, so escape your own text
@@ -160,8 +172,10 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
160
172
  * @param {boolean} [o.disabled] the trigger is off: no stop, no panel, the kit's
161
173
  * unavailable paint. #580
162
174
  * @param {string} [o.ariaLabel] accessible name for the panel and trigger
163
- * @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query } —
164
- * a field above the rows that filters them; `empty` may carry {q}
175
+ * @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query, count, noun } —
176
+ * a field above the rows that filters them; `empty` may carry {q}; `count` (a number, or
177
+ * true to count the rows) puts the size of the set in the placeholder and the field's
178
+ * name, "Search 214 people", and `noun` replaces "people"
165
179
  * @returns {string} html
166
180
  */
167
181
  export function dropdown({
@@ -174,13 +188,18 @@ export function dropdown({
174
188
  const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
175
189
  const listRole = isSelect ? 'listbox' : 'menu';
176
190
  const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
177
- const sx = search ? ddSearchContext(search, id) : null;
191
+ const sx = search ? ddSearchContext(search, id, flat) : null;
178
192
  const name = ariaLabel || (label ? String(label).replace(/:\s*$/, '') : '') || 'Options';
179
193
 
194
+ // A picker of people: the trigger leads with the chosen row's mark, and with no value
195
+ // a dashed ring beside the placeholder. why: docs/components.md#avatar
196
+ const marked = isSelect && flat.some((it) => it && it.avatar);
197
+ const chosen = marked && cur != null ? flat.find((it) => it && it.label === cur) : null;
198
+ const lead = marked && (chosen?.avatar || cur == null) ? avatar(chosen?.avatar ?? { empty: true }) : '';
180
199
  const trig = triggerContent != null
181
200
  ? triggerContent
182
- : `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
183
- `<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
201
+ : `${lead}${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
202
+ `<span class="${esc(cx('ui-dropdown__value', marked && cur == null && 'is-placeholder'))}">${esc(cur != null ? cur : placeholder)}</span>`;
184
203
  const triggerAttrs = [
185
204
  `class="${esc(cx('ui-dropdown__trigger', triggerClass))}"`,
186
205
  'type="button"',
@@ -962,6 +981,13 @@ function selectOption(dd, item) {
962
981
  const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
963
982
  const label = item.querySelector('.ui-dropdown__label');
964
983
  if (valueEl && label) valueEl.textContent = label.textContent;
984
+ // A picker of people carries the row's mark up into the trigger.
985
+ const mark = item.querySelector(':scope > .ui-avatar');
986
+ const lead = dd.querySelector('[data-dropdown-trigger] > .ui-avatar');
987
+ if (mark && lead) lead.replaceWith(mark.cloneNode(true));
988
+ else if (mark) dd.querySelector('[data-dropdown-trigger]')?.prepend(mark.cloneNode(true));
989
+ else lead?.remove();
990
+ valueEl?.classList.remove('is-placeholder');
965
991
  }
966
992
 
967
993
  // Click-outside, Escape and the repositioning sweep, registered once per document
@@ -4,6 +4,7 @@
4
4
  // why: docs/components.md#the-page-shell
5
5
  import { topbar as productTopbar } from './topbar.js';
6
6
  import { esc, icon, button } from './index.js';
7
+ import { avatar } from './avatar.js';
7
8
  import { sidebarNav, breadcrumbs, wireNav } from './nav.js';
8
9
  import { dropdown, wireDropdown } from './dropdown.js';
9
10
  import { drawer, wireDrawer, closeDrawer } from './drawer.js';
@@ -67,7 +68,7 @@ const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email)
67
68
  const toMenuReader = (a) => {
68
69
  const { name, email } = toReader(a);
69
70
  const rest = isRecord(a) && !Array.isArray(a) ? a : {};
70
- const out = { ...rest, name: esc(name), email: esc(email), initials: esc(initials(name, email)) };
71
+ const out = { ...rest, name: esc(name), email: esc(email), initials: initials(name, email), key: email || name };
71
72
  if (out.nav != null) out.nav = toItems(out.nav).map(toMenuTuple);
72
73
  return out;
73
74
  };
@@ -195,7 +196,7 @@ function settle(options) {
195
196
  // inside it. why: docs/components.md#the-page-shell
196
197
  const readerFace = (name, email, named, markOnly = false) =>
197
198
  `<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
198
- + `${esc(initials(name, email))}</span>`
199
+ + `${avatar({ key: email || name, initials: initials(name, email), size: 32 })}</span>`
199
200
  + (markOnly ? '' : `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
200
201
  + (name ? `<b>${esc(name)}</b>` : '')
201
202
  + (email ? `<span>${esc(email)}</span>` : '')
@@ -16,6 +16,9 @@ import { toast } from './index.js';
16
16
  const reduceMotion = () =>
17
17
  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
18
18
 
19
+ const resolve = (container) =>
20
+ (typeof container === 'string' ? document.querySelector(container) : container) || null;
21
+
19
22
  // Slide-and-fade a toast out, then remove it. Idempotent.
20
23
  export function dismissToast(el) {
21
24
  if (!el || el.dataset.leaving) return;
@@ -36,20 +39,41 @@ function wireToast(el) {
36
39
  el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
37
40
 
38
41
  const bar = el.querySelector('[data-toast-timer]');
39
- if (bar && !reduceMotion()) {
42
+ if (bar) {
40
43
  bar.classList.add('is-running');
41
44
  const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
42
- let timer = setTimeout(() => dismissToast(el), dur * 1000);
43
- el.addEventListener('mouseenter', () => { clearTimeout(timer); bar.style.animationPlayState = 'paused'; });
44
- el.addEventListener('mouseleave', () => {
45
- bar.style.animationPlayState = 'running';
46
- const left = (parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7)) || 1);
47
- timer = setTimeout(() => dismissToast(el), dur * 1000 * left);
48
- });
49
- } else if (bar && reduceMotion()) {
50
- // No animated bar under reduced motion, but still auto-dismiss on time.
51
- const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
52
- setTimeout(() => dismissToast(el), dur * 1000);
45
+ let remaining = dur * 1000;
46
+ let started = Date.now();
47
+ let timer = setTimeout(() => dismissToast(el), remaining);
48
+ // The line's own pause lives in the sheet, on :hover and :focus-within, so
49
+ // it covers a keyboard reader too. What is left here is the countdown that
50
+ // has to stop with it. This runs even under reduced motion, where the line
51
+ // is hidden but the reader still gets the same hold.
52
+ let held = false;
53
+ const hold = () => {
54
+ if (held) return;
55
+ held = true;
56
+ clearTimeout(timer);
57
+ const visualLeft = !reduceMotion()
58
+ ? parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7))
59
+ : NaN;
60
+ remaining = Number.isFinite(visualLeft)
61
+ ? dur * 1000 * visualLeft
62
+ : Math.max(0, remaining - (Date.now() - started));
63
+ };
64
+ const release = () => {
65
+ if (!held || el.matches(':hover, :focus-within')) return;
66
+ held = false;
67
+ started = Date.now();
68
+ timer = setTimeout(() => dismissToast(el), remaining);
69
+ };
70
+ el.addEventListener('mouseenter', hold);
71
+ el.addEventListener('mouseleave', release);
72
+ el.addEventListener('focusin', hold);
73
+ // At focusout the old target has already blurred and the new one has not
74
+ // focused yet, so :focus-within reads false even for a move from the close
75
+ // button to the action. relatedTarget is the only thing that knows.
76
+ el.addEventListener('focusout', (e) => { if (!el.contains(e.relatedTarget)) release(); });
53
77
  }
54
78
 
55
79
  // Swipe-to-dismiss (ignore drags that start on a button).
@@ -77,7 +101,7 @@ function wireToast(el) {
77
101
 
78
102
  // Wire every toast currently inside a container (string selector or element).
79
103
  export function wireToastStack(container) {
80
- const root = typeof container === 'string' ? document.querySelector(container) : container;
104
+ const root = resolve(container);
81
105
  if (!root) return null;
82
106
  root.querySelectorAll('.ui-toast').forEach(wireToast);
83
107
  return root;
@@ -85,7 +109,7 @@ export function wireToastStack(container) {
85
109
 
86
110
  // Build a toast from opts, prepend it to a live stack (newest on top), wire it.
87
111
  export function pushToast(container, opts = {}) {
88
- const root = typeof container === 'string' ? document.querySelector(container) : container;
112
+ const root = resolve(container);
89
113
  if (!root) return null;
90
114
  root.insertAdjacentHTML('afterbegin', toast(opts));
91
115
  const el = root.firstElementChild;
@@ -4,6 +4,7 @@ import { brand } from '../assets/brand.js';
4
4
  import { icon, sun, moon } from '../assets/icons.js';
5
5
  import { esc } from './index.js';
6
6
  import { wireDropdown } from './dropdown.js';
7
+ import { avatar } from './avatar.js';
7
8
  import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
8
9
  import { trustedAttr, trustedUrl } from '../html.js';
9
10
  const THEME_KEY = 'apliteni-strategy-theme';
@@ -55,7 +56,7 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
55
56
  // shell.js computes the mark from the caller's own strings and passes it down
56
57
  // beside them. Left out, it is computed here from `name` and `email`.
57
58
  export function accountMenu({
58
- name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark,
59
+ name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark, key,
59
60
  } = {}) {
60
61
  // initials() is shared with the rail's avatar — the two are the same reader
61
62
  // on the /account preset, and they used to disagree about who that was.
@@ -76,9 +77,9 @@ export function accountMenu({
76
77
  // `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
77
78
  // Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
78
79
  return `<div class="acct on" data-dropdown>` +
79
- `<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
80
+ `<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${avatar({ key: key ?? (email || name), initials: ini, size: 32 })}</button>` +
80
81
  `<div class="amenu" data-dropdown-panel role="menu">` +
81
- `<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${trustedAttr(email)}">${email}</span></span></div>` +
82
+ `<div class="ahead">${avatar({ key: key ?? (email || name), initials: ini, size: 32 })}<span class="aw"><span class="anm">${name}</span><span class="aem" title="${trustedAttr(email)}">${email}</span></span></div>` +
82
83
  items.map(it).join('') +
83
84
  `<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
84
85
  `</div></div>`;
package/src/index.css CHANGED
@@ -21,6 +21,7 @@
21
21
  @import "./styles/button.css";
22
22
  @import "./styles/card.css";
23
23
  @import "./styles/badge.css";
24
+ @import "./styles/avatar.css";
24
25
  @import "./styles/segmented.css";
25
26
  @import "./styles/tabs.css";
26
27
  @import "./styles/input.css";
package/src/index.d.ts CHANGED
@@ -31,6 +31,14 @@ export declare function icon(name: string, className?: string): string;
31
31
  export declare function snippet(opts?: Record<string, unknown>): string;
32
32
  export declare function button(opts?: Record<string, unknown>): string;
33
33
  export declare function badge(label: string, variant?: string): string;
34
+ export declare const AVATAR_HUES: 8;
35
+ /** Which of the eight person hues a key gets: FNV-1a 32 bit over its UTF-8 bytes, modulo 8. */
36
+ export declare function avatarHue(key: unknown): number;
37
+ /** The first letter of the first word and of the last word, upper-cased; '' for no name. */
38
+ export declare function avatarInitials(name: unknown): string;
39
+ export declare function avatar(opts?: {
40
+ key?: string | number; name?: string | null; initials?: string; src?: string; size?: 24 | 32; empty?: boolean;
41
+ }): string;
34
42
  export declare const calloutIcons: Record<'neutral' | 'info' | 'success' | 'warn' | 'danger', string>;
35
43
  export declare function callout(opts?: Record<string, unknown>): string;
36
44
  export declare function card(opts?: Record<string, unknown>): string;
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // apliteni-ui — public entry.
2
2
  // Styles ship separately: `import 'apliteni-ui/css'`.
3
3
  export * from './components/index.js';
4
+ export * from './components/avatar.js';
4
5
  export * from './components/dropdown.js';
5
6
  export * from './components/tooltip.js';
6
7
  export * from './components/tabs.js';
package/src/inline.js CHANGED
@@ -35,6 +35,7 @@ export const styles = {
35
35
  button: read('styles/button.css'),
36
36
  card: read('styles/card.css'),
37
37
  badge: read('styles/badge.css'),
38
+ avatar: read('styles/avatar.css'),
38
39
  segmented: read('styles/segmented.css'),
39
40
  tabs: read('styles/tabs.css'),
40
41
  input: read('styles/input.css'),
@@ -72,6 +73,7 @@ export const cssText = [
72
73
  styles.button,
73
74
  styles.card,
74
75
  styles.badge,
76
+ styles.avatar,
75
77
  styles.segmented,
76
78
  styles.tabs,
77
79
  styles.input,
@@ -0,0 +1,28 @@
1
+ /** How many hues a person's mark can take. One per `--person-N` token. */
2
+ export const AVATAR_HUES = 8;
3
+
4
+ /**
5
+ * Which hue a person gets: FNV-1a, 32 bit, over the UTF-8 bytes of the app's stable
6
+ * key, modulo the number of hues. The kit ships it so two apps give one person one hue.
7
+ */
8
+ export function avatarHue(key) {
9
+ let h = 0x811c9dc5;
10
+ for (const byte of new TextEncoder().encode(String(key ?? ''))) {
11
+ h ^= byte;
12
+ h = Math.imul(h, 0x01000193) >>> 0;
13
+ }
14
+ return h % AVATAR_HUES;
15
+ }
16
+
17
+ /**
18
+ * The letters of a mark: the first letter of the first word and of the last word, so a
19
+ * one-word name gives one letter. Empty when there is no name to read.
20
+ */
21
+ export function avatarInitials(name) {
22
+ const words = String(name ?? '').normalize('NFC').trim().split(/\s+/).filter(Boolean);
23
+ if (!words.length) return '';
24
+ const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
25
+ const first = (w) => segmenter ? segmenter.segment(w)[Symbol.iterator]().next().value.segment : Array.from(w)[0];
26
+ const letters = words.length === 1 ? [first(words[0])] : [first(words[0]), first(words[words.length - 1])];
27
+ return letters.join('').toLocaleUpperCase();
28
+ }
@@ -0,0 +1,42 @@
1
+ /* Avatar — a person's round mark. Solid fill in the person's hue, the letters in the
2
+ solid-signal ink. Only the part's own paint is here: a control that holds one keeps
3
+ its own cursor, hover and ring. why: docs/components.md#avatar */
4
+ .ui-avatar {
5
+ --ui-avatar-size: 24px;
6
+ --ui-avatar-hue: var(--person-0);
7
+ inline-size: var(--ui-avatar-size);
8
+ block-size: var(--ui-avatar-size);
9
+ flex: none;
10
+ display: inline-grid;
11
+ place-items: center;
12
+ position: relative;
13
+ overflow: hidden;
14
+ border-radius: 50%;
15
+ font-size: var(--text-xs);
16
+ font-weight: var(--weight-bold);
17
+ line-height: 1;
18
+ vertical-align: middle;
19
+ background: var(--ui-avatar-hue); /* code-bg: inherit — an opaque fill with its own ink. */
20
+ color: var(--signal-solid-ink);
21
+ }
22
+ .ui-avatar--32 { --ui-avatar-size: 32px; font-size: var(--text-base); }
23
+ /* Two letters in 24px leave the type at the kit's smallest step; in 32px one step down. */
24
+ .ui-avatar--32[data-initials="2"] { font-size: var(--text-sm); }
25
+ .ui-avatar--h0 { --ui-avatar-hue: var(--person-0); }
26
+ .ui-avatar--h1 { --ui-avatar-hue: var(--person-1); }
27
+ .ui-avatar--h2 { --ui-avatar-hue: var(--person-2); }
28
+ .ui-avatar--h3 { --ui-avatar-hue: var(--person-3); }
29
+ .ui-avatar--h4 { --ui-avatar-hue: var(--person-4); }
30
+ .ui-avatar--h5 { --ui-avatar-hue: var(--person-5); }
31
+ .ui-avatar--h6 { --ui-avatar-hue: var(--person-6); }
32
+ .ui-avatar--h7 { --ui-avatar-hue: var(--person-7); }
33
+
34
+ /* The photo covers the letters, so one that fails leaves them showing. */
35
+ .ui-avatar img { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; object-fit: cover; opacity: 0; }
36
+ .ui-avatar img.is-loaded { opacity: 1; }
37
+ .ui-avatar svg { inline-size: 62%; block-size: 62%; stroke: currentColor; fill: none; stroke-width: 2.5; }
38
+
39
+ /* No name: the user glyph on the kit's quiet fill. */
40
+ .ui-avatar--anon { background: var(--surface-3); /* code-bg: inherit — a glyph, no text on it. */ /* muted-ink: glyph — a person with no name */ color: var(--muted); }
41
+ /* No one: a dashed ring of the same size. */
42
+ .ui-avatar--empty { background: none; border: 1px dashed var(--muted); /* muted-ink: glyph — the ring is a mark, not text */ color: var(--muted); }
@@ -93,9 +93,16 @@
93
93
  .ui-toast__close:hover { color: var(--text); }
94
94
  .ui-toast__close:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: var(--radius-xs); }
95
95
 
96
- /* auto-dismiss progress bar — .is-running is added by wireToastStack() */
96
+ /* auto-dismiss progress line — .is-running is added by wireToastStack() */
97
97
  .ui-toast__timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--toast-accent); opacity: 0.85; transform-origin: left; }
98
- .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the bar spends the toast's dismiss timer, which the caller sets */ }
98
+ .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the line spends the toast's dismiss timer, which the caller sets */ }
99
+ /* The line spends the notice's own timer, so it stops exactly when the timer
100
+ does: under the pointer, and while focus is anywhere inside. Holding both
101
+ pauses here rather than in each face's event wiring keeps the line and the
102
+ countdown from drifting apart, and gives a keyboard reader the same reprieve
103
+ a pointer reader gets. */
104
+ .ui-toast:hover > .ui-toast__timer.is-running,
105
+ .ui-toast:focus-within > .ui-toast__timer.is-running { animation-play-state: paused; }
99
106
 
100
107
  /* Legacy style classes share the neutral card. */
101
108
  .ui-toast--soft, .ui-toast--solid, .ui-toast--outline { background: var(--surface); color: var(--text); }
@@ -110,7 +117,10 @@
110
117
 
111
118
  @media (prefers-reduced-motion: reduce) {
112
119
  .ui-toast, .ui-toast.is-leaving { animation: none; }
113
- .ui-toast__timer.is-running { animation: none; }
120
+ /* A line that cannot move sits full for the whole five seconds and reads as
121
+ "no time spent yet" — the opposite of what it is there to say. The notice
122
+ still expires on time; only the false readout goes. */
123
+ .ui-toast__timer { display: none; }
114
124
  }
115
125
 
116
126
  /* stack container for queued toasts */
@@ -425,3 +425,21 @@
425
425
  /* muted-ink: state — off, unset, disabled or archived badge */
426
426
  color: var(--muted);
427
427
  }
428
+
429
+ /* A picker of people. A mark leads the trigger and each row; with no value the trigger
430
+ shows the dashed ring beside the placeholder in the placeholder ink. The mark's own
431
+ margins give back the room it takes, so the trigger keeps its height.
432
+ why: docs/components.md#avatar */
433
+ .ui-dropdown__trigger > .ui-avatar {
434
+ --ui-avatar-pull: 4px;
435
+ --ui-avatar-pull-start: 10px;
436
+ margin-block: calc(var(--ui-avatar-pull) * -1);
437
+ margin-inline-start: calc(var(--ui-avatar-pull-start) * -1);
438
+ }
439
+ .ui-dropdown__value.is-placeholder { /* muted-ink: placeholder — no person chosen */ color: var(--muted); font-weight: var(--weight-medium); }
440
+
441
+ /* A dropdown that is a form control takes its field's width, and its panel the
442
+ trigger's. why: docs/components.md#avatar */
443
+ .ui-field > .ui-dropdown { display: grid; }
444
+ .ui-field > .ui-dropdown > .ui-dropdown__trigger > .ui-dropdown__chevron { margin-left: auto; }
445
+ .ui-field > .ui-dropdown > .ui-dropdown__panel { inline-size: 100%; min-width: 0; }
@@ -161,13 +161,10 @@
161
161
  .ui-app__av {
162
162
  width: var(--ui-app-av); height: var(--ui-app-av); flex: none;
163
163
  margin-inline-start: calc((var(--ui-nav-strip) - var(--ui-app-av)) / 2);
164
- border-radius: var(--radius-pill);
165
- background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
166
- color: var(--strong);
167
- display: grid; place-items: center;
168
- font-size: calc(var(--text-xs) + 1px);
169
- font-weight: var(--weight-semibold);
164
+ display: grid;
170
165
  }
166
+ /* The mark is the Avatar part in the reader's hue, filling this box like the top bar's. */
167
+ .ui-app__av > .ui-avatar { --ui-avatar-size: 100%; }
171
168
  .ui-app__who { font-size: calc(var(--text-sm) - 0.5px); line-height: 1.3; min-width: 0; }
172
169
  .ui-app__who b { display: block; color: var(--strong); font-weight: var(--weight-semibold); }
173
170
  /* nowrap is what makes the ellipsis fire — without it the address wraps, and
@@ -209,7 +209,7 @@
209
209
  src/styles/dropdown.css:271 `.ui-dropdown__item:hover`.
210
210
  why: docs/foundations.md#elevation */
211
211
  .vopt:hover { outline: 1px solid var(--control-edge); outline-offset: -1px; background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
212
- /* The row is a keyboard stop: src/components/dropdown.js:1165
212
+ /* The row is a keyboard stop: src/components/dropdown.js:1191
213
213
  `items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
214
214
  the arrow keys, and this is one. It takes the ring on its own 10px radius, the
215
215
  same way `.amenu a` above does. #482 */
@@ -306,6 +306,10 @@
306
306
  what the kit's rows already chose. why: docs/foundations.md#the-focus-ring */
307
307
  .acct .avatar:hover:not(:focus-visible) { border-color: var(--accent); }
308
308
  .avatar:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
309
+ /* The account button holding an Avatar: the part paints the circle in the reader's hue and
310
+ the button keeps its edge, cursor, hover and ring. why: docs/components.md#avatar */
311
+ .avatar:has(> .ui-avatar) { background: none; padding: 0; overflow: hidden; }
312
+ .avatar > .ui-avatar { --ui-avatar-size: 100%; }
309
313
  .amenu {
310
314
  position: absolute;
311
315
  right: 0; top: 44px;
@@ -372,7 +376,6 @@
372
376
  border-radius: 14px 14px 0 0;
373
377
  display: flex; align-items: center; gap: 11px;
374
378
  }
375
- .amenu .ahead .avatar { cursor: default; }
376
379
  .amenu .ahead .aw { min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.35; }
377
380
  .amenu .ahead .anm { color: var(--strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
378
381
  .amenu .ahead .aem { color: var(--text); font-size: calc(var(--text-xs) + 0.5px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -257,6 +257,19 @@
257
257
  --signal-solid-info: var(--cyan);
258
258
  --signal-solid-neutral: var(--muted);
259
259
 
260
+ /* Person hues: the eight fills an Avatar takes from a hash of the person's key. Fixed
261
+ values and not accent tints, so a person keeps one hue under every accent and in
262
+ every app. Each is an existing kit value for this theme, and --signal-solid-ink
263
+ holds 4.5:1 on all eight. why: docs/components.md#avatar */
264
+ --person-0: #bd8cff;
265
+ --person-1: #5ab0ff;
266
+ --person-2: #20dcf5;
267
+ --person-3: #3ad9a0;
268
+ --person-4: #98ff8f;
269
+ --person-5: #ffcf6a;
270
+ --person-6: #ff8a5c;
271
+ --person-7: #f5aec6;
272
+
260
273
  /* DEPRECATED since #295: no surface below the floating step casts, and the floating
261
274
  step reads --elev-drop rather than any of these. Published still, so a consumer
262
275
  reading one gets nothing rather than an unresolved var(), and a consumer who wants a
@@ -446,6 +459,16 @@
446
459
  --signal-solid-danger: var(--chip-danger-ink);
447
460
  --signal-solid-warn: var(--chip-warn-ink);
448
461
  --signal-solid-info: var(--chip-info-ink);
462
+
463
+ /* Person hues, light: the deepened forms, so white holds on every one. */
464
+ --person-0: #6a2dcc;
465
+ --person-1: #005ab4;
466
+ --person-2: #096a7c;
467
+ --person-3: #076c48;
468
+ --person-4: #145a26;
469
+ --person-5: #825900;
470
+ --person-6: #a8370c;
471
+ --person-7: #b7295f;
449
472
  --signal-solid-neutral: var(--muted);
450
473
 
451
474
  /* Deprecated and unread — see the dark block. */