From bb86e626a24b3dd6f5bcc05171b99524824b2c5d Mon Sep 17 00:00:00 2001 From: zhangdagou Date: Sun, 8 Nov 2026 22:33:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(workbench):=20=E4=BC=98=E5=8C=96=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=E6=82=AC=E5=81=9C=E3=80=81=E9=80=89=E4=B8=AD=E5=92=8C?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=A1=86=E6=A0=B7=E5=BC=8F=20(#2094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar buttons used a larger radius than the rest of the IDE, and their tooltips opened below the button, often over neighbouring stripe buttons. Stripe buttons now use a 6px radius, and their tooltips open toward the inside of the window, with a dark background in light themes. Siblings covered: every theme and plugin theme falls back to its base tooltip tokens, long tooltips flip correctly at the window edge, the shortcut hint stays readable, and the More button gets the same tooltip. Co-authored-by: PLZY <41029728+PLZY@users.noreply.github.com> --- docs/plugins/themes.md | 10 +- e2e/stripeTooltip.spec.ts | 128 ++++++++++++++++++ .../themes/harbor-night.theme.json | 4 + src/styles/tokens.css | 25 +++- src/ui/Tooltip.module.css | 12 ++ src/ui/Tooltip.test.tsx | 27 ++++ src/ui/Tooltip.tsx | 22 ++- .../toolwindows/ToolWindows.module.css | 15 +- src/workbench/toolwindows/ToolWindows.tsx | 2 + 9 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 e2e/stripeTooltip.spec.ts diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 5a43da1c7..e6813bb2c 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -103,6 +103,10 @@ Three things still layer on top of it: The selection is remembered. When the plugin is disabled or uninstalled the built-in theme returns. +Stripe tooltips use `--bg-stripe-tooltip`, `--fg-stripe-tooltip`, +`--border-stripe-tooltip` and `--shadow-stripe-tooltip`. A plugin that omits +these tokens inherits its corresponding base tooltip tokens, including in light themes. + ## Building, packaging, installing There is no build step. The package *is* the folder. @@ -324,7 +328,7 @@ a token. -243 themeable tokens: 121 in the UI theme file, 122 in the editor colour scheme file. +247 themeable tokens: 125 in the UI theme file, 122 in the editor colour scheme file. ### Surfaces (UI theme) @@ -571,6 +575,10 @@ a token. | Token | Islands Dark | Islands Light | |---|---|---| +| `--bg-stripe-tooltip` | `var(--bg-tooltip)` | `#27282e` | +| `--fg-stripe-tooltip` | `var(--fg-tooltip)` | `#ffffff` | +| `--border-stripe-tooltip` | `var(--border-tooltip)` | `#383a42` | +| `--shadow-stripe-tooltip` | `var(--shadow-tooltip)` | `0 2px 8px rgba(0, 0, 0, 0.24)` | | `--bg-tooltip` | `#33353b` | `#ffffff` | | `--fg-tooltip` | `#d1d3d9` | `#000000` | | `--border-tooltip` | `#33353b` | `#d1d3d9` | diff --git a/e2e/stripeTooltip.spec.ts b/e2e/stripeTooltip.spec.ts new file mode 100644 index 000000000..a70e4b7ed --- /dev/null +++ b/e2e/stripeTooltip.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from "./fixtures"; + +test("stripe tooltips stay inside the viewport with a gap on either side and at corners", async ({ + workbench: page, +}) => { + await page.setViewportSize({ width: 520, height: 760 }); + await page.evaluate(() => { + const button = document.createElement("button"); + button.id = "tooltip-anchor"; + button.dataset.tooltipVerbatim = ""; + button.dataset.tooltipShortcut = "⌘1"; + button.style.cssText = "position:fixed;width:40px;height:40px;z-index:2000"; + document.body.append(button); + }); + const button = page.locator("#tooltip-anchor"); + const tooltip = page.getByRole("tooltip"); + for (const side of ["left", "right"]) { + for (const top of [0, 360, 720]) { + for (const long of [false, true]) { + await page.mouse.move(260, 200); + await button.evaluate( + (button, { side, top, long }) => { + button.dataset.tooltipStripe = side; + button.dataset.tooltip = long ? "A long tool window title ".repeat(12) : "Project"; + button.style.left = side === "left" ? "0" : "480px"; + button.style.top = `${top}px`; + }, + { side, top, long }, + ); + await button.hover(); + await expect(tooltip).toHaveAttribute("data-placed", "true"); + const a = (await button.boundingBox())!; + const t = (await tooltip.boundingBox())!; + expect(t.x).toBeGreaterThanOrEqual(6); + expect(t.y).toBeGreaterThanOrEqual(6); + expect(t.x + t.width).toBeLessThanOrEqual(514); + expect(t.y + t.height).toBeLessThanOrEqual(754); + expect( + t.x >= a.x + a.width + 5 || + t.x + t.width <= a.x - 5 || + t.y >= a.y + a.height + 5 || + t.y + t.height <= a.y - 5, + ).toBe(true); + if (!long) expect(side === "left" ? t.x > a.x : t.x < a.x).toBe(true); + await page.keyboard.press("Escape"); + await expect(tooltip).toHaveCount(0); + } + } + } +}); + +test("stripe themes inherit base tooltip tokens and keep shortcut text readable", async ({ workbench: page }) => { + const button = page.locator('[data-tooltip-stripe="left"][data-tooltip-shortcut]').first(); + for (const theme of ["dark", "light", "dark-hc", "solarized-light", "solarized-dark", "one-light", "one-dark"]) { + await page.mouse.move(700, 400); + await page.evaluate((theme) => { + const root = document.documentElement; + root.dataset.theme = theme.endsWith("light") ? "light" : theme === "dark-hc" ? "dark-hc" : "dark"; + if (theme.includes("-light") || ["solarized-dark", "one-dark"].includes(theme)) root.dataset.colorTheme = theme; + else delete root.dataset.colorTheme; + }, theme); + await button.hover(); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toHaveAttribute("data-placed", "true"); + const ratio = await tooltip.evaluate((tooltip) => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d")!; + const background = getComputedStyle(tooltip).backgroundColor; + const foreground = getComputedStyle(tooltip.children[1]!).color; + const luminance = () => { + const rgb = [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3).map((v) => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return rgb[0]! * 0.2126 + rgb[1]! * 0.7152 + rgb[2]! * 0.0722; + }; + ctx.fillStyle = background; + ctx.fillRect(0, 0, 1, 1); + const bg = luminance(); + ctx.fillStyle = foreground; + ctx.fillRect(0, 0, 1, 1); + const fg = luminance(); + return (Math.max(bg, fg) + 0.05) / (Math.min(bg, fg) + 0.05); + }); + if (["dark", "light", "dark-hc"].includes(theme)) expect(ratio, theme).toBeGreaterThanOrEqual(4.5); + if (theme !== "light") { + const aliases = await page.evaluate(() => { + const style = getComputedStyle(document.documentElement); + return ["bg", "fg", "border", "shadow"].every( + (role) => style.getPropertyValue(`--${role}-stripe-tooltip`) === style.getPropertyValue(`--${role}-tooltip`), + ); + }); + expect(aliases, theme).toBe(true); + } + await page.keyboard.press("Escape"); + } + for (const kind of ["light", "dark"]) { + await page.mouse.move(700, 400); + await page.evaluate(async (kind) => { + const root = document.documentElement; + delete root.dataset.colorTheme; + root.dataset.theme = kind; + root.dataset.pluginTheme = "test/base-tooltip"; + const path = "/src/features/plugins/bridge.ts"; + const { pluginThemeCss } = await import(/* @vite-ignore */ path); + const style = document.createElement("style"); + style.id = "test-plugin-theme"; + style.textContent = pluginThemeCss({ + kind, + tokens: { + "--bg-tooltip": "#183452", + "--fg-tooltip": "#ffffff", + "--border-tooltip": "#abcdef", + "--shadow-tooltip": "0 2px 8px #00000040", + }, + }); + document.body.append(style); + }, kind); + await button.hover(); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toHaveCSS("background-color", "rgb(24, 52, 82)"); + await expect(tooltip).toHaveCSS("color", "rgb(255, 255, 255)"); + await expect(tooltip).toHaveCSS("border-top-color", "rgb(171, 205, 239)"); + await page.evaluate(() => document.querySelector("#test-plugin-theme")!.remove()); + await page.keyboard.press("Escape"); + } +}); diff --git a/plugins/official/harbor-night/themes/harbor-night.theme.json b/plugins/official/harbor-night/themes/harbor-night.theme.json index ed7d0886a..821c931d9 100644 --- a/plugins/official/harbor-night/themes/harbor-night.theme.json +++ b/plugins/official/harbor-night/themes/harbor-night.theme.json @@ -83,6 +83,10 @@ "--traffic-off-border": "#363e49", // Additive + "--bg-stripe-tooltip": "var(--bg-tooltip)", + "--fg-stripe-tooltip": "var(--fg-tooltip)", + "--border-stripe-tooltip": "var(--border-tooltip)", + "--shadow-stripe-tooltip": "var(--shadow-tooltip)", "--bg-tooltip": "#2a333f", "--fg-tooltip": "#c9d1dc", "--border-tooltip": "#2a333f", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 39a8411ea..7f103e261 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -300,6 +300,10 @@ html { --dur: 140ms; /* additive: overlays, layers, controls */ + --bg-stripe-tooltip: var(--bg-tooltip); + --fg-stripe-tooltip: var(--fg-tooltip); + --border-stripe-tooltip: var(--border-tooltip); + --shadow-stripe-tooltip: var(--shadow-tooltip); --bg-tooltip: #33353b; --fg-tooltip: #d1d3d9; --border-tooltip: #33353b; @@ -337,7 +341,6 @@ html { --fg-stripe-label: #9fa2a8; --fs-stripe-label: 11px; --stripe-button-w: 58px; - --stripe-button-radius: 6px; --bg-tab-selected: #233558; --border-tab-selected: #2e4d89; --bg-tab-selected-inactive: #26282c; @@ -650,6 +653,10 @@ html { --traffic-off-border: #c8c8c8; /* additive */ + --bg-stripe-tooltip: #27282e; + --fg-stripe-tooltip: #ffffff; + --border-stripe-tooltip: #383a42; + --shadow-stripe-tooltip: 0 2px 8px rgba(0, 0, 0, 0.24); --bg-tooltip: #ffffff; --fg-tooltip: #000000; --border-tooltip: #d1d3d9; @@ -909,6 +916,10 @@ html { --terminal-ansi-bright-cyan: var(--ansi-bright-cyan); --terminal-ansi-bright-white: var(--ansi-bright-white); + --bg-stripe-tooltip: var(--bg-tooltip); + --fg-stripe-tooltip: var(--fg-tooltip); + --border-stripe-tooltip: var(--border-tooltip); + --shadow-stripe-tooltip: var(--shadow-tooltip); --bg-tooltip: #24262b; --fg-tooltip: #ffffff; --border-tooltip: #6f737a; @@ -985,6 +996,10 @@ html { * Keep before appearance options so accessibility preferences still win. */ :root[data-color-theme] { + --bg-stripe-tooltip: var(--bg-tooltip); + --fg-stripe-tooltip: var(--fg-tooltip); + --border-stripe-tooltip: var(--border-tooltip); + --shadow-stripe-tooltip: var(--shadow-tooltip); --bg-app: var(--palette-bg); --bg-island: var(--palette-bg); --bg-input: var(--palette-bg); @@ -1230,6 +1245,14 @@ html { --terminal-ansi-bright-white: var(--ansi-bright-white); } +/* Plugin themes can opt into stripe colours; otherwise their base tooltip tokens win. */ +:root[data-plugin-theme] { + --bg-stripe-tooltip: var(--bg-tooltip); + --fg-stripe-tooltip: var(--fg-tooltip); + --border-stripe-tooltip: var(--border-tooltip); + --shadow-stripe-tooltip: var(--shadow-tooltip); +} + :root[data-color-theme="solarized-light"] { --palette-yellow: #b58900; --palette-orange: #cb4b16; diff --git a/src/ui/Tooltip.module.css b/src/ui/Tooltip.module.css index d5717f3d1..5b4e606c3 100644 --- a/src/ui/Tooltip.module.css +++ b/src/ui/Tooltip.module.css @@ -34,6 +34,18 @@ flex-shrink: 0; } +.stripe { + color: var(--fg-stripe-tooltip, var(--fg-tooltip)); + padding: 8px 12px; + border-radius: 4px; + background: var(--bg-stripe-tooltip, var(--bg-tooltip)); + border-color: var(--border-stripe-tooltip, var(--border-tooltip)); + box-shadow: var(--shadow-stripe-tooltip, var(--shadow-tooltip)); + line-height: 18px; +} +.stripe .shortcut { + color: color-mix(in srgb, var(--fg-stripe-tooltip, var(--fg-tooltip)) 80%, transparent); +} @media (prefers-reduced-motion: reduce) { .tooltip[data-placed] { transition: none; diff --git a/src/ui/Tooltip.test.tsx b/src/ui/Tooltip.test.tsx index 813bd8a8f..b2fc7e9fa 100644 --- a/src/ui/Tooltip.test.tsx +++ b/src/ui/Tooltip.test.tsx @@ -64,4 +64,31 @@ describe("TooltipHost", () => { fireEvent.pointerOut(button); expect(screen.queryByRole("tooltip")).toBeNull(); }); + + it.each(["left", "right", undefined, "invalid"])("keeps dismissal behavior for %s tooltips", (stripe) => { + render( + <> + + + , + ); + const button = screen.getByRole("button"); + for (const dismiss of [ + () => fireEvent.pointerOut(button), + () => fireEvent.pointerDown(button), + () => fireEvent.keyDown(button, { key: "Escape" }), + () => fireEvent.scroll(document), + () => fireEvent.wheel(document), + () => fireEvent.blur(window), + ]) { + fireEvent.pointerOver(button); + act(() => vi.advanceTimersByTime(TOOLTIP_DELAY)); + const tooltip = screen.getByRole("tooltip"); + expect(tooltip.classList.contains(styles.stripe!)).toBe(stripe === "left" || stripe === "right"); + dismiss(); + expect(screen.queryByRole("tooltip")).toBeNull(); + } + }); }); diff --git a/src/ui/Tooltip.tsx b/src/ui/Tooltip.tsx index fbf7dd75e..3e37ef62a 100644 --- a/src/ui/Tooltip.tsx +++ b/src/ui/Tooltip.tsx @@ -4,6 +4,7 @@ * 600ms of hover. Hidden on scroll, key press, pointer down or leave. */ import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; import { createPortal } from "react-dom"; import { computePosition, flip, offset, shift } from "@floating-ui/react-dom"; import styles from "./Tooltip.module.css"; @@ -27,6 +28,7 @@ interface Shown { x: number; y: number; placed: boolean; + stripe?: "left" | "right"; } export function TooltipHost() { @@ -52,7 +54,15 @@ export function TooltipHost() { if (!text || !el.isConnected) return; visible = true; anchorRef.current = el; - setShown({ text, shortcut: el.dataset.tooltipShortcut, x: -9999, y: -9999, placed: false }); + const stripe = el.dataset.tooltipStripe; + setShown({ + text, + shortcut: el.dataset.tooltipShortcut, + x: -9999, + y: -9999, + placed: false, + stripe: stripe === "left" || stripe === "right" ? stripe : undefined, + }); }; const onOver = (e: PointerEvent) => { @@ -103,9 +113,13 @@ export function TooltipHost() { if (!anchor || !tip) return; let cancelled = false; void computePosition(anchor, tip, { - placement: "bottom", + placement: shown.stripe === "right" ? "left" : shown.stripe === "left" ? "right" : "bottom", strategy: "fixed", - middleware: [offset(6), flip({ padding: 6 }), shift({ padding: 6 })], + middleware: [ + offset(6), + flip({ padding: 6, ...(shown.stripe && { fallbackAxisSideDirection: "end" }) }), + shift({ padding: 6 }), + ], }).then(({ x, y }) => { if (!cancelled) setShown((s) => (s ? { ...s, x, y, placed: true } : s)); }); @@ -119,7 +133,7 @@ export function TooltipHost() {
diff --git a/src/workbench/toolwindows/ToolWindows.module.css b/src/workbench/toolwindows/ToolWindows.module.css index c033e8978..27d3a5f78 100644 --- a/src/workbench/toolwindows/ToolWindows.module.css +++ b/src/workbench/toolwindows/ToolWindows.module.css @@ -1,5 +1,4 @@ .frame { - --stripe-button-radius: var(--radius-popup); flex: 1; display: flex; min-height: 0; @@ -246,7 +245,7 @@ align-items: center; justify-content: center; gap: 3px; - border-radius: var(--stripe-button-radius); + border-radius: var(--radius-toolbar-button); color: var(--fg-muted); transition: background var(--dur-fast) var(--ease), @@ -267,8 +266,6 @@ .stripeButton:hover, .stripeButton[aria-expanded="true"] { color: var(--fg-secondary); -} -.stripeButton[aria-expanded="true"] { background: var(--bg-stripe-hover); } .stripeActive, @@ -278,8 +275,8 @@ } .stripeFocused, .stripeFocused:hover { - background: var(--bg-button-primary); - color: var(--fg-button-primary); + background: var(--bg-stripe-selected); + color: var(--fg-emphasis); --icon-color: currentColor; } .stripeButton:focus-visible { @@ -466,3 +463,9 @@ :global(:root[data-tool-window-bg="different"]) .anchor { background: var(--bg-elev); } + +@media (prefers-reduced-motion: reduce) { + .stripeButton { + transition: none; + } +} diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index f4628773c..64af94552 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -420,6 +420,7 @@ function StripeButton({ e.preventDefault(); showContextMenu(e, stripeButtonMenu(tw, showName)); }} + data-tooltip-stripe={side} {...tooltipAttrs(titleOf(tw), shortcut, true)} > @@ -463,6 +464,7 @@ function StripeMore({ windows }: { windows: ToolWindowContribution[] }) { aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((v) => !v)} + data-tooltip-stripe="left" {...tooltipAttrs("More Tool Windows")} >