From eaec75efc1d5014e0450aa7f9c40eac807e5b9f9 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Fri, 7 Aug 2026 15:02:19 +0300 Subject: [PATCH 1/2] fix(a11y): hold sidebar small text to WCAG AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar text that carries real information renders at 10-11px, which WCAG AA treats as small text and holds to 4.5:1. Several of those elements were well under the floor on the paper sidebar: SidebarSection collapsed count --sidebar-muted/60 1.43:1 Tag-group heading --muted-foreground/70 3.62:1 Tag list show-more control --sidebar-muted 1.87:1 Per-tag note count --muted-foreground/40 1.95:1 The section heading also swapped to `hover:text-sidebar-foreground`, which is 3.18:1 there, so it lost its guarantee exactly while being pointed at. All of them now take `--sidebar-section-heading`, the sidebar's de-emphasised small-text token, kept separate from `--sidebar-muted` because that one also colours chevrons and decorative icon buttons where the extra weight is too loud. Headings drop their hover colour — the chevron fading in, or the row's `bg-muted`, carries the affordance. The show-more control is a control rather than a label, so it keeps a hover colour, but one that raises the ratio instead of lowering it. Measured against each theme's own surface: 5.07 / 5.16 / 6.42 on `--sidebar`, and 5.07 / 5.06 / 6.01 on `--muted` for the elements whose row paints a hover background while the text is visible. jsdom has no cascade and no layout, so a render can never prove a ratio. The palette is literal hex in base.css, so tests/utils/contrast.ts reads the theme blocks straight from source and does the arithmetic. It fails on any state variant that repaints the text below the floor, or merely below where it rested, and understands Tailwind's `/NN` opacity modifier (`text-sidebar-muted/60` is not `--sidebar-muted`). Both components seed UI state from localStorage and nothing in the renderer setup clears it, so the tests pin it in `beforeEach`: a stored value would otherwise outrank `defaultExpanded`, collapse the tag category, or reorder the tags out from under a `maxVisible` slice, and which happened would depend on test order. --- apps/desktop/src/renderer/src/assets/base.css | 12 ++ .../sidebar-section.contrast.test.tsx | 75 +++++++ .../src/components/sidebar-section.tsx | 14 +- .../sidebar-tag-list.contrast.test.tsx | 85 ++++++++ .../components/sidebar/sidebar-tag-list.tsx | 21 +- apps/desktop/tests/utils/contrast.ts | 189 ++++++++++++++++++ docs/DESIGN_TOKENS.md | 9 + 7 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/sidebar-section.contrast.test.tsx create mode 100644 apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.contrast.test.tsx create mode 100644 apps/desktop/tests/utils/contrast.ts 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. */}