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/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/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",
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");