Skip to content

Commit 07fb362

Browse files
committed
Pin fg-secondary/dimmed alpha to WCAG AA against the active theme bg
Light Omarchy themes (Catppuccin Latte, Rose Pine Dawn) put fg foreground around #4c4f69 — at the previous fixed alphas of 0.75 / 0.5 the alpha-blended secondary/dimmed labels landed at ~4.5:1 and ~2.5:1, the latter failing even AA-Large. Compute the minimum alpha that hits AA (4.5:1) for fg-secondary and AA-Large (3:1) for fg-dimmed via the new wcagAlphaForContrast helper, falling back to the design alpha when contrast budget allows so dark-theme dimming stays unchanged.
1 parent a21bb12 commit 07fb362

2 files changed

Lines changed: 80 additions & 6 deletions

File tree

frontend/src/App.svelte

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
import KeymapDialog from '$lib/components/shared/KeymapDialog.svelte';
7979
import CommandPalette from '$lib/components/shared/CommandPalette.svelte';
8080
import {initKeyboardShortcuts, registerShortcut} from '$lib/utils/keyboard';
81-
import {hexToRgb, isLightRgb} from '$lib/utils/color';
81+
import {hexToRgb, isLightRgb, wcagAlphaForContrast} from '$lib/utils/color';
8282
import {buildCommands} from '$lib/commands/commands.svelte';
8383
import type {main} from '../wailsjs/go/models';
8484
@@ -100,6 +100,12 @@
100100
['--color-border', 0.1, 0.08],
101101
['--color-border-focus', 0.2, 0.18],
102102
];
103+
// Design alpha for fg-secondary/dimmed; bumped at apply-time when needed
104+
// to satisfy WCAG against the actual theme bg/fg pair.
105+
const FG_SECONDARY_DESIGN_ALPHA = 0.75;
106+
const FG_DIMMED_DESIGN_ALPHA = 0.5;
107+
const WCAG_AA_RATIO = 4.5;
108+
const WCAG_AA_LARGE_RATIO = 3;
103109
104110
// Mirror editor state into Go (debounced) so `aether status` and other IPC
105111
// readers reflect live edits without waiting for an Apply. Long enough
@@ -311,17 +317,36 @@
311317
'--color-fg-primary',
312318
colors.foreground
313319
);
314-
// Derive secondary/dimmed from foreground so labels
315-
// stay readable when the theme bg is light — the
316-
// default tokens are tuned for dark bg only.
320+
// Pick alpha that satisfies WCAG AA / AA-Large
321+
// against the actual theme bg, falling back to the
322+
// designed dim levels when contrast budget allows.
317323
const fg = hexToRgb(colors.foreground);
324+
const bg = colors.background
325+
? hexToRgb(colors.background)
326+
: null;
327+
const secondaryAlpha = bg
328+
? wcagAlphaForContrast(
329+
fg,
330+
bg,
331+
FG_SECONDARY_DESIGN_ALPHA,
332+
WCAG_AA_RATIO
333+
)
334+
: FG_SECONDARY_DESIGN_ALPHA;
335+
const dimmedAlpha = bg
336+
? wcagAlphaForContrast(
337+
fg,
338+
bg,
339+
FG_DIMMED_DESIGN_ALPHA,
340+
WCAG_AA_LARGE_RATIO
341+
)
342+
: FG_DIMMED_DESIGN_ALPHA;
318343
root.style.setProperty(
319344
'--color-fg-secondary',
320-
`rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.75)`
345+
`rgba(${fg.r}, ${fg.g}, ${fg.b}, ${secondaryAlpha})`
321346
);
322347
root.style.setProperty(
323348
'--color-fg-dimmed',
324-
`rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.5)`
349+
`rgba(${fg.r}, ${fg.g}, ${fg.b}, ${dimmedAlpha})`
325350
);
326351
document.body.style.color = colors.foreground;
327352
}

frontend/src/lib/utils/color.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,62 @@ export function copyColor(hex: string): void {
199199
// photopic luminous efficiency (Rec. 709). Returns 0..1.
200200
export function relativeLuminance(hex: string): number {
201201
const {r, g, b} = hexToRgb(hex);
202+
return luminanceRgb(r, g, b);
203+
}
204+
205+
function luminanceRgb(r: number, g: number, b: number): number {
202206
const chan = (v: number) => {
203207
const s = v / 255;
204208
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
205209
};
206210
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);
207211
}
208212

213+
// Source-over composite of an RGB foreground at `alpha` over an opaque RGB
214+
// background. Returns the resulting opaque RGB.
215+
export function alphaBlend(
216+
fg: {r: number; g: number; b: number},
217+
bg: {r: number; g: number; b: number},
218+
alpha: number
219+
): {r: number; g: number; b: number} {
220+
const a = Math.max(0, Math.min(1, alpha));
221+
return {
222+
r: fg.r * a + bg.r * (1 - a),
223+
g: fg.g * a + bg.g * (1 - a),
224+
b: fg.b * a + bg.b * (1 - a),
225+
};
226+
}
227+
228+
// Smallest alpha in [designAlpha, 1] such that the alpha-blended fg over bg
229+
// meets `targetRatio`. Returns `designAlpha` if it already passes; returns 1
230+
// if even fully opaque can't reach the target (theme's own contrast is too
231+
// weak — caller decides what to do).
232+
export function wcagAlphaForContrast(
233+
fg: {r: number; g: number; b: number},
234+
bg: {r: number; g: number; b: number},
235+
designAlpha: number,
236+
targetRatio: number
237+
): number {
238+
const lbg = luminanceRgb(bg.r, bg.g, bg.b);
239+
const ratioAt = (a: number) => {
240+
const c = alphaBlend(fg, bg, a);
241+
const lc = luminanceRgb(c.r, c.g, c.b);
242+
const hi = Math.max(lc, lbg);
243+
const lo = Math.min(lc, lbg);
244+
return (hi + 0.05) / (lo + 0.05);
245+
};
246+
if (ratioAt(designAlpha) >= targetRatio) return designAlpha;
247+
if (ratioAt(1) < targetRatio) return 1;
248+
let lo = designAlpha;
249+
let hi = 1;
250+
for (let i = 0; i < 14; i++) {
251+
const mid = (lo + hi) / 2;
252+
if (ratioAt(mid) >= targetRatio) hi = mid;
253+
else lo = mid;
254+
}
255+
return hi;
256+
}
257+
209258
export type ContrastLevel = 'AAA' | 'AA' | 'AA-L' | 'fail';
210259

211260
/**

0 commit comments

Comments
 (0)