mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin-browserskill): compact overlay toolbar and four-corner resize
Use icon-only actions with a short hover tip, swap the interrupt glyph to RiStopCircleLine, and let every corner resize the card without a grip icon.
This commit is contained in:
@@ -111,7 +111,7 @@ shell's theme cannot bleed back in.
|
||||
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
||||
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
||||
stage while the next one loads, and is kept on errors so the card does not flash),
|
||||
and the action row (Interrupt + Pop out).
|
||||
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
|
||||
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
|
||||
as the chat Stop button — the current action fails, the agent run may continue). Strip items
|
||||
carry their own hover interrupt button.
|
||||
@@ -120,8 +120,9 @@ shell's theme cannot bleed back in.
|
||||
again to release); errored sessions get a red edge without stealing focus; sessions the daemon
|
||||
lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys
|
||||
the interrupt button until captures recover.
|
||||
- **Drag & resize**: drag the header to move the card, drag the corner handle to resize
|
||||
(min 240×180, max 80% of the viewport); both are remembered for the page lifetime.
|
||||
- **Drag & resize**: drag the header to move the card; drag any of the four
|
||||
corners to resize (min 240×180, max 80% of the viewport; no visible grip).
|
||||
Both are remembered for the page lifetime.
|
||||
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
|
||||
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
|
||||
in-page card with state intact. Browsers without Document PiP simply hide the button.
|
||||
|
||||
@@ -118,53 +118,116 @@
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
/* A little inset so icons are not flush with the resize corners. */
|
||||
padding: 4px 8px;
|
||||
min-height: 32px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.interrupt-wrap {
|
||||
.tool-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tool-danger {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Hover the wrap (not just :hover on the button) so the tooltip target and
|
||||
the left-corner resize overlay still light the icon up. */
|
||||
.tool-wrap:hover .tool-button:not(:disabled) {
|
||||
color: var(--foreground);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tool-wrap:hover .tool-danger:not(:disabled) {
|
||||
color: var(--destructive);
|
||||
background: color-mix(in oklch, var(--destructive) 18%, var(--card));
|
||||
}
|
||||
|
||||
.tool-wrap:hover .tool-button:disabled {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
width: 220px;
|
||||
padding: 6px 8px;
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
padding: 4px 6px;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 16%);
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
.hint[data-align="end"] {
|
||||
left: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: "";
|
||||
/* Invisible hit targets — no grip glyph; all four corners resize. */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 2px solid var(--muted-foreground);
|
||||
border-bottom: 2px solid var(--muted-foreground);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
touch-action: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handle[data-corner="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle[data-corner="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle[data-corner="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle[data-corner="se"] {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.capsule {
|
||||
@@ -288,6 +351,7 @@
|
||||
}
|
||||
|
||||
.icon-button:focus-visible,
|
||||
.tool-button:focus-visible,
|
||||
.capsule:focus-visible,
|
||||
.strip-main:focus-visible,
|
||||
.strip-interrupt:focus-visible {
|
||||
|
||||
@@ -7,20 +7,20 @@
|
||||
// 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 { cn } from "@browser-skill/ui";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiErrorWarningLine,
|
||||
RiPictureInPicture2Line,
|
||||
RiPushpinFill,
|
||||
RiStopMiniFill,
|
||||
RiStopCircleLine,
|
||||
} 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 IconStop = asIcon(RiStopCircleLine);
|
||||
const IconPip = asIcon(RiPictureInPicture2Line);
|
||||
const IconDown = asIcon(RiArrowDownSLine);
|
||||
const IconWarn = asIcon(RiErrorWarningLine);
|
||||
@@ -82,6 +82,31 @@ function clampPos(pos: Point, size: Size, viewport: Size): Point {
|
||||
};
|
||||
}
|
||||
|
||||
export type ResizeCorner = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
/** Grow/shrink from one corner, keeping the opposite corner planted. */
|
||||
export function applyResize(
|
||||
base: Point & Size,
|
||||
corner: ResizeCorner,
|
||||
dx: number,
|
||||
dy: number,
|
||||
viewport: Size,
|
||||
): { pos: Point; size: Size } {
|
||||
const nextW = corner === "ne" || corner === "se" ? base.w + dx : base.w - dx;
|
||||
const nextH = corner === "sw" || corner === "se" ? base.h + dy : base.h - dy;
|
||||
const size = clampSize({ w: nextW, h: nextH }, viewport);
|
||||
const x = corner === "nw" || corner === "sw" ? base.x + base.w - size.w : base.x;
|
||||
const y = corner === "nw" || corner === "ne" ? base.y + base.h - size.h : base.y;
|
||||
return { pos: clampPos({ x, y }, size, viewport), size };
|
||||
}
|
||||
|
||||
const CORNER_LABEL: Record<ResizeCorner, string> = {
|
||||
nw: "top left",
|
||||
ne: "top right",
|
||||
sw: "bottom left",
|
||||
se: "bottom right",
|
||||
};
|
||||
|
||||
function formatElapsed(sinceMs: number, nowMs: number): string {
|
||||
const total = Math.max(0, Math.floor((nowMs - sinceMs) / 1000));
|
||||
const mm = String(Math.floor(total / 60)).padStart(2, "0");
|
||||
@@ -106,6 +131,41 @@ export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
|
||||
return obs.action === "idle" ? "idle" : "active";
|
||||
}
|
||||
|
||||
/** Compact toolbar icon: no label, hover bubble for the name / semantics. */
|
||||
function IconAction(props: {
|
||||
label: string;
|
||||
hint: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
align?: "start" | "end";
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={css["tool-wrap"]}
|
||||
onPointerEnter={() => setOpen(true)}
|
||||
onPointerLeave={() => setOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(css["tool-button"], props.danger === true && css["tool-danger"])}
|
||||
disabled={props.disabled}
|
||||
aria-label={props.label}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
{open ? (
|
||||
<span className={css.hint} data-align={props.align} role="tooltip">
|
||||
{props.hint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
||||
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
||||
const color =
|
||||
@@ -179,7 +239,7 @@ function StripItem(props: {
|
||||
void store.interrupt(obs.sessionId);
|
||||
}}
|
||||
>
|
||||
<IconStop size={9} />
|
||||
<IconStop size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -214,9 +274,6 @@ function OverlayBody(props: {
|
||||
onHeaderPointerDown,
|
||||
} = props;
|
||||
const [interrupting, setInterrupting] = useState(false);
|
||||
// One-time semantics hint bubble, retired after the first interrupt.
|
||||
const [hintOpen, setHintOpen] = useState(false);
|
||||
const [hintSeen, setHintSeen] = useState(false);
|
||||
|
||||
const thumbId = focus?.thumbnailAttachmentId;
|
||||
useEffect(() => {
|
||||
@@ -236,7 +293,6 @@ function OverlayBody(props: {
|
||||
focus.dead !== true;
|
||||
const onInterrupt = (): void => {
|
||||
if (!canInterrupt || focus === undefined) return;
|
||||
setHintSeen(true);
|
||||
setInterrupting(true);
|
||||
void store.interrupt(focus.sessionId).finally(() => setInterrupting(false));
|
||||
};
|
||||
@@ -306,43 +362,24 @@ function OverlayBody(props: {
|
||||
</div>
|
||||
) : null}
|
||||
<div className={css.actions}>
|
||||
<span
|
||||
className={css["interrupt-wrap"]}
|
||||
onPointerEnter={() => {
|
||||
if (!hintSeen) setHintOpen(true);
|
||||
}}
|
||||
onPointerLeave={() => setHintOpen(false)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/60 hover:bg-destructive/10"
|
||||
<IconAction
|
||||
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
||||
hint="Stop the current browser action."
|
||||
disabled={!canInterrupt}
|
||||
aria-label="Interrupt the current browser action"
|
||||
danger
|
||||
onClick={onInterrupt}
|
||||
>
|
||||
<IconStop size={14} />
|
||||
{interrupting ? "Interrupting…" : "Interrupt"}
|
||||
</Button>
|
||||
{/* BSK ships no shared tooltip component; this bubble follows the BSK
|
||||
card spec (bg-card, border, 12px radius, caption text). */}
|
||||
{hintOpen && !hintSeen ? (
|
||||
<span className={css.hint} role="tooltip">
|
||||
Interrupt stops only the current browser action — the agent run continues (use the
|
||||
chat Stop button to halt the run).
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<IconStop size={16} />
|
||||
</IconAction>
|
||||
{onPopOut !== undefined ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Pop out into a mini window"
|
||||
<IconAction
|
||||
label="Pop out into a mini window"
|
||||
hint="Pop out into a mini window"
|
||||
align="end"
|
||||
onClick={onPopOut}
|
||||
>
|
||||
<IconPip size={14} />
|
||||
Pop out
|
||||
</Button>
|
||||
<IconPip size={16} />
|
||||
</IconAction>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,6 +407,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||
const dragRef = useRef<{
|
||||
kind: "move" | "resize";
|
||||
corner?: ResizeCorner;
|
||||
startX: number;
|
||||
startY: number;
|
||||
base: Point & Size;
|
||||
@@ -407,8 +445,10 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
viewport(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSize(clampSize({ w: drag.base.w + dx, h: drag.base.h + dy }, viewport()));
|
||||
} else if (drag.corner !== undefined) {
|
||||
const next = applyResize(drag.base, drag.corner, dx, dy, viewport());
|
||||
setPos(next.pos);
|
||||
setSize(next.size);
|
||||
}
|
||||
}, []);
|
||||
const onPointerUp = useCallback(() => {
|
||||
@@ -417,19 +457,55 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
}, [onPointerMove]);
|
||||
|
||||
const beginDrag = (kind: "move" | "resize") => (event: ReactPointerEvent) => {
|
||||
const cardOrigin = (): Point & Size => {
|
||||
const vp = viewport();
|
||||
return {
|
||||
x: pos?.x ?? vp.w - size.w - EDGE_MARGIN,
|
||||
y: pos?.y ?? TOP_OFFSET,
|
||||
w: size.w,
|
||||
h: size.h,
|
||||
};
|
||||
};
|
||||
|
||||
const beginMove = (event: ReactPointerEvent) => {
|
||||
event.preventDefault();
|
||||
const rect = (
|
||||
event.currentTarget.closest("[data-obs-card]") as HTMLElement | null
|
||||
)?.getBoundingClientRect();
|
||||
const origin = cardOrigin();
|
||||
const base = {
|
||||
x: rect?.left ?? pos?.x ?? viewport().w - size.w - EDGE_MARGIN,
|
||||
y: rect?.top ?? pos?.y ?? TOP_OFFSET,
|
||||
w: rect?.width ?? size.w,
|
||||
h: rect?.height ?? size.h,
|
||||
x: rect?.left ?? origin.x,
|
||||
y: rect?.top ?? origin.y,
|
||||
w: rect !== undefined && rect.width > 0 ? rect.width : origin.w,
|
||||
h: rect !== undefined && rect.height > 0 ? rect.height : origin.h,
|
||||
};
|
||||
dragRef.current = { kind, startX: event.clientX, startY: event.clientY, base };
|
||||
if (kind === "move") setPos({ x: base.x, y: base.y });
|
||||
dragRef.current = { kind: "move", startX: event.clientX, startY: event.clientY, base };
|
||||
setPos({ x: base.x, y: base.y });
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
const beginResize = (corner: ResizeCorner) => (event: ReactPointerEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = (
|
||||
event.currentTarget.closest("[data-obs-card]") as HTMLElement | null
|
||||
)?.getBoundingClientRect();
|
||||
const origin = cardOrigin();
|
||||
const base = {
|
||||
x: rect?.left ?? origin.x,
|
||||
y: rect?.top ?? origin.y,
|
||||
w: rect !== undefined && rect.width > 0 ? rect.width : origin.w,
|
||||
h: rect !== undefined && rect.height > 0 ? rect.height : origin.h,
|
||||
};
|
||||
dragRef.current = {
|
||||
kind: "resize",
|
||||
corner,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
base,
|
||||
};
|
||||
setPos({ x: base.x, y: base.y });
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
@@ -463,7 +539,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
inPip={pipWindow !== null}
|
||||
onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined}
|
||||
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
||||
onHeaderPointerDown={pipWindow === null ? beginDrag("move") : undefined}
|
||||
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -499,34 +575,38 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
return (
|
||||
<div className={cn(css.card, "bsk-obs")} style={style} data-obs-card data-testid="obs-card">
|
||||
{body}
|
||||
{(["nw", "ne", "sw", "se"] as const).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
className={css["resize-handle"]}
|
||||
data-testid="obs-resize"
|
||||
aria-label="Resize overlay"
|
||||
data-corner={corner}
|
||||
data-testid={`obs-resize-${corner}`}
|
||||
aria-label={`Resize overlay from the ${CORNER_LABEL[corner]}`}
|
||||
role="separator"
|
||||
aria-valuenow={size.w}
|
||||
aria-valuetext={`${Math.round(size.w)} by ${Math.round(size.h)} pixels`}
|
||||
aria-valuemin={MIN_SIZE.w}
|
||||
aria-valuemax={Math.round(viewport().w * 0.8)}
|
||||
tabIndex={0}
|
||||
onPointerDown={beginDrag("resize")}
|
||||
onKeyDown={(event) => {
|
||||
tabIndex={corner === "se" ? 0 : -1}
|
||||
onPointerDown={beginResize(corner)}
|
||||
onKeyDown={
|
||||
corner === "se"
|
||||
? (event) => {
|
||||
const step = 16;
|
||||
const delta =
|
||||
event.key === "ArrowRight"
|
||||
? { w: step, h: 0 }
|
||||
: event.key === "ArrowLeft"
|
||||
? { w: -step, h: 0 }
|
||||
: event.key === "ArrowDown"
|
||||
? { w: 0, h: step }
|
||||
: event.key === "ArrowUp"
|
||||
? { w: 0, h: -step }
|
||||
: undefined;
|
||||
if (delta === undefined) return;
|
||||
const dx =
|
||||
event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0;
|
||||
const dy =
|
||||
event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0;
|
||||
if (dx === 0 && dy === 0) return;
|
||||
event.preventDefault();
|
||||
setSize(clampSize({ w: size.w + delta.w, h: size.h + delta.h }, viewport()));
|
||||
}}
|
||||
const next = applyResize(cardOrigin(), "se", dx, dy, viewport());
|
||||
setPos(next.pos);
|
||||
setSize(next.size);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
// ObservationOverlay: visibility lifecycle, focus view, interrupt interaction,
|
||||
// one-time hint, drag move/resize with clamps, collapse capsule, and PiP
|
||||
// hover tooltip, drag move/resize from every corner with clamps, collapse capsule, and PiP
|
||||
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
||||
// (act-flushing), not vi.waitFor, when asserting store-driven UI updates.
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||
import type { SessionObservation } from "../../src/observation";
|
||||
|
||||
@@ -130,16 +130,18 @@ describe("ObservationOverlay", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the semantics hint once", async () => {
|
||||
it("shows a hover tooltip on the interrupt icon and keeps the button icon-only", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
const button = await screen.findByRole("button", { name: /Interrupt the current/ });
|
||||
fireEvent.pointerEnter(button);
|
||||
await screen.findByRole("tooltip");
|
||||
fireEvent.pointerLeave(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.pointerEnter(button);
|
||||
expect(button.textContent ?? "").not.toMatch(/Interrupt/i);
|
||||
fireEvent.pointerEnter(button.parentElement as HTMLElement);
|
||||
const tip = await screen.findByRole("tooltip");
|
||||
expect(tip.textContent).toBe("Stop the current browser action.");
|
||||
fireEvent.pointerLeave(button.parentElement as HTMLElement);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
fireEvent.pointerEnter(button.parentElement as HTMLElement);
|
||||
expect(screen.getByRole("tooltip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses to a capsule and expands back", async () => {
|
||||
@@ -159,17 +161,38 @@ describe("ObservationOverlay", () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
const card = await screen.findByTestId("obs-card");
|
||||
card.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
right: 320,
|
||||
bottom: 240,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
expect(Number.parseFloat(card.style.width)).toBe(320);
|
||||
fireEvent.pointerDown(screen.getByTestId("obs-resize"), { clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(document, { clientX: 40, clientY: 40 });
|
||||
fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(document, { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerUp(document);
|
||||
expect(Number.parseFloat(card.style.width)).toBe(240); // min width clamp
|
||||
fireEvent.pointerDown(screen.getByTestId("obs-resize"), { clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(document, { clientX: 5000, clientY: 5000 });
|
||||
fireEvent.pointerUp(document);
|
||||
expect(Number.parseFloat(card.style.width)).toBe(window.innerWidth * 0.8);
|
||||
});
|
||||
|
||||
it("exposes an invisible resize hit target on every corner", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
await screen.findByTestId("obs-card");
|
||||
for (const corner of ["nw", "ne", "sw", "se"]) {
|
||||
expect(screen.getByTestId(`obs-resize-${corner}`)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("moves the card with header drags, clamped to the viewport", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
@@ -315,3 +338,38 @@ describe("ObservationOverlay", () => {
|
||||
expect(screen.getByText(/s2 · idle/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResize", () => {
|
||||
const base = { x: 200, y: 100, w: 320, h: 240 };
|
||||
const viewport = { w: 1000, h: 800 };
|
||||
|
||||
it("grows the planted opposite corner from each handle", () => {
|
||||
expect(applyResize(base, "se", 20, 10, viewport)).toEqual({
|
||||
pos: { x: 200, y: 100 },
|
||||
size: { w: 340, h: 250 },
|
||||
});
|
||||
expect(applyResize(base, "nw", -20, -10, viewport)).toEqual({
|
||||
pos: { x: 180, y: 90 },
|
||||
size: { w: 340, h: 250 },
|
||||
});
|
||||
expect(applyResize(base, "ne", 20, -10, viewport)).toEqual({
|
||||
pos: { x: 200, y: 90 },
|
||||
size: { w: 340, h: 250 },
|
||||
});
|
||||
expect(applyResize(base, "sw", -20, 10, viewport)).toEqual({
|
||||
pos: { x: 180, y: 100 },
|
||||
size: { w: 340, h: 250 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps to the minimum size without drifting the planted corner", () => {
|
||||
expect(applyResize(base, "se", -200, -200, viewport)).toEqual({
|
||||
pos: { x: 200, y: 100 },
|
||||
size: { w: 240, h: 180 },
|
||||
});
|
||||
expect(applyResize(base, "nw", 200, 200, viewport)).toEqual({
|
||||
pos: { x: 280, y: 160 },
|
||||
size: { w: 240, h: 180 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user