diff --git a/AGENTS.md b/AGENTS.md index 1182c6ba10..432724a1e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,11 @@ - Route decoupling note: `src/components/` should stay free of direct `react-router` imports. Route state now flows through `src/context/navigation-context.tsx`, the standalone app bridges router state with `src/routes/react-router-navigation-provider.tsx`, and link-like UI should use `src/components/shared/navigation-link.tsx`. - Test helper note: `test-utils.tsx` now wraps renders with a default `NavigationProvider` (`currentPath: "/"`, `conversationId: "test-conversation-id"`). Navigation-sensitive tests can override that via `renderWithProviders(..., { navigation: { ... } })`. +- CSS isolation for embeddable/hosted use now relies on a scoped wrapper attribute: all bundled CSS is prefixed under `[data-agent-server-ui]` via `postcss-prefix-selector` in `vite.config.ts`, with selector exceptions handled by `transformAgentServerUISelector()` in `src/styles/agent-server-ui-style-scope.ts`. That transform must remap global selectors like `:root`, `html`, and `body` directly onto the scoped shell instead of emitting impossible descendants such as `[data-agent-server-ui] :root`. +- Public embedding entry points should use `AgentServerUIProviders` (scoped root on by default) or `AgentServerUIRoot` for manual control. The standalone app already renders its own scoped root in `src/root.tsx`, so `src/entry.client.tsx` must pass `withStyleRoot={false}` to avoid nesting duplicate shells. Keep `AgentServerUIRoot` and the scoping constants re-exported from `src/lib/index.ts` so library consumers can customize the host wrapper without reaching into private paths. +- Theme/customization tokens for the embedded shell are exposed as `--oh-*` CSS variables. Override them through `styleOverrides`, `style`, or host CSS targeting `[data-agent-server-ui]`; Tailwind theme tokens in `src/tailwind.css` should continue to reference those variables with `@theme inline` so host apps can restyle the UI without reworking component class names. +- Regression coverage for the CSS isolation work lives in `__tests__/agent-server-ui-providers.test.tsx`, `__tests__/agent-server-ui-style-scope.test.ts`, and the browser-level `tests/css-isolation.spec.ts` Playwright test. + - Library packaging notes: - Public npm entrypoints now come from `src/index.ts` → `src/lib/index.ts`, with domain barrels under `src/components/{conversation,terminal,browser,files,settings,sidebar}/index.ts`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1b589ad2e9..bdf751eeed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,6 +67,34 @@ Useful targeted verification for the isolated dev launcher: npm run test -- __tests__/api/agent-server-config.test.ts __tests__/scripts/dev-safe.test.ts ``` +## CSS isolation and host-app customization + +The standalone app and the exported provider/root wrapper now scope all bundled CSS under a dedicated shell element with the `data-agent-server-ui` attribute. That means Tailwind utilities, HeroUI component styles, xterm styles, and local CSS only apply inside the OpenHands UI subtree instead of leaking into a host app. + +### Embedding strategy + +- Use `AgentServerUIProviders` in host apps. It renders a scoped style root by default. +- For direct wrapper control, use `AgentServerUIRoot`. +- The standalone app opts out of the provider wrapper because the router layout already renders the scoped root. + +### Customization strategy + +Theme and surface tokens are exposed as CSS custom properties on the scoped root. You can override them either through the provider/root `styleOverrides` prop or with host CSS targeting `[data-agent-server-ui]`. + +```tsx + + + +``` + +If you want Tailwind layout utilities on the inner themed container, pass `contentClassName` instead of `className`, because the outer scope element is what all generated selectors key off of. + ## Environment variables You can create a `.env` file in the project directory with these variables based on `.env.sample`. diff --git a/__tests__/agent-server-ui-providers.test.tsx b/__tests__/agent-server-ui-providers.test.tsx index 1d05317446..8ee2adb9d8 100644 --- a/__tests__/agent-server-ui-providers.test.tsx +++ b/__tests__/agent-server-ui-providers.test.tsx @@ -11,6 +11,8 @@ vi.mock("react-i18next", async (importOriginal) => import OptionService from "#/api/option-service/option-service.api"; import { + AGENT_SERVER_UI_SCOPE_SELECTOR, + AgentServerUIRoot, AgentServerUIProviders, DEFAULT_AGENT_SERVER_ANALYTICS, OPENHANDS_I18N_NAMESPACE, @@ -189,4 +191,70 @@ describe("AgentServerUIProviders", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); }); }); + + it("wraps children in a scoped, customizable style root by default", () => { + const { unmount } = render( + +
child
+
, + ); + + const scopeRoot = document.querySelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ); + + expect(scopeRoot).toBeInTheDocument(); + expect(scopeRoot?.style.getPropertyValue("--oh-color-base")).toBe( + "#010203", + ); + + const themedContainer = + scopeRoot?.firstElementChild as HTMLDivElement | null; + expect(themedContainer).toHaveAttribute("data-theme", "dark"); + expect(themedContainer).toHaveClass("dark", "min-h-screen"); + expect(themedContainer).toContainElement( + screen.getByTestId("styled-child"), + ); + + unmount(); + + render( + +
child
+
, + ); + + expect(document.querySelector(AGENT_SERVER_UI_SCOPE_SELECTOR)).toBeNull(); + }); + + it("exposes a standalone style root for host-controlled customization", () => { + render( + +
child
+
, + ); + + const scopeRoot = document.querySelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ); + + expect(scopeRoot).toHaveClass("outer-shell"); + expect(scopeRoot?.style.getPropertyValue("--oh-color-primary")).toBe( + "#abcdef", + ); + + const themedContainer = + scopeRoot?.firstElementChild as HTMLDivElement | null; + expect(themedContainer).toHaveAttribute("data-theme", "light"); + expect(themedContainer).toHaveClass("light", "inner-shell"); + expect(themedContainer).toContainElement(screen.getByTestId("root-child")); + }); }); diff --git a/__tests__/agent-server-ui-style-scope.test.ts b/__tests__/agent-server-ui-style-scope.test.ts new file mode 100644 index 0000000000..8335ae10b7 --- /dev/null +++ b/__tests__/agent-server-ui-style-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_SERVER_UI_SCOPE_SELECTOR, + transformAgentServerUISelector, +} from "#/styles/agent-server-ui-style-scope"; + +describe("transformAgentServerUISelector", () => { + it("prefixes ordinary selectors under the scoped root", () => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ".button-base", + `${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`, + ), + ).toBe(`${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`); + }); + + it("replaces :host selectors with the scoped root", () => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ":host", + `${AGENT_SERVER_UI_SCOPE_SELECTOR} :host`, + ), + ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); + }); + + it.each([":root", "body", "html"])( + "maps %s selectors directly to the scoped root", + (selector) => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + selector, + `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, + ), + ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); + }, + ); + + it("does not double-prefix selectors that are already scoped", () => { + const selector = `${AGENT_SERVER_UI_SCOPE_SELECTOR} .xterm`; + + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + selector, + `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, + ), + ).toBe(selector); + }); +}); diff --git a/__tests__/library-entrypoints.test.ts b/__tests__/library-entrypoints.test.ts index caeaccf2f3..507594587f 100644 --- a/__tests__/library-entrypoints.test.ts +++ b/__tests__/library-entrypoints.test.ts @@ -19,6 +19,11 @@ describe("library public entrypoints", () => { expect(publicApi.Sidebar).toBeTypeOf("function"); expect(publicApi.ConversationPanel).toBeTypeOf("function"); expect(publicApi.AgentServerUIProviders).toBeTypeOf("function"); + expect(publicApi.AgentServerUIRoot).toBeTypeOf("function"); + expect(publicApi.AGENT_SERVER_UI_SCOPE_SELECTOR).toBe( + "[data-agent-server-ui]", + ); + expect(publicApi.AGENT_SERVER_UI_DEFAULT_THEME).toBe("dark"); }); it("keeps each component-domain barrel importable", () => { diff --git a/global.d.ts b/global.d.ts index c6a059caff..c75305b980 100644 --- a/global.d.ts +++ b/global.d.ts @@ -25,3 +25,18 @@ interface Window { }; }; } + +declare module "postcss-prefix-selector" { + interface PrefixerOptions { + prefix: string; + transform?: ( + prefix: string, + selector: string, + prefixedSelector: string, + ) => string; + } + + export default function prefixer( + options: PrefixerOptions, + ): import("postcss").AcceptedPlugin; +} diff --git a/package-lock.json b/package-lock.json index e67d46eb5c..557f4e6bd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "jsdom": "29.0.2", "lint-staged": "16.4.0", "msw": "2.13.4", + "postcss-prefix-selector": "2.1.1", "prettier": "3.8.3", "tailwindcss": "4.2.2", "typescript": "6.0.3", @@ -12043,6 +12044,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-prefix-selector": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/postcss-prefix-selector/-/postcss-prefix-selector-2.1.1.tgz", + "integrity": "sha512-ZBgf427Et6+XnrnJ9VXtJEKCjJwTvn2wn/qMg+wvvlRhIeFIAxdbrlZZ0CSsWYMJfcyPLBh8ogj5O1kb/Mcx3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, "node_modules/postcss-selector-parser": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", diff --git a/package.json b/package.json index 77fbf56566..1ff47fac5f 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "jsdom": "29.0.2", "lint-staged": "16.4.0", "msw": "2.13.4", + "postcss-prefix-selector": "2.1.1", "prettier": "3.8.3", "tailwindcss": "4.2.2", "typescript": "6.0.3", diff --git a/src/components/providers/agent-server-ui-providers.tsx b/src/components/providers/agent-server-ui-providers.tsx index 76a5acb778..286a3fea67 100644 --- a/src/components/providers/agent-server-ui-providers.tsx +++ b/src/components/providers/agent-server-ui-providers.tsx @@ -14,6 +14,10 @@ import { setI18n, } from "#/i18n"; import { PostHogWrapper } from "./posthog-wrapper"; +import { + AgentServerUIRoot, + type AgentServerUIRootProps, +} from "./agent-server-ui-root"; export type AgentServerUIAnalyticsConfig = | { @@ -26,11 +30,15 @@ export const DEFAULT_AGENT_SERVER_ANALYTICS: AgentServerUIAnalyticsConfig = { provider: "posthog", }; -export interface AgentServerUIProvidersProps { +export interface AgentServerUIProvidersProps extends Pick< + AgentServerUIRootProps, + "className" | "contentClassName" | "style" | "styleOverrides" | "theme" +> { children: React.ReactNode; queryClient?: QueryClient; analytics?: AgentServerUIAnalyticsConfig; i18n?: I18nInstance; + withStyleRoot?: boolean; } export function AgentServerUIProviders({ @@ -38,6 +46,12 @@ export function AgentServerUIProviders({ queryClient, analytics, i18n, + className, + contentClassName, + style, + styleOverrides, + theme, + withStyleRoot = true, }: AgentServerUIProvidersProps) { const resolvedQueryClient = React.useMemo( () => queryClient ?? getDefaultQueryClient(), @@ -76,10 +90,24 @@ export function AgentServerUIProviders({ children ); + const wrappedContent = withStyleRoot ? ( + + {content} + + ) : ( + content + ); + return ( - {content} + {wrappedContent} ); diff --git a/src/components/providers/agent-server-ui-root.tsx b/src/components/providers/agent-server-ui-root.tsx new file mode 100644 index 0000000000..4707035da7 --- /dev/null +++ b/src/components/providers/agent-server-ui-root.tsx @@ -0,0 +1,53 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React from "react"; +import { cn } from "#/utils/utils"; +import { + AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES, + AGENT_SERVER_UI_DEFAULT_THEME, + type AgentServerUIStyleOverrides, + type AgentServerUITheme, +} from "#/styles/agent-server-ui-style-scope"; + +export interface AgentServerUIRootProps extends Omit< + React.HTMLAttributes, + "style" +> { + children: React.ReactNode; + theme?: AgentServerUITheme; + style?: React.CSSProperties; + styleOverrides?: AgentServerUIStyleOverrides; + contentClassName?: string; +} + +export function AgentServerUIRoot({ + children, + theme = AGENT_SERVER_UI_DEFAULT_THEME, + className, + style, + styleOverrides, + contentClassName, + ...divProps +}: AgentServerUIRootProps) { + const scopedStyle = React.useMemo( + () => + ({ + ...AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES, + ...styleOverrides, + ...style, + }) as React.CSSProperties, + [style, styleOverrides], + ); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts index 3fee4c1cae..d0a0835416 100644 --- a/src/components/providers/index.ts +++ b/src/components/providers/index.ts @@ -4,4 +4,8 @@ export { type AgentServerUIAnalyticsConfig, type AgentServerUIProvidersProps, } from "./agent-server-ui-providers"; +export { + AgentServerUIRoot, + type AgentServerUIRootProps, +} from "./agent-server-ui-root"; export { PostHogWrapper } from "./posthog-wrapper"; diff --git a/src/entry.client.tsx b/src/entry.client.tsx index 0f157278a6..c93357ca30 100644 --- a/src/entry.client.tsx +++ b/src/entry.client.tsx @@ -32,7 +32,10 @@ prepareApp().then(() => hydrateRoot( document, - + , diff --git a/src/index.css b/src/index.css index 538aee842f..f67c24ec6a 100644 --- a/src/index.css +++ b/src/index.css @@ -1,19 +1,28 @@ -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap"); :root { - --bg-dark: #0c0e10; - --bg-light: #292929; - --bg-input: #393939; - --bg-workspace: #1f2228; - --border: #3c3c4a; - --text-editor-base: #9099ac; - --text-editor-active: #c4cbda; - --bg-editor-sidebar: #24272e; - --bg-editor-active: #31343d; - --border-editor-sidebar: #3c3c4a; - background-color: var(--base) !important; - --bg-neutral-muted: #afb8c133; + --oh-bg-dark: #0c0e10; + --oh-bg-light: #292929; + --oh-bg-input: #393939; + --oh-bg-workspace: #1f2228; + --oh-text-editor-base: #9099ac; + --oh-text-editor-active: #c4cbda; + --oh-bg-editor-sidebar: #24272e; + --oh-bg-editor-active: #31343d; + --oh-border-editor-sidebar: #3c3c4a; + --oh-bg-neutral-muted: #afb8c133; + --bg-dark: var(--oh-bg-dark); + --bg-light: var(--oh-bg-light); + --bg-input: var(--oh-bg-input); + --bg-workspace: var(--oh-bg-workspace); + --text-editor-base: var(--oh-text-editor-base); + --text-editor-active: var(--oh-text-editor-active); + --bg-editor-sidebar: var(--oh-bg-editor-sidebar); + --bg-editor-active: var(--oh-bg-editor-active); + --border-editor-sidebar: var(--oh-border-editor-sidebar); + --bg-neutral-muted: var(--oh-bg-neutral-muted); + background-color: var(--oh-color-base) !important; } body { diff --git a/src/lib/index.ts b/src/lib/index.ts index c462d82ccb..526dd08340 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -6,9 +6,11 @@ export * from "../components/sidebar"; export * from "../components/terminal"; export { AgentServerUIProviders, + AgentServerUIRoot, DEFAULT_AGENT_SERVER_ANALYTICS, type AgentServerUIAnalyticsConfig, type AgentServerUIProvidersProps, + type AgentServerUIRootProps, } from "../components/providers"; export { createAgentServerQueryClient, @@ -27,3 +29,12 @@ export { translationResources, waitForI18n, } from "../i18n"; +export { + AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES, + AGENT_SERVER_UI_DEFAULT_THEME, + AGENT_SERVER_UI_SCOPE_ATTRIBUTE, + AGENT_SERVER_UI_SCOPE_SELECTOR, + type AgentServerUICssVariableName, + type AgentServerUIStyleOverrides, + type AgentServerUITheme, +} from "../styles/agent-server-ui-style-scope"; diff --git a/src/root.tsx b/src/root.tsx index e23738b87a..37c3bcf6ce 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -22,6 +22,7 @@ import { import { AgentServerConnectionForm } from "#/components/features/settings/agent-server-onboarding"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useConfig } from "#/hooks/query/use-config"; +import { AgentServerUIRoot } from "#/components/providers"; export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -32,12 +33,14 @@ export function Layout({ children }: { children: React.ReactNode }) { - - {children} + + + {children} + +