anentrypoint-design 0.0.400 → 0.0.402

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.400",
3
+ "version": "0.0.402",
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",
@@ -116,7 +116,8 @@
116
116
  "a11y": "node scripts/a11y-audit.mjs",
117
117
  "a11y:baseline": "node scripts/a11y-audit.mjs --write-baseline",
118
118
  "visual": "node scripts/visual-baseline.mjs check",
119
- "visual:update": "node scripts/visual-baseline.mjs update"
119
+ "visual:update": "node scripts/visual-baseline.mjs update",
120
+ "lint:dead-controls": "node scripts/lint-dead-controls.mjs"
120
121
  },
121
122
  "repository": {
122
123
  "type": "git",
@@ -27,6 +27,7 @@ import { lintInlineStylesOrThrow } from './lint-inline-styles.mjs';
27
27
  import { lintDuplicateSelectorsOrThrow } from './lint-duplicate-selectors.mjs';
28
28
  import { lintSwallowCommentsOrThrow } from './lint-swallow-comments.mjs';
29
29
  import { lintInlineCssOrThrow } from './lint-inline-css.mjs';
30
+ import { lintDeadControlsOrThrow } from './lint-dead-controls.mjs';
30
31
 
31
32
  // Each entry: a human label for the report, and the check function to run.
32
33
  // EVERY check here throws on violation and is counted in the pass/fail tally.
@@ -60,6 +61,12 @@ const CHECKS = [
60
61
  ['inline-styles', lintInlineStylesOrThrow],
61
62
  ['duplicate-selectors', lintDuplicateSelectorsOrThrow],
62
63
  ['swallow-comments', lintSwallowCommentsOrThrow],
64
+ // A rendered control that cannot act — an empty handler body, or an
65
+ // href="#" that goes nowhere. Both shipped in quantity (6 no-op handlers
66
+ // across three kits, 24 placeholder links across blog and docs) precisely
67
+ // because nothing checked: the affordance renders either way, so the defect
68
+ // is invisible until someone clicks.
69
+ ['dead-controls', lintDeadControlsOrThrow],
63
70
  ];
64
71
 
65
72
  // Runs every rule module's check, prints one aggregated report, and returns
@@ -202,6 +202,10 @@ export function mountCommunityApp(root, adapter = {}) {
202
202
  : chatView(s);
203
203
  const showVoiceBanner = s.voiceConnected && s.voiceChannelName && !(inVoiceChannel && s.voiceChannelName === ch.name);
204
204
  return h('div', { class: 'ca-app' },
205
+ // Same skip-link contract AppShell() provides. This app builds its
206
+ // own chrome, so without this a keyboard user had to tab through
207
+ // the whole topbar nav and channel rail to reach the messages.
208
+ h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
205
209
  // top bar (sole app chrome above the chat-head)
206
210
  h('header', { class: 'app-topbar' },
207
211
  h('span', { class: 'brand' }, 'zellous', h('span', { class: 'slash' }, ' / '), h('span', {}, ch.name || 'general')),
@@ -217,7 +221,15 @@ export function mountCommunityApp(root, adapter = {}) {
217
221
  Banner({ tone: 'success', visible: !!showVoiceBanner, message: showVoiceBanner ? ('In voice: ' + (s.voiceChannelName || '') + ' — click to return') : '', actionLabel: 'Leave', onAction: (e) => { if (e && e.stopPropagation) e.stopPropagation(); A.leaveVoice && A.leaveVoice(); }, onClick: () => A.returnToVoice && A.returnToVoice() }),
218
222
  h('div', { class: 'app-body' + (s.mobileMenuOpen ? ' ca-rail-open' : '') },
219
223
  h('aside', { class: 'app-side ca-rail' + (s.mobileMenuOpen ? ' open' : '') }, railView(s)),
220
- h('main', { class: 'app-main', onclick: () => { if (s.mobileMenuOpen && A.closeMobileMenu) A.closeMobileMenu(); } },
224
+ // id + tabindex match AppShell()'s contract so the skip link
225
+ // above actually lands somewhere; this app builds its own shell
226
+ // and so inherited neither.
227
+ h('main', { class: 'app-main', id: 'app-main', tabindex: '0', onclick: () => { if (s.mobileMenuOpen && A.closeMobileMenu) A.closeMobileMenu(); } },
228
+ // The channel name is the page title, but it lived only in
229
+ // the topbar brand span, leaving the document with no
230
+ // heading at all. sr-only because the topbar and chat-head
231
+ // already show it — this adds the semantics, not a visual.
232
+ h('h1', { class: 'sr-only' }, ch.name || 'general'),
221
233
  !inVoiceChannel && s.voiceConnected ? VoiceStrip({ channelName: s.voiceChannelName, status: s.voiceConnectionState || 'connected', muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onLeave: () => A.leaveVoice && A.leaveVoice(), open: true }) : null,
222
234
  UserPanel({ name: (s.currentUser && (s.currentUser.displayName || s.currentUser.username || s.currentUser.name)) || 'You', tag: s.currentUser && s.currentUser.tag, color: avatarColor(s.userId), muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onSettings: () => A.openSettings && A.openSettings() }),
223
235
  bodyMain,
@@ -10,7 +10,14 @@ const h = webjsx.createElement;
10
10
  export function ChatHeader({ icon = '#', name, topic, toolbar = [] } = {}) {
11
11
  return h('div', { class: 'cm-chat-header' },
12
12
  h('span', { class: 'cm-chat-header-icon' }, icon),
13
- h('span', { class: 'cm-chat-header-name' }, name),
13
+ // h1, not span: this is already the visible title of the content column
14
+ // — the channel you are reading. It carried no heading semantics, so
15
+ // both community kits rendered a document with zero headings. An h1
16
+ // here reuses the element that was always the page title rather than
17
+ // adding a second, hidden one. .cm-chat-header-name pins weight/size/
18
+ // colour and now also zeroes the UA h1 margin, so this is a semantic
19
+ // change with no visual change.
20
+ h('h1', { class: 'cm-chat-header-name' }, name),
14
21
  topic ? h('span', { class: 'cm-chat-header-topic' }, topic) : null,
15
22
  h('div', { class: 'cm-chat-header-toolbar' }, ...toolbar)
16
23
  );
@@ -8,9 +8,18 @@ const h = webjsx.createElement;
8
8
 
9
9
  export function CommunityShell({ serverRailProps, sidebarProps, children, memberListProps, voiceStripProps } = {}) {
10
10
  return h('div', { class: 'cm-shell' },
11
+ // Same skip-link + <main id="app-main"> contract AppShell() provides.
12
+ // This shell is the AppShell alternative for the community layout, so
13
+ // the kits built on it were the only ones in the repo with no main
14
+ // landmark and no way for a keyboard user to get past the server rail
15
+ // and channel sidebar — two full columns of links before any content.
16
+ h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
11
17
  serverRailProps ? ServerRail(serverRailProps) : null,
12
18
  sidebarProps ? ChannelSidebar(sidebarProps) : null,
13
- h('div', { class: 'cm-main' }, ...(Array.isArray(children) ? children : [children])),
19
+ // <main>, not <div>: this is the content column. tabindex=0 for the
20
+ // same reason AppShell uses it — the column scrolls, so it must be
21
+ // reachable by Tab for a keyboard-only user to scroll it at all.
22
+ h('main', { class: 'cm-main', id: 'app-main', tabindex: '0' }, ...(Array.isArray(children) ? children : [children])),
14
23
  memberListProps ? MemberList(memberListProps) : null,
15
24
  voiceStripProps ? VoiceStrip(voiceStripProps) : null
16
25
  );
@@ -100,14 +100,22 @@ export function Select({ label, value = '', options = [], onChange, name, key, p
100
100
  const lab = typeof o === 'string' ? o : (o.label != null ? o.label : (o.id || o.value));
101
101
  opts.push(h('option', { key: 'o-' + id, value: id, selected: id === value }, lab));
102
102
  }
103
+ // When this select is returned bare (the no-label/no-hint/md branch below),
104
+ // it is the node the caller keys, so it must carry the caller's key. The
105
+ // internal 'i' only has to be unique among THIS component's own children,
106
+ // which is why the wrapped branches can keep it. Previously 'i' was
107
+ // hardcoded here and the bare branch dropped `key` on the floor, so two
108
+ // sibling label-less Selects both keyed as 'i' and webjsx's keyed diff
109
+ // collapsed them into one on the next re-render.
110
+ const bare = label == null && hint == null && size === 'md';
103
111
  const select = h('select', {
104
- key: 'i', name, class: 'ds-select',
112
+ key: bare && key != null ? key : 'i', name, class: 'ds-select',
105
113
  // Guarantee an accessible name even when rendered without a visible label.
106
114
  'aria-label': ariaLabel || (label == null ? (title || placeholder || name) : null),
107
115
  title,
108
116
  onchange: onChange ? (e) => onChange(e.target.value, e) : null
109
117
  }, ...opts);
110
- if (label == null && hint == null && size === 'md') return select;
118
+ if (bare) return select;
111
119
  if (label == null && hint == null) return h('label', { key, class: 'ds-field' + sizeCls }, select);
112
120
  return h('label', { key, class: 'ds-field' + sizeCls },
113
121
  label != null ? h('span', { key: 'l', class: 'ds-field-label' }, label) : null,
@@ -93,16 +93,34 @@ export function Side({ sections = [] } = {}) {
93
93
  return h('div', { class: 'app-side-group', key: sec.group, role: 'group', 'aria-labelledby': groupId },
94
94
  h('h2', { class: 'group', id: groupId }, sec.group),
95
95
  ...sec.items.map((item, i) => {
96
- const { glyph, label, href = '#', active, count, color, onClick } = item;
96
+ const { glyph, label, href, active, count, color, onClick } = item;
97
97
  const countLabel = (count != null && count !== 0 && count !== '0') ? ` (${count})` : '';
98
+ // An item with neither href nor onClick is not a control, and
99
+ // must not look like one. href used to default to '#', so a
100
+ // forgotten destination silently produced a pointer-cursor,
101
+ // tab-stoppable anchor that navigated nowhere — which is why
102
+ // dead sidebar rows kept reappearing in new kits: the dead
103
+ // affordance was the DEFAULT. Such an item keeps the <a> (every
104
+ // .app-side row rule is anchor-scoped, so a different element
105
+ // would lose the whole grid layout) but drops href entirely: an
106
+ // anchor with no href is not a link, takes no tab stop, and is
107
+ // styled inert by .app-side a:not([href]) in topbar.css.
108
+ // An onClick with no href IS a real control and still gets
109
+ // href='#' so it stays keyboard-activatable.
110
+ const isControl = href != null || onClick != null;
111
+ // href is spread in only when this row is a control. Setting it
112
+ // to null instead would NOT omit the attribute — webjsx passes a
113
+ // null straight to updatePropOrAttr, which stringifies it, and
114
+ // the row renders href="null": still a link, still tab-stopped,
115
+ // still pointer-cursored. The key has to be absent.
98
116
  return h('a', {
99
117
  key: sec.group + i,
100
- href,
118
+ ...(isControl ? { href: href != null ? href : '#' } : {}),
101
119
  class: active ? 'active' : '',
102
120
  'aria-current': active ? 'page' : null,
103
121
  'aria-label': label + countLabel,
104
122
  onclick: onClick,
105
- onkeydown: onSideLinkKeyDown
123
+ onkeydown: isControl ? onSideLinkKeyDown : null
106
124
  },
107
125
  glyph != null ? Glyph({ children: glyph, color }) : h('span', { class: 'glyph', 'aria-hidden': 'true' }),
108
126
  h('span', {}, label),
@@ -19,52 +19,90 @@ const MODES = [
19
19
  ['ink', 'dark'],
20
20
  ];
21
21
 
22
- // Track instances so an OS-theme change while in 'auto' re-renders the
23
- // glyph in the compact variant (the segmented variant doesn't need it).
24
- const refresh = new Set();
25
- let _bound = false;
26
- function bindOnce() {
27
- if (_bound) return;
28
- _bound = true;
29
- onThemeChange(() => { for (const cb of refresh) cb(); });
22
+ // Both variants render from `getTheme()` at the moment their vnode is built,
23
+ // but nothing re-renders this control when the theme changes: a host kit's view
24
+ // is not subscribed to the theme, and applyTheme() only touches <html> and the
25
+ // theme listeners. So each instance repaints itself from the live theme through
26
+ // a ref — once on mount, then on every theme change. `el._dsThemeBound` makes
27
+ // that idempotent, since a ref callback runs again on each re-render of the
28
+ // host. (This replaces a `refresh` Set that nothing ever added to, so the
29
+ // onThemeChange subscription it fed always iterated an empty set.)
30
+ function bindThemePaint(el, paint) {
31
+ if (!el || el._dsThemeBound) return;
32
+ el._dsThemeBound = true;
33
+ paint();
34
+ onThemeChange(paint);
30
35
  }
31
36
 
32
37
  export function ThemeToggle({ compact = false, onChange } = {}) {
33
- bindOnce();
34
38
  const current = getTheme();
35
39
 
36
40
  if (compact) {
37
41
  // Plain words only - 'ink'/'paper' are internal theme codenames a user
38
42
  // never chose; the resolved scheme rides in the title, not the label.
39
- const resolvedWord = resolvedTheme() === 'ink' ? 'dark' : 'light';
40
- const word = current === 'auto' ? 'auto' : (current === 'ink' ? 'dark' : 'light');
41
- const label = 'theme: ' + word;
43
+ const wordFor = (t) => (t === 'auto' ? 'auto' : (t === 'ink' ? 'dark' : 'light'));
44
+ const labelFor = (t) => 'theme: ' + wordFor(t);
45
+ const titleFor = (t) => labelFor(t)
46
+ + (t === 'auto' ? ' (currently ' + (resolvedTheme() === 'ink' ? 'dark' : 'light') + ')' : '')
47
+ + ' — click to cycle';
42
48
  return h('button', {
43
49
  class: 'btn ds-theme-toggle',
44
50
  type: 'button',
45
- 'aria-label': label,
46
- title: label + (current === 'auto' ? ' (currently ' + resolvedWord + ')' : '') + ' — click to cycle',
51
+ 'aria-label': labelFor(current),
52
+ title: titleFor(current),
53
+ // getTheme() at click time, not the `current` captured when this
54
+ // vnode was built. Nothing re-renders this button on a theme
55
+ // change, so a captured value goes stale after the first click and
56
+ // the cycle sticks: auto -> paper, then every later click computes
57
+ // from a stale 'auto' and lands on paper again.
47
58
  onclick: () => {
48
- const next = current === 'auto' ? 'paper' : (current === 'paper' ? 'ink' : 'auto');
59
+ const now = getTheme();
60
+ const next = now === 'auto' ? 'paper' : (now === 'paper' ? 'ink' : 'auto');
49
61
  applyTheme(next);
50
62
  if (onChange) try { onChange(next); } catch { /* swallow: consumer onChange callback must not break the toggle */ }
51
- }
63
+ },
64
+ ref: (el) => bindThemePaint(el, () => {
65
+ const now = getTheme();
66
+ el.setAttribute('aria-label', labelFor(now));
67
+ el.setAttribute('title', titleFor(now));
68
+ const lab = el.querySelector('.ds-theme-toggle-label');
69
+ if (lab) lab.textContent = labelFor(now);
70
+ })
52
71
  },
53
72
  // CSS-drawn half-disc so the control still reads as the theme switch
54
73
  // when the label is hidden (icon-only rail strip).
55
74
  h('span', { class: 'ds-theme-disc', 'aria-hidden': 'true' }),
56
- h('span', { class: 'ds-theme-toggle-label' }, label));
75
+ h('span', { class: 'ds-theme-toggle-label' }, labelFor(current)));
57
76
  }
58
77
 
59
78
  return h('div', {
60
79
  class: 'ds-theme-toggle ds-segmented',
61
80
  role: 'radiogroup',
62
- 'aria-label': 'theme'
81
+ 'aria-label': 'theme',
82
+ // Paint the selected segment from the live theme rather than only from
83
+ // `current` (captured when this vnode was built). Nothing re-renders
84
+ // this control when it is clicked: the host kit's view is not
85
+ // subscribed to the theme, so applyTheme() correctly flipped
86
+ // <html data-theme> while the segment kept `is-on` on whatever was
87
+ // selected at mount — clicking "light" changed the theme but left
88
+ // "auto" looking selected, which reads as a control that did nothing.
89
+ // Doing it in a ref keeps this correct for BOTH triggers: a click here,
90
+ // and an external change (another toggle, or the OS scheme flipping
91
+ // while in auto) via the onThemeChange subscription.
92
+ ref: (el) => bindThemePaint(el, () => {
93
+ const now = getTheme();
94
+ for (const btn of el.querySelectorAll('.ds-seg-btn')) {
95
+ const on = btn.dataset.mode === now;
96
+ btn.classList.toggle('is-on', on);
97
+ btn.setAttribute('aria-checked', on ? 'true' : 'false');
98
+ }
99
+ })
63
100
  }, ...MODES.map(([mode, label]) =>
64
101
  h('button', {
65
102
  key: mode,
66
103
  type: 'button',
67
104
  role: 'radio',
105
+ 'data-mode': mode,
68
106
  'aria-checked': current === mode ? 'true' : 'false',
69
107
  class: 'ds-seg-btn' + (current === mode ? ' is-on' : ''),
70
108
  onclick: () => {
@@ -155,10 +155,19 @@ a.row:hover .meta, .row[role="button"]:hover .meta { color: var(--accent-ink); t
155
155
  takes the full width instead of wrapping in the narrow code gutter. */
156
156
  .row.row-nocode { grid-template-columns: minmax(0, 1fr) auto; }
157
157
 
158
- .row .code { font-family: var(--ff-mono); font-size: var(--fs-xs); color: var(--fg-3); font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
158
+ /* `.row-code` / `.row-title` / `.row-meta` are the BEM-ish spelling of the same
159
+ three cells as `.code` / `.title` / `.meta`. Both spellings are live in the
160
+ wild (preview/index-row.html uses the hyphenated one), and a cell that
161
+ matches no rule falls back to browser defaults — 16px body text in all three
162
+ tracks, which is precisely the undifferentiated render the specimen page was
163
+ shipping. Aliased rather than renamed so neither spelling can silently go
164
+ unstyled again. */
165
+ .row .code,
166
+ .row .row-code { font-family: var(--ff-mono); font-size: var(--fs-xs); color: var(--fg-3); font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
159
167
  /* Title defaults to the quiet app voice (fs-sm/500) — the 18px/600 marketing
160
168
  voice is now opt-in via .row--lede. */
161
- .row .title { font-family: var(--ff-body); font-weight: 500; font-size: var(--fs-sm); line-height: var(--lh-snug); display: flex; align-items: baseline; gap: var(--space-2-5); flex-wrap: wrap; min-width: 0; }
169
+ .row .title,
170
+ .row .row-title { font-family: var(--ff-body); font-weight: 500; font-size: var(--fs-sm); line-height: var(--lh-snug); display: flex; align-items: baseline; gap: var(--space-2-5); flex-wrap: wrap; min-width: 0; }
162
171
  .row .title .sub { font-family: var(--ff-body); font-weight: 400; font-size: var(--fs-sm); color: var(--fg-3); }
163
172
  /* Marketing/editorial index row — restores the roomier padding + larger title
164
173
  the default carried before app-scale became the base. */
@@ -174,7 +183,20 @@ a.row:hover .meta, .row[role="button"]:hover .meta { color: var(--accent-ink); t
174
183
  /* Context-pane cwd fact reads as a path (like .ds-dash-cwd/.ds-session-agent/
175
184
  .ds-dash-model) -- scoped to that one row so no other Row .sub is affected. */
176
185
  .ds-context-cwd-row .row .sub { font-family: var(--ff-mono); }
177
- .row .meta { font-family: var(--ff-mono); font-size: var(--fs-xs); color: var(--fg-3); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; align-self: center; }
186
+ .row .meta,
187
+ .row .row-meta { font-family: var(--ff-mono); font-size: var(--fs-xs); color: var(--fg-3); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; align-self: center; }
188
+ /* `.row-meta` carries free prose ("state machine · 3k · shipping"), unlike
189
+ `.meta`'s short tabular counts — so it wraps instead of forcing the row's
190
+ third track wide enough to overflow a phone viewport. */
191
+ .row .row-meta { white-space: normal; }
192
+ /* Hairline-divided index list. `.row` is deliberately hairline-FREE (the rail
193
+ is the affordance), but an editorial index reads as a printed table of
194
+ contents: the divider IS the structure, which is what
195
+ preview/index-row.html's caption claims. Opt-in on the container so no
196
+ existing rail-based list changes. */
197
+ .row-list > .row + .row { margin-top: 0; border-top: 1px solid var(--rule); border-radius: 0; }
198
+ .row-list > .row:first-child { border-radius: var(--r-1) var(--r-1) 0 0; }
199
+ .row-list > .row:last-child { border-radius: 0 0 var(--r-1) var(--r-1); }
178
200
  /* WorksList meta pairs a label with a disclosure chevron, inline-aligned. */
179
201
  .ds-works-meta { display: inline-flex; align-items: center; gap: .4em; }
180
202
 
@@ -165,6 +165,15 @@
165
165
  .chip.tone-wip, .chip.tone-neutral {
166
166
  background: var(--bg-3); color: var(--fg-2);
167
167
  }
168
+ /* `.chip.accent` is the bare-adjective spelling of tone-accent (ui_kits/blog
169
+ uses it for a "draft" chip). It had no rule anywhere, so an accented chip
170
+ rendered byte-identical to a plain one. Folded into the alias grouping rather
171
+ than given its own block, so the two spellings cannot drift apart. --accent
172
+ is the FILL and --accent-ink the readable text tone (AGENTS.md) — the bare
173
+ lead is ~1.07:1 on paper and unreadable as text. */
174
+ .chip.accent, .chip.tone-accent {
175
+ background: var(--accent-tint); color: var(--accent-ink);
176
+ }
168
177
  /* tone-dim was applied by five kits (aicat, project_page, signin, system_primer,
169
178
  terminal) with no rule anywhere, so a "dim" chip rendered byte-identical to a
170
179
  plain one — measured at rgb(234,230,218) on rgb(31,31,38), same opacity. It is
@@ -222,4 +231,17 @@
222
231
  font-family: var(--ff-mono); font-size: var(--fs-tiny);
223
232
  opacity: 0.8;
224
233
  }
234
+ /* Glyph() (src/components/shell/atoms.js) emits `glyph glyph-{size}` and an
235
+ inline `font-size: var(--glyph-size-{size}, <literal>)`. The custom property
236
+ is the documented theming hook — it is what lets a theme retune glyph scale
237
+ without touching the component — but nothing declared it, so every glyph fell
238
+ through to the hard-coded inline literal and the hook was inert. Declaring
239
+ the three rungs on the scale makes the hook real; the sm/lg classes also give
240
+ the box a matching footprint instead of the one-size 18px square. */
241
+ /* Values mirror Glyph()'s own inline fallbacks exactly (11 / 13 / 16px), so
242
+ declaring the hook changes nothing visually — it only makes the property
243
+ themeable, which is what it always claimed to be. */
244
+ .glyph { --glyph-size-sm: var(--fs-nano); --glyph-size-base: var(--fs-tiny); --glyph-size-lg: var(--fs-body); }
245
+ .glyph-sm { width: 14px; height: 14px; }
246
+ .glyph-lg { width: 22px; height: 22px; }
225
247
 
@@ -164,6 +164,14 @@ body.canvas-host { background: transparent !important; }
164
164
  padding: var(--space-5) var(--space-3);
165
165
  font-size: var(--fs-sm);
166
166
  }
167
+ /* Sidebar section wrapper emitted by Side() (src/components/shell/app-shell.js).
168
+ It is the `role="group"` that owns the heading + its links, so it must be the
169
+ thing that groups them visually too: without a rule it was a bare block, and
170
+ the only separation between sections came from .app-side's own flex gap —
171
+ which meant the heading floated equidistant between the group it labels and
172
+ the one above it. Stacking the group's own children tightly re-attaches the
173
+ heading to its links. */
174
+ .app-side-group { display: flex; flex-direction: column; gap: var(--space-hair); }
167
175
  .app-side .group {
168
176
  font-size: var(--fs-xs); font-weight: 600;
169
177
  color: var(--fg-3); padding: 0 var(--space-3);
@@ -184,7 +192,20 @@ body.canvas-host { background: transparent !important; }
184
192
  @media (pointer: coarse) {
185
193
  .app-side a { min-height: 44px; padding: var(--space-2-75) var(--space-3); }
186
194
  }
187
- .app-side a:hover { background: var(--bg-2); color: var(--fg); }
195
+ /* A sidebar row with no href and no handler is a static label, not a control:
196
+ Side() emits it as an <a> only so it keeps the grid layout above. It must not
197
+ claim to be pressable — no pointer, no hover highlight. (An anchor without
198
+ href is already skipped by the tab order and exposed as generic, not link.) */
199
+ .app-side a:not([href]) { cursor: default; }
200
+ /* An entry naming a section/tag that does not exist yet — in the sidebar tree
201
+ or the topbar nav. It stays visible so the intended shape of the docs is
202
+ legible, but it is not a link and must not read as one: dimmed, and inert
203
+ like any other href-less anchor. Both selectors are needed because the two
204
+ navs style their anchors separately. */
205
+ .app-side a.is-unwritten,
206
+ .app-topbar nav a.is-unwritten { color: var(--fg-3); cursor: default; }
207
+ .app-topbar nav a.is-unwritten:hover { background: none; color: var(--fg-3); }
208
+ .app-side a[href]:hover { background: var(--bg-2); color: var(--fg); }
188
209
  .app-side a:focus-visible {
189
210
  outline: var(--focus-w) solid var(--focus-color);
190
211
  outline-offset: var(--focus-offset);