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;