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.
This commit is contained in:
johnnyjoygh 2026-09-06 22:43:21 +08:00
parent ef410846c4
commit c031b77c83
29 changed files with 118 additions and 209 deletions

View file

@ -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 (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
className={SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES}
onClick={onClick}
aria-label={label}
data-new-memo-trigger
/>
}
>
<TooltipTrigger render={<Button variant="outline" size="icon-compact" onClick={onClick} aria-label={label} data-new-memo-trigger />}>
<SquarePenIcon className="size-4" strokeWidth={1.8} />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
@ -628,14 +614,7 @@ export const MobileAppHeader = () => {
const { setMobileOpen } = useAppSidebar();
return (
<header className="sticky top-0 z-20 flex h-12 w-full items-center justify-start gap-1 border-b border-border/70 bg-background/90 px-2 backdrop-blur-md md:hidden">
<Button
variant="ghost"
size="icon-sm"
className="size-8 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
data-mobile-navigation-trigger
>
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="Open navigation" data-mobile-navigation-trigger>
<MenuIcon className="size-[18px]" />
</Button>
<SidebarBrand className="max-w-[12rem]" size="md" />

View file

@ -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 {

View file

@ -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<string, number>;
@ -110,16 +110,7 @@ const TagsSection = ({ tagCount, onSelect, scope }: Props) => {
<TooltipTrigger render={<span className="inline-flex" />}>
<DropdownMenuTrigger
aria-label={`${t("common.tags")}: ${t("common.more")}`}
render={
<Button
variant="ghost"
size="icon-sm"
className={cn(
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
"data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
)}
/>
}
render={<Button variant="quiet" size="icon-sm" />}
>
<MoreHorizontalIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</DropdownMenuTrigger>

View file

@ -26,7 +26,7 @@ import SidebarRow, {
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";
/** The row's ⋯ menu: a trailing slot control that stays hidden until the row is engaged. */
const VIEW_MENU_TRIGGER_CLASSES = cn(
@ -77,13 +77,7 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
!manageActive && (
<div className="flex items-center gap-0.5">
<MemoDisplaySettingMenu />
<Button
variant="ghost"
size="icon-sm"
className={SIDEBAR_SECTION_ACTION_BUTTON_CLASSES}
onClick={handleCreate}
aria-label={t("common.create")}
>
<Button variant="quiet" size="icon-sm" onClick={handleCreate} aria-label={t("common.create")}>
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</Button>
</div>

View file

@ -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",
)}

View file

@ -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
<header className="flex h-9 shrink-0 items-center ps-px">
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
<div className="ms-auto flex items-center gap-0.5">
<CalendarLink
to={buildCalendarPath(addMonths(month, -1))}
aria-label={t("common.previous-month")}
className={CALENDAR_ICON_CONTROL_CLASSES}
>
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={iconControlClassName}>
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
<CalendarLink
to={buildCalendarPath(addMonths(month, 1))}
aria-label={t("common.next-month")}
className={CALENDAR_ICON_CONTROL_CLASSES}
>
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={iconControlClassName}>
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
{/* 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. */}
<button
type="button"
aria-pressed={todayOpen}
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "ms-1.5", todayOpen && CALENDAR_CONTROL_ACTIVE_CLASSES)}
className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "ms-1.5")}
onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })}
>
{t("common.today")}

View file

@ -5,6 +5,7 @@ import { MentionResolutionProvider } from "@/components/MemoContent/MentionResol
import MemoEditor from "@/components/MemoEditor";
import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import MemoView from "@/components/MemoView";
import { buttonVariants } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
@ -13,7 +14,6 @@ import { parseLocalDate } from "@/lib/calendar-utils";
import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
export interface DayPanelProps {
/** `YYYY-MM-DD` */
@ -58,7 +58,12 @@ export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => {
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
{onClose && (
<button type="button" aria-label={t("calendar.close-day")} className={CALENDAR_ICON_CONTROL_CLASSES} onClick={onClose}>
<button
type="button"
aria-label={t("calendar.close-day")}
className={cn(buttonVariants({ variant: "quiet", size: "icon-compact" }))}
onClick={onClose}
>
<XIcon strokeWidth={1.8} />
</button>
)}
@ -92,7 +97,7 @@ export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => {
<button
type="button"
// The cards' own bottom margin sets the gap; the chip's edge lines up with theirs.
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "self-start")}
className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "self-start")}
onClick={() => setComposingFor(date)}
>
<PlusIcon strokeWidth={1.8} />

