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",