Files
Katia Bulatova fbd6df33b4 feat(webapp): Themes + contrast settings update (#4206)
Adds System Preferences, Dark and Light themes, gated by the
`hasThemeSwitcher` feature flag (off by default — dark stays the default
theme for everyone).

Old theme is now "Classic"and set as default. 
"System preferences" theme has both Light and Dark modes and uses your
laptop settings to use a correct one.
It has less color accents (specifically less colored text), and they are
the same for both modes, only grayscale values change between them. And
Light/Dark themes can be used separately.

New Contrast setting is available for System Preferences, Dark and Light
themes - it changes the contrast for the whole app. All new visual
Settings live in Account.
2026-08-03 19:29:33 +02:00

159 lines
4.3 KiB
TypeScript

import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const sizes = {
small: {
control: "h-6",
option: "px-2 text-xs",
container: "gap-x-0.5",
},
medium: {
control: "h-10",
option: "px-3 py-[0.13rem] text-sm",
container: "p-1 gap-x-0.5",
},
};
const theme = {
primary: {
base: "bg-background-raised",
active: "text-text-bright hover:bg-background-hover/50",
inactive: "text-text-dimmed transition hover:text-text-bright",
selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary",
},
secondary: {
base: "bg-transparent dark:bg-background-raised/50",
active: "text-text-bright",
inactive: "text-text-dimmed transition hover:text-text-bright",
selected:
"absolute inset-0 rounded bg-white border border-grid-bright dark:bg-background-raised dark:border-border-bright",
},
};
type Size = keyof typeof sizes;
type Theme = keyof typeof theme;
type VariantStyle = {
base: string;
active: string;
inactive: string;
option: string;
container: string;
selected: string;
};
function createVariant(sizeName: Size, themeName: Theme): VariantStyle {
return {
base: cn(sizes[sizeName].control, theme[themeName].base),
active: theme[themeName].active,
inactive: theme[themeName].inactive,
option: sizes[sizeName].option,
container: sizes[sizeName].container,
selected: theme[themeName].selected,
};
}
const variants = {
"primary/small": createVariant("small", "primary"),
"primary/medium": createVariant("medium", "primary"),
"secondary/small": createVariant("small", "secondary"),
"secondary/medium": createVariant("medium", "secondary"),
} as const;
type VariantType = keyof typeof variants;
type Options = {
label: ReactNode;
value: string;
};
type SegmentedControlProps = {
name: string;
value?: string;
defaultValue?: string;
options: Options[];
variant?: VariantType;
fullWidth?: boolean;
onChange?: (value: string) => void;
/** Override the control's outer styling (e.g. a height that matches an adjacent input). */
className?: string;
};
export default function SegmentedControl({
name,
value,
defaultValue,
options,
variant = "secondary/medium",
fullWidth,
onChange,
className,
}: SegmentedControlProps) {
const variantStyle = variants[variant];
const _isPrimary = variant.startsWith("primary");
return (
<div
className={cn(
"flex rounded text-text-bright",
variantStyle.base,
fullWidth ? "w-full" : "w-fit",
className
)}
>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
className="w-full"
>
<div
className={cn("flex h-full w-full items-center justify-between", variantStyle.container)}
>
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked }) =>
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus-custom",
checked ? variantStyle.active : variantStyle.inactive
)
}
>
{({ checked }) => (
<>
<div
className={cn(
"relative flex h-full w-full items-center justify-between",
variantStyle.option
)}
>
<div className="z-10 flex h-full w-full items-center justify-center">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className={variantStyle.selected}
/>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}