Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 45 additions & 31 deletions src/settings/styleInjector.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,52 @@
import { describe, it, expect } from 'vitest';
import { compensateForReadability, darkerUnderline, getColorMap } from './styleInjector';
import { compensateForReadability, themedUnderline, getColorMap } from './styleInjector';
import type { Settings } from './data';

describe('darkerUnderline', () => {
it('emits hsl() string with saturation 100%', () => {
expect(darkerUnderline('#fff3a3')).toMatch(/^hsl\(\d+ 100% \d+%\)$/);
});
it('default yellow #fff3a3 → hsl(52 100% 52%)', () => {
expect(darkerUnderline('#fff3a3')).toBe('hsl(52 100% 52%)');
});
it('default red #ffb3b3 → hsl(0 100% 55%)', () => {
expect(darkerUnderline('#ffb3b3')).toBe('hsl(0 100% 55%)');
});
it('default green #b3e6b3 → hsl(120 100% 50%)', () => {
expect(darkerUnderline('#b3e6b3')).toBe('hsl(120 100% 50%)');
});
it('normalizes 3-digit hex (#fff → #ffffff); always emits saturation 100%', () => {
// Achromatic input still emits 100% saturation — visual result is identical at l=70%.
expect(darkerUnderline('#fff')).toBe('hsl(0 100% 70%)');
});
it('clamps lightness at 0 for black', () => {
expect(darkerUnderline('#000')).toBe('hsl(0 100% 0%)');
});
it('uppercase hex normalizes correctly', () => {
expect(darkerUnderline('#FFB3B3')).toBe('hsl(0 100% 55%)');
describe('themedUnderline', () => {
describe('light theme (darkened)', () => {
it('emits hsl() string for chromatic input', () => {
expect(themedUnderline('#fff3a3', 'light')).toMatch(/^hsl\(\d+ 100% \d+%\)$/);
});
it('default yellow #fff3a3 → hsl(52 100% 52%)', () => {
expect(themedUnderline('#fff3a3', 'light')).toBe('hsl(52 100% 52%)');
});
it('default red #ffb3b3 → hsl(0 100% 55%)', () => {
expect(themedUnderline('#ffb3b3', 'light')).toBe('hsl(0 100% 55%)');
});
it('default green #b3e6b3 → hsl(120 100% 50%)', () => {
expect(themedUnderline('#b3e6b3', 'light')).toBe('hsl(120 100% 50%)');
});
it('achromatic input uses 0% saturation, not red', () => {
expect(themedUnderline('#fff', 'light')).toBe('hsl(0 0% 70%)');
});
it('clamps lightness at 0 for black', () => {
expect(themedUnderline('#000', 'light')).toBe('hsl(0 0% 0%)');
});
it('uppercase hex normalizes correctly', () => {
expect(themedUnderline('#FFB3B3', 'light')).toBe('hsl(0 100% 55%)');
});
it('grey input produces grey underline, not red', () => {
const result = themedUnderline('#888', 'light');
expect(result).toBe('hsl(0 0% 23%)');
});
});
it('reduces lightness by 30 percentage points (approx)', () => {
// #888 = lightness ~53% → ~23%
const result = darkerUnderline('#888');
const match = result.match(/hsl\(\d+ \d+% (\d+)%\)/);
expect(match).not.toBeNull();
const l = Number(match![1]);
expect(l).toBeGreaterThanOrEqual(22);
expect(l).toBeLessThanOrEqual(24);
describe('dark theme (brightened)', () => {
it('brightens yellow', () => {
// #fff3a3 l~82% → 82+15 = 97% (capped at 100% test below)
expect(themedUnderline('#fff3a3', 'dark')).toBe('hsl(52 100% 97%)');
});
it('brightens grey instead of darkening it', () => {
// #888 l~53% → 53+15 = 68%
expect(themedUnderline('#888', 'dark')).toBe('hsl(0 0% 68%)');
});
it('brightens black', () => {
// #000 l~0% → 0+15 = 15%
expect(themedUnderline('#000', 'dark')).toBe('hsl(0 0% 15%)');
});
it('caps lightness at 100% for white', () => {
// #fff l=100% → 100+15 = 115% capped at 100%
expect(themedUnderline('#fff', 'dark')).toBe('hsl(0 0% 100%)');
});
});
});

Expand Down
33 changes: 16 additions & 17 deletions src/settings/styleInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,19 @@ export interface HighlightColorVars {
}

function varsForColor(slug: string, hex: string, paletteId: string, mode: ReadabilityMode | null): HighlightColorVars {
// Locked builtin rows normally use live Obsidian theme tokens. Those tokens
// are tuned for accent *text*, so as a highlight *fill* they can be too
// bright (dark theme) or too dark (light theme) — resolve the token to a
// concrete hex and run it through the same HSV compensation as custom
// colors. When compensation is off we keep the raw CSS var so it tracks
// the theme live.
const themeMode = getThemeMode();
if (paletteId === 'builtin' && isBuiltinColorSlug(slug)) {
if (!mode) {
return { bg: builtinCssVar(slug), underline: builtinUnderlineCss(slug) };
}
const resolved = typeof activeDocument === 'undefined'
? null
: cssColorToHex(getComputedStyle(activeDocument.body).getPropertyValue(`--color-${slug}`).trim());
if (resolved) {
const bg = compensateForReadability(resolved, mode);
return { bg, underline: darkerUnderline(bg) };
const bg = mode ? compensateForReadability(resolved, mode) : builtinCssVar(slug);
return { bg, underline: themedUnderline(resolved, themeMode) };
}
return { bg: builtinCssVar(slug), underline: builtinUnderlineCss(slug) };
}
const bg = mode ? compensateForReadability(hex, mode) : hex;
return { bg, underline: darkerUnderline(bg) };
return { bg, underline: themedUnderline(hex, themeMode) };
}

/** `'dark'` when a dark Obsidian theme is active, `'light'` otherwise.
Expand Down Expand Up @@ -136,22 +128,29 @@ export function removeHighlightStyle(): void {
}
}

// Lightness drop 30% + saturation 100% — matches iA Writer bg/underline pair (#FAECA0 → #FFD900).
export function darkerUnderline(hex: string): string {
/**
* Compute an underline color for the given hex. On light themes the underline
* is darkened (l - 30pp); on dark themes it is brightened (l + 30pp).
* Achromatic inputs (R=G=B) always produce neutral-grey output (0% saturation),
* avoiding the bug where hue defaults to red when max===min.
*/
export function themedUnderline(hex: string, mode: ReadabilityMode): string {
const norm = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
const r = parseInt(norm.slice(1, 3), 16) / 255;
const g = parseInt(norm.slice(3, 5), 16) / 255;
const b = parseInt(norm.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const isAchromatic = max === min;
const l = (max + min) / 2;
let h = 0;
if (max !== min) {
if (!isAchromatic) {
const d = max - min;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
const newL = Math.max(0, l - 0.3);
return `hsl(${Math.round(h * 360)} 100% ${Math.round(newL * 100)}%)`;
const sat = isAchromatic ? 0 : 100;
const newL = mode === 'dark' ? Math.min(1, l + 0.15) : Math.max(0, l - 0.3);
return `hsl(${Math.round(h * 360)} ${sat}% ${Math.round(newL * 100)}%)`;
}
4 changes: 2 additions & 2 deletions src/settings/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from './data';
import { commandIdForColor, getHotkeyForCommand, openHotkeyAssignment } from '../plugin/hotkeys';
import { capitalize } from '../plugin/contextMenu';
import { compensateForReadability, darkerUnderline, getThemeMode } from './styleInjector';
import { compensateForReadability, themedUnderline, getThemeMode } from './styleInjector';
import { t } from '../i18n';

const STYLES: HighlightStyle[] = ['default', 'lowlight', 'underlined'];
Expand Down Expand Up @@ -115,7 +115,7 @@ export class HighlightSettingTab extends PluginSettingTab {
? color.hex
: compensateForReadability(color.hex, mode);
el.style.setProperty('--hl-bg', compensated);
el.style.setProperty('--hl-underline', darkerUnderline(compensated));
el.style.setProperty('--hl-underline', themedUnderline(color.hex, mode));
}
};
for (const style of STYLES) {
Expand Down
Loading