From fcc52f2fe93929c4e31501cdb4b0496970aa7e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Mon, 24 Aug 2026 09:15:00 +0200 Subject: [PATCH 1/2] fix(runner): keep the toolbar CTA tooltips inside the window (DEV-2593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.hot-cta-tooltip` was anchored `left: 0` to its trigger and is 320px wide. Ask AI and Style both sit in the top bar's right-hand cluster, with Download, the theme toggle and the account menu still to their right, so the panel grew straight past the right edge of the window on every viewport width — and nothing between the tooltip and scrolls, so the overflow was cut off rather than reachable. Measured at 1280: the Style tooltip's right edge landed at 1339. Anchor it to the trigger's right edge instead, so it grows leftwards, and cap it at `calc(100vw - 32px)` for the narrow window where the cluster itself is close to the left side — shrink rather than run off the other edge. The spec measures `getBoundingClientRect()` against the document's `clientWidth` for both triggers. `boundingClientRect` and not `offsetWidth` here, unlike the drawer-width test above it: what is under test is where the box lands relative to the window, and nothing in this subtree is transformed. Without the CSS change the Style case fails on the number above; the Ask AI case passes either way, and is kept because it is the same treatment and the one that would regress if someone re-anchored the class to the left. Co-Authored-By: Claude Opus 5 --- runner/apps/authoring/src/panels.css | 11 ++++++++++- runner/e2e/panels.spec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/runner/apps/authoring/src/panels.css b/runner/apps/authoring/src/panels.css index d22648a5..f9539225 100644 --- a/runner/apps/authoring/src/panels.css +++ b/runner/apps/authoring/src/panels.css @@ -365,9 +365,18 @@ .hot-cta-tooltip { position: absolute; top: calc(100% + 8px); - left: 0; + /* Right-anchored, not `left: 0` (DEV-2593). Both triggers live in the top + bar's right-hand cluster with Download, the theme toggle and the account + menu still to their right, so a 320px panel growing rightwards ran past the + window on every viewport width — and nothing between here and + scrolls, so the overflow was simply cut off. Growing leftwards from the + trigger's right edge keeps the whole panel on screen. */ + right: 0; z-index: 950; width: 320px; + /* The other edge, for the narrow window where the cluster itself is near the + left: shrink rather than disappear off the left side. */ + max-width: calc(100vw - 32px); background: var(--hot-color-surface-raised); border: 1px solid var(--hot-color-control-border); border-radius: 8px; diff --git a/runner/e2e/panels.spec.ts b/runner/e2e/panels.spec.ts index a98923c9..5d46ea27 100644 --- a/runner/e2e/panels.spec.ts +++ b/runner/e2e/panels.spec.ts @@ -11,6 +11,8 @@ import { test, expect, type Locator, type Page } from "@playwright/test"; const CHAT = 'aside[aria-label="Ask about this example"]'; const STYLE = 'aside[aria-label="Style this demo"]'; +// The hover CTA both toolbar buttons raise — one treatment, one class. +const HINT = ".hot-cta-tooltip"; async function openPlayground(page: Page, mode: "light" | "dark") { await page.addInitScript((m) => localStorage.setItem("hot-theme", m), mode); @@ -167,6 +169,30 @@ test.describe("drawer chrome", () => { await page.keyboard.press("Escape"); await expect(page.locator(CHAT)).toBeVisible(); }); + + // DEV-2593: the tooltip is 320px wide and used to hang off the *left* edge of + // its trigger, so it ran past the right side of the window — both CTAs sit in + // the top bar's right-hand cluster, and no ancestor scrolls, so the overflow + // is simply cut off by the viewport. Measured, not screenshotted: a clipped + // panel and a narrow one look the same in a picture. + for (const trigger of ["Ask AI", "Style"]) { + test(`the ${trigger} tooltip stays inside the viewport`, async ({ page }) => { + await openPlayground(page, "light"); + await page.getByRole("button", { name: trigger, exact: true }).hover(); + + const hint = page.locator(HINT); + await expect(hint).toBeVisible(); + + // `getBoundingClientRect`, not `offsetWidth`: what is under test is where + // the box lands relative to the window, and nothing here is transformed. + const box = await hint.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, viewport: document.documentElement.clientWidth }; + }); + expect(box.right).toBeLessThanOrEqual(box.viewport); + expect(box.left).toBeGreaterThanOrEqual(0); + }); + } }); /** WCAG 2.1 contrast ratio of one element's text against its own background, From 827cb324eea8fc141e1191a11722f284d9b30569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Mon, 24 Aug 2026 09:27:40 +0200 Subject: [PATCH 2/2] test(runner): the tooltip's narrow-window clamp, measured (Bugbot #257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot read `max-width: calc(100vw - 32px)` as a guard against the cluster sitting near the left edge and pointed out — correctly — that the flex spacer keeps the cluster pinned right, so that case never happens and the comment was describing something that cannot occur. Its conclusion, that the panel therefore runs off the *left* at mid-narrow widths, does not reproduce: measured at 1280/1024/900/768/700/640/560/500/420/ 375, the left edge sits at 780, 524, 400, 268, 200, 140, 60, 37, 37, 37 — it never approaches zero, because only what sits right of the trigger moves the panel and below ~537px the bar stops shrinking altogether. The clamp is load-bearing all the same, for a different reason: under ~352px the panel is wider than the window, and with the clamp removed the left edge goes negative at a 320px window. So the rule stays and the comment is rewritten to say what it actually does, with the widths it was measured at. The new spec pins that: at 320 the panel must land inside both edges *and* be narrower than its natural 320px, so it cannot pass by the trigger happening to sit far enough in. Neutering the `max-width` fails it on the left-edge assertion. Co-Authored-By: Claude Opus 5 --- runner/apps/authoring/src/panels.css | 8 ++++++-- runner/e2e/panels.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/runner/apps/authoring/src/panels.css b/runner/apps/authoring/src/panels.css index f9539225..4d72bf4b 100644 --- a/runner/apps/authoring/src/panels.css +++ b/runner/apps/authoring/src/panels.css @@ -374,8 +374,12 @@ right: 0; z-index: 950; width: 320px; - /* The other edge, for the narrow window where the cluster itself is near the - left: shrink rather than disappear off the left side. */ + /* The left edge is not at risk from the cluster moving — the flex spacer keeps + it pinned right, so the trigger's distance from the *right* of the window is + what sets where this lands, and measured across 320–1280px the panel's left + edge never gets closer to zero than 32px. What is at risk below ~352px is + the panel simply being wider than the window: there the clamp binds (308px + at a 340 window, 288 at 320) and both edges stay inside. */ max-width: calc(100vw - 32px); background: var(--hot-color-surface-raised); border: 1px solid var(--hot-color-control-border); diff --git a/runner/e2e/panels.spec.ts b/runner/e2e/panels.spec.ts index 5d46ea27..212fbb0b 100644 --- a/runner/e2e/panels.spec.ts +++ b/runner/e2e/panels.spec.ts @@ -193,6 +193,31 @@ test.describe("drawer chrome", () => { expect(box.left).toBeGreaterThanOrEqual(0); }); } + + // The other edge, and the reason the `max-width` next to `right: 0` is not + // decoration. Right-anchoring cannot push the panel off the left — the flex + // spacer pins the cluster to the right, so only what sits *right* of the + // trigger moves it, and across 320–1280px the left edge never comes within + // 32px of zero. What does bite below ~352px is the panel being wider than the + // window at all; there the clamp has to shrink it or one edge goes. + test("a window narrower than the tooltip shrinks it instead of clipping it", async ({ page }) => { + await openPlayground(page, "light"); + await page.setViewportSize({ width: 320, height: 720 }); + await page.getByRole("button", { name: "Style", exact: true }).hover(); + + const hint = page.locator(HINT); + await expect(hint).toBeVisible(); + + const box = await hint.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, width: r.width, viewport: document.documentElement.clientWidth }; + }); + expect(box.right).toBeLessThanOrEqual(box.viewport); + expect(box.left).toBeGreaterThanOrEqual(0); + // And it got there by shrinking, not by the trigger happening to sit far + // enough in: at its natural 320px nothing would fit a 320px window. + expect(box.width).toBeLessThan(320); + }); }); /** WCAG 2.1 contrast ratio of one element's text against its own background,