diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index bc56b318f..4d7cc0c5d 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -52,6 +52,8 @@ export function BrandingAdmin() { const currentDescription = useBrandingAsset("siteDescription"); const currentPrimaryColor = useBrandingAsset("primaryColor"); const currentAccentColor = useBrandingAsset("accentColor"); + const branding = useStreamplaceStore((st) => st.branding); + const brandingValue = (key: string) => branding?.[key]?.data || ""; const currentBgDark = useBrandingAsset("backgroundColor"); const currentFgDark = useBrandingAsset("foregroundColor"); const currentBgLight = useBrandingAsset("backgroundColorLight"); @@ -133,6 +135,11 @@ export function BrandingAdmin() { case "foregroundColor": case "backgroundColorLight": case "foregroundColorLight": + case "dangerColor": + case "successColor": + case "warningColor": + case "infoColor": + case "liveColor": setChromeInputs((prev) => ({ ...prev, [key]: "" })); break; case "defaultStreamer": @@ -577,6 +584,302 @@ export function BrandingAdmin() { + {t("branding-status-colors")} + + + + + {t("branding-status-colors-description")} + + + + + + + + + {t("branding-danger-color")} + + + {t("branding-current", { + value: + brandingValue("dangerColor") || t("branding-default"), + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + dangerColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-success-color")} + + + {t("branding-current", { + value: + brandingValue("successColor") || + t("branding-default"), + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + successColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-warning-color")} + + + {t("branding-current", { + value: + brandingValue("warningColor") || + t("branding-default"), + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + warningColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-info-color")} + + + {t("branding-current", { + value: + brandingValue("infoColor") || t("branding-default"), + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + infoColor: v, + })) + } + /> + + + + + + + + + + + + + {t("branding-live-color")} + + + {t("branding-current", { + value: + brandingValue("liveColor") || t("branding-default"), + })} + + + + + setChromeInputs((prev) => ({ + ...prev, + liveColor: v, + })) + } + /> + + + + + + + + + {t("branding-theme")} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 9ed0d7b57..f28201001 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -19,6 +19,7 @@ import { useStreamplaceStore, useTheme, useViewerLockedOut, + withAlpha, zero, } from "@streamplace/components"; import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens"; @@ -983,7 +984,7 @@ export default function Shell() { left: 0, right: 0, paddingHorizontal: spacing[2], - backgroundColor: "rgba(10,10,11,0.55)", // token-ok: glass tint + backgroundColor: withAlpha(z.theme.colors.surface0, 0.55), backdropFilter: "blur(18px)", WebkitBackdropFilter: "blur(18px)", borderBottomWidth: 1, diff --git a/js/components/src/lib/theme/branded-theme-provider.tsx b/js/components/src/lib/theme/branded-theme-provider.tsx index de5017dd0..6142ce09c 100644 --- a/js/components/src/lib/theme/branded-theme-provider.tsx +++ b/js/components/src/lib/theme/branded-theme-provider.tsx @@ -5,7 +5,7 @@ import { usePrimaryColor, useStreamplaceStore, } from "../../streamplace-store"; -import { ThemeProvider, type Theme } from "./theme"; +import { ThemeProvider, type BrandColors, type Theme } from "./theme"; interface BrandedThemeProviderProps { children: ReactNode; @@ -61,12 +61,55 @@ export function BrandedThemeProvider({ return overrides; }, [primaryColor, accentColor, brandingLoading]); + // Accent doubles as the secondary color (the only accent-ish token the + // app actually renders); status and live colors are their own keys. + const dangerColor = useBrandingAsset("dangerColor")?.data; + const successColor = useBrandingAsset("successColor")?.data; + const warningColor = useBrandingAsset("warningColor")?.data; + const infoColor = useBrandingAsset("infoColor")?.data; + const liveColor = useBrandingAsset("liveColor")?.data; + const accentLight = useBrandingAsset("accentColorLight")?.data; + const dangerLight = useBrandingAsset("dangerColorLight")?.data; + const successLight = useBrandingAsset("successColorLight")?.data; + const warningLight = useBrandingAsset("warningColorLight")?.data; + const infoLight = useBrandingAsset("infoColorLight")?.data; + const brandColors = useMemo(() => { + if (brandingLoading) return undefined; + return { + secondary: accentColor, + danger: dangerColor, + success: successColor, + warning: warningColor, + info: infoColor, + live: liveColor, + secondaryLight: accentLight, + dangerLight, + successLight, + warningLight, + infoLight, + }; + }, [ + brandingLoading, + accentColor, + dangerColor, + successColor, + warningColor, + infoColor, + liveColor, + accentLight, + dangerLight, + successLight, + warningLight, + infoLight, + ]); + return ( {children} diff --git a/js/components/src/lib/theme/chrome.ts b/js/components/src/lib/theme/chrome.ts index 9e485fb05..29c6fdaad 100644 --- a/js/components/src/lib/theme/chrome.ts +++ b/js/components/src/lib/theme/chrome.ts @@ -83,16 +83,37 @@ const BORDER_ALPHAS = { light: { subtle: 0.06, default: 0.09, strong: 0.13 }, } as const; +/** White or near-black, whichever reads on `color`. */ +export function contrastForeground(color: string): string { + const rgb = parseHexColor(color); + if (!rgb) return "#ffffff"; + return luminance(rgb) > 0.4 ? "#09090b" : "#ffffff"; +} + +/** `color` (hex) at `alpha`, as an rgba() string. */ +export function withAlpha(color: string, alpha: number): string { + const rgb = parseHexColor(color); + if (!rgb) return color; + return rgba(rgb, alpha); +} + /** - * 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. + * Derive the surface / text / border ramps for one scheme. Either color may + * be omitted: a missing foreground is white or near-black by the + * background's luminance, a missing background is the scheme's default. + * Returns null when neither is set (or both are invalid), so callers keep + * the token defaults. */ export function deriveChrome( chrome: Partial | undefined, isDark: boolean, ): DerivedChrome | null { - const bg = parseHexColor(chrome?.background); - const fg = parseHexColor(chrome?.foreground); + const defaults = isDark ? DEFAULT_CHROME.dark : DEFAULT_CHROME.light; + let bg = parseHexColor(chrome?.background); + let fg = parseHexColor(chrome?.foreground); + if (!bg && !fg) return null; + if (!bg) bg = parseHexColor(defaults.background); + if (!fg) fg = parseHexColor(contrastForeground(toHex(bg!))); if (!bg || !fg) return null; const steps = isDark ? SURFACE_STEPS.dark : SURFACE_STEPS.light; const borders = isDark ? BORDER_ALPHAS.dark : BORDER_ALPHAS.light; diff --git a/js/components/src/lib/theme/index.ts b/js/components/src/lib/theme/index.ts index 2e7dc8dc0..79980c1df 100644 --- a/js/components/src/lib/theme/index.ts +++ b/js/components/src/lib/theme/index.ts @@ -8,6 +8,7 @@ export { lightTheme, usePlatformTypography, useTheme, + type BrandColors, type Theme, type ThemeIcons, } from "./theme"; @@ -68,8 +69,10 @@ export * as tokens from "./tokens"; // Branded chrome derivation export { DEFAULT_CHROME, + contrastForeground, deriveChrome, parseHexColor, + withAlpha, 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 af06d9317..fa570a256 100644 --- a/js/components/src/lib/theme/theme.tsx +++ b/js/components/src/lib/theme/theme.tsx @@ -9,8 +9,11 @@ import { } from "react"; import { Platform, useColorScheme } from "react-native"; import { + contrastForeground, defaultChrome, deriveChrome, + parseHexColor, + withAlpha, type ChromeColors, type DerivedChrome, } from "./chrome"; @@ -27,6 +30,40 @@ import { typography, } from "./tokens"; +/** + * Branded accent and status colors. Each is a hex color; invalid or missing + * values keep the token defaults. The scheme-specific variants win for that + * scheme when set. Foreground-on-color tokens are derived by luminance. + */ +export interface BrandColors { + secondary?: string; + danger?: string; + success?: string; + warning?: string; + info?: string; + live?: string; + secondaryLight?: string; + dangerLight?: string; + successLight?: string; + warningLight?: string; + infoLight?: string; +} + +const validHex = (v: string | undefined) => + v && parseHexColor(v) ? v.trim() : undefined; + +// pick the branded value for this scheme, if any +function brandFor( + brand: BrandColors | undefined, + key: "secondary" | "danger" | "success" | "warning" | "info", + isDark: boolean, +): string | undefined { + if (!brand) return undefined; + const light = validHex(brand[`${key}Light`]); + if (!isDark && light) return light; + return validHex(brand[key]); +} + import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ToastProvider } from "../../components/ui/toast"; @@ -218,23 +255,29 @@ const createThemeColors = ( darkTheme?: ColorPalette | Theme["colors"], colorTheme?: Partial, chrome?: { dark?: DerivedChrome | null; light?: DerivedChrome | null }, + brand?: BrandColors, ): Theme["colors"] => { let baseColors: Theme["colors"]; if (isDark && darkTheme) { // Use dark theme baseColors = isColorPalette(darkTheme) - ? generateThemeColorsFromPalette(darkTheme, true, chrome) + ? generateThemeColorsFromPalette(darkTheme, true, chrome, brand) : darkTheme; } else if (!isDark && lightTheme) { // Use light theme baseColors = isColorPalette(lightTheme) - ? generateThemeColorsFromPalette(lightTheme, false, chrome) + ? generateThemeColorsFromPalette(lightTheme, false, chrome, brand) : lightTheme; } else { // Fall back to default gray theme const defaultPalette = colors.neutral; - baseColors = generateThemeColorsFromPalette(defaultPalette, isDark, chrome); + baseColors = generateThemeColorsFromPalette( + defaultPalette, + isDark, + chrome, + brand, + ); } // Merge with custom color overrides if provided. Focus rings follow the @@ -246,6 +289,11 @@ const createThemeColors = ( if (colorTheme?.ring && !colorTheme.focus) { merged.focus = colorTheme.ring; } + // A branded primary picks its own text color, so a light brand color + // doesn't get white-on-pastel buttons. + if (colorTheme?.primary && !colorTheme.primaryForeground) { + merged.primaryForeground = contrastForeground(colorTheme.primary); + } return merged; }; @@ -385,6 +433,7 @@ function generateThemeColorsFromPalette( palette: ColorPalette, isDark: boolean, chrome?: { dark?: DerivedChrome | null; light?: DerivedChrome | null }, + brand?: BrandColors, ): Theme["colors"] { // The neutral chrome comes from the node's branded background/foreground // pair when it has one (see chrome.ts), else the design tokens. @@ -394,9 +443,26 @@ function generateThemeColorsFromPalette( const surface = own.surface; const text = own.text; const border = own.border; - const status = isDark ? statusColors.dark : statusColors.light; + const tokenStatus = isDark ? statusColors.dark : statusColors.light; const isDefaultPalette = palette === colors.neutral; + // Branded status colors, with their soft tints re-derived so a custom + // danger red gets a matching hover wash. + const danger = brandFor(brand, "danger", isDark); + const success = brandFor(brand, "success", isDark); + const warning = brandFor(brand, "warning", isDark); + const info = brandFor(brand, "info", isDark); + const live = validHex(brand?.live); + const status = { + danger: danger ?? tokenStatus.danger, + dangerSoft: danger + ? withAlpha(danger, isDark ? 0.14 : 0.1) + : tokenStatus.dangerSoft, + success: success ?? tokenStatus.success, + warning: warning ?? tokenStatus.warning, + }; + const brandedSecondary = brandFor(brand, "secondary", isDark); + return { background: surface[0], foreground: text[1], @@ -413,13 +479,20 @@ function generateThemeColorsFromPalette( primary: colors.primary[500], primaryForeground: colors.white, - // Teal, aligned with the web app's `--secondary`. - secondary: isDefaultPalette - ? colors.secondary[500] + // Teal, aligned with the web app's `--secondary`; a node's accentColor + // branding replaces it. + secondary: + brandedSecondary ?? + (isDefaultPalette + ? colors.secondary[500] + : isDark + ? palette[800] + : palette[100]), + secondaryForeground: brandedSecondary + ? contrastForeground(brandedSecondary) : isDark - ? palette[800] - : palette[100], - secondaryForeground: isDark ? surface[0] : colors.white, + ? surface[0] + : colors.white, muted: isDefaultPalette ? surface[2] : isDark ? palette[800] : palette[100], mutedForeground: text[2], @@ -437,14 +510,22 @@ function generateThemeColorsFromPalette( destructiveForeground: colors.white, success: status.success, - successForeground: isDark ? surface[0] : colors.white, + successForeground: success + ? contrastForeground(success) + : isDark + ? surface[0] + : colors.white, warning: status.warning, - warningForeground: isDark ? surface[0] : colors.white, + warningForeground: warning + ? contrastForeground(warning) + : 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. - info: isDark ? "#88c0f9" : "#335b83", + info: info ?? (isDark ? "#88c0f9" : "#335b83"), infoForeground: text[1], border: border.default, @@ -470,9 +551,9 @@ function generateThemeColorsFromPalette( borderSubtle: border.subtle, borderStrong: border.strong, - live: statusColors.live, - liveDim: statusColors.liveDim, - liveForeground: colors.white, + live: live ?? statusColors.live, + liveDim: live ? withAlpha(live, 0.16) : statusColors.liveDim, + liveForeground: live ? contrastForeground(live) : colors.white, overlay: isDark ? scrims.dark : scrims.light, focus: colors.primary[500], @@ -504,6 +585,8 @@ interface ThemeProviderProps { dark?: Partial; light?: Partial; }; + /** Branded accent and status colors; see BrandColors. */ + brandColors?: BrandColors; } // Theme provider component @@ -516,6 +599,7 @@ export function ThemeProvider({ lightTheme, darkTheme, chromeColors, + brandColors, }: ThemeProviderProps) { const systemColorScheme = useColorScheme(); const chrome = useMemo( @@ -552,6 +636,7 @@ export function ThemeProvider({ darkTheme, colorTheme, chrome, + brandColors, ); return { colors: themeColors, @@ -563,7 +648,7 @@ export function ThemeProvider({ animations, motion, }; - }, [isDark, lightTheme, darkTheme, colorTheme, chrome]); + }, [isDark, lightTheme, darkTheme, colorTheme, chrome, brandColors]); // 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 9fb06c68a..68cc94695 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -225,6 +225,14 @@ branding-foreground-dark = Foreground (dark) branding-background-light = Background (light) branding-foreground-light = Foreground (light) branding-reset = Reset +branding-default = default +branding-status-colors = Status Colors +branding-status-colors-description = Colors for errors, success, warnings, informational notes and the live badge. Hex colors (#rrggbb); text on top of them is chosen automatically. +branding-danger-color = Danger / error +branding-success-color = Success +branding-warning-color = Warning +branding-info-color = Info +branding-live-color = Live badge branding-images = Images ## Branding Fields @@ -240,7 +248,7 @@ branding-default-streamer-placeholder = did:plc:... branding-clear-default-streamer = Clear Default Streamer branding-primary-color = Primary Color branding-primary-color-placeholder = #6366f1 -branding-accent-color = Accent Color +branding-accent-color = Accent Color (secondary surfaces, highlights) branding-accent-color-placeholder = #8b5cf6 branding-main-logo = Main Logo branding-main-logo-description = SVG, PNG, or JPEG (max 500KB) diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 71bbf57aa..26cf0c0e5 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -167,6 +167,14 @@ "branding-background-light": "Background (light)", "branding-foreground-light": "Foreground (light)", "branding-reset": "Reset", + "branding-default": "default", + "branding-status-colors": "Status Colors", + "branding-status-colors-description": "Colors for errors, success, warnings, informational notes and the live badge. Hex colors (#rrggbb); text on top of them is chosen automatically.", + "branding-danger-color": "Danger / error", + "branding-success-color": "Success", + "branding-warning-color": "Warning", + "branding-info-color": "Info", + "branding-live-color": "Live badge", "branding-images": "Images", "branding-broadcaster-did": "Broadcaster DID", "branding-broadcaster-did-description": "Leave empty to use server default", @@ -180,7 +188,7 @@ "branding-clear-default-streamer": "Clear Default Streamer", "branding-primary-color": "Primary Color", "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Accent Color", + "branding-accent-color": "Accent Color (secondary surfaces, highlights)", "branding-accent-color-placeholder": "#8b5cf6", "branding-main-logo": "Main Logo", "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 71bbf57aa..26cf0c0e5 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -167,6 +167,14 @@ "branding-background-light": "Background (light)", "branding-foreground-light": "Foreground (light)", "branding-reset": "Reset", + "branding-default": "default", + "branding-status-colors": "Status Colors", + "branding-status-colors-description": "Colors for errors, success, warnings, informational notes and the live badge. Hex colors (#rrggbb); text on top of them is chosen automatically.", + "branding-danger-color": "Danger / error", + "branding-success-color": "Success", + "branding-warning-color": "Warning", + "branding-info-color": "Info", + "branding-live-color": "Live badge", "branding-images": "Images", "branding-broadcaster-did": "Broadcaster DID", "branding-broadcaster-did-description": "Leave empty to use server default", @@ -180,7 +188,7 @@ "branding-clear-default-streamer": "Clear Default Streamer", "branding-primary-color": "Primary Color", "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Accent Color", + "branding-accent-color": "Accent Color (secondary surfaces, highlights)", "branding-accent-color-placeholder": "#8b5cf6", "branding-main-logo": "Main Logo", "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 3702bf20b..3c84addf4 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -62,6 +62,16 @@ var BrandingAssetList = [...]string{ "foregroundColor", "backgroundColorLight", "foregroundColorLight", + "accentColorLight", + "dangerColor", + "dangerColorLight", + "successColor", + "successColorLight", + "warningColor", + "warningColorLight", + "infoColor", + "infoColorLight", + "liveColor", } // inlineBrandingImageLimit caps the image assets embedded as data URLs in diff --git a/pkg/spxrpc/place_stream_branding.go b/pkg/spxrpc/place_stream_branding.go index 06e155145..6613e1fcb 100644 --- a/pkg/spxrpc/place_stream_branding.go +++ b/pkg/spxrpc/place_stream_branding.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "github.com/labstack/echo/v4" @@ -36,15 +37,40 @@ var defaultBrandingAssets = map[string]struct { "foregroundColor": {data: []byte(""), mime: "text/plain"}, "backgroundColorLight": {data: []byte(""), mime: "text/plain"}, "foregroundColorLight": {data: []byte(""), mime: "text/plain"}, + // Accent (secondary surfaces), status and live colors; the *Light keys + // override the light scheme. Empty means the app's defaults. + "accentColorLight": {data: []byte(""), mime: "text/plain"}, + "dangerColor": {data: []byte(""), mime: "text/plain"}, + "dangerColorLight": {data: []byte(""), mime: "text/plain"}, + "successColor": {data: []byte(""), mime: "text/plain"}, + "successColorLight": {data: []byte(""), mime: "text/plain"}, + "warningColor": {data: []byte(""), mime: "text/plain"}, + "warningColorLight": {data: []byte(""), mime: "text/plain"}, + "infoColor": {data: []byte(""), mime: "text/plain"}, + "infoColorLight": {data: []byte(""), mime: "text/plain"}, + "liveColor": {data: []byte(""), mime: "text/plain"}, } -// brandingTextKeys are the small text-valued assets (1KB cap). -var brandingTextKeys = map[string]bool{ - "siteTitle": true, "siteDescription": true, "primaryColor": true, "accentColor": true, - "defaultStreamer": true, "backgroundColor": true, "foregroundColor": true, - "backgroundColorLight": true, "foregroundColorLight": true, +// hexColor is the only form the app's theme accepts for color keys; it does +// string math on the value (alpha tints), so anything else breaks silently. +var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// isColorKey reports whether a branding key holds a color. +func isColorKey(key string) bool { + return strings.HasSuffix(key, "Color") || strings.HasSuffix(key, "ColorLight") } +// brandingTextKeys are the small text-valued assets (1KB cap). +var brandingTextKeys = func() map[string]bool { + m := map[string]bool{} + for key, def := range defaultBrandingAssets { + if def.mime == "text/plain" { + m[key] = true + } + } + return m +}() + // NormalizeBroadcasterID turns the optional `broadcaster` parameter into the // key branding is stored under. Assets are written under the broadcaster's // DID (did:web:), which is what clients send; an empty parameter means @@ -210,6 +236,13 @@ func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input if len(data) > maxSize { return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("blob too large (max %d bytes)", maxSize)) } + if isColorKey(input.Key) { + v := strings.TrimSpace(string(data)) + if !hexColor.MatchString(v) { + return nil, echo.NewHTTPError(http.StatusBadRequest, "InvalidColor: colors must be hex, like #1a2b3c") + } + data = []byte(strings.ToLower(v)) + } // store in database var width, height *int