diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
index 41efdfb9e8..3da8d92604 100644
--- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
+++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
@@ -6,8 +6,13 @@
* registers a nav item, a top-level route, a `task-sidebar` slot component,
* a `main-top-bar` slot component, a `task.created` WS handler, and the
* `open-demo` keybinding (declared in manifest.yaml's `ui.keybindings`,
- * default `mod+shift+j`) which opens a `host.openModal(...)` demo modal.
- * Uses only host.React/host.jsx.
+ * default `mod+shift+j`) which opens a `host.openModal(...)` demo modal
+ * containing a `host.ui` Tooltip. Uses only host.React/host.jsx/host.ui.
+ *
+ * The plugin page also renders a `host.theme` readout kept current purely
+ * through `host.onThemeChange`, so an e2e can flip the app theme for real and
+ * prove the subscription fires in a browser (jsdom's MutationObserver is a
+ * shim, and jsdom cannot open a Radix tooltip from synthetic hover at all).
*
* It also registers the plugin-hooks surface this fixture exists to drive
* end to end: a "Notes" task panel (registerTaskPanel, mobile-enabled) that
@@ -54,14 +59,44 @@
initialize: function (registry, host) {
var React = host.React;
var jsx = host.jsx;
+ var ui = host.ui;
+
+ // Reads host.theme once on mount, then tracks it purely through
+ // host.onThemeChange — so the readout only stays correct if the
+ // subscription actually fires. Also counts notifications, proving the
+ // host does not re-notify on unrelated class churn.
+ function useHostTheme() {
+ var state = React.useState(function () {
+ return { theme: host.theme, changes: 0 };
+ });
+ var value = state[0];
+ var setValue = state[1];
+ React.useEffect(function () {
+ return host.onThemeChange(function (next) {
+ setValue(function (prev) {
+ return { theme: next, changes: prev.changes + 1 };
+ });
+ });
+ }, []);
+ return value;
+ }
function PluginPage() {
var count = useCounter(React);
+ var themeState = useHostTheme();
return jsx(
"div",
{ id: "hello-plugin-page-root" },
jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"),
jsx("span", { id: "hello-task-counter" }, String(count)),
+ jsx(
+ "span",
+ {
+ "data-testid": "hello-theme-readout",
+ "data-theme-changes": String(themeState.changes),
+ },
+ themeState.theme,
+ ),
);
}
@@ -242,10 +277,25 @@
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
+ // The Tooltip is the point of this modal in e2e: PluginModalHost
+ // mounts outside AppShell, so without its own TooltipProvider a
+ // Tooltip here throws on render and the error boundary swallows the
+ // whole modal. jsdom cannot open a Radix tooltip from synthetic
+ // hover, so real pointer hover is only assertable here.
return jsx(
"div",
{ id: "hello-demo-modal", "data-testid": "hello-demo-modal" },
"Hello from the plugin modal",
+ jsx(
+ ui.Tooltip,
+ null,
+ jsx(
+ ui.TooltipTrigger,
+ { "data-testid": "hello-modal-tooltip-trigger" },
+ "hover me",
+ ),
+ jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"),
+ ),
);
}
host.openModal({
diff --git a/apps/backend/internal/backendapp/plugins_version_test.go b/apps/backend/internal/backendapp/plugins_version_test.go
new file mode 100644
index 0000000000..66e4e94712
--- /dev/null
+++ b/apps/backend/internal/backendapp/plugins_version_test.go
@@ -0,0 +1,71 @@
+package backendapp
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/common/config"
+ "github.com/kandev/kandev/internal/events/bus"
+)
+
+// TestProvideServicesWiresPluginsKandevVersion pins the production wiring of
+// the running build version into the plugin service.
+//
+// internal/plugins has always been able to enforce a package's
+// manifest.min_kandev_version, but checkMinKandevVersion short-circuits to
+// nil while s.kandevVersion is empty — so for as long as SetKandevVersion had
+// no production caller, the whole mechanism was a silent no-op in every
+// shipped build while its unit tests stayed green. Only a test that goes
+// through provideServices can catch that regressing again.
+func TestProvideServicesWiresPluginsKandevVersion(t *testing.T) {
+ const wantVersion = "9.8.7"
+
+ services, _ := provideTestServices(t, wantVersion)
+ if services.Plugins == nil {
+ t.Fatal("provideServices returned a nil Plugins service")
+ }
+ if got := services.Plugins.KandevVersion(); got != wantVersion {
+ t.Fatalf("Plugins.KandevVersion() = %q, want %q — min_kandev_version is unenforced without it", got, wantVersion)
+ }
+}
+
+// provideTestServices boots the real service graph over a temp-dir SQLite
+// database, the same way Run does, and returns the resulting services.
+func provideTestServices(t *testing.T, version string) (*Services, *config.Config) {
+ t.Helper()
+
+ cfg := &config.Config{
+ HomeDir: t.TempDir(),
+ Database: config.DatabaseConfig{Driver: "sqlite"},
+ }
+ log := newTestLogger()
+
+ pool, repos, cleanups, err := provideRepositories(context.Background(), cfg, log, version)
+ if err != nil {
+ t.Fatalf("provideRepositories: %v", err)
+ }
+ t.Cleanup(func() {
+ for i := len(cleanups) - 1; i >= 0; i-- {
+ if cleanups[i] != nil {
+ _ = cleanups[i]()
+ }
+ }
+ })
+
+ agentRegistry, registryCleanup, err := registry.Provide(log)
+ if err != nil {
+ t.Fatalf("registry.Provide: %v", err)
+ }
+ t.Cleanup(func() {
+ if registryCleanup != nil {
+ _ = registryCleanup()
+ }
+ })
+
+ services, _, err := provideServices(cfg, log, repos, pool, bus.NewMemoryEventBus(log), agentRegistry, version)
+ if err != nil {
+ t.Fatalf("provideServices: %v", err)
+ }
+ return services, cfg
+}
diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go
index c777ad52d8..62f0101463 100644
--- a/apps/backend/internal/backendapp/services.go
+++ b/apps/backend/internal/backendapp/services.go
@@ -139,6 +139,11 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log)
pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log)
if pluginsSvc != nil {
+ // The ldflags-injected build version, so Install can enforce a
+ // package's manifest.min_kandev_version. This is the only production
+ // caller; without it the check stays a no-op. An un-stamped local
+ // build passes "dev", which the service treats as "don't enforce".
+ pluginsSvc.SetKandevVersion(version)
pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, pluginsTaskWriterAdapter{svc: taskSvc})
}
shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version)
diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go
index b27c1803b4..a8026cab2a 100644
--- a/apps/backend/internal/plugins/service.go
+++ b/apps/backend/internal/plugins/service.go
@@ -122,9 +122,9 @@ type Service struct {
// kandevVersion is the currently running kandev build version, used to
// enforce a package's manifest.min_kandev_version at Install (see
- // SetKandevVersion / checkMinKandevVersion). Empty (the default) means
- // no enforcement — no caller currently wires this in production; see
- // SetKandevVersion's doc comment.
+ // SetKandevVersion / checkMinKandevVersion). Empty (the default, e.g. in
+ // tests that construct a Service directly) or the "dev" sentinel means no
+ // enforcement; backendapp wires the real ldflags-injected version.
kandevVersion string
httpClient *http.Client
@@ -346,15 +346,21 @@ func (s *Service) authLoginBridge() AuthLoginBridge {
// SetKandevVersion wires the currently running kandev build version,
// enabling Install to enforce a package's manifest.min_kandev_version
// (checkMinKandevVersion): a package requiring a newer kandev is rejected
-// rather than installed and left to fail confusingly at spawn time. Not
-// currently called by Provide — the running build version needs to be
-// threaded down from internal/backendapp's ldflags-injected Version, which
-// is outside this package; until a caller wires it, min_kandev_version
-// remains parsed and stored but unenforced (the pre-existing behavior).
+// rather than installed and left to fail confusingly at spawn time.
+// Called once during startup wiring from internal/backendapp, which owns the
+// ldflags-injected Version. Passing DevKandevVersion (an un-stamped local
+// build) leaves the check a no-op — see checkMinKandevVersion.
func (s *Service) SetKandevVersion(v string) {
s.kandevVersion = v
}
+// KandevVersion returns the running build version wired via
+// SetKandevVersion, or "" when startup wiring never supplied one (in which
+// case min_kandev_version is not enforced).
+func (s *Service) KandevVersion() string {
+ return s.kandevVersion
+}
+
// SetRuntime wires the runtime.Manager Provide constructed.
func (s *Service) SetRuntime(rt PluginRuntime) {
s.runtime = rt
@@ -785,14 +791,21 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
return installed, activateErr
}
+// DevKandevVersion is the version string an un-stamped local build carries
+// (cmd/kandev's `Version` default, mirrored by internal/system/updates'
+// devVersion). It sorts meaninglessly against real semver, and a developer
+// running from source must still be able to install a package that declares
+// a min_kandev_version, so it disables the check entirely.
+const DevKandevVersion = "dev"
+
// checkMinKandevVersion rejects a package whose manifest declares a
// min_kandev_version newer than the currently running kandev build
// (manifest.CompareVersions). A no-op (nil error) when either side is
-// unset: minVersion == "" (the manifest doesn't declare one, the common
-// case today) or s.kandevVersion == "" (no running version wired via
-// SetKandevVersion).
+// unset or unusable: minVersion == "" (the manifest doesn't declare one,
+// the common case today), s.kandevVersion == "" (no running version wired
+// via SetKandevVersion), or s.kandevVersion == DevKandevVersion.
func (s *Service) checkMinKandevVersion(minVersion string) error {
- if minVersion == "" || s.kandevVersion == "" {
+ if minVersion == "" || s.kandevVersion == "" || s.kandevVersion == DevKandevVersion {
return nil
}
if manifest.CompareVersions(s.kandevVersion, minVersion) < 0 {
diff --git a/apps/backend/internal/plugins/service_install_test.go b/apps/backend/internal/plugins/service_install_test.go
index b1c51c0cc4..01b21e65ad 100644
--- a/apps/backend/internal/plugins/service_install_test.go
+++ b/apps/backend/internal/plugins/service_install_test.go
@@ -259,6 +259,36 @@ func TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion(t *testing.T)
}
}
+// TestServiceInstall_NoEnforcementOnDevBuild pins the dev-build escape
+// hatch. An un-stamped local build reports the "dev" sentinel, and a
+// developer running from source must still be able to install any package —
+// exactly as before this check was wired up in production.
+//
+// The escape hatch has to be explicit rather than left to
+// manifest.CompareVersions, because "dev" is not a version and that function
+// falls back to a plain strings.Compare when a segment won't parse. Which
+// side wins then is an accident of the alphabet: "dev" happens to sort above
+// a bare "999.0.0", but below anything starting past 'd' — and
+// min_kandev_version is a free-form manifest string, so a "v"-prefixed value
+// like "v1.0.0" is a spelling an author can (and does) write. Without the
+// sentinel skip that spelling locks every dev build out of the plugin.
+func TestServiceInstall_NoEnforcementOnDevBuild(t *testing.T) {
+ for _, minVersion := range []string{"999.0.0", "v1.0.0"} {
+ t.Run(minVersion, func(t *testing.T) {
+ svc, _, _ := newTestService(t)
+ svc.SetKandevVersion(DevKandevVersion)
+
+ rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", minVersion))
+ if err != nil {
+ t.Fatalf("Install() unexpected error on a dev build (min_kandev_version %q): %v", minVersion, err)
+ }
+ if rec.Status != StatusActive {
+ t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
+ }
+ })
+ }
+}
+
// TestServiceInstall_NoEnforcementWithoutKandevVersionWired pins the
// no-op default: a Service that never called SetKandevVersion (e.g. every
// other test in this file, and any caller that hasn't wired it yet) must
diff --git a/apps/web/components/plugins/plugin-modal-host.test.tsx b/apps/web/components/plugins/plugin-modal-host.test.tsx
index 0bced99e69..6852bf5bda 100644
--- a/apps/web/components/plugins/plugin-modal-host.test.tsx
+++ b/apps/web/components/plugins/plugin-modal-host.test.tsx
@@ -1,5 +1,6 @@
-import { act, cleanup, render, screen } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { pluginModalManager } from "@/lib/plugins/modal-manager";
import { PluginModalHost } from "./plugin-modal-host";
@@ -45,3 +46,51 @@ describe("PluginModalHost", () => {
expect(screen.queryByTestId("modal-content")).toBeNull();
});
});
+
+/**
+ * The host mounts as a sibling of `` (src/main.tsx), so it is
+ * outside the app-wide `TooltipProvider` in app/layout.tsx. Without a
+ * provider of its own, a `Tooltip` anywhere in plugin modal content throws on
+ * render and the whole modal is lost to the error boundary.
+ *
+ * jsdom does not reliably open a Radix tooltip from synthetic hover (see
+ * apps/web/CLAUDE.md), so the open assertion goes through focus. Pointer
+ * hover is a Playwright concern.
+ */
+describe("PluginModalHost — tooltips in modal content", () => {
+ afterEach(() => {
+ cleanup();
+ cleanupModals("plugin-a");
+ });
+
+ function TooltipContentComponent() {
+ return (
+
+ Trigger
+ Helpful hint
+
+ );
+ }
+
+ it("renders modal content containing a Tooltip without throwing", () => {
+ pluginModalManager.openModal("plugin-a", {
+ title: "Tooltip Modal",
+ content: TooltipContentComponent,
+ });
+
+ render();
+
+ expect(screen.getByTestId("tooltip-trigger")).not.toBeNull();
+ });
+
+ it("opens the tooltip on focus, proving a TooltipProvider is in scope", async () => {
+ pluginModalManager.openModal("plugin-a", { content: TooltipContentComponent });
+
+ render();
+ fireEvent.focus(screen.getByTestId("tooltip-trigger"));
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Helpful hint").length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/apps/web/components/plugins/plugin-modal-host.tsx b/apps/web/components/plugins/plugin-modal-host.tsx
index 05da40ee59..ac92b06957 100644
--- a/apps/web/components/plugins/plugin-modal-host.tsx
+++ b/apps/web/components/plugins/plugin-modal-host.tsx
@@ -1,6 +1,7 @@
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
+import { TooltipProvider } from "@kandev/ui/tooltip";
import {
pluginModalManager,
usePluginModals,
@@ -61,15 +62,22 @@ function PluginModalInstance({ modal }: { modal: OpenPluginModal }) {
* Renders every open plugin modal (`host.openModal(...)`) in a `@kandev/ui`
* `Dialog`, each isolated behind its own `PluginErrorBoundary`. Mounted once
* at the app root, alongside ``.
+ *
+ * The `TooltipProvider` is not redundant with the app-wide one in
+ * `app/layout.tsx`: this host mounts as a *sibling* of `` (see
+ * `src/main.tsx`), so it is outside that provider's subtree and a `Tooltip`
+ * inside plugin modal content would throw for want of a provider. Radix
+ * allows nesting, so this stays correct if the host is ever moved inside the
+ * shell.
*/
export function PluginModalHost() {
const modals = usePluginModals();
if (modals.length === 0) return null;
return (
- <>
+
{modals.map((modal) => (
))}
- >
+
);
}
diff --git a/apps/web/components/settings/plugins/use-plugin-actions.ts b/apps/web/components/settings/plugins/use-plugin-actions.ts
index 425509fb60..78779e72a7 100644
--- a/apps/web/components/settings/plugins/use-plugin-actions.ts
+++ b/apps/web/components/settings/plugins/use-plugin-actions.ts
@@ -7,7 +7,6 @@ import { toast } from "@/lib/toast/sonner";
import { t } from "@/lib/i18n";
import type { StoreApi } from "zustand";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
-import { useTheme } from "@/components/theme/app-theme";
import {
disablePlugin,
enablePlugin,
@@ -57,7 +56,6 @@ function withStatus(plugin: PluginRecord, status: PluginStatus): PluginRecord {
async function loadIfActive(
record: PluginRecord,
storeApi: StoreApi,
- theme: "light" | "dark",
evictCache: boolean,
) {
if (record.status !== "active") return;
@@ -68,7 +66,7 @@ async function loadIfActive(
} else {
unloadPlugin(record.id, { transition: "reload" });
}
- await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi, theme));
+ await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi));
}
/**
@@ -79,7 +77,6 @@ async function loadIfActive(
*/
function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
const storeApi = useAppStoreApi();
- const { resolvedTheme } = useTheme();
const [busyId, setBusyId] = useState(null);
const handleEnable = async (plugin: PluginRecord) => {
@@ -88,7 +85,7 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
await enablePlugin(plugin.id);
const updated = withStatus(plugin, "active");
upsertPlugin(updated);
- await loadIfActive(updated, storeApi, resolvedTheme, false);
+ await loadIfActive(updated, storeApi, false);
} catch (err) {
try {
const refreshed = await getPlugin(plugin.id, { cache: "no-store" });
@@ -197,7 +194,6 @@ function useUninstallAction(removePlugin: (id: string) => void) {
*/
function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
const storeApi = useAppStoreApi();
- const { resolvedTheme } = useTheme();
const [installOpen, setInstallOpenState] = useState(false);
const [installBusy, setInstallBusy] = useState(false);
const [installError, setInstallError] = useState(null);
@@ -212,7 +208,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
// "installed" toast, so it takes priority over the success toast.
const afterInstall = async ({ plugin, warning }: InstallResult) => {
upsertPlugin(plugin);
- await loadIfActive(plugin, storeApi, resolvedTheme, true);
+ await loadIfActive(plugin, storeApi, true);
if (warning) {
toast.warning(warning);
} else {
diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts
index cae0d2910e..0795674717 100644
--- a/apps/web/e2e/tests/plugins/plugins.spec.ts
+++ b/apps/web/e2e/tests/plugins/plugins.spec.ts
@@ -446,7 +446,10 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await testPage.keyboard.press("ControlOrMeta+Shift+J");
const modal = testPage.getByTestId("hello-demo-modal");
await expect(modal).toBeVisible();
- await expect(modal).toHaveText("Hello from the plugin modal");
+ // toContainText, not toHaveText: the modal body also carries the tooltip
+ // trigger exercised by the hover spec below, and this assertion is about
+ // the keybinding reaching host.openModal, not the body's exact contents.
+ await expect(modal).toContainText("Hello from the plugin modal");
// --- The host Dialog's built-in close button dismisses the (dismissible
// by default) modal. ---
@@ -454,6 +457,77 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect(modal).not.toBeVisible();
});
+ // `PluginModalHost` mounts as a sibling of `` (src/main.tsx), so it
+ // is outside the app-wide TooltipProvider in app/layout.tsx and needs its
+ // own. The unit test for this asserts via focus, because jsdom does not
+ // reliably open a Radix tooltip from synthetic hover (apps/web/CLAUDE.md) —
+ // real pointer hover, and the portaled role="tooltip" it produces, are only
+ // assertable in a browser.
+ test("a Tooltip inside host.openModal content opens on real pointer hover", async ({
+ testPage,
+ }) => {
+ test.setTimeout(60_000);
+
+ await openInstallDialog(testPage);
+ await uploadPackage(testPage, PACKAGE_PATH);
+ await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
+
+ await testPage.goto("/");
+ await testPage.reload();
+ await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await testPage.keyboard.press("ControlOrMeta+Shift+J");
+ const modal = testPage.getByTestId("hello-demo-modal");
+ await expect(modal).toBeVisible();
+
+ // Without a provider in scope the Tooltip throws during render and
+ // PluginErrorBoundary swallows the entire modal body, so the trigger
+ // being present is itself part of the assertion.
+ const trigger = testPage.getByTestId("hello-modal-tooltip-trigger");
+ await expect(trigger).toBeVisible();
+
+ await trigger.hover();
+ await expect(
+ testPage.getByRole("tooltip").filter({ hasText: "Tooltip inside a plugin modal" }),
+ ).toBeVisible();
+ });
+
+ // host.theme is a live getter and host.onThemeChange is backed by a
+ // MutationObserver on 's class. jsdom's MutationObserver is a shim, so
+ // only a browser proves a real theme flip reaches a plugin subscriber.
+ test("host.onThemeChange fires in a real browser when the app theme flips", async ({
+ testPage,
+ }) => {
+ test.setTimeout(60_000);
+
+ await openInstallDialog(testPage);
+ await uploadPackage(testPage, PACKAGE_PATH);
+ await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
+
+ await testPage.goto("/");
+ await testPage.reload();
+ await testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`).click();
+
+ // The readout seeds from host.theme once, then only ever updates from an
+ // onThemeChange notification — so a stale value here means the
+ // subscription never fired.
+ const readout = testPage.getByTestId("hello-theme-readout");
+ await expect(readout).toBeVisible({ timeout: 15_000 });
+ const before = await readout.textContent();
+ expect(before === "light" || before === "dark").toBe(true);
+
+ // Flip through the app's own command, not by poking the DOM, so this
+ // exercises AppThemeProvider the way a user would.
+ const target = before === "dark" ? "Switch to Light Mode" : "Switch to Dark Mode";
+ await testPage.keyboard.press("ControlOrMeta+k");
+ await testPage.getByRole("option", { name: target }).click();
+
+ await expect(readout).toHaveText(before === "dark" ? "light" : "dark");
+ await expect(readout).toHaveAttribute("data-theme-changes", "1");
+ });
+
test("settings page: schema-driven form, secret masking, and Host GetConfig delivery", async ({
testPage,
apiClient,
diff --git a/apps/web/lib/plugins/boot.ts b/apps/web/lib/plugins/boot.ts
index 9a7f5816cd..afd5fa64fc 100644
--- a/apps/web/lib/plugins/boot.ts
+++ b/apps/web/lib/plugins/boot.ts
@@ -12,11 +12,6 @@ import { installPluginGlobal, loadPlugins } from "./host";
/** Store instances that have already had `loadPlugins` triggered — boot is a one-shot per store. */
const bootedStores = new WeakSet>();
-/** Reads the resolved light/dark theme applied to the document by AppThemeProvider. */
-function readResolvedTheme(win: Window = window): "light" | "dark" {
- return win.document.documentElement.classList.contains("dark") ? "dark" : "light";
-}
-
/**
* Installs `window.registerKandevPlugin` and loads every plugin in
* `bootPayload.plugins`, building each one's `PluginHostApi` from `storeApi`.
@@ -35,6 +30,5 @@ export function bootPlugins(
bootedStores.add(storeApi);
installPluginGlobal();
- const theme = readResolvedTheme();
- void loadPlugins(bootPayload.plugins, (pluginId) => buildHostApi(pluginId, storeApi, theme));
+ void loadPlugins(bootPayload.plugins, (pluginId) => buildHostApi(pluginId, storeApi));
}
diff --git a/apps/web/lib/plugins/duplicate-render.test.tsx b/apps/web/lib/plugins/duplicate-render.test.tsx
index 96f68c1299..26b0e51102 100644
--- a/apps/web/lib/plugins/duplicate-render.test.tsx
+++ b/apps/web/lib/plugins/duplicate-render.test.tsx
@@ -28,6 +28,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
+ onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
diff --git a/apps/web/lib/plugins/host-api.test.ts b/apps/web/lib/plugins/host-api.test.ts
index 71f8aea498..17370f0b5f 100644
--- a/apps/web/lib/plugins/host-api.test.ts
+++ b/apps/web/lib/plugins/host-api.test.ts
@@ -44,7 +44,7 @@ describe("buildHostApi — api.fetch", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
await host.api.fetch("/issues", { method: "POST" });
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -57,7 +57,7 @@ describe("buildHostApi — api.fetch", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
await host.api.fetch("/issues");
const [, init] = fetchMock.mock.calls[0];
@@ -68,7 +68,7 @@ describe("buildHostApi — api.fetch", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
await host.api.fetch("issues");
const [url] = fetchMock.mock.calls[0];
@@ -82,7 +82,7 @@ describe("buildHostApi", () => {
});
it("exposes the host React instance and a jsx alias for React.createElement", () => {
- const host = buildHostApi("jira", createAppStore(), "dark");
+ const host = buildHostApi("jira", createAppStore());
expect(host.React).toBe(React);
expect(host.jsx).toBe(React.createElement);
@@ -94,7 +94,7 @@ describe("buildHostApi", () => {
const setStateSpy = vi.spyOn(store, "setState");
const subscribeSpy = vi.spyOn(store, "subscribe");
- const host = buildHostApi("jira", store, "dark");
+ const host = buildHostApi("jira", store);
expect(host.store.getState()).toBe(store.getState());
host.store.setState({});
@@ -107,10 +107,9 @@ describe("buildHostApi", () => {
unsubscribe();
});
- it("exposes the requested theme and a curated ui component subset", () => {
- const host = buildHostApi("jira", createAppStore(), "dark");
+ it("exposes a curated ui component subset", () => {
+ const host = buildHostApi("jira", createAppStore());
- expect(host.theme).toBe("dark");
// Expanded primitive set for full native-feeling plugin pages.
for (const name of EXPECTED_UI_PRIMITIVES) {
expect(host.ui[name], `host.ui.${name}`).toBeDefined();
@@ -118,14 +117,14 @@ describe("buildHostApi", () => {
});
it("exposes first-party app components for native flows and page chrome", () => {
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
expect(host.ui.PageTopbar).toBeDefined();
expect(host.ui.TaskCreateDialog).toBeDefined();
expect(host.ui.Combobox).toBeDefined();
});
it("exposes navigate() that soft-navigates via history push/replace", () => {
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
const pushSpy = vi.spyOn(window.history, "pushState");
const replaceSpy = vi.spyOn(window.history, "replaceState");
@@ -148,18 +147,18 @@ describe("buildHostApi", () => {
});
it("exposes the backend API origin on api.baseUrl", () => {
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
expect(typeof host.api.baseUrl).toBe("string");
});
it("sets pluginId on the returned host api", () => {
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
expect(host.pluginId).toBe("jira");
});
it("routes openModal to the modal manager, scoped to this plugin's id", async () => {
const { pluginModalManager } = await import("./modal-manager");
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
const before = pluginModalManager.getSnapshot().length;
const handle = host.openModal({ content: () => null, title: "Test" });
@@ -176,9 +175,102 @@ describe("buildHostApi", () => {
});
});
+/**
+ * jsdom delivers MutationObserver records on a microtask, so awaiting an
+ * already-resolved promise is enough to let a pending class mutation land.
+ */
+function flushMutationObservers(): Promise {
+ return Promise.resolve();
+}
+
+/**
+ * `host` is built once per plugin load, so a theme captured into it at boot
+ * can never follow a light/dark switch. These pin the live-read contract and
+ * the change notification a canvas-painting plugin needs on top of it.
+ */
+describe("buildHostApi — host.theme / host.onThemeChange", () => {
+ afterEach(() => {
+ document.documentElement.classList.remove("dark", "light");
+ });
+
+ it("reads the resolved theme live rather than freezing it at build time", () => {
+ document.documentElement.classList.remove("dark");
+ const host = buildHostApi("jira", createAppStore());
+ expect(host.theme).toBe("light");
+
+ document.documentElement.classList.add("dark");
+ expect(host.theme).toBe("dark");
+
+ document.documentElement.classList.remove("dark");
+ expect(host.theme).toBe("light");
+ });
+
+ it("notifies onThemeChange subscribers once per flip and stops after unsubscribe", async () => {
+ document.documentElement.classList.remove("dark");
+ const host = buildHostApi("jira", createAppStore());
+ const listener = vi.fn();
+ const unsubscribe = host.onThemeChange(listener);
+
+ document.documentElement.classList.add("dark");
+ await flushMutationObservers();
+ expect(listener).toHaveBeenCalledExactlyOnceWith("dark");
+
+ unsubscribe();
+ document.documentElement.classList.remove("dark");
+ await flushMutationObservers();
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ // Cross-plugin fan-out: one buggy plugin must not silently stop theme
+ // updates for every other plugin. The throwing listener is registered
+ // FIRST so an unguarded loop would abort before reaching the healthy one.
+ it("keeps notifying later listeners when an earlier one throws", async () => {
+ document.documentElement.classList.remove("dark");
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const host = buildHostApi("jira", createAppStore());
+
+ const boom = vi.fn(() => {
+ throw new Error("plugin theme listener blew up");
+ });
+ const healthy = vi.fn();
+ const unsubscribeBoom = host.onThemeChange(boom);
+ const unsubscribeHealthy = host.onThemeChange(healthy);
+
+ document.documentElement.classList.add("dark");
+ await flushMutationObservers();
+
+ expect(boom).toHaveBeenCalledTimes(1);
+ expect(healthy).toHaveBeenCalledExactlyOnceWith("dark");
+ expect(consoleError).toHaveBeenCalledWith(
+ "[plugins] theme change listener threw:",
+ expect.any(Error),
+ );
+
+ unsubscribeBoom();
+ unsubscribeHealthy();
+ consoleError.mockRestore();
+ });
+
+ it("ignores class mutations that leave the resolved theme unchanged", async () => {
+ document.documentElement.classList.add("dark");
+ const host = buildHostApi("jira", createAppStore());
+ const listener = vi.fn();
+ const unsubscribe = host.onThemeChange(listener);
+
+ // Unrelated class churn on — the rendering-engine marker, a
+ // transition-suppression class, etc. — must not wake every plugin.
+ document.documentElement.classList.add("some-unrelated-class");
+ await flushMutationObservers();
+ expect(listener).not.toHaveBeenCalled();
+
+ unsubscribe();
+ document.documentElement.classList.remove("some-unrelated-class");
+ });
+});
+
describe("buildHostApi — ui", () => {
it("exposes RichTextEditor/RichTextReadOnly for plugin notes-style UIs", () => {
- const host = buildHostApi("jira", createAppStore(), "light");
+ const host = buildHostApi("jira", createAppStore());
expect(host.ui.RichTextEditor).toBeDefined();
expect(host.ui.RichTextReadOnly).toBeDefined();
});
@@ -203,7 +295,7 @@ describe("buildHostApi — host.storage", () => {
);
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
const entry = await host.storage.get("task", "task_1", "note");
expect(entry).toEqual({ key: "note", value: "hi", updatedAt: TEST_UPDATED_AT });
@@ -216,7 +308,7 @@ describe("buildHostApi — host.storage", () => {
.fn()
.mockResolvedValue(new Response(null, { status: 404 })) as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
expect(await host.storage.get("task", "task_1", "note")).toBeUndefined();
});
@@ -225,7 +317,7 @@ describe("buildHostApi — host.storage", () => {
.fn()
.mockResolvedValue(new Response(null, { status: 500 })) as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await expect(host.storage.get("task", "task_1", "note")).rejects.toThrow();
});
});
@@ -246,7 +338,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => {
);
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
const result = await host.storage.set("task", "task_1", "note", "hello");
expect(result).toEqual({ updatedAt: TEST_UPDATED_AT });
@@ -267,7 +359,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => {
);
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await host.storage.set("task", "task_1", "note", "a");
await host.storage.set("task", "task_1", "note", "b");
@@ -280,7 +372,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 409 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await expect(
host.storage.set("task", "task_1", "note", "hello", { ifUnmodifiedSince: TEST_UPDATED_AT }),
).rejects.toThrow(/modified since/i);
@@ -293,7 +385,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await host.storage.delete("task", "task_1", "note");
const [url, init] = fetchMock.mock.calls[0];
@@ -312,13 +404,13 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => {
new Response(JSON.stringify({ entries }), { status: 200 }),
) as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
expect(await host.storage.list("task", "task_1")).toEqual(entries);
});
it("subscribe() returns an unsubscribe function wired through the plugin registry", async () => {
const { pluginRegistry } = await import("./registry");
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
const before = pluginRegistry.getWsHandlers("plugin.user-state.updated").length;
const unsubscribe = host.storage.subscribe({}, () => {});
@@ -348,7 +440,7 @@ describe("buildHostApi — host.storage writerId scoping", () => {
);
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await host.storage.set("task", "task_1", "note", "hello", { writerId: "panel-xyz" });
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
@@ -360,7 +452,7 @@ describe("buildHostApi — host.storage writerId scoping", () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchMock as unknown as typeof fetch;
- const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light");
+ const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore());
await host.storage.delete("task", "task_1", "note", { writerId: "panel-xyz" });
const [url] = fetchMock.mock.calls[0];
@@ -399,7 +491,7 @@ describe("buildHostApi — non-secure context (http:// on a non-localhost origin
new Response(JSON.stringify({ updatedAt: TEST_UPDATED_AT }), { status: 200 }),
) as unknown as typeof fetch;
- const host = freshHostApi.buildHostApi(NOTES_PLUGIN_ID, freshCreateStore(), "light");
+ const host = freshHostApi.buildHostApi(NOTES_PLUGIN_ID, freshCreateStore());
await host.storage.set("task", "task_1", "note", { body: "hi" });
const body = JSON.parse(
diff --git a/apps/web/lib/plugins/host-api.ts b/apps/web/lib/plugins/host-api.ts
index a745166f6d..96eceb6f6d 100644
--- a/apps/web/lib/plugins/host-api.ts
+++ b/apps/web/lib/plugins/host-api.ts
@@ -64,7 +64,7 @@ import { Switch } from "@kandev/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Textarea } from "@kandev/ui/textarea";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { Combobox } from "@/components/combobox";
import { RichTextEditor, RichTextReadOnly } from "@/components/editors/tiptap/rich-text-editor";
import { PageTopbar } from "@/components/page-topbar";
@@ -74,6 +74,7 @@ import { generateUUID } from "@/lib/utils";
import { softNavigate } from "@/lib/routing/client-router";
import type { AppState } from "@/lib/state/store";
import { pluginModalManager } from "./modal-manager";
+import { readResolvedTheme, subscribeToThemeChanges } from "./theme";
import { composeWriterId, subscribeToUserStateChanges } from "./user-state-sync";
import {
PluginStorageConflictError,
@@ -159,6 +160,14 @@ const PLUGIN_UI: Record = {
Textarea,
Tooltip,
TooltipContent,
+ // A plain Tooltip works everywhere a plugin renders, but from two different
+ // providers: routes and slots render inside AppShell and inherit the
+ // app-wide TooltipProvider in app/layout.tsx, while `openModal` content
+ // mounts *outside* AppShell and gets its own from PluginModalHost. This is
+ // exported for plugins that want their own `delayDuration`/
+ // `skipDelayDuration` for a dense cluster of tooltips — nesting a provider
+ // is supported by Radix.
+ TooltipProvider,
TooltipTrigger,
// App UI (not shadcn primitives), exposed so plugins compose kandev-native
// surfaces instead of re-implementing them:
@@ -212,11 +221,7 @@ function effectiveWriterId(surfaceId: string | undefined): string {
return composeWriterId(TAB_WRITER_ID, surfaceId);
}
-export function buildHostApi(
- pluginId: string,
- storeApi: StoreApi,
- theme: "light" | "dark",
-): PluginHostApi {
+export function buildHostApi(pluginId: string, storeApi: StoreApi): PluginHostApi {
return {
pluginId,
React,
@@ -235,7 +240,14 @@ export function buildHostApi(
},
},
ui: PLUGIN_UI,
- theme,
+ // Getter, not a value captured at boot: a plugin built once at page load
+ // would otherwise read the boot-time theme forever, and one that paints
+ // imperatively (canvas, inline SVG colors) could never follow a
+ // light/dark switch without a full reload.
+ get theme() {
+ return readResolvedTheme();
+ },
+ onThemeChange: (listener) => subscribeToThemeChanges(listener),
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
openModal: (options) => pluginModalManager.openModal(pluginId, options),
storage: buildStorageApi(pluginId),
diff --git a/apps/web/lib/plugins/host-lifecycle.test.ts b/apps/web/lib/plugins/host-lifecycle.test.ts
index cba9644dd2..115c9c41cb 100644
--- a/apps/web/lib/plugins/host-lifecycle.test.ts
+++ b/apps/web/lib/plugins/host-lifecycle.test.ts
@@ -27,6 +27,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
+ onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts
index b8718719ec..6ca8038824 100644
--- a/apps/web/lib/plugins/host.test.ts
+++ b/apps/web/lib/plugins/host.test.ts
@@ -34,6 +34,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
+ onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
diff --git a/apps/web/lib/plugins/theme.ts b/apps/web/lib/plugins/theme.ts
new file mode 100644
index 0000000000..ccc8b26167
--- /dev/null
+++ b/apps/web/lib/plugins/theme.ts
@@ -0,0 +1,86 @@
+/**
+ * Resolved light/dark theme for the plugin host (`host.theme` /
+ * `host.onThemeChange`).
+ *
+ * `AppThemeProvider` (components/theme/app-theme.tsx) applies the resolved
+ * theme by toggling the `light`/`dark` class on ``, and emits no event
+ * of its own. Observing that class is therefore the only source that covers
+ * every way the theme can flip — the settings picker, a live preview hover,
+ * and an OS-level `prefers-color-scheme` change while the app sits on
+ * "system" — without the plugin host having to subscribe to React context it
+ * is mounted outside of.
+ */
+
+export type ResolvedTheme = "light" | "dark";
+
+/** Reads the resolved light/dark theme currently applied to the document. */
+export function readResolvedTheme(doc: Document = document): ResolvedTheme {
+ return doc.documentElement.classList.contains("dark") ? "dark" : "light";
+}
+
+type ThemeListener = (theme: ResolvedTheme) => void;
+
+const listeners = new Set();
+let observer: MutationObserver | null = null;
+let lastTheme: ResolvedTheme | null = null;
+
+/**
+ * Fans a class mutation out to subscribers, but only when the *resolved*
+ * theme actually changed. ``'s class list carries far more than the
+ * theme (Tailwind state classes, the rendering-engine marker, transition
+ * suppression), so an unfiltered observer would wake every plugin on
+ * unrelated churn — expensive for the canvas repaints this exists to drive.
+ *
+ * Never throws. These listeners are third-party plugin callbacks and this is
+ * a cross-plugin fan-out, so one throwing listener must not starve the ones
+ * after it — the same isolation `dispatchToPluginWsHandlers` and
+ * `PluginErrorBoundary` already give every other plugin extension point.
+ * Without it the damage outlives the throw: `lastTheme` has already advanced,
+ * so the equality guard above means a repeat mutation to the same theme is a
+ * no-op and the skipped listeners stay stale until some *different* flip
+ * happens.
+ */
+function handleMutation(): void {
+ const next = readResolvedTheme();
+ if (next === lastTheme) return;
+ lastTheme = next;
+ for (const listener of [...listeners]) {
+ try {
+ listener(next);
+ } catch (error) {
+ console.error("[plugins] theme change listener threw:", error);
+ }
+ }
+}
+
+/**
+ * Subscribes to resolved-theme changes; returns an unsubscribe function.
+ *
+ * One `MutationObserver` is shared across every subscriber and is connected
+ * lazily on the first subscription / disconnected after the last one, so a
+ * page with no plugins (or none that care about the theme) observes nothing.
+ *
+ * That shared observer and its `listeners`/`lastTheme` state are module
+ * scope — correct for a browser (one document, one theme), but it means a
+ * test that subscribes without unsubscribing leaks a live observer into every
+ * later test in the file. Always call the returned function in cleanup.
+ */
+export function subscribeToThemeChanges(listener: ThemeListener): () => void {
+ listeners.add(listener);
+ if (!observer) {
+ lastTheme = readResolvedTheme();
+ observer = new MutationObserver(handleMutation);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ }
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && observer) {
+ observer.disconnect();
+ observer = null;
+ lastTheme = null;
+ }
+ };
+}
diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts
index 676b5d0abb..b89ff110ea 100644
--- a/apps/web/lib/plugins/types.ts
+++ b/apps/web/lib/plugins/types.ts
@@ -376,7 +376,22 @@ export interface PluginHostApi {
* `lib/plugins/host-api.ts` for the full list.
*/
ui: Record;
- theme: "light" | "dark";
+ /**
+ * The resolved light/dark theme, read live on every access — a plugin's
+ * `host` object is built once, so this must not be captured into a local
+ * variable that outlives a render if you want it to stay current.
+ * Components that read it during render re-read it on every re-render;
+ * anything that paints imperatively (canvas, inline SVG colors) should pair
+ * it with `onThemeChange`.
+ */
+ readonly theme: "light" | "dark";
+ /**
+ * Subscribes to light/dark changes — the settings picker, its live preview,
+ * and an OS `prefers-color-scheme` flip while the app is on "system".
+ * Returns an unsubscribe function; call it on teardown (component unmount,
+ * `KandevPlugin.destroy`) or the listener outlives the surface that owns it.
+ */
+ onThemeChange(listener: (theme: "light" | "dark") => void): () => void;
/** Soft SPA navigation (history push/replace + re-render), same as the app's router. */
navigate(href: string, options?: { replace?: boolean }): void;
/**
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index 56f3fe7e5a..91dab4f60c 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -57,7 +57,16 @@ interface PluginHostApi {
baseUrl: string;
};
ui: Record; // curated @kandev/ui components + app UI (see below)
- theme: "light" | "dark";
+ // The resolved light/dark theme, read live on every access. `host` is built
+ // once per plugin load, so copying this into a variable that outlives a
+ // render freezes it; read it during render, and pair it with onThemeChange
+ // for anything that paints imperatively (canvas, inline SVG colors).
+ readonly theme: "light" | "dark";
+ // Fires on every light/dark change — the settings picker, its live preview,
+ // and an OS prefers-color-scheme flip while the app is set to "system".
+ // Returns an unsubscribe function; call it on teardown (component unmount,
+ // KandevPlugin.destroy) or the listener outlives the surface that owns it.
+ onThemeChange(listener: (theme: "light" | "dark") => void): () => void;
// Soft SPA navigation (history push/replace + SPA re-render) — same code
// path as the app router, so plugin pages can link into native routes
// (e.g. /t/{taskId}) without a full reload.
@@ -156,7 +165,7 @@ interface PluginModalHandle {
`host.ui` contents: shadcn primitives (Alert*, Badge, Button, Card*, Checkbox,
Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*,
Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea,
-Tooltip*) plus first-party app UI: `PageTopbar` (the kandev title bar, for
+Tooltip*, including `TooltipProvider`) plus first-party app UI: `PageTopbar` (the kandev title bar, for
routes that opt out of the default chrome and own their layout),
`TaskCreateDialog` (kandev's real create-task modal, prefilled via
`initialValues`), `Combobox` (the app's Command+Popover picker), and
@@ -169,6 +178,12 @@ Radix/portal/context-based would split React context across instances and
break refs/`asChild`. Pure-React libs (e.g. `@tabler/icons-react`) bundle
fine.
+The host wraps plugin routes, slots, and `openModal` content in a
+`TooltipProvider`, so a plain `Tooltip` works anywhere without one.
+`TooltipProvider` is exported for plugins that want their own
+`delayDuration`/`skipDelayDuration` over a dense cluster of tooltips; Radix
+supports nesting it.
+
### `host.ui.RichTextEditor` / `host.ui.RichTextReadOnly`
Pixel-identical to the Plan panel's markdown editor (paste handling, slash
diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md
index e006b7d1b9..d76222d786 100644
--- a/docs/public/plugins-manifest.md
+++ b/docs/public/plugins-manifest.md
@@ -101,7 +101,7 @@ ui: # optional native frontend plugin
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
| `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
-| `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. |
+| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= , running ` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. |
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |