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
3 changes: 3 additions & 0 deletions packages/svelte/src/lib/components/ui/button/button.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<!-- button.svelte
2026-09-30 (call UI new look): a `success` variant (ripple success fill and
foreground) for Accept / Join call actions.
2026-09-17 (fix/port-gaps): status-coloured text moved onto the readable
text tokens (text-ripple-{error,success,warning,info}-text; red text that
read text-destructive now reads text-ripple-error-text, the same hue since
Expand All @@ -19,6 +21,7 @@
ghost: "hover:bg-ripple-muted hover:text-ripple-surface-foreground dark:hover:bg-ripple-muted/50 aria-expanded:bg-ripple-muted aria-expanded:text-ripple-surface-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-ripple-error-text focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-ripple-accent underline-offset-4 hover:underline",
success: "bg-ripple-success text-ripple-success-foreground hover:bg-ripple-success/90",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
Expand Down
18 changes: 18 additions & 0 deletions packages/svelte/src/lib/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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 /
Expand Down Expand Up @@ -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';
14 changes: 13 additions & 1 deletion packages/svelte/src/lib/ui/ui-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<Ripple spec>` wrapper and
* no renderer context.
Expand Down Expand Up @@ -155,6 +157,16 @@ const PROPS: Record<string, Record<string, unknown>> = {
// 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. */
Expand Down Expand Up @@ -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');
Expand Down
183 changes: 183 additions & 0 deletions packages/svelte/src/lib/widgets/call/BottomSheet.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<!--
BottomSheet.svelte — a NON-modal bottom sheet with three stops: peek, half,
full. NEW 2026-09-30 (call UI new look, slice 0). Lifted from
paw-enterprise's CallChatSheet (the phone in-call chat).

Not the `Sheet` overlay: no dialog role, no focus trap, no backdrop. It is an
overlay positioned absolute at the bottom of its positioned parent, so what
sits under it never remounts. Content stays mounted across stops.
Handle: drag it more than 48px to move one stop (up: peek→half→full, down:
full→half→peek); a press under 6px is a tap and toggles (peek→half,
half→full, full→half). Keyboard on the handle: Enter/Space toggle,
ArrowUp/ArrowDown step. `stop` is bindable and reflected as data-state;
onStopChange fires on every change the sheet makes. handleLabel(stop) names
the handle (default "Expand" / "Shrink"); handleTestId tags it.
Heights: peek 3.5rem, half 50%, full 100%, overridable via --ripple-sheet-peek
/ --ripple-sheet-half. Tokens only.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';

type Stop = 'peek' | 'half' | 'full';

let {
stop = $bindable('peek'),
label,
onStopChange,
handleLabel = (s: Stop) => (s === 'full' ? 'Shrink' : 'Expand'),
handleTestId,
class: className,
style,
children,
...rest
}: Omit<HTMLAttributes<HTMLDivElement>, 'style'> & {
stop?: Stop;
label?: string;
onStopChange?: (stop: Stop) => void;
handleLabel?: (stop: Stop) => string;
handleTestId?: string;
style?: string;
children?: Snippet;
} = $props();

const ORDER: Stop[] = ['peek', 'half', 'full'];
const DRAG = 48;
const TAP = 6;

let dragging = $state(false);
let dy = $state(0);
let startY = 0;

function go(next: Stop) {
if (next === stop) return;
stop = next;
onStopChange?.(next);
}
const step = (dir: 1 | -1) => go(ORDER[Math.max(0, Math.min(2, ORDER.indexOf(stop) + dir))]);
const toggle = () => go(stop === 'half' ? 'full' : 'half');

function down(e: PointerEvent) {
if (e.button !== 0) return;
dragging = true;
dy = 0;
startY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function move(e: PointerEvent) {
if (dragging) dy = e.clientY - startY;
}
function up() {
if (!dragging) return;
const d = dy;
dragging = false;
dy = 0;
if (d < -DRAG) step(1);
else if (d > DRAG) step(-1);
else if (Math.abs(d) < TAP) toggle();
}
function key(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
step(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
step(-1);
}
}
</script>

<div
role="region"
aria-label={label}
data-slot="bottom-sheet"
data-state={stop}
data-dragging={dragging ? '' : undefined}
class={cn('ripple-sheet', className)}
style="{dragging && dy ? `transform:translateY(${dy}px);` : ''}{style ?? ''}"
{...rest}
>
<div
role="button"
tabindex="0"
aria-label={handleLabel(stop)}
data-slot="bottom-sheet-handle"
data-testid={handleTestId}
class="ripple-sheet-handle"
onpointerdown={down}
onpointermove={move}
onpointerup={up}
onpointercancel={up}
onkeydown={key}
>
<span class="ripple-sheet-grip" aria-hidden="true"></span>
</div>
<div class="ripple-sheet-body">{@render children?.()}</div>
</div>

<style>
.ripple-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
flex-direction: column;
height: var(--ripple-sheet-peek, 3.5rem);
overflow: hidden;
border-top-left-radius: 1.25rem;
border-top-right-radius: 1.25rem;
border: 1px solid var(--ripple-border);
border-bottom: 0;
color: var(--ripple-surface-foreground);
background: color-mix(in oklch, var(--ripple-surface) 88%, transparent);
backdrop-filter: blur(28px) saturate(1.5);
-webkit-backdrop-filter: blur(28px) saturate(1.5);
transition:
height 260ms var(--ripple-ease-out),
transform 260ms var(--ripple-ease-out);
}
.ripple-sheet[data-state='half'] {
height: var(--ripple-sheet-half, 50%);
}
.ripple-sheet[data-state='full'] {
height: 100%;
border-radius: 0;
}
.ripple-sheet[data-dragging] {
transition: none;
}
.ripple-sheet-handle {
display: grid;
place-items: center;
flex: none;
height: 1.25rem;
cursor: grab;
touch-action: none;
outline: none;
}
.ripple-sheet-handle:focus-visible .ripple-sheet-grip {
box-shadow: 0 0 0 2px var(--ripple-ring);
}
.ripple-sheet-grip {
width: 2.25rem;
height: 0.25rem;
border-radius: 999px;
background: color-mix(in oklch, var(--ripple-surface-foreground) 35%, transparent);
}
.ripple-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
}
@media (prefers-reduced-motion: reduce) {
.ripple-sheet {
transition: none;
}
}
</style>
81 changes: 81 additions & 0 deletions packages/svelte/src/lib/widgets/call/ControlBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!--
ControlBar.svelte — the container for a row of ControlButtons.
NEW 2026-09-30 (call UI new look, slice 0).
role=group with an aria-label (not a toolbar: it adds no arrow-key roving).
Same three looks as ControlButton, passed separately to each (no context):
pill — floating glass capsule (the Glass layout)
bar — full-width 56px bar docked to the bottom edge (Focus)
classic — a plain centred row (today's look)
size="compact" drops the chrome for the dock and PiP. Forwards all div
attributes. Tokens only.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';

let {
label,
variant = 'classic',
size = 'default',
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & {
label?: string;
variant?: 'pill' | 'bar' | 'classic';
size?: 'default' | 'compact';
children?: Snippet;
} = $props();
</script>

<div
role="group"
aria-label={label}
data-slot="control-bar"
data-variant={variant}
data-size={size}
class={cn('ripple-ctl-bar', className)}
{...rest}
>
{@render children?.()}
</div>

<style>
.ripple-ctl-bar {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--ripple-surface-foreground);
}
.ripple-ctl-bar[data-variant='pill'] {
gap: 0.375rem;
padding: 0.375rem;
border-radius: 999px;
border: 1px solid var(--ripple-border);
background: color-mix(in oklch, var(--ripple-surface) 80%, transparent);
backdrop-filter: blur(28px) saturate(1.5);
-webkit-backdrop-filter: blur(28px) saturate(1.5);
box-shadow: 0 18px 50px color-mix(in oklch, var(--ripple-surface) 60%, transparent);
}
.ripple-ctl-bar[data-variant='bar'] {
justify-content: center;
gap: 0.25rem;
width: 100%;
height: 3.5rem;
padding-inline: 1rem;
border-top: 1px solid var(--ripple-border);
background: color-mix(in oklch, var(--ripple-surface) 88%, transparent);
}
.ripple-ctl-bar[data-size='compact'] {
gap: 0.375rem;
width: auto;
height: auto;
padding: 0;
border: 0;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
</style>
Loading
Loading