diff --git a/jest.config.js b/jest.config.js index b581d02a..a63ca1c1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,7 @@ export default { ], moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, setupFilesAfterEnv: ['/src/test/jest.setup.ts'], diff --git a/lib/publish.js b/lib/publish.js index a6263bf3..aba03a18 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -11,7 +11,7 @@ * or `node lib/publish.js --b firefox` */ -const glob = require("glob"); +const { globSync } = require("glob"); const semver = require("semver"); const { execSync } = require("child_process"); const path = require("path"); @@ -98,7 +98,7 @@ function getLatestFiles(browser) { const pattern = `dist/betterseqtaplus@*-*${browser}.zip`; console.log("Glob pattern:", pattern); - const files = glob.sync(pattern); + const files = globSync(pattern); console.log("Files found for browser", browser, ":", files); if (files.length === 0) { diff --git a/package.json b/package.json index 8c5ab429..fda71559 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "dependency-cruiser": "^17.0.1", "eslint": "^9.33.0", "eslint-plugin-import": "^2.31.0", - "glob": "^11.0.1", + "glob": "^13.0.6", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "mime-types": "^3.0.1", @@ -129,5 +129,13 @@ "uuid": "^11.1.0", "vite": "^6.2.1", "webextension-polyfill": "^0.12.0" + }, + "overrides": { + "glob": "^13.0.6" + }, + "pnpm": { + "overrides": { + "glob": "^13.0.6" + } } } diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts index 62b2f637..ae0dbc0b 100644 --- a/src/seqta/ui/colors/parseCssColor.test.ts +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -1,4 +1,8 @@ -import { extractSolidColor, normalizeCssColorString } from "./parseCssColor"; +import { + extractSolidColor, + normalizeCssColorString, + parseCssColor, +} from "./parseCssColor"; describe("normalizeCssColorString", () => { it("lowercases uppercase RGBA/RGB function names", () => { @@ -24,3 +28,19 @@ describe("extractSolidColor", () => { ).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 index 2ba50bb6..d2835f94 100644 --- a/src/seqta/ui/colors/parseCssColor.ts +++ b/src/seqta/ui/colors/parseCssColor.ts @@ -1,3 +1,7 @@ +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. @@ -28,3 +32,34 @@ export function extractSolidColor(value: string): string | 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/test/mocks/color.ts b/src/test/mocks/color.ts new file mode 100644 index 00000000..585602a5 --- /dev/null +++ b/src/test/mocks/color.ts @@ -0,0 +1,86 @@ +type ColorChannels = { + r: number; + g: number; + b: number; + a: number; +}; + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function toHexByte(value: number): string { + return clampByte(value).toString(16).padStart(2, "0"); +} + +function createColor(channels: ColorChannels) { + const color = { + red: () => clampByte(channels.r), + green: () => clampByte(channels.g), + blue: () => clampByte(channels.b), + alpha: () => clampAlpha(channels.a), + hex: () => + `#${toHexByte(channels.r)}${toHexByte(channels.g)}${toHexByte(channels.b)}`, + }; + + return { + ...color, + alpha: (value?: number) => { + if (value === undefined) return clampAlpha(channels.a); + return createColor({ ...channels, a: value }); + }, + }; +} + +function parseHex(input: string): ColorChannels | null { + const short = input.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split(""); + return { + r: parseInt(`${r}${r}`, 16), + g: parseInt(`${g}${g}`, 16), + b: parseInt(`${b}${b}`, 16), + a: 1, + }; + } + + const long = input.match(/^#([0-9a-f]{6})$/i); + if (!long) return null; + + return { + r: parseInt(long[1].slice(0, 2), 16), + g: parseInt(long[1].slice(2, 4), 16), + b: parseInt(long[1].slice(4, 6), 16), + a: 1, + }; +} + +function parseRgb(input: string): ColorChannels | null { + const match = input.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (!match) return null; + + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] !== undefined ? Number(match[4]) : 1, + }; +} + +function Color(input: string) { + const channels = parseHex(input) ?? parseRgb(input); + if (!channels) { + throw new Error(`Unable to parse color: ${input}`); + } + return createColor(channels); +} + +Color.rgb = (r: number, g: number, b: number) => createColor({ r, g, b, a: 1 }); + +export default Color; diff --git a/src/utils/verboseLog.ts b/src/utils/verboseLog.ts index 85616c2c..9d77f3f4 100644 --- a/src/utils/verboseLog.ts +++ b/src/utils/verboseLog.ts @@ -1,5 +1,38 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -export function verboseLog(...args: unknown[]): void { - if (settingsState.devMode) console.log(...args); +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); }