diff --git a/.changeset/fn-058-medieval-theme.md b/.changeset/fn-058-medieval-theme.md
index 895b01ecfb..5b83b1bec3 100644
--- a/.changeset/fn-058-medieval-theme.md
+++ b/.changeset/fn-058-medieval-theme.md
@@ -4,4 +4,4 @@
summary: Add a Medieval dashboard theme with parchment surfaces and wood-framed modals.
category: feature
-dev: Bundles the local Press Start 2P pixel font without a CDN request.
+dev: Bundles a local pixel font without a CDN request.
diff --git a/.changeset/fn-061-medieval-theme-refinement.md b/.changeset/fn-061-medieval-theme-refinement.md
new file mode 100644
index 0000000000..e7d2af74b8
--- /dev/null
+++ b/.changeset/fn-061-medieval-theme-refinement.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Refine Medieval with readable pixel text and textured paper-and-wood surfaces.
+category: fix
+dev: Replaces the bundled UI font and confines CSS wood grain to generic modal frames.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 074b8cee0f..09141f0b91 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -2248,10 +2248,10 @@ Tokens in the table are stable. Renaming or removing one requires a deprecation
-
+
-Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 90 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), Aurora (navy/teal/violet dark surfaces with a misty blue-white light counterpart), Calm (low-stimulation slate/sage dark surfaces with a misty blue-white light counterpart), Dawn (indigo/plum dark surfaces with muted amber accents and a dawn-white light counterpart), Sage (soft green-gray surfaces with a muted olive accent and readable dark/light ramps), Midnight (deep-navy surfaces with restrained violet/indigo accents and a cool pale light counterpart), Velvet (deep plum/burgundy dark surfaces with rose-gold accents and a warm blush-white light counterpart), Medieval (restrained parchment surfaces, wood-framed generic modals, and local Press Start 2P pixel typography), Factory Dark (graphite/steel dark surfaces, cool gunmetal borders, and restrained safety-orange accents with a concrete-gray light counterpart), and Factory Light (bright concrete/warm-white surfaces, steel-gray borders, a darkened safety-orange accent, and a warm-graphite dark counterpart), the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink.
+Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 90 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), Aurora (navy/teal/violet dark surfaces with a misty blue-white light counterpart), Calm (low-stimulation slate/sage dark surfaces with a misty blue-white light counterpart), Dawn (indigo/plum dark surfaces with muted amber accents and a dawn-white light counterpart), Sage (soft green-gray surfaces with a muted olive accent and readable dark/light ramps), Midnight (deep-navy surfaces with restrained violet/indigo accents and a cool pale light counterpart), Velvet (deep plum/burgundy dark surfaces with rose-gold accents and a warm blush-white light counterpart), Medieval (readable local Pixelify Sans for ordinary UI, deliberate mono code/terminal exceptions, CSS paper texture, and wood-framed generic modals), Factory Dark (graphite/steel dark surfaces, cool gunmetal borders, and restrained safety-orange accents with a concrete-gray light counterpart), and Factory Light (bright concrete/warm-white surfaces, steel-gray borders, a darkened safety-orange accent, and a warm-graphite dark counterpart), the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink.
Choose color themes from **Settings → Appearance** or from the Command Center **Overview** theme card. Settings merges its selector into the current-theme row, which opens the full color-theme list; Command Center keeps the compact `ThemeDropdown` trigger. Both use the same filterable list: type a visible theme name to narrow the displayed labels and color-chip swatches, then select an option to change the theme. Filtering alone never changes the chosen theme. The selected color theme, including Midnight, Velvet, and Medieval, persists in browser storage and is validated before React hydrates on both web and Electron startup. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index fa21375375..84ffa36a69 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -75,7 +75,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|---|---|---:|---|
| `anthropicAuthPreference` | `"api-key" \| "subscription"` | `"api-key"` | Which Anthropic credential wins when BOTH a raw API key and a Claude subscription OAuth login are configured. `"api-key"` keeps the historical precedence; `"subscription"` moves the raw key below the OAuth steps so a stale or revoked saved key cannot shadow a working subscription (the `401 invalid x-api-key` failure mode). Global, because credentials live in the global `~/.fusion/agent/auth.json`. Never removes a source — with one credential configured, resolution reaches it under either value. Surfaced in Settings → Authentication, where the two Anthropic cards show which credential is in use; the control appears only when both are connected. |
| `themeMode` | `"dark" \| "light" \| "system"` | `"system"` | Dashboard theme mode. Fresh installs follow the operating system light/dark preference until the user chooses Light, Dark, or System. |
-| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Medieval adds restrained aged-parchment surfaces, a wood-framed generic modal treatment, and the locally bundled Press Start 2P pixel font with no CDN/runtime font request. Aurora is a built-in navy/teal/violet palette with a misty light counterpart; Calm is a low-stimulation slate/sage palette with a misty light counterpart; Dawn uses indigo/plum dark surfaces, amber accents, and dawn-white light surfaces; Midnight uses deep-navy surfaces with restrained violet/indigo accents and a cool pale light counterpart; Velvet uses deep plum/burgundy dark surfaces with rose-gold accents and a warm blush-white light counterpart; Factory Dark uses graphite/steel surfaces, cool gunmetal borders, and restrained safety-orange accents with a concrete-gray light counterpart; Factory Light uses bright concrete/warm-white surfaces, steel-gray borders, a darkened safety-orange accent, and a warm-graphite dark counterpart. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. |
+| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Medieval adds CSS-generated paper texture, a wood-framed generic modal treatment, and the locally bundled Pixelify Sans ordinary UI font with no CDN/runtime font request; code and terminal surfaces remain monospace. Aurora is a built-in navy/teal/violet palette with a misty light counterpart; Calm is a low-stimulation slate/sage palette with a misty light counterpart; Dawn uses indigo/plum dark surfaces, amber accents, and dawn-white light surfaces; Midnight uses deep-navy surfaces with restrained violet/indigo accents and a cool pale light counterpart; Velvet uses deep plum/burgundy dark surfaces with rose-gold accents and a warm blush-white light counterpart; Factory Dark uses graphite/steel surfaces, cool gunmetal borders, and restrained safety-orange accents with a concrete-gray light counterpart; Factory Light uses bright concrete/warm-white surfaces, steel-gray borders, a darkened safety-orange accent, and a warm-graphite dark counterpart. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. |
| `shadcnCustomColors` | `Record` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. |
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko" \| "pt-BR"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
diff --git a/packages/dashboard/app/__tests__/medieval-theme.test.ts b/packages/dashboard/app/__tests__/medieval-theme.test.ts
index 72ebf6107f..a74ad264d3 100644
--- a/packages/dashboard/app/__tests__/medieval-theme.test.ts
+++ b/packages/dashboard/app/__tests__/medieval-theme.test.ts
@@ -1,7 +1,24 @@
-import { describe, expect, it } from "vitest";
-import { COLOR_THEMES as CORE_COLOR_THEMES } from "@fusion/core";
+import { createElement as h } from "react";
+import { describe, expect, it, afterEach, beforeEach, vi } from "vitest";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { COLOR_THEMES as CORE_COLOR_THEMES, type ColorTheme, type MergeResult, type Task, type TaskDetail } from "@fusion/core";
import { COLOR_THEMES as DASHBOARD_COLOR_THEMES } from "../components/themeOptions";
+import { TaskDetailModal } from "../components/TaskDetailModal";
+import { ThemeSelector } from "../components/ThemeSelector";
+import { useTheme } from "../hooks/useTheme";
import { readAppFile } from "../test/cssFixture";
+import { fetchGlobalSettings, updateGlobalSettings } from "../api";
+
+vi.mock("../api", async (importOriginal) => {
+ const { createDashboardApiMock } = await import("../test/mockApi");
+ return createDashboardApiMock(() => importOriginal(), {
+ fetchGlobalSettings: vi.fn(),
+ updateGlobalSettings: vi.fn(),
+ });
+});
+
+const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
+const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
/*
FNXC:DashboardTheming 2026-08-19-19:23:
@@ -11,8 +28,29 @@ describe("Medieval color theme", () => {
const themeData = readAppFile("public/theme-data.css");
const themeSelector = readAppFile("components/ThemeSelector.css");
const styles = readAppFile("styles.css");
+ const documentsStyles = readAppFile("components/DocumentsView.css");
const dashboardIndexHtml = readAppFile("index.html");
const desktopIndexHtml = readAppFile("../../desktop/src/renderer/index.html");
+ let stylesheet: HTMLStyleElement | undefined;
+
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-theme");
+ document.documentElement.removeAttribute("data-color-theme");
+ document.documentElement.style.cssText = "";
+ mockFetchGlobalSettings.mockReset();
+ mockFetchGlobalSettings.mockImplementation(() => new Promise(() => {}));
+ mockUpdateGlobalSettings.mockReset();
+ mockUpdateGlobalSettings.mockResolvedValue({});
+ stylesheet = document.createElement("style");
+ stylesheet.textContent = `${themeData}\n${styles}\n${documentsStyles}`;
+ document.head.appendChild(stylesheet);
+ });
+
+ afterEach(() => {
+ stylesheet?.remove();
+ vi.clearAllMocks();
+ });
it("keeps persisted, selector, and first-paint registries in exact order", () => {
const coreIds = [...CORE_COLOR_THEMES];
@@ -27,30 +65,136 @@ describe("Medieval color theme", () => {
expect(desktopValidThemes).toEqual(coreIds);
});
- it("defines readable parchment tokens and a bundled pixel display face in both modes", () => {
+ it("defines readable bundled Pixelify Sans and paper tokens in both modes", () => {
for (const selector of ['[data-color-theme="medieval"]', '[data-color-theme="medieval"][data-theme="light"]']) {
const block = extractSelectorBlock(themeData, selector);
- for (const token of ["--bg:", "--surface:", "--card:", "--surface-hover:", "--border:", "--text:", "--color-success:", "--color-warning:", "--color-error:", "--color-info:", "--cta-bg:", "--cta-text:", "--accent:", "--focus-ring:", "--shadow-glow:", "--font-primary:"]) expect(block).toContain(token);
- expect(block).toContain('"Press Start 2P", ui-monospace, monospace');
+ for (const token of ["--bg:", "--surface:", "--card:", "--surface-hover:", "--border:", "--text:", "--color-success:", "--color-warning:", "--color-error:", "--color-info:", "--cta-bg:", "--cta-text:", "--accent:", "--focus-ring:", "--shadow-glow:", "--font-primary:", "--medieval-paper-fiber:", "--medieval-paper-mottle:", "--medieval-wood-light:", "--medieval-wood-dark:"]) expect(block).toContain(token);
+ expect(block).toContain('"Pixelify Sans", system-ui, sans-serif');
}
- expect(themeData).toContain('[data-color-theme="medieval"] body');
- expect(readAppFile("main.tsx")).toContain('@fontsource/press-start-2p/400.css');
+ const paperCanvas = extractSelectorBlock(themeData, '[data-color-theme="medieval"] body');
+ expect(paperCanvas).toContain("radial-gradient");
+ expect(paperCanvas).toContain("repeating-linear-gradient");
+ expect(themeData).not.toMatch(new RegExp(["Press", "Start", "2P"].join(" ")));
+ expect(paperCanvas).not.toMatch(/::before|backdrop-filter|\bfilter\s*:/);
+ expect(readAppFile("main.tsx")).toContain('@fontsource/pixelify-sans/400.css');
+ expect(readAppFile("main.tsx")).not.toContain("press-start");
});
- it("uses mode-specific swatches and generic modal paint without changing modal behavior", () => {
+ it.each(["dark", "light"] as const)("selects Medieval through Settings and applies every supported scale in %s mode", (themeMode) => {
+ localStorage.setItem("kb-dashboard-theme-mode", themeMode);
+ render(h(MedievalSymptomFixture));
+
+ fireEvent.click(screen.getByRole("button", { name: new RegExp(`current theme ${themeMode} / shadcn ember`, "i") }));
+ const listbox = screen.getByRole("listbox", { name: "Color theme" });
+ fireEvent.change(screen.getByRole("searchbox", { name: /filter color themes/i }), { target: { value: "medieval" } });
+ fireEvent.click(within(listbox).getByRole("option", { name: "Medieval" }));
+
+ expect(document.documentElement).toHaveAttribute("data-color-theme", "medieval");
+ expect(document.documentElement).toHaveAttribute("data-theme", themeMode);
+ for (const [label, scale] of [["Small", "90%"], ["Default", "100%"], ["Large", "110%"], ["Largest", "120%"]] as const) {
+ fireEvent.click(screen.getByRole("button", { name: label }));
+ expect(document.documentElement.style.fontSize).toBe(scale);
+ }
+ });
+
+ it("renders the Settings date/link/modal symptom fixture with ordinary Pixelify type and mono exceptions", () => {
+ localStorage.setItem("kb-dashboard-theme-mode", "dark");
+ render(h(MedievalSymptomFixture));
+
+ /* FNXC:DashboardTheming 2026-08-20-01:25: Visual proof must use the shipped selector controls, not a lookalike settings fixture. */
+ for (const name of ["Light mode", "Dark mode", "System mode", "Small", "Default", "Large", "Largest", "Reset to default theme"]) {
+ expect(screen.getByRole("button", { name })).toBeInTheDocument();
+ }
+ expect(getComputedStyle(screen.getByRole("radiogroup", { name: "Dashboard font size" })).gridTemplateColumns).toContain("minmax(0, 1fr)");
+ selectMedievalFromSettings();
+
+ expect(document.documentElement).toHaveAttribute("data-color-theme", "medieval");
+ const taskDetail = document.querySelector(".modal.task-detail-modal");
+ expect(taskDetail).not.toBeNull();
+ const modalPaint = getComputedStyle(taskDetail!);
+ expect(modalPaint.backgroundImage).toContain("repeating-linear-gradient");
+ expect(modalPaint.backgroundClip).toBe("padding-box, border-box");
+ expect(getComputedStyle(screen.getByRole("link", { name: "#61" })).fontFamily).toBe("var(--font-primary)");
+ expect(getComputedStyle(taskDetail!.querySelector("time")!).fontFamily).toBe("var(--font-primary)");
+
+ const ordinaryType = extractSelectorBlock(styles, '[data-color-theme="medieval"] :where(a, time, button, label, input, textarea, select, option, [role="button"], [role="link"], [role="textbox"], [role="option"])');
+ expect(ordinaryType).toContain("font-family: var(--font-primary);");
+ expect(ordinaryType).not.toContain("!important");
+ expect(styles).not.toContain('[data-color-theme="medieval"] *');
+ expect(extractSelectorBlock(documentsStyles, ".markdown-file-item-path")).toContain("font-family: var(--font-mono)");
+ expect(extractSelectorBlock(documentsStyles, ".documents-file-path-header")).toContain("font-family: var(--font-mono)");
+ });
+
+ it("uses mode-specific swatches and border-box wood frames without changing modal behavior", () => {
for (const block of [extractSelectorBlock(themeData, ":root"), extractSelectorBlock(themeData, '[data-theme="light"]'), extractSelectorBlock(themeSelector, ".theme-swatch-medieval"), extractSelectorBlock(themeSelector, '[data-theme="light"] .theme-swatch-medieval')]) {
for (const sample of [1, 2, 3, 4]) expect(block).toContain(`medieval-swatch-sample-${sample}`);
}
const medievalModal = extractSelectorBlock(styles, '[data-color-theme="medieval"] .modal');
- expect(medievalModal).toContain("border-color:");
- expect(medievalModal).toContain("box-shadow:");
- expect(medievalModal).toContain("background-image:");
- expect(medievalModal).not.toMatch(/(?:position|width|max-height|z-index|resize|touch-action)\s*:/);
- expect(styles).toContain('[data-color-theme="medieval"] .modal:not(.confirm-dialog)');
- expect(styles).toContain('Mobile\'s generic modal reset removes all borders');
+ for (const property of ["border-color:", "border-width:", "background-image:", "background-origin: border-box", "background-clip: padding-box, border-box", "repeating-linear-gradient"]) expect(medievalModal).toContain(property);
+ expect(medievalModal).not.toMatch(/(?:^|\n)\s*(?:position|width|max-height|z-index|resize|touch-action|padding|height)\s*:/);
+ const mobileFrameStart = styles.indexOf('[data-color-theme="medieval"] .modal:not(.confirm-dialog)');
+ const mobileFrame = styles.slice(mobileFrameStart, styles.indexOf("}", mobileFrameStart));
+ for (const variant of [".modal-lg", ".modal-md", ".gm-modal", "border-style: solid", "background-clip: padding-box, border-box"]) expect(mobileFrame).toContain(variant);
+ expect(styles).toContain("Mobile's generic modal reset removes all borders");
});
});
+/*
+FNXC:DashboardTheming 2026-08-20-01:13:
+FN-061 must prove the shipped Settings selector and TaskDetailModal rather than a lookalike modal. The fixture keeps production ownership of the date, link, and generic modal frame while adapters only make unrelated task actions inert.
+*/
+function MedievalSymptomFixture() {
+ const theme = useTheme();
+ return h(
+ "div",
+ undefined,
+ h(ThemeSelector, {
+ themeMode: theme.themeMode,
+ colorTheme: theme.colorTheme as ColorTheme,
+ dashboardFontScalePct: theme.dashboardFontScalePct,
+ resolvedThemeMode: theme.resolvedThemeMode,
+ shadcnCustomColors: theme.shadcnCustomColors,
+ onThemeModeChange: theme.setThemeMode,
+ onColorThemeChange: theme.setColorTheme,
+ onDashboardFontScaleChange: theme.setDashboardFontScalePct,
+ onShadcnCustomColorsChange: theme.setShadcnCustomColors,
+ }),
+ h(TaskDetailModal, {
+ task: medievalTask,
+ onClose: noop,
+ onOpenDetail: noop,
+ onMoveTask: async () => medievalTask,
+ onDeleteTask: async () => medievalTask,
+ onMergeTask: async () => ({ merged: false }) as MergeResult,
+ addToast: noop,
+ }),
+ );
+}
+
+const medievalTask = {
+ id: "FN-061",
+ title: "Readable Medieval task detail",
+ description: "Ordinary task detail prose.",
+ column: "todo",
+ dependencies: [],
+ prompt: "",
+ steps: [],
+ log: [],
+ currentStep: 0,
+ createdAt: "2026-08-20T00:00:00.000Z",
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ prInfo: { number: 61, url: "https://example.test/pull/61" },
+} as TaskDetail;
+
+const noop = () => undefined;
+
+function selectMedievalFromSettings(): void {
+ fireEvent.click(screen.getByRole("button", { name: /current theme dark \/ shadcn ember/i }));
+ const listbox = screen.getByRole("listbox", { name: "Color theme" });
+ fireEvent.change(screen.getByRole("searchbox", { name: /filter color themes/i }), { target: { value: "medieval" } });
+ fireEvent.click(within(listbox).getByRole("option", { name: "Medieval" }));
+}
+
function extractValidThemes(html: string): string[] {
const match = html.match(/var validThemes = \[([\s\S]*?)\];/);
if (!match) throw new Error("Could not find pre-hydration validThemes array");
diff --git a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts
index ab2ad974c0..362a9ec276 100644
--- a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts
@@ -222,6 +222,20 @@ describe("useTheme", () => {
expect(document.documentElement.getAttribute("data-color-theme")).toBe("medieval");
});
+ it.each(["dark", "light"] as const)("preserves Medieval's selected font scales in %s mode", (themeMode) => {
+ localStorageMock[THEME_MODE_STORAGE_KEY] = themeMode;
+ localStorageMock[COLOR_THEME_STORAGE_KEY] = "medieval";
+
+ const { result } = renderHook(() => useTheme());
+ for (const fontScale of [90, 100, 110, 120]) {
+ act(() => result.current.setDashboardFontScalePct(fontScale));
+ expect(result.current.dashboardFontScalePct).toBe(fontScale);
+ expect(document.documentElement.style.fontSize).toBe(`${fontScale}%`);
+ expect(document.documentElement.getAttribute("data-color-theme")).toBe("medieval");
+ expect(document.documentElement.getAttribute("data-theme")).toBe(themeMode);
+ }
+ });
+
it("hydrates dashboard font scale from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ dashboardFontScalePct: 110 });
diff --git a/packages/dashboard/app/main.tsx b/packages/dashboard/app/main.tsx
index 7a87d021c0..79b039531d 100644
--- a/packages/dashboard/app/main.tsx
+++ b/packages/dashboard/app/main.tsx
@@ -9,7 +9,7 @@ import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import { i18nReady } from "./i18n";
-import "@fontsource/press-start-2p/400.css";
+import "@fontsource/pixelify-sans/400.css";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
diff --git a/packages/dashboard/app/public/theme-data.css b/packages/dashboard/app/public/theme-data.css
index 6137f45652..53f7394596 100644
--- a/packages/dashboard/app/public/theme-data.css
+++ b/packages/dashboard/app/public/theme-data.css
@@ -7453,8 +7453,8 @@ FNXC:DashboardTheming 2026-07-20-00:00:
Aurora gives operators navy/teal/violet dark surfaces and misty light surfaces. Literal palette values stay in these canonical tokens, while globally defined swatch variables keep the unselected option accurate in either mode.
*/
/*
-FNXC:DashboardTheming 2026-08-19-19:42:
-Medieval keeps the dashboard clean and legible while applying a restrained aged-parchment canvas, wood-framed generic modals, and the bundled pixel display face. Press Start 2P remains primary, while explicit system fallbacks render any unsupported localized glyph rather than showing tofu; terminal typography stays on --font-mono and no runtime font request is made.
+FNXC:DashboardTheming 2026-08-20-00:29:
+Operator feedback supersedes the original oversized pixel choice because its apparent UI size harmed readability. Medieval keeps a locally bundled Pixelify Sans face for ordinary UI while code and terminal surfaces retain --font-mono; paper and wood paints stay CSS backgrounds rather than interaction overlays.
*/
[data-color-theme="medieval"] {
--bg: #21170f;
@@ -7488,7 +7488,11 @@ Medieval keeps the dashboard clean and legible while applying a restrained aged-
--shadow-glow: 0 0 var(--space-lg) color-mix(in srgb, var(--accent) 28%, transparent);
--focus-ring: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--accent) 24%, transparent);
--focus-ring-strong: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--accent) 36%, transparent);
- --font-primary: "Press Start 2P", ui-monospace, monospace;
+ --font-primary: "Pixelify Sans", system-ui, sans-serif;
+ --medieval-paper-fiber: color-mix(in srgb, var(--text) 6%, transparent);
+ --medieval-paper-mottle: color-mix(in srgb, var(--border) 18%, transparent);
+ --medieval-wood-light: color-mix(in srgb, var(--accent) 68%, var(--card));
+ --medieval-wood-dark: color-mix(in srgb, var(--border) 76%, var(--bg));
}
[data-color-theme="medieval"][data-theme="light"] {
@@ -7523,12 +7527,20 @@ Medieval keeps the dashboard clean and legible while applying a restrained aged-
--shadow-glow: 0 0 var(--space-lg) color-mix(in srgb, var(--accent) 20%, transparent);
--focus-ring: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--accent) 18%, transparent);
--focus-ring-strong: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--accent) 28%, transparent);
- --font-primary: "Press Start 2P", ui-monospace, monospace;
+ --font-primary: "Pixelify Sans", system-ui, sans-serif;
+ --medieval-paper-fiber: color-mix(in srgb, var(--text) 5%, transparent);
+ --medieval-paper-mottle: color-mix(in srgb, var(--border) 16%, transparent);
+ --medieval-wood-light: color-mix(in srgb, var(--accent) 64%, var(--card));
+ --medieval-wood-dark: color-mix(in srgb, var(--border) 78%, var(--bg));
}
[data-color-theme="medieval"] body {
background-color: var(--bg);
- background-image: linear-gradient(color-mix(in srgb, var(--text) 3%, transparent) 0 0), repeating-linear-gradient(0deg, transparent 0, transparent var(--space-2xl), color-mix(in srgb, var(--border) 12%, transparent) calc(var(--space-2xl) + var(--btn-border-width)));
+ background-image:
+ radial-gradient(ellipse at top left, var(--medieval-paper-fiber), transparent var(--space-2xl)),
+ radial-gradient(ellipse at bottom right, var(--medieval-paper-mottle), transparent calc(var(--space-2xl) * 2)),
+ repeating-linear-gradient(0deg, transparent 0, transparent var(--space-lg), var(--medieval-paper-fiber) calc(var(--space-lg) + var(--btn-border-width))),
+ repeating-linear-gradient(90deg, transparent 0, transparent var(--space-2xl), var(--medieval-paper-mottle) calc(var(--space-2xl) + var(--btn-border-width)));
}
/*
FNXC:MidnightTheme 2026-08-03-02:05:
diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css
index e90ea4da94..1602cd4585 100644
--- a/packages/dashboard/app/styles.css
+++ b/packages/dashboard/app/styles.css
@@ -1314,13 +1314,19 @@ Modal and dock pop-out surfaces should not darken or blur the app behind them. K
}
/*
-FNXC:DashboardTheming 2026-08-19-19:27:
-Medieval modal chrome uses only paint properties on the shared generic surface, preserving modal sizing, dismissal, focus, touch, and mobile behavior while providing the requested restrained wood frame.
+FNXC:DashboardTheming 2026-08-20-00:49:
+Operator feedback requires one readable pixel face for ordinary prose, links, dates, and native or role-based controls. Scope the override to those UI semantics rather than every descendant, so direct mono contracts for paths, identifiers, logs, code, editors, and terminals continue to describe machine-readable content.
*/
-[data-color-theme="medieval"] .modal {
- border-color: color-mix(in srgb, var(--border) 82%, var(--accent) 18%);
- box-shadow: inset 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--card) 70%, var(--border) 30%), var(--shadow-lg);
- background-image: linear-gradient(90deg, color-mix(in srgb, var(--border) 16%, transparent), transparent var(--space-2xl), color-mix(in srgb, var(--border) 12%, transparent));
+[data-color-theme="medieval"] {
+ font-family: var(--font-primary);
+}
+
+[data-color-theme="medieval"] :where(a, time, button, label, input, textarea, select, option, [role="button"], [role="link"], [role="textbox"], [role="option"]) {
+ font-family: var(--font-primary);
+}
+
+[data-color-theme="medieval"] :is(pre, pre *, code, code *, kbd, kbd *, samp, samp *, .cm-editor, .cm-editor *, .xterm, .xterm *, .terminal, .terminal *, .session-terminal, .session-terminal *) {
+ font-family: var(--font-mono) !important;
}
.modal {
@@ -1335,6 +1341,22 @@ Medieval modal chrome uses only paint properties on the shared generic surface,
flex-direction: column;
}
+/*
+FNXC:DashboardTheming 2026-08-20-00:29:
+Medieval modal chrome must follow the generic modal shorthand reset so its border-box grain remains visible. Paint-only layers keep sizing, dismissal, focus, touch, and mobile behavior owned by the existing modal contract.
+*/
+[data-color-theme="medieval"] .modal {
+ border-color: transparent;
+ border-width: var(--btn-border-width);
+ background-color: var(--surface);
+ background-image:
+ linear-gradient(var(--surface), var(--surface)),
+ repeating-linear-gradient(90deg, var(--medieval-wood-dark) 0, var(--medieval-wood-light) var(--space-xs), var(--medieval-wood-dark) var(--space-sm), var(--medieval-wood-light) var(--space-md));
+ background-origin: border-box;
+ background-clip: padding-box, border-box;
+ box-shadow: var(--shadow-lg);
+}
+
.modal-resize-grip {
position: absolute;
right: 0;
@@ -3811,8 +3833,16 @@ Toast text must contrast its status background across every dashboard theme and
[data-color-theme="medieval"] .modal-lg,
[data-color-theme="medieval"] .modal-md,
[data-color-theme="medieval"] .gm-modal {
- border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 82%, var(--accent) 18%);
- box-shadow: inset 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--card) 70%, var(--border) 30%), var(--shadow-lg);
+ border-color: transparent;
+ border-width: var(--btn-border-width);
+ border-style: solid;
+ background-color: var(--surface);
+ background-image:
+ linear-gradient(var(--surface), var(--surface)),
+ repeating-linear-gradient(90deg, var(--medieval-wood-dark) 0, var(--medieval-wood-light) var(--space-xs), var(--medieval-wood-dark) var(--space-sm), var(--medieval-wood-light) var(--space-md));
+ background-origin: border-box;
+ background-clip: padding-box, border-box;
+ box-shadow: var(--shadow-lg);
}
/* Settings modal: use the section picker as the only mobile navigation */
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json
index 34d4e452ad..476ba2582e 100644
--- a/packages/dashboard/package.json
+++ b/packages/dashboard/package.json
@@ -106,7 +106,7 @@
"@codemirror/view": "^6.36.4",
"@earendil-works/pi-ai": "0.84.1",
"@earendil-works/pi-coding-agent": "0.84.1",
- "@fontsource/press-start-2p": "^5.3.0",
+ "@fontsource/pixelify-sans": "^5.3.0",
"@fusion-plugin-examples/claude-runtime": "workspace:*",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/compound-engineering": "workspace:*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c1c508a8c..bf6adaf5ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -275,7 +275,7 @@ importers:
'@earendil-works/pi-coding-agent':
specifier: 0.84.1
version: 0.84.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.21.1)(zod@3.25.76)
- '@fontsource/press-start-2p':
+ '@fontsource/pixelify-sans':
specifier: ^5.3.0
version: 5.3.0
'@fusion-plugin-examples/claude-runtime':
@@ -2349,8 +2349,8 @@ packages:
'@noble/hashes':
optional: true
- '@fontsource/press-start-2p@5.3.0':
- resolution: {integrity: sha512-gxWFxdeDglPhEYSwEeooomcaFK8kSQYLQwVxfnOJeYRj2sPEHXjpVtFgEaYfqzOjuAJGrIcJOipu2KY5YkbyuQ==}
+ '@fontsource/pixelify-sans@5.3.0':
+ resolution: {integrity: sha512-4RCe9nXbi7wUu5+H0FNX1sgPZ2kJ1MmY1uTkEfvQIzzqhoS8zGKJaJfdK8oreE0OU+5gM+NdurFi3H3FJRBvyw==}
'@google/genai@1.52.0':
resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==}
@@ -9536,7 +9536,7 @@ snapshots:
optionalDependencies:
'@noble/hashes': 2.3.0
- '@fontsource/press-start-2p@5.3.0': {}
+ '@fontsource/pixelify-sans@5.3.0': {}
'@google/genai@1.52.0':
dependencies:
diff --git a/screenshots/fn-061-medieval-desktop.png b/screenshots/fn-061-medieval-desktop.png
new file mode 100644
index 0000000000..23d19355bf
Binary files /dev/null and b/screenshots/fn-061-medieval-desktop.png differ
diff --git a/screenshots/fn-061-medieval-mobile.png b/screenshots/fn-061-medieval-mobile.png
new file mode 100644
index 0000000000..653e595191
Binary files /dev/null and b/screenshots/fn-061-medieval-mobile.png differ