jengacore.shadcn registry · Tailwind v4 · Radix and Base UI
Components
jengacore components · @jengacore/app-shell

AppShell

Frame, 56px top bar with breadcrumbs and environment chip, main area and the PageHeader anatomy.

UAT
01 · Monitor · today

Screening overview

Install

terminal
npx shadcn@latest add @jengacore/app-shell

Without 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>
  )
}