Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/assets/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof SidebarSection>> = {}) {
return render(
<SidebarProvider>
<SidebarSection id="contrast" label="Notes" {...props}>
<div>child</div>
</SidebarSection>
</SidebarProvider>
)
}

function headerButton(): HTMLElement {
renderSection()
return screen.getByRole('button', { name: /^Notes section/ })
}

// `SidebarSection` seeds `isExpanded` from `sidebar-section-<id>-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')
})
})
14 changes: 11 additions & 3 deletions apps/desktop/src/renderer/src/components/sidebar-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -135,7 +139,11 @@ export const SidebarSection = ({
<SectionChevron expanded={isExpanded} />

{!isExpanded && totalCount !== undefined && totalCount > 0 && (
<span className="text-sidebar-muted/60 tabular-nums text-[10px] leading-3">
// 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.
<span className="text-sidebar-section-heading tabular-nums text-[10px] leading-3">
({totalCount})
</span>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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, unknown>) =>
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(<SidebarTagList maxVisible={1} />)
}

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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ function TagTreeItem({
</ContextMenuContent>
</ContextMenu>

<span className="ms-auto pe-2.5 text-[10px] text-muted-foreground/40 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{/* 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. */}
<span className="ms-auto pe-2.5 text-[10px] text-sidebar-section-heading tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{node.totalCount}
</span>
</div>
Expand Down Expand Up @@ -526,10 +529,16 @@ export function SidebarTagList({

return (
<div key={group.id} className="flex flex-col gap-0.5">
{/* 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. */}
<button
type="button"
onClick={() => handleToggle(`${CATEGORY_KEY_PREFIX}${group.id}`)}
className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide text-sidebar-section-heading hover:bg-muted transition-colors"
aria-label={isGroupExpanded ? t('action.collapse') : t('action.expand')}
>
{isGroupExpanded ? (
Expand All @@ -556,7 +565,13 @@ export function SidebarTagList({
<button
type="button"
onClick={() => toggleShowAllForGroup(group.id)}
className="rounded-sm py-0.5 px-2 ms-6 text-[11px] font-medium leading-3.5 text-sidebar-muted hover:text-sidebar-foreground transition-colors text-start"
// 11px is small text under WCAG AA. `--sidebar-muted`
// was 1.87:1 on the paper sidebar and hovering made it
// 3.18:1 — still under 4.5:1. This is a control rather
// than a heading, so unlike the section headings it
// keeps a hover colour: `--sidebar-primary` raises the
// ratio (15.2:1 paper) instead of lowering it.
className="rounded-sm py-0.5 px-2 ms-6 text-[11px] font-medium leading-3.5 text-sidebar-section-heading hover:text-sidebar-primary transition-colors text-start"
>
{showAllForGroup
? t('action.showLess')
Expand Down
Loading
Loading