{
  "example": "import { AppLauncher, Topbar } from \"@godxjp/ui/layout\";\n\n<Topbar\n  start={brand}\n  end={\n    <AppLauncher\n      apps={[\n        { id: \"console\", name: t(\"app.console\"), href: \"/console\", icon: <BarChart3 />, current: true },\n        { id: \"billing\", name: t(\"app.billing\"), href: \"/billing\", icon: <Receipt /> },\n      ]}\n      groups={[{ label: t(\"app.more\"), apps: [{ id: \"support\", name: t(\"app.support\"), href: supportUrl, external: true }] }]}\n      linkComponent={inertiaSidebarLink(Link)}\n      labels={labels}\n    />\n  }\n/>",
  "group": "layout",
  "importPath": "@godxjp/ui/layout",
  "name": "AppLauncher",
  "props": [
    {
      "description": "Ungrouped app tiles, rendered first with no heading. Each is { id, name, href, icon?, current?, external? } and each tile is a REAL <a href> — pass only apps the viewer may open; there is no disabled tile.",
      "name": "apps",
      "required": true,
      "type": "readonly AppLauncherApp[]"
    },
    {
      "description": "Labelled sections rendered after `apps` — the \"more from …\" band. Each group is a named role=\"group\", not a heading, so the same markup is correct inside the popover and inside the Sheet.",
      "name": "groups",
      "type": "readonly AppLauncherGroup[]"
    },
    {
      "description": "Localized trigger name, panel title, empty/loading/retry copy, and the optional `externalHint` announced on an external tile (WCAG 3.2.5). `trigger` is a plain string, not a function of the current app: the nine-dot button shows no current value.",
      "name": "labels",
      "required": true,
      "type": "AppLauncherLabels"
    },
    {
      "defaultValue": "3",
      "description": "Grid column count, written inline to the `--app-launcher-columns` custom property. Omit it and `.ui-app-launcher-panel` keeps its own declaration of 3 — the default sits in the stylesheet, where a theme can reach it, rather than in a component token (the token NAME vocabulary has no word for a count).",
      "name": "columns",
      "type": "number"
    },
    {
      "description": "Framework router link — the SAME contract `Sidebar` and `NavList` take, so `inertiaSidebarLink(Link)` / `createSidebarLink(Link, \"to\")` is reused verbatim. The launcher still composes the tile; `external` apps bypass it and render a plain anchor.",
      "name": "linkComponent",
      "type": "SidebarLinkComponentProp"
    },
    {
      "defaultValue": "false",
      "description": "Shows the loading state; the trigger stays openable.",
      "name": "loading",
      "type": "boolean"
    },
    {
      "description": "Consumer-supplied error state.",
      "name": "error",
      "type": "ReactNode"
    },
    {
      "description": "Consumer-owned retry callback.",
      "name": "onRetry",
      "type": "() => void"
    },
    {
      "defaultValue": "\"auto\"",
      "description": "Presentation contract. \"auto\" resolves through the SHARED Sheet hook useSheetResponsiveMode(): desktop popover above --sheet-responsive-breakpoint-width (48rem/768px), focus-trapped bottom Sheet at/below it. \"fullscreen\" is the LAUNCHPAD — one full-viewport modal at every width, the page behind it blurred and the grid floating on that ground (macOS Launchpad / Windows Start), with the column count stepping 3·4·5·6 on the house container ladder. Reach for it in a PLATFORM start bar, where the launcher is the primary way to move between products; keep \"auto\" for a launcher inside one app's topbar, where the grid is a shortcut and the work behind it should stay visible.",
      "name": "responsive",
      "type": "\"auto\" | \"popover\" | \"sheet\" | \"fullscreen\""
    },
    {
      "defaultValue": "\"bar\"",
      "description": "The BOX the trigger takes — the same split `AppSettingPicker menu={false}` draws, for the same reason. `bar` (default) is a TopbarItem: a cell as tall as the bar, whose hover IS the bar's surface. `icon` is a square ghost Button, for chrome that is NOT a bar — a nav rail (GoDX Dock puts it there), a card header, a toolbar. A TopbarItem outside a bar has nothing to bleed to: it stretches to a container that never set a band height, and its squared corners and full-bleed hover read as a broken cell rather than a control. The panel, the grid, the labels and the responsive contract are identical in both.",
      "name": "appearance",
      "type": "\"bar\" | \"icon\""
    },
    {
      "description": "Which way the panel opens. DERIVED from `appearance` when unset — `bottom` in a bar (the panel drops below the trigger, the only direction that does not cover the bar itself), `right` otherwise, because a rail is vertical and its panel goes beside it. State it when the chrome can be RE-DOCKED: `appearance` says the trigger is NOT in a bar, and it cannot say which way is out — a rail pinned to the top edge is not a bar and still opens downward. Measured without it, an embedded bar trigger at (2,50) put its panel at (12,90), lying over the host application's sidebar. Not used by `responsive=\"fullscreen\"`, which has no side.",
      "name": "side",
      "type": "\"top\" | \"right\" | \"bottom\" | \"left\""
    },
    {
      "description": "Where the panel sits along the `side` edge — the cross-axis half of the same decision, and derived the same way: `end` in a bar (the Workspace shape, flush with the bar's end), `start` otherwise (aligned to the rail trigger's own start). State it alongside `side` when you state either.",
      "name": "align",
      "type": "\"start\" | \"center\" | \"end\""
    },
    {
      "description": "Controlled open state.",
      "name": "open",
      "type": "boolean"
    },
    {
      "description": "Open-state callback.",
      "name": "onOpenChange",
      "type": "(open: boolean) => void"
    },
    {
      "description": "Secondary links under the grid — \"Request access\", \"All apps\" (the Workspace / Okta / My Apps row, gh#1172). Pass the links; the launcher draws the row (hairline above, small type, --app-launcher-footer-*) outside the scrolling grid, after the tiles in focus order, in every state including empty/loading/error. Not a tile: never fake it as a one-tile group.",
      "name": "footer",
      "type": "ReactNode"
    }
  ],
  "related": [
    "ServiceLauncherCard — the PAGE-SIZED launcher tile (status, hostname, plan, action, locked reason) for a service-catalogue page, where choosing is a considered act. AppLauncher's tile is bar-sized: mark + name, the whole tile one link, because changing app is a reflex. Neither is built out of the other; a grid of ServiceLauncherCards inside a popover is the wrong component.",
    "AppShell (navRail) — the SAME platform scope expressed as a docked column instead of a bar control. Pick ONE: the launcher for a platform with MANY apps where switching is occasional (Google Workspace), the rail for a single product where switching workspace is constant enough to deserve permanent screen width (Slack).",
    "OrgSwitcher — the other platform-scope control: which ORGANIZATION you are in, not which app. They compose; they do not replace each other.",
    "TopbarItem — the bar cell the trigger is built from; use it directly for a one-off bar control."
  ],
  "rules": [],
  "storyPath": "layout/app-launcher.tsx",
  "tagline": "Nine-dot topbar app grid — the platform-standard way to switch app (the Google Workspace shape).",
  "usage": [
    "DO drop it straight into a Topbar slot. It renders NO wrapper element, so the trigger is the slot's own flex child and `.ui-topbar-item { align-self: stretch }` reaches the bar's height.",
    "DON'T hand-roll the trigger as `Button variant=\"ghost\"` IN A BAR. A Button in a bar is a --control-height pill floating in a taller strip, with its own hover fill and its own focus ring — the exact regression corrected in 20.0.0. In a bar the trigger is a `TopbarItem`, and that is what `appearance=\"bar\"` (the default) renders. OUTSIDE a bar the square ghost Button is correct, and `appearance=\"icon\"` renders it for you — still hand-roll nothing.",
    "DO mark the app the viewer is inside with `current` — it becomes `aria-current=\"page\"`, which is what both the tint and the announcement key off.",
    "DO set `external` on a destination outside this SPA. It renders a plain anchor with target/rel and skips `linkComponent`, because a client-side router link to another origin routes nowhere.",
    "DON'T wrap it in your own media query to pick popover vs sheet — `responsive=\"auto\"` already reads the shared --sheet-responsive-breakpoint-width token.",
    "`className`, `id` and every `data-*` land on the TRIGGER, so an end-to-end selector can hold it without depending on the localized accessible name."
  ],
  "useCases": [
    "A platform with several apps (console, billing, chat, people) where the bar must offer all of them from every page.",
    "Replacing a hand-rolled dropdown of product links in the topbar end slot."
  ]
}
