{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "site-footer",
  "title": "Site Footer",
  "description": "A contract-driven site footer: brand block with named social links, collapsible link columns on mobile, and a legal bottom bar with an optional locale switcher.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/site-footer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowUpRight,\n  AtSign,\n  Briefcase,\n  Camera,\n  ChevronDown,\n  GitBranch,\n  Globe,\n  type LucideIcon,\n  Mail,\n  MessageCircle,\n  RotateCcw,\n  Rss,\n  Video,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  FooterGroup,\n  FooterLink,\n  FooterLocale,\n  SiteFooterData,\n  SocialChannel,\n} from \"./site-footer.contract\"\n\n/**\n * Channel icons are **neutral glyphs**, not brand marks. The platform name lives in\n * `label` (the screen-reader-audible accessible name); for real brand marks swap the\n * whole table for simple-icons or similar — see Customization levers in the Prompt.\n */\nconst CHANNEL_ICONS: Record<SocialChannel, LucideIcon> = {\n  chat: MessageCircle,\n  code: GitBranch,\n  email: Mail,\n  photo: Camera,\n  post: AtSign,\n  rss: Rss,\n  video: Video,\n  website: Globe,\n  work: Briefcase,\n}\n\n/** Complement of Tailwind's `md:` (48rem) — change the breakpoint here and in the md: prefixes below together */\nconst NARROW_QUERY = \"(max-width: 47.99rem)\"\n\nlet narrowMql: MediaQueryList | null = null\nfunction getNarrowMql() {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return null\n  narrowMql ??= window.matchMedia(NARROW_QUERY)\n  return narrowMql\n}\n\n/** Never reads window during render: useSyncExternalStore, whose server snapshot is always false (all expanded, links in the HTML) */\nfunction useIsNarrow(enabled: boolean) {\n  const subscribe = React.useCallback((onStoreChange: () => void) => {\n    const mql = getNarrowMql()\n    if (!mql) return () => {}\n    mql.addEventListener(\"change\", onStoreChange)\n    return () => mql.removeEventListener(\"change\", onStoreChange)\n  }, [])\n  const matches = React.useSyncExternalStore(\n    subscribe,\n    () => getNarrowMql()?.matches ?? false,\n    () => false,\n  )\n  return enabled && matches\n}\n\nfunction FooterAnchor({ link, className }: { link: FooterLink; className?: string }) {\n  return (\n    <a\n      className={cn(\n        \"inline-flex items-center gap-1 rounded-sm text-sm text-muted-foreground transition-colors\",\n        \"hover:text-foreground focus-visible:text-foreground focus-visible:outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\",\n        className,\n      )}\n      href={link.href}\n      {...(link.external ? { rel: \"noreferrer\", target: \"_blank\" } : null)}\n    >\n      {link.label}\n      {link.external && (\n        <>\n          <ArrowUpRight aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n          <span className=\"sr-only\">(opens in a new tab)</span>\n        </>\n      )}\n    </a>\n  )\n}\n\nfunction LinkColumn({\n  collapsed,\n  collapsible,\n  group,\n  headingId,\n  onToggle,\n  panelId,\n}: {\n  collapsed: boolean\n  collapsible: boolean\n  group: FooterGroup\n  headingId: string\n  onToggle: () => void\n  panelId: string\n}) {\n  return (\n    <div className={cn(collapsible && \"border-b pb-2 last:border-b-0\")}>\n      <h3 className=\"text-sm font-medium\">\n        {collapsible ? (\n          <button\n            aria-controls={panelId}\n            aria-expanded={!collapsed}\n            className={cn(\n              \"flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm py-2 text-left\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            )}\n            id={headingId}\n            onClick={onToggle}\n            type=\"button\"\n          >\n            {group.label}\n            <ChevronDown\n              aria-hidden=\"true\"\n              className={cn(\n                \"size-4 shrink-0 text-muted-foreground transition-transform duration-200\",\n                \"motion-reduce:transition-none\",\n                !collapsed && \"rotate-180\",\n              )}\n            />\n          </button>\n        ) : (\n          <span id={headingId}>{group.label}</span>\n        )}\n      </h3>\n\n      {/* Collapse transitions grid-rows; a collapsed panel is inert — zero height but still\n          tabbable is an invisible keyboard trap.\n          overflow-hidden has to sit on the grid **child**: a 0fr track's automatic minimum\n          size is only 0 when the child's overflow is not visible, so putting it on the\n          container leaves the ul's padding showing as a 16px sliver.\n          The outer -m-1 plus the ul's p-1 give the focus ring 4px that won't be clipped. */}\n      <div\n        className={cn(\n          \"-m-1 grid transition-[grid-template-rows] duration-200 ease-out\",\n          \"motion-reduce:transition-none\",\n          collapsed ? \"grid-rows-[0fr]\" : \"grid-rows-[1fr]\",\n        )}\n        id={panelId}\n        inert={collapsed || undefined}\n      >\n        <div className=\"overflow-hidden\">\n          <ul aria-labelledby={headingId} className=\"flex flex-col gap-2.5 p-1 pt-3\">\n            {group.links.map(link => (\n              <li key={link.id}>\n                <FooterAnchor link={link} />\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    </div>\n  )\n}\n\n/** Locale switch: a roving-tabindex radiogroup — arrows move between options and select as they go */\nfunction LocaleSwitcher({\n  label,\n  locales,\n  onChange,\n  value,\n}: {\n  label: string\n  locales: FooterLocale[]\n  onChange: (value: string) => void\n  value: string | undefined\n}) {\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([])\n  // An unknown value falls back to the first option: an all -1 roving tabindex would put the whole group out of keyboard reach\n  const active = Math.max(\n    0,\n    locales.findIndex(locale => locale.value === value),\n  )\n\n  const moveTo = (index: number) => {\n    const next = (index + locales.length) % locales.length\n    onChange(locales[next].value)\n    refs.current[next]?.focus()\n  }\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Globe aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" />\n      <div\n        aria-label={label}\n        className=\"inline-flex items-center gap-0.5 rounded-lg border bg-muted/50 p-0.5\"\n        role=\"radiogroup\"\n      >\n        {locales.map((locale, index) => (\n          <button\n            aria-checked={index === active}\n            className={cn(\n              \"cursor-pointer rounded-md px-2 py-1 text-xs transition-colors\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n              \"motion-reduce:transition-none\",\n              index === active\n                ? \"bg-background font-medium text-foreground shadow-sm\"\n                : \"text-muted-foreground hover:text-foreground\",\n            )}\n            key={locale.value}\n            onClick={() => onChange(locale.value)}\n            onKeyDown={event => {\n              if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n                event.preventDefault()\n                moveTo(active + 1)\n              } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n                event.preventDefault()\n                moveTo(active - 1)\n              } else if (event.key === \"Home\") {\n                event.preventDefault()\n                moveTo(0)\n              } else if (event.key === \"End\") {\n                event.preventDefault()\n                moveTo(locales.length - 1)\n              }\n            }}\n            ref={element => {\n              refs.current[index] = element\n            }}\n            role=\"radio\"\n            tabIndex={index === active ? 0 : -1}\n            type=\"button\"\n          >\n            {locale.label}\n          </button>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nexport interface SiteFooterProps\n  extends SiteFooterData,\n    Omit<React.ComponentPropsWithoutRef<\"footer\">, keyof SiteFooterData | \"children\"> {\n  /** brand mark before the wordmark; defaults to a monogram tile built from `brand.name` */\n  logo?: React.ReactNode\n  /** false = columns stay expanded at every width (plain stacked list on narrow screens) */\n  collapsibleOnMobile?: boolean\n  /** rendered in the bottom bar only when both `locales` and `onLocaleChange` are supplied */\n  locales?: FooterLocale[]\n  activeLocale?: string\n  onLocaleChange?: (value: string) => void\n  /** refetch the link groups; the error state hides its button when omitted */\n  onRetry?: () => void\n  /** accessible names — override for non-English hosts */\n  labels?: { groups?: string; legal?: string; locale?: string; social?: string }\n}\n\nexport function SiteFooter({\n  activeLocale,\n  brand,\n  className,\n  collapsibleOnMobile = true,\n  groups,\n  labels,\n  legal,\n  locales,\n  logo,\n  onLocaleChange,\n  onRetry,\n  social,\n  status,\n  ...footerProps\n}: SiteFooterProps) {\n  const uid = React.useId()\n  const isNarrow = useIsNarrow(collapsibleOnMobile)\n  const [openIds, setOpenIds] = React.useState<readonly string[]>([])\n\n  const groupsLabel = labels?.groups ?? \"Footer\"\n  const legalLabel = labels?.legal ?? \"Legal\"\n  const localeLabel = labels?.locale ?? \"Language\"\n  const socialLabel = labels?.social ?? \"Social media\"\n\n  // Data arrived with zero groups is the empty state — otherwise this renders a grid holding nothing\n  const resolved = status === \"ready\" && groups.length === 0 ? \"empty\" : status\n  const monogram = brand.name.trim().charAt(0).toUpperCase()\n\n  return (\n    <footer className={cn(\"w-full border-t bg-background\", className)} {...footerProps}>\n      <div className=\"mx-auto flex w-full max-w-7xl flex-col px-6 py-12\">\n        <div className=\"grid gap-10 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]\">\n          {/* Brand column: wordmark + one-liner + social icons */}\n          <div className=\"flex flex-col items-start gap-4\">\n            <a\n              className={cn(\n                \"inline-flex items-center gap-2 rounded-md text-base font-semibold tracking-tight\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n              )}\n              href={brand.href}\n            >\n              {logo ?? (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground\"\n                >\n                  {monogram}\n                </span>\n              )}\n              {brand.name}\n            </a>\n\n            {brand.tagline && (\n              <p className=\"max-w-xs text-sm text-balance text-muted-foreground\">{brand.tagline}</p>\n            )}\n\n            {social.length > 0 && (\n              <ul aria-label={socialLabel} className=\"flex flex-wrap items-center gap-1\">\n                {social.map(item => {\n                  const Icon = CHANNEL_ICONS[item.channel]\n                  return (\n                    <li key={item.id}>\n                      <a\n                        className={cn(\n                          \"flex size-9 items-center justify-center rounded-lg border text-muted-foreground\",\n                          \"transition-colors hover:bg-muted hover:text-foreground\",\n                          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                          \"motion-reduce:transition-none\",\n                        )}\n                        href={item.href}\n                        rel=\"noreferrer\"\n                        target=\"_blank\"\n                      >\n                        <Icon aria-hidden=\"true\" className=\"size-4\" />\n                        {/* Without this line a screen reader hears a row of nameless empty links */}\n                        <span className=\"sr-only\">{item.label}</span>\n                      </a>\n                    </li>\n                  )\n                })}\n              </ul>\n            )}\n          </div>\n\n          {/* Link groups: the only place the four states branch (brand and legal come from static site config and always render) */}\n          {resolved === \"loading\" && (\n            <div aria-hidden=\"true\" className=\"grid gap-8 md:grid-cols-3\">\n              {Array.from({ length: 3 }, (_, column) => (\n                <div className=\"flex flex-col gap-3\" key={column}>\n                  <div className=\"h-4 w-20 animate-pulse rounded bg-muted\" />\n                  {[\"w-4/5\", \"w-3/5\", \"w-2/3\", \"w-1/2\"].map(width => (\n                    <div className={cn(\"h-3 animate-pulse rounded bg-muted\", width)} key={width} />\n                  ))}\n                </div>\n              ))}\n            </div>\n          )}\n\n          {resolved === \"empty\" && (\n            <div className=\"flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed px-6 py-10 text-center\">\n              <p className=\"text-sm font-medium\">No link groups yet</p>\n              <p className=\"text-sm text-muted-foreground\">\n                Columns appear here once navigation is published.\n              </p>\n            </div>\n          )}\n\n          {resolved === \"error\" && (\n            <div className=\"flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed px-6 py-10 text-center\">\n              <div className=\"flex flex-col gap-1\">\n                <p className=\"text-sm font-medium\">Couldn&apos;t load the footer navigation</p>\n                <p className=\"text-sm text-muted-foreground\">The link source didn&apos;t respond.</p>\n              </div>\n              {onRetry && (\n                <button\n                  className={cn(\n                    \"inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm\",\n                    \"transition-colors hover:bg-muted focus-visible:outline-none\",\n                    \"focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\",\n                  )}\n                  onClick={onRetry}\n                  type=\"button\"\n                >\n                  <RotateCcw aria-hidden=\"true\" className=\"size-3.5\" />\n                  Try again\n                </button>\n              )}\n            </div>\n          )}\n\n          {resolved === \"ready\" && (\n            <nav aria-label={groupsLabel}>\n              <div\n                className={cn(\n                  \"grid gap-x-8 md:grid-cols-3 xl:grid-cols-4\",\n                  // In accordion mode each column carries its own border-b; row gap would leave those rules floating\n                  isNarrow ? \"gap-y-0\" : \"gap-y-8\",\n                )}\n              >\n                {groups.map((group, index) => (\n                  <LinkColumn\n                    collapsed={isNarrow && !openIds.includes(group.id)}\n                    collapsible={isNarrow}\n                    group={group}\n                    headingId={`${uid}-h${index}`}\n                    key={group.id}\n                    onToggle={() =>\n                      setOpenIds(current =>\n                        current.includes(group.id)\n                          ? current.filter(id => id !== group.id)\n                          : [...current, group.id],\n                      )\n                    }\n                    panelId={`${uid}-p${index}`}\n                  />\n                ))}\n              </div>\n            </nav>\n          )}\n        </div>\n\n        {/* Bottom bar: copyright + legal links + optional locale switch */}\n        <div className=\"mt-10 flex flex-col gap-4 border-t pt-6 sm:flex-row sm:items-center sm:justify-between\">\n          <p className=\"text-sm text-muted-foreground\">{legal.copyright}</p>\n          <div className=\"flex flex-wrap items-center gap-x-6 gap-y-3\">\n            {legal.links.length > 0 && (\n              <nav aria-label={legalLabel}>\n                <ul className=\"flex flex-wrap items-center gap-x-5 gap-y-2\">\n                  {legal.links.map(link => (\n                    <li key={link.id}>\n                      <FooterAnchor link={link} />\n                    </li>\n                  ))}\n                </ul>\n              </nav>\n            )}\n            {locales && locales.length > 0 && onLocaleChange && (\n              <LocaleSwitcher\n                label={localeLabel}\n                locales={locales}\n                onChange={onLocaleChange}\n                value={activeLocale}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    </footer>\n  )\n}\n\nexport default SiteFooter\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/site-footer.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Interaction honesty: every link must point at something the host app really serves.\n * The contract rejects dead anchors outright, so \"render it now, wire it later\" fakes\n * never reach production.\n */\nconst hrefSchema = z\n  .string()\n  .min(1)\n  .refine(href => href.trim() !== \"\" && href.trim() !== \"#\", {\n    message: 'href must point at a real destination — the dead anchor \"#\" is rejected',\n  })\n\nexport const footerLinkSchema = z.object({\n  id: z.string(),\n  label: z.string(),\n  href: hrefSchema,\n  /** opens in a new tab; the component adds rel=noreferrer + an sr-only hint */\n  external: z.boolean().optional(),\n})\n\n/**\n * Channel semantics, not brand names — the component only knows \"this is code\n * hosting / chat / video…\". Which platform it is comes from `label` (the\n * screen-reader-audible accessible name), and the icon table can be swapped wholesale.\n */\nexport const socialChannelSchema = z.enum([\n  \"chat\",\n  \"code\",\n  \"email\",\n  \"photo\",\n  \"post\",\n  \"rss\",\n  \"video\",\n  \"website\",\n  \"work\",\n])\n\nexport const footerSocialSchema = z.object({\n  id: z.string(),\n  /** accessible name, e.g. \"GitHub\" — rendered as sr-only text next to the glyph */\n  label: z.string(),\n  href: hrefSchema,\n  channel: socialChannelSchema,\n})\n\nexport const footerGroupSchema = z.object({\n  id: z.string(),\n  /** column heading; also the accessible name of the column's list */\n  label: z.string(),\n  links: z.array(footerLinkSchema),\n})\n\nexport const footerBrandSchema = z.object({\n  /** never hardcoded in the component — the host ships its own name */\n  name: z.string(),\n  /** one-line positioning statement under the wordmark */\n  tagline: z.string().optional(),\n  /** home destination behind the wordmark */\n  href: hrefSchema,\n})\n\nexport const footerLocaleSchema = z.object({\n  value: z.string(),\n  label: z.string(),\n})\n\nexport const siteFooterSchema = z.object({\n  /** only the link groups are fetched; brand + legal come from static site config */\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  brand: footerBrandSchema,\n  groups: z.array(footerGroupSchema),\n  social: z.array(footerSocialSchema),\n  legal: z.object({\n    /** e.g. \"© 2026 Acme, Inc.\" — the host formats the year it wants */\n    copyright: z.string(),\n    links: z.array(footerLinkSchema),\n  }),\n})\n\nexport type FooterLink = z.infer<typeof footerLinkSchema>\nexport type FooterGroup = z.infer<typeof footerGroupSchema>\nexport type FooterSocial = z.infer<typeof footerSocialSchema>\nexport type FooterBrand = z.infer<typeof footerBrandSchema>\nexport type FooterLocale = z.infer<typeof footerLocaleSchema>\nexport type SocialChannel = z.infer<typeof socialChannelSchema>\nexport type SiteFooterData = z.infer<typeof siteFooterSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}