#300: route icon-only controls through IconButton/IconLink
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s

The notification bell sat higher and larger than search and the theme
toggle next to it. The cause was not the glyph: `.notifications-bell__button`
carried its own rules with neither flex centring nor an icon size, so the
svg was laid out inline on the text baseline and rendered at lucide's
24px default instead of the 1.15rem the shared `.icon-button` enforces.

Route every icon-only control through the shared components instead:

- `IconLink` joins `IconButton`, sharing one class helper. Three controls
  navigate (pond settings, graph, trash) and are links, not buttons —
  without a link twin they would have stayed the one group gluing the
  class on by hand.
- 17 hand-applied `className="icon-button …"` usages across nine files
  now go through the components, which is what enforces the accessible
  name on a control that shows only an icon.
- The bell's unread count reaches assistive technology. The badge sits
  inside the control, so `aria-label` hid it and a screen reader
  announced "Notifications" without ever saying how many.

An ESLint rule keeps it that way: `icon-button` on a raw button, anchor
or Link is now an error, in both string and template-literal form.

The plugin uninstall button keeps a title that differs from its name (it
explains why a required plugin is locked); IconButton spreads rest last,
so the explicit title still wins.

Also drops the graphify block from CLAUDE.md — it duplicates the
workspace-level instructions.
This commit is contained in:
Claude Opus 5 2026-08-01 06:56:13 +02:00
parent 1f56f34113
commit 5a4a99196e
17 changed files with 164 additions and 133 deletions

View File

