From b85b9b1cc34d6ca126122b155f351e97776d75d8 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 16 Jun 2026 19:15:04 -0700 Subject: [PATCH 1/2] fix(react-grab): harden app theme detection (oklch backgrounds, color-scheme, body markers) (#476) --- .changeset/theme-oklch-detection.md | 10 ++ .../e2e/app-theme-detection.spec.ts | 126 ++++++++++++++++++ .../react-grab/src/utils/detect-app-theme.ts | 110 ++++++++------- 3 files changed, 197 insertions(+), 49 deletions(-) create mode 100644 .changeset/theme-oklch-detection.md create mode 100644 packages/react-grab/e2e/app-theme-detection.spec.ts diff --git a/.changeset/theme-oklch-detection.md b/.changeset/theme-oklch-detection.md new file mode 100644 index 000000000..12755bec3 --- /dev/null +++ b/.changeset/theme-oklch-detection.md @@ -0,0 +1,10 @@ +--- +"react-grab": patch +--- + +Make app theme detection (which drives the overlay's inverted theme) more robust: + +- Read background luminance through the existing `parseAnyColor` helper so pages whose background is authored with `oklch()` (e.g. Tailwind v4) are no longer mis-detected. Browsers serialize these computed colors in their own color space rather than `rgb()`, so the previous `rgb()`-only luminance heuristic silently failed and fell back to `prefers-color-scheme` — a forced-light page then looked dark to dark-OS visitors. +- Treat a dual `color-scheme` (`light dark` / `dark light`) as "decided by the OS preference / actual paint" instead of blindly trusting the first listed token, which mis-detected dark-OS visitors on sites that opt into both schemes. +- Inspect `` in addition to `` for theme markers (class, `data-theme`/`data-bs-theme`/etc., and presence attributes) so apps that theme the body are detected. +- Fall back from the body background to the root element when the body background is transparent. diff --git a/packages/react-grab/e2e/app-theme-detection.spec.ts b/packages/react-grab/e2e/app-theme-detection.spec.ts new file mode 100644 index 000000000..f49de7434 --- /dev/null +++ b/packages/react-grab/e2e/app-theme-detection.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, type ReactGrabPageObject } from "./fixtures.js"; + +// react-grab paints its overlay in the *inverse* of the detected app theme so it +// stays legible. The host therefore carries `data-rg-theme="dark"` on a light app +// and `data-rg-theme="light"` on a dark app. +const OVERLAY_THEME_ON_LIGHT_APP = "dark"; +const OVERLAY_THEME_ON_DARK_APP = "light"; + +const waitForOverlayTheme = async ( + reactGrab: ReactGrabPageObject, + expectedTheme: string, +): Promise => { + await reactGrab.page + .waitForFunction( + (theme) => + document.querySelector("[data-react-grab]")?.getAttribute("data-rg-theme") === theme, + expectedTheme, + { timeout: 2000 }, + ) + .catch(() => undefined); + + return reactGrab.getOverlayHost().getAttribute("data-rg-theme"); +}; + +const resolveOverlayThemeForBackground = async ( + reactGrab: ReactGrabPageObject, + backgroundColor: string, + expectedTheme: string, +): Promise => { + await reactGrab.page.evaluate((color) => { + document.documentElement.classList.remove("dark", "light"); + document.body.style.backgroundColor = color; + }, backgroundColor); + + return waitForOverlayTheme(reactGrab, expectedTheme); +}; + +test.describe("App Theme Detection", () => { + // Regression: modern browsers serialize computed colors authored with oklch() + // in their own color space, so the previous rgb()-only parser failed to read + // the page background and fell back to `prefers-color-scheme`. A light page on + // a dark-OS visitor was then mis-detected as dark. + test("detects a light background authored in oklch even under a dark OS preference", async ({ + reactGrab, + }) => { + await reactGrab.page.emulateMedia({ colorScheme: "dark" }); + + const overlayTheme = await resolveOverlayThemeForBackground( + reactGrab, + "oklch(1 0 0)", + OVERLAY_THEME_ON_LIGHT_APP, + ); + + expect(overlayTheme).toBe(OVERLAY_THEME_ON_LIGHT_APP); + }); + + test("detects a dark background authored in oklch even under a light OS preference", async ({ + reactGrab, + }) => { + await reactGrab.page.emulateMedia({ colorScheme: "light" }); + + const overlayTheme = await resolveOverlayThemeForBackground( + reactGrab, + "oklch(0.145 0 0)", + OVERLAY_THEME_ON_DARK_APP, + ); + + expect(overlayTheme).toBe(OVERLAY_THEME_ON_DARK_APP); + }); + + // `color-scheme: light dark` advertises support for both schemes; the active + // one follows the OS preference, not the token order. Previously the first + // token ("light") was always returned, mis-detecting dark-OS visitors. + test("defers to a dark OS preference when color-scheme allows both schemes", async ({ + reactGrab, + }) => { + await reactGrab.page.emulateMedia({ colorScheme: "dark" }); + await reactGrab.page.evaluate(() => { + document.documentElement.style.colorScheme = "light dark"; + }); + + expect(await waitForOverlayTheme(reactGrab, OVERLAY_THEME_ON_DARK_APP)).toBe( + OVERLAY_THEME_ON_DARK_APP, + ); + }); + + test("defers to a light OS preference when color-scheme allows both schemes", async ({ + reactGrab, + }) => { + await reactGrab.page.emulateMedia({ colorScheme: "light" }); + await reactGrab.page.evaluate(() => { + document.documentElement.style.colorScheme = "dark light"; + }); + + expect(await waitForOverlayTheme(reactGrab, OVERLAY_THEME_ON_LIGHT_APP)).toBe( + OVERLAY_THEME_ON_LIGHT_APP, + ); + }); + + test("honors a single-value color-scheme that forces dark against a light OS", async ({ + reactGrab, + }) => { + await reactGrab.page.emulateMedia({ colorScheme: "light" }); + await reactGrab.page.evaluate(() => { + document.documentElement.style.colorScheme = "dark"; + }); + + expect(await waitForOverlayTheme(reactGrab, OVERLAY_THEME_ON_DARK_APP)).toBe( + OVERLAY_THEME_ON_DARK_APP, + ); + }); + + // Some apps (and a few Bootstrap/MUI setups) mark the theme on rather + // than ; the previous detector only inspected the document element. + test("honors a theme marker set on the body element", async ({ reactGrab }) => { + await reactGrab.page.emulateMedia({ colorScheme: "light" }); + await reactGrab.page.evaluate(() => { + document.documentElement.classList.remove("dark", "light"); + document.body.classList.add("dark"); + }); + + expect(await waitForOverlayTheme(reactGrab, OVERLAY_THEME_ON_DARK_APP)).toBe( + OVERLAY_THEME_ON_DARK_APP, + ); + }); +}); diff --git a/packages/react-grab/src/utils/detect-app-theme.ts b/packages/react-grab/src/utils/detect-app-theme.ts index 21fb84292..81689b3a6 100644 --- a/packages/react-grab/src/utils/detect-app-theme.ts +++ b/packages/react-grab/src/utils/detect-app-theme.ts @@ -1,9 +1,9 @@ import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "./native-raf.js"; +import { parseAnyColor } from "./parse-any-color.js"; +import { parseHexChannels } from "./parse-color.js"; type AppTheme = "dark" | "light"; -const isAppTheme = (token: string): token is AppTheme => token === "dark" || token === "light"; - interface ThemeWatcherResult { theme: AppTheme; cleanup: () => void; @@ -26,21 +26,6 @@ const PRESENCE_ATTRIBUTES: readonly { attribute: string; theme: AppTheme }[] = [ const LUMINANCE_DARK_THRESHOLD = 0.18; -// Matches both legacy comma-separated (Chrome <101, Firefox <113) and modern -// space-separated (Chrome 101+, Firefox 113+, Safari 15+) computed rgb/rgba. -// legacy: rgba(18, 18, 18, 1) or rgb(18, 18, 18) -// modern: rgb(18 18 18) or rgb(18 18 18 / 1) -const RGB_PATTERN = - /rgba?\(\s*(\d+(?:\.\d+)?)\s*[,\s]\s*(\d+(?:\.\d+)?)\s*[,\s]\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*(\d+(?:\.\d+)?))?\s*\)/; - -const isTransparent = (backgroundColor: string): boolean => { - if (backgroundColor === "transparent") return true; - const rgbMatch = backgroundColor.match(RGB_PATTERN); - if (!rgbMatch) return false; - const alpha = rgbMatch[4]; - return alpha !== undefined && Number(alpha) === 0; -}; - const relativeLuminance = (red: number, green: number, blue: number): number => { const [linearRed, linearGreen, linearBlue] = [red, green, blue].map((channel) => { const normalized = channel / 255; @@ -49,19 +34,16 @@ const relativeLuminance = (red: number, green: number, blue: number): number => return 0.2126 * linearRed + 0.7152 * linearGreen + 0.0722 * linearBlue; }; -const themeFromBackgroundLuminance = (): AppTheme | null => { - const target = document.body ?? document.documentElement; - const backgroundColor = getComputedStyle(target).backgroundColor; - if (!backgroundColor || isTransparent(backgroundColor)) { - return null; - } - const rgbMatch = backgroundColor.match(RGB_PATTERN); - if (!rgbMatch) return null; - const luminance = relativeLuminance( - Number(rgbMatch[1]), - Number(rgbMatch[2]), - Number(rgbMatch[3]), - ); +// `parseAnyColor` resolves the full CSS color grammar (named/rgb/hsl/hwb via +// canvas, plus oklch via exact math) to a hex string, which `parseHexChannels` +// turns into channels - covering modern computed values like `oklch(...)` that a +// naive `rgb()` parse would miss. +const themeFromElementBackground = (element: HTMLElement): AppTheme | null => { + const hex = parseAnyColor(getComputedStyle(element).backgroundColor); + const channels = hex ? parseHexChannels(hex) : null; + // A transparent background tells us nothing about the rendered theme. + if (!channels || channels.alpha === 0) return null; + const luminance = relativeLuminance(channels.red, channels.green, channels.blue); return luminance < LUMINANCE_DARK_THRESHOLD ? "dark" : "light"; }; @@ -72,45 +54,75 @@ const themeFromAttributeValue = (attributeValue: string): AppTheme | null => { return null; }; -// CSS color-scheme can be multi-value ("light dark" or "dark light"). -// First listed value is the preferred scheme; fall back to includes-check -// for single-value strings. +// `color-scheme` only forces a theme when it lists a single value. When it +// lists both ("light dark" / "dark light") the active scheme is chosen by the +// user's OS preference and reflected in the actual paint - token order does NOT +// decide it - so we defer to luminance / prefers-color-scheme instead of +// guessing the first token. const themeFromColorScheme = (colorSchemeValue: string): AppTheme | null => { const normalized = colorSchemeValue.trim().toLowerCase(); if (!normalized || normalized === "normal" || normalized === "auto") return null; const tokens = normalized.split(/\s+/); - return tokens.find(isAppTheme) ?? null; + const allowsDark = tokens.includes("dark"); + const allowsLight = tokens.includes("light"); + if (allowsDark && allowsLight) return null; + if (allowsDark) return "dark"; + if (allowsLight) return "light"; + return null; }; -const detectTheme = (): AppTheme => { - const htmlElement = document.documentElement; +const themeFromColorSchemeOf = (element: HTMLElement): AppTheme | null => { + const colorSchemeValue = element.style.colorScheme || getComputedStyle(element).colorScheme; + return colorSchemeValue ? themeFromColorScheme(colorSchemeValue) : null; +}; - if (htmlElement.classList.contains("dark")) return "dark"; - if (htmlElement.classList.contains("light")) return "light"; +// Most frameworks mark the theme on (Tailwind, next-themes), but some +// put it on (a few Bootstrap/MUI setups and hand-rolled apps), so both +// roots are inspected. +const themeFromElementMarkers = (element: HTMLElement): AppTheme | null => { + if (element.classList.contains("dark")) return "dark"; + if (element.classList.contains("light")) return "light"; for (const attributeName of THEME_ATTRIBUTES) { - const attributeValue = htmlElement.getAttribute(attributeName); + const attributeValue = element.getAttribute(attributeName); if (!attributeValue) continue; const result = themeFromAttributeValue(attributeValue); if (result) return result; } for (const { attribute, theme } of PRESENCE_ATTRIBUTES) { - if (htmlElement.hasAttribute(attribute)) return theme; + if (element.hasAttribute(attribute)) return theme; } - const colorSchemeProperty = - htmlElement.style.colorScheme || getComputedStyle(htmlElement).colorScheme; - if (colorSchemeProperty) { - const result = themeFromColorScheme(colorSchemeProperty); - if (result) return result; - } + return null; +}; - const luminanceResult = themeFromBackgroundLuminance(); - if (luminanceResult) return luminanceResult; +const firstThemeFromRoots = ( + roots: readonly (HTMLElement | null)[], + classify: (element: HTMLElement) => AppTheme | null, +): AppTheme | null => { + for (const root of roots) { + if (!root) continue; + const theme = classify(root); + if (theme) return theme; + } + return null; +}; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +const detectTheme = (): AppTheme => { + // Explicit theme markers and `color-scheme` are inspected root-first; the + // painted background is read body-first, since frameworks usually paint the + // page background on while declaring the theme on . + const rootFirst = [document.documentElement, document.body] as const; + const bodyFirst = [document.body, document.documentElement] as const; + + return ( + firstThemeFromRoots(rootFirst, themeFromElementMarkers) ?? + firstThemeFromRoots(rootFirst, themeFromColorSchemeOf) ?? + firstThemeFromRoots(bodyFirst, themeFromElementBackground) ?? + (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + ); }; const invertTheme = (theme: AppTheme): AppTheme => (theme === "dark" ? "light" : "dark"); From 4bf71722623a2174e57d696731c5b665e68c5456 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:28:50 -0700 Subject: [PATCH 2/2] chore: version packages (#477) --- .changeset/theme-oklch-detection.md | 10 ---------- packages/cli/CHANGELOG.md | 2 ++ packages/cli/package.json | 2 +- packages/grab/CHANGELOG.md | 6 ++++++ packages/grab/package.json | 2 +- packages/react-grab/CHANGELOG.md | 12 ++++++++++++ packages/react-grab/package.json | 2 +- 7 files changed, 23 insertions(+), 13 deletions(-) delete mode 100644 .changeset/theme-oklch-detection.md diff --git a/.changeset/theme-oklch-detection.md b/.changeset/theme-oklch-detection.md deleted file mode 100644 index 12755bec3..000000000 --- a/.changeset/theme-oklch-detection.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"react-grab": patch ---- - -Make app theme detection (which drives the overlay's inverted theme) more robust: - -- Read background luminance through the existing `parseAnyColor` helper so pages whose background is authored with `oklch()` (e.g. Tailwind v4) are no longer mis-detected. Browsers serialize these computed colors in their own color space rather than `rgb()`, so the previous `rgb()`-only luminance heuristic silently failed and fell back to `prefers-color-scheme` — a forced-light page then looked dark to dark-OS visitors. -- Treat a dual `color-scheme` (`light dark` / `dark light`) as "decided by the OS preference / actual paint" instead of blindly trusting the first listed token, which mis-detected dark-OS visitors on sites that opt into both schemes. -- Inspect `` in addition to `` for theme markers (class, `data-theme`/`data-bs-theme`/etc., and presence attributes) so apps that theme the body are detected. -- Fall back from the body background to the root element when the body background is transparent. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 3c3ae13b2..16d83a85e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @react-grab/cli +## 0.1.46 + ## 0.1.45 ## 0.1.44 diff --git a/packages/cli/package.json b/packages/cli/package.json index 5efe9e549..98ef1272c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@react-grab/cli", - "version": "0.1.45", + "version": "0.1.46", "repository": { "type": "git", "url": "git+https://github.com/aidenybai/react-grab.git" diff --git a/packages/grab/CHANGELOG.md b/packages/grab/CHANGELOG.md index f6e61a13b..d4b9ba27c 100644 --- a/packages/grab/CHANGELOG.md +++ b/packages/grab/CHANGELOG.md @@ -1,5 +1,11 @@ # grab +## 0.1.46 + +### Patch Changes + +- @react-grab/cli@0.1.46 + ## 0.1.45 ### Patch Changes diff --git a/packages/grab/package.json b/packages/grab/package.json index 69fe0ab3d..0d19e6001 100644 --- a/packages/grab/package.json +++ b/packages/grab/package.json @@ -1,6 +1,6 @@ { "name": "grab", - "version": "0.1.45", + "version": "0.1.46", "description": "Select context for coding agents directly from your website", "keywords": [ "agent", diff --git a/packages/react-grab/CHANGELOG.md b/packages/react-grab/CHANGELOG.md index 3ffaf6dc9..b7ba66412 100644 --- a/packages/react-grab/CHANGELOG.md +++ b/packages/react-grab/CHANGELOG.md @@ -1,5 +1,17 @@ # react-grab +## 0.1.46 + +### Patch Changes + +- b85b9b1: Make app theme detection (which drives the overlay's inverted theme) more robust: + + - Read background luminance through the existing `parseAnyColor` helper so pages whose background is authored with `oklch()` (e.g. Tailwind v4) are no longer mis-detected. Browsers serialize these computed colors in their own color space rather than `rgb()`, so the previous `rgb()`-only luminance heuristic silently failed and fell back to `prefers-color-scheme` — a forced-light page then looked dark to dark-OS visitors. + - Treat a dual `color-scheme` (`light dark` / `dark light`) as "decided by the OS preference / actual paint" instead of blindly trusting the first listed token, which mis-detected dark-OS visitors on sites that opt into both schemes. + - Inspect `` in addition to `` for theme markers (class, `data-theme`/`data-bs-theme`/etc., and presence attributes) so apps that theme the body are detected. + - Fall back from the body background to the root element when the body background is transparent. + - @react-grab/cli@0.1.46 + ## 0.1.45 ### Patch Changes diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 5934d9ebd..6ff5a2f20 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -1,6 +1,6 @@ { "name": "react-grab", - "version": "0.1.45", + "version": "0.1.46", "description": "Select context for coding agents directly from your website", "keywords": [ "agent",