anentrypoint-design 0.0.388 → 0.0.390

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": "anentrypoint-design",
3
- "version": "0.0.388",
3
+ "version": "0.0.390",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -98,7 +98,11 @@
98
98
  "docs:components": "node scripts/generate-component-docs.mjs",
99
99
  "lint:component-docs": "node scripts/generate-component-docs.mjs --check",
100
100
  "generate:ui-kits": "node scripts/generate-ui-kit-scaffolds.mjs",
101
- "generate:preview-index": "node scripts/generate-preview-index.mjs"
101
+ "generate:preview-index": "node scripts/generate-preview-index.mjs",
102
+ "a11y": "node scripts/a11y-audit.mjs",
103
+ "a11y:baseline": "node scripts/a11y-audit.mjs --write-baseline",
104
+ "visual": "node scripts/visual-baseline.mjs check",
105
+ "visual:update": "node scripts/visual-baseline.mjs update"
102
106
  },
103
107
  "repository": {
104
108
  "type": "git",
@@ -71,8 +71,16 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
71
71
  }, 'retry') : null)];
72
72
  const reactionRow = reactions && reactions.length
73
73
  ? h('div', { class: 'chat-reactions' },
74
- ...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
75
- h('span', { class: 'e', 'aria-hidden': 'true' }, r.emoji), h('span', { class: 'n', 'aria-hidden': 'true' }, String(r.count)))))
74
+ // A bare <span> has no role, so it can carry no accessible name
75
+ // an aria-label here was silently DISCARDED and the whole reaction
76
+ // announced as nothing, while aria-hidden suppressed the only real
77
+ // text. So the visible label/count stay in the accessibility tree
78
+ // as content, and an .sr-only span supplies just the wording the
79
+ // visuals imply but do not spell out.
80
+ ...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i },
81
+ h('span', { class: 'e' }, r.emoji),
82
+ h('span', { class: 'n' }, String(r.count)),
83
+ h('span', { class: 'sr-only' }, ` ${String(r.count) === '1' ? 'reaction' : 'reactions'}${r.you ? ', you reacted' : ''}`))))
76
84
  : null;
77
85
  const tickNode = resolvedWho === 'you' && receipt
78
86
  ? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), role: 'img', 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, Icon(receipt === 'read' ? 'check-check' : 'check', { size: 14 }))
@@ -71,7 +71,12 @@ export const AICAT_FACE = ` /\\_/\\\n( o.o )\n > ^ <`;
71
71
 
72
72
  export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
