From a25c55c3abb04b33385ae325c0b2a3c3241e8764 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 01/10] test(ui): behaviour tests for the call parts (red first) --- .../svelte/src/lib/widgets/call/call.test.ts | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/call.test.ts diff --git a/packages/svelte/src/lib/widgets/call/call.test.ts b/packages/svelte/src/lib/widgets/call/call.test.ts new file mode 100644 index 00000000..00ad7ac3 --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/call.test.ts @@ -0,0 +1,378 @@ +// @file widgets/call/call.test.ts +// @description NEW 2026-09-30 (call UI new look, slice 0). Behaviour tests for the +// call parts on `./ui`: ControlButton, ControlBar, CountBadge, ParticipantTile, +// FloatingDock, IncomingCallCard and BottomSheet, plus the `success` variant on +// the primitives Button. Written red first. Pure presentation: no media, no +// LiveKit. Layout (rects) is stubbed where jsdom has none; CSS-only behaviour +// (the bar label hiding under 640px, the glows) is not asserted here. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import ControlButton from './ControlButton.svelte'; +import ControlBar from './ControlBar.svelte'; +import CountBadge from './CountBadge.svelte'; +import ParticipantTile from './ParticipantTile.svelte'; +import FloatingDock from './FloatingDock.svelte'; +import IncomingCallCard from './IncomingCallCard.svelte'; +import BottomSheet from './BottomSheet.svelte'; +import { Button } from '../../components/ui/button/index.js'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const html = (markup: string) => createRawSnippet(() => ({ render: () => markup })); + +describe('ControlButton', () => { + it('is a native button that forwards attrs and fires onclick', async () => { + const onclick = vi.fn(); + const { container } = render(ControlButton, { + 'aria-label': 'Mute', + 'data-testid': 'mic', + onclick, + icon: html(''), + }); + const btn = container.querySelector('button')!; + expect(btn.getAttribute('type')).toBe('button'); + expect(btn.getAttribute('aria-label')).toBe('Mute'); + expect(btn.getAttribute('data-testid')).toBe('mic'); + expect(btn.querySelector('[data-icon]')).not.toBeNull(); + await fireEvent.click(btn); + expect(onclick).toHaveBeenCalledOnce(); + }); + + it('reflects pressed as aria-pressed on/off, and omits it when unset', async () => { + const { container, rerender } = render(ControlButton, { 'aria-label': 'Camera', pressed: true }); + const btn = container.querySelector('button')!; + expect(btn.getAttribute('aria-pressed')).toBe('true'); + await rerender({ 'aria-label': 'Camera', pressed: false }); + expect(btn.getAttribute('aria-pressed')).toBe('false'); + await rerender({ 'aria-label': 'Camera', pressed: undefined }); + expect(btn.hasAttribute('aria-pressed')).toBe(false); + }); + + it('exposes tone, size and variant as data attributes', () => { + const { container } = render(ControlButton, { + 'aria-label': 'Leave', + tone: 'danger', + size: 'compact', + variant: 'pill', + }); + const btn = container.querySelector('button')!; + expect(btn.dataset.tone).toBe('danger'); + expect(btn.dataset.size).toBe('compact'); + expect(btn.dataset.variant).toBe('pill'); + }); + + it('shows the text label only in the bar variant', async () => { + const { container, rerender } = render(ControlButton, { 'aria-label': 'Mute', label: 'Mute', variant: 'bar' }); + expect(container.querySelector('[data-slot="control-label"]')!.textContent).toBe('Mute'); + await rerender({ 'aria-label': 'Mute', label: 'Mute', variant: 'classic' }); + expect(container.querySelector('[data-slot="control-label"]')).toBeNull(); + await rerender({ 'aria-label': 'Mute', label: 'Mute', variant: 'pill' }); + expect(container.querySelector('[data-slot="control-label"]')).toBeNull(); + }); + + it('forwards disabled to the native button (the browser then blocks clicks)', () => { + const { container } = render(ControlButton, { 'aria-label': 'Share', disabled: true }); + expect(container.querySelector('button')!.disabled).toBe(true); + }); + + it('renders a count badge with a caller test id', () => { + const { container } = render(ControlButton, { + 'aria-label': 'Open chat', + count: 3, + badgeTestId: 'call-chat-badge', + }); + expect(container.querySelector('[data-testid="call-chat-badge"]')!.textContent!.trim()).toBe('3'); + }); +}); + +describe('ControlBar', () => { + it('is a labelled group carrying its variant and size, and renders children', () => { + const { container } = render(ControlBar, { + label: 'Call controls', + variant: 'bar', + size: 'compact', + 'data-testid': 'bar', + children: html(''), + }); + const bar = container.querySelector('[data-testid="bar"]')!; + expect(bar.getAttribute('role')).toBe('group'); + expect(bar.getAttribute('aria-label')).toBe('Call controls'); + expect((bar as HTMLElement).dataset.variant).toBe('bar'); + expect((bar as HTMLElement).dataset.size).toBe('compact'); + expect(bar.querySelector('button')).not.toBeNull(); + }); +}); + +describe('CountBadge', () => { + it('shows N, 99+ over 99, @ for a mention, and nothing for 0', async () => { + const { container, rerender } = render(CountBadge, { count: 7, 'data-testid': 'b' }); + const text = () => container.querySelector('[data-testid="b"]')?.textContent?.trim(); + expect(text()).toBe('7'); + await rerender({ count: 99, 'data-testid': 'b' }); + expect(text()).toBe('99'); + await rerender({ count: 100, 'data-testid': 'b' }); + expect(text()).toBe('99+'); + await rerender({ count: 4, mention: true, 'data-testid': 'b' }); + expect(text()).toBe('@'); + expect(container.querySelector('[data-mention]')).not.toBeNull(); + await rerender({ count: 0, mention: false, 'data-testid': 'b' }); + expect(container.querySelector('[data-testid="b"]')).toBeNull(); + await rerender({ count: 0, mention: true, 'data-testid': 'b' }); + expect(text()).toBe('@'); + }); +}); + +describe('ParticipantTile', () => { + it('shows the video snippet when given, instead of the avatar', () => { + const { container } = render(ParticipantTile, { name: 'Ada Lovelace', video: html('') }); + expect(container.querySelector('[data-v]')).not.toBeNull(); + expect(container.querySelector('[data-slot="tile-avatar"]')).toBeNull(); + }); + + it('falls back to the ripple Avatar with initials, or a caller avatar snippet', async () => { + const { container, rerender } = render(ParticipantTile, { name: 'Ada Lovelace' }); + expect(container.querySelector('[data-slot="tile-avatar"]')!.textContent).toContain('AL'); + await rerender({ name: 'Paw', avatar: html('') }); + expect(container.querySelector('[data-paw]')).not.toBeNull(); + }); + + it('marks speaking, carries skin and size, and forwards attrs', () => { + const { container } = render(ParticipantTile, { + name: 'Maya', + speaking: true, + skin: 'focus', + size: 'lg', + 'data-testid': 'tile', + }); + const tile = container.querySelector('[data-testid="tile"]') as HTMLElement; + expect(tile.hasAttribute('data-speaking')).toBe(true); + expect(tile.dataset.skin).toBe('focus'); + expect(tile.dataset.size).toBe('lg'); + }); + + it('shows the name pill, a muted indicator with text, badge and menu slots', () => { + const { container } = render(ParticipantTile, { + name: 'Maya Chen', + muted: true, + badge: html('Agent'), + menu: html(''), + }); + const pill = container.querySelector('[data-slot="tile-name"]')!; + expect(pill.textContent).toContain('Maya Chen'); + expect(pill.textContent).toContain('muted'); + expect(pill.querySelector('[data-badge]')).not.toBeNull(); + expect(container.querySelector('[data-menu]')).not.toBeNull(); + }); + + it('the orb size hides the name pill and the menu but keeps the name as a title', () => { + const { container } = render(ParticipantTile, { name: 'Maya', size: 'orb', menu: html('') }); + expect(container.querySelector('[data-slot="tile-name"]')).toBeNull(); + expect(container.querySelector('[data-menu]')).toBeNull(); + expect(container.firstElementChild!.getAttribute('title')).toBe('Maya'); + }); +}); + +/** Stubs layout: a 1000x800 viewport / parent and a 200x100 dock at (100,100). */ +function stubLayout(dock: HTMLElement) { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === dock) return { left: 100, top: 100, width: 200, height: 100, right: 300, bottom: 200, x: 100, y: 100 } as DOMRect; + return { left: 0, top: 0, width: 1000, height: 800, right: 1000, bottom: 800, x: 0, y: 0 } as DOMRect; + }); + Object.defineProperty(dock, 'offsetWidth', { configurable: true, value: 200 }); + Object.defineProperty(dock, 'offsetHeight', { configurable: true, value: 100 }); + // bounds="window" (the default) measures the viewport. + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); +} + +describe('FloatingDock', () => { + it('is a labelled, focusable region starting in the given corner', () => { + const { container } = render(FloatingDock, { label: 'Call dock', corner: 'top-left', children: html('

