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(
+ <>
+