feat: add custom UI font support
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m17s
CI / Lint & Test (pull_request) Successful in 18m28s
CI / Build Linux (pull_request) Successful in 22m18s
CI / Build Windows (cross-compile) (pull_request) Successful in 33m40s

Allow users to specify a custom font for the entire app interface
(menus, labels, buttons) separately from the terminal font. Supports
Google Fonts URLs, direct font file URLs, and local file paths.

- Add custom_ui_font_path and custom_ui_font_family to Rust config
- Refactor applyCustomFont into shared applyFontFromSource helper
- Add applyCustomUiFont function using --ui-font-family CSS variable
- Update app.css to use --ui-font-family with fallback
- Apply custom UI font on startup in +page.svelte
- Add Custom UI Font section to ConfigSidebar settings panel
- Add tests for applyCustomUiFont and setCustomUiFont
This commit is contained in:
2026-03-03 18:41:21 -08:00
committed by Naomi Carrigan
parent c5feb9b43c
commit d2c39fd5c2
7 changed files with 306 additions and 50 deletions
+136
View File
@@ -6,6 +6,7 @@ import {
clampFontSize,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyTheme,
applyCustomThemeColors,
clearCustomThemeColors,
@@ -214,6 +215,8 @@ describe("config store", () => {
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -268,6 +271,8 @@ describe("config store", () => {
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
expect(config.model).toBeNull();
@@ -821,6 +826,8 @@ describe("config store", () => {
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
const mockInvokeImpl = vi.mocked(invoke);
@@ -1317,4 +1324,133 @@ describe("config store", () => {
);
});
});
describe("applyCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
readFileMock.mockReset();
});
it("removes CSS variable when both path and family are null", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
await applyCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
});
it("removes CSS variable when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
await applyCustomUiFont("", "");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
});
it("injects @font-face for a direct font file URL (.woff2)", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
});
it("uses HikariCustomUiFont as fallback family for direct font URLs when family is empty", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("'HikariCustomUiFont'");
});
it("reads local file and embeds as data URL", async () => {
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
});
it("sets CSS variable when only family is provided (no path)", async () => {
await applyCustomUiFont("", "SystemUiFont");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'SystemUiFont', sans-serif"
);
});
it("replaces a previously injected style element", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
const styles = document.querySelectorAll("#hikari-custom-ui-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto");
});
});
describe("setCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the UI font", async () => {
await configStore.setCustomUiFont(null, null);
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
custom_ui_font_family: "Inter",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
});
it("clears UI font when called with nulls", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
await configStore.setCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: null,
custom_ui_font_family: null,
}),
})
);
});
});
});