diff --git a/.changeset/devtools-panel-theme.md b/.changeset/devtools-panel-theme.md
new file mode 100644
index 0000000..3e503df
--- /dev/null
+++ b/.changeset/devtools-panel-theme.md
@@ -0,0 +1,7 @@
+---
+"@paramour-js/devtools-panel": major
+---
+
+The devtools panel now follows the TanStack Devtools theme. Before this fix it always rendered light, even in a dark shell. The shell passes its theme only to a plugin's `render` function and renders an element `render` as-is, so `paramourDevtoolsPlugin()` now returns `render` as `(el, { theme }) => `.
+
+**Breaking (types):** `ParamourDevtoolsPluginEntry.render` changes from `ReactElement` to `(el: HTMLElement, props: { readonly theme: "dark" | "light" }) => ReactElement`. Passing the entry to `` is unchanged. Only code that reads `entry.render` as an element needs updating. Call it with the shell's theme or render `` directly.
diff --git a/docs/content/docs/guides/devtools.mdx b/docs/content/docs/guides/devtools.mdx
index b82d34c..5663d7e 100644
--- a/docs/content/docs/guides/devtools.mdx
+++ b/docs/content/docs/guides/devtools.mdx
@@ -73,7 +73,8 @@ nothing to register per route.
`paramourDevtoolsPlugin({ defaultOpen: true })` opens the panel on load if
you want it in your face during a debugging session. If you're composing a
custom devtools shell, `ParamourDevtoolsPanel` is also exported directly —
-the plugin helper is just `{ id, name, render: }`.
+the plugin helper is just
+`{ id, name, render: (_el, { theme }) => }`.
## Production cost: none
diff --git a/docs/content/docs/reference/devtools.mdx b/docs/content/docs/reference/devtools.mdx
index a4ac2bb..cede90f 100644
--- a/docs/content/docs/reference/devtools.mdx
+++ b/docs/content/docs/reference/devtools.mdx
@@ -46,8 +46,8 @@ export function Devtools() {
`defaultOpen: true` opens the panel on load — useful when you want it in
your face during a debugging session. That's the only option; the helper
-just builds `{ id, name, render }` with the panel component as `render`,
-plus the flag.
+just builds `{ id, name, render }`, where `render` mounts the panel with the
+shell's theme, plus the flag.
Gotchas:
@@ -87,8 +87,9 @@ export function CustomShellPanel({ theme }: { theme: "dark" | "light" }) {
`theme` selects the panel's token set via a `data-theme` attribute (no
theme context, no remount) and defaults to `"light"`. When the panel is
mounted through [`paramourDevtoolsPlugin`](#paramourdevtoolsplugin) you
-never pass it — the TanStack shell clones the rendered element and injects
-`theme` itself; the prop exists for shells that don't.
+never pass it: the TanStack shell calls the plugin's `render` function with
+its current theme, and the panel follows the shell's theme setting live.
+In a custom shell, pass `theme` yourself.
## ParamourDevtoolsPluginEntry
@@ -99,14 +100,19 @@ interface ParamourDevtoolsPluginEntry {
readonly defaultOpen?: boolean;
readonly id: string;
readonly name: string;
- readonly render: ReactElement;
+ readonly render: (
+ el: HTMLElement,
+ props: { readonly theme: "dark" | "light" },
+ ) => ReactElement;
}
```
Declared *structurally* — the shell's contract is just
`name`/`render`/`id`/`defaultOpen` — so this package never imports
`@tanstack/react-devtools` at runtime; assignability to the real plugin
-type is certified by the package's type tests.
+type is certified by the package's type tests. `render` is a function
+because the shell passes its theme only to function renders. It renders
+an element `render` as-is.
```ts twoslash
import { paramourDevtoolsPlugin } from "@paramour-js/devtools-panel";
diff --git a/packages/devtools/src/components/panel.tsx b/packages/devtools/src/components/panel.tsx
index 9cd95ea..815523f 100644
--- a/packages/devtools/src/components/panel.tsx
+++ b/packages/devtools/src/components/panel.tsx
@@ -10,9 +10,9 @@ import { Sidebar } from "./sidebar.js";
export interface ParamourDevtoolsPanelProps {
/**
- * Injected by the TanStack shell (the one contract it guarantees every
- * panel, regardless of framework); selects the stylesheet's token set via
- * a `data-theme` attribute — no theme context, no remount.
+ * The shell's theme, passed through by `paramourDevtoolsPlugin()`'s
+ * render function; selects the stylesheet's token set via a `data-theme`
+ * attribute — no theme context, no remount.
*/
readonly theme?: "dark" | "light";
}
diff --git a/packages/devtools/src/plugin.tsx b/packages/devtools/src/plugin.tsx
index 5672cb8..fbf2a0a 100644
--- a/packages/devtools/src/plugin.tsx
+++ b/packages/devtools/src/plugin.tsx
@@ -9,13 +9,21 @@ import { ParamourDevtoolsPanel } from "./components/panel.js";
* entry type is declared STRUCTURALLY (the shell's contract is just
* name/render/id/defaultOpen) so this module never imports the shell at
* runtime; assignability to the real plugin type is certified by the
- * package's type tests. The shell clones `render` and injects `theme`.
+ * package's type tests.
+ *
+ * `render` is a function because that is the only way the panel receives
+ * the shell's theme: the shell calls a function render as
+ * `render(el, { theme, devtoolsOpen })` whenever its theme changes, and
+ * renders an element as-is with no props injected.
*/
export interface ParamourDevtoolsPluginEntry {
readonly defaultOpen?: boolean;
readonly id: string;
readonly name: string;
- readonly render: ReactElement;
+ readonly render: (
+ el: HTMLElement,
+ props: { readonly theme: "dark" | "light" },
+ ) => ReactElement;
}
export interface ParamourDevtoolsPluginOptions {
@@ -31,6 +39,6 @@ export function paramourDevtoolsPlugin(
: { defaultOpen: options.defaultOpen }),
id: "paramour-devtools",
name: "Paramour",
- render: ,
+ render: (_el, { theme }) => ,
};
}
diff --git a/packages/devtools/test/public-api.tst.tsx b/packages/devtools/test/public-api.tst.tsx
index 19b53f2..006a3b4 100644
--- a/packages/devtools/test/public-api.tst.tsx
+++ b/packages/devtools/test/public-api.tst.tsx
@@ -48,7 +48,14 @@ test("plugin helper returns the entry shape", () => {
expect(
paramourDevtoolsPlugin({ defaultOpen: true }),
).type.toBe();
- expect(paramourDevtoolsPlugin().render).type.toBeAssignableTo();
+ // A function, not an element: the shell passes the theme only to a
+ // function render.
+ expect(paramourDevtoolsPlugin().render).type.toBe<
+ (
+ el: HTMLElement,
+ props: { readonly theme: "dark" | "light" },
+ ) => ReactElement
+ >();
});
test("the entry is assignable to the REAL shell's plugins array", () => {
diff --git a/packages/devtools/test/theme.test.tsx b/packages/devtools/test/theme.test.tsx
index f3a1b2a..8b6875b 100644
--- a/packages/devtools/test/theme.test.tsx
+++ b/packages/devtools/test/theme.test.tsx
@@ -4,6 +4,7 @@ import { defineAppRoute } from "paramour";
import { afterEach, describe, expect, it } from "vitest";
import { ParamourDevtoolsPanel } from "../src/components/panel.js";
+import { paramourDevtoolsPlugin } from "../src/plugin.js";
import { freshSeam, paramsObservation, setUrl } from "./helpers.js";
const route = defineAppRoute("/home", {});
@@ -28,6 +29,23 @@ describe("theme switching", () => {
expect(root?.getAttribute("data-theme")).toBe("dark");
});
+ it("the plugin entry renders the theme the shell passes to render()", () => {
+ // TanStack's shell calls a function render as render(el, { theme, … })
+ // on every theme change; an element render never receives the theme.
+ freshSeam();
+ const entry = paramourDevtoolsPlugin();
+ const host = document.createElement("div");
+ const { container, rerender } = render(
+ entry.render(host, { theme: "dark" }),
+ );
+ const root = container.querySelector(".pmr-root");
+ expect(root?.getAttribute("data-theme")).toBe("dark");
+
+ rerender(entry.render(host, { theme: "light" }));
+ expect(container.querySelector(".pmr-root")).toBe(root);
+ expect(root?.getAttribute("data-theme")).toBe("light");
+ });
+
it("defaults to light when the shell passes no theme", () => {
freshSeam();
const { container } = render();