Improve control contrast and accessible search labeling #62

Merged
eleith merged 1 commits from accessibility-fixes into main 2026-10-27 21:35:46 +00:00
11 changed files with 89 additions and 32 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@directory/web': patch
---
Improve primary button and read-only destination text contrast, remove the sign-in lock icon, and give Search a single native label.
+5
View File
@@ -310,6 +310,11 @@ for (const width of [360, 1280]) {
await page.goto(`${server.url}/?link=alpha`);
const dialog = page.getByRole('dialog', { name: 'Alpha service' });
await expect(dialog).toBeVisible();
const destination = dialog.getByRole('textbox', { name: 'Destination', exact: true });
await expect(destination).toHaveCSS(
'color',
colorScheme === 'light' ? 'rgb(23, 23, 23)' : 'rgb(245, 245, 245)'
);
const close = dialog.getByRole('button', { name: 'Close', exact: true });
const external = dialog.getByRole('link', {
name: 'Open Alpha service (opens in a new tab)',
+4
View File
@@ -39,6 +39,10 @@ test('search finds a bookmark, opens its details and clears back to browsing', a
await expect(search).toBeEnabled();
await expect(page.getByRole('heading', { level: 2 })).toHaveText(['Media', 'Tools']);
const label = page.locator('label[for="directory-search"]');
await expect(label).toHaveCount(1);
await label.click();
await expect(search).toBeFocused();
await search.fill('photos');
await expect(results.getByRole('link')).toHaveCount(1);
await expect(results.getByRole('link')).toContainText('Family photos');
+5 -9
View File
@@ -21,7 +21,7 @@ const logo =
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect width="48" height="48" fill="#abcdef"/></svg>';
const favicon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7"/></svg>';
const light = '#abc';
const light = '#0f766e';
const dark = '#13579b';
let publicDir: string;
@@ -127,15 +127,12 @@ for (const strategy of ['credentials', 'sso'] as const) {
colorScheme === 'light' ? 'rgb(254, 242, 242)' : 'rgb(69, 10, 10)'
);
const button = page.getByRole('button');
await expect(button).toHaveCSS(
'background-color',
colorScheme === 'light' ? 'rgb(170, 187, 204)' : 'rgb(19, 87, 155)'
);
const colors = await button.evaluate((node) => ({
foreground: getComputedStyle(node).color,
background: getComputedStyle(node).backgroundColor
}));
expect(contrast(colors.foreground, colors.background)).toBeGreaterThanOrEqual(4.5);
expect(colors.background).toMatch(/^rgb\(\d+, \d+, \d+\)$/);
expect(contrast(colors.foreground, colors.background)).toBeGreaterThanOrEqual(7);
for (const input of await page.locator('input:not([type="hidden"])').all()) {
expect((await input.boundingBox())!.height).toBeGreaterThanOrEqual(44);
}
@@ -163,6 +160,7 @@ for (const strategy of ['credentials', 'sso'] as const) {
expect(Math.abs(card!.y + card!.height / 2 - 450)).toBeLessThan(1);
}
if (strategy === 'credentials') {
await expect(button.locator('svg')).toHaveCount(0);
await expect(page.getByLabel('Username')).toBeFocused();
await expect(page.getByLabel('Username')).toHaveAttribute('autocomplete', 'username');
await expect(page.getByLabel('Password')).toHaveAttribute(
@@ -205,9 +203,7 @@ for (const strategy of ['credentials', 'sso'] as const) {
expectPrivate(text, app);
for (const value of ['/logo', '/favicon']) expect(text).toContain(value);
if (path === '/signin') {
expect(text).toContain(
`<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(#171717,#f5f5f5)}</style>`
);
expect(text).toContain(`--color-primary:light-dark(${light},${dark})`);
} else {
expect(text).not.toContain('--color-primary');
}
@@ -139,6 +139,7 @@
aria-hidden="true"
focusable="false"
/>
<span class="visually-hidden">Search</span>
{/snippet}
{#snippet clearButton()}
@@ -148,7 +149,6 @@
{/snippet}
<div class="search">
<label class="visually-hidden" for="directory-search">Search</label>
<Input
class="search-controls"
bind:element={input}
+4 -5
View File
@@ -23,12 +23,12 @@
.ui-button--primary {
--ui-control-focus-color: var(--color-on-primary);
--ui-control-focus-background: var(--color-primary);
--ui-control-focus-background: var(--color-primary-fill);
min-width: 80px;
font-weight: 600;
color: var(--color-on-primary);
background: var(--color-primary);
background: var(--color-primary-fill);
}
.ui-button--icon {
@@ -116,7 +116,6 @@
.ui-input--readonly > .ui-input__control {
padding-inline: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.ui-input--trailing > .ui-input__control {
@@ -157,7 +156,7 @@
}
.ui-button--primary:not(:disabled):hover {
background: var(--color-primary);
background: var(--color-primary-fill);
box-shadow:
inset 0 0 0 var(--border-width) var(--color-on-primary),
var(--shadow-card);
@@ -185,7 +184,7 @@
}
.ui-button--primary:not(:disabled):active {
background: var(--color-primary);
background: var(--color-primary-fill);
box-shadow: inset 0 0 0 3px var(--color-on-primary);
outline: none;
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it } from 'vitest';
import { themeStyleTag } from './appearance.js';
import { getContrastBackground } from './color.js';
import { prepareSnapshot } from './directory/snapshot.js';
function snapshot(light: string, dark: string) {
@@ -37,8 +38,10 @@ describe('server-owned theme style', () => {
const captured = snapshot(light, dark);
const before = JSON.stringify(captured);
const style = themeStyleTag(captured.display.appearance);
const fillLight = getContrastBackground(light, onLight);
const fillDark = getContrastBackground(dark, onDark);
expect(style).toBe(
`<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(${onLight},${onDark})}</style>`
`<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(${onLight},${onDark});--color-primary-fill:light-dark(${fillLight},${fillDark})}</style>`
);
expect(Object.isFrozen(captured.display.appearance)).toBe(true);
expect(JSON.stringify(captured)).toBe(before);
@@ -56,8 +59,9 @@ describe('server-owned theme style', () => {
throw new Error('Theme must not traverse configuration');
}
};
const fillDark = getContrastBackground('#13579b', '#f5f5f5');
expect(themeStyleTag(appearance)).toBe(
'<style>:root{--color-primary:light-dark(#abc,#13579b);--color-on-primary:light-dark(#171717,#f5f5f5)}</style>'
`<style>:root{--color-primary:light-dark(#abc,#13579b);--color-on-primary:light-dark(#171717,#f5f5f5);--color-primary-fill:light-dark(#abc,${fillDark})}</style>`
);
});
+4 -2
View File
@@ -1,10 +1,12 @@
import type { DisplayAppearance } from '../display/types.js';
import { getContrastText } from './color.js';
import { getContrastBackground, getContrastText } from './color.js';
// Snapshot publication validates these colors as #rgb or #rrggbb before they reach HTML.
export function themeStyleTag(appearance: DisplayAppearance): string {
const { primary_light_color: light, primary_dark_color: dark } = appearance;
const onLight = getContrastText(light, '#171717');
const onDark = getContrastText(dark, '#f5f5f5');
return `<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(${onLight},${onDark})}</style>`;
const fillLight = getContrastBackground(light, onLight);
const fillDark = getContrastBackground(dark, onDark);
return `<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(${onLight},${onDark});--color-primary-fill:light-dark(${fillLight},${fillDark})}</style>`;
}
+7 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getContrastText } from './color.js';
import { getContrastBackground, getContrastText } from './color.js';
function contrast(primary: string, foreground: string): number {
function luminance(hex: string) {
@@ -28,10 +28,14 @@ describe('configured primary foreground', () => {
});
it.each(['#000', '#fff', '#ABC', '#abcdef', '#777777', '#00f', '#ff0000', '#0f766e', '#5eead4'])(
'provides at least 4.5:1 contrast against %s in both schemes',
'keeps readable accent text and provides a 7:1 filled background for %s in both schemes',
(primary) => {
for (const preferred of ['#171717', '#f5f5f5']) {
expect(contrast(primary, getContrastText(primary, preferred))).toBeGreaterThanOrEqual(4.5);
const foreground = getContrastText(primary, preferred);
const background = getContrastBackground(primary, foreground);
expect(contrast(primary, foreground)).toBeGreaterThanOrEqual(4.5);
expect(contrast(background, foreground)).toBeGreaterThanOrEqual(7);
if (contrast(primary, foreground) >= 7) expect(background).toBe(primary);
}
}
);
+47 -5
View File
@@ -1,12 +1,17 @@
type Rgb = readonly [red: number, green: number, blue: number];
function luminance(hex: string): number {
let digits = hex.slice(1);
if (digits.length === 3) digits = digits.replace(/./g, '$&$&');
function channelLuminance(offset: number): number {
const channel = parseInt(digits.slice(offset, offset + 2), 16) / 255;
const [red, green, blue] = parseHex(hex);
function channelLuminance(value: number): number {
const channel = value / 255;
if (channel <= 0.04045) return channel / 12.92;
return Math.pow((channel + 0.055) / 1.055, 2.4);
}
return channelLuminance(0) * 0.2126 + channelLuminance(2) * 0.7152 + channelLuminance(4) * 0.0722;
return (
channelLuminance(red) * 0.2126 +
channelLuminance(green) * 0.7152 +
channelLuminance(blue) * 0.0722
);
}
function contrastRatio(foreground: string, background: string): number {
@@ -24,3 +29,40 @@ export function getContrastText(primary: string, preferred: string): string {
if (contrastRatio('#ffffff', primary) >= 4.5) return '#ffffff';
return '#000000';
}
export function getContrastBackground(primary: string, foreground: string): string {
if (contrastRatio(foreground, primary) >= 7) return primary;
const contrastWithBlack = contrastRatio(foreground, '#000000');
const contrastWithWhite = contrastRatio(foreground, '#ffffff');
const targetColor = contrastWithBlack > contrastWithWhite ? '#000000' : '#ffffff';
const primaryRgb = parseHex(primary);
const targetRgb = parseHex(targetColor);
for (let percent = 1; percent <= 100; percent++) {
const blendedRgb = mixRgb(primaryRgb, targetRgb, percent);
const background = formatHex(blendedRgb);
if (contrastRatio(foreground, background) >= 7) return background;
}
return targetColor;
}
function parseHex(hex: string): Rgb {
let digits = hex.slice(1);
if (digits.length === 3) digits = [...digits].map((digit) => digit.repeat(2)).join('');
return [
parseInt(digits.slice(0, 2), 16),
parseInt(digits.slice(2, 4), 16),
parseInt(digits.slice(4, 6), 16)
];
}
function mixRgb(start: Rgb, end: Rgb, percent: number): Rgb {
function mixChannel(from: number, to: number): number {
return Math.round(from + ((to - from) * percent) / 100);
}
return [mixChannel(start[0], end[0]), mixChannel(start[1], end[1]), mixChannel(start[2], end[2])];
}
function formatHex(color: Rgb): string {
const channels = color.map((channel) => channel.toString(16).padStart(2, '0'));
return `#${channels.join('')}`;
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
import IconLock from 'virtual:icons/ph/lock';
import IconSignIn from 'virtual:icons/ph/sign-in';
import Logo from '$lib/components/Logo.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -68,10 +67,7 @@
required
/>
</div>
<Button variant="primary" type="submit">
<span class="icon"><IconLock aria-hidden="true" focusable="false" /></span>
Sign in
</Button>
<Button variant="primary" type="submit">Sign in</Button>
{:else}
<p class="sso-description">Use your single sign-on account to access the directory.</p>
<Button bind:element={ssoButton} variant="primary" type="submit">