@apliteni/apliteni-ui 0.41.3 → 0.41.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.41.3",
3
+ "version": "0.41.5",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -37,20 +37,16 @@ Its glyph is 13px and its icon-only target is 24×24px; labelled xs buttons use
37
37
 
38
38
  `CommandPalette` renders the kit's `commandPalette()` markup, class for class, and imports the
39
39
  kit's ranking rather than repeating it — so a palette a server rendered and the same palette
40
- after a keystroke put the same row first. Three differences from the vanilla one. Two are
41
- because a React host already owns the state the wiring would: it has no Cmd+K binding, since
40
+ after a keystroke put the same row first. Two differences from the vanilla one follow from
41
+ the React host owning state: it has no Cmd+K binding, since
42
42
  `open` is the host's prop to set from whatever key it wants; and a destructive row names an
43
43
  `onConfirm` callback rather than the id of a confirm dialog, which is refused the same way — a
44
44
  `danger` row with neither renders disabled. `rank={false}` hands the query back through
45
45
  `onQueryChange` for a palette a server feeds.
46
46
 
47
- The third is not a design decision and is going: **one Escape closes a `Modal` and the palette
48
- under it.** The React palette and the React `Modal` each register their own listener on the
49
- document, and `inert` does not stop a document listener, so the question and the surface it was
50
- asked about close together. The vanilla pair does not do this — every kit overlay is on one
51
- stack there, and Escape closes the top one only. The React half joins that shared dialog stack
52
- when this branch is rebased onto the drawer branch, which is where `dialog.ts` lands; until then,
53
- open a `Modal` from a palette row knowing both will close.
47
+ The React palette, Modal and Drawer share one dialog stack. Escape closes only the top
48
+ one, so dismissing a confirmation leaves the palette open. Vanilla overlays use a separate
49
+ stack; do not open a React dialog and a vanilla overlay over each other on the same page.
54
50
 
55
51
  `Pagination` renders the kit's `pagination()` markup, class for class, so its styles come from
56
52
  `@apliteni/apliteni-ui/css` rather than from this bundle. One deliberate difference: it takes no
@@ -1,7 +1,7 @@
1
1
  // Apliteni seedling brand mark + wordmark.
2
2
  // `p` prefixes gradient ids so multiple marks on one page never collide.
3
-
4
- export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width="${size}" height="${Math.round(size * 36 / 38)}" aria-hidden="true"><defs><linearGradient id="${p}1" x1="1" x2="0" y1="0.248" y2="0.752"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient><linearGradient id="${p}2" x1="1" x2="0" y1="0.497" y2="0.503"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient></defs><g transform="translate(1 1)"><path d="M 23.115 33.95 C 22.982 34.379 23.862 31.827 24.095 30.048 C 25.239 21.404 23.879 13.04 15.283 6.191 C 11.018 2.783 5.476 0.988 0 1.235 C 1.974 1.647 3.17 3.705 3.484 5.697 C 3.8 7.673 3.468 9.697 3.7 11.69 C 4.165 15.691 6.953 19.264 10.57 21.108 C 13.84 22.771 17.689 23.1 21.306 22.359 C 21.306 22.359 17.772 13.501 11.234 10.801 C 11.234 10.801 20.062 13.451 22.617 21.981 C 24.31 27.694 23.115 33.951 23.115 33.951 Z" fill="url(#${p}1)"/><path d="M 24.509 17.09 C 24.495 17.045 24.478 17.002 24.459 16.959 C 25.322 13.469 28.425 10.109 30.582 9.089 C 28.392 9.171 24.708 12.546 23.562 14.621 C 22.857 13.042 21.972 11.551 20.924 10.175 C 24.84 6.026 33.851 6.026 35.527 0 C 36.157 3.886 36.157 9.632 34.532 13.221 C 32.988 16.613 29.752 21.454 25.355 20.878 C 25.181 19.594 24.898 18.327 24.509 17.091 Z" fill="url(#${p}2)"/></g></svg>`;
3
+ import { esc, trustedAttr, safeUrl } from '../html.js';
4
+ export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width="${esc(size)}" height="${Math.round(size * 36 / 38)}" aria-hidden="true"><defs><linearGradient id="${esc(p)}1" x1="1" x2="0" y1="0.248" y2="0.752"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient><linearGradient id="${esc(p)}2" x1="1" x2="0" y1="0.497" y2="0.503"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient></defs><g transform="translate(1 1)"><path d="M 23.115 33.95 C 22.982 34.379 23.862 31.827 24.095 30.048 C 25.239 21.404 23.879 13.04 15.283 6.191 C 11.018 2.783 5.476 0.988 0 1.235 C 1.974 1.647 3.17 3.705 3.484 5.697 C 3.8 7.673 3.468 9.697 3.7 11.69 C 4.165 15.691 6.953 19.264 10.57 21.108 C 13.84 22.771 17.689 23.1 21.306 22.359 C 21.306 22.359 17.772 13.501 11.234 10.801 C 11.234 10.801 20.062 13.451 22.617 21.981 C 24.31 27.694 23.115 33.951 23.115 33.951 Z" fill="url(#${esc(p)}1)"/><path d="M 24.509 17.09 C 24.495 17.045 24.478 17.002 24.459 16.959 C 25.322 13.469 28.425 10.109 30.582 9.089 C 28.392 9.171 24.708 12.546 23.562 14.621 C 22.857 13.042 21.972 11.551 20.924 10.175 C 24.84 6.026 33.851 6.026 35.527 0 C 36.157 3.886 36.157 9.632 34.532 13.221 C 32.988 16.613 29.752 21.454 25.355 20.878 C 25.181 19.594 24.898 18.327 24.509 17.091 Z" fill="url(#${esc(p)}2)"/></g></svg>`;
5
5
 
6
6
  // The Apliteni mark — a rounded "sub-theme prism" of the four ready-made accents