@ -27,13 +27,3 @@ AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -16,6 +16,7 @@ import { ApiError, apiGet } from '../lib/api';
import { usePondLabels } from '../labels/use-pond-labels';
import { usePondMembers } from '../members/use-pond-members';
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
import { IconButton } from '../components/IconButton';
type ScopedType = 'label' | 'page';
@ -238,15 +239,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{error}
</p>
)}
<button
className="icon-button rule-add__submit"
<IconButton
className="rule-add__submit"
type="submit"
disabled={!canSubmit}
aria-label={t('add.submit')}
title={t('add.submit')}
label={t('add.submit')}
>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{rules.length === 0 ? (
@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{group.rules.map((rule) => (
<li key={rule.id} className="rule-item">
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button
type="button"
className="icon-button rule-remove"
aria-label={t('remove')}
title={t('remove')}
<IconButton
className="rule-remove"
label={t('remove')}
onClick={() => void mutations.remove(rule.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -1,4 +1,14 @@
import type { ButtonHTMLAttributes } from 'react';
import { Link, type LinkProps } from 'react-router-dom';
/** The shared class list behind both controls (issue #300): one place decides
* what an icon-only control looks like, so the box, the icon size, the hover
* and the focus ring cannot drift apart between a button and a link. */
function iconClasses(active: boolean | undefined, className: string | undefined): string {
return ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
}
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Localized accessible name; also shown as the hover tooltip. */
@ -15,12 +25,42 @@ export function IconButton({
children,
...rest
}: IconButtonProps): React.JSX.Element {
const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
<button
type="button"
className={iconClasses(active, className)}
aria-label={label}
title={label}
{...rest}
>
{children}
</button>
);
}
interface IconLinkProps extends LinkProps {
/** Localized accessible name; also shown as the hover tooltip. */
label: string;
active?: boolean;
}
/**
* The navigating twin of {@link IconButton} (issue #300). An icon-only control
* that goes somewhere is a link, not a button but it has to look and focus
* exactly like one, which is why both share {@link iconClasses}. Without it the
* three navigating icons (pond settings, graph, trash) stayed the one group
* that had to glue the class on by hand.
*/
export function IconLink({
label,
active,
className,
children,
...rest
}: IconLinkProps): React.JSX.Element {
return (
<Link className={iconClasses(active, className)} aria-label={label} title={label} {...rest}>
{children}
</Link>
);
}

View File

@ -8,6 +8,7 @@ import { FormError } from '../components/forms';
import { apiDelete, apiGet } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
import { IconButton } from '../components/IconButton';
/**
* Pond-wide file manager (issue #61), Pond-Admin-gated in the api. Lists every
@ -69,15 +70,13 @@ export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Eleme
{formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '}
{item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>}
</span>
<button
type="button"
className="icon-button attachments-item__delete"
aria-label={t('delete')}
title={t('delete')}
<IconButton
className="attachments-item__delete"
label={t('delete')}
onClick={() => void remove(item.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -4,6 +4,7 @@ import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useImport } from './use-import';
import { IconButton } from '../components/IconButton';
interface ImportControlProps {
pondId: string;
@ -27,15 +28,13 @@ export function ImportControl({ pondId, pondSlug }: ImportControlProps): React.J
return (
<div className="sidebar__import">
<button
type="button"
className="icon-button sidebar__import-action"
title={t('action')}
aria-label={t('action')}
<IconButton
className="sidebar__import-action"
label={t('action')}
onClick={() => inputRef.current?.click()}
>
<Import aria-hidden />
</button>
</IconButton>
<input
ref={inputRef}
type="file"

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
import { IconButton } from '../components/IconButton';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
@ -72,15 +73,9 @@ export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
/>
<button
type="submit"
className="icon-button"
disabled={!newRoot.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<IconButton type="submit" disabled={!newRoot.trim()} label={t('settings.add')}>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{error && (
@ -250,15 +245,13 @@ function LabelNode({
>
{t('settings.addChild')}
</button>
<button
type="button"
className="icon-button label-node__delete"
aria-label={t('settings.delete')}
title={t('settings.delete')}
<IconButton
className="label-node__delete"
label={t('settings.delete')}
onClick={() => void remove()}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</div>
</div>
@ -278,15 +271,9 @@ function LabelNode({
aria-label={t('settings.addChild')}
onChange={(event) => setChildName(event.target.value)}
/>
<button
type="submit"
className="icon-button"
disabled={!childName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<IconButton type="submit" disabled={!childName.trim()} label={t('settings.add')}>
<Plus aria-hidden />
</button>
</IconButton>
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
{t('settings.cancel')}
</button>

View File

@ -9,6 +9,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { ApiError, apiDelete, apiGet, apiPost } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
import { IconButton } from '../components/IconButton';
/**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
@ -161,15 +162,14 @@ export function LabelPicker({
aria-label={t('settings.newRootPlaceholder')}
onChange={(event) => setNewName(event.target.value)}
/>
<button
<IconButton
className="label-picker__create-submit"
type="submit"
className="icon-button label-picker__create-submit"
disabled={!newName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
label={t('settings.add')}
>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{createError && (
<p className="label-picker__error" role="alert">

View File

@ -24,6 +24,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { IconButton, IconLink } from '../components/IconButton';
import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
@ -395,34 +396,30 @@ function SidebarContent({
bottom (#124): graph, new page, import, trash hover hints via
title. The graph is for every member (#112); trash is owner-only. */}
<div className="sidebar__footer">
<Link
<IconLink
to={`/p/${pondSlug}/graph`}
className="icon-button sidebar__graph-link"
title={t('graph:link')}
aria-label={t('graph:link')}
className="sidebar__graph-link"
label={t('graph:link')}
>
<Waypoints aria-hidden />
</Link>
<button
type="button"
className="icon-button sidebar__new-page"
title={t('layout.sidebar.newPageHint')}
aria-label={t('layout.sidebar.newPageHint')}
</IconLink>
<IconButton
className="sidebar__new-page"
label={t('layout.sidebar.newPageHint')}
aria-expanded={creating}
onClick={() => setCreating(!creating)}
>
<FilePlus aria-hidden />
</button>
</IconButton>
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && (
<Link
<IconLink
to={`/p/${pondSlug}/trash`}
className="icon-button sidebar__trash-link"
title={t('editor:trash.showLink')}
aria-label={t('editor:trash.showLink')}
className="sidebar__trash-link"
label={t('editor:trash.showLink')}
>
<Trash2 aria-hidden />
</Link>
</IconLink>
)}
</div>
</>

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard';
import { useDismissable } from '../lib/use-dismissable';
@ -94,28 +94,25 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
return (
<header className="topbar">
<button
type="button"
className="icon-button"
<IconButton
onClick={onToggleSidebar}
aria-expanded={!sidebarCollapsed}
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
>
<Menu aria-hidden />
</button>
</IconButton>
<Link to="/" className="topbar__brand">
Dorfteich
</Link>
{user && <PondSwitcher />}
{isPondOwner && pondSlug && (
<Link
<IconLink
to={`/p/${pondSlug}/settings`}
className="icon-button topbar__pond-settings"
aria-label={t('labels:link')}
title={t('labels:link')}
className="topbar__pond-settings"
label={t('labels:link')}
>
<Settings aria-hidden />
</Link>
</IconLink>
)}
<span className="topbar__spacer" />
{/* Page-scoped slots, rendered only for signed-in users: live presence

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useMemberMutations, usePondMembers } from './use-pond-members';
import { IconButton } from '../components/IconButton';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
@ -140,15 +141,14 @@ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element
))}
</select>
</label>
<button
className="icon-button member-add__submit"
<IconButton
className="member-add__submit"
type="submit"
disabled={addBlockedByQuota}
aria-label={t('add.submit')}
title={t('add.submit')}
label={t('add.submit')}
>
<UserPlus aria-hidden />
</button>
</IconButton>
{addBlockedByQuota && (
<p className="member-add__quota-full" role="note">
{t('add.quotaFull', { role: roleLabel(addRole) })}
@ -238,15 +238,9 @@ function MemberRow({
))}
</select>
</label>
<button
type="button"
className="icon-button member-row__remove"
aria-label={t('actions.remove')}
title={t('actions.remove')}
onClick={onRemove}
>
<IconButton className="member-row__remove" label={t('actions.remove')} onClick={onRemove}>
<UserMinus aria-hidden />
</button>
</IconButton>
</span>
) : (
<span className="member-row__role-label">{roleLabel(member.role)}</span>

View File

@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { apiGet, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
@ -48,18 +49,22 @@ export function NotificationsBell(): React.JSX.Element {
return (
<div className="notifications-bell" ref={bellRef}>
<button
type="button"
className="notifications-bell__button"
<IconButton
// The unread count belongs in the accessible name (issue #300): the
// badge sits inside the control, so aria-label would otherwise hide it
// and a screen reader announced only "Notifications", never how many.
label={unread > 0 ? t('titleUnread', { count: unread }) : t('title')}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('title')}
title={t('title')}
onClick={() => setOpen((value) => !value)}
>
<Bell aria-hidden />
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
</button>
{unread > 0 && (
<span className="notifications-bell__badge" aria-hidden>
{unread}
</span>
)}
</IconButton>
{open && (
<div className="notifications-bell__dropdown" role="menu">
<div className="notifications-bell__header">

View File

@ -8,6 +8,7 @@ import { PLUGIN_INSTANCE_MODES, type PluginInstanceMode, type PluginView } from
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api';
import { IconButton } from '../components/IconButton';
/**
* Site Admin plugin administration (issue #72): the installed-plugin list with
@ -190,18 +191,20 @@ export function PluginManager(): React.JSX.Element {
<Link className="plugin-manager__preview" to={`/admin/plugins/${plugin.id}/preview`}>
{t('admin.preview')}
</Link>
<button
type="button"
className="icon-button plugin-manager__uninstall"
<IconButton
className="plugin-manager__uninstall"
disabled={plugin.mode === 'required'}
aria-label={t('admin.uninstall')}
label={t('admin.uninstall')}
// A required plugin explains in the tooltip why it cannot be
// removed, so the title deliberately differs from the name;
// IconButton spreads rest last, which lets it through.
title={
plugin.mode === 'required' ? t('admin.requiredLocked') : t('admin.uninstall')
}
onClick={() => uninstall.mutate(plugin.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</div>
</li>
))}

View File

@ -3811,19 +3811,15 @@ ul[data-type='task_list'] li p:last-of-type {
position: relative;
}
.notifications-bell__button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
position: relative;
padding: var(--space-1);
}
/* The bell is an `.icon-button` like search and the theme toggle (issue
#300) it used to carry its own rules, which lacked the flex centring
and the icon size, so the glyph sat on the text baseline and rendered at
lucide's 24px default. The badge stays positioned against that button;
`.icon-button` is `position: relative` for exactly this. */
.notifications-bell__badge {
position: absolute;
top: -2px;
right: -4px;
top: -1px;
right: -3px;
background: var(--color-danger);
color: var(--color-danger-contrast);
border-radius: 999px;

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiDelete, apiGet } from '../lib/api';
import { IconButton } from '../components/IconButton';
/**
* The account's watch list (issue #93): everything the user follows, with
@ -42,15 +43,13 @@ export function WatchesSection(): React.JSX.Element {
{watch.name}
</Link>
<span className="watches-list__type">{t(`settings.types.${watch.targetType}`)}</span>
<button
type="button"
className="icon-button watches-list__unwatch"
aria-label={t('settings.unwatch')}
title={t('settings.unwatch')}
<IconButton
className="watches-list__unwatch"
label={t('settings.unwatch')}
onClick={() => void unwatch(watch.targetType, watch.targetId)}
>
<EyeOff aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -52,6 +52,31 @@ export default tseslint.config(
},
},
},
{
// Icon-only controls go through the shared components (issue #300).
// Gluing `icon-button` onto a raw element copies the looks but skips the
// contract that guarantees an accessible name — that is how the
// notification bell drifted into its own size and focus ring.
files: ['apps/web/src/**/*.tsx'],
ignores: ['apps/web/src/components/IconButton.tsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
'JSXOpeningElement[name.name=/^(button|a|Link)$/] > JSXAttribute[name.name="className"] > Literal[value=/(^|\\s)icon-button(\\s|$)/]',
message:
'Use <IconButton> (or <IconLink> for navigation) from components/IconButton instead of putting the icon-button class on a raw element — the component enforces the accessible name.',
},
{
selector:
'JSXOpeningElement[name.name=/^(button|a|Link)$/] > JSXAttribute[name.name="className"] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=/(^|\\s)icon-button(\\s|$)/]',
message:
'Use <IconButton> (or <IconLink> for navigation) from components/IconButton instead of putting the icon-button class on a raw element — the component enforces the accessible name.',
},
],
},
},
{
rules: {
// Unused values are usually bugs; underscore-prefix marks intentional ones.

View File

@ -1,5 +1,6 @@
{
"title": "Benachrichtigungen",
"titleUnread": "Benachrichtigungen, {{count}} ungelesen",
"markAllRead": "Alle als gelesen markieren",
"empty": "Noch keine Benachrichtigungen.",
"someone": "Jemand",

View File

@ -1,5 +1,6 @@
{
"title": "Notifications",
"titleUnread": "Notifications, {{count}} unread",
"markAllRead": "Mark all read",
"empty": "No notifications yet.",
"someone": "Someone",