diff --git a/.changeset/accessible-control-contrast.md b/.changeset/accessible-control-contrast.md
new file mode 100644
index 0000000..bcf43ca
--- /dev/null
+++ b/.changeset/accessible-control-contrast.md
@@ -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.
diff --git a/apps/web/e2e/details-dialog.e2e.ts b/apps/web/e2e/details-dialog.e2e.ts
index 0ac1d3a..e499e51 100644
--- a/apps/web/e2e/details-dialog.e2e.ts
+++ b/apps/web/e2e/details-dialog.e2e.ts
@@ -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)',
diff --git a/apps/web/e2e/search.e2e.ts b/apps/web/e2e/search.e2e.ts
index f4d827c..779bf69 100644
--- a/apps/web/e2e/search.e2e.ts
+++ b/apps/web/e2e/search.e2e.ts
@@ -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');
diff --git a/apps/web/e2e/signin.e2e.ts b/apps/web/e2e/signin.e2e.ts
index 007e0c7..eeae3a7 100644
--- a/apps/web/e2e/signin.e2e.ts
+++ b/apps/web/e2e/signin.e2e.ts
@@ -21,7 +21,7 @@ const logo =
'';
const favicon =
'';
-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(
- ``
- );
+ expect(text).toContain(`--color-primary:light-dark(${light},${dark})`);
} else {
expect(text).not.toContain('--color-primary');
}
diff --git a/apps/web/src/lib/components/search/BookmarkSearch.svelte b/apps/web/src/lib/components/search/BookmarkSearch.svelte
index 56bf190..0b03315 100644
--- a/apps/web/src/lib/components/search/BookmarkSearch.svelte
+++ b/apps/web/src/lib/components/search/BookmarkSearch.svelte
@@ -139,6 +139,7 @@
aria-hidden="true"
focusable="false"
/>
+ Search
{/snippet}
{#snippet clearButton()}
@@ -148,7 +149,6 @@
{/snippet}
-
.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;
}
diff --git a/apps/web/src/lib/server/appearance.test.ts b/apps/web/src/lib/server/appearance.test.ts
index e9dfec4..065ee93 100644
--- a/apps/web/src/lib/server/appearance.test.ts
+++ b/apps/web/src/lib/server/appearance.test.ts
@@ -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(
- ``
+ ``
);
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(
- ''
+ ``
);
});
diff --git a/apps/web/src/lib/server/appearance.ts b/apps/web/src/lib/server/appearance.ts
index 4d9d767..8b9bdbf 100644
--- a/apps/web/src/lib/server/appearance.ts
+++ b/apps/web/src/lib/server/appearance.ts
@@ -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 ``;
+ const fillLight = getContrastBackground(light, onLight);
+ const fillDark = getContrastBackground(dark, onDark);
+ return ``;
}
diff --git a/apps/web/src/lib/server/color.test.ts b/apps/web/src/lib/server/color.test.ts
index 9dd027a..c5f233f 100644
--- a/apps/web/src/lib/server/color.test.ts
+++ b/apps/web/src/lib/server/color.test.ts
@@ -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);
}
}
);
diff --git a/apps/web/src/lib/server/color.ts b/apps/web/src/lib/server/color.ts
index 2b4db71..72b8bf9 100644
--- a/apps/web/src/lib/server/color.ts
+++ b/apps/web/src/lib/server/color.ts
@@ -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('')}`;
+}
diff --git a/apps/web/src/routes/(public)/signin/+page.svelte b/apps/web/src/routes/(public)/signin/+page.svelte
index 323d6f9..2d2a00d 100644
--- a/apps/web/src/routes/(public)/signin/+page.svelte
+++ b/apps/web/src/routes/(public)/signin/+page.svelte
@@ -1,7 +1,6 @@