diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 571f96e8..5e681a7c 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -65,27 +65,13 @@ import { import TagsSection from "./TagsSection"; import ViewsSection from "./ViewsSection"; -const SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES = - "size-7 shrink-0 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"; - const NewMemoAction = ({ onClick }: { onClick: () => void }) => { const t = useTranslate(); const label = t("editor.new-memo"); return ( - - } - > + }> {label} @@ -628,14 +614,7 @@ export const MobileAppHeader = () => { const { setMobileOpen } = useAppSidebar(); return (
- diff --git a/web/src/components/AppSidebar/SidebarSection.tsx b/web/src/components/AppSidebar/SidebarSection.tsx index abc1569e..2ce6d167 100644 --- a/web/src/components/AppSidebar/SidebarSection.tsx +++ b/web/src/components/AppSidebar/SidebarSection.tsx @@ -3,10 +3,7 @@ import SidebarSectionHeader from "./SidebarSectionHeader"; export const SIDEBAR_SECTION_STACK_CLASSES = "flex flex-col gap-3"; export const SIDEBAR_SECTION_CONTENT_CLASSES = "flex flex-col gap-0.5"; -export const SIDEBAR_SECTION_ACTION_BUTTON_CLASSES = - "size-6 rounded text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"; -// Surface-free on purpose: in the rail a fill means a selected row, and a mode toggle is not one. -export const SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES = "text-foreground"; +// Section actions are the kit's quiet `icon-sm` buttons; only their glyph is section-specific. export const SIDEBAR_SECTION_ACTION_ICON_CLASSES = "size-3.5"; interface Props { diff --git a/web/src/components/AppSidebar/TagsSection.tsx b/web/src/components/AppSidebar/TagsSection.tsx index a6c8e848..044a3c23 100644 --- a/web/src/components/AppSidebar/TagsSection.tsx +++ b/web/src/components/AppSidebar/TagsSection.tsx @@ -22,7 +22,7 @@ import { sidebarRowStateAttributes, sidebarRowStateClasses, } from "./SidebarRow"; -import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection"; +import SidebarSection, { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection"; interface Props { tagCount: Record; @@ -110,16 +110,7 @@ const TagsSection = ({ tagCount, onSelect, scope }: Props) => { }> - } + render={ diff --git a/web/src/components/CalendarView/CalendarDayCell.tsx b/web/src/components/CalendarView/CalendarDayCell.tsx index 4881f0f5..66017b64 100644 --- a/web/src/components/CalendarView/CalendarDayCell.tsx +++ b/web/src/components/CalendarView/CalendarDayCell.tsx @@ -1,10 +1,10 @@ import { memo } from "react"; import { type CalendarDayCell as CalendarDayCellData, getTooltipText } from "@/components/ActivityCalendar"; +import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "@/components/ui/focus"; import type { MemoTimeBasis } from "@/contexts/ViewContext"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarLink } from "./CalendarLink"; -import { CALENDAR_FOCUS_CLASSES } from "./controls"; import type { CalendarDaySummary } from "./dayModel"; import { buildCalendarPath, getMonthOfDate } from "./paths"; @@ -59,7 +59,7 @@ export const CalendarDayCell = memo( !isLastColumn && "border-e", !isLastRow && "border-b", corner && CORNER_CLASSES[corner], - CALENDAR_FOCUS_CLASSES, + FOCUS_VISIBLE_OUTLINE_CLASSES, // The open day is the place you are, so it takes the fill the sidebar gives a current row. day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45", )} diff --git a/web/src/components/CalendarView/CalendarHeader.tsx b/web/src/components/CalendarView/CalendarHeader.tsx index 90fa4193..76a9bca9 100644 --- a/web/src/components/CalendarView/CalendarHeader.tsx +++ b/web/src/components/CalendarView/CalendarHeader.tsx @@ -1,10 +1,10 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; +import { buttonVariants } from "@/components/ui/button"; import { addMonths } from "@/lib/calendar-utils"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarLink } from "./CalendarLink"; -import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls"; import { MonthPicker } from "./MonthPicker"; import { buildCalendarPath, getMonthOfDate } from "./paths"; @@ -35,6 +35,7 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable const navigate = useNavigate(); const { search } = useLocation(); const todayOpen = activeDate === today; + const iconControlClassName = cn(buttonVariants({ variant: "quiet", size: "icon-compact" })); return ( // The title's text starts on the grid's text axis (border + cell padding); the month @@ -42,25 +43,18 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable
- + - + - {/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. */} + {/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. + Its pressed state is the quiet variant's accent fill, keyed off aria-pressed. */} )} @@ -92,7 +97,7 @@ export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => { )} - diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts index 7d46f371..0de0d0e5 100644 --- a/web/src/components/MemoActionMenu/types.ts +++ b/web/src/components/MemoActionMenu/types.ts @@ -5,6 +5,5 @@ export interface MemoActionMenuProps { memo: Memo; parentScope: MemoOriginScope; readonly?: boolean; - className?: string; onEdit?: () => void; } diff --git a/web/src/components/MemoDetailSidebar/MemoOutline.tsx b/web/src/components/MemoDetailSidebar/MemoOutline.tsx index 379e2c0b..7a298c5d 100644 --- a/web/src/components/MemoDetailSidebar/MemoOutline.tsx +++ b/web/src/components/MemoDetailSidebar/MemoOutline.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow"; import type { HeadingItem } from "@/components/MemoContent/pipeline"; +import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { findAnchorTarget, findMemoContentRoot } from "@/utils/markdown-manipulation"; @@ -12,6 +12,12 @@ interface MemoOutlineProps { /** Distance from the viewport top of the "reading line" used to decide the active section. */ const READING_LINE_OFFSET = 100; +/** + * Each heading is a quiet row: the kit's `quiet` treatment at the sidebar's 28px row height, + * so the section you are reading takes the accent fill through `aria-current`. + */ +const OUTLINE_ROW_CLASSES = cn(buttonVariants({ variant: "quiet", size: "sm" }), "relative w-full justify-start"); + /** Outline navigation for memo headings (h1–h4) with active-section tracking. */ const MemoOutline = ({ headings, memoName }: MemoOutlineProps) => { const [activeSlug, setActiveSlug] = useState(null); @@ -72,12 +78,7 @@ const MemoOutline = ({ headings, memoName }: MemoOutlineProps) => { href={`#${heading.slug}`} onClick={(e) => handleClick(e, heading.slug)} aria-current={active ? "location" : undefined} - className={cn( - SIDEBAR_ROW_BOX_CLASSES, - "relative", - heading.level === minLevel && "font-medium", - active ? "text-foreground" : "text-muted-foreground/70 hover:bg-sidebar-accent/65 hover:text-foreground", - )} + className={cn(OUTLINE_ROW_CLASSES, heading.level === minLevel && "font-medium")} style={{ paddingInlineStart: 8 + (heading.level - minLevel) * 12 }} > }> diff --git a/web/src/components/MemoReactionListView/ReactionSelector.tsx b/web/src/components/MemoReactionListView/ReactionSelector.tsx index f760bcf8..50c727cf 100644 --- a/web/src/components/MemoReactionListView/ReactionSelector.tsx +++ b/web/src/components/MemoReactionListView/ReactionSelector.tsx @@ -1,5 +1,6 @@ import { SmilePlusIcon } from "lucide-react"; import { useState } from "react"; +import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "@/components/ui/focus"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useInstance } from "@/contexts/InstanceContext"; import { cn } from "@/lib/utils"; @@ -37,7 +38,8 @@ const ReactionSelector = (props: Props) => { type="button" aria-label={t("setting.memo.add-reaction")} className={cn( - "flex size-7 cursor-pointer items-center justify-center rounded-full border text-muted-foreground transition-all hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + "flex size-7 cursor-pointer items-center justify-center rounded-full border text-muted-foreground transition-all hover:opacity-80", + FOCUS_VISIBLE_OUTLINE_CLASSES, className, )} /> diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index e4a1bd83..897e4eb4 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -2,7 +2,7 @@ import { BookmarkIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; import RelativeTime from "@/components/RelativeTime"; -import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useNewMemo } from "@/contexts/NewMemoContext"; import useNavigateTo from "@/hooks/useNavigateTo"; @@ -22,8 +22,8 @@ import { createMemoNavigationState } from "../navigation"; import type { MemoHeaderProps } from "../types"; import MemoSpaceBadge from "./MemoSpaceBadge"; -const MEMO_HEADER_ACTION_CLASSES = - "size-6 shrink-0 rounded-md border-none bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground"; +/** The card's trailing actions are the kit's quiet 24px squares, whether or not they are kit buttons. */ +const MEMO_HEADER_ACTION_CLASSES = cn(buttonVariants({ variant: "quiet", size: "icon-sm" })); const MemoHeader: React.FC = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => { const t = useTranslate(); @@ -93,6 +93,8 @@ const MemoHeader: React.FC = ({ timeDisplay = "relative", showC = ({ timeDisplay = "relative", showC {showPinned && memo.pinned && ( + {/* The pinned mark keeps its primary ink; that custom look lives on the raw trigger, not a kit button. */} - } + aria-label={t("common.unpin")} + className={cn(MEMO_HEADER_ACTION_CLASSES, "text-primary hover:text-primary")} + onClick={unpinMemo} > @@ -133,13 +130,7 @@ const MemoHeader: React.FC = ({ timeDisplay = "relative", showC )} - +
); diff --git a/web/src/components/StatisticsView/MonthNavigator.tsx b/web/src/components/StatisticsView/MonthNavigator.tsx index 3914d6e7..0b84f41e 100644 --- a/web/src/components/StatisticsView/MonthNavigator.tsx +++ b/web/src/components/StatisticsView/MonthNavigator.tsx @@ -18,23 +18,11 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange }: MonthNaviga

{monthLabel}

diff --git a/web/src/components/ui/README.md b/web/src/components/ui/README.md index f8ed310c..44963e6a 100644 --- a/web/src/components/ui/README.md +++ b/web/src/components/ui/README.md @@ -24,7 +24,7 @@ Color comes from the semantic OKLCH tokens documented in | Component | variant | size / shape | Notes | | --- | --- | --- | --- | -| **Button** | default · destructive · outline · secondary · ghost · link | size: default · sm · lg · icon · **icon-sm** | `icon-sm` (`size-6`) is local — for dense/compact icon buttons. Auto-sizes unsized `svg` children to `size-4`. | +| **Button** | default · destructive · outline · secondary · ghost · link · **quiet** | size: default · sm · lg · icon · **icon-compact** · **icon-sm** | Every variant carries the kit's ring-free keyboard focus (`FOCUS_VISIBLE_OUTLINE_CLASSES` from `ui/focus.ts`; raw controls import the same constant). `quiet` is local: 13px muted ink that darkens under a `bg-muted/60` hover wash, and the accent fill only for `aria-pressed`, `aria-current` or `data-popup-open`. Use it via `buttonVariants({ variant: "quiet" })` on raw ` ), @@ -109,16 +110,19 @@ describe("MemoHeader navigation", () => { expect(actionRail).toHaveClass("items-center", "gap-1"); expect(reaction).toHaveClass("sm:group-focus-within:flex"); - for (const action of [reaction, actions]) { - expect(action).toHaveClass( - "size-6", - "rounded-md", - "hover:bg-accent", - "hover:text-foreground", - "focus-visible:ring-2", - "data-popup-open:bg-accent", - ); - } + // The reaction chip gives up its round bordered look for the header's quiet square. + expect(reaction).toHaveClass( + "size-6", + "rounded-md", + "border-none", + "text-muted-foreground/70", + "hover:bg-muted/60", + "hover:text-foreground", + "focus-visible:outline-2", + "data-popup-open:bg-accent", + ); + expect(reaction).not.toHaveClass("rounded-full"); + expect(reaction.className).not.toMatch(/ring-/); }); it.each([false, true])("uses a keyboard-operable timestamp and preserves origin when showCreator=%s", (showCreator) => { diff --git a/web/tests/reaction-selector.test.tsx b/web/tests/reaction-selector.test.tsx index c4fc1f26..d6b11633 100644 --- a/web/tests/reaction-selector.test.tsx +++ b/web/tests/reaction-selector.test.tsx @@ -25,7 +25,8 @@ describe("ReactionSelector", () => { render(); const trigger = screen.getByRole("button", { name: "setting.memo.add-reaction" }); - expect(trigger).toHaveClass("size-7", "rounded-full", "focus-visible:ring-2"); + expect(trigger).toHaveClass("size-7", "rounded-full", "focus-visible:outline-2"); + expect(trigger.className).not.toMatch(/ring-/); expect(trigger).toHaveAttribute("aria-expanded", "false"); fireEvent.click(trigger); diff --git a/web/tests/sidebar-row-grammar.test.tsx b/web/tests/sidebar-row-grammar.test.tsx index 35b79f00..0f18d600 100644 --- a/web/tests/sidebar-row-grammar.test.tsx +++ b/web/tests/sidebar-row-grammar.test.tsx @@ -7,10 +7,7 @@ import SidebarRow, { SIDEBAR_ROW_SLOT_BUTTON_CLASSES, SIDEBAR_ROW_SLOT_CLASSES, } from "@/components/AppSidebar/SidebarRow"; -import SidebarSection, { - SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, - SIDEBAR_SECTION_CONTENT_CLASSES, -} from "@/components/AppSidebar/SidebarSection"; +import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection"; import TagTree from "@/components/TagTree"; vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key })); @@ -53,7 +50,6 @@ describe("sidebar row grammar", () => { expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:-inset-0.5"); expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:content-['']"); expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).not.toContain("-mx-1"); - expect(SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" ")).toContain("size-6"); }); it("gives tag tree rows the same box as a nav row", () => { diff --git a/web/tests/sidebar-row-state.test.tsx b/web/tests/sidebar-row-state.test.tsx index e5e8e74c..95dce8af 100644 --- a/web/tests/sidebar-row-state.test.tsx +++ b/web/tests/sidebar-row-state.test.tsx @@ -5,7 +5,6 @@ import { useEffect } from "react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import SidebarRow, { sidebarRowStateClasses } from "@/components/AppSidebar/SidebarRow"; -import { SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES } from "@/components/AppSidebar/SidebarSection"; import TagsSection from "@/components/AppSidebar/TagsSection"; import MemoFilters from "@/components/MemoFilters"; import { AppSidebarProvider } from "@/contexts/AppSidebarContext"; @@ -59,10 +58,6 @@ describe("sidebar selected grammar", () => { expect(row).toHaveAttribute("data-checked"); expect(row).not.toHaveClass("bg-sidebar-accent"); }); - - it("keeps section mode toggles surface-free", () => { - expect(tokens(SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES)).not.toContain("bg-sidebar-accent"); - }); }); const SelectView = ({ id }: { id: string }) => { diff --git a/web/tests/tags-section.test.tsx b/web/tests/tags-section.test.tsx index 358e3152..1a19576b 100644 --- a/web/tests/tags-section.test.tsx +++ b/web/tests/tags-section.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SIDEBAR_ROW_BOX_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SIDEBAR_ROW_SLOT_CLASSES } from "@/components/AppSidebar/SidebarRow"; -import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection"; +import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection"; import TagsSection from "@/components/AppSidebar/TagsSection"; import { MemoFilterProvider } from "@/contexts/MemoFilterContext"; @@ -26,7 +26,7 @@ describe("TagsSection", () => { expect(heading.parentElement).toHaveTextContent(/^common.tags$/); const trigger = screen.getByRole("button", { name: "common.tags: common.more" }); - expect(trigger).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" ")); + expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "hover:text-foreground"); expect(trigger.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES); expect(screen.queryByRole("button", { name: "common.tags: memo.layout-list" })).not.toBeInTheDocument(); expect(screen.queryByRole("menuitemcheckbox")).not.toBeInTheDocument();