7
7
  // (Nebula / Phoenix / Ocean / Emerald). This is the current mark used everywhere
@@ -9,10 +9,10 @@ export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width
9
9
  // is kept only for backward compatibility. `p` prefixes the clip id so multiple
10
10
  // marks on one page never collide.
11
11
  export const prism = (p = 'pr', size = 26) =>
12
- `<svg viewBox="0 0 32 32" width="${size}" height="${size}" aria-hidden="true"><defs><clipPath id="${p}"><rect x="1" y="1" width="30" height="30" rx="9"/></clipPath></defs><g clip-path="url(#${p})"><rect x="1" y="1" width="15" height="15" fill="#9b5dff"/><rect x="16" y="1" width="15" height="15" fill="#ff6a3d"/><rect x="1" y="16" width="15" height="15" fill="#3b9dff"/><rect x="16" y="16" width="15" height="15" fill="#16c98a"/></g></svg>`;
12
+ `<svg viewBox="0 0 32 32" width="${esc(size)}" height="${esc(size)}" aria-hidden="true"><defs><clipPath id="${esc(p)}"><rect x="1" y="1" width="30" height="30" rx="9"/></clipPath></defs><g clip-path="url(#${esc(p)})"><rect x="1" y="1" width="15" height="15" fill="#9b5dff"/><rect x="16" y="1" width="15" height="15" fill="#ff6a3d"/><rect x="1" y="16" width="15" height="15" fill="#3b9dff"/><rect x="16" y="16" width="15" height="15" fill="#16c98a"/></g></svg>`;
13
13
 
14
14
  // Brand lockup: the prism mark + product word. `word` defaults to "Strategy"
15
15
  // (the strategy portal). Uses the prism — the current Apliteni mark — not the
16
16
  // legacy seedling (still exported above for anything that needs it).
17
17
  export const brand = ({ p = 'lg', word = 'Strategy', size = 22, href = '/' } = {}) =>
18
- `<a class="brand" href="${href}" aria-label="Apliteni ${word}">${prism(p, size)}<span>${word}</span></a>`;
18
+ `<a class="brand" href="${esc(safeUrl(href))}" aria-label="Apliteni ${trustedAttr(word)}">${prism(p, size)}<span>${word}</span></a>`;
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // Naming, grouping and provenance, and the gates that hold them:
10
10
  // why: CONTRIBUTING.md#add-a-glyph
