diff --git a/.changeset/fullpage-container-sizing.md b/.changeset/fullpage-container-sizing.md new file mode 100644 index 0000000..f46a5ef --- /dev/null +++ b/.changeset/fullpage-container-sizing.md @@ -0,0 +1,5 @@ +--- +'@smooai/chat-widget': patch +--- + +Full-page mode now sizes to its **container**, never a hardcoded viewport unit. Previously `:host` and the inner `.panel.fullpage` both pinned `min-height: 100vh`, so `mountFullPageChat` into a fixed-height (`overflow: hidden`) box overflowed it and clipped the composer out of view — visitors saw only the example-prompt chips with no way to type (hit live on chakrabpc.com/transformation-posture). The host now hands its box down through a `.wrap` flex chain (`.panel.fullpage { flex: 1 }`), and a `data-viewport-fallback` attribute — set only when a layout probe finds the container gives the host no resolved height (e.g. mounted straight into an auto-height ``) — restores the `min-height: 100dvh` viewport fill for bare full-page routes. Page-side `::part(panel)` overrides remain compatible. diff --git a/src/element.test.ts b/src/element.test.ts index d592a01..dbaf186 100644 --- a/src/element.test.ts +++ b/src/element.test.ts @@ -280,3 +280,37 @@ describe(' render', () => { }); }); }); + +describe('fullpage container sizing (composer-clip regression)', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + function mountFullpage(clientHeight: number): HTMLElement { + defineChatWidget(); + const el = document.createElement(ELEMENT_TAG); + // jsdom has no layout: emulate what a sized container (positive) vs an + // auto-height mount (0) resolves the host's box to. + Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true }); + el.setAttribute('endpoint', 'wss://e/ws'); + el.setAttribute('agent-id', 'a1'); + el.setAttribute('mode', 'fullpage'); + document.body.appendChild(el); + return el; + } + + it('renders the .wrap flex chain that hands the host box to the panel', () => { + const el = mountFullpage(600); + expect(el.shadowRoot!.querySelector('.wrap .panel.fullpage')).not.toBeNull(); + }); + + it('does NOT apply the viewport fallback when the container gives the host a box', () => { + const el = mountFullpage(600); + expect(el.hasAttribute('data-viewport-fallback')).toBe(false); + }); + + it('applies the viewport fallback when the container gives the host no height (bare body mount)', () => { + const el = mountFullpage(0); + expect(el.hasAttribute('data-viewport-fallback')).toBe(true); + }); +}); diff --git a/src/element.ts b/src/element.ts index b434b82..ed2324e 100644 --- a/src/element.ts +++ b/src/element.ts @@ -342,6 +342,7 @@ export class SmoothAgentChatElement extends HTMLElement { `; const container = document.createElement('div'); + container.className = 'wrap'; container.innerHTML = ` ${fullpage ? '' : ``}
@@ -407,6 +408,10 @@ export class SmoothAgentChatElement extends HTMLElement { })(); }); + // Full-page mode sizes to the host's box; fall back to the viewport only + // when the container gives the host no height. + if (fullpage) this.syncViewportFallback(); + // Full-page mode connects eagerly (there's no launcher click to trigger it) — // but only once any identity gate is cleared. if (fullpage && !gating) void this.controller?.connect().catch(() => {}); @@ -998,6 +1003,25 @@ export class SmoothAgentChatElement extends HTMLElement { } } + /** + * Full-page sizing probe: decide whether the host's container gives it a + * real box. With the `.wrap` flex chain hidden, `height: 100%` of a SIZED + * container still resolves (clientHeight > 0), while an auto-height parent + * (e.g. mounted straight into ``) collapses to ~0. Only the latter + * gets `data-viewport-fallback`, whose CSS applies `min-height: 100dvh` — + * so an embed inside a fixed-height box never overflows it (the composer + * stays visible), and a bare full-page route still fills the viewport. + */ + private syncViewportFallback(): void { + const wrap = this.shadowRoot?.querySelector('.wrap'); + if (!wrap) return; + const prev = wrap.style.display; + wrap.style.display = 'none'; + const heightless = this.clientHeight < 8; + wrap.style.display = prev; + this.toggleAttribute('data-viewport-fallback', heightless); + } + /** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */ private resetReveal(): void { if (this.rafId && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.rafId); diff --git a/src/styles.test.ts b/src/styles.test.ts index 2ad9e00..1fba704 100644 --- a/src/styles.test.ts +++ b/src/styles.test.ts @@ -26,11 +26,18 @@ describe('buildStyles', () => { expect(css).toContain('bottom: 24px'); }); - it('fills its container (not fixed) in fullpage mode', () => { + it('fills its container (not fixed) in fullpage mode — no unconditional viewport unit', () => { const css = buildStyles(theme, 'fullpage'); // The fullpage :host fills its container instead of pinning to the viewport. expect(css).not.toContain('position: fixed'); - expect(css).toContain('min-height: 100vh'); + // Regression (composer clipped inside fixed-height containers): fullpage + // must NOT hardcode a viewport min-height on the host or panel … + expect(css).not.toContain('min-height: 100vh'); + // … only the attribute-gated fallback for auto-height mounts remains, + expect(css).toContain(':host([data-viewport-fallback]) { min-height: 100dvh; }'); + // and the .wrap flex chain hands the host's box down to the panel. + expect(css).toContain('.wrap {'); + expect(css).toMatch(/\.panel\.fullpage \{[^}]*flex: 1/); }); it('always ships the launcher, panel, typing indicator, and reduced-motion guard', () => { diff --git a/src/styles.ts b/src/styles.ts index 073b843..a17cc70 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -44,11 +44,11 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove ${ mode === 'fullpage' ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */ - display: block; + display: flex; + flex-direction: column; position: relative; width: 100%; - height: 100%; - min-height: 100vh;` + height: 100%;` : `/* Popover: float in the bottom-right corner. */ position: fixed; bottom: 24px; @@ -58,7 +58,24 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; } - +${ + mode === 'fullpage' + ? ` +/* Viewport fallback — the element sets this attribute only when the host's + container gives it no resolved height (e.g. mounted straight into an + auto-height ). A sized container always wins, so an embed inside a + fixed-height box never overflows it (composer stays visible). */ +:host([data-viewport-fallback]) { min-height: 100dvh; } +/* The render wrapper passes the host's box down to the panel via flex. */ +.wrap { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} +` + : '' +} * { box-sizing: border-box; } /* ───────────────────────────── Launcher ───────────────────────────── */ @@ -140,11 +157,13 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove pointer-events: none; background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%); } -/* Full-page: the panel becomes the whole surface. */ +/* Full-page: the panel becomes the whole surface — it follows the host's box + (via the .wrap flex chain), never a hardcoded viewport unit. */ .panel.fullpage { width: 100%; - height: 100%; - min-height: 100vh; + flex: 1; + height: auto; + min-height: 0; max-width: none; max-height: none; border: none;