From dfbf7184f3fede3101f1324822fdb3118ed7e89a Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Tue, 22 Sep 2026 01:30:09 -0700 Subject: [PATCH 1/3] fix(core-beta): place the notice card from main's geometry, not the page's width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card centred itself with `margin-inline: auto`, which centres against the PAGE's width. That width is sometimes still the pre-resize value: main has set the view's new bounds and the page has not processed them yet. Centring against it puts the card one shadow gutter off the anchor — and the beak is pinned to the card, so the whole thing points beside the bell. Caught by the anchor assertion's diagnostic, which named it on its first outing: bell=620 viewX=462 viewWidth=316 cardLeft=10 cardWidth=280 beakInCard=140 beakInlineStyle=50% pageWidth=300 | viewCentreVsBell=0 `pageWidth=300` inside a `viewWidth=316` view. The card centred in 300 lands at 10 where it belongs at 18, and half that 16px gap is the 8px the test measured. `viewCentreVsBell=0` clears main's placement — the view was always centred on the bell correctly. So main now sends the card's offset within the view alongside the beak fraction, and the renderer applies it. Main already computed that number; the renderer was re-deriving it from the one quantity it cannot trust. `null` when absent, falling back to the existing CSS centring, so an older main degrades to today's behaviour rather than to a bogus offset. This is user-visible, not just a test failure: anyone whose popup renderer lags a resize sees the card and its beak 8px off the bell. Measured, not assumed. The e2e reproduced it 3 times in 11 runs before; with this change it is 16 clean runs out of 16. At the prior rate that outcome lands by chance under one percent of the time. Co-Authored-By: Claude Opus 5 --- src/main/popups/titleCoachmark.test.ts | 16 +++++++++++++ src/main/popups/titleCoachmark.ts | 19 ++++++++++++--- src/preload/comfyTitleTooltipPreload.ts | 15 +++++++++--- .../src/comfyTitleTooltip/TitleTooltipApp.vue | 23 +++++++++++++++---- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/main/popups/titleCoachmark.test.ts b/src/main/popups/titleCoachmark.test.ts index ea96dbaf5..d90f80511 100644 --- a/src/main/popups/titleCoachmark.test.ts +++ b/src/main/popups/titleCoachmark.test.ts @@ -81,6 +81,22 @@ describe('positionCoachmark beak tracking', () => { return placement.x + COACHMARK_SHADOW_GUTTER + placement.beakFraction * cardWidth } + it('reports where the card sits inside the view, so the renderer need not guess', () => { + // The renderer would otherwise centre the card against its OWN viewport width, and that + // width is sometimes still the pre-resize value — the page had not processed the new + // bounds yet. Centring against it puts the card one gutter off the anchor, taking the + // beak with it. Main knows the real geometry, so it sends the offset. + const placement = positionCoachmark({ + anchor: { leftX: 500, rightX: 540, bottomY: 36 }, + bubble, + parentBounds: { width: 1200, height: 800 } + }) + expect(placement.cardLeftInView).toBe(COACHMARK_SHADOW_GUTTER) + // The card, placed at that offset, is centred on the anchor — which is what the beak + // fraction is computed against. + expect(placement.x + placement.cardLeftInView + cardWidth / 2).toBeCloseTo(520, 5) + }) + it('centres the beak when the card is not clamped', () => { const placement = positionCoachmark({ anchor: { leftX: 500, rightX: 540, bottomY: 36 }, diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 3bdec61ce..2b49ec4c6 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -91,6 +91,10 @@ export interface CoachmarkPlacement { width: number height: number beakFraction: number + /** The card's left offset within the view, in CSS px. Sent to the renderer so the card's + * placement comes from the geometry main already computed rather than from the page's own + * idea of its width. */ + cardLeftInView: number } /** Compute popup bounds centering the card under the anchor, clamped to the parent, plus where @@ -130,7 +134,13 @@ export function positionCoachmark(opts: { // the beak to the very corner the margin exists to keep it off. const beakMargin = Math.min(0.5, COACHMARK_BEAK_EDGE_MARGIN / cardWidth) const beakFraction = Math.min(1 - beakMargin, Math.max(beakMargin, rawFraction)) - return { x, y, width: viewWidth, height: viewHeight, beakFraction } + // Where the card sits INSIDE the view. The renderer cannot safely work this out itself: it + // would have to centre against its own viewport, and that viewport is sometimes still the + // previous width when this runs — the page had not processed the resize yet. Centring + // against a stale width puts the card, and the beak pinned to it, one gutter off the anchor. + // Measured at 8px on a card whose page still thought it was 16px narrower than its view. + const cardLeftInView = (viewWidth - cardWidth) / 2 + return { x, y, width: viewWidth, height: viewHeight, beakFraction, cardLeftInView } } let _coachmarkTokenSeq = 0 @@ -255,14 +265,17 @@ function repositionAndShow( ): void { if (!entry.pendingAnchor || entry.view.isDestroyed()) return const parentBounds = entry.view.parentWindow.getContentBounds() - const { beakFraction, ...bounds } = positionCoachmark({ + const { beakFraction, cardLeftInView, ...bounds } = positionCoachmark({ anchor: entry.pendingAnchor, bubble, parentBounds }) entry.view.popup.setBounds(bounds) // Tell the card where to draw its beak now that the final, possibly clamped, x is known. - entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { beakFraction }) + entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { + beakFraction, + cardLeftInView + }) // Focus so the dismiss button is keyboard-reachable. entry.view.showOnTop({ focus: true }) } diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index bf868f364..021934209 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -33,7 +33,7 @@ export interface ComfyTitleTooltipBridge { onConfig(cb: (config: TitleTooltipConfig) => void): () => void /** Beak position, pushed after main has measured the card and settled its final (possibly * clamped) bounds. Separate from the config push because it is only knowable then. */ - onBeak(cb: (payload: { beakFraction: number }) => void): () => void + onBeak(cb: (payload: { beakFraction: number; cardLeftInView: number | null }) => void): () => void /** Coachmark dismiss button; no-op for the tooltip variant. `configToken` names the card * the click landed on, so main can discard a click from a card it has since replaced. */ dismissCoachmark(configToken: string): void @@ -74,8 +74,17 @@ const bridge: ComfyTitleTooltipBridge = { }, onBeak: (cb) => { const handler = (_event: IpcRendererEvent, data: unknown): void => { - const raw = (data as { beakFraction?: unknown } | undefined)?.beakFraction - if (typeof raw === 'number' && Number.isFinite(raw)) cb({ beakFraction: raw }) + const payload = data as { beakFraction?: unknown; cardLeftInView?: unknown } | undefined + const raw = payload?.beakFraction + const left = payload?.cardLeftInView + if (typeof raw === 'number' && Number.isFinite(raw)) { + cb({ + beakFraction: raw, + // `null` rather than a guess: an older main that does not send it must fall back to + // CSS centring, not to a bogus offset. + cardLeftInView: typeof left === 'number' && Number.isFinite(left) ? left : null + }) + } } ipcRenderer.on('comfy-titletooltip:set-beak', handler) return () => ipcRenderer.removeListener('comfy-titletooltip:set-beak', handler) diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index c97a46263..646396b9c 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -33,7 +33,9 @@ interface Bridge { dismissCoachmark?(configToken: string): void /** Beak position as a fraction of the card's width, pushed once main has measured the card * and settled its final bounds. */ - onBeak?(cb: (payload: { beakFraction: number }) => void): () => void + onBeak?( + cb: (payload: { beakFraction: number; cardLeftInView: number | null }) => void + ): () => void /** Coachmark secondary action — retires the card the same way dismiss does, and lets the * owning feature run its follow-up (e.g. opening Settings). */ actionCoachmark?(configToken: string): void @@ -50,6 +52,15 @@ const cmActionLabel = ref('') /** Defaults to centred, which is what a card with no clamp and a correct anchor resolves to * anyway — so a missed push degrades to the old behaviour rather than to a detached beak. */ const cmBeakFraction = ref(0.5) +/** The card's left offset inside the view, as MAIN computed it — `null` until it arrives, and + * on an older main that never sends it, which falls back to the CSS centring below. + * + * Placement comes from main rather than from centring here because this page's own width is + * sometimes still the previous one: the view has been resized and the page has not processed + * it yet. Centring against that stale width puts the card one gutter off the anchor, and the + * beak is pinned to the card, so the whole thing points beside the bell. Measured at 8px, + * intermittently, with the page reporting 300 inside a 316-wide view. */ +const cmCardLeft = ref(null) const themeBg = ref('#211927') const themeText = ref('#ffffff') const themeBorder = ref('#38303d') @@ -110,8 +121,9 @@ onMounted(() => { if (cfg.theme.accent) themeAccent.value = cfg.theme.accent void measureAndAck() }) - unsubBeak = bridge?.onBeak?.(({ beakFraction }) => { + unsubBeak = bridge?.onBeak?.(({ beakFraction, cardLeftInView }) => { cmBeakFraction.value = Math.min(1, Math.max(0, beakFraction)) + cmCardLeft.value = cardLeftInView }) bridge?.ready() // Re-measure if Inter loads mid-session (after the initial ack) so main can @@ -155,7 +167,8 @@ onUnmounted(() => { :style="{ background: themeBg, color: themeText, - borderColor: coachmarkBorder + borderColor: coachmarkBorder, + ...(cmCardLeft === null ? {} : { marginLeft: `${cmCardLeft}px`, marginRight: '0' }) }" > { display: block; width: max-content; max-width: 280px; - /* `margin-top` for the beak; `auto` inline so the card CENTRES in the view. + /* `margin-top` for the beak; `auto` inline as the FALLBACK centring — main normally sends + an explicit left offset (`cmCardLeft`) which overrides this inline, because centring here + depends on the page's own width and that is sometimes still the pre-resize value. Body's flex centring does not reach it: `#app` is `width: 100%`, so the flex item that gets centred is a full-width box and the card inside it stays flush-left. Main sizes the view as the card plus a shadow gutter each side and centres that VIEW on the bell, so a From dae1ab6090459ccc75bedf9222fe25067e6253bb Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Tue, 22 Sep 2026 01:51:17 -0700 Subject: [PATCH 2/3] fix(core-beta): pin the card's CENTRE, not its left edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the first version traded one staleness for another. Pinning the left edge uses the width main ASSUMED (`bubble.width`), so it is only right while the card renders exactly that wide — and on the fallback show the view is sized before any measurement exists. It fixed "wrong when the page's width is stale" by introducing "wrong when the card's width is stale". Main now sends the card's centre — the view's own midpoint — and the renderer places it with `left` plus `translateX(-50%)`. That offsets by half of whatever the card actually is, so it is correct for any rendered width AND for a page still laid out at its previous width. Neither assumption is load-bearing any more. Also from review: - the payload is accepted only when finite and non-negative, and the renderer tests the value rather than `=== null`. An older preload sends `undefined`, which the strict check would have passed through to the style binding as `undefinedpx`; - the unit test could not fail. It compared the offset against the gutter and re-used the same width it had fed in, so both sides reduced to the same constant for every reachable input. It now varies card width and anchor, including a clamped one, and asserts the card centre lands on the ANCHOR. Verified by shifting the computed centre 4px: three cases fail. Measured again after the rework: 10 clean runs out of 10, against 3 failures in 11 before any fix. Co-Authored-By: Claude Opus 5 --- src/main/popups/titleCoachmark.test.ts | 44 ++++++++++++------- src/main/popups/titleCoachmark.ts | 34 ++++++++------ src/preload/comfyTitleTooltipPreload.ts | 13 ++++-- .../src/comfyTitleTooltip/TitleTooltipApp.vue | 42 ++++++++++++------ 4 files changed, 88 insertions(+), 45 deletions(-) diff --git a/src/main/popups/titleCoachmark.test.ts b/src/main/popups/titleCoachmark.test.ts index d90f80511..991b4d066 100644 --- a/src/main/popups/titleCoachmark.test.ts +++ b/src/main/popups/titleCoachmark.test.ts @@ -81,21 +81,35 @@ describe('positionCoachmark beak tracking', () => { return placement.x + COACHMARK_SHADOW_GUTTER + placement.beakFraction * cardWidth } - it('reports where the card sits inside the view, so the renderer need not guess', () => { - // The renderer would otherwise centre the card against its OWN viewport width, and that - // width is sometimes still the pre-resize value — the page had not processed the new - // bounds yet. Centring against it puts the card one gutter off the anchor, taking the - // beak with it. Main knows the real geometry, so it sends the offset. - const placement = positionCoachmark({ - anchor: { leftX: 500, rightX: 540, bottomY: 36 }, - bubble, - parentBounds: { width: 1200, height: 800 } - }) - expect(placement.cardLeftInView).toBe(COACHMARK_SHADOW_GUTTER) - // The card, placed at that offset, is centred on the anchor — which is what the beak - // fraction is computed against. - expect(placement.x + placement.cardLeftInView + cardWidth / 2).toBeCloseTo(520, 5) - }) + it.each([ + [200, { leftX: 500, rightX: 540 }], + [280, { leftX: 500, rightX: 540 }], + [280, { leftX: 1130, rightX: 1150 }] + ])( + 'reports a card centre that lands on the anchor (card %s, clamped or not)', + (width, anchor) => { + // Asserted against the ANCHOR, not against the formula. An earlier version of this test + // compared `cardLeftInView` to the gutter and re-used the same width it fed in, so both + // sides reduced to the same constant and it could not fail for any input. This varies + // the card width and the anchor — including one that clamps at the right edge — and + // checks the property that actually matters. + const placement = positionCoachmark({ + anchor: { ...anchor, bottomY: 36 }, + bubble: { width, height: 90 }, + parentBounds: { width: 1200, height: 800 } + }) + const anchorCentre = (anchor.leftX + anchor.rightX) / 2 + const cardCentreInWindow = placement.x + placement.cardCentreInView + const clamped = placement.x <= 0 || placement.x + placement.width >= 1200 + if (clamped) { + // Clamped, the card cannot sit on the anchor — but it must still be centred in its + // own view, which is what the beak fraction is then measured against. + expect(placement.cardCentreInView).toBeCloseTo(placement.width / 2, 5) + } else { + expect(cardCentreInWindow).toBeCloseTo(anchorCentre, 5) + } + } + ) it('centres the beak when the card is not clamped', () => { const placement = positionCoachmark({ diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 2b49ec4c6..c8ce69244 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -91,10 +91,11 @@ export interface CoachmarkPlacement { width: number height: number beakFraction: number - /** The card's left offset within the view, in CSS px. Sent to the renderer so the card's - * placement comes from the geometry main already computed rather than from the page's own - * idea of its width. */ - cardLeftInView: number + /** Where the card's midpoint belongs within the view, in CSS px. Sent so placement comes + * from the geometry main already computed rather than from the page's own idea of its + * width — and as a centre rather than an edge, so it holds whatever width the card + * actually renders at. */ + cardCentreInView: number } /** Compute popup bounds centering the card under the anchor, clamped to the parent, plus where @@ -134,13 +135,20 @@ export function positionCoachmark(opts: { // the beak to the very corner the margin exists to keep it off. const beakMargin = Math.min(0.5, COACHMARK_BEAK_EDGE_MARGIN / cardWidth) const beakFraction = Math.min(1 - beakMargin, Math.max(beakMargin, rawFraction)) - // Where the card sits INSIDE the view. The renderer cannot safely work this out itself: it - // would have to centre against its own viewport, and that viewport is sometimes still the - // previous width when this runs — the page had not processed the resize yet. Centring - // against a stale width puts the card, and the beak pinned to it, one gutter off the anchor. - // Measured at 8px on a card whose page still thought it was 16px narrower than its view. - const cardLeftInView = (viewWidth - cardWidth) / 2 - return { x, y, width: viewWidth, height: viewHeight, beakFraction, cardLeftInView } + // The card's CENTRE inside the view, which is the view's own midpoint. + // + // Sent because the renderer cannot derive it safely. Centring with auto margins measures the + // page's own width, and that width is sometimes still the pre-resize value — the page had + // not processed the new bounds yet. Measured at 8px off, on a page still reporting 300 + // inside a 316-wide view. + // + // The CENTRE rather than the left edge, deliberately: a left offset is only correct while + // the card renders exactly as wide as `bubble.width` said it would, so it would trade a + // stale-viewport failure for a stale-width one — including on the fallback show, where the + // view is sized before any measurement exists. Pinning the centre is right for any rendered + // width, because the renderer offsets by half of whatever the card actually is. + const cardCentreInView = viewWidth / 2 + return { x, y, width: viewWidth, height: viewHeight, beakFraction, cardCentreInView } } let _coachmarkTokenSeq = 0 @@ -265,7 +273,7 @@ function repositionAndShow( ): void { if (!entry.pendingAnchor || entry.view.isDestroyed()) return const parentBounds = entry.view.parentWindow.getContentBounds() - const { beakFraction, cardLeftInView, ...bounds } = positionCoachmark({ + const { beakFraction, cardCentreInView, ...bounds } = positionCoachmark({ anchor: entry.pendingAnchor, bubble, parentBounds @@ -274,7 +282,7 @@ function repositionAndShow( // Tell the card where to draw its beak now that the final, possibly clamped, x is known. entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { beakFraction, - cardLeftInView + cardCentreInView }) // Focus so the dismiss button is keyboard-reachable. entry.view.showOnTop({ focus: true }) diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index 021934209..26021f4d6 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -33,7 +33,9 @@ export interface ComfyTitleTooltipBridge { onConfig(cb: (config: TitleTooltipConfig) => void): () => void /** Beak position, pushed after main has measured the card and settled its final (possibly * clamped) bounds. Separate from the config push because it is only knowable then. */ - onBeak(cb: (payload: { beakFraction: number; cardLeftInView: number | null }) => void): () => void + onBeak( + cb: (payload: { beakFraction: number; cardCentreInView: number | null }) => void + ): () => void /** Coachmark dismiss button; no-op for the tooltip variant. `configToken` names the card * the click landed on, so main can discard a click from a card it has since replaced. */ dismissCoachmark(configToken: string): void @@ -74,15 +76,18 @@ const bridge: ComfyTitleTooltipBridge = { }, onBeak: (cb) => { const handler = (_event: IpcRendererEvent, data: unknown): void => { - const payload = data as { beakFraction?: unknown; cardLeftInView?: unknown } | undefined + const payload = data as { beakFraction?: unknown; cardCentreInView?: unknown } | undefined const raw = payload?.beakFraction - const left = payload?.cardLeftInView + const centre = payload?.cardCentreInView if (typeof raw === 'number' && Number.isFinite(raw)) { cb({ beakFraction: raw, // `null` rather than a guess: an older main that does not send it must fall back to // CSS centring, not to a bogus offset. - cardLeftInView: typeof left === 'number' && Number.isFinite(left) ? left : null + // Non-negative as well as finite: a negative or NaN centre would place the card + // off its own view, and the renderer treats null as "fall back to CSS centring". + cardCentreInView: + typeof centre === 'number' && Number.isFinite(centre) && centre >= 0 ? centre : null }) } } diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index 646396b9c..531d68a1f 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -34,7 +34,7 @@ interface Bridge { /** Beak position as a fraction of the card's width, pushed once main has measured the card * and settled its final bounds. */ onBeak?( - cb: (payload: { beakFraction: number; cardLeftInView: number | null }) => void + cb: (payload: { beakFraction: number; cardCentreInView: number | null }) => void ): () => void /** Coachmark secondary action — retires the card the same way dismiss does, and lets the * owning feature run its follow-up (e.g. opening Settings). */ @@ -52,15 +52,18 @@ const cmActionLabel = ref('') /** Defaults to centred, which is what a card with no clamp and a correct anchor resolves to * anyway — so a missed push degrades to the old behaviour rather than to a detached beak. */ const cmBeakFraction = ref(0.5) -/** The card's left offset inside the view, as MAIN computed it — `null` until it arrives, and - * on an older main that never sends it, which falls back to the CSS centring below. +/** Where the card's midpoint belongs inside the view, as MAIN computed it — `null` until it + * arrives, and on an older main that never sends it, which falls back to the CSS centring. * - * Placement comes from main rather than from centring here because this page's own width is - * sometimes still the previous one: the view has been resized and the page has not processed - * it yet. Centring against that stale width puts the card one gutter off the anchor, and the - * beak is pinned to the card, so the whole thing points beside the bell. Measured at 8px, - * intermittently, with the page reporting 300 inside a 316-wide view. */ -const cmCardLeft = ref(null) + * Placement comes from main because centring here measures this page's own width, and that + * width is sometimes still the pre-resize value: the view has new bounds and the page has not + * processed them. Centring against it puts the card one gutter off the anchor, and the beak + * is pinned to the card, so the whole thing points beside the bell. Measured at 8px, with the + * page reporting 300 inside a 316-wide view. + * + * A centre rather than a left edge, so it stays correct at whatever width the card actually + * renders — `translateX(-50%)` offsets by half of the real card, not half of an assumed one. */ +const cmCardCentre = ref(null) const themeBg = ref('#211927') const themeText = ref('#ffffff') const themeBorder = ref('#38303d') @@ -121,9 +124,14 @@ onMounted(() => { if (cfg.theme.accent) themeAccent.value = cfg.theme.accent void measureAndAck() }) - unsubBeak = bridge?.onBeak?.(({ beakFraction, cardLeftInView }) => { + unsubBeak = bridge?.onBeak?.(({ beakFraction, cardCentreInView }) => { cmBeakFraction.value = Math.min(1, Math.max(0, beakFraction)) - cmCardLeft.value = cardLeftInView + // `?? null` and a finiteness guard, not a `=== null` test: an older preload sends + // `undefined`, which would otherwise reach the style binding and emit `undefinedpx`. + cmCardCentre.value = + typeof cardCentreInView === 'number' && Number.isFinite(cardCentreInView) + ? Math.max(0, cardCentreInView) + : null }) bridge?.ready() // Re-measure if Inter loads mid-session (after the initial ack) so main can @@ -168,7 +176,15 @@ onUnmounted(() => { background: themeBg, color: themeText, borderColor: coachmarkBorder, - ...(cmCardLeft === null ? {} : { marginLeft: `${cmCardLeft}px`, marginRight: '0' }) + ...(cmCardCentre === null + ? {} + : { + marginLeft: '0', + marginRight: '0', + position: 'relative', + left: `${cmCardCentre}px`, + transform: 'translateX(-50%)' + }) }" > { width: max-content; max-width: 280px; /* `margin-top` for the beak; `auto` inline as the FALLBACK centring — main normally sends - an explicit left offset (`cmCardLeft`) which overrides this inline, because centring here + an explicit centre (`cmCardCentre`) which overrides this inline, because centring here depends on the page's own width and that is sometimes still the pre-resize value. Body's flex centring does not reach it: `#app` is `width: 100%`, so the flex item that gets centred is a full-width box and the card inside it stays flush-left. Main sizes the From 917ed6b73669b3f050e32cdde916f103309be4f2 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Tue, 22 Sep 2026 02:01:31 -0700 Subject: [PATCH 3/3] refactor(core-beta): one shared type for the set-beak payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three processes have to agree on this payload — main sends it, the preload validates it, the renderer applies it — and each declared its own shape. An IPC boundary gives no compile error when those drift: `send` is untyped and `ipcRenderer.on` hands back `unknown`, so a later change could make them disagree and the card would land in the wrong place with everything still building. Declared once in `src/types/ipc.ts`, whose own header already says it is the single source of truth for exactly this, and imported by all three. Type-only, so nothing new reaches any bundle. Raised by CodeRabbit on this PR, and it is the same fix I argued for when I DECLINED a related suggestion on #1552: there the objection was that the type lived in a main-process module with value imports, and I said the clean answer was a type-only declaration both sides import rather than two copies. This is that answer, applied to the payload this PR introduces. Co-Authored-By: Claude Opus 5 --- src/main/popups/titleCoachmark.ts | 7 +++---- src/preload/comfyTitleTooltipPreload.ts | 5 ++--- .../src/comfyTitleTooltip/TitleTooltipApp.vue | 5 ++--- src/types/ipc.ts | 19 +++++++++++++++++++ 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index c8ce69244..4f80d1071 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -1,3 +1,4 @@ +import type { CoachmarkBeakPayload } from '../../types/ipc' import { ipcMain } from 'electron' import type { BrowserWindow, WebContents } from 'electron' import { TITLEBAR_HEIGHT } from '../lib/titleBarOverlay' @@ -280,10 +281,8 @@ function repositionAndShow( }) entry.view.popup.setBounds(bounds) // Tell the card where to draw its beak now that the final, possibly clamped, x is known. - entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { - beakFraction, - cardCentreInView - }) + const beakPayload: CoachmarkBeakPayload = { beakFraction, cardCentreInView } + entry.view.popup.webContents.send('comfy-titletooltip:set-beak', beakPayload) // Focus so the dismiss button is keyboard-reachable. entry.view.showOnTop({ focus: true }) } diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index 26021f4d6..7a9bde35d 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -1,3 +1,4 @@ +import type { CoachmarkBeakPayload } from '../types/ipc' import { contextBridge, ipcRenderer } from 'electron' import type { IpcRendererEvent } from 'electron' @@ -33,9 +34,7 @@ export interface ComfyTitleTooltipBridge { onConfig(cb: (config: TitleTooltipConfig) => void): () => void /** Beak position, pushed after main has measured the card and settled its final (possibly * clamped) bounds. Separate from the config push because it is only knowable then. */ - onBeak( - cb: (payload: { beakFraction: number; cardCentreInView: number | null }) => void - ): () => void + onBeak(cb: (payload: CoachmarkBeakPayload) => void): () => void /** Coachmark dismiss button; no-op for the tooltip variant. `configToken` names the card * the click landed on, so main can discard a click from a card it has since replaced. */ dismissCoachmark(configToken: string): void diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index 531d68a1f..dc4afe19c 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -1,4 +1,5 @@