View file

@ -1,11 +1,11 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
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 { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
export interface MonthPickerProps {
@ -18,10 +18,13 @@ export interface MonthPickerProps {
const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
const TEXT_CONTROL_CLASSES = cn(buttonVariants({ variant: "quiet", size: "sm" }));
const ICON_CONTROL_CLASSES = cn(buttonVariants({ variant: "quiet", size: "icon-compact" }));
/**
* The month title is the trigger; the popup is a year stepper over the twelve months, the
* shown month filled and the current month marked. Months are links so the browser owns
* history and middle-click.
* shown month filled (it is the `aria-current` month) and the current month marked. Months
* are links so the browser owns history and middle-click.
*/
export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
const t = useTranslate();
@ -45,7 +48,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label={t("calendar.select-month")}
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
className={cn(TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
>
{monthLabel}
</PopoverTrigger>
@ -54,7 +57,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
<button
type="button"
aria-label={t("calendar.previous-year")}
className={CALENDAR_ICON_CONTROL_CLASSES}
className={ICON_CONTROL_CLASSES}
onClick={() => setYear((value) => value - 1)}
>
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
@ -63,7 +66,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
<button
type="button"
aria-label={t("calendar.next-year")}
className={CALENDAR_ICON_CONTROL_CLASSES}
className={ICON_CONTROL_CLASSES}
onClick={() => setYear((value) => value + 1)}
>
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
@ -79,11 +82,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
key={key}
to={buildCalendarPath(key)}
aria-current={isShown ? "page" : undefined}
className={cn(
CALENDAR_TEXT_CONTROL_CLASSES,
"relative justify-center text-foreground",
isShown && ["font-medium", CALENDAR_CONTROL_ACTIVE_CLASSES],
)}
className={cn(TEXT_CONTROL_CLASSES, "relative text-foreground", isShown && "font-medium")}
onClick={() => setOpen(false)}
>
{monthShortLabels[value - 1]}

View file

@ -1,25 +0,0 @@
import { cn } from "@/lib/utils";
/**
* One quiet control grammar for the calendar, in the spirit of Notion and Linear: 28px tall,
* 13px text, muted ink that darkens on hover under a light wash, even padding, and the accent
* fill reserved for a pressed or current state. Raw elements carry these classes so the kit's
* variants never fight them.
*/
/** The ring-free focus treatment every calendar control and grid cell shares. */
export const CALENDAR_FOCUS_CLASSES =
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60";
const CALENDAR_CONTROL_CLASSES = cn(
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md text-ui text-muted-foreground/70 no-underline transition-colors hover:bg-muted/60 hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0",
CALENDAR_FOCUS_CLASSES,
);
/** A control that is only a glyph: a 28px square. */
export const CALENDAR_ICON_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "w-7 justify-center");
/** A control with a label: 8px of side padding, so the hover wash is a chip around the words. */
export const CALENDAR_TEXT_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "px-2");
/** The one state that takes a fill: the place you are, or the thing that is on. */
export const CALENDAR_CONTROL_ACTIVE_CLASSES = "bg-accent text-accent-foreground hover:bg-accent";

View file

@ -25,7 +25,6 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { useTranslate } from "@/utils/i18n";
import { useMemoActionHandlers } from "./hooks";
@ -64,19 +63,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t("common.more")}
className={cn(
"text-muted-foreground/70 hover:text-foreground 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-accent-foreground",
props.className,
)}
/>
}
>
<DropdownMenuTrigger render={<Button variant="quiet" size="icon-sm" aria-label={t("common.more")} />}>
<MoreVerticalIcon className="size-4" strokeWidth={1.8} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>

View file

@ -108,22 +108,12 @@ const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps)
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
{supportsNativeShare && (
<Button
variant="ghost"
className="text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleNativeShare}
disabled={isRendering}
>
<Button variant="quiet" onClick={handleNativeShare} disabled={isRendering}>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
{t("memo.share.image-share")}
</Button>
)}
<Button
variant="outline"
className="border-border/70 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleDownload}
disabled={isRendering}
>
<Button variant="outline" onClick={handleDownload} disabled={isRendering}>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
{t("memo.share.image-download")}
</Button>

