Skip to content
48 changes: 48 additions & 0 deletions apps/ui/src/components/agent-working-indicator/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { __ } from '@wordpress/i18n';
import { clsx } from 'clsx';
import styles from './style.module.css';
import type { CSSProperties } from 'react';

// Per-cell breathing timings. The durations are deliberately all different
// (and non-multiples): with a shared period, any set of phase offsets is a
// standing wave and the eye reads it as a chase. Distinct periods make the
// cells drift in and out of phase forever, so no sequence ever repeats.
const BREATHE_TIMINGS = [
{ duration: 2.2, phase: 0.1 },
{ duration: 2.9, phase: 0.6 },
{ duration: 2.5, phase: 0.35 },
{ duration: 3.3, phase: 0.8 },
{ duration: 2.7, phase: 0 },
{ duration: 3.1, phase: 0.5 },
];

/**
* The agent's "working" mark: a 2×3 grid of brand-blue pixels that breathe
* out of phase with one another — same square-pixel language as the W
* particle toy on empty chats. Size via the `--agent-pixel-size` /
* `--agent-pixel-gap` custom properties.
*/
export function AgentWorkingIndicator( {
className,
label = __( 'Working…' ),
}: {
className?: string;
label?: string;
} ) {
return (
<span className={ clsx( styles.grid, className ) } role="status" aria-label={ label }>
{ BREATHE_TIMINGS.map( ( { duration, phase }, cellIndex ) => (
<span
key={ cellIndex }
className={ styles.pixel }
style={
{
'--breathe-duration': `${ duration }s`,
'--breathe-delay': `${ -phase * duration }s`,
} as CSSProperties
}
/>
) ) }
</span>
);
}
40 changes: 40 additions & 0 deletions apps/ui/src/components/agent-working-indicator/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.grid {
--agent-pixel-size: 3px;
--agent-pixel-gap: 2px;
display: inline-grid;
grid-template-columns: repeat(2, var(--agent-pixel-size));
grid-auto-rows: var(--agent-pixel-size);
gap: var(--agent-pixel-gap);
flex-shrink: 0;
}

/* Square pixels, same language as the W particle toy. Each cell breathes —
a slow ease-in-out swell between dim and bright — on its own period
(set per cell in the component) so the cells drift in and out of phase
and never settle into a chase. The negative delay backdates each cycle
so every pixel is mid-breath on mount. */
.pixel {
background-color: var(--wpds-color-fg-interactive-brand, #2563eb);
opacity: 0.3;
animation: breathe var(--breathe-duration, 2.4s) ease-in-out infinite;
animation-delay: var(--breathe-delay, 0s);
}

@keyframes breathe {
0%,
100% {
opacity: 0.3;
transform: scale(0.85);
}
50% {
opacity: 0.95;
transform: scale(1);
}
}

@media (prefers-reduced-motion: reduce) {
.pixel {
animation: none;
opacity: 0.7;
}
}
86 changes: 86 additions & 0 deletions apps/ui/src/components/delete-site-dialog/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { __, sprintf } from '@wordpress/i18n';
import { Button, Dialog } from '@wordpress/ui';
import { useState } from 'react';
import { useDeleteSite } from '@/data/queries/use-sites';
import styles from './style.module.css';
import type { SiteDetails } from '@/data/core';

interface DeleteSiteDialogProps {
site: SiteDetails;
open: boolean;
onOpenChange: ( open: boolean ) => void;
onDeleted?: () => void;
}

export function DeleteSiteDialog( { site, open, onOpenChange, onDeleted }: DeleteSiteDialogProps ) {
const deleteSite = useDeleteSite();
const [ deleteFiles, setDeleteFiles ] = useState( true );
const [ error, setError ] = useState< string | null >( null );

const handleConfirm = () => {
setError( null );
deleteSite.mutate(
{ id: site.id, deleteFiles },
{
onSuccess: () => {
onOpenChange( false );
onDeleted?.();
},
onError: ( err: Error ) => {
setError( err.message ?? __( 'Unable to delete the site. Please try again.' ) );
},
}
);
};

return (
<Dialog.Root
open={ open }
onOpenChange={ ( next ) => {
if ( deleteSite.isPending ) {
return;
}
onOpenChange( next );
if ( ! next ) {
setError( null );
}
} }
>
<Dialog.Popup size="small">
<Dialog.Header>
<Dialog.Title>{ sprintf( __( 'Delete %s' ), site.name ) }</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<p className={ styles.dialogText }>
{ __(
"The site's database will be lost, including all posts, pages, comments, and media."
) }
</p>
<label className={ styles.dialogCheckbox }>
<input
type="checkbox"
checked={ deleteFiles }
onChange={ ( event ) => setDeleteFiles( event.target.checked ) }
/>
<span>{ __( 'Delete site files from my computer' ) }</span>
</label>
{ error ? <div className={ styles.dialogError }>{ error }</div> : null }
</Dialog.Content>
<Dialog.Footer>
<Dialog.Action variant="minimal" tone="neutral" disabled={ deleteSite.isPending }>
{ __( 'Cancel' ) }
</Dialog.Action>
<Button
variant="solid"
tone="brand"
loading={ deleteSite.isPending }
loadingAnnouncement={ __( 'Deleting site' ) }
onClick={ handleConfirm }
>
{ __( 'Delete site' ) }
</Button>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
);
}
24 changes: 24 additions & 0 deletions apps/ui/src/components/delete-site-dialog/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.dialogText {
margin: 0;
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
color: var(--wpds-color-fg-content-neutral-weak);
}

.dialogCheckbox {
display: flex;
align-items: center;
gap: var(--wpds-dimension-padding-sm);
margin-top: var(--wpds-dimension-padding-sm);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
color: var(--wpds-color-fg-content-neutral);
cursor: var(--wpds-cursor-control);
}

.dialogError {
margin-top: var(--wpds-dimension-padding-sm);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
color: var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral));
}
14 changes: 13 additions & 1 deletion apps/ui/src/components/menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { forwardRef } from 'react';
import motionStyles from '@/components/floating-surface-motion/style.module.css';
import { unlock } from '@/lock-unlock';
import styles from './style.module.css';
import type { ComponentPropsWithoutRef, ElementRef, ReactNode } from 'react';
import type {
ComponentPropsWithoutRef,
ElementRef,
MouseEventHandler,
PointerEventHandler,
ReactNode,
} from 'react';

const { ThemeProvider } = unlock( privateApis );

Expand All @@ -21,6 +27,8 @@ type PopupProps = {
sideOffset?: number;
alignOffset?: number;
className?: string;
onClick?: MouseEventHandler< HTMLElement >;
onPointerDown?: PointerEventHandler< HTMLElement >;
};

/**
Expand All @@ -35,6 +43,8 @@ export function Popup( {
sideOffset = 4,
alignOffset,
className,
onClick,
onPointerDown,
}: PopupProps ) {
return (
<BaseMenu.Portal>
Expand All @@ -53,6 +63,8 @@ export function Popup( {
<ThemeProvider density="compact">
<BaseMenu.Popup
className={ `${ styles.popup } ${ motionStyles.motion } ${ className ?? '' }` }
onClick={ onClick }
onPointerDown={ onPointerDown }
>
{ children }
</BaseMenu.Popup>
Expand Down
81 changes: 74 additions & 7 deletions apps/ui/src/components/sidebar-header/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,102 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { SidebarHeader } from './index';
import type { ButtonHTMLAttributes, ReactNode } from 'react';

const navigate = vi.fn();

vi.mock( '@tanstack/react-router', () => ( {
useNavigate: () => vi.fn(),
useNavigate: () => navigate,
} ) );

vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );

vi.mock( '@wordpress/ui', () => ( {
Icon: ( { children }: { children?: ReactNode } ) => <span>{ children }</span>,
IconButton: ( {
label,
icon,
tone,
variant,
size,
...props
}: ButtonHTMLAttributes< HTMLButtonElement > & {
label: string;
icon?: unknown;
tone?: string;
variant?: string;
size?: string;
} ) => {
void icon;
void tone;
void variant;
void size;
return (
<button type="button" aria-label={ label } { ...props }>
{ label }
</button>
);
},
} ) );

vi.mock( '@/components/menu', () => ( {
Root: ( { children }: { children: ReactNode } ) => <div>{ children }</div>,
Trigger: ( { render }: { render: ReactNode } ) => <>{ render }</>,
Popup: ( { children }: { children: ReactNode } ) => <div role="menu">{ children }</div>,
Item: ( { children, onClick }: { children: ReactNode; onClick?: () => void } ) => (
<button type="button" onClick={ onClick }>
{ children }
</button>
),
} ) );

vi.mock( '@/hooks/use-traffic-light-space', () => ( {
useTrafficLightSpace: () => false,
useTrafficLightSpace: () => true,
} ) );

const useConnectorMock = vi.mocked( useConnector, { partial: true } );

describe( 'SidebarHeader', () => {
it( 'shows the app menu button when the host has no native menu bar', () => {
beforeEach( () => {
vi.clearAllMocks();
useConnectorMock.mockReturnValue( { showsAppMenuButton: true, popupAppMenu: vi.fn() } );
} );

it( 'opens site creation routes from the top-right create menu', () => {
render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );

expect( screen.getByRole( 'button', { name: 'Create new' } ) ).toBeInTheDocument();

render( <SidebarHeader onToggleSidebar={ () => {} } /> );
fireEvent.click( screen.getByRole( 'button', { name: 'New site' } ) );
expect( navigate ).toHaveBeenCalledWith( { to: '/onboarding' } );

fireEvent.click( screen.getByRole( 'button', { name: 'Import from…' } ) );
expect( navigate ).toHaveBeenCalledWith( { to: '/onboarding/import' } );
} );

it( 'hides the sidebar from the header toggle', () => {
const onToggleSidebar = vi.fn();
render( <SidebarHeader onToggleSidebar={ onToggleSidebar } /> );

fireEvent.click( screen.getByRole( 'button', { name: 'Hide sidebar' } ) );

expect( onToggleSidebar ).toHaveBeenCalledTimes( 1 );
} );

it( 'shows the app menu button when the host has no native menu bar', () => {
render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );

expect( screen.getByRole( 'button', { name: 'Menu' } ) ).toBeInTheDocument();
} );

it( 'hides the app menu button when the host has a native menu bar', () => {
useConnectorMock.mockReturnValue( { showsAppMenuButton: false } );

render( <SidebarHeader onToggleSidebar={ () => {} } /> );
render( <SidebarHeader onToggleSidebar={ vi.fn() } /> );

expect( screen.queryByRole( 'button', { name: 'Menu' } ) ).not.toBeInTheDocument();
} );
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/components/sidebar-header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function SidebarHeader( { onToggleSidebar }: Props ) {
size="small"
icon={ plus }
label={ __( 'Create new' ) }
className={ styles.createButton }
/>
}
/>
Expand Down
19 changes: 14 additions & 5 deletions apps/ui/src/components/sidebar-header/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,24 @@
display: flex;
align-items: center;
margin-inline-start: auto;
/* Match the site-row action buttons (`.siteActions` in site-list), which
sit flush with no gap. */
gap: 0;
/* The site rows have their action rail tucked into the row padding. Pull
the header buttons over by the row's inner padding so the create/toggle
controls share those same trailing columns. */
the header button over by the row's inner padding so it shares the same
trailing column. */
margin-inline-end: calc(-1 * var(--wpds-dimension-padding-sm));
-webkit-app-region: no-drag;
}

.popup {
min-width: 200px;
}

/* The minimal button's hover fill is nearly the same grey as the sidebar
background, so it reads as having no hover state. Use a foreground-based
tint that stays visible on the sidebar in both color schemes. */
.createButton {
--wp-ui-button-background-color-active: color-mix(
in srgb,
var(--wpds-color-fg-content-neutral) 8%,
transparent
);
}
Loading