feat(ui): consume common app shell

This commit is contained in:
Saravana Achu Mac 2026-05-06 13:35:54 -07:00
parent 4cfe5aee5e
commit 63211c0019
4 changed files with 96 additions and 67 deletions

View File

@ -25,10 +25,12 @@
--bl-accent-muted: var(--nl-accent-muted); --bl-accent-muted: var(--nl-accent-muted);
--bl-surface-card: var(--nl-surface-card); --bl-surface-card: var(--nl-surface-card);
--bl-surface-muted: var(--nl-surface-muted); --bl-surface-muted: var(--nl-surface-muted);
--bl-surface-sidebar: var(--nl-surface-sidebar);
--bl-text-primary: var(--nl-text-primary); --bl-text-primary: var(--nl-text-primary);
--bl-text-secondary: var(--nl-text-secondary); --bl-text-secondary: var(--nl-text-secondary);
--bl-text-tertiary: var(--nl-text-tertiary); --bl-text-tertiary: var(--nl-text-tertiary);
--bl-border: var(--nl-border-default); --bl-border: var(--nl-border-default);
--bl-overlay-scrim: var(--nl-overlay-scrim);
--bl-success: var(--nl-status-success); --bl-success: var(--nl-status-success);
--bl-success-muted: var(--nl-success-muted); --bl-success-muted: var(--nl-success-muted);
--bl-success-border: var(--nl-status-success); --bl-success-border: var(--nl-status-success);
@ -92,6 +94,7 @@ button {
.main-panel { .main-panel {
min-width: 0; min-width: 0;
padding: var(--nl-space-8); padding: var(--nl-space-8);
padding-left: calc(var(--bl-app-sidebar-width, 280px) + var(--nl-space-8));
} }
.app-shell > .sidebar { .app-shell > .sidebar {

View File

@ -3,7 +3,14 @@
import { type ReactNode, useState, useCallback, useEffect } from "react"; import { type ReactNode, useState, useCallback, useEffect } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import { Button, Card } from "@/components/ui/Primitives"; import {
AppShell as AppShellFrame,
AppShellMain,
AppShellMobileToggle,
AppShellOverlay,
AppShellPageHeader,
AppShellSkipLink,
} from "@/components/ui/Primitives";
export function AppShell({ export function AppShell({
title, title,
@ -27,47 +34,15 @@ export function AppShell({
const close = useCallback(() => setSidebarOpen(false), []); const close = useCallback(() => setSidebarOpen(false), []);
return ( return (
<div className="app-shell"> <AppShellFrame sidebarWidth={280}>
<a href="#main-content" className="skip-link"> <AppShellSkipLink />
Skip to main content <AppShellMobileToggle open={sidebarOpen} onClick={toggle} />
</a> <AppShellOverlay open={sidebarOpen} onClick={close} />
<Button
className="sidebar-toggle"
onClick={toggle}
aria-label={sidebarOpen ? "Close menu" : "Open menu"}
type="button"
variant="secondary"
size="sm"
>
{sidebarOpen ? "\u2715" : "\u2630"}
</Button>
<div
className={`sidebar-overlay${sidebarOpen ? " open" : ""}`}
onClick={close}
aria-hidden="true"
/>
<Sidebar open={sidebarOpen} /> <Sidebar open={sidebarOpen} />
<main id="main-content" className="main-panel" tabIndex={-1} aria-labelledby="page-title"> <AppShellMain labelledBy="page-title" className="main-panel">
<div className="page-grid"> <AppShellPageHeader title={title} description={description} actions={actions} />
<header> {children}
<Card </AppShellMain>
style={{ padding: "var(--nl-space-6)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-4)", alignItems: "start", flexWrap: "wrap" }} </AppShellFrame>
>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<h1
id="page-title"
style={{ margin: 0, fontFamily: "var(--nl-font-display)", fontSize: "var(--nl-fs-2xl)", fontWeight: 700 }}
>
{title}
</h1>
<div style={{ color: "var(--nl-text-secondary)", maxWidth: 720 }}>{description}</div>
</div>
{actions ? <div aria-label="Page actions">{actions}</div> : null}
</Card>
</header>
{children}
</div>
</main>
</div>
); );
} }

View File

@ -1,10 +1,16 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle, Brain } from "lucide-react"; import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle, Brain } from "lucide-react";
import { PRODUCT_NAME } from "@/lib/product-config"; import { PRODUCT_NAME } from "@/lib/product-config";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import {
AppShellNav,
AppShellNavItem,
AppShellSidebar,
Badge,
Panel,
} from "@/components/ui/Primitives";
const navItems: { href: string; label: string; icon: typeof House; flag?: string }[] = [ const navItems: { href: string; label: string; icon: typeof House; flag?: string }[] = [
{ href: "/dashboard", label: "Dashboard", icon: House }, { href: "/dashboard", label: "Dashboard", icon: House },
@ -21,56 +27,47 @@ export function Sidebar({ open }: { open?: boolean }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
return ( return (
<aside className={`sidebar app-sidebar${open ? ' open' : ''}`} style={{ padding: "var(--nl-space-6)" }} aria-label="Primary"> <AppShellSidebar open={open ?? false} width={280} className="sidebar p-[var(--nl-space-6)]">
<div style={{ display: "grid", gap: "var(--nl-space-6)" }}> <div style={{ display: "grid", gap: "var(--nl-space-6)" }}>
<div className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}> <Panel as="div" style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<div className="badge" style={{ width: "fit-content" }}> <Badge style={{ width: "fit-content" }}>
<Sparkles size={14} /> <Sparkles size={14} />
Web Agent workstream Web Agent workstream
</div> </Badge>
<div> <div>
<div style={{ fontSize: "var(--nl-fs-xl)", fontFamily: "var(--nl-font-display)", fontWeight: 700 }}>{PRODUCT_NAME}</div> <div style={{ fontSize: "var(--nl-fs-xl)", fontFamily: "var(--nl-font-display)", fontWeight: 700 }}>{PRODUCT_NAME}</div>
<div style={{ color: "var(--nl-text-secondary)", marginTop: 6 }}> <div style={{ color: "var(--nl-text-secondary)", marginTop: 6 }}>
Human-first notes UX with agent-safe structure and review surfaces. Human-first notes UX with agent-safe structure and review surfaces.
</div> </div>
</div> </div>
</div> </Panel>
<nav aria-label="Primary navigation" style={{ display: "grid", gap: "var(--nl-space-2)" }}> <AppShellNav>
{navItems.filter((item) => !item.flag || isFeatureEnabled(item.flag)).map((item) => { {navItems.filter((item) => !item.flag || isFeatureEnabled(item.flag)).map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return ( return (
<Link <AppShellNavItem
key={item.href} key={item.href}
href={item.href} href={item.href}
className="surface-muted nav-link" active={active}
aria-current={active ? "page" : undefined} icon={<Icon size={18} />}
style={{
display: "flex",
alignItems: "center",
gap: "var(--nl-space-3)",
padding: "12px 14px",
borderColor: active ? "var(--nl-accent-primary)" : undefined,
background: active ? "var(--nl-accent-muted)" : undefined,
}}
> >
<Icon size={18} /> {item.label}
<span>{item.label}</span> </AppShellNavItem>
</Link>
); );
})} })}
</nav> </AppShellNav>
<div className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-2)" }}> <Panel as="div" style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>Keyboard flow</strong> <strong>Keyboard flow</strong>
<span style={{ color: "var(--nl-text-secondary)" }}> <span style={{ color: "var(--nl-text-secondary)" }}>
<kbd style={{ opacity: 0.85 }}>K</kbd> / <kbd style={{ opacity: 0.85 }}>Ctrl+K</kbd> command palette <kbd style={{ opacity: 0.85 }}>K</kbd> / <kbd style={{ opacity: 0.85 }}>Ctrl+K</kbd> command palette
</span> </span>
<span style={{ color: "var(--nl-text-secondary)" }}>Use Tab to move between navigation, filters, and dense result surfaces.</span> <span style={{ color: "var(--nl-text-secondary)" }}>Use Tab to move between navigation, filters, and dense result surfaces.</span>
<span style={{ color: "var(--nl-text-secondary)" }}>Use the skip link to jump directly into page content.</span> <span style={{ color: "var(--nl-text-secondary)" }}>Use the skip link to jump directly into page content.</span>
</div> </Panel>
</div> </div>
</aside> </AppShellSidebar>
); );
} }

View File

@ -1,4 +1,13 @@
import { import {
AppShell as BytelystAppShell,
AppShellMain as BytelystAppShellMain,
AppShellMobileToggle as BytelystAppShellMobileToggle,
AppShellNav as BytelystAppShellNav,
AppShellNavItem as BytelystAppShellNavItem,
AppShellOverlay as BytelystAppShellOverlay,
AppShellPageHeader as BytelystAppShellPageHeader,
AppShellSidebar as BytelystAppShellSidebar,
AppShellSkipLink as BytelystAppShellSkipLink,
Badge as BytelystBadge, Badge as BytelystBadge,
Button as BytelystButton, Button as BytelystButton,
Card as BytelystCard, Card as BytelystCard,
@ -53,6 +62,15 @@ import {
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
type AppShellMainProps,
type AppShellMobileToggleProps,
type AppShellNavItemProps,
type AppShellNavProps,
type AppShellOverlayProps,
type AppShellPageHeaderProps,
type AppShellProps,
type AppShellSidebarProps,
type AppShellSkipLinkProps,
type BadgeProps, type BadgeProps,
type ButtonProps, type ButtonProps,
type CardProps, type CardProps,
@ -144,6 +162,42 @@ const statusToneMap: Record<NoteLettStatus, StatusTone> = {
review: "accent", review: "accent",
}; };
export function AppShell({ className, ...props }: AppShellProps) {
return <BytelystAppShell className={className} {...props} />;
}
export function AppShellSkipLink({ className, ...props }: AppShellSkipLinkProps) {
return <BytelystAppShellSkipLink className={className} {...props} />;
}
export function AppShellMobileToggle({ className, ...props }: AppShellMobileToggleProps) {
return <BytelystAppShellMobileToggle className={className} {...props} />;
}
export function AppShellOverlay({ className, ...props }: AppShellOverlayProps) {
return <BytelystAppShellOverlay className={className} {...props} />;
}
export function AppShellSidebar({ className, ...props }: AppShellSidebarProps) {
return <BytelystAppShellSidebar className={className} {...props} />;
}
export function AppShellMain({ className, ...props }: AppShellMainProps) {
return <BytelystAppShellMain className={className} {...props} />;
}
export function AppShellPageHeader({ className, ...props }: AppShellPageHeaderProps) {
return <BytelystAppShellPageHeader className={className} {...props} />;
}
export function AppShellNav({ className, ...props }: AppShellNavProps) {
return <BytelystAppShellNav className={className} {...props} />;
}
export function AppShellNavItem({ className, ...props }: AppShellNavItemProps) {
return <BytelystAppShellNavItem className={className} {...props} />;
}
export function getStatusTone(status: NoteLettStatus): StatusTone { export function getStatusTone(status: NoteLettStatus): StatusTone {
return statusToneMap[status]; return statusToneMap[status];
} }