From ebca0e2055c47112f79e3b5f98a282cd447d4f19 Mon Sep 17 00:00:00 2001 From: dominikhorn Date: Mon, 19 Oct 2026 23:58:45 +0200 Subject: [PATCH 1/2] fix(shell): place a tooltip again when it grows after it is shown A web font that arrives after the tooltip was placed makes it wider, so the "Zoom in" tooltip ran 2px past the 12px edge in Context Maps' CI and failed the kit's geometry case. The tooltip now observes its own size and places itself again while it is shown. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shell/src/dom/tooltip.test.ts | 17 +++++++++++++++++ packages/shell/src/dom/tooltip.ts | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/shell/src/dom/tooltip.test.ts b/packages/shell/src/dom/tooltip.test.ts index 4813a5bc..1fdcb382 100644 --- a/packages/shell/src/dom/tooltip.test.ts +++ b/packages/shell/src/dom/tooltip.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TOOLTIP_DELAY_MS } from "../core/index.js"; import { + FakeResizeObserver, chrome, cleanup, fakePalette, @@ -149,6 +150,22 @@ describe("chrome tooltip (guide ยง5.6)", () => { expect(tooltip.style.top).toBe(`${748 - 8 - 28}px`); }); + it("is placed again when it grows after showing, for example once a web font arrives", () => { + const { root } = mount({}, { width: 1000, height: 800 }); + const zoom = chrome(root, "zoom"); + setBox(zoom, { left: 815, top: 748, width: 173, height: 40 }); + const zoomIn = zoomButton(root, "in"); + setBox(zoomIn, { left: 950, top: 752, width: 32, height: 32 }); + const tooltip = tooltipOf(root); + Object.defineProperty(tooltip, "offsetWidth", { configurable: true, value: 80 }); + Object.defineProperty(tooltip, "offsetHeight", { configurable: true, value: 28 }); + zoomIn.focus(); + expect(tooltip.style.left).toBe(`${1000 - 12 - 80}px`); + Object.defineProperty(tooltip, "offsetWidth", { configurable: true, value: 82 }); + FakeResizeObserver.resize(tooltip); + expect(tooltip.style.left).toBe(`${1000 - 12 - 82}px`); + }); + it("describes a control only when it says more than the control's name", () => { const { shell, root } = mount({ problems: { onActivate() {} } }); shell.setProblems({ count: 2, errors: 1 }); diff --git a/packages/shell/src/dom/tooltip.ts b/packages/shell/src/dom/tooltip.ts index 8514aa72..39492661 100644 --- a/packages/shell/src/dom/tooltip.ts +++ b/packages/shell/src/dom/tooltip.ts @@ -43,6 +43,15 @@ export function createTooltip({ root, layer, id }: TooltipContext): TooltipContr // A clicked control keeps its tooltip hidden until the pointer leaves it. let pressed: HTMLElement | undefined; const detachers = new Set<() => void>(); + // The tooltip grows when a web font arrives after it was placed; place it again so it keeps + // its edge distance instead of running past the container. + const resizeObserver = + typeof ResizeObserver === "function" + ? new ResizeObserver(() => { + if (shown) place(shown.control); + }) + : undefined; + resizeObserver?.observe(element); function cancelPending(): void { if (pending) clearTimeout(pending.timer); @@ -231,6 +240,7 @@ export function createTooltip({ root, layer, id }: TooltipContext): TooltipContr destroy() { hide(); for (const detach of [...detachers]) detach(); + resizeObserver?.disconnect(); element.remove(); }, }; From 9ad441ded0f87b01f8c4d83b23ea54021caf72ee Mon Sep 17 00:00:00 2001 From: dominikhorn Date: Mon, 19 Oct 2026 23:59:45 +0200 Subject: [PATCH 2/2] test(shell): find observers by what they observe, now that the tooltip has one The palette test took the first ResizeObserver, which is now the tooltip's. It looks the palette's observer up by the element it observes, and the destroy test checks that every observer is disconnected. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shell/src/dom/islands.test.ts | 5 ++++- packages/shell/src/dom/mount.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shell/src/dom/islands.test.ts b/packages/shell/src/dom/islands.test.ts index 7e907efa..1fb9f6d0 100644 --- a/packages/shell/src/dom/islands.test.ts +++ b/packages/shell/src/dom/islands.test.ts @@ -341,7 +341,10 @@ describe("palette registration", () => { const second = fakePalette(); const unregisterFirst = shell.registerPalette(first); shell.registerPalette(second); - const observer = FakeResizeObserver.instances[0]!; + const observer = FakeResizeObserver.instances.find((candidate) => + candidate.observed.has(second.element), + )!; + expect(observer).toBeDefined(); expect(observer.observed.has(first.element)).toBe(false); expect(observer.observed.has(second.element)).toBe(true); unregisterFirst(); diff --git a/packages/shell/src/dom/mount.test.ts b/packages/shell/src/dom/mount.test.ts index 17d2a407..cca3c110 100644 --- a/packages/shell/src/dom/mount.test.ts +++ b/packages/shell/src/dom/mount.test.ts @@ -190,7 +190,7 @@ describe("destroy", () => { shell.inspector?.open(); shell.setStatus({ severity: "danger", text: "Invalid" }, { delayMs: 800 }); shell.menu.open(); - const observer = FakeResizeObserver.instances[0]; + const observers = [...FakeResizeObserver.instances]; shell.destroy(); shell.destroy(); @@ -200,7 +200,8 @@ describe("destroy", () => { expect(root.getAttributeNames()).toEqual(["id"]); expect(root.className).toBe(""); expect(document.documentElement.className).toBe(""); - expect(observer?.disconnected).toBe(true); + expect(observers.length).toBeGreaterThan(1); + expect(observers.every((observer) => observer.disconnected)).toBe(true); expect(target.listeners.size).toBe(0); vi.advanceTimersByTime(2000);