{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "article-page",
  "title": "Article Page",
  "description": "Long-form reading with metadata, text size controls, sharing, and related articles.",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "toggle",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/author-card.json",
    "https://uaiblocks.dev/r/share-menu.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/blocks/article-page.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { toggleVariants } from \"@/components/ui/toggle\";\nimport { AuthorCard, type AuthorCardProps } from \"@/components/ui/uai/author-card\";\nimport {\n  ShareMenu,\n  type ShareMenuProps,\n  type ShareMenuVariant,\n} from \"@/components/ui/uai/share-menu\";\nimport { cn } from \"@/lib/uai-utils\";\n\n/**\n * Single-choice segments are plain radio buttons styled like ToggleGroupItem: ToggleGroup's value\n * is a string on Radix but an array on Base UI, so the group's selection stays in uai state.\n */\nconst segmentClass = \"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10\";\n\nexport const ARTICLE_PAGE_VARIANTS = [\"centered\", \"sidebar\", \"compact\"] as const;\nexport type ArticlePageVariant = (typeof ARTICLE_PAGE_VARIANTS)[number];\nexport const ARTICLE_PAGE_TEXT_SIZES = [\"small\", \"default\", \"large\"] as const;\nexport type ArticlePageTextSize = (typeof ARTICLE_PAGE_TEXT_SIZES)[number];\nexport type ArticlePageProps = ComponentProps<\"article\"> & {\n  variant?: ArticlePageVariant;\n  /** Reading text size for ArticlePageContent. */\n  textSize?: ArticlePageTextSize;\n  defaultTextSize?: ArticlePageTextSize;\n  onTextSizeChange?: (textSize: ArticlePageTextSize) => void;\n};\n\ntype ArticleContext = {\n  id: string;\n  variant: ArticlePageVariant;\n  textSize: ArticlePageTextSize;\n  setTextSize: (textSize: ArticlePageTextSize) => void;\n};\nconst Context = createContext<ArticleContext | null>(null);\nfunction useArticle(part: string) {\n  const context = useContext(Context);\n  if (!context) throw new Error(`${part} must be used within ArticlePage`);\n  return context;\n}\nconst RelatedContext = createContext<string | null>(null);\n\nconst contentType: Record<ArticlePageTextSize, string> = {\n  small: \"text-[14px]/[22px]\",\n  default: \"text-[16px]/[26px]\",\n  large: \"text-[18px]/[30px]\",\n};\nconst shareVariants: Record<ArticlePageVariant, ShareMenuVariant> = {\n  centered: \"outlined\",\n  sidebar: \"outlined\",\n  compact: \"compact\",\n};\n\nconst articlePageLayoutVariants = cva(\"grid min-w-0\", {\n  variants: {\n    variant: {\n      centered: \"mx-auto max-w-[680px] gap-7\",\n      sidebar:\n        \"gap-7 @min-[760px]:grid-cols-[minmax(0,1fr)_240px] @min-[760px]:items-start @min-[760px]:gap-x-12\",\n      compact: \"gap-4\",\n    },\n  },\n});\n\n/** A long-form article with metadata, reading controls, content, sharing, and related reading. */\nexport function ArticlePage({\n  variant = \"centered\",\n  textSize,\n  defaultTextSize = \"default\",\n  onTextSizeChange,\n  className,\n  children,\n  ...props\n}: ArticlePageProps) {\n  const id = useId();\n  const [internal, setInternal] = useState<ArticlePageTextSize>(defaultTextSize);\n  const current = textSize ?? internal;\n  return (\n    <Context.Provider\n      value={{\n        id,\n        variant,\n        textSize: current,\n        setTextSize: (next) => {\n          if (next === current) return;\n          if (textSize === undefined) setInternal(next);\n          onTextSizeChange?.(next);\n        },\n      }}\n    >\n      <article\n        aria-labelledby={`${id}-title`}\n        data-slot=\"article-page\"\n        className={cn(\n          \"@container box-border min-w-0 text-[13px]/[18px] text-foreground\",\n          className,\n        )}\n        {...props}\n        data-variant={variant}\n      >\n        <div data-slot=\"article-page-layout\" className={articlePageLayoutVariants({ variant })}>\n          {children}\n        </div>\n      </article>\n    </Context.Provider>\n  );\n}\n\nexport function ArticlePageHeader({ className, ...props }: ComponentProps<\"header\">) {\n  const { variant } = useArticle(\"ArticlePageHeader\");\n  return (\n    <header\n      data-slot=\"article-page-header\"\n      className={cn(\n        \"grid min-w-0 border-b\",\n        variant === \"compact\" ? \"gap-2.5 pb-3\" : \"gap-4 pb-5\",\n        variant === \"sidebar\" && \"@min-[760px]:col-span-full\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** A short label above the title, such as the section or topic. */\nexport function ArticlePageCategory({ className, ...props }: ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"article-page-category\"\n      className={cn(\"m-0 text-xs/4 font-medium text-primary-ink\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ArticlePageTitle({ className, ...props }: ComponentProps<\"h1\">) {\n  const { id, variant } = useArticle(\"ArticlePageTitle\");\n  return (\n    <h1\n      data-slot=\"article-page-title\"\n      className={cn(\n        \"m-0 font-semibold tracking-[-0.02em] text-balance\",\n        variant === \"compact\"\n          ? \"text-[22px]/[1.15]\"\n          : \"text-[length:clamp(26px,3cqi+12px,36px)]/[1.15]\",\n        className,\n      )}\n      {...props}\n      id={`${id}-title`}\n    />\n  );\n}\n\nexport function ArticlePageDescription({ className, ...props }: ComponentProps<\"p\">) {\n  const { variant } = useArticle(\"ArticlePageDescription\");\n  return (\n    <p\n      data-slot=\"article-page-description\"\n      className={cn(\n        \"m-0 max-w-[60ch] text-pretty text-muted-foreground\",\n        variant === \"compact\" ? \"text-[13px]/[18px]\" : \"text-[15px]/[22px]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Byline row: the author, dates, and reading time on the start side, reading controls on the end side. */\nexport function ArticlePageMeta({ className, ...props }: ComponentProps<\"div\">) {\n  useArticle(\"ArticlePageMeta\");\n  return (\n    <div\n      data-slot=\"article-page-meta\"\n      className={cn(\"flex min-w-0 flex-wrap items-center justify-between gap-3\", className)}\n      {...props}\n    />\n  );\n}\n\n/** The byline, an inline Author Card. Compose Author Card parts inside it. */\nexport function ArticlePageAuthor({ className, ...props }: Omit<AuthorCardProps, \"variant\">) {\n  useArticle(\"ArticlePageAuthor\");\n  return (\n    <AuthorCard\n      data-slot=\"article-page-author\"\n      className={cn(\"items-center gap-y-0.5\", className)}\n      {...props}\n      variant=\"inline\"\n    />\n  );\n}\n\n/** Muted publication details such as dates and reading time. */\nexport function ArticlePageDetails({ className, ...props }: ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"article-page-details\"\n      className={cn(\n        \"m-0 flex flex-wrap gap-x-2 gap-y-0 text-[12px] text-subtle-foreground tabular-nums\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Reading controls and sharing. */\nexport function ArticlePageToolbar({\n  \"aria-label\": label = \"Reading controls\",\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  useArticle(\"ArticlePageToolbar\");\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a labelled group of controls, not a fieldset.\n    <div\n      role=\"group\"\n      aria-label={label}\n      data-slot=\"article-page-toolbar\"\n      className={cn(\"flex flex-wrap items-center gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type ArticlePageTextSizeControlProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"dir\">;\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** A radio group that sets the reading size of ArticlePageContent. Arrow keys move the selection. */\nexport function ArticlePageTextSizeControl({\n  \"aria-label\": label = \"Text size\",\n  onKeyDown,\n  className,\n  children,\n  ...props\n}: ArticlePageTextSizeControlProps) {\n  const { variant, textSize } = useArticle(\"ArticlePageTextSizeControl\");\n  const ref = useRef<HTMLDivElement>(null);\n  const [thumb, setThumb] = useState<{ x: number; y: number; width: number; height: number }>();\n  useIsomorphicLayoutEffect(() => {\n    const list = ref.current;\n    if (!list) return;\n    const measure = () => {\n      const checked = list.querySelector<HTMLElement>(\"[role=radio][aria-checked=true]\");\n      if (!checked || checked.offsetWidth === 0) return setThumb(undefined);\n      setThumb({\n        x: checked.offsetLeft,\n        y: checked.offsetTop,\n        width: checked.offsetWidth,\n        height: checked.offsetHeight,\n      });\n    };\n    measure();\n    if (typeof ResizeObserver !== \"function\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [textSize]);\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      data-slot=\"article-page-text-size-control\"\n      className={cn(\n        \"group/text-size relative isolate inline-flex items-center gap-0.5 rounded-full bg-card\",\n        variant === \"compact\" ? \"p-px\" : \"p-0.5\",\n        className,\n      )}\n      {...props}\n      ref={ref}\n      data-ready={thumb ? \"\" : undefined}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (!event.defaultPrevented) moveRadio(event);\n      }}\n    >\n      {/* One measured thumb slides between sizes; until it is measured the checked option paints itself. */}\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"article-page-text-size-thumb\"\n        className=\"pointer-events-none absolute top-0 left-0 -z-1 rounded-full bg-accent opacity-0 transition-[translate,width] duration-240 ease-out-quint group-data-ready/text-size:opacity-100 motion-reduce:transition-none\"\n        style={{\n          width: thumb?.width ?? 0,\n          height: thumb?.height ?? 0,\n          translate: thumb ? `${thumb.x}px ${thumb.y}px` : undefined,\n        }}\n      />\n      {children}\n    </div>\n  );\n}\n\nexport type ArticlePageTextSizeOptionProps = Omit<ComponentProps<\"button\">, \"value\"> & {\n  value: ArticlePageTextSize;\n};\n\nconst optionTextSize: Record<ArticlePageTextSize, string> = {\n  small: \"text-[11px]/none\",\n  default: \"text-[13px]/none\",\n  large: \"text-[15px]/none\",\n};\n\nexport function ArticlePageTextSizeOption({\n  value,\n  onClick,\n  className,\n  ...props\n}: ArticlePageTextSizeOptionProps) {\n  const context = useArticle(\"ArticlePageTextSizeOption\");\n  const checked = context.textSize === value;\n  const compact = context.variant === \"compact\";\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a segment button in a radio group; ToggleGroupItem renders the same role=\"radio\" button.\n    <button\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={checked}\n      data-slot=\"article-page-text-size-option\"\n      data-state={checked ? \"on\" : \"off\"}\n      className={cn(\n        toggleVariants(),\n        segmentClass,\n        \"grid cursor-pointer place-items-center rounded-full border-0 px-1.5 font-medium\",\n        \"transition-[color,scale] duration-[120ms,140ms] ease-[ease-out,cubic-bezier(0.23,1,0.32,1)]\",\n        \"focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid active:scale-94\",\n        \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n        compact ? \"h-6 min-w-6\" : \"h-6 min-w-7\",\n        checked\n          ? \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground group-data-ready/text-size:bg-transparent group-data-ready/text-size:hover:bg-transparent\"\n          : \"bg-transparent text-subtle-foreground hover:bg-transparent hover:text-foreground\",\n        optionTextSize[value],\n        className,\n      )}\n      {...props}\n      tabIndex={checked ? 0 : -1}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setTextSize(value);\n      }}\n    />\n  );\n}\n\n/** Share Menu sized for the article. Compose ShareMenuTrigger and ShareMenuContent inside it. */\nexport function ArticlePageShare(props: Omit<ShareMenuProps, \"variant\">) {\n  const { variant } = useArticle(\"ArticlePageShare\");\n  return <ShareMenu data-slot=\"article-page-share\" {...props} variant={shareVariants[variant]} />;\n}\n\nconst articleContentProse = [\n  \"[&>*]:mx-0 [&>*]:mb-0 [&>:where(:first-child)]:mt-0 [&>*+*]:mt-[1em]\",\n  \"[&_h2]:mt-[1.6em] [&_h2]:text-[1.25em]/[1.3] [&_h2]:font-medium [&_h2]:tracking-[-0.01em]\",\n  \"[&_h3]:mt-[1.4em] [&_h3]:text-[1.08em]/[1.35] [&_h3]:font-medium\",\n  \"[&_a]:text-inherit [&_a]:underline [&_a]:decoration-border-strong [&_a]:underline-offset-3 [&_a]:transition-[text-decoration-color] [&_a]:duration-120 [&_a]:ease-[ease-out] [&_a:hover]:decoration-foreground motion-reduce:[&_a]:transition-none\",\n  \"[&_blockquote]:border-s-2 [&_blockquote]:border-border-strong [&_blockquote]:py-0.5 [&_blockquote]:ps-4 [&_blockquote]:pe-0 [&_blockquote]:text-muted-foreground\",\n  \"[&_ol]:list-decimal [&_ol]:ps-[1.25em] [&_ul]:list-disc [&_ul]:ps-[1.25em] [&_li+li]:mt-[0.35em] [&_li::marker]:text-subtle-foreground\",\n  \"[&_code]:rounded-md [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-px [&_code]:text-[0.88em]\",\n];\n\n/** The article body. Type size follows the reading controls. */\nexport function ArticlePageContent({ className, ...props }: ComponentProps<\"div\">) {\n  const { textSize, variant } = useArticle(\"ArticlePageContent\");\n  return (\n    <div\n      data-slot=\"article-page-content\"\n      className={cn(\n        \"min-w-0 text-pretty wrap-anywhere\",\n        variant !== \"compact\" && \"max-w-[68ch]\",\n        contentType[textSize],\n        articleContentProse,\n        className,\n      )}\n      {...props}\n      data-text-size={textSize}\n    />\n  );\n}\n\n/** Related reading, rendered as a labelled complementary region. */\nexport function ArticlePageRelated({ className, ...props }: ComponentProps<\"aside\">) {\n  const { variant } = useArticle(\"ArticlePageRelated\");\n  const id = useId();\n  const compact = variant === \"compact\";\n  return (\n    <RelatedContext.Provider value={id}>\n      <aside\n        aria-labelledby={id}\n        data-slot=\"article-page-related\"\n        className={cn(\n          \"grid min-w-0 bg-card\",\n          compact ? \"gap-2 rounded-xl p-2.5\" : \"gap-3 rounded-[14px] p-3\",\n          variant === \"sidebar\" && \"@min-[760px]:sticky @min-[760px]:top-4\",\n          className,\n        )}\n        {...props}\n      />\n    </RelatedContext.Provider>\n  );\n}\n\nexport function ArticlePageRelatedTitle({ className, ...props }: ComponentProps<\"h2\">) {\n  const id = useContext(RelatedContext);\n  if (!id) throw new Error(\"ArticlePageRelatedTitle must be used within ArticlePageRelated\");\n  return (\n    <h2\n      data-slot=\"article-page-related-title\"\n      className={cn(\n        \"m-0 px-2 pt-0.5 text-[11.5px]/4 font-medium text-subtle-foreground\",\n        className,\n      )}\n      {...props}\n      id={id}\n    />\n  );\n}\n\nexport function ArticlePageRelatedList({ className, ...props }: ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"article-page-related-list\"\n      className={cn(\"m-0 grid list-none gap-0.5 p-0\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function ArticlePageRelatedItem({ className, children, ...props }: ComponentProps<\"a\">) {\n  const { variant } = useArticle(\"ArticlePageRelatedItem\");\n  return (\n    <li className=\"grid min-w-0\">\n      <a\n        data-slot=\"article-page-related-item\"\n        className={cn(\n          \"grid min-w-0 gap-0.5 rounded-lg font-medium text-foreground no-underline\",\n          \"transition-[background-color] duration-120 ease-[ease-out] hover:bg-accent motion-reduce:transition-none\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring\",\n          variant === \"compact\" ? \"px-2 py-1.5\" : \"p-2\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </a>\n    </li>\n  );\n}\n\n/** Muted detail under a related link, such as the reading time. */\nexport function ArticlePageRelatedMeta({ className, ...props }: ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"article-page-related-meta\"\n      className={cn(\"text-[12px] font-normal text-subtle-foreground tabular-nums\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction moveRadio(event: KeyboardEvent<HTMLElement>) {\n  const radios = Array.from(\n    event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]:not(:disabled)'),\n  );\n  const index = radios.indexOf(document.activeElement as HTMLButtonElement);\n  if (index === -1) return;\n  const next = {\n    ArrowRight: index + 1,\n    ArrowDown: index + 1,\n    ArrowLeft: index - 1,\n    ArrowUp: index - 1,\n    Home: 0,\n    End: radios.length - 1,\n  }[event.key];\n  if (next === undefined) return;\n  event.preventDefault();\n  const target = radios[(next + radios.length) % radios.length];\n  target?.focus();\n  target?.click();\n}\n",
      "type": "registry:component",
      "target": "@components/uai/article-page.tsx"
    }
  ],
  "categories": [
    "content",
    "community"
  ],
  "type": "registry:block"
}