fix(theme): keep inverted text opaque over background images

This commit is contained in:
zipg
2026-09-22 10:59:40 +08:00
committed by GitHub
parent 8858a731aa
commit cc966142ac
16 changed files with 96 additions and 16 deletions
+1 -1
View File
@@ -4498,7 +4498,7 @@ onUnmounted(() => {
@animationend="finishSqlLibraryFlyAnimation(sqlLibraryFlyAnimation.id)"
/>
<Transition name="toast">
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 mx-auto z-99999 w-max max-w-[90vw] sm:max-w-3xl px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text whitespace-pre-wrap break-words">
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 mx-auto z-99999 w-max max-w-[90vw] sm:max-w-3xl px-4 py-2 rounded-lg bg-foreground text-background-solid text-sm shadow-lg select-text whitespace-pre-wrap break-words">
<span>{{ toastMessage }}</span>
<button v-if="toastAction" type="button" class="ml-3 shrink-0 rounded border border-background/40 bg-background/10 px-2 py-0.5 text-xs font-medium hover:bg-background/20" @click="toastAction.onClick()">
{{ toastAction.label }}
@@ -6901,7 +6901,7 @@ function openExternalUrl(url: string) {
<span>{{ t(option.labelKey) }}</span>
<Badge v-if="option.recommended" class="h-4 rounded-full px-1.5 text-[10px] leading-none">{{ t("connection.sqliteWorkerPlacementDefault") }}</Badge>
</div>
<p class="text-[11px] leading-relaxed text-background/80">{{ t(option.hintKey) }}</p>
<p class="text-[11px] leading-relaxed text-background-solid/80">{{ t(option.hintKey) }}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -5985,11 +5985,11 @@ async function openExternalUrl(url: string) {
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
<Square class="h-3.5 w-3.5" />
</button>
<button v-else-if="hasActiveRunForCurrentConversation" class="h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background disabled:opacity-30 flex" :disabled="!canSubmitPrompt" :title="t('ai.queueSendHint')" @click="onSendClick">
<button v-else-if="hasActiveRunForCurrentConversation" class="h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background-solid disabled:opacity-30 flex" :disabled="!canSubmitPrompt" :title="t('ai.queueSendHint')" @click="onSendClick">
<Hourglass class="h-3.5 w-3.5" />
<span>{{ t("ai.queueSend") }}</span>
</button>
<button v-else class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
<button v-else class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background-solid flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
<ArrowUp class="h-4 w-4" />
</button>
</div>
@@ -88,7 +88,7 @@ const emit = defineEmits<{
</span>
<template #content>
<div class="dbx-column-info-tooltip grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">
<span class="text-background/70">{{ columnNameLabel }}</span>
<span class="text-background-solid/70">{{ columnNameLabel }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ name }}</span>
<button data-column-header-copy-name type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
@@ -96,19 +96,19 @@ const emit = defineEmits<{
</button>
</span>
<template v-if="tooltipColumnType ?? columnType">
<span class="text-background/70">{{ columnTypeLabel }}</span>
<span class="text-background-solid/70">{{ columnTypeLabel }}</span>
<span :class="typeClass">{{ tooltipColumnType ?? columnType }}</span>
</template>
<template v-if="tooltipColumnComment ?? columnComment">
<span class="text-background/70">{{ columnCommentLabel }}</span>
<span class="text-background-solid/70">{{ columnCommentLabel }}</span>
<span>{{ tooltipColumnComment ?? columnComment }}</span>
</template>
<template v-if="columnNullability">
<span class="text-background/70">{{ nullableLabel }}</span>
<span class="text-background-solid/70">{{ nullableLabel }}</span>
<span>{{ columnNullability === "nullable" ? yesLabel : noLabel }}</span>
</template>
<template v-if="columnIndexKind && columnIndexKind !== 'none'">
<span class="text-background/70">{{ columnIndexLabel }}</span>
<span class="text-background-solid/70">{{ columnIndexLabel }}</span>
<span class="flex items-center gap-1">
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
<Hash v-else class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
@@ -702,7 +702,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
<Teleport to="body">
<div v-if="historyPreview" class="pointer-events-none fixed z-[140] rounded-md bg-foreground shadow-xl" :style="previewStyle">
<span class="absolute h-3 w-3 rotate-45 bg-foreground" :class="historyPreview.side === 'left' ? '-left-1.5' : '-right-1.5'" :style="previewArrowStyle" />
<div class="max-h-[min(320px,calc(100vh-16px))] overflow-auto rounded-md px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words text-background">{{ historyPreview.value }}</div>
<div class="max-h-[min(320px,calc(100vh-16px))] overflow-auto rounded-md px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words text-background-solid">{{ historyPreview.value }}</div>
</div>
</Teleport>
</div>
@@ -3069,7 +3069,7 @@ useUpdateBlocker(() =>
:key="format"
type="button"
class="shrink-0 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors"
:class="configType === format ? 'border-foreground/80 bg-foreground text-background' : 'border-transparent text-muted-foreground hover:border-border hover:bg-background hover:text-foreground'"
:class="configType === format ? 'border-foreground/80 bg-foreground text-background-solid' : 'border-transparent text-muted-foreground hover:border-border hover:bg-background hover:text-foreground'"
:disabled="readOnly"
:aria-pressed="configType === format"
@click="setConfigFormat(format)"
@@ -79,7 +79,7 @@ const arrowClass = computed(() => {
}
});
const tooltipSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover text-popover-foreground" : "bg-foreground text-background"));
const tooltipSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover text-popover-foreground" : "bg-foreground text-background-solid"));
const arrowSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover border-border" : "bg-foreground border-foreground"));
@@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)',
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background-solid z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)',
props.class,
)
"
@@ -86,6 +86,45 @@ describe("useBackgroundImage", () => {
}
});
it("keeps an opaque companion for inverted text while the surfaces turn translucent", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
const originalPalette = theme.themePalette.value;
const originalColors = { ...theme.customUiColors.value };
theme.setThemeMode("light");
theme.setCustomUiColors({ ...originalColors, background: "#123456" });
theme.setThemePalette("custom");
const createObjectURL = vi.fn(() => "blob:bg-solid");
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true, writable: true });
readBackgroundImageMock.mockResolvedValue("aGVsbG8=");
try {
const settings = settingsWith({ filePath: "/data/background-image.png", opacity: 0.3 });
const bg = useBackgroundImage(settings);
await vi.waitFor(() => expect(bg.active.value).toBe(true));
await vi.waitFor(() => {
// The surface itself goes translucent so the wallpaper shows through...
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86 / 0.3)");
// ...but the color used as text on `bg-foreground` widgets stays opaque,
// otherwise tooltip/toast glyphs wash out (#8678).
expect(document.documentElement.style.getPropertyValue("--background-solid")).toBe("rgb(18 52 86)");
});
// No other surface grows a companion: only the inverted-text vars do.
expect(document.documentElement.style.getPropertyValue("--sidebar-solid")).toBe("");
settings.editorSettings.backgroundImage = { ...defaultBackgroundImageSettings() };
await vi.waitFor(() => expect(bg.active.value).toBe(false));
await vi.waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--background-solid")).toBe("");
});
// Inactive: the custom palette's own inline value is restored untouched.
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86)");
} finally {
theme.setCustomUiColors(originalColors);
theme.setThemeMode(originalMode);
theme.setThemePalette(originalPalette);
}
});
it("re-emits custom palette inline surface colors when the wallpaper is inactive", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
@@ -1,5 +1,5 @@
import { computed, ref, watch, watchPostEffect, type ComputedRef, type Ref } from "vue";
import { BACKGROUND_IMAGE_SURFACE_VARS, backgroundImageStyle, backgroundImageSurfaceAlpha, surfaceColorWithAlpha, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { BACKGROUND_IMAGE_INVERTED_TEXT_VARS, BACKGROUND_IMAGE_SURFACE_VARS, backgroundImageSolidVarName, backgroundImageStyle, backgroundImageSurfaceAlpha, surfaceColorWithAlpha, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { APP_CUSTOM_UI_COLOR_DEFS, appCustomUiColorValue, deriveCustomUiColors } from "@/lib/app/appTheme";
import { readBackgroundImage } from "@/lib/backend/api";
import { useTheme } from "@/composables/useTheme";
@@ -112,7 +112,7 @@ export function useBackgroundImage(settingsStore: SettingsStoreLike): Background
const alpha = backgroundImageSurfaceAlpha(backgroundSettings.value);
const isActive = active.value;
const customBase = customSurfaceBaseColors();
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
for (const varName of [...BACKGROUND_IMAGE_SURFACE_VARS, ...BACKGROUND_IMAGE_INVERTED_TEXT_VARS.map(backgroundImageSolidVarName)]) {
doc.style.removeProperty(varName);
}
doc.classList.toggle(BACKGROUND_IMAGE_ACTIVE_CLASS, isActive);
@@ -125,10 +125,16 @@ export function useBackgroundImage(settingsStore: SettingsStoreLike): Background
}
const computedStyle = getComputedStyle(doc);
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
const base = customBase.get(varName) ?? computedStyle.getPropertyValue(varName);
const base = (customBase.get(varName) ?? computedStyle.getPropertyValue(varName)).trim();
const tinted = surfaceColorWithAlpha(base, alpha);
if (tinted) doc.style.setProperty(varName, tinted);
}
// Inverted-text surfaces (`bg-foreground text-background`) keep the opaque
// palette color: their glyphs must not inherit the wallpaper alpha.
for (const varName of BACKGROUND_IMAGE_INVERTED_TEXT_VARS) {
const base = (customBase.get(varName) ?? computedStyle.getPropertyValue(varName)).trim();
if (base) doc.style.setProperty(backgroundImageSolidVarName(varName), base);
}
});
return {
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
BACKGROUND_IMAGE_FILE_EXTENSIONS,
BACKGROUND_IMAGE_INVERTED_TEXT_VARS,
BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES,
BACKGROUND_IMAGE_SURFACE_VARS,
backgroundImageFileExtension,
backgroundImageSolidVarName,
backgroundImageStyle,
backgroundImageSurfaceAlpha,
defaultBackgroundImageSettings,
@@ -149,6 +151,16 @@ describe("surfaceColorWithAlpha", () => {
});
});
describe("inverted-text surface vars", () => {
it("only marks surface vars that turn translucent, and names their opaque companion", () => {
for (const varName of BACKGROUND_IMAGE_INVERTED_TEXT_VARS) {
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain(varName);
expect(backgroundImageSolidVarName(varName)).toBe(`${varName}-solid`);
}
expect(BACKGROUND_IMAGE_INVERTED_TEXT_VARS).toContain("--background");
});
});
describe("BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES", () => {
it("is 20 MB", () => {
expect(BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES).toBe(20 * 1024 * 1024);
@@ -113,6 +113,25 @@ export function backgroundImageStyle(settings: Pick<BackgroundImageSettings, "di
*/
export const BACKGROUND_IMAGE_SURFACE_VARS = ["--background", "--sidebar", "--muted", "--secondary", "--accent", "--dbx-chrome", "--dbx-chrome-muted", "--dbx-content", "--dbx-editor-toolbar", "--dbx-gutter", "--dbx-sidebar-header"] as const;
/**
* Surface variables that are also consumed as *foreground* colors, and
* therefore need an opaque companion while the surface itself turns
* translucent.
*
* `--background` backs `text-background`, the inverted text painted on
* `bg-foreground` widgets (tooltips, toasts, grid chips). Tinting it for the
* wallpaper also tints those glyphs, so a toolbar tooltip ends up with washed
* out gray text on its own opaque bubble — reported as "the popup background
* and the text color merged" in #8678. Consumers of the inverted text read
* `text-background-solid` (tokens.css) instead.
*/
export const BACKGROUND_IMAGE_INVERTED_TEXT_VARS = ["--background"] as const;
/** Opaque companion variable published for an inverted-text surface var. */
export function backgroundImageSolidVarName(varName: string): string {
return `${varName}-solid`;
}
/** Alpha for the surface variables derived from the surface-opacity setting. */
export function backgroundImageSurfaceAlpha(settings: Pick<BackgroundImageSettings, "opacity">): number {
return clampNumber(settings.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY);
+4
View File
@@ -94,6 +94,10 @@
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
/* Opaque background for inverted text (`bg-foreground text-background-solid`).
useBackgroundImage publishes `--background-solid` while a wallpaper is active,
so tooltips/toasts keep readable glyphs when the surfaces turn translucent. */
--color-background-solid: var(--background-solid, var(--background));
--radius-sm: var(--dbx-radius-sm);
--radius-md: var(--dbx-radius-md);
--radius-lg: var(--dbx-radius-lg);
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB