Skip to content
Draft
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
31 changes: 31 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@
const LIGHT_BACKGROUND = "#ffffff";
const DARK_BACKGROUND = "#161616";
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
try {
const storedColors = window.localStorage.getItem("t3code:theme-colors");
const colors = storedColors ? JSON.parse(storedColors) : null;
const isHexColor = (value) =>
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim());
if (isHexColor(colors?.accentColor)) {
document.documentElement.style.setProperty(
"--theme-accent-seed",
colors.accentColor.trim().toLowerCase(),
);
}
if (isHexColor(colors?.neutralColor)) {
document.documentElement.style.setProperty(
"--theme-neutral-seed",
colors.neutralColor.trim().toLowerCase(),
);
}
if (typeof colors?.contrast === "number" && Number.isFinite(colors.contrast)) {
const contrast = Math.min(100, Math.max(0, colors.contrast));
const properties = {
"--theme-background-strength": `${4 + contrast * 0.12}%`,
"--theme-surface-strength": `${6 + contrast * 0.16}%`,
"--theme-control-strength": `${3 + contrast * 0.08}%`,
"--theme-accent-strength": `${6 + contrast * 0.2}%`,
"--theme-border-strength": `${8 + contrast * 0.16}%`,
};
for (const [property, value] of Object.entries(properties)) {
document.documentElement.style.setProperty(property, value);
}
}
} catch {}
try {
const storedTheme = window.localStorage.getItem("t3code:theme");
const theme =
Expand Down
233 changes: 208 additions & 25 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
import { isElectron } from "../../env";
import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
import { useTheme } from "../../hooks/useTheme";
import { DEFAULT_THEME_COLORS, type ThemeColors, useThemeColors } from "../../hooks/useThemeColors";
import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
Expand Down Expand Up @@ -155,6 +156,129 @@ const THEME_OPTIONS = [
},
] as const;

const THEME_COLOR_SWATCHES = [
"#3b5bdb",
"#7c3aed",
"#db2777",
"#dc2626",
"#ea580c",
"#16a34a",
"#0891b2",
] as const;

const THEME_NEUTRAL_SWATCHES = ["#737373", "#78716c", "#64748b", "#71717a"] as const;

type ThemeOption = (typeof THEME_OPTIONS)[number];

function ThemeModePreview({
option,
selected,
accentColor,
onSelect,
}: {
option: ThemeOption;
selected: boolean;
accentColor: string;
onSelect: () => void;
}) {
const previewBackground =
option.value === "system"
? "bg-[linear-gradient(to_right,#fafafa_0_50%,#18181b_50%)]"
: option.value === "light"
? "bg-zinc-50"
: "bg-zinc-900";
const previewForeground = option.value === "light" ? "bg-zinc-400" : "bg-zinc-500";
const cardBackground =
option.value === "system"
? "bg-[linear-gradient(to_right,rgba(255,255,255,0.9)_0_32%,rgba(39,39,42,0.95)_32%)]"
: option.value === "light"
? "bg-white/90"
: "bg-zinc-800/95";

return (
<button
type="button"
aria-label={`Use ${option.label.toLowerCase()} theme`}
aria-pressed={selected}
className="group min-w-0 cursor-pointer text-left"
onClick={onSelect}
>
<span
className={`relative block h-24 overflow-hidden rounded-xl border transition group-hover:border-primary/60 ${
selected ? "border-primary ring-2 ring-primary/20" : "border-border"
} ${previewBackground}`}
>
<span className="absolute inset-y-0 left-0 w-[31%] border-r border-black/10 bg-black/[0.04] dark:border-white/10" />
<span
className="absolute top-4 left-3 h-1.5 w-8 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span className={`absolute top-8 left-3 h-1 w-10 rounded-full ${previewForeground}`} />
<span className="absolute top-4 right-4 h-2 w-16 rounded-full bg-zinc-400/60" />
<span
className={`absolute right-3 bottom-3 h-14 w-[60%] rounded-lg border border-black/10 shadow-sm ${cardBackground}`}
>
<span className="absolute top-3 left-3 h-1.5 w-12 rounded-full bg-zinc-400/60" />
<span className="absolute top-7 left-3 h-1 w-[70%] rounded-full bg-zinc-400/30" />
<span
className="absolute bottom-2 left-3 h-1.5 w-8 rounded-full"
style={{ backgroundColor: accentColor }}
/>
</span>
{option.value === "system" ? (
<span className="absolute inset-y-0 left-1/2 border-l border-white/50" />
) : null}
</span>
<span
className={`mt-2 block text-center text-xs font-medium ${
selected ? "text-foreground" : "text-muted-foreground"
}`}
>
{option.label}
</span>
</button>
);
}

function ThemeColorControl({
label,
value,
swatches,
onChange,
}: {
label: string;
value: string;
swatches: readonly string[];
onChange: (value: string) => void;
}) {
return (
<fieldset className="grid min-w-0 gap-2">
<legend className="mb-2 text-xs font-medium text-foreground">{label}</legend>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
aria-label={`Custom ${label.toLowerCase()}`}
className="h-8 w-10 cursor-pointer rounded-lg border border-input bg-background p-0.5"
/>
{swatches.map((swatch) => (
<button
key={swatch}
type="button"
className="size-6 cursor-pointer rounded-full border border-black/10 ring-offset-2 ring-offset-background transition hover:scale-105 data-[selected=true]:ring-2 data-[selected=true]:ring-ring dark:border-white/20"
style={{ backgroundColor: swatch }}
data-selected={value === swatch}
aria-pressed={value === swatch}
onClick={() => onChange(swatch)}
aria-label={`Use ${swatch} for ${label.toLowerCase()}`}
/>
))}
</div>
</fieldset>
);
}

