Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4892632726 feat(plugins): make derived theme colors overridable
Sidebar passive text was derived against the page even though timestamps render on hover and selected surfaces. Derive it against the resolved state surface and let contributed themes override each derived semantic role explicitly.
2026-11-13 15:59:00 +01:00
Mohamed Boudra 1a99307052 fix(app): leave the light terminal's ANSI white as it was
ANSI white is both text and a cell background, so no single palette value is readable in
both roles; making it readable as text broke black text on white cells. That needs a
contrast rule in the terminal renderer, so light white and bright white return to their
previous values and the derivation and its test floors are removed.
2026-11-13 15:58:44 +01:00
Mohamed Boudra fcdbf49210 docs(app): state the trade-off between the neutral fill step and text contrast
Below about 3.8:1 body text the fill keeps its minimum step and text on it falls under
3:1. The comment now says so and a test pins both outcomes.
2026-11-13 15:58:44 +01:00
Mohamed Boudra f2bb2eca6d fix(app): keep the derived neutral fill visible when a theme's own text is quiet
Bounding the fill by text contrast could reach a step of 1 or less on a palette whose
body text is under about 3:1, returning the page background itself. The step now has a
minimum.
2026-11-13 15:58:44 +01:00
Mohamed Boudra 28c78e8db0 fix(app): keep derived plugin fills and tertiary text readable on low-contrast palettes
A fixed contrast step for the neutral fill left text on message bubbles, secondary
buttons and badges at 2.6:1 on a dark palette whose body text is 4.75:1, and the
fixed fade made Solarized Light tertiary text 1.92:1. The fill step is now bounded by
the text it carries and tertiary text has a floor. The plugin reference states the
minimum Paseo version for sidebar and that the terminal white is derived on light
themes only.
2026-11-13 15:58:44 +01:00
Mohamed Boudra 596248aec7 fix(app): accept alpha hex in plugin theme colors when deriving
The plugin schema accepts #rgba and #rrggbbaa, but contrast derivation parsed
only opaque hex and threw, which would crash rendering for such a theme.
Derivation now reads those colors as the opaque color they sit on.
2026-11-13 15:58:44 +01:00
Mohamed BoudraandNathan Green 8a0e1a07dc fix(app): derive theme states and plugin fills so light and Pure Black themes stay readable
Hover, selected, the neutral fill and tertiary text were colors a theme
picked by eye, and plugin palettes filled them by borrowing `ring` and
`border`. A light plugin theme got a dark selected row under dark text,
Pure Black's hover barely separated from its surface, and light terminal
ANSI white was white on white.

States are now fixed WCAG-contrast steps from the surface they sit on,
toward the foreground, derived in styles/derive-colors.ts. Plugin
themes derive their neutral fill and tertiary text instead of aliasing
`ring` and `border`, and may set an optional `sidebar` color. The web
focus outline follows the theme's `ring`, and light terminal white and
bright white are derived to read on the background. Tokens with no
consumers are removed.

Existing plugin themes change look with no author action, and the
composer placeholder uses foregroundMuted like every other placeholder.

