diff --git a/apps/desktop/src/renderer/src/assets/base.css b/apps/desktop/src/renderer/src/assets/base.css index 473755017..abacc0d8f 100644 --- a/apps/desktop/src/renderer/src/assets/base.css +++ b/apps/desktop/src/renderer/src/assets/base.css @@ -2098,6 +2098,13 @@ del.bn-inline-content { --sidebar-border: #d9d5ce; --sidebar-ring: var(--tint); --sidebar-muted: #b5b0a6; + /* The sidebar's de-emphasized small-text colour: section and tag-group + headings, item counts, the tag list's show-more control — anything + carrying real information at 10-11px, which WCAG AA treats as small text + (4.5:1). Separate from --sidebar-muted, which also colors chevrons and + decorative icon buttons where the extra weight would be too loud. + 5.07:1 on --sidebar, 5.07:1 on --muted (the row hover surface). */ + --sidebar-section-heading: #6b6459; --sidebar-terracotta: var(--tint); --sidebar-text-folder: #3d3a35; --sidebar-text-child: #5c5850; @@ -2116,6 +2123,8 @@ del.bn-inline-content { --sidebar-border: #e9e9e7; --sidebar-ring: var(--tint); --sidebar-muted: #b0afab; + /* 5.16:1 on --sidebar, 5.06:1 on --muted. See the :root block. */ + --sidebar-section-heading: #6b6966; --sidebar-terracotta: var(--tint); --sidebar-text-folder: #37352f; --sidebar-text-child: #6b6966; @@ -2134,6 +2143,8 @@ del.bn-inline-content { --sidebar-border: #333333; --sidebar-ring: var(--tint); --sidebar-muted: #6b6b6b; + /* 6.42:1 on --sidebar, 6.01:1 on --muted. See the :root block. */ + --sidebar-section-heading: #9d9d9d; --sidebar-terracotta: var(--tint); --sidebar-text-folder: #c5c0b8; --sidebar-text-child: #9a958d; @@ -2241,6 +2252,7 @@ del.bn-inline-content { --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-muted: var(--sidebar-muted); + --color-sidebar-section-heading: var(--sidebar-section-heading); --color-sidebar-terracotta: var(--sidebar-terracotta); --color-sidebar-text-folder: var(--sidebar-text-folder); --color-sidebar-text-child: var(--sidebar-text-child); diff --git a/apps/desktop/src/renderer/src/components/sidebar-section.contrast.test.tsx b/apps/desktop/src/renderer/src/components/sidebar-section.contrast.test.tsx new file mode 100644 index 000000000..17adef421 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/sidebar-section.contrast.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { SidebarSection } from './sidebar-section' +import { SidebarProvider } from '@/components/ui/sidebar' +import { + AA_SMALL_TEXT, + THEMES, + assertSmallTextContrast, + contrastRatio, + resolveColor +} from '@tests/utils/contrast' + +// The section heading renders at 11px and its collapsed item count at 10px, +// which WCAG AA treats as small text and holds to 4.5:1. jsdom has no cascade +// and no layout, so the ratios are computed from the literal hex in base.css — +// see tests/utils/contrast.ts. + +function renderSection(props: Partial> = {}) { + return render( + + +
child
+
+
+ ) +} + +function headerButton(): HTMLElement { + renderSection() + return screen.getByRole('button', { name: /^Notes section/ }) +} + +// `SidebarSection` seeds `isExpanded` from `sidebar-section--expanded` and +// writes it back on every toggle, and jsdom keeps localStorage for the whole +// file. A stored value silently outranks the `defaultExpanded={false}` the +// count test depends on, so clear it rather than let test order decide. +beforeEach(() => { + localStorage.clear() +}) + +describe('sidebar section contrast', () => { + it('defines the heading token in every sidebar theme', () => { + for (const selector of THEMES) { + expect(resolveColor(selector, '--sidebar-section-heading')).toMatch(/^#[0-9a-f]{6}$/i) + expect(resolveColor(selector, '--sidebar')).toMatch(/^#[0-9a-f]{6}$/i) + } + }) + + it.each(THEMES)('clears WCAG AA for small text in %s', (selector) => { + const heading = resolveColor(selector, '--sidebar-section-heading') + const background = resolveColor(selector, '--sidebar') + + expect(contrastRatio(heading, background)).toBeGreaterThanOrEqual(AA_SMALL_TEXT) + }) + + it('paints the header with the heading token', () => { + expect(headerButton().className.split(/\s+/)).toContain('text-sidebar-section-heading') + }) + + it('never swaps the header to a colour below AA on hover', () => { + // The sidebar paints `bg-sidebar`; the header has no background of its own. + expect(() => assertSmallTextContrast(headerButton().className, ['--sidebar'])).not.toThrow() + }) + + it('holds the collapsed item count to AA too', () => { + // The count is the only thing left saying how much a collapsed section + // hides, so it is informational text, not decoration. + renderSection({ defaultExpanded: false, totalCount: 7 }) + const count = screen.getByText('(7)') + + expect(() => assertSmallTextContrast(count.className, ['--sidebar'])).not.toThrow() + expect(count.className.split(/\s+/)).toContain('text-sidebar-section-heading') + }) +}) diff --git a/apps/desktop/src/renderer/src/components/sidebar-section.tsx b/apps/desktop/src/renderer/src/components/sidebar-section.tsx index c3eb9fb6e..d9135ff01 100644 --- a/apps/desktop/src/renderer/src/components/sidebar-section.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar-section.tsx @@ -123,8 +123,12 @@ export const SidebarSection = ({ 'flex flex-1 min-w-0 cursor-pointer items-center gap-1.5 px-2 py-1 h-6 shrink-0', 'text-[11px] leading-3.5 font-medium tracking-[0.04em]', "font-['DM_Sans',system-ui,sans-serif]", - 'text-sidebar-muted hover:text-sidebar-foreground', - 'transition-colors focus-visible:outline-none' + // Resting colour only. `hover:text-sidebar-foreground` used to live + // here, and on the paper sidebar that is 3.18:1 — under the 4.5:1 AA + // floor for 11px text — so the heading lost its guarantee exactly + // while being pointed at. The chevron fading in carries the affordance. + 'text-sidebar-section-heading', + 'focus-visible:outline-none' )} aria-expanded={isExpanded} aria-controls={contentId} @@ -135,7 +139,11 @@ export const SidebarSection = ({ {!isExpanded && totalCount !== undefined && totalCount > 0 && ( - + // The count is the only thing left telling you how much a + // collapsed section hides, so it is informational text, not + // decoration: `--sidebar-muted` at 60% was 1.43:1 on the paper + // sidebar. The heading token carries it at 5.07:1. + ({totalCount}) )} diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.contrast.test.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.contrast.test.tsx new file mode 100644 index 000000000..b22a91426 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.contrast.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { SidebarTagList } from './sidebar-tag-list' +import { assertSmallTextContrast } from '@tests/utils/contrast' + +// The tag list lives inside `bg-sidebar`. Its group headings render at 10px and +// its show-more control at 11px, both of which WCAG AA treats as small text and +// holds to 4.5:1. jsdom has no cascade and no layout, so the ratios come from +// the literal hex in base.css — see tests/utils/contrast.ts. + +vi.mock('@memry/i18n/renderer', () => ({ + useT: () => ({ + t: (key: string, params?: Record) => + String(params?.count ?? key.split('.').at(-1) ?? key) + }) +})) + +const TAGS = [ + { tag: 'alpha', count: 4, color: 'blue', icon: null, sortOrder: 0 }, + { tag: 'beta', count: 2, color: 'green', icon: null, sortOrder: 1 } +] + +vi.mock('@/hooks/use-notes-query', () => ({ + useNoteTagsQuery: () => ({ tags: TAGS, isLoading: false, error: null }) +})) + +vi.mock('@/hooks/use-sidebar-navigation', () => ({ + useSidebarNavigation: () => ({ openSidebarItem: vi.fn() }) +})) + +vi.mock('@/hooks/use-tag-categories', () => ({ + useTagCategories: () => ({ + categories: [{ id: 'work', name: 'Work', sortOrder: 0, tags: TAGS }], + uncategorized: [], + isLoading: false, + error: null + }) +})) + +// `SidebarTagList` seeds its expanded set and its sort order from localStorage +// and writes the expanded set back on mount, and jsdom keeps localStorage for +// the whole file. A stored collapsed category hides every element these tests +// query, and a stored sort order changes which tag survives `maxVisible={1}`, +// so clear it rather than let test order decide. +beforeEach(() => { + localStorage.clear() +}) + +// maxVisible below the group size is what makes the show-more control render. +function renderTagList(): void { + render() +} + +describe('sidebar tag list contrast', () => { + it('holds the tag-group heading to AA at rest and on hover', () => { + renderTagList() + const heading = screen.getByRole('button', { name: 'collapse' }) + + // The heading swaps its own background to `bg-muted` on hover, so it has to + // clear the floor against both surfaces. + expect(() => assertSmallTextContrast(heading.className, ['--sidebar', '--muted'])).not.toThrow() + expect(heading.className.split(/\s+/)).toContain('text-sidebar-section-heading') + }) + + it('holds the show-more control to AA at rest and on hover', () => { + renderTagList() + // The mocked translator echoes the interpolated count, so "1" is the label + // of "show 1 more". + const showMore = screen.getByRole('button', { name: '1' }) + + expect(() => assertSmallTextContrast(showMore.className, ['--sidebar'])).not.toThrow() + expect(showMore.className.split(/\s+/)).toContain('text-sidebar-section-heading') + }) + + it('holds the per-tag note count to AA', () => { + renderTagList() + // The count only fades in while its row is hovered, and that same hover + // paints the row `bg-muted`, so `--muted` is the only surface it sits on. + const count = screen.getByText('4') + + expect(() => assertSmallTextContrast(count.className, ['--muted'])).not.toThrow() + expect(count.className.split(/\s+/)).toContain('text-sidebar-section-heading') + }) +}) diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx index b0bdf12b2..1c56c6f55 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx @@ -260,7 +260,10 @@ function TagTreeItem({ - + {/* The note count is information, not chrome, and 10px is small text + under WCAG AA. `--muted-foreground` at 40% was 1.95:1 on the row's + hover background; the sidebar heading token holds 5.06:1 there. */} + {node.totalCount} @@ -526,10 +529,16 @@ export function SidebarTagList({ return (
+ {/* A category heading, so it takes the same token as every + other sidebar section heading. `--muted-foreground` at 70% + was 3.62:1 (paper) and 2.85:1 (white) at 10px, under the + 4.5:1 AA floor for small text. The colour no longer changes + on hover — `bg-muted` carries the affordance, and the token + clears AA on that surface too. */}