11
-
11
+ import { esc } from '../html.js';
12
12
  const NAV = {
13
13
  chevronDown: '<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/>',
14
14
  chevronUp: '<polyline points="18 15 12 9 6 15"/>',
@@ -134,7 +134,7 @@ export const iconCategories = [
134
134
  // purpose; a named graphic would let a caller hang a control's only accessible
135
135
  // name off a decorative glyph, which is what we just fixed.
136
136
  export const icon = (name, cls = '') =>
137
- `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"${cls ? ` class="${cls}"` : ''}>${ICONS[name] || ''}</svg>`;
137
+ `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"${cls ? ` class="${esc(cls)}"` : ''}>${ICONS[name] || ''}</svg>`;
138
138
 
139
139
  export const iconNames = Object.keys(ICONS);
140
140
 
@@ -23,7 +23,7 @@ const BARE = 'Back';
23
23
  // C0 controls and spaces from the front and removes every tab, LF and CR wherever
24
24
  // they sit, so "java\tscript:" is still javascript:. The check reads the address the
25
25
  // same way (WHATWG URL Standard, basic URL parser).
26
- const SCRIPTED = /^javascript:/i;
26
+ const SCRIPTED = /^(?:javascript|data|vbscript):/i;
27
27
  const LEADING = /^[\u0000-\u0020]+/;
28
28
  const TAB_OR_NEWLINE = /[\t\n\r]/g;
29
29
 
@@ -10,7 +10,7 @@
10
10
  // why: docs/specification.md#the-command-palette
11
11
  import { esc, icon } from './index.js';
12
12
  import { OVERLAY_LAYER, adoptOverlay, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
13
-
13
+ import { safeUrl } from '../html.js';
14
14
  const cx = (...a) => a.filter(Boolean).join(' ');
15
15
 
16
16
  let _uid = 0;
@@ -204,12 +204,12 @@ function paletteItem(it, uid, index, active) {
204
204
  `class="${cx('ui-cmdk__item', it.danger && !disabled && 'is-danger', disabled && 'is-disabled', active && 'is-active')}"`,
205
205
  'role="option"',
206
206
  'tabindex="-1"',
207
- `id="${uid}-o${index}"`,
207
+ `id="${esc(uid)}-o${esc(index)}"`,
208
208
  'data-cmdk-item',
209
- `data-i="${index}"`,
209
+ `data-i="${esc(index)}"`,
210
210
  searchAttrs(it),
211
211
  it.id != null ? `data-id="${esc(it.id)}"` : '',
212
- it.href && !disabled ? `data-href="${esc(it.href)}"` : '',
212
+ it.href && !disabled ? `data-href="${esc(safeUrl(it.href))}"` : '',
213
213
  it.confirm && !disabled ? `data-confirm-open="${esc(it.confirm)}"` : '',
214
214
  it.confirm && !disabled ? 'aria-haspopup="dialog"' : '',
215
215
  disabled ? 'aria-disabled="true"' : '',
@@ -260,10 +260,10 @@ export function commandPaletteList(groups = [], { uid = nextId(), from = 0 } = {
260
260
  if (!items.length) return '';
261
261
  const headId = g.label ? `${uid}-g${gi}` : null;
262
262
  const head = headId
263
- ? `<div class="ui-cmdk__group-head" id="${headId}">${esc(g.label)}</div>` : '';
263
+ ? `<div class="ui-cmdk__group-head" id="${esc(headId)}">${esc(g.label)}</div>` : '';
264
264
  const rows = items.map((it) => paletteItem(it, uid, n, n++ === activeAt)).join('');
265
265
  return `<div class="ui-cmdk__group" role="group" data-cmdk-group data-i="${gi}"`
266
- + `${headId ? ` aria-labelledby="${headId}"` : ''}>${head}${rows}</div>`;
266
+ + `${headId ? ` aria-labelledby="${esc(headId)}"` : ''}>${head}${rows}</div>`;
267
267
  }).join('');
268
268
  }
269
269
 
@@ -60,7 +60,7 @@ export function drawer({
60
60
  const footEl = footer ? `<footer class="ui-drawer__footer">${footer}</footer>` : '';
61
61
 
62
62
  const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, (open || specimen) && 'is-open');
63
- return `<div class="${rootCls}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
63
+ return `<div class="${esc(rootCls)}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
64
64
  + `${dismissible ? '' : ' data-drawer-static'}${id ? ` id="${esc(id)}"` : ''}>`
65
65
  + `<div class="ui-drawer__scrim" data-drawer-scrim></div>`
66
66
  + `<aside class="ui-drawer__panel" role="dialog"${specimen ? '' : ' aria-modal="true"'} ${nameAttr} tabindex="-1" data-drawer-panel>`
@@ -13,7 +13,7 @@
13
13
  // container.innerHTML = dropdown({ label: 'version:', value: '…', items });
14
14
  // wireDropdown(container); // or let wireTopbar() do it
15
15
  import { esc, icon } from './index.js';
16
-
16
+ import { safeUrl } from '../html.js';
17
17
  const cx = (...a) => a.filter(Boolean).join(' ');
18
18
 
19
19
  // A trailing status badge. `badge` is the text shown, in the case it is written
@@ -24,7 +24,7 @@ function ddBadge(badge) {
24
24
  let tone = typeof badge === 'string' ? '' : (badge.tone || '');
25
25
  if (!tone) tone = /^live$/i.test(text) ? 'live'
26
26
  : /^(?:off|unset|disabled|archive|archived)$/i.test(text) ? 'state' : 'neutral';
27
- return `<span class="${cx('ui-dropdown__badge', `is-${tone}`)}">${esc(text)}</span>`;
27
+ return `<span class="${esc(cx('ui-dropdown__badge', `is-${tone}`))}">${esc(text)}</span>`;
28
28
  }
29
29
 
30
30
  // One item row. `listbox` picks role=option (selectable) vs role=menuitem (action).
@@ -41,14 +41,14 @@ function ddItem(it, listbox, ext) {
41
41
  const badge = ddBadge(it.badge);
42
42
  const tick = listbox ? `<span class="ui-dropdown__tick" aria-hidden="true">${icon('check')}</span>` : '';
43
43
  const attrs = [
44
- `class="${cx('ui-dropdown__item', selected && 'is-selected', disabled && 'is-disabled', it.danger && 'is-danger')}"`,
44
+ `class="${esc(cx('ui-dropdown__item', selected && 'is-selected', disabled && 'is-disabled', it.danger && 'is-danger'))}"`,
45
45
  'data-dd-item',
46
46
  `role="${role}"`,
47
47
  'tabindex="-1"',
48
48
  it.value != null ? `data-value="${esc(it.value)}"` : '',
49
49
  listbox ? `aria-selected="${selected ? 'true' : 'false'}"` : '',
50
50
  disabled ? 'aria-disabled="true"' : '',
51
- asLink ? `href="${esc(it.href)}"` : '',
51
+ asLink ? `href="${esc(safeUrl(it.href))}"` : '',
52
52
  asLink && it.target ? `target="${esc(it.target)}"` : '',
53
53
  ext?.id ? `id="${esc(ext.id)}"` : '',
54
54
  ext?.hidden ? 'hidden' : '',
@@ -189,7 +189,7 @@ export function dropdown({
189
189
  : `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
190
190
  `<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
191
191
  const triggerAttrs = [
192
- `class="${cx('ui-dropdown__trigger', triggerClass)}"`,
192
+ `class="${esc(cx('ui-dropdown__trigger', triggerClass))}"`,
193
193
  'type="button"',
194
194
  'data-dropdown-trigger',
195
195
  `aria-haspopup="${sx ? 'dialog' : listRole}"`,
@@ -200,7 +200,7 @@ export function dropdown({
200
200
  // `is-open` beside `open` because the descendant selector the panel normally
201
201
  // takes its open state from stops matching once wireDropdown() portals it.
202
202
  const panelAttrs = [
203
- `class="${cx(
203
+ `class="${esc(cx(
204
204
  'ui-dropdown__panel',
205
205
  align === 'end' && 'is-end',
206
206
  direction === 'up' && 'is-up',
@@ -209,7 +209,7 @@ export function dropdown({
209
209
  portal && 'ui-dropdown__panel--portal',
210
210
  portal && open && 'is-open',
211
211
  panelClass,
212
- )}"`,
212
+ ))}"`,
213
213
  'data-dropdown-panel',
214
214
  // With search the panel holds a field and a list, which a listbox may not.
215
215
  `role="${sx ? 'dialog' : listRole}"`,
@@ -227,7 +227,7 @@ export function dropdown({
227
227
  + (direction === 'auto' ? ' data-dropdown-direction="auto"' : '')
228
228
  + (portal ? ' data-dropdown-portal' : '');
229
229
 
230
- return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
230
+ return `<div class="${esc(cx('ui-dropdown', open && 'open'))}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
231
231
  `<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
232
232
  `<div ${panelAttrs}>${header}`
233
233
  + `${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}`
@@ -7,7 +7,7 @@
7
7
  // app — compact, in-product (tight padding, surface background)
8
8
  import { brand as brandLockup } from '../assets/brand.js';
9
9
  import { esc } from './index.js';
10
-
10
+ import { safeUrl } from '../html.js';
11
11
  const cx = (...a) => a.filter(Boolean).join(' ');
12
12
 
13
13
  // Minimal brand-glyph set for the optional social row. Feather-ish where generic,
@@ -23,13 +23,13 @@ const SOCIAL = {
23
23
  function socialLink({ label, href = '#', icon: ic = 'github' } = {}) {
24
24
  const glyph = SOCIAL[ic] || SOCIAL.github;
25
25
  const fill = ic === 'github' || ic === 'x' ? 'currentColor' : 'none';
26
- return `<a class="ui-footer__social" href="${href}" aria-label="${esc(label || ic)}">` +
26
+ return `<a class="ui-footer__social" href="${esc(safeUrl(href))}" aria-label="${esc(label || ic)}">` +
27
27
  `<svg viewBox="0 0 24 24" fill="${fill}" aria-hidden="true">${glyph}</svg></a>`;
28
28
  }
29
29
 
30
30
  function linkList(links = []) {
31
31
  return `<ul class="ui-footer__links">${links.map(({ label, href = '#', target }) =>
32
- `<li><a href="${href}"${target ? ` target="${target}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
32
+ `<li><a href="${esc(safeUrl(href))}"${target ? ` target="${esc(target)}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
33
33
  }
34
34
 
35
35
  // The column title is an h2: it names a top-level section of the page's end
@@ -47,7 +47,7 @@ function column({ title, links = [] } = {}) {
47
47
  function legalBar({ legal, legalLinks = [], switcher = '' } = {}) {
48
48
  const links = legalLinks.length
49
49
  ? `<nav class="ui-footer__legal-links" aria-label="Legal">` +
50
- legalLinks.map(({ label, href = '#' }) => `<a href="${href}">${esc(label)}</a>`).join('') + `</nav>`
50
+ legalLinks.map(({ label, href = '#' }) => `<a href="${esc(safeUrl(href))}">${esc(label)}</a>`).join('') + `</nav>`
51
51
  : '';
52
52
  const sw = switcher ? `<div class="ui-footer__switcher">${switcher}</div>` : '';
53
53
  return `<div class="ui-footer__bar">` +
@@ -77,7 +77,7 @@ export function footer({
77
77
 
78
78
  // slim + app: a single legal row, no brand block or columns.
79
79
  if (variant === 'slim' || variant === 'app') {
80
- return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
80
+ return `<footer class="${esc(cls)}" role="contentinfo"><div class="ui-footer__in">` +
81
81
  legalBar({ legal, legalLinks, switcher }) +
82
82
  `</div></footer>`;
83
83
  }
@@ -93,7 +93,7 @@ export function footer({
93
93
  const nav = columns.length
94
94
  ? `<nav class="ui-footer__nav" aria-label="Footer">${columns.map(column).join('')}</nav>` : '';
95
95
 
96
- return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
96
+ return `<footer class="${esc(cls)}" role="contentinfo"><div class="ui-footer__in">` +
97
97
  `<div class="ui-footer__top">${brandBlock}${nav}</div>` +
98
98
  legalBar({ legal, legalLinks, switcher }) +
99
99
  `</div></footer>`;
@@ -3,9 +3,9 @@
3
3
  import { icon } from '../assets/icons.js';
4
4
  import { illo } from '../assets/illustrations.js';
5
5
  import { successCheck } from './success.js';
6
- const HTML_ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
6
+ import { esc, safeUrl } from '../html.js';
7
+ export { esc } from '../html.js';
7
8
  const cx = (...a) => a.filter(Boolean).join(' ');
8
- export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ENTITIES[c]);
9
9
  // ---- Button --------------------------------------------------------------
10
10
  // `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
11
11
  // branded glyphs the kit's icon set doesn't own — e.g. a Google "G". It takes
@@ -37,20 +37,20 @@ export function button({
37
37
  // Static busy ⇒ disabled; setButtonBusy wires focus-preserving activation guards.
38
38
  const name = String(label == null ? '' : label).trim() || ic || 'Button';
39
39
  const named = iconOnly ? ` aria-label="${esc(name)}" title="${esc(name)}"` : '';
40
- const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${disabled && busy ? ' data-btn-disabled' : ''}${named}`;
40
+ const attrs = `class="${esc(cls)}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${disabled && busy ? ' data-btn-disabled' : ''}${named}`;
41
41
  const control = href
42
- ? `<a href="${href}" ${attrs}>${inner}</a>`
43
- : `<button type="${type}" ${attrs}>${inner}</button>`;
42
+ ? `<a href="${esc(safeUrl(href))}" ${attrs}>${inner}</a>`
43
+ : `<button type="${esc(type)}" ${attrs}>${inner}</button>`;
44
44
  return busy ? `${control}<span class="ui-sr ui-btn__status" role="status" aria-live="polite"></span>` : control;
45
45
  }
46
46
 
47
47
  // ---- Badge / Pill --------------------------------------------------------
48
48
  export function badge(label, variant = 'neutral') {
49
49
  const v = variant === 'neutral' ? '' : `ui-badge--${variant}`;
50
- return `<span class="${cx('ui-badge', v)}">${esc(label)}</span>`;
50
+ return `<span class="${esc(cx('ui-badge', v))}">${esc(label)}</span>`;
51
51
  }
52
52
  export function pill(label, variant) {
53
- return `<span class="${cx('ui-pill', variant && `ui-pill--${variant}`)}">${esc(label)}</span>`;
53
+ return `<span class="${esc(cx('ui-pill', variant && `ui-pill--${variant}`))}">${esc(label)}</span>`;
54
54
  }
55
55
  export function statusDot(live = false) {
56
56
  return `<span class="${cx('ui-dot', live && 'is-live')}"></span>`;
@@ -66,7 +66,7 @@ export function card({ title, sub, body = '', variant, pad, icon: ic, level = 2
66
66
  const head = title
67
67
  ? `<${h} class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</${h}>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
68
68
  : '';
69
- return `<div class="${cls}">${head}${body}</div>`;
69
+ return `<div class="${esc(cls)}">${head}${body}</div>`;
70
70
  }
71
71
 
72
72
  // ---- Segmented control ---------------------------------------------------
@@ -93,7 +93,7 @@ export function segmented({ options = [], active = 0, size, block, name = 'seg',
93
93
  return `<button type="button"${disabled || o.disabled ? ' disabled' : ''} aria-pressed="${on}" tabindex="${i === rove ? '0' : '-1'}"`
94
94
  + ` data-value="${esc(val)}"${on ? ' class="is-active"' : ''}>${esc(label)}</button>`;
95
95
  }).join('');
96
- return `<div class="${cls}" role="toolbar" aria-label="${esc(ariaLabel)}" data-seg="${name}">${btns}</div>`;
96
+ return `<div class="${esc(cls)}" role="toolbar" aria-label="${esc(ariaLabel)}" data-seg="${esc(name)}">${btns}</div>`;
97
97
  }
98
98
 
99
99
  // ---- Accent picker -------------------------------------------------------
@@ -176,7 +176,7 @@ export function field({ label, hint, error, control = '', id, required = false }
176
176
  required: required || null,
177
177
  });
178
178
  const lab = label
179
- ? `<label class="ui-field__label"${forId ? ` for="${forId}"` : ''}>${esc(label)}`
179
+ ? `<label class="ui-field__label"${forId ? ` for="${id ? esc(id) : forId}"` : ''}>${esc(label)}`
180
180
  + `${required ? '<span class="ui-field__req" aria-hidden="true">*</span>' : ''}</label>`
181
181
  : '';
182
182
  const foot = error
@@ -188,14 +188,14 @@ export function field({ label, hint, error, control = '', id, required = false }
188
188
  // `invalid` paints the control red AND says so in aria-invalid — the red on its
189
189
  // own is a state only a sighted user can read.
190
190
  export function input({ type = 'text', placeholder = '', value = '', icon: ic, invalid, disabled, required, name, id, ariaLabel } = {}) {
191
- const attrs = `${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}`
191
+ const attrs = `${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}`
192
192
  + `${invalid ? ' aria-invalid="true"' : ''}${required ? ' required' : ''}${disabled ? ' disabled' : ''}`;
193
- const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${type}" placeholder="${esc(placeholder)}" value="${esc(value)}"${attrs}>`;
193
+ const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${esc(type)}" placeholder="${esc(placeholder)}" value="${esc(value)}"${attrs}>`;
194
194
  if (!ic) return el;
195
195
  return `<div class="ui-input-group"><span class="ui-input-group__icon">${icon(ic)}</span>${el}</div>`;
196
196
  }
197
197
  export function textarea({ placeholder = '', value = '', rows = 4, name, id, ariaLabel } = {}) {
198
- return `<textarea class="ui-textarea" rows="${rows}" placeholder="${esc(placeholder)}"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}>${esc(value)}</textarea>`;
198
+ return `<textarea class="ui-textarea" rows="${esc(rows)}" placeholder="${esc(placeholder)}"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}>${esc(value)}</textarea>`;
199
199
  }
200
200
  // Native <select>. Pass a `label` via field() or an `ariaLabel` for a bare one —
201
201
  // a select with neither has no accessible name.
@@ -206,20 +206,20 @@ export function select({ options = [], value, name, id, ariaLabel, disabled } =
206
206
  const sel = value != null && String(val) === String(value);
207
207
  return `<option value="${esc(val)}"${sel ? ' selected' : ''}>${esc(label)}</option>`;
208
208
  }).join('');
209
- return `<select class="ui-select"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}>${opts}</select>`;
209
+ return `<select class="ui-select"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}>${opts}</select>`;
210
210
  }
211
211
  export function checkbox({ label, checked, type = 'checkbox', name } = {}) {
212
- return `<label class="ui-check"><input type="${type}"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
212
+ return `<label class="ui-check"><input type="${esc(type)}"${name ? ` name="${esc(name)}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
213
213
  }
214
214
  // `label` becomes the input's accessible name (a bare switch has no visible text,
215
215
  // so it needs one). Defaults to "Toggle" so a control is never left unlabelled.
216
216
  export function switchToggle({ checked = false, disabled = false, name, label = 'Toggle' } = {}) {
217
- return `<label class="ui-switch"><input type="checkbox"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}${disabled ? ' disabled' : ''} aria-label="${esc(label)}"><span class="ui-switch__track"></span></label>`;
217
+ return `<label class="ui-switch"><input type="checkbox"${name ? ` name="${esc(name)}"` : ''}${checked ? ' checked' : ''}${disabled ? ' disabled' : ''} aria-label="${esc(label)}"><span class="ui-switch__track"></span></label>`;
218
218
  }
219
219
 
220
220
  // ---- Callout / toast / success ------------------------------------------
221
221
  export function callout({ variant, icon: ic = 'info', body } = {}) {
222
- return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
222
+ return `<div class="${esc(cx('ui-callout', variant && `ui-callout--${variant}`))}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
223
223
  }
224
224
  // Default icon per status — overridable with `icon`. The circle family is not
225
225
  // decoration: a circled glyph is a STATE the system reports, a bare one is an
@@ -250,7 +250,7 @@ export function toast({
250
250
  const bodyHtml = compact || !body ? '' : `<div class="ui-toast__text">${esc(body)}</div>`;
251
251
  const dur = typeof timer === 'number' ? ` style="--toast-dur:${timer}s"` : '';
252
252
  const timerBar = timer ? '<span class="ui-toast__timer" data-toast-timer></span>' : '';
253
- return `<div class="${cls}" role="status" aria-live="polite"${dur}>`
253
+ return `<div class="${esc(cls)}" role="status" aria-live="polite"${dur}>`
254
254
  + `<span class="ui-toast__icon">${icon(ic || TOAST_ICON[variant] || 'info')}</span>`
255
255
  + `<div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${bodyHtml}</div>`
256
256
  + `${actBtn}${closeBtn}${timerBar}</div>`;
@@ -28,7 +28,7 @@ export function skeleton({ lines = 3, width, height, radius, className = '' } =
28
28
  };
29
29
  const bars = Array.from({ length: n }, (_, i) =>
30
30
  `<span class="ui-skel__bar m-skeleton"${styleFor(i)}></span>`).join('');
31
- return `<div class="${cx('ui-skel', className)}" aria-hidden="true">${bars}</div>`;
31
+ return `<div class="${esc(cx('ui-skel', className))}" aria-hidden="true">${bars}</div>`;
32
32
  }
33
33
 
34
34
  // A table's worth of skeleton — `rows` × `cols` of bar, laid out on a grid so
@@ -36,7 +36,7 @@ export function skeleton({ lines = 3, width, height, radius, className = '' } =
36
36
  // load a table are the common case, and hand-rolling this per screen is how
37
37
  // four slightly different loading tables get shipped.
38
38
  export function skeletonTable({ rows = 5, cols = 4, head = true } = {}) {
39
- const row = (cls) => `<div class="${cls}">`
39
+ const row = (cls) => `<div class="${esc(cls)}">`
40
40
  + Array.from({ length: Math.max(1, cols | 0) }, () => '<span class="ui-skel__bar m-skeleton"></span>').join('')
41
41
  + '</div>';
42
42
  const body = Array.from({ length: Math.max(1, rows | 0) }, () => row('ui-skel__row')).join('');
@@ -56,7 +56,7 @@ export function busyRegion({
56
56
  body, lines = 3, className = '',
57
57
  } = {}) {
58
58
  const inner = body != null ? body : skeleton({ lines });
59
- return `<div class="${cx('ui-busy', className)}" data-busy`
59
+ return `<div class="${esc(cx('ui-busy', className))}" data-busy`
60
60
  + ` data-busy-label="${esc(label)}" data-busy-ready="${esc(readyLabel)}"`
61
61
  + ` role="status" aria-live="polite" aria-busy="${busy ? 'true' : 'false'}">`
62
62
  + `<span class="ui-sr" data-busy-msg>${esc(busy ? label : readyLabel)}</span>`
@@ -119,7 +119,7 @@ export function deniedState({
119
119
  const actionsEl = actions.length
120
120
  ? `<div class="ui-denied__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
121
121
  : '';
122
- return `<div class="${cx('ui-denied', className)}">`
122
+ return `<div class="${esc(cx('ui-denied', className))}">`
123
123
  + `<div class="ui-denied__seal" aria-hidden="true">${icon(ic)}</div>`
124
124
  + `<div class="ui-denied__title">${esc(title)}</div>`
125
125
  + `${sub ? `<div class="ui-denied__sub">${esc(sub)}</div>` : ''}`
@@ -8,7 +8,7 @@
8
8
  // is for). Only the collapsible sidebar groups need JS; wire with wireNav().
9
9
  import { esc, icon } from './index.js';
10
10
  import { playEntrance } from '../motion.js';
11
-
11
+ import { safeUrl } from '../html.js';
12
12
  const cx = (...a) => a.filter(Boolean).join(' ');
13
13
 
14
14
  // Unique-per-render ids so a section heading can label its own list. Module
@@ -28,7 +28,7 @@ function navBadge(badge) {
28
28
  const text = badgeText(badge);
29
29
  if (!text) return '';
30
30
  const tone = typeof badge === 'object' && badge.tone ? badge.tone : 'neutral';
31
- return `<span class="${cx('ui-nav__badge', `is-${tone}`)}">${esc(text)}</span>`;
31
+ return `<span class="${esc(cx('ui-nav__badge', `is-${tone}`))}">${esc(text)}</span>`;
32
32
  }
33
33
 
34
34
  // An item's accessible name. It is emitted at every width, not only when
@@ -59,7 +59,7 @@ function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
59
59
  }
60
60
  const attrs = [
61
61
  `class="${cls}"`,
62
- `href="${esc(it.href || '#' + (it.id ?? ''))}"`,
62
+ `href="${esc(safeUrl(it.href || '#' + (it.id ?? '')))}"`,
63
63
  it.target ? `target="${esc(it.target)}"` : '',
64
64
  on ? `aria-current="${current}"` : '',
65
65
  name.trim(),
@@ -140,13 +140,13 @@ export function navTabs({
140
140
  }
141
141
  const attrs = [
142
142
  `class="${cx('ui-nav__tab', on && 'is-active')}"`,
143
- `href="${esc(it.href || '#' + (it.id ?? ''))}"`,
143
+ `href="${esc(safeUrl(it.href || '#' + (it.id ?? '')))}"`,
144
144
  it.target ? `target="${esc(it.target)}"` : '',
145
145
  on ? 'aria-current="page"' : '',
146
146
  ].filter(Boolean).join(' ');
147
147
  return `<a ${attrs}>${inner}</a>`;
148
148
  }).join('');
149
- return `<nav class="${cx('ui-nav', 'ui-nav--tabs', `is-${variant}`)}"` +
149
+ return `<nav class="${esc(cx('ui-nav', 'ui-nav--tabs', `is-${variant}`))}"` +
150
150
  ` aria-label="${esc(ariaLabel)}"${id ? ` id="${esc(id)}"` : ''}>${tabs}</nav>`;
151
151
  }
152
152
 
@@ -161,7 +161,7 @@ export function breadcrumbs({ items = [], ariaLabel = 'Breadcrumb', id } = {}) {
161
161
  const inner = `${lead}${label}`;
162
162
  const isLast = i === last;
163
163
  const cell = (!isLast && it.href)
164
- ? `<a class="ui-nav__crumb" href="${esc(it.href)}"${it.target ? ` target="${esc(it.target)}"` : ''}>${inner}</a>`
164
+ ? `<a class="ui-nav__crumb" href="${esc(safeUrl(it.href))}"${it.target ? ` target="${esc(it.target)}"` : ''}>${inner}</a>`
165
165
  : `<span class="${cx('ui-nav__crumb', isLast && 'is-current')}"${isLast ? ' aria-current="page"' : ''}>${inner}</span>`;
166
166
  return `<li class="ui-nav__crumb-item">${cell}</li>`;
167
167
  }).join('');
@@ -8,7 +8,7 @@
8
8
  // fetched. The component then knows no last page, so it draws Prev and Next and
9
9
  // nothing else — see `ui-pager--open` below.
10
10
  import { esc } from './index.js';
11
-
11
+ import { safeUrl } from '../html.js';
12
12
  // The sizes a table offers, and the one it starts on. Named here so no call site
13
13
  // writes either number: both moved once already and would have moved in thirteen
14
14
  // files. MUI's DataGrid ships exactly this pair (default 100, options 25/50/100),
@@ -74,7 +74,7 @@ const fmt = (n) => n.toLocaleString('en-US');
74
74
  function control({ cls, page, label, href, disabled = false, current = false }) {
75
75
  const attrs = `class="${cls}" data-page="${page}"${current ? ' aria-current="page"' : ''}`;
76
76
  return href && !disabled
77
- ? `<a href="${esc(href(page))}" ${attrs}>${esc(label)}</a>`
77
+ ? `<a href="${esc(safeUrl(href(page)))}" ${attrs}>${esc(label)}</a>`
78
78
  : `<button type="button" ${attrs}${disabled ? ' disabled aria-disabled="true"' : ''}>${esc(label)}</button>`;
79
79
  }
80
80
 
@@ -10,7 +10,7 @@ import { backLink } from './back.js';
10
10
  import { prism } from '../assets/brand.js';
11
11
  import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
12
12
  import { paletteHotkey } from './command-palette.js';
13
-
13
+ import { safeUrl } from '../html.js';
14
14
  // The one account navigation definition lives in account-nav.js because topbar.js needs
15
15
  // it too; re-exported here so the name docs/library.md publishes keeps working.
16
16
  export { ACCOUNT_NAV };
@@ -294,7 +294,7 @@ export function appShell(options = {}) {
294
294
  // the name is written out — the mark itself is aria-hidden. The banded layout's own
295
295
  // bar says no word, so there the rail keeps the lockup: that layout's head band IS
296
296
  // the product's mark. why: docs/specification.md#the-page-shell
297
- const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
297
+ const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(safeUrl(brandHref))}" aria-label="${esc(word)}">`
298
298
  + `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
299
299
  // A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
300
300
  // page's primary navigation and the signed-in reader. The <nav> inside it is already
@@ -46,9 +46,9 @@ function confettiField() {
46
46
  // Optional auto-redirect countdown. Markup-only here (a conic ring sweeps and a
47
47
  // static number shows); wireSuccess() turns it into a live ticking counter.
48
48
  function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
49
- return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${seconds}s">
49
+ return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${esc(seconds)}s">
50
50
  <span class="ui-sx__count-ring" aria-hidden="true"></span>
51
- <span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${seconds}</b>s</span>
51
+ <span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${esc(seconds)}</b>s</span>
52
52
  </div>`;
53
53
  }
54
54
 
@@ -103,7 +103,7 @@ export function success({
103
103
  : '';
104
104
  const countEl = countdown ? countdownEl(countdown) : '';
105
105
 
106
- return `<div class="${cls}" role="status" aria-live="polite">
106
+ return `<div class="${esc(cls)}" role="status" aria-live="polite">
107
107
  ${bd}
108
108
  ${confetti ? confettiField() : ''}
109
109
  <div class="ui-sx__inner">
@@ -1,5 +1,5 @@
1
1
  import { esc } from './index.js';
2
-
2
+ import { safeUrl } from '../html.js';
3
3
  export function numericValue({ value, unit = '', missing = 'Not available' } = {}) {
4
4
  if (value == null || value === '') return `<span class="ui-value" aria-label="${esc(missing)}">—</span>`;
5
5
  return `<span class="ui-value">${esc(value)}${unit ? `<span class="ui-value__unit">${esc(unit)}</span>` : ''}</span>`;
@@ -13,8 +13,8 @@ export function deltaValue({ value, tone = 'neutral', basisId, missing = 'No ear
13
13
 
14
14
  export function rowIdentity({ symbol = '', name = '', logo, href } = {}) {
15
15
  const tag = href ? 'a' : 'span';
16
- return `<${tag} class="ui-identity"${href ? ` href="${esc(href)}"` : ''}>`
17
- + `<span class="ui-identity__logo" aria-hidden="true"><span>${esc(symbol.slice(0, 1))}</span>${logo ? `<img src="${esc(logo)}" alt="">` : ''}</span>`
16
+ return `<${tag} class="ui-identity"${href ? ` href="${esc(safeUrl(href))}"` : ''}>`
17
+ + `<span class="ui-identity__logo" aria-hidden="true"><span>${esc(symbol.slice(0, 1))}</span>${logo ? `<img src="${esc(safeUrl(logo, ''))}" alt="">` : ''}</span>`
18
18
  + `<span class="ui-identity__symbol">${esc(symbol)}</span><span class="ui-identity__name">${esc(name)}</span></${tag}>`;
19
19
  }
20
20
 
@@ -15,14 +15,14 @@
15
15
  // aria-selected, and aria-controls / aria-labelledby wiring. `name` must be unique
16
16
  // per tabs instance on a page (it seeds the tab/panel ids).
17
17
  import { playEntrance } from '../motion.js';
18
-
18
+ import { esc } from '../html.js';
19
19
  export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs', className = '' } = {}) {
20
20
  const cls = ['ui-tabs', className].filter(Boolean).join(' ');
21
21
  const list = items
22
22
  .map((it, i) => {
23
23
  const on = i === active;
24
24
  return `<button type="button" class="ui-tabs__tab${on ? ' is-active' : ''}" role="tab"`
25
- + ` id="${name}-tab-${i}" aria-controls="${name}-panel-${i}"`
25
+ + ` id="${esc(name)}-tab-${i}" aria-controls="${esc(name)}-panel-${i}"`
26
26
  + ` aria-selected="${on ? 'true' : 'false'}" tabindex="${on ? '0' : '-1'}">${it.label}</button>`;
27
27
  })
28
28
  .join('');
@@ -33,12 +33,12 @@ export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs'
33
33
  // focusable content of its own, and most panels here are plain text. Without
34
34
  // it a keyboard user reaches the tab strip and then cannot reach what it
35
35
  // switched to — Tab jumps straight past the panel to whatever follows.
36
- return `<div class="ui-tabs__panel" role="tabpanel" id="${name}-panel-${i}"`
37
- + ` aria-labelledby="${name}-tab-${i}" tabindex="0"${on ? '' : ' hidden'}>${it.panel || ''}</div>`;
36
+ return `<div class="ui-tabs__panel" role="tabpanel" id="${esc(name)}-panel-${i}"`
37
+ + ` aria-labelledby="${esc(name)}-tab-${i}" tabindex="0"${on ? '' : ' hidden'}>${it.panel || ''}</div>`;
38
38
  })
39
39
  .join('');
40
- return `<div class="${cls}" data-tabs>`
41
- + `<div class="ui-tabs__list" role="tablist" aria-label="${ariaLabel}">${list}</div>`
40
+ return `<div class="${esc(cls)}" data-tabs>`
41
+ + `<div class="ui-tabs__list" role="tablist" aria-label="${esc(ariaLabel)}">${list}</div>`
42
42
  + `<div class="ui-tabs__panels">${panels}</div>`
43
43
  + `</div>`;
44
44
  }
@@ -5,7 +5,7 @@ import { icon, sun, moon } from '../assets/icons.js';
5
5
  import { esc } from './index.js';
6
6
  import { wireDropdown } from './dropdown.js';
7
7
  import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
8
-
8
+ import { trustedAttr, trustedUrl } from '../html.js';
9
9
  const THEME_KEY = 'apliteni-strategy-theme';
10
10
 
11
11
  // A stateful control in this kit reports the state it is IN, never the state a
@@ -60,7 +60,7 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
60
60
  // `nav` mirrors the account sidebar, DERIVED from the one ACCOUNT_NAV definition
61
61
  // rather than restated: a second literal agreed with it by hand about the icon
62
62
  // and disagreed about the encoding, which is the drift #127 was filed about.
63
- // Every field below is interpolated raw, so what arrives has to arrive escaped.
63
+ // Body slots below are trusted HTML; attribute copies retain encoded entities.
64
64
  //
65
65
  // `initials` is the avatar. A derived value has to be derived BEFORE the
66
66
  // escaping — `<Ada>` and `&lt;Ada&gt;` do not begin with the same character — so
@@ -84,13 +84,13 @@ export function accountMenu({
84
84
  const items = (Array.isArray(nav) ? nav : accountMenuNav())
85
85
  .map((n) => (Array.isArray(n) ? n : toMenuTuple(n)));
86
86
  const it = ([id, ic, label, href, target]) =>
87
- `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
87
+ `<a href="${trustedUrl(href || '#' + id)}"${target ? ` target="${trustedAttr(target)}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
88
88
  // `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
89
89
  // Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
90
90
  return `<div class="acct on" data-dropdown>` +
91
91
  `<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
92
92
  `<div class="amenu" data-dropdown-panel role="menu">` +
93
- `<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${email}">${email}</span></span></div>` +
93
+ `<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>` +
94
94
  items.map(it).join('') +
95
95
  `<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
96
96
  `</div></div>`;
package/src/html.js ADDED
@@ -0,0 +1,23 @@
1
+ // Shared HTML boundaries; public esc remains exported from components/index.js.
2
+ const ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
3
+ export const esc = value => String(value == null ? '' : value).replace(/[&<>"]/g, c => ENTITIES[c]);
4
+
5
+ // Legacy topbar/brand slots accept already escaped HTML. Preserve their entities.
6
+ export const trustedAttr = value => String(value ?? '').replace(/[<>"]/g, c => ENTITIES[c]);
7
+
8
+ // why: docs/specification.md#vanilla-html-boundaries
9
+ export function safeUrl(value, fallback = '#') {
10
+ const raw = String(value ?? '');
11
+ const scheme = raw.replace(/[\t\n\r]/g, '').replace(/^[\u0000-\u0020]+/, '');
12
+ return /^(?:javascript|data|vbscript):/i.test(scheme) ? fallback : raw;
13
+ }
14
+
15
+ export function trustedUrl(value) {
16
+ // Legacy attributes are already HTML encoded, so check their decoded scheme.
17
+ const decoded = String(value ?? '').replace(/&#(x[\da-f]+|\d+);?|&(colon|Tab|NewLine);/gi, (match, number, name) => {
18
+ if (!number) return { colon: ':', tab: '\t', newline: '\n' }[name.toLowerCase()];
19
+ const code = number[0].toLowerCase() === 'x' ? parseInt(number.slice(1), 16) : Number(number);
20
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
21
+ });
22
+ return safeUrl(decoded) === '#' ? '#' : trustedAttr(value);
23
+ }