diff --git a/docs/redesign/DESIGN-SYSTEM.md b/docs/redesign/DESIGN-SYSTEM.md index c81aea277..9ed87e5f8 100644 --- a/docs/redesign/DESIGN-SYSTEM.md +++ b/docs/redesign/DESIGN-SYSTEM.md @@ -35,12 +35,12 @@ Legacy aliases (kept working): `background`→surface0, `card`→surface1, White (dark) / ink (light) at fixed alphas. -| Token | Dark | Light | Use | -| ------- | ----------------------- | ------------------- | ---------------------------------- | -| `text1` | `rgba(255,255,255,.92)` | `rgba(9,9,11,.92)` | Primary: titles, body | -| `text2` | `rgba(255,255,255,.65)` | `rgba(9,9,11,.66)` | Secondary: metadata, descriptions | -| `text3` | `rgba(255,255,255,.45)` | `rgba(9,9,11,.46)` | Tertiary: placeholders, timestamps | -| `text4` | `rgba(255,255,255,.30)` | `rgba(9,9,11,.32)` | Disabled | +| Token | Dark | Light | Use | +| ------- | ----------------------- | ------------------ | ---------------------------------- | +| `text1` | `rgba(255,255,255,.92)` | `rgba(9,9,11,.92)` | Primary: titles, body | +| `text2` | `rgba(255,255,255,.65)` | `rgba(9,9,11,.66)` | Secondary: metadata, descriptions | +| `text3` | `rgba(255,255,255,.45)` | `rgba(9,9,11,.46)` | Tertiary: placeholders, timestamps | +| `text4` | `rgba(255,255,255,.30)` | `rgba(9,9,11,.32)` | Disabled | Legacy aliases: `text`→text1, `textMuted`→text2, `textDisabled`→text4. diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index d6c653280..bc56b318f 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -1,5 +1,6 @@ import { Button, + DEFAULT_CHROME, Input, MenuContainer, MenuGroup, @@ -38,6 +39,7 @@ export function BrandingAdmin() { const [siteDescription, setSiteDescription] = useState(""); const [primaryColor, setPrimaryColor] = useState(""); const [accentColor, setAccentColor] = useState(""); + const [chromeInputs, setChromeInputs] = useState>({}); const [defaultStreamer, setDefaultStreamer] = useState(""); const [broadcasterDID, setBroadcasterDID] = useState(""); const [uploading, setUploading] = useState(false); @@ -50,6 +52,10 @@ export function BrandingAdmin() { const currentDescription = useBrandingAsset("siteDescription"); const currentPrimaryColor = useBrandingAsset("primaryColor"); const currentAccentColor = useBrandingAsset("accentColor"); + const currentBgDark = useBrandingAsset("backgroundColor"); + const currentFgDark = useBrandingAsset("foregroundColor"); + const currentBgLight = useBrandingAsset("backgroundColorLight"); + const currentFgLight = useBrandingAsset("foregroundColorLight"); const currentDefaultStreamer = useBrandingAsset("defaultStreamer"); const currentLogo = useBrandingAsset("mainLogo"); const currentFavicon = useBrandingAsset("favicon"); @@ -123,6 +129,12 @@ export function BrandingAdmin() { case "accentColor": setAccentColor(""); break; + case "backgroundColor": + case "foregroundColor": + case "backgroundColorLight": + case "foregroundColorLight": + setChromeInputs((prev) => ({ ...prev, [key]: "" })); + break; case "defaultStreamer": setDefaultStreamer(""); break; @@ -565,6 +577,247 @@ export function BrandingAdmin() { + {t("branding-theme")} + + + + + {t("branding-theme-description")} + + + + + + + + + {t("branding-background-dark")} + + + {t("branding-current", { + value: + currentBgDark?.data || DEFAULT_CHROME.dark.background, + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + backgroundColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-foreground-dark")} + + + {t("branding-current", { + value: + currentFgDark?.data || DEFAULT_CHROME.dark.foreground, + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + foregroundColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-background-light")} + + + {t("branding-current", { + value: + currentBgLight?.data || + DEFAULT_CHROME.light.background, + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + backgroundColorLight: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-foreground-light")} + + + {t("branding-current", { + value: + currentFgLight?.data || + DEFAULT_CHROME.light.foreground, + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + foregroundColorLight: v, + })) + } + /> + + + + + + + + + {t("branding-legal-links")} diff --git a/js/components/src/lib/theme/branded-theme-provider.tsx b/js/components/src/lib/theme/branded-theme-provider.tsx index 20cc9d110..de5017dd0 100644 --- a/js/components/src/lib/theme/branded-theme-provider.tsx +++ b/js/components/src/lib/theme/branded-theme-provider.tsx @@ -1,6 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { useAccentColor, + useBrandingAsset, usePrimaryColor, useStreamplaceStore, } from "../../streamplace-store"; @@ -25,6 +26,20 @@ export function BrandedThemeProvider({ const accentColor = useAccentColor(); const brandingLoading = useStreamplaceStore((state) => state.brandingLoading); + // Chrome: the node's background/foreground pair per scheme, from which the + // theme derives surfaces, text and borders. Empty values leave defaults. + const bgDark = useBrandingAsset("backgroundColor")?.data; + const fgDark = useBrandingAsset("foregroundColor")?.data; + const bgLight = useBrandingAsset("backgroundColorLight")?.data; + const fgLight = useBrandingAsset("foregroundColorLight")?.data; + const chromeColors = useMemo( + () => ({ + dark: { background: bgDark, foreground: fgDark }, + light: { background: bgLight, foreground: fgLight }, + }), + [bgDark, fgDark, bgLight, fgLight], + ); + // Build color theme overrides from branding const colorTheme = useMemo>(() => { // don't override until branding is loaded @@ -51,6 +66,7 @@ export function BrandedThemeProvider({ defaultTheme={defaultTheme} forcedTheme={forcedTheme} colorTheme={colorTheme} + chromeColors={chromeColors} > {children} diff --git a/js/components/src/lib/theme/chrome.ts b/js/components/src/lib/theme/chrome.ts new file mode 100644 index 000000000..9e485fb05 --- /dev/null +++ b/js/components/src/lib/theme/chrome.ts @@ -0,0 +1,133 @@ +import { borderAlphas, surfaces, textAlphas } from "./tokens"; + +/** + * Chrome colors: the neutral scaffolding of the UI (surfaces, text, hairline + * borders) derived from one background and one foreground per color scheme. + * The defaults in tokens.ts are exactly such a derivation (a near-black base + * stepped toward white; white text at fixed alphas), so a node that sets its + * own pair through branding gets the same ramp structure in its own colors. + */ +export interface ChromeColors { + /** Base surface, e.g. "#0a0a0b". */ + background: string; + /** Text and border color, e.g. "#ffffff". */ + foreground: string; +} + +export interface DerivedChrome { + surface: { 0: string; 1: string; 2: string; 3: string }; + text: { 1: string; 2: string; 3: string; 4: string }; + border: { subtle: string; default: string; strong: string }; +} + +type RGB = { r: number; g: number; b: number }; + +/** Parse #rgb / #rrggbb / #rrggbbaa (alpha ignored). Null when invalid. */ +export function parseHexColor(input: string | undefined | null): RGB | null { + if (!input) return null; + const hex = input.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(hex)) { + return null; + } + const full = + hex.length === 3 + ? hex + .split("") + .map((c) => c + c) + .join("") + : hex.slice(0, 6); + return { + r: parseInt(full.slice(0, 2), 16), + g: parseInt(full.slice(2, 4), 16), + b: parseInt(full.slice(4, 6), 16), + }; +} + +const toHex = ({ r, g, b }: RGB) => + "#" + + [r, g, b] + .map((v) => + Math.round(Math.max(0, Math.min(255, v))) + .toString(16) + .padStart(2, "0"), + ) + .join(""); + +const mix = (a: RGB, b: RGB, t: number): RGB => ({ + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, +}); + +const rgba = ({ r, g, b }: RGB, alpha: number) => + `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`; + +/** Relative luminance, for telling a dark pair from a light one. */ +export function luminance({ r, g, b }: RGB): number { + const lin = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +// How far each raised surface steps from the base toward the foreground. +// Dark schemes step further: the same 3% reads as less on a dark ground. +const SURFACE_STEPS = { + dark: [0, 0.03, 0.06, 0.09], + light: [0, 0.02, 0.045, 0.075], +} as const; +const TEXT_ALPHAS = [0.92, 0.65, 0.45, 0.3] as const; +const BORDER_ALPHAS = { + dark: { subtle: 0.06, default: 0.08, strong: 0.1 }, + light: { subtle: 0.06, default: 0.09, strong: 0.13 }, +} as const; + +/** + * Derive the surface / text / border ramps for one scheme. Returns null when + * either color is not a valid hex color, so callers fall back to defaults. + */ +export function deriveChrome( + chrome: Partial | undefined, + isDark: boolean, +): DerivedChrome | null { + const bg = parseHexColor(chrome?.background); + const fg = parseHexColor(chrome?.foreground); + if (!bg || !fg) return null; + const steps = isDark ? SURFACE_STEPS.dark : SURFACE_STEPS.light; + const borders = isDark ? BORDER_ALPHAS.dark : BORDER_ALPHAS.light; + return { + surface: { + 0: toHex(mix(bg, fg, steps[0])), + 1: toHex(mix(bg, fg, steps[1])), + 2: toHex(mix(bg, fg, steps[2])), + 3: toHex(mix(bg, fg, steps[3])), + }, + text: { + 1: rgba(fg, TEXT_ALPHAS[0]), + 2: rgba(fg, TEXT_ALPHAS[1]), + 3: rgba(fg, TEXT_ALPHAS[2]), + 4: rgba(fg, TEXT_ALPHAS[3]), + }, + border: { + subtle: rgba(fg, borders.subtle), + default: rgba(fg, borders.default), + strong: rgba(fg, borders.strong), + }, + }; +} + +/** The design-token defaults in DerivedChrome shape. */ +export function defaultChrome(isDark: boolean): DerivedChrome { + return { + surface: isDark ? surfaces.dark : surfaces.light, + text: isDark ? textAlphas.dark : textAlphas.light, + border: isDark ? borderAlphas.dark : borderAlphas.light, + }; +} + +/** The default pairs, as an operator would enter them. */ +export const DEFAULT_CHROME: { dark: ChromeColors; light: ChromeColors } = { + dark: { background: surfaces.dark[0], foreground: "#ffffff" }, + light: { background: surfaces.light[0], foreground: "#09090b" }, +}; diff --git a/js/components/src/lib/theme/index.ts b/js/components/src/lib/theme/index.ts index 3ff7320cb..2e7dc8dc0 100644 --- a/js/components/src/lib/theme/index.ts +++ b/js/components/src/lib/theme/index.ts @@ -64,3 +64,12 @@ export { // Convenience re-exports export * as atoms from "./atoms"; export * as tokens from "./tokens"; + +// Branded chrome derivation +export { + DEFAULT_CHROME, + deriveChrome, + parseHexColor, + type ChromeColors, + type DerivedChrome, +} from "./chrome"; diff --git a/js/components/src/lib/theme/theme.tsx b/js/components/src/lib/theme/theme.tsx index f7aed0a5e..af06d9317 100644 --- a/js/components/src/lib/theme/theme.tsx +++ b/js/components/src/lib/theme/theme.tsx @@ -8,9 +8,14 @@ import { type ReactNode, } from "react"; import { Platform, useColorScheme } from "react-native"; +import { + defaultChrome, + deriveChrome, + type ChromeColors, + type DerivedChrome, +} from "./chrome"; import { animations, - borderAlphas, borderRadius, colors, motion, @@ -18,8 +23,6 @@ import { shadows, spacing, statusColors, - surfaces, - textAlphas, touchTargets, typography, } from "./tokens"; @@ -214,23 +217,24 @@ const createThemeColors = ( lightTheme?: ColorPalette | Theme["colors"], darkTheme?: ColorPalette | Theme["colors"], colorTheme?: Partial, + chrome?: { dark?: DerivedChrome | null; light?: DerivedChrome | null }, ): Theme["colors"] => { let baseColors: Theme["colors"]; if (isDark && darkTheme) { // Use dark theme baseColors = isColorPalette(darkTheme) - ? generateThemeColorsFromPalette(darkTheme, true) + ? generateThemeColorsFromPalette(darkTheme, true, chrome) : darkTheme; } else if (!isDark && lightTheme) { // Use light theme baseColors = isColorPalette(lightTheme) - ? generateThemeColorsFromPalette(lightTheme, false) + ? generateThemeColorsFromPalette(lightTheme, false, chrome) : lightTheme; } else { // Fall back to default gray theme const defaultPalette = colors.neutral; - baseColors = generateThemeColorsFromPalette(defaultPalette, isDark); + baseColors = generateThemeColorsFromPalette(defaultPalette, isDark, chrome); } // Merge with custom color overrides if provided. Focus rings follow the @@ -380,10 +384,16 @@ function isColorPalette( function generateThemeColorsFromPalette( palette: ColorPalette, isDark: boolean, + chrome?: { dark?: DerivedChrome | null; light?: DerivedChrome | null }, ): Theme["colors"] { - const surface = isDark ? surfaces.dark : surfaces.light; - const text = isDark ? textAlphas.dark : textAlphas.light; - const border = isDark ? borderAlphas.dark : borderAlphas.light; + // The neutral chrome comes from the node's branded background/foreground + // pair when it has one (see chrome.ts), else the design tokens. + const own = (isDark ? chrome?.dark : chrome?.light) ?? defaultChrome(isDark); + const other = + (isDark ? chrome?.light : chrome?.dark) ?? defaultChrome(!isDark); + const surface = own.surface; + const text = own.text; + const border = own.border; const status = isDark ? statusColors.dark : statusColors.light; const isDefaultPalette = palette === colors.neutral; @@ -409,7 +419,7 @@ function generateThemeColorsFromPalette( : isDark ? palette[800] : palette[100], - secondaryForeground: isDark ? surfaces.dark[0] : colors.white, + secondaryForeground: isDark ? surface[0] : colors.white, muted: isDefaultPalette ? surface[2] : isDark ? palette[800] : palette[100], mutedForeground: text[2], @@ -421,16 +431,16 @@ function generateThemeColorsFromPalette( : isDark ? palette[800] : palette[100], - accentForeground: isDark ? surfaces.dark[0] : text[1], + accentForeground: isDark ? surface[0] : text[1], destructive: status.danger, destructiveForeground: colors.white, success: status.success, - successForeground: isDark ? surfaces.dark[0] : colors.white, + successForeground: isDark ? surface[0] : colors.white, warning: status.warning, - warningForeground: isDark ? surfaces.dark[0] : colors.white, + warningForeground: isDark ? surface[0] : colors.white, // Info is a blue, distinct from the pink primary. Aligned with the web's // `--color-info` (chart-3); the light value reads on dark, the dark on light. @@ -472,7 +482,7 @@ function generateThemeColorsFromPalette( // Paper on dark, Ink on light — the opposite scheme's raised surface, with // the current scheme's base surface as its text. - inverse: (isDark ? surfaces.light : surfaces.dark)[1], + inverse: other.surface[1], inverseForeground: surface[0], }; } @@ -485,6 +495,15 @@ interface ThemeProviderProps { colorTheme?: Partial; lightTheme?: ColorPalette | Theme["colors"]; darkTheme?: ColorPalette | Theme["colors"]; + /** + * Branded chrome: a background + foreground pair per scheme from which the + * surface, text and border ramps are derived. Missing or invalid pairs + * keep the design-token defaults for that scheme. + */ + chromeColors?: { + dark?: Partial; + light?: Partial; + }; } // Theme provider component @@ -496,8 +515,21 @@ export function ThemeProvider({ colorTheme, lightTheme, darkTheme, + chromeColors, }: ThemeProviderProps) { const systemColorScheme = useColorScheme(); + const chrome = useMemo( + () => ({ + dark: deriveChrome(chromeColors?.dark, true), + light: deriveChrome(chromeColors?.light, false), + }), + [ + chromeColors?.dark?.background, + chromeColors?.dark?.foreground, + chromeColors?.light?.background, + chromeColors?.light?.foreground, + ], + ); const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">( defaultTheme, ); @@ -519,6 +551,7 @@ export function ThemeProvider({ lightTheme, darkTheme, colorTheme, + chrome, ); return { colors: themeColors, @@ -530,7 +563,7 @@ export function ThemeProvider({ animations, motion, }; - }, [isDark, lightTheme, darkTheme, colorTheme]); + }, [isDark, lightTheme, darkTheme, colorTheme, chrome]); // Create theme-aware zero tokens const zero = useMemo(() => { diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index 52ad18a1a..9fb06c68a 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -218,6 +218,13 @@ branding-configuration = Configuration branding-text-settings = Text Settings branding-colors = Colors branding-legal-links = Legal Links +branding-theme = Theme +branding-theme-description = The app derives its surfaces, text and borders from one background and one foreground color per color scheme. Hex colors (#rrggbb). Leave a value unset to use the default. +branding-background-dark = Background (dark) +branding-foreground-dark = Foreground (dark) +branding-background-light = Background (light) +branding-foreground-light = Foreground (light) +branding-reset = Reset branding-images = Images ## Branding Fields diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 810c22331..71bbf57aa 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -160,6 +160,13 @@ "branding-text-settings": "Text Settings", "branding-colors": "Colors", "branding-legal-links": "Legal Links", + "branding-theme": "Theme", + "branding-theme-description": "The app derives its surfaces, text and borders from one background and one foreground color per color scheme. Hex colors (#rrggbb). Leave a value unset to use the default.", + "branding-background-dark": "Background (dark)", + "branding-foreground-dark": "Foreground (dark)", + "branding-background-light": "Background (light)", + "branding-foreground-light": "Foreground (light)", + "branding-reset": "Reset", "branding-images": "Images", "branding-broadcaster-did": "Broadcaster DID", "branding-broadcaster-did-description": "Leave empty to use server default", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 810c22331..71bbf57aa 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -160,6 +160,13 @@ "branding-text-settings": "Text Settings", "branding-colors": "Colors", "branding-legal-links": "Legal Links", + "branding-theme": "Theme", + "branding-theme-description": "The app derives its surfaces, text and borders from one background and one foreground color per color scheme. Hex colors (#rrggbb). Leave a value unset to use the default.", + "branding-background-dark": "Background (dark)", + "branding-foreground-dark": "Foreground (dark)", + "branding-background-light": "Background (light)", + "branding-foreground-light": "Foreground (light)", + "branding-reset": "Reset", "branding-images": "Images", "branding-broadcaster-did": "Broadcaster DID", "branding-broadcaster-did-description": "Leave empty to use server default",