{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nx-app-card",
  "title": "Nx App Card",
  "description": "The launcher card for one app in the Nexus catalogue: the large home-screen square with its seeded pattern field (earthworks, geotechnical, intelligence) and optional hero asset, and the compact administration/internal row. Renders the enabled, locked, upsell and coming-soon states nx-id resolved — presentation only, never a security boundary.",
  "dependencies": [
    "@untitledui/icons@^0.0.23"
  ],
  "registryDependencies": [
    "@nx-ui/groundwork-background",
    "@nx-ui/nx-app-icon",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/nx-app-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, Check, Lock01 } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  NxAppIcon,\n  type NxAppIconMark,\n  type NxAppIconMarkSpec,\n  type NxAppIconTone,\n  type NxAppIconToneSpec,\n} from \"@/components/blocks/nx-app-icon\"\nimport {\n  createTerrain,\n  polygon,\n  projector,\n  randomAt,\n  siteLinks,\n  xy,\n  type Point,\n} from \"@/components/blocks/groundwork-background/terrain\"\n\n/**\n * NxAppCard — the launcher card for one app in the Nexus catalogue. The\n * `large` size is the home-screen square (pattern field, tile, name,\n * description, hero asset when the catalogue carries one); `compact` is the\n * administration / internal-tools row (tile + name + one-line description +\n * arrow) in the same border and hover language.\n *\n * Props are the `NxCatalogueApp` fields from `nx-apps` — nx-id resolved the\n * state, this renders it. The card is NOT a security boundary: a locked card\n * only says \"we are not showing you a link\", and the destination gates itself.\n *\n * States: `enabled` is a link; `locked` / `upsell` render desaturated with a\n * lock glyph and a reason label — as a `<button>` when `onLocked` is given, so\n * a consumer can open an upsell dialog, and otherwise as an inert card with\n * nothing to press; `coming_soon` is inert with a \"Soon\" pill.\n *\n * Pass the catalogue fields explicitly — a `{...app}` spread would push wire\n * fields (`key`, `section`, `sortOrder`, `reason`, `keywords`…) onto the DOM.\n *\n * @example\n * <NxAppCardGrid>\n *   {apps.map((app) => (\n *     <NxAppCard\n *       key={app.key}\n *       size=\"large\"\n *       name={app.name}\n *       description={app.description}\n *       href={app.href}\n *       state={app.state}\n *       badge={app.badge}\n *       visual={app.visual}\n *       assets={app.assets}\n *       onLocked={() => openUpsell(app)}\n *     />\n *   ))}\n * </NxAppCardGrid>\n */\n\nexport type NxAppCardPattern =\n  | \"earthworks\"\n  | \"geotechnical\"\n  | \"intelligence\"\n  | \"none\"\n\nexport type NxAppCardState = \"enabled\" | \"locked\" | \"upsell\" | \"coming_soon\"\n\nexport type NxAppCardAssetVariant = { light?: string; dark?: string }\n\nexport type NxAppCardProps = {\n  name: string\n  description?: string\n  /** Destination. Ignored unless `state` is `enabled`. */\n  href?: string | null\n  /** Resolver verdict from nx-id. Defaults to `enabled`. */\n  state?: NxAppCardState\n  /** Short status label (e.g. \"Beta\") rendered as a pill. */\n  badge?: string\n  visual?: {\n    tone?: NxAppIconTone | NxAppIconToneSpec\n    mark?: NxAppIconMark | NxAppIconMarkSpec\n    pattern?: NxAppCardPattern\n    badge?: { label: string; tone: \"gray\" | \"brand\" }\n  }\n  assets?: {\n    logo?: NxAppCardAssetVariant\n    hero?: NxAppCardAssetVariant\n    thumbnail?: NxAppCardAssetVariant\n  }\n  /** `large` is the square launcher card; `compact` the single-line row. */\n  size?: \"large\" | \"compact\"\n  /** Marks the app the user is already in. */\n  active?: boolean\n  /** Label under the name when locked. Defaults per state. */\n  lockedLabel?: string\n  /** Called when a locked/upsell card is activated — open your upsell dialog. */\n  onLocked?: () => void\n  className?: string\n}\n\n/* The status pill (Soon / Beta). Inlined rather than taking a Badge\n * dependency, matching AppSwitcher's tile pill. */\nfunction StatusPill({\n  tone = \"gray\",\n  children,\n}: {\n  tone?: \"gray\" | \"brand\"\n  children: React.ReactNode\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-[0.6875rem] font-medium\",\n        tone === \"gray\" &&\n          \"border-utility-neutral-200 bg-utility-neutral-50 text-utility-neutral-700\",\n        tone === \"brand\" &&\n          \"border-utility-brand-200 bg-utility-brand-50 text-utility-brand-700\"\n      )}\n    >\n      {children}\n    </span>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Pattern field\n//\n// The three catalogue patterns, drawn from the groundwork terrain/exchange\n// geometry. Deterministic and computed ONCE per module (not per card, not per\n// render) — a card grid draws the same three path strings however many apps it\n// holds. Strokes are `currentColor` at low opacity, so the field reads in both\n// light and dark without a theme lookup. No motion.\n// ---------------------------------------------------------------------------\n\n/** The \"field\" projection the backgrounds use: the terrain oversamples past the\n * viewBox so slicing never shows an edge. */\nconst FIELD = (() => {\n  const projection = projector(45, 180)\n  return (x: number, y: number, h: number) =>\n    projection((x - 0.5) * 3.4 + 0.5, (y - 0.5) * 3.4 + 0.5, h)\n})()\n\nconst required = <T,>(items: readonly T[], index: number): T => {\n  const item = items[index]\n  if (item === undefined) throw new RangeError(`Missing generated geometry at ${index}`)\n  return item\n}\n\n/** `earthworks` = terraced cut-and-fill blocks; `geotechnical` = the raw\n * triangulated survey mesh. Same generator, different height quantisation. */\nfunction terrainPattern(terraced: boolean): string {\n  const terrain = createTerrain(12, terraced ? 71 : 193, 0.55)\n  const n = terrain.size\n  const height = (x: number, y: number) =>\n    terraced\n      ? Math.round(Math.min(required(required(terrain.heights, y), x), 0.62) * 7) / 7\n      : required(required(terrain.heights, y), x)\n  const paths: string[] = []\n  for (let y = 0; y < n; y++) {\n    for (let x = 0; x < n; x++) {\n      const corners: Point[] = [\n        [x, y],\n        [x + 1, y],\n        [x + 1, y + 1],\n        [x, y + 1],\n      ]\n      const points = corners.map(([u, v]) =>\n        FIELD(u / n, v / n, terraced ? height(x, y) : height(u, v))\n      )\n      if (terraced) {\n        paths.push(polygon(points))\n        for (const [a, b] of [\n          [1, 2],\n          [2, 3],\n        ] as const) {\n          const lower = [a, b].map((i) =>\n            FIELD(required(corners, i)[0] / n, required(corners, i)[1] / n, -0.18)\n          )\n          paths.push(polygon([required(points, a), required(points, b), required(lower, 1), required(lower, 0)]))\n        }\n      } else {\n        paths.push(\n          polygon([required(points, 0), required(points, 1), required(points, 2)]),\n          polygon([required(points, 0), required(points, 2), required(points, 3)])\n        )\n      }\n    }\n  }\n  return paths.join(\" \")\n}\n\n/** `intelligence` = the regional exchange fabric: seeded sites, a spanning\n * route network with a little redundancy, and a plate + dot per site. */\nfunction exchangePattern(seed = 307, count = 30): string {\n  const columns = Math.ceil(Math.sqrt(count * 1.2))\n  const rows = Math.ceil(count / columns)\n  const sites: Point[] = Array.from({ length: count }, (_, i) => [\n    0.1 + (((i % columns) + 0.2 + randomAt(i, 1, seed) * 0.6) / columns) * 0.8,\n    0.1 +\n      ((Math.floor(i / columns) + 0.2 + randomAt(i, 2, seed) * 0.6) / rows) *\n        0.8,\n  ])\n  const lift = (i: number) => 0.26 + randomAt(i, 3, seed) * 0.25\n  const centres = sites.map(([x, y], i) => FIELD(x, y, lift(i)))\n\n  const routes = siteLinks(sites, 2).map(([a, b]) => {\n    const start = required(centres, a)\n    const end = required(centres, b)\n    const arc = Math.hypot(end[0] - start[0], end[1] - start[1]) * 0.18 + 24\n    return `M${xy(start)} C${start[0]},${start[1] - arc} ${end[0]},${end[1] - arc} ${xy(end)}`\n  })\n\n  const plates = sites.map(([x, y], i) => {\n    const w = 0.021 + randomAt(i, 4, seed) * 0.016\n    return polygon(\n      (\n        [\n          [x - w, y - w],\n          [x + w, y - w],\n          [x + w, y + w],\n          [x - w, y + w],\n        ] as Point[]\n      ).map(([u, v]) => FIELD(u, v, lift(i)))\n    )\n  })\n\n  // A small diamond per site — the whole field is one <path>, so no <circle>.\n  const dots = centres.map(([cx, cy]) =>\n    polygon([\n      [cx, cy - 3],\n      [cx + 3, cy],\n      [cx, cy + 3],\n      [cx - 3, cy],\n    ])\n  )\n\n  return [...routes, ...plates, ...dots].join(\" \")\n}\n\nconst PATTERNS: Record<Exclude<NxAppCardPattern, \"none\">, string> = {\n  earthworks: terrainPattern(true),\n  geotechnical: terrainPattern(false),\n  intelligence: exchangePattern(),\n}\n\nfunction AppPattern({ kind }: { kind: NxAppCardPattern }) {\n  if (!kind || kind === \"none\") return null\n  return (\n    <svg\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      data-app-pattern={kind}\n      className=\"pointer-events-none absolute inset-0 size-full opacity-[0.12]\"\n      viewBox=\"0 0 1200 720\"\n      preserveAspectRatio=\"xMidYMid slice\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.4\"\n    >\n      <path d={PATTERNS[kind]} />\n    </svg>\n  )\n}\n\n/**\n * The hosted hero, with a dark variant when the catalogue carries one. Both\n * images render and the theme class picks one (`dark:` is bound to nx-ui's\n * `.dark-mode` class), so the asset follows the app's own light/dark choice\n * rather than the OS scheme. Decorative: the card's accessible name is its\n * heading, so `alt=\"\"` and `aria-hidden`.\n */\nfunction AppHero({ variant }: { variant?: NxAppCardAssetVariant }) {\n  const light = variant?.light ?? variant?.dark\n  const dark = variant?.dark ?? variant?.light\n  if (!light || !dark) return null\n  const base =\n    \"pointer-events-none absolute inset-0 size-full object-cover object-top\"\n  const both = light === dark\n  return (\n    <>\n      {/* eslint-disable-next-line @next/next/no-img-element */}\n      <img\n        src={light}\n        alt=\"\"\n        aria-hidden=\"true\"\n        loading=\"lazy\"\n        className={cn(base, !both && \"dark:hidden\")}\n      />\n      {both ? null : (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          src={dark}\n          alt=\"\"\n          aria-hidden=\"true\"\n          loading=\"lazy\"\n          className={cn(base, \"hidden dark:block\")}\n        />\n      )}\n      {/* Legibility scrim: the card's own surface fading up off the bottom, so\n          the tile, name and description stay readable on a light hero in light\n          mode and a dark hero in dark mode. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-3/5 bg-gradient-to-t from-card via-card/85 to-transparent\"\n      />\n    </>\n  )\n}\n\nfunction NxAppCard({\n  name,\n  description,\n  href,\n  state = \"enabled\",\n  badge,\n  visual,\n  assets,\n  size = \"large\",\n  active = false,\n  lockedLabel,\n  onLocked,\n  className,\n  ...props\n}: NxAppCardProps &\n  Omit<React.HTMLAttributes<HTMLElement>, keyof NxAppCardProps>) {\n  const large = size === \"large\"\n  const locked = state === \"locked\" || state === \"upsell\"\n  const comingSoon = state === \"coming_soon\"\n  const isLink = state === \"enabled\" && Boolean(href) && !active\n  // A locked card is only pressable when the consumer has somewhere to send\n  // the press; otherwise it is an inert card, not a button that does nothing.\n  const isLockedButton = locked && Boolean(onLocked)\n  const pillLabel = badge ?? visual?.badge?.label\n  const pillTone = visual?.badge?.tone ?? \"brand\"\n  const reason =\n    lockedLabel ??\n    (state === \"upsell\" ? \"Not on your organisation\" : \"No access\")\n\n  const frame = cn(\n    \"group relative flex overflow-hidden rounded-xl border border-border bg-card text-left text-card-foreground outline-none\",\n    \"transition-[background-color,box-shadow,transform] duration-200 ease-out motion-reduce:transition-none\",\n    large\n      ? \"aspect-[4/3] w-full flex-col justify-end p-4\"\n      : \"w-full items-center gap-3 p-3\",\n    isLink &&\n      \"cursor-pointer hover:bg-muted/60 hover:shadow-sm focus-visible:ring-4 focus-visible:ring-ring/25 active:scale-[0.99] motion-reduce:active:scale-100\",\n    locked && \"saturate-50\",\n    isLockedButton &&\n      \"cursor-pointer focus-visible:ring-4 focus-visible:ring-ring/25\",\n    locked && !isLockedButton && \"cursor-not-allowed opacity-75\",\n    comingSoon && \"cursor-not-allowed opacity-60\",\n    active && \"bg-muted/60\",\n    className\n  )\n\n  const tile = (\n    <span className=\"relative inline-flex\">\n      <NxAppIcon\n        tone={visual?.tone ?? \"navy\"}\n        mark={visual?.mark}\n        size={large ? 48 : 36}\n        radius={0.26}\n        className={cn(locked && \"opacity-70\")}\n      />\n      {active ? (\n        <span className=\"absolute -top-1 -right-1 grid size-4 place-items-center rounded-full bg-background ring-1 ring-border\">\n          <Check\n            className=\"size-3 stroke-[3px] text-fg-brand-primary\"\n            aria-hidden=\"true\"\n          />\n        </span>\n      ) : locked ? (\n        <span className=\"absolute -right-1 -bottom-1 grid size-4 place-items-center rounded-full bg-background ring-1 ring-border\">\n          <Lock01 className=\"size-2.5 text-fg-quaternary\" aria-hidden=\"true\" />\n        </span>\n      ) : null}\n    </span>\n  )\n\n  const meta = (\n    <>\n      {comingSoon ? (\n        <StatusPill>Soon</StatusPill>\n      ) : locked ? (\n        <span className=\"text-xs text-muted-foreground\">{reason}</span>\n      ) : pillLabel ? (\n        <StatusPill tone={pillTone}>{pillLabel}</StatusPill>\n      ) : null}\n    </>\n  )\n\n  const body = large ? (\n    <>\n      <AppPattern kind={visual?.pattern ?? \"none\"} />\n      <AppHero variant={assets?.hero} />\n      <span className=\"relative flex flex-col gap-3\">\n        {tile}\n        <span className=\"flex flex-col gap-1\">\n          <span className=\"flex items-center gap-2\">\n            <span className=\"truncate text-sm font-semibold text-foreground\">\n              {name}\n            </span>\n            {meta}\n          </span>\n          {description ? (\n            <span className=\"line-clamp-2 text-xs text-muted-foreground\">\n              {description}\n            </span>\n          ) : null}\n        </span>\n      </span>\n    </>\n  ) : (\n    <>\n      {tile}\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"flex items-center gap-2\">\n          <span className=\"truncate text-sm font-semibold text-foreground\">\n            {name}\n          </span>\n          {meta}\n        </span>\n        {description ? (\n          <span className=\"truncate text-xs text-muted-foreground\">\n            {description}\n          </span>\n        ) : null}\n      </span>\n      {isLink ? (\n        <ArrowUpRight\n          className=\"size-4 shrink-0 text-fg-quaternary transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 motion-reduce:group-hover:translate-y-0\"\n          aria-hidden=\"true\"\n        />\n      ) : null}\n    </>\n  )\n\n  if (isLink) {\n    return (\n      <a\n        {...props}\n        href={href ?? undefined}\n        className={frame}\n        aria-label={`Open ${name}`}\n      >\n        {body}\n      </a>\n    )\n  }\n\n  if (isLockedButton) {\n    return (\n      <button\n        {...props}\n        type=\"button\"\n        onClick={onLocked}\n        className={frame}\n        aria-label={`${name} — ${reason}`}\n      >\n        {body}\n      </button>\n    )\n  }\n\n  return (\n    <div\n      {...props}\n      className={frame}\n      aria-disabled={comingSoon || locked ? \"true\" : undefined}\n      aria-current={active ? \"true\" : undefined}\n    >\n      {body}\n    </div>\n  )\n}\n\n/** The responsive card grid — four large cards per row at desktop width, one\n * compact column per row below `sm`. Nothing but layout; use your own grid if\n * this does not suit. */\nfunction NxAppCardGrid({\n  size = \"large\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & { size?: \"large\" | \"compact\" }) {\n  return (\n    <div\n      data-slot=\"nx-app-card-grid\"\n      className={cn(\n        \"grid gap-3\",\n        size === \"large\"\n          ? \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\"\n          : \"grid-cols-1 sm:grid-cols-2\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n\n/** The subtle uppercase sub-heading above a catalogue section. */\nfunction NxAppSectionHeading({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"h2\">) {\n  return (\n    <h2\n      data-slot=\"nx-app-section-heading\"\n      className={cn(\n        \"text-xs font-semibold tracking-wide text-muted-foreground uppercase\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </h2>\n  )\n}\n\nexport { NxAppCard, NxAppCardGrid, NxAppSectionHeading }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:block"
}