diff --git a/.stylelintignore b/.stylelintignore
index adfcf64..b96eb12 100644
--- a/.stylelintignore
+++ b/.stylelintignore
@@ -3,3 +3,6 @@
**/.output/**
**/target/**
**/node_modules/**
+# Generated client CSS (tailwind compile output); built by
+# packages/dsh-plugin-browserskill/scripts/build-client-css.mjs.
+**/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css
diff --git a/packages/dsh-plugin-browserskill/.gitignore b/packages/dsh-plugin-browserskill/.gitignore
index c3af857..a6d91cc 100644
--- a/packages/dsh-plugin-browserskill/.gitignore
+++ b/packages/dsh-plugin-browserskill/.gitignore
@@ -1 +1,4 @@
lib/
+src/client/bsk-ui.nomodule.css
+.bsk-ui.input.css
+.bsk-ui.raw.css
diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md
index f0cc68f..53103de 100644
--- a/packages/dsh-plugin-browserskill/README.md
+++ b/packages/dsh-plugin-browserskill/README.md
@@ -76,8 +76,13 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
When the plugin runs inside the dsh Web UI, an **observation overlay** floats over the app
(registered into the `shell.overlay` seat): a breathing thumbnail per owned session plus its
current action and elapsed time. The card docks at the top-right of the content area (clear of
-the composer and the shell's header controls) and follows the shell's own visual language —
-shared `ui-primitives` controls and `--dsw-alias-*` semantic tokens throughout.
+the composer and the shell's header controls) and wears the **BrowserSkill product family's own
+look**: the overlay reuses `@browser-skill/ui` components (`Button`, `cn`) and its oklch design
+tokens (`--card`, `--primary`, `--destructive`, `--ring`, …), status dots spec'd after the
+extension popup's connection indicator, and Remix icons. The BSK utility sheet is compiled
+scoped under the `.bsk-obs` root class (`scripts/build-client-css.mjs`), so the overlay looks
+like the BrowserSkill extension without leaking a single selector into the host shell — and the
+shell's theme cannot bleed back in.
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
`browser_session_start`; disappears when all sessions stop (or the plugin unloads).
diff --git a/packages/dsh-plugin-browserskill/package.json b/packages/dsh-plugin-browserskill/package.json
index 4d6154f..50b5b04 100644
--- a/packages/dsh-plugin-browserskill/package.json
+++ b/packages/dsh-plugin-browserskill/package.json
@@ -34,9 +34,10 @@
}
},
"scripts": {
- "build": "tsdown",
+ "build": "node scripts/build-client-css.mjs && tsdown",
"typecheck": "tsc --noEmit",
- "test": "vitest run"
+ "test": "vitest run",
+ "build:css": "node scripts/build-client-css.mjs"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
@@ -82,16 +83,24 @@
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
+ "@tailwindcss/cli": "^4.3.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.0",
"@types/react": "~18.3.1",
"@types/react-dom": "^18.3.0",
"happy-dom": "^15.11.7",
"lightningcss": "^1.30.0",
+ "postcss": "^8.5.0",
+ "postcss-prefix-selector": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "tailwindcss": "^4.3.3",
"tsdown": "^0.22.14",
"typescript": "^5.8.0",
"vitest": "^4.1.6"
+ },
+ "dependencies": {
+ "@browser-skill/ui": "workspace:*",
+ "@remixicon/react": "^4.9.0"
}
}
diff --git a/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs b/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs
new file mode 100644
index 0000000..dc55950
--- /dev/null
+++ b/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+/**
+ * Build the scoped BrowserSkill-UI utility sheet for the dsh client bundle.
+ *
+ * The overlay reuses @browser-skill/ui components (shadcn-style, Tailwind v4
+ * utility classes). Those classes need a stylesheet — but a global utility
+ * sheet inside the dsh page would collide with the host UI, so the compiled
+ * output is scope-prefixed under `.bsk-obs` (the overlay root class):
+ * 1. tailwindcss compiles the utilities used by packages/ui + our client;
+ * 2. postcss-prefix-selector nests every rule under .bsk-obs.
+ * Token values themselves live in src/client/bsk-tokens.nomodule.css
+ * (hand-mirrored from packages/ui/src/styles/tailwind.css).
+ *
+ * Output: src/client/bsk-ui.nomodule.css (gitignored build artifact).
+ */
+import { execFileSync } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import postcss from "postcss";
+import prefixer from "postcss-prefix-selector";
+
+const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+const input = join(pkgRoot, ".bsk-ui.input.css");
+const rawOut = join(pkgRoot, ".bsk-ui.raw.css");
+const finalOut = join(pkgRoot, "src/client/bsk-ui.nomodule.css");
+
+// The canonical sheet can't be imported directly (its own `@import
+// "tailwindcss"` must resolve from a package that has it), so we inline the
+// canonical @theme block read from packages/ui at build time.
+const canonical = readFileSync(join(pkgRoot, "..", "ui", "src", "styles", "tailwind.css"), "utf8");
+const themeBlock = canonical.match(/@theme inline \{[\s\S]*?\n\}/)?.[0];
+if (themeBlock === undefined) throw new Error("canonical @theme inline block not found");
+writeFileSync(
+ input,
+ [
+ '@import "tailwindcss";',
+ "@custom-variant dark (&:is(.dark *));",
+ themeBlock,
+ '@source "../ui/src";',
+ '@source "./src/client";',
+ "",
+ ].join("\n"),
+);
+
+execFileSync(
+ process.execPath,
+ [
+ join(pkgRoot, "node_modules", "@tailwindcss", "cli", "dist", "index.mjs"),
+ "-i",
+ input,
+ "-o",
+ rawOut,
+ ],
+ { cwd: pkgRoot, stdio: "inherit" },
+);
+
+const result = await postcss([
+ prefixer({
+ prefix: ".bsk-obs",
+ transform(prefix, selector, prefixedSelector) {
+ // Root-level anchors collapse onto the scope root itself.
+ if (selector === ":root" || selector === "html" || selector === "body") return prefix;
+ if (selector.startsWith(prefix)) return selector;
+ return prefixedSelector;
+ },
+ }),
+]).process(readFileSync(rawOut, "utf8"), { from: rawOut });
+
+mkdirSync(dirname(finalOut), { recursive: true });
+writeFileSync(finalOut, result.css);
+console.log(`bsk-ui sheet: ${result.css.length} bytes -> ${finalOut}`);
diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
index 4df7550..5095811 100644
--- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
+++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
@@ -1,8 +1,10 @@
-/* Observation overlay: floating card + collapsed capsule. Every color,
- surface, shadow, and label style rides the dsh --dsw-alias-* semantic
- tokens (dsh 0.1 ships no spacing/radius scales — those stay px, same as
- dsh's own components). The shell overlay layer is click-through, so the
- card and capsule opt back into pointer events themselves. */
+/* Observation overlay skin, BrowserSkill family: colors/radius ride the BSK
+ design tokens (--card/--border/--primary/--destructive/--ring/--radius,
+ defined on the .bsk-obs scope root by bsk-tokens.nomodule.css — values
+ mirrored from packages/ui/src/styles/tailwind.css). Layout-only here;
+ component chrome (buttons etc.) comes from @browser-skill/ui plus the
+ scope-prefixed utility sheet. The shell overlay layer is click-through, so
+ the card and capsule opt back into pointer events themselves. */
.card {
position: fixed;
@@ -10,11 +12,12 @@
display: flex;
flex-direction: column;
pointer-events: auto;
- background: var(--dsw-alias-bg-overlay);
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 10px;
- box-shadow: var(--dsw-shadow-lv2);
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 18%);
overflow: hidden;
+ color: var(--card-foreground);
}
.body {
@@ -22,9 +25,9 @@
flex-direction: column;
height: 100%;
min-height: 0;
- font: var(--dsw-font-base-16, inherit);
font-size: 12px;
- color: var(--dsw-alias-label-primary);
+ background: var(--card);
+ color: var(--card-foreground);
}
.header {
@@ -35,34 +38,7 @@
cursor: move;
user-select: none;
touch-action: none;
- border-bottom: 1px solid var(--dsw-alias-border-l2);
-}
-
-.dot {
- flex: none;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--dsw-alias-label-tertiary);
-}
-
-.dot[data-state="active"] {
- background: var(--dsw-alias-state-success-primary);
- animation: bsk-obs-pulse 1.6s ease-in-out infinite;
-}
-
-.dot[data-state="error"] {
- background: var(--dsw-alias-state-error-primary);
-}
-@keyframes bsk-obs-pulse {
- 0%,
- 100% {
- opacity: 1;
- }
-
- 50% {
- opacity: 0.35;
- }
+ border-bottom: 1px solid var(--border);
}
.status-text {
@@ -81,15 +57,15 @@
border: none;
background: none;
padding: 2px;
- border-radius: 4px;
+ border-radius: calc(var(--radius) - 6px);
font: inherit;
- color: var(--dsw-alias-label-tertiary);
+ color: var(--muted-foreground);
cursor: pointer;
}
.icon-button:hover {
- color: var(--dsw-alias-label-secondary);
- background: var(--dsw-alias-interactive-bg-hover);
+ color: var(--foreground);
+ background: var(--accent);
}
.stage {
@@ -99,7 +75,7 @@
display: flex;
align-items: center;
justify-content: center;
- background: var(--dsw-alias-bg-base);
+ background: var(--background);
overflow: hidden;
}
@@ -120,7 +96,7 @@
}
.placeholder {
- color: var(--dsw-alias-label-tertiary);
+ color: var(--muted-foreground);
font-size: 12px;
}
@@ -134,9 +110,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
- color: var(--dsw-alias-state-warn-primary);
- border: 1px solid var(--dsw-alias-state-warn-primary);
- background: var(--dsw-alias-bg-overlay);
+ color: oklch(0.7 0.19 60);
+ border: 1px solid oklch(0.7 0.19 60);
+ background: var(--card);
}
.actions {
@@ -145,17 +121,27 @@
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
- border-top: 1px solid var(--dsw-alias-border-l2);
+ border-top: 1px solid var(--border);
}
-/* Destructive action, styled like the shell's own danger affordances. */
-.interrupt {
- color: var(--dsw-alias-state-error-primary);
- border-color: var(--dsw-alias-state-error-primary);
+.interrupt-wrap {
+ position: relative;
+ display: inline-flex;
}
-.interrupt:not(:disabled):hover {
- background: var(--dsw-alias-interactive-bg-hover-danger);
+.hint {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 0;
+ width: 220px;
+ padding: 6px 8px;
+ border-radius: calc(var(--radius) - 4px);
+ background: var(--card);
+ border: 1px solid var(--border);
+ color: var(--muted-foreground);
+ font-size: 11px;
+ box-shadow: 0 4px 14px rgb(0 0 0 / 16%);
+ z-index: 41;
}
.resize-handle {
@@ -175,8 +161,8 @@
bottom: 3px;
width: 7px;
height: 7px;
- border-right: 2px solid var(--dsw-alias-label-tertiary);
- border-bottom: 2px solid var(--dsw-alias-label-tertiary);
+ border-right: 2px solid var(--muted-foreground);
+ border-bottom: 2px solid var(--muted-foreground);
}
.capsule {
@@ -190,16 +176,16 @@
padding: 6px 12px;
pointer-events: auto;
border-radius: 999px;
- border: 1px solid var(--dsw-alias-border-l2);
- background: var(--dsw-alias-bg-overlay);
- box-shadow: var(--dsw-shadow-lv1);
+ border: 1px solid var(--border);
+ background: var(--card);
+ box-shadow: 0 4px 14px rgb(0 0 0 / 16%);
font-size: 12px;
- color: var(--dsw-alias-label-secondary);
+ color: var(--muted-foreground);
cursor: pointer;
}
.capsule:hover {
- background: var(--dsw-alias-interactive-bg-hover);
+ background: var(--accent);
}
.strip {
@@ -207,22 +193,22 @@
gap: 6px;
padding: 4px 8px;
overflow-x: auto;
- border-top: 1px solid var(--dsw-alias-border-l2);
+ border-top: 1px solid var(--border);
}
.strip-item {
position: relative;
flex: none;
- border-radius: 6px;
- border: 1px solid var(--dsw-alias-border-l2);
+ border-radius: calc(var(--radius) - 6px);
+ border: 1px solid var(--border);
}
.strip-item[data-focused] {
- border-color: var(--dsw-alias-brand-primary);
+ border-color: var(--primary);
}
.strip-item[data-state="error"] {
- border-color: var(--dsw-alias-state-error-primary);
+ border-color: var(--destructive);
}
.strip-item[data-state="dead"] {
@@ -235,16 +221,16 @@
gap: 4px;
padding: 3px 6px;
border: none;
- border-radius: 6px;
+ border-radius: calc(var(--radius) - 6px);
background: none;
font: inherit;
font-size: 11px;
- color: var(--dsw-alias-label-secondary);
+ color: var(--muted-foreground);
cursor: pointer;
}
.strip-main:hover {
- background: var(--dsw-alias-interactive-bg-hover);
+ background: var(--accent);
}
.strip-thumb {
@@ -252,7 +238,7 @@
height: 20px;
overflow: hidden;
border-radius: 3px;
- background: var(--dsw-alias-bg-base);
+ background: var(--background);
display: inline-flex;
}
@@ -274,7 +260,7 @@
}
.pin-badge {
- color: var(--dsw-alias-brand-primary);
+ color: var(--primary);
}
.strip-interrupt {
@@ -287,9 +273,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
- border: 1px solid var(--dsw-alias-state-error-primary);
- background: var(--dsw-alias-bg-overlay);
- color: var(--dsw-alias-state-error-primary);
+ border: 1px solid var(--destructive);
+ background: var(--card);
+ color: var(--destructive);
padding: 0;
cursor: pointer;
}
@@ -303,14 +289,10 @@
.capsule:focus-visible,
.strip-main:focus-visible,
.strip-interrupt:focus-visible {
- outline: 2px solid var(--dsw-alias-brand-primary);
+ outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
- .dot[data-state="active"] {
- animation: none;
- }
-
.thumb {
animation: none;
}
diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
index a8aac5a..5959732 100644
--- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
+++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
@@ -3,19 +3,31 @@
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
// content to a native Document PiP window (user gesture required by the
// browser). Multi-session renders a meeting-style strip under the focus view.
-// Visuals ride dsh's shared primitives and --dsw-alias-* tokens throughout,
-// so the card reads as a native part of the shell, not a foreign widget.
+// Visuals follow the BrowserSkill product family: @browser-skill/ui components
+// and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own
+// surface without leaking styles into (or inheriting themes from) the shell.
+
+import { Button, cn } from "@browser-skill/ui";
+import {
+ RiArrowDownSLine,
+ RiErrorWarningLine,
+ RiPictureInPicture2Line,
+ RiPushpinFill,
+ RiStopMiniFill,
+} from "@remixicon/react";
+
+// remixicon's component types target @types/react 19 while the dsh shell
+// runs react 18 — a compile-time-only recast keeps the 18 typecheck happy.
+type IconComponent = (props: { size?: number | string; className?: string }) => ReactNode;
+const asIcon = (component: unknown): IconComponent => component as IconComponent;
+const IconStop = asIcon(RiStopMiniFill);
+const IconPip = asIcon(RiPictureInPicture2Line);
+const IconDown = asIcon(RiArrowDownSLine);
+const IconWarn = asIcon(RiErrorWarningLine);
+const IconPin = asIcon(RiPushpinFill);
import {
- Button,
- IconChevronDownOutline14,
- IconRightUpOutline16,
- IconStopFill16,
- IconWarningOutline16,
- StateDot,
- Tooltip,
-} from "@deepseek-ai/dsh-client-ui-primitives";
-import {
+ type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -94,19 +106,23 @@ export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
return obs.action === "idle" ? "idle" : "active";
}
-function dotStateOf(
- state: "active" | "idle" | "error" | "dead",
-): "done" | "ongoing" | "error" | "warning" {
- switch (state) {
- case "active":
- return "ongoing";
- case "error":
- return "error";
- case "dead":
- return "warning";
- default:
- return "done";
- }
+/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
+function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
+ const color =
+ state === "active"
+ ? "bg-emerald-500"
+ : state === "error"
+ ? "bg-red-500"
+ : state === "dead"
+ ? "bg-amber-500"
+ : "bg-muted-foreground/40";
+ return (
+
+ );
}
/** One strip item: mini frame, id, status dot, hover interrupt, pin toggle. */
@@ -149,21 +165,8 @@ function StripItem(props: {
)}
{obs.sessionId}
-
- {pinned ? (
-
- ) : null}
+
+ {pinned ? : null}
{hover && !pinned && obs.dead !== true ? (
) : null}
@@ -211,7 +214,8 @@ function OverlayBody(props: {
onHeaderPointerDown,
} = props;
const [interrupting, setInterrupting] = useState(false);
- // One-time semantics hint: a shared Tooltip that retires after first use.
+ // One-time semantics hint bubble, retired after the first interrupt.
+ const [hintOpen, setHintOpen] = useState(false);
const [hintSeen, setHintSeen] = useState(false);
const thumbId = focus?.thumbnailAttachmentId;
@@ -241,14 +245,14 @@ function OverlayBody(props: {
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return (
-
+
-
+
{statusText}
{onCollapse !== undefined ? (
) : null}
@@ -280,7 +284,7 @@ function OverlayBody(props: {
)}
{thumb?.status === "error" ? (
-
+
) : null}
@@ -299,34 +303,41 @@ function OverlayBody(props: {
) : null}
-
{
+ if (!hintSeen) setHintOpen(true);
+ }}
+ onPointerLeave={() => setHintOpen(false)}
>
-
- }
- disabled={!canInterrupt}
- aria-label="Interrupt the current browser action"
- onClick={onInterrupt}
- >
- {interrupting ? "Interrupting…" : "Interrupt"}
-
-
-
+
+ {/* BSK ships no shared tooltip component; this bubble follows the BSK
+ card spec (bg-card, border, 12px radius, caption text). */}
+ {hintOpen && !hintSeen ? (
+
+ Interrupt stops only the current browser action — the agent run continues (use the
+ chat Stop button to halt the run).
+
+ ) : null}
+
{onPopOut !== undefined ? (
}
aria-label="Pop out into a mini window"
onClick={onPopOut}
>
+
Pop out
) : null}
@@ -457,12 +468,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
return (