jengacore components · @jengacore/app-shell
AppShell
Frame, 56px top bar with breadcrumbs and environment chip, main area and the PageHeader anatomy.
01 · Monitor · today
Screening overview
Install
terminal
npx shadcn@latest add @jengacore/app-shellWithout the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/app-shell.json
Registry dependencies
Source
components/jc/app-shell.tsx
import * as React from "react"
import { Bell, ChevronRight, Search } from "lucide-react"
import { EnvChip, type Environment } from "@/components/jc/env-chip"
import { cn } from "@/lib/utils"
/**
* The frame every jengacore product shares: navy sidebar (both themes), a 56px top bar with breadcrumbs
* and the environment chip, and the `canvas` workspace. A user moving from the Finance Portal to Disputes
* should only notice the wordmark and the marker colour change.
*/
export function AppShell({ sidebar, children, className }: { sidebar: React.ReactNode; children: React.ReactNode; className?: string }) {
return (
<div data-slot="app-shell" className={cn("flex min-h-svh bg-canvas", className)}>
<div className="sticky top-0 h-svh shrink-0">{sidebar}</div>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
)
}
export type Crumb = { label: string; href?: string }
/**
* 56px top bar: breadcrumbs, environment chip, then the search field (opens the ⌘K palette, "/" anywhere)
* and notifications on the right. `actions` adds anything else (user menu, tenant switcher).
*/
export function AppTopbar({
crumbs = [],
env,
search,
notifications,
actions,
className,
}: {
crumbs?: Crumb[]
env?: Environment
/** Placeholder names what can be found: "Transfer ID, MSISDN, alert…". */
search?: { placeholder: string; onOpen: () => void }
notifications?: { unread: number; onOpen?: () => void }
actions?: React.ReactNode
className?: string
}) {
return (
<header
data-slot="app-topbar"
className={cn("sticky top-0 z-30 flex min-h-14 flex-wrap items-center gap-3 border-b border-border bg-background px-7 py-2", className)}
>
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-2 text-[13px] text-muted-foreground before:h-px before:w-[26px] before:shrink-0 before:bg-primary">
<ol className="flex min-w-0 items-center gap-2">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1
return (
<li key={c.label + i} className="flex min-w-0 items-center gap-2">
{last ? (
<span aria-current="page" className="truncate font-medium text-foreground">
{c.label}
</span>
) : c.href ? (
<a href={c.href} className="truncate hover:text-foreground">
{c.label}
</a>
) : (
<span className="truncate">{c.label}</span>
)}
{!last ? <ChevronRight className="size-3.5 shrink-0 stroke-[1.75]" aria-hidden /> : null}
</li>
)
})}
</ol>
</nav>
{env ? <EnvChip env={env} /> : null}
<div className="ml-auto flex items-center gap-2">
{search ? (
<button
type="button"
onClick={search.onOpen}
className="flex h-[34px] w-[300px] max-w-full items-center gap-2 rounded-md border border-border-strong bg-card px-2.5 text-[13px] text-muted-foreground transition-colors duration-fast hover:border-input focus-ring max-sm:w-auto"
>
<Search className="size-4 shrink-0 stroke-[1.75]" aria-hidden />
<span className="truncate max-sm:sr-only">{search.placeholder}</span>
<kbd className="ml-auto rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[10.5px] leading-4 max-sm:hidden">/</kbd>
</button>
) : null}
{notifications ? (
<button
type="button"
onClick={notifications.onOpen}
aria-label={notifications.unread ? `Notifications, ${notifications.unread} unread` : "Notifications"}
className="relative grid size-[34px] place-items-center rounded-md text-foreground/80 transition-colors duration-fast hover:bg-accent focus-ring"
>
<Bell className="size-[18px] stroke-[1.75]" aria-hidden />
{notifications.unread ? <span className="absolute top-[7px] right-[8px] size-[7px] rounded-full bg-destructive ring-2 ring-background" /> : null}
</button>
) : null}
{actions}
</div>
</header>
)
}
/** Page content area: 24px top, 28px gutters, 22px between blocks. */
export function AppMain({ className, ...props }: React.ComponentProps<"main">) {
return <main data-slot="app-main" className={cn("flex flex-col gap-[22px] px-7 pt-6 pb-9", className)} {...props} />
}
/**
* Page anatomy: mono eyebrow (MONITOR · TODAY) → app-title → one primary action on the right.
* The title states what the page is; one primary button per view.
*/
export function PageHeader({
index,
eyebrow,
title,
description,
actions,
className,
}: {
/** Section number in the product, shown in primary before the eyebrow: "01". */
index?: string
eyebrow?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
className?: string
}) {
return (
<div data-slot="page-header" className={cn("flex animate-rise flex-wrap items-end justify-between gap-4", className)}>
<div className="min-w-0">
{eyebrow || index ? (
<div className="eyebrow">
{index ? <b className="font-medium text-primary">{index}</b> : null}
{index && eyebrow ? " · " : null}
{eyebrow}
</div>
) : null}
<h1 className="mt-1 text-[26px] leading-[34px] font-semibold tracking-[-0.005em]">{title}</h1>
{description ? <p className="mt-1 max-w-prose text-[13px] text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}