fix(web): the selected tab is marked by an accent, not a background step (BUG-3420) (#1829)

In dark mode the active workspace tab was hard to tell from the others:
its background is the page colour (the tab flows into the page), which
measured 1.07:1 against the strip and its neighbours (1.09:1 in light),
with the same text colour and weight. It now carries a 2px accent line
along its top edge (inset box-shadow): 4.85:1 / 5.20:1 in dark, 5.70:1 /
5.24:1 in light, against the strip and the tab.

A survey of every selected-state control with siblings, across 12 pages
in both themes, found three tab-like segmented toggles failing the same
way (background steps of 1.05-1.16:1): the item pane's Rich / Markdown
toggle, the tag page's view toggle and the Insights window selector. Each
selected segment gets a 1px inset accent ring (>= 4.47:1). Underline tab
bars (item pane, settings, library, admin) already met 3:1 and are
unchanged.

The e2e pins the requirement for the tab bar in both colour schemes: the
active tab's indicator reaches 3:1 against an inactive tab and the strip.
Red on the old build (no indicator) and on a mutant drawing the line in
the border grey.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-06 10:49:45 -04:00
committed by GitHub
parent f9d79aea94
commit d39cb9f00a
5 changed files with 100 additions and 2 deletions
@@ -0,0 +1,82 @@
import { test, expect, type Page } from '@playwright/test';
import { asNewUser, openTabs, show } from './lib/workspace-tabs';
/**
* BUG-3420: in dark mode the selected workspace tab was hard to tell from the
* others. Its background is the page colour (the tab flows into the page), and
* that measured 1.07:1 against the strip and its neighbours in dark mode
* (1.09:1 in light), with the same text colour and weight. The fix is an accent
* line along the active tab's top edge, an inset box-shadow.
*
* This pins the requirement, not the style: in BOTH colour schemes the active
* tab carries an indicator colour that reaches WCAG 1.4.11's 3:1 against an
* inactive tab's background AND the strip's. Computed in the browser from the
* rendered colours, so a token change that sinks the contrast fails here.
*/
type Ratios = { indicator: string | null; vsInactive: number; vsStrip: number };
async function activeTabIndicator(page: Page): Promise<Ratios> {
return page.evaluate(() => {
const parse = (c: string) => {
const m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const p = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
};
type C = NonNullable<ReturnType<typeof parse>>;
const lum = ({ r, g, b }: C) => {
const f = (v: number) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (a: C, b: C) => {
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
return (x + 0.05) / (y + 0.05);
};
// The background an element shows: blend up to the first opaque ancestor.
const effBg = (el: Element | null): C => {
const stack: C[] = [];
for (let n = el; n; n = n.parentElement) {
const c = parse(getComputedStyle(n).backgroundColor);
if (c && c.a > 0) {
stack.push(c);
if (c.a >= 1) break;
}
}
let acc: C = { r: 0, g: 0, b: 0, a: 1 };
for (let i = stack.length - 1; i >= 0; i--) {
const t = stack[i];
acc = { r: t.r * t.a + acc.r * (1 - t.a), g: t.g * t.a + acc.g * (1 - t.a), b: t.b * t.a + acc.b * (1 - t.a), a: 1 };
}
return acc;
};
const active = document.querySelector('.workspace-tab.active')!;
const inactive = document.querySelector('.workspace-tab:not(.active)')!;
const strip = active.parentElement!;
const shadow = getComputedStyle(active).boxShadow;
const ind = shadow === 'none' ? null : parse(shadow);
if (!ind || ind.a < 1) return { indicator: null, vsInactive: 0, vsStrip: 0 };
return {
indicator: shadow,
vsInactive: +ratio(ind, effBg(inactive)).toFixed(2),
vsStrip: +ratio(ind, effBg(strip)).toFixed(2)
};
});
}
for (const colorScheme of ['dark', 'light'] as const) {
test(`BUG-3420: the active workspace tab has a 3:1 indicator (${colorScheme})`, async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'the tab bar is desktop only');
await page.emulateMedia({ colorScheme });
const { username, slugs } = await asNewUser(page);
await openTabs(page, slugs, 3);
await show(page, username, slugs[1], 3);
await expect
.poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme')))
.toBe(colorScheme === 'light' ? 'light' : null);
const r = await activeTabIndicator(page);
expect(r.indicator, 'the active tab carries no solid indicator').not.toBeNull();
expect(r.vsInactive, `indicator vs an inactive tab (${r.indicator})`).toBeGreaterThanOrEqual(3);
expect(r.vsStrip, `indicator vs the strip (${r.indicator})`).toBeGreaterThanOrEqual(3);
});
}
@@ -8391,10 +8391,12 @@
color: var(--text-secondary);
}
/* Selected segment: a 1px accent ring (BUG-3420). Background alone measured
1.05-1.16:1 against its neighbours in both themes; the ring is >= 4.4:1. */
.mode-btn.active {
background: var(--bg-secondary);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
/* Code context */
@@ -1028,9 +1028,18 @@
.workspace-tab:hover {
background: var(--bg-hover);
}
/* The selected tab carries a 2px accent line along its top edge (BUG-3420).
Its background alone could not mark it: the active tab flows into the
page, so it IS the page colour, and that measured 1.07:1 (dark) and
1.09:1 (light) against the strip and its neighbours, with the same text
colour and weight. The line is WCAG non-text contrast against both: in
dark #9268f8 is 4.85:1 on the strip and 5.20:1 on the tab, in light
#7c3aed is 5.70:1 and 5.24:1. Inset, so it follows the rounded corners
and moves nothing. */
.workspace-tab.active {
background: var(--bg-primary);
border-color: var(--border);
box-shadow: inset 0 2px 0 var(--accent-primary);
z-index: 1;
}
.workspace-tab-close {
@@ -848,10 +848,12 @@
.window-btn:hover {
color: var(--text-primary);
}
/* Selected segment: a 1px accent ring (BUG-3420). Background alone measured
1.05-1.16:1 against its neighbours in both themes; the ring is >= 4.4:1. */
.window-btn.active {
background: var(--bg-primary, var(--bg));
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
/* ── Print report link ────────────────────────────────────────────── */
@@ -287,10 +287,13 @@
border-left: 1px solid var(--border);
}
/* Selected segment: a 1px accent ring (BUG-3420). Background alone measured
1.05-1.16:1 against its neighbours in both themes; the ring is >= 4.4:1. */
.view-btn.active {
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: 600;
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
.tag-board {