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)} > - - - - + + {/* 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 ? ( ) : null} @@ -457,12 +468,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) return (