Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/AppearanceSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QuickMessageAppearanceSettings } from "./QuickMessageAppearanceSettings";
import type { CSSProperties } from "react";
import { useState } from "react";
import {
Expand Down Expand Up @@ -103,6 +104,8 @@ export function AppearanceSettingsPanel() {
</div>
</SettingsSection>

<QuickMessageAppearanceSettings />

<SettingsSection id="appearance-interface" title="Interface">
<SettingsRow
{...searchableSetting("setting-appearance-contrast")}
Expand Down
227 changes: 227 additions & 0 deletions apps/web/src/components/settings/QuickMessageAppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { useState, type CSSProperties } from "react";
import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
import { useClientSettings, updateClientSettings } from "../../hooks/useSettings";
import { useTheme } from "../../hooks/useTheme";
import { useCustomThemes } from "../../hooks/useCustomThemes";
import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme";
import { resolveQuickMessageAppearance } from "../../quickMessageAppearance";
import {
getStandardThemeColors,
getThemeDefinition,
getThemeColorsForMode,
} from "../../themePalette";
import { Select, SelectTrigger, SelectValue, SelectItem, SelectPopup } from "../ui/select";
import { SettingsRow, SettingsSection, SettingResetButton } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";

const SOURCES = { app: "Follow app", adapt: "Adapt app theme", theme: "Choose theme" } as const;
const APPEARANCES = { app: "Same as app", system: "System", light: "Light", dark: "Dark" } as const;

export function QuickMessageAppearanceSettings() {
const settings = useClientSettings();
const app = useTheme();
const customThemes = useCustomThemes();
const environmentThemes = useEnvironmentThemeDefinitions();
const [backdrop, setBackdrop] = useState("light");
const [error, setError] = useState<string | null>(null);
if (!window.desktopBridge?.quickMessage) return null;

const save = (patch: Parameters<typeof updateClientSettings>[0]) => {
setError(null);
void updateClientSettings(patch).catch(() =>
setError("Could not save quick message appearance."),
);
};
const themes = [...BUILT_IN_THEMES, ...environmentThemes, ...customThemes];
const resolved = resolveQuickMessageAppearance(
settings,
{
theme: app.theme,
appearance: app.resolvedTheme,
halves: app.themeHalves,
},
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const definition = getThemeDefinition(resolved.theme);
const colors =
(definition && getThemeColorsForMode(definition, resolved.appearance)) ??
getStandardThemeColors(resolved.appearance);
const previewStyle = {
"--quick-preview-surface":
definition || resolved.appearance === "dark" ? colors.surfaceRaised : colors.surface,
"--quick-preview-text": colors.text,
"--quick-preview-muted": colors.secondaryLabel,
"--quick-preview-border": colors.border,
"--quick-preview-action": colors.messageAction,
"--quick-preview-action-text": colors.messageActionForeground,
"--quick-preview-canvas": colors.canvas,
"--appearance-contrast-base": `${Math.min(resolved.contrast, 100)}%`,
"--appearance-contrast-boost": `${Math.max(resolved.contrast - 100, 0)}%`,
"--appearance-contrast-border-boost": `${Math.max(resolved.contrast - 100, 0) / 4}%`,
"--appearance-contrast-target": resolved.appearance === "dark" ? "white" : "black",
} as CSSProperties;
const themeItems = [
{ value: "light", label: "T3 Code" },
...themes.map((theme) => ({ value: theme.id, label: theme.label })),
...(!["light", "dark", "system", ...themes.map((theme) => theme.id)].includes(
settings.quickMessageTheme,
)
? [{ value: settings.quickMessageTheme, label: "Unavailable theme (following app)" }]
: []),
];
return (
<SettingsSection {...searchableSetting("quick-message-appearance")} title="Quick message">
<SettingsRow
title="Theme source"
description="Only affects the floating quick message window."
status={error ?? undefined}
resetAction={
settings.quickMessageThemeSource !== "app" ? (
<SettingResetButton
label="quick message appearance"
tooltip="Follow app theme"
onClick={() => save({ quickMessageThemeSource: "app" })}
/>
) : undefined
}
control={
<Select
value={settings.quickMessageThemeSource}
onValueChange={(value) => value && save({ quickMessageThemeSource: value })}
>
<SelectTrigger>
<SelectValue>{SOURCES[settings.quickMessageThemeSource]}</SelectValue>
</SelectTrigger>
<SelectPopup>
{Object.entries(SOURCES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
{settings.quickMessageThemeSource === "theme" && (
<SettingsRow
title="Theme"
control={
<Select
items={themeItems}
value={
settings.quickMessageTheme === "dark" || settings.quickMessageTheme === "system"
? "light"
: settings.quickMessageTheme
}
onValueChange={(value) => value && save({ quickMessageTheme: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{themeItems.map((theme) => (
<SelectItem key={theme.value} value={theme.value}>
{theme.label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
)}
{settings.quickMessageThemeSource !== "app" && (
<SettingsRow
title="Appearance"
description="Themes with only one appearance keep their supported mode."
control={
<Select
value={settings.quickMessageAppearance}
onValueChange={(value) => value && save({ quickMessageAppearance: value })}
>
<SelectTrigger>
<SelectValue>{APPEARANCES[settings.quickMessageAppearance]}</SelectValue>
</SelectTrigger>
<SelectPopup>
{Object.entries(APPEARANCES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
)}
{settings.quickMessageThemeSource === "adapt" && (
<SettingsRow
title="Extra contrast"
description="Add to the app’s text and border contrast, up to 200%."
control={
<div className="flex w-full items-center gap-3 sm:w-52">
<output
htmlFor="quick-message-extra-contrast"
className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
>
+{settings.quickMessageExtraContrast}%
</output>
<input
id="quick-message-extra-contrast"
aria-label="Quick message extra contrast"
className="settings-slider min-w-0 flex-1"
type="range"
min={0}
max={100}
step={5}
value={settings.quickMessageExtraContrast}
onChange={(e) => save({ quickMessageExtraContrast: Number(e.currentTarget.value) })}
style={
{
"--settings-slider-progress": `${settings.quickMessageExtraContrast}%`,
"--settings-slider-fill-offset": `${0.5 - settings.quickMessageExtraContrast / 100}rem`,
} as CSSProperties
}
/>
</div>
}
/>
)}
<div className="space-y-3 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Preview over another app</p>
<div className="w-32">
<Select value={backdrop} onValueChange={(value) => value && setBackdrop(value)}>
<SelectTrigger size="sm">
<SelectValue>{backdrop === "light" ? "Light" : "Dark"}</SelectValue>
</SelectTrigger>
<SelectPopup>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectPopup>
</Select>
</div>
</div>
<div
className="quick-message-theme-preview rounded-xl p-5"
style={{ ...previewStyle, background: backdrop === "light" ? "#f4f4f5" : "#18181b" }}
>
<div
className="quick-message-theme-preview-composer rounded-3xl border p-4 shadow-lg"
aria-label="Quick message theme preview"
>
<div className="flex items-center justify-between text-xs">
<span>t3code⌄</span>
<span>···</span>
</div>
<p className="quick-message-theme-preview-prompt py-5 text-sm">Ask anything…</p>
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<span>Model ⌄  Medium ⌄</span>
<span className="quick-message-theme-preview-action rounded-full px-3 py-2">
Create worktree
</span>
</div>
</div>
</div>
</div>
</SettingsSection>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,12 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Contrast"]
: []),
...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
...(settings.quickMessageThemeSource !== DEFAULT_UNIFIED_SETTINGS.quickMessageThemeSource ||
settings.quickMessageTheme !== DEFAULT_UNIFIED_SETTINGS.quickMessageTheme ||
settings.quickMessageAppearance !== DEFAULT_UNIFIED_SETTINGS.quickMessageAppearance ||
settings.quickMessageExtraContrast !== DEFAULT_UNIFIED_SETTINGS.quickMessageExtraContrast
? ["Quick message appearance"]
: []),
...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
? ["Diff colors"]
: []),
Expand Down Expand Up @@ -644,6 +650,10 @@ export function useSettingsRestore(onRestored?: () => void) {
return;
}
updateSettings({
quickMessageThemeSource: DEFAULT_UNIFIED_SETTINGS.quickMessageThemeSource,
quickMessageTheme: DEFAULT_UNIFIED_SETTINGS.quickMessageTheme,
quickMessageAppearance: DEFAULT_UNIFIED_SETTINGS.quickMessageAppearance,
quickMessageExtraContrast: DEFAULT_UNIFIED_SETTINGS.quickMessageExtraContrast,
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["appearance colors palette custom import"],
targetId: "appearance",
},
{
id: "quick-message-appearance",
title: "Quick message appearance",
to: "/settings/appearance",
searchTerms: ["floating composer theme contrast independent light dark"],
},
{
// Prefixed because the slider control already owns the `appearance-contrast` id.
id: "setting-appearance-contrast",
Expand Down
57 changes: 51 additions & 6 deletions apps/web/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ import {
type ThemePreferenceMode,
} from "../themePalette";

import { isQuickMessageWindow } from "../components/quickMessage/quickMessageWindow";
import {
resolveQuickMessageAppearance,
type QuickMessageThemeSettings,
} from "../quickMessageAppearance";
import { applyAppearanceContrast } from "../appearanceContrast";

let quickMessageThemeSettings: QuickMessageThemeSettings | null = null;

export function configureQuickMessageAppearance(settings: QuickMessageThemeSettings | null) {
const previous = quickMessageThemeSettings;
quickMessageThemeSettings = settings;
lastAppliedTheme = null;
applyTheme(getStored(), { suppressTransitions: true });
if (!settings && previous)
applyAppearanceContrast(document.documentElement, previous.appearanceContrast);
emitChange();
}

type Theme = ThemePreference;
type ThemeSnapshot = {
theme: Theme;
Expand Down Expand Up @@ -340,8 +359,23 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
appearanceMode,
themeHalves,
);
applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
const quick = quickMessageThemeSettings
? resolveQuickMessageAppearance(
quickMessageThemeSettings,
{ theme, appearance: resolvedAppearance, halves: themeHalves },
getSystemDark(),
)
: null;
const appearance = quick?.appearance ?? resolvedAppearance;
applyThemePalette(quick?.theme ?? resolveThemeHalf(theme, themeHalves, appearance), appearance);
document.documentElement.classList.toggle("dark", appearance === "dark");
if (quick) {
document.documentElement.dataset.quickMessageThemeSource =
quickMessageThemeSettings?.quickMessageThemeSource;
applyAppearanceContrast(document.documentElement, quick.contrast);
} else {
delete document.documentElement.dataset.quickMessageThemeSource;
}
lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
syncBrowserChromeTheme();
syncDesktopTheme(theme, followSystem, appearanceMode);
Expand Down Expand Up @@ -373,7 +407,7 @@ function syncDesktopTheme(
followSystem?: boolean,
appearanceMode?: ThemePreferenceMode,
) {
if (typeof window === "undefined") return;
if (typeof window === "undefined" || isQuickMessageWindow) return;
const bridge = window.desktopBridge;
const halves = readStoredThemeHalves();
const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves);
Expand Down Expand Up @@ -411,16 +445,23 @@ function getSnapshot(): ThemeSnapshot {
const theme = getStored();
const appearanceMode = readAppearanceModePreference(theme);
const followSystem = appearanceMode === "system";
const systemDark = followSystem ? getSystemDark() : false;
const systemDark = getSystemDark();
const themeHalves = readStoredThemeHalves();

const resolvedTheme = resolveThemeAppearance(
const appAppearance = resolveThemeAppearance(
theme,
systemDark,
followSystem,
appearanceMode,
themeHalves,
);
const resolvedTheme = quickMessageThemeSettings
? resolveQuickMessageAppearance(
quickMessageThemeSettings,
{ theme, appearance: appAppearance, halves: themeHalves },
getSystemDark(),
).appearance
: appAppearance;
if (
lastSnapshot &&
lastSnapshot.theme === theme &&
Expand All @@ -443,7 +484,11 @@ function getServerSnapshot() {

function handleSystemAppearanceChange() {
const storedTheme = getStored();
if (readAppearanceModePreference(storedTheme) === "system") {
if (
readAppearanceModePreference(storedTheme) === "system" ||
quickMessageThemeSettings?.quickMessageAppearance === "system"
) {
lastAppliedTheme = null;
applyTheme(storedTheme, { suppressTransitions: true });
}
emitChange();
Expand Down
Loading