Co-authored-by: Nathan Green <342098+nathansgreen@users.noreply.github.com>
2026-11-13 15:58:43 +01:00
24 changed files with 893 additions and 91 deletions
+2
View File
@@ -44,6 +44,8 @@ Foreground is for the thing being acted on: row titles, section headings, the se
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
@@ -5,6 +5,10 @@ import path from "node:path";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { contrastRatio } from "../../src/utils/color";
import type { Locator, Page } from "@playwright/test";
const PLUGIN_ID = "plugin-theme-e2e";
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
ring: "#9ca0b0",
},
});
for (const [id, name, overrides] of [
["solarized-dark", "Solarized Dark", {}],
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
]) {
plugin.addTheme({ id, name, appearance: "dark", colors: {
background: "#002b36", foreground: "#839496", raised: "#073642",
control: "#073642", border: "#073642", accent: "#268bd2",
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
}});
}
return () => {};
}`;
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
// contributed palette reached the semantic tokens rather than just the swatch.
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
const LATTE_RING = "rgb(156, 160, 176)";
async function selectTheme(page: Page, name: string) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
const theme = page.getByRole("radio", { name, exact: true });
await expect(theme).toBeVisible({ timeout: 30_000 });
await theme.click();
await expect(theme).toBeChecked();
}
async function expectTimestampContrast(row: Locator) {
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
await expect(timestamp).toBeVisible();
const hex = (rgb: string) =>
`#${rgb
.match(/\d+/g)!
.slice(0, 3)
.map((value) => Number(value).toString(16).padStart(2, "0"))
.join("")}`;
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
}
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const selected = await seedMockAgentWorkspace({
repoPrefix: "theme-selected-",
title: "Selected theme workspace",
});
const hovered = await seedMockAgentWorkspace({
repoPrefix: "theme-hovered-",
title: "Hovered theme workspace",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await selectTheme(page, "Solarized Dark");
await openAgentRoute(page, selected);
const selectedRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
);
const hoveredRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
);
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
await expectTimestampContrast(selectedRow);
await hoveredRow.hover();
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
await expectTimestampContrast(hoveredRow);
await selectTheme(page, "Solarized Dark Custom");
await openAgentRoute(page, selected);
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
"color",
"rgb(221, 238, 255)",
);
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close();
await selected.cleanup();
await hovered.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
test("applies a contributed theme and falls back when its plugin is gone", async ({
page,
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
});
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
await page.mouse.move(0, 0);
await page.keyboard.press("Tab");
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
});
await mochaItem.click();
await expect(mochaItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
+4 -2
View File
@@ -81,12 +81,14 @@
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
themed ring for keyboard users via :focus-visible. The ring is the active
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
only shows before the theme mounts. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid #20744a;
outline: 2px solid var(--colors-ring, #20744a);
outline-offset: 2px;
}
</style>
@@ -674,7 +674,11 @@ function WorkspaceRowRightGroup({
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
<SidebarWorkspaceTrailingContent
workspace={workspace}
trailing={trailing}
backdrop={backdrop}
/>
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
@@ -1029,7 +1029,11 @@ function StatusWorkspaceActionSlot({
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
<SidebarWorkspaceTrailingContent
workspace={workspace}
trailing={trailing}
backdrop={backdrop}
/>
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
@@ -180,6 +180,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
backdrop={backdrop}
currentBranch={workspace.currentBranch}
projectName={leadingProjectName}
hostBadge={hostBadge ?? null}
@@ -15,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import {
PluginWorkspaceRowBadgeItems,
@@ -75,6 +76,7 @@ export function WorkspaceMetaRow({
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
backdrop,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -84,6 +86,7 @@ export function WorkspaceMetaRow({
labels?: readonly WorkspaceLabelDefinition[];
/** The real workspace behind the row; previews leave it out and get no plugin items. */
workspace?: { serverId: string; workspaceId: string } | null;
backdrop: SidebarSurfaceBackdrop;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
@@ -104,7 +107,17 @@ export function WorkspaceMetaRow({
<View style={styles.row}>
{items.map((item, index) => (
<Fragment key={item.kind}>
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
{index > 0 ? (
<Text
style={[
styles.separator,
backdrop === "surfaceSidebarHover" && styles.separatorHovered,
backdrop === "surfaceSidebarSelected" && styles.separatorSelected,
]}
>
·
</Text>
) : null}
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
@@ -362,6 +375,8 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 16,
flexShrink: 0,
},
separatorHovered: { color: theme.colors.foregroundSidebarHover },
separatorSelected: { color: theme.colors.foregroundSidebarSelected },
// Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`.
// Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so
// a crowded line takes from the two of them in proportion rather than emptying one of them.
@@ -5,6 +5,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
import { useAppSettings } from "@/hooks/use-settings";
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
export type { SidebarWorkspaceTrailing };
@@ -39,9 +40,11 @@ export function hasSidebarWorkspaceTrailing({
export function SidebarWorkspaceTrailingContent({
workspace,
trailing,
backdrop,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
backdrop: SidebarSurfaceBackdrop;
}) {
if (trailing === "diff" && workspace.diffStat) {
return (
@@ -49,7 +52,7 @@ export function SidebarWorkspaceTrailingContent({
);
}
if (trailing === "timestamp" && workspace.statusEnteredAt) {
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} backdrop={backdrop} />;
}
return null;
}
@@ -59,10 +62,24 @@ export function SidebarWorkspaceTrailingContent({
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
*/
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
function WorkspaceTimestamp({
enteredAt,
backdrop,
}: {
enteredAt: Date;
backdrop: SidebarSurfaceBackdrop;
}) {
const label = useCompactTimeAgo(enteredAt);
return (
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
<Text
style={[
styles.timestamp,
backdrop === "surfaceSidebarHover" && styles.hovered,
backdrop === "surfaceSidebarSelected" && styles.selected,
]}
numberOfLines={1}
testID="sidebar-workspace-timestamp"
>
{label}
</Text>
);
@@ -80,4 +97,6 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
hovered: { color: theme.colors.foregroundSidebarHover },
selected: { color: theme.colors.foregroundSidebarSelected },
}));
+1 -1
View File
@@ -97,7 +97,7 @@ import {
} from "@/components/ui/text-input";
const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
placeholderTextColor: theme.colors.foregroundMuted,
}));
import {
resolveSendTooltipLabel,
+147 -1
View File
@@ -3,7 +3,13 @@ import { QueryClient } from "@tanstack/react-query";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { describe, expect, it } from "vitest";
import { darkTheme, lightTheme } from "@/styles/theme";
import { collectPluginThemes, rememberPluginThemeHost } from "./themes";
import { contrastRatio } from "@/utils/color";
import {
buildPluginTheme,
collectPluginThemes,
parsePluginThemeContribution,
rememberPluginThemeHost,
} from "./themes";
import { toPluginTheme } from "./theme";
import type { InstalledPlugin } from "./types";
@@ -168,6 +174,146 @@ describe("plugin theme palettes", () => {
});
});
function withColors(
contribution: PluginThemeContribution,
colors: Partial<PluginThemeContribution["colors"]>,
): PluginThemeContribution {
return { ...contribution, colors: { ...contribution.colors, ...colors } };
}
// A role only drives what it is named for. `ring` once filled the neutral fill, tertiary text and
// the light selected row, and `border` once filled the neutral fill, so a theme tuning either for
// its own purpose repainted unrelated surfaces.
describe("plugin theme roles", () => {
it("derives selected text against an overridden selected surface", () => {
const colors = buildPluginTheme(withColors(MOCHA, { sidebarSelected: "#68687c" })).colors;
expect(colors.surfaceSidebarSelected).toBe("#68687c");
expect(
contrastRatio(colors.foregroundSidebarSelected, colors.surfaceSidebarSelected),
).toBeGreaterThanOrEqual(2.5);
});
it("uses an explicit tertiary color unchanged on sidebar states", () => {
const colors = buildPluginTheme(withColors(MOCHA, { foregroundExtraMuted: "#123456" })).colors;
expect(colors.foregroundSidebarHover).toBe("#123456");
expect(colors.foregroundSidebarSelected).toBe("#123456");
});
it("keeps explicit semantic colors independent of ring and border", () => {
const overrides = {
sidebarHover: "#111111",
sidebarSelected: "#222222",
surface3: "#333333",
surface4: "#444444",
foregroundExtraMuted: "#aaaaaa",
};
const before = buildPluginTheme(withColors(MOCHA, overrides)).colors;
const after = buildPluginTheme(
withColors(MOCHA, { ...overrides, ring: "#ff0000", border: "#00ff00" }),
).colors;
for (const role of [
"surfaceSidebarHover",
"surfaceSidebarSelected",
"surface3",
"surface4",
"foregroundExtraMuted",
"foregroundSidebarHover",
"foregroundSidebarSelected",
] as const) {
expect(after[role]).toBe(before[role]);
}
});
it.each([
["sidebarHover", "surfaceSidebarHover"],
["sidebarSelected", "surfaceSidebarSelected"],
["surface3", "surface3"],
["surface4", "surface4"],
["foregroundExtraMuted", "foregroundExtraMuted"],
] as const)("accepts and applies %s independently", (input, output) => {
const contribution = withColors(MOCHA, { [input]: "#123456" });
const parsed = parsePluginThemeContribution(contribution);
const colors = buildPluginTheme(parsed).colors;
expect(colors[output]).toBe("#123456");
expect(() => parsePluginThemeContribution(withColors(MOCHA, { [input]: "no" }))).toThrow();
const baseline = buildPluginTheme(MOCHA).colors;
for (const other of [
"surfaceSidebarHover",
"surfaceSidebarSelected",
"surface3",
"surface4",
"foregroundExtraMuted",
] as const) {
if (other !== output) expect(colors[other]).toBe(baseline[other]);
}
});
it.each([LATTE, MOCHA])("keeps `ring` out of surfaces and text in $name", (contribution) => {
const base = buildPluginTheme(contribution).colors;
const changed = buildPluginTheme(withColors(contribution, { ring: "#ff0000" })).colors;
expect(changed.ring).toBe("#ff0000");
expect(changed.terminal.brightBlack).toBe("#ff0000");
for (const token of [
"surface3",
"surface4",
"foregroundExtraMuted",
"foregroundSidebarHover",
"foregroundSidebarSelected",
"surfaceSidebar",
"surfaceSidebarHover",
"surfaceSidebarSelected",
] as const) {
expect(changed[token], token).toBe(base[token]);
}
});
it.each([LATTE, MOCHA])("keeps `border` out of the neutral fill in $name", (contribution) => {
const base = buildPluginTheme(contribution).colors;
const changed = buildPluginTheme(withColors(contribution, { border: "#ff0000" })).colors;
expect(changed.border).toBe("#ff0000");
expect(changed.surface3).toBe(base.surface3);
expect(changed.surface4).toBe(base.surface4);
});
it("builds a theme from colors that carry an alpha channel", () => {
const theme = buildPluginTheme(
withColors(MOCHA, {
background: "#1e1e2eff",
foreground: "#cdd6f4ff",
mutedForeground: "#a6adc8ff",
}),
);
expect(theme.colors.surface3).toBe(buildPluginTheme(MOCHA).colors.surface3);
});
it("paints the sidebar with `sidebar` when the theme gives one", () => {
const withSidebar = buildPluginTheme(withColors(MOCHA, { sidebar: "#11111b" })).colors;
expect(withSidebar.surfaceSidebar).toBe("#11111b");
expect(withSidebar.surfaceSidebarHover).not.toBe("#11111b");
expect(withSidebar.surfaceSidebarSelected).not.toBe(withSidebar.surfaceSidebarHover);
});
it("leaves the sidebar where it was when the theme gives none", () => {
expect(buildPluginTheme(MOCHA).colors.surfaceSidebar).toBe(MOCHA.colors.background);
expect(buildPluginTheme(LATTE).colors.surfaceSidebar).toBe(LATTE.colors.control);
});
it("accepts an optional hex `sidebar` and rejects unknown colors", () => {
expect(parsePluginThemeContribution(withColors(MOCHA, { sidebar: "#11111b" }))).toMatchObject({
colors: { sidebar: "#11111b" },
});
expect(() => parsePluginThemeContribution(withColors(MOCHA, { sidebar: "darker" }))).toThrow();
expect(() =>
parsePluginThemeContribution({
...MOCHA,
colors: { ...MOCHA.colors, unknownColor: "#000000" },
}),
).toThrow();
});
});
const SUPPORTED = new Set(["host-a", "host-b", "host-z"]);
describe("collectPluginThemes", () => {
+49 -11
View File
@@ -13,6 +13,12 @@ import {
lightTheme,
type Theme,
} from "@/styles/theme";
import {
deriveNeutralFills,
deriveTertiaryText,
deriveSurfaceStates,
deriveSidebarStateText,
} from "@/styles/derive-colors";
import {
getPreferredPluginContributionHost,
rememberPluginContributionHost,
@@ -37,6 +43,12 @@ const contributionSchema: z.ZodType<PluginThemeContribution> = z.strictObject({
accent: hexColorSchema.optional(),
mutedForeground: hexColorSchema,
ring: hexColorSchema,
sidebar: hexColorSchema.optional(),
sidebarHover: hexColorSchema.optional(),
sidebarSelected: hexColorSchema.optional(),
surface3: hexColorSchema.optional(),
surface4: hexColorSchema.optional(),
foregroundExtraMuted: hexColorSchema.optional(),
}),
});
@@ -61,18 +73,31 @@ export function parsePluginThemeContribution(value: unknown): PluginThemeContrib
function buildDarkPluginTheme(contribution: PluginThemeContribution): Theme {
const colors = contribution.colors;
const accent = colors.accent ?? colors.foreground;
const fills = deriveNeutralFills(colors.background, colors.foreground, "dark");
const sidebar = colors.sidebar ?? colors.background;
const states = deriveSurfaceStates(sidebar, colors.foreground, "dark");
const hover = colors.sidebarHover ?? states.hover;
const selected = colors.sidebarSelected ?? states.selected;
const tertiary =
colors.foregroundExtraMuted ?? deriveTertiaryText(colors.mutedForeground, colors.background);
return buildDarkTheme(
buildDarkSemanticColors({
surface0: colors.background,
surface1: colors.raised,
surface2: colors.control,
surface3: colors.border,
surface4: colors.ring,
surfaceDiffEmpty: colors.raised,
surfaceSidebar: colors.background,
surface3: colors.surface3 ?? fills.surface3,
surface4: colors.surface4 ?? fills.surface4,
surfaceSidebar: sidebar,
surfaceSidebarHover: hover,
surfaceSidebarSelected: selected,
foregroundSidebarHover:
colors.foregroundExtraMuted ?? deriveSidebarStateText(tertiary, hover, colors.foreground),
foregroundSidebarSelected:
colors.foregroundExtraMuted ??
deriveSidebarStateText(tertiary, selected, colors.foreground),
foreground: colors.foreground,
foregroundMuted: colors.mutedForeground,
foregroundExtraMuted: colors.ring,
foregroundExtraMuted: tertiary,
border: colors.border,
borderAccent: colors.border,
accent,
@@ -89,18 +114,31 @@ function buildDarkPluginTheme(contribution: PluginThemeContribution): Theme {
function buildLightPluginTheme(contribution: PluginThemeContribution): Theme {
const colors = contribution.colors;
const accent = colors.accent ?? colors.foreground;
const fills = deriveNeutralFills(colors.background, colors.foreground, "light");
const sidebar = colors.sidebar ?? colors.control;
const states = deriveSurfaceStates(sidebar, colors.foreground, "light");
const hover = colors.sidebarHover ?? states.hover;
const selected = colors.sidebarSelected ?? states.selected;
const tertiary =
colors.foregroundExtraMuted ?? deriveTertiaryText(colors.mutedForeground, colors.background);
return buildLightTheme(
buildLightSemanticColors({
surface0: colors.background,
surface1: colors.raised,
surface2: colors.control,
surface3: colors.border,
surface4: colors.ring,
surfaceDiffEmpty: colors.raised,
surfaceSidebar: colors.control,
surface3: colors.surface3 ?? fills.surface3,
surface4: colors.surface4 ?? fills.surface4,
surfaceSidebar: sidebar,
surfaceSidebarHover: hover,
surfaceSidebarSelected: selected,
foregroundSidebarHover:
colors.foregroundExtraMuted ?? deriveSidebarStateText(tertiary, hover, colors.foreground),
foregroundSidebarSelected:
colors.foregroundExtraMuted ??
deriveSidebarStateText(tertiary, selected, colors.foreground),
foreground: colors.foreground,
foregroundMuted: colors.mutedForeground,
foregroundExtraMuted: colors.ring,
foregroundExtraMuted: tertiary,
border: colors.border,
borderAccent: colors.border,
accent,
@@ -116,7 +154,7 @@ function buildLightPluginTheme(contribution: PluginThemeContribution): Theme {
);
}
function buildPluginTheme(contribution: PluginThemeContribution): Theme {
export function buildPluginTheme(contribution: PluginThemeContribution): Theme {
return contribution.appearance === "light"
? buildLightPluginTheme(contribution)
: buildDarkPluginTheme(contribution);
@@ -230,6 +230,7 @@ function BadgePreview({
{t("settings.host.appearance.preview.workspaceName")}
</Text>
<WorkspaceMetaRow
backdrop="surfaceSidebar"
currentBranch={null}
projectName={null}
hostBadge={hostBadge}
+90
View File
@@ -0,0 +1,90 @@
/**
* The relationships between theme colors, stated once.
*
* A theme supplies places (a background, a sidebar) and identity (foreground, accent). What sits
* on top of a place — its hover, its selected state, the neutral fill of a chip, the text one
* notch quieter than muted — is a relationship, and is derived here so that no theme has to pick
* it by eye and no plugin has to borrow another role's color to fill it.
*
* Every step is a WCAG contrast ratio from the color it starts at, moving toward the foreground.
* A ratio keeps the step equally visible on every base; a lightness delta disappears near black.
* The numbers come from the default themes: Paseo dark and Light.
*/
import { contrastRatio, mixHexColors, stepContrast } from "@/utils/color";
export type ColorScheme = "light" | "dark";
const SURFACE_STATE_STEPS = {
dark: { hover: 1.11, selected: 1.245 },
light: { hover: 1.15, selected: 1.35 },
} as const;
// Fills for chips, bubbles, tracks and secondary buttons, as `surface3` and `surface4`.
const NEUTRAL_FILL_STEPS = {
dark: { surface3: 1.8, surface4: 2.5 },
light: { surface3: 1.27, surface4: 1.48 },
} as const;
// `surface3` carries text (message bubbles, secondary buttons, badges), so its step is bounded by
// the contrast the theme gives its own text: foreground stays at least this readable on it. The
// margin over 3:1 absorbs the rounding of the fill to 8-bit channels.
const NEUTRAL_FILL_TEXT_FLOOR = 3.05;
// The fill never steps less than this, so it stays visible against the page. A theme whose body
// text is under about 3.8:1 (3.05 x 1.25) cannot have both: the fill keeps this step and text on
// it falls under 3:1.
const NEUTRAL_FILL_MIN_STEP = 1.25;
// Tertiary text: muted text, that fraction of the way toward the background, but never quieter
// than this contrast against it (or than the muted text itself, when that is already quieter). The
// margin over 2.5:1 absorbs the rounding to 8-bit channels.
const TERTIARY_TEXT_FADE = 0.35;
const TERTIARY_TEXT_FLOOR = 2.55;
/** Hover and selected fills for a row that sits on `surface`. */
export function deriveSurfaceStates(
surface: string,
foreground: string,
scheme: ColorScheme,
): { hover: string; selected: string } {
const steps = SURFACE_STATE_STEPS[scheme];
return {
hover: stepContrast(surface, foreground, steps.hover),
selected: stepContrast(surface, foreground, steps.selected),
};
}
/** The neutral fills that sit on the page background. */
export function deriveNeutralFills(
background: string,
foreground: string,
scheme: ColorScheme,
): { surface3: string; surface4: string } {
const steps = NEUTRAL_FILL_STEPS[scheme];
// Foreground on the fill is about (foreground on background) / step.
const textBound = contrastRatio(background, foreground) / NEUTRAL_FILL_TEXT_FLOOR;
return {
surface3: stepContrast(
background,
foreground,
Math.max(NEUTRAL_FILL_MIN_STEP, Math.min(steps.surface3, textBound)),
),
surface4: stepContrast(background, foreground, steps.surface4),
};
}
export function deriveTertiaryText(mutedForeground: string, background: string): string {
const faded = mixHexColors(mutedForeground, background, TERTIARY_TEXT_FADE);
const floor = Math.min(TERTIARY_TEXT_FLOOR, contrastRatio(background, mutedForeground));
if (contrastRatio(background, faded) >= floor) return faded;
return stepContrast(background, mutedForeground, floor);
}
/** Keep page tertiary text where it is readable; otherwise derive it against the row state. */
export function deriveSidebarStateText(
tertiary: string,
surface: string,
foreground: string,
): string {
if (contrastRatio(tertiary, surface) >= TERTIARY_TEXT_FLOOR) return tertiary;
return stepContrast(surface, foreground, TERTIARY_TEXT_FLOOR);
}
@@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { buildPluginTheme } from "@/plugins/themes";
import { contrastRatio } from "@/utils/color";
import { THEME_OPTIONS, type Theme } from "./theme";
// Palettes of the plugin themes users run into: Catppuccin (the e2e fixture) and
// @mousebomb/paseo-solarized 0.1.1, the palette behind issue #7471.
const LATTE: PluginThemeContribution = {
id: "latte",
name: "Catppuccin Latte",
appearance: "light",
colors: {
background: "#eff1f5",
foreground: "#4c4f69",
raised: "#e6e9ef",
control: "#dce0e8",
border: "#ccd0da",
accent: "#8839ef",
mutedForeground: "#6c6f85",
ring: "#9ca0b0",
},
};
const MOCHA: PluginThemeContribution = {
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
};
const SOLARIZED_LIGHT: PluginThemeContribution = {
id: "solarized-light",
name: "Solarized Light",
appearance: "light",
colors: {
background: "#fdf6e3",
foreground: "#657b83",
raised: "#eee8d5",
control: "#eee8d5",
border: "#e6e0cd",
accent: "#cb4b16",
mutedForeground: "#839496",
ring: "#586e75",
},
};
const SOLARIZED_DARK: PluginThemeContribution = {
id: "solarized-dark",
name: "Solarized Dark",
appearance: "dark",
colors: {
background: "#002b36",
foreground: "#839496",
raised: "#073642",
control: "#073642",
border: "#073642",
accent: "#268bd2",
mutedForeground: "#586e75",
ring: "#93a1a1",
},
};
const themes: Array<{ name: string; theme: Theme }> = [
...THEME_OPTIONS.flatMap((option) =>
"theme" in option ? [{ name: option.name, theme: option.theme as Theme }] : [],
),
...[LATTE, MOCHA, SOLARIZED_LIGHT, SOLARIZED_DARK].map((contribution) => ({
name: `plugin ${contribution.name}`,
theme: buildPluginTheme(contribution),
})),
];
// Contrast floors. A state is a fixed step from the surface it sits on; the built-in Paseo dark
// and Light themes sit at about 1.11/1.25 and 1.15/1.35.
const HOVER_STEP = 1.08;
const SELECTED_STEP = 1.2;
const SELECTED_OVER_HOVER_STEP = 1.04;
// Text on a selected row may cost at most 40% of the legibility the theme gives its own text, and
// never drops below the floor for large UI text.
const SELECTED_TEXT_KEEPS = 0.6;
const SELECTED_TEXT_FLOOR = 2.5;
const NEUTRAL_FILL_STEP = 1.2;
const NEUTRAL_FILL_TEXT_FLOOR = 3;
const TERTIARY_TEXT_FLOOR = 2.5;
// The bright hues are dim on a cream or white background by design; the floor only has to catch a
// color that disappears.
const TERMINAL_COLOR_FLOOR = 1.5;
it("keeps Solarized Dark's selected workspace timestamp readable", () => {
const { colors } = buildPluginTheme(SOLARIZED_DARK);
expect(
contrastRatio(colors.foregroundSidebarSelected, colors.surfaceSidebarSelected),
).toBeGreaterThanOrEqual(2.5);
});
describe.each(themes)("$name", ({ theme }) => {
const colors = theme.colors;
it("separates a hovered and a selected sidebar row from the sidebar", () => {
expect(contrastRatio(colors.surfaceSidebar, colors.surfaceSidebarHover)).toBeGreaterThanOrEqual(
HOVER_STEP,
);
expect(
contrastRatio(colors.surfaceSidebar, colors.surfaceSidebarSelected),
).toBeGreaterThanOrEqual(SELECTED_STEP);
expect(
contrastRatio(colors.surfaceSidebarHover, colors.surfaceSidebarSelected),
).toBeGreaterThanOrEqual(SELECTED_OVER_HOVER_STEP);
});
it("keeps the selected row's title readable", () => {
const onSidebar = contrastRatio(colors.foreground, colors.surfaceSidebar);
const onSelected = contrastRatio(colors.foreground, colors.surfaceSidebarSelected);
expect(onSelected).toBeGreaterThanOrEqual(SELECTED_TEXT_FLOOR);
expect(onSelected).toBeGreaterThanOrEqual(onSidebar * SELECTED_TEXT_KEEPS);
});
it("keeps the neutral fill apart from the page and readable under text", () => {
expect(contrastRatio(colors.surface0, colors.surface3)).toBeGreaterThanOrEqual(
NEUTRAL_FILL_STEP,
);
expect(contrastRatio(colors.foreground, colors.surface3)).toBeGreaterThanOrEqual(
NEUTRAL_FILL_TEXT_FLOOR,
);
});
it("keeps terminal text readable on the terminal background", () => {
const terminal = colors.terminal;
const { background, ...palette } = terminal;
// The whites and black are fills as often as text, so only the hues are held to a floor.
for (const key of [
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"brightRed",
"brightGreen",
"brightYellow",
"brightBlue",
"brightMagenta",
"brightCyan",
] as const) {
expect(contrastRatio(background, palette[key]), key).toBeGreaterThanOrEqual(
TERMINAL_COLOR_FLOOR,
);
}
});
});
describe.each(themes.filter(({ name }) => name.startsWith("plugin ")))(
"$name derived text",
({ theme }) => {
it("keeps tertiary text readable on the page", () => {
expect(
contrastRatio(theme.colors.surface0, theme.colors.foregroundExtraMuted),
).toBeGreaterThanOrEqual(TERTIARY_TEXT_FLOOR);
});
it("keeps passive text readable on hovered and selected sidebar rows", () => {
expect(
contrastRatio(theme.colors.foregroundSidebarHover, theme.colors.surfaceSidebarHover),
).toBeGreaterThanOrEqual(TERTIARY_TEXT_FLOOR);
expect(
contrastRatio(theme.colors.foregroundSidebarSelected, theme.colors.surfaceSidebarSelected),
).toBeGreaterThanOrEqual(TERTIARY_TEXT_FLOOR);
expect(contrastRatio(theme.colors.surface0, theme.colors.foregroundExtraMuted)).toBeLessThan(
contrastRatio(theme.colors.surface0, theme.colors.foreground),
);
});
},
);
describe("a plugin theme whose own text is quiet", () => {
it("still separates the neutral fill from the page", () => {
const { colors } = buildPluginTheme({
...MOCHA,
colors: { ...MOCHA.colors, background: "#202020", foreground: "#6a6a6a" },
});
expect(contrastRatio(colors.surface0, colors.surface3)).toBeGreaterThanOrEqual(
NEUTRAL_FILL_STEP,
);
});
});
describe("a plugin theme with body text between 3:1 and 3.8:1", () => {
it("keeps the fill visible and lets text on it fall under 3:1", () => {
const { colors } = buildPluginTheme({
...MOCHA,
colors: { ...MOCHA.colors, background: "#202020", foreground: "#757575" },
});
expect(contrastRatio(colors.surface0, colors.foreground)).toBeLessThan(3.8);
expect(contrastRatio(colors.surface0, colors.surface3)).toBeGreaterThanOrEqual(
NEUTRAL_FILL_STEP,
);
expect(contrastRatio(colors.foreground, colors.surface3)).toBeLessThan(NEUTRAL_FILL_TEXT_FLOOR);
});
});
describe("built-in themes", () => {
it.each(themes.filter(({ name }) => !name.startsWith("plugin ")))(
"$name keeps a control hover visible on the workspace surface",
({ theme }) => {
expect(
contrastRatio(theme.colors.surfaceWorkspace, theme.colors.surface2),
).toBeGreaterThanOrEqual(1.09);
},
);
});
+20 -13
View File
@@ -53,12 +53,10 @@ describe("Pure black theme", () => {
expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0");
});
it("derives sidebar interaction surfaces from the surface scale", () => {
it("steps its sidebar hover and selection off the black sidebar", () => {
expect(darkPureBlackTheme.colors.surfaceSidebar).toBe("#000000");
expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe(darkPureBlackTheme.colors.surface1);
expect(darkPureBlackTheme.colors.surfaceSidebarSelected).toBe(
darkPureBlackTheme.colors.surface2,
);
expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe("#111111");
expect(darkPureBlackTheme.colors.surfaceSidebarSelected).toBe("#1d1d1d");
});
it("keeps ANSI black output readable on its zero-luminance terminal background", () => {
@@ -67,16 +65,26 @@ describe("Pure black theme", () => {
});
});
// Hover and selected are derived from the sidebar color. The values the default themes had when
// they were picked by eye are the reference: derivation lands within a few RGB units of them.
describe("Sidebar interaction surfaces", () => {
it("keeps Light hover and selection darker than the sidebar surface", () => {
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3);
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4);
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
function expectWithin(actual: string, expected: string, units: number) {
const channels = (hex: string) =>
[1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16));
const expectedChannels = channels(expected);
channels(actual).forEach((channel, index) => {
expect(Math.abs(channel - expectedChannels[index])).toBeLessThanOrEqual(units);
});
}
it("keeps Light hover and selection where they were", () => {
expectWithin(lightTheme.colors.surfaceSidebarHover, "#e4e4e7", 3);
expectWithin(lightTheme.colors.surfaceSidebarSelected, "#d4d4d8", 3);
});
it("derives Dark hover and selection from the first two raised surfaces", () => {
expect(darkTheme.colors.surfaceSidebarHover).toBe(darkTheme.colors.surface1);
expect(darkTheme.colors.surfaceSidebarSelected).toBe(darkTheme.colors.surface2);
it("keeps Dark hover and selection where they were", () => {
expectWithin(darkTheme.colors.surfaceSidebarHover, "#1e2120", 2);
expectWithin(darkTheme.colors.surfaceSidebarSelected, "#272a29", 2);
});
});
@@ -86,7 +94,6 @@ describe("Built-in light theme", () => {
primary: "#18181b",
primaryForeground: "#fafafa",
destructiveForeground: "#ffffff",
successForeground: "#ffffff",
terminal: {
black: "#1a1a1e",
brightBlack: "#3f3f46",
+26 -33
View File
@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
import { deriveSurfaceStates } from "./derive-colors";
export const baseColors = {
// Base colors
@@ -223,8 +224,11 @@ export interface LightThemeConfig {
surface2: string;
surface3: string;
surface4: string;
surfaceDiffEmpty: string;
surfaceSidebar: string;
surfaceSidebarHover?: string;
surfaceSidebarSelected?: string;
foregroundSidebarHover?: string;
foregroundSidebarSelected?: string;
foreground: string;
foregroundMuted: string;
foregroundExtraMuted: string;
@@ -238,7 +242,7 @@ export interface LightThemeConfig {
destructive: string;
terminalBlack: string;
terminalBrightBlack: string;
ring: string;
ring?: string;
}
const lightTerminalAnsi = {
@@ -259,16 +263,18 @@ const lightTerminalAnsi = {
} as const;
export function buildLightSemanticColors(tint: LightThemeConfig) {
const sidebarStates = deriveSurfaceStates(tint.surfaceSidebar, tint.foreground, "light");
return {
surface0: tint.surface0,
surface1: tint.surface1,
surface2: tint.surface2,
surface3: tint.surface3,
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface3,
surfaceSidebarSelected: tint.surface4,
surfaceSidebarHover: tint.surfaceSidebarHover ?? sidebarStates.hover,
surfaceSidebarSelected: tint.surfaceSidebarSelected ?? sidebarStates.selected,
foregroundSidebarHover: tint.foregroundSidebarHover ?? tint.foregroundExtraMuted,
foregroundSidebarSelected: tint.foregroundSidebarSelected ?? tint.foregroundExtraMuted,
surfaceWorkspace: tint.surface0,
interactionHighlight: "rgba(0, 0, 0, 0.06)",
@@ -286,20 +292,14 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
destructive: tint.destructive,
destructiveForeground: tint.surface0,
success: tint.accent,
successForeground: tint.surface0,
background: tint.surface0,
popover: tint.surface0,
popoverForeground: tint.foreground,
primary: tint.primary,
primaryForeground: tint.primaryForeground,
secondary: tint.surface2,
secondaryForeground: tint.foreground,
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: tint.ring,
ring: tint.ring ?? tint.accent,
...lightDiffColors,
...lightStatusColors,
@@ -326,7 +326,6 @@ const lightSemanticColors = buildLightSemanticColors({
surface2: "#f4f4f5",
surface3: "#e4e4e7",
surface4: "#d4d4d8",
surfaceDiffEmpty: "#f6f6f6",
surfaceSidebar: "#f4f4f5",
foreground: "#1a1a1e",
foregroundMuted: "#71717a",
@@ -341,7 +340,6 @@ const lightSemanticColors = buildLightSemanticColors({
destructive: "#b04138",
terminalBlack: "#1a1a1e",
terminalBrightBlack: "#3f3f46",
ring: "#18181b",
});
// ---------------------------------------------------------------------------
@@ -354,8 +352,11 @@ export interface DarkThemeConfig {
surface2: string;
surface3: string;
surface4: string;
surfaceDiffEmpty: string;
surfaceSidebar: string;
surfaceSidebarHover?: string;
surfaceSidebarSelected?: string;
foregroundSidebarHover?: string;
foregroundSidebarSelected?: string;
foregroundMuted: string;
foregroundExtraMuted: string;
border: string;
@@ -389,17 +390,19 @@ const darkTerminalAnsi = {
export function buildDarkSemanticColors(tint: DarkThemeConfig) {
const foreground = tint.foreground ?? "#fafafa";
const ring = tint.ring ?? "#d4d4d8";
const ring = tint.ring ?? tint.accent;
const sidebarStates = deriveSurfaceStates(tint.surfaceSidebar, foreground, "dark");
return {
surface0: tint.surface0,
surface1: tint.surface1,
surface2: tint.surface2,
surface3: tint.surface3,
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface1,
surfaceSidebarSelected: tint.surface2,
surfaceSidebarHover: tint.surfaceSidebarHover ?? sidebarStates.hover,
surfaceSidebarSelected: tint.surfaceSidebarSelected ?? sidebarStates.selected,
foregroundSidebarHover: tint.foregroundSidebarHover ?? tint.foregroundExtraMuted,
foregroundSidebarSelected: tint.foregroundSidebarSelected ?? tint.foregroundExtraMuted,
surfaceWorkspace: tint.surface1,
interactionHighlight: "rgba(255, 255, 255, 0.08)",
@@ -417,7 +420,6 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) {
destructive: tint.destructive,
destructiveForeground: "#ffffff",
success: tint.accent,
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: tint.surface0,
@@ -425,12 +427,7 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) {
popoverForeground: foreground,
primary: foreground,
primaryForeground: tint.surface0,
secondary: tint.surface2,
secondaryForeground: foreground,
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring,
...darkDiffColors,
@@ -463,7 +460,6 @@ const paseoDarkColors = buildDarkSemanticColors({
surface2: "#272A29",
surface3: "#434645",
surface4: "#595B5B",
surfaceDiffEmpty: "#252827",
surfaceSidebar: "#141716",
foregroundMuted: "#A1A5A4",
foregroundExtraMuted: "#717574",
@@ -483,7 +479,6 @@ const zincDarkColors = buildDarkSemanticColors({
surface2: "#27272a",
surface3: "#3f3f46",
surface4: "#52525b",
surfaceDiffEmpty: "#242427",
surfaceSidebar: "#131316",
foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a",
@@ -504,7 +499,6 @@ const midnightDarkColors = buildDarkSemanticColors({
surface2: "#252731",
surface3: "#3c3e4c",
surface4: "#535564",
surfaceDiffEmpty: "#222430",
surfaceSidebar: "#121420",
foregroundMuted: "#9a9db0",
foregroundExtraMuted: "#6b6e82",
@@ -524,7 +518,6 @@ const claudeDarkColors = buildDarkSemanticColors({
surface2: "#2f2d2b",
surface3: "#4a4745",
surface4: "#605d5b",
surfaceDiffEmpty: "#2a2826",
surfaceSidebar: "#1a1918",
foregroundMuted: "#ada9a5",
foregroundExtraMuted: "#78746f",
@@ -544,7 +537,6 @@ const ghosttyDarkColors = buildDarkSemanticColors({
surface2: "#383c48",
surface3: "#4a4f5e",
surface4: "#5b6175",
surfaceDiffEmpty: "#323643",
surfaceSidebar: "#21252d",
foregroundMuted: "#c8ccd8",
foregroundExtraMuted: "#a0a4b2",
@@ -719,14 +711,15 @@ export const darkMidnightTheme = buildDarkTheme(midnightDarkColors);
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors);
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors);
// Pure black — zero-luminance background with high-contrast surfaces.
// Pure black — zero-luminance background with high-contrast surfaces. A control over the
// workspace surface (`surface2` on `surface1`) steps by a contrast ratio of 1.12, the step Paseo
// dark has there; the old `#111111` gave half of it.
const pureBlackDarkColors = buildDarkSemanticColors({
surface0: "#000000",
surface1: "#0a0a0a",
surface2: "#111111",
surface2: "#181818",
surface3: "#202020",
surface4: "#2d2d2d",
surfaceDiffEmpty: "#0c0c0c",
surfaceSidebar: "#000000",
foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a",
+48 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { desaturateHexColor, hexColorWithAlpha, parseHexColor } from "./color";
import {
contrastRatio,
desaturateHexColor,
hexColorWithAlpha,
mixHexColors,
parseHexColor,
stepContrast,
} from "./color";
describe("parseHexColor", () => {
it("parses six-digit hex", () => {
@@ -71,3 +78,43 @@ describe("desaturateHexColor", () => {
expect(desaturateHexColor("currentColor", 0.5)).toBe("currentColor");
});
});
describe("contrastRatio", () => {
it("is 21 between black and white and 1 for the same color", () => {
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
expect(contrastRatio("#3e704a", "#3e704a")).toBe(1);
});
});
describe("stepContrast", () => {
it("steps off pure black by a visible contrast, where a lightness delta would not", () => {
const stepped = stepContrast("#000000", "#fafafa", 1.11);
expect(stepped).not.toBe("#000000");
expect(contrastRatio("#000000", stepped)).toBeCloseTo(1.11, 1);
});
it("moves toward the target, lighter or darker", () => {
expect(stepContrast("#f4f4f5", "#1a1a1e", 1.35) < "#f4f4f5").toBe(true);
expect(stepContrast("#181b1a", "#fafafa", 1.245) > "#181b1a").toBe(true);
});
it("stops at the target when the ratio is out of reach", () => {
expect(stepContrast("#808080", "#909090", 21)).toBe("#909090");
});
});
describe("colours with an alpha channel", () => {
it("are compared and stepped as the opaque colour", () => {
expect(contrastRatio("#1e1e2eff", "#ffffff")).toBe(contrastRatio("#1e1e2e", "#ffffff"));
expect(contrastRatio("#fffe", "#000")).toBeCloseTo(21, 5);
expect(stepContrast("#1e1e2e80", "#cdd6f4", 1.8)).toBe(stepContrast("#1e1e2e", "#cdd6f4", 1.8));
});
});
describe("mixHexColors", () => {
it("blends by the given fraction", () => {
expect(mixHexColors("#000000", "#ffffff", 0)).toBe("#000000");
expect(mixHexColors("#000000", "#ffffff", 1)).toBe("#ffffff");
expect(mixHexColors("#000000", "#ffffff", 0.5)).toBe("#808080");
});
});
+64
View File
@@ -104,3 +104,67 @@ export function desaturateHexColor(hex: string, amount: number): string {
return `#${toHexChannel(linearToSrgb(linearR))}${toHexChannel(linearToSrgb(linearG))}${toHexChannel(linearToSrgb(linearB))}`;
}
function relativeLuminance([r, g, b]: [number, number, number]): number {
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}
// Derivation compares opaque colors. `#rgba` and `#rrggbbaa` are valid theme input, so their alpha
// channel is dropped here and the colour is read as the opaque one it sits on.
function withoutAlpha(hex: string): string {
if (/^#[0-9a-fA-F]{4}$/.test(hex)) return hex.slice(0, 4);
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7);
return hex;
}
function requireRgb(hex: string): [number, number, number] {
const rgb = parseHexColor(withoutAlpha(hex));
if (!rgb) throw new TypeError(`Expected a hex color, received ${hex}`);
return rgb;
}
/** WCAG contrast ratio between two hex colours, from 1 (identical) to 21. */
export function contrastRatio(a: string, b: string): number {
const first = relativeLuminance(requireRgb(a));
const second = relativeLuminance(requireRgb(b));
const [lighter, darker] = first >= second ? [first, second] : [second, first];
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Moves `hex` toward `toward` until it sits at `ratio` WCAG contrast from where it started.
*
* A step is expressed as a contrast ratio rather than a lightness delta because a lightness step
* vanishes near black: the same OKLab delta that is obvious on mid grey changes nothing visible
* on `#000000`, while a contrast step stays equally visible on every base. The move is a blend in
* linear light, so hue stays with the base. A ratio the target cannot reach stops at the target.
*/
export function stepContrast(hex: string, toward: string, ratio: number): string {
const from = requireRgb(hex);
const goal = requireRgb(toward);
const fromLuminance = relativeLuminance(from);
const goalLuminance = relativeLuminance(goal);
if (goalLuminance === fromLuminance) return hex;
const lighter = goalLuminance > fromLuminance;
const targetLuminance = lighter
? ratio * (fromLuminance + 0.05) - 0.05
: (fromLuminance + 0.05) / ratio - 0.05;
const reach = (targetLuminance - fromLuminance) / (goalLuminance - fromLuminance);
const amount = Math.min(1, Math.max(0, reach));
const channels = from.map((channel, index) => {
const start = srgbToLinear(channel);
const end = srgbToLinear(goal[index]);
return toHexChannel(linearToSrgb(start + (end - start) * amount));
});
return `#${channels.join("")}`;
}
/** Blends `hex` toward `toward` by `amount` (0 keeps `hex`, 1 is `toward`) in sRGB. */
export function mixHexColors(hex: string, toward: string, amount: number): string {
const from = requireRgb(hex);
const goal = requireRgb(toward);
const channels = from.map((channel, index) =>
toHexChannel(channel + (goal[index] - channel) * amount),
);
return `#${channels.join("")}`;
}
+6
View File
@@ -90,6 +90,12 @@ export interface PluginThemeColors {
accent?: string;
mutedForeground: string;
ring: string;
sidebar?: string;
sidebarHover?: string;
sidebarSelected?: string;
surface3?: string;
surface4?: string;
foregroundExtraMuted?: string;
}
export interface PluginThemeContribution<Text extends PluginText = string> {
@@ -14,6 +14,12 @@ export default function contribute(client: PluginClientContext) {
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
sidebar: "#181825",
sidebarHover: "#313244",
sidebarSelected: "#45475a",
surface3: "#45475a",
surface4: "#585b70",
foregroundExtraMuted: "#7f849c",
},
});
return () => {};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "catppuccin",
"requirements": {
"paseo": ">=0.8.0"
"paseo": ">=0.16.0"
}
}
+29 -10
View File
@@ -1331,6 +1331,12 @@ export default function contribute(client: PluginClientContext) {
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
sidebar: "#181825",
sidebarHover: "#313244",
sidebarSelected: "#45475a",
surface3: "#45475a",
surface4: "#585b70",
foregroundExtraMuted: "#7f849c",
},
});
return () => {};
@@ -1341,16 +1347,29 @@ Every color is a hex string; anything else fails to load. Paseo expands the pale
token set the built-in dark themes use, so a contributed theme covers panels, menus, diffs, status
colors, and the terminal without listing them.
| Color | Becomes |
| ----------------- | ----------------------------------------------------------------- |
| `background` | App, workspace, and terminal background |
| `foreground` | Primary text, terminal foreground and cursor |
| `raised` | Cards, popovers, and hovered rows |
| `control` | Inputs, secondary fills, and the light-theme sidebar |
| `border` | Borders and the highest raised-surface tint |
| `accent` | Buttons, selection, and focus. Optional; `foreground` if omitted. |
| `mutedForeground` | Secondary text |
| `ring` | Focus rings, scrollbars, and terminal bright black |
| Color | Becomes |
| ---------------------- | ------------------------------------------------------------------------------ |
| `background` | App, workspace, and terminal background |
| `foreground` | Primary text, terminal foreground and cursor |
| `raised` | Cards and popovers |
| `control` | Inputs, secondary fills, and the light-theme sidebar unless `sidebar` is set |
| `border` | Borders |
| `accent` | Buttons, selection, and focus. Optional; `foreground` if omitted. |
| `mutedForeground` | Secondary text |
| `ring` | The keyboard focus outline on web, and terminal bright black |
| `sidebar` | Sidebar background. Optional; `background` in dark themes, `control` in light. |
| `sidebarHover` | Optional hovered sidebar row background. |
| `sidebarSelected` | Optional selected sidebar row background. |
| `surface3` | Optional neutral fill for message bubbles, secondary buttons, and chips. |
| `surface4` | Optional stronger neutral fill for tracks and other states. |
| `foregroundExtraMuted` | Optional tertiary text, including sidebar timestamps. |
Each optional override is used exactly as given. Omitted state, fill, and tertiary colors are derived
independently from the palette. When you override a color, choose it for readable contrast with the
surfaces or text it accompanies.
`sidebar` and the five overrides require Paseo 0.16.0 or later. Older apps reject a theme with an unknown color, and the
plugin's other contributions fail to load with it, so raise `requirements.paseo` when you use any of them.
`appearance` is `"light"` or `"dark"`. Paseo uses it to select the matching surface, status,
diff, syntax, terminal, and shadow derivation.
+26 -10
View File
@@ -1109,6 +1109,12 @@ export default function contribute(client: PluginClientContext) {
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
sidebar: "#181825",
sidebarHover: "#313244",
sidebarSelected: "#45475a",
surface3: "#45475a",
surface4: "#585b70",
foregroundExtraMuted: "#7f849c",
},
});
return () => {};
@@ -1117,16 +1123,26 @@ export default function contribute(client: PluginClientContext) {
每种颜色都是十六进制字符串;其他格式会导致加载失败。Paseo 会把调色板扩展为内置深色主题所使用的完整令牌集合,因此贡献的主题无需逐一列出,就能覆盖面板、菜单、diff、状态颜色和终端。
| 颜色 | 用作 |
| ----------------- | ------------------------------------------------- |
| `background` | 应用、工作区和终端的背景 |
| `foreground` | 主要文字、终端前景色和光标 |
| `raised` | 卡片、弹出框和悬停的行 |
| `control` | 输入框、次要填充以及浅色主题的侧边栏 |
| `border` | 边框以及最高层凸起表面的色调 |
| `accent` | 按钮、选中和焦点。可选;省略时使用 `foreground`。 |
| `mutedForeground` | 次要文字 |
| `ring` | 焦点环、滚动条以及终端的亮黑色 |
| 颜色 | 用作 |
| ---------------------- | --------------------------------------------------------------- |
| `background` | 应用、工作区和终端的背景 |
| `foreground` | 主要文字、终端前景色和光标 |
| `raised` | 卡片和弹出框 |
| `control` | 输入框、次要填充,以及未设置 `sidebar` 时浅色主题的侧边栏 |
| `border` | 边框 |
| `accent` | 按钮、选中和焦点。可选;省略时使用 `foreground`。 |
| `mutedForeground` | 次要文字 |
| `ring` | Web 上的键盘焦点轮廓,以及终端的亮黑色 |
| `sidebar` | 侧边栏背景。可选;深色主题默认为 `background`,浅色为 `control` |
| `sidebarHover` | 可选的侧边栏行悬停背景 |
| `sidebarSelected` | 可选的侧边栏行选中背景 |
| `surface3` | 可选的消息气泡、次要按钮和芯片中性填充 |
| `surface4` | 可选的轨道和其他状态的较强中性填充 |
| `foregroundExtraMuted` | 可选的三级文字颜色,包括侧边栏时间戳 |
可选覆盖颜色按原值使用。省略的状态、填充和三级文字颜色分别由调色板推导。覆盖颜色时,请确保它与对应的背景或文字有足够对比度。
`sidebar` 和以上五个覆盖颜色需要 Paseo 0.16.0 或更高版本。较旧的应用会拒绝包含未知颜色的主题,该插件的其他贡献也会随之加载失败,因此使用其中任何一个时请提高 `requirements.paseo`。
`appearance` 为 `"light"` 或 `"dark"`。Paseo 用它选择对应的表面、状态、diff、语法高亮、终端和阴影的推导方式。