View file

@ -5,6 +5,5 @@ export interface MemoActionMenuProps {
memo: Memo;
parentScope: MemoOriginScope;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}

View file

@ -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<string | null>(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 }}
>
<span

View file

@ -1,7 +1,8 @@
import { useDirection } from "@base-ui/react/direction-provider";
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, SlidersHorizontalIcon } from "lucide-react";
import type { ReactNode } from "react";
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 { buttonVariants } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -193,12 +194,7 @@ function MemoDisplaySettingMenu({ className }: Props) {
<TooltipTrigger render={<span className="inline-flex" />}>
<PopoverTrigger
aria-label={t("memo.view-options")}
className={cn(
"flex items-center justify-center",
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
"data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
className,
)}
className={cn(buttonVariants({ variant: "quiet", size: "icon-sm" }), className)}
>
<SlidersHorizontalIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</PopoverTrigger>

View file

@ -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,
)}
/>

View file

@ -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<MemoHeaderProps> = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => {
const t = useTranslate();
@ -93,6 +93,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
<ReactionSelector
className={cn(
MEMO_HEADER_ACTION_CLASSES,
// The chip's own round bordered look gives way to the header's quiet square.
"border-none hover:opacity-100",
reactionSelectorOpen && "sm:flex!",
"flex sm:hidden sm:group-hover:flex sm:group-focus-within:flex",
)}
@ -113,16 +115,11 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
{showPinned && memo.pinned && (
<TooltipProvider>
<Tooltip>
{/* The pinned mark keeps its primary ink; that custom look lives on the raw trigger, not a kit button. */}
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t("common.unpin")}
className={cn(MEMO_HEADER_ACTION_CLASSES, "text-primary hover:text-primary data-popup-open:text-primary")}
onClick={unpinMemo}
/>
}
aria-label={t("common.unpin")}
className={cn(MEMO_HEADER_ACTION_CLASSES, "text-primary hover:text-primary")}
onClick={unpinMemo}
>
<BookmarkIcon className="size-4" strokeWidth={1.8} />
</TooltipTrigger>
@ -133,13 +130,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
</TooltipProvider>
)}
<MemoActionMenu
memo={memo}
parentScope={parentScope}
readonly={readonly}
className={MEMO_HEADER_ACTION_CLASSES}
onEdit={openEditor}
/>
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
</div>
</div>
);

View file

@ -18,23 +18,11 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange }: MonthNaviga
<h2 className="min-w-0 truncate font-medium tracking-[-0.015em] text-foreground/90 select-none">{monthLabel}</h2>
<nav className="flex shrink-0 items-center gap-0.5" aria-label={t("common.month-navigation")}>
<Button
variant="ghost"
size="icon-sm"
onClick={handlePrevMonth}
aria-label={t("common.previous-month")}
className="size-6 rounded text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
>
<Button variant="quiet" size="icon-sm" onClick={handlePrevMonth} aria-label={t("common.previous-month")}>
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={handleNextMonth}
aria-label={t("common.next-month")}
className="size-6 rounded text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
>
<Button variant="quiet" size="icon-sm" onClick={handleNextMonth} aria-label={t("common.next-month")}>
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
</nav>

View file

@ -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 `<button>`/`<a>` elements too (calendar controls, memo outline, card actions). `icon-compact` (`size-7`) is the 28px square matching `sm`'s height; `icon-sm` (`size-6`) is for dense icon buttons. Auto-sizes unsized `svg` children to `size-4`. |
| **Badge** | default · secondary · destructive · outline · **warning** | shape: default · **pill** | `warning` + `shape` are local additions. `pill` = `rounded-full`. |
| **Tabs** | — | variant: **segmented** · **underline** | Local component (not upstream shadcn). Context-based `Tabs`/`TabsList`/`TabsTrigger`, no Radix. `underline` relies on the consumer's own divider. |
| **Dialog** | — | size: sm · default · lg · xl · 2xl · full | Local `size` variants + auto-focus prevention on open. |

View file

