Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fullpage-container-sizing.md
Original file line number Diff line number Diff line change
@@ -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 `<body>`) — restores the `min-height: 100dvh` viewport fill for bare full-page routes. Page-side `::part(panel)` overrides remain compatible.
34 changes: 34 additions & 0 deletions src/element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,37 @@ describe('<smooth-agent-chat> 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 <body> 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);
});
});
24 changes: 24 additions & 0 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export class SmoothAgentChatElement extends HTMLElement {
</div>`;

const container = document.createElement('div');
container.className = 'wrap';
container.innerHTML = `
${fullpage ? '' : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
<div class="panel${fullpage ? ' fullpage' : ' hidden'}" part="panel" role="${fullpage ? 'region' : 'dialog'}" aria-label="${escapeHtml(resolved.agentName)} chat">
Expand Down Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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 `<body>`) 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<HTMLElement>('.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);
Expand Down
11 changes: 9 additions & 2 deletions src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
33 changes: 26 additions & 7 deletions src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <body>). 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 ───────────────────────────── */
Expand Down Expand Up @@ -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;
Expand Down
Loading