const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
artwork: "Artwork",
pill: "Version pill",
Expand Down Expand Up @@ -558,6 +682,7 @@ function AboutVersionSection() {

export function useSettingsRestore(onRestored?: () => void) {
const { theme, setTheme } = useTheme();
const { colors, setThemeColors } = useThemeColors();
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();

Expand All @@ -570,6 +695,10 @@ export function useSettingsRestore(onRestored?: () => void) {
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
...(colors.accentColor !== DEFAULT_THEME_COLORS.accentColor ||
colors.neutralColor !== DEFAULT_THEME_COLORS.neutralColor
? ["Theme colors"]
: []),
...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
...(settings.environmentIdentificationMode !==
DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
Expand Down Expand Up @@ -637,6 +766,8 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.timestampFormat,
settings.wordWrap,
theme,
colors.accentColor,
colors.neutralColor,
],
);

Expand All @@ -651,6 +782,7 @@ export function useSettingsRestore(onRestored?: () => void) {
if (!confirmed) return;

setTheme("system");
setThemeColors(DEFAULT_THEME_COLORS);
updateSettings({
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
Expand All @@ -674,7 +806,7 @@ export function useSettingsRestore(onRestored?: () => void) {
textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
});
onRestored?.();
}, [changedSettingLabels, onRestored, setTheme, updateSettings]);
}, [changedSettingLabels, onRestored, setTheme, setThemeColors, updateSettings]);

return {
changedSettingLabels,
Expand Down Expand Up @@ -950,6 +1082,7 @@ function BackgroundActivityAdvancedDialog({

export function AppearanceSettingsPanel() {
const { theme, setTheme } = useTheme();
const { colors, setThemeColors } = useThemeColors();
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();
const environmentStageLabel = useEnvironmentStageLabel();
Expand All @@ -967,36 +1100,86 @@ export function AppearanceSettingsPanel() {
<SettingsSection id="appearance" title="Appearance">
<SettingsRow
{...searchableSetting("theme")}
description="Choose how T3 Code looks across the app."
description="Choose a mode with a quick preview of the workspace."
resetAction={
theme !== "system" ? (
<SettingResetButton label="theme" onClick={() => setTheme("system")} />
) : null
}
control={
<Select
value={theme}
onValueChange={(value) => {
if (value === "system" || value === "light" || value === "dark") {
setTheme(value);
}
}}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
<SelectValue>
{THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{THEME_OPTIONS.map((option) => (
<SelectItem hideIndicator key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectPopup>
</Select>
>
<div className="mt-4 grid grid-cols-3 gap-3 pb-3">
{THEME_OPTIONS.map((option) => (
<ThemeModePreview
key={option.value}
option={option}
selected={theme === option.value}
accentColor={colors.accentColor}
onSelect={() => setTheme(option.value)}
/>
))}
</div>
</SettingsRow>

<SettingsRow
{...searchableSetting("theme-colors")}
description="Pick two color seeds, then tune how strongly they separate surfaces and controls."
resetAction={
colors.accentColor !== DEFAULT_THEME_COLORS.accentColor ||
colors.neutralColor !== DEFAULT_THEME_COLORS.neutralColor ||
colors.contrast !== DEFAULT_THEME_COLORS.contrast ? (
<SettingResetButton
label="theme colors"
onClick={() => setThemeColors(DEFAULT_THEME_COLORS)}
/>
) : null
}
/>
>
<div className="mt-4 grid gap-4 pb-2 sm:grid-cols-2">
<ThemeColorControl
label="Accent color"
value={colors.accentColor}
swatches={THEME_COLOR_SWATCHES}
onChange={(accentColor) =>
setThemeColors({ ...colors, accentColor } satisfies ThemeColors)
}
/>
<ThemeColorControl
label="Surface tint"
value={colors.neutralColor}
swatches={THEME_NEUTRAL_SWATCHES}
onChange={(neutralColor) =>
setThemeColors({ ...colors, neutralColor } satisfies ThemeColors)
}
/>
</div>
<div className="flex items-center gap-4 border-t border-border/70 py-4">
<label htmlFor="theme-contrast" className="text-xs font-medium text-foreground">
Interface contrast
</label>
<input
id="theme-contrast"
type="range"
min={0}
max={100}
step={5}
value={colors.contrast}
style={{ accentColor: colors.accentColor }}
onChange={(event) =>
setThemeColors({
...colors,
contrast: Number(event.currentTarget.value),
} satisfies ThemeColors)
}
className="min-w-0 flex-1 cursor-pointer"
/>
<output
htmlFor="theme-contrast"
className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground"
>
{colors.contrast}
</output>
</div>
</SettingsRow>

<SettingsRow
{...searchableSetting("setting-glass-opacity")}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Theme",
to: "/settings/appearance",
},
{
id: "theme-colors",
title: "Theme colors",
to: "/settings/appearance",
},
{
// Prefixed because the slider control already owns the `glass-opacity` id.
id: "setting-glass-opacity",
Expand Down
Loading
Loading