mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 20:05:11 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4892632726 | ||
|
|
1a99307052 | ||
|
|
fcdbf49210 | ||
|
|
f2bb2eca6d | ||
|
|
28c78e8db0 | ||
|
|
596248aec7 | ||
|
|
8a0e1a07dc |
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("")}`;
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"id": "catppuccin",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
"paseo": ">=0.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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、语法高亮、终端和阴影的推导方式。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user