diff --git a/packages/appearance/src/color-vision.ts b/packages/appearance/src/color-vision.ts new file mode 100644 index 0000000..bed37a8 --- /dev/null +++ b/packages/appearance/src/color-vision.ts @@ -0,0 +1,103 @@ +import type { ThemeScheme } from "./themes" + +/** + * Color-vision assistance modes. + * + * These modes select palettes that are easier to distinguish. They are not + * simulations of how another person sees the interface and intentionally do + * not apply a filter to images or the complete document. + */ +export type ColorVisionMode = "standard" | "protan" | "deutan" | "tritan" + +export const colorVisionModes = [ + "standard", + "protan", + "deutan", + "tritan", +] as const satisfies readonly ColorVisionMode[] + +export const COLOR_VISION_ATTRIBUTE = "data-ui-color-vision" + +type ColorVisionVariables = Readonly< + Record< + "destructive" | "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5", + string + > +> + +/** + * Palettes for semantic data colors that commonly need to be distinguished. + * Components must still pair color with text, icons, shapes, or patterns. + */ +export const colorVisionPalettes: Readonly< + Record< + Exclude, + Record + > +> = { + protan: { + light: { + destructive: "#b54a00", + "chart-1": "#0072b2", + "chart-2": "#e69f00", + "chart-3": "#009e73", + "chart-4": "#cc79a7", + "chart-5": "#6b5ca5", + }, + dark: { + destructive: "#ff9d66", + "chart-1": "#56b4e9", + "chart-2": "#f0e442", + "chart-3": "#65d5b3", + "chart-4": "#e8a4ce", + "chart-5": "#afa4e8", + }, + }, + deutan: { + light: { + destructive: "#a64b00", + "chart-1": "#005f9e", + "chart-2": "#d88c00", + "chart-3": "#8a6f00", + "chart-4": "#bd63a0", + "chart-5": "#168c9e", + }, + dark: { + destructive: "#ff9d66", + "chart-1": "#65bff0", + "chart-2": "#f3c969", + "chart-3": "#d7c85d", + "chart-4": "#e5a0cf", + "chart-5": "#63c7d5", + }, + }, + tritan: { + light: { + destructive: "#b83261", + "chart-1": "#b83280", + "chart-2": "#008f72", + "chart-3": "#d55e00", + "chart-4": "#6b5ca5", + "chart-5": "#53606d", + }, + dark: { + destructive: "#f27ca5", + "chart-1": "#ed8bc9", + "chart-2": "#62d1b3", + "chart-3": "#ff9d66", + "chart-4": "#afa4e8", + "chart-5": "#b8c2cc", + }, + }, +} + +export function isColorVisionMode(value: unknown): value is ColorVisionMode { + return colorVisionModes.some((mode) => mode === value) +} + +export function getColorVisionCssVariables( + mode: ColorVisionMode, + scheme: ThemeScheme +): ColorVisionVariables | undefined { + return mode === "standard" ? undefined : colorVisionPalettes[mode][scheme] +} diff --git a/packages/appearance/src/display.ts b/packages/appearance/src/display.ts new file mode 100644 index 0000000..af59c0d --- /dev/null +++ b/packages/appearance/src/display.ts @@ -0,0 +1,77 @@ +import type { ThemeScheme } from "./themes" + +export type Brightness = "dim" | "normal" | "bright" +export type MotionMode = "system" | "reduce" | "full" +export type ResolvedMotionMode = Exclude +export type TextScale = "100" | "125" | "150" | "200" + +export const brightnessModes = [ + "dim", + "normal", + "bright", +] as const satisfies readonly Brightness[] +export const motionModes = [ + "system", + "reduce", + "full", +] as const satisfies readonly MotionMode[] +export const textScales = [ + "100", + "125", + "150", + "200", +] as const satisfies readonly TextScale[] + +export const BRIGHTNESS_ATTRIBUTE = "data-ui-brightness" +export const MOTION_ATTRIBUTE = "data-ui-motion" +export const TEXT_SCALE_ATTRIBUTE = "data-ui-text-scale" + +const surfaceVariableNames = [ + "background", + "card", + "popover", + "secondary", + "muted", + "highlight", + "sidebar", +] as const + +export function isBrightness(value: unknown): value is Brightness { + return brightnessModes.some((mode) => mode === value) +} + +export function isMotionMode(value: unknown): value is MotionMode { + return motionModes.some((mode) => mode === value) +} + +export function isTextScale(value: unknown): value is TextScale { + return textScales.some((scale) => scale === value) +} + +/** + * Adjusts interface surfaces without filtering media, illustrations, or brand + * colors. Foreground colors are preserved so dimming does not reduce contrast. + */ +export function applyBrightnessToTheme( + variables: Record, + brightness: Brightness, + scheme: ThemeScheme +): Record { + if (brightness === "normal") return variables + + const target = brightness === "dim" ? "black" : "white" + const amount = { + bright: scheme === "light" ? 12 : 8, + dim: scheme === "light" ? 5 : 12, + }[brightness] + const adjusted = { ...variables } + + for (const name of surfaceVariableNames) { + const value = variables[name] + if (value) { + adjusted[name] = `color-mix(in oklch,${value},${target} ${amount}%)` + } + } + + return adjusted +}