x

') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + expect(dock.getAttribute('aria-label')).toBe('Call dock'); + expect(dock.tabIndex).toBe(0); + expect(dock.dataset.corner).toBe('top-left'); + }); + + it('drags, snaps to the nearer side on release and reports the position', async () => { + const onPositionChange = vi.fn(); + const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('

x

') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + stubLayout(dock); + await fireEvent.pointerDown(dock, { clientX: 150, clientY: 150, button: 0, pointerId: 1 }); + // grab offset 50,50 → move to (250,450) puts the dock's left at 200: nearer the left side. + await fireEvent.pointerMove(dock, { clientX: 250, clientY: 450, pointerId: 1 }); + await fireEvent.pointerUp(dock, { clientX: 250, clientY: 450, pointerId: 1 }); + expect(onPositionChange).toHaveBeenLastCalledWith({ x: 12, y: 400 }); + expect(dock.style.left).toBe('12px'); + expect(dock.style.top).toBe('400px'); + }); + + it('snaps right and clamps inside the bounds', async () => { + const onPositionChange = vi.fn(); + const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('

x

') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + stubLayout(dock); + await fireEvent.pointerDown(dock, { clientX: 150, clientY: 150, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(dock, { clientX: 900, clientY: 5000, pointerId: 1 }); + await fireEvent.pointerUp(dock, { pointerId: 1 }); + // right edge: 1000 - 200 - 12; bottom clamp: 800 - 100 - 12. + expect(onPositionChange).toHaveBeenLastCalledWith({ x: 788, y: 688 }); + }); + + it('ignores drags that start on a button', async () => { + const onPositionChange = vi.fn(); + const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + stubLayout(dock); + const btn = container.querySelector('[data-b]')!; + await fireEvent.pointerDown(btn, { clientX: 150, clientY: 150, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(dock, { clientX: 400, clientY: 400, pointerId: 1 }); + await fireEvent.pointerUp(dock, { pointerId: 1 }); + expect(onPositionChange).not.toHaveBeenCalled(); + expect(dock.style.left).toBe(''); + }); + + it('re-clamps on window resize', async () => { + const onPositionChange = vi.fn(); + const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('

x

') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + stubLayout(dock); + await fireEvent.pointerDown(dock, { clientX: 150, clientY: 150, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(dock, { clientX: 900, clientY: 700, pointerId: 1 }); + await fireEvent.pointerUp(dock, { pointerId: 1 }); + // The bounds shrink to 500x400. + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === dock) return { left: 0, top: 0, width: 200, height: 100 } as DOMRect; + return { left: 0, top: 0, width: 500, height: 400, right: 500, bottom: 400, x: 0, y: 0 } as DOMRect; + }); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 400 }); + await fireEvent(window, new Event('resize')); + expect(dock.style.left).toBe('288px'); + expect(dock.style.top).toBe('288px'); + }); + + it('moves with the arrow keys when the region itself has focus', async () => { + const onPositionChange = vi.fn(); + const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('

x

') }); + const dock = container.querySelector('[role="region"]') as HTMLElement; + stubLayout(dock); + await fireEvent.keyDown(dock, { key: 'ArrowUp' }); + // From the dock's rect (100,100): up by 16. + expect(onPositionChange).toHaveBeenLastCalledWith({ x: 100, y: 84 }); + }); +}); + +describe('IncomingCallCard', () => { + it('is an alert with title, subtitle, avatar and both action snippets', () => { + const { container } = render(IncomingCallCard, { + title: 'Maya Chen', + subtitle: 'Incoming call', + 'data-testid': 'incoming', + primary: html(''), + secondary: html(''), + }); + const card = container.querySelector('[data-testid="incoming"]')!; + expect(card.getAttribute('role')).toBe('alert'); + expect(card.textContent).toContain('Maya Chen'); + expect(card.textContent).toContain('Incoming call'); + expect(card.querySelector('[data-accept]')).not.toBeNull(); + expect(card.querySelector('[data-decline]')).not.toBeNull(); + expect(card.querySelector('[data-slot="call-card-halo"]')).not.toBeNull(); + }); + + it('takes a status role and drops the halo for joinable / rejoin cards', () => { + const { container } = render(IncomingCallCard, { title: 'Call in progress', role: 'status', halo: false }); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="call-card-halo"]')).toBeNull(); + }); +}); + +describe('Button success variant (primitives)', () => { + it('paints with the ripple success tokens', () => { + const { container } = render(Button, { variant: 'success', children: html('Accept') }); + expect(container.querySelector('button')!.className).toContain('bg-ripple-success'); + }); +}); + +describe('BottomSheet', () => { + const setup = (props: Record = {}) => { + const onStopChange = vi.fn(); + const r = render(BottomSheet, { + label: 'Call chat', + handleTestId: 'handle', + onStopChange, + children: html('

body

'), + ...props, + }); + const sheet = r.container.querySelector('[data-slot="bottom-sheet"]') as HTMLElement; + const handle = r.container.querySelector('[data-testid="handle"]') as HTMLElement; + return { ...r, sheet, handle, onStopChange }; + }; + + it('is a labelled, non-modal region starting at peek', () => { + const { sheet } = setup(); + expect(sheet.dataset.state).toBe('peek'); + expect(sheet.getAttribute('aria-label')).toBe('Call chat'); + expect(sheet.getAttribute('role')).toBe('region'); + expect(sheet.hasAttribute('aria-modal')).toBe(false); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('Enter/Space toggle peek → half → full → half and keep the content mounted', async () => { + const { sheet, handle, container, onStopChange } = setup(); + const body = container.querySelector('[data-body]'); + await fireEvent.keyDown(handle, { key: 'Enter' }); + expect(sheet.dataset.state).toBe('half'); + await fireEvent.keyDown(handle, { key: ' ' }); + expect(sheet.dataset.state).toBe('full'); + await fireEvent.keyDown(handle, { key: 'Enter' }); + expect(sheet.dataset.state).toBe('half'); + expect(onStopChange.mock.calls.map((c) => c[0])).toEqual(['half', 'full', 'half']); + expect(container.querySelector('[data-body]')).toBe(body); + }); + + it('ArrowUp / ArrowDown step between stops and stop at the ends', async () => { + const { sheet, handle } = setup({ stop: 'half' }); + await fireEvent.keyDown(handle, { key: 'ArrowUp' }); + expect(sheet.dataset.state).toBe('full'); + await fireEvent.keyDown(handle, { key: 'ArrowUp' }); + expect(sheet.dataset.state).toBe('full'); + await fireEvent.keyDown(handle, { key: 'ArrowDown' }); + await fireEvent.keyDown(handle, { key: 'ArrowDown' }); + expect(sheet.dataset.state).toBe('peek'); + await fireEvent.keyDown(handle, { key: 'ArrowDown' }); + expect(sheet.dataset.state).toBe('peek'); + }); + + it('a drag past 48px moves one stop; under 6px is a tap; in between does nothing', async () => { + const { sheet, handle } = setup(); + await fireEvent.pointerDown(handle, { clientY: 500, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + await fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + expect(sheet.dataset.state).toBe('half'); + await fireEvent.pointerDown(handle, { clientY: 500, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(handle, { clientY: 520, pointerId: 1 }); + await fireEvent.pointerUp(handle, { clientY: 520, pointerId: 1 }); + expect(sheet.dataset.state).toBe('half'); + await fireEvent.pointerDown(handle, { clientY: 500, button: 0, pointerId: 1 }); + await fireEvent.pointerUp(handle, { clientY: 502, pointerId: 1 }); + expect(sheet.dataset.state).toBe('full'); + await fireEvent.pointerDown(handle, { clientY: 300, button: 0, pointerId: 1 }); + await fireEvent.pointerMove(handle, { clientY: 400, pointerId: 1 }); + await fireEvent.pointerUp(handle, { clientY: 400, pointerId: 1 }); + expect(sheet.dataset.state).toBe('half'); + }); + + it('labels the handle per stop through handleLabel', async () => { + const { handle } = setup({ handleLabel: (s: string) => `chat ${s}` }); + expect(handle.getAttribute('aria-label')).toBe('chat peek'); + await fireEvent.keyDown(handle, { key: 'Enter' }); + expect(handle.getAttribute('aria-label')).toBe('chat half'); + }); +}); From 99169f887a8bbd2b78c22b11be131614f0e05959 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 02/10] feat(ui): ControlButton, ControlBar and CountBadge for call controls --- .../src/lib/widgets/call/ControlBar.svelte | 81 +++++++ .../src/lib/widgets/call/ControlButton.svelte | 205 ++++++++++++++++++ .../src/lib/widgets/call/CountBadge.svelte | 57 +++++ 3 files changed, 343 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/ControlBar.svelte create mode 100644 packages/svelte/src/lib/widgets/call/ControlButton.svelte create mode 100644 packages/svelte/src/lib/widgets/call/CountBadge.svelte diff --git a/packages/svelte/src/lib/widgets/call/ControlBar.svelte b/packages/svelte/src/lib/widgets/call/ControlBar.svelte new file mode 100644 index 00000000..9ed762e6 --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/ControlBar.svelte @@ -0,0 +1,81 @@ + + + +
+ {@render children?.()} +
+ + diff --git a/packages/svelte/src/lib/widgets/call/ControlButton.svelte b/packages/svelte/src/lib/widgets/call/ControlButton.svelte new file mode 100644 index 00000000..c89f1d2b --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/ControlButton.svelte @@ -0,0 +1,205 @@ + + + + + + diff --git a/packages/svelte/src/lib/widgets/call/CountBadge.svelte b/packages/svelte/src/lib/widgets/call/CountBadge.svelte new file mode 100644 index 00000000..e2a2f9b1 --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/CountBadge.svelte @@ -0,0 +1,57 @@ + + + +{#if mention || count > 0} + {text} +{/if} + + From ba25210c04557b9a5f1d231168a802e1885d940c Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 03/10] feat(ui): ParticipantTile with glass, focus and classic skins --- .../lib/widgets/call/ParticipantTile.svelte | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/ParticipantTile.svelte diff --git a/packages/svelte/src/lib/widgets/call/ParticipantTile.svelte b/packages/svelte/src/lib/widgets/call/ParticipantTile.svelte new file mode 100644 index 00000000..e08f02ad --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/ParticipantTile.svelte @@ -0,0 +1,240 @@ + + + +
+ {#if video && !orb} +
{@render video()}
+ {:else} +
+ {#if avatar} + {@render avatar()} + {:else} + + {/if} +
+ {/if} + + {#if menu && !orb} +
{@render menu()}
+ {/if} + + {#if !orb} +
+ {#if muted}
+ {/if} +
+ + From 486b0437ab42fa38663305d4dbaf4e4b2feed283 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 04/10] feat(ui): FloatingDock, a draggable container that snaps and clamps --- .../src/lib/widgets/call/FloatingDock.svelte | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/FloatingDock.svelte diff --git a/packages/svelte/src/lib/widgets/call/FloatingDock.svelte b/packages/svelte/src/lib/widgets/call/FloatingDock.svelte new file mode 100644 index 00000000..f98b20f7 --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/FloatingDock.svelte @@ -0,0 +1,178 @@ + + + + pos && (pos = clamp(pos.x, pos.y))} /> + +
+ {@render children?.()} +
+ + From 4e295677f7def365ab73ff2fc415cb18f3dcf607 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 05/10] feat(ui): IncomingCallCard and a success variant on the primitives Button --- .../lib/components/ui/button/button.svelte | 3 + .../lib/widgets/call/IncomingCallCard.svelte | 124 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/IncomingCallCard.svelte diff --git a/packages/svelte/src/lib/components/ui/button/button.svelte b/packages/svelte/src/lib/components/ui/button/button.svelte index b6072006..4b49be19 100644 --- a/packages/svelte/src/lib/components/ui/button/button.svelte +++ b/packages/svelte/src/lib/components/ui/button/button.svelte @@ -1,4 +1,6 @@ + + +
+ {#if halo || avatar || name || src} + + {#if avatar} + {@render avatar()} + {:else} + + {/if} + {#if halo} + + {/if} + + {/if} +
+ {title} + {#if subtitle}{subtitle}{/if} +
+ {#if primary || secondary} +
+ {@render secondary?.()} + {@render primary?.()} +
+ {/if} +
+ + From e6c400d58a81d7e9513ea6e176c7f79eff305590 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 06/10] feat(ui): BottomSheet with peek, half and full stops --- .../src/lib/widgets/call/BottomSheet.svelte | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 packages/svelte/src/lib/widgets/call/BottomSheet.svelte diff --git a/packages/svelte/src/lib/widgets/call/BottomSheet.svelte b/packages/svelte/src/lib/widgets/call/BottomSheet.svelte new file mode 100644 index 00000000..836eb230 --- /dev/null +++ b/packages/svelte/src/lib/widgets/call/BottomSheet.svelte @@ -0,0 +1,183 @@ + + + +
+
+ +
+
{@render children?.()}
+
+ + From 25b7ca2315ca9cee10aa199820c13ff397dca125 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:48:05 +0530 Subject: [PATCH 07/10] feat(ui): export the call parts and Slider from ./ui --- packages/svelte/src/lib/ui/index.ts | 18 ++++++++++++++++++ packages/svelte/src/lib/ui/ui-contract.test.ts | 14 +++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/lib/ui/index.ts b/packages/svelte/src/lib/ui/index.ts index ea526372..8309a9ff 100644 --- a/packages/svelte/src/lib/ui/index.ts +++ b/packages/svelte/src/lib/ui/index.ts @@ -78,6 +78,14 @@ * banners. EmptyState gained icon-snippet / size / tone / actions in place. * The manifest count still reads 189. * + * Updated 2026-09-30 (call UI new look, slice 0): the call parts join as new + * ./ui-only exports (no registry entry, no manifest entry, count still 189): + * `CountBadge` and `ControlButton` (atoms), `ControlBar`, `ParticipantTile`, + * `IncomingCallCard` (molecules), `FloatingDock` and `BottomSheet` + * (organisms: they own drag and stop state). The existing shadcn `Slider` + * joins the overlays' namespace shape (`Slider.Root`) for the call volume + * popover. + * * Updated 2026-09-14 (beautiful-ui re-skin, lane C): two organisms added, * `TaskRows` and `PromptBar`. They are the first exports here that exist ONLY * on this surface — no spec-registry entry and no manifest entry, so the @@ -100,6 +108,8 @@ export { default as ProgressRing } from '../widgets/display/ProgressRing.svelte' export { default as Shimmer } from '../widgets/premium/Shimmer.svelte'; export { default as Separator } from '../widgets/layout/Separator.svelte'; export { default as Kbd } from '../widgets/display/Kbd.svelte'; +export { default as CountBadge } from '../widgets/call/CountBadge.svelte'; +export { default as ControlButton } from '../widgets/call/ControlButton.svelte'; /* ── molecules ───────────────────────────────────────────────────────────── Compose atoms; still no app state. */ @@ -119,6 +129,12 @@ export { default as SectionHeader } from '../widgets/layout/SectionHeader.svelte export { default as PanelHeader } from '../widgets/layout/PanelHeader.svelte'; export { default as PageHeader } from '../widgets/layout/PageHeader.svelte'; export { default as InlineAlert } from '../widgets/display/InlineAlert.svelte'; +export { default as ControlBar } from '../widgets/call/ControlBar.svelte'; +export { default as ParticipantTile } from '../widgets/call/ParticipantTile.svelte'; +export { default as IncomingCallCard } from '../widgets/call/IncomingCallCard.svelte'; + +/* ── form parts with sub-parts: shadcn composable namespace ────────────── */ +export * as Slider from '../components/ui/slider/index.js'; /* ── overlays ────────────────────────────────────────────────────────────── The canonical overlay set, one namespace per kind (anchored / modal / @@ -153,3 +169,5 @@ export { default as ContextCards } from '../widgets/ai/ContextCards.svelte'; export { default as RecommendationCard } from '../widgets/ai/RecommendationCard.svelte'; export { default as FineTuneCard } from '../widgets/ai/FineTuneCard.svelte'; export { default as SelectionActions } from '../widgets/ai/SelectionActions.svelte'; +export { default as FloatingDock } from '../widgets/call/FloatingDock.svelte'; +export { default as BottomSheet } from '../widgets/call/BottomSheet.svelte'; diff --git a/packages/svelte/src/lib/ui/ui-contract.test.ts b/packages/svelte/src/lib/ui/ui-contract.test.ts index 277c87d4..7c4eebcd 100644 --- a/packages/svelte/src/lib/ui/ui-contract.test.ts +++ b/packages/svelte/src/lib/ui/ui-contract.test.ts @@ -1,5 +1,7 @@ /** * @file ui/ui-contract.test.ts + * Updated 2026-09-30 (call UI new look, slice 0): PROPS for the call parts and + * `Slider` in the namespace list. * @description The contract behind the `./ui` export: every name listed there * must mount as a plain Svelte component, with no `` wrapper and * no renderer context. @@ -155,6 +157,16 @@ const PROPS: Record> = { // The feature-page exports. PageHeader: { title: 'Agents' }, InlineAlert: { title: 'Could not load agents', tone: 'error' }, + // The call parts (2026-09-30). CountBadge paints only with a count. + CountBadge: { count: 3 }, + ControlButton: { 'aria-label': 'Mute' }, + ControlBar: { label: 'Call controls' }, + ParticipantTile: { name: 'Ada Lovelace' }, + IncomingCallCard: { title: 'Maya Chen', subtitle: 'Incoming call' }, + FloatingDock: { label: 'Call dock' }, + BottomSheet: { label: 'Call chat' }, + // bits-ui's Slider.Root is a union on `type`; it paints its track. + Slider: { type: 'single', value: 1, max: 2 }, }; /** Exported by name but not mountable: the confirm-dialog store. */ @@ -268,7 +280,7 @@ test('the ./ui surface is not empty and every name is a component, namespace or expect(names.length).toBeGreaterThanOrEqual(25); for (const n of components) expect(typeof (ui as never)[n]).toBe('function'); expect(namespaces.sort()).toEqual( - ['Command', 'ConfirmDialog', 'ContextMenu', 'Dialog', 'DropdownMenu', 'HoverCard', 'Popover', 'Sheet', 'Tooltip'] + ['Command', 'ConfirmDialog', 'ContextMenu', 'Dialog', 'DropdownMenu', 'HoverCard', 'Popover', 'Sheet', 'Slider', 'Tooltip'] ); for (const n of namespaces) expect(typeof entry(n), `${n} has no Root`).toBe('function'); expect(typeof ui.confirmDialog).toBe('function'); From 33fd396bf1e94991f9d107aa9d80fc4f39420c52 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:54:27 +0530 Subject: [PATCH 08/10] fix(ui): FloatingDock drops the focusable region and keyboard move (a11y lint; parity with the app dock) --- .../src/lib/widgets/call/FloatingDock.svelte | 34 +++---------------- .../svelte/src/lib/widgets/call/call.test.ts | 13 +------ 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/packages/svelte/src/lib/widgets/call/FloatingDock.svelte b/packages/svelte/src/lib/widgets/call/FloatingDock.svelte index f98b20f7..0c55c10c 100644 --- a/packages/svelte/src/lib/widgets/call/FloatingDock.svelte +++ b/packages/svelte/src/lib/widgets/call/FloatingDock.svelte @@ -11,12 +11,12 @@ - on release it snaps to the nearer side (left/right) and clamps inside its bounds, `gap` px from every edge - re-clamps on window resize (, no $effect) - - keyboard: the region is focusable; arrow keys move it 16px (clamped) while - the region itself has focus + - keyboard: a labelled region; its controls stay in the tab order. There is + no keyboard move (the production dock has none either) bounds: 'window' (position: fixed, the production dock) or 'parent' (position: absolute inside the positioned parent, e.g. a lab frame). Until the first move it sits in `corner`. onPositionChange({x, y}) fires on - drop and on each keyboard move, in bounds-relative px. Tokens only. + drop, in bounds-relative px. Tokens only. --> @@ -109,7 +91,6 @@ bind:this={el} role="region" aria-label={label} - tabindex="0" data-slot="floating-dock" data-corner={corner} data-bounds={bounds} @@ -119,7 +100,6 @@ onpointermove={move} onpointerup={up} onpointercancel={up} - onkeydown={key} {...rest} > {@render children?.()} @@ -142,7 +122,6 @@ transition: left 200ms var(--ripple-ease-out), top 200ms var(--ripple-ease-out); - outline: none; } .ripple-dock[data-bounds='parent'] { position: absolute; @@ -151,9 +130,6 @@ cursor: grabbing; transition: none; } - .ripple-dock:focus-visible { - box-shadow: 0 0 0 2px var(--ripple-ring); - } .ripple-dock[data-corner='top-left'] { top: 12px; left: 12px; diff --git a/packages/svelte/src/lib/widgets/call/call.test.ts b/packages/svelte/src/lib/widgets/call/call.test.ts index 00ad7ac3..46cf2083 100644 --- a/packages/svelte/src/lib/widgets/call/call.test.ts +++ b/packages/svelte/src/lib/widgets/call/call.test.ts @@ -190,11 +190,10 @@ function stubLayout(dock: HTMLElement) { } describe('FloatingDock', () => { - it('is a labelled, focusable region starting in the given corner', () => { + it('is a labelled region starting in the given corner', () => { const { container } = render(FloatingDock, { label: 'Call dock', corner: 'top-left', children: html('

x

') }); const dock = container.querySelector('[role="region"]') as HTMLElement; expect(dock.getAttribute('aria-label')).toBe('Call dock'); - expect(dock.tabIndex).toBe(0); expect(dock.dataset.corner).toBe('top-left'); }); @@ -256,16 +255,6 @@ describe('FloatingDock', () => { expect(dock.style.left).toBe('288px'); expect(dock.style.top).toBe('288px'); }); - - it('moves with the arrow keys when the region itself has focus', async () => { - const onPositionChange = vi.fn(); - const { container } = render(FloatingDock, { label: 'Call dock', onPositionChange, children: html('

x

') }); - const dock = container.querySelector('[role="region"]') as HTMLElement; - stubLayout(dock); - await fireEvent.keyDown(dock, { key: 'ArrowUp' }); - // From the dock's rect (100,100): up by 16. - expect(onPositionChange).toHaveBeenLastCalledWith({ x: 100, y: 84 }); - }); }); describe('IncomingCallCard', () => { From 2116533cd45bb96439f3571bb6ffd9d2ddc2b89f Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:54:28 +0530 Subject: [PATCH 09/10] feat(showcase): /showcase/call demo for the call parts in all three layouts --- .../svelte/src/routes/showcase/+page.svelte | 4 + .../src/routes/showcase/call/+page.svelte | 161 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 packages/svelte/src/routes/showcase/call/+page.svelte diff --git a/packages/svelte/src/routes/showcase/+page.svelte b/packages/svelte/src/routes/showcase/+page.svelte index 9d424efb..0365e0a4 100644 --- a/packages/svelte/src/routes/showcase/+page.svelte +++ b/packages/svelte/src/routes/showcase/+page.svelte @@ -1,5 +1,8 @@ + + +
+

Call parts

+

+ ControlButton, ControlBar, ParticipantTile, FloatingDock, IncomingCallCard, BottomSheet and Slider from ./ui. +

+ +
+ + (layout = v as Layout)} + /> +
+ + +
+
+ {#each people as p (p.id)} + + {#snippet badge()} + {#if p.agent}Agent{/if} + {/snippet} + {#snippet menu()} + + + + + +

Volume {Math.round(volume * 100)}%

+ +
+
+ {/snippet} +
+ {/each} +
+ +
+ + (muted = !muted)}> + {#snippet icon()}{#if muted}{:else}{/if}{/snippet} + + (camera = !camera)}> + {#snippet icon()} + ((chatOpen = !chatOpen), (unread = 0))} + > + {#snippet icon()}{/snippet} + + (log = 'Leave call')}> + {#snippet icon()}{/snippet} + + +
+ + (log = `dock at ${p.x},${p.y}`)}> +
+
+ Design sync + + (muted = !muted)}> + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + +
+
+
+ +
+
+ + {#snippet secondary()} + + {/snippet} + {#snippet primary()} + + {/snippet} + + + {#snippet primary()} + + {/snippet} + +
+ +
+

Sheet stop: {stop}

+ (s === 'peek' ? 'Open chat' : s === 'half' ? 'Expand chat' : 'Shrink chat')}> +

Maya: the deck is in the channel.

+
+
+
+ +

{log}

+
From 940e2ac322a2b623afb0470591289f25a4369c9c Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Wed, 30 Sep 2026 18:55:58 +0530 Subject: [PATCH 10/10] feat(ui): wide ControlButton for the hang-up key --- .../src/lib/widgets/call/ControlButton.svelte | 23 ++++++++++++++++++- .../svelte/src/lib/widgets/call/call.test.ts | 5 ++++ .../src/routes/showcase/call/+page.svelte | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/lib/widgets/call/ControlButton.svelte b/packages/svelte/src/lib/widgets/call/ControlButton.svelte index c89f1d2b..9c036e22 100644 --- a/packages/svelte/src/lib/widgets/call/ControlButton.svelte +++ b/packages/svelte/src/lib/widgets/call/ControlButton.svelte @@ -14,7 +14,9 @@ the label hides under 640px so a phone keeps an icon row tone: default | danger (solid error fill) | accent (lit: tinted accent in pill/bar, solid accent in classic). size: default | compact (36px, for the - dock and PiP). A hang-up is tone="danger" plus a caller width class. + dock and PiP). A hang-up is tone="danger" plus `wide` (a longer key in each + look). Width lives in this scoped CSS, which beats a Tailwind width class, so + use `wide` (or restyle via :global) rather than w-* utilities. `count` / `mention` paint a CountBadge on the corner; `badgeTestId` names it. Accent text on glass reads `--ripple-accent-on-glass` when the host sets one @@ -31,6 +33,8 @@ tone?: 'default' | 'danger' | 'accent'; size?: 'default' | 'compact'; variant?: 'pill' | 'bar' | 'classic'; + /** A longer key (hang-up). */ + wide?: boolean; /** Visible text beside the icon, bar variant only. Keep aria-label for the name. */ label?: string; icon?: Snippet; @@ -45,6 +49,7 @@ tone = 'default', size = 'default', variant = 'classic', + wide = false, label, icon, children, @@ -64,6 +69,7 @@ data-tone={tone} data-size={size} data-variant={variant} + data-wide={wide ? '' : undefined} aria-pressed={pressed === undefined ? undefined : pressed ? 'true' : 'false'} {...rest} > @@ -166,6 +172,18 @@ background: color-mix(in oklch, var(--ripple-accent) 20%, transparent); } + /* wide — the hang-up key */ + .ripple-ctl[data-wide] { + width: 3.5rem; + } + .ripple-ctl[data-variant='pill'][data-wide] { + width: 4rem; + } + .ripple-ctl[data-variant='bar'][data-wide] { + width: auto; + padding-inline: 1rem; + } + /* compact — dock / PiP */ .ripple-ctl[data-size='compact'] { width: 2.25rem; @@ -174,6 +192,9 @@ padding: 0; border-radius: 999px; } + .ripple-ctl[data-size='compact'][data-wide] { + width: 2.75rem; + } .ripple-ctl[data-size='compact'] .ripple-ctl-icon :global(svg) { width: 1rem; height: 1rem; diff --git a/packages/svelte/src/lib/widgets/call/call.test.ts b/packages/svelte/src/lib/widgets/call/call.test.ts index 46cf2083..8ee03638 100644 --- a/packages/svelte/src/lib/widgets/call/call.test.ts +++ b/packages/svelte/src/lib/widgets/call/call.test.ts @@ -65,6 +65,11 @@ describe('ControlButton', () => { expect(btn.dataset.variant).toBe('pill'); }); + it('marks a wide (hang-up) key', () => { + const { container } = render(ControlButton, { 'aria-label': 'Leave call', tone: 'danger', wide: true }); + expect(container.querySelector('button')!.hasAttribute('data-wide')).toBe(true); + }); + it('shows the text label only in the bar variant', async () => { const { container, rerender } = render(ControlButton, { 'aria-label': 'Mute', label: 'Mute', variant: 'bar' }); expect(container.querySelector('[data-slot="control-label"]')!.textContent).toBe('Mute'); diff --git a/packages/svelte/src/routes/showcase/call/+page.svelte b/packages/svelte/src/routes/showcase/call/+page.svelte index 15dcb17e..bd1c7dde 100644 --- a/packages/svelte/src/routes/showcase/call/+page.svelte +++ b/packages/svelte/src/routes/showcase/call/+page.svelte @@ -110,7 +110,7 @@ > {#snippet icon()}{/snippet} - (log = 'Leave call')}> + (log = 'Leave call')}> {#snippet icon()}{/snippet}