From 6e97ca574d1206df55b528aedcac7011f72cedaf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:50:56 +0100 Subject: [PATCH] fix: make switches that are off visible against the page --- .../__tests__/settings-section.test.tsx | 44 +++++++++++++++++++ components/settings/settings-section.tsx | 9 ++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/components/settings/__tests__/settings-section.test.tsx b/components/settings/__tests__/settings-section.test.tsx index b1060cdb7..038ddfe6b 100644 --- a/components/settings/__tests__/settings-section.test.tsx +++ b/components/settings/__tests__/settings-section.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { SettingItem, ToggleSwitch, RadioGroup, Select } from '../settings-section'; @@ -59,3 +61,45 @@ describe('SettingItem controls', () => { expect(screen.getByRole('switch', { name: 'Standalone' })).toBeTruthy(); }); }); + +describe('ToggleSwitch off state (finding 56)', () => { + // The token of the off track's outline and knob, per scheme, against the page. + function token(block: string, name: string): string { + const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8'); + const start = css.indexOf(block); + const end = css.indexOf('}', start); + const match = new RegExp(`${name}:[ \\t]*(#[0-9a-f]{6})`, 'i').exec(css.slice(start, end)); + if (!match) throw new Error(`${name} not found in ${block}`); + return match[1]; + } + function luminance(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + } + + it('outlines the off track and greys its knob; the on state is unchanged', () => { + const { rerender } = render( {}} ariaLabel="Off" />); + const off = screen.getByRole('switch'); + expect(off).toHaveClass('ring-1', 'ring-inset', 'ring-muted-foreground'); + expect(off.firstElementChild).toHaveClass('bg-muted-foreground'); + rerender( {}} ariaLabel="On" />); + const on = screen.getByRole('switch'); + expect(on).toHaveClass('bg-primary'); + expect(on).not.toHaveClass('ring-1'); + expect(on.firstElementChild).toHaveClass('bg-background'); + }); + + it('reaches 3:1 against the page in light and dark', () => { + for (const block of [':root {', '.dark {']) { + const ratio = contrast(token(block, '--color-muted-foreground'), token(block, '--color-background')); + expect(ratio, block).toBeGreaterThanOrEqual(3); + } + }); +}); diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index a3e103c78..f1b9d6e6d 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -256,14 +256,17 @@ export function ToggleSwitch({ checked, onChange, disabled, ariaLabel, testId }: onClick={() => onChange(!checked)} className={cn( 'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150', - checked ? 'bg-primary' : 'bg-muted', + // Off: an outlined track and a grey knob, 3:1 against the page in + // both schemes (a light track with a white knob was ~1.1:1). The + // ring is inset, so the on state keeps its size and look. + checked ? 'bg-primary' : 'bg-muted ring-1 ring-inset ring-muted-foreground', disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer' )} >