From 82afeecf5e5213554b7ddb21aeb55059afb14c9a Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Wed, 19 Aug 2026 08:50:38 +0200 Subject: [PATCH] fix(toast): stop clipping messages wider than the screen `.shellular-toast` was `white-space: nowrap` with no `max-width`, positioned with `left: 50%` and `translateX(-50%)`. Anything wider than the viewport therefore overflowed symmetrically and was clipped at BOTH ends, losing the start and the finish of the message. Measured on a 390px viewport: "Could not reach : WebSocket closed before session handshake" rendered 471px wide at x = -40..430. Toasts carry host names, absolute file paths and raw CLI error text, so this is reachable today: "Update failed: " in ConnectionInfo and "Downloaded to " in FileList both interpolate arbitrary length. The single line was load-bearing, which is why this is not a one-line change. Stacking positioned each toast with `bottom: 24 + index * (TOAST_HEIGHT + GAP)` and so needed a known, fixed height. Moving the stack into a flex container makes height irrelevant, after which the toast can wrap. Same visual order, newest above oldest, and the same offsets for the keyboard and safe area. That also retires an index bug: `toastCount` was decremented when a toast finished animating out, so a new toast could be handed the slot of one still on screen. Message text now goes in through a text node instead of `innerHTML`. It is interpolated from CLI-supplied strings, which had no business being parsed as markup. --- src/lib/toast.ts | 65 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/lib/toast.ts b/src/lib/toast.ts index d75d2eb..4d324bb 100644 --- a/src/lib/toast.ts +++ b/src/lib/toast.ts @@ -1,7 +1,5 @@ let styleInjected = false; -let toastCount = 0; -const GAP = 10; -const TOAST_HEIGHT = 40; +let stack: HTMLDivElement | null = null; const DEFAULT_TIMEOUT = 2000; function injectStyles() { @@ -10,14 +8,25 @@ function injectStyles() { const style = document.createElement("style"); style.id = "shellular-toast-styles"; style.textContent = ` - .shellular-toast { + .shellular-toast-stack { position: fixed; - bottom: 24px; left: 50%; transform: translateX(-50%); + bottom: calc(var(--keyboard-height, 0px) + var(--sab, 0px) + 24px); + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 10px; + width: max-content; + max-width: min(calc(100vw - 32px), 420px); + z-index: 9999; + pointer-events: none; + } + .shellular-toast { display: flex; align-items: center; gap: 8px; + max-width: 100%; padding: 10px 18px; border-radius: 10px; background: var(--popup-background); @@ -25,9 +34,13 @@ function injectStyles() { color: var(--primary-text); font-size: 13px; font-weight: 500; - white-space: nowrap; + text-align: left; + /* A toast carries host names, file paths and raw CLI errors, so it has + to be allowed to wrap. It used to be nowrap with no max-width, which + clipped anything wider than the screen at BOTH ends, because the + element is centred. */ + overflow-wrap: anywhere; box-shadow: 0 8px 32px var(--shadow-color); - z-index: 9999; pointer-events: none; animation: shellularToastIn 180ms ease-out both; } @@ -35,46 +48,57 @@ function injectStyles() { animation: shellularToastOut 150ms ease-in both; } .shellular-toast .icon-check { + flex-shrink: 0; font-size: 14px; color: var(--success); } @keyframes shellularToastIn { from { opacity: 0; - transform: translateX(-50%) translateY(8px) scale(0.95); + transform: translateY(8px) scale(0.95); } to { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } } @keyframes shellularToastOut { from { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } to { opacity: 0; - transform: translateX(-50%) translateY(8px) scale(0.95); + transform: translateY(8px) scale(0.95); } } `; document.head.appendChild(style); } +function getStack(): HTMLDivElement { + if (stack?.isConnected) return stack; + stack = document.createElement("div"); + stack.className = "shellular-toast-stack"; + document.body.appendChild(stack); + return stack; +} + export default function toast(msg: string, timeoutMs?: number): void { injectStyles(); const el = document.createElement("div"); el.className = "shellular-toast"; - el.innerHTML = `${msg}`; - - const index = toastCount++; + const icon = document.createElement("span"); + icon.className = "icon-check"; + icon.setAttribute("aria-hidden", "true"); + el.append(icon, document.createTextNode(msg)); - el.style.bottom = `calc(var(--keyboard-height, 0px) + var(--sab, 0px) + ${ - 24 + index * (TOAST_HEIGHT + GAP) - }px)`; - document.body.appendChild(el); + // Stacking is the container's job. Doing it with a per-toast offset needed a + // fixed toast height, which is what forced the single line in the first + // place, and its index counter also let a new toast land on the slot of one + // that was still animating out. + getStack().appendChild(el); const duration = timeoutMs ?? DEFAULT_TIMEOUT; @@ -82,7 +106,10 @@ export default function toast(msg: string, timeoutMs?: number): void { el.classList.add("shellular-toast-out"); el.addEventListener("animationend", () => { el.remove(); - toastCount--; + if (stack && !stack.childElementCount) { + stack.remove(); + stack = null; + } }); }, duration); }