diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts new file mode 100644 index 00000000..ae0dbc0b --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -0,0 +1,46 @@ +import { + extractSolidColor, + normalizeCssColorString, + parseCssColor, +} from "./parseCssColor"; + +describe("normalizeCssColorString", () => { + it("lowercases uppercase RGBA/RGB function names", () => { + expect(normalizeCssColorString("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + expect(normalizeCssColorString("RGB(10, 20, 30)")).toBe("rgb(10, 20, 30)"); + }); +}); + +describe("extractSolidColor", () => { + it("extracts solid uppercase RGBA values", () => { + expect(extractSolidColor("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + }); + + it("extracts the first rgba stop from gradients with mixed casing", () => { + expect( + extractSolidColor( + "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", + ), + ).toBe("rgba(201,61,0,1)"); + }); +}); + +describe("parseCssColor", () => { + it("parses uppercase RGBA without throwing", () => { + const parsed = parseCssColor("RGBA(3, 29, 11, 0.58)"); + expect(parsed.alpha()).toBeCloseTo(0.58, 2); + expect(parsed.red()).toBe(3); + expect(parsed.green()).toBe(29); + expect(parsed.blue()).toBe(11); + }); + + it("falls back when the value is not a colour", () => { + expect(parseCssColor("not-a-color", "#007bff").hex().toLowerCase()).toBe( + "#007bff", + ); + }); +}); diff --git a/src/seqta/ui/colors/parseCssColor.ts b/src/seqta/ui/colors/parseCssColor.ts new file mode 100644 index 00000000..d2835f94 --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.ts @@ -0,0 +1,65 @@ +import Color from "color"; + +type ColorInstance = ReturnType; + +/** + * SEQTA themes and user gradients often use uppercase `RGBA()` / `RGB()`. + * The `color` package only accepts lowercase function names. + */ +export function normalizeCssColorString(value: string): string { + return value + .trim() + .replace(/\bRGBA?\(/gi, (match) => match.toLowerCase()) + .replace(/\bHSLA?\(/gi, (match) => match.toLowerCase()); +} + +/** Pick a single solid colour from a CSS value (hex, rgb(a), hsl(a), or gradient). */ +export function extractSolidColor(value: string): string | null { + const trimmed = normalizeCssColorString(value); + if (!trimmed || trimmed === "initial") return null; + if ( + trimmed.startsWith("#") || + /^rgba?\(/i.test(trimmed) || + /^hsla?\(/i.test(trimmed) + ) { + return trimmed; + } + if (trimmed.includes("gradient")) { + const match = trimmed.match( + /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/gi, + ); + return match?.[0] ? normalizeCssColorString(match[0]) : null; + } + return null; +} + +/** Parse a CSS colour for the `color` library; never throws. */ +export function parseCssColor(value: string, fallback = "#007bff"): ColorInstance { + const candidates = [ + extractSolidColor(value), + normalizeCssColorString(value), + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + try { + return Color(candidate); + } catch { + // try next strategy + } + + const rgbaMatch = candidate.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (rgbaMatch) { + try { + const [, r, g, b, a] = rgbaMatch; + const rgb = Color.rgb(Number(r), Number(g), Number(b)); + return a !== undefined ? rgb.alpha(Number(a)) : rgb; + } catch { + // fall through + } + } + } + + return Color(fallback); +} diff --git a/src/utils/verboseLog.ts b/src/utils/verboseLog.ts new file mode 100644 index 00000000..9d77f3f4 --- /dev/null +++ b/src/utils/verboseLog.ts @@ -0,0 +1,38 @@ +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; + +const VERBOSE_LOG_ATTR = "data-bsplus-verbose-log"; + +export function isVerboseLoggingEnabled(): boolean { + return Boolean(settingsState.devMode && settingsState.verboseLogging); +} + +export function syncVerboseLogDomFlag(): void { + if (typeof document === "undefined") return; + document.documentElement.toggleAttribute( + VERBOSE_LOG_ATTR, + isVerboseLoggingEnabled(), + ); +} + +let initialized = false; + +/** Register DOM flag sync when dev / verbose toggles change. Call after settings load. */ +export function initVerboseLogging(): void { + if (initialized) return; + initialized = true; + syncVerboseLogDomFlag(); + settingsState.register("devMode", () => syncVerboseLogDomFlag()); + settingsState.register("verboseLogging", () => syncVerboseLogDomFlag()); +} + +export function verboseDebug(...args: unknown[]): void { + if (isVerboseLoggingEnabled()) console.debug(...args); +} + +export function verboseInfo(...args: unknown[]): void { + if (isVerboseLoggingEnabled()) console.info(...args); +} + +export function verboseLog(...args: unknown[]): void { + if (isVerboseLoggingEnabled()) console.log(...args); +}