Improve control contrast and accessible search labeling #62
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.
|
||||
@@ -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)',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user