@ -2,9 +2,13 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "./focus";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
FOCUS_VISIBLE_OUTLINE_CLASSES,
),
{
variants: {
variant: {
@ -14,15 +18,26 @@ const buttonVariants = cva(
secondary: "bg-secondary text-secondary-foreground hover:shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
// A quiet control in the spirit of Notion and Linear: 13px muted ink that darkens under
// a light hover wash, and the accent fill reserved for the one state that is "on" —
// pressed, current, or holding a popup open.
quiet:
"text-ui font-normal text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-[current]:bg-accent aria-[current]:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground",
},
size: {
default: "h-8 px-3",
sm: "h-7 rounded-md gap-1 px-2 has-[>svg]:px-2",
lg: "h-9 rounded-md px-4",
icon: "size-8",
// The 28px square: the height of `sm`, and the sidebar's compose-control square.
"icon-compact": "size-7",
"icon-sm": "size-6",
},
},
compoundVariants: [
// A quiet chip gives its glyph and label a little more air than the kit's tight `sm`.
{ variant: "quiet", size: "sm", className: "gap-1.5" },
],
defaultVariants: {
variant: "default",
size: "default",

View file

@ -0,0 +1,8 @@
/**
* The kit's one focus treatment: a 2px inset outline in the ring color, drawn only for
* keyboard focus. Outline, not `ring-*`, so it never adds to the box or reads as a border.
* Kit buttons carry it from their base; raw elements that act as controls (calendar grid
* cells, the reaction chip) take it from here.
*/
export const FOCUS_VISIBLE_OUTLINE_CLASSES =
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60";

View file

@ -47,7 +47,8 @@ const tabsTriggerVariants = cva(
{
variant: "underline",
active: false,
className: "border-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground",
// The inactive tab is a quiet control: the same resting ink and hover wash as the button kit's `quiet` variant.
className: "border-transparent text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground",
},
],
defaultVariants: { variant: "segmented", active: false },

View file

@ -3,7 +3,7 @@ import { fireEvent, screen, render as testingLibraryRender, within } from "@test
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
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 { type MemoFilter, parseFilterQuery } from "@/contexts/MemoFilterContext";
const authState = vi.hoisted(() => ({
@ -553,7 +553,7 @@ describe("App sidebar logo", () => {
const viewOptions = screen.getByRole("button", { name: "memo.view-options" });
const createView = screen.getByRole("button", { name: "common.create" });
expect(viewOptions.compareDocumentPosition(createView) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(createView).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
expect(createView).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "hover:text-foreground");
expect(createView.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
const tasksView = screen.getByRole("button", { name: "common.tasks" });
expect(tasksView).toHaveTextContent("common.tasks");

View file

@ -30,12 +30,13 @@ vi.mock("@/utils/i18n", () => ({
}));
describe("MemoActionMenu", () => {
it("uses the standard compact action surface and preserves its open state", async () => {
it("is a quiet compact control that takes the accent fill while open", async () => {
const memo = create(MemoSchema, { name: "memos/1", state: State.NORMAL, pinned: false });
render(<MemoActionMenu memo={memo} parentScope="preserve" />);
const trigger = screen.getByRole("button", { name: "common.more" });
expect(trigger).toHaveClass("size-6", "rounded-md", "hover:bg-accent", "focus-visible:ring-2", "data-popup-open:bg-accent");
expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "data-popup-open:bg-accent");
expect(trigger.className).not.toMatch(/ring-/);
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger.querySelector(".lucide-ellipsis-vertical")).toHaveClass("size-4");

View file

@ -1,7 +1,7 @@
import { DirectionProvider } from "@base-ui/react/direction-provider";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
import { ViewProvider } from "@/contexts/ViewContext";
@ -44,7 +44,7 @@ describe("MemoDisplaySettingMenu", () => {
);
const trigger = screen.getByRole("button", { name: "View options" });
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);
fireEvent.click(trigger);

View file

@ -12,9 +12,10 @@ vi.mock("@/components/RelativeTime", () => ({
default: () => <>time</>,
}));
// The menu owns its trigger's look now (see memo-action-menu.test.tsx); the header only places it.
vi.mock("@/components/MemoActionMenu", () => ({
default: ({ className }: { className?: string }) => (
<button type="button" aria-label="memo-actions" className={className}>
default: () => (
<button type="button" aria-label="memo-actions">
Memo actions
</button>
),
@ -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) => {

View file

@ -25,7 +25,8 @@ describe("ReactionSelector", () => {
render(<ReactionSelector memo={memo} />);
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);

View file

@ -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", () => {

View file

@ -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 }) => {

View file

@ -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();