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")} >