From b4aca7277bf8099c3953dca0f4f8ee8e36391810 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 26 Jun 2026 21:38:04 +0930 Subject: [PATCH] fix: fix RGBA issue --- src/plugins/built-in/gradeAnalytics/ui.ts | 21 +----- .../gradeAnalytics/utils/accentColor.ts | 5 +- src/seqta/ui/colors/parseCssColor.test.ts | 47 ++++++++++++++ src/seqta/ui/colors/parseCssColor.ts | 65 +++++++++++++++++++ 4 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 src/seqta/ui/colors/parseCssColor.test.ts create mode 100644 src/seqta/ui/colors/parseCssColor.ts diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts index 5beecc69..cf320d70 100644 --- a/src/plugins/built-in/gradeAnalytics/ui.ts +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -4,6 +4,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { mount, unmount } from "svelte"; import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte"; import { buildContrastAccentPalette } from "./utils/accentColor"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; type ThemeSettingKey = | "selectedColor" @@ -62,26 +63,6 @@ const ACCENT_CSS_VARS = [ "--colour-betterseqta-blue", ] as const; -/** Resolve a solid colour for charts (gradients → first stop). */ -function extractSolidColor(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed || trimmed === "initial") return null; - if ( - trimmed.startsWith("#") || - trimmed.startsWith("rgb") || - trimmed.startsWith("hsl") - ) { - return trimmed; - } - if (trimmed.includes("gradient")) { - const match = trimmed.match( - /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i, - ); - return match?.[0] ?? null; - } - return null; -} - const THEME_ACCENT_OVERRIDES: Record = { "bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80", }; diff --git a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts index 606b2042..2858771a 100644 --- a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts +++ b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts @@ -1,4 +1,5 @@ import Color from "color"; +import { parseCssColor } from "@/seqta/ui/colors/parseCssColor"; export type ContrastAccentPalette = { accent: string; @@ -52,8 +53,8 @@ export function buildContrastAccentPalette( accentRaw: string, backgroundRaw: string, ): ContrastAccentPalette { - const accent = Color(accentRaw); - const background = Color(backgroundRaw); + const accent = parseCssColor(accentRaw); + const background = parseCssColor(backgroundRaw, "#ffffff"); const isDark = background.isDark(); const { h, s } = accent.hsl().object(); diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts new file mode 100644 index 00000000..b7a71181 --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +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); +}