Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @react-grab/cli

## 0.1.46

## 0.1.45

## 0.1.44
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 6 additions & 0 deletions packages/grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# grab

## 0.1.46

### Patch Changes

- @react-grab/cli@0.1.46

## 0.1.45

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/grab/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/react-grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<body>` in addition to `<html>` 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
Expand Down
126 changes: 126 additions & 0 deletions packages/react-grab/e2e/app-theme-detection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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<string | null> => {
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 <body> rather
// than <html>; 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,
);
});
});
2 changes: 1 addition & 1 deletion packages/react-grab/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
110 changes: 61 additions & 49 deletions packages/react-grab/src/utils/detect-app-theme.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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";
};

Expand All @@ -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 <html> (Tailwind, next-themes), but some
// put it on <body> (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 <body> while declaring the theme on <html>.
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");
Expand Down
Loading