From 5a4a99196e51f1de34ffc79265b954d9b480040a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 06:56:13 +0200 Subject: [PATCH] #300: route icon-only controls through IconButton/IconLink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 10 ---- apps/web/src/access/AccessRulesManager.tsx | 20 ++++---- apps/web/src/components/IconButton.tsx | 48 +++++++++++++++++-- apps/web/src/files/PondFileManager.tsx | 11 ++--- apps/web/src/import/ImportControl.tsx | 11 ++--- apps/web/src/labels/LabelManager.tsx | 31 ++++-------- apps/web/src/labels/LabelPicker.tsx | 10 ++-- apps/web/src/layout/Sidebar.tsx | 29 +++++------ apps/web/src/layout/TopBar.tsx | 19 ++++---- apps/web/src/members/MemberManager.tsx | 20 +++----- .../src/notifications/NotificationsBell.tsx | 19 +++++--- apps/web/src/pages/PluginManager.tsx | 13 +++-- apps/web/src/styles/base.css | 18 +++---- apps/web/src/watches/WatchesSection.tsx | 11 ++--- eslint.config.mjs | 25 ++++++++++ packages/shared/i18n/de/notifications.json | 1 + packages/shared/i18n/en/notifications.json | 1 + 17 files changed, 164 insertions(+), 133 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 498ee32..5ed0718 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` 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). diff --git a/apps/web/src/access/AccessRulesManager.tsx b/apps/web/src/access/AccessRulesManager.tsx index e91674a..5c439ee 100644 --- a/apps/web/src/access/AccessRulesManager.tsx +++ b/apps/web/src/access/AccessRulesManager.tsx @@ -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}

)} - + {rules.length === 0 ? ( @@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El {group.rules.map((rule) => (
  • {ruleSentence(rule, t)} - +
  • ))} diff --git a/apps/web/src/components/IconButton.tsx b/apps/web/src/components/IconButton.tsx index d19c45f..feec82f 100644 --- a/apps/web/src/components/IconButton.tsx +++ b/apps/web/src/components/IconButton.tsx @@ -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 { /** 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 ( - ); } + +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 ( + + {children} + + ); +} diff --git a/apps/web/src/files/PondFileManager.tsx b/apps/web/src/files/PondFileManager.tsx index 15c967e..b58adf7 100644 --- a/apps/web/src/files/PondFileManager.tsx +++ b/apps/web/src/files/PondFileManager.tsx @@ -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 ?? {t('orphan')}} - + ))} diff --git a/apps/web/src/import/ImportControl.tsx b/apps/web/src/import/ImportControl.tsx index a6e8d0e..a656da7 100644 --- a/apps/web/src/import/ImportControl.tsx +++ b/apps/web/src/import/ImportControl.tsx @@ -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 (
    - + string { @@ -72,15 +73,9 @@ export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element placeholder={t('settings.newRootPlaceholder')} aria-label={t('settings.newRootPlaceholder')} /> - + {error && ( @@ -250,15 +245,13 @@ function LabelNode({ > {t('settings.addChild')} - +
    @@ -278,15 +271,9 @@ function LabelNode({ aria-label={t('settings.addChild')} onChange={(event) => setChildName(event.target.value)} /> - + diff --git a/apps/web/src/labels/LabelPicker.tsx b/apps/web/src/labels/LabelPicker.tsx index 053e145..224e856 100644 --- a/apps/web/src/labels/LabelPicker.tsx +++ b/apps/web/src/labels/LabelPicker.tsx @@ -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)} /> - + {createError && (

    diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 3cc0153..9034662 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -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. */}

    - - - + {isOwner && ( - - + )}
    diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index 991bc6d..558103d 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -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 (
    - + Dorfteich {user && } {isPondOwner && pondSlug && ( - - + )} {/* Page-scoped slots, rendered only for signed-in users: live presence diff --git a/apps/web/src/members/MemberManager.tsx b/apps/web/src/members/MemberManager.tsx index 6327cd1..04bb68e 100644 --- a/apps/web/src/members/MemberManager.tsx +++ b/apps/web/src/members/MemberManager.tsx @@ -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 ))} - + {addBlockedByQuota && (

    {t('add.quotaFull', { role: roleLabel(addRole) })} @@ -238,15 +238,9 @@ function MemberRow({ ))} - + ) : ( {roleLabel(member.role)} diff --git a/apps/web/src/notifications/NotificationsBell.tsx b/apps/web/src/notifications/NotificationsBell.tsx index 6e493cf..24e4a4a 100644 --- a/apps/web/src/notifications/NotificationsBell.tsx +++ b/apps/web/src/notifications/NotificationsBell.tsx @@ -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 (

    - + {unread > 0 && ( + + {unread} + + )} + {open && (
    diff --git a/apps/web/src/pages/PluginManager.tsx b/apps/web/src/pages/PluginManager.tsx index 5c25bcb..0cdd2fc 100644 --- a/apps/web/src/pages/PluginManager.tsx +++ b/apps/web/src/pages/PluginManager.tsx @@ -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 { {t('admin.preview')} - +
    ))} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 35b6990..19e0641 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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; diff --git a/apps/web/src/watches/WatchesSection.tsx b/apps/web/src/watches/WatchesSection.tsx index 04565b1..fe56061 100644 --- a/apps/web/src/watches/WatchesSection.tsx +++ b/apps/web/src/watches/WatchesSection.tsx @@ -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} {t(`settings.types.${watch.targetType}`)} - + ))} diff --git a/eslint.config.mjs b/eslint.config.mjs index 77c0a0f..4779c85 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 (or 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 (or 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. diff --git a/packages/shared/i18n/de/notifications.json b/packages/shared/i18n/de/notifications.json index ef9614c..e2e68b3 100644 --- a/packages/shared/i18n/de/notifications.json +++ b/packages/shared/i18n/de/notifications.json @@ -1,5 +1,6 @@ { "title": "Benachrichtigungen", + "titleUnread": "Benachrichtigungen, {{count}} ungelesen", "markAllRead": "Alle als gelesen markieren", "empty": "Noch keine Benachrichtigungen.", "someone": "Jemand", diff --git a/packages/shared/i18n/en/notifications.json b/packages/shared/i18n/en/notifications.json index 88799e9..3d874b8 100644 --- a/packages/shared/i18n/en/notifications.json +++ b/packages/shared/i18n/en/notifications.json @@ -1,5 +1,6 @@ { "title": "Notifications", + "titleUnread": "Notifications, {{count}} unread", "markAllRead": "Mark all read", "empty": "No notifications yet.", "someone": "Someone",