73
73
  return h('div', { class: 'aicat-portrait' },
74
- h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
74
+ // role="img" collapses the ASCII art into a single named image for a
75
+ // screen reader (otherwise the slashes and parens are read out
76
+ // character by character as text). It is also what makes the
77
+ // aria-label legal here: a bare <pre> has no role and so can carry no
78
+ // accessible name, exactly like the chat reaction spans.
79
+ h('pre', { class: 'aicat-face', role: 'img', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
75
80
  h('div', { class: 'aicat-meta' },
76
81
  h('span', { class: 'name' }, name),
77
82
  h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
@@ -46,7 +46,7 @@ export function ChannelItem({ id, name, type = 'text', active, voiceActive, voic
46
46
  const ICON_FOR = CHANNEL_ICON_FOR;
47
47
  const icon = Icon(ICON_FOR[type] || 'hash', { size: 15 });
48
48
  const handleActionClick = (a, e) => { e.stopPropagation(); a.onClick && a.onClick(id, e); };
49
- return h('div', { class: 'cm-channel-item-wrap', 'data-channel-wrap': id },
49
+ return h('div', { class: 'cm-channel-item-wrap', 'data-channel-wrap': id, role: 'listitem' },
50
50
  h('div', {
51
51
  class: 'cm-channel-item' + (active ? ' active' : '') + (voiceActive ? ' voice-active' : '') + (voiceConnecting ? ' voice-connecting' : ''),
52
52
  'data-id': id,
@@ -72,7 +72,15 @@ export function ChannelItem({ id, name, type = 'text', active, voiceActive, voic
72
72
  }
73
73
  },
74
74
  tabindex: '0',
75
- role: 'option'
75
+ // NOT role="option": this is channel NAVIGATION, not a select-one
76
+ // listbox — activating a row changes the view, and each row also
77
+ // contains its own action buttons, which `option` forbids (its
78
+ // children must be presentational). A link inside a list is the
79
+ // structure a screen reader should announce, and the current
80
+ // channel is conveyed by aria-current="page", the navigation
81
+ // idiom, rather than aria-selected.
82
+ role: 'link',
83
+ 'aria-current': active ? 'page' : null
76
84
  },
77
85
  h('span', { class: 'cm-ch-icon' + (voiceActive ? ' voice-active-badge' : ''), 'data-voice-active': voiceActive ? 'true' : null }, icon),
78
86
  voiceConnecting ? h('span', { class: 'cm-ch-spinner', title: 'Connecting…', 'aria-label': 'Connecting to voice channel…' }) : voiceActive ? h('span', { class: 'cm-ch-voice-badge', title: 'Voice active', 'aria-label': 'Voice channel active' }) : null,
@@ -108,7 +116,10 @@ export function ChannelCategory({ id, name, channels = [], collapsed, activeId,
108
116
  extraButton ? h('button', { class: 'cm-cat-extra', onclick: (e) => { e.stopPropagation(); extraButton.onClick && extraButton.onClick(id, e); }, 'aria-label': extraButton.title || 'Category action' }, extraButton.icon || extraButton.label || '+') : null,
109
117
  onAddChannel ? h('button', { class: 'cm-cat-add', onclick: (e) => { e.stopPropagation(); onAddChannel(id); }, 'aria-label': 'Add channel to ' + name }, '+') : null
110
118
  ),
111
- collapsed ? null : h('div', { class: 'cm-cat-channels' },
119
+ // role=list + role=listitem on each wrap: the channel rows are a real
120
+ // list of navigation targets, so a screen reader announces position
121
+ // and count ("3 of 7") instead of a flat run of links.
122
+ collapsed ? null : h('div', { class: 'cm-cat-channels', role: 'list', 'aria-label': name + ' channels' },
112
123
  ...channels.map(c => ChannelItem({
113
124
  ...c,
114
125
  draggable: channelDraggable,
@@ -13,7 +13,13 @@ export function Topbar({ brand = '247420', leaf = '', items = [], active = '', o
13
13
  return h('header', { class: 'app-topbar', role: 'banner' },
14
14
  Brand({ name: brand, leaf }),
15
15
  search ? h('label', { class: 'app-search' },
16
- h('span', { class: 'icon', 'aria-hidden': 'true' }, 'search'),
16
+ // Line-icon, not the literal word "search" as a pseudo-glyph: the
17
+ // text stand-in inherited .app-search .icon's 0.6 opacity, which
18
+ // dropped --fg-3 to 3.74:1 on --bg-2 and failed AA as real text.
19
+ // An SVG is decorative (aria-hidden) rather than text, so the
20
+ // contrast rule no longer applies to it and the affordance stops
21
+ // depending on a colour value at all.
22
+ h('span', { class: 'icon', 'aria-hidden': 'true' }, Icon('search', { size: 15 })),
17
23
  // `search` is either a plain placeholder string (renders the
18
24
  // default uncontrolled input) or a caller-built VElement (has
19
25
  // .type/.props — e.g. a controlled <input> wired to app state)
@@ -186,9 +192,15 @@ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
186
192
  h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
187
193
  h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: (e) => toggleSide(false, e.currentTarget) }),
188
194
  h('div', { class: 'app-side-shell', id: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
189
- // tabindex=-1 so the skip-link (href="#app-main") actually moves
190
- // keyboard focus into the main region, not just scroll to it.
191
- h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '-1' }, ...(Array.isArray(main) ? main : [main]))
195
+ // tabindex=0 (not -1): .app-main is a scroll container
196
+ // (overflow:auto), so it must be reachable by Tab for a
197
+ // keyboard-only user to scroll it with the arrow keys at all
198
+ // tabindex=-1 made it focusable only programmatically, which
199
+ // satisfied the skip-link but left the region unscrollable
200
+ // without a pointer. 0 keeps the skip-link target working AND
201
+ // puts the region in the tab order. <main> is a landmark, so it
202
+ // is already named for assistive tech without an aria-label.
203
+ h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '0' }, ...(Array.isArray(main) ? main : [main]))
192
204
  ),
193
205
  status || null
194
206
  );
@@ -11,6 +11,7 @@ const h = webjsx.createElement;
11
11
  // coherent line-icon set instead of multicolor OS emoji. 16px box, 1.6 stroke.
12
12
  export const ICON_PATHS = {
13
13
  lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
14
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
14
15
  mic: '<path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
15
16
  'mic-off': '<path d="M9 9v2a3 3 0 0 0 4.5 2.6M15 11V6a3 3 0 0 0-5.9-.8"/><path d="M5 11a7 7 0 0 0 11.5 5.4M12 18v3"/><path d="m4 4 16 16"/>',
16
17
  speaker: '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>',
@@ -101,16 +101,37 @@ export function WsResizer(col) {
101
101
  document.body.style.cursor = 'col-resize';
102
102
  };
103
103
  const [lo, hi] = WS_RESIZE_CLAMP[col] || [120, 600];
104
- // Seed aria-valuenow from the rendered track width so AT announces real widths.
104
+ // Seed aria-valuenow from the rendered track width so AT announces real
105
+ // widths. Deferred a frame: the resizers are rendered as the LAST children
106
+ // of .ws-shell, so at ref time the .ws-<col> track is not in the DOM yet
107
+ // and this measured nothing — aria-valuenow was simply never set, which is
108
+ // what `aria-required-attr` (role=separator requires it) was reporting.
109
+ // The markup below now also ships a valid value up-front, so the attribute
110
+ // is present even if this correction never runs.
105
111
  const seedNow = (el) => {
106
112
  if (!el) return;
107
- const track = el.closest('.ws-shell') && el.closest('.ws-shell').querySelector('.ws-' + col);
108
- if (track) { const w = Math.round(track.getBoundingClientRect().width); el.setAttribute('aria-valuenow', String(w)); el.setAttribute('aria-valuetext', w + ' pixels'); }
113
+ const measure = () => {
114
+ const shell = el.closest('.ws-shell');
115
+ const track = shell && shell.querySelector('.ws-' + col);
116
+ if (!track) return;
117
+ const w = Math.round(track.getBoundingClientRect().width);
118
+ if (!w) return;
119
+ el.setAttribute('aria-valuenow', String(w));
120
+ el.setAttribute('aria-valuetext', w + ' pixels');
121
+ };
122
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(measure);
123
+ else measure();
109
124
  };
110
125
  return h('div', {
111
126
  class: 'ws-resizer ws-resizer-' + col, role: 'separator', tabindex: '0',
112
127
  'aria-orientation': 'vertical', 'aria-label': 'resize ' + col + ' column (arrow keys)',
113
- 'aria-valuemin': String(lo), 'aria-valuemax': String(hi), 'aria-valuetext': String(hi) + ' pixels',
128
+ // valuenow/valuetext seeded at the clamp MINIMUM (and corrected to the
129
+ // measured width by seedNow on the next frame). The previous markup
130
+ // omitted aria-valuenow entirely — required by role=separator when it
131
+ // is focusable — and hardcoded valuetext to the clamp MAXIMUM, so the
132
+ // announced width contradicted the actual one.
133
+ 'aria-valuemin': String(lo), 'aria-valuemax': String(hi),
134
+ 'aria-valuenow': String(lo), 'aria-valuetext': String(lo) + ' pixels',
114
135
  onpointerdown: onDown, onkeydown: onKey, ref: seedNow,
115
136
  });
116
137
  }
@@ -501,8 +501,17 @@
501
501
  display: inline-flex; align-items: center; justify-content: center;
502
502
  background: color-mix(in oklab, var(--mascot) 22%, var(--bg-2));
503
503
  border-radius: 50%; flex-shrink: 0;
504
+ /* Cancels the global `pre { overflow-x: auto }`. This is a fixed 32px
505
+ decorative chip, so the art overflowing its own box made it an
506
+ unreachable keyboard scroll container (WCAG scrollable-region-focusable)
507
+ rather than something a user could ever usefully scroll. */
508
+ overflow: hidden;
504
509
  }
505
510
  .aicat-meta { display: flex; flex-direction: column; font-family: var(--ff-mono); font-size: var(--fs-xs); color: var(--fg-3); }
511
+ /* --mascot-deep is now theme-tuned at the token layer (paper #B81F63 /
512
+ ink #FF5C9E), so this needs no per-theme override. The previous
513
+ [data-theme="dark"],[data-theme="ink"] override missed data-theme="auto"
514
+ under OS-dark, which is how the kits actually render — the author name
515
+ fell back to the paper tone at 3.13:1 on ink. */
506
516
  .aicat-meta .name { color: var(--mascot-deep, var(--mascot)); font-weight: 600; }
507
- [data-theme="dark"] .aicat-meta .name, [data-theme="ink"] .aicat-meta .name { color: var(--mascot); }
508
517