From 21cd1748f99c7e32df64f9ffc5e8a0d15d893198 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:28:03 +0200 Subject: [PATCH 01/21] fix(layout): let the viewport resize around the on-screen keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - declare interactiveWidget resizes-content so bottom sheets sit above the keyboard Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/app/layout.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d64bd1d..f81756a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import type React from "react"; @@ -20,6 +20,15 @@ import { cn } from "@/lib/utils"; const inter = Inter({ subsets: ["latin"] }); +// Without resizes-content the layout viewport ignores the on-screen keyboard, so +// bottom-anchored sheets keep their full height behind it and push their fields +// off the top of the screen. +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + interactiveWidget: "resizes-content", +}; + export const metadata: Metadata = { metadataBase: new URL(siteUrl), title: metaTitle, From 557ec3c9ee1894200ce045a519cd170eb13eaf00 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:28:22 +0200 Subject: [PATCH 02/21] fix(ui): raise mobile touch targets to 44px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - size buttons and inputs per breakpoint so mobile reaches 44px - hold desktop density at the existing heights Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/ui/button.tsx | 12 ++++++------ src/components/ui/input.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e0fe145..8bbab69 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,14 +19,14 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", + default: "h-11 px-4 py-2 has-[>svg]:px-3 md:h-9", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", + sm: "h-9 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 md:h-8", + lg: "h-12 rounded-md px-6 has-[>svg]:px-4 md:h-10", + icon: "size-11 md:size-9", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", + "icon-sm": "size-10 md:size-8", + "icon-lg": "size-12 md:size-10", }, }, defaultVariants: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 762c2b5..8204db7 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", + "h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:text-sm dark:bg-input/30", "focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", className From 9c336a74584e13978cf3b89eba6279ce940705cb Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:28:42 +0200 Subject: [PATCH 03/21] fix(ui): keep a tall drawer's footer reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scroll the drawer body so long forms keep their footer on screen - let the wrapper own the sheet's horizontal inset - measure the drawer cap against the dynamic viewport height Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/ui/drawer.tsx | 4 ++-- src/components/ui/responsive-modal.tsx | 28 +++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index fd33db0..3cbcd21 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -51,8 +51,8 @@ function DrawerContent({ data-slot="drawer-content" className={cn( "group/drawer-content fixed z-50 flex h-auto flex-col bg-background", - "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", - "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", + "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80dvh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", + "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80dvh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", className diff --git a/src/components/ui/responsive-modal.tsx b/src/components/ui/responsive-modal.tsx index 5c52867..4a9065e 100644 --- a/src/components/ui/responsive-modal.tsx +++ b/src/components/ui/responsive-modal.tsx @@ -22,11 +22,20 @@ import { DrawerTrigger, } from "@/components/ui/drawer"; import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/lib/utils"; const ModalContext = React.createContext(false); const useModalIsMobile = () => React.useContext(ModalContext); +// DrawerContent caps itself at 80vh without scrolling, so a tall form clips its +// own footer off-screen; this wrapper scrolls instead. It owns the whole sheet's +// horizontal inset too, so the drawer's own header/footer padding is zeroed out +// rather than stacking and insetting them deeper than the body between them. +const SCROLLABLE_BODY = [ + "flex min-h-0 flex-col gap-4 overflow-y-auto px-4 pb-8", + "[&_[data-slot=drawer-header]]:p-0 [&_[data-slot=drawer-header]]:pt-2", + "[&_[data-slot=drawer-footer]]:p-0", +].join(" "); + function Modal({ children, ...props }: React.ComponentProps) { const isMobile = useIsMobile(); const Root = isMobile ? Drawer : Dialog; @@ -50,11 +59,24 @@ function ModalClose(props: React.ComponentProps) { function ModalContent({ className, + children, ...props }: React.ComponentProps) { const isMobile = useModalIsMobile(); - const Content = isMobile ? DrawerContent : DialogContent; - return ; + + if (!isMobile) { + return ( + + {children} + + ); + } + + return ( + +
{children}
+
+ ); } function ModalHeader(props: React.ComponentProps) { From 12d1609efca3957ca1ab58535dce7232fd28be27 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:29:00 +0200 Subject: [PATCH 04/21] fix(ui): render tooltips as a plain pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - show the bubble on its own without a pointer - offset the bubble from its trigger Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/ui/tooltip.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 538ee06..edac1a0 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -30,7 +30,7 @@ function TooltipTrigger({ function TooltipContent({ className, - sideOffset = 0, + sideOffset = 4, children, ...props }: React.ComponentProps) { @@ -46,7 +46,6 @@ function TooltipContent({ {...props} > {children} - ); From 784d18e0375e0799590ef574b161a85ce407f3ac Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:29:57 +0200 Subject: [PATCH 05/21] fix(sidebar): keep server and client markup identical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render the mobile sheet and the desktop tree unconditionally - mount sheet content only while it is open - hide the desktop tree below the md breakpoint through CSS Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/ui/sidebar.tsx | 112 +++++++++++++++++----------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 4db6feb..0e3019a 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -193,9 +193,12 @@ function Sidebar({ ); } - if (isMobile) { - return ( - + // Both renderings are unconditional so server and client HTML never diverge: + // the sheet mounts content only while open, and the desktop block is CSS-hidden + // below md. Branching on isMobile here mismatched hydration of streamed chunks. + return ( + <> + {children} - ); - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */}
- +
+ ); } From 07ceeab42f49de77d410a11d774c5885c0e4b1e5 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:30:18 +0200 Subject: [PATCH 06/21] feat(sidebar): give the rail a handle that shows where it will move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draw a centred two-segment handle that splits into a chevron on hover - slide the handle toward the direction the sidebar will travel - name the action in a tooltip - let the rail own the divider line between sidebar and content - render the rail in the app sidebar Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/sidebar/app-sidebar.tsx | 2 + src/components/ui/sidebar.tsx | 69 +++++++++++++++++++------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 5f74b38..98e45bf 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -16,6 +16,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { getChromeUser } from "@/lib/session"; // @catalyst:auth-end @@ -79,6 +80,7 @@ export async function AppSidebar() { {/* @catalyst:auth-end */} + ); } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 0e3019a..61fad0b 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -250,7 +250,7 @@ function Sidebar({ // Adjust the padding for floating and inset variants. variant === "floating" || variant === "inset" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", className )} {...props} @@ -306,28 +306,61 @@ function SidebarTrigger({ ); } +const RAIL_SEGMENT = + "h-3 w-0.5 bg-sidebar-foreground/40 transition-[rotate,background-color] duration-200 group-hover/rail:bg-sidebar-foreground/70"; + function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); + const { state, toggleSidebar } = useSidebar(); return ( - + + + {state === "expanded" ? "Click to close" : "Click to open"} + + + +
); } From 92a54f3779c99f315770e08581a1a636986c5476 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:31:09 +0200 Subject: [PATCH 07/21] perf(sidebar): render the static shell outside the session boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keep the sidebar synchronous so its chrome paints on first render - read the session inside the footer user block and suspend it alone - reduce the fallback to the user row it stands in for Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/app/(protected)/layout.tsx | 5 +- .../sidebar/app-sidebar-skeleton.tsx | 40 ++------ src/components/sidebar/app-sidebar.tsx | 93 ++++++++++--------- 3 files changed, 60 insertions(+), 78 deletions(-) diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index 9e08bf0..402c7cf 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -3,7 +3,6 @@ import type React from "react"; import { Suspense } from "react"; import { SessionGate } from "@/components/auth/session-gate"; import { AppSidebar } from "@/components/sidebar/app-sidebar"; -import { AppSidebarSkeleton } from "@/components/sidebar/app-sidebar-skeleton"; import TopMenu from "@/components/top-menu"; import { SidebarProvider } from "@/components/ui/sidebar"; @@ -21,9 +20,7 @@ export default function Layout({ - }> - - +
diff --git a/src/components/sidebar/app-sidebar-skeleton.tsx b/src/components/sidebar/app-sidebar-skeleton.tsx index b088277..fdb660b 100644 --- a/src/components/sidebar/app-sidebar-skeleton.tsx +++ b/src/components/sidebar/app-sidebar-skeleton.tsx @@ -1,37 +1,13 @@ -import Image from "next/image"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuItem, -} from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; -export function AppSidebarSkeleton() { +export function SidebarUserSkeleton() { return ( - - -
- Catalyst - Catalyst -
-
- - - - -
- -
- - -
-
-
-
-
-
+
+ +
+ + +
+
); } diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 98e45bf..2eca144 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -4,8 +4,10 @@ import Link from "next/link"; // @catalyst:auth-start import { ChevronsUpDown } from "lucide-react"; +import { Suspense } from "react"; import ProfileMenu from "@/components/auth/profile-menu"; import { SignInButton } from "@/components/auth/sign-in-button"; +import { SidebarUserSkeleton } from "@/components/sidebar/app-sidebar-skeleton"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { @@ -21,11 +23,10 @@ import { import { getChromeUser } from "@/lib/session"; // @catalyst:auth-end -export async function AppSidebar() { - // @catalyst:auth-start - const user = await getChromeUser(); - // @catalyst:auth-end - +// Synchronous on purpose: the logo and rail carry no data, so awaiting the +// session here would hold the whole sidebar behind a fallback. Only the footer +// user block reads the session, and it suspends on its own. +export function AppSidebar() { return ( @@ -39,43 +40,9 @@ export async function AppSidebar() { - {user ? ( - - - - - - - {user?.name - ?.split(" ") - .slice(0, 2) - .map((n) => n[0]) - .join("") || "A"} - - -
- - {user?.name || "Anonymous"} - - {user?.email && ( - {user.email} - )} -
- -
-
- -
- ) : ( - - )} + }> + +
@@ -84,3 +51,45 @@ export async function AppSidebar() {
); } + +// @catalyst:auth-start +async function SidebarUser() { + const user = await getChromeUser(); + + if (!user) { + return ; + } + + return ( + + + + + + + {user?.name + ?.split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") || "A"} + + +
+ {user?.name || "Anonymous"} + {user?.email && {user.email}} +
+ +
+
+ +
+ ); +} +// @catalyst:auth-end From 5e8ae5547d34357b5eeb8a08f8d5870d3a9b9398 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:31:36 +0200 Subject: [PATCH 08/21] fix(auth): size skeletons to the elements they stand in for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - match label placeholders to the label's rendered height - track the breakpoint-aware field and button heights - size the nav placeholder to the button it replaces Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/auth/auth-nav.tsx | 2 +- src/components/auth/password-form-skeleton.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/auth/auth-nav.tsx b/src/components/auth/auth-nav.tsx index 9429c3e..da2579b 100644 --- a/src/components/auth/auth-nav.tsx +++ b/src/components/auth/auth-nav.tsx @@ -28,5 +28,5 @@ export async function AuthNav() { } export function AuthNavSkeleton() { - return ; + return ; } diff --git a/src/components/auth/password-form-skeleton.tsx b/src/components/auth/password-form-skeleton.tsx index 48dc4a2..855e853 100644 --- a/src/components/auth/password-form-skeleton.tsx +++ b/src/components/auth/password-form-skeleton.tsx @@ -6,8 +6,8 @@ const ACCOUNT_FIELDS = ["current-password", "new-password", "confirm-password"]; function FieldRowSkeleton() { return (
- - + +
); } @@ -18,7 +18,7 @@ export function PasswordResetFormSkeleton() { {RESET_FIELDS.map((field) => ( ))} - +
); } @@ -29,7 +29,7 @@ export function PasswordFormSkeleton() { {ACCOUNT_FIELDS.map((field) => ( ))} - + ); } From eb587280702290fc72d9f4871071cce24c449fcd Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:32:33 +0200 Subject: [PATCH 09/21] fix(auth): give the sign-in surfaces a consistent card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fit the auth card to the viewport so narrow screens scroll vertically only - name each page with a heading element and a plain-language lead - build both password reset pages from the shared auth card - let the card stand without provider buttons where they do not apply Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/app/(public)/login/page.tsx | 6 +- src/app/(public)/register/page.tsx | 8 +-- src/app/(public)/reset-password/page.tsx | 36 +++++------ .../(public)/reset-password/request/page.tsx | 26 +++----- src/components/auth/auth-card.tsx | 61 ++++++++++--------- 5 files changed, 61 insertions(+), 76 deletions(-) diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx index 8363074..302d31d 100644 --- a/src/app/(public)/login/page.tsx +++ b/src/app/(public)/login/page.tsx @@ -23,10 +23,10 @@ export const metadata: Metadata = { export default function Login() { return ( -
+
+
diff --git a/src/app/(public)/reset-password/page.tsx b/src/app/(public)/reset-password/page.tsx index 1c2e167..b0b9c04 100644 --- a/src/app/(public)/reset-password/page.tsx +++ b/src/app/(public)/reset-password/page.tsx @@ -1,15 +1,9 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Suspense } from "react"; +import AuthCard from "@/components/auth/auth-card"; import { PasswordResetFormSkeleton } from "@/components/auth/password-form-skeleton"; import PasswordResetForm from "@/components/auth/password-reset-form"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { openGraph } from "@/lib/metadata"; type SearchParams = Promise>; @@ -36,18 +30,16 @@ export const metadata: Metadata = { export default function Page({ searchParams }: Readonly<{ searchParams: SearchParams }>) { return ( -
- - - Reset Password - Enter your new password - - - }> - - - - +
+ + }> + + +
); } @@ -67,9 +59,9 @@ async function TokenBoundForm({ function InvalidResetLink() { return (

- This reset link is invalid or incomplete.{" "} - - Request a new one + This link is not valid any more.{" "} + + Ask for a new one .

diff --git a/src/app/(public)/reset-password/request/page.tsx b/src/app/(public)/reset-password/request/page.tsx index f66450f..413a6fa 100644 --- a/src/app/(public)/reset-password/request/page.tsx +++ b/src/app/(public)/reset-password/request/page.tsx @@ -1,12 +1,6 @@ import type { Metadata } from "next"; +import AuthCard from "@/components/auth/auth-card"; import RequestPasswordResetForm from "@/components/auth/request-password-reset-form"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { openGraph } from "@/lib/metadata"; const path = "/reset-password/request"; @@ -30,16 +24,14 @@ export const metadata: Metadata = { export default async function Page() { return ( -
- - - Reset Password - Enter your email to reset your password - - - - - +
+ + +
); } diff --git a/src/components/auth/auth-card.tsx b/src/components/auth/auth-card.tsx index badb0fc..3fccd66 100644 --- a/src/components/auth/auth-card.tsx +++ b/src/components/auth/auth-card.tsx @@ -1,14 +1,6 @@ import Link from "next/link"; import type { ReactNode } from "react"; import OAuthForm from "@/components/auth/oauth-form"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import conf from "@/lib/config"; export default function AuthCard({ @@ -18,46 +10,55 @@ export default function AuthCard({ ctaQuestion, ctaText, ctaLink, + showOAuth = true, }: { title: string; description: string; children: ReactNode; - ctaQuestion: string; - ctaText: string; - ctaLink: string; + ctaQuestion?: string; + ctaText?: string; + ctaLink?: string; + showOAuth?: boolean; }) { const hasGitHub = Boolean(conf.githubId) && Boolean(conf.githubSecret); const hasGoogle = Boolean(conf.googleId) && Boolean(conf.googleSecret); + const oauth = showOAuth && (hasGitHub || hasGoogle); return ( - - - {title} - {description} - - - {children} - {(hasGitHub || hasGoogle) && ( +
+
+

{title}

+

{description}

+
+ + {children} + + {oauth && ( +
-
- Or continue with +
+ or continue with
- )} - {hasGoogle && } - {hasGitHub && } - - -

+ {hasGoogle && } + {hasGitHub && } +

+ )} + + {ctaQuestion && ctaText && ctaLink && ( +

{ctaQuestion}{" "} - + {ctaText}

- - + )} +
); } From 842754d662f15ed02b470527e56ac3ea3f32d171 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:32:52 +0200 Subject: [PATCH 10/21] refactor(copy): settle on "log in" and "sign up" across auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - name the two actions the same way in the nav, links and buttons - write field labels and buttons in sentence case - distinguish the confirm field as the new password Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/auth/auth-nav.tsx | 4 ++-- src/components/auth/login-form.tsx | 2 +- src/components/auth/password-reset-form.tsx | 6 +++--- src/components/auth/request-password-reset-form.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/auth/auth-nav.tsx b/src/components/auth/auth-nav.tsx index da2579b..0afd2dc 100644 --- a/src/components/auth/auth-nav.tsx +++ b/src/components/auth/auth-nav.tsx @@ -19,9 +19,9 @@ export async function AuthNav() { return ( <> - Login + Log in ); diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index 70887e6..136bd7a 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -99,7 +99,7 @@ export default function LoginForm() { href="/reset-password/request" className="text-primary text-xs underline-offset-4 hover:underline" > - Forgot Password? + Forgot password?
- +
- +
); diff --git a/src/components/auth/request-password-reset-form.tsx b/src/components/auth/request-password-reset-form.tsx index 2109ca2..bc49c44 100644 --- a/src/components/auth/request-password-reset-form.tsx +++ b/src/components/auth/request-password-reset-form.tsx @@ -49,14 +49,14 @@ export default function RequestPasswordResetForm() { />
- Back to Login + Back to log in
From ece0b9a12b4c34a3cc8dcb0fdf2a0ff98a166ae1 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:33:48 +0200 Subject: [PATCH 11/21] fix(settings): lay the page out in the app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - place the page on the shared container with a heading and lead - group password, API keys and account removal into labelled sections - draw the removal zone with the destructive palette and a warning icon - confirm removal through the responsive modal - write field labels in sentence case Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/app/(protected)/settings/page.tsx | 61 ++++----- .../settings/delete-account-form.tsx | 122 ++++++++++-------- src/components/settings/password-form.tsx | 6 +- 3 files changed, 106 insertions(+), 83 deletions(-) diff --git a/src/app/(protected)/settings/page.tsx b/src/app/(protected)/settings/page.tsx index 3001bda..bce4f0e 100644 --- a/src/app/(protected)/settings/page.tsx +++ b/src/app/(protected)/settings/page.tsx @@ -13,40 +13,43 @@ import ApiKeysSkeleton from "@/components/settings/api-keys-skeleton"; // @catalyst:mcp-end import DeleteAccountForm from "@/components/settings/delete-account-form"; import PasswordForm from "@/components/settings/password-form"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { userHasPassword } from "@/lib/dao/users"; import { getUserIdFromSession } from "@/lib/session"; export default function SettingsPage() { return ( -
- - - Settings - Manage your account settings and preferences. - - -
- }> - - - {/* @catalyst:mcp-start */} - - }> - - - - {/* @catalyst:mcp-end */} - -
-
-
+
+
+

Settings

+

+ Change your password, or close your account. +

+
+ +
+

Password

+
+ }> + + +
+
+ + {/* @catalyst:mcp-start */} +
+

API keys

+ + }> + + + +
+ {/* @catalyst:mcp-end */} + +
+

Your account

+ +
); } diff --git a/src/components/settings/delete-account-form.tsx b/src/components/settings/delete-account-form.tsx index 54a98bc..bd95199 100644 --- a/src/components/settings/delete-account-form.tsx +++ b/src/components/settings/delete-account-form.tsx @@ -1,29 +1,33 @@ "use client"; +import { TriangleAlert } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useActionState, useState } from "react"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { useActionState, useId, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + ModalTrigger, +} from "@/components/ui/responsive-modal"; import useToast from "@/hooks/use-toast"; import { deleteUser } from "@/lib/actions/users"; import { signOut } from "@/lib/auth-client"; import { type FormState, initialState } from "@/lib/utils"; +const CONFIRM_PHRASE = "delete my account"; + export default function DeleteAccountForm() { const [state, formAction] = useActionState(deleteUser, initialState); const [open, setOpen] = useState(false); const [confirmText, setConfirmText] = useState(""); - const isConfirmed = confirmText === "delete my account"; + const confirmFieldId = useId(); + const isConfirmed = confirmText === CONFIRM_PHRASE; const router = useRouter(); function handleOpenChange(open: boolean) { @@ -47,46 +51,62 @@ export default function DeleteAccountForm() { useToast(state, successCallback); return ( -
-

Delete Account

-

- This action is irreversible. All your data will be permanently deleted. -

- - - - - - - Are you sure? - - - Warning: This action cannot be undone. - - - - For security, please type 'delete my account' to proceed: - - setConfirmText(e.target.value)} - placeholder="delete my account" - /> - - - - - Cancel -
- + + + + Delete your account? + + Your sites, tests and every measurement they collected are deleted with + it, and none of it can be recovered. + + +
+ + setConfirmText(e.target.value)} + placeholder={CONFIRM_PHRASE} + value={confirmText} + /> +
+ + - -
-
-
+
+ +
+ + + +
); } diff --git a/src/components/settings/password-form.tsx b/src/components/settings/password-form.tsx index 2edc41c..d708ae8 100644 --- a/src/components/settings/password-form.tsx +++ b/src/components/settings/password-form.tsx @@ -85,7 +85,7 @@ export default function PasswordForm({ hasPassword }: { hasPassword?: boolean })
{hasPassword && (
- + )}
- +
- + Date: Wed, 19 Aug 2026 11:34:11 +0200 Subject: [PATCH 12/21] feat(app): show a missing page inside the app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render a 404 for protected routes with the sidebar and chrome intact - offer a route back to the dashboard Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/app/(protected)/not-found.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/app/(protected)/not-found.tsx diff --git a/src/app/(protected)/not-found.tsx b/src/app/(protected)/not-found.tsx new file mode 100644 index 0000000..a6aca55 --- /dev/null +++ b/src/app/(protected)/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+
+

This page is not here

+

+ It may have been deleted, or the link may be out of date. Nothing is broken. +

+
+ +
+
+
+ ); +} From 2b059fa4aba84a2bc4fb42309d87a39fa35ee09a Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Wed, 19 Aug 2026 11:34:35 +0200 Subject: [PATCH 13/21] feat(ui): add a native select primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - carry the select's border, spacing and breakpoint-aware height in one place Co-authored by Jarvis · https://github.com/kovrichard/portable-agent-layer --- src/components/ui/native-select.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/components/ui/native-select.tsx diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx new file mode 100644 index 0000000..e16305b --- /dev/null +++ b/src/components/ui/native-select.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function NativeSelect({ className, ...props }: React.ComponentProps<"select">) { + return ( +