From c031b77c83a81649662d0f8dc3d1103d765acd9c Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Sun, 6 Sep 2026 22:43:21 +0800 Subject: [PATCH] chore: add a quiet button variant and one ring-free focus for kit buttons The calendar shipped its own "quiet control" grammar as a page-local set of class strings: 28px tall, 13px text, muted ink that darkens under a light hover wash, ring-free outline focus, and the accent fill reserved for a pressed or current state. The same look was hand-rolled elsewhere with drifting values (/65 ink, /50 washes, ring focus) as className overrides on the kit's Button, against the kit policy. The grammar now lives in the kit as `variant="quiet"`, with the accent fill keyed off `aria-pressed`, `aria-current` and `data-popup-open`, so a control that is on, current, or holding a popup open fills itself. A `size="icon-compact"` names the 28px square the sidebar already used for its compose control; `sm` already had the height for labelled chips. Kit buttons had no focus treatment of their own, so sites that needed one bolted `focus-visible:ring-*` overrides onto the Button. The base now carries a 2px inset outline from a new `ui/focus.ts` constant that raw controls (calendar grid cells, the reaction chip) import too, and the calendar's controls.ts goes away. With both in place the hand-rolled sites collapse onto the variant: the calendar's header, month picker and day panel; the statistics month navigator; the sidebar's section actions and compose and mobile menu controls; the memo card's trailing actions and its action menu, which no longer takes a className; the share-image dialog's footer; the memo outline; and the inactive underline tab. The month title fills while its picker is open and the outline's current heading fills like every other current thing. Tests follow the classes they assert. --- web/src/components/AppSidebar/AppSidebar.tsx | 25 ++-------------- .../components/AppSidebar/SidebarSection.tsx | 5 +--- web/src/components/AppSidebar/TagsSection.tsx | 13 ++------- .../components/AppSidebar/ViewsSection.tsx | 10 ++----- .../CalendarView/CalendarDayCell.tsx | 4 +-- .../CalendarView/CalendarHeader.tsx | 20 +++++-------- web/src/components/CalendarView/DayPanel.tsx | 11 +++++-- .../components/CalendarView/MonthPicker.tsx | 21 +++++++------- web/src/components/CalendarView/controls.ts | 25 ---------------- .../MemoActionMenu/MemoActionMenu.tsx | 15 +--------- .../MemoActionMenu/MemoShareImageDialog.tsx | 14 ++------- web/src/components/MemoActionMenu/types.ts | 1 - .../MemoDetailSidebar/MemoOutline.tsx | 15 +++++----- web/src/components/MemoDisplaySettingMenu.tsx | 10 ++----- .../MemoReactionListView/ReactionSelector.tsx | 4 ++- .../MemoView/components/MemoHeader.tsx | 29 +++++++------------ .../StatisticsView/MonthNavigator.tsx | 16 ++-------- web/src/components/ui/README.md | 2 +- web/src/components/ui/button.tsx | 17 ++++++++++- web/src/components/ui/focus.ts | 8 +++++ web/src/components/ui/tabs.tsx | 3 +- web/tests/app-sidebar-logo.test.tsx | 4 +-- web/tests/memo-action-menu.test.tsx | 5 ++-- web/tests/memo-display-setting-menu.test.tsx | 4 +-- web/tests/memo-header-navigation.test.tsx | 28 ++++++++++-------- web/tests/reaction-selector.test.tsx | 3 +- web/tests/sidebar-row-grammar.test.tsx | 6 +--- web/tests/sidebar-row-state.test.tsx | 5 ---- web/tests/tags-section.test.tsx | 4 +-- 29 files changed, 118 insertions(+), 209 deletions(-) delete mode 100644 web/src/components/CalendarView/controls.ts create mode 100644 web/src/components/ui/focus.ts 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();