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
99 changes: 99 additions & 0 deletions apps/storybook/stories/AppShell.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/nextjs';
import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppShellSkipLink } from '@/components/AppShellSkipLink';
import AdminPage from '@/app/admin/components/AdminPage';
import { AppSidebarView } from '@/app/admin/components/AppSidebar';

const meta: Meta = {
title: 'Components/Layout/App Shell',
parameters: {
layout: 'fullscreen',
nextjs: {
appDirectory: true,
navigation: {
pathname: '/admin/users',
query: {},
},
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

function AdminUsersPreview() {
return (
<div className="w-full rounded-xl border border-border bg-card">
<div className="border-b border-border px-6 py-4">
<h2 className="type-heading">Users</h2>
<p className="type-body text-muted-foreground mt-1">
Admin page content begins below the canonical 56px topbar.
</p>
</div>
<div className="grid grid-cols-[1.2fr_1fr_0.8fr] border-b border-border px-6 py-3 type-label text-muted-foreground">
<span>User</span>
<span>Status</span>
<span>Credits</span>
</div>
{[
['Jean du Plessis', 'Active', '$142.20'],
['Avery Stone', 'Trial', '$18.00'],
['Morgan Lee', 'Blocked', '$0.00'],
].map(row => (
<div
key={row[0]}
className="grid grid-cols-[1.2fr_1fr_0.8fr] items-center border-b border-border/70 px-6 py-3 type-body last:border-b-0"
>
<span className="font-medium">{row[0]}</span>
<span className="text-muted-foreground">{row[1]}</span>
<span className="font-mono tabular-nums">{row[2]}</span>
</div>
))}
</div>
);
}

function AdminShellPreview() {
return (
<SidebarProvider defaultOpen>
<AppShellSkipLink />
<div className="flex min-h-screen">
<AppSidebarView
variant="inset"
pathname="/admin/users"
session={null}
pendingDisputesCount={3}
>
<div className="type-label text-muted-foreground">Build preview</div>
</AppSidebarView>
<SidebarInset>
<main id="main-content" tabIndex={-1} className="flex min-h-0 flex-1 flex-col">
<AdminPage
breadcrumbs={
<BreadcrumbItem>
<BreadcrumbPage>Users</BreadcrumbPage>
</BreadcrumbItem>
}
buttons={<Button variant="outline">Export users</Button>}
>
<AdminUsersPreview />
</AdminPage>
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}

export const AdminShell: Story = {
render: () => <AdminShellPreview />,
};

export const SkipLinkFocused: Story = {
render: () => <AdminShellPreview />,
play: async () => {
document.querySelector<HTMLAnchorElement>('a[href="#main-content"]')?.focus();
},
};
11 changes: 11 additions & 0 deletions apps/storybook/stories/OrganizationSwitcher.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const organizations: OrganizationSwitcherOrganization[] = [
organizationName: 'Cloud Platform',
role: 'member',
},
{
organizationId: 'org-long',
organizationName: '[seed:cost-insights] Northstar Labs',
role: 'owner',
},
];

function OrganizationSwitcherStory({
Expand Down Expand Up @@ -71,6 +76,12 @@ export const OrganizationSelected: Story = {
},
};

export const LongOrganizationSelected: Story = {
args: {
organizationId: 'org-long',
},
};

export const Loading: Story = {
args: {
isPending: true,
Expand Down
8 changes: 6 additions & 2 deletions apps/storybook/stories/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar';
import { AppShellSkipLink } from '@/components/AppShellSkipLink';
import { OrganizationSwitcherView } from '@/app/(app)/components/OrganizationSwitcher';
import SidebarMenuList from '@/app/(app)/components/SidebarMenuList';
import SidebarUserFooter from '@/app/(app)/components/SidebarUserFooter';
Expand Down Expand Up @@ -292,7 +293,7 @@ function StoryOrganizationSwitcher() {

function StoryTopbar({ title }: { title: string }) {
return (
<header className="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center border-b">
<header className="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center border-b border-border">
<div className="flex aspect-square h-14 items-center justify-center">
<SidebarTrigger className="-ml-1" />
</div>
Expand All @@ -307,14 +308,17 @@ function StoryInset({ title, children }: { title: string; children: ReactNode })
return (
<SidebarInset>
<StoryTopbar title={title} />
<main className="bg-background w-full flex-1">{children}</main>
<main id="main-content" tabIndex={-1} className="bg-background w-full flex-1">
{children}
</main>
</SidebarInset>
);
}

function AppSidebarShell({ children }: { children: ReactNode }) {
return (
<SidebarProvider defaultOpen>
<AppShellSkipLink />
<div className="flex min-h-screen w-full">
{children}
<StoryInset title="Sessions">
Expand Down
28 changes: 27 additions & 1 deletion apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
import { config as loadEnv } from 'dotenv';

const e2eEnvKeys = new Set<string>();

function loadE2eEnvFile(path: string, override = false) {
const result = loadEnv({ path, override });
for (const key of Object.keys(result.parsed ?? {})) {
e2eEnvKeys.add(key);
}
}

loadE2eEnvFile('.env');
loadE2eEnvFile('.env.test', true);
loadE2eEnvFile('.env.test.local', true);

const e2eEnv = Object.fromEntries(
[...e2eEnvKeys].flatMap(key => {
const value = process.env[key];
return value === undefined ? [] : [[key, value]];
})
);

const port = process.env.PORT ? Number(process.env.PORT) : 3000;
// Use localhost instead of 127.0.0.1 to match cookie domain
Expand Down Expand Up @@ -53,17 +74,22 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
// Always use dev mode for Playwright tests - never production
command: `dotenvx run --convention=nextjs -- pnpm next dev -p ${port}`,
command: `pnpm run copy:swagger-ui-assets && pnpm next dev -p ${port}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: {
...e2eEnv,
// Always use development mode for Playwright tests
NODE_ENV: 'development',
DEBUG_SHOW_DEV_UI: 'true', // Enable fake login
APP_URL_OVERRIDE: baseURL,
NEXTAUTH_URL: baseURL,
PORT: String(port),
VERCEL_ENV: '',
VERCEL_TARGET_ENV: '',
},
},
});
4 changes: 2 additions & 2 deletions apps/web/src/app/(app)/account-deleted/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LinkButton } from '@/components/Button';

export default function AccountDeletedPage() {
return (
<main className="mx-auto flex w-full max-w-xl grow flex-col items-center justify-center">
<div className="mx-auto flex w-full max-w-xl grow flex-col items-center justify-center">
<Card className="w-full rounded-xl shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-950">
Expand All @@ -27,6 +27,6 @@ export default function AccountDeletedPage() {
</div>
</CardContent>
</Card>
</main>
</div>
);
}
3 changes: 2 additions & 1 deletion apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Sidebar, SidebarContent, SidebarHeader } from '@/components/ui/sidebar';
import { Sidebar, SidebarContent, SidebarHeader, SidebarRail } from '@/components/ui/sidebar';
import { useUser } from '@/hooks/useUser';
import {
Bot,
Expand Down Expand Up @@ -419,6 +419,7 @@ export default function OrganizationAppSidebar({
</SidebarContent>

<SidebarUserFooter user={user} isLoading={isLoading} />
<SidebarRail />
</Sidebar>
);
}
31 changes: 18 additions & 13 deletions apps/web/src/app/(app)/components/OrganizationSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,20 @@ type OrganizationSwitcherViewProps = {
};

const triggerClassName =
'h-auto min-h-12 w-full justify-between rounded-lg border border-border bg-transparent px-3 py-1.5 text-left hover:border-border-strong hover:bg-sidebar-accent hover:text-sidebar-accent-foreground';
'h-auto min-h-12 w-full justify-between gap-2 rounded-lg border border-border bg-transparent px-3 py-1.5 text-left hover:border-border-strong hover:bg-sidebar-accent hover:text-sidebar-accent-foreground';

const menuItemClassName =
'flex min-h-12 cursor-pointer items-center rounded-md border border-transparent px-3 py-1.5 hover:border-border hover:bg-accent hover:text-accent-foreground';

const selectedMenuItemClassName = 'border-border bg-surface-selected text-foreground';

const switcherTitleClassName = 'text-foreground text-sm leading-4 font-semibold';
const switcherSubtitleClassName = 'text-muted-foreground text-xs leading-4';
const switcherTextClassName = 'flex min-w-0 flex-1 flex-col items-start gap-0.5';
const switcherRowClassName = 'flex w-full min-w-0 items-center justify-between gap-2';
const switcherTitleClassName =
'text-foreground max-w-full truncate text-sm leading-4 font-semibold';
const switcherSubtitleClassName = 'text-muted-foreground max-w-full truncate text-xs leading-4';
const switcherIconClassName = 'text-muted-foreground h-4 w-4 shrink-0';
const selectedIconClassName = 'text-primary h-4 w-4 shrink-0';

export default function OrganizationSwitcher({ organizationId = null }: OrganizationSwitcherProps) {
const trpc = useTRPC();
Expand Down Expand Up @@ -102,11 +107,11 @@ export function OrganizationSwitcherView({
return (
<div>
<Button variant="ghost" disabled className={triggerClassName}>
<div className="flex flex-col items-start gap-0.5">
<div className={switcherTextClassName}>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
<ChevronDown className="text-muted-foreground h-4 w-4" />
<ChevronDown className={switcherIconClassName} />
</Button>
</div>
);
Expand All @@ -122,15 +127,15 @@ export function OrganizationSwitcherView({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className={triggerClassName}>
<div className="flex flex-col items-start gap-0.5">
<div className={switcherTextClassName}>
<div className={switcherTitleClassName}>
{currentOrg ? currentOrg.organizationName : 'Personal'}
</div>
<div className={switcherSubtitleClassName}>
{currentOrg ? getRoleLabel(currentOrg.role) : 'Personal Workspace'}
</div>
</div>
<ChevronDown className="text-muted-foreground h-4 w-4" />
<ChevronDown className={switcherIconClassName} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
Expand All @@ -148,13 +153,13 @@ export function OrganizationSwitcherView({
organizationId === org.organizationId && selectedMenuItemClassName
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex-1">
<div className={switcherRowClassName}>
<div className={switcherTextClassName}>
<div className={switcherTitleClassName}>{org.organizationName}</div>
<div className={switcherSubtitleClassName}>{getRoleLabel(org.role)}</div>
</div>
{organizationId === org.organizationId && (
<Check className="text-primary ml-2 h-4 w-4" />
<Check className={selectedIconClassName} />
)}
</div>
</DropdownMenuItem>
Expand All @@ -168,12 +173,12 @@ export function OrganizationSwitcherView({
onClick={() => onOrganizationSwitch(null)}
className={cn(menuItemClassName, !organizationId && selectedMenuItemClassName)}
>
<div className="flex w-full items-center justify-between">
<div className="flex-1">
<div className={switcherRowClassName}>
<div className={switcherTextClassName}>
<div className={switcherTitleClassName}>Personal</div>
<div className={switcherSubtitleClassName}>Personal Workspace</div>
</div>
{!organizationId && <Check className="text-primary ml-2 h-4 w-4" />}
{!organizationId && <Check className={selectedIconClassName} />}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/(app)/components/PersonalAppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Sidebar, SidebarContent, SidebarHeader } from '@/components/ui/sidebar';
import { Sidebar, SidebarContent, SidebarHeader, SidebarRail } from '@/components/ui/sidebar';
import { useUser } from '@/hooks/useUser';
import { useKiloClawNavState } from '@/hooks/useKiloClaw';
import { useState } from 'react';
Expand Down Expand Up @@ -385,6 +385,7 @@ export default function PersonalAppSidebar(props: React.ComponentProps<typeof Si

{sidebarPromoEligibility?.showPromoBanner && <SidebarPromoBanner />}
<SidebarUserFooter user={user} isLoading={isLoading} />
<SidebarRail />
</Sidebar>
);
}
8 changes: 7 additions & 1 deletion apps/web/src/app/(app)/components/SidebarMenuList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export default function SidebarMenuList({
isActive={isActive}
size={item.subtitle ? 'lg' : 'default'}
>
<Link href={item.url} prefetch={false} className={buttonClassName}>
<Link
href={item.url}
prefetch={false}
className={buttonClassName}
aria-current={isActive ? 'page' : undefined}
>
{content}
</Link>
</SidebarMenuButton>
Expand All @@ -103,6 +108,7 @@ export default function SidebarMenuList({
isActive={isActive}
size={item.subtitle ? 'lg' : 'default'}
className={cn('cursor-pointer', buttonClassName)}
aria-current={isActive ? 'page' : undefined}
>
{content}
</SidebarMenuButton>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RoleTestingProvider } from '@/contexts/RoleTestingContext';
import { PageTitleProvider } from '@/contexts/PageTitleContext';
import { EventServiceProvider } from '@/contexts/EventServiceContext';
import { AdminOmnibox } from '@/components/admin-omnibox';
import { AppShellSkipLink } from '@/components/AppShellSkipLink';
import { PrefetchedOrganizations } from './components/PrefetchedOrganizations';
import { PlatformPresenceMount } from './components/PlatformPresenceMount';
export default function AppLayout({ children }: { children: React.ReactNode }) {
Expand All @@ -15,11 +16,14 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<PlatformPresenceMount />
<SidebarProvider>
<PrefetchedOrganizations>
<AppShellSkipLink />
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset>
<AppTopbar />
<main className="bg-background w-full flex-1">{children}</main>
<main id="main-content" tabIndex={-1} className="bg-background w-full flex-1">
{children}
</main>
</SidebarInset>
</div>
</PrefetchedOrganizations>
